fix(sites): HHClub parsing, resource list layout and request restraint
HHClub (憨憨) was checked read-only against a real account (one detail
request; list, user data and health came from the product's own fetch).
Parsing fixes:
- list titles carried the site's new-torrent marker "[新]" (58 of 100
rows; the detail page title has none); stripped with a replace filter
- refreshing a detail overwrote seeders / leechers / snatched / category
name / H&R with null because the HHClub div detail page lacks them;
detail values now fall back to the list snapshot (mergeDetailOverList),
and an unknown detail promotion keeps the list promotion
- level "贵宾" is VIP (umt.vipLevels)
- sanitized minimal fixture HHANCLUB_LIST_PAGE_2026 + tests; README marks
HHClub as verified 2026-09-29
Size, counts, category ids/names, +08:00 time and subtitle were correct.
Request restraint:
- site definitions may carry umt.defaults; merged with settings.sites
(longer intervals, fewer pages): new settings defaultSnapshotMaxPages 1,
defaultHealthCheckIntervalSec 6 h, defaultDetailCacheSec 24 h; default
burst 2. HHClub: snapshot 60 min page 1 only, health 6 h, search
concurrency 1, detail cache 24 h, 1 token / 3 s burst 2
- periodic / manual snapshots are capped to that page count; scheduled
health checks skip sites checked within the interval; detail refresh is
served from cache inside the window; live searches on one site queue
- every rate-limiter token is counted per site per hour
(site_request_count, migration 0046); GET /sites/{id}/request-stats;
site overview and resource list show requests in the last 24 hours
Resource list: two-layer rows. Main line: title + spec tags (episodes,
resolution, source, codec, HDR, group, 国语/中字, site tags) + promotion
with time left + H&R. Second line: subtitle (one line, ellipsis) and
fixed-width category / size / seed-leech-snatched / upload time
(relative, absolute on hover) columns, stale badge, inventory overlay;
folds into a card on phones.
Also aligns identity_candidate.normalized_title order in db types with
the migrations (schema drift test).
Takes effect after the stack is rebuilt (migration 0046).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
20b63db254
commit
901b01d1a1
43 files changed
+1222
-1315
No files matched your search
@@ -41,6 +41,97 @@ export function promotionLabel(r: Pick<SourceRelease, "promotion" | "promotionEx
|
||||
export const isFree = (r: Pick<SourceRelease, "promotion">) =>
|
||||
r.promotion.kind === "free" || r.promotion.kind === "2xfree";
|
||||
|
||||
/** 完成数(站点 raw._site.snatched);没有时 null */
|
||||
export function snatchedOf(r: Pick<SourceRelease, "raw">): number | null {
|
||||
const v = siteExtra(r).snatched;
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
/** 促销剩余时间:"剩 3 小时";没有截止时间或已过期返回 null */
|
||||
export function remainingLabel(iso: string | null | undefined, now = Date.now()): string | null {
|
||||
if (!iso) return null;
|
||||
const left = new Date(iso).getTime() - now;
|
||||
if (!Number.isFinite(left) || left <= 0) return null;
|
||||
if (left < 3_600_000) return `剩 ${Math.max(1, Math.round(left / 60_000))} 分钟`;
|
||||
if (left < 2 * 86_400_000) return `剩 ${Math.round(left / 3_600_000)} 小时`;
|
||||
return `剩 ${Math.round(left / 86_400_000)} 天`;
|
||||
}
|
||||
|
||||
/** 绝对时间(本地时区,精确到分钟),用在悬停提示 */
|
||||
export function absTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
type ParsedLike = {
|
||||
resolution?: string;
|
||||
source?: string;
|
||||
videoCodec?: string;
|
||||
hdr?: string[];
|
||||
releaseGroup?: { name?: string };
|
||||
subtitles?: string[];
|
||||
season?: number;
|
||||
seasonEnd?: number;
|
||||
seasonInferred?: boolean;
|
||||
episodes?: { start: number; end: number };
|
||||
totalEpisodes?: number;
|
||||
isSeasonPack?: boolean;
|
||||
};
|
||||
|
||||
export type SpecTagKind = "episode" | "resolution" | "source" | "codec" | "hdr" | "group" | "lang" | "site";
|
||||
export interface SpecTag {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: SpecTagKind;
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* 资源规格标签:季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字,再加站点自带标签(tagsRaw)。
|
||||
* 都来自标题解析结果与标题、副标题原文,不猜;同名去重。
|
||||
*/
|
||||
export function releaseSpecTags(r: Pick<SourceRelease, "parsed" | "title" | "subtitle" | "tagsRaw">): SpecTag[] {
|
||||
const p = (r.parsed ?? {}) as ParsedLike;
|
||||
const out: SpecTag[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (kind: SpecTagKind, label: string | undefined | null) => {
|
||||
const l = label?.trim();
|
||||
if (!l || seen.has(l.toLowerCase())) return;
|
||||
seen.add(l.toLowerCase());
|
||||
out.push({ key: `${kind}:${l}`, label: l, kind });
|
||||
};
|
||||
// 季号是推定的(标题里没写)就不显示季,只显示集
|
||||
const season =
|
||||
p.season !== undefined && !p.seasonInferred
|
||||
? `S${pad2(p.season)}${p.seasonEnd && p.seasonEnd !== p.season ? `-S${pad2(p.seasonEnd)}` : ""}`
|
||||
: "";
|
||||
const episode = p.episodes
|
||||
? `E${pad2(p.episodes.start)}${p.episodes.end !== p.episodes.start ? `-E${pad2(p.episodes.end)}` : ""}`
|
||||
: "";
|
||||
const total = !episode && p.totalEpisodes ? `全 ${p.totalEpisodes} 集` : "";
|
||||
add("episode", [`${season}${episode}`, total].filter(Boolean).join(" "));
|
||||
add("resolution", p.resolution === "2160p" ? "4K" : p.resolution);
|
||||
add("source", p.source);
|
||||
add("codec", p.videoCodec);
|
||||
for (const h of p.hdr ?? []) add("hdr", h);
|
||||
add("group", p.releaseGroup?.name);
|
||||
const text = `${r.title} ${r.subtitle ?? ""}`;
|
||||
if (/国语|國語|国配|普通话|中配/.test(text)) add("lang", "国语");
|
||||
if (/粤语|粵語/.test(text)) add("lang", "粤语");
|
||||
if (
|
||||
/中字|中文字幕|简中|繁中|简繁|簡繁|双语字幕|雙語字幕|内嵌中|内封中/.test(text) ||
|
||||
(p.subtitles ?? []).some((l) => l.startsWith("zh"))
|
||||
) {
|
||||
add("lang", "中字");
|
||||
}
|
||||
for (const t of r.tagsRaw ?? []) add("site", t);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 相对时间(中文);未来时间显示"x 后" */
|
||||
export function relTime(iso: string | null | undefined, now = Date.now()): string {
|
||||
if (!iso) return "—";
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, Clock } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Check } from "lucide-react";
|
||||
import { inventoryFlags } from "~/api/inventory.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { categoryLabel, hrOf, ORIGIN_LABEL, promotionLabel, relTime } from "./release-fields.ts";
|
||||
import {
|
||||
absTime,
|
||||
categoryLabel,
|
||||
hrOf,
|
||||
ORIGIN_LABEL,
|
||||
promotionLabel,
|
||||
releaseSpecTags,
|
||||
relTime,
|
||||
remainingLabel,
|
||||
snatchedOf,
|
||||
} from "./release-fields.ts";
|
||||
|
||||
/** 促销 / H&R 徽章(列表与详情共用) */
|
||||
/** 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明 */
|
||||
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
const promo = promotionLabel(r);
|
||||
const hr = hrOf(r);
|
||||
const left = promo && !promo.expired ? remainingLabel(r.promotionExpiresAt) : null;
|
||||
return (
|
||||
<>
|
||||
{promo ? (
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}>
|
||||
{promo.label}
|
||||
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
|
||||
</StatusBadge>
|
||||
<span title={r.promotionExpiresAt ? `促销截止 ${absTime(r.promotionExpiresAt)}` : undefined}>
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "warning"}>
|
||||
{promo.label}
|
||||
{promo.expired ? "(已过期)" : left ? ` · ${left}` : ""}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
) : null}
|
||||
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||||
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||||
@@ -23,6 +36,40 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 规格标签(季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字 / 站点标签):中性样式,不占状态色 */
|
||||
export function SpecTags({ r }: { r: Pick<WorkbenchRelease, "parsed" | "title" | "subtitle" | "tagsRaw"> }) {
|
||||
const tags = releaseSpecTags(r);
|
||||
if (!tags.length) return null;
|
||||
return (
|
||||
<>
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t.key}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
|
||||
t.kind === "resolution" || t.kind === "hdr"
|
||||
? "border-border text-foreground"
|
||||
: "border-border/70 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 没有关联作品时的说明(明确写出,不猜) */
|
||||
export function unresolvedLabel(release: Pick<WorkbenchRelease, "resolutionStatus">): string {
|
||||
return release.resolutionStatus === "ambiguous"
|
||||
? "识别有歧义"
|
||||
: release.resolutionStatus === "unidentifiable"
|
||||
? "无法识别"
|
||||
: release.resolutionStatus === "rejected"
|
||||
? "已排除"
|
||||
: "未识别到本地作品";
|
||||
}
|
||||
|
||||
/** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */
|
||||
export function InventoryOverlay({
|
||||
release,
|
||||
@@ -33,17 +80,7 @@ export function InventoryOverlay({
|
||||
entry: InventoryEntry | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (!release.entity) {
|
||||
const label =
|
||||
release.resolutionStatus === "ambiguous"
|
||||
? "识别有歧义"
|
||||
: release.resolutionStatus === "unidentifiable"
|
||||
? "无法识别"
|
||||
: release.resolutionStatus === "rejected"
|
||||
? "已排除"
|
||||
: "未识别到本地作品";
|
||||
return <span className="text-meta text-muted-foreground">{label}</span>;
|
||||
}
|
||||
if (!release.entity) return <span className="text-meta text-muted-foreground">{unresolvedLabel(release)}</span>;
|
||||
const flags = inventoryFlags(entry, release.resolutionStatus, release.id);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
@@ -68,6 +105,11 @@ export function InventoryOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源列表行,两层:
|
||||
* 主行 = 标题 + 规格标签 + 促销 / H&R;副行 = 副标题(单行省略)+ 分类 / 大小 / 做种·下载·完成 / 上传时间 + 库存叠加。
|
||||
* 宽屏副行的数字列定宽右对齐,上下行对得齐;窄屏整行折成卡片,数字换到副标题下面。
|
||||
*/
|
||||
export function ReleaseRow({
|
||||
r,
|
||||
entry,
|
||||
@@ -86,6 +128,11 @@ export function ReleaseRow({
|
||||
}) {
|
||||
const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore;
|
||||
const cat = categoryLabel(r);
|
||||
const snatched = snatchedOf(r);
|
||||
const timeTitle = [
|
||||
r.sourcePublishedAt ? `上传 ${absTime(r.sourcePublishedAt)}` : "上传时间未知",
|
||||
`${ORIGIN_LABEL[r.origin]}数据 ${relTime(r.dataUpdatedAt)}确认`,
|
||||
].join("\n");
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
@@ -93,37 +140,68 @@ export function ReleaseRow({
|
||||
onClick={onOpen}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"block w-full min-w-0 cursor-pointer px-4 py-3 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
"group block w-full min-w-0 cursor-pointer px-4 py-2.5 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
selected && "bg-foreground/[0.05]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<ReleaseBadges r={r} />
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className="min-w-0 text-sm font-medium text-foreground [overflow-wrap:anywhere] group-hover:underline group-hover:underline-offset-2"
|
||||
title={r.title}
|
||||
>
|
||||
{r.title}
|
||||
</span>
|
||||
<SpecTags r={r} />
|
||||
<ReleaseBadges r={r} />
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 flex-col gap-1 text-meta text-muted-foreground sm:flex-row sm:items-center sm:gap-4">
|
||||
<span className="min-w-0 truncate sm:flex-1" title={r.subtitle ?? undefined}>
|
||||
{r.subtitle ?? ""}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 tabular-nums sm:shrink-0 sm:flex-nowrap sm:gap-x-4">
|
||||
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : undefined}>
|
||||
{cat ?? "分类未知"}
|
||||
</span>
|
||||
<span className="sm:w-16 sm:text-right">
|
||||
{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5 sm:w-32" title="做种 / 下载 / 完成">
|
||||
<ArrowUp className="h-3 w-3" aria-label="做种" />
|
||||
<span className={cn(r.seeders === 0 && "text-warning")}>{r.seeders ?? "?"}</span>
|
||||
<ArrowDown className="ml-1.5 h-3 w-3" aria-label="下载" />
|
||||
{r.leechers ?? "?"}
|
||||
<Check className="ml-1.5 h-3 w-3" aria-label="完成" />
|
||||
{snatched ?? "?"}
|
||||
</span>
|
||||
<span className="sm:w-20 sm:text-right" title={timeTitle}>
|
||||
{relTime(r.sourcePublishedAt)}
|
||||
</span>
|
||||
{!r.entity && r.resolutionStatus !== "ambiguous" ? (
|
||||
<span className="sm:w-24 sm:truncate">{unresolvedLabel(r)}</span>
|
||||
) : null}
|
||||
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
||||
</span>
|
||||
</div>
|
||||
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
||||
{cat ? <span>{cat}</span> : null}
|
||||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{r.seeders ?? "?"}
|
||||
<ArrowDown className="ml-1 h-3 w-3" />
|
||||
{r.leechers ?? "?"}
|
||||
</span>
|
||||
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
|
||||
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
|
||||
</span>
|
||||
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<InventoryOverlay release={r} entry={entry} loading={inventoryLoading} />
|
||||
</div>
|
||||
<InventoryLine release={r} entry={entry} loading={inventoryLoading} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 副行末尾的库存叠加:只在识别到作品(或识别有歧义)时占一行,未识别的不重复刷"无法识别" */
|
||||
function InventoryLine({
|
||||
release,
|
||||
entry,
|
||||
loading,
|
||||
}: {
|
||||
release: WorkbenchRelease;
|
||||
entry: InventoryEntry | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (!release.entity && release.resolutionStatus !== "ambiguous") return null;
|
||||
return (
|
||||
<div className="mt-1.5 min-w-0">
|
||||
<InventoryOverlay release={release} entry={entry} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user