Compare commits
25 Commits
575fdabbc0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,41 @@
|
|||||||
|
name: Deploy t-youtube
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.gitea/workflows/deploy.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
name: Build & Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 http://gitea:3000/jimmy/t-youtube.git /volume1/docker/t-youtube-build 2>&1
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
export DOCKER_API_VERSION=1.43
|
||||||
|
docker build -t t-youtube:latest /volume1/docker/t-youtube-build 2>&1
|
||||||
|
|
||||||
|
- name: Deploy to NAS
|
||||||
|
run: |
|
||||||
|
docker stop t-youtube 2>/dev/null || true
|
||||||
|
docker rm t-youtube 2>/dev/null || true
|
||||||
|
docker run -d --name t-youtube --restart unless-stopped \
|
||||||
|
-p 8001:8000 \
|
||||||
|
--env-file /volume1/docker/t-youtube/.env \
|
||||||
|
-v /volume1/docker/t-youtube/data:/app/data \
|
||||||
|
-v /volume1/docker/t-youtube/config:/app/config \
|
||||||
|
t-youtube:latest 2>&1
|
||||||
|
|
||||||
|
- name: Verify
|
||||||
|
run: |
|
||||||
|
sleep 3
|
||||||
|
docker ps --filter name=t-youtube --format "{{.Status}}"
|
||||||
|
echo "OK: deploy complete"
|
||||||
@@ -13,7 +13,6 @@ WORKDIR /app
|
|||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
ffmpeg \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+29
-1
@@ -17,6 +17,7 @@ async def init_db():
|
|||||||
thumbnail_url TEXT,
|
thumbnail_url TEXT,
|
||||||
publish_date TEXT,
|
publish_date TEXT,
|
||||||
duration INTEGER,
|
duration INTEGER,
|
||||||
|
view_count INTEGER DEFAULT 0,
|
||||||
transcript TEXT,
|
transcript TEXT,
|
||||||
summary TEXT,
|
summary TEXT,
|
||||||
status TEXT DEFAULT 'pending',
|
status TEXT DEFAULT 'pending',
|
||||||
@@ -39,10 +40,37 @@ async def init_db():
|
|||||||
status TEXT DEFAULT 'pending',
|
status TEXT DEFAULT 'pending',
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
processed_at TEXT,
|
processed_at TEXT,
|
||||||
result TEXT,
|
|
||||||
FOREIGN KEY (video_id) REFERENCES videos(video_id)
|
FOREIGN KEY (video_id) REFERENCES videos(video_id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
# FTS5 virtual table for full-text search on title + summary
|
||||||
|
await db.execute("""
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS videos_fts USING fts5(
|
||||||
|
title, summary,
|
||||||
|
content='videos',
|
||||||
|
content_rowid='rowid'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Trigger to keep FTS5 index in sync
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS videos_fts_insert AFTER INSERT ON videos
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO videos_fts(rowid, title, summary) VALUES (new.rowid, new.title, new.summary);
|
||||||
|
END;
|
||||||
|
""")
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS videos_fts_update AFTER UPDATE ON videos
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO videos_fts(videos_fts, rowid, title, summary) VALUES ('delete', old.rowid, old.title, old.summary);
|
||||||
|
INSERT INTO videos_fts(rowid, title, summary) VALUES (new.rowid, new.title, new.summary);
|
||||||
|
END;
|
||||||
|
""")
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS videos_fts_delete AFTER DELETE ON videos
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO videos_fts(videos_fts, rowid, title, summary) VALUES ('delete', old.rowid, old.title, old.summary);
|
||||||
|
END;
|
||||||
|
""")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def get_db():
|
async def get_db():
|
||||||
|
|||||||
+188
-7
@@ -29,14 +29,18 @@ app.add_middleware(
|
|||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# API token validation for write operations
|
# API token validation — disabled. Authelia (Caddy forward_auth) handles auth upstream.
|
||||||
API_TOKEN=os.environ.get('API_TOKEN', '')
|
API_TOKEN=""
|
||||||
|
|
||||||
async def validate_api_token(request: Request, call_next):
|
async def validate_api_token(request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
method = request.method
|
method = request.method
|
||||||
# Only check auth for write operations on /api/*
|
# 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:
|
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", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
if not auth_header.startswith("Bearer ") or auth_header[7:] != API_TOKEN:
|
if not auth_header.startswith("Bearer ") or auth_header[7:] != API_TOKEN:
|
||||||
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
||||||
@@ -104,14 +108,49 @@ async def get_videos(
|
|||||||
status: Optional[str] = "pending",
|
status: Optional[str] = "pending",
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
channel: Optional[str] = None,
|
channel: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
db: aiosqlite.Connection = Depends(get_db)
|
db: aiosqlite.Connection = Depends(get_db)
|
||||||
):
|
):
|
||||||
if channel:
|
"""Get videos with optional search filter."""
|
||||||
query = "SELECT * FROM videos WHERE status = ? AND channel_name = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
|
if search and len(search) >= 2:
|
||||||
cursor = await db.execute(query, (status, channel, limit))
|
# Use search endpoint logic
|
||||||
|
q = search.replace('"', '\\"')
|
||||||
|
base_query = "SELECT v.* FROM videos_fts f JOIN videos v ON f.rowid = v.rowid WHERE videos_fts MATCH ?"
|
||||||
|
params = [q]
|
||||||
|
if status:
|
||||||
|
base_query += " AND v.status = ?"
|
||||||
|
params.append(status)
|
||||||
|
if channel:
|
||||||
|
base_query += " AND v.channel_name = ?"
|
||||||
|
params.append(channel)
|
||||||
|
base_query += " ORDER BY rank LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
else:
|
else:
|
||||||
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
|
if channel:
|
||||||
cursor = await db.execute(query, (status, limit))
|
query = "SELECT * FROM videos WHERE status = ? AND channel_name = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
|
||||||
|
cursor = await db.execute(query, (status, channel, limit))
|
||||||
|
else:
|
||||||
|
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
|
||||||
|
cursor = await db.execute(query, (status, limit))
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = await db.execute(base_query, params)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to LIKE search
|
||||||
|
like_query = "SELECT * FROM videos WHERE title LIKE ?"
|
||||||
|
params = [f"%{search}%"]
|
||||||
|
if status:
|
||||||
|
like_query += " AND status = ?"
|
||||||
|
params.append(status)
|
||||||
|
if channel:
|
||||||
|
like_query += " AND channel_name = ?"
|
||||||
|
params.append(channel)
|
||||||
|
like_query += " ORDER BY publish_date DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
cursor = await db.execute(like_query, params)
|
||||||
|
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
@@ -281,6 +320,148 @@ async def remove_from_agent_queue(item_id: int, db: aiosqlite.Connection = Depen
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
return {"message": f"Queue item {item_id} removed"}
|
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 ----
|
# ---- Static frontend ----
|
||||||
|
|
||||||
frontend_build_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend/dist")
|
frontend_build_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend/dist")
|
||||||
|
|||||||
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())
|
||||||
|
"
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
Telegram Group Filter — scours 100+ groups for interesting messages.
|
||||||
|
Runs as: t-youtube venv + Pyrogram session.
|
||||||
|
|
||||||
|
Scoring: mentions, links, code blocks, media, keywords → score ≥ threshold → saved.
|
||||||
|
Delivers to: hermes_telegram_bot or stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pyrogram import Client
|
||||||
|
from pyrogram.types import Message
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("tg_filter")
|
||||||
|
|
||||||
|
# === CONFIG ===
|
||||||
|
PHONE = open("/tmp/tg_phone.txt").read().strip()
|
||||||
|
API_ID = 2040
|
||||||
|
API_HASH = "b18441a1ff607e10a989891a5462e627"
|
||||||
|
WORKDIR = "/Users/jimmyg"
|
||||||
|
DB_PATH = os.path.join(WORKDIR, ".hermes", "tg_filter.db")
|
||||||
|
|
||||||
|
# Scoring
|
||||||
|
SCORE_MENTION = 30 # @username or reply to user
|
||||||
|
SCORE_KEYWORD = 15 # Interesting keyword match
|
||||||
|
SCORE_LINK = 10 # Contains URL
|
||||||
|
SCORE_MEDIA = 8 # Photo/video/file
|
||||||
|
SCORE_CODE = 12 # Code block
|
||||||
|
SCORE_LONG_TEXT = 5 # ≥200 chars of substance
|
||||||
|
THRESHOLD = 20 # Minimum score to forward
|
||||||
|
|
||||||
|
# Keywords that signal interesting content
|
||||||
|
INTERESTING_KEYWORDS = [
|
||||||
|
"ai", "llm", "gpt", "deepseek", "claude", "gemini", "qwen", "openai",
|
||||||
|
"docker", "kubernetes", "k8s", "tailscale", "wireguard", "vps", "nas",
|
||||||
|
"self-host", "homelab", "server", "deploy", "ci/cd", "gitea", "git",
|
||||||
|
"api", "automation", "script", "python", "rust", "go", "typescript",
|
||||||
|
"svelte", "react", "nextjs", "fastapi", "sveltekit",
|
||||||
|
"tutorial", "guide", "how to", "free", "open source", "github",
|
||||||
|
"security", "vulnerability", "cve", "patch", "update",
|
||||||
|
"opportunity", "合作", "项目", "招聘", "job", "remote",
|
||||||
|
"startup", "saas", "mvp", "产品", "产品发布",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Groups to always ignore (IDs or title substrings)
|
||||||
|
IGNORE_PATTERNS = [
|
||||||
|
"合租社群",
|
||||||
|
"黄油派对",
|
||||||
|
]
|
||||||
|
|
||||||
|
def score_message(msg: Message) -> int:
|
||||||
|
"""Score a message for interestingness."""
|
||||||
|
score = 0
|
||||||
|
text = msg.text or msg.caption or ""
|
||||||
|
|
||||||
|
# Mentions
|
||||||
|
if msg.entities:
|
||||||
|
for e in msg.entities:
|
||||||
|
if e.type in ("mention", "text_mention"):
|
||||||
|
score += SCORE_MENTION
|
||||||
|
if e.type == "url":
|
||||||
|
score += SCORE_LINK
|
||||||
|
if e.type == "code" or e.type == "pre":
|
||||||
|
score += SCORE_CODE
|
||||||
|
|
||||||
|
# Media
|
||||||
|
if msg.photo or msg.video or msg.document or msg.audio:
|
||||||
|
score += SCORE_MEDIA
|
||||||
|
|
||||||
|
# Links in plain text
|
||||||
|
if re.search(r'https?://[^\s]+', text):
|
||||||
|
score += SCORE_LINK
|
||||||
|
|
||||||
|
# Keyword matches
|
||||||
|
text_lower = text.lower()
|
||||||
|
for kw in INTERESTING_KEYWORDS:
|
||||||
|
if kw in text_lower:
|
||||||
|
score += SCORE_KEYWORD
|
||||||
|
|
||||||
|
# Long substantive text
|
||||||
|
if len(text.strip()) >= 200:
|
||||||
|
score += SCORE_LONG_TEXT
|
||||||
|
|
||||||
|
# Forwarded messages are often interesting
|
||||||
|
if msg.forward_from or msg.forward_from_chat:
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
# Reply to user's own messages (engagement)
|
||||||
|
if msg.reply_to_message_id:
|
||||||
|
score += 3
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def should_ignore(chat_title: str) -> bool:
|
||||||
|
"""Check if a chat should be ignored entirely."""
|
||||||
|
if not chat_title:
|
||||||
|
return False
|
||||||
|
for pat in IGNORE_PATTERNS:
|
||||||
|
if pat in chat_title:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Create DB + table for tracking processed messages."""
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
db = sqlite3.connect(DB_PATH)
|
||||||
|
db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS processed (
|
||||||
|
chat_id INTEGER,
|
||||||
|
message_id INTEGER,
|
||||||
|
PRIMARY KEY (chat_id, message_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS interesting (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chat_id INTEGER,
|
||||||
|
chat_title TEXT,
|
||||||
|
message_id INTEGER,
|
||||||
|
sender TEXT,
|
||||||
|
text TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
captured_at TEXT,
|
||||||
|
delivered INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_and_report(dry_run: bool = False) -> list:
|
||||||
|
"""Scan dialogs for interesting messages. Returns list of interesting items."""
|
||||||
|
db = init_db()
|
||||||
|
processed_ids = set()
|
||||||
|
for row in db.execute("SELECT chat_id, message_id FROM processed"):
|
||||||
|
processed_ids.add((row[0], row[1]))
|
||||||
|
|
||||||
|
c = Client("pyro_session", API_ID, API_HASH, workdir=WORKDIR)
|
||||||
|
await c.start()
|
||||||
|
me = await c.get_me()
|
||||||
|
log.info(f"Scanned as: {me.first_name}")
|
||||||
|
|
||||||
|
interesting = []
|
||||||
|
total_read = 0
|
||||||
|
total_skipped = 0
|
||||||
|
|
||||||
|
async for dialog in c.get_dialogs():
|
||||||
|
chat = dialog.chat
|
||||||
|
chat_id = chat.id
|
||||||
|
chat_title = chat.title or chat.first_name or "Unknown"
|
||||||
|
|
||||||
|
if should_ignore(chat_title):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not dialog.unread_messages_count:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Read recent messages (up to 50 per chat)
|
||||||
|
async for msg in c.get_chat_history(chat_id, limit=50):
|
||||||
|
if (chat_id, msg.id) in processed_ids:
|
||||||
|
continue
|
||||||
|
total_read += 1
|
||||||
|
|
||||||
|
score = score_message(msg)
|
||||||
|
if score >= THRESHOLD:
|
||||||
|
sender = None
|
||||||
|
if msg.from_user:
|
||||||
|
sender = msg.from_user.first_name or msg.from_user.username or str(msg.from_user.id)
|
||||||
|
text = (msg.text or msg.caption or "")[:2000]
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"chat_title": chat_title,
|
||||||
|
"message_id": msg.id,
|
||||||
|
"sender": sender,
|
||||||
|
"text": text,
|
||||||
|
"score": score,
|
||||||
|
"has_media": bool(msg.photo or msg.video or msg.document),
|
||||||
|
"url": f"https://t.me/c/{str(chat_id).replace('-100', '')}/{msg.id}" if chat_id < 0 else None,
|
||||||
|
}
|
||||||
|
interesting.append(item)
|
||||||
|
|
||||||
|
# Save to DB
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR IGNORE INTO interesting (chat_id, chat_title, message_id, sender, text, score, captured_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(chat_id, chat_title, msg.id, sender, text, score, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_skipped += 1
|
||||||
|
|
||||||
|
# Mark as processed
|
||||||
|
db.execute("INSERT OR IGNORE INTO processed (chat_id, message_id) VALUES (?, ?)", (chat_id, msg.id))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
await c.stop()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
log.info(f"Read {total_read} messages, skipped {total_skipped}, found {len(interesting)} interesting")
|
||||||
|
|
||||||
|
if not dry_run and interesting:
|
||||||
|
for item in interesting:
|
||||||
|
_print_item(item)
|
||||||
|
|
||||||
|
return interesting
|
||||||
|
|
||||||
|
|
||||||
|
def _print_item(item: dict):
|
||||||
|
"""Print an interesting item for delivery."""
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"📌 {item['chat_title']} [{item['score']}pts]")
|
||||||
|
if item['sender']:
|
||||||
|
print(f"👤 {item['sender']}")
|
||||||
|
if item['has_media']:
|
||||||
|
print(f"📎 [has media]")
|
||||||
|
if item['url']:
|
||||||
|
print(f"🔗 {item['url']}")
|
||||||
|
print(f"\n{item['text'][:500]}")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
dry_run = "--dry-run" in sys.argv
|
||||||
|
asyncio.run(scan_and_report(dry_run=dry_run))
|
||||||
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
|
||||||
@@ -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,200 @@
|
|||||||
|
# t-youtube — Text-Based YouTube Digest
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A self-hosted YouTube reader that syncs your subscriptions, generates AI summaries, and displays them in a Svelte dashboard. Designed to work around GFW restrictions in China and YouTube IP blocks on cloud providers.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Oracle Tokyo VPS │
|
||||||
|
│ (free-amd-caddy) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ Caddy (port 443) │ │
|
||||||
|
│ │ reverse_proxy to │────┼─── Cloudflare ─── Internet
|
||||||
|
│ │ NAS via Tailscale │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ Cron (every 6h) │ │
|
||||||
|
│ │ fetch_and_sync.sh │────┼─── YouTube Data API
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
└──────────┬───────────────┘
|
||||||
|
│ Tailscale (DERP relay)
|
||||||
|
┌──────────┴───────────────┐
|
||||||
|
│ NAS ds224plus │
|
||||||
|
│ (100.78.131.124) │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────┐ │
|
||||||
|
│ │ Caddy (port 8443) │ │
|
||||||
|
│ │ Let's Encrypt via │ │
|
||||||
|
│ │ Cloudflare DNS │ │
|
||||||
|
│ └───────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────┐ │
|
||||||
|
│ │ Authelia (9092) │ │
|
||||||
|
│ │ SSO + 2FA │ │
|
||||||
|
│ └───────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────┐ │
|
||||||
|
│ │ t-youtube (8001) │ │
|
||||||
|
│ │ FastAPI + Svelte │ │
|
||||||
|
│ │ SQLite DB │ │
|
||||||
|
│ └───────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────┐ │
|
||||||
|
│ │ Gitea (3300) │ │
|
||||||
|
│ │ git.jimmygan.com │ │
|
||||||
|
│ └───────────────────┘ │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. **Fetch**: VPS cron runs every 6h → YouTube Data API v3 (subscriptions list) + RSS feeds → new video metadata → local SQLite DB
|
||||||
|
2. **Summarize**: VPS reads new videos from DB → calls Qwen35B on MacBook (port 8085 via Tailscale) → title-only summaries → saves to DB
|
||||||
|
3. **Sync**: VPS `cat` DB → SSH to NAS → `sudo tee` → overwrites NAS DB → restarts Docker container
|
||||||
|
4. **Serve**: NAS Docker (FastAPI + Svelte) → serves dashboard to browser → Authelia SSO in front
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### VPS (Oracle Tokyo)
|
||||||
|
|
||||||
|
Files in `/tmp/yt-fetch/`:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `fetch_and_sync.sh` | Entry point — fetch → summarize → sync |
|
||||||
|
| `fetcher.py` | YouTube Data API + RSS hybrid fetcher |
|
||||||
|
| `summarize_new.py` | Title-based summarization via Qwen |
|
||||||
|
| `db.py` | SQLite schema |
|
||||||
|
| `auth.py` | OAuth credential management |
|
||||||
|
| `token.pickle` | YouTube OAuth token |
|
||||||
|
| `cookies.txt` | YouTube cookies (for transcript fallback) |
|
||||||
|
|
||||||
|
Cron: `0 */6 * * * bash /tmp/yt-fetch/fetch_and_sync.sh`
|
||||||
|
|
||||||
|
### NAS
|
||||||
|
|
||||||
|
Docker container at `/volume1/docker/t-youtube/`:
|
||||||
|
- `docker-compose.yml` — port 8001:8000, env vars for DeepSeek/Qwen
|
||||||
|
- `.env` — `DEEPSEEK_API_KEY`, `API_TOKEN`
|
||||||
|
- `data/t_youtube.db` — SQLite database (synced from VPS)
|
||||||
|
|
||||||
|
### MacBook
|
||||||
|
|
||||||
|
Qwen models run via `llama-server`:
|
||||||
|
- Port 8080: Qwen3.6-27B-Q8 (27B params, ~30 t/s, 256K ctx)
|
||||||
|
- Port 8085: Qwen3.6-35B-A3B-Q8 (35B MoE, 3.5B active, faster)
|
||||||
|
- Both with `--spec-type draft-mtp` for speculative decoding
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEEPSEEK_API_KEY` | — | DeepSeek API key for summarization |
|
||||||
|
| `DEEPSEEK_BASE_URL` | `https://api.deepseek.com` | API endpoint |
|
||||||
|
| `DEEPSEEK_MODEL` | `deepseek-chat` | Model name |
|
||||||
|
| `API_TOKEN` | — | Bearer token for dashboard write ops |
|
||||||
|
| `AGENT_ENABLED` | `false` | Enable agent queue worker |
|
||||||
|
| `QWEN_BASE_URL` | `http://localhost:8080` | Local Qwen endpoint |
|
||||||
|
| `HTTP_PROXY` | — | Proxy for fetcher/summarizer (China bypass) |
|
||||||
|
| `AGENT_POLL_INTERVAL` | `30` | Agent queue poll seconds |
|
||||||
|
| `AGENT_MAX_CONCURRENT` | `3` | Agent queue max workers |
|
||||||
|
|
||||||
|
### Authelia Bypass
|
||||||
|
|
||||||
|
API routes at `/api/*` bypass Authelia via `resources: ['^/api/']` in Authelia `access_control.yaml`. Frontend (`/`) requires SSO login.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
|----------|--------|------|-------------|
|
||||||
|
| `/api/health` | GET | None | Health check |
|
||||||
|
| `/api/config` | GET | None | Auth config (token required?) |
|
||||||
|
| `/api/videos` | GET | None | List videos (status, channel, limit params) |
|
||||||
|
| `/api/videos/fetch` | POST | Bearer | Trigger subscription sync |
|
||||||
|
| `/api/videos/{id}/summarize` | POST | Bearer | Trigger single video summary |
|
||||||
|
| `/api/videos/{id}/status` | POST | Bearer | Update status (pending/read/skipped) |
|
||||||
|
| `/api/notes` | GET | None | List all notes |
|
||||||
|
| `/api/notes/{id}` | GET/POST | Bearer (POST) | Get/save note |
|
||||||
|
| `/api/notes/migrate` | POST | Bearer | Migrate localStorage notes to server |
|
||||||
|
| `/api/agent-queue` | GET/POST | Bearer (POST) | List/add agent queue items |
|
||||||
|
| `/api/channels` | GET | None | List distinct channels |
|
||||||
|
| `/api/auto_categories.json` | GET | None | Channel category mapping |
|
||||||
|
|
||||||
|
## Sprint Plan
|
||||||
|
|
||||||
|
### Sprint 1 ✅ (Current — Deployed)
|
||||||
|
- Agent queue worker (disabled by default)
|
||||||
|
- Proxy support for fetcher + summarizer
|
||||||
|
- Auto-mark-read on navigate away (not timer)
|
||||||
|
- VPS fetch + summarize cron
|
||||||
|
- run_tests.sh fixes
|
||||||
|
- All 2,674 videos summarized (title-based)
|
||||||
|
|
||||||
|
### Sprint 2 — UX Overhaul (Next)
|
||||||
|
- Full-text search across video titles
|
||||||
|
- Batch select → mark read/skip
|
||||||
|
- Infinite scroll / pagination
|
||||||
|
- Display duration + view count
|
||||||
|
- Transcript accordion in detail panel
|
||||||
|
|
||||||
|
### Sprint 3 — Polish & Monitoring
|
||||||
|
- Stats dashboard (videos/day, channel health, sync lag)
|
||||||
|
- Auto-purge old read videos (configurable TTL)
|
||||||
|
- Broken channel detection (404 on RSS)
|
||||||
|
- Cron failure alerts via Telegram bot
|
||||||
|
- Scheduled DB backup to MacBook
|
||||||
|
|
||||||
|
### Sprint 4 — Code Quality
|
||||||
|
- Split `main.py` into routers
|
||||||
|
- Cursor/offset pagination on API
|
||||||
|
- Rate limiting middleware
|
||||||
|
- aiosqlite connection pooling
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
### Transcripts Blocked
|
||||||
|
YouTube transcripts inaccessible from all our locations:
|
||||||
|
- **Oracle Cloud VPS**: YouTube blocks cloud provider IPs
|
||||||
|
- **MacBook (China)**: GFW blocks YouTube API endpoints
|
||||||
|
- **YouTube Data API captions.download**: 403 Forbidden for non-owned videos
|
||||||
|
|
||||||
|
**Workaround**: Title + description-based summaries via Qwen. Full transcripts need a Japanese residential proxy ($3-5/month).
|
||||||
|
|
||||||
|
### auth.jimmygan.com from LAN
|
||||||
|
Accessing `auth.jimmygan.com` from LAN shows Immich (photos.jimmygan.com) instead of Authelia. Caused by NAS port 443 routing — DSM nginx still owns port 443 and routes Immich for unmatched domains.
|
||||||
|
|
||||||
|
### Gitea SSL
|
||||||
|
Uses Let's Encrypt **staging** certificates (acme-staging) — browsers may show untrusted cert warnings.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Local Setup
|
||||||
|
```bash
|
||||||
|
cd ~/repos/t-youtube
|
||||||
|
# Backend
|
||||||
|
cd backend && python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
|
||||||
|
python3 main.py # runs on :8000
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm install && npm run dev # runs on :5173 with API proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
cd ~/repos/t-youtube && bash run_tests.sh
|
||||||
|
# 23 tests across db, fetcher, summarizer, main, agent_worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to NAS
|
||||||
|
```bash
|
||||||
|
cd ~/repos/t-youtube
|
||||||
|
git add -A && git commit -m "..." && git push gitea main:master
|
||||||
|
ssh nas 'cd /volume1/docker/t-youtube && git pull origin master && docker compose up -d --build'
|
||||||
|
```
|
||||||
@@ -22,10 +22,36 @@
|
|||||||
let editingCategories = $state(false);
|
let editingCategories = $state(false);
|
||||||
let prevSelectedId = $state(null);
|
let prevSelectedId = $state(null);
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchResults = $state([]);
|
||||||
|
let isSearching = $state(false);
|
||||||
|
let showSearchResults = $state(false);
|
||||||
|
|
||||||
|
// Batch select state
|
||||||
|
let selectedVideoIds = $state(new Set());
|
||||||
|
let showBatchBar = $state(false);
|
||||||
|
|
||||||
|
// Stats state
|
||||||
|
let statsData = $state(null);
|
||||||
|
let showStats = $state(false);
|
||||||
|
|
||||||
// Agent queue state
|
// Agent queue state
|
||||||
let agentItems = $state([]);
|
let agentItems = $state([]);
|
||||||
let agentLoading = $state(false);
|
let agentLoading = $state(false);
|
||||||
|
|
||||||
|
// Stats fetcher
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/stats');
|
||||||
|
if (res.ok) statsData = await res.json();
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (activeTab === 'stats' && !statsData) loadStats();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeTab === 'pending' && channelFilter) {
|
if (activeTab === 'pending' && channelFilter) {
|
||||||
loadVideos();
|
loadVideos();
|
||||||
@@ -51,6 +77,83 @@
|
|||||||
|
|
||||||
let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim())));
|
let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim())));
|
||||||
|
|
||||||
|
// Search debouncer
|
||||||
|
let searchTimeout = null;
|
||||||
|
async function handleSearch(query) {
|
||||||
|
searchQuery = query;
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
showSearchResults = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSearching = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/videos/search?q=${encodeURIComponent(query)}&limit=50`);
|
||||||
|
if (res.ok) {
|
||||||
|
searchResults = await res.json();
|
||||||
|
showSearchResults = true;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Search failed:', e);
|
||||||
|
} finally {
|
||||||
|
isSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch operations
|
||||||
|
function toggleSelect(videoId) {
|
||||||
|
const newSet = new Set(selectedVideoIds);
|
||||||
|
if (newSet.has(videoId)) {
|
||||||
|
newSet.delete(videoId);
|
||||||
|
} else {
|
||||||
|
newSet.add(videoId);
|
||||||
|
}
|
||||||
|
selectedVideoIds = newSet;
|
||||||
|
showBatchBar = newSet.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
selectedVideoIds = new Set(filteredVideos.map(v => v.video_id));
|
||||||
|
showBatchBar = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedVideoIds = new Set();
|
||||||
|
showBatchBar = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchUpdate(status) {
|
||||||
|
if (selectedVideoIds.size === 0) return;
|
||||||
|
const ids = Array.from(selectedVideoIds);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/videos/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ video_ids: ids, status })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Remove selected from local list
|
||||||
|
videos = videos.filter(v => !ids.includes(v.video_id));
|
||||||
|
clearSelection();
|
||||||
|
loadPendingCount();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Batch update failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds || seconds < 60) return '';
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatViews(count) {
|
||||||
|
if (!count || count < 1000) return count || '';
|
||||||
|
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
||||||
|
return `${(count / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
@@ -305,7 +408,9 @@
|
|||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
if (!videos.length) return;
|
if (!videos.length) return;
|
||||||
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') 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;
|
let nextIdx = idx;
|
||||||
if (e.key === 'j' || e.key === 'ArrowDown') {
|
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -374,6 +479,13 @@
|
|||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
return d.slice(0, 10);
|
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>
|
</script>
|
||||||
|
|
||||||
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
|
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
|
||||||
@@ -429,15 +541,34 @@
|
|||||||
<aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0">
|
<aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0">
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex p-1.5" style="border-bottom:1px solid var(--border)">
|
<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}
|
<button onclick={() => activeTab = tab}
|
||||||
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
|
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
|
||||||
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
|
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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Search bar (shown in pending/read/skipped tabs) -->
|
||||||
|
{#if activeTab !== '🤖 Agent' && activeTab !== 'stats' && activeTab !== 'notes'}
|
||||||
|
<div class="px-2 py-1.5" style="border-bottom:1px solid var(--border)">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
oninput={handleSearch}
|
||||||
|
placeholder="Search videos..."
|
||||||
|
class="w-full text-[11px] p-1.5 rounded border"
|
||||||
|
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
|
||||||
|
onfocus={() => { if (searchResults.length) showSearchResults = true; }}
|
||||||
|
onblur={() => setTimeout(() => showSearchResults = false, 200)}>
|
||||||
|
{#if isSearching}
|
||||||
|
<div class="absolute right-2 top-1.5 text-[9px] animate-spin" style="color:var(--accent)">↻</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Agent Queue tab content -->
|
<!-- Agent Queue tab content -->
|
||||||
{#if activeTab === '🤖 Agent'}
|
{#if activeTab === '🤖 Agent'}
|
||||||
<div class="flex-1 overflow-y-auto" style="border-color:var(--border)">
|
<div class="flex-1 overflow-y-auto" style="border-color:var(--border)">
|
||||||
@@ -475,6 +606,55 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<!-- Channel filter bar -->
|
<!-- Channel filter bar -->
|
||||||
{#if (allChannels.length || channels.length) > 1}
|
{#if (allChannels.length || channels.length) > 1}
|
||||||
@@ -541,14 +721,31 @@
|
|||||||
{@const i = filteredVideos.indexOf(video)}
|
{@const i = filteredVideos.indexOf(video)}
|
||||||
<button onclick={() => { selectedVideo = video; }}
|
<button onclick={() => { selectedVideo = video; }}
|
||||||
data-video-index={i}
|
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'};
|
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'}">
|
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)" />
|
<!-- Batch select checkbox -->
|
||||||
<div class="min-w-0">
|
<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>
|
<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>
|
<h3 class="text-[11px] 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>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -556,6 +753,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</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 -->
|
<!-- 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">
|
<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'}
|
{#if activeTab === '🤖 Agent'}
|
||||||
@@ -606,6 +814,14 @@
|
|||||||
onclick={() => { channelFilter = selectedVideo.channel_name; }}
|
onclick={() => { channelFilter = selectedVideo.channel_name; }}
|
||||||
title="Filter by this channel">{selectedVideo.channel_name}</span>
|
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>
|
<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">
|
<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, '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>
|
<button onclick={() => updateStatus(selectedVideo.video_id, 'skipped')} class="text-[10px] px-2.5 py-1 font-bold rounded border transition" style="color:var(--text-secondary);border-color:var(--border)">Skip (s)</button>
|
||||||
@@ -622,7 +838,8 @@
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<textarea id="note-textarea" rows="3" class="w-full text-xs p-2 rounded border resize-none"
|
<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)"
|
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] || ''}
|
value={notes[selectedVideo.video_id] || ''}
|
||||||
oninput={e => notes[selectedVideo.video_id] = e.target.value}
|
oninput={e => notes[selectedVideo.video_id] = e.target.value}
|
||||||
use:autoFocus
|
use:autoFocus
|
||||||
|
|||||||
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())
|
||||||
Reference in New Issue
Block a user