Compare commits
10 Commits
5bfd72ab3d
...
ffa47a1335
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa47a1335 | |||
| a154bc7978 | |||
| 77c6a9d963 | |||
| 5c641903c9 | |||
| 94ef5e2bb2 | |||
| 63d42594d0 | |||
| d87c2aba44 | |||
| d5af5e9b7d | |||
| 8ce359764f | |||
| 1c4f9dc713 |
@@ -22,3 +22,4 @@ config/cookies.txt
|
||||
token.pickle
|
||||
config/client_secret.json
|
||||
config/token.pickle
|
||||
backend/sync_rotation.json
|
||||
|
||||
+1
-4
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
+194
-7
@@ -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,8 +103,13 @@ async def root():
|
||||
async def get_videos(
|
||||
status: Optional[str] = "pending",
|
||||
limit: int = 50,
|
||||
channel: Optional[str] = None,
|
||||
db: aiosqlite.Connection = Depends(get_db)
|
||||
):
|
||||
if channel:
|
||||
query = "SELECT * FROM videos WHERE status = ? AND channel_name = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
|
||||
cursor = await db.execute(query, (status, channel, limit))
|
||||
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()
|
||||
@@ -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,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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
+161
-87
@@ -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")
|
||||
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
|
||||
|
||||
|
||||
@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_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
|
||||
|
||||
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_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
|
||||
|
||||
@@ -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,22 +131,15 @@ 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")
|
||||
|
||||
+8
-7
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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 }) });
|
||||
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' });
|
||||
});
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
{: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 videos as video (video.video_id)}
|
||||
{@const i = videos.indexOf(video)}
|
||||
{#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"
|
||||
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 || ''} {notes[video.video_id] ? '📝' : ''}</span>
|
||||
<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... 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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user