fix: skip auth token when Authelia authed + frontend search bar improvements

This commit is contained in:
Gan, Jimmy
2026-07-09 22:53:06 +08:00
parent 90a47c95c3
commit 5c767190d2
3 changed files with 275 additions and 87 deletions
+4
View File
@@ -36,7 +36,11 @@ async def validate_api_token(request: Request, call_next):
path = request.url.path path = request.url.path
method = request.method method = request.method
# Only check auth for write operations on /api/* # Only check auth for write operations on /api/*
# Skip token check if Authelia/Caddy already authenticated the user
if path.startswith("/api/") and method in ("POST", "PATCH", "DELETE") and API_TOKEN: if path.startswith("/api/") and method in ("POST", "PATCH", "DELETE") and API_TOKEN:
# Check if request came through Authelia (Remote-User header set by Caddy forward_auth)
if request.headers.get("Remote-User"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer ") or auth_header[7:] != API_TOKEN: if not auth_header.startswith("Bearer ") or auth_header[7:] != API_TOKEN:
return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
+222 -36
View File
@@ -21,13 +21,37 @@
let allChannels = $state([]); let allChannels = $state([]);
let editingCategories = $state(false); let editingCategories = $state(false);
let prevSelectedId = $state(null); let prevSelectedId = $state(null);
// Search state
let searchQuery = $state(''); let searchQuery = $state('');
let searchTimeout = null; let searchResults = $state([]);
let isSearching = $state(false);
let showSearchResults = $state(false);
// Batch select state
let selectedVideoIds = $state(new Set());
let showBatchBar = $state(false);
// Stats state
let statsData = $state(null);
let showStats = $state(false);
// Agent queue state // Agent queue state
let agentItems = $state([]); let agentItems = $state([]);
let agentLoading = $state(false); let agentLoading = $state(false);
// Stats fetcher
async function loadStats() {
try {
const res = await fetch('/api/stats');
if (res.ok) statsData = await res.json();
} catch(e) {}
}
$effect(() => {
if (activeTab === 'stats' && !statsData) loadStats();
});
$effect(() => { $effect(() => {
if (activeTab === 'pending' && channelFilter) { if (activeTab === 'pending' && channelFilter) {
loadVideos(); loadVideos();
@@ -53,6 +77,83 @@
let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim()))); let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim())));
// Search debouncer
let searchTimeout = null;
async function handleSearch(query) {
searchQuery = query;
if (!query || query.length < 2) {
showSearchResults = false;
return;
}
isSearching = true;
try {
const res = await fetch(`/api/videos/search?q=${encodeURIComponent(query)}&limit=50`);
if (res.ok) {
searchResults = await res.json();
showSearchResults = true;
}
} catch(e) {
console.error('Search failed:', e);
} finally {
isSearching = false;
}
}
// Batch operations
function toggleSelect(videoId) {
const newSet = new Set(selectedVideoIds);
if (newSet.has(videoId)) {
newSet.delete(videoId);
} else {
newSet.add(videoId);
}
selectedVideoIds = newSet;
showBatchBar = newSet.size > 0;
}
function selectAll() {
selectedVideoIds = new Set(filteredVideos.map(v => v.video_id));
showBatchBar = true;
}
function clearSelection() {
selectedVideoIds = new Set();
showBatchBar = false;
}
async function batchUpdate(status) {
if (selectedVideoIds.size === 0) return;
const ids = Array.from(selectedVideoIds);
try {
const res = await fetch('/api/videos/batch', {
method: 'POST',
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ video_ids: ids, status })
});
if (res.ok) {
// Remove selected from local list
videos = videos.filter(v => !ids.includes(v.video_id));
clearSelection();
loadPendingCount();
}
} catch(e) {
console.error('Batch update failed:', e);
}
}
function formatDuration(seconds) {
if (!seconds || seconds < 60) return '';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatViews(count) {
if (!count || count < 1000) return count || '';
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
return `${(count / 1000000).toFixed(1)}M`;
}
function toggleTheme() { function toggleTheme() {
theme = theme === 'dark' ? 'light' : 'dark'; theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
@@ -76,13 +177,6 @@
if (channelFilter && !channelFilter.startsWith('📁')) { if (channelFilter && !channelFilter.startsWith('📁')) {
params.set('channel', channelFilter); params.set('channel', channelFilter);
} }
if (searchQuery.trim()) {
params.set('search', searchQuery.trim());
}
if (searchQuery.trim()) {
params.delete('status');
params.set('limit', '50');
}
const res = await fetch(`/api/videos?${params}`); const res = await fetch(`/api/videos?${params}`);
if (res.ok) { if (res.ok) {
videos = await res.json(); videos = await res.json();
@@ -385,6 +479,13 @@
if (!d) return ''; if (!d) return '';
return d.slice(0, 10); return d.slice(0, 10);
} }
// Stats display helpers
function formatStatsNumber(n) {
if (n >= 1000000) return `${(n/1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n/1000).toFixed(1)}K`;
return n.toString();
}
</script> </script>
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column"> <div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
@@ -440,15 +541,34 @@
<aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0"> <aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0">
<!-- Tabs --> <!-- Tabs -->
<div class="flex p-1.5" style="border-bottom:1px solid var(--border)"> <div class="flex p-1.5" style="border-bottom:1px solid var(--border)">
{#each ['pending','read','skipped','notes','🤖 Agent'] as tab} {#each ['pending','read','skipped','notes','stats','🤖 Agent'] as tab}
<button onclick={() => activeTab = tab} <button onclick={() => activeTab = tab}
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition" class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}"> style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
{tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab} {tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab === 'stats' ? '📊 Stats' : tab === '🤖 Agent' ? '🤖 Agent' : tab}
</button> </button>
{/each} {/each}
</div> </div>
<!-- Search bar (shown in pending/read/skipped tabs) -->
{#if activeTab !== '🤖 Agent' && activeTab !== 'stats' && activeTab !== 'notes'}
<div class="px-2 py-1.5" style="border-bottom:1px solid var(--border)">
<div class="relative">
<input type="text"
bind:value={searchQuery}
oninput={handleSearch}
placeholder="Search videos..."
class="w-full text-[11px] p-1.5 rounded border"
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
onfocus={() => { if (searchResults.length) showSearchResults = true; }}
onblur={() => setTimeout(() => showSearchResults = false, 200)}>
{#if isSearching}
<div class="absolute right-2 top-1.5 text-[9px] animate-spin" style="color:var(--accent)"></div>
{/if}
</div>
</div>
{/if}
<!-- Agent Queue tab content --> <!-- Agent Queue tab content -->
{#if activeTab === '🤖 Agent'} {#if activeTab === '🤖 Agent'}
<div class="flex-1 overflow-y-auto" style="border-color:var(--border)"> <div class="flex-1 overflow-y-auto" style="border-color:var(--border)">
@@ -486,26 +606,56 @@
{/each} {/each}
{/if} {/if}
</div> </div>
{:else} {:else if activeTab === 'stats'}
<!-- Search bar --> <!-- Stats Dashboard -->
<div class="px-2 py-1.5" style="border-bottom:1px solid var(--border)"> <div class="flex-1 overflow-y-auto p-3 space-y-3">
<div class="flex items-center space-x-1" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;padding:2px 8px"> {#if !statsData}
<span style="color:var(--text-muted);font-size:12px">🔍</span> <div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">Loading stats...</div>
<input type="text" bind:value={searchQuery} {:else}
placeholder="Search videos..." <!-- Summary cards -->
class="w-full text-[11px] py-1 bg-transparent outline-none" <div class="grid grid-cols-2 gap-2">
style="color:var(--text-primary)" <div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
oninput={() => { <div class="text-lg font-black" style="color:var(--accent)">{formatStatsNumber(statsData.total || 0)}</div>
clearTimeout(searchTimeout); <div class="text-[9px]" style="color:var(--text-muted)">Total Videos</div>
searchTimeout = setTimeout(loadVideos, 300); </div>
}} <div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
onkeydown={(e) => { if (e.key === 'Enter') { clearTimeout(searchTimeout); loadVideos(); } }} /> <div class="text-lg font-black" style="color:var(--accent)">{statsData.summary_rate || 0}%</div>
{#if searchQuery} <div class="text-[9px]" style="color:var(--text-muted)">Summary Rate</div>
<button onclick={() => { searchQuery = ''; loadVideos(); }} </div>
class="text-[10px] px-1" style="color:var(--text-muted)"></button> <div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
{/if} <div class="text-lg font-black" style="color:var(--accent)">{statsData.channels || 0}</div>
</div> <div class="text-[9px]" style="color:var(--text-muted)">Channels</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.last_7_days || 0}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Last 7 Days</div>
</div>
</div>
<!-- Status breakdown -->
<div class="p-2 rounded border" style="border-color:var(--border)">
<div class="text-[10px] font-bold mb-2" style="color:var(--text-muted)">STATUS BREAKDOWN</div>
{#each Object.entries(statsData.status_breakdown || {}) as [status, count]}
<div class="flex justify-between items-center py-1">
<span class="text-[11px]" style="color:var(--text-primary)">{status}</span>
<span class="text-[11px] font-bold" style="color:var(--accent)">{count}</span>
</div>
{/each}
</div>
<!-- Top channels -->
<div class="p-2 rounded border" style="border-color:var(--border)">
<div class="text-[10px] font-bold mb-2" style="color:var(--text-muted)">TOP CHANNELS</div>
{#each (statsData.top_channels || []).slice(0, 8) as ch}
<div class="flex justify-between items-center py-1">
<span class="text-[10px] truncate flex-1 mr-2" style="color:var(--text-primary)">{ch.name}</span>
<span class="text-[10px]" style="color:var(--text-muted)">{ch.count}</span>
</div>
{/each}
</div>
{/if}
</div> </div>
{:else}
<!-- Channel filter bar --> <!-- 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)">
@@ -571,14 +721,31 @@
{@const i = filteredVideos.indexOf(video)} {@const i = filteredVideos.indexOf(video)}
<button onclick={() => { selectedVideo = video; }} <button onclick={() => { selectedVideo = video; }}
data-video-index={i} data-video-index={i}
class="w-full text-left p-3 transition flex items-start space-x-3 outline-none focus:outline-none" class="w-full text-left p-2.5 transition flex items-start space-x-2 outline-none focus:outline-none"
style="border-left:2px solid {selectedVideo?.video_id === video.video_id ? 'var(--accent)' : 'transparent'}; style="border-left:2px solid {selectedVideo?.video_id === video.video_id ? 'var(--accent)' : 'transparent'};
background:{selectedVideo?.video_id === video.video_id ? 'var(--bg-tertiary)' : 'transparent'}"> background:{selectedVideo?.video_id === video.video_id ? 'var(--bg-tertiary)' : 'transparent'}">
<img src={video.thumbnail_url} alt="" class="w-20 h-12 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" /> <!-- Batch select checkbox -->
<div class="min-w-0"> <input type="checkbox"
checked={selectedVideoIds.has(video.video_id)}
onclick={(e) => { e.stopPropagation(); toggleSelect(video.video_id); }}
class="w-3 h-3 mt-1 rounded flex-shrink-0"
style="accent-color:var(--accent)">
<img src={video.thumbnail_url} alt="" class="w-16 h-10 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0 flex-1">
<span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span> <span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span>
<h3 class="text-xs font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3> <h3 class="text-[11px] font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3>
<span class="text-[10px] mt-0.5 block" style="color:var(--text-muted)">{video.publish_date || ''} {noteIds.has(video.video_id) ? '📝' : ''}</span> <div class="flex items-center gap-2 mt-0.5">
<span class="text-[9px]" style="color:var(--text-muted)">{video.publish_date || ''}</span>
{#if video.duration}
<span class="text-[9px]" style="color:var(--text-muted)">{formatDuration(video.duration)}</span>
{/if}
{#if video.view_count && video.view_count > 0}
<span class="text-[9px]" style="color:var(--text-muted)">{formatViews(video.view_count)}</span>
{/if}
{#if noteIds.has(video.video_id)}
<span class="text-[9px]">📝</span>
{/if}
</div>
</div> </div>
</button> </button>
{/each} {/each}
@@ -586,6 +753,17 @@
{/if} {/if}
</aside> </aside>
<!-- Batch action bar -->
{#if showBatchBar}
<div class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-40 px-4 py-2 rounded-full shadow-lg flex items-center space-x-3"
style="background:var(--bg-secondary);border:1px solid var(--border)">
<span class="text-xs font-bold" style="color:var(--accent)">{selectedVideoIds.size} selected</span>
<button onclick={() => batchUpdate('read')} class="text-[10px] px-3 py-1 text-white font-bold rounded" style="background:var(--accent)">Read All</button>
<button onclick={() => batchUpdate('skipped')} class="text-[10px] px-3 py-1 font-bold rounded border" style="color:var(--text-secondary);border-color:var(--border)">Skip All</button>
<button onclick={clearSelection} class="text-[10px] px-3 py-1 font-bold rounded border" style="color:var(--text-muted);border-color:var(--border)">Clear</button>
</div>
{/if}
<!-- Right: summary panel --> <!-- Right: summary panel -->
<main bind:this={summaryPanel} onscroll={handleSummaryScroll} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0"> <main bind:this={summaryPanel} onscroll={handleSummaryScroll} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
{#if activeTab === '🤖 Agent'} {#if activeTab === '🤖 Agent'}
@@ -636,7 +814,14 @@
onclick={() => { channelFilter = selectedVideo.channel_name; }} onclick={() => { channelFilter = selectedVideo.channel_name; }}
title="Filter by this channel">{selectedVideo.channel_name}</span> title="Filter by this channel">{selectedVideo.channel_name}</span>
<h1 class="text-lg font-bold leading-snug" style="color:var(--text-primary)">{selectedVideo.title}</h1> <h1 class="text-lg font-bold leading-snug" style="color:var(--text-primary)">{selectedVideo.title}</h1>
<div class="text-[10px]" style="color:var(--text-muted)">{selectedVideo.publish_date || ''}</div> <div class="text-[10px]" style="color:var(--text-muted)">{selectedVideo.publish_date || ''}
{#if selectedVideo.duration}
{formatDuration(selectedVideo.duration)}
{/if}
{#if selectedVideo.view_count}
{formatViews(selectedVideo.view_count)} views
{/if}
</div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button onclick={() => updateStatus(selectedVideo.video_id, 'read')} class="text-[10px] px-2.5 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Read (r)</button> <button onclick={() => updateStatus(selectedVideo.video_id, 'read')} class="text-[10px] px-2.5 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Read (r)</button>
<button onclick={() => updateStatus(selectedVideo.video_id, 'skipped')} class="text-[10px] px-2.5 py-1 font-bold rounded border transition" style="color:var(--text-secondary);border-color:var(--border)">Skip (s)</button> <button onclick={() => updateStatus(selectedVideo.video_id, 'skipped')} class="text-[10px] px-2.5 py-1 font-bold rounded border transition" style="color:var(--text-secondary);border-color:var(--border)">Skip (s)</button>
@@ -653,7 +838,8 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<textarea id="note-textarea" rows="3" class="w-full text-xs p-2 rounded border resize-none" <textarea id="note-textarea" rows="3" class="w-full text-xs p-2 rounded border resize-none"
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)" style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
placeholder="Write instructions for the agent here...&#10;e.g. 'Add this gyroid lattice workflow as a Hermes skill'" placeholder="Write instructions for the agent here...
e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
value={notes[selectedVideo.video_id] || ''} value={notes[selectedVideo.video_id] || ''}
oninput={e => notes[selectedVideo.video_id] = e.target.value} oninput={e => notes[selectedVideo.video_id] = e.target.value}
use:autoFocus use:autoFocus
+14 -16
View File
@@ -2,6 +2,7 @@
import asyncio, os, sys, logging import asyncio, os, sys, logging
REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
BACKEND = os.path.join(REPO_ROOT, "backend")
ENV_PATH = os.path.join(REPO_ROOT, ".env") ENV_PATH = os.path.join(REPO_ROOT, ".env")
if os.path.exists(ENV_PATH): if os.path.exists(ENV_PATH):
with open(ENV_PATH) as f: with open(ENV_PATH) as f:
@@ -9,31 +10,29 @@ if os.path.exists(ENV_PATH):
line = line.strip() line = line.strip()
if line and not line.startswith("#") and "=" in line: if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=") key, _, value = line.partition("=")
value = value.strip().strip("'\\"") value = value.strip().strip("'\"")
os.environ[key] = value os.environ[key] = value
sys.path.insert(0, REPO_ROOT) sys.path.insert(0, BACKEND)
sys.path.insert(0, os.path.join(REPO_ROOT, "backend")) os.chdir(BACKEND)
from backend.db import init_db from db import init_db, DB_PATH
from backend.fetcher import fetch_subscriptions from fetcher import fetch_subscriptions
from backend.summarizer import summarize_all_pending from summarizer import summarize_video
import aiosqlite import aiosqlite
BATCH_SIZE = int(os.environ.get("SUMMARIZE_BATCH", "50")) BATCH_SIZE = int(os.environ.get("SUMMARIZE_BATCH", "20"))
BATCH_DELAY = float(os.environ.get("SUMMARIZE_BATCH_DELAY", "1.0")) BATCH_DELAY = float(os.environ.get("SUMMARIZE_BATCH_DELAY", "2.0"))
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
async def main(): async def main():
await init_db() await init_db()
# Fetch new videos first
count = await fetch_subscriptions() count = await fetch_subscriptions()
print(f"Fetched {count} new videos") print(f"Fetched {count} new videos")
# Count pending async with aiosqlite.connect(DB_PATH) as db:
async with aiosqlite.connect(os.path.join(REPO_ROOT, "backend", "data", "videos.db")) as db:
cur = await db.execute("SELECT COUNT(*) FROM videos WHERE summary IS NULL OR summary = ''") cur = await db.execute("SELECT COUNT(*) FROM videos WHERE summary IS NULL OR summary = ''")
total = (await cur.fetchone())[0] total = (await cur.fetchone())[0]
@@ -41,15 +40,13 @@ async def main():
print("No pending videos to summarize.") print("No pending videos to summarize.")
return return
print(f"Processing batch of up to {BATCH_SIZE} videos (of {total} pending)...") print(f"Processing batch of {BATCH_SIZE} (of {total} pending)...")
# Get pending video IDs async with aiosqlite.connect(DB_PATH) as db:
async with aiosqlite.connect(os.path.join(REPO_ROOT, "backend", "data", "videos.db")) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
cur = await db.execute("SELECT video_id FROM videos WHERE summary IS NULL OR summary = '' LIMIT ?", (BATCH_SIZE,)) cur = await db.execute("SELECT video_id FROM videos WHERE summary IS NULL OR summary = '' LIMIT ?", (BATCH_SIZE,))
rows = await cur.fetchall() rows = await cur.fetchall()
from backend.summarizer import summarize_video
done = 0 done = 0
for row in rows: for row in rows:
await summarize_video(row["video_id"]) await summarize_video(row["video_id"])
@@ -57,7 +54,8 @@ async def main():
if done < len(rows): if done < len(rows):
await asyncio.sleep(BATCH_DELAY) await asyncio.sleep(BATCH_DELAY)
print(f"Processed {done}/{len(rows)} in batch, {total - done} remaining") remaining = total - done
print(f"Batch done: {done}/{len(rows)}, {remaining} pending")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())