Compare commits

..

53 Commits

Author SHA1 Message Date
Gan, Jimmy af24287748 chore: tg_filter emoji fix, sprint spec update, tg_digest script 2026-07-12 01:41:15 +08:00
Gan, Jimmy f1ada7d277 feat: use yt-dlp as primary transcript fetcher, fallback to youtube-transcript-api
- Add _ytdlp_transcript() — fetch transcripts via yt-dlp Python API
  (supports json3/srv3/vtt/srt subtitle formats, cookies.txt auth)
- Add _parse_subtitles() — parse subtitle formats into plain text
- Keep _yttranscript_api_transcript() as fallback
- download_transcript() tries yt-dlp first, then youtube-transcript-api
- Add yt-dlp to requirements.txt
- Update tests to mock both paths (all 23 pass)
- Document Google Data API v3 caption limitations in architecture.md
  (captions.list works, captions.download=403 for non-owned videos)
2026-07-12 01:36:53 +08:00
Gan, Jimmy fd16217076 agent frontend: Agents tab UI + sprint 003.5 spec
Deploy t-youtube / Build & Deploy (push) Successful in 1m43s
2026-07-12 00:15:31 +08:00
Gan, Jimmy 757c4b29c8 feat: agent_worker DeepSeek fallback & Sprint 003 spec
Deploy t-youtube / Build & Deploy (push) Successful in 1m48s
2026-07-11 16:09:29 +08:00
Gan, Jimmy 40a2666e55 ci: revert clone URL to http://gitea:3000 now that container network is properly configured
Deploy t-youtube / Build & Deploy (push) Successful in 2m28s
2026-07-11 14:18:45 +08:00
Gan, Jimmy 82e0be08de ci: use docker:cli container, install git dynamically, mount /volume1
Deploy t-youtube / Build & Deploy (push) Failing after 31s
2026-07-11 14:17:43 +08:00
Gan, Jimmy 8020a66ae7 ci: remove duplicate docker.sock volume mount to fix container creation error
Deploy t-youtube / Build & Deploy (push) Failing after 9s
2026-07-11 14:15:38 +08:00
Gan, Jimmy 39a665b5b9 ci: specify image ubuntu:latest in container block to trigger proper docker container spawn with volume mounts
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-11 14:14:57 +08:00
Gan, Jimmy 554060c665 ci: mount /volume1 into deploy container so docker cli can read host .env file
Deploy t-youtube / Build & Deploy (push) Failing after 2s
2026-07-11 14:14:24 +08:00
Gan, Jimmy a62b8b3473 ci: use localhost 127.0.0.1:3300 for checkout since job runs in host network namespace
Deploy t-youtube / Build & Deploy (push) Failing after 1m9s
2026-07-11 14:00:44 +08:00
Gan, Jimmy 01b918eb91 ci: use host.docker.internal:3300 for checkout to bypass DNS issue
Deploy t-youtube / Build & Deploy (push) Failing after 2s
2026-07-11 13:57:16 +08:00
Gan, Jimmy 6d8f76321f ci: clear build folder before git clone to prevent checkout error
Deploy t-youtube / Build & Deploy (push) Failing after 4s
2026-07-11 13:51:14 +08:00
Gan, Jimmy b81f6ef775 docs: create sprint-002 specification and mark all tasks as completed 2026-07-11 13:45:29 +08:00
Gan, Jimmy 022b024603 Sprint 002: Ingest duration & view count, FTS search, infinite scroll pagination, and transcript accordion
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-11 13:16:59 +08:00
Gan, Jimmy 623dff149c sprint: mark all ACs complete — sync verified OK, TG delivery verified 2026-07-11 12:20:17 +08:00
Gan, Jimmy 20a3469e01 ci: mount docker socket in deploy container for docker-in-docker
Deploy t-youtube / Build & Deploy (push) Failing after 0s
2026-07-11 12:08:09 +08:00
Gan, Jimmy 7f9d6393e7 ci: re-trigger with fixed runner config
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-11 11:46:37 +08:00
Gan, Jimmy 4dfd3c04da ci: retry after runner config fix (nas:ubuntu) 2026-07-11 11:46:23 +08:00
Gan, Jimmy deeb2a04a5 ci: trigger rebuild with nas runner label
Deploy t-youtube / Build & Deploy (push) Failing after 0s
2026-07-11 11:33:20 +08:00
Gan, Jimmy 5313a5b54d ci: trigger rebuild after runner config fix (nas label) 2026-07-11 11:31:21 +08:00
Gan, Jimmy 3ebbd25ca0 fix CI: use nas runner with gitea_gitea network, remove save/load pipe
Deploy t-youtube / Build & Deploy (push) Failing after 1s
2026-07-11 09:03:32 +08:00
Gan, Jimmy a2d5d28aaf refactor: reformat tg_filter.py and clean up
Deploy t-youtube / Build & Deploy (push) Failing after 1m30s
2026-07-11 05:24:40 +08:00
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
32 changed files with 2300 additions and 103 deletions
+43
View File
@@ -0,0 +1,43 @@
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: nas
container:
image: docker:cli
network: gitea_gitea
volumes:
- /volume1:/volume1
steps:
- name: Checkout
run: |
apk add --no-cache git
rm -rf /volume1/docker/t-youtube-build && 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: Swap container on host
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: |
export DOCKER_API_VERSION=1.43
sleep 3
docker ps --filter name=t-youtube --format "{{.Status}}"
echo "OK: deploy complete"
+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/*
+40
View File
@@ -64,3 +64,43 @@ t-youtube/
* **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.
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`.
+30 -6
View File
@@ -13,8 +13,12 @@ import db
log = logging.getLogger(__name__)
QWEN_BASE_URL = os.environ.get("QWEN_BASE_URL", "http://localhost:8080")
# LLM config: use local Qwen if QWEN_BASE_URL set, otherwise fall back to DeepSeek
QWEN_BASE_URL = os.environ.get("QWEN_BASE_URL", "")
QWEN_MODEL = os.environ.get("QWEN_MODEL", "current")
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat")
POLL_INTERVAL = int(os.environ.get("AGENT_POLL_INTERVAL", "30"))
MAX_CONCURRENT = int(os.environ.get("AGENT_MAX_CONCURRENT", "3"))
SYSTEM_PROMPT = os.environ.get(
@@ -27,8 +31,8 @@ SYSTEM_PROMPT = os.environ.get(
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
async def _call_qwen(prompt: str, video_title: str, video_context: str) -> str:
"""Call the local Qwen27B API with video context + user prompt."""
async def _call_llm(prompt: str, video_title: str, video_context: str) -> str:
"""Call local Qwen or DeepSeek API with video context + user prompt."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
@@ -45,11 +49,27 @@ async def _call_qwen(prompt: str, video_title: str, video_context: str) -> str:
https_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
proxies = http_proxy or https_proxy
if QWEN_BASE_URL:
# Use local Qwen
base_url = QWEN_BASE_URL
model = QWEN_MODEL
headers = {}
log.debug("Using local Qwen at %s", base_url)
elif DEEPSEEK_API_KEY:
# Fall back to DeepSeek cloud
base_url = DEEPSEEK_BASE_URL
model = DEEPSEEK_MODEL
headers = {"Authorization": f"Bearer {DEEPSEEK_API_KEY}"}
log.debug("Using DeepSeek cloud API")
else:
raise RuntimeError("No LLM configured: set QWEN_BASE_URL or DEEPSEEK_API_KEY")
async with httpx.AsyncClient(proxy=proxies, timeout=180) as client:
resp = await client.post(
f"{QWEN_BASE_URL}/v1/chat/completions",
f"{base_url}/v1/chat/completions",
headers=headers,
json={
"model": QWEN_MODEL,
"model": model,
"messages": messages,
"max_tokens": 2048,
"temperature": 0.3,
@@ -60,6 +80,10 @@ async def _call_qwen(prompt: str, video_title: str, video_context: str) -> str:
return data["choices"][0]["message"]["content"]
# Backward compat alias
_call_qwen = _call_llm
async def _process_item(item: dict) -> None:
"""Process a single agent_queue item."""
video_id = item["video_id"]
@@ -102,7 +126,7 @@ async def _process_item(item: dict) -> None:
video_context = "\n\n".join(context_parts) if context_parts else "No additional context available."
try:
result = await _call_qwen(user_prompt, video["title"], video_context)
result = await _call_llm(user_prompt, video["title"], video_context)
status = "done"
except Exception as e:
log.warning(f"Qwen API call failed for item {item['id']}: {e}")
+39 -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',
@@ -37,12 +38,49 @@ async def init_db():
video_id TEXT NOT NULL,
user_prompt TEXT NOT NULL,
status TEXT DEFAULT 'pending',
result TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
processed_at TEXT,
result TEXT,
FOREIGN KEY (video_id) REFERENCES videos(video_id)
)
""")
# Migration: Add result column if it doesn't exist (since the DB might already exist)
try:
async with db.execute("PRAGMA table_info(agent_queue)") as cursor:
columns = [row[1] for row in await cursor.fetchall()]
if "result" not in columns:
await db.execute("ALTER TABLE agent_queue ADD COLUMN result TEXT")
except Exception as e:
# Table might not exist yet if not created, but CREATE TABLE IF NOT EXISTS handles it
pass
# 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():
+27 -4
View File
@@ -6,6 +6,7 @@ import logging
import aiosqlite
import httpx
import xml.etree.ElementTree as ET
import re
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from auth import get_credentials
@@ -65,6 +66,20 @@ async def _fetch_rss(client, channel_id):
return []
def parse_duration(duration_str: str) -> int:
"""Parse ISO 8601 duration string (e.g., PT1H2M10S) to seconds."""
if not duration_str:
return 0
pattern = re.compile(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?')
match = pattern.match(duration_str)
if not match:
return 0
hours = int(match.group(1)) if match.group(1) else 0
minutes = int(match.group(2)) if match.group(2) else 0
seconds = int(match.group(3)) if match.group(3) else 0
return hours * 3600 + minutes * 60 + seconds
async def fetch_subscriptions():
"""Fetch latest videos from subscribed channels using RSS + YouTube Data API v3."""
try:
@@ -144,16 +159,22 @@ async def fetch_subscriptions():
for i in range(0, len(new_ids), 50):
batch = new_ids[i:i+50]
try:
resp = youtube.videos().list(part="snippet", id=",".join(batch)).execute()
resp = youtube.videos().list(part="snippet,contentDetails,statistics", id=",".join(batch)).execute()
for item in resp.get("items", []):
vid = item["id"]
snippet = item["snippet"]
cid, rss_title = rss_map.get(vid, (snippet.get("channelId", ""), snippet.get("title", "")))
content_details = item.get("contentDetails", {})
statistics = item.get("statistics", {})
duration_str = content_details.get("duration", "")
duration_secs = parse_duration(duration_str)
view_count = int(statistics.get("viewCount", 0))
await db.execute("""
INSERT INTO videos
(video_id, title, channel_name, channel_id, url, thumbnail_url, publish_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
(video_id, title, channel_name, channel_id, url, thumbnail_url, publish_date, duration, view_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
vid,
html.unescape(snippet.get("title", rss_title)),
@@ -161,7 +182,9 @@ async def fetch_subscriptions():
snippet.get("channelId", cid),
f"https://www.youtube.com/watch?v={vid}",
snippet.get("thumbnails", {}).get("high", {}).get("url", ""),
(snippet.get("publishTime") or "")[:10]
(snippet.get("publishTime") or "")[:10],
duration_secs,
view_count
))
new_count += 1
except HttpError as e:
+188 -6
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"})
@@ -103,15 +107,51 @@ async def root():
async def get_videos(
status: Optional[str] = "pending",
limit: int = 50,
offset: int = 0,
channel: Optional[str] = None,
search: Optional[str] = None,
db: aiosqlite.Connection = Depends(get_db)
):
"""Get videos with optional search filter and pagination."""
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:
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))
base_query += " AND v.channel_name = ?"
params.append(channel)
base_query += " ORDER BY rank LIMIT ? OFFSET ?"
params.extend([limit, offset])
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 ? OFFSET ?"
cursor = await db.execute(query, (status, channel, limit, offset))
else:
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ? OFFSET ?"
cursor = await db.execute(query, (status, limit, offset))
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 ? OFFSET ?"
params.extend([limit, offset])
cursor = await db.execute(like_query, params)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@@ -281,6 +321,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")
Binary file not shown.
+1
View File
@@ -1,6 +1,7 @@
fastapi>=0.100.0
uvicorn>=0.20.0
youtube-transcript-api>=0.6.0
yt-dlp>=2025.0.0
aiosqlite>=0.18.0
httpx>=0.24.0
pytest>=7.0.0
+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())
"
+105 -5
View File
@@ -1,5 +1,6 @@
import os
import asyncio
import json
import logging
import httpx
import aiosqlite
@@ -41,10 +42,98 @@ for p in [os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)
COOKIES_PATH = p
break
def download_transcript(video_id: str) -> str:
def _ytdlp_transcript(video_id: str) -> str | None:
"""Fetch transcript using yt-dlp Python API (preferred)."""
try:
from yt_dlp import YoutubeDL
ydl_opts = {
"skip_download": True,
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": ["en", "zh-Hans", "zh-Hant", "zh", "ja"],
"subtitlesformat": "json3",
"quiet": True,
"no_warnings": True,
}
proxy = proxy_from_env()
if proxy:
ydl_opts["proxy"] = proxy
if COOKIES_PATH and os.path.exists(COOKIES_PATH):
ydl_opts["cookiefile"] = COOKIES_PATH
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False)
# Try requested subtitles first (manually created), then auto-generated
subs = info.get("subtitles", {}) or {}
for lang in ["en", "zh-Hans", "zh-Hant", "zh", "ja"]:
if lang in subs:
sub_data = subs[lang]
for fmt in ["json3", "srv3", "srv2", "vtt", "srt"]:
if fmt in sub_data:
url = sub_data[fmt][0]["url"]
resp = requests.get(url, timeout=30)
if resp.status_code == 200:
return _parse_subtitles(resp.text, fmt)
# Try auto-generated captions (requested_subs)
req_subs = info.get("requested_subtitles", {}) or {}
for lang in ["en", "zh-Hans", "zh-Hant", "zh", "ja"]:
if lang in req_subs:
sub_data = req_subs[lang]
for fmt in ["json3", "srv3", "srv2", "vtt", "srt"]:
if fmt in sub_data:
url = sub_data[fmt][0]["url"]
resp = requests.get(url, timeout=30)
if resp.status_code == 200:
return _parse_subtitles(resp.text, fmt)
return None
except ImportError:
log.debug("yt-dlp not installed, skipping")
return None
except Exception as e:
log.warning(f"yt-dlp transcript failed for {video_id}: {e}")
return None
def _parse_subtitles(text: str, fmt: str) -> str:
"""Parse subtitle text from various formats into plain text."""
if fmt == "json3":
try:
data = json.loads(text)
events = data.get("events", [])
texts = []
for ev in events:
segs = ev.get("segs", [])
for seg in segs:
txt = seg.get("utf8", "")
if txt.strip():
texts.append(txt.strip())
return " ".join(texts)
except (json.JSONDecodeError, KeyError):
pass
# Fallback: strip HTML-like tags from VTT/SRT
import re
# Remove timestamps and position markers
text = re.sub(r"^\d+:\d+:\d+[.,]\d+\s*-->\s*\d+:\d+:\d+[.,]\d+.*$", "", text, flags=re.MULTILINE)
text = re.sub(r"^\d+\s*$", "", text, flags=re.MULTILINE)
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"&nbsp;", " ", text)
text = re.sub(r"&amp;", "&", text)
text = re.sub(r"&lt;", "<", text)
text = re.sub(r"&gt;", ">", text)
lines = [l.strip() for l in text.split("\n") if l.strip()]
return " ".join(lines)
def _yttranscript_api_transcript(video_id: str) -> str | None:
"""Fallback transcript fetcher using youtube-transcript-api."""
try:
session = requests.Session()
# Respect HTTP_PROXY/HTTPS_PROXY env vars
proxy = proxy_from_env()
if proxy:
session.proxies = {"http": proxy, "https": proxy}
@@ -52,7 +141,7 @@ def download_transcript(video_id: str) -> str:
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept-Language": "en-US,en;q=0.9"
})
if COOKIES_PATH:
if COOKIES_PATH and os.path.exists(COOKIES_PATH):
from http.cookiejar import MozillaCookieJar
cj = MozillaCookieJar(COOKIES_PATH)
cj.load(ignore_discard=True, ignore_expires=True)
@@ -68,9 +157,17 @@ def download_transcript(video_id: str) -> str:
parts = transcript.fetch()
return " ".join([part["text"] for part in parts])
except Exception as e:
log.warning(f"Failed to fetch transcript for {video_id}: {e}")
log.warning(f"youtube-transcript-api failed for {video_id}: {e}")
return None
def download_transcript(video_id: str) -> str | None:
"""Fetch transcript using yt-dlp first, fall back to youtube-transcript-api."""
transcript = _ytdlp_transcript(video_id)
if transcript:
return transcript
return _yttranscript_api_transcript(video_id)
async def summarize_video(video_id: str) -> str | None:
# 1. Try transcript, fall back to description (offload sync call to thread)
transcript = await asyncio.to_thread(download_transcript, video_id)
@@ -141,9 +238,12 @@ async def summarize_video(video_id: str) -> str | None:
return summary
async def summarize_all_pending():
async def summarize_all_pending(max_videos: int = 0):
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
if max_videos > 0:
cursor = await db.execute("SELECT video_id FROM videos WHERE summary IS NULL OR summary = '' LIMIT ?", (max_videos,))
else:
cursor = await db.execute("SELECT video_id FROM videos WHERE summary IS NULL OR summary = ''")
rows = await cursor.fetchall()
+4
View File
@@ -31,6 +31,10 @@ async def test_agent_worker_polls_and_processes(test_db, monkeypatch):
item = dict(await cursor.fetchone())
# Mock Qwen API
monkeypatch.setenv("QWEN_BASE_URL", "http://mock-qwen:8080")
import agent_worker
agent_worker.QWEN_BASE_URL = "http://mock-qwen:8080"
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
+15 -1
View File
@@ -43,8 +43,18 @@ def _mock_youtube_build(sub_channels=None, video_data=None):
"channelId": "chan001",
"thumbnails": {"high": {"url": f"https://img.youtube.com/vi/{vid}/hqdefault.jpg"}},
"publishTime": "2026-06-01T00:00:00Z",
"contentDetails": {"duration": "PT5M30S"},
"statistics": {"viewCount": "1250"},
})
snippet = {k: v for k, v in data.items() if k not in ("contentDetails", "statistics")}
content_details = data.get("contentDetails", {"duration": "PT5M30S"})
statistics = data.get("statistics", {"viewCount": "1250"})
items.append({
"id": vid,
"snippet": snippet,
"contentDetails": content_details,
"statistics": statistics
})
items.append({"id": vid, "snippet": data})
return MagicMock(execute=MagicMock(return_value={"items": items}))
svc = MagicMock()
@@ -115,8 +125,12 @@ async def test_fetch_new_videos(test_db):
assert len(rows) == 2
assert rows[0]["video_id"] == "vid001"
assert rows[0]["title"] == "First Video"
assert rows[0]["duration"] == 330 # PT5M30S = 330s
assert rows[0]["view_count"] == 1250
assert rows[1]["video_id"] == "vid002"
assert rows[1]["title"] == "Second Video"
assert rows[1]["duration"] == 330
assert rows[1]["view_count"] == 1250
@pytest.mark.asyncio
+8 -4
View File
@@ -43,7 +43,8 @@ async def test_download_transcript_success():
mock_trans = MockTranscript()
mock_list = MockTranscriptList(mock_trans)
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list):
with patch.object(summarizer, "_ytdlp_transcript", return_value=None), \
patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list):
text = summarizer.download_transcript("test_vid")
assert text == "Welcome to this video tutorial. Today we are learning about Svelte 5 and runes."
@@ -60,7 +61,8 @@ async def test_summarize_video_success(test_db):
mock_list = MockTranscriptList(mock_trans)
mock_response = _mock_deepseek_response("**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line.")
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
with patch.object(summarizer, "_ytdlp_transcript", return_value=None), \
patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
summary = await summarizer.summarize_video("summarize_vid")
@@ -106,7 +108,8 @@ async def test_summarize_video_deepseek_error_leaves_null(test_db):
"429 Rate Limited", request=MagicMock(), response=mock_response
)
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
with patch.object(summarizer, "_ytdlp_transcript", return_value=None), \
patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
result = await summarizer.summarize_video("err_vid")
@@ -133,7 +136,8 @@ async def test_summarize_all_pending(test_db):
mock_list = MockTranscriptList(mock_trans)
mock_response = _mock_deepseek_response("Fresh summary")
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
with patch.object(summarizer, "_ytdlp_transcript", return_value=None), \
patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
await summarizer.summarize_all_pending()
+233
View File
@@ -0,0 +1,233 @@
"""
Telegram Group Filter — scours 100+ groups for interesting messages.
Rotates through all groups across cron cycles to avoid rate limits.
Resets processed tracking per batch so each cycle gets fresh messages.
"""
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
SCORE_KEYWORD = 15
SCORE_LINK = 10
SCORE_MEDIA = 8
SCORE_CODE = 12
SCORE_LONG_TEXT = 5
THRESHOLD = 40
# Scan config
MSGS_PER_GROUP = 500
GROUPS_PER_CYCLE = 0 # 0 = scan all eligible groups (was 56)
# Keywords
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", "产品", "产品发布",
]
IGNORE_PATTERNS = ["合租社群", "黄油派对"]
def score_message(msg: Message) -> int:
score = 0
text = msg.text or msg.caption or ""
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
if msg.photo or msg.video or msg.document or msg.audio: score += SCORE_MEDIA
if re.search(r'https?://[^\s]+', text): score += SCORE_LINK
text_lower = text.lower()
for kw in INTERESTING_KEYWORDS:
if kw in text_lower: score += SCORE_KEYWORD
if len(text.strip()) >= 200: score += SCORE_LONG_TEXT
if msg.forward_from or msg.forward_from_chat: score += 5
if msg.reply_to_message_id: score += 3
return score
def should_ignore(chat_title: str) -> bool:
if not chat_title: return False
for pat in IGNORE_PATTERNS:
if pat in chat_title: return True
return False
def init_db():
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.execute("CREATE TABLE IF NOT EXISTS group_stats (chat_id INTEGER PRIMARY KEY, chat_title TEXT, total_scanned INTEGER DEFAULT 0, interesting_found INTEGER DEFAULT 0, last_interesting_at TEXT, last_scan_at TEXT)")
db.commit()
return db
def update_group_stats(db, chat_id: int, chat_title: str, found_interesting: int, now: str):
cur = db.execute("SELECT total_scanned, interesting_found FROM group_stats WHERE chat_id = ?", (chat_id,))
row = cur.fetchone()
if row:
db.execute(
"UPDATE group_stats SET total_scanned = total_scanned + 1, interesting_found = interesting_found + ?, "
"last_interesting_at = CASE WHEN ? > 0 THEN ? ELSE last_interesting_at END, last_scan_at = ? WHERE chat_id = ?",
(found_interesting, found_interesting, now if found_interesting > 0 else None, now, chat_id)
)
else:
db.execute(
"INSERT INTO group_stats (chat_id, chat_title, total_scanned, interesting_found, last_interesting_at, last_scan_at) "
"VALUES (?, ?, 1, ?, ?, ?)",
(chat_id, chat_title, found_interesting, now if found_interesting > 0 else None, now)
)
async def scan_and_report(dry_run: bool = False) -> list:
db = init_db()
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
total_groups = 0
now = datetime.now(timezone.utc).isoformat()
# Collect all eligible dialogs
candidates = []
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
candidates.append((chat_id, chat_title))
log.info(f"Total eligible groups: {len(candidates)}")
# Sort by last_scan_at (NULLs first) to rotate
sorted_candidates = []
for cid, title in candidates:
cur = db.execute("SELECT last_scan_at FROM group_stats WHERE chat_id = ?", (cid,))
row = cur.fetchone()
if row and row[0]:
sorted_candidates.append((cid, title, row[0]))
else:
sorted_candidates.append((cid, title, ""))
sorted_candidates.sort(key=lambda x: (x[2] == "", x[2]))
sorted_candidates = sorted_candidates[:len(candidates)] if GROUPS_PER_CYCLE == 0 else sorted_candidates[:GROUPS_PER_CYCLE]
log.info(f"Scanning {len(sorted_candidates)} groups this cycle")
# Clear processed entries for these groups so we get fresh messages
group_ids = [cid for cid, _, _ in sorted_candidates]
db.execute("DELETE FROM processed WHERE chat_id IN ({})".format(
",".join("?" * len(group_ids))
), group_ids)
db.commit()
for chat_id, chat_title, _ in sorted_candidates:
total_groups += 1
group_found = 0
async for msg in c.get_chat_history(chat_id, limit=MSGS_PER_GROUP):
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)
try:
text = (msg.text or msg.caption or "")[:2000]
except (UnicodeDecodeError, UnicodeEncodeError):
# surrogate-pass decode failure on certain emoji sequences
text = (str(msg.caption or "")[:2000]) if msg.caption else "[text unreadable]"
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)
group_found += 1
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, now),
)
else:
total_skipped += 1
update_group_stats(db, chat_id, chat_title, group_found, now)
db.commit()
await c.stop()
db.close()
log.info(f"Scanned {total_groups} groups, read {total_read} msgs, found {len(interesting)} interesting")
if not dry_run and interesting:
for item in interesting:
_print_item(item)
# Group quality summary
print(f"\n{'='*50}")
print(f"📊 GROUP QUALITY SUMMARY")
print(f"{'='*50}")
db2 = sqlite3.connect(DB_PATH)
rows = db2.execute(
"SELECT chat_title, total_scanned, interesting_found, last_interesting_at FROM group_stats ORDER BY interesting_found DESC LIMIT 50"
).fetchall()
for title, scanned, found, last_interest in rows:
status = "🟢" if found >= 1 else "🟡" if scanned >= 3 else ""
print(f"{status} {title}{scanned} scans, {found} interesting, last signal: {last_interest or 'never'}")
db2.close()
return interesting
def _print_item(item: dict):
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.
+231
View File
@@ -0,0 +1,231 @@
# 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
#### Google Data API v3 Transcript Support (Investigation 2025-07-11)
| Endpoint | Works? | Notes |
|----------|--------|-------|
| `captions.list` (part=snippet, videoId=...) | ✅ Yes | Lists available caption tracks with language, kind (standard/asr), status |
| `captions.download` (id=..., tfmt='srt') | ❌ **403 Forbidden** | Only works for videos **you own**. Returns error: *"permissions associated with the request are not sufficient to download the caption track"* |
| `videos.list` (part=snippet) | ✅ Yes | Returns video description (~200-300 chars typical), but NOT transcript/captions |
**Conclusion**: There is no Google official API to download captions of other creators' videos. The `captions.download` endpoint is strictly limited to video owners. Third-party tools (`youtube-transcript-api`, `yt-dlp`) reverse-engineer YouTube's internal innertube API to extract captions, which is why they get blocked.
#### Transcript Retrieval Strategy
The only reliable way to get transcripts is **fresh YouTube session cookies + yt-dlp**:
```
Cookies (exported from browser) → yt-dlp (write-subs) → HTML/JSON → transcript text
```
yt-dlp is preferred over `youtube-transcript-api` because:
1. More actively maintained and widely used
2. Better at evading bot detection (more complete browser fingerprinting)
3. Supports `--cookies-from-browser` for direct cookie extraction
4. Has built-in subtitle extraction (`--write-subs`)
**Current blockers** (verified 2025-07-11):
1. `config/cookies.txt` is **expired** (last exported 2024-06-24) — YouTube session cookies expire quickly
2. macOS encrypts Chrome cookies via Keychain → `browser_cookie3` and `yt-dlp --cookies-from-browser` fail in non-interactive sessions
3. `youtube-transcript-api` returns `RequestBlocked` from this environment
**Fix path**: Re-export cookies.txt from browser → `summarizer.py` uses yt-dlp with that cookies file.
**Workaround** (current, degraded): Title + description-based summaries via Qwen. Description provides ~200-300 chars of useful text.
### 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-003.5-agent-frontend.md
+34
View File
@@ -0,0 +1,34 @@
# Sprint 002 — UX Overhaul, Full-Text Search & Metadata Enrichment
**Goal:** Overhaul the t-youtube reading experience by completing the search workflow, adding transcript visibility, fetching duration/views metadata, and implementing cursor-based/offset pagination (infinite scroll).
---
## T2.1 — Full-Text Search Frontend Integration
- **Files:** `frontend/src/routes/YoutubeDashboard.svelte`
- **Done means:** Search queries directly populate the main video list in the sidebar (rather than a temporary dropdown), and clearing the search resets the view.
- **Verify by:**
1. Type search query -> video list dynamically updates with FTS results from backend.
2. Click a search result -> detail view loads it.
3. Clear query/blur search -> list reverts to standard tab view.
## T2.2 — Transcript Accordion in Details View
- **Files:** `frontend/src/routes/YoutubeDashboard.svelte`
- **Done means:** Add an expandable accordion below the "AI Digest" section to view the raw video transcript. If transcript is long, render in scrollable container.
- **Verify by:**
1. Click video with transcript -> "Show Full Transcript" accordion appears.
2. Click to expand -> raw transcript displays correctly.
## T2.3 — Ingest Duration and View Count Metadata
- **Files:** `backend/fetcher.py`, `backend/tests/test_fetcher.py`
- **Done means:** Update `fetcher.py` videos.list API call to request `part="snippet,contentDetails,statistics"`. Parse ISO 8601 duration into integer seconds, and store duration + view count in SQLite db. Update unit tests to include mock response fields.
- **Verify by:**
1. Run `./run_tests.sh` to ensure fetcher tests pass.
2. Sync subscriptions -> inspect database to confirm `duration` and `view_count` are populated for new videos.
## T2.4 — Cursor/Offset Pagination (Infinite Scroll)
- **Files:** `backend/main.py`, `frontend/src/routes/YoutubeDashboard.svelte`
- **Done means:** Add pagination queries to `/api/videos` backend endpoint (e.g. `offset` and `limit`). In Svelte frontend, implement scroll listener on the sidebar video list to trigger next page load when reaching bottom.
- **Verify by:**
1. Load pending tab -> initially loads 50 videos.
2. Scroll to bottom of sidebar -> next 50 videos append automatically.
@@ -0,0 +1,34 @@
# Sprint 002 — UX Overhaul, Full-Text Search & Metadata Enrichment
**Goal:** Overhaul the t-youtube reading experience by completing the search workflow, adding transcript visibility, fetching duration/views metadata, and implementing cursor-based/offset pagination (infinite scroll).
---
## [x] T2.1 — Full-Text Search Frontend Integration
- **Files:** `frontend/src/routes/YoutubeDashboard.svelte`
- **Done means:** Search queries directly populate the main video list in the sidebar (rather than a temporary dropdown), and clearing the search resets the view.
- **Verify by:**
1. Type search query -> video list dynamically updates with FTS results from backend.
2. Click a search result -> detail view loads it.
3. Clear query/blur search -> list reverts to standard tab view.
## [x] T2.2 — Transcript Accordion in Details View
- **Files:** `frontend/src/routes/YoutubeDashboard.svelte`
- **Done means:** Add an expandable accordion below the "AI Digest" section to view the raw video transcript. If transcript is long, render in scrollable container.
- **Verify by:**
1. Click video with transcript -> "Show Full Transcript" accordion appears.
2. Click to expand -> raw transcript displays correctly.
## [x] T2.3 — Ingest Duration and View Count Metadata
- **Files:** `backend/fetcher.py`, `backend/tests/test_fetcher.py`
- **Done means:** Update `fetcher.py` videos.list API call to request `part="snippet,contentDetails,statistics"`. Parse ISO 8601 duration into integer seconds, and store duration + view count in SQLite db. Update unit tests to include mock response fields.
- **Verify by:**
1. Run `./run_tests.sh` to ensure fetcher tests pass.
2. Sync subscriptions -> inspect database to confirm `duration` and `view_count` are populated for new videos.
## [x] T2.4 — Cursor/Offset Pagination (Infinite Scroll)
- **Files:** `backend/main.py`, `frontend/src/routes/YoutubeDashboard.svelte`
- **Done means:** Add pagination queries to `/api/videos` backend endpoint (e.g. `offset` and `limit`). In Svelte frontend, implement scroll listener on the sidebar video list to trigger next page load when reaching bottom.
- **Verify by:**
1. Load pending tab -> initially loads 50 videos.
2. Scroll to bottom of sidebar -> next 50 videos append automatically.
+82
View File
@@ -0,0 +1,82 @@
# Sprint 003 — Agent Worker + Daily Telegram Digest
**Depends on:** Sprint 002 (FTS, duration, view count, pagination)
**Duration:** ~8h total
**Goal:** Make the agent queue functional (LLM processing), and deliver a daily Telegram digest of new YouTube summaries.
---
## T3.1 — Implement agent_worker LLM processing
- **Files:** `backend/agent_worker.py`, `backend/main.py`
- **Done means:** `agent_worker.py` processes items from `agent_queue` with status=`pending`:
- Calls local LLM (via `QWEN_BASE_URL` env) or DeepSeek API to process the queued note/prompt.
- Stores result in `agent_queue.result` column.
- Updates status: `pending``processing``done` (or `error`).
- Runs as background loop every 30s.
- **Verify by:**
1. Add item to agent queue via frontend "Save to Agent".
2. Watch status change to `processing` then `done` in queue tab.
3. `result` field populated with LLM response.
- **Risk:** LLM endpoint may be unreachable from NAS. Use `AGENT_BASE_URL` env var with fallback to DeepSeek API.
- [ ] T3.1 — Agent worker LLM processing
## T3.2 — Daily Telegram digest via cron
- **Files:** New cron job in Hermes (not in repo — Hermes-managed)
- **Done means:** Every morning at 8:00 AM CST (00:00 UTC), Hermes:
1. Calls `GET /api/videos?status=pending&limit=20` on t-youtube backend.
2. Groups by channel.
3. Formats and sends a Telegram digest message: channel name, video title, summary snippet.
4. Marks videos as `read` after sending (optional, or just inform).
- **Verify by:**
1. Trigger cron manually → Telegram message received.
2. Message shows channel groupings, titles, and summaries.
- **Risk:** t-youtube API behind Authelia — cron needs API token, not SSO. Use `API_TOKEN` env var.
- [ ] T3.2 — Daily Telegram digest cron
## T3.3 — Duration + view count display in frontend
- **Files:** `frontend/src/routes/YoutubeDashboard.svelte`
- **Done means:** Video list items show duration (formatted as `MM:SS` or `H:MM:SS`) and view count (formatted as `1.2M`, `45K`) in the sidebar list item.
- **Verify by:**
1. Open dashboard → sidebar shows duration + view count for videos where populated.
2. Videos with `duration=null` show nothing (no broken display).
- **Risk:** Most existing videos don't have duration yet (only newly synced ones do). Display only if non-null.
- [ ] T3.3 — Duration + views display in video list
## T3.4 — Search UX polish: debounce + loading state
- **Files:** `frontend/src/routes/YoutubeDashboard.svelte`
- **Done means:**
- Search input debounced 300ms (no API call on every keystroke).
- Loading spinner shows while fetching search results.
- "No results" state when FTS returns empty.
- **Verify by:**
1. Type quickly → only one API call fires (after 300ms pause).
2. Loading state visible while waiting.
3. Type nonsense → "No results" shows cleanly.
- [ ] T3.4 — Search debounce + loading state
## T3.5 — Update active.md symlink to Sprint 003
- **File:** `docs/specs/active.md`
- **Done means:** Symlink points to `sprint-003-agent-digest.md`.
- **Verify by:** `ls -la docs/specs/active.md` → points to sprint-003.
- [ ] T3.5 — Update active.md symlink
---
## Exit criteria
- [ ] Agent queue processes items via LLM — status changes pending → done, result populated
- [ ] Daily Telegram digest cron fires at 8 AM CST with grouped summaries
- [ ] Duration + view count shown in video list (non-null only)
- [ ] Search debounced, has loading state and empty state
- [ ] All 23+ backend tests pass
- [ ] Frontend builds cleanly
+13
View File
@@ -0,0 +1,13 @@
# Sprint 003.5: Agent Queue Frontend Tab
## T3.5 Agent Queue Frontend Tab
- **Status Filtering:** Add filter buttons (All, Pending, Processing, Done, Failed).
- **Queue Table:** Display ID, Video Title, User Prompt, Status, Result, and Created At.
- **Delete/Retry Actions:** Provide buttons to remove items or retry (if failed).
- **Auto-Refresh:** Poll /api/agent-queue every 10s.
## ACs
- [ ] Users can see 'Agents' tab in the dashboard.
- [ ] Tab displays agent_queue items fetched from backend.
- [ ] Status filtering works as expected.
- [ ] Auto-refresh updates the table live.
+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'
```
+48
View File
@@ -0,0 +1,48 @@
<script>
import { onMount } from 'svelte';
let queue = $state([]);
let filter = $state('all');
async function fetchQueue() {
const res = await fetch('/api/agent-queue');
if (res.ok) queue = await res.json();
}
onMount(() => {
fetchQueue();
const interval = setInterval(fetchQueue, 10000);
return () => clearInterval(interval);
});
const filteredQueue = $derived(
filter === 'all' ? queue : queue.filter(item => item.status === filter)
);
</script>
<div class="p-4">
<div class="flex gap-2 mb-4">
{#each ['all', 'pending', 'processing', 'done', 'failed'] as f}
<button
class="px-3 py-1 rounded {filter === f ? 'bg-blue-600' : 'bg-gray-700'}"
onclick={() => filter = f}>
{f.toUpperCase()}
</button>
{/each}
</div>
<table class="w-full text-left">
<thead>
<tr><th>ID</th><th>Prompt</th><th>Status</th><th>Result</th></tr>
</thead>
<tbody>
{#each filteredQueue as item}
<tr class="border-b border-gray-700">
<td>{item.id}</td>
<td>{item.user_prompt}</td>
<td>{item.status}</td>
<td class="text-xs text-gray-400">{item.result || ''}</td>
</tr>
{/each}
</tbody>
</table>
</div>
+311 -28
View File
@@ -22,18 +22,42 @@
let editingCategories = $state(false);
let prevSelectedId = $state(null);
// Search state
let searchQuery = $state('');
let page = $state(0);
let hasMore = $state(true);
let isMoreLoading = $state(false);
let showTranscript = $state(false);
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);
$effect(() => {
if (activeTab === 'pending' && channelFilter) {
loadVideos();
} else if (activeTab === 'pending' && !channelFilter) {
loadVideos();
// 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();
});
// Auto-generated categories (AI-categorized)
const AUTO_CATEGORIES = {"AI Engineer":"AI & Tech","AI-Fan AI研究室-帆哥":"AI & Tech","AI大模型":"AI & Tech","AI探索与发现":"AI & Tech","Aurora Tech":"AI & Tech","Claude":"AI & Tech","ColdFusion":"AI & Tech","Data Is Beautiful":"AI & Tech","DeepLearningAI":"AI & Tech","Google":"AI & Tech","Google Cloud Tech":"AI & Tech","Lex Fridman":"AI & Tech","NiceKate AI":"AI & Tech","PC Sim":"AI & Tech","Stanford Online":"AI & Tech","The Linux Foundation":"AI & Tech","回到Axton":"AI & Tech","回形针PaperClip":"AI & Tech","林亦LYi":"AI & Tech","硅谷101播客":"AI & Tech","老石谈芯":"AI & Tech","鲲鹏Talk":"AI & Tech","3D Beast":"3D & Design","Arrimus3D Modeling and Design":"3D & Design","Blender":"3D & Design","Blender Guru":"3D & Design","Creators 3D":"3D & Design","Designfusion":"3D & Design","Foundry":"3D & Design","Grant Abbitt (Gabbitt)":"3D & Design","Gravity Sketch":"3D & Design","Hyperganic":"3D & Design","MSLattice":"3D & Design","Manolo Remiddi":"3D & Design","Rhino 3D (Rhinoceros3d official)":"3D & Design","Shaper3d":"3D & Design","nTop":"3D & Design","spherene AG":"3D & Design","魔界造物":"3D & Design","Chuck Severance":"Programming","Coursera":"Programming","freeCodeCamp.org":"Programming","Kevin Stratvert":"Programming","Leila Gharani":"Programming","LinkedIn Learning":"Programming","Metabase":"Programming","n8n":"Programming","The Coding Gopher":"Programming","Brian Casel":"Programming","code秘密花园":"Programming","cs tech":"Programming","fequalsf":"Programming","openscreencast":"Programming","技术爬爬虾 TechShrimp":"Programming","林本兔Limbuntu":"Programming","Best Choice Review":"Hardware & Reviews","Marques Brownlee":"Hardware & Reviews","TESTV":"Hardware & Reviews","先看评测":"Hardware & Reviews","极客湾Geekerwan":"Hardware & Reviews","硬核拆解":"Hardware & Reviews","笔吧评测室":"Hardware & Reviews","差评硬件部":"Hardware & Reviews","充电头网":"Hardware & Reviews","Apple Developer":"Apple Dev","Made by Google":"Apple Dev","Allen的分享":"Apple Dev","Sypnotix":"Apple Dev","8K World":"Photography","Can Fiona":"Photography","小天fotos":"Photography","影像极客Fotogeeker":"Photography","摄影师PHiL":"Photography","虚空光影CosmosFilm":"Photography","路易斯 LouisDrone":"Photography","Bruno Mars":"Music","GraceLeeMusic":"Music","Katie Melua":"Music","The Weeknd":"Music","TheWeekndVEVO":"Music","杰威爾歌詞MV頻道JVR Lyric MV":"Music","薛汀哲":"Music","Salah Trainer":"Sports","Skills N Talents (swimming)":"Sports","TotalSports TV":"Sports","WorldSBK":"Sports","Everyday Cycling":"Sports","爱羽客羽毛球网":"Sports","adidas":"Sports","Sneaks & Feet极客鞋谈":"Automotive","TOP的遥控玩具":"Automotive","悟空的日常":"Automotive"};
@@ -41,7 +65,9 @@
let channels = $derived([...new Set(videos.map(v => v.channel_name))].sort());
let filteredVideos = $derived(
channelFilter
searchQuery.trim().length >= 2
? searchResults
: channelFilter
? channelFilter.startsWith('📁')
? videos.filter(v => (categories[v.channel_name] || '') === channelFilter.replace('📁 ', ''))
: videos.filter(v => v.channel_name === channelFilter)
@@ -51,32 +77,144 @@
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 (searchTimeout) clearTimeout(searchTimeout);
if (!query || query.trim().length < 2) {
showSearchResults = false;
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
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;
}
}, 250);
}
async function handleSidebarScroll(e) {
if (searchQuery.trim().length >= 2) return;
if (isLoading || isMoreLoading || !hasMore) return;
const container = e.target;
const threshold = 100;
const atBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < threshold;
if (atBottom) {
isMoreLoading = true;
page += 1;
await loadVideos(true);
isMoreLoading = 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);
document.documentElement.className = theme;
}
async function loadVideos() {
async function loadVideos(isAppend = false) {
if (!isAppend) {
page = 0;
hasMore = true;
isLoading = true;
}
try {
const limit = 50;
const offset = page * limit;
if (activeTab === 'notes') {
// Notes tab: show only pending videos that have notes
const res = await fetch(`/api/videos?status=pending&limit=50`);
const params = new URLSearchParams({ status: 'pending', limit: limit.toString(), offset: offset.toString() });
const res = await fetch(`/api/videos?${params}`);
if (res.ok) {
const all = await res.json();
videos = all.filter(v => noteIds.has(v.video_id));
const fetched = await res.json();
const filtered = fetched.filter(v => noteIds.has(v.video_id));
if (isAppend) {
videos = [...videos, ...filtered];
} else {
videos = filtered;
if (videos.length > 0) selectedVideo = videos[0];
else selectedVideo = null;
}
} else {
const params = new URLSearchParams({status: activeTab, limit: '200'});
if (fetched.length < limit) hasMore = false;
}
} else if (activeTab !== 'stats' && activeTab !== '🤖 Agent') {
const params = new URLSearchParams({ status: activeTab, limit: limit.toString(), offset: offset.toString() });
if (channelFilter && !channelFilter.startsWith('📁')) {
params.set('channel', channelFilter);
}
const res = await fetch(`/api/videos?${params}`);
if (res.ok) {
videos = await res.json();
const fetched = await res.json();
if (isAppend) {
videos = [...videos, ...fetched];
} else {
videos = fetched;
if (videos.length > 0) {
const currentId = selectedVideo?.video_id;
const found = videos.find(v => v.video_id === currentId);
@@ -85,6 +223,8 @@
selectedVideo = null;
}
}
if (fetched.length < limit) hasMore = false;
}
}
} catch (err) {
console.error("Error loading videos:", err);
@@ -223,7 +363,17 @@
// ---- Lifecycle ----
$effect(() => { if (activeTab) loadVideos(); });
$effect(() => {
if (activeTab && activeTab !== 'stats' && activeTab !== '🤖 Agent') {
// Clear search when changing tab or filter
searchQuery = '';
searchResults = [];
showSearchResults = false;
loadVideos(false);
}
});
$effect(() => {
// Scroll to top when switching videos
if (selectedVideo && summaryPanel) {
@@ -231,8 +381,7 @@
}
const prevId = prevSelectedId;
prevSelectedId = selectedVideo?.video_id || null;
// NOTE: auto-mark-read is now handled by scroll-to-bottom detection (see below)
// and explicit user actions (r/Enter key or Read button).
showTranscript = false; // Reset accordion on video switch
});
// Scroll-based auto-mark-read: mark video as read when user scrolls to bottom of summary
@@ -305,7 +454,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 +525,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 +587,36 @@
<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"
value={searchQuery}
oninput={(e) => handleSearch(e.target.value)}
placeholder="Search videos..."
class="w-full text-[11px] p-1.5 pr-8 rounded border"
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)">
{#if searchQuery}
<button onclick={() => { searchQuery = ''; searchResults = []; showSearchResults = false; }}
class="absolute right-7 top-2 text-[10px] text-gray-500 hover:text-white transition"></button>
{/if}
{#if isSearching}
<div class="absolute right-2 top-2 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 +654,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}
@@ -536,26 +764,57 @@
</div>
{/if}
<!-- Video list -->
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
<div onscroll={handleSidebarScroll} class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
{#each filteredVideos as video (video.video_id)}
{@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}
</div>
{#if isMoreLoading}
<div class="p-3 text-center text-[10px]" style="color:var(--text-muted)">Loading more...</div>
{/if}
</div>
{/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 +865,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 +889,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
@@ -661,6 +929,21 @@
{@html formatMarkdown(selectedVideo.summary)}
</div>
</div>
<!-- Transcript Accordion -->
{#if selectedVideo.transcript && selectedVideo.transcript !== "No transcript available"}
<div class="rounded-xl border p-5 space-y-3 shadow-lg" style="background:var(--card-bg);border-color:var(--border)">
<div class="flex items-center justify-between cursor-pointer select-none" onclick={() => showTranscript = !showTranscript}>
<h2 class="text-xs font-black tracking-widest uppercase" style="color:var(--text-muted)">Video Transcript</h2>
<span class="text-xs transition-transform duration-200" style="transform: rotate({showTranscript ? '90deg' : '0deg'})"></span>
</div>
{#if showTranscript}
<div class="prose max-w-none text-xs leading-relaxed overflow-y-auto max-h-96 whitespace-pre-wrap pt-2 border-t" style="color:var(--text-secondary);border-color:var(--border)">
{selectedVideo.transcript}
</div>
{/if}
</div>
{/if}
</div>
{:else}
<div class="flex-1 flex items-center justify-center" style="color:var(--text-muted)">
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())
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""
t-youtube Telegram Digest Cron
Sprint 003 AC T3.2 Daily summary of today's videos by channel
Run daily at 09:00 UTC+8 via Hermes cron
"""
import os
import json
import sys
import urllib.request
import urllib.error
from datetime import datetime, timezone, timedelta
# --- Config ---
BASE_URL = os.environ.get("TY_API_URL", "http://127.0.0.1:8080")
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
# --- Fetch today's videos ---
def fetch_videos():
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
url = f"{BASE_URL}/api/videos?since={today}"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
return data.get("videos", data.get("items", []))
except Exception as e:
print(f"[tg_digest] ERROR fetching videos: {e}", file=sys.stderr)
return []
# --- Build digest ---
def build_digest(videos):
if not videos:
return "📭 No new videos today."
# Group by channel
channels = {}
for v in videos:
ch = v.get("channel", v.get("channel_name", "Unknown"))
channels.setdefault(ch, []).append(v)
lines = ["📺 **YouTube Digest — Today's Videos**", "", f"_{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_", ""]
for ch, items in sorted(channels.items()):
lines.append(f"### {ch}")
lines.append(f"_{len(items)} video(s)_")
lines.append("")
for v in items:
title = v.get("title", "Untitled")
url = v.get("url", v.get("watch_url", ""))
duration = v.get("duration", "?")
lines.append(f"- **{title}** ({duration})")
if url:
lines.append(f" {url}")
lines.append("")
lines.append("---")
lines.append(f"Total: {len(videos)} videos from {len(channels)} channels")
return "\n".join(lines)
# --- Send to Telegram ---
def send_telegram(text):
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
print("[tg_digest] WARNING: TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID not set — skipping send")
print("[tg_digest] Digest would be:\n")
print(text)
return False
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
body = json.dumps({
"chat_id": TELEGRAM_CHAT_ID,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": False,
}).encode()
try:
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode())
if result.get("ok"):
print(f"[tg_digest] Sent digest to Telegram chat {TELEGRAM_CHAT_ID}")
return True
else:
print(f"[tg_digest] Telegram API error: {result}", file=sys.stderr)
return False
except Exception as e:
print(f"[tg_digest] ERROR sending Telegram: {e}", file=sys.stderr)
return False
# --- Main ---
def main():
videos = fetch_videos()
digest = build_digest(videos)
print(digest)
print("")
send_telegram(digest)
if __name__ == "__main__":
main()