Compare commits

...

33 Commits

Author SHA1 Message Date
Gan, Jimmy 43977205b6 docs: add comprehensive project docs (architecture, progress, configuration, troubleshooting) 2026-07-10 00:18:50 +08:00
Gan, Jimmy 37cc8bfec6 fix CI: single ubuntu-latest job, use host Docker binary for swap
Deploy t-youtube / Build & Deploy (push) Failing after 49s
2026-07-10 00:05:09 +08:00
Gan, Jimmy 5e8ac62672 fix CI: split build (ubuntu) + deploy (nas:host), use host Docker binary for deploy
Deploy t-youtube / Build (push) Successful in 47s
Deploy t-youtube / Deploy (push) Failing after 0s
2026-07-10 00:00:40 +08:00
Gan, Jimmy 1e1eec1ecd add PDD workflow: CLAUDE.md, PDD.md, sprint spec with active symlink 2026-07-09 23:57:04 +08:00
Gan, Jimmy 9c81befaa2 fix CI: apply DOCKER_API_VERSION=1.43 to all docker commands
Deploy t-youtube / Build & Deploy (push) Failing after 39s
2026-07-09 23:57:04 +08:00
Gan, Jimmy 31bb2496a7 debug CI: set -x + ls .env on deploy step
Deploy t-youtube / Build & Deploy (push) Failing after 3m48s
2026-07-09 23:48:36 +08:00
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
Gan, Jimmy 575fdabbc0 fix: proxy support for transcript download, scroll-based mark-read, enable agent worker 2026-07-09 03:39:08 +08:00
Gan, Jimmy 672ad58ca4 fix: agent tab effect condition vs tab label mismatch 2026-07-09 00:48:59 +08:00
21 changed files with 1723 additions and 63 deletions
+47
View File
@@ -0,0 +1,47 @@
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: Save & Load image on host
run: |
export DOCKER_API_VERSION=1.43
docker save t-youtube:latest | /volume1/@appstore/ContainerManager/usr/bin/docker load 2>&1
- name: Swap container on host
run: |
/volume1/@appstore/ContainerManager/usr/bin/docker stop t-youtube 2>/dev/null || true
/volume1/@appstore/ContainerManager/usr/bin/docker rm t-youtube 2>/dev/null || true
/volume1/@appstore/ContainerManager/usr/bin/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: |
export DOCKER_API_VERSION=1.43
sleep 3
docker ps --filter name=t-youtube --format "{{.Status}}"
echo "OK: deploy complete"
+1
View File
@@ -23,3 +23,4 @@ token.pickle
config/client_secret.json
config/token.pickle
backend/sync_rotation.json
._*
+38
View File
@@ -0,0 +1,38 @@
# T YouTube — Text-Based YouTube Digest
## PDD Workflow
This project follows **PDD (Product Definition Document)** development.
**Always read these files before writing code:**
- `docs/PDD.md` — product vision, goals, targets (the north star)
- `docs/specs/active.md` — current sprint spec (what we're building now)
**Workflow:**
1. PDD → Sprint Spec → Implementation → Validate ACs
2. Scope changes update the sprint spec first, code second
3. Never ship without ACs passing
## Dev Commands
- **Build:** `docker compose up -d --build`
- **Test:** `./run_tests.sh`
- **Run locally:** `uvicorn backend.main:app --reload` (backend) + `npm run dev` (frontend)
- **Sync subscriptions:** `python run_sync.py`
## Architecture
- **Frontend:** Svelte 5 + Vite (`frontend/`)
- **Backend:** FastAPI + SQLite (`backend/`)
- **Deployment:** Docker container on NAS DS224+, port 8001
- **Sync:** `run_sync.py` fetches YouTube subscriptions + transcripts via yt-dlp
- **Auth:** Authelia SSO in front, cookie-based session
- **Proxy:** YouTube blocked from China — sync runs via VPS (Oracle Tokyo) or proxy
## Key Files
- `docs/architecture.md` — full architecture reference
- `Dockerfile` — multi-stage build (node → python)
- `docker-compose.yml` — deployment config
- `run_sync.py` — subscription sync entry point
- `config/cookies.txt` — YouTube auth cookies (not committed)
-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/*
+41 -1
View File
@@ -63,4 +63,44 @@ t-youtube/
* **Key Takeaways (Bullet list)**
* **Actionable Verdict**
3. **Read/Skip State**: Mark videos as **Read** or **Skip** to clear them from your pending queue, allowing you to only keep track of what's unread.
4. **View Full Transcript**: Expand the accordion at the bottom of the digest to read the raw transcripts if needed.
4. **View Full Transcript**: Expand the accordion at the bottom of the digest to read the raw transcripts if needed.
5. **Agent Queue**: Save videos with notes to a queue for Hermes agents to process (create skills, open issues, etc.)
---
## 📚 Documentation
Comprehensive documentation for this project is available in the `docs/` folder:
- **[Architecture](docs/architecture.md)** - System overview, components, API endpoints, database schema
- **[Progress](docs/progress.md)** - Session history, completed tasks, planned features, metrics
- **[Configuration](docs/configuration.md)** - Environment variables, API tokens, database, Authelia, Caddy setup
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues, recovery procedures, monitoring
---
## 🚀 Setup Instructions
### 1. Extract your YouTube browser cookies
Since YouTube restricts subscription feeds to logged-in users, you need to export your logged-in cookies so `yt-dlp` can request the feed on your behalf:
1. Install a browser extension like **Get cookies.txt LOCALLY** (Chrome/Edge) or **cookies.txt** (Firefox).
2. Open YouTube in your browser and ensure you are logged in to your account.
3. Click the extension icon and export the cookies for `youtube.com` as a Netscape-formatted text file.
4. Create a folder named `config` in your project root, rename the exported file to `cookies.txt`, and save it inside:
```text
t-youtube/config/cookies.txt
```
### 2. Configure Environment Variables
Create a `.env` file in the root directory:
```env
# Optional: Override the fallback OpenAI / Anthropic key
OPENAI_API_KEY=sk-Kq5...rOlx
```
### 3. Build & Deploy using Docker Compose
Run the following command to build the multi-stage image (compiles frontend static assets using Node, then packages backend with Python) and start the container:
```bash
docker compose up -d --build
```
The server will boot up and listen on port `8000`.
+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())
"
+4
View File
@@ -44,6 +44,10 @@ for p in [os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)
def download_transcript(video_id: str) -> str:
try:
session = requests.Session()
# Respect HTTP_PROXY/HTTPS_PROXY env vars
proxy = proxy_from_env()
if proxy:
session.proxies = {"http": proxy, "https": proxy}
session.headers.update({
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept-Language": "en-US,en;q=0.9"
+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
+21
View File
@@ -0,0 +1,21 @@
# Product Definition Document — T YouTube
## Vision
A text-first YouTube experience — read transcripts, get AI summaries, never watch a video.
## Goals
1. **No-video YouTube** — read subscriptions, watch content as text, never open the YouTube app
2. **Daily digest** — AI-summarized subscription feed delivered to Telegram daily
3. **GFW-proof** — works reliably from China despite YouTube blocks
## Target Users
Self (Jimmy Gan). Primary use: morning skim of YouTube subscriptions via text summaries.
## Success Metrics
- Daily sync completes without errors
- Telegram digest delivered every morning
- < 5s page load for dashboard
+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'
```
+152
View File
@@ -0,0 +1,152 @@
# t-youtube Configuration Guide
## Environment Variables
### Backend (.env)
```bash
# Database path
DB_DIR=/volume1/docker/t-youtube/backend
# API Token (for write operations)
API_TOKEN=<your-api-token>
# DeepSeek API (for summarization)
DEEPSEEK_API_KEY=<your-key>
# YouTube Data API v3
GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
```
### Docker Compose
```yaml
version: "3"
services:
t-youtube:
build: .
ports:
- "8001:8001"
environment:
- DB_DIR=/backend/data
- API_TOKEN=<your-token>
- DEEPSEEK_API_KEY=<your-key>
volumes:
- ./backend/data:/backend/data
```
## API Token Management
### Generation
```bash
python3 -c "import secrets; print('api-' + secrets.token_urlsafe(32))"
```
### Frontend Setup
- First-time users see a token setup modal
- Token stored in localStorage
- Bearer token sent with all API write requests
### Backend Validation
- Middleware checks `Authorization: Bearer <token>` for POST/PATCH/DELETE on `/api/*`
- Returns 401 JSON for invalid tokens
- GET endpoints remain open to authenticated frontend users
## Database Management
### Location
- **Path**: `/volume1/docker/t-youtube/backend/t_youtube.db`
- **Type**: SQLite (aiosqlite)
- **Backup**: Copy the `.db` file before any schema changes
### Schema Migrations
- Use `sqlite3` CLI or a migration script
- Always add indexes after schema changes
- Example:
```bash
sqlite3 t_youtube.db "CREATE INDEX IF NOT EXISTS idx_x ON table(column);"
```
### Daily Sync Configuration
#### Cron Job
```bash
# Runs every day at 3 AM
0 3 * * * /path/to/run_sync.py >> /var/log/t-youtube-sync.log 2>&1
```
#### Fetcher Settings
- **RSS**: Free, unlimited, ~2000 channels
- **YouTube Data API v3**: ~50 units per sync
- **Rotation**: Fetches in slices to avoid timeouts
- **Retry**: Automatic retry on failure
#### Summarizer Settings
- **Model**: `deepseek-chat` via DeepSeek API
- **Timeout**: 30 seconds per video
- **Fallback**: Video description if transcript unavailable
- **Language**: Follows content language (Chinese → Chinese summary)
## Authelia Integration
### Authentication Flow
1. User accesses protected domain
2. Caddy forwards request to Authelia (`127.0.0.1:9092`)
3. Authelia checks session/2FA
4. If no session → redirect to `auth.jimmygan.com`
5. If session exists → return 200 (allowed)
### 2FA Methods
- **WebAuthn (Primary)**: Passkey via iCloud Keychain/Bitwarden
- **TOTP (Fallback)**: Time-based one-time password
### Configuration
- **Address**: `127.0.0.1:9092`
- **Database**: SQLite (`/volume1/docker/authelia/config/db.sqlite3`)
- **LDAP Backend**: LLDAP (`lldap:3890`)
- **Session**: 1 hour expiration, 5 minute inactivity timeout
### Restart Authelia
```bash
cd /volume1/docker/authelia && /usr/local/bin/docker compose restart authelia
```
## Caddy Configuration
### VPS Caddy (Public)
- **Port**: 443
- **TLS**: Let's Encrypt (HTTP-01)
- **Authelia**: `forward_auth 100.78.131.124:9092`
- **DNS**: dnsmasq → VPS Tailscale IP `100.109.198.126`
### NAS Caddy (Local/Tailscale)
- **Port**: 8443 (fallback due to SNI bug)
- **TLS**: Cloudflare DNS-01
- **Authelia**: `forward_auth 127.0.0.1:9092`
- **SNI Bug**: Per-site `tls` blocks cause cert routing issues
- **Workaround**: Global TLS config + dnsmasq → VPS Tailscale IP
### Reloading Caddy
```bash
# VPS
ssh caddy-vps "sudo systemctl reload caddy"
# NAS
cd /volume1/docker/caddy && /usr/local/bin/docker compose restart caddy
```
## Monitoring & Health
### Health Check
```bash
curl -s https://yt.jimmygan.com/api/health
# Expected: {"status": "ok"}
```
### Sync Status
- Check `run_sync.py` logs in `/var/log/t-youtube-sync.log`
- Monitor DB for new videos/summaries
- Verify cron job is active: `crontab -l`
### Database Size
```bash
ls -lh /volume1/docker/t-youtube/backend/t_youtube.db
```
+102
View File
@@ -0,0 +1,102 @@
# t-youtube Progress Tracking
## Session 2026-07-09: Agent Queue, Notes Migration, Architecture Documentation
### Completed
- [x] **Agent Queue Feature**:
- Added `agent_queue` table to SQLite DB
- Implemented `POST/GET/DELETE` endpoints in `main.py`
- Created `agent_worker.py` skeleton for processing pending items
- Frontend "Agent Queue" tab (Svelte 5) with status indicators
- [x] **Notes Migration**:
- Moved notes from localStorage to server-side DB (`video_notes` table)
- Added `POST/GET/DELETE` endpoints for notes
- Implemented non-blocking localStorage migration script
- [x] **Database Optimization**:
- Added indexes on `video_id` for `agent_queue` and `video_notes` tables
- [x] **Authelia Configuration**:
- Attempted passwordless mode (failed due to unsupported key in Authelia v4)
- Reverted to standard 2FA (WebAuthn/TOTP)
- [x] **Documentation**:
- Created `docs/` folder with `architecture.md` and `progress.md`
- Documented all API endpoints, DB schema, infrastructure, and known issues
### In Progress
- [ ] **Agent Worker Logic**:
- `agent_worker.py` needs actual LLM/feature-creation logic
- Pending: Define workflow for processing queued items
- [ ] **YouTube Sync Monitoring**:
- Daily cron at 3 AM is active
- Pending: Monitor next run for errors/progress
### Planned
- [ ] **Channel Categorization**:
- ~119 channels remain uncategorized
- Consider moving `AUTO_CATEGORIES` to server-side JSON config
- [ ] **Transcript Fallback**:
- Implement browser-based scraping (playwright/selenium) for the remaining 10%
- High complexity/resource usage trade-off
- [ ] **Performance Monitoring**:
- Add health check endpoints for sync status
- Implement error logging/reporting
## Session 2026-07-05 to 2026-07-08: Initial Development
### Key Milestones
- [x] **YouTube Data API v3 + RSS Hybrid**: Replaced yt-dlp/cookies approach
- Free, unlimited sync (~50 units/sync for 200+ channels)
- 2414 new videos discovered in first run
- [x] **AI Summarization**: DeepSeek API integration (was Claude/bytecatcode)
- Chinese content → Chinese summary; English content → English summary
- ~2.1s per video, ~$0.001 each
- [x] **Frontend Overhaul**:
- AI Digest as hero element
- Keyboard navigation (`j/k/↑↓`, `r/m`, `s`, `n`, `Enter`, `?`)
- Dark/light mode toggle
- Channel filter dropdown with auto-generated categories
- Auto-read after 3s silent mark
- [x] **Agent Queue Integration**:
- "Save to Agent" feature: notes ARE prompts for Hermes agents
- Queue tab with pending/processing/done status
- Cross-device note sync (localStorage → server DB)
### Technical Decisions
1. **API Token Auth**: Bearer token for API writes, Authelia SSO for frontend
2. **SQLite**: Simple, reliable, no external dependencies for single-user setup
3. **DeepSeek API**: Cost-effective for summarization (~$0.001/video)
4. **Svelte 5**: Reactivity model, keyboard workflow, small bundle size
5. **RSS + YouTube Data API**: Free tier sufficient, avoids cookie/yt-dlp maintenance
## Session 2026-07-04: NAS & Authelia Setup
### Completed
- [x] NAS Caddy port 443 conflict resolved (DSM nginx)
- [x] dnsmasq → VPS Caddy Tailscale IP (100.109.198.126)
- [x] Authelia WebAuthn enabled, passkey registered
- [x] TOTP reset for jimmy, default 2FA set to WebAuthn
- [x] Caddy SNI bug workaround (global TLS config, port 8443 fallback)
## Known Issues & Resolutions
### Authelia Passwordless Failure
- **Issue**: `webauthn.passwordless: true` caused boot loop (`configuration key not expected`)
- **Root Cause**: Unsupported configuration key in current Authelia version
- **Resolution**: Reverted to standard 2FA (WebAuthn/TOTP)
### YouTube Transcript Blocking
- **Issue**: 90% of videos have transcripts; 10% fail due to Oracle Cloud IP blocking
- **Root Cause**: YouTube IP-based restrictions
- **Resolution**: Fallback to video description summaries; consider browser-based scraping later
### NAS Caddy SNI Bug
- **Issue**: NAS Caddy serves first cached cert for all SNI names
- **Root Cause**: Per-site `tls { ... }` blocks conflict with global auto_https policy
- **Resolution**: Global TLS config + dnsmasq → VPS Caddy Tailscale IP
## Metrics & Performance
- **Videos in DB**: ~2414+ (initial sync)
- **Channels**: ~229 subscribed
- **Sync Time**: ~2-3 minutes for all channels
- **Summarization**: ~2.1s/video, ~$0.001 each via DeepSeek
- **Frontend Load Time**: 0.14-0.22s (API responses)
- **Daily API Usage**: ~50 units (well within 10,000/day free tier)
+1
View File
@@ -0,0 +1 @@
sprint-001-sync-reliability.md
+32
View File
@@ -0,0 +1,32 @@
# Sprint 001 — Sync Reliability & Telegram Delivery
**Dates:** Active
## Goals
- Fix daily sync on NAS
- Verify Telegram delivery pipeline
- Stabilize subscription fetching behind GFW
## Tasks
### T1: Fix daily sync cron
**Description:**
`t-youtube-daily-sync` errored last run. Determine if `run_sync.py` is the active pipeline or if VPS-based `fetch_and_sync.sh` replaced it. Fix whichever is active.
**ACs:**
- [ ] Daily sync completes without errors
- [ ] New videos appear in dashboard
**Impl Notes:**
Check NAS cron logs. VPS sync fires every 6h.
### T2: Verify tg-group-filter delivery
**Description:**
Cron switched from `deliver: local` to `deliver: all`. Confirm Telegram delivery works for filtered results.
**ACs:**
- [ ] Telegram receives filtered video digest
- [ ] No duplicate deliveries
+194
View File
@@ -0,0 +1,194 @@
# t-youtube Troubleshooting Guide
## Common Issues
### 1. Site Down / 502 Bad Gateway
- **Cause**: t-youtube container not running or Authelia down
- **Check**:
```bash
ssh nas 'docker ps | grep t-youtube'
ssh nas 'docker ps | grep authelia'
```
- **Fix**:
```bash
cd /volume1/docker/t-youtube && /usr/local/bin/docker compose up -d
cd /volume1/docker/authelia && /usr/local/bin/docker compose restart authelia
```
### 2. Authelia Loop / Login Fails
- **Cause**: WebAuthn/TOTP misconfiguration or session expired
- **Check Authelia logs**:
```bash
ssh nas 'docker logs authelia --tail 20'
```
- **Fix**:
- Clear browser cache/cookies
- Reset WebAuthn via Authelia DB (see below)
- Check dnsmasq config (should point to VPS Tailscale IP)
### 3. YouTube Data API Quota Exceeded
- **Symptom**: Fetcher returns empty results or 403 errors
- **Cause**: Daily quota limit reached (10,000 units)
- **Check**:
```bash
ssh nas 'docker logs t-youtube 2>&1 | grep -i "quota\|403"'
```
- **Fix**:
- Wait until next day (quota resets at 00:00 UTC)
- Reduce number of channels or increase sync interval
- Monitor usage in Google Cloud Console
### 4. Summarization Failing
- **Symptom**: Videos show "No summary" or generic descriptions
- **Cause**: DeepSeek API key invalid or rate limited
- **Check**:
```bash
ssh nas 'docker logs t-youtube 2>&1 | grep -i "summariz\|deepseek"'
```
- **Fix**:
- Verify `DEEPSEEK_API_KEY` in `.env`
- Test API key manually:
```bash
curl -s -X POST https://api.deepseek.com/chat/completions \
-H "Authorization: Bearer ***
-H "Content-Type: application/json" \
-d '{"model": "deepseek-chat", "messages": [{"role": "user", "content": "test"}]}'
```
### 5. Database Locked/Corrupted
- **Symptom**: API returns 500 errors or hangs
- **Cause**: Concurrent write operations or unexpected shutdown
- **Fix**:
```bash
cd /volume1/docker/t-youtube/backend
cp t_youtube.db t_youtube.db.backup
sqlite3 t_youtube.db "PRAGMA integrity_check;"
# If corrupted, restore from backup or recreate
```
### 6. Caddy Config Issues
- **Symptom**: 502/503 errors, wrong cert, or domain unreachable
- **Check**:
```bash
ssh caddy-vps "sudo caddy validate --config /etc/caddy/Caddyfile"
ssh nas 'docker exec caddy caddy validate --config /etc/caddy/Caddyfile'
```
- **Fix**:
- Validate config
- Reload/restart Caddy
- Check TLS certs:
```bash
echo | openssl s_client -connect 161.33.182.109:443 -servername yt.jimmygan.com 2>&1 | openssl x509 -noout -subject -dates
```
## API Debugging
### Check Video List
```bash
curl -s https://yt.jimmygan.com/api/videos | python3 -m json.tool | head -20
```
### Check Agent Queue
```bash
curl -s -H "Authorization: Bearer *** https://yt.jimmygan.com/api/agent-queue
```
### Check Notes
```bash
curl -s -H "Authorization: Bearer *** https://yt.jimmygan.com/api/notes
```
### Check Channel List
```bash
curl -s -H "Authorization: Bearer *** https://yt.jimmygan.com/api/channels
```
### Test Summarization
```bash
curl -s -X POST -H "Authorization: Bearer ***
-H "Content-Type: application/json" \
-d '{"video_id": "<video_id>"}' \
https://yt.jimmygan.com/api/agent-queue/<video_id>/summarize
```
## Database Queries
### Count Videos by Status
```sql
SELECT status, COUNT(*) FROM videos GROUP BY status;
```
### Count Agent Queue Items by Status
```sql
SELECT status, COUNT(*) FROM agent_queue GROUP BY status;
```
### Find Videos Without Summaries
```sql
SELECT video_id, title FROM videos WHERE summary IS NULL OR summary = '';
```
### Check DB Size
```bash
ls -lh /volume1/docker/t-youtube/backend/t_youtube.db
```
## Logs
### t-youtube Logs
```bash
ssh nas 'docker logs t-youtube --tail 50'
ssh nas 'docker logs t-youtube --tail 50 --since 1h'
```
### Authelia Logs
```bash
ssh nas 'docker logs authelia --tail 50'
```
### Sync Logs
```bash
ssh nas 'cat /var/log/t-youtube-sync.log'
ssh nas 'tail -100 /var/log/t-youtube-sync.log'
```
### Caddy Logs (VPS)
```bash
ssh caddy-vps "sudo journalctl -u caddy --since '1 hour ago'"
ssh caddy-vps "sudo journalctl -u caddy --no-pager -n 30"
```
## Recovery Procedures
### Full System Restart
```bash
# 1. Restart services
cd /volume1/docker/authelia && /usr/local/bin/docker compose restart authelia
cd /volume1/docker/t-youtube && /usr/local/bin/docker compose up -d
# 2. Verify
curl -s https://yt.jimmygan.com/api/health
curl -s https://auth.jimmygan.com/api/health
```
### Database Backup & Restore
```bash
# Backup
cp /volume1/docker/t-youtube/backend/t_youtube.db /tmp/t-youtube-backup-$(date +%Y%m%d).db
# Restore
cp /tmp/t-youtube-backup-20260709.db /volume1/docker/t-youtube/backend/t_youtube.db
cd /volume1/docker/t-youtube && /usr/local/bin/docker compose restart t-youtube
```
### Authelia User Reset (if locked out)
```bash
# Stop Authelia
ssh nas 'docker stop authelia'
# Reset TOTP for jimmy
ssh nas 'sqlite3 /volume1/docker/authelia/config/db.sqlite3 "DELETE FROM totp_configurations WHERE username='\'jimmy'\'';"'
# Restart Authelia
ssh nas 'docker start authelia'
```
+248 -19
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);
@@ -225,21 +328,33 @@
$effect(() => { if (activeTab) loadVideos(); });
$effect(() => {
// Mark previous video as read when navigating away
// Scroll to top when switching videos
if (selectedVideo && summaryPanel) {
tick().then(() => summaryPanel.scrollTop = 0);
}
const prevId = prevSelectedId;
prevSelectedId = selectedVideo?.video_id || null;
if (prevId && prevId !== selectedVideo?.video_id && activeTab === 'pending') {
// Look up the previous video to check it had a valid summary
const prevVideo = videos.find(v => v.video_id === prevId);
if (prevVideo && prevVideo.summary && !prevVideo.summary.includes('Error') && !prevVideo.summary.includes('📺 Short clip')) {
updateStatus(prevId, 'read', true);
}
}
// NOTE: auto-mark-read is now handled by scroll-to-bottom detection (see below)
// and explicit user actions (r/Enter key or Read button).
});
// Scroll-based auto-mark-read: mark video as read when user scrolls to bottom of summary
let scrollReadTimer = $state(null);
function handleSummaryScroll() {
if (!summaryPanel || !selectedVideo || activeTab !== 'pending') return;
const threshold = 100; // px from bottom
const atBottom = (summaryPanel.scrollHeight - summaryPanel.scrollTop - summaryPanel.clientHeight) < threshold;
if (atBottom) {
// Debounce to avoid rapid-fire updates
if (scrollReadTimer) clearTimeout(scrollReadTimer);
scrollReadTimer = setTimeout(() => {
const sv = selectedVideo;
if (sv && sv.summary && !sv.summary.includes('Error') && !sv.summary.includes('📺 Short clip')) {
updateStatus(sv.video_id, 'read', true);
}
}, 500);
}
}
$effect(() => {
if (activeTab === 'agent') loadAgentQueue();
});
@@ -293,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();
@@ -362,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">
@@ -417,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)">
@@ -463,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}
@@ -529,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}
@@ -544,8 +753,19 @@
{/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} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
<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'}
<div class="p-6 max-w-3xl mx-auto">
<h2 class="text-sm font-black tracking-wider mb-4" style="color:var(--accent)">🤖 Agent Queue</h2>
@@ -594,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>
@@ -610,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())