diff --git a/CHANGELOG.md b/CHANGELOG.md index 80eedd59..a1e27706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 与图片写进媒体库目录) diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index edf4be3f..8ff26f65 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -295,6 +295,12 @@ function toBase(site: Pick, 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 多选筛选并统计分面 */ diff --git a/apps/server/src/modules/sources-identity/service/workbench.ts b/apps/server/src/modules/sources-identity/service/workbench.ts index f138a282..cce01668 100644 --- a/apps/server/src/modules/sources-identity/service/workbench.ts +++ b/apps/server/src/modules/sources-identity/service/workbench.ts @@ -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): FacetInput { @@ -143,6 +152,16 @@ export function lightFacetInput(r: LightRow, site: Pick) 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`r.promotion`.as("promotion"), "r.promotion_expires_at", sql`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) { diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index 8fe4c90e..b75f2c72 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -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")]; diff --git a/apps/web/app/app.css b/apps/web/app/app.css index 4df6287e..e863b6d3 100644 --- a/apps/web/app/app.css +++ b/apps/web/app/app.css @@ -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); diff --git a/apps/web/app/components/search/quick-filter-bar.tsx b/apps/web/app/components/search/quick-filter-bar.tsx index 43b41235..d1dc7de0 100644 --- a/apps/web/app/components/search/quick-filter-bar.tsx +++ b/apps/web/app/components/search/quick-filter-bar.tsx @@ -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(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 = ( - + setSheet(g) : undefined} /> ); return mobile ? ( chip @@ -132,6 +122,27 @@ export function QuickFilterBar({ ); })} + {moreGroups.length ? ( + mobile ? ( + setMoreOpen(true)} more /> + ) : ( + + + + + + onChange(clearDims(value, moreDims))} + count={moreCount} + /> + + + ) + ) : null} {aside && !mobile ?
{aside.content("inline")}
: null} @@ -172,6 +183,33 @@ export function QuickFilterBar({ ) : null} + {mobile && moreGroups.length ? ( + + + + 更多筛选 + 可多选,数字是选中后的条数 + +
+ +
+ + + + +
+
+ ) : null} + {mobile ? ( !o && setSheet(null)}> @@ -316,3 +354,133 @@ function GroupPanel({ ); } + +/** 筛选条上的入口按钮;Popover 的 asChild 需要转发 ref 与事件,所以透传其余属性 */ +function FilterChip({ + label, + count, + more = false, + className, + ...rest +}: React.ComponentProps<"button"> & { label: string; count: number; more?: boolean }) { + return ( + + ); +} + +/** 「更多筛选」:每个维度一节,选项是带计数的小按钮;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 ( +
+ {bare ? null : ( +
+ 更多筛选 + +
+ )} +
+ {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 ( +
+ + {d.label} + {n ? ( + + ) : null} + +
+ {all.map((o) => { + const on = chosen.includes(o.value); + const dead = o.count === 0 && !on; + return ( + + ); + })} +
+
+ ); + }), + )} +
+
+ ); +} diff --git a/apps/web/app/components/search/quick-filter-state.test.ts b/apps/web/app/components/search/quick-filter-state.test.ts index 69ad1b16..44b51dcc 100644 --- a/apps/web/app/components/search/quick-filter-state.test.ts +++ b/apps/web/app/components/search/quick-filter-state.test.ts @@ -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"); diff --git a/apps/web/app/components/search/quick-filter-state.ts b/apps/web/app/components/search/quick-filter-state.ts index 7c0d5066..38db6280 100644 --- a/apps/web/app/components/search/quick-filter-state.ts +++ b/apps/web/app/components/search/quick-filter-state.ts @@ -7,18 +7,22 @@ import { useCallback, useEffect, useState } from "react"; export type QuickDim = keyof ReleaseQuickFacets; export type QuickSelection = Partial>; -/** 筛选条上的一个入口;「季/集」一个入口里分两节 */ -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 { diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx index cb017f44..d9bd1592 100644 --- a/apps/web/app/components/search/release-results.tsx +++ b/apps/web/app/components/search/release-results.tsx @@ -184,6 +184,8 @@ export function ReleaseResults({ value={quick} onChange={setQuick} onClearAll={clearAll} + // 大小已有精确区间(大小与排序),不再给分档 + hide={["size"]} extraTags={ sizeTag ? [ diff --git a/apps/web/app/components/search/search2.test.ts b/apps/web/app/components/search/search2.test.ts index 86a9fb26..cdac0aca 100644 --- a/apps/web/app/components/search/search2.test.ts +++ b/apps/web/app/components/search/search2.test.ts @@ -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(); }); diff --git a/apps/web/app/routes/console/sites.$siteId.tsx b/apps/web/app/routes/console/sites.$siteId.tsx index 47973f3e..eee65307 100644 --- a/apps/web/app/routes/console/sites.$siteId.tsx +++ b/apps/web/app/routes/console/sites.$siteId.tsx @@ -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({ seeding: "", size: "", age: "" }); + const [local, setLocal] = useState({ 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() { > - setLocal((l) => ({ ...l, seeding: v }))} - options={[ - { value: "alive", label: "有人做种" }, - { value: "hot", label: "≥ 20 做种" }, - ]} - /> - setLocal((l) => ({ ...l, size: v }))} - options={SIZE} - /> {/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */} - + {/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */} + {coverage || refreshFailed ? ( @@ -429,7 +407,7 @@ export default function SiteWorkbenchPage() { } description={ all.length - ? "做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。" + ? "上传时间只筛选已加载的结果。加载更多或放宽条件。" : quickHidden ? "减少快速筛选条件再看。" : coverage?.liveError diff --git a/docs/plan/00-first-version-plan.md b/docs/plan/00-first-version-plan.md index 0ba582cc..be6445d5 100644 --- a/docs/plan/00-first-version-plan.md +++ b/docs/plan/00-first-version-plan.md @@ -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 的对照」。 diff --git a/docs/runbook/search.md b/docs/runbook/search.md index a7f3154e..67036172 100644 --- a/docs/runbook/search.md +++ b/docs/runbook/search.md @@ -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` | 列表快照 | 放「更多筛选」 | ## 从搜索结果下载 diff --git a/packages/contracts/src/release-facets.ts b/packages/contracts/src/release-facets.ts index 7a018afe..a88d7810 100644 --- a/packages/contracts/src/release-facets.ts +++ b/packages/contracts/src/release-facets.ts @@ -12,7 +12,10 @@ export type FacetCount = z.infer; * 快速筛选参数 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; diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index 7de5ebae..4d13884c 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -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; diff --git a/packages/domain/src/release-facets/index.ts b/packages/domain/src/release-facets/index.ts index 4a8c169f..606cdf87 100644 --- a/packages/domain/src/release-facets/index.ts +++ b/packages/domain/src/release-facets/index.ts @@ -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 = { + 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(); + 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(); + 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): 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 (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); } diff --git a/packages/domain/src/release-parser/index.ts b/packages/domain/src/release-parser/index.ts index ad3f3067..9257e722 100644 --- a/packages/domain/src/release-parser/index.ts +++ b/packages/domain/src/release-parser/index.ts @@ -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"; diff --git a/packages/domain/src/release-parser/parse.ts b/packages/domain/src/release-parser/parse.ts index e554f379..5b581531 100644 --- a/packages/domain/src/release-parser/parse.ts +++ b/packages/domain/src/release-parser/parse.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 { diff --git a/packages/domain/src/release-parser/types.ts b/packages/domain/src/release-parser/types.ts index a4f8f398..c6b5a66e 100644 --- a/packages/domain/src/release-parser/types.ts +++ b/packages/domain/src/release-parser/types.ts @@ -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; diff --git a/packages/domain/test/release-facets.test.ts b/packages/domain/test/release-facets.test.ts index d9a72bb5..ff809cf6 100644 --- a/packages/domain/test/release-facets.test.ts +++ b/packages/domain/test/release-facets.test.ts @@ -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[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"]); + }); });