Compare commits

..

10 Commits

Author SHA1 Message Date
Gan, Jimmy ffa47a1335 sprint1: agent queue worker, proxy support, auto-mark-on-navigate-away, test fixes 2026-07-08 22:05:05 +08:00
Gan, Jimmy a154bc7978 feat: notes + agent queue, frontend rebuild, pip mirror, gitignore sync_rotation.json
- Add video_notes table and agent_queue table for persistent notes and AI agent tasks
- Add API endpoints: POST/GET /api/notes/{id}, POST /api/notes/migrate, agent queue endpoints
- Rebuild frontend: fix duplicate loadVideos(), non-blocking localStorage migration, notes UI
- Switch pip install to Tsinghua mirror for faster downloads in China
- Add backend/sync_rotation.json to .gitignore (runtime state)
2026-07-08 02:23:00 +08:00
Gan, Jimmy 77c6a9d963 sprint: fixes batch — saveNote history, DeepSeek retry, channel rotation, test fixes, docker env, blocking transcript, sync errors 2026-06-28 17:08:32 +08:00
Gan, Jimmy 5c641903c9 fix: clickable channel badge filters by channel; fix cat dropdown value; clean up saveNote 2026-06-27 01:40:06 +08:00
Gan, Jimmy 94ef5e2bb2 feat: channel/category dropdown with API filter; AI auto-categories embedded 2026-06-24 18:20:12 +08:00
Gan, Jimmy 63d42594d0 feat: restore manual sync button — safe at ~50 units/sync 2026-06-24 02:59:16 +08:00
Gan, Jimmy d87c2aba44 feat: RSS hybrid fetcher — 221 channels, 2,414 videos, ~50 units (was 2,000) 2026-06-24 02:47:34 +08:00
Gan, Jimmy d5af5e9b7d feat: rotate 50 channels daily — covers all 229 over 5 days 2026-06-24 02:38:04 +08:00
Gan, Jimmy 8ce359764f feat: 50 channels, daily 3AM cron sync, countdown timer, remove manual button 2026-06-24 02:35:22 +08:00
Gan, Jimmy 1c4f9dc713 fix: decode HTML entities in titles; add note timestamps; reorder layout — title first, AI digest below 2026-06-24 02:18:18 +08:00
16 changed files with 1404 additions and 277 deletions
+1
View File
@@ -22,3 +22,4 @@ config/cookies.txt
token.pickle
config/client_secret.json
config/token.pickle
backend/sync_rotation.json
+1 -4
View File
@@ -16,13 +16,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Download latest official yt-dlp to ensure subscription scrapers do not break
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp
# Install python packages
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
# Copy built static frontend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
+166
View File
@@ -0,0 +1,166 @@
"""
Agent Queue Worker — polls agent_queue for pending items and
processes them against the local Qwen27B API (:8080).
"""
import asyncio
import json
import logging
import os
import aiosqlite
import httpx
import db
log = logging.getLogger(__name__)
QWEN_BASE_URL = os.environ.get("QWEN_BASE_URL", "http://localhost:8080")
QWEN_MODEL = os.environ.get("QWEN_MODEL", "current")
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(
"AGENT_SYSTEM_PROMPT",
"You are a helpful AI assistant. The user has watched a YouTube video and "
"left you instructions. Read the video info below and respond to their "
"prompt. Be concise and actionable."
)
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."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": (
f"## Video Title\n{video_title}\n\n"
f"## Video Context\n{video_context}\n\n"
f"## User's Request\n{prompt}"
),
},
]
http_proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy")
https_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
proxies = http_proxy or https_proxy
async with httpx.AsyncClient(proxy=proxies, timeout=180) as client:
resp = await client.post(
f"{QWEN_BASE_URL}/v1/chat/completions",
json={
"model": QWEN_MODEL,
"messages": messages,
"max_tokens": 2048,
"temperature": 0.3,
},
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
async def _process_item(item: dict) -> None:
"""Process a single agent_queue item."""
video_id = item["video_id"]
user_prompt = item["user_prompt"]
async with aiosqlite.connect(db.DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
# Mark as processing
await conn.execute(
"UPDATE agent_queue SET status = 'processing' WHERE id = ?",
(item["id"],),
)
await conn.commit()
# Get video info
cursor = await conn.execute(
"SELECT title, channel_name, summary, transcript FROM videos WHERE video_id = ?",
(video_id,),
)
video = await cursor.fetchone()
if not video:
async with aiosqlite.connect(db.DB_PATH) as conn:
await conn.execute(
"UPDATE agent_queue SET status = 'failed', result = 'Video not found in DB' WHERE id = ?",
(item["id"],),
)
await conn.commit()
return
# Build video context from whatever we have
context_parts = []
if video["channel_name"]:
context_parts.append(f"Channel: {video['channel_name']}")
if video["summary"] and not video["summary"].startswith("📺 Short clip"):
context_parts.append(f"Summary:\n{video['summary']}")
if video["transcript"] and video["transcript"] != "No transcript available":
context_parts.append(f"Transcript (first 4000 chars):\n{video['transcript'][:4000]}")
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)
status = "done"
except Exception as e:
log.warning(f"Qwen API call failed for item {item['id']}: {e}")
result = f"Error: {e}"
status = "failed"
async with aiosqlite.connect(db.DB_PATH) as conn:
await conn.execute(
"UPDATE agent_queue SET status = ?, result = ?, processed_at = CURRENT_TIMESTAMP WHERE id = ?",
(status, result, item["id"]),
)
await conn.commit()
async def process_queue_loop() -> None:
"""Main polling loop — runs forever."""
log.info(
f"Agent worker started: Qwen at {QWEN_BASE_URL}, "
f"poll every {POLL_INTERVAL}s, max {MAX_CONCURRENT} concurrent"
)
while True:
try:
async with aiosqlite.connect(db.DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
cursor = await conn.execute(
"SELECT id, video_id, user_prompt FROM agent_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?",
(MAX_CONCURRENT,),
)
items = await cursor.fetchall()
if items:
log.info(f"Agent queue: processing {len(items)} item(s)")
tasks = [_process_item(dict(item)) for item in items]
await asyncio.gather(*tasks)
except Exception as e:
log.error(f"Agent worker loop error: {e}")
await asyncio.sleep(POLL_INTERVAL)
async def start_worker():
"""Entry point — called from main.py startup."""
task = asyncio.create_task(process_queue_loop())
log.info("Agent queue worker scheduled")
return task
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
async def main():
from db import init_db
await init_db()
log.info("Agent worker running in foreground. Ctrl+C to stop.")
await process_queue_loop()
asyncio.run(main())
+20
View File
@@ -23,6 +23,26 @@ async def init_db():
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS video_notes (
video_id TEXT PRIMARY KEY,
note_text TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (video_id) REFERENCES videos(video_id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS agent_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT NOT NULL,
user_prompt TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
processed_at TEXT,
result TEXT,
FOREIGN KEY (video_id) REFERENCES videos(video_id)
)
""")
await db.commit()
async def get_db():
+119 -37
View File
@@ -1,6 +1,11 @@
import asyncio
import os
import json
import html
import logging
import aiosqlite
import httpx
import xml.etree.ElementTree as ET
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from auth import get_credentials
@@ -8,28 +13,72 @@ from db import DB_PATH
log = logging.getLogger(__name__)
ATOM_NS = "http://www.w3.org/2005/Atom"
CHANNELS_PER_SYNC = 50 # Rotate through all channels — N per sync
_ROTATION_FILE = os.path.join(os.environ.get("DB_DIR", os.path.dirname(os.path.abspath(__file__))), "sync_rotation.json")
def _get_rotation_slice(total: int) -> tuple[int, int]:
"""Return (start, end) indices for this sync's channel slice."""
state = {"offset": 0, "total": total}
if os.path.exists(_ROTATION_FILE):
try:
with open(_ROTATION_FILE) as f:
state = json.load(f)
except (json.JSONDecodeError, OSError):
pass
# If total changed (subscribed/unsubscribed), reset
if state.get("total") != total:
state = {"offset": 0, "total": total}
start = state["offset"]
end = min(start + CHANNELS_PER_SYNC, total)
return start, end
def _save_rotation_slice(total: int, start: int, end: int):
"""Persist next sync offset."""
next_offset = end # Continue from where we left off
if next_offset >= total:
next_offset = 0 # Wrap around — full cycle complete
os.makedirs(os.path.dirname(_ROTATION_FILE), exist_ok=True)
with open(_ROTATION_FILE, "w") as f:
json.dump({"offset": next_offset, "total": total}, f)
async def _fetch_rss(client, channel_id):
"""Fetch latest videos from a channel's RSS feed (free). Returns list of (video_id, title)."""
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
try:
resp = await client.get(url, timeout=15)
if resp.status_code != 200:
return []
root = ET.fromstring(resp.text)
entries = []
for entry in root.findall(f"{{{ATOM_NS}}}entry"):
vid = entry.find(f"{{{ATOM_NS}}}link").get("href").split("=")[-1]
title_el = entry.find(f"{{{ATOM_NS}}}title")
title = html.unescape(title_el.text) if title_el is not None and title_el.text else "Untitled"
entries.append((vid, title))
return entries
except Exception as e:
log.warning(f"RSS error for {channel_id}: {e}")
return []
async def fetch_subscriptions():
"""Fetch latest videos from subscribed channels using YouTube Data API v3."""
"""Fetch latest videos from subscribed channels using RSS + YouTube Data API v3."""
try:
creds = get_credentials()
except FileNotFoundError as e:
log.error(f"OAuth setup incomplete: {e}")
return 0
except Exception as e:
log.error(f"OAuth credential error: {e}")
return 0
youtube = build("youtube", "v3", credentials=creds)
# Step 1: Get subscribed channel IDs
# Step 1: Get subscribed channel IDs (1 unit)
channel_ids = []
try:
request = youtube.subscriptions().list(
part="snippet",
mine=True,
maxResults=50,
order="alphabetical"
)
request = youtube.subscriptions().list(part="snippet", mine=True, maxResults=50, order="alphabetical")
while request:
response = request.execute()
for item in response.get("items", []):
@@ -38,61 +87,94 @@ async def fetch_subscriptions():
channel_ids.append(cid)
request = youtube.subscriptions().list_next(request, response)
except HttpError as e:
log.error(f"YouTube API error fetching subscriptions: {e}")
log.error(f"YouTube API error: {e}")
return 0
log.info(f"Found {len(channel_ids)} subscribed channels")
if not channel_ids:
log.warning("No subscribed channels found. Make sure the authenticated account has subscriptions.")
# Step 2: Rotate — only process CHANNELS_PER_SYNC channels per sync
start, end = _get_rotation_slice(len(channel_ids))
slice_ids = channel_ids[start:end]
log.info(f"Processing channels {start+1}{end} of {len(channel_ids)} ({len(slice_ids)} this sync)")
# Step 3: Fetch RSS from slice channels (free, parallel)
rss_videos = {}
async with httpx.AsyncClient(
proxy=os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or
os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or None,
timeout=20
) as client:
tasks = [_fetch_rss(client, cid) for cid in slice_ids]
results = await asyncio.gather(*tasks)
for cid, entries in zip(slice_ids, results):
if entries:
rss_videos[cid] = entries
all_video_ids = []
rss_map = {}
for cid, entries in rss_videos.items():
for vid, title in entries:
if vid not in all_video_ids:
all_video_ids.append(vid)
rss_map[vid] = (cid, title)
log.info(f"RSS discovered {len(all_video_ids)} videos from {len(rss_videos)} channels")
if not all_video_ids:
_save_rotation_slice(len(channel_ids), start, end)
return 0
# Step 2: For each channel, get latest videos
# Step 3: Check which videos are new (in SQLite)
async with aiosqlite.connect(DB_PATH) as db:
new_ids = []
for vid in all_video_ids:
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (vid,))
if not await cursor.fetchone():
new_ids.append(vid)
log.info(f"{len(new_ids)} new videos to fetch metadata for")
if not new_ids:
_save_rotation_slice(len(channel_ids), start, end)
return 0
# Step 4: Fetch metadata via videos.list (1 unit per 50 videos)
new_count = 0
async with aiosqlite.connect(DB_PATH) as db:
for cid in channel_ids[:20]:
for i in range(0, len(new_ids), 50):
batch = new_ids[i:i+50]
try:
req = youtube.search().list(
part="snippet",
channelId=cid,
maxResults=10,
order="date",
type="video"
)
resp = req.execute()
resp = youtube.videos().list(part="snippet", id=",".join(batch)).execute()
for item in resp.get("items", []):
vid = item["id"]["videoId"]
vid = item["id"]
snippet = item["snippet"]
cid, rss_title = rss_map.get(vid, (snippet.get("channelId", ""), snippet.get("title", "")))
cursor = await db.execute(
"SELECT 1 FROM videos WHERE video_id = ?", (vid,)
)
if await cursor.fetchone():
continue
pub_time = snippet.get("publishTime", "")
await db.execute("""
INSERT INTO videos
(video_id, title, channel_name, channel_id, url, thumbnail_url, publish_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
vid,
snippet.get("title", "Untitled"),
snippet.get("channelTitle", "Unknown"),
html.unescape(snippet.get("title", rss_title)),
html.unescape(snippet.get("channelTitle", "Unknown")),
snippet.get("channelId", cid),
f"https://www.youtube.com/watch?v={vid}",
snippet.get("thumbnails", {}).get("high", {}).get("url", ""),
pub_time[:10] if pub_time else None
(snippet.get("publishTime") or "")[:10]
))
new_count += 1
log.debug(f"New video: {snippet.get('title', 'Untitled')[:50]}")
except HttpError as e:
log.warning(f"Channel {cid} fetch error: {e}")
log.warning(f"videos.list error: {e}")
continue
await db.commit()
log.info(f"Ingestion completed. Found {new_count} new videos.")
# Save rotation state for next sync
_save_rotation_slice(len(channel_ids), start, end)
return new_count
+196 -9
View File
@@ -1,6 +1,9 @@
import os
import json
import logging
from fastapi import FastAPI, BackgroundTasks, Depends, HTTPException, Query
from fastapi import FastAPI, BackgroundTasks, Depends, HTTPException, Query, Response
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -10,33 +13,87 @@ import aiosqlite
from db import init_db, get_db
from fetcher import fetch_subscriptions
from summarizer import summarize_video, summarize_all_pending
from agent_worker import start_worker
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
log = logging.getLogger(__name__)
app = FastAPI(title="T YouTube", description="Text-based YouTube Summarizer")
# Enable CORS for development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API token validation for write operations
API_TOKEN=os.environ.get('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/*
if path.startswith("/api/") and method in ("POST", "PATCH", "DELETE") and API_TOKEN:
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"})
return await call_next(request)
app.middleware("http")(validate_api_token)
class StatusUpdate(BaseModel):
status: str
# Database Startup Hook
class NoteUpdate(BaseModel):
note_text: str
queue_to_agent: bool = False
class NoteMigrate(BaseModel):
notes: dict
class AgentQueueItem(BaseModel):
video_id: str
user_prompt: str
class AgentQueueUpdate(BaseModel):
status: str
@app.on_event("startup")
async def startup():
await init_db()
if os.environ.get("AGENT_ENABLED", "").lower() == "true":
await start_worker()
log.info("Agent queue worker enabled")
else:
log.info("Agent queue worker disabled (set AGENT_ENABLED=true to enable)")
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.get("/api/config")
async def api_config():
"""Return API config without exposing the token."""
return {"auth_enabled": bool(API_TOKEN)}
@app.get("/auto_categories.json")
async def auto_categories():
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "src", "auto_categories.json")
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return {}
@app.get("/api/channels")
async def get_channels(db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT DISTINCT channel_name, channel_id FROM videos ORDER BY channel_name")
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@app.get("/")
async def root():
from fastapi.responses import FileResponse
@@ -46,10 +103,15 @@ async def root():
async def get_videos(
status: Optional[str] = "pending",
limit: int = 50,
channel: Optional[str] = None,
db: aiosqlite.Connection = Depends(get_db)
):
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, limit))
if channel:
query = "SELECT * FROM videos WHERE status = ? AND channel_name = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, channel, limit))
else:
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, limit))
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@@ -73,7 +135,6 @@ async def trigger_summarize(video_id: str, background_tasks: BackgroundTasks, db
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Video not found")
background_tasks.add_task(summarize_video, video_id)
return {"message": f"Summarization for video {video_id} queued."}
@@ -85,17 +146,143 @@ async def update_status(
):
if update.status not in ["pending", "read", "skipped"]:
raise HTTPException(status_code=400, detail="Invalid status value")
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Video not found")
await db.execute("UPDATE videos SET status = ? WHERE video_id = ?", (update.status, video_id))
await db.commit()
return {"message": f"Video {video_id} status updated to {update.status}."}
# Mount frontend built files for production if they exist
# ---- Notes (server-side, replaces localStorage) ----
@app.get("/api/notes")
async def list_notes(db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT video_id, note_text, updated_at FROM video_notes ORDER BY updated_at DESC")
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@app.get("/api/notes/{video_id}")
async def get_note(video_id: str, db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT note_text, updated_at FROM video_notes WHERE video_id = ?", (video_id,))
row = await cursor.fetchone()
if not row:
return {"note_text": "", "updated_at": None}
return dict(row)
@app.post("/api/notes/{video_id}")
async def save_note(video_id: str, update: NoteUpdate, db: aiosqlite.Connection = Depends(get_db)):
await db.execute("""
INSERT INTO video_notes (video_id, note_text, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(video_id) DO UPDATE SET
note_text = excluded.note_text,
updated_at = CURRENT_TIMESTAMP
""", (video_id, update.note_text))
await db.commit()
if update.queue_to_agent and update.note_text.strip():
await db.execute("""
INSERT INTO agent_queue (video_id, user_prompt, status)
VALUES (?, ?, 'pending')
""", (video_id, update.note_text.strip()))
await db.commit()
log.info(f"Queued video {video_id} for agent: {update.note_text[:80]}")
return {"message": "Note saved"}
@app.post("/api/notes/migrate")
async def migrate_notes(migration: NoteMigrate, db: aiosqlite.Connection = Depends(get_db)):
count = 0
for video_id, note_text in migration.notes.items():
if not note_text.strip():
continue
await db.execute("""
INSERT INTO video_notes (video_id, note_text, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(video_id) DO UPDATE SET
note_text = excluded.note_text,
updated_at = CURRENT_TIMESTAMP
""", (video_id, note_text))
count += 1
await db.commit()
return {"message": f"{count} notes migrated"}
# ---- Agent Queue ----
@app.get("/api/agent-queue")
async def list_agent_queue(
status: Optional[str] = None,
limit: int = 50,
db: aiosqlite.Connection = Depends(get_db)
):
if status:
query = """
SELECT q.id, q.video_id, q.user_prompt, q.status, q.created_at, q.processed_at,
v.title, v.channel_name, v.url, v.summary
FROM agent_queue q
LEFT JOIN videos v ON q.video_id = v.video_id
WHERE q.status = ?
ORDER BY q.created_at DESC LIMIT ?
"""
cursor = await db.execute(query, (status, limit))
else:
query = """
SELECT q.id, q.video_id, q.user_prompt, q.status, q.created_at, q.processed_at,
v.title, v.channel_name, v.url, v.summary
FROM agent_queue q
LEFT JOIN videos v ON q.video_id = v.video_id
ORDER BY q.created_at DESC LIMIT ?
"""
cursor = await db.execute(query, (limit,))
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@app.post("/api/agent-queue")
async def add_to_agent_queue(item: AgentQueueItem, db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (item.video_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Video not found")
await db.execute("""
INSERT INTO agent_queue (video_id, user_prompt, status)
VALUES (?, ?, 'pending')
""", (item.video_id, item.user_prompt))
await db.commit()
return {"message": "Item queued for agent"}
@app.patch("/api/agent-queue/{item_id}")
async def update_agent_queue(item_id: int, update: AgentQueueUpdate, db: aiosqlite.Connection = Depends(get_db)):
valid_statuses = ["pending", "processing", "done", "failed"]
if update.status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
cursor = await db.execute("SELECT 1 FROM agent_queue WHERE id = ?", (item_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Queue item not found")
if update.status in ("done", "failed"):
await db.execute("""
UPDATE agent_queue SET status = ?, processed_at = CURRENT_TIMESTAMP WHERE id = ?
""", (update.status, item_id))
else:
await db.execute("UPDATE agent_queue SET status = ? WHERE id = ?", (update.status, item_id))
await db.commit()
return {"message": f"Queue item {item_id} updated to {update.status}"}
@app.delete("/api/agent-queue/{item_id}")
async def remove_from_agent_queue(item_id: int, db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT 1 FROM agent_queue WHERE id = ?", (item_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Queue item not found")
await db.execute("DELETE FROM agent_queue WHERE id = ?", (item_id,))
await db.commit()
return {"message": f"Queue item {item_id} removed"}
# ---- Static frontend ----
frontend_build_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend/dist")
if os.path.exists(frontend_build_path):
app.mount("/", StaticFiles(directory=frontend_build_path, html=True), name="frontend")
-1
View File
@@ -1,6 +1,5 @@
fastapi>=0.100.0
uvicorn>=0.20.0
yt-dlp>=2023.0.0
youtube-transcript-api>=0.6.0
aiosqlite>=0.18.0
httpx>=0.24.0
+10 -5
View File
@@ -9,6 +9,11 @@ from db import DB_PATH
log = logging.getLogger(__name__)
def proxy_from_env():
"""Return proxy URL from HTTP_PROXY/HTTPS_PROXY env vars, or None."""
return os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or \
os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or None
# Fallback pattern to retrieve the correct bytecatcode credentials
API_KEY = os.environ.get("DEEPSEEK_API_KEY", os.environ.get("OPENAI_API_KEY", ""))
BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
@@ -62,9 +67,9 @@ def download_transcript(video_id: str) -> str:
log.warning(f"Failed to fetch transcript for {video_id}: {e}")
return None
async def summarize_video(video_id: str) -> str:
# 1. Try transcript, fall back to description
transcript = download_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)
if not transcript:
# Fallback: use video description from YouTube API
@@ -108,7 +113,7 @@ async def summarize_video(video_id: str) -> str:
]
}
async with httpx.AsyncClient(timeout=120) as client:
async with httpx.AsyncClient(proxy=proxy_from_env(), timeout=120) as client:
try:
resp = await client.post(
f"{BASE_URL}/v1/chat/completions",
@@ -120,7 +125,7 @@ async def summarize_video(video_id: str) -> str:
summary = data["choices"][0]["message"]["content"]
except Exception as e:
log.error(f"DeepSeek API request failed for {video_id}: {e}")
summary = f"Error generating summary: {e}"
return None # Leave summary NULL for retry on next sync
# 3. Save transcript & summary to db
async with aiosqlite.connect(DB_PATH) as db:
+112
View File
@@ -0,0 +1,112 @@
"""Tests for agent_worker.py — mocks Qwen API."""
import pytest
import os
import json
import aiosqlite
import httpx
from unittest.mock import patch, AsyncMock, MagicMock
@pytest.mark.asyncio
async def test_agent_worker_polls_and_processes(test_db, monkeypatch):
"""Worker picks up a pending item, calls Qwen, marks it done."""
from agent_worker import _process_item
# Seed a pending queue item + video
await test_db.execute("""
INSERT INTO videos (video_id, title, channel_name, url, summary, status)
VALUES ('ag_vid', 'Test Video', 'Test Channel', 'https://youtube.com/watch?v=ag_vid',
'**Summary**\n- Point 1\n- Point 2\nVerdict.', 'pending')
""")
await test_db.execute("""
INSERT INTO agent_queue (video_id, user_prompt, status)
VALUES ('ag_vid', 'Save this as a skill', 'pending')
""")
await test_db.commit()
# Get the item
cursor = await test_db.execute(
"SELECT id, video_id, user_prompt FROM agent_queue WHERE status = 'pending'"
)
item = dict(await cursor.fetchone())
# Mock Qwen API
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"choices": [{"message": {"content": "I've saved the skill. Done."}}]
}
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
await _process_item(item)
# Verify
cursor = await test_db.execute(
"SELECT status, result FROM agent_queue WHERE id = ?", (item["id"],)
)
row = await cursor.fetchone()
assert row["status"] == "done"
assert "I've saved the skill" in row["result"]
@pytest.mark.asyncio
async def test_agent_worker_handles_missing_video(test_db):
"""When video doesn't exist, marks as failed gracefully."""
from agent_worker import _process_item
await test_db.execute("""
INSERT INTO agent_queue (video_id, user_prompt, status)
VALUES ('missing_vid', 'Do something', 'pending')
""")
await test_db.commit()
cursor = await test_db.execute(
"SELECT id, video_id, user_prompt FROM agent_queue WHERE status = 'pending'"
)
item = dict(await cursor.fetchone())
await _process_item(item)
cursor = await test_db.execute(
"SELECT status, result FROM agent_queue WHERE id = ?", (item["id"],)
)
row = await cursor.fetchone()
assert row["status"] == "failed"
assert "not found" in row["result"].lower()
@pytest.mark.asyncio
async def test_agent_worker_qwen_error_marks_failed(test_db):
"""Qwen API failure should mark as failed, not crash."""
from agent_worker import _process_item
await test_db.execute("""
INSERT INTO videos (video_id, title, channel_name, url, status)
VALUES ('err_vid', 'Error Video', 'Test', 'https://youtube.com/watch?v=err_vid', 'pending')
""")
await test_db.execute("""
INSERT INTO agent_queue (video_id, user_prompt, status)
VALUES ('err_vid', 'Do it', 'pending')
""")
await test_db.commit()
cursor = await test_db.execute(
"SELECT id, video_id, user_prompt FROM agent_queue WHERE status = 'pending'"
)
item = dict(await cursor.fetchone())
# Mock HTTP error
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 503
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"503 Service Unavailable", request=MagicMock(), response=mock_response
)
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
await _process_item(item)
cursor = await test_db.execute(
"SELECT status FROM agent_queue WHERE id = ?", (item["id"],)
)
row = await cursor.fetchone()
assert row["status"] == "failed"
+165 -91
View File
@@ -1,112 +1,186 @@
"""Tests for fetcher.py — mocks YouTube Data API v3 + RSS."""
import pytest
import os
import json
import aiosqlite
import fetcher
import asyncio
from unittest.mock import patch, AsyncMock
from unittest.mock import patch, AsyncMock, MagicMock
class MockProcess:
def __init__(self, stdout_data, returncode=0, stderr_data=b""):
self.stdout_data = stdout_data
self.stderr_data = stderr_data
self.returncode = returncode
async def communicate(self):
return self.stdout_data, self.stderr_data
def _clean_rotation_file():
"""Remove rotation state file if it exists."""
path = getattr(fetcher, "_ROTATION_FILE", None)
if path and os.path.exists(path):
os.remove(path)
@pytest.fixture(autouse=True)
def auto_clean_rotation():
_clean_rotation_file()
yield
_clean_rotation_file()
def _mock_youtube_build(sub_channels=None, video_data=None):
"""Build a mock YouTube API service that returns canned responses."""
sub_channels = sub_channels or [
{"snippet": {"resourceId": {"channelId": "chan001"}}},
{"snippet": {"resourceId": {"channelId": "chan002"}}},
]
video_data = video_data or {}
def mock_subscriptions_list(**kwargs):
return MagicMock(execute=MagicMock(return_value={"items": sub_channels}))
def mock_videos_list(**kwargs):
vid_str = kwargs.get("id", "")
ids = [v for v in vid_str.split(",") if v]
items = []
for vid in ids:
data = video_data.get(vid, {
"title": f"Video {vid}",
"channelTitle": "Test Channel",
"channelId": "chan001",
"thumbnails": {"high": {"url": f"https://img.youtube.com/vi/{vid}/hqdefault.jpg"}},
"publishTime": "2026-06-01T00:00:00Z",
})
items.append({"id": vid, "snippet": data})
return MagicMock(execute=MagicMock(return_value={"items": items}))
svc = MagicMock()
svc.subscriptions = MagicMock(return_value=MagicMock(list=mock_subscriptions_list, list_next=MagicMock(return_value=None)))
svc.videos = MagicMock(return_value=MagicMock(list=mock_videos_list))
return svc
def _mock_rss_response(videos):
"""Build RSS XML string for a list of (video_id, title) tuples."""
entries = []
for vid, title in videos:
entries.append(f"""<entry>
<id>yt:video:{vid}</id>
<link href="https://www.youtube.com/watch?v={vid}"/>
<title>{title}</title>
</entry>""")
return f"""<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
{''.join(entries)}
</feed>"""
@pytest.mark.asyncio
async def test_fetch_subscriptions_no_cookies(tmp_path, monkeypatch):
monkeypatch.setattr(fetcher, "COOKIES_PATH", "/non/existent/path/cookies.txt")
count = await fetcher.fetch_subscriptions()
assert count == 0
@pytest.mark.asyncio
async def test_fetch_subscriptions_success(test_db, tmp_path, monkeypatch):
cookies_file = os.path.join(tmp_path, "mock_cookies.txt")
with open(cookies_file, "w") as f:
f.write("# Netscape HTTP Cookie File")
monkeypatch.setattr(fetcher, "COOKIES_PATH", cookies_file)
video1 = {
"id": "vid123",
"title": "Mock Video 1",
"uploader": "Uploader One",
"uploader_id": "chan123",
"upload_date": "20260601",
"duration": 360,
"thumbnail": "https://img.youtube.com/vi/vid123/hqdefault.jpg"
}
video2 = {
"id": "vid456",
"title": "Mock Video 2",
"uploader": "Uploader Two",
"uploader_id": "chan456",
"upload_date": "20260602",
"duration": 720
}
stdout_bytes = (json.dumps(video1) + "\n" + json.dumps(video2) + "\n").encode("utf-8")
mock_process = MockProcess(stdout_data=stdout_bytes, returncode=0)
with patch("asyncio.create_subprocess_exec", return_value=mock_process) as mock_exec:
async def test_fetch_zero_channels(test_db):
"""When no subscriptions exist, should return 0."""
mock_svc = _mock_youtube_build(sub_channels=[])
with patch("fetcher.build", return_value=mock_svc), \
patch("fetcher.get_credentials", return_value="mock_creds"):
count = await fetcher.fetch_subscriptions()
assert count == 0
mock_exec.assert_called_once()
args = mock_exec.call_args[0]
assert "yt-dlp" in args
assert "https://www.youtube.com/feed/subscriptions" in args
assert cookies_file in args
@pytest.mark.asyncio
async def test_fetch_rss_empty(test_db):
"""When RSS returns nothing, count should be 0."""
mock_svc = _mock_youtube_build()
with patch("fetcher.build", return_value=mock_svc), \
patch("fetcher.get_credentials", return_value="mock_creds"), \
patch("httpx.AsyncClient.get", AsyncMock(return_value=MagicMock(status_code=404))):
count = await fetcher.fetch_subscriptions()
assert count == 0
@pytest.mark.asyncio
async def test_fetch_new_videos(test_db):
"""New videos from RSS should be inserted into DB."""
mock_svc = _mock_youtube_build(video_data={
"vid001": {"title": "First Video", "channelTitle": "Chan One",
"channelId": "chan001", "thumbnails": {"high": {"url": ""}},
"publishTime": "2026-06-01T00:00:00Z"},
"vid002": {"title": "Second Video", "channelTitle": "Chan Two",
"channelId": "chan002", "thumbnails": {"high": {"url": ""}},
"publishTime": "2026-06-02T00:00:00Z"},
})
rss_xml = _mock_rss_response([("vid001", "First Video"), ("vid002", "Second Video")])
mock_response = MagicMock(status_code=200, text=rss_xml)
with patch("fetcher.build", return_value=mock_svc), \
patch("fetcher.get_credentials", return_value="mock_creds"), \
patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)):
count = await fetcher.fetch_subscriptions()
assert count == 2
# Query db to verify values
# Verify DB
cursor = await test_db.execute("SELECT * FROM videos ORDER BY video_id")
videos = await cursor.fetchall()
assert len(videos) == 2
rows = await cursor.fetchall()
assert len(rows) == 2
assert rows[0]["video_id"] == "vid001"
assert rows[0]["title"] == "First Video"
assert rows[1]["video_id"] == "vid002"
assert rows[1]["title"] == "Second Video"
assert videos[0]["video_id"] == "vid123"
assert videos[0]["title"] == "Mock Video 1"
assert videos[0]["channel_name"] == "Uploader One"
assert videos[0]["channel_id"] == "chan123"
assert videos[0]["url"] == "https://www.youtube.com/watch?v=vid123"
assert videos[0]["thumbnail_url"] == "https://img.youtube.com/vi/vid123/hqdefault.jpg"
assert videos[0]["publish_date"] == "2026-06-01"
assert videos[0]["duration"] == 360
assert videos[0]["status"] == "pending"
assert videos[1]["video_id"] == "vid456"
assert videos[1]["title"] == "Mock Video 2"
assert videos[1]["publish_date"] == "2026-06-02"
assert videos[1]["duration"] == 720
@pytest.mark.asyncio
async def test_fetch_subscriptions_skip_duplicates(test_db, tmp_path, monkeypatch):
cookies_file = os.path.join(tmp_path, "mock_cookies.txt")
with open(cookies_file, "w") as f:
f.write("# Netscape HTTP Cookie File")
monkeypatch.setattr(fetcher, "COOKIES_PATH", cookies_file)
# Insert an existing video record first
await test_db.execute("""
INSERT INTO videos (video_id, title, url, status)
VALUES ('vid123', 'Mock Video 1', 'https://www.youtube.com/watch?v=vid123', 'pending')
""")
async def test_fetch_skip_duplicates(test_db):
"""Existing videos should be skipped."""
# Pre-insert one video
await test_db.execute(
"INSERT INTO videos (video_id, title, url, status) VALUES ('vid001', 'Old', 'https://youtube.com/watch?v=vid001', 'pending')"
)
await test_db.commit()
video1 = {
"id": "vid123",
"title": "Mock Video 1",
"upload_date": "20260601"
}
video2 = {
"id": "vid456",
"title": "Mock Video 2",
"upload_date": "20260602"
}
stdout_bytes = (json.dumps(video1) + "\n" + json.dumps(video2) + "\n").encode("utf-8")
mock_svc = _mock_youtube_build(video_data={
"vid001": {"title": "First Video (updated)", "channelTitle": "Chan One",
"channelId": "chan001"},
"vid002": {"title": "Second Video", "channelTitle": "Chan Two",
"channelId": "chan002"},
})
rss_xml = _mock_rss_response([("vid001", "First Video"), ("vid002", "Second Video")])
mock_response = MagicMock(status_code=200, text=rss_xml)
mock_process = MockProcess(stdout_data=stdout_bytes, returncode=0)
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
with patch("fetcher.build", return_value=mock_svc), \
patch("fetcher.get_credentials", return_value="mock_creds"), \
patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)):
count = await fetcher.fetch_subscriptions()
assert count == 1 # Only 1 new video should be added (vid456)
assert count == 1 # Only vid002 is new
# Verify DB — vid001 still has original title (not updated)
cursor = await test_db.execute("SELECT title FROM videos WHERE video_id='vid001'")
row = await cursor.fetchone()
assert row["title"] == "Old"
@pytest.mark.asyncio
async def test_rotation_advances(test_db):
"""Rotation slice should advance after each sync."""
# Mock 150 channels
sub_channels = [{"snippet": {"resourceId": {"channelId": f"chan{i:03d}"}}} for i in range(150)]
mock_svc = _mock_youtube_build(sub_channels=sub_channels)
rss_xml = _mock_rss_response([]) # No videos, just test rotation
mock_response = MagicMock(status_code=200, text=rss_xml)
with patch("fetcher.build", return_value=mock_svc), \
patch("fetcher.get_credentials", return_value="mock_creds"), \
patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)):
# First sync — should process chan000-chan049
count = await fetcher.fetch_subscriptions()
# Second sync — chan050-chan099
count2 = await fetcher.fetch_subscriptions()
# Third sync — chan100-chan149
count3 = await fetcher.fetch_subscriptions()
# Fourth sync — wrap around, chan000-chan049 again
count4 = await fetcher.fetch_subscriptions()
# Verify rotation file
with open(fetcher._ROTATION_FILE) as f:
state = json.load(f)
assert state["offset"] == 50 # After 4th sync, offset = 50 (wrapped from 150)
assert state["total"] == 150
@pytest.mark.asyncio
async def test_fetch_quietly_handles_missing_credentials(test_db):
"""When OAuth is missing, should return 0 gracefully."""
with patch("fetcher.get_credentials", side_effect=FileNotFoundError("No client_secret")):
count = await fetcher.fetch_subscriptions()
assert count == 0
+54 -28
View File
@@ -1,3 +1,4 @@
"""Tests for summarizer.py — mocks transcript API + DeepSeek API."""
import pytest
import os
import httpx
@@ -5,6 +6,7 @@ import aiosqlite
import summarizer
from unittest.mock import patch, MagicMock, AsyncMock
class MockTranscript:
def fetch(self):
return [
@@ -25,6 +27,17 @@ class MockTranscriptList:
def __iter__(self):
return iter([self.transcript])
def _mock_deepseek_response(content: str) -> MagicMock:
"""Build a DeepSeek/OpenAI-compatible API response."""
r = MagicMock(spec=httpx.Response)
r.status_code = 200
r.json.return_value = {
"choices": [{"message": {"content": content}}]
}
return r
@pytest.mark.asyncio
async def test_download_transcript_success():
mock_trans = MockTranscript()
@@ -34,9 +47,9 @@ async def test_download_transcript_success():
text = summarizer.download_transcript("test_vid")
assert text == "Welcome to this video tutorial. Today we are learning about Svelte 5 and runes."
@pytest.mark.asyncio
async def test_summarize_video_success(test_db):
# Insert a pending video in db first
await test_db.execute("""
INSERT INTO videos (video_id, title, url, status)
VALUES ('summarize_vid', 'Video to Summarize', 'https://www.youtube.com/watch?v=summarize_vid', 'pending')
@@ -45,19 +58,8 @@ async def test_summarize_video_success(test_db):
mock_trans = MockTranscript()
mock_list = MockTranscriptList(mock_trans)
mock_response = _mock_deepseek_response("**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line.")
# Mock Claude HTTP response
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"content": [
{
"text": "**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line."
}
]
}
# Patch both transcript api and httpx AsyncClient post call
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
@@ -69,12 +71,12 @@ async def test_summarize_video_success(test_db):
assert summarizer.BASE_URL in url
headers = mock_post.call_args[1]["headers"]
assert "x-api-key" in headers
assert "Authorization" in headers
assert headers["User-Agent"] is not None
payload = mock_post.call_args[1]["json"]
assert payload["model"] == "claude-haiku-4-5-20251001"
assert len(payload["messages"]) == 1
assert payload["model"] == "deepseek-chat"
assert len(payload["messages"]) == 2 # system + user
assert summary == "**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line."
@@ -85,9 +87,40 @@ async def test_summarize_video_success(test_db):
assert video["transcript"] == "Welcome to this video tutorial. Today we are learning about Svelte 5 and runes."
assert video["summary"] == "**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line."
@pytest.mark.asyncio
async def test_summarize_video_deepseek_error_leaves_null(test_db):
"""When DeepSeek fails, summary should stay NULL for retry."""
await test_db.execute("""
INSERT INTO videos (video_id, title, url, status)
VALUES ('err_vid', 'Error Video', 'https://youtube.com/watch?v=err_vid', 'pending')
""")
await test_db.commit()
mock_trans = MockTranscript()
mock_list = MockTranscriptList(mock_trans)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 429
# raise_for_status on 429 should raise HTTPStatusError
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"429 Rate Limited", request=MagicMock(), response=mock_response
)
with 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")
assert result is None # Returns None instead of an error string
# Verify DB — summary should remain NULL
cursor = await test_db.execute("SELECT summary FROM videos WHERE video_id = 'err_vid'")
row = await cursor.fetchone()
assert row["summary"] is None
@pytest.mark.asyncio
async def test_summarize_all_pending(test_db):
# Insert multiple videos, one pending and one already summarized
await test_db.execute("""
INSERT INTO videos (video_id, title, url, summary, status)
VALUES
@@ -98,28 +131,21 @@ async def test_summarize_all_pending(test_db):
mock_trans = MockTranscript()
mock_list = MockTranscriptList(mock_trans)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"content": [{"text": "Fresh summary"}]
}
mock_response = _mock_deepseek_response("Fresh summary")
with 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()
# Should only call API once for 'pending_vid'
# Should only call API once for 'pending_vid' (done_vid already has summary)
mock_post.assert_called_once()
payload = mock_post.call_args[1]["json"]
assert "Welcome to this video tutorial." in payload["messages"][0]["content"]
# Verify db states
cursor = await test_db.execute("SELECT video_id, summary FROM videos ORDER BY video_id")
videos = await cursor.fetchall()
assert videos[0]["video_id"] == "done_vid"
assert videos[0]["summary"] == "Existing summary" # Untouched
assert videos[0]["summary"] == "Existing summary" # Untouched
assert videos[1]["video_id"] == "pending_vid"
assert videos[1]["summary"] == "Fresh summary" # Updated
assert videos[1]["summary"] == "Fresh summary" # Updated
+8 -7
View File
@@ -7,14 +7,15 @@ services:
dockerfile: Dockerfile
container_name: t-youtube
restart: unless-stopped
network_mode: host
ports:
- "8001:8000"
env_file:
- .env
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-Kq5JbgmnEEkdzTqNAQ6bV6nztQ0ngeHTHF9Vg8nUglQvrOlx}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- ANTHROPIC_BASE_URL=https://www.bytecatcode.org
- HTTP_PROXY=http://100.70.115.1:8118
- HTTPS_PROXY=http://100.70.115.1:8118
- NO_PROXY=localhost,127.0.0.1,100.0.0.0/8,192.168.0.0/16
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
- DEEPSEEK_BASE_URL=https://api.deepseek.com
- DEEPSEEK_MODEL=deepseek-chat
- DB_DIR=/app/data
volumes:
- ./data:/app/data
- ./config:/app/config
+1
View File
@@ -0,0 +1 @@
export default {"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"}
+1
View File
@@ -0,0 +1 @@
{"AI Engineer": "AI & Tech", "AI-Fan AI\u7814\u7a76\u5ba4-\u5e06\u54e5": "AI & Tech", "AI\u5927\u6a21\u578b": "AI & Tech", "AI\u63a2\u7d22\u4e0e\u53d1\u73b0": "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", "\u56de\u5230Axton": "AI & Tech", "\u56de\u5f62\u9488PaperClip": "AI & Tech", "\u6797\u4ea6LYi": "AI & Tech", "\u7845\u8c37101\u64ad\u5ba2": "AI & Tech", "\u8001\u77f3\u8c08\u82af": "AI & Tech", "\u9cb2\u9e4fTalk": "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", "\u9b54\u754c\u9020\u7269": "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\u79d8\u5bc6\u82b1\u56ed": "Programming", "cs tech": "Programming", "fequalsf": "Programming", "openscreencast": "Programming", "\u6280\u672f\u722c\u722c\u867e TechShrimp": "Programming", "\u6797\u672c\u5154Limbuntu": "Programming", "Best Choice Review": "Hardware & Reviews", "Marques Brownlee": "Hardware & Reviews", "TESTV": "Hardware & Reviews", "\u5148\u770b\u8bc4\u6d4b": "Hardware & Reviews", "\u6781\u5ba2\u6e7eGeekerwan": "Hardware & Reviews", "\u786c\u6838\u62c6\u89e3": "Hardware & Reviews", "\u7b14\u5427\u8bc4\u6d4b\u5ba4": "Hardware & Reviews", "\u5dee\u8bc4\u786c\u4ef6\u90e8": "Hardware & Reviews", "\u5145\u7535\u5934\u7f51": "Hardware & Reviews", "Apple Developer": "Apple Dev", "Made by Google": "Apple Dev", "Allen\u7684\u5206\u4eab": "Apple Dev", "Sypnotix": "Apple Dev", "8K World": "Photography", "Can Fiona": "Photography", "\u5c0f\u5929fotos": "Photography", "\u5f71\u50cf\u6781\u5ba2Fotogeeker": "Photography", "\u6444\u5f71\u5e08PHiL": "Photography", "\u865a\u7a7a\u5149\u5f71CosmosFilm": "Photography", "\u8def\u6613\u65af LouisDrone": "Photography", "Bruno Mars": "Music", "GraceLeeMusic": "Music", "Katie Melua": "Music", "The Weeknd": "Music", "TheWeekndVEVO": "Music", "\u6770\u5a01\u723e\u6b4c\u8a5eMV\u983b\u9053JVR Lyric MV": "Music", "\u859b\u6c40\u54f2": "Music", "Salah Trainer": "Sports", "Skills N Talents (swimming)": "Sports", "TotalSports TV": "Sports", "WorldSBK": "Sports", "Everyday Cycling": "Sports", "\u7231\u7fbd\u5ba2\u7fbd\u6bdb\u7403\u7f51": "Sports", "adidas": "Sports", "Sneaks & Feet\u6781\u5ba2\u978b\u8c08": "Automotive", "TOP\u7684\u9065\u63a7\u73a9\u5177": "Automotive", "\u609f\u7a7a\u7684\u65e5\u5e38": "Automotive"}
+520 -81
View File
@@ -7,10 +7,49 @@
let isLoading = $state(false);
let isSyncing = $state(false);
let syncMessage = $state('');
let pendingCount = $state(0);
let theme = $state(localStorage.getItem('theme') || 'dark');
let apiToken = $state(localStorage.getItem('t-yt-token') || '');
let apiTokenInput = $state('');
let showTokenSetup = $state(false);
let summaryPanel = $state(null);
let notes = $state(JSON.parse(localStorage.getItem('t-yt-notes') || '{}'));
let notes = $state({});
let activeNote = $state(null);
let queueToAgent = $state(false);
let showHelp = $state(false);
let channelFilter = $state('');
let allChannels = $state([]);
let editingCategories = $state(false);
let prevSelectedId = $state(null);
// Agent queue state
let agentItems = $state([]);
let agentLoading = $state(false);
$effect(() => {
if (activeTab === 'pending' && channelFilter) {
loadVideos();
} else if (activeTab === 'pending' && !channelFilter) {
loadVideos();
}
});
// 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"};
let categories = $state({...AUTO_CATEGORIES});
let channels = $derived([...new Set(videos.map(v => v.channel_name))].sort());
let filteredVideos = $derived(
channelFilter
? channelFilter.startsWith('📁')
? videos.filter(v => (categories[v.channel_name] || '') === channelFilter.replace('📁 ', ''))
: videos.filter(v => v.channel_name === channelFilter)
: videos
);
let catList = $derived([...new Set(Object.values(categories).flat())].sort());
let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim())));
function toggleTheme() {
theme = theme === 'dark' ? 'light' : 'dark';
@@ -22,16 +61,20 @@
isLoading = true;
try {
if (activeTab === 'notes') {
// Notes tab: show all pending videos, but only those with notes
// Notes tab: show only pending videos that have notes
const res = await fetch(`/api/videos?status=pending&limit=50`);
if (res.ok) {
const all = await res.json();
videos = all.filter(v => notes[v.video_id]);
videos = all.filter(v => noteIds.has(v.video_id));
if (videos.length > 0) selectedVideo = videos[0];
else selectedVideo = null;
}
} else {
const res = await fetch(`/api/videos?status=${activeTab}&limit=50`);
const params = new URLSearchParams({status: activeTab, limit: '200'});
if (channelFilter && !channelFilter.startsWith('📁')) {
params.set('channel', channelFilter);
}
const res = await fetch(`/api/videos?${params}`);
if (res.ok) {
videos = await res.json();
if (videos.length > 0) {
@@ -54,63 +97,248 @@
isSyncing = true;
syncMessage = 'Syncing...';
try {
const res = await fetch('/api/videos/fetch', { method: 'POST' });
if (res.ok) setTimeout(() => { loadVideos(); isSyncing = false; syncMessage = ''; }, 6000);
} catch (err) { isSyncing = false; syncMessage = ''; }
const res = await fetch('/api/videos/fetch', { method: 'POST', headers: getAuthHeaders() });
if (res.ok) {
syncMessage = 'Sync started...';
setTimeout(() => { loadVideos(); isSyncing = false; syncMessage = ''; }, 6000);
} else {
isSyncing = false;
syncMessage = 'Sync failed';
}
} catch (err) { isSyncing = false; syncMessage = 'Network error'; }
}
async function updateStatus(videoId, newStatus) {
await fetch(`/api/videos/${videoId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) });
videos = videos.filter(v => v.video_id !== videoId);
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
async function updateStatus(videoId, newStatus, silent = false) {
await fetch(`/api/videos/${videoId}/status`, { method: 'POST', headers: getAuthHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ status: newStatus }) });
if (!silent) {
videos = videos.filter(v => v.video_id !== videoId);
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
if (videos.length < 20) loadVideos();
}
}
async function generateSummary(videoId) {
if (selectedVideo?.video_id === videoId) selectedVideo.summary = "Generating...";
await fetch(`/api/videos/${videoId}/summarize`, { method: 'POST' });
await fetch(`/api/videos/${videoId}/summarize`, { method: 'POST', headers: getAuthHeaders() });
setTimeout(() => loadVideos(), 3000);
}
// ---- Server-side notes ----
async function loadNotes() {
try {
const res = await fetch('/api/notes');
if (res.ok) {
const all = await res.json();
const map = {};
for (const item of all) map[item.video_id] = item.note_text;
notes = map;
}
} catch(e) { console.error('Failed to load notes:', e); }
}
async function migrateLocalNotes() {
try {
const local = JSON.parse(localStorage.getItem('t-yt-notes') || '{}');
const keys = Object.keys(local).filter(k => local[k].trim());
if (keys.length === 0) return;
const missing = keys.filter(k => !notes[k]);
if (missing.length === 0) return;
const payload = {};
for (const k of missing) payload[k] = local[k];
await fetch('/api/notes/migrate', {
method: 'POST',
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ notes: payload })
});
await loadNotes();
} catch(e) {}
}
async function saveNote() {
const vid = selectedVideo?.video_id;
if (!vid) return;
const textarea = document.querySelector('#note-textarea');
const current = textarea ? textarea.value : (notes[vid] || '');
if (!current.trim()) return;
// Save to server
await fetch(`/api/notes/${vid}`, {
method: 'POST',
headers: getAuthHeaders({ 'Content-Type': 'application/json' } ),
body: JSON.stringify({ note_text: current, queue_to_agent: queueToAgent })
});
// Update local state
notes = {...notes, [vid]: current};
activeNote = null;
queueToAgent = false;
}
// ---- Agent queue ----
async function loadAgentQueue() {
agentLoading = true;
try {
const res = await fetch('/api/agent-queue');
if (res.ok) agentItems = await res.json();
} catch(e) { console.error('Failed to load agent queue:', e); }
finally { agentLoading = false; }
}
async function removeAgentItem(id) {
await fetch(`/api/agent-queue/${id}`, { method: 'DELETE', headers: getAuthHeaders() });
agentItems = agentItems.filter(i => i.id !== id);
}
async function initAuth() {
// Check if auth is enabled on the backend
try {
const res = await fetch('/api/config');
if (res.ok) {
const config = await res.json();
if (config.auth_enabled && !apiToken) {
showTokenSetup = true;
return false;
}
}
} catch(e) {}
showTokenSetup = false;
return true;
}
async function setupToken() {
if (apiTokenInput.trim()) {
apiToken = apiTokenInput.trim();
localStorage.setItem('t-yt-token', apiToken);
showTokenSetup = false;
}
}
function getAuthHeaders(extraHeaders = {}) {
const headers = { ...extraHeaders };
if (apiToken) {
headers['Authorization'] = 'Bearer ' + apiToken;
}
return headers;
}
// ---- Lifecycle ----
$effect(() => { if (activeTab) loadVideos(); });
$effect(() => {
// Mark previous video as read when navigating away
if (selectedVideo && summaryPanel) {
tick().then(() => summaryPanel.scrollTop = 0);
}
const prevId = prevSelectedId;
prevSelectedId = selectedVideo?.video_id || null;
if (prevId && prevId !== selectedVideo?.video_id && activeTab === 'pending') {
// Look up the previous video to check it had a valid summary
const prevVideo = videos.find(v => v.video_id === prevId);
if (prevVideo && prevVideo.summary && !prevVideo.summary.includes('Error') && !prevVideo.summary.includes('📺 Short clip')) {
updateStatus(prevId, 'read', true);
}
}
});
onMount(() => {
$effect(() => {
if (activeTab === 'agent') loadAgentQueue();
});
onMount(async () => {
document.documentElement.className = theme;
loadVideos();
await initAuth();
await loadNotes();
// Migrate localStorage notes to server (non-blocking)
migrateLocalNotes();
await loadVideos();
loaded = true;
loadPendingCount();
updateSyncTimer();
loadChannels();
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
function updateSyncTimer() {
const el = document.getElementById('sync-timer');
if (!el) return;
const now = new Date();
const next = new Date(now);
next.setHours(3, 0, 0, 0);
if (now > next) next.setDate(next.getDate() + 1);
const diff = next - now;
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
el.textContent = `Next sync in ${h}h ${m}m`;
setTimeout(updateSyncTimer, 60000);
}
async function loadPendingCount() {
try {
const res = await fetch('/api/videos?status=pending&limit=201');
if (res.ok) {
const all = await res.json();
pendingCount = all.length >= 201 ? '200+' : all.length;
}
} catch(e) {}
}
async function loadChannels() {
try {
const res = await fetch('/api/channels');
if (res.ok) allChannels = await res.json();
} catch(e) {}
}
function handleKeydown(e) {
if (!videos.length) return;
const idx = videos.findIndex(v => v.video_id === selectedVideo?.video_id);
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
const idx = filteredVideos.findIndex(v => v.video_id === selectedVideo?.video_id);
let nextIdx = idx;
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < videos.length - 1 ? idx + 1 : 0;
selectedVideo = videos[next];
scrollToVideo(next);
if (idx < filteredVideos.length - 1) nextIdx = idx + 1; else return;
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault();
const prev = idx > 0 ? idx - 1 : videos.length - 1;
selectedVideo = videos[prev];
scrollToVideo(prev);
if (idx > 0) nextIdx = idx - 1; else return;
} else if (e.key === 'r' || e.key === 'm') {
e.preventDefault();
if (selectedVideo) updateStatus(selectedVideo.video_id, 'read');
return;
} else if (e.key === 's') {
e.preventDefault();
if (selectedVideo) updateStatus(selectedVideo.video_id, 'skipped');
return;
} else if (e.key === 'n') {
e.preventDefault();
if (selectedVideo) activeNote = selectedVideo.video_id;
return;
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeNote && selectedVideo && activeNote === selectedVideo.video_id) {
saveNote();
} else if (selectedVideo) {
updateStatus(selectedVideo.video_id, 'read');
}
return;
} else if (e.key === '?') {
e.preventDefault();
showHelp = !showHelp;
return;
} else {
return;
}
selectedVideo = filteredVideos[nextIdx];
scrollToVideo(nextIdx);
}
function scrollToVideo(index) {
requestAnimationFrame(() => {
const el = document.querySelector(`[data-video-index="${index}"]`);
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
const el = document.querySelector(`[data-video-index="${index}"]`);
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'instant' });
}
function autoFocus(node) {
requestAnimationFrame(() => node.focus());
}
function formatMarkdown(text) {
@@ -124,10 +352,44 @@
}).join('');
return html;
}
function noteWordCount(noteText) {
if (!noteText) return 0;
return noteText.trim().split(/\s+/).length;
}
function formatDate(d) {
if (!d) return '';
return d.slice(0, 10);
}
</script>
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
<!-- Top bar -->
<!-- Token Setup Modal -->
{#if showTokenSetup}
<div class="fixed inset-0 z-50 flex items-center justify-center" style="background:rgba(0,0,0,0.5)">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg max-w-md w-full mx-4"
style="border:1px solid var(--border)">
<h2 class="text-xl font-bold mb-2">API Token Required</h2>
<p class="text-sm mb-4" style="color:var(--text-secondary)">
This app requires an API token to save notes and manage videos.
Please enter the token configured on the server.
</p>
<input type="text" bind:value={apiTokenInput}
placeholder="Enter API token..." class="w-full px-3 py-2 rounded"
style="background:var(--bg-primary);border:1px solid var(--border);color:var(--text-primary)"
onkeydown={(e) => e.key === 'Enter' && setupToken()} />
<div class="flex space-x-2 mt-4">
<button onclick={setupToken}
class="px-4 py-2 rounded text-white font-bold"
style="background:var(--accent)">
Submit
</button>
</div>
</div>
</div>
{/if}
<header style="background:var(--bg-secondary);border-bottom:1px solid var(--border);" class="flex items-center justify-between px-5 py-2.5">
<div class="flex items-center space-x-3">
<span class="text-lg font-black tracking-wider" style="color:var(--accent)">T YouTube</span>
@@ -144,89 +406,249 @@
title="Toggle theme">
{theme === 'dark' ? '☀' : '☾'}
</button>
{#if !isSyncing}
<span class="text-[10px]" style="color:var(--text-muted)" id="sync-timer"></span>
{/if}
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Left: video list -->
<!-- Left sidebar -->
<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'] as tab}
{#each ['pending','read','skipped','notes','🤖 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}
{tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab}
</button>
{/each}
</div>
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
{#each videos as video (video.video_id)}
{@const i = videos.indexOf(video)}
<button onclick={() => { selectedVideo = video; }}
data-video-index={i}
class="w-full text-left p-3 transition flex items-start space-x-3"
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">
<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 || ''} {notes[video.video_id] ? '📝' : ''}</span>
<!-- Agent Queue tab content -->
{#if activeTab === '🤖 Agent'}
<div class="flex-1 overflow-y-auto" style="border-color:var(--border)">
{#if agentLoading}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">Loading...</div>
{:else if agentItems.length === 0}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">
<p>No items queued.<br>Write a note on a video and check "Send to Agent" to add one.</p>
</div>
</button>
{/each}
</div>
{:else}
{#each agentItems as item}
<div class="p-3 border-b" style="border-color:var(--border)">
<div class="flex items-start space-x-2">
<div class="min-w-0 flex-1">
<p class="text-[11px] font-bold leading-tight line-clamp-2" style="color:var(--text-primary)">{item.title || 'Unknown'}</p>
<p class="text-[10px] mt-1" style="color:var(--accent)">{item.channel_name || ''}</p>
<p class="text-[10px] mt-1" style="color:var(--text-muted)">Prompt: {item.user_prompt.slice(0, 120)}{item.user_prompt.length > 120 ? '...' : ''}</p>
<div class="flex items-center space-x-2 mt-1.5">
<span class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
style="background:{item.status === 'done' ? '#10b981' : item.status === 'processing' ? '#f59e0b' : item.status === 'failed' ? '#ef4444' : 'var(--bg-tertiary)'};color:white">
{item.status}
</span>
<span class="text-[9px]" style="color:var(--text-muted)">{formatDate(item.created_at)}</span>
</div>
{#if item.status === 'pending'}
<button onclick={() => removeAgentItem(item.id)}
class="text-[9px] mt-1 px-2 py-0.5 rounded border"
style="color:var(--text-muted);border-color:var(--border)">
Remove
</button>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
{:else}
<!-- Channel filter bar -->
{#if (allChannels.length || channels.length) > 1}
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
<select bind:value={channelFilter}
class="w-full text-[11px] p-1.5 rounded border bg-transparent"
style="color:var(--text-primary);border-color:var(--border);background:var(--bg-tertiary)">
<option value="">All Channels</option>
{#if catList.length}
<option disabled>── Categories ──</option>
{#each catList as cat}
<option value={'📁 ' + cat}>📁 {cat}</option>
{/each}
<option disabled>── Channels ──</option>
{/if}
{#each allChannels.length ? allChannels.map(c => c.channel_name) : channels as ch}
<option value={ch}>{ch}</option>
{/each}
</select>
<button onclick={() => editingCategories = !editingCategories}
class="w-full text-[10px] py-1 rounded border transition text-center"
style="color:var(--text-muted);border-color:var(--border)">
{editingCategories ? 'Done' : '✎ Edit Categories'}
</button>
<button onclick={async () => {
const res = await fetch('/auto_categories.json');
if (res.ok) {
const data = await res.json();
for (const [k, v] of Object.entries(data)) categories[k] = v;
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
loadChannels();
}
}}
class="w-full text-[10px] py-1 rounded border transition text-center"
style="color:var(--text-muted);border-color:var(--border)">
↻ Reset to Auto
</button>
</div>
{/if}
{#if editingCategories}
<div class="overflow-y-auto max-h-48 p-2 space-y-1" style="border-bottom:1px solid var(--border);background:var(--bg-tertiary)">
<div class="text-[10px] pb-1" style="color:var(--text-muted)">Type category name next to each channel:</div>
<input class="w-full text-[11px] p-1 rounded border mb-1" style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
placeholder="New category name..." id="new-cat-input"
onkeydown={e => { if (e.key === 'Enter') { const v = e.target.value.trim(); if (v) { categories[v] = []; localStorage.setItem('t-yt-categories', JSON.stringify(categories)); e.target.value = ''; } } }}>
{#each allChannels as ch}
<div class="flex items-center space-x-2 text-[11px]">
<span class="flex-1 truncate" style="color:var(--text-primary)">{ch.channel_name}</span>
<input class="w-20 text-[10px] p-0.5 rounded border text-center"
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
placeholder="cat"
value={categories[ch.channel_name] || ''}
oninput={e => {
categories[ch.channel_name] = e.target.value;
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
}}>
</div>
{/each}
</div>
{/if}
<!-- Video list -->
<div 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"
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">
<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>
</div>
</button>
{/each}
</div>
{/if}
</aside>
<!-- Right: summary PRIMARY -->
<!-- Right: summary panel -->
<main bind:this={summaryPanel} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
{#if selectedVideo}
{#if activeTab === '🤖 Agent'}
<div class="p-6 max-w-3xl mx-auto">
<h2 class="text-sm font-black tracking-wider mb-4" style="color:var(--accent)">🤖 Agent Queue</h2>
{#if agentItems.length === 0}
<p class="text-xs opacity-50 italic">Queue empty. Watch a video, press <kbd class="px-1 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">n</kbd> to write a note, check "Send to Agent", and save.</p>
{:else}
<div class="space-y-3">
{#each agentItems as item}
<div class="p-4 rounded-xl border" style="background:var(--card-bg);border-color:var(--border)">
<div class="flex items-start space-x-3">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-bold" style="color:var(--text-primary)">{item.title || 'Unknown'}</h3>
<p class="text-[10px] mt-0.5" style="color:var(--accent)">{item.channel_name}</p>
<div class="mt-3 p-2 rounded text-xs leading-relaxed" style="background:var(--bg-tertiary)">
<span class="font-bold text-[10px]" style="color:var(--text-muted)">PROMPT:</span>
<p class="mt-1 whitespace-pre-wrap" style="color:var(--text-primary)">{item.user_prompt}</p>
</div>
<div class="flex items-center space-x-2 mt-2">
<span class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
style="background:{item.status === 'done' ? '#10b981' : item.status === 'processing' ? '#f59e0b' : item.status === 'failed' ? '#ef4444' : 'var(--bg-tertiary)'};color:white">
{item.status}
</span>
<span class="text-[9px]" style="color:var(--text-muted)">{formatDate(item.created_at)}</span>
<a href={item.url} target="_blank" rel="noopener noreferrer"
class="text-[9px] px-2 py-0.5 rounded border" style="color:var(--accent);border-color:var(--accent)">▶ Watch</a>
{#if item.status === 'pending'}
<button onclick={() => removeAgentItem(item.id)}
class="text-[9px] px-2 py-0.5 rounded border" style="color:var(--text-muted);border-color:var(--border)">Remove</button>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if selectedVideo}
<div class="p-6 max-w-3xl mx-auto space-y-4">
<!-- ★ AI Digest -->
<!-- Video header -->
<div class="flex items-start space-x-4">
<img src={selectedVideo.thumbnail_url} alt="" class="w-40 h-24 rounded-lg object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0 space-y-2">
<span class="text-[10px] px-2 py-0.5 font-bold rounded-full cursor-pointer hover:opacity-80 transition"
style="color:var(--accent);background:var(--bg-tertiary)"
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="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>
<button onclick={() => activeNote = activeNote === selectedVideo.video_id ? null : selectedVideo.video_id}
class="text-[10px] px-2.5 py-1 font-bold rounded border transition"
style="color:var(--text-muted);border-color:var(--border)">✎ Note (n)</button>
<a href={selectedVideo.url} target="_blank" rel="noopener noreferrer" class="text-[10px] px-2.5 py-1 text-white font-bold rounded transition" style="background:var(--accent)">▶ Watch</a>
</div>
</div>
</div>
<!-- Note editor/viewer -->
{#if activeNote === selectedVideo.video_id}
<div class="space-y-1.5">
<textarea id="note-textarea" rows="3" class="w-full text-xs p-2 rounded border resize-none"
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
placeholder="Write instructions for the agent here...&#10;e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
value={notes[selectedVideo.video_id] || ''}
oninput={e => notes[selectedVideo.video_id] = e.target.value}
use:autoFocus
onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }}
></textarea>
<div class="flex items-center space-x-3">
<label class="flex items-center space-x-1.5 text-[11px] cursor-pointer" style="color:var(--text-muted)">
<input type="checkbox" bind:checked={queueToAgent}
class="w-3 h-3 rounded" style="accent-color:var(--accent)">
<span>Send to Agent</span>
</label>
<div class="flex-1"></div>
<button onclick={saveNote}
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">
{queueToAgent ? 'Save + 🤖 Queue' : 'Save'}
</button>
</div>
</div>
{:else if notes[selectedVideo.video_id]}
<div class="p-2 rounded border text-xs leading-relaxed cursor-pointer"
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
onclick={() => activeNote = selectedVideo.video_id}
title="Click to edit">{notes[selectedVideo.video_id]}</div>
{/if}
<!-- AI Digest -->
<div class="rounded-xl border p-5 space-y-3 shadow-lg" style="background:var(--card-bg);border-color:var(--accent);border-left-width:3px">
<div class="flex items-center justify-between pb-2" style="border-bottom:1px solid var(--border)">
<h2 class="text-xs font-black tracking-widest uppercase" style="color:var(--accent)">AI Digest</h2>
{#if !selectedVideo.summary || selectedVideo.summary.includes("Error")}
<button onclick={() => generateSummary(selectedVideo.video_id)}
class="text-[11px] px-3 py-1 font-semibold rounded border transition" style="color:var(--accent);border-color:var(--accent)">
Generate
</button>
class="text-[11px] px-3 py-1 font-semibold rounded border transition" style="color:var(--accent);border-color:var(--accent)">Generate</button>
{/if}
</div>
<div class="prose max-w-none text-sm leading-relaxed" style="color:var(--text-primary)">
{@html formatMarkdown(selectedVideo.summary)}
</div>
</div>
<!-- Video info -->
<div class="flex items-start space-x-3">
<img src={selectedVideo.thumbnail_url} alt="" class="w-32 h-18 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0 space-y-1.5">
<span class="text-[10px] px-2 py-0.5 font-bold rounded-full" style="color:var(--accent);background:var(--bg-tertiary)">{selectedVideo.channel_name}</span>
<h1 class="text-sm font-bold leading-snug" style="color:var(--text-primary)">{selectedVideo.title}</h1>
<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</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</button>
<button onclick={() => activeNote = activeNote === selectedVideo.video_id ? null : selectedVideo.video_id}
class="text-[10px] px-2.5 py-1 font-bold rounded border transition"
style="color:var(--text-muted);border-color:var(--border)">✎ Note</button>
<a href={selectedVideo.url} target="_blank" rel="noopener noreferrer" class="text-[10px] px-2.5 py-1 text-white font-bold rounded transition" style="background:var(--accent)">▶ Watch</a>
</div>
{#if activeNote === selectedVideo.video_id}
<div class="space-y-1.5 mt-2">
<textarea rows="2" 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="Quick note..."
value={notes[selectedVideo.video_id] || ''}
oninput={e => notes[selectedVideo.video_id] = e.target.value}
onkeydown={e => { if (e.key === 'Escape') { localStorage.setItem('t-yt-notes', JSON.stringify(notes)); activeNote = null; } }}
></textarea>
<button onclick={() => { localStorage.setItem('t-yt-notes', JSON.stringify(notes)); activeNote = null; }}
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Save</button>
</div>
{/if}
</div>
</div>
</div>
{:else}
<div class="flex-1 flex items-center justify-center" style="color:var(--text-muted)">
@@ -235,4 +657,21 @@
{/if}
</main>
</div>
{#if showHelp}
<div class="fixed inset-0 z-50 flex items-center justify-center" style="background:rgba(0,0,0,0.6)" onclick={() => showHelp = false}>
<div class="rounded-xl p-6 max-w-xs w-full space-y-3 shadow-2xl" style="background:var(--card-bg);border:1px solid var(--border)" onclick={e => e.stopPropagation()}>
<h3 class="text-sm font-bold" style="color:var(--accent)">Keyboard Shortcuts</h3>
<div class="space-y-1.5 text-xs" style="color:var(--text-secondary)">
<div class="flex justify-between"><span>Navigate</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">j</kbd> <kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)"></kbd></span></div>
<div class="flex justify-between"><span>Navigate up</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">k</kbd> <kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)"></kbd></span></div>
<div class="flex justify-between"><span>Mark Read</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">r</kbd> / <kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">Enter</kbd></span></div>
<div class="flex justify-between"><span>Skip</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">s</kbd></span></div>
<div class="flex justify-between"><span>Note</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">n</kbd></span></div>
<div class="flex justify-between"><span>Save note</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">Enter</kbd> / <kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">Esc</kbd></span></div>
</div>
<p class="text-[10px] text-center pt-1" style="color:var(--text-muted)">Press <kbd class="px-1 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">?</kbd> to toggle</p>
</div>
</div>
{/if}
</div>
+30 -14
View File
@@ -4,15 +4,20 @@ set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VIDEO_ID="jNQXAC9IVRw"
# 1. Auto-detect and configure SOCKS5 proxy if active on 7890
# 1. Auto-detect and configure proxy
PROXY_DETECTED=false
if nc -z 127.0.0.1 7890 >/dev/null 2>&1; then
echo "=== Auto-detected local proxy on port 7890. Enabling proxy... ==="
echo "=== Auto-detected local proxy on port 7890. Enabling SOCKS5 proxy... ==="
export http_proxy=socks5://127.0.0.1:7890
export https_proxy=socks5://127.0.0.1:7890
export all_proxy=socks5://127.0.0.1:7890
export HTTP_PROXY=socks5://127.0.0.1:7890
export HTTPS_PROXY=socks5://127.0.0.1:7890
export ALL_PROXY=socks5://127.0.0.1:7890
PROXY_DETECTED=true
elif [ -n "$HTTP_PROXY" ] || [ -n "$http_proxy" ]; then
echo "=== Using existing HTTP_PROXY env var: ${HTTP_PROXY:-$http_proxy} ==="
PROXY_DETECTED=true
fi
# 2. Set up virtual environment and install requirements if not set up
@@ -25,9 +30,11 @@ fi
echo "=== Step 1: Running Mock Unit Tests ==="
"$DIR/backend/.venv/bin/python3" -m pytest "$DIR/backend/tests/"
echo ""
echo "=== Step 2: Seeding DB for Live Integration Test ==="
"$DIR/backend/.venv/bin/python3" -c "import asyncio, aiosqlite, sys; sys.path.insert(0, '$DIR/backend'); import db;
# Only run live integration tests if a proxy is available (needed from China)
if [ "$PROXY_DETECTED" = true ]; then
echo ""
echo "=== Step 2: Seeding DB for Live Integration Test ==="
"$DIR/backend/.venv/bin/python3" -c "import asyncio, aiosqlite, sys; sys.path.insert(0, '$DIR/backend'); import db;
async def run():
await db.init_db()
async with aiosqlite.connect(db.DB_PATH) as conn:
@@ -35,14 +42,15 @@ async def run():
await conn.commit()
asyncio.run(run())"
echo "=== Step 3: Running Live Ingestion & AI Summarization ==="
set +e
"$DIR/backend/.venv/bin/python3" "$DIR/backend/summarizer.py" "$VIDEO_ID"
set -e
echo "=== Step 3: Running Live Ingestion & AI Summarization ==="
set +e
"$DIR/backend/.venv/bin/python3" "$DIR/backend/summarizer.py" "$VIDEO_ID"
SUMMARIZER_EXIT=$?
set -e
echo ""
echo "=== Step 4: Verifying SQLite Database Storage ==="
"$DIR/backend/.venv/bin/python3" -c "import asyncio, aiosqlite, sys; sys.path.insert(0, '$DIR/backend'); import db;
echo ""
echo "=== Step 4: Verifying SQLite Database Storage ==="
"$DIR/backend/.venv/bin/python3" -c "import asyncio, aiosqlite, sys; sys.path.insert(0, '$DIR/backend'); import db;
async def run():
async with aiosqlite.connect(db.DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
@@ -57,9 +65,17 @@ async def run():
print()
print('=============================================')
print('WARNING: LIVE INTEGRATION TEST COULD NOT COMPLETE')
print('YouTube blocked this request or cookies.txt was missing.')
print('YouTube blocked this request or DeepSeek is unreachable.')
print('Please ensure your proxy is active on port 7890 or export')
print('your youtube cookies to config/cookies.txt.')
print('HTTP_PROXY before running.')
print('This warning is expected and did not break the build.')
print('=============================================')
asyncio.run(run())"
else
echo ""
echo "=== Skipping Live Integration Test (no proxy detected) ==="
echo "From China, YouTube API and DeepSeek require a proxy."
echo "Export HTTP_PROXY or SOCKS5 on port 7890 to enable."
echo ""
echo "=== Mock tests passed — live test skipped (OK) ==="
fi