feat: channel/category dropdown with API filter; AI auto-categories embedded

This commit is contained in:
Gan, Jimmy
2026-06-24 18:20:12 +08:00
parent 63d42594d0
commit 94ef5e2bb2
4 changed files with 131 additions and 12 deletions
+22 -2
View File
@@ -37,6 +37,21 @@ async def startup():
async def health():
return {"status": "ok"}
@app.get("/auto_categories.json")
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")
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return {}
@app.get("/api/channels")
async def get_channels(db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT DISTINCT channel_name, channel_id FROM videos ORDER BY channel_name")
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@app.get("/")
async def root():
from fastapi.responses import FileResponse
@@ -46,10 +61,15 @@ async def root():
async def get_videos(
status: Optional[str] = "pending",
limit: int = 50,
channel: Optional[str] = None,
db: aiosqlite.Connection = Depends(get_db)
):
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, limit))
if channel:
query = "SELECT * FROM videos WHERE status = ? AND channel_name = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, channel, limit))
else:
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, limit))
rows = await cursor.fetchall()
return [dict(row) for row in rows]
+1
View File
@@ -0,0 +1 @@
export default {"AI Engineer": "AI & Tech", "AI-Fan AI研究室-帆哥": "AI & Tech", "AI大模型": "AI & Tech", "AI探索与发现": "AI & Tech", "Aurora Tech": "AI & Tech", "Claude": "AI & Tech", "ColdFusion": "AI & Tech", "Data Is Beautiful": "AI & Tech", "DeepLearningAI": "AI & Tech", "Google": "AI & Tech", "Google Cloud Tech": "AI & Tech", "Lex Fridman": "AI & Tech", "NiceKate AI": "AI & Tech", "PC Sim": "AI & Tech", "Stanford Online": "AI & Tech", "The Linux Foundation": "AI & Tech", "回到Axton": "AI & Tech", "回形针PaperClip": "AI & Tech", "林亦LYi": "AI & Tech", "硅谷101播客": "AI & Tech", "老石谈芯": "AI & Tech", "鲲鹏Talk": "AI & Tech", "3D Beast": "3D & Design", "Arrimus3D Modeling and Design": "3D & Design", "Blender": "3D & Design", "Blender Guru": "3D & Design", "Creators 3D": "3D & Design", "Designfusion": "3D & Design", "Foundry": "3D & Design", "Grant Abbitt (Gabbitt)": "3D & Design", "Gravity Sketch": "3D & Design", "Hyperganic": "3D & Design", "MSLattice": "3D & Design", "Manolo Remiddi": "3D & Design", "Rhino 3D (Rhinoceros3d official)": "3D & Design", "Shaper3d": "3D & Design", "nTop": "3D & Design", "spherene AG": "3D & Design", "魔界造物": "3D & Design", "Chuck Severance": "Programming", "Coursera": "Programming", "freeCodeCamp.org": "Programming", "Kevin Stratvert": "Programming", "Leila Gharani": "Programming", "LinkedIn Learning": "Programming", "Metabase": "Programming", "n8n": "Programming", "The Coding Gopher": "Programming", "Brian Casel": "Programming", "code秘密花园": "Programming", "cs tech": "Programming", "fequalsf": "Programming", "openscreencast": "Programming", "技术爬爬虾 TechShrimp": "Programming", "林本兔Limbuntu": "Programming", "Best Choice Review": "Hardware & Reviews", "Marques Brownlee": "Hardware & Reviews", "TESTV": "Hardware & Reviews", "先看评测": "Hardware & Reviews", "极客湾Geekerwan": "Hardware & Reviews", "硬核拆解": "Hardware & Reviews", "笔吧评测室": "Hardware & Reviews", "差评硬件部": "Hardware & Reviews", "充电头网": "Hardware & Reviews", "Apple Developer": "Apple Dev", "Made by Google": "Apple Dev", "Allen的分享": "Apple Dev", "Sypnotix": "Apple Dev", "8K World": "Photography", "Can Fiona": "Photography", "小天fotos": "Photography", "影像极客Fotogeeker": "Photography", "摄影师PHiL": "Photography", "虚空光影CosmosFilm": "Photography", "路易斯 LouisDrone": "Photography", "Bruno Mars": "Music", "GraceLeeMusic": "Music", "Katie Melua": "Music", "The Weeknd": "Music", "TheWeekndVEVO": "Music", "杰威爾歌詞MV頻道JVR Lyric MV": "Music", "薛汀哲": "Music", "Salah Trainer": "Sports", "Skills N Talents (swimming)": "Sports", "TotalSports TV": "Sports", "WorldSBK": "Sports", "Everyday Cycling": "Sports", "爱羽客羽毛球网": "Sports", "adidas": "Sports", "Sneaks & Feet极客鞋谈": "Automotive", "TOP的遥控玩具": "Automotive", "悟空的日常": "Automotive"}
+1
View File
@@ -0,0 +1 @@
{"AI Engineer": "AI & Tech", "AI-Fan AI\u7814\u7a76\u5ba4-\u5e06\u54e5": "AI & Tech", "AI\u5927\u6a21\u578b": "AI & Tech", "AI\u63a2\u7d22\u4e0e\u53d1\u73b0": "AI & Tech", "Aurora Tech": "AI & Tech", "Claude": "AI & Tech", "ColdFusion": "AI & Tech", "Data Is Beautiful": "AI & Tech", "DeepLearningAI": "AI & Tech", "Google": "AI & Tech", "Google Cloud Tech": "AI & Tech", "Lex Fridman": "AI & Tech", "NiceKate AI": "AI & Tech", "PC Sim": "AI & Tech", "Stanford Online": "AI & Tech", "The Linux Foundation": "AI & Tech", "\u56de\u5230Axton": "AI & Tech", "\u56de\u5f62\u9488PaperClip": "AI & Tech", "\u6797\u4ea6LYi": "AI & Tech", "\u7845\u8c37101\u64ad\u5ba2": "AI & Tech", "\u8001\u77f3\u8c08\u82af": "AI & Tech", "\u9cb2\u9e4fTalk": "AI & Tech", "3D Beast": "3D & Design", "Arrimus3D Modeling and Design": "3D & Design", "Blender": "3D & Design", "Blender Guru": "3D & Design", "Creators 3D": "3D & Design", "Designfusion": "3D & Design", "Foundry": "3D & Design", "Grant Abbitt (Gabbitt)": "3D & Design", "Gravity Sketch": "3D & Design", "Hyperganic": "3D & Design", "MSLattice": "3D & Design", "Manolo Remiddi": "3D & Design", "Rhino 3D (Rhinoceros3d official)": "3D & Design", "Shaper3d": "3D & Design", "nTop": "3D & Design", "spherene AG": "3D & Design", "\u9b54\u754c\u9020\u7269": "3D & Design", "Chuck Severance": "Programming", "Coursera": "Programming", "freeCodeCamp.org": "Programming", "Kevin Stratvert": "Programming", "Leila Gharani": "Programming", "LinkedIn Learning": "Programming", "Metabase": "Programming", "n8n": "Programming", "The Coding Gopher": "Programming", "Brian Casel": "Programming", "code\u79d8\u5bc6\u82b1\u56ed": "Programming", "cs tech": "Programming", "fequalsf": "Programming", "openscreencast": "Programming", "\u6280\u672f\u722c\u722c\u867e TechShrimp": "Programming", "\u6797\u672c\u5154Limbuntu": "Programming", "Best Choice Review": "Hardware & Reviews", "Marques Brownlee": "Hardware & Reviews", "TESTV": "Hardware & Reviews", "\u5148\u770b\u8bc4\u6d4b": "Hardware & Reviews", "\u6781\u5ba2\u6e7eGeekerwan": "Hardware & Reviews", "\u786c\u6838\u62c6\u89e3": "Hardware & Reviews", "\u7b14\u5427\u8bc4\u6d4b\u5ba4": "Hardware & Reviews", "\u5dee\u8bc4\u786c\u4ef6\u90e8": "Hardware & Reviews", "\u5145\u7535\u5934\u7f51": "Hardware & Reviews", "Apple Developer": "Apple Dev", "Made by Google": "Apple Dev", "Allen\u7684\u5206\u4eab": "Apple Dev", "Sypnotix": "Apple Dev", "8K World": "Photography", "Can Fiona": "Photography", "\u5c0f\u5929fotos": "Photography", "\u5f71\u50cf\u6781\u5ba2Fotogeeker": "Photography", "\u6444\u5f71\u5e08PHiL": "Photography", "\u865a\u7a7a\u5149\u5f71CosmosFilm": "Photography", "\u8def\u6613\u65af LouisDrone": "Photography", "Bruno Mars": "Music", "GraceLeeMusic": "Music", "Katie Melua": "Music", "The Weeknd": "Music", "TheWeekndVEVO": "Music", "\u6770\u5a01\u723e\u6b4c\u8a5eMV\u983b\u9053JVR Lyric MV": "Music", "\u859b\u6c40\u54f2": "Music", "Salah Trainer": "Sports", "Skills N Talents (swimming)": "Sports", "TotalSports TV": "Sports", "WorldSBK": "Sports", "Everyday Cycling": "Sports", "\u7231\u7fbd\u5ba2\u7fbd\u6bdb\u7403\u7f51": "Sports", "adidas": "Sports", "Sneaks & Feet\u6781\u5ba2\u978b\u8c08": "Automotive", "TOP\u7684\u9065\u63a7\u73a9\u5177": "Automotive", "\u609f\u7a7a\u7684\u65e5\u5e38": "Automotive"}
+107 -10
View File
@@ -14,6 +14,32 @@
let notes = $state(JSON.parse(localStorage.getItem('t-yt-notes') || '{}'));
let activeNote = $state(null);
let showHelp = $state(false);
let channelFilter = $state('');
let allChannels = $state([]);
let editingCategories = $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());
function toggleTheme() {
theme = theme === 'dark' ? 'light' : 'dark';
@@ -34,7 +60,11 @@
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) {
@@ -67,6 +97,8 @@
if (!silent) {
videos = videos.filter(v => v.video_id !== videoId);
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
// Reload when list gets short
if (videos.length < 20) loadVideos();
}
}
@@ -94,6 +126,7 @@
loadVideos();
loadPendingCount();
updateSyncTimer();
loadChannels();
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
@@ -114,23 +147,29 @@
async function loadPendingCount() {
try {
const res = await fetch('/api/videos?status=pending&limit=1');
const res = await fetch('/api/videos?status=pending&limit=201');
if (res.ok) {
// Count from header or estimate — just ping for now
const all = await fetch('/api/videos?status=pending&limit=200');
if (all.ok) pendingCount = (await all.json()).length;
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;
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
const idx = videos.findIndex(v => v.video_id === selectedVideo?.video_id);
const idx = filteredVideos.findIndex(v => v.video_id === selectedVideo?.video_id);
let nextIdx = idx;
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
if (idx < videos.length - 1) nextIdx = idx + 1; else return;
if (idx < filteredVideos.length - 1) nextIdx = idx + 1; else return;
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault();
if (idx > 0) nextIdx = idx - 1; else return;
@@ -162,7 +201,7 @@
} else {
return;
}
selectedVideo = videos[nextIdx];
selectedVideo = filteredVideos[nextIdx];
scrollToVideo(nextIdx);
}
@@ -235,9 +274,67 @@
</button>
{/each}
</div>
{#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}
<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 outline-none focus:outline-none"