feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)

生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

@@ -7,6 +7,7 @@ import { settingsPath } from "~/components/console/nav.ts";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { SearchHistory, useSearchHistory } from "./search-history.tsx";
import { actionIcon, mediaKindLabel } from "./search-labels.ts";
import {
isSearchShortcut,
@@ -77,6 +78,7 @@ export function CommandPalette() {
function PaletteBody({ initial }: { initial: string }) {
const navigate = useNavigate();
const history = useSearchHistory();
const listId = useId();
const [q, setQ] = useState(initial);
const term = useDebounced(q.trim(), 150);
@@ -87,6 +89,7 @@ function PaletteBody({ initial }: { initial: string }) {
const close = () => setSearchPaletteOpen(false);
const go = (to: string) => {
if (q.trim()) history.add(q);
close();
navigate(to);
};
@@ -183,9 +186,17 @@ function PaletteBody({ initial }: { initial: string }) {
className="min-h-0 flex-1 overflow-y-auto p-2 sm:max-h-[60vh]"
>
{!term ? (
<p className="px-3 py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。选中作品打开详情页。
</p>
<div className="px-3 pt-2">
<SearchHistory
onSearch={(term) => {
history.add(term);
go(searchPagePath(term));
}}
/>
<p className="py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。选中作品打开详情页。
</p>
</div>
) : (
<>
{items.map((it, i) => {
@@ -19,9 +19,12 @@ import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch }
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
@@ -119,7 +122,15 @@ export function ReleaseResults({
const pages = res.data?.pages ?? [];
const first = pages[0];
const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const paged = useListPagination(rows, {
hasMore: res.hasNextPage,
onLoadMore: () => res.fetchNextPage(),
loadingMore: res.isFetchingNextPage,
error: res.error,
resetKey: JSON.stringify(api),
});
const [dl, setDl] = useState<SearchRelease | null>(null);
const [selected, setSelected] = useState<SearchRelease | null>(null);
const filtered =
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
// 旧链接里的免费 / H&R 参数并进快速筛选
@@ -227,18 +238,12 @@ export function ReleaseResults({
/>
) : (
<ul className="divide-y divide-border/60">
{rows.map((r) => (
<ReleaseRow key={r.key} r={r} onDownload={() => setDl(r)} />
{paged.items?.map((r) => (
<ReleaseRow key={r.key} r={r} onOpen={() => setSelected(r)} onDownload={() => setDl(r)} />
))}
</ul>
)}
{rows.length > 0 && res.hasNextPage ? (
<div className="flex justify-center border-t border-border/60 px-4 py-3">
<Button variant="outline" size="sm" onClick={() => res.fetchNextPage()} disabled={res.isFetchingNextPage}>
{res.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</div>
{first ? (
@@ -254,6 +259,17 @@ export function ReleaseResults({
) : null}
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
{selected?.sourceReleaseId ? (
<ReleaseDetailSheet
key={selected.key}
siteId={selected.siteId}
release={null}
releaseId={selected.sourceReleaseId}
title={selected.title}
entry={selected.inventory ?? undefined}
onClose={() => setSelected(null)}
/>
) : null}
</section>
);
}
@@ -404,7 +420,7 @@ function SizeSortControls({
);
}
function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => void }) {
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
const promo = promoLabel(r.promotion.type);
const inv = r.inventory ? INVENTORY_STATE[r.inventory.state] : null;
const p = r.parsed;
@@ -412,7 +428,14 @@ function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => voi
return (
<li className="px-4 py-3">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
<button
type="button"
onClick={onOpen}
disabled={!r.sourceReleaseId}
className="min-w-0 flex-1 cursor-pointer text-left text-sm font-medium break-all text-foreground underline-offset-4 hover:underline focus-visible:underline disabled:cursor-default disabled:no-underline"
>
{r.title}
</button>
<span className="flex flex-wrap gap-1">
{promo ? <StatusBadge tone={r.free ? "success" : "neutral"}>{promo}</StatusBadge> : null}
{r.hr === true ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
@@ -1,44 +1,15 @@
import type { ManualAcquisitionResult, SearchRelease } from "@umt/contracts";
import { Bot, Download } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import type { SearchRelease } from "@umt/contracts";
import { usePlanFromSearch } from "~/api/search.ts";
import { useSlots } from "~/api/slots.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx";
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
const STEPS = [
"预留收录版本和预算;每个收录版本同时只有一个在途的下载",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
];
/** 委托 Agent:和工作台一样带上资源、作品、站点 */
export function delegateToAgent(r: SearchRelease) {
export function delegateToAgent(release: SearchRelease) {
openAgentDrawer({
label: `资源:${r.title}`,
releaseId: r.sourceReleaseId ?? undefined,
entityId: r.entity?.id,
siteId: r.siteId,
goal: r.sourceReleaseId
? "判断这个资源是否值得下载;合适就按正常流程提交"
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
label: `资源:${release.title}`,
releaseId: release.sourceReleaseId ?? undefined,
entityId: release.entity?.id,
siteId: release.siteId,
goal: `判断资源「${release.title}」是否值得下载;合适就按手动下载流程提交`,
});
}
@@ -49,124 +20,23 @@ export function SearchDownloadDialog({
}: {
release: SearchRelease;
open: boolean;
onOpenChange: (o: boolean) => void;
onOpenChange: (open: boolean) => void;
}) {
const plan = usePlanFromSearch();
const entityId = release.entity?.id;
const slots = useSlots({ mediaEntityId: entityId }, open && !!entityId);
const slotList = slots.data?.items ?? [];
const [slotId, setSlotId] = useState("");
const [rejected, setRejected] = useState<ManualAcquisitionResult["rejected"] | null>(null);
const [done, setDone] = useState<string | null>(null);
const slotName = (id: string | null) => slotList.find((s) => s.id === id)?.name ?? null;
const go = () =>
plan.mutate(
{
siteId: release.siteId,
siteReleaseId: release.siteReleaseId,
...(slotId ? { slotId } : entityId ? { entityId } : {}),
},
{
onSuccess: (r) => {
if (r.submitted && r.plan) {
setRejected(null);
setDone(r.plan.id);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载面板」`);
} else {
setRejected(r.rejected);
toast.warning("没有下载");
}
},
onError: (e) => toast.error(errorMessage(e)),
},
);
const submit = usePlanFromSearch();
return (
<Dialog
<ManualDownloadDialog
key={`${release.siteId}:${release.siteReleaseId}`}
open={open}
onOpenChange={(o) => {
if (!o) {
setRejected(null);
setDone(null);
}
onOpenChange(o);
onOpenChange={onOpenChange}
resource={{
title: release.title,
sizeBytes: release.sizeBytes,
entityId: release.entityMatch === "resolved" ? release.entity?.id : undefined,
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>下载这个资源</DialogTitle>
<DialogDescription className="break-all">
{release.siteName} · {release.title}
{release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""}
</DialogDescription>
</DialogHeader>
<ol className="list-decimal space-y-1 pl-5 text-caption text-muted-foreground">
{STEPS.map((s) => (
<li key={s}>{s}</li>
))}
</ol>
{!entityId ? (
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在「身份与元数据」指定。</p>
) : slots.isPending ? (
<p className="text-meta text-muted-foreground">正在读取收录版本…</p>
) : slotList.length ? (
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label="收录版本"
value={slotId}
onChange={setSlotId}
allLabel="自动匹配"
options={slotList.map((s) => ({
value: s.id,
label: `${s.name}(${SLOT_STATUS[s.status]?.label ?? s.status})`,
}))}
/>
</div>
) : (
<p className="text-meta text-muted-foreground">这个作品没有收录版本。先在订阅里新建。</p>
)}
{release.entityMatch === "alias" ? (
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
) : null}
{rejected ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
<p className="mb-1">没有下载:</p>
<ul className="list-disc space-y-0.5 pl-5">
{rejected.map((r, i) => (
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
{slotName(r.slotId) ? `${slotName(r.slotId)}:` : ""}
{r.message}
</li>
))}
</ul>
</div>
) : null}
{done ? (
<p className="text-caption text-success">
已加入下载。
<Link to={`/console/downloads?plan=${done}`} className="underline underline-offset-4">
查看
</Link>
</p>
) : null}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
delegateToAgent(release);
}}
>
<Bot />
委托智能体
</Button>
<Button onClick={go} disabled={plan.isPending || !!done || !entityId}>
<Download />
{plan.isPending ? "提交中…" : "提交下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
onDelegate={() => delegateToAgent(release)}
submit={(options) =>
submit.mutateAsync({ ...options, siteId: release.siteId, siteReleaseId: release.siteReleaseId })
}
/>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { addHistory, HISTORY_LIMIT, parseHistory } from "./search-history.tsx";
describe("search history", () => {
it("ignores malformed storage and non-text entries", () => {
expect(parseHistory("invalid json")).toEqual([]);
expect(parseHistory('[null,8," ","剧名","剧名"]')).toEqual(["剧名"]);
});
it("moves repeat searches to the front without saving intermediate typing", () => {
expect(addHistory(["Movie", "剧集"], " movie ")).toEqual(["movie", "剧集"]);
expect(addHistory(["剧集"], " ")).toEqual(["剧集"]);
});
it("retains only the latest searches", () => {
const history = Array.from({ length: HISTORY_LIMIT }, (_, i) => `query-${i}`);
const next = addHistory(history, "新搜索");
expect(next).toHaveLength(HISTORY_LIMIT);
expect(next[0]).toBe("新搜索");
expect(next).not.toContain(history.at(-1));
});
});
@@ -0,0 +1,114 @@
import { History, X } from "lucide-react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useSession } from "~/api/auth.ts";
import { Button } from "~/components/ui/button.tsx";
const EVENT = "umt:search-history";
export const HISTORY_LIMIT = 30;
export function parseHistory(raw: string): string[] {
try {
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value)) return [];
return [
...new Set(value.filter((s): s is string => typeof s === "string" && !!s.trim()).map((s) => s.trim())),
].slice(0, HISTORY_LIMIT);
} catch {
return [];
}
}
export function addHistory(history: string[], term: string) {
const q = term.trim();
return q
? [q, ...history.filter((s) => s.toLocaleLowerCase() !== q.toLocaleLowerCase())].slice(0, HISTORY_LIMIT)
: history;
}
function subscribe(notify: () => void) {
window.addEventListener(EVENT, notify);
window.addEventListener("storage", notify);
return () => {
window.removeEventListener(EVENT, notify);
window.removeEventListener("storage", notify);
};
}
const memory = new Map<string, string>();
function read(key: string) {
try {
return localStorage.getItem(key) ?? "";
} catch {
return memory.get(key) ?? "";
}
}
export function useSearchHistory() {
const session = useSession();
const actor = session.data?.actor;
const key = actor ? `umt.search-history.${actor.type}.${actor.id}` : "";
const raw = useSyncExternalStore(
subscribe,
() => (key ? read(key) : ""),
() => "",
);
const entries = useMemo(() => parseHistory(raw), [raw]);
const write = useCallback(
(next: string[]) => {
if (!key) return;
const value = JSON.stringify(next);
memory.set(key, value);
try {
localStorage.setItem(key, value);
} catch {
/* session-only fallback */
}
window.dispatchEvent(new Event(EVENT));
},
[key],
);
return {
entries,
add: useCallback((term: string) => write(addHistory(parseHistory(key ? read(key) : ""), term)), [key, write]),
remove: (term: string) => write(entries.filter((s) => s !== term)),
clear: () => write([]),
};
}
export function SearchHistory({ onSearch }: { onSearch: (term: string) => void }) {
const history = useSearchHistory();
if (!history.entries.length) return null;
return (
<section aria-label="搜索历史" className="mb-6 min-w-0">
<div className="mb-2 flex items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-caption font-medium text-muted-foreground">
<History className="h-4 w-4" />
搜索历史
</h2>
<Button type="button" size="sm" variant="ghost" onClick={history.clear}>
清空历史
</Button>
</div>
<div className="flex flex-wrap gap-2">
{history.entries.map((term) => (
<div
key={term}
className="flex min-w-0 max-w-full items-center rounded-full border border-border bg-foreground/[0.03] text-caption"
>
<button
type="button"
onClick={() => onSearch(term)}
className="min-w-0 truncate rounded-l-full px-3 py-1.5 text-left outline-none hover:bg-foreground/[0.05] focus-visible:ring-2 focus-visible:ring-ring"
>
{term}
</button>
<button
type="button"
aria-label={`删除搜索历史:${term}`}
onClick={() => history.remove(term)}
className="mr-1 shrink-0 rounded-full p-1 text-muted-foreground outline-none hover:bg-foreground/[0.07] focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</section>
);
}
@@ -6,7 +6,9 @@ import { mediaServers } from "~/api/instances.ts";
import { useSearch } from "~/api/search.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { settingsPath } from "~/components/console/nav.ts";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { actionIcon } from "./search-labels.ts";
import { SitePickerDialog } from "./site-picker-dialog.tsx";
import { releaseSearchPath, WorkCard, workRef } from "./work-card.tsx";
@@ -40,11 +42,12 @@ export function useReleaseSearchLauncher() {
}
export function WorksSection({ q }: { q: string }) {
const res = useSearch(q, "media", 20);
const res = useSearch(q, "media", 50);
const ms = mediaServers.useList();
const emby = (ms.data?.items ?? []).some((i) => (i as { enabled?: boolean }).enabled !== false);
const launcher = useReleaseSearchLauncher();
const works = res.data?.media?.works ?? [];
const paged = useListPagination(works, { resetKey: q });
const tmdb = res.data?.media?.tmdb;
const searchReleases = (w: SearchWork) => {
const ref = workRef(w);
@@ -77,11 +80,12 @@ export function WorksSection({ q }: { q: string }) {
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{works.map((w) => (
{paged.items?.map((w) => (
<WorkCard key={w.key} w={w} emby={emby} onSearchReleases={searchReleases} />
))}
</div>
)}
{res.data ? <Pagination {...paged.pagination} /> : null}
{tmdb && !tmdb.configured ? (
<p className="mt-2 text-meta text-muted-foreground">
TMDB 未配置,只搜了本地作品。