Compare commits
53 Commits
575fdabbc0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| af24287748 | |||
| f1ada7d277 | |||
| fd16217076 | |||
| 757c4b29c8 | |||
| 40a2666e55 | |||
| 82e0be08de | |||
| 8020a66ae7 | |||
| 39a665b5b9 | |||
| 554060c665 | |||
| a62b8b3473 | |||
| 01b918eb91 | |||
| 6d8f76321f | |||
| b81f6ef775 | |||
| 022b024603 | |||
| 623dff149c | |||
| 20a3469e01 | |||
| 7f9d6393e7 | |||
| 4dfd3c04da | |||
| deeb2a04a5 | |||
| 5313a5b54d | |||
| 3ebbd25ca0 | |||
| a2d5d28aaf | |||
| 43977205b6 | |||
| 37cc8bfec6 | |||
| 5e8ac62672 | |||
| 1e1eec1ecd | |||
| 9c81befaa2 | |||
| 31bb2496a7 | |||
| 2cd0ba13dd | |||
| cc9a4487ac | |||
| bc77de05b5 | |||
| ca2dc468ca | |||
| 7659d7187e | |||
| 67af93999e | |||
| 11264efbb3 | |||
| 22719e5e0d | |||
| 2275f9cc1f | |||
| 57dcc8527c | |||
| 505c19eb8a | |||
| cfa8ae5b04 | |||
| 92dcfd6b04 | |||
| 429ea40f29 | |||
| 613bff06f4 | |||
| 1946aa845a | |||
| 312e787055 | |||
| dcf3c0ef78 | |||
| a2fad2a6e1 | |||
| 5c767190d2 | |||
| 90a47c95c3 | |||
| c5f3bd7696 | |||
| ed77b89e98 | |||
| 296422ff57 | |||
| 271ad96612 |
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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/*
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
Executable
+25
@@ -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
@@ -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" ", " ", text)
|
||||
text = re.sub(r"&", "&", text)
|
||||
text = re.sub(r"<", "<", text)
|
||||
text = re.sub(r">", ">", 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()
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
Executable
+6
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
sprint-003.5-agent-frontend.md
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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... 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
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user