feat: 增加视频清单搜索,并在开发期缓存剧目清单
This commit is contained in:
@@ -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 "获取清单失败"
|
||||
|
||||
@@ -34,6 +34,7 @@ class AdbBody(BaseModel):
|
||||
class SessionBody(BaseModel):
|
||||
token: str
|
||||
device: DeviceBody | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class ProxyBody(BaseModel):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user