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:
+1
-7
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,19 +162,23 @@
|
||||
|
||||
<div class="glass overflow-hidden">
|
||||
<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>
|
||||
<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 && 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"><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="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">
|
||||
<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 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>
|
||||
<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-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="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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -205,27 +209,55 @@
|
||||
</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">
|
||||
<table class="tbl">
|
||||
<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><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>
|
||||
</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>
|
||||
<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 && 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"><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="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">
|
||||
<tr class="row-hover" @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>
|
||||
<tr class="row-hover" :class="isSelected(v.video_id) ? 'row-selected' : ''" @click="openVideo(v.video_id)">
|
||||
<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-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="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><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>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -251,7 +283,7 @@
|
||||
<template x-if="!loading.detail && detail.video">
|
||||
<div class="space-y-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">
|
||||
<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>
|
||||
@@ -263,7 +295,12 @@
|
||||
<div><span class="pill" :class="statusClass(detail.video.status)" x-text="detail.video.status"></span></div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -524,27 +561,119 @@
|
||||
|
||||
<!-- tool grid -->
|
||||
<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>
|
||||
<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>
|
||||
Re-render all
|
||||
</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 class="glass p-5">
|
||||
<h3 class="text-sm font-semibold text-zinc-200">Export catalog</h3>
|
||||
<!-- Export -->
|
||||
<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>
|
||||
<button class="btn btn-ghost w-full" @click="setView('export')">Export tools</button>
|
||||
</div>
|
||||
|
||||
<div class="glass p-5">
|
||||
<h3 class="text-sm font-semibold text-zinc-200">Cookie vault</h3>
|
||||
<!-- Search -->
|
||||
<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>
|
||||
<button class="btn btn-ghost w-full" @click="setView('cookies')">Manage cookies</button>
|
||||
</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>
|
||||
|
||||
<!-- open folders -->
|
||||
@@ -622,6 +751,157 @@
|
||||
|
||||
</div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -154,3 +154,84 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp
|
||||
/* fade-up transition for views */
|
||||
.fade-enter-active { transition: all 0.22s ease; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user