sprint1: agent queue worker, proxy support, auto-mark-on-navigate-away, test fixes
This commit is contained in:
@@ -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())
|
||||||
@@ -39,6 +39,7 @@ async def init_db():
|
|||||||
status TEXT DEFAULT 'pending',
|
status TEXT DEFAULT 'pending',
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
processed_at TEXT,
|
processed_at TEXT,
|
||||||
|
result TEXT,
|
||||||
FOREIGN KEY (video_id) REFERENCES videos(video_id)
|
FOREIGN KEY (video_id) REFERENCES videos(video_id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|||||||
+5
-1
@@ -99,7 +99,11 @@ async def fetch_subscriptions():
|
|||||||
|
|
||||||
# Step 3: Fetch RSS from slice channels (free, parallel)
|
# Step 3: Fetch RSS from slice channels (free, parallel)
|
||||||
rss_videos = {}
|
rss_videos = {}
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
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]
|
tasks = [_fetch_rss(client, cid) for cid in slice_ids]
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
for cid, entries in zip(slice_ids, results):
|
for cid, entries in zip(slice_ids, results):
|
||||||
|
|||||||
+31
-1
@@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -11,6 +13,7 @@ import aiosqlite
|
|||||||
from db import init_db, get_db
|
from db import init_db, get_db
|
||||||
from fetcher import fetch_subscriptions
|
from fetcher import fetch_subscriptions
|
||||||
from summarizer import summarize_video, summarize_all_pending
|
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')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -23,8 +26,25 @@ app.add_middleware(
|
|||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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):
|
class StatusUpdate(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
@@ -45,11 +65,21 @@ class AgentQueueUpdate(BaseModel):
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
await init_db()
|
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")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
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")
|
@app.get("/auto_categories.json")
|
||||||
async def auto_categories():
|
async def auto_categories():
|
||||||
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "src", "auto_categories.json")
|
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "src", "auto_categories.json")
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ from db import DB_PATH
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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
|
# Fallback pattern to retrieve the correct bytecatcode credentials
|
||||||
API_KEY = os.environ.get("DEEPSEEK_API_KEY", os.environ.get("OPENAI_API_KEY", ""))
|
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")
|
BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
|
||||||
@@ -108,7 +113,7 @@ async def summarize_video(video_id: str) -> str | None:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(proxy=proxy_from_env(), timeout=120) as client:
|
||||||
try:
|
try:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{BASE_URL}/v1/chat/completions",
|
f"{BASE_URL}/v1/chat/completions",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
let syncMessage = $state('');
|
let syncMessage = $state('');
|
||||||
let pendingCount = $state(0);
|
let pendingCount = $state(0);
|
||||||
let theme = $state(localStorage.getItem('theme') || 'dark');
|
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 summaryPanel = $state(null);
|
||||||
let notes = $state({});
|
let notes = $state({});
|
||||||
let activeNote = $state(null);
|
let activeNote = $state(null);
|
||||||
@@ -17,6 +20,7 @@
|
|||||||
let channelFilter = $state('');
|
let channelFilter = $state('');
|
||||||
let allChannels = $state([]);
|
let allChannels = $state([]);
|
||||||
let editingCategories = $state(false);
|
let editingCategories = $state(false);
|
||||||
|
let prevSelectedId = $state(null);
|
||||||
|
|
||||||
// Agent queue state
|
// Agent queue state
|
||||||
let agentItems = $state([]);
|
let agentItems = $state([]);
|
||||||
@@ -93,7 +97,7 @@
|
|||||||
isSyncing = true;
|
isSyncing = true;
|
||||||
syncMessage = 'Syncing...';
|
syncMessage = 'Syncing...';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/videos/fetch', { method: 'POST' });
|
const res = await fetch('/api/videos/fetch', { method: 'POST', headers: getAuthHeaders() });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
syncMessage = 'Sync started...';
|
syncMessage = 'Sync started...';
|
||||||
setTimeout(() => { loadVideos(); isSyncing = false; syncMessage = ''; }, 6000);
|
setTimeout(() => { loadVideos(); isSyncing = false; syncMessage = ''; }, 6000);
|
||||||
@@ -105,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateStatus(videoId, newStatus, silent = false) {
|
async function updateStatus(videoId, newStatus, silent = false) {
|
||||||
await fetch(`/api/videos/${videoId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) });
|
await fetch(`/api/videos/${videoId}/status`, { method: 'POST', headers: getAuthHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ status: newStatus }) });
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
videos = videos.filter(v => v.video_id !== videoId);
|
videos = videos.filter(v => v.video_id !== videoId);
|
||||||
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
|
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
|
||||||
@@ -115,7 +119,7 @@
|
|||||||
|
|
||||||
async function generateSummary(videoId) {
|
async function generateSummary(videoId) {
|
||||||
if (selectedVideo?.video_id === videoId) selectedVideo.summary = "Generating...";
|
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);
|
setTimeout(() => loadVideos(), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +148,7 @@
|
|||||||
for (const k of missing) payload[k] = local[k];
|
for (const k of missing) payload[k] = local[k];
|
||||||
await fetch('/api/notes/migrate', {
|
await fetch('/api/notes/migrate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ notes: payload })
|
body: JSON.stringify({ notes: payload })
|
||||||
});
|
});
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
@@ -160,7 +164,7 @@
|
|||||||
// Save to server
|
// Save to server
|
||||||
await fetch(`/api/notes/${vid}`, {
|
await fetch(`/api/notes/${vid}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders({ 'Content-Type': 'application/json' } ),
|
||||||
body: JSON.stringify({ note_text: current, queue_to_agent: queueToAgent })
|
body: JSON.stringify({ note_text: current, queue_to_agent: queueToAgent })
|
||||||
});
|
});
|
||||||
// Update local state
|
// Update local state
|
||||||
@@ -181,21 +185,58 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeAgentItem(id) {
|
async function removeAgentItem(id) {
|
||||||
await fetch(`/api/agent-queue/${id}`, { method: 'DELETE' });
|
await fetch(`/api/agent-queue/${id}`, { method: 'DELETE', headers: getAuthHeaders() });
|
||||||
agentItems = agentItems.filter(i => i.id !== id);
|
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 ----
|
// ---- Lifecycle ----
|
||||||
|
|
||||||
$effect(() => { if (activeTab) loadVideos(); });
|
$effect(() => { if (activeTab) loadVideos(); });
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Mark previous video as read when navigating away
|
||||||
if (selectedVideo && summaryPanel) {
|
if (selectedVideo && summaryPanel) {
|
||||||
tick().then(() => summaryPanel.scrollTop = 0);
|
tick().then(() => summaryPanel.scrollTop = 0);
|
||||||
const vid = selectedVideo.video_id;
|
}
|
||||||
const hasSummary = selectedVideo.summary && !selectedVideo.summary.includes('Error') && !selectedVideo.summary.includes('📺 Short clip');
|
const prevId = prevSelectedId;
|
||||||
if (hasSummary && activeTab === 'pending') {
|
prevSelectedId = selectedVideo?.video_id || null;
|
||||||
const timer = setTimeout(() => updateStatus(vid, 'read', true), 3000);
|
|
||||||
return () => clearTimeout(timer);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -205,6 +246,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
document.documentElement.className = theme;
|
document.documentElement.className = theme;
|
||||||
|
await initAuth();
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
// Migrate localStorage notes to server (non-blocking)
|
// Migrate localStorage notes to server (non-blocking)
|
||||||
migrateLocalNotes();
|
migrateLocalNotes();
|
||||||
@@ -323,6 +365,31 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
|
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
|
||||||
|
<!-- 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">
|
<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">
|
<div class="flex items-center space-x-3">
|
||||||
<span class="text-lg font-black tracking-wider" style="color:var(--accent)">T YouTube</span>
|
<span class="text-lg font-black tracking-wider" style="color:var(--accent)">T YouTube</span>
|
||||||
|
|||||||
+20
-4
@@ -4,15 +4,20 @@ set -e
|
|||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
VIDEO_ID="jNQXAC9IVRw"
|
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
|
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 http_proxy=socks5://127.0.0.1:7890
|
||||||
export https_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 all_proxy=socks5://127.0.0.1:7890
|
||||||
export HTTP_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 HTTPS_PROXY=socks5://127.0.0.1:7890
|
||||||
export ALL_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
|
fi
|
||||||
|
|
||||||
# 2. Set up virtual environment and install requirements if not set up
|
# 2. Set up virtual environment and install requirements if not set up
|
||||||
@@ -25,6 +30,8 @@ fi
|
|||||||
echo "=== Step 1: Running Mock Unit Tests ==="
|
echo "=== Step 1: Running Mock Unit Tests ==="
|
||||||
"$DIR/backend/.venv/bin/python3" -m pytest "$DIR/backend/tests/"
|
"$DIR/backend/.venv/bin/python3" -m pytest "$DIR/backend/tests/"
|
||||||
|
|
||||||
|
# Only run live integration tests if a proxy is available (needed from China)
|
||||||
|
if [ "$PROXY_DETECTED" = true ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Step 2: Seeding DB for Live Integration Test ==="
|
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;
|
"$DIR/backend/.venv/bin/python3" -c "import asyncio, aiosqlite, sys; sys.path.insert(0, '$DIR/backend'); import db;
|
||||||
@@ -38,6 +45,7 @@ asyncio.run(run())"
|
|||||||
echo "=== Step 3: Running Live Ingestion & AI Summarization ==="
|
echo "=== Step 3: Running Live Ingestion & AI Summarization ==="
|
||||||
set +e
|
set +e
|
||||||
"$DIR/backend/.venv/bin/python3" "$DIR/backend/summarizer.py" "$VIDEO_ID"
|
"$DIR/backend/.venv/bin/python3" "$DIR/backend/summarizer.py" "$VIDEO_ID"
|
||||||
|
SUMMARIZER_EXIT=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -57,9 +65,17 @@ async def run():
|
|||||||
print()
|
print()
|
||||||
print('=============================================')
|
print('=============================================')
|
||||||
print('WARNING: LIVE INTEGRATION TEST COULD NOT COMPLETE')
|
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('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('This warning is expected and did not break the build.')
|
||||||
print('=============================================')
|
print('=============================================')
|
||||||
asyncio.run(run())"
|
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