feat: 增加视频清单搜索,并在开发期缓存剧目清单

This commit is contained in:
Nixevol
2026-09-27 04:21:00 +08:00
parent cc930cd1ed
commit f010426f94
7 changed files with 268 additions and 21 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ async def load_catalog(body: SessionBody):
async def generate():
try:
async for event in catalog.stream(token, raw_device):
async for event in catalog.stream(token, raw_device, body.refresh):
yield json.dumps(event, ensure_ascii=False) + "\n"
except Exception as exc:
message = str(exc) or "获取清单失败"
+1
View File
@@ -34,6 +34,7 @@ class AdbBody(BaseModel):
class SessionBody(BaseModel):
token: str
device: DeviceBody | None = None
refresh: bool = False
class ProxyBody(BaseModel):
+31 -4
View File
@@ -4,6 +4,7 @@ from server.services.covers import covers
from server.services.device import DeviceProfile
from server.services.http import http
from server.services.rapt import API_BASE
from server.services import catalog_cache
from server.services.sessions import sessions
CLASSIFY_MAX_PAGES = 50
@@ -39,7 +40,27 @@ class CatalogService:
raise RuntimeError("没有获取到分类")
return rows
async def stream(self, token: str, raw_device: dict | None):
async def stream(self, token: str, raw_device: dict | None, refresh: bool = False):
if catalog_cache.enabled():
if refresh:
catalog_cache.clear()
else:
cached = catalog_cache.read()
if cached:
for event in cached:
yield event
return
collected: list[dict] = []
failed = False
async for event in self._fetch(token, raw_device):
if event.get("failed"):
failed = True
collected.append(event)
yield event
if catalog_cache.enabled() and not failed and collected and collected[-1].get("type") == "done":
catalog_cache.write(collected)
async def _fetch(self, token: str, raw_device: dict | None):
token = token.strip()
if not token:
raise RuntimeError("请先登录")
@@ -79,7 +100,10 @@ class CatalogService:
for event in self._iter_paged(key, name, token, device, path, method, body, max_pages, note):
loop.call_soon_threadsafe(queue.put_nowait, event)
except Exception as exc:
loop.call_soon_threadsafe(queue.put_nowait, self._event(key, name, [], str(exc) or "获取清单失败", True))
loop.call_soon_threadsafe(
queue.put_nowait,
self._event(key, name, [], str(exc) or "获取清单失败", done=True, failed=True),
)
finally:
loop.call_soon_threadsafe(queue.put_nowait, None)
@@ -101,8 +125,11 @@ class CatalogService:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
def _event(self, key: str, name: str, items: list[dict], note: str = "", done: bool = False) -> dict:
return {"type": "page", "key": key, "name": name, "note": note, "items": items, "done": done}
def _event(self, key: str, name: str, items: list[dict], note: str = "", done: bool = False, failed: bool = False) -> dict:
event = {"type": "page", "key": key, "name": name, "note": note, "items": items, "done": done}
if failed:
event["failed"] = True
return event
def _drama(self, item: dict) -> dict | None:
vid = item.get("id")
+41
View File
@@ -0,0 +1,41 @@
"""开发期剧目清单缓存。
只缓存分类和剧目清单,不缓存封面。封面仍由 cache/covers 负责。
手动刷新会删掉这份缓存并重新请求。
正式打包时 Tauri 会把 RAPTD_CATALOG_CACHE 设为 0,从而关闭这里。
"""
import json
import os
from pathlib import Path
PATH = Path(__file__).resolve().parents[2] / "cache" / "catalog.json"
def enabled() -> bool:
flag = os.environ.get("RAPTD_CATALOG_CACHE", "1").strip().lower()
return flag not in ("0", "false", "no")
def read() -> list[dict] | None:
if not PATH.is_file():
return None
try:
data = json.loads(PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
events = data.get("events") if isinstance(data, dict) else None
if not isinstance(events, list) or not events:
return None
return events
def write(events: list[dict]) -> None:
PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = PATH.with_suffix(".json.tmp")
temporary.write_text(json.dumps({"events": events}, ensure_ascii=False), encoding="utf-8")
temporary.replace(PATH)
def clear() -> None:
PATH.unlink(missing_ok=True)
+5 -1
View File
@@ -52,7 +52,11 @@ fn spawn_backend(root: &Path, port: u16) -> Result<Child, String> {
.arg("--port")
.arg(port.to_string())
.env("RAPTD_STATIC", &static_dir)
.env("PYTHONUNBUFFERED", "1");
.env("PYTHONUNBUFFERED", "1")
.env(
"RAPTD_CATALOG_CACHE",
if cfg!(debug_assertions) { "1" } else { "0" },
);
#[cfg(all(windows, not(debug_assertions)))]
{
use std::os::windows::process::CommandExt;
+36 -7
View File
@@ -81,7 +81,17 @@ const error = ref("");
const token = ref("");
const profile = ref<Profile | null>(null);
const page = ref<"download" | "profile">("download");
const downloadRef = ref<{ reload: () => void; busy: boolean } | null>(null);
const downloadRef = ref<{
reload: (refresh?: boolean) => void;
busy: boolean;
find: (keyword?: string) => void;
searchHint: string;
clearSearch: () => void;
} | null>(null);
const catalogQuery = ref("");
watch(catalogQuery, (value) => {
if (!String(value ?? "").trim()) downloadRef.value?.clearSearch();
});
let profileTimer = 0;
let profilePulling = false;
const builtinDevice = ref<DeviceProfile>({
@@ -531,8 +541,19 @@ onUnmounted(stopProfileWatch);
<n-layout class="main-pane">
<n-layout-header bordered class="bar">
<n-text strong>{{ page === "download" ? "视频清单" : "个人信息" }}</n-text>
<div v-if="page === 'download'" class="bar-search">
<n-input
v-model:value="catalogQuery"
class="catalog-query"
placeholder="搜索名称"
clearable
@keyup.enter="downloadRef?.find(catalogQuery)"
/>
<n-button secondary @click="downloadRef?.find(catalogQuery)">搜索</n-button>
<n-text v-if="downloadRef?.searchHint" class="search-hint" depth="3">{{ downloadRef.searchHint }}</n-text>
</div>
<div class="bar-actions">
<n-button v-if="page === 'download'" secondary :loading="downloadRef?.busy" @click="downloadRef?.reload()">刷新</n-button>
<n-button v-if="page === 'download'" secondary :loading="downloadRef?.busy" @click="downloadRef?.reload(true)">刷新</n-button>
</div>
</n-layout-header>
<n-layout-content content-style="height: 100%; padding: 0">
@@ -571,9 +592,6 @@ onUnmounted(stopProfileWatch);
</n-layout>
</n-layout>
<n-layout v-else class="shell" position="absolute">
<n-layout-header bordered class="bar">
<n-text strong>RaptDrama</n-text>
</n-layout-header>
<n-layout-content content-style="padding: 28px 20px">
<div class="card">
<n-tabs v-model:value="method" type="segment" size="small">
@@ -811,11 +829,22 @@ html, body, #app { height: 100%; margin: 0; }
stroke-linejoin: round;
}
.bar {
position: sticky; top: 0; z-index: 2;
position: relative;
display: flex; align-items: center; justify-content: space-between;
height: auto; padding: 12px 20px;
}
.bar-actions { display: flex; gap: 8px; }
.bar-search {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 8px;
}
.search-hint { min-width: 2.5em; }
.bar-actions { display: flex; align-items: center; gap: 8px; }
.catalog-query { width: 220px; }
.card { max-width: 560px; margin: 0 auto; }
.card .n-tabs { margin-bottom: 16px; }
.card .n-form-item { margin-bottom: 16px; }
+153 -8
View File
@@ -23,8 +23,16 @@ const expanded = ref<string[]>([]);
const selected = ref<Set<number>>(new Set());
const busy = ref(false);
const error = ref("");
const query = ref("");
const searchHint = ref("");
const flashKey = ref("");
let catalogAbort: AbortController | null = null;
let catalogGeneration = 0;
let searchCursor = 0;
let lastQuery = "";
let revealGeneration = 0;
const flashSerial = ref(0);
const scrollEl = ref<HTMLElement | null>(null);
function coverOf(image: string) {
if (!image) return "";
@@ -98,7 +106,7 @@ function applyPage(event: { key: string; name: string; note?: string; items?: Dr
if (event.done) section.loading = false;
}
async function reload() {
async function reload(refresh = false) {
catalogAbort?.abort();
const current = new AbortController();
catalogAbort = current;
@@ -109,7 +117,7 @@ async function reload() {
const response = await fetch("/api/catalog", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: props.token, device: props.device }),
body: JSON.stringify({ token: props.token, device: props.device, refresh }),
signal: current.signal,
});
if (!response.ok || !response.body) {
@@ -157,14 +165,124 @@ async function reload() {
}
}
function cardKey(sectionKey: string, id: number) {
return `${sectionKey}-${id}`;
}
function matches() {
const keyword = query.value.trim().toLowerCase();
const found: { sectionKey: string; id: number }[] = [];
if (!keyword) return found;
for (const section of sections.value) {
for (const item of section.items) {
if (item.title.toLowerCase().includes(keyword)) {
found.push({ sectionKey: section.key, id: item.id });
}
}
}
return found;
}
function clearSearch() {
searchHint.value = "";
flashKey.value = "";
lastQuery = "";
searchCursor = 0;
}
function finishFlash(key: string, serial: number, event: AnimationEvent) {
if (event.animationName !== "drama-flash") return;
if (flashKey.value === key && flashSerial.value === serial) flashKey.value = "";
}
function cardInView(node: HTMLElement, scroller: HTMLElement) {
const card = node.getBoundingClientRect();
const view = scroller.getBoundingClientRect();
return card.top >= view.top && card.bottom <= view.bottom;
}
function scrollCardIntoView(node: HTMLElement) {
const scroller = scrollEl.value;
return new Promise<void>((resolve) => {
if (!scroller) {
resolve();
return;
}
node.scrollIntoView({ block: "center", behavior: "smooth" });
let last = scroller.scrollTop;
let stable = 0;
let moved = false;
const started = performance.now();
const tick = () => {
const current = scroller.scrollTop;
if (Math.abs(current - last) >= 0.5) {
moved = true;
stable = 0;
} else {
stable += 1;
}
last = current;
const arrived = cardInView(node, scroller);
const stopped = moved ? stable >= 4 : stable >= 8;
if ((stopped && arrived) || performance.now() - started > 3000) {
resolve();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}
async function find(keyword = query.value) {
const generation = ++revealGeneration;
const text = keyword.trim();
query.value = text;
if (!text) {
searchHint.value = "请输入名称";
return;
}
const found = matches();
if (!found.length) {
searchHint.value = "没有找到";
flashKey.value = "";
return;
}
if (text.toLowerCase() !== lastQuery) {
lastQuery = text.toLowerCase();
searchCursor = 0;
} else {
searchCursor = (searchCursor + 1) % found.length;
}
const hit = found[searchCursor];
const key = cardKey(hit.sectionKey, hit.id);
searchHint.value = found.length > 1 ? `${searchCursor + 1}/${found.length}` : "";
flashKey.value = "";
if (!isExpanded(hit.sectionKey)) {
expanded.value = [...expanded.value, hit.sectionKey];
}
await nextTick();
if (generation !== revealGeneration) return;
const node = scrollEl.value?.querySelector(`[data-card="${key}"]`);
if (node instanceof HTMLElement) await scrollCardIntoView(node);
if (generation !== revealGeneration) return;
if (node instanceof HTMLElement && scrollEl.value && !cardInView(node, scrollEl.value)) {
node.scrollIntoView({ block: "center", behavior: "auto" });
}
flashSerial.value += 1;
flashKey.value = "";
await nextTick();
if (generation !== revealGeneration) return;
flashKey.value = key;
}
onMounted(reload);
defineExpose({ reload, busy });
defineExpose({ reload, busy, find, searchHint, clearSearch });
</script>
<template>
<div class="download-page">
<div class="download-body">
<div class="download-scroll">
<div ref="scrollEl" class="download-scroll">
<n-alert v-if="error" class="catalog-error" type="error" :show-icon="false">{{ error }}</n-alert>
<section v-for="section in sections" :key="section.key" class="category">
<div class="category-bar">
@@ -189,7 +307,13 @@ defineExpose({ reload, busy });
<n-text v-if="section.note" depth="3">{{ section.note }}</n-text>
<n-text v-else-if="!section.loading && section.items.length === 0" depth="3">暂无剧集</n-text>
<div v-else class="drama-grid">
<article v-for="item in section.items" :key="item.id" class="drama-card" @click="toggleDrama(item.id, !selected.has(item.id))">
<article
v-for="item in section.items"
:key="item.id"
class="drama-card"
:data-card="cardKey(section.key, item.id)"
@click="toggleDrama(item.id, !selected.has(item.id))"
>
<div class="cover">
<img v-if="item.image" :src="coverOf(item.image)" :alt="item.title" />
</div>
@@ -197,6 +321,12 @@ defineExpose({ reload, busy });
<n-checkbox :checked="selected.has(item.id)" @update:checked="toggleDrama(item.id, $event)" />
</div>
<div class="drama-title">{{ item.title }}</div>
<div
v-if="flashKey === cardKey(section.key, item.id)"
:key="flashSerial"
class="drama-mask"
@animationend="finishFlash(cardKey(section.key, item.id), flashSerial, $event)"
></div>
</article>
</div>
</div>
@@ -210,10 +340,12 @@ defineExpose({ reload, busy });
</template>
<style>
.download-page { height: 100%; min-height: 0; display: flex; flex-direction: column; }
.download-body { position: relative; flex: 1; min-height: 0; }
.download-scroll { height: 100%; overflow: auto; padding: 0 0 12px; }
.download-page { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.download-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.download-scroll { flex: 1; min-height: 0; overflow: auto; padding: 0 0 12px; }
.download-foot {
position: relative;
z-index: 2;
flex: none;
display: flex;
align-items: center;
@@ -278,5 +410,18 @@ defineExpose({ reload, busy });
border-radius: 6px;
background: rgba(0, 0, 0, 0.45);
}
.drama-mask {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: 0;
background: rgba(99, 226, 183, 0.42);
animation: drama-flash 0.45s ease-in-out 3;
}
@keyframes drama-flash {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.drama-title { padding: 8px; font-size: 13px; line-height: 1.35; }
</style>