feat: notes + agent queue, frontend rebuild, pip mirror, gitignore sync_rotation.json
- Add video_notes table and agent_queue table for persistent notes and AI agent tasks
- Add API endpoints: POST/GET /api/notes/{id}, POST /api/notes/migrate, agent queue endpoints
- Rebuild frontend: fix duplicate loadVideos(), non-blocking localStorage migration, notes UI
- Switch pip install to Tsinghua mirror for faster downloads in China
- Add backend/sync_rotation.json to .gitignore (runtime state)
This commit is contained in:
@@ -22,3 +22,4 @@ config/cookies.txt
|
|||||||
token.pickle
|
token.pickle
|
||||||
config/client_secret.json
|
config/client_secret.json
|
||||||
config/token.pickle
|
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 \
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
# Install python packages
|
||||||
COPY backend/requirements.txt ./
|
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 built static frontend
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ async def init_db():
|
|||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
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,
|
||||||
|
FOREIGN KEY (video_id) REFERENCES videos(video_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def get_db():
|
async def get_db():
|
||||||
|
|||||||
+144
-7
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from fastapi import FastAPI, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import FastAPI, BackgroundTasks, Depends, HTTPException, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -16,7 +17,6 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
app = FastAPI(title="T YouTube", description="Text-based YouTube Summarizer")
|
app = FastAPI(title="T YouTube", description="Text-based YouTube Summarizer")
|
||||||
|
|
||||||
# Enable CORS for development
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -28,7 +28,20 @@ app.add_middleware(
|
|||||||
class StatusUpdate(BaseModel):
|
class StatusUpdate(BaseModel):
|
||||||
status: str
|
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")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
await init_db()
|
await init_db()
|
||||||
@@ -39,7 +52,6 @@ async def health():
|
|||||||
|
|
||||||
@app.get("/auto_categories.json")
|
@app.get("/auto_categories.json")
|
||||||
async def auto_categories():
|
async def auto_categories():
|
||||||
import json, os
|
|
||||||
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")
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
@@ -93,7 +105,6 @@ async def trigger_summarize(video_id: str, background_tasks: BackgroundTasks, db
|
|||||||
exists = await cursor.fetchone()
|
exists = await cursor.fetchone()
|
||||||
if not exists:
|
if not exists:
|
||||||
raise HTTPException(status_code=404, detail="Video not found")
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
|
||||||
background_tasks.add_task(summarize_video, video_id)
|
background_tasks.add_task(summarize_video, video_id)
|
||||||
return {"message": f"Summarization for video {video_id} queued."}
|
return {"message": f"Summarization for video {video_id} queued."}
|
||||||
|
|
||||||
@@ -105,17 +116,143 @@ async def update_status(
|
|||||||
):
|
):
|
||||||
if update.status not in ["pending", "read", "skipped"]:
|
if update.status not in ["pending", "read", "skipped"]:
|
||||||
raise HTTPException(status_code=400, detail="Invalid status value")
|
raise HTTPException(status_code=400, detail="Invalid status value")
|
||||||
|
|
||||||
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,))
|
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,))
|
||||||
exists = await cursor.fetchone()
|
exists = await cursor.fetchone()
|
||||||
if not exists:
|
if not exists:
|
||||||
raise HTTPException(status_code=404, detail="Video not found")
|
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.execute("UPDATE videos SET status = ? WHERE video_id = ?", (update.status, video_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"message": f"Video {video_id} status updated to {update.status}."}
|
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")
|
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):
|
if os.path.exists(frontend_build_path):
|
||||||
app.mount("/", StaticFiles(directory=frontend_build_path, html=True), name="frontend")
|
app.mount("/", StaticFiles(directory=frontend_build_path, html=True), name="frontend")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
fastapi>=0.100.0
|
fastapi>=0.100.0
|
||||||
uvicorn>=0.20.0
|
uvicorn>=0.20.0
|
||||||
yt-dlp>=2023.0.0
|
|
||||||
youtube-transcript-api>=0.6.0
|
youtube-transcript-api>=0.6.0
|
||||||
aiosqlite>=0.18.0
|
aiosqlite>=0.18.0
|
||||||
httpx>=0.24.0
|
httpx>=0.24.0
|
||||||
|
|||||||
@@ -8,16 +8,20 @@
|
|||||||
let isSyncing = $state(false);
|
let isSyncing = $state(false);
|
||||||
let syncMessage = $state('');
|
let syncMessage = $state('');
|
||||||
let pendingCount = $state(0);
|
let pendingCount = $state(0);
|
||||||
let nextSync = $state(localStorage.getItem('t-yt-next-sync') || '');
|
|
||||||
let theme = $state(localStorage.getItem('theme') || 'dark');
|
let theme = $state(localStorage.getItem('theme') || 'dark');
|
||||||
let summaryPanel = $state(null);
|
let summaryPanel = $state(null);
|
||||||
let notes = $state(JSON.parse(localStorage.getItem('t-yt-notes') || '{}'));
|
let notes = $state({});
|
||||||
let activeNote = $state(null);
|
let activeNote = $state(null);
|
||||||
|
let queueToAgent = $state(false);
|
||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
let channelFilter = $state('');
|
let channelFilter = $state('');
|
||||||
let allChannels = $state([]);
|
let allChannels = $state([]);
|
||||||
let editingCategories = $state(false);
|
let editingCategories = $state(false);
|
||||||
|
|
||||||
|
// Agent queue state
|
||||||
|
let agentItems = $state([]);
|
||||||
|
let agentLoading = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeTab === 'pending' && channelFilter) {
|
if (activeTab === 'pending' && channelFilter) {
|
||||||
loadVideos();
|
loadVideos();
|
||||||
@@ -41,6 +45,8 @@
|
|||||||
);
|
);
|
||||||
let catList = $derived([...new Set(Object.values(categories).flat())].sort());
|
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() {
|
function toggleTheme() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
@@ -51,11 +57,11 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
if (activeTab === 'notes') {
|
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`);
|
const res = await fetch(`/api/videos?status=pending&limit=50`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const all = await res.json();
|
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];
|
if (videos.length > 0) selectedVideo = videos[0];
|
||||||
else selectedVideo = null;
|
else selectedVideo = null;
|
||||||
}
|
}
|
||||||
@@ -103,7 +109,6 @@
|
|||||||
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;
|
||||||
// Reload when list gets short
|
|
||||||
if (videos.length < 20) loadVideos();
|
if (videos.length < 20) loadVideos();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,11 +119,78 @@
|
|||||||
setTimeout(() => loadVideos(), 3000);
|
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: { '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: { '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' });
|
||||||
|
agentItems = agentItems.filter(i => i.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lifecycle ----
|
||||||
|
|
||||||
$effect(() => { if (activeTab) loadVideos(); });
|
$effect(() => { if (activeTab) loadVideos(); });
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedVideo && summaryPanel) {
|
if (selectedVideo && summaryPanel) {
|
||||||
tick().then(() => summaryPanel.scrollTop = 0);
|
tick().then(() => summaryPanel.scrollTop = 0);
|
||||||
// Auto-mark as read after 3s of viewing a summarized video
|
|
||||||
const vid = selectedVideo.video_id;
|
const vid = selectedVideo.video_id;
|
||||||
const hasSummary = selectedVideo.summary && !selectedVideo.summary.includes('Error') && !selectedVideo.summary.includes('📺 Short clip');
|
const hasSummary = selectedVideo.summary && !selectedVideo.summary.includes('Error') && !selectedVideo.summary.includes('📺 Short clip');
|
||||||
if (hasSummary && activeTab === 'pending') {
|
if (hasSummary && activeTab === 'pending') {
|
||||||
@@ -127,9 +199,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
|
if (activeTab === 'agent') loadAgentQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
document.documentElement.className = theme;
|
document.documentElement.className = theme;
|
||||||
loadVideos();
|
await loadNotes();
|
||||||
|
// Migrate localStorage notes to server (non-blocking)
|
||||||
|
migrateLocalNotes();
|
||||||
|
await loadVideos();
|
||||||
|
loaded = true;
|
||||||
loadPendingCount();
|
loadPendingCount();
|
||||||
updateSyncTimer();
|
updateSyncTimer();
|
||||||
loadChannels();
|
loadChannels();
|
||||||
@@ -142,7 +222,7 @@
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const next = new Date(now);
|
const next = new Date(now);
|
||||||
next.setHours(3, 0, 0, 0); // 3 AM daily
|
next.setHours(3, 0, 0, 0);
|
||||||
if (now > next) next.setDate(next.getDate() + 1);
|
if (now > next) next.setDate(next.getDate() + 1);
|
||||||
const diff = next - now;
|
const diff = next - now;
|
||||||
const h = Math.floor(diff / 3600000);
|
const h = Math.floor(diff / 3600000);
|
||||||
@@ -194,8 +274,7 @@
|
|||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeNote && selectedVideo && activeNote === selectedVideo.video_id) {
|
if (activeNote && selectedVideo && activeNote === selectedVideo.video_id) {
|
||||||
localStorage.setItem('t-yt-notes', JSON.stringify(notes));
|
saveNote();
|
||||||
activeNote = null;
|
|
||||||
} else if (selectedVideo) {
|
} else if (selectedVideo) {
|
||||||
updateStatus(selectedVideo.video_id, 'read');
|
updateStatus(selectedVideo.video_id, 'read');
|
||||||
}
|
}
|
||||||
@@ -220,20 +299,6 @@
|
|||||||
requestAnimationFrame(() => node.focus());
|
requestAnimationFrame(() => node.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveNote() {
|
|
||||||
const vid = selectedVideo?.video_id;
|
|
||||||
if (!vid) return;
|
|
||||||
const prev = notes[vid] || '';
|
|
||||||
const textarea = document.querySelector('#note-textarea');
|
|
||||||
const current = textarea ? textarea.value : prev;
|
|
||||||
if (!current.trim()) return;
|
|
||||||
const ts = new Date().toLocaleString();
|
|
||||||
const stamped = `[${ts}] ${current.trim()}\n${prev}`;
|
|
||||||
notes = {...notes, [vid]: stamped};
|
|
||||||
localStorage.setItem('t-yt-notes', JSON.stringify(notes));
|
|
||||||
activeNote = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMarkdown(text) {
|
function formatMarkdown(text) {
|
||||||
if (!text) return '<p class="opacity-50 italic">No summary yet.</p>';
|
if (!text) return '<p class="opacity-50 italic">No summary yet.</p>';
|
||||||
let html = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold" style="color:var(--accent)">$1</strong>');
|
let html = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold" style="color:var(--accent)">$1</strong>');
|
||||||
@@ -245,10 +310,19 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
return html;
|
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>
|
</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">
|
||||||
<!-- Top bar -->
|
|
||||||
<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>
|
||||||
@@ -272,10 +346,11 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<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">
|
<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)">
|
<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}
|
<button onclick={() => activeTab = tab}
|
||||||
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
|
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
|
||||||
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
|
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
|
||||||
@@ -283,6 +358,46 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
{#if (allChannels.length || channels.length) > 1}
|
||||||
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
|
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
|
||||||
<select bind:value={channelFilter}
|
<select bind:value={channelFilter}
|
||||||
@@ -341,6 +456,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Video list -->
|
||||||
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
|
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
|
||||||
{#each filteredVideos as video (video.video_id)}
|
{#each filteredVideos as video (video.video_id)}
|
||||||
{@const i = filteredVideos.indexOf(video)}
|
{@const i = filteredVideos.indexOf(video)}
|
||||||
@@ -353,16 +469,54 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span>
|
<span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span>
|
||||||
<h3 class="text-xs font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3>
|
<h3 class="text-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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</aside>
|
</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">
|
<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">
|
<div class="p-6 max-w-3xl mx-auto space-y-4">
|
||||||
<!-- Video header -->
|
<!-- Video header -->
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
@@ -387,16 +541,26 @@
|
|||||||
<!-- Note editor/viewer -->
|
<!-- Note editor/viewer -->
|
||||||
{#if activeNote === selectedVideo.video_id}
|
{#if activeNote === selectedVideo.video_id}
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<textarea id="note-textarea" rows="2" class="w-full text-xs p-2 rounded border resize-none"
|
<textarea id="note-textarea" rows="3" class="w-full text-xs p-2 rounded border resize-none"
|
||||||
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
|
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
|
||||||
placeholder="Quick note..."
|
placeholder="Write instructions for the agent here... e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
|
||||||
value={notes[selectedVideo.video_id] || ''}
|
value={notes[selectedVideo.video_id] || ''}
|
||||||
oninput={e => notes[selectedVideo.video_id] = e.target.value}
|
oninput={e => notes[selectedVideo.video_id] = e.target.value}
|
||||||
use:autoFocus
|
use:autoFocus
|
||||||
onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }}
|
onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }}
|
||||||
></textarea>
|
></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}
|
<button onclick={saveNote}
|
||||||
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Save</button>
|
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">
|
||||||
|
{queueToAgent ? 'Save + 🤖 Queue' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if notes[selectedVideo.video_id]}
|
{:else if notes[selectedVideo.video_id]}
|
||||||
<div class="p-2 rounded border text-xs leading-relaxed cursor-pointer"
|
<div class="p-2 rounded border text-xs leading-relaxed cursor-pointer"
|
||||||
|
|||||||
Reference in New Issue
Block a user