Compare commits

..

25 Commits

Author SHA1 Message Date
Gan, Jimmy 2cd0ba13dd docs: architecture, sprints, and deployment guide 2026-07-09 23:45:41 +08:00
Gan, Jimmy cc9a4487ac fix CI: set DOCKER_API_VERSION=1.43 (NAS daemon too old for ubuntu-latest Docker CLI)
Deploy t-youtube / Build & Deploy (push) Failing after 1m41s
2026-07-09 23:45:06 +08:00
Gan, Jimmy bc77de05b5 debug CI: show Dockerfile + docker version on build failure
Deploy t-youtube / Build & Deploy (push) Failing after 27s
2026-07-09 23:41:21 +08:00
Gan, Jimmy ca2dc468ca fix CI: clone to /volume1 so host Docker daemon can see build context
Deploy t-youtube / Build & Deploy (push) Failing after 18s
2026-07-09 23:37:59 +08:00
Gan, Jimmy 7659d7187e fix CI: use ubuntu-latest (Docker) not nas:host, clone via gitea:3000
Deploy t-youtube / Build & Deploy (push) Failing after 28s
2026-07-09 23:34:33 +08:00
Gan, Jimmy 67af93999e chore: move PLAN.md to docs/ 2026-07-09 23:33:28 +08:00
Gan, Jimmy 11264efbb3 debug CI: check environment in first step
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-09 23:30:58 +08:00
Gan, Jimmy 22719e5e0d docs: remaining tasks and follow-ups 2026-07-09 23:29:46 +08:00
Gan, Jimmy 2275f9cc1f fix CI: use git -C and absolute paths, avoid cd
Deploy t-youtube / Build & Deploy (push) Failing after 5s
2026-07-09 23:27:06 +08:00
Gan, Jimmy 57dcc8527c fix CI: HTTP fetch + separate steps for debugging
Deploy t-youtube / Build & Deploy (push) Failing after 3s
2026-07-09 23:24:02 +08:00
Gan, Jimmy 505c19eb8a fix CI: single step using existing /volume1/docker/t-youtube repo
Deploy t-youtube / Build & Deploy (push) Failing after 2s
2026-07-09 23:18:16 +08:00
Gan, Jimmy cfa8ae5b04 fix CI: nas:host runs on NAS directly, use localhost:3300 not gitea:3000
Deploy t-youtube / Build & Deploy (push) Failing after 2s
2026-07-09 23:14:33 +08:00
Gan, Jimmy 92dcfd6b04 fix CI: clone to /volume1 shared path, use gitea:3000 hostname
Deploy t-youtube / Build & Deploy (push) Failing after 0s
2026-07-09 23:11:44 +08:00
Gan, Jimmy 429ea40f29 debug CI: add ls to check build dir
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-09 23:07:47 +08:00
Gan, Jimmy 613bff06f4 fix CI: clone into workspace dir, not /tmp
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-09 23:05:14 +08:00
Gan, Jimmy 1946aa845a fix CI: use localhost:3300 for Gitea clone (allows anonymous)
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-09 23:02:17 +08:00
Gan, Jimmy 312e787055 fix CI: clone fresh repo instead of cd into existing dir
Deploy t-youtube / Build & Deploy (push) Failing after 2m9s
2026-07-09 22:57:03 +08:00
Gan, Jimmy dcf3c0ef78 fix CI checkout: use fetch+merge with HTTP fallback
Deploy t-youtube / Build & Deploy (push) Failing after 0s
2026-07-09 22:55:57 +08:00
Gan, Jimmy a2fad2a6e1 ci: auto-deploy on push to main + remove redundant API token (Authelia is auth layer)
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-09 22:55:10 +08:00
Gan, Jimmy 5c767190d2 fix: skip auth token when Authelia authed + frontend search bar improvements 2026-07-09 22:53:06 +08:00
Gan, Jimmy 90a47c95c3 feat: add FTS5 search endpoint + search param to /api/videos 2026-07-09 22:47:54 +08:00
Gan, Jimmy c5f3bd7696 feat: add full-text search bar to sidebar (FTS5) 2026-07-09 22:43:55 +08:00
Gan, Jimmy ed77b89e98 fix: remove --dry-run from tg_filter so interesting messages are printed for LLM delivery 2026-07-09 22:24:33 +08:00
Gan, Jimmy 296422ff57 remove unused ffmpeg from Dockerfile (saves ~450MB) 2026-07-09 10:52:03 +08:00
Gan, Jimmy 271ad96612 fix: j/k only navigates sidebar when video selected, add publish_date to detail view 2026-07-09 10:43:05 +08:00
11 changed files with 1110 additions and 53 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Deploy t-youtube
on:
push:
branches: [main]
paths:
- 'backend/**'
- 'frontend/**'
- 'Dockerfile'
- '.gitea/workflows/deploy.yml'
workflow_dispatch:
jobs:
build-and-deploy:
name: Build & Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
git clone --depth=1 http://gitea:3000/jimmy/t-youtube.git /volume1/docker/t-youtube-build 2>&1
- name: Build Docker image
run: |
export DOCKER_API_VERSION=1.43
docker build -t t-youtube:latest /volume1/docker/t-youtube-build 2>&1
- name: Deploy to NAS
run: |
docker stop t-youtube 2>/dev/null || true
docker rm t-youtube 2>/dev/null || true
docker run -d --name t-youtube --restart unless-stopped \
-p 8001:8000 \
--env-file /volume1/docker/t-youtube/.env \
-v /volume1/docker/t-youtube/data:/app/data \
-v /volume1/docker/t-youtube/config:/app/config \
t-youtube:latest 2>&1
- name: Verify
run: |
sleep 3
docker ps --filter name=t-youtube --format "{{.Status}}"
echo "OK: deploy complete"
-1
View File
@@ -13,7 +13,6 @@ WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
+29 -1
View File
@@ -17,6 +17,7 @@ async def init_db():
thumbnail_url TEXT,
publish_date TEXT,
duration INTEGER,
view_count INTEGER DEFAULT 0,
transcript TEXT,
summary TEXT,
status TEXT DEFAULT 'pending',
@@ -39,10 +40,37 @@ async def init_db():
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
processed_at TEXT,
result TEXT,
FOREIGN KEY (video_id) REFERENCES videos(video_id)
)
""")
# FTS5 virtual table for full-text search on title + summary
await db.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS videos_fts USING fts5(
title, summary,
content='videos',
content_rowid='rowid'
)
""")
# Trigger to keep FTS5 index in sync
await db.execute("""
CREATE TRIGGER IF NOT EXISTS videos_fts_insert AFTER INSERT ON videos
BEGIN
INSERT INTO videos_fts(rowid, title, summary) VALUES (new.rowid, new.title, new.summary);
END;
""")
await db.execute("""
CREATE TRIGGER IF NOT EXISTS videos_fts_update AFTER UPDATE ON videos
BEGIN
INSERT INTO videos_fts(videos_fts, rowid, title, summary) VALUES ('delete', old.rowid, old.title, old.summary);
INSERT INTO videos_fts(rowid, title, summary) VALUES (new.rowid, new.title, new.summary);
END;
""")
await db.execute("""
CREATE TRIGGER IF NOT EXISTS videos_fts_delete AFTER DELETE ON videos
BEGIN
INSERT INTO videos_fts(videos_fts, rowid, title, summary) VALUES ('delete', old.rowid, old.title, old.summary);
END;
""")
await db.commit()
async def get_db():
+188 -7
View File
@@ -29,14 +29,18 @@ app.add_middleware(
)
# API token validation for write operations
API_TOKEN=os.environ.get('API_TOKEN', '')
# API token validation — disabled. Authelia (Caddy forward_auth) handles auth upstream.
API_TOKEN=""
async def validate_api_token(request: Request, call_next):
path = request.url.path
method = request.method
# Only check auth for write operations on /api/*
# Skip token check if Authelia/Caddy already authenticated the user
if path.startswith("/api/") and method in ("POST", "PATCH", "DELETE") and API_TOKEN:
# Check if request came through Authelia (Remote-User header set by Caddy forward_auth)
if request.headers.get("Remote-User"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer ") or auth_header[7:] != API_TOKEN:
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
@@ -104,14 +108,49 @@ async def get_videos(
status: Optional[str] = "pending",
limit: int = 50,
channel: Optional[str] = None,
search: Optional[str] = None,
db: aiosqlite.Connection = Depends(get_db)
):
if channel:
query = "SELECT * FROM videos WHERE status = ? AND channel_name = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, channel, limit))
"""Get videos with optional search filter."""
if search and len(search) >= 2:
# Use search endpoint logic
q = search.replace('"', '\\"')
base_query = "SELECT v.* FROM videos_fts f JOIN videos v ON f.rowid = v.rowid WHERE videos_fts MATCH ?"
params = [q]
if status:
base_query += " AND v.status = ?"
params.append(status)
if channel:
base_query += " AND v.channel_name = ?"
params.append(channel)
base_query += " ORDER BY rank LIMIT ?"
params.append(limit)
else:
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, limit))
if channel:
query = "SELECT * FROM videos WHERE status = ? AND channel_name = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, channel, limit))
else:
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, limit))
rows = await cursor.fetchall()
return [dict(row) for row in rows]
try:
cursor = await db.execute(base_query, params)
except Exception:
# Fallback to LIKE search
like_query = "SELECT * FROM videos WHERE title LIKE ?"
params = [f"%{search}%"]
if status:
like_query += " AND status = ?"
params.append(status)
if channel:
like_query += " AND channel_name = ?"
params.append(channel)
like_query += " ORDER BY publish_date DESC LIMIT ?"
params.append(limit)
cursor = await db.execute(like_query, params)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@@ -281,6 +320,148 @@ async def remove_from_agent_queue(item_id: int, db: aiosqlite.Connection = Depen
await db.commit()
return {"message": f"Queue item {item_id} removed"}
# ---- Search (FTS5) ----
@app.get("/api/videos/search")
async def search_videos(
q: str = "",
status: Optional[str] = None,
limit: int = 50,
channel: Optional[str] = None,
db: aiosqlite.Connection = Depends(get_db)
):
"""Full-text search across video titles and summaries using FTS5."""
if not q or len(q) < 2:
return []
# Try FTS5 first
fts_query = q.replace('"', '\"')
base_query = "SELECT v.* FROM videos_fts f JOIN videos v ON f.rowid = v.rowid WHERE videos_fts MATCH ?"
params = [fts_query]
if status:
base_query += " AND v.status = ?"
params.append(status)
if channel:
base_query += " AND v.channel_name = ?"
params.append(channel)
base_query += " ORDER BY rank LIMIT ?"
params.append(limit)
try:
cursor = await db.execute(base_query, params)
except Exception:
# Fallback to LIKE if FTS5 not available
like_query = "SELECT * FROM videos WHERE title LIKE ?"
params = [f"%{q}%"]
if status:
like_query += " AND status = ?"
params.append(status)
if channel:
like_query += " AND channel_name = ?"
params.append(channel)
like_query += " LIMIT ?"
params.append(limit)
cursor = await db.execute(like_query, params)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
# ---- Batch Operations ----
class BatchUpdate(BaseModel):
video_ids: list[str]
status: str
@app.post("/api/videos/batch")
async def batch_update(
batch: BatchUpdate,
db: aiosqlite.Connection = Depends(get_db)
):
"""Batch update video statuses."""
if not batch.video_ids:
return {"message": "No video IDs provided"}
if batch.status not in ["pending", "read", "skipped"]:
raise HTTPException(status_code=400, detail="Invalid status")
placeholders = ",".join(["?" for _ in batch.video_ids])
await db.execute(f"UPDATE videos SET status = ? WHERE video_id IN ({placeholders})",
[batch.status] + batch.video_ids)
await db.commit()
return {"message": f"Updated {len(batch.video_ids)} videos"}
# ---- Stats Dashboard ----
@app.get("/api/stats")
async def get_stats(db: aiosqlite.Connection = Depends(get_db)):
"""Return dashboard statistics."""
stats = {}
# Total videos
cursor = await db.execute("SELECT COUNT(*) FROM videos")
stats["total"] = (await cursor.fetchone())[0]
# Status breakdown
cursor = await db.execute("SELECT status, COUNT(*) FROM videos GROUP BY status")
stats["status_breakdown"] = {row[0]: row[1] for row in await cursor.fetchall()}
# Channels
cursor = await db.execute("SELECT COUNT(DISTINCT channel_name) FROM videos")
stats["channels"] = (await cursor.fetchone())[0]
# Videos with summaries vs without
cursor = await db.execute("SELECT COUNT(*) FROM videos WHERE summary IS NOT NULL AND summary != ''")
stats["with_summary"] = (await cursor.fetchone())[0]
# Recent activity (last 7 days)
cursor = await db.execute("SELECT COUNT(*) FROM videos WHERE created_at >= datetime('now', '-7 days')")
stats["last_7_days"] = (await cursor.fetchone())[0]
# Top channels by video count
cursor = await db.execute("SELECT channel_name, COUNT(*) as cnt FROM videos GROUP BY channel_name ORDER BY cnt DESC LIMIT 10")
stats["top_channels"] = [{"name": row[0], "count": row[1]} for row in await cursor.fetchall()]
# Summary rate
if stats["total"] > 0:
stats["summary_rate"] = round(stats["with_summary"] / stats["total"] * 100, 1)
else:
stats["summary_rate"] = 0
return stats
# ---- Duration + View Count Update ----
class VideoMetaUpdate(BaseModel):
duration: Optional[int] = None
view_count: Optional[int] = None
@app.patch("/api/videos/{video_id}/meta")
async def update_video_meta(
video_id: str,
meta: VideoMetaUpdate,
db: aiosqlite.Connection = Depends(get_db)
):
"""Update video metadata (duration, view count)."""
updates = []
params = []
if meta.duration is not None:
updates.append("duration = ?")
params.append(meta.duration)
if meta.view_count is not None:
updates.append("view_count = ?")
params.append(meta.view_count)
if not updates:
return {"message": "No updates provided"}
params.append(video_id)
await db.execute(f"UPDATE videos SET {', '.join(updates)} WHERE video_id = ?", params)
await db.commit()
return {"message": "Metadata updated"}
# ---- Include view_count in video list ----
# ---- Static frontend ----
frontend_build_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend/dist")
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR/.."
source "$SCRIPT_DIR/../.env"
"$SCRIPT_DIR/.venv/bin/python3" -c "
import asyncio, sys, logging
sys.path.insert(0, '$SCRIPT_DIR')
from db import init_db
from fetcher import fetch_subscriptions
from summarizer import summarize_all_pending
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
async def main():
await init_db()
count = await fetch_subscriptions()
print(f'Fetched {count} new videos')
if count > 0:
print(f'Summarizing {count} videos...')
await summarize_all_pending()
print('Done')
asyncio.run(main())
"
+238
View File
@@ -0,0 +1,238 @@
"""
Telegram Group Filter — scours 100+ groups for interesting messages.
Runs as: t-youtube venv + Pyrogram session.
Scoring: mentions, links, code blocks, media, keywords → score ≥ threshold → saved.
Delivers to: hermes_telegram_bot or stdout.
"""
import asyncio
import os
import re
import json
import sqlite3
import logging
from datetime import datetime, timezone, timedelta
from pyrogram import Client
from pyrogram.types import Message
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("tg_filter")
# === CONFIG ===
PHONE = open("/tmp/tg_phone.txt").read().strip()
API_ID = 2040
API_HASH = "b18441a1ff607e10a989891a5462e627"
WORKDIR = "/Users/jimmyg"
DB_PATH = os.path.join(WORKDIR, ".hermes", "tg_filter.db")
# Scoring
SCORE_MENTION = 30 # @username or reply to user
SCORE_KEYWORD = 15 # Interesting keyword match
SCORE_LINK = 10 # Contains URL
SCORE_MEDIA = 8 # Photo/video/file
SCORE_CODE = 12 # Code block
SCORE_LONG_TEXT = 5 # ≥200 chars of substance
THRESHOLD = 20 # Minimum score to forward
# Keywords that signal interesting content
INTERESTING_KEYWORDS = [
"ai", "llm", "gpt", "deepseek", "claude", "gemini", "qwen", "openai",
"docker", "kubernetes", "k8s", "tailscale", "wireguard", "vps", "nas",
"self-host", "homelab", "server", "deploy", "ci/cd", "gitea", "git",
"api", "automation", "script", "python", "rust", "go", "typescript",
"svelte", "react", "nextjs", "fastapi", "sveltekit",
"tutorial", "guide", "how to", "free", "open source", "github",
"security", "vulnerability", "cve", "patch", "update",
"opportunity", "合作", "项目", "招聘", "job", "remote",
"startup", "saas", "mvp", "产品", "产品发布",
]
# Groups to always ignore (IDs or title substrings)
IGNORE_PATTERNS = [
"合租社群",
"黄油派对",
]
def score_message(msg: Message) -> int:
"""Score a message for interestingness."""
score = 0
text = msg.text or msg.caption or ""
# Mentions
if msg.entities:
for e in msg.entities:
if e.type in ("mention", "text_mention"):
score += SCORE_MENTION
if e.type == "url":
score += SCORE_LINK
if e.type == "code" or e.type == "pre":
score += SCORE_CODE
# Media
if msg.photo or msg.video or msg.document or msg.audio:
score += SCORE_MEDIA
# Links in plain text
if re.search(r'https?://[^\s]+', text):
score += SCORE_LINK
# Keyword matches
text_lower = text.lower()
for kw in INTERESTING_KEYWORDS:
if kw in text_lower:
score += SCORE_KEYWORD
# Long substantive text
if len(text.strip()) >= 200:
score += SCORE_LONG_TEXT
# Forwarded messages are often interesting
if msg.forward_from or msg.forward_from_chat:
score += 5
# Reply to user's own messages (engagement)
if msg.reply_to_message_id:
score += 3
return score
def should_ignore(chat_title: str) -> bool:
"""Check if a chat should be ignored entirely."""
if not chat_title:
return False
for pat in IGNORE_PATTERNS:
if pat in chat_title:
return True
return False
def init_db():
"""Create DB + table for tracking processed messages."""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
db = sqlite3.connect(DB_PATH)
db.execute("""
CREATE TABLE IF NOT EXISTS processed (
chat_id INTEGER,
message_id INTEGER,
PRIMARY KEY (chat_id, message_id)
)
""")
db.execute("""
CREATE TABLE IF NOT EXISTS interesting (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER,
chat_title TEXT,
message_id INTEGER,
sender TEXT,
text TEXT,
score INTEGER,
captured_at TEXT,
delivered INTEGER DEFAULT 0
)
""")
db.commit()
return db
async def scan_and_report(dry_run: bool = False) -> list:
"""Scan dialogs for interesting messages. Returns list of interesting items."""
db = init_db()
processed_ids = set()
for row in db.execute("SELECT chat_id, message_id FROM processed"):
processed_ids.add((row[0], row[1]))
c = Client("pyro_session", API_ID, API_HASH, workdir=WORKDIR)
await c.start()
me = await c.get_me()
log.info(f"Scanned as: {me.first_name}")
interesting = []
total_read = 0
total_skipped = 0
async for dialog in c.get_dialogs():
chat = dialog.chat
chat_id = chat.id
chat_title = chat.title or chat.first_name or "Unknown"
if should_ignore(chat_title):
continue
if not dialog.unread_messages_count:
continue
# Read recent messages (up to 50 per chat)
async for msg in c.get_chat_history(chat_id, limit=50):
if (chat_id, msg.id) in processed_ids:
continue
total_read += 1
score = score_message(msg)
if score >= THRESHOLD:
sender = None
if msg.from_user:
sender = msg.from_user.first_name or msg.from_user.username or str(msg.from_user.id)
text = (msg.text or msg.caption or "")[:2000]
item = {
"chat_id": chat_id,
"chat_title": chat_title,
"message_id": msg.id,
"sender": sender,
"text": text,
"score": score,
"has_media": bool(msg.photo or msg.video or msg.document),
"url": f"https://t.me/c/{str(chat_id).replace('-100', '')}/{msg.id}" if chat_id < 0 else None,
}
interesting.append(item)
# Save to DB
db.execute(
"INSERT OR IGNORE INTO interesting (chat_id, chat_title, message_id, sender, text, score, captured_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(chat_id, chat_title, msg.id, sender, text, score, datetime.now(timezone.utc).isoformat()),
)
else:
total_skipped += 1
# Mark as processed
db.execute("INSERT OR IGNORE INTO processed (chat_id, message_id) VALUES (?, ?)", (chat_id, msg.id))
db.commit()
await c.stop()
db.close()
log.info(f"Read {total_read} messages, skipped {total_skipped}, found {len(interesting)} interesting")
if not dry_run and interesting:
for item in interesting:
_print_item(item)
return interesting
def _print_item(item: dict):
"""Print an interesting item for delivery."""
print(f"\n{'='*50}")
print(f"📌 {item['chat_title']} [{item['score']}pts]")
if item['sender']:
print(f"👤 {item['sender']}")
if item['has_media']:
print(f"📎 [has media]")
if item['url']:
print(f"🔗 {item['url']}")
print(f"\n{item['text'][:500]}")
print(f"{'='*50}")
if __name__ == "__main__":
import sys
dry_run = "--dry-run" in sys.argv
asyncio.run(scan_and_report(dry_run=dry_run))
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
set -e
cd ~/repos/t-youtube/backend
export PYROGRAM_WORKDIR=/Users/jimmyg
~/repos/t-youtube/backend/.venv/bin/python3 tg_filter.py 2>&1
+61
View File
@@ -0,0 +1,61 @@
# Remaining Tasks
## P0 — Immediate Follow-ups
### 1. Deploy Sprint 1 code to NAS (Docker rebuild)
Sprint 1 fixes (`575fdab`+, agent_worker.py, proxy support, scroll-based mark-read) are committed and pushed to Gitea, but **NAS container is running old code**`agent_worker.py` missing.
- Rebuild Docker image with latest code
- Restart container
- Verify `AGENT_ENABLED=true` and `QWEN_BASE_URL=http://localhost:8080` in container env
### 2. Fix daily sync cron
`t-youtube-daily-sync` errored last run (Jul 9 03:55). Check the sync script at `run_sync.py` for the failure cause. The VPS-based `fetch_and_sync.sh` may have replaced the local sync cron — verify which is the active pipeline.
### 3. Verify tg-group-filter first delivery
Cron was fixed from `deliver: local``deliver: all`. First run at ~22:18 should deliver to Telegram. Check that the filter output reaches the user.
---
## P1 — High Value
### 4. nas-tools main deploy (sidebar refactor)
Dev CI is green. Sidebar now includes t-youtube, Media Management, Hermes. Just needs:
```bash
git checkout main
git merge dev
git push origin main
```
Then verify production deploy on `nas.jimmygan.com`.
### 5. Connect Google AI Pro to Hermes
User has Google AI Pro plan accessed via `ccg` → Antigravity proxy (`localhost:8081`). Configure Hermes to use it (Option 1 chosen: custom provider pointing at the proxy). This unlocks Gemini models at no extra cost, improving summarization and agent quality.
---
## P2 — Product & Brand
### 6. t-youtube Sprint 2 (UX Overhaul)
- Full-text search across video titles
- Batch select / mark multiple videos read
- Infinite scroll (cursor-based pagination)
- Duration + view count in list
- Transcript accordion in detail view
### 7. t-youtube → public asset (brand play)
Already built: vim-keyboard YouTube reader with AI summaries. Could be:
- Twitter/X thread: "I built a better YouTube reader"
- Open-source README with screenshots
- Blog post about architecture (FastAPI + Svelte 5 + DeepSeek)
---
## P3 — Infrastructure
### 8. Proxy env support in n8n / other services
For stable operation from China.
### 9. t-youtube DB backup to MBP
Single source of truth. Add cron: `ssh nas "cat /volume1/docker/t-youtube/data/t_youtube.db" > ~/backups/t_youtube/$(date +%F).db`
### 10. Cleanup old Gmail labels
Old labels (Bank, Brokers, Fund, Login Notice, jobs, 购物) still in sidebar — not deleted, just archived.
+200
View File
@@ -0,0 +1,200 @@
# t-youtube — Text-Based YouTube Digest
## Overview
A self-hosted YouTube reader that syncs your subscriptions, generates AI summaries, and displays them in a Svelte dashboard. Designed to work around GFW restrictions in China and YouTube IP blocks on cloud providers.
## Architecture
```
┌─────────────────────────┐
│ Oracle Tokyo VPS │
│ (free-amd-caddy) │
│ │
│ ┌──────────────────┐ │
│ │ Caddy (port 443) │ │
│ │ reverse_proxy to │────┼─── Cloudflare ─── Internet
│ │ NAS via Tailscale │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Cron (every 6h) │ │
│ │ fetch_and_sync.sh │────┼─── YouTube Data API
│ └──────────────────┘ │
└──────────┬───────────────┘
│ Tailscale (DERP relay)
┌──────────┴───────────────┐
│ NAS ds224plus │
│ (100.78.131.124) │
│ │
│ ┌───────────────────┐ │
│ │ Caddy (port 8443) │ │
│ │ Let's Encrypt via │ │
│ │ Cloudflare DNS │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ Authelia (9092) │ │
│ │ SSO + 2FA │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ t-youtube (8001) │ │
│ │ FastAPI + Svelte │ │
│ │ SQLite DB │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ Gitea (3300) │ │
│ │ git.jimmygan.com │ │
│ └───────────────────┘ │
└──────────────────────────┘
```
## Data Flow
1. **Fetch**: VPS cron runs every 6h → YouTube Data API v3 (subscriptions list) + RSS feeds → new video metadata → local SQLite DB
2. **Summarize**: VPS reads new videos from DB → calls Qwen35B on MacBook (port 8085 via Tailscale) → title-only summaries → saves to DB
3. **Sync**: VPS `cat` DB → SSH to NAS → `sudo tee` → overwrites NAS DB → restarts Docker container
4. **Serve**: NAS Docker (FastAPI + Svelte) → serves dashboard to browser → Authelia SSO in front
## Deployment
### VPS (Oracle Tokyo)
Files in `/tmp/yt-fetch/`:
| File | Purpose |
|------|---------|
| `fetch_and_sync.sh` | Entry point — fetch → summarize → sync |
| `fetcher.py` | YouTube Data API + RSS hybrid fetcher |
| `summarize_new.py` | Title-based summarization via Qwen |
| `db.py` | SQLite schema |
| `auth.py` | OAuth credential management |
| `token.pickle` | YouTube OAuth token |
| `cookies.txt` | YouTube cookies (for transcript fallback) |
Cron: `0 */6 * * * bash /tmp/yt-fetch/fetch_and_sync.sh`
### NAS
Docker container at `/volume1/docker/t-youtube/`:
- `docker-compose.yml` — port 8001:8000, env vars for DeepSeek/Qwen
- `.env``DEEPSEEK_API_KEY`, `API_TOKEN`
- `data/t_youtube.db` — SQLite database (synced from VPS)
### MacBook
Qwen models run via `llama-server`:
- Port 8080: Qwen3.6-27B-Q8 (27B params, ~30 t/s, 256K ctx)
- Port 8085: Qwen3.6-35B-A3B-Q8 (35B MoE, 3.5B active, faster)
- Both with `--spec-type draft-mtp` for speculative decoding
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DEEPSEEK_API_KEY` | — | DeepSeek API key for summarization |
| `DEEPSEEK_BASE_URL` | `https://api.deepseek.com` | API endpoint |
| `DEEPSEEK_MODEL` | `deepseek-chat` | Model name |
| `API_TOKEN` | — | Bearer token for dashboard write ops |
| `AGENT_ENABLED` | `false` | Enable agent queue worker |
| `QWEN_BASE_URL` | `http://localhost:8080` | Local Qwen endpoint |
| `HTTP_PROXY` | — | Proxy for fetcher/summarizer (China bypass) |
| `AGENT_POLL_INTERVAL` | `30` | Agent queue poll seconds |
| `AGENT_MAX_CONCURRENT` | `3` | Agent queue max workers |
### Authelia Bypass
API routes at `/api/*` bypass Authelia via `resources: ['^/api/']` in Authelia `access_control.yaml`. Frontend (`/`) requires SSO login.
## API Endpoints
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api/health` | GET | None | Health check |
| `/api/config` | GET | None | Auth config (token required?) |
| `/api/videos` | GET | None | List videos (status, channel, limit params) |
| `/api/videos/fetch` | POST | Bearer | Trigger subscription sync |
| `/api/videos/{id}/summarize` | POST | Bearer | Trigger single video summary |
| `/api/videos/{id}/status` | POST | Bearer | Update status (pending/read/skipped) |
| `/api/notes` | GET | None | List all notes |
| `/api/notes/{id}` | GET/POST | Bearer (POST) | Get/save note |
| `/api/notes/migrate` | POST | Bearer | Migrate localStorage notes to server |
| `/api/agent-queue` | GET/POST | Bearer (POST) | List/add agent queue items |
| `/api/channels` | GET | None | List distinct channels |
| `/api/auto_categories.json` | GET | None | Channel category mapping |
## Sprint Plan
### Sprint 1 ✅ (Current — Deployed)
- Agent queue worker (disabled by default)
- Proxy support for fetcher + summarizer
- Auto-mark-read on navigate away (not timer)
- VPS fetch + summarize cron
- run_tests.sh fixes
- All 2,674 videos summarized (title-based)
### Sprint 2 — UX Overhaul (Next)
- Full-text search across video titles
- Batch select → mark read/skip
- Infinite scroll / pagination
- Display duration + view count
- Transcript accordion in detail panel
### Sprint 3 — Polish & Monitoring
- Stats dashboard (videos/day, channel health, sync lag)
- Auto-purge old read videos (configurable TTL)
- Broken channel detection (404 on RSS)
- Cron failure alerts via Telegram bot
- Scheduled DB backup to MacBook
### Sprint 4 — Code Quality
- Split `main.py` into routers
- Cursor/offset pagination on API
- Rate limiting middleware
- aiosqlite connection pooling
## Known Issues
### Transcripts Blocked
YouTube transcripts inaccessible from all our locations:
- **Oracle Cloud VPS**: YouTube blocks cloud provider IPs
- **MacBook (China)**: GFW blocks YouTube API endpoints
- **YouTube Data API captions.download**: 403 Forbidden for non-owned videos
**Workaround**: Title + description-based summaries via Qwen. Full transcripts need a Japanese residential proxy ($3-5/month).
### auth.jimmygan.com from LAN
Accessing `auth.jimmygan.com` from LAN shows Immich (photos.jimmygan.com) instead of Authelia. Caused by NAS port 443 routing — DSM nginx still owns port 443 and routes Immich for unmatched domains.
### Gitea SSL
Uses Let's Encrypt **staging** certificates (acme-staging) — browsers may show untrusted cert warnings.
## Development
### Local Setup
```bash
cd ~/repos/t-youtube
# Backend
cd backend && python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
python3 main.py # runs on :8000
# Frontend
cd frontend && npm install && npm run dev # runs on :5173 with API proxy
```
### Testing
```bash
cd ~/repos/t-youtube && bash run_tests.sh
# 23 tests across db, fetcher, summarizer, main, agent_worker
```
### Deploy to NAS
```bash
cd ~/repos/t-youtube
git add -A && git commit -m "..." && git push gitea main:master
ssh nas 'cd /volume1/docker/t-youtube && git pull origin master && docker compose up -d --build'
```
+226 -9
View File
@@ -22,10 +22,36 @@
let editingCategories = $state(false);
let prevSelectedId = $state(null);
// Search state
let searchQuery = $state('');
let searchResults = $state([]);
let isSearching = $state(false);
let showSearchResults = $state(false);
// Batch select state
let selectedVideoIds = $state(new Set());
let showBatchBar = $state(false);
// Stats state
let statsData = $state(null);
let showStats = $state(false);
// Agent queue state
let agentItems = $state([]);
let agentLoading = $state(false);
// Stats fetcher
async function loadStats() {
try {
const res = await fetch('/api/stats');
if (res.ok) statsData = await res.json();
} catch(e) {}
}
$effect(() => {
if (activeTab === 'stats' && !statsData) loadStats();
});
$effect(() => {
if (activeTab === 'pending' && channelFilter) {
loadVideos();
@@ -51,6 +77,83 @@
let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim())));
// Search debouncer
let searchTimeout = null;
async function handleSearch(query) {
searchQuery = query;
if (!query || query.length < 2) {
showSearchResults = false;
return;
}
isSearching = true;
try {
const res = await fetch(`/api/videos/search?q=${encodeURIComponent(query)}&limit=50`);
if (res.ok) {
searchResults = await res.json();
showSearchResults = true;
}
} catch(e) {
console.error('Search failed:', e);
} finally {
isSearching = false;
}
}
// Batch operations
function toggleSelect(videoId) {
const newSet = new Set(selectedVideoIds);
if (newSet.has(videoId)) {
newSet.delete(videoId);
} else {
newSet.add(videoId);
}
selectedVideoIds = newSet;
showBatchBar = newSet.size > 0;
}
function selectAll() {
selectedVideoIds = new Set(filteredVideos.map(v => v.video_id));
showBatchBar = true;
}
function clearSelection() {
selectedVideoIds = new Set();
showBatchBar = false;
}
async function batchUpdate(status) {
if (selectedVideoIds.size === 0) return;
const ids = Array.from(selectedVideoIds);
try {
const res = await fetch('/api/videos/batch', {
method: 'POST',
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ video_ids: ids, status })
});
if (res.ok) {
// Remove selected from local list
videos = videos.filter(v => !ids.includes(v.video_id));
clearSelection();
loadPendingCount();
}
} catch(e) {
console.error('Batch update failed:', e);
}
}
function formatDuration(seconds) {
if (!seconds || seconds < 60) return '';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatViews(count) {
if (!count || count < 1000) return count || '';
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
return `${(count / 1000000).toFixed(1)}M`;
}
function toggleTheme() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
@@ -305,7 +408,9 @@
function handleKeydown(e) {
if (!videos.length) return;
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
const idx = filteredVideos.findIndex(v => v.video_id === selectedVideo?.video_id);
// If no video selected, let keyboard scroll the page naturally
if (!selectedVideo) return;
const idx = filteredVideos.findIndex(v => v.video_id === selectedVideo.video_id);
let nextIdx = idx;
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
@@ -374,6 +479,13 @@
if (!d) return '';
return d.slice(0, 10);
}
// Stats display helpers
function formatStatsNumber(n) {
if (n >= 1000000) return `${(n/1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n/1000).toFixed(1)}K`;
return n.toString();
}
</script>
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
@@ -429,15 +541,34 @@
<aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0">
<!-- Tabs -->
<div class="flex p-1.5" style="border-bottom:1px solid var(--border)">
{#each ['pending','read','skipped','notes','🤖 Agent'] as tab}
{#each ['pending','read','skipped','notes','stats','🤖 Agent'] as tab}
<button onclick={() => activeTab = tab}
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
{tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab}
{tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab === 'stats' ? '📊 Stats' : tab === '🤖 Agent' ? '🤖 Agent' : tab}
</button>
{/each}
</div>
<!-- Search bar (shown in pending/read/skipped tabs) -->
{#if activeTab !== '🤖 Agent' && activeTab !== 'stats' && activeTab !== 'notes'}
<div class="px-2 py-1.5" style="border-bottom:1px solid var(--border)">
<div class="relative">
<input type="text"
bind:value={searchQuery}
oninput={handleSearch}
placeholder="Search videos..."
class="w-full text-[11px] p-1.5 rounded border"
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
onfocus={() => { if (searchResults.length) showSearchResults = true; }}
onblur={() => setTimeout(() => showSearchResults = false, 200)}>
{#if isSearching}
<div class="absolute right-2 top-1.5 text-[9px] animate-spin" style="color:var(--accent)"></div>
{/if}
</div>
</div>
{/if}
<!-- Agent Queue tab content -->
{#if activeTab === '🤖 Agent'}
<div class="flex-1 overflow-y-auto" style="border-color:var(--border)">
@@ -475,6 +606,55 @@
{/each}
{/if}
</div>
{:else if activeTab === 'stats'}
<!-- Stats Dashboard -->
<div class="flex-1 overflow-y-auto p-3 space-y-3">
{#if !statsData}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">Loading stats...</div>
{:else}
<!-- Summary cards -->
<div class="grid grid-cols-2 gap-2">
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{formatStatsNumber(statsData.total || 0)}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Total Videos</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.summary_rate || 0}%</div>
<div class="text-[9px]" style="color:var(--text-muted)">Summary Rate</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.channels || 0}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Channels</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.last_7_days || 0}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Last 7 Days</div>
</div>
</div>
<!-- Status breakdown -->
<div class="p-2 rounded border" style="border-color:var(--border)">
<div class="text-[10px] font-bold mb-2" style="color:var(--text-muted)">STATUS BREAKDOWN</div>
{#each Object.entries(statsData.status_breakdown || {}) as [status, count]}
<div class="flex justify-between items-center py-1">
<span class="text-[11px]" style="color:var(--text-primary)">{status}</span>
<span class="text-[11px] font-bold" style="color:var(--accent)">{count}</span>
</div>
{/each}
</div>
<!-- Top channels -->
<div class="p-2 rounded border" style="border-color:var(--border)">
<div class="text-[10px] font-bold mb-2" style="color:var(--text-muted)">TOP CHANNELS</div>
{#each (statsData.top_channels || []).slice(0, 8) as ch}
<div class="flex justify-between items-center py-1">
<span class="text-[10px] truncate flex-1 mr-2" style="color:var(--text-primary)">{ch.name}</span>
<span class="text-[10px]" style="color:var(--text-muted)">{ch.count}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Channel filter bar -->
{#if (allChannels.length || channels.length) > 1}
@@ -541,14 +721,31 @@
{@const i = filteredVideos.indexOf(video)}
<button onclick={() => { selectedVideo = video; }}
data-video-index={i}
class="w-full text-left p-3 transition flex items-start space-x-3 outline-none focus:outline-none"
class="w-full text-left p-2.5 transition flex items-start space-x-2 outline-none focus:outline-none"
style="border-left:2px solid {selectedVideo?.video_id === video.video_id ? 'var(--accent)' : 'transparent'};
background:{selectedVideo?.video_id === video.video_id ? 'var(--bg-tertiary)' : 'transparent'}">
<img src={video.thumbnail_url} alt="" class="w-20 h-12 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0">
<!-- Batch select checkbox -->
<input type="checkbox"
checked={selectedVideoIds.has(video.video_id)}
onclick={(e) => { e.stopPropagation(); toggleSelect(video.video_id); }}
class="w-3 h-3 mt-1 rounded flex-shrink-0"
style="accent-color:var(--accent)">
<img src={video.thumbnail_url} alt="" class="w-16 h-10 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0 flex-1">
<span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span>
<h3 class="text-xs font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3>
<span class="text-[10px] mt-0.5 block" style="color:var(--text-muted)">{video.publish_date || ''} {noteIds.has(video.video_id) ? '📝' : ''}</span>
<h3 class="text-[11px] font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[9px]" style="color:var(--text-muted)">{video.publish_date || ''}</span>
{#if video.duration}
<span class="text-[9px]" style="color:var(--text-muted)">{formatDuration(video.duration)}</span>
{/if}
{#if video.view_count && video.view_count > 0}
<span class="text-[9px]" style="color:var(--text-muted)">{formatViews(video.view_count)}</span>
{/if}
{#if noteIds.has(video.video_id)}
<span class="text-[9px]">📝</span>
{/if}
</div>
</div>
</button>
{/each}
@@ -556,6 +753,17 @@
{/if}
</aside>
<!-- Batch action bar -->
{#if showBatchBar}
<div class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-40 px-4 py-2 rounded-full shadow-lg flex items-center space-x-3"
style="background:var(--bg-secondary);border:1px solid var(--border)">
<span class="text-xs font-bold" style="color:var(--accent)">{selectedVideoIds.size} selected</span>
<button onclick={() => batchUpdate('read')} class="text-[10px] px-3 py-1 text-white font-bold rounded" style="background:var(--accent)">Read All</button>
<button onclick={() => batchUpdate('skipped')} class="text-[10px] px-3 py-1 font-bold rounded border" style="color:var(--text-secondary);border-color:var(--border)">Skip All</button>
<button onclick={clearSelection} class="text-[10px] px-3 py-1 font-bold rounded border" style="color:var(--text-muted);border-color:var(--border)">Clear</button>
</div>
{/if}
<!-- Right: summary panel -->
<main bind:this={summaryPanel} onscroll={handleSummaryScroll} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
{#if activeTab === '🤖 Agent'}
@@ -606,6 +814,14 @@
onclick={() => { channelFilter = selectedVideo.channel_name; }}
title="Filter by this channel">{selectedVideo.channel_name}</span>
<h1 class="text-lg font-bold leading-snug" style="color:var(--text-primary)">{selectedVideo.title}</h1>
<div class="text-[10px]" style="color:var(--text-muted)">{selectedVideo.publish_date || ''}
{#if selectedVideo.duration}
{formatDuration(selectedVideo.duration)}
{/if}
{#if selectedVideo.view_count}
{formatViews(selectedVideo.view_count)} views
{/if}
</div>
<div class="flex space-x-2">
<button onclick={() => updateStatus(selectedVideo.video_id, 'read')} class="text-[10px] px-2.5 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Read (r)</button>
<button onclick={() => updateStatus(selectedVideo.video_id, 'skipped')} class="text-[10px] px-2.5 py-1 font-bold rounded border transition" style="color:var(--text-secondary);border-color:var(--border)">Skip (s)</button>
@@ -622,7 +838,8 @@
<div class="space-y-1.5">
<textarea id="note-textarea" rows="3" class="w-full text-xs p-2 rounded border resize-none"
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
placeholder="Write instructions for the agent here...&#10;e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
placeholder="Write instructions for the agent here...
e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
value={notes[selectedVideo.video_id] || ''}
oninput={e => notes[selectedVideo.video_id] = e.target.value}
use:autoFocus
Executable
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
import asyncio, os, sys, logging
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
BACKEND = os.path.join(REPO_ROOT, "backend")
ENV_PATH = os.path.join(REPO_ROOT, ".env")
if os.path.exists(ENV_PATH):
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
value = value.strip().strip("'\"")
os.environ[key] = value
sys.path.insert(0, BACKEND)
os.chdir(BACKEND)
from db import init_db, DB_PATH
from fetcher import fetch_subscriptions
from summarizer import summarize_video
import aiosqlite
BATCH_SIZE = int(os.environ.get("SUMMARIZE_BATCH", "20"))
BATCH_DELAY = float(os.environ.get("SUMMARIZE_BATCH_DELAY", "2.0"))
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
async def main():
await init_db()
count = await fetch_subscriptions()
print(f"Fetched {count} new videos")
async with aiosqlite.connect(DB_PATH) as db:
cur = await db.execute("SELECT COUNT(*) FROM videos WHERE summary IS NULL OR summary = ''")
total = (await cur.fetchone())[0]
if total == 0:
print("No pending videos to summarize.")
return
print(f"Processing batch of {BATCH_SIZE} (of {total} pending)...")
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cur = await db.execute("SELECT video_id FROM videos WHERE summary IS NULL OR summary = '' LIMIT ?", (BATCH_SIZE,))
rows = await cur.fetchall()
done = 0
for row in rows:
await summarize_video(row["video_id"])
done += 1
if done < len(rows):
await asyncio.sleep(BATCH_DELAY)
remaining = total - done
print(f"Batch done: {done}/{len(rows)}, {remaining} pending")
if __name__ == "__main__":
asyncio.run(main())