Merge branch 'search4'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # docs/plan/00-first-version-plan.md
This commit is contained in:
commit
511ad93077
20 files changed
+773
-94
No files matched your search
@@ -4,6 +4,16 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增(SEARCH-4 资源筛选对齐 MP)
|
||||
- 搜索结果与站点资源的快速筛选新增 8 个维度:音频编码、字幕(中文 / 简体 / 繁体 / 英文…)、配音(国语 / 粤语)、特性(10bit / 50 帧以上 / 特别剪辑版)、年份、分类、大小分档、做种分档,都带计数、可多选、可组合。
|
||||
- 筛选条顺序照 MP(站点、季/集、促销、编码、质量、HDR、分辨率、制作组),其余维度收进「更多筛选」(桌面宽弹层、手机抽屉)。原「版本」改叫「质量」。
|
||||
- 解析器补「配音语言」字段(`audioLangs`:国语 / 粤语 / 英语)。
|
||||
- MP 维度对照表见 `docs/runbook/search.md`「与 MP 的对照」。
|
||||
|
||||
### 变更
|
||||
- 全站弹层、下拉选择、下拉菜单、对话框、抽屉改为不透明实底(不再半透明 + 背景模糊),文字压在列表上也看得清。
|
||||
- 站点资源 Tab 的「做种」「大小」下拉(只筛已加载的结果)换成快速筛选里的分档,按全部本地资源计数和筛选。
|
||||
|
||||
## [0.3.5] - 2026-09-30
|
||||
|
||||
### 新增(SCR-1 刮削:NFO 与图片写进媒体库目录)
|
||||
|
||||
@@ -295,6 +295,12 @@ function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceRel
|
||||
videoCodec: p.videoCodec ?? null,
|
||||
hdr: [...p.hdr],
|
||||
releaseGroup: p.releaseGroup?.name ?? null,
|
||||
audio: [...p.audio],
|
||||
subtitles: p.subtitles ? [...p.subtitles] : null,
|
||||
audioLangs: p.audioLangs ? [...p.audioLangs] : null,
|
||||
bitDepth: p.bitDepth ?? null,
|
||||
fps: p.fps ?? null,
|
||||
cut: p.cut ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -358,6 +364,16 @@ export const searchFacetInput = (r: BaseRelease): FacetInput => ({
|
||||
expiresAt: r.promotion.expiresAt,
|
||||
},
|
||||
hr: r.hr,
|
||||
audio: r.parsed.audio ?? [],
|
||||
subtitles: r.parsed.subtitles ?? null,
|
||||
audioLangs: r.parsed.audioLangs ?? null,
|
||||
bitDepth: r.parsed.bitDepth ?? null,
|
||||
fps: r.parsed.fps ?? null,
|
||||
cut: r.parsed.cut ?? null,
|
||||
year: r.parsed.year,
|
||||
category: r.category.name,
|
||||
sizeBytes: r.sizeBytes,
|
||||
seeders: r.seeders,
|
||||
});
|
||||
|
||||
/** 快速筛选(SEARCH-3):先过旧筛选参数,再按 qf 多选筛选并统计分面 */
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
WorkbenchReleaseQuery,
|
||||
} from "@umt/contracts";
|
||||
import { type SiteRow, sql } from "@umt/db";
|
||||
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam } from "@umt/domain";
|
||||
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, scanAudioLangs } from "@umt/domain";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { decodeCursor, page, toApi } from "../rows.ts";
|
||||
import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
|
||||
@@ -111,10 +111,19 @@ interface LightRow {
|
||||
promotion_expires_at: Date | null;
|
||||
/** raw._site.hr(列表快照解析的 H&R);没有为 null */
|
||||
hr: unknown;
|
||||
/** 站点分类名(列表快照 → RSS → 分类 ID) */
|
||||
category: string | null;
|
||||
size_bytes: number | string | null;
|
||||
seeders: number | null;
|
||||
/** 旧解析结果没有 audioLangs 时从标题 / 副标题补 */
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
}
|
||||
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
||||
const strs = (v: unknown): string[] | null =>
|
||||
Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.length > 0) : null;
|
||||
|
||||
/** 资源表行(parsed = ParsedRelease JSON,promotion = {kind, raw:{downloadFactor, uploadFactor}}) → 快速筛选口径 */
|
||||
export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">): FacetInput {
|
||||
@@ -143,6 +152,16 @@ export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">)
|
||||
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null,
|
||||
},
|
||||
hr: r.hr === true || r.hr === false ? r.hr : "unknown",
|
||||
audio: strs(p.audio) ?? [],
|
||||
subtitles: strs(p.subtitles),
|
||||
audioLangs: strs(p.audioLangs) ?? scanAudioLangs(`${r.title ?? ""} ${r.subtitle ?? ""}`),
|
||||
bitDepth: num(p.bitDepth),
|
||||
fps: num(p.fps),
|
||||
cut: str(p.cut),
|
||||
year: num(p.year),
|
||||
category: r.category,
|
||||
sizeBytes: r.size_bytes === null || r.size_bytes === undefined ? null : Number(r.size_bytes),
|
||||
seeders: r.seeders,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +203,15 @@ export async function listSiteReleases(
|
||||
sql<unknown>`r.promotion`.as("promotion"),
|
||||
"r.promotion_expires_at",
|
||||
sql<unknown>`r.raw->'_site'->'hr'`.as("hr"),
|
||||
sql<
|
||||
string | null
|
||||
>`coalesce(nullif(r.raw->'_site'->>'categoryName', ''), nullif(r.raw->'rss'->>'categoryName', ''), r.category_raw)`.as(
|
||||
"category",
|
||||
),
|
||||
"r.size_bytes",
|
||||
"r.seeders",
|
||||
"r.title",
|
||||
"r.subtitle",
|
||||
])
|
||||
.where("r.site_id", "=", site.id);
|
||||
if (q.q) {
|
||||
|
||||
@@ -483,6 +483,48 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
expect([nexusSearches(), mteamSearches()]).toEqual(before);
|
||||
});
|
||||
|
||||
it("SEARCH-4 新维度:音频 / 年份 / 分类 / 做种 / 大小,搜索结果与站点资源列表同一口径", async () => {
|
||||
const q = (qf: string) => req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
|
||||
const list = (qf: string) =>
|
||||
req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
|
||||
const pick = (f: Loose) =>
|
||||
Object.fromEntries(
|
||||
["audioCodec", "subtitle", "dub", "feature", "year", "category", "size", "seeders"].map((k) => [
|
||||
k,
|
||||
Object.fromEntries(f[k].map((o: Loose) => [o.value, o.count])),
|
||||
]),
|
||||
);
|
||||
const d = await q("");
|
||||
expect(pick(d.facets)).toMatchObject({
|
||||
audioCodec: { Atmos: 1, TrueHD: 1, DDP: 1, AAC: 1 },
|
||||
subtitle: { zh: 1, unknown: 2 },
|
||||
dub: { cmn: 1, en: 1, unknown: 2 },
|
||||
feature: {},
|
||||
year: { "2024": 3 },
|
||||
category: { 电影: 3 },
|
||||
size: { "5-10": 1, "20-50": 1, "50+": 1 },
|
||||
seeders: { "10-49": 1, "50+": 2 },
|
||||
});
|
||||
// 组合:年份 × 做种 × 音频(同维度 OR)
|
||||
const hit = await q("year:2024;seeders:50+;audioCodec:AAC,DDP");
|
||||
expect(hit.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
||||
expect(hit.facets.audioCodec.find((o: Loose) => o.value === "DDP").count).toBe(0);
|
||||
expect(hit.facets.seeders.find((o: Loose) => o.value === "10-49").count).toBe(1);
|
||||
// 站点资源列表:从资源表的解析结果 / 分类 / 大小 / 做种算,不请求站点
|
||||
const before = nexusSearches();
|
||||
const l = await list("");
|
||||
expect(pick(l.facets)).toMatchObject({
|
||||
audioCodec: { Atmos: 1, TrueHD: 1, AAC: 1 },
|
||||
dub: { cmn: 1, unknown: 1 },
|
||||
category: { 电影: 2 },
|
||||
size: { "5-10": 1, "50+": 1 },
|
||||
seeders: { "50+": 2 },
|
||||
});
|
||||
expect((await list("dub:cmn;category:电影")).total).toBe(1);
|
||||
expect((await list("size:5-10;audioCodec:AAC")).items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
||||
expect(nexusSearches()).toBe(before);
|
||||
});
|
||||
|
||||
it("H&R 筛选:true 只要明确 H&R;false 只排除明确 H&R(unknown 保留)", () => {
|
||||
const base = (id: string, hr: boolean | "unknown") => ({ ...sample, key: id, siteReleaseId: id, hr });
|
||||
const items = [base("a", true), base("b", false), base("c", "unknown")];
|
||||
|
||||
@@ -328,10 +328,11 @@
|
||||
--mat-chrome-shadow: 0 8px 24px -20px rgba(20, 22, 28, 0.25);
|
||||
--mat-chrome-side-shadow: 8px 0 24px -20px rgba(20, 22, 28, 0.25);
|
||||
--mat-chrome-solid: rgba(255, 255, 255, 0.95);
|
||||
/* float:门户小型悬浮面板 */
|
||||
--mat-float-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0.82) 100%);
|
||||
--mat-float-blur: blur(28px) saturate(170%);
|
||||
--mat-float-border: rgba(255, 255, 255, 0.75);
|
||||
/* float:门户小型悬浮面板。SEARCH-4(2026-09-30)起不透明实底 = popover token,不做背景模糊:
|
||||
半透明弹层压在资源列表上文字看不清(用户反馈) */
|
||||
--mat-float-bg: var(--popover);
|
||||
--mat-float-blur: none;
|
||||
--mat-float-border: rgba(20, 22, 28, 0.1);
|
||||
--mat-float-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 0 0 1px rgba(20, 22, 28, 0.05), 0 2px 6px -2px rgba(20, 22, 28, 0.08), 0 20px 40px -20px rgba(20, 22, 28, 0.2);
|
||||
--mat-float-solid: rgb(251, 251, 252);
|
||||
/* 胶囊按钮:主操作是有填充的薄玻璃,次操作只描边。日夜对称——夜间白色薄玻璃配白字,
|
||||
@@ -474,9 +475,9 @@
|
||||
--mat-chrome-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
--mat-chrome-side-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
|
||||
--mat-chrome-solid: rgba(12, 13, 17, 0.92);
|
||||
--mat-float-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.035) 32%, rgba(255, 255, 255, 0) 62%), rgba(20, 21, 25, 0.9);
|
||||
--mat-float-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0) 40%), var(--popover);
|
||||
--mat-float-solid: rgb(22, 23, 27);
|
||||
--mat-float-blur: blur(36px) saturate(180%);
|
||||
--mat-float-blur: none;
|
||||
--mat-float-border: rgba(255, 255, 255, 0.14);
|
||||
/* 胶囊按钮:夜间主操作是白色玻璃 */
|
||||
--mat-pill-primary-bg: rgba(255, 255, 255, 0.05);
|
||||
@@ -910,7 +911,8 @@
|
||||
|
||||
/* 全站唯一的悬浮层材质(2026-09-26 起):下拉菜单、弹出框、下拉选择、对话框、抽屉,
|
||||
门户与流媒体、播放器共用;日夜跟随,播放页恒为夜间。圆角分两档:菜单类 rounded-2xl,
|
||||
对话框 rounded-3xl。悬浮物打开时是静态的,可以放心用 backdrop-filter。 */
|
||||
对话框 rounded-3xl。2026-09-30(SEARCH-4)起底色是不透明的 popover 实底、不做背景模糊,
|
||||
立体感只靠边框与投影;组件侧不要再给弹层加半透明底或 backdrop-blur。 */
|
||||
.pg-float {
|
||||
background: var(--mat-float-bg);
|
||||
backdrop-filter: var(--mat-float-blur);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
|
||||
import { Check, ChevronDown, SlidersHorizontal, X } from "lucide-react";
|
||||
import { Check, ChevronDown, ListFilter, SlidersHorizontal, X } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useIsMobile } from "~/components/files/use-mobile.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -8,6 +9,7 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import {
|
||||
clearDims,
|
||||
MORE_DIMS,
|
||||
QUICK_GROUPS,
|
||||
type QuickDim,
|
||||
type QuickGroup,
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。
|
||||
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
|
||||
// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。
|
||||
// SEARCH-4:对齐 MP 后维度变多,常用的留在筛选条上,其余(音频、字幕、配音、特性、年份、分类、大小、做种、H&R)
|
||||
// 收进「更多筛选」:桌面一个宽弹层分节列出,手机一个抽屉。弹层与抽屉都是不透明实底(pg-float)。
|
||||
|
||||
/** 分面之外的已选条件(如大小区间),和分面标签排在一起 */
|
||||
export type ExtraTag = { key: string; dimLabel: string; label: string; onRemove: () => void };
|
||||
@@ -52,8 +56,9 @@ export function QuickFilterBar({
|
||||
}) {
|
||||
const mobile = useIsMobile();
|
||||
const [sheet, setSheet] = useState<QuickGroup | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [asideOpen, setAsideOpen] = useState(false);
|
||||
const groups = QUICK_GROUPS.filter((g) => {
|
||||
const visible = QUICK_GROUPS.filter((g) => {
|
||||
const dims = g.dims.filter((d) => !hide.includes(d.dim));
|
||||
if (!dims.length) return false;
|
||||
const opts = dims.flatMap((d) => facets?.[d.dim] ?? []);
|
||||
@@ -67,8 +72,12 @@ export function QuickFilterBar({
|
||||
) > 0
|
||||
);
|
||||
});
|
||||
const groups = visible.filter((g) => !g.more);
|
||||
const moreGroups = visible.filter((g) => g.more);
|
||||
const moreDims = MORE_DIMS.filter((d) => !hide.includes(d));
|
||||
const moreCount = selectedCount(value, moreDims);
|
||||
const tags = selectedTags(value, facets).filter((t) => !hide.includes(t.dim));
|
||||
if (!groups.length && !tags.length && !extraTags.length && !aside) return null;
|
||||
if (!visible.length && !tags.length && !extraTags.length && !aside) return null;
|
||||
const clearAll = onClearAll ?? (() => onChange({}));
|
||||
|
||||
return (
|
||||
@@ -100,26 +109,7 @@ export function QuickFilterBar({
|
||||
const dims = g.dims.map((d) => d.dim);
|
||||
const n = selectedCount(value, dims);
|
||||
const chip = (
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
onClick={mobile ? () => setSheet(g) : undefined}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-full border px-3 text-caption font-medium whitespace-nowrap transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
|
||||
n
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{g.label}
|
||||
{n ? (
|
||||
<span className="min-w-4 rounded-full bg-primary px-1 text-center text-meta leading-4 text-primary-foreground tabular-nums">
|
||||
{n}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</button>
|
||||
<FilterChip key={g.key} label={g.label} count={n} onClick={mobile ? () => setSheet(g) : undefined} />
|
||||
);
|
||||
return mobile ? (
|
||||
chip
|
||||
@@ -132,6 +122,27 @@ export function QuickFilterBar({
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
{moreGroups.length ? (
|
||||
mobile ? (
|
||||
<FilterChip label="更多筛选" count={moreCount} onClick={() => setMoreOpen(true)} more />
|
||||
) : (
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FilterChip label="更多筛选" count={moreCount} more />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[min(44rem,calc(100vw-2rem))] p-0">
|
||||
<MorePanel
|
||||
groups={moreGroups}
|
||||
facets={facets}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={() => onChange(clearDims(value, moreDims))}
|
||||
count={moreCount}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{aside && !mobile ? <div className="flex shrink-0 items-center gap-1.5">{aside.content("inline")}</div> : null}
|
||||
</div>
|
||||
@@ -172,6 +183,33 @@ export function QuickFilterBar({
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
{mobile && moreGroups.length ? (
|
||||
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<SheetContent side="bottom" className="max-h-[85dvh] gap-0 rounded-t-2xl">
|
||||
<SheetHeader className="pb-2">
|
||||
<SheetTitle>更多筛选</SheetTitle>
|
||||
<SheetDescription className="text-meta">可多选,数字是选中后的条数</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 overflow-y-auto px-4 pb-3">
|
||||
<MorePanel groups={moreGroups} facets={facets} value={value} onChange={onChange} bare />
|
||||
</div>
|
||||
<SheetFooter className="flex-row gap-2 border-t border-border/60">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!moreCount}
|
||||
onClick={() => onChange(clearDims(value, moreDims))}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => setMoreOpen(false)}>
|
||||
完成
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
{mobile ? (
|
||||
<Sheet open={!!sheet} onOpenChange={(o) => !o && setSheet(null)}>
|
||||
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
|
||||
@@ -316,3 +354,133 @@ function GroupPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 筛选条上的入口按钮;Popover 的 asChild 需要转发 ref 与事件,所以透传其余属性 */
|
||||
function FilterChip({
|
||||
label,
|
||||
count,
|
||||
more = false,
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentProps<"button"> & { label: string; count: number; more?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
{...rest}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-full border px-3 text-caption font-medium whitespace-nowrap transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
|
||||
count
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{more ? <ListFilter className="h-3.5 w-3.5" /> : null}
|
||||
{label}
|
||||
{count ? (
|
||||
<span className="min-w-4 rounded-full bg-primary px-1 text-center text-meta leading-4 text-primary-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
{more ? null : <ChevronDown className="h-3.5 w-3.5 opacity-70" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「更多筛选」:每个维度一节,选项是带计数的小按钮;0 计数且未选时置灰 */
|
||||
function MorePanel({
|
||||
groups,
|
||||
facets,
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
count = 0,
|
||||
bare = false,
|
||||
}: {
|
||||
groups: QuickGroup[];
|
||||
facets: ReleaseQuickFacets | undefined;
|
||||
value: QuickSelection;
|
||||
onChange: (next: QuickSelection) => void;
|
||||
onClear?: () => void;
|
||||
count?: number;
|
||||
bare?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex max-h-[min(70dvh,32rem)] flex-col">
|
||||
{bare ? null : (
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-4 py-2.5">
|
||||
<span className="text-caption font-medium text-foreground">更多筛选</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!count}
|
||||
onClick={onClear}
|
||||
className="cursor-pointer text-meta text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("grid min-h-0 gap-x-6 gap-y-4 overflow-y-auto", bare ? "" : "p-4 sm:grid-cols-2")}>
|
||||
{groups.map((g) =>
|
||||
g.dims.map((d) => {
|
||||
const opts: FacetCount[] = facets?.[d.dim] ?? [];
|
||||
const chosen = value[d.dim] ?? [];
|
||||
const extra = chosen.filter((v) => !opts.some((o) => o.value === v));
|
||||
const all = [...opts, ...extra.map((v) => ({ value: v, label: v, count: 0 }))];
|
||||
if (!all.length) return null;
|
||||
const n = chosen.length;
|
||||
return (
|
||||
<fieldset key={d.dim} className="m-0 min-w-0 border-0 p-0">
|
||||
<legend className="mb-1.5 flex w-full items-center justify-between">
|
||||
<span className="text-meta font-medium text-muted-foreground">{d.label}</span>
|
||||
{n ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(clearDims(value, [d.dim]))}
|
||||
className="cursor-pointer text-meta text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
) : null}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{all.map((o) => {
|
||||
const on = chosen.includes(o.value);
|
||||
const dead = o.count === 0 && !on;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
disabled={dead}
|
||||
onClick={() => onChange(toggleValue(value, d.dim, o.value))}
|
||||
className={cn(
|
||||
"inline-flex h-8 max-w-full cursor-pointer items-center gap-1.5 rounded-full border px-3 text-caption transition-colors duration-micro md:h-7",
|
||||
on
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-foreground hover:bg-foreground/[0.06]",
|
||||
dead ? "cursor-default opacity-40 hover:bg-transparent" : "",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{o.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-meta tabular-nums",
|
||||
on ? "text-primary-foreground/80" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{o.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearDims,
|
||||
formatQuickFilter,
|
||||
MORE_DIMS,
|
||||
mergeLegacyFilters,
|
||||
parseStored,
|
||||
persistable,
|
||||
QUICK_GROUPS,
|
||||
quickFilterStorageKey,
|
||||
selectedCount,
|
||||
selectedTags,
|
||||
@@ -24,12 +26,23 @@ const empty = {
|
||||
releaseGroup: [],
|
||||
season: [],
|
||||
episode: [],
|
||||
audioCodec: [],
|
||||
subtitle: [],
|
||||
dub: [],
|
||||
feature: [],
|
||||
year: [],
|
||||
category: [],
|
||||
size: [],
|
||||
seeders: [],
|
||||
};
|
||||
|
||||
describe("快速筛选状态", () => {
|
||||
it("qf 按维度顺序拼接,值编码;空选择不带参数", () => {
|
||||
it("qf 按维度顺序(对齐 MP 筛选条)拼接,值编码;空选择不带参数", () => {
|
||||
expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe(
|
||||
"resolution:2160p;hdr:DV;releaseGroup:A%2CB",
|
||||
"hdr:DV;resolution:2160p;releaseGroup:A%2CB",
|
||||
);
|
||||
expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"] })).toBe(
|
||||
"site:s;audioCodec:TrueHD;seeders:0",
|
||||
);
|
||||
expect(formatQuickFilter({ resolution: [] })).toBeUndefined();
|
||||
});
|
||||
@@ -47,8 +60,8 @@ describe("快速筛选状态", () => {
|
||||
it("已选标签取分面里的选项名,取不到用原值", () => {
|
||||
const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] };
|
||||
expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([
|
||||
{ dim: "releaseType", value: "encode", dimLabel: "版本", label: "蓝光压制" },
|
||||
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
|
||||
{ dim: "releaseType", value: "encode", dimLabel: "质量", label: "蓝光压制" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -63,6 +76,40 @@ describe("快速筛选状态", () => {
|
||||
expect(quickFilterStorageKey("site")).not.toBe(quickFilterStorageKey("search"));
|
||||
});
|
||||
|
||||
it("SEARCH-4 新增的固定档位维度记忆,年份 / 分类随结果变化不记", () => {
|
||||
const sel = { audioCodec: ["TrueHD"], subtitle: ["zh"], dub: ["cmn"], year: ["2024"], category: ["电影"] };
|
||||
expect(persistable(sel)).toEqual({ audioCodec: ["TrueHD"], subtitle: ["zh"], dub: ["cmn"] });
|
||||
expect(parseStored(JSON.stringify({ seeders: ["50+"], feature: ["10bit"], size: ["20-50"] }))).toEqual({
|
||||
seeders: ["50+"],
|
||||
feature: ["10bit"],
|
||||
size: ["20-50"],
|
||||
});
|
||||
});
|
||||
|
||||
it("「更多筛选」收的是 MP 筛选条之外的维度", () => {
|
||||
expect(MORE_DIMS).toEqual([
|
||||
"audioCodec",
|
||||
"subtitle",
|
||||
"dub",
|
||||
"feature",
|
||||
"year",
|
||||
"category",
|
||||
"size",
|
||||
"seeders",
|
||||
"hr",
|
||||
]);
|
||||
expect(QUICK_GROUPS.filter((g) => !g.more).map((g) => g.label)).toEqual([
|
||||
"站点",
|
||||
"季/集",
|
||||
"促销",
|
||||
"编码",
|
||||
"质量",
|
||||
"HDR",
|
||||
"分辨率",
|
||||
"制作组",
|
||||
]);
|
||||
});
|
||||
|
||||
it("H&R 分面也记忆;旧版存的内容(没有 hr)照常读出,键不变", () => {
|
||||
expect(persistable({ hr: ["no"], site: ["x"] })).toEqual({ hr: ["no"] });
|
||||
expect(quickFilterStorageKey("search")).toBe("umt.quickFilters.search");
|
||||
|
||||
@@ -7,18 +7,22 @@ import { useCallback, useEffect, useState } from "react";
|
||||
export type QuickDim = keyof ReleaseQuickFacets;
|
||||
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
|
||||
|
||||
/** 筛选条上的一个入口;「季/集」一个入口里分两节 */
|
||||
export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[] };
|
||||
/**
|
||||
* 筛选条上的一个入口;「季/集」一个入口里分两节。
|
||||
* more = 收进「更多筛选」(SEARCH-4):常用维度按 MP 搜索结果筛选条的顺序放在前面
|
||||
* (站点、季集、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。
|
||||
*/
|
||||
export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[]; more?: boolean };
|
||||
|
||||
const one = (dim: QuickDim, label: string, more?: boolean): QuickGroup => ({
|
||||
key: dim,
|
||||
label,
|
||||
dims: [{ dim, label }],
|
||||
...(more ? { more } : {}),
|
||||
});
|
||||
|
||||
export const QUICK_GROUPS: QuickGroup[] = [
|
||||
{ key: "resolution", label: "分辨率", dims: [{ dim: "resolution", label: "分辨率" }] },
|
||||
{ key: "videoCodec", label: "编码", dims: [{ dim: "videoCodec", label: "编码" }] },
|
||||
{ key: "releaseType", label: "版本", dims: [{ dim: "releaseType", label: "版本" }] },
|
||||
{ key: "site", label: "站点", dims: [{ dim: "site", label: "站点" }] },
|
||||
{ key: "promotion", label: "促销", dims: [{ dim: "promotion", label: "促销" }] },
|
||||
{ key: "hdr", label: "HDR", dims: [{ dim: "hdr", label: "HDR" }] },
|
||||
{ key: "hr", label: "H&R", dims: [{ dim: "hr", label: "H&R" }] },
|
||||
{ key: "releaseGroup", label: "制作组", dims: [{ dim: "releaseGroup", label: "制作组" }] },
|
||||
one("site", "站点"),
|
||||
{
|
||||
key: "seasonEpisode",
|
||||
label: "季/集",
|
||||
@@ -27,12 +31,43 @@ export const QUICK_GROUPS: QuickGroup[] = [
|
||||
{ dim: "episode", label: "集" },
|
||||
],
|
||||
},
|
||||
one("promotion", "促销"),
|
||||
one("videoCodec", "编码"),
|
||||
one("releaseType", "质量"),
|
||||
one("hdr", "HDR"),
|
||||
one("resolution", "分辨率"),
|
||||
one("releaseGroup", "制作组"),
|
||||
one("audioCodec", "音频", true),
|
||||
one("subtitle", "字幕", true),
|
||||
one("dub", "配音", true),
|
||||
one("feature", "特性", true),
|
||||
one("year", "年份", true),
|
||||
one("category", "分类", true),
|
||||
one("size", "大小", true),
|
||||
one("seeders", "做种", true),
|
||||
one("hr", "H&R", true),
|
||||
];
|
||||
|
||||
export const MORE_GROUPS = QUICK_GROUPS.filter((g) => g.more);
|
||||
export const MORE_DIMS: QuickDim[] = MORE_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
|
||||
|
||||
const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
|
||||
|
||||
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集随搜索变化,不记 */
|
||||
const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr", "hr"];
|
||||
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集、年份、分类随搜索变化,不记 */
|
||||
const PERSISTED: QuickDim[] = [
|
||||
"resolution",
|
||||
"videoCodec",
|
||||
"releaseType",
|
||||
"promotion",
|
||||
"hdr",
|
||||
"hr",
|
||||
"audioCodec",
|
||||
"subtitle",
|
||||
"dub",
|
||||
"feature",
|
||||
"size",
|
||||
"seeders",
|
||||
];
|
||||
|
||||
/** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */
|
||||
export function formatQuickFilter(sel: QuickSelection): string | undefined {
|
||||
|
||||
@@ -184,6 +184,8 @@ export function ReleaseResults({
|
||||
value={quick}
|
||||
onChange={setQuick}
|
||||
onClearAll={clearAll}
|
||||
// 大小已有精确区间(大小与排序),不再给分档
|
||||
hide={["size"]}
|
||||
extraTags={
|
||||
sizeTag
|
||||
? [
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("站点选择", () => {
|
||||
expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q");
|
||||
// 快速筛选进 qf(同维度逗号分隔,值编码)
|
||||
expect(toApiFilters({ q: "x" }, f, "", { resolution: ["2160p", "1080p"], promotion: ["50%"], hdr: [] }).qf).toBe(
|
||||
"resolution:2160p,1080p;promotion:50%25",
|
||||
"promotion:50%25;resolution:2160p,1080p",
|
||||
);
|
||||
expect(toApiFilters({ q: "x" }, f, "", {}).qf).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -56,33 +56,19 @@ const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
|
||||
{ value: "ambiguous", label: "有歧义" },
|
||||
{ value: "unidentifiable", label: "无法识别" },
|
||||
];
|
||||
const SIZE = [
|
||||
{ value: "lt5", label: "< 5 GB" },
|
||||
{ value: "5to20", label: "5–20 GB" },
|
||||
{ value: "20to60", label: "20–60 GB" },
|
||||
{ value: "gt60", label: "> 60 GB" },
|
||||
];
|
||||
const AGE = [
|
||||
{ value: "1", label: "24 小时内" },
|
||||
{ value: "7", label: "7 天内" },
|
||||
{ value: "30", label: "30 天内" },
|
||||
];
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
/** 服务端不支持的维度(做种/大小/上传时间)在已加载结果里筛选,界面上写明;H&R 在快速筛选里(POLISH-1) */
|
||||
type LocalFilters = { seeding: string; size: string; age: string };
|
||||
/**
|
||||
* 服务端不支持的维度(上传时间)在已加载结果里筛选,界面上写明。H&R(POLISH-1)、做种与大小(SEARCH-4)
|
||||
* 在快速筛选里,按全部本地资源计数和筛选。
|
||||
*/
|
||||
type LocalFilters = { age: string };
|
||||
|
||||
function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) {
|
||||
if (f.seeding === "alive" && !(r.seeders && r.seeders > 0)) return false;
|
||||
if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false;
|
||||
if (f.size) {
|
||||
const s = r.sizeBytes;
|
||||
if (s === null) return false;
|
||||
if (f.size === "lt5" && s >= 5 * GB) return false;
|
||||
if (f.size === "5to20" && (s < 5 * GB || s >= 20 * GB)) return false;
|
||||
if (f.size === "20to60" && (s < 20 * GB || s >= 60 * GB)) return false;
|
||||
if (f.size === "gt60" && s < 60 * GB) return false;
|
||||
}
|
||||
if (f.age) {
|
||||
const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null;
|
||||
if (t === null || now - t > Number(f.age) * 86_400_000) return false;
|
||||
@@ -151,7 +137,7 @@ export default function SiteWorkbenchPage() {
|
||||
const q = useDebounced(qInput.trim());
|
||||
const [category, setCategory] = useState("");
|
||||
const [resolution, setResolution] = useState("");
|
||||
const [local, setLocal] = useState<LocalFilters>({ seeding: "", size: "", age: "" });
|
||||
const [local, setLocal] = useState<LocalFilters>({ age: "" });
|
||||
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
|
||||
const [quick, setQuick] = useQuickFilters("site");
|
||||
const qf = formatQuickFilter(quick);
|
||||
@@ -202,7 +188,7 @@ export default function SiteWorkbenchPage() {
|
||||
setCategory("");
|
||||
setQuick({});
|
||||
setResolution("");
|
||||
setLocal({ seeding: "", size: "", age: "" });
|
||||
setLocal({ age: "" });
|
||||
};
|
||||
const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick));
|
||||
|
||||
@@ -377,21 +363,6 @@ export default function SiteWorkbenchPage() {
|
||||
>
|
||||
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
|
||||
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
|
||||
<FilterSelect
|
||||
label="做种"
|
||||
value={local.seeding}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, seeding: v }))}
|
||||
options={[
|
||||
{ value: "alive", label: "有人做种" },
|
||||
{ value: "hot", label: "≥ 20 做种" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="大小"
|
||||
value={local.size}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, size: v }))}
|
||||
options={SIZE}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="上传"
|
||||
value={local.age}
|
||||
@@ -401,7 +372,14 @@ export default function SiteWorkbenchPage() {
|
||||
</FilterBar>
|
||||
|
||||
{/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */}
|
||||
<QuickFilterBar facets={pages[0]?.facets} value={quick} onChange={setQuick} hide={["site"]} hideClear />
|
||||
{/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */}
|
||||
<QuickFilterBar
|
||||
facets={pages[0]?.facets}
|
||||
value={quick}
|
||||
onChange={setQuick}
|
||||
hide={["site", "category"]}
|
||||
hideClear
|
||||
/>
|
||||
|
||||
{coverage || refreshFailed ? (
|
||||
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
|
||||
@@ -429,7 +407,7 @@ export default function SiteWorkbenchPage() {
|
||||
}
|
||||
description={
|
||||
all.length
|
||||
? "做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
|
||||
? "上传时间只筛选已加载的结果。加载更多或放宽条件。"
|
||||
: quickHidden
|
||||
? "减少快速筛选条件再看。"
|
||||
: coverage?.liveError
|
||||
|
||||
@@ -218,3 +218,5 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
|
||||
- **刮削(SCR-1,用户 2026-09-30 问"刮削测试过了吗")**:协调者核对——**没做**:文件管理里的 scrape 只是把元数据刷进本工具的库,不往媒体目录写 NFO / 海报 / 剧照(runbook files.md 已注明"NFO/图片投影仍未做");示例库里只有 mkv/srt。照 MP 的刮削实现(tvshow.nfo / season.nfo / 每集 nfo / movie.nfo,poster / fanart / season-poster / 剧照,Emby/Jellyfin/Kodi 兼容),入库后自动刮削 + 手动刮削 + 覆盖策略,再在示例库上验证。
|
||||
- **搜索筛选对齐 MP(SEARCH-4,用户 2026-09-30)**:筛选维度太少,对齐 MP 搜索结果页的全部筛选(站点、季集、促销、分辨率、质量/来源、特效 HDR/DV、视频编码、音频编码、制作组/字幕组、字幕/语言、分类、年份、大小/做种等,以 MP 前端为准逐项核对);筛选弹层半透明看不清 → 改不透明实底。
|
||||
**状态(分支 scr1)**:已实现——任务 `scrapeMedia`(文件道)、设置「刮削」、作品详情刮削状态与「重新刮削」、文件管理「刮削」;NFO 与 MP `TmdbScraper` 差分(登记差异见 `apps/server/test/scrape/nfo-deviations.json`);测试库 + 临时目录验证《人生切割术》S01 两集 + 《星际穿越》自动刮削。见 `docs/runbook/scrape.md`。
|
||||
**SEARCH-4 已实现(分支 search4,待合并)**:新增音频、字幕、配音、特性、年份、分类、大小、做种 8 个分面(解析器补配音语言),筛选条按 MP 顺序、其余进「更多筛选」;
|
||||
全站悬浮层改不透明实底。MP 对照表与不做的项(特效字幕、官种、3D、音乐音质)见 `docs/runbook/search.md`「与 MP 的对照」。
|
||||
+36
-5
@@ -56,15 +56,46 @@
|
||||
- `sort=default|seeders|size|uploadedAt|site`,配合 `order=asc|desc`。`default` 按做种数降序,做种数相同时按发布时间从新到旧;空值始终排在最后。
|
||||
- `facets.sites` / `resolutions` / `sources` 统计的是未筛选的全部结果(旧字段)。`total` 是筛选后的条数,`unfilteredTotal` 是筛选前的条数。游标是不透明的偏移量。
|
||||
|
||||
### 快速筛选(SEARCH-3,对照 MP)
|
||||
### 快速筛选(SEARCH-3,SEARCH-4 对齐 MP)
|
||||
|
||||
`GET /search/releases` 与 `GET /sites/{id}/releases` 共用,口径在 `packages/domain/src/release-facets/`。
|
||||
|
||||
- 参数 `qf=维度:值,值;维度:值`,值经 `encodeURIComponent`。同一维度多值为 OR,不同维度为 AND;与其它筛选参数叠加(AND),排序、游标照旧。
|
||||
- 维度与档位:`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);`site` 站点 ID;`promotion` free / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;`hdr` DV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;`releaseGroup` 制作组名(不分大小写);`season` 季号(多季合集展开到每一季);`episode` 集号,整季资源为 `pack`。
|
||||
- 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,0 表示在其它条件下没有结果。固定档位总会列出,other 只在有资源时列出;制作组只列计数最多的 20 个(已选的补在后面);没有剧集资源时 `season` / `episode` 为空;已选但当前没有资源的值计数 0。
|
||||
- 站点资源列表:先按 `q` / 分类 / 促销 / 识别状态从资源表取出全部候选的解析字段与促销,在内存里算分面和筛选,再只为当前页取完整行。只读本地资源表,`qf` 不会触发实时补查;响应另有 `total`(快速筛选后)与 `unfilteredTotal`。
|
||||
- 前端:`components/search/quick-filter-bar.tsx`(桌面弹层、手机底部抽屉),状态与 `qf` 拼接在 `quick-filter-state.ts`。本机记住上次选择(localStorage `umt.quickFilters.search` / `umt.quickFilters.site`),只记分辨率、编码、版本、促销、HDR;站点、制作组、季集随结果变化,不记。
|
||||
- 维度与档位:`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);`site` 站点 ID;`promotion` free / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;`hdr` DV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;`hr` yes / no / unknown;`releaseGroup` 制作组名(不分大小写);`season` 季号(多季合集展开到每一季);`episode` 集号,整季资源为 `pack`。
|
||||
- SEARCH-4 新增:`audioCodec` Atmos / TrueHD / DTS:X / DTS-HD(MA、HR)/ DTS / DDP(DD+、E-AC3)/ DD(AC3)/ AAC / FLAC / LPCM(含 PCM)/ other / unknown(标题没写),一条可属于多个,声道和 `2Audios` 不算编码;`subtitle` zh(任何中文字幕)/ zh-Hans / zh-Hant / en / ja / ko / unknown;`dub` cmn 国语 / yue 粤语 / en 英语 / unknown(来自「国语」「国配」「国粤双语」「国英双语」等标记);`feature` 10bit / hfr(50 帧以上)/ cut(加长、导演剪辑等剪辑版),没有的不落任何档;`year` 年份,新的在前;`category` 站点分类名,计数最多的 20 个;`size` lt1 / 1-5 / 5-10 / 10-20 / 20-50 / 50+(GiB)/ unknown;`seeders` 0 / 1-9 / 10-49 / 50+ / unknown。
|
||||
- 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,0 表示在其它条件下没有结果。固定档位总会列出,标了"只在有资源时列出"的档位(other、unknown、日文 / 韩文、特性、DTS:X、FLAC、LPCM 等)只在有资源时列出;制作组、分类只列计数最多的 20 个(已选的补在后面);没有剧集资源时 `season` / `episode` 为空;已选但当前没有资源的值计数 0。
|
||||
- 站点资源列表:先按 `q` / 分类 / 促销 / 识别状态从资源表取出全部候选的解析字段、促销、分类名(列表快照 → RSS → 分类 ID)、大小、做种,在内存里算分面和筛选,再只为当前页取完整行。只读本地资源表,`qf` 不会触发实时补查;响应另有 `total`(快速筛选后)与 `unfilteredTotal`。配音语言是解析器 SEARCH-4 才补的字段,解析结果里没有时按标题 + 副标题现扫(不改库)。
|
||||
- 前端:`components/search/quick-filter-bar.tsx`(桌面弹层、手机底部抽屉),状态与 `qf` 拼接在 `quick-filter-state.ts`。筛选条顺序照 MP:站点、季/集、促销、编码、质量、HDR、分辨率、制作组;其余收进「更多筛选」(桌面一个宽弹层分节、手机一个抽屉):音频、字幕、配音、特性、年份、分类、大小、做种、H&R。搜索结果页已有精确的大小区间(「大小与排序」),不显示大小分档;站点资源 Tab 已有站点分类下拉(与 MP 站点资源一致),不显示分类分面,原来只筛已加载结果的「做种 / 大小」下拉换成分面(按全部本地资源)。
|
||||
- 本机记住上次选择(localStorage `umt.quickFilters.search` / `umt.quickFilters.site`),只记与结果无关的固定档位:分辨率、编码、质量、促销、HDR、H&R、音频、字幕、配音、特性、大小、做种;站点、制作组、季集、年份、分类随结果变化,不记。「清除」清全部,弹层 / 抽屉里的「清除」只清本节。
|
||||
- 弹层材质:全站悬浮层(`pg-float`:Popover、Select、下拉菜单、对话框、抽屉)从 SEARCH-4 起是不透明实底(`--mat-float-bg` = `--popover`),不做背景模糊,只改 token,组件侧不要再加半透明底。
|
||||
|
||||
#### 与 MP 的对照(SEARCH-4,MP 前端 `17b9d51`)
|
||||
|
||||
MP 搜索结果页筛选条(`src/composables/useTorrentFilter.ts` + `components/filter/TorrentFilterBar.vue`)是 7 个维度,取值是结果里出现过的原始字符串,没有计数;排序 默认 / 站点 / 大小 / 做种 / 发布时间。MP 站点资源弹窗(`components/dialog/SiteResourceDialog.vue`)只有关键词 + 站点分类,表头排序时间 / 大小 / 做种 / 下载。音频、中字、国语、60 帧等在 MP 里是「过滤规则」(`api/constants.ts` `innerFilterRules`:CNSUB、SPECSUB、CNVOI、HKVOI、GZ、FREE、ATMOS、DOLBY、60FPS、3D、UHD、H265、REMUX…),用于订阅 / 搜索优先级,不在结果筛选条上;这里把其中有解析字段支撑的做成分面。
|
||||
|
||||
| MP 维度(取值来源) | 本工具 | 解析字段来源 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 站点 `site`(site_name) | 已有 `site` | 站点 | 单站资源列表不显示 |
|
||||
| 季 `season`(season_episode,如 `S01 E03`) | 已有 `season` + `episode` | `season` / `seasonEnd` / `episodes` | MP 季集是一个字符串;这里季、集分两节,多季合集展开到每季,整季单列 |
|
||||
| 促销 `freeState`(普通 / 免费 / 2X / 2X免费 / 50% / 2X 50% / 30%…) | 已有 `promotion` | 站点促销 + 到期 | 归到 免费 / 2x / 50% / 其他 / 无,2X免费同时算免费和 2x;过期算无 |
|
||||
| 视频编码 `videoCode`(video_encode) | 已有 `videoCodec` | `videoCodec` | H.265 / H.264 / AV1 / 其他 |
|
||||
| 质量 `edition`(resource_type + resource_effect,如 `BluRay DoVi`) | 已有 `releaseType`(质量)+ `hdr` | `source`、`hdr` | MP 把来源和特效拼成一个字符串;这里拆成「质量」和「HDR」两个维度,组合更灵活 |
|
||||
| 分辨率 `resolution`(resource_pix) | 已有 `resolution` | `resolution` | |
|
||||
| 制作组 `releaseGroup`(resource_team) | 已有 `releaseGroup` | `releaseGroup` | 不分大小写合并,列前 20 |
|
||||
| 规则 ATMOS / DOLBY / 音频格式 | **新增** `audioCodec` | `audio` | 分面放「更多筛选」 |
|
||||
| 规则 CNSUB 中字 | **新增** `subtitle` | `subtitles`(CHS/CHT、「简繁」「中字」「中英」等) | 另列简体 / 繁体 / 英文 / 日文 / 韩文 |
|
||||
| 规则 CNVOI 国语 / HKVOI 粤语 | **新增** `dub` | **解析器新增 `audioLangs`** | 之前「国语」「粤语」只从标题里去掉,不记录 |
|
||||
| 规则 60FPS | **新增** `feature` 的「50 帧以上」 | `fps` | 同一维度另有 10bit(`bitDepth`)、特别剪辑版(`cut`) |
|
||||
| (MP 无)年份 | **新增** `year` | `year` | 用户要求 |
|
||||
| 站点资源:分类 | **新增** `category`(搜索结果页) | 站点分类名 | 站点资源 Tab 沿用原来的分类下拉,不重复给分面 |
|
||||
| 排序:大小 | 已有(排序 + 搜索页大小区间);**新增** `size` 分档 | `sizeBytes` | 搜索结果页不显示分档(已有精确区间) |
|
||||
| 排序:做种 | 已有(排序);**新增** `seeders` 分档 | `seeders` | 「无人做种」可直接排除死种 |
|
||||
| 规则 SPECSUB 特效字幕 | 不做 | — | 解析器没有可靠标记,标题里很少写 |
|
||||
| 规则 GZ 官种 | 不做 | — | 需要站点官组表(MP 从站点配置取),本工具还没有这份数据 |
|
||||
| 规则 3D | 不做 | — | 解析器只把 3D 当停止词,没有字段;3D 资源极少 |
|
||||
| 规则 UHD | 不做单独维度 | `uhd` | 等价于「2160p」+「原盘 / Remux」的组合,用现有维度即可 |
|
||||
| 规则 BITRATE / HIRES / LOSSLESS(音乐) | 不做 | — | 音乐资源不在本工具范围 |
|
||||
| (MP 无)H&R | 已有 `hr` | 列表快照 | 放「更多筛选」 |
|
||||
|
||||
## 从搜索结果下载
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ export type FacetCount = z.infer<typeof FacetCount>;
|
||||
* 快速筛选参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent。
|
||||
* 维度:resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、
|
||||
* releaseType(disc/remux/encode/webdl/hdtv/other)、site(站点 ID)、promotion(free/2x/50%/other/none)、
|
||||
* hdr(DV/HDR10/HDR10+/other/SDR)、hr(yes/no/unknown)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季)。
|
||||
* hdr(DV/HDR10/HDR10+/other/SDR)、hr(yes/no/unknown)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季);
|
||||
* SEARCH-4 起:audioCodec(Atmos/TrueHD/DTS:X/DTS-HD/DTS/DDP/DD/AAC/FLAC/LPCM/other/unknown)、
|
||||
* subtitle(zh/zh-Hans/zh-Hant/en/ja/ko/unknown)、dub(cmn/yue/en/unknown)、feature(10bit/hfr/cut)、
|
||||
* year(年份)、category(站点分类名)、size(lt1/1-5/5-10/10-20/20-50/50+/unknown,GiB)、seeders(0/1-9/10-49/50+/unknown)。
|
||||
*/
|
||||
export const QuickFilterParam = z.string().max(4000).optional();
|
||||
|
||||
@@ -31,5 +34,19 @@ export const ReleaseQuickFacets = z.object({
|
||||
/** 没有剧集资源时为空 */
|
||||
season: z.array(FacetCount),
|
||||
episode: z.array(FacetCount),
|
||||
/** 音频编码(一条可属于多个;没写的归「未标注」) */
|
||||
audioCodec: z.array(FacetCount),
|
||||
/** 字幕:任何中文字幕都算 zh,简 / 繁另列 */
|
||||
subtitle: z.array(FacetCount),
|
||||
/** 配音:国语 / 粤语(MP 的 CNVOI / HKVOI) */
|
||||
dub: z.array(FacetCount),
|
||||
/** 特性:10bit / 50 帧以上 / 特别剪辑版;没有的不计入任何档 */
|
||||
feature: z.array(FacetCount),
|
||||
/** 年份,新的在前 */
|
||||
year: z.array(FacetCount),
|
||||
/** 站点分类名,计数最多的前 20 个 + 已选的 */
|
||||
category: z.array(FacetCount),
|
||||
size: z.array(FacetCount),
|
||||
seeders: z.array(FacetCount),
|
||||
});
|
||||
export type ReleaseQuickFacets = z.infer<typeof ReleaseQuickFacets>;
|
||||
@@ -79,6 +79,16 @@ export const SearchReleaseParsed = z.object({
|
||||
videoCodec: z.string().nullable(),
|
||||
hdr: z.array(z.string()),
|
||||
releaseGroup: z.string().nullable(),
|
||||
/** 音轨(`TrueHD`、`Atmos 7.1`…,SEARCH-4 快速筛选用);空 = 标题没写 */
|
||||
audio: z.array(z.string()).optional(),
|
||||
/** 字幕语言 zh / zh-Hans / zh-Hant / en / ja / ko;null = 标题没写 */
|
||||
subtitles: z.array(z.string()).nullable().optional(),
|
||||
/** 配音语言 cmn 国语 / yue 粤语 / en 英语;null = 标题没写 */
|
||||
audioLangs: z.array(z.string()).nullable().optional(),
|
||||
bitDepth: z.number().int().nullable().optional(),
|
||||
fps: z.number().nullable().optional(),
|
||||
/** 剪辑版(加长版、导演剪辑版…) */
|
||||
cut: z.string().nullable().optional(),
|
||||
});
|
||||
export type SearchReleaseParsed = z.infer<typeof SearchReleaseParsed>;
|
||||
|
||||
|
||||
@@ -16,6 +16,15 @@ export const FACET_DIMS = [
|
||||
"releaseGroup",
|
||||
"season",
|
||||
"episode",
|
||||
// SEARCH-4:对齐 MP 过滤规则里的音频 / 中字 / 国语粤语配音 / 60 帧,再加年份、分类、大小、做种
|
||||
"audioCodec",
|
||||
"subtitle",
|
||||
"dub",
|
||||
"feature",
|
||||
"year",
|
||||
"category",
|
||||
"size",
|
||||
"seeders",
|
||||
] as const;
|
||||
export type FacetDim = (typeof FACET_DIMS)[number];
|
||||
|
||||
@@ -49,6 +58,21 @@ export interface FacetInput {
|
||||
};
|
||||
/** H&R 三态;缺省 / null 按未知 */
|
||||
hr?: boolean | "unknown" | null;
|
||||
/** 解析出的音轨(`TrueHD`、`Atmos 7.1`、`DDP 5.1`…);空 = 标题没写 */
|
||||
audio?: readonly string[];
|
||||
/** 字幕语言(zh / zh-Hans / zh-Hant / en / ja / ko);缺省 / 空 = 标题没写 */
|
||||
subtitles?: readonly string[] | null;
|
||||
/** 配音语言(cmn / yue / en);缺省 / 空 = 标题没写 */
|
||||
audioLangs?: readonly string[] | null;
|
||||
bitDepth?: number | null;
|
||||
fps?: number | null;
|
||||
/** 剪辑版(加长版、导演剪辑版、IMAX…) */
|
||||
cut?: string | null;
|
||||
year?: number | null;
|
||||
/** 站点分类名 */
|
||||
category?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
seeders?: number | null;
|
||||
}
|
||||
|
||||
/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */
|
||||
@@ -92,10 +116,62 @@ const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; h
|
||||
{ value: "no", label: "无" },
|
||||
{ value: "unknown", label: "未知", hideEmpty: true },
|
||||
],
|
||||
audioCodec: [
|
||||
{ value: "Atmos", label: "Atmos" },
|
||||
{ value: "TrueHD", label: "TrueHD" },
|
||||
{ value: "DTS:X", label: "DTS:X", hideEmpty: true },
|
||||
{ value: "DTS-HD", label: "DTS-HD MA" },
|
||||
{ value: "DTS", label: "DTS" },
|
||||
{ value: "DDP", label: "DD+" },
|
||||
{ value: "DD", label: "DD" },
|
||||
{ value: "AAC", label: "AAC" },
|
||||
{ value: "FLAC", label: "FLAC", hideEmpty: true },
|
||||
{ value: "LPCM", label: "LPCM", hideEmpty: true },
|
||||
{ value: "other", label: "其他", hideEmpty: true },
|
||||
{ value: "unknown", label: "未标注", hideEmpty: true },
|
||||
],
|
||||
subtitle: [
|
||||
{ value: "zh", label: "中文字幕" },
|
||||
{ value: "zh-Hans", label: "简体" },
|
||||
{ value: "zh-Hant", label: "繁体" },
|
||||
{ value: "en", label: "英文" },
|
||||
{ value: "ja", label: "日文", hideEmpty: true },
|
||||
{ value: "ko", label: "韩文", hideEmpty: true },
|
||||
{ value: "unknown", label: "未标注", hideEmpty: true },
|
||||
],
|
||||
dub: [
|
||||
{ value: "cmn", label: "国语" },
|
||||
{ value: "yue", label: "粤语" },
|
||||
{ value: "en", label: "英语", hideEmpty: true },
|
||||
{ value: "unknown", label: "未标注", hideEmpty: true },
|
||||
],
|
||||
feature: [
|
||||
{ value: "10bit", label: "10bit", hideEmpty: true },
|
||||
{ value: "hfr", label: "50 帧以上", hideEmpty: true },
|
||||
{ value: "cut", label: "特别剪辑版", hideEmpty: true },
|
||||
],
|
||||
size: [
|
||||
{ value: "lt1", label: "1 GB 以下" },
|
||||
{ value: "1-5", label: "1–5 GB" },
|
||||
{ value: "5-10", label: "5–10 GB" },
|
||||
{ value: "10-20", label: "10–20 GB" },
|
||||
{ value: "20-50", label: "20–50 GB" },
|
||||
{ value: "50+", label: "50 GB 以上" },
|
||||
{ value: "unknown", label: "未知", hideEmpty: true },
|
||||
],
|
||||
seeders: [
|
||||
{ value: "0", label: "无人做种" },
|
||||
{ value: "1-9", label: "1–9" },
|
||||
{ value: "10-49", label: "10–49" },
|
||||
{ value: "50+", label: "50 以上" },
|
||||
{ value: "unknown", label: "未知", hideEmpty: true },
|
||||
],
|
||||
};
|
||||
|
||||
/** 制作组只列计数最多的前 N 个(已选的总会列出) */
|
||||
export const RELEASE_GROUP_TOP = 20;
|
||||
/** 分类只列计数最多的前 N 个(已选的总会列出) */
|
||||
export const CATEGORY_TOP = 20;
|
||||
/** 一条资源最多展开多少集(防止异常集号范围撑爆计数) */
|
||||
const MAX_EPISODES_PER_RELEASE = 200;
|
||||
const MAX_SEASONS_PER_RELEASE = 50;
|
||||
@@ -155,6 +231,71 @@ export function hrBucket(hr: FacetInput["hr"]): string {
|
||||
return hr === true ? "yes" : hr === false ? "no" : "unknown";
|
||||
}
|
||||
|
||||
const AUDIO_BUCKET: Record<string, string> = {
|
||||
Atmos: "Atmos",
|
||||
TrueHD: "TrueHD",
|
||||
"DTS:X": "DTS:X",
|
||||
"DTS-HD MA": "DTS-HD",
|
||||
"DTS-HD HR": "DTS-HD",
|
||||
"DTS-HD": "DTS-HD",
|
||||
DTS: "DTS",
|
||||
DDP: "DDP",
|
||||
"E-AC3": "DDP",
|
||||
DD: "DD",
|
||||
AC3: "DD",
|
||||
AAC: "AAC",
|
||||
FLAC: "FLAC",
|
||||
LPCM: "LPCM",
|
||||
PCM: "LPCM",
|
||||
};
|
||||
|
||||
/** 音频编码档位(一条可有多条音轨);声道(`5.1`)、音轨数(`2Audios`)不算编码 */
|
||||
export function audioBuckets(audio: readonly string[] | undefined): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const a of audio ?? []) {
|
||||
const codec = a.replace(/\s*\d\.\d$/, "").trim();
|
||||
if (!codec || /^\d+Audios?$/i.test(codec)) continue;
|
||||
out.add(AUDIO_BUCKET[codec] ?? "other");
|
||||
}
|
||||
return out.size ? [...out] : ["unknown"];
|
||||
}
|
||||
|
||||
/** 字幕档位:任何中文字幕都算「中文字幕」,简 / 繁另列 */
|
||||
export function subtitleBuckets(subs: readonly string[] | null | undefined): string[] {
|
||||
if (!subs?.length) return ["unknown"];
|
||||
const out = new Set<string>();
|
||||
for (const s of subs) {
|
||||
if (s.startsWith("zh")) out.add("zh");
|
||||
if (s === "zh-Hans" || s === "zh-Hant" || s === "en" || s === "ja" || s === "ko") out.add(s);
|
||||
}
|
||||
return out.size ? [...out] : ["unknown"];
|
||||
}
|
||||
|
||||
export function dubBuckets(langs: readonly string[] | null | undefined): string[] {
|
||||
const out = (langs ?? []).filter((l) => l === "cmn" || l === "yue" || l === "en");
|
||||
return out.length ? [...new Set(out)] : ["unknown"];
|
||||
}
|
||||
|
||||
export function featureBuckets(r: Pick<FacetInput, "bitDepth" | "fps" | "cut">): string[] {
|
||||
const out: string[] = [];
|
||||
if (r.bitDepth && r.bitDepth >= 10) out.push("10bit");
|
||||
if (r.fps && r.fps >= 50) out.push("hfr");
|
||||
if (r.cut) out.push("cut");
|
||||
return out;
|
||||
}
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
export function sizeBucket(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return "unknown";
|
||||
const g = bytes / GIB;
|
||||
return g < 1 ? "lt1" : g < 5 ? "1-5" : g < 10 ? "5-10" : g < 20 ? "10-20" : g < 50 ? "20-50" : "50+";
|
||||
}
|
||||
|
||||
export function seedersBucket(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || !Number.isFinite(n) || n < 0) return "unknown";
|
||||
return n === 0 ? "0" : n < 10 ? "1-9" : n < 50 ? "10-49" : "50+";
|
||||
}
|
||||
|
||||
function seasonValues(r: FacetInput): string[] {
|
||||
if (r.season === null || r.season === undefined) return [];
|
||||
const end =
|
||||
@@ -188,6 +329,14 @@ export function facetValues(r: FacetInput, now: number): Record<FacetDim, string
|
||||
releaseGroup: r.releaseGroup ? [r.releaseGroup] : [],
|
||||
season: seasonValues(r),
|
||||
episode: episodeValues(r),
|
||||
audioCodec: audioBuckets(r.audio),
|
||||
subtitle: subtitleBuckets(r.subtitles),
|
||||
dub: dubBuckets(r.audioLangs),
|
||||
feature: featureBuckets(r),
|
||||
year: r.year ? [String(r.year)] : [],
|
||||
category: r.category?.trim() ? [r.category.trim()] : [],
|
||||
size: [sizeBucket(r.sizeBytes)],
|
||||
seeders: [seedersBucket(r.seeders)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,9 +439,11 @@ function sortDynamic(
|
||||
const n = (v: string) => (v === "pack" ? -1 : Number(v));
|
||||
return options.sort((a, b) => n(a.value) - n(b.value)).map(strip);
|
||||
}
|
||||
// 年份新的在前
|
||||
if (dim === "year") return options.sort((a, b) => Number(b.value) - Number(a.value)).map(strip);
|
||||
const sorted = options.sort((a, b) => b.total - a.total || a.label.localeCompare(b.label));
|
||||
if (dim !== "releaseGroup") return sorted.map(strip);
|
||||
const head = sorted.slice(0, top);
|
||||
if (dim !== "releaseGroup" && dim !== "category") return sorted.map(strip);
|
||||
const head = sorted.slice(0, dim === "category" ? CATEGORY_TOP : top);
|
||||
const extra = sorted.slice(top).filter((o) => chosen.has(norm(dim, o.value)));
|
||||
return [...head, ...extra].map(strip);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { cnToInt } from "./cn-number.ts";
|
||||
export { evalEpisodeOffset, normalizeOffsetExpression, OffsetExpressionError } from "./offset.ts";
|
||||
export { type CnMarkers, confidenceOf, fileKindOf, parseRelease, scanCnMarkers } from "./parse.ts";
|
||||
export { type CnMarkers, confidenceOf, fileKindOf, parseRelease, scanAudioLangs, scanCnMarkers } from "./parse.ts";
|
||||
export { mergeParsed, type PathSegments, parseReleasePath, splitReleasePath } from "./path.ts";
|
||||
export { buildGroupRegex, RELEASE_GROUP_SEED } from "./release-groups.ts";
|
||||
export * from "./types.ts";
|
||||
|
||||
@@ -188,6 +188,7 @@ export interface CnMarkers {
|
||||
contentType?: ContentType;
|
||||
variantTags: string[];
|
||||
subtitles?: string[];
|
||||
audioLangs?: string[];
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
@@ -204,6 +205,18 @@ const SUB_KEYWORDS =
|
||||
/字幕|中字|双字|雙字|内封|內封|内嵌|內嵌|外挂|外掛|简繁|簡繁|简中|繁中|简体|简日|繁日|繁体|繁體|簡體|简英|繁英|中英|双语|雙語/u;
|
||||
const AUDIO_LANG_RE = /国粤双语|國粵雙語|国英双语|國英雙語|国语配音|國語配音|国配|國配|国语|國語|粤语|粵語/gu;
|
||||
|
||||
/** 配音语言标记(国语 / 粤语 / 国英双语…)→ cmn / yue / en */
|
||||
export function scanAudioLangs(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of text.matchAll(AUDIO_LANG_RE)) {
|
||||
const t = m[0];
|
||||
if (/[国國]/u.test(t) && !out.includes("cmn")) out.push("cmn");
|
||||
if (/[粤粵]/u.test(t) && !out.includes("yue")) out.push("yue");
|
||||
if (/英/u.test(t) && !out.includes("en")) out.push("en");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scanSubtitleLangs(run: string): string[] {
|
||||
const langs: string[] = [];
|
||||
const add = (l: string) => {
|
||||
@@ -261,6 +274,11 @@ export function scanCnMarkers(input: string): { s: string } & CnMarkers {
|
||||
out.totalEpisodes = cnToInt((m[1] ?? m[2])!);
|
||||
out.evidence.push("total:cn");
|
||||
});
|
||||
const dubs = scanAudioLangs(s);
|
||||
if (dubs.length) {
|
||||
out.audioLangs = dubs;
|
||||
out.evidence.push("audio_lang:cn");
|
||||
}
|
||||
s = s.replace(AUDIO_LANG_RE, " ");
|
||||
for (const [re, ct, ev] of CONTENT_RULES) {
|
||||
take(re, () => {
|
||||
@@ -1097,6 +1115,10 @@ function applyCnMarkers(d: Draft, cm: CnMarkers, prefix: string): void {
|
||||
d.subtitles = [...new Set([...(d.subtitles ?? []), ...cm.subtitles])];
|
||||
note("subtitle");
|
||||
}
|
||||
if (cm.audioLangs) {
|
||||
d.audioLangs = [...new Set([...(d.audioLangs ?? []), ...cm.audioLangs])];
|
||||
note("audio_lang");
|
||||
}
|
||||
}
|
||||
|
||||
function finalize(d: Draft, opts: ParseOptions): ParsedRelease {
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface ParsedRelease {
|
||||
part?: string;
|
||||
/** 字幕语言(zh / zh-Hans / zh-Hant / en / ja / ko);undefined = 标题无信息(未知),不是"无字幕" */
|
||||
subtitles?: string[];
|
||||
/** 配音语言(cmn 国语 / yue 粤语 / en 英语,来自「国语」「国粤双语」等标记);undefined = 标题无信息 */
|
||||
audioLangs?: string[];
|
||||
sizeBytes?: number;
|
||||
contentType: ContentType;
|
||||
segment?: Segment;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyFacets,
|
||||
audioBuckets,
|
||||
type FacetInput,
|
||||
formatFacetParam,
|
||||
parseFacetParam,
|
||||
parseRelease,
|
||||
promotionBuckets,
|
||||
releaseTypeBucket,
|
||||
seedersBucket,
|
||||
sizeBucket,
|
||||
subtitleBuckets,
|
||||
} from "../src/index.ts";
|
||||
|
||||
// SEARCH-3 快速筛选:档位、计数(按其它维度已选条件)、同维度 OR / 跨维度 AND、qf 编解码
|
||||
@@ -186,4 +191,115 @@ describe("release-facets", () => {
|
||||
expect(parseFacetParam(s)).toEqual(sel);
|
||||
expect(parseFacetParam("bogus:1;hdr:;hdr:DV,DV")).toEqual({ hdr: ["DV"] });
|
||||
});
|
||||
|
||||
// ── SEARCH-4:对齐 MP 后新增的维度 ──
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const rich = [
|
||||
mk("a", {
|
||||
audio: ["TrueHD", "Atmos 7.1"],
|
||||
subtitles: ["zh-Hans", "en"],
|
||||
audioLangs: ["cmn"],
|
||||
bitDepth: 10,
|
||||
year: 2024,
|
||||
category: "电影",
|
||||
sizeBytes: 60 * GB,
|
||||
seeders: 80,
|
||||
}),
|
||||
mk("b", {
|
||||
audio: ["DDP 5.1"],
|
||||
subtitles: ["zh-Hant"],
|
||||
audioLangs: ["cmn", "yue"],
|
||||
fps: 60,
|
||||
year: 2023,
|
||||
category: "电影",
|
||||
sizeBytes: 8 * GB,
|
||||
seeders: 0,
|
||||
}),
|
||||
mk("c", { audio: ["DTS-HD MA 5.1", "2Audios"], year: 2024, category: "剧集", sizeBytes: 3 * GB, seeders: 12 }),
|
||||
mk("d", { audio: [], cut: "Extended", sizeBytes: null, seeders: null }),
|
||||
];
|
||||
const run2 = (sel: Parameters<typeof applyFacets>[2]) => applyFacets(rich, (x) => x, sel, { now: NOW });
|
||||
|
||||
it("新维度档位与计数:音频 / 字幕 / 配音 / 特性 / 年份 / 分类 / 大小 / 做种", () => {
|
||||
const r = run2({});
|
||||
expect(counts(r.facets.audioCodec)).toEqual({
|
||||
Atmos: 1,
|
||||
TrueHD: 1,
|
||||
"DTS-HD": 1,
|
||||
DTS: 0,
|
||||
DDP: 1,
|
||||
DD: 0,
|
||||
AAC: 0,
|
||||
unknown: 1,
|
||||
});
|
||||
expect(r.facets.audioCodec.find((o) => o.value === "DDP")?.label).toBe("DD+");
|
||||
expect(counts(r.facets.subtitle)).toEqual({ zh: 2, "zh-Hans": 1, "zh-Hant": 1, en: 1, unknown: 2 });
|
||||
expect(counts(r.facets.dub)).toEqual({ cmn: 2, yue: 1, unknown: 2 });
|
||||
expect(counts(r.facets.feature)).toEqual({ "10bit": 1, hfr: 1, cut: 1 });
|
||||
// 年份新的在前;分类按条数
|
||||
expect(r.facets.year.map((o) => [o.value, o.count])).toEqual([
|
||||
["2024", 2],
|
||||
["2023", 1],
|
||||
]);
|
||||
expect(r.facets.category.map((o) => [o.label, o.count])).toEqual([
|
||||
["电影", 2],
|
||||
["剧集", 1],
|
||||
]);
|
||||
expect(counts(r.facets.size)).toEqual({
|
||||
lt1: 0,
|
||||
"1-5": 1,
|
||||
"5-10": 1,
|
||||
"10-20": 0,
|
||||
"20-50": 0,
|
||||
"50+": 1,
|
||||
unknown: 1,
|
||||
});
|
||||
expect(counts(r.facets.seeders)).toEqual({ "0": 1, "1-9": 0, "10-49": 1, "50+": 1, unknown: 1 });
|
||||
});
|
||||
|
||||
it("新维度组合:同维度 OR、跨维度 AND,计数按其它维度已选条件", () => {
|
||||
// 中文字幕 AND 国语 → a、b;再加 Atmos → 只剩 a
|
||||
expect(ids(run2({ subtitle: ["zh"], dub: ["cmn"] }))).toEqual(["a", "b"]);
|
||||
const r = run2({ subtitle: ["zh"], dub: ["cmn"], audioCodec: ["Atmos"] });
|
||||
expect(ids(r)).toEqual(["a"]);
|
||||
// 音频维度自己的计数不受已选 Atmos 影响:DD+ 仍可多出 b
|
||||
expect(counts(r.facets.audioCodec).DDP).toBe(1);
|
||||
// 同维度 OR:Atmos 或 DTS-HD
|
||||
expect(ids(run2({ audioCodec: ["Atmos", "DTS-HD"] }))).toEqual(["a", "c"]);
|
||||
// 年份 × 分类 × 做种
|
||||
expect(ids(run2({ year: ["2024"], category: ["电影", "剧集"], seeders: ["10-49", "50+"] }))).toEqual(["a", "c"]);
|
||||
expect(ids(run2({ size: ["unknown"], feature: ["cut"] }))).toEqual(["d"]);
|
||||
// 已选但当前没有资源的年份列出、计数 0
|
||||
expect(run2({ year: ["1999"] }).facets.year.find((o) => o.value === "1999")).toEqual({
|
||||
value: "1999",
|
||||
label: "1999",
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("新维度的归档函数", () => {
|
||||
expect(audioBuckets(["E-AC3", "AC3 2.0", "PCM", "Opus", "5.1"])).toEqual(["DDP", "DD", "LPCM", "other"]);
|
||||
expect(audioBuckets(["2Audios"])).toEqual(["unknown"]);
|
||||
expect(subtitleBuckets(["zh"])).toEqual(["zh"]);
|
||||
expect(subtitleBuckets(null)).toEqual(["unknown"]);
|
||||
expect([0.5 * GB, GB, 49.9 * GB, 50 * GB, null, -1].map(sizeBucket)).toEqual([
|
||||
"lt1",
|
||||
"1-5",
|
||||
"20-50",
|
||||
"50+",
|
||||
"unknown",
|
||||
"unknown",
|
||||
]);
|
||||
expect([0, 9, 10, 50, null].map(seedersBucket)).toEqual(["0", "1-9", "10-49", "50+", "unknown"]);
|
||||
});
|
||||
|
||||
it("解析器补齐配音语言(MP 的 CNVOI / HKVOI)", () => {
|
||||
expect(parseRelease("Movie 2020 1080p 国语中字 WEB-DL").audioLangs).toEqual(["cmn"]);
|
||||
expect(parseRelease("某剧 第01集 国粤双语 1080p").audioLangs).toEqual(["cmn", "yue"]);
|
||||
expect(parseRelease("Movie.2020.1080p.BluRay.x264-GRP").audioLangs).toBeUndefined();
|
||||
expect(
|
||||
parseRelease("Movie.2020.2160p.BluRay.x265-GRP", { subtitle: "电影 | 国英双语 简繁字幕" }).audioLangs,
|
||||
).toEqual(["cmn", "en"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user