feat: local content-mining platform for YouTube creator scraping

Scraper de canales de YouTube hacia notas Markdown para base de conocimiento
(Obsidian-ready), con plataforma web local.

Engine + CLI (Workstream A):
- Modular pipeline: discover/extract/parse/chapters/render/store + ratelimit
- SQLite store con migración idempotente: FTS5 (transcript search), columnas
  de metadata enriquecida, tablas cookies_meta y scrape_jobs
- Módulos: segments, cookies (Netscape vault), export (json/csv/srt/html),
  analysis (word freq/timeline/wordcloud), monitor (watch loop), pipeline
- CLI Click group: search, export, audio, channels, watch, analyze, re-render
- Fix del bug de scoping de cookies en cli.py

Webapp local (Workstream B):
- FastAPI backend: dashboard, channels, videos facetado, transcript, search
  FTS, analysis, scrape jobs con SSE, cookies drag-and-drop, exports,
  folders (abrir en OS), tools (re-render, formato)
- SPA no-build (Alpine.js + Tailwind + Chart.js por CDN): 9 vistas, tema
  dark command-center con acento rojo→rosa, cookie vault drag-drop,
  consola de scrapeo con progreso live vía SSE

Launcher + subagentes (Workstream C):
- start-server.bat / stop-server.bat con auto port-scan + browser open
- .opencode/agent/webapp-builder.md + .opencode/goals/webapp-build.md

Tests: 38 pytest verdes. Sin funcionalidad de IA (enfoque data-mining).
This commit is contained in:
urieljareth
2026-07-26 23:19:34 -06:00
commit 621bbc5f5c
45 changed files with 6541 additions and 0 deletions
+731
View File
@@ -0,0 +1,731 @@
from __future__ import annotations
import json
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
SCHEMA = """
CREATE TABLE IF NOT EXISTS channels (
channel_id TEXT PRIMARY KEY,
handle TEXT,
name TEXT,
last_scraped TEXT,
video_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS videos (
video_id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
title TEXT,
url TEXT NOT NULL,
upload_date TEXT,
duration INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
error_msg TEXT,
transcript_lang TEXT,
transcript_src TEXT,
has_chapters INTEGER DEFAULT 0,
markdown_path TEXT,
discovered_at TEXT NOT NULL,
processed_at TEXT,
FOREIGN KEY (channel_id) REFERENCES channels(channel_id)
);
CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status);
CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(channel_id);
"""
# New columns added by the platform migration (idempotent ALTERs)
_VIDEO_COLUMNS: dict[str, str] = {
"view_count": "INTEGER",
"like_count": "INTEGER",
"tags": "TEXT",
"thumbnail": "TEXT",
"description": "TEXT",
"chapters_json": "TEXT",
"segments_json": "TEXT",
}
_EXTRA_SCHEMA = """
CREATE TABLE IF NOT EXISTS transcript_segments (
video_id TEXT NOT NULL,
idx INTEGER NOT NULL,
start_sec REAL NOT NULL,
end_sec REAL NOT NULL,
text TEXT NOT NULL,
PRIMARY KEY (video_id, idx),
FOREIGN KEY (video_id) REFERENCES videos(video_id)
);
CREATE INDEX IF NOT EXISTS idx_segments_video ON transcript_segments(video_id);
CREATE VIRTUAL TABLE IF NOT EXISTS transcript_fts USING fts5(
video_id UNINDEXED, idx UNINDEXED, start_sec UNINDEXED, end_sec UNINDEXED, text
);
CREATE TABLE IF NOT EXISTS cookies_meta (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
label TEXT,
added_at TEXT NOT NULL,
expires_at TEXT,
is_active INTEGER DEFAULT 0,
has_session INTEGER DEFAULT 0,
cookie_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS scrape_jobs (
id TEXT PRIMARY KEY,
channel_id TEXT,
opts_json TEXT,
status TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
total INTEGER DEFAULT 0,
completed INTEGER DEFAULT 0,
last_error TEXT
);
"""
@dataclass
class VideoRef:
video_id: str
channel_id: str
title: str
url: str
upload_date: str | None = None
duration: int | None = None
@dataclass
class VideoRow:
video_id: str
channel_id: str
title: str | None
url: str
upload_date: str | None
duration: int | None
status: str
error_msg: str | None
transcript_lang: str | None
transcript_src: str | None
has_chapters: int
markdown_path: str | None
view_count: int | None = None
like_count: int | None = None
tags: str | None = None
thumbnail: str | None = None
description: str | None = None
chapters_json: str | None = None
segments_json: str | None = None
@dataclass
class SegmentRow:
video_id: str
idx: int
start_sec: float
end_sec: float
text: str
@dataclass
class SearchHit:
video_id: str
channel_id: str
title: str | None
idx: int
start_sec: float
end_sec: float
snippet: str
rank: float
@dataclass
class CookieRow:
id: str
filename: str
label: str | None
added_at: str
expires_at: str | None
is_active: bool
has_session: bool
cookie_count: int
@dataclass
class JobRow:
id: str
channel_id: str | None
opts_json: str | None
status: str
started_at: str | None
finished_at: str | None
total: int
completed: int
last_error: str | None
class Store:
def __init__(self, db_path: str | Path):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_schema()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _init_schema(self) -> None:
with self._connect() as conn:
conn.executescript(SCHEMA)
# idempotent column adds
existing = {row["name"] for row in conn.execute("PRAGMA table_info(videos)")}
for col, coltype in _VIDEO_COLUMNS.items():
if col not in existing:
conn.execute(f"ALTER TABLE videos ADD COLUMN {col} {coltype}")
conn.executescript(_EXTRA_SCHEMA)
@contextmanager
def _cursor(self) -> Iterator[sqlite3.Cursor]:
conn = self._connect()
try:
yield conn.cursor()
conn.commit()
finally:
conn.close()
# ------------------------------------------------------------------ channels
def upsert_channel(self, channel_id: str, handle: str | None, name: str | None, video_count: int = 0) -> None:
now = _now_iso()
with self._cursor() as cur:
cur.execute(
"""INSERT INTO channels (channel_id, handle, name, last_scraped, video_count)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(channel_id) DO UPDATE SET
handle = excluded.handle,
name = excluded.name,
last_scraped = excluded.last_scraped,
video_count = excluded.video_count""",
(channel_id, handle, name, now, video_count),
)
def list_channels(self) -> list[dict]:
with self._cursor() as cur:
cur.execute("SELECT * FROM channels ORDER BY name")
return [dict(r) for r in cur.fetchall()]
def get_channel(self, channel_id: str) -> dict | None:
with self._cursor() as cur:
cur.execute("SELECT * FROM channels WHERE channel_id = ?", (channel_id,))
row = cur.fetchone()
return dict(row) if row else None
def delete_channel(self, channel_id: str) -> None:
with self._cursor() as cur:
cur.execute("DELETE FROM transcript_segments WHERE video_id IN (SELECT video_id FROM videos WHERE channel_id = ?)", (channel_id,))
cur.execute("DELETE FROM transcript_fts WHERE video_id IN (SELECT video_id FROM videos WHERE channel_id = ?)", (channel_id,))
cur.execute("DELETE FROM videos WHERE channel_id = ?", (channel_id,))
cur.execute("DELETE FROM channels WHERE channel_id = ?", (channel_id,))
# ------------------------------------------------------------------ videos
def upsert_videos(self, refs: list[VideoRef]) -> int:
now = _now_iso()
inserted = 0
with self._cursor() as cur:
for r in refs:
cur.execute(
"""INSERT INTO videos
(video_id, channel_id, title, url, upload_date, duration, status, discovered_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)
ON CONFLICT(video_id) DO UPDATE SET
title = excluded.title,
upload_date = excluded.upload_date,
duration = excluded.duration""",
(r.video_id, r.channel_id, r.title, r.url, r.upload_date, r.duration, now),
)
if cur.rowcount > 0:
inserted += 1
return inserted
def get_pending(self, channel_id: str | None = None, limit: int | None = None) -> list[VideoRow]:
sql = "SELECT * FROM videos WHERE status = 'pending'"
params: list[Any] = []
if channel_id:
sql += " AND channel_id = ?"
params.append(channel_id)
sql += " ORDER BY discovered_at ASC"
if limit:
sql += " LIMIT ?"
params.append(limit)
with self._cursor() as cur:
cur.execute(sql, params)
return [_row_to_videorow(row) for row in cur.fetchall()]
def get_all(self, channel_id: str | None = None) -> list[VideoRow]:
sql = "SELECT * FROM videos"
params: list[Any] = []
if channel_id:
sql += " WHERE channel_id = ?"
params.append(channel_id)
sql += " ORDER BY discovered_at ASC"
with self._cursor() as cur:
cur.execute(sql, params)
return [_row_to_videorow(row) for row in cur.fetchall()]
def get_video(self, video_id: str) -> VideoRow | None:
with self._cursor() as cur:
cur.execute("SELECT * FROM videos WHERE video_id = ?", (video_id,))
row = cur.fetchone()
return _row_to_videorow(row) if row else None
def query_videos(
self,
channel_id: str | None = None,
status: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
min_duration: int | None = None,
q: str | None = None,
sort: str = "upload_date_desc",
page: int = 1,
size: int = 50,
) -> tuple[list[VideoRow], int]:
where: list[str] = []
params: list[Any] = []
if channel_id:
where.append("channel_id = ?"); params.append(channel_id)
if status:
where.append("status = ?"); params.append(status)
if date_from:
where.append("upload_date >= ?"); params.append(date_from.replace("-", ""))
if date_to:
where.append("upload_date <= ?"); params.append(date_to.replace("-", ""))
if min_duration is not None:
where.append("duration >= ?"); params.append(min_duration)
if q:
where.append("(title LIKE ? OR description LIKE ?)")
params.extend([f"%{q}%", f"%{q}%"])
clause = ("WHERE " + " AND ".join(where)) if where else ""
order = {
"upload_date_desc": "upload_date DESC",
"upload_date_asc": "upload_date ASC",
"duration_desc": "duration DESC",
"views_desc": "view_count DESC",
"title_asc": "title ASC",
}.get(sort, "upload_date DESC")
offset = max(0, (page - 1) * size)
with self._cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS n FROM videos {clause}", params)
total = cur.fetchone()["n"]
cur.execute(
f"SELECT * FROM videos {clause} ORDER BY {order} LIMIT ? OFFSET ?",
[*params, size, offset],
)
rows = [_row_to_videorow(r) for r in cur.fetchall()]
return rows, total
def update_video_metadata(
self,
video_id: str,
*,
view_count: int | None = None,
like_count: int | None = None,
tags: list[str] | None = None,
thumbnail: str | None = None,
description: str | None = None,
chapters_json: str | None = None,
segments_json: str | None = None,
) -> None:
sets: list[str] = []
params: list[Any] = []
if view_count is not None:
sets.append("view_count = ?"); params.append(view_count)
if like_count is not None:
sets.append("like_count = ?"); params.append(like_count)
if tags is not None:
sets.append("tags = ?"); params.append(json.dumps(tags, ensure_ascii=False))
if thumbnail is not None:
sets.append("thumbnail = ?"); params.append(thumbnail)
if description is not None:
sets.append("description = ?"); params.append(description[:4000])
if chapters_json is not None:
sets.append("chapters_json = ?"); params.append(chapters_json)
if segments_json is not None:
sets.append("segments_json = ?"); params.append(segments_json)
if not sets:
return
params.append(video_id)
with self._cursor() as cur:
cur.execute(f"UPDATE videos SET {', '.join(sets)} WHERE video_id = ?", params)
def mark_done(
self,
video_id: str,
markdown_path: str,
transcript_lang: str | None,
transcript_src: str | None,
has_chapters: bool,
) -> None:
now = _now_iso()
with self._cursor() as cur:
cur.execute(
"""UPDATE videos SET
status = 'done',
markdown_path = ?,
transcript_lang = ?,
transcript_src = ?,
has_chapters = ?,
processed_at = ?,
error_msg = NULL
WHERE video_id = ?""",
(markdown_path, transcript_lang, transcript_src, int(has_chapters), now, video_id),
)
def mark_status(self, video_id: str, status: str) -> None:
now = _now_iso()
with self._cursor() as cur:
cur.execute(
"UPDATE videos SET status = ?, processed_at = ? WHERE video_id = ?",
(status, now, video_id),
)
def set_upload_date(self, video_id: str, upload_date: str | None) -> None:
if not upload_date:
return
with self._cursor() as cur:
cur.execute(
"UPDATE videos SET upload_date = ? WHERE video_id = ? AND upload_date IS NULL",
(upload_date, video_id),
)
def mark_error(self, video_id: str, error_msg: str) -> None:
now = _now_iso()
with self._cursor() as cur:
cur.execute(
"""UPDATE videos SET status = 'error', error_msg = ?, processed_at = ?
WHERE video_id = ?""",
(error_msg[:500], now, video_id),
)
# ------------------------------------------------------------------ segments / FTS
def store_segments(self, video_id: str, segments: list) -> None:
with self._cursor() as cur:
cur.execute("DELETE FROM transcript_segments WHERE video_id = ?", (video_id,))
cur.execute("DELETE FROM transcript_fts WHERE video_id = ?", (video_id,))
rows = []
fts_rows = []
for i, seg in enumerate(segments):
start = float(getattr(seg, "start", 0.0))
end = float(getattr(seg, "end", start))
text = (getattr(seg, "text", "") or "").strip()
if not text:
continue
rows.append((video_id, i, start, end, text))
fts_rows.append((video_id, i, start, end, text))
if rows:
cur.executemany(
"INSERT INTO transcript_segments (video_id, idx, start_sec, end_sec, text) VALUES (?, ?, ?, ?, ?)",
rows,
)
cur.executemany(
"INSERT INTO transcript_fts (video_id, idx, start_sec, end_sec, text) VALUES (?, ?, ?, ?, ?)",
fts_rows,
)
def get_segments(self, video_id: str) -> list[SegmentRow]:
with self._cursor() as cur:
cur.execute(
"SELECT * FROM transcript_segments WHERE video_id = ? ORDER BY idx ASC",
(video_id,),
)
return [
SegmentRow(
video_id=r["video_id"], idx=r["idx"], start_sec=r["start_sec"],
end_sec=r["end_sec"], text=r["text"],
)
for r in cur.fetchall()
]
def has_segments(self, video_id: str) -> bool:
with self._cursor() as cur:
cur.execute("SELECT 1 FROM transcript_segments WHERE video_id = ? LIMIT 1", (video_id,))
return cur.fetchone() is not None
def search_segments(self, query: str, channel_id: str | None = None, limit: int = 50) -> list[SearchHit]:
# FTS5 MATCH; join videos for title + optional channel filter.
fts_query = _sanitize_fts(query)
if not fts_query:
return []
sql = (
"SELECT f.video_id, f.idx, f.start_sec, f.end_sec, f.text, v.channel_id, v.title, bm25(transcript_fts) AS rank "
"FROM transcript_fts f JOIN videos v ON v.video_id = f.video_id "
"WHERE transcript_fts MATCH ?"
)
params: list[Any] = [fts_query]
if channel_id:
sql += " AND v.channel_id = ?"
params.append(channel_id)
sql += " ORDER BY rank LIMIT ?"
params.append(limit)
with self._cursor() as cur:
cur.execute(sql, params)
return [
SearchHit(
video_id=r["video_id"], channel_id=r["channel_id"], title=r["title"],
idx=r["idx"], start_sec=r["start_sec"], end_sec=r["end_sec"],
snippet=r["text"], rank=r["rank"],
)
for r in cur.fetchall()
]
# ------------------------------------------------------------------ cookies
def upsert_cookie(self, cookie_id: str, filename: str, label: str | None, expires_at: str | None, has_session: bool, cookie_count: int) -> None:
now = _now_iso()
with self._cursor() as cur:
cur.execute(
"""INSERT INTO cookies_meta (id, filename, label, added_at, expires_at, is_active, has_session, cookie_count)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(id) DO UPDATE SET
filename = excluded.filename,
label = excluded.label,
expires_at = excluded.expires_at,
has_session = excluded.has_session,
cookie_count = excluded.cookie_count""",
(cookie_id, filename, label, now, expires_at, int(has_session), cookie_count),
)
def list_cookies(self) -> list[CookieRow]:
with self._cursor() as cur:
cur.execute("SELECT * FROM cookies_meta ORDER BY added_at DESC")
return [_row_to_cookierow(r) for r in cur.fetchall()]
def get_cookie(self, cookie_id: str) -> CookieRow | None:
with self._cursor() as cur:
cur.execute("SELECT * FROM cookies_meta WHERE id = ?", (cookie_id,))
r = cur.fetchone()
return _row_to_cookierow(r) if r else None
def set_active_cookie(self, cookie_id: str | None) -> None:
with self._cursor() as cur:
cur.execute("UPDATE cookies_meta SET is_active = 0")
if cookie_id:
cur.execute("UPDATE cookies_meta SET is_active = 1 WHERE id = ?", (cookie_id,))
def get_active_cookie(self) -> CookieRow | None:
with self._cursor() as cur:
cur.execute("SELECT * FROM cookies_meta WHERE is_active = 1 LIMIT 1")
r = cur.fetchone()
return _row_to_cookierow(r) if r else None
def delete_cookie(self, cookie_id: str) -> None:
with self._cursor() as cur:
cur.execute("DELETE FROM cookies_meta WHERE id = ?", (cookie_id,))
# ------------------------------------------------------------------ jobs
def create_job(self, job_id: str, channel_id: str | None, opts: dict) -> None:
now = _now_iso()
with self._cursor() as cur:
cur.execute(
"""INSERT INTO scrape_jobs (id, channel_id, opts_json, status, started_at, total, completed)
VALUES (?, ?, ?, 'queued', ?, 0, 0)""",
(job_id, channel_id, json.dumps(opts, ensure_ascii=False), now),
)
def update_job(self, job_id: str, *, status: str | None = None, total: int | None = None, completed: int | None = None, last_error: str | None = None, finished: bool = False) -> None:
sets: list[str] = []
params: list[Any] = []
if status:
sets.append("status = ?"); params.append(status)
if total is not None:
sets.append("total = ?"); params.append(total)
if completed is not None:
sets.append("completed = ?"); params.append(completed)
if last_error is not None:
sets.append("last_error = ?"); params.append(last_error[:500])
if finished:
sets.append("finished_at = ?"); params.append(_now_iso())
if not sets:
return
params.append(job_id)
with self._cursor() as cur:
cur.execute(f"UPDATE scrape_jobs SET {', '.join(sets)} WHERE id = ?", params)
def get_job(self, job_id: str) -> JobRow | None:
with self._cursor() as cur:
cur.execute("SELECT * FROM scrape_jobs WHERE id = ?", (job_id,))
r = cur.fetchone()
return _row_to_jobrow(r) if r else None
def list_jobs(self, limit: int = 50) -> list[JobRow]:
with self._cursor() as cur:
cur.execute("SELECT * FROM scrape_jobs ORDER BY started_at DESC LIMIT ?", (limit,))
return [_row_to_jobrow(r) for r in cur.fetchall()]
# ------------------------------------------------------------------ aggregates
def stats(self, channel_id: str | None = None) -> dict[str, int]:
sql = "SELECT status, COUNT(*) as n FROM videos"
params: list[Any] = []
if channel_id:
sql += " WHERE channel_id = ?"
params.append(channel_id)
sql += " GROUP BY status"
result: dict[str, int] = {}
with self._cursor() as cur:
cur.execute(sql, params)
for row in cur.fetchall():
result[row["status"]] = row["n"]
return result
def dashboard(self) -> dict:
with self._cursor() as cur:
channels = [dict(r) for r in cur.execute("SELECT * FROM channels ORDER BY name").fetchall()]
for ch in channels:
cid = ch["channel_id"]
row = cur.execute(
"SELECT COUNT(*) AS n, COALESCE(SUM(duration),0) AS dur, MIN(upload_date) AS mind, MAX(upload_date) AS maxd, COALESCE(SUM(view_count),0) AS views, COALESCE(SUM(like_count),0) AS likes FROM videos WHERE channel_id = ?",
(cid,),
).fetchone()
ch["video_count_db"] = row["n"]
ch["total_duration"] = row["dur"]
ch["date_min"] = row["mind"]
ch["date_max"] = row["maxd"]
ch["total_views"] = row["views"]
ch["total_likes"] = row["likes"]
status_breakdown = {
r["status"]: r["n"]
for r in cur.execute("SELECT status, COUNT(*) AS n FROM videos GROUP BY status").fetchall()
}
# uploads over time (by month)
uploads = [
{"month": r["m"], "count": r["n"]}
for r in cur.execute(
"SELECT substr(upload_date,1,6) AS m, COUNT(*) AS n FROM videos WHERE upload_date IS NOT NULL GROUP BY m ORDER BY m"
).fetchall()
]
# duration histogram (buckets)
hist = [
{"bucket": r["b"], "count": r["n"]}
for r in cur.execute(
"""SELECT
CASE WHEN duration < 300 THEN '<5m'
WHEN duration < 600 THEN '5-10m'
WHEN duration < 1200 THEN '10-20m'
WHEN duration < 2400 THEN '20-40m'
ELSE '40m+' END AS b,
COUNT(*) AS n
FROM videos WHERE duration IS NOT NULL GROUP BY b"""
).fetchall()
]
# top tags (tags is JSON array text)
tag_rows = cur.execute("SELECT tags FROM videos WHERE tags IS NOT NULL AND tags != '[]'").fetchall()
tag_counts: dict[str, int] = {}
for tr in tag_rows:
try:
for t in json.loads(tr["tags"]):
t = (t or "").strip().lower()
if t:
tag_counts[t] = tag_counts.get(t, 0) + 1
except (json.JSONDecodeError, TypeError):
continue
top_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:20]
return {
"channels": channels,
"status_breakdown": status_breakdown,
"uploads_over_time": uploads,
"duration_histogram": hist,
"top_tags": [{"tag": t, "count": c} for t, c in top_tags],
}
def reset_errors(self, channel_id: str | None = None) -> int:
sql = "UPDATE videos SET status = 'pending', error_msg = NULL WHERE status = 'error'"
params: list[Any] = []
if channel_id:
sql += " AND channel_id = ?"
params.append(channel_id)
with self._cursor() as cur:
cur.execute(sql, params)
return cur.rowcount
def _sanitize_fts(query: str) -> str:
# Build a safe AND FTS5 query from whitespace-separated terms.
cleaned = (query or "").strip()
if not cleaned:
return ""
terms = []
for tok in cleaned.split():
tok = tok.strip('"')
if tok:
terms.append(f'"{tok.replace("\"", "")}"')
return " AND ".join(terms)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _row_to_videorow(row: sqlite3.Row) -> VideoRow:
keys = row.keys()
return VideoRow(
video_id=row["video_id"],
channel_id=row["channel_id"],
title=row["title"],
url=row["url"],
upload_date=row["upload_date"],
duration=row["duration"],
status=row["status"],
error_msg=row["error_msg"],
transcript_lang=row["transcript_lang"],
transcript_src=row["transcript_src"],
has_chapters=row["has_chapters"],
markdown_path=row["markdown_path"],
view_count=row["view_count"] if "view_count" in keys else None,
like_count=row["like_count"] if "like_count" in keys else None,
tags=row["tags"] if "tags" in keys else None,
thumbnail=row["thumbnail"] if "thumbnail" in keys else None,
description=row["description"] if "description" in keys else None,
chapters_json=row["chapters_json"] if "chapters_json" in keys else None,
segments_json=row["segments_json"] if "segments_json" in keys else None,
)
def _row_to_cookierow(row: sqlite3.Row) -> CookieRow:
return CookieRow(
id=row["id"],
filename=row["filename"],
label=row["label"],
added_at=row["added_at"],
expires_at=row["expires_at"],
is_active=bool(row["is_active"]),
has_session=bool(row["has_session"]),
cookie_count=row["cookie_count"],
)
def _row_to_jobrow(row: sqlite3.Row) -> JobRow:
return JobRow(
id=row["id"],
channel_id=row["channel_id"],
opts_json=row["opts_json"],
status=row["status"],
started_at=row["started_at"],
finished_at=row["finished_at"],
total=row["total"],
completed=row["completed"],
last_error=row["last_error"],
)