merge: ui2/u-organize-terms
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
19969c9b0b
78 files changed
+855
-345
No files matched your search
@@ -133,7 +133,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "cache.threshold",
|
key: "cache.threshold",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "下载盘检查",
|
name: "下载盘检查",
|
||||||
description: "下载盘超过暂停线时清理已入库的下载",
|
description: "下载盘超过暂停线时清理已整理的下载",
|
||||||
group: "maintain",
|
group: "maintain",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -155,8 +155,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
registerScheduledJob(ctx, {
|
registerScheduledJob(ctx, {
|
||||||
key: "downloads.orchestrate",
|
key: "downloads.orchestrate",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "补提交与补入库",
|
name: "补提交与补整理",
|
||||||
description: "补交没提交成功的下载,补整理下载完但没入库的任务,重新排队卡住的整理",
|
description: "补交没提交成功的下载,补整理下载完但没整理的任务,重新排队卡住的整理",
|
||||||
group: "download",
|
group: "download",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
@@ -165,7 +165,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
const imports = await sweepPendingImports(d);
|
const imports = await sweepPendingImports(d);
|
||||||
const rec = await recoverStalledRecords(d);
|
const rec = await recoverStalledRecords(d);
|
||||||
return (
|
return (
|
||||||
`补提交 ${submitted},补入库 ${imports}` +
|
`补提交 ${submitted},补整理 ${imports}` +
|
||||||
(rec.requeued ? `,重排整理 ${rec.requeued}` : "") +
|
(rec.requeued ? `,重排整理 ${rec.requeued}` : "") +
|
||||||
(rec.failed ? `,整理停止 ${rec.failed}` : "") +
|
(rec.failed ? `,整理停止 ${rec.failed}` : "") +
|
||||||
(rec.removed ? `,移除下载任务 ${rec.removed}` : "")
|
(rec.removed ? `,移除下载任务 ${rec.removed}` : "")
|
||||||
@@ -278,7 +278,7 @@ async function sweepPendingImports(d: AoDeps): Promise<number> {
|
|||||||
);
|
);
|
||||||
if (q) n++;
|
if (q) n++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
d.ctx.logger.warn({ downloadTaskId: r.id, err: { message: errMessage(err) } }, "补入库入队失败");
|
d.ctx.logger.warn({ downloadTaskId: r.id, err: { message: errMessage(err) } }, "补整理入队失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/download-cache/cleanup",
|
path: "/download-cache/cleanup",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
summary: "入队一轮缓存清理(只清已验证入库且满足保留/H&R 的纳管任务);返回任务 id,进度见任务页",
|
summary: "入队一轮缓存清理(只清已核验整理且满足保留/H&R 的纳管任务);返回任务 id,进度见任务页",
|
||||||
body: z.object({ downloaderInstanceId: z.uuid().optional() }),
|
body: z.object({ downloaderInstanceId: z.uuid().optional() }),
|
||||||
response: accepted,
|
response: accepted,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function registerLibraryVersionRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/library/{id}/versions",
|
path: "/library/{id}/versions",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对",
|
summary: "作品的已整理版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对",
|
||||||
params: IdParam,
|
params: IdParam,
|
||||||
query: LibraryVersionsQuery,
|
query: LibraryVersionsQuery,
|
||||||
response: LibraryVersions,
|
response: LibraryVersions,
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
actor: { type: actor?.type === "api_key" ? "api_key" : "user", id: actor?.id ?? null },
|
actor: { type: actor?.type === "api_key" ? "api_key" : "user", id: actor?.id ?? null },
|
||||||
});
|
});
|
||||||
if (!recs.length) throw new ApiException("precondition_failed", "作品没有已入库文件。到文件管理里对目录刮削");
|
if (!recs.length) throw new ApiException("precondition_failed", "作品没有已整理文件。到文件管理里对目录刮削");
|
||||||
return {
|
return {
|
||||||
mediaEntityId: params.id,
|
mediaEntityId: params.id,
|
||||||
taskId: recs[0]?.taskId ?? null,
|
taskId: recs[0]?.taskId ?? null,
|
||||||
|
|||||||
@@ -57,7 +57,31 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
.select((eb) => eb.fn.countAll<string>().as("n")),
|
.select((eb) => eb.fn.countAll<string>().as("n")),
|
||||||
{ ...query, attention: true },
|
{ ...query, attention: true },
|
||||||
).executeTakeFirst();
|
).executeTakeFirst();
|
||||||
|
const facet = async (dim: "action" | "libraryRootId" | "category") => {
|
||||||
|
const col =
|
||||||
|
dim === "action"
|
||||||
|
? sql<string>`action::text`
|
||||||
|
: dim === "libraryRootId"
|
||||||
|
? sql<string>`target_library_root_id::text`
|
||||||
|
: CATEGORY_SQL;
|
||||||
|
const rows = await recordFilters(
|
||||||
|
db()
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select([col.as("value"), (eb) => eb.fn.countAll<string>().as("n")])
|
||||||
|
.groupBy(col)
|
||||||
|
.orderBy(sql`count(*)`, "desc"),
|
||||||
|
{ ...query, [dim]: undefined },
|
||||||
|
).execute();
|
||||||
|
return rows
|
||||||
|
.filter((r: { value: string | null }) => r.value)
|
||||||
|
.map((r: { value: string; n: string }) => ({ value: r.value, n: Number(r.n) }));
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
|
facets: {
|
||||||
|
action: await facet("action"),
|
||||||
|
libraryRootId: await facet("libraryRootId"),
|
||||||
|
category: await facet("category"),
|
||||||
|
},
|
||||||
attention: Number(att?.n ?? 0),
|
attention: Number(att?.n ?? 0),
|
||||||
total: rows.reduce((a, r) => a + Number(r.n), 0),
|
total: rows.reduce((a, r) => a + Number(r.n), 0),
|
||||||
succeeded: n("succeeded"),
|
succeeded: n("succeeded"),
|
||||||
@@ -339,6 +363,9 @@ export function likePattern(input: string): string {
|
|||||||
return /[*?]/.test(input) ? esc.replace(/\*/g, "%").replace(/\?/g, "_") : `%${esc}%`;
|
return /[*?]/.test(input) ? esc.replace(/\*/g, "%").replace(/\?/g, "_") : `%${esc}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 冻结计划里的二级分类 */
|
||||||
|
const CATEGORY_SQL = sql<string>`plan->'classification'->>'category'`;
|
||||||
|
|
||||||
/** 列表 / 统计共用筛选(不含状态) */
|
/** 列表 / 统计共用筛选(不含状态) */
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: 同一张表的 select 与 groupBy 查询共用
|
// biome-ignore lint/suspicious/noExplicitAny: 同一张表的 select 与 groupBy 查询共用
|
||||||
function recordFilters<Q extends SelectQueryBuilder<DB, "organization_record", any>>(
|
function recordFilters<Q extends SelectQueryBuilder<DB, "organization_record", any>>(
|
||||||
@@ -355,6 +382,9 @@ function recordFilters<Q extends SelectQueryBuilder<DB, "organization_record", a
|
|||||||
if (query.since) q = q.where("created_at", ">=", new Date(query.since));
|
if (query.since) q = q.where("created_at", ">=", new Date(query.since));
|
||||||
if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]);
|
if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]);
|
||||||
if (query.attention) q = q.where(attentionSql());
|
if (query.attention) q = q.where(attentionSql());
|
||||||
|
if (query.action) q = q.where("action", "=", query.action);
|
||||||
|
if (query.libraryRootId) q = q.where("target_library_root_id", "=", query.libraryRootId);
|
||||||
|
if (query.category) q = q.where(sql<boolean>`${CATEGORY_SQL} = ${query.category}`);
|
||||||
if (query.kind === "none") q = q.where("media_entity_id", "is", null);
|
if (query.kind === "none") q = q.where("media_entity_id", "is", null);
|
||||||
else if (query.kind)
|
else if (query.kind)
|
||||||
q = q.where("media_entity_id", "in", (eb: ExpressionBuilder<DB, "organization_record">) =>
|
q = q.where("media_entity_id", "in", (eb: ExpressionBuilder<DB, "organization_record">) =>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export async function planManualAcquisition(
|
|||||||
if (!topo?.savePath) throw new ApiException("precondition_failed", "没有可用的下载器或保存目录");
|
if (!topo?.savePath) throw new ApiException("precondition_failed", "没有可用的下载器或保存目录");
|
||||||
const replacementIds = body.importMode === "replace" ? [...new Set(body.replacementAssetIds)] : [];
|
const replacementIds = body.importMode === "replace" ? [...new Set(body.replacementAssetIds)] : [];
|
||||||
if (body.importMode === "replace" && (!entityId || replacementIds.length === 0))
|
if (body.importMode === "replace" && (!entityId || replacementIds.length === 0))
|
||||||
throw new ApiException("bad_request", "洗版入库需要明确作品及要替换的已有版本");
|
throw new ApiException("bad_request", "洗版需要明确作品及要替换的已有版本");
|
||||||
if (replacementIds.length) {
|
if (replacementIds.length) {
|
||||||
const assets = await db
|
const assets = await db
|
||||||
.selectFrom("media_asset")
|
.selectFrom("media_asset")
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ export async function executeRecord(
|
|||||||
if (outcome.replaced?.disposition === "left") {
|
if (outcome.replaced?.disposition === "left") {
|
||||||
await upsertDecision(d, db, {
|
await upsertDecision(d, db, {
|
||||||
kind: "other",
|
kind: "other",
|
||||||
title: "新版本已入库,旧文件未能处置",
|
title: "新版本已整理,旧文件未能处置",
|
||||||
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
|
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
|
||||||
subjectType: "organization_record",
|
subjectType: "organization_record",
|
||||||
subjectId: recordId,
|
subjectId: recordId,
|
||||||
@@ -604,7 +604,7 @@ export async function verifyInPlace(
|
|||||||
): Promise<FileOutcome> {
|
): Promise<FileOutcome> {
|
||||||
const observed = await fsr.proxy.statSafe(path, { root: frozen.rootPath, sentinel: frozen.sentinel });
|
const observed = await fsr.proxy.statSafe(path, { root: frozen.rootPath, sentinel: frozen.sentinel });
|
||||||
if (observed.kind !== "ok" || !observed.facts.isFile || observed.facts.isSymbolicLink) {
|
if (observed.kind !== "ok" || !observed.facts.isFile || observed.facts.isSymbolicLink) {
|
||||||
return { status: "failed", error: { code: "SOURCE_MISSING", message: "原地入库的文件不存在或不可访问" } };
|
return { status: "failed", error: { code: "SOURCE_MISSING", message: "原地整理的文件不存在或不可访问" } };
|
||||||
}
|
}
|
||||||
const planned = frozen.sourceFacts?.[path];
|
const planned = frozen.sourceFacts?.[path];
|
||||||
if (observed.facts.size !== size || (planned && observed.facts.mtimeMs !== planned.mtimeMs)) {
|
if (observed.facts.size !== size || (planned && observed.facts.mtimeMs !== planned.mtimeMs)) {
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
|||||||
kind: "other",
|
kind: "other",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
title: "旧版本待处置",
|
title: "旧版本待处置",
|
||||||
summary: "新版本已核验入库,旧文件保留,按保留策略处理",
|
summary: "新版本已核验整理,旧文件保留,按保留策略处理",
|
||||||
subjectType: "media_asset",
|
subjectType: "media_asset",
|
||||||
subjectId: old,
|
subjectId: old,
|
||||||
dedupeKey: `old_version:${old}`,
|
dedupeKey: `old_version:${old}`,
|
||||||
@@ -583,7 +583,7 @@ export async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskI
|
|||||||
key: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
|
key: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
|
||||||
optionKey: null,
|
optionKey: null,
|
||||||
by,
|
by,
|
||||||
note: "整理已成功入库",
|
note: "整理成功",
|
||||||
organizationRecordId: rec.id,
|
organizationRecordId: rec.id,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function freezeReplacement(
|
|||||||
ids: string[],
|
ids: string[],
|
||||||
): Promise<FrozenReplacement> {
|
): Promise<FrozenReplacement> {
|
||||||
const selected = [...new Set(ids)];
|
const selected = [...new Set(ids)];
|
||||||
if (!selected.length) throw new Error("洗版入库必须选择旧版本");
|
if (!selected.length) throw new Error("洗版必须选择旧版本");
|
||||||
const assets = await d.ctx.db.selectFrom("media_asset").selectAll().where("id", "in", selected).execute();
|
const assets = await d.ctx.db.selectFrom("media_asset").selectAll().where("id", "in", selected).execute();
|
||||||
if (
|
if (
|
||||||
assets.length !== selected.length ||
|
assets.length !== selected.length ||
|
||||||
@@ -592,7 +592,7 @@ export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId
|
|||||||
const names = [...rejectedMains].map((p) => basename(p));
|
const names = [...rejectedMains].map((p) => basename(p));
|
||||||
await upsertDecision(d, db, {
|
await upsertDecision(d, db, {
|
||||||
kind: "other",
|
kind: "other",
|
||||||
title: `${rejectedMains.size} 个文件不是视频,没有入库`,
|
title: `${rejectedMains.size} 个文件不是视频,没有整理`,
|
||||||
summary: `${names.slice(0, 5).join("、")}${names.length > 5 ? " 等" : ""} 没有视频流(伪装或损坏的文件)。${
|
summary: `${names.slice(0, 5).join("、")}${names.length > 5 ? " 等" : ""} 没有视频流(伪装或损坏的文件)。${
|
||||||
moved.length ? "已拷进媒体库的文件移入回收站" : ""
|
moved.length ? "已拷进媒体库的文件移入回收站" : ""
|
||||||
}${left.length ? `${moved.length ? "," : ""}${left.length} 个文件留在媒体库里待核对` : ""}${
|
}${left.length ? `${moved.length ? "," : ""}${left.length} 个文件留在媒体库里待核对` : ""}${
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ export async function scrapeAfterImport(
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 新记录入队失败时 queueScrape 已把它标成 task_enqueue_failed(整理详情可见);这里再兜一条,防止没留下记录
|
// 新记录入队失败时 queueScrape 已把它标成 task_enqueue_failed(整理详情可见);这里再兜一条,防止没留下记录
|
||||||
d.ctx.logger.warn({ entityId, recordId, dir, err: { message: errMessage(err) } }, "入库后自动刮削没排上");
|
d.ctx.logger.warn({ entityId, recordId, dir, err: { message: errMessage(err) } }, "整理后自动刮削没排上");
|
||||||
const marked = await d.ctx.db
|
const marked = await d.ctx.db
|
||||||
.selectFrom("scrape_record")
|
.selectFrom("scrape_record")
|
||||||
.select("id")
|
.select("id")
|
||||||
@@ -577,7 +577,7 @@ export async function scrapeAfterImport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "入库后自动刮削没排上");
|
d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "整理后自动刮削没排上");
|
||||||
await recordScrapeSkipped(d, recordId, "scrape_internal_error", { root, match });
|
await recordScrapeSkipped(d, recordId, "scrape_internal_error", { root, match });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -798,7 +798,7 @@ export async function afterSlotVerified(
|
|||||||
kind: "other",
|
kind: "other",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
title: "疑似缺集",
|
title: "疑似缺集",
|
||||||
summary: `已入库最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500),
|
summary: `已整理最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500),
|
||||||
subjectType: "acquisition_slot",
|
subjectType: "acquisition_slot",
|
||||||
subjectId: slot.id,
|
subjectId: slot.id,
|
||||||
dedupeKey: `suspected_missing:${slot.id}`,
|
dedupeKey: `suspected_missing:${slot.id}`,
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const OUTCOME_TEXT: Record<NameIdentifyResult["outcome"], string> = {
|
|||||||
export function takeoverSummary(t: TakeoverIdentify | null): string {
|
export function takeoverSummary(t: TakeoverIdentify | null): string {
|
||||||
if (!t) return "下载内容未关联作品。识别或指定作品后再整理";
|
if (!t) return "下载内容未关联作品。识别或指定作品后再整理";
|
||||||
const what = [t.parsed.title, t.parsed.year].filter(Boolean).join(" ");
|
const what = [t.parsed.title, t.parsed.year].filter(Boolean).join(" ");
|
||||||
return `按文件名识别${what ? `「${what}」` : ""}:${OUTCOME_TEXT[t.result.outcome]}。选择作品、作为临时作品入库,或忽略`;
|
return `按文件名识别${what ? `「${what}」` : ""}:${OUTCOME_TEXT[t.result.outcome]}。选择作品、作为临时作品整理,或忽略`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 接管识别失败 / 歧义的事项选项:候选(最多 3 个)、选择作品(本地 / TMDB)、作为临时作品入库、忽略 */
|
/** 接管识别失败 / 歧义的事项选项:候选(最多 3 个)、选择作品(本地 / TMDB)、作为临时作品入库、忽略 */
|
||||||
@@ -140,7 +140,7 @@ export function identityOptions(t: TakeoverIdentify | null, fallbackTitle: strin
|
|||||||
t?.parsed.mediaTypeHint === "tv" || t?.parsed.season !== undefined || t?.parsed.episodes !== undefined;
|
t?.parsed.mediaTypeHint === "tv" || t?.parsed.season !== undefined || t?.parsed.episodes !== undefined;
|
||||||
out.push({
|
out.push({
|
||||||
key: "provisional",
|
key: "provisional",
|
||||||
label: "作为临时作品入库",
|
label: "作为临时作品整理",
|
||||||
description: "按文件名建本地临时作品后整理,之后可合并或补外部身份",
|
description: "按文件名建本地临时作品后整理,之后可合并或补外部身份",
|
||||||
input: { provisional: { kind: tvLike ? "series" : "movie", title, year: t?.parsed.year ?? null } },
|
input: { provisional: { kind: tvLike ? "series" : "movie", title, year: t?.parsed.year ?? null } },
|
||||||
});
|
});
|
||||||
@@ -153,7 +153,7 @@ export function identityOptions(t: TakeoverIdentify | null, fallbackTitle: strin
|
|||||||
export const CONFLICT_OPTIONS: DecisionOptionInput[] = [
|
export const CONFLICT_OPTIONS: DecisionOptionInput[] = [
|
||||||
{ key: "replace", label: "替换", description: "新文件核验通过后替换已有文件,旧文件进回收站" },
|
{ key: "replace", label: "替换", description: "新文件核验通过后替换已有文件,旧文件进回收站" },
|
||||||
{ key: "keep_both", label: "保留两者", description: "新文件改名(加序号)后放在旁边,已有文件不动" },
|
{ key: "keep_both", label: "保留两者", description: "新文件改名(加序号)后放在旁边,已有文件不动" },
|
||||||
{ key: "skip", label: "跳过", description: "已存在的文件不整理,其余文件照常入库" },
|
{ key: "skip", label: "跳过", description: "已存在的文件不整理,其余文件照常整理" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── 保留两者:把目标已存在的主视频(连同附属文件)改名为 "<名称> (n)" ──
|
// ── 保留两者:把目标已存在的主视频(连同附属文件)改名为 "<名称> (n)" ──
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function renameEntry(
|
|||||||
if (hits.length > 0)
|
if (hits.length > 0)
|
||||||
throw new FilesError(
|
throw new FilesError(
|
||||||
"download_active",
|
"download_active",
|
||||||
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理入库再改`,
|
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理再改`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dirAbs = dirname(r.abs);
|
const dirAbs = dirname(r.abs);
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export async function organizeFiles(
|
|||||||
if (action === "in_place") {
|
if (action === "in_place") {
|
||||||
// 本次保留实际库根与路径,不使用来源配置的搬运目标。
|
// 本次保留实际库根与路径,不使用来源配置的搬运目标。
|
||||||
if (!r.def.libraryRootId)
|
if (!r.def.libraryRootId)
|
||||||
throw new FilesError("bad_request", "原地入库需要文件位于已登记的媒体库目录;请先在设置中登记该目录");
|
throw new FilesError("bad_request", "原地整理需要文件位于已登记的媒体库目录;请先在设置中登记该目录");
|
||||||
targetLib = r.def.libraryRootId;
|
targetLib = r.def.libraryRootId;
|
||||||
}
|
}
|
||||||
if (action !== "in_place" && it.targetDir) {
|
if (action !== "in_place" && it.targetDir) {
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export async function transfer(
|
|||||||
if (hnrBlocks.length > 0)
|
if (hnrBlocks.length > 0)
|
||||||
throw new FilesError(
|
throw new FilesError(
|
||||||
"download_active",
|
"download_active",
|
||||||
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`,
|
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理,它会按做种义务决定能否移动`,
|
||||||
);
|
);
|
||||||
if (!isAdmin(principal)) {
|
if (!isAdmin(principal)) {
|
||||||
// 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项
|
// 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ const BLOCK_ERR: Record<
|
|||||||
> = {
|
> = {
|
||||||
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
|
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
|
||||||
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
|
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
|
||||||
needs_retire: ["needs_retire", "含已入库文件,需勾选「同时从媒体库移除」"],
|
needs_retire: ["needs_retire", "含已整理文件,需勾选「同时从媒体库移除」"],
|
||||||
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
|
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
|
||||||
root_unavailable: ["root_unavailable", "根不可用"],
|
root_unavailable: ["root_unavailable", "根不可用"],
|
||||||
not_found: ["not_found", "不存在"],
|
not_found: ["not_found", "不存在"],
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe.skipIf(!hasPg)("整理:不是视频的文件不留在媒体库", () =>
|
|||||||
.select(["title", "status"])
|
.select(["title", "status"])
|
||||||
.where("dedupe_key", "=", `organize_not_video:${plan.recordId}`)
|
.where("dedupe_key", "=", `organize_not_video:${plan.recordId}`)
|
||||||
.execute();
|
.execute();
|
||||||
expect(decisions).toEqual([{ title: "1 个文件不是视频,没有入库", status: "open" }]);
|
expect(decisions).toEqual([{ title: "1 个文件不是视频,没有整理", status: "open" }]);
|
||||||
|
|
||||||
// 重放:已结束记录不重复移动
|
// 重放:已结束记录不重复移动
|
||||||
await executeRecord(h.d, plan.recordId!);
|
await executeRecord(h.d, plan.recordId!);
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
|
|||||||
it("统计与列表同口径,不受状态筛选影响", async () => {
|
it("统计与列表同口径,不受状态筛选影响", async () => {
|
||||||
const all = await h.api("GET", "/organization-records/stats");
|
const all = await h.api("GET", "/organization-records/stats");
|
||||||
expect(all.status).toBe(200);
|
expect(all.status).toBe(200);
|
||||||
expect(all.body.data).toEqual({
|
expect(all.body.data).toMatchObject({
|
||||||
total: 4,
|
total: 4,
|
||||||
succeeded: 2,
|
succeeded: 2,
|
||||||
partial: 0,
|
partial: 0,
|
||||||
@@ -120,6 +120,44 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
|
|||||||
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
|
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("整理方式、媒体库目录、分类:列表筛选与维度计数", async () => {
|
||||||
|
const root = await h.db
|
||||||
|
.insertInto("library_root")
|
||||||
|
.values({ name: "电影库", local_path: "/lib/movies" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ target_library_root_id: root.id })
|
||||||
|
.where("id", "=", ids.film!)
|
||||||
|
.execute();
|
||||||
|
try {
|
||||||
|
expect(idsOf(await list("organizeOnly=true&action=copy"))).toEqual([ids.film, ids.unknown].sort());
|
||||||
|
expect(idsOf(await list("action=move"))).toEqual([ids.show]);
|
||||||
|
expect(idsOf(await list(`libraryRootId=${root.id}`))).toEqual([ids.film]);
|
||||||
|
expect(idsOf(await list(`category=${encodeURIComponent("欧美电影")}`))).toEqual([ids.film]);
|
||||||
|
expect(await list(`category=${encodeURIComponent("纪录片")}`)).toEqual([]);
|
||||||
|
// 维度计数:按其余条件计,不含本维度自己的条件;只列非零项
|
||||||
|
const s = await h.api("GET", "/organization-records/stats?organizeOnly=true&action=copy");
|
||||||
|
expect(s.body.data.total).toBe(2);
|
||||||
|
expect(s.body.data.facets.action).toEqual([
|
||||||
|
{ value: "copy", n: 2 },
|
||||||
|
{ value: "move", n: 1 },
|
||||||
|
]);
|
||||||
|
expect(s.body.data.facets.libraryRootId).toEqual([{ value: root.id, n: 1 }]);
|
||||||
|
expect(s.body.data.facets.category).toEqual([{ value: "欧美电影", n: 1 }]);
|
||||||
|
const bad = await h.api("GET", "/organization-records?action=rename");
|
||||||
|
expect(bad.status).toBe(422);
|
||||||
|
} finally {
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ target_library_root_id: null })
|
||||||
|
.where("id", "=", ids.film!)
|
||||||
|
.execute();
|
||||||
|
await h.db.deleteFrom("library_root").where("id", "=", root.id).execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("历史分页和统计排除排队与运行记录,显式active仍可查询", async () => {
|
it("历史分页和统计排除排队与运行记录,显式active仍可查询", async () => {
|
||||||
const inserted = await h.db
|
const inserted = await h.db
|
||||||
.insertInto("organization_record")
|
.insertInto("organization_record")
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ export const DOWNLOAD_STATUS_LABEL: Record<DownloadStatus, string> = {
|
|||||||
paused: "已暂停",
|
paused: "已暂停",
|
||||||
stalled: "无进展",
|
stalled: "无进展",
|
||||||
completed: "下载完成",
|
completed: "下载完成",
|
||||||
importing: "入库中",
|
importing: "整理中",
|
||||||
imported: "已入库",
|
imported: "已整理",
|
||||||
failed: "失败",
|
failed: "失败",
|
||||||
removed: "已移除",
|
removed: "已移除",
|
||||||
missing: "下载器中缺失",
|
missing: "下载器中缺失",
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export type RecordFilters = {
|
|||||||
organizeOnly?: boolean;
|
organizeOnly?: boolean;
|
||||||
/** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */
|
/** 只看需要处理的(失败 / 部分完成没被取代、没被忽略,或有未关闭的事项) */
|
||||||
attention?: boolean;
|
attention?: boolean;
|
||||||
|
/** 整理方式 */
|
||||||
|
action?: "copy" | "move" | "hardlink" | "in_place";
|
||||||
|
/** 目标媒体库目录 */
|
||||||
|
libraryRootId?: string;
|
||||||
|
/** 冻结计划里的二级分类 */
|
||||||
|
category?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recordKeys = {
|
export const recordKeys = {
|
||||||
@@ -176,7 +182,7 @@ export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自
|
|||||||
/** 整理方式 / 文件操作(记录的 action) */
|
/** 整理方式 / 文件操作(记录的 action) */
|
||||||
export const ACTION_LABEL = {
|
export const ACTION_LABEL = {
|
||||||
copy: "复制",
|
copy: "复制",
|
||||||
in_place: "原地入库",
|
in_place: "原地整理",
|
||||||
move: "移动",
|
move: "移动",
|
||||||
rename: "重命名",
|
rename: "重命名",
|
||||||
relocate: "目录调整",
|
relocate: "目录调整",
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ describe("下载面板展示换算(DL-2)", () => {
|
|||||||
expect(formatEta(3 * 86400 + 4 * 3600)).toBe("3 天 4 小时");
|
expect(formatEta(3 * 86400 + 4 * 3600)).toBe("3 天 4 小时");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库;有问题的链到整理记录就地处理", () => {
|
it("最近一次整理结果:已整理 / 整理中 / 待整理 / 整理失败 / 部分整理;有问题的链到整理记录就地处理", () => {
|
||||||
expect(organizeState({ status: "completed", organize: null })).toMatchObject({ label: "待整理", to: null });
|
expect(organizeState({ status: "completed", organize: null })).toMatchObject({ label: "待整理", to: null });
|
||||||
expect(organizeState({ status: "importing", organize: null })?.label).toBe("整理中");
|
expect(organizeState({ status: "importing", organize: null })?.label).toBe("整理中");
|
||||||
expect(organizeState({ status: "imported", organize: null })?.label).toBe("已入库");
|
expect(organizeState({ status: "imported", organize: null })?.label).toBe("已整理");
|
||||||
expect(organizeState({ status: "completed", organize: rec("succeeded") })).toMatchObject({
|
expect(organizeState({ status: "completed", organize: rec("succeeded") })).toMatchObject({
|
||||||
label: "已入库",
|
label: "已整理",
|
||||||
tone: "success",
|
tone: "success",
|
||||||
to: `/console/organize/${RID}`,
|
to: `/console/organize/${RID}`,
|
||||||
});
|
});
|
||||||
@@ -44,7 +44,7 @@ describe("下载面板展示换算(DL-2)", () => {
|
|||||||
});
|
});
|
||||||
// 原因码转成人话,不直接给人看
|
// 原因码转成人话,不直接给人看
|
||||||
expect(failed).toMatchObject({
|
expect(failed).toMatchObject({
|
||||||
label: "入库失败",
|
label: "整理失败",
|
||||||
tone: "destructive",
|
tone: "destructive",
|
||||||
hint: "没识别出作品。指定作品后重新整理",
|
hint: "没识别出作品。指定作品后重新整理",
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ describe("下载面板展示换算(DL-2)", () => {
|
|||||||
expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) })?.hint).toBe(
|
expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) })?.hint).toBe(
|
||||||
"磁盘已满",
|
"磁盘已满",
|
||||||
);
|
);
|
||||||
expect(organizeState({ status: "completed", organize: rec("partial") })?.label).toBe("部分入库");
|
expect(organizeState({ status: "completed", organize: rec("partial") })?.label).toBe("部分整理");
|
||||||
expect(organizeState({ status: "completed", organize: rec("failed", { reason: "transfer_failed" }) })?.hint).toBe(
|
expect(organizeState({ status: "completed", organize: rec("failed", { reason: "transfer_failed" }) })?.hint).toBe(
|
||||||
"文件没有整理成功。在整理记录里查看失败的文件",
|
"文件没有整理成功。在整理记录里查看失败的文件",
|
||||||
);
|
);
|
||||||
@@ -72,11 +72,11 @@ describe("下载面板展示换算(DL-2)", () => {
|
|||||||
it("外部任务:没整理过显示 —(null),整理过的照常显示结果", () => {
|
it("外部任务:没整理过显示 —(null),整理过的照常显示结果", () => {
|
||||||
expect(organizeState({ status: "completed", organize: null, managed: false })).toBeNull();
|
expect(organizeState({ status: "completed", organize: null, managed: false })).toBeNull();
|
||||||
expect(organizeState({ status: "completed", organize: null, managed: true })?.label).toBe("待整理");
|
expect(organizeState({ status: "completed", organize: null, managed: true })?.label).toBe("待整理");
|
||||||
expect(organizeState({ status: "completed", organize: rec("succeeded"), managed: false })?.label).toBe("已入库");
|
expect(organizeState({ status: "completed", organize: rec("succeeded"), managed: false })?.label).toBe("已整理");
|
||||||
expect(
|
expect(
|
||||||
organizeState({ status: "completed", organize: rec("failed", { reason: "transfer_failed" }), managed: false })
|
organizeState({ status: "completed", organize: rec("failed", { reason: "transfer_failed" }), managed: false })
|
||||||
?.label,
|
?.label,
|
||||||
).toBe("入库失败");
|
).toBe("整理失败");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("连接数:全 0 且总数未知时不显示", () => {
|
it("连接数:全 0 且总数未知时不显示", () => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function reasonHint(raw: string | null | undefined): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 已完成任务的最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库。
|
* 已完成任务的最近一次整理结果:已整理 / 整理中 / 待整理 / 整理失败 / 部分整理。
|
||||||
* 外部任务(managed=false)没整理过时不算"待整理",返回 null(界面显示 —);整理过的照常显示结果。
|
* 外部任务(managed=false)没整理过时不算"待整理",返回 null(界面显示 —);整理过的照常显示结果。
|
||||||
*/
|
*/
|
||||||
export function organizeState(
|
export function organizeState(
|
||||||
@@ -51,9 +51,9 @@ export function organizeState(
|
|||||||
// 有问题的整理在整理记录详情里就地处理(待处理中心已取消)
|
// 有问题的整理在整理记录详情里就地处理(待处理中心已取消)
|
||||||
const to = rec ? consolePath("organize", rec.recordId) : null;
|
const to = rec ? consolePath("organize", rec.recordId) : null;
|
||||||
if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed"))
|
if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed"))
|
||||||
return { label: "已入库", tone: "success", to, hint: null };
|
return { label: "已整理", tone: "success", to, hint: null };
|
||||||
if (rec?.status === "failed") return { label: "入库失败", tone: "destructive", to, hint: reasonHint(rec.reason) };
|
if (rec?.status === "failed") return { label: "整理失败", tone: "destructive", to, hint: reasonHint(rec.reason) };
|
||||||
if (rec?.status === "partial") return { label: "部分入库", tone: "warning", to, hint: reasonHint(rec.reason) };
|
if (rec?.status === "partial") return { label: "部分整理", tone: "warning", to, hint: reasonHint(rec.reason) };
|
||||||
if (rec?.status === "queued" || rec?.status === "running" || t.status === "importing")
|
if (rec?.status === "queued" || rec?.status === "running" || t.status === "importing")
|
||||||
return { label: "整理中", tone: "neutral", to, hint: null };
|
return { label: "整理中", tone: "neutral", to, hint: null };
|
||||||
return { label: "待整理", tone: "warning", to: rec?.decision ? to : null, hint: null };
|
return { label: "待整理", tone: "warning", to: rec?.decision ? to : null, hint: null };
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function CacheVolumes() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title="按规则清理下载缓存?"
|
title="按规则清理下载缓存?"
|
||||||
description="只清理已入库、且满足保留期与 H&R 的本工具任务,其余不动。"
|
description="只清理已整理、且满足保留期与 H&R 的本工具任务,其余不动。"
|
||||||
confirmLabel="清理"
|
confirmLabel="清理"
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
cleanup.mutateAsync(undefined).then(
|
cleanup.mutateAsync(undefined).then(
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
|
|||||||
const trace = useDownloadTrace(task, plan.data);
|
const trace = useDownloadTrace(task, plan.data);
|
||||||
const [organizing, setOrganizing] = useState(false);
|
const [organizing, setOrganizing] = useState(false);
|
||||||
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
|
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
|
||||||
// 下载完成但最近一次整理失败:状态行只写"下载完成"会误导,单独说明入库失败
|
// 下载完成但最近一次整理失败:状态行只写"下载完成"会误导,单独说明整理失败
|
||||||
const lastRecord = [...(trace.records.data?.items ?? [])].sort((a, b) =>
|
const lastRecord = [...(trace.records.data?.items ?? [])].sort((a, b) =>
|
||||||
(b.startedAt ?? b.createdAt).localeCompare(a.startedAt ?? a.createdAt),
|
(b.startedAt ?? b.createdAt).localeCompare(a.startedAt ?? a.createdAt),
|
||||||
)[0];
|
)[0];
|
||||||
@@ -143,7 +143,7 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
|
|||||||
) : null}
|
) : null}
|
||||||
{importFailed ? (
|
{importFailed ? (
|
||||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
|
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
|
||||||
入库失败:{reasonLine(importFailed.reason)}。
|
整理失败:{reasonLine(importFailed.reason)}。
|
||||||
<Link to={`/console/organize/${importFailed.id}`} className="ml-1 underline underline-offset-4">
|
<Link to={`/console/organize/${importFailed.id}`} className="ml-1 underline underline-offset-4">
|
||||||
去整理记录处理
|
去整理记录处理
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -610,10 +610,10 @@ export function ExistingVersionsSection({
|
|||||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||||
<p className="min-w-0 text-caption text-foreground">{summary ?? `媒体库已有 ${versions.length} 个版本`}</p>
|
<p className="min-w-0 text-caption text-foreground">{summary ?? `媒体库已有 ${versions.length} 个版本`}</p>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
label="入库方式"
|
label="已有版本"
|
||||||
value={mode === "normal" ? "" : mode}
|
value={mode === "normal" ? "" : mode}
|
||||||
onChange={(v) => onModeChange(v === "replace" ? "replace" : "normal")}
|
onChange={(v) => onModeChange(v === "replace" ? "replace" : "normal")}
|
||||||
allLabel="正常入库,保留已有版本"
|
allLabel="保留"
|
||||||
options={[{ value: "replace", label: "一次性洗版" }]}
|
options={[{ value: "replace", label: "一次性洗版" }]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -640,7 +640,7 @@ export function ExistingVersionsSection({
|
|||||||
))}
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p className="text-meta text-muted-foreground">
|
<p className="text-meta text-muted-foreground">
|
||||||
本次新文件成功入库后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
|
本次新文件整理成功后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const VALIDATION: Record<EpisodeValidationState, { label: string; tone: S
|
|||||||
pass: { label: "对得上", tone: "success" },
|
pass: { label: "对得上", tone: "success" },
|
||||||
unverified: { label: "未核对", tone: "warning" },
|
unverified: { label: "未核对", tone: "warning" },
|
||||||
fail: { label: "对不上", tone: "destructive" },
|
fail: { label: "对不上", tone: "destructive" },
|
||||||
special: { label: "不按集入库", tone: "neutral" },
|
special: { label: "不按集整理", tone: "neutral" },
|
||||||
skipped: { label: "不适用", tone: "neutral" },
|
skipped: { label: "不适用", tone: "neutral" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export function RenameDialog({
|
|||||||
<Notice tone="neutral">同步 {pv.assetLocations} 条文件位置,写一条整理记录。</Notice>
|
<Notice tone="neutral">同步 {pv.assetLocations} 条文件位置,写一条整理记录。</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
{pv.bindings.length > 0 ? (
|
{pv.bindings.length > 0 ? (
|
||||||
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。</Notice>
|
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后整理用新目录。</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -629,7 +629,7 @@ export function TransferDialog({
|
|||||||
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
|
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
|
||||||
) : null}
|
) : null}
|
||||||
{plan.pairs.some((p) => p.registered) ? (
|
{plan.pairs.some((p) => p.registered) ? (
|
||||||
<StatusBadge>含已入库文件,执行后同步位置</StatusBadge>
|
<StatusBadge>含已整理文件,执行后同步位置</StatusBadge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{hnrBlocked ? (
|
{hnrBlocked ? (
|
||||||
@@ -790,7 +790,7 @@ export function DeleteDialog({
|
|||||||
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
|
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
|
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
|
||||||
<DialogDescription>已检查做种任务、已入库文件和路径绑定。下载器里的任务不受影响。</DialogDescription>
|
<DialogDescription>已检查做种任务、已整理文件和路径绑定。下载器里的任务不受影响。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 space-y-3 overflow-y-auto">
|
<div className="min-h-0 space-y-3 overflow-y-auto">
|
||||||
{check.isPending ? (
|
{check.isPending ? (
|
||||||
@@ -837,7 +837,7 @@ export function DeleteDialog({
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{c.assets.length ? (
|
{c.assets.length ? (
|
||||||
<p>已入库文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)</p>
|
<p>已整理文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)</p>
|
||||||
) : null}
|
) : null}
|
||||||
{c.bindings.map((bd) => (
|
{c.bindings.map((bd) => (
|
||||||
<p key={bd.bindingId}>
|
<p key={bd.bindingId}>
|
||||||
@@ -869,7 +869,7 @@ export function DeleteDialog({
|
|||||||
<span>
|
<span>
|
||||||
解除路径绑定
|
解除路径绑定
|
||||||
<span className="block text-meta text-muted-foreground">
|
<span className="block text-meta text-muted-foreground">
|
||||||
绑定标为已替代,该作品下次入库重新选目录。
|
绑定标为已替代,该作品下次整理重新选目录。
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -1139,7 +1139,7 @@ export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; roo
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
|
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没入库的也能刮削。
|
按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没整理的也能刮削。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!started ? (
|
{!started ? (
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function FileDetail({
|
|||||||
value:
|
value:
|
||||||
e.type === "dir"
|
e.type === "dir"
|
||||||
? e.dirSummary
|
? e.dirSummary
|
||||||
? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已入库`
|
? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已整理`
|
||||||
: "—"
|
: "—"
|
||||||
: formatBytes(e.size, 2),
|
: formatBytes(e.size, 2),
|
||||||
},
|
},
|
||||||
@@ -135,7 +135,7 @@ export function FileDetail({
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
{e.asset ? (
|
{e.asset ? (
|
||||||
<DetailSection title="已入库文件">
|
<DetailSection title="已整理文件">
|
||||||
<KvList
|
<KvList
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const base = {
|
|||||||
|
|
||||||
describe("状态与禁用原因", () => {
|
describe("状态与禁用原因", () => {
|
||||||
it("状态徽标带文字,H&R 未满足为 warning,逃逸为 destructive", () => {
|
it("状态徽标带文字,H&R 未满足为 warning,逃逸为 destructive", () => {
|
||||||
expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已入库");
|
expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已整理");
|
||||||
expect(
|
expect(
|
||||||
entryStatusView({
|
entryStatusView({
|
||||||
...base,
|
...base,
|
||||||
@@ -125,7 +125,7 @@ describe("状态与禁用原因", () => {
|
|||||||
expect(opDisabledReason("move", seeding, lib)).toMatch(/H&R/);
|
expect(opDisabledReason("move", seeding, lib)).toMatch(/H&R/);
|
||||||
expect(opDisabledReason("move", base, { kind: "library", writable: false })).toMatch(/只读/);
|
expect(opDisabledReason("move", base, { kind: "library", writable: false })).toMatch(/只读/);
|
||||||
expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull();
|
expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull();
|
||||||
expect(opDisabledReason("scrape", base, lib)).toMatch(/未登记/);
|
expect(opDisabledReason("scrape", base, lib)).toMatch(/整理后才能/);
|
||||||
expect(opDisabledReason("writeNfo", base, lib)).toBeNull();
|
expect(opDisabledReason("writeNfo", base, lib)).toBeNull();
|
||||||
expect(opDisabledReason("writeNfo", base, { kind: "library", writable: false })).toMatch(/只读/);
|
expect(opDisabledReason("writeNfo", base, { kind: "library", writable: false })).toMatch(/只读/);
|
||||||
expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull();
|
expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull();
|
||||||
@@ -272,7 +272,7 @@ describe("v2 菜单与副行", () => {
|
|||||||
type: "dir" as const,
|
type: "dir" as const,
|
||||||
dirSummary: { videoCount: 13, registeredCount: 0, partial: false },
|
dirSummary: { videoCount: 13, registeredCount: 0, partial: false },
|
||||||
})?.text,
|
})?.text,
|
||||||
).toBe("13 个视频 · 未入库");
|
).toBe("13 个视频 · 未整理");
|
||||||
// 没有 parsedHint(服务端解析不出标题)时不显示副行,前端不自己解析
|
// 没有 parsedHint(服务端解析不出标题)时不显示副行,前端不自己解析
|
||||||
expect(entrySubline({ ...video, parsedHint: null })).toBeNull();
|
expect(entrySubline({ ...video, parsedHint: null })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "esca
|
|||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
};
|
};
|
||||||
case "registered":
|
case "registered":
|
||||||
return { label: "已入库", tone: "success" };
|
return { label: "已整理", tone: "success" };
|
||||||
case "seeding":
|
case "seeding":
|
||||||
return e.download?.hnr === "unsatisfied"
|
return e.download?.hnr === "unsatisfied"
|
||||||
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 满足前不能改名、删除或移动" }
|
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 满足前不能改名、删除或移动" }
|
||||||
@@ -42,7 +42,7 @@ export const FILTER_CHIPS = [
|
|||||||
{ value: "", label: "全部" },
|
{ value: "", label: "全部" },
|
||||||
{ value: "video", label: "视频" },
|
{ value: "video", label: "视频" },
|
||||||
{ value: "unidentified", label: "未识别" },
|
{ value: "unidentified", label: "未识别" },
|
||||||
{ value: "registered", label: "已入库" },
|
{ value: "registered", label: "已整理" },
|
||||||
{ value: "downloading", label: "下载中" },
|
{ value: "downloading", label: "下载中" },
|
||||||
{ value: "temp", label: "未完成下载" },
|
{ value: "temp", label: "未完成下载" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -98,7 +98,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
|
|||||||
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
|
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
|
||||||
return null;
|
return null;
|
||||||
case "scrape":
|
case "scrape":
|
||||||
return e.asset ? null : "未登记,先整理入库";
|
return e.asset ? null : "整理后才能刷新元数据";
|
||||||
case "writeNfo":
|
case "writeNfo":
|
||||||
if (root.kind === "library" && !root.writable) return "只读目录,不写 NFO";
|
if (root.kind === "library" && !root.writable) return "只读目录,不写 NFO";
|
||||||
if (e.status === "temp") return "未完成的文件不能刮削";
|
if (e.status === "temp") return "未完成的文件不能刮削";
|
||||||
@@ -314,7 +314,7 @@ export function deleteBlockText(b: string | null): { text: string; soft: boolean
|
|||||||
case "is_root":
|
case "is_root":
|
||||||
return { text: "不能删除登记目录本身", soft: false };
|
return { text: "不能删除登记目录本身", soft: false };
|
||||||
case "needs_retire":
|
case "needs_retire":
|
||||||
return { text: "含已入库文件,勾选「同时从媒体库移除」后才能删除", soft: true };
|
return { text: "含已整理文件,勾选「同时从媒体库移除」后才能删除", soft: true };
|
||||||
case "needs_unbind":
|
case "needs_unbind":
|
||||||
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
|
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
|
||||||
case "root_unavailable":
|
case "root_unavailable":
|
||||||
@@ -431,7 +431,7 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 行内 hover / 键盘聚焦时浮出的常用操作(桌面,§5.1;照 MP 的悬停图标,但只露 2–3 个,其余留在 ⋯ 菜单):
|
* 行内 hover / 键盘聚焦时浮出的常用操作(桌面,§5.1;照 MP 的悬停图标,但只露 2–3 个,其余留在 ⋯ 菜单):
|
||||||
* 已入库 → 打开作品、刷新元数据、重命名;未入库视频 / 目录 → 整理、识别、刮削;
|
* 已整理 → 打开作品、刷新元数据、重命名;未整理视频 / 目录 → 整理、识别、刮削;
|
||||||
* 其余能识别的(临时文件里的视频等)→ 识别、重命名、移动到;别的文件 → 重命名、移动到。
|
* 其余能识别的(临时文件里的视频等)→ 识别、重命名、移动到;别的文件 → 重命名、移动到。
|
||||||
*/
|
*/
|
||||||
export function hoverOps(e: FileEntry): MenuKey[] {
|
export function hoverOps(e: FileEntry): MenuKey[] {
|
||||||
@@ -472,7 +472,7 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint
|
|||||||
const s = e.dirSummary;
|
const s = e.dirSummary;
|
||||||
if (!s.videoCount && !s.registeredCount) return null;
|
if (!s.videoCount && !s.registeredCount) return null;
|
||||||
return {
|
return {
|
||||||
text: `${s.videoCount}${s.partial ? "+" : ""} 个视频${s.registeredCount ? ` · ${s.registeredCount} 个已入库` : s.videoCount ? " · 未入库" : ""}`,
|
text: `${s.videoCount}${s.partial ? "+" : ""} 个视频${s.registeredCount ? ` · ${s.registeredCount} 个已整理` : s.videoCount ? " · 未整理" : ""}`,
|
||||||
tone: "meta",
|
tone: "meta",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export function seasonLine(s: Pick<EpisodeStructureSeason, "airDate" | "episodeC
|
|||||||
return [s.airDate ? s.airDate.slice(0, 4) : null, `${s.episodeCount} 集`].filter(Boolean).join(" · ");
|
return [s.airDate ? s.airDate.slice(0, 4) : null, `${s.episodeCount} 集`].filter(Boolean).join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已入库 x/y;一集都没有时返回 null(只列非零项) */
|
/** 已整理 x/y;一集都没有时返回 null(只列非零项) */
|
||||||
export function inLibraryText(s: Pick<EpisodeStructureSeason, "inLibrary" | "episodeCount">): string | null {
|
export function inLibraryText(s: Pick<EpisodeStructureSeason, "inLibrary" | "episodeCount">): string | null {
|
||||||
return s.inLibrary > 0 ? `已入库 ${s.inLibrary}/${s.episodeCount}` : null;
|
return s.inLibrary > 0 ? `已整理 ${s.inLibrary}/${s.episodeCount}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
@@ -63,5 +63,5 @@ export function originText(
|
|||||||
|
|
||||||
/** 切换确认的说明:改的是作品设置,影响季表、集数映射、订阅与刮削 */
|
/** 切换确认的说明:改的是作品设置,影响季表、集数映射、订阅与刮削 */
|
||||||
export function switchDescription(target: string): string {
|
export function switchDescription(target: string): string {
|
||||||
return `作品改按「${target}」分季:季与集按 TMDB 原季集迁到新位置,已入库文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`;
|
return `作品改按「${target}」分季:季与集按 TMDB 原季集迁到新位置,已整理文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`;
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ describe("季集结构:纯函数", () => {
|
|||||||
it("季卡片文案与原季集对照", () => {
|
it("季卡片文案与原季集对照", () => {
|
||||||
expect(seasonLine({ airDate: "2024-07-07", episodeCount: 13 })).toBe("2024 · 13 集");
|
expect(seasonLine({ airDate: "2024-07-07", episodeCount: 13 })).toBe("2024 · 13 集");
|
||||||
expect(inLibraryText({ inLibrary: 0, episodeCount: 13 })).toBeNull();
|
expect(inLibraryText({ inLibrary: 0, episodeCount: 13 })).toBeNull();
|
||||||
expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已入库 3/13");
|
expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已整理 3/13");
|
||||||
expect(originText(2, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14 })).toBe("原 S01E14");
|
expect(originText(2, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14 })).toBe("原 S01E14");
|
||||||
expect(originText(1, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 2 })).toBeNull();
|
expect(originText(1, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 2 })).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -131,14 +131,14 @@ describe("季集结构:组件", () => {
|
|||||||
expect(html).toContain('aria-label="当前"');
|
expect(html).toContain('aria-label="当前"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("季卡片:季名、首播年 · 集数、已入库 x/y,选中的高亮", () => {
|
it("季卡片:季名、首播年 · 集数、已整理 x/y,选中的高亮", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
createElement(SeasonCardGrid, { seasons: structure.seasons, selected: 2, onPick: () => {} }),
|
createElement(SeasonCardGrid, { seasons: structure.seasons, selected: 2, onPick: () => {} }),
|
||||||
);
|
);
|
||||||
expect(html).toContain("第一部");
|
expect(html).toContain("第一部");
|
||||||
expect(html).toContain("2024 · 12 集");
|
expect(html).toContain("2024 · 12 集");
|
||||||
expect(html).toContain("已入库 1/13");
|
expect(html).toContain("已整理 1/13");
|
||||||
expect(html).not.toContain("已入库 0/12");
|
expect(html).not.toContain("已整理 0/12");
|
||||||
expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected/);
|
expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[
|
|||||||
overview: null,
|
overview: null,
|
||||||
summary: (
|
summary: (
|
||||||
<StatusBadge tone={s.episodeCount && s.inLibrary >= s.episodeCount ? "success" : "neutral"}>
|
<StatusBadge tone={s.episodeCount && s.inLibrary >= s.episodeCount ? "success" : "neutral"}>
|
||||||
已入库 {s.inLibrary}/{s.episodeCount}
|
已整理 {s.inLibrary}/{s.episodeCount}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -171,7 +171,7 @@ export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[
|
|||||||
runtime: null,
|
runtime: null,
|
||||||
stillPath: null,
|
stillPath: null,
|
||||||
voteAverage: null,
|
voteAverage: null,
|
||||||
status: e.inLibrary ? { label: "已入库", tone: "success" } : { label: "未入库", tone: "neutral" },
|
status: e.inLibrary ? { label: "已整理", tone: "success" } : { label: "未整理", tone: "neutral" },
|
||||||
extra: originText(s.seasonNumber, e) ? (
|
extra: originText(s.seasonNumber, e) ? (
|
||||||
<p className="mt-0.5 font-mono text-meta text-muted-foreground">
|
<p className="mt-0.5 font-mono text-meta text-muted-foreground">
|
||||||
{originText(s.seasonNumber, e)}
|
{originText(s.seasonNumber, e)}
|
||||||
@@ -228,7 +228,7 @@ export function EpisodeStructureSwitcher({ entityId, children }: { entityId: str
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-dashed border-border px-3 py-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-dashed border-border px-3 py-2">
|
||||||
<p className="text-caption text-muted-foreground">
|
<p className="text-caption text-muted-foreground">
|
||||||
预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已入库按 TMDB 原季集对照。
|
预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已整理按 TMDB 原季集对照。
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" disabled={setGroup.isPending} onClick={() => setConfirm(true)}>
|
<Button size="sm" disabled={setGroup.isPending} onClick={() => setConfirm(true)}>
|
||||||
改用这套结构
|
改用这套结构
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export function ExternalTab() {
|
|||||||
empty={{
|
empty={{
|
||||||
icon: Fingerprint,
|
icon: Fingerprint,
|
||||||
title: "没有外部身份",
|
title: "没有外部身份",
|
||||||
description: "没有外部编号的作品也能入库。",
|
description: "没有外部编号的作品也能整理。",
|
||||||
}}
|
}}
|
||||||
hasMore={list.hasNextPage}
|
hasMore={list.hasNextPage}
|
||||||
onLoadMore={() => list.fetchNextPage()}
|
onLoadMore={() => list.fetchNextPage()}
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ export const DOWNLOAD_STATUS: Record<DownloadStatus, ToneLabel> = {
|
|||||||
paused: { label: "已暂停", tone: "warning" },
|
paused: { label: "已暂停", tone: "warning" },
|
||||||
stalled: { label: "无进展", tone: "warning" },
|
stalled: { label: "无进展", tone: "warning" },
|
||||||
completed: { label: "下载完成", tone: "neutral" },
|
completed: { label: "下载完成", tone: "neutral" },
|
||||||
importing: { label: "入库中", tone: "neutral" },
|
importing: { label: "整理中", tone: "neutral" },
|
||||||
imported: { label: "已入库", tone: "success" },
|
imported: { label: "已整理", tone: "success" },
|
||||||
failed: { label: "失败", tone: "destructive" },
|
failed: { label: "失败", tone: "destructive" },
|
||||||
removed: { label: "已移除", tone: "neutral" },
|
removed: { label: "已移除", tone: "neutral" },
|
||||||
missing: { label: "下载器中缺失", tone: "warning" },
|
missing: { label: "下载器中缺失", tone: "warning" },
|
||||||
@@ -334,10 +334,10 @@ export const opts = <K extends string>(rec: Record<K, string | { label: string }
|
|||||||
|
|
||||||
/** 收录版本 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
|
/** 收录版本 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
|
||||||
const SLOT_REASON: Record<string, string> = {
|
const SLOT_REASON: Record<string, string> = {
|
||||||
"blocked:no_main_video": "没有可入库的主视频",
|
"blocked:no_main_video": "没有可整理的主视频",
|
||||||
"deferred:slot_busy": "已有计划在途,等它结束再评估",
|
"deferred:slot_busy": "已有计划在途,等它结束再评估",
|
||||||
verified: "已核验入库",
|
verified: "已核验整理",
|
||||||
verified_partial: "部分核验入库",
|
verified_partial: "部分核验整理",
|
||||||
hard_conditions_met: "硬条件全部满足",
|
hard_conditions_met: "硬条件全部满足",
|
||||||
};
|
};
|
||||||
export function slotReasonText(raw: string): string {
|
export function slotReasonText(raw: string): string {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const ROLE: Record<string, string> = {
|
|||||||
episode_thumb: "剧照",
|
episode_thumb: "剧照",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE: Record<string, string> = { library: "已入库的作品", auto: "按路径识别", chosen: "手动指定" };
|
const SOURCE: Record<string, string> = { library: "已整理的作品", auto: "按路径识别", chosen: "手动指定" };
|
||||||
|
|
||||||
export function scrapeCounts(s: Pick<ScrapeRecord, "written" | "skipped" | "failed">): string {
|
export function scrapeCounts(s: Pick<ScrapeRecord, "written" | "skipped" | "failed">): string {
|
||||||
return [
|
return [
|
||||||
@@ -153,7 +153,7 @@ export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
|
|||||||
title="刮削"
|
title="刮削"
|
||||||
description={
|
description={
|
||||||
s?.finishedAt
|
s?.finishedAt
|
||||||
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "入库后自动" : "手动"}`
|
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "整理后自动" : "手动"}`
|
||||||
: "NFO 和图片写进作品目录,Emby、Jellyfin、Kodi 直接读取。"
|
: "NFO 和图片写进作品目录,Emby、Jellyfin、Kodi 直接读取。"
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ function VersionDetail({ v, match }: { v: AssetVersion; match?: UnitMatch | unde
|
|||||||
{ label: "字幕", value: v.subtitleLanguages.map(langLabel).join("、") || null },
|
{ label: "字幕", value: v.subtitleLanguages.map(langLabel).join("、") || null },
|
||||||
{ label: "时长", value: duration(v.durationSec) },
|
{ label: "时长", value: duration(v.durationSec) },
|
||||||
{ label: "色深", value: v.bitDepth ? `${v.bitDepth} bit` : null },
|
{ label: "色深", value: v.bitDepth ? `${v.bitDepth} bit` : null },
|
||||||
{ label: "入库时间", value: formatDateTime(v.organizedAt) },
|
{ label: "整理时间", value: formatDateTime(v.organizedAt) },
|
||||||
{
|
{
|
||||||
label: "下载任务",
|
label: "下载任务",
|
||||||
value: v.downloadTaskId ? (
|
value: v.downloadTaskId ? (
|
||||||
@@ -192,7 +192,7 @@ export function VersionList({
|
|||||||
<PaginatedList
|
<PaginatedList
|
||||||
rows={versions}
|
rows={versions}
|
||||||
as="ul"
|
as="ul"
|
||||||
label="入库文件版本"
|
label="已整理文件版本"
|
||||||
resetKey={match?.assetId ?? "all"}
|
resetKey={match?.assetId ?? "all"}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
className="space-y-0.5"
|
className="space-y-0.5"
|
||||||
@@ -296,7 +296,7 @@ export function MovieVersionsPanel({ entityId }: { entityId: string }) {
|
|||||||
);
|
);
|
||||||
const state = match?.state === "satisfied" ? `符合「${target?.name}」` : gapLine(match);
|
const state = match?.state === "satisfied" ? `符合「${target?.name}」` : gapLine(match);
|
||||||
return (
|
return (
|
||||||
<Panel title="入库版本" description={[summaryText(data.summary), state].filter(Boolean).join(" · ")}>
|
<Panel title="已整理版本" description={[summaryText(data.summary), state].filter(Boolean).join(" · ")}>
|
||||||
<div className="-mx-2">
|
<div className="-mx-2">
|
||||||
<VersionList versions={versions} match={match} />
|
<VersionList versions={versions} match={match} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ export const EXCLUDE_REASON: Record<string, string> = {
|
|||||||
below_min_size: "低于最小体积",
|
below_min_size: "低于最小体积",
|
||||||
sample: "样片",
|
sample: "样片",
|
||||||
too_small: "体积过小",
|
too_small: "体积过小",
|
||||||
not_imported_kind: "不入库的文件类型",
|
not_imported_kind: "不整理的文件类型",
|
||||||
orphan_sidecar: "孤立附属文件",
|
orphan_sidecar: "孤立附属文件",
|
||||||
no_episode: "解析不出集号",
|
no_episode: "解析不出集号",
|
||||||
special_extra: "特典/花絮",
|
special_extra: "特典/花絮",
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
|||||||
<p className="text-meta text-muted-foreground">{source.scanSummary}</p>
|
<p className="text-meta text-muted-foreground">{source.scanSummary}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<FormSelect
|
<FormSelect
|
||||||
label={inPlace ? "入库位置" : "来源配置"}
|
label={inPlace ? "整理位置" : "来源配置"}
|
||||||
value={inPlace ? "none" : (profile?.id ?? (ambiguous ? "choose" : "none"))}
|
value={inPlace ? "none" : (profile?.id ?? (ambiguous ? "choose" : "none"))}
|
||||||
onChange={pickProfile}
|
onChange={pickProfile}
|
||||||
choices={[
|
choices={[
|
||||||
@@ -631,9 +631,9 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
choices={[
|
choices={[
|
||||||
{ value: "in_place", label: "原地入库 · 保留位置与名称", disabled: !canInPlace },
|
{ value: "in_place", label: "原地整理 · 保留位置与名称", disabled: !canInPlace },
|
||||||
{ value: "copy", label: "复制 · 保留源文件" },
|
{ value: "copy", label: "复制 · 保留源文件" },
|
||||||
{ value: "move", label: "移动 · 入库后删除源文件", disabled: hnrCount > 0 },
|
{ value: "move", label: "移动 · 整理后删除源文件", disabled: hnrCount > 0 },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{!inPlace ? (
|
{!inPlace ? (
|
||||||
@@ -861,7 +861,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (submitting.current) return;
|
if (submitting.current) return;
|
||||||
onClose();
|
onClose();
|
||||||
navigate("/console/organize?tab=queue");
|
navigate("/console/organize?queue=1");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileStack />
|
<FileStack />
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function StepOptions({
|
|||||||
onSelect={() => set({ action: "move" })}
|
onSelect={() => set({ action: "move" })}
|
||||||
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
|
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
|
||||||
title="移动"
|
title="移动"
|
||||||
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
|
hint="整理后删源;跨卷时按复制 → 核验 → 删源"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -207,7 +207,7 @@ export function StepOptions({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="入库后">
|
<Section title="整理后">
|
||||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-border px-3 py-2">
|
<div className="flex items-start justify-between gap-3 rounded-xl border border-border px-3 py-2">
|
||||||
<span className="text-body">
|
<span className="text-body">
|
||||||
刷新元数据
|
刷新元数据
|
||||||
@@ -216,7 +216,7 @@ export function StepOptions({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={options.refreshMetadata}
|
checked={options.refreshMetadata}
|
||||||
onCheckedChange={(v) => set({ refreshMetadata: v })}
|
onCheckedChange={(v) => set({ refreshMetadata: v })}
|
||||||
aria-label="入库后刷新元数据"
|
aria-label="整理后刷新元数据"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-meta text-muted-foreground">
|
<p className="text-meta text-muted-foreground">
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ function FileLine({ f, roots, nested }: { f: GroupFile; roots: FileRoot[]; neste
|
|||||||
) : f.target === f.source ? (
|
) : f.target === f.source ? (
|
||||||
<StatusBadge>原地登记</StatusBadge>
|
<StatusBadge>原地登记</StatusBadge>
|
||||||
) : !f.target ? (
|
) : !f.target ? (
|
||||||
<StatusBadge>不入库</StatusBadge>
|
<StatusBadge>不整理</StatusBadge>
|
||||||
) : null;
|
) : null;
|
||||||
return (
|
return (
|
||||||
<li className={cn("min-w-0 py-1", nested && "pl-5")}>
|
<li className={cn("min-w-0 py-1", nested && "pl-5")}>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function Summary({
|
|||||||
{files.length ? (
|
{files.length ? (
|
||||||
<p>
|
<p>
|
||||||
{files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。
|
{files.join("、")}({formatBytes(plan.bytes, 1)})会移入回收站,可以在回收站还原。
|
||||||
{plan.assets ? `其中 ${plan.assets} 个是已入库文件,会从媒体库移除。` : ""}
|
{plan.assets ? `其中 ${plan.assets} 个是已整理文件,会从媒体库移除。` : ""}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p>{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}</p>
|
<p>{withFiles ? "没有找到还在的文件,只删记录。" : "只删记录,文件不动。"}</p>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { EngineRun } from "@umt/contracts";
|
import type { EngineRun } from "@umt/contracts";
|
||||||
import { FileStack, RefreshCw } from "lucide-react";
|
import { FileStack, ListOrdered, Loader2, RefreshCw } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { useDownloadTask } from "~/api/downloads.ts";
|
import { useDownloadTask } from "~/api/downloads.ts";
|
||||||
import { useEngineRun, useEngineRuns } from "~/api/engine.ts";
|
import { useEngineRun, useEngineRuns, useEngineRunsPage } from "~/api/engine.ts";
|
||||||
import { useScrapeRecords } from "~/api/files.ts";
|
import { useScrapeRecords } from "~/api/files.ts";
|
||||||
import { ACTION_LABEL, useRecord } from "~/api/records.ts";
|
import { ACTION_LABEL, useRecord } from "~/api/records.ts";
|
||||||
import { useRecordsForDownload } from "~/api/tasks.ts";
|
import { useRecordsForDownload } from "~/api/tasks.ts";
|
||||||
@@ -13,9 +13,11 @@ import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
|||||||
import { relTime } from "~/components/library/parts.tsx";
|
import { relTime } from "~/components/library/parts.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
import { useSse } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import { organizeQueueStage, organizeRunInput, scrapeQueueStage } from "./queue-model.ts";
|
import { organizeQueueStage, organizeRunInput, scrapeQueueStage } from "./queue-model.ts";
|
||||||
|
import { queueCountLabel } from "./record-filter-model.ts";
|
||||||
import { recordFileProgress, recordTitle } from "./record-parts.ts";
|
import { recordFileProgress, recordTitle } from "./record-parts.ts";
|
||||||
|
|
||||||
function ScrapeQueueItem({ run }: { run: EngineRun }) {
|
function ScrapeQueueItem({ run }: { run: EngineRun }) {
|
||||||
@@ -81,7 +83,7 @@ function QueueItem({ run }: { run: EngineRun }) {
|
|||||||
const download = useDownloadTask(downloadId);
|
const download = useDownloadTask(downloadId);
|
||||||
const record = recordQuery.data ?? matchingRecord;
|
const record = recordQuery.data ?? matchingRecord;
|
||||||
const progress = record ? recordFileProgress(record) : null;
|
const progress = record ? recordFileProgress(record) : null;
|
||||||
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验入库任务");
|
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验整理任务");
|
||||||
const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath;
|
const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath;
|
||||||
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
|
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
|
||||||
return (
|
return (
|
||||||
@@ -135,18 +137,19 @@ function QueueItem({ run }: { run: EngineRun }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Existing engine workflows are authoritative, including jobs that have not created a record yet. */
|
const QUEUE_FILTER = { workflowName: "verifyAndImport,scrapeMedia", status: "pending,running,sleeping" } as const;
|
||||||
export function OrganizeQueue() {
|
|
||||||
|
function useQueueInvalidation() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const runs = useEngineRuns(
|
|
||||||
{ workflowName: "verifyAndImport,scrapeMedia", status: "pending,running,sleeping" },
|
|
||||||
true,
|
|
||||||
5_000,
|
|
||||||
);
|
|
||||||
useSse(["task.created", "task.status_changed", "organization.changed", "download.status_changed"], () => {
|
useSse(["task.created", "task.status_changed", "organization.changed", "download.status_changed"], () => {
|
||||||
void qc.invalidateQueries({ queryKey: qk.all("engine") });
|
void qc.invalidateQueries({ queryKey: qk.all("engine") });
|
||||||
void qc.invalidateQueries({ queryKey: ["records"] });
|
void qc.invalidateQueries({ queryKey: ["records"] });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排队与执行中的整理 / 刮削任务;以引擎运行为准(含还没生成整理记录的任务) */
|
||||||
|
export function OrganizeQueue() {
|
||||||
|
const runs = useEngineRuns(QUEUE_FILTER, true, 5_000);
|
||||||
const rows = runs.data?.pages.flatMap((page) => page.items);
|
const rows = runs.data?.pages.flatMap((page) => page.items);
|
||||||
const paged = useListPagination(rows, {
|
const paged = useListPagination(rows, {
|
||||||
hasMore: runs.hasNextPage,
|
hasMore: runs.hasNextPage,
|
||||||
@@ -157,10 +160,7 @@ export function OrganizeQueue() {
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<section aria-label="排队与执行中的整理任务" className="space-y-3">
|
<section aria-label="排队与执行中的整理任务" className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex justify-end">
|
||||||
<p className="text-caption text-muted-foreground">
|
|
||||||
下载核验、手动与自动整理、整理后的 NFO / 图片刮削在此排队。整理完成后进入记录,刮削继续显示到任务结束。
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" size="sm" disabled={runs.isFetching} onClick={() => void runs.refetch()}>
|
<Button variant="outline" size="sm" disabled={runs.isFetching} onClick={() => void runs.refetch()}>
|
||||||
<RefreshCw />
|
<RefreshCw />
|
||||||
刷新
|
刷新
|
||||||
@@ -187,3 +187,44 @@ export function OrganizeQueue() {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COUNT_LIMIT = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整理队列入口(对照 MP 整理历史页的「整理队列」按钮):标题栏一个按钮写当前排队 + 执行中的任务数,
|
||||||
|
* 点开右侧面板看队列。面板开关由页面放在 URL(?queue=1),旧链接 ?tab=queue 也落到这里。
|
||||||
|
*/
|
||||||
|
export function OrganizeQueueButton({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
useQueueInvalidation();
|
||||||
|
const page = useEngineRunsPage(QUEUE_FILTER, COUNT_LIMIT, 10_000);
|
||||||
|
const items = page.data?.items ?? [];
|
||||||
|
const running = items.some((r) => r.status === "running");
|
||||||
|
const count = queueCountLabel(items.length, !!page.data?.nextCursor);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={count ? `整理队列:${count} 个任务` : "整理队列"}
|
||||||
|
onClick={() => onOpenChange(true)}
|
||||||
|
>
|
||||||
|
{running ? <Loader2 className="animate-spin" /> : <ListOrdered />}
|
||||||
|
队列
|
||||||
|
{count ? <span className="tabular-nums">{count}</span> : null}
|
||||||
|
</Button>
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="w-full gap-0 sm:max-w-xl">
|
||||||
|
<SheetHeader className="pr-12">
|
||||||
|
<SheetTitle>整理队列</SheetTitle>
|
||||||
|
<SheetDescription className="text-meta">
|
||||||
|
下载核验、手动与自动整理、整理后的 NFO / 图片刮削在这里排队。整理完成后进入整理记录,刮削显示到任务结束。
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{open ? <OrganizeQueue /> : null}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ const decision = (over: Partial<NonNullable<RecordAttention["decision"]>> = {})
|
|||||||
{ key: "candidate_0", label: "Foo (2019)", input: { candidate: { mediaEntityId: R } } },
|
{ key: "candidate_0", label: "Foo (2019)", input: { candidate: { mediaEntityId: R } } },
|
||||||
{ key: "select_entity", label: "选择作品(本地)", input: { requires: "mediaEntityId" } },
|
{ key: "select_entity", label: "选择作品(本地)", input: { requires: "mediaEntityId" } },
|
||||||
{ key: "select_tmdb", label: "选择作品(TMDB)", input: { requires: "tmdb" } },
|
{ key: "select_tmdb", label: "选择作品(TMDB)", input: { requires: "tmdb" } },
|
||||||
{ key: "provisional", label: "作为临时作品入库", input: {} },
|
{ key: "provisional", label: "作为临时作品整理", input: {} },
|
||||||
{ key: "dismiss", label: "忽略" },
|
{ key: "dismiss", label: "忽略" },
|
||||||
],
|
],
|
||||||
agentRunId: null,
|
agentRunId: null,
|
||||||
@@ -54,7 +54,7 @@ describe("整理记录行内处理", () => {
|
|||||||
for (const t of ["指定作品", "让智能体识别", "重新整理", "忽略", "Foo (2019)"]) expect(out).toContain(t);
|
for (const t of ["指定作品", "让智能体识别", "重新整理", "忽略", "Foo (2019)"]) expect(out).toContain(t);
|
||||||
// 「选择作品(本地 / TMDB)」并进「指定作品」,不单列;临时作品不在行内
|
// 「选择作品(本地 / TMDB)」并进「指定作品」,不单列;临时作品不在行内
|
||||||
expect(out).not.toContain("选择作品(本地)");
|
expect(out).not.toContain("选择作品(本地)");
|
||||||
expect(out).not.toContain("作为临时作品入库");
|
expect(out).not.toContain("作为临时作品整理");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("智能体识别中:显示进度与会话入口,不再给指定 / 识别按钮", () => {
|
it("智能体识别中:显示进度与会话入口,不再给指定 / 识别按钮", () => {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { RECORD_STATUS_LABEL, TRIGGER_LABEL } from "~/api/records.ts";
|
||||||
|
import { buildDims, filterTags, normalizeLegacyParams, queueCountLabel } from "./record-filter-model.ts";
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
statusLabel: RECORD_STATUS_LABEL,
|
||||||
|
triggerLabel: TRIGGER_LABEL,
|
||||||
|
rootName: (id: string) => ({ r1: "电影库", r2: "剧集库" })[id] ?? "已移除的目录",
|
||||||
|
facets: {
|
||||||
|
action: [
|
||||||
|
{ value: "copy", n: 12 },
|
||||||
|
{ value: "move", n: 3 },
|
||||||
|
],
|
||||||
|
libraryRootId: [
|
||||||
|
{ value: "r1", n: 10 },
|
||||||
|
{ value: "r2", n: 5 },
|
||||||
|
],
|
||||||
|
category: [{ value: "欧美电影", n: 7 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("整理记录筛选维度", () => {
|
||||||
|
it("维度顺序与选项:状态不含排队 / 执行中,整理方式与目录来自统计并带条数", () => {
|
||||||
|
const dims = buildDims({}, ctx);
|
||||||
|
expect(dims.map((d) => d.label)).toEqual(["状态", "类型", "整理方式", "媒体库目录", "分类", "来源", "时间"]);
|
||||||
|
expect(dims[0]!.options.map((o) => o.value)).toEqual(["succeeded", "partial", "failed", "cancelled"]);
|
||||||
|
expect(dims[2]!.options).toEqual([
|
||||||
|
{ value: "copy", label: "复制", n: 12 },
|
||||||
|
{ value: "move", label: "移动", n: 3 },
|
||||||
|
]);
|
||||||
|
expect(dims[3]!.options.map((o) => o.label)).toEqual(["电影库", "剧集库"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已选值当前没有记录时仍列出,免得选中项消失", () => {
|
||||||
|
const dims = buildDims({ category: "纪录片", action: "in_place" }, ctx);
|
||||||
|
expect(dims.find((d) => d.key === "category")!.options).toContainEqual({ value: "纪录片", label: "纪录片", n: 0 });
|
||||||
|
expect(dims.find((d) => d.key === "action")!.options).toContainEqual({
|
||||||
|
value: "in_place",
|
||||||
|
label: "原地整理",
|
||||||
|
n: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("统计还没回来时整理方式列全部,目录与分类为空", () => {
|
||||||
|
const dims = buildDims({}, { ...ctx, facets: undefined });
|
||||||
|
expect(dims.find((d) => d.key === "action")!.options.map((o) => o.label)).toEqual([
|
||||||
|
"复制",
|
||||||
|
"移动",
|
||||||
|
"硬链接",
|
||||||
|
"原地整理",
|
||||||
|
]);
|
||||||
|
expect(dims.find((d) => d.key === "root")!.options).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("已选条件标签", () => {
|
||||||
|
it("本次整理、作品、需要处理在前,其余按维度顺序,关键词最后", () => {
|
||||||
|
const v = { status: "failed", root: "r1", category: "欧美电影", attention: "1", q: "Arrival" };
|
||||||
|
const tags = filterTags(v, buildDims(v, ctx), { entityId: "e1", entityTitle: "沙丘", pinned: 0 });
|
||||||
|
expect(tags.map((t) => `${t.dimLabel}:${t.label}`)).toEqual([
|
||||||
|
"作品:沙丘",
|
||||||
|
"只看:需要处理",
|
||||||
|
"状态:失败",
|
||||||
|
"媒体库目录:电影库",
|
||||||
|
"分类:欧美电影",
|
||||||
|
"关键词:Arrival",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有条件时没有标签;作品标题没取到时写「已选作品」", () => {
|
||||||
|
expect(filterTags({}, buildDims({}, ctx), {})).toEqual([]);
|
||||||
|
expect(filterTags({}, buildDims({}, ctx), { entityId: "e1" })[0]).toMatchObject({ label: "已选作品" });
|
||||||
|
expect(filterTags({}, buildDims({}, ctx), { pinned: 3 })[0]).toMatchObject({ dimLabel: "本次整理", label: "3 条" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("旧链接与队列按钮", () => {
|
||||||
|
it("?tab=queue 打开队列面板,?tab=records 去掉,其余参数保留", () => {
|
||||||
|
expect(normalizeLegacyParams(new URLSearchParams("tab=queue"))?.toString()).toBe("queue=1");
|
||||||
|
expect(normalizeLegacyParams(new URLSearchParams("tab=records&attention=1"))?.toString()).toBe("attention=1");
|
||||||
|
expect(normalizeLegacyParams(new URLSearchParams("status=failed"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("队列数:0 不写,超过一页写 N+", () => {
|
||||||
|
expect(queueCountLabel(0, false)).toBeNull();
|
||||||
|
expect(queueCountLabel(3, false)).toBe("3");
|
||||||
|
expect(queueCountLabel(100, true)).toBe("100+");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { OrganizationStats } from "@umt/contracts";
|
||||||
|
|
||||||
|
// 整理记录筛选(对照 MP 的维度按钮 + 可删条件标签):每个维度一个按钮,点开单选;已选条件排成可移除的标签。
|
||||||
|
// 条件都写在 URL 里(刷新、分享不丢);旧链接 ?tab=queue / ?tab=records 换成单页 + 队列面板(?queue=1)。
|
||||||
|
|
||||||
|
export const RECORD_FILTER_KEYS = [
|
||||||
|
"status",
|
||||||
|
"kind",
|
||||||
|
"action",
|
||||||
|
"root",
|
||||||
|
"category",
|
||||||
|
"trigger",
|
||||||
|
"time",
|
||||||
|
"q",
|
||||||
|
"group",
|
||||||
|
"attention",
|
||||||
|
] as const;
|
||||||
|
export type RecordFilterKey = (typeof RECORD_FILTER_KEYS)[number];
|
||||||
|
|
||||||
|
export const KIND_LABEL = { movie: "电影", series: "剧集", none: "未识别" } as const;
|
||||||
|
export const TIME_LABEL = { today: "今天", "7d": "最近 7 天", "30d": "最近 30 天" } as const;
|
||||||
|
/** 整理记录能筛的整理方式(不含文件管理的改名、删除、还原) */
|
||||||
|
export const ORGANIZE_ACTION_LABEL = { copy: "复制", move: "移动", hardlink: "硬链接", in_place: "原地整理" } as const;
|
||||||
|
|
||||||
|
export type FilterOptionN = { value: string; label: string; n?: number };
|
||||||
|
|
||||||
|
/** 一个维度按钮:单选,value 为空 = 不限 */
|
||||||
|
export type FilterDim = {
|
||||||
|
key: "status" | "kind" | "action" | "root" | "category" | "trigger" | "time";
|
||||||
|
label: string;
|
||||||
|
options: FilterOptionN[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 可移除的条件标签 */
|
||||||
|
export type FilterTag = { key: string; dimLabel: string; label: string };
|
||||||
|
|
||||||
|
type Values = Partial<Record<RecordFilterKey, string>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 维度按钮的选项。整理方式 / 媒体库目录 / 分类只列有记录的值(来自统计的 facets,带条数);
|
||||||
|
* 已选但当前没有记录的值仍然列出,免得选中项"消失"。
|
||||||
|
*/
|
||||||
|
export function buildDims(
|
||||||
|
v: Values,
|
||||||
|
ctx: {
|
||||||
|
statusLabel: Record<string, string>;
|
||||||
|
triggerLabel: Record<string, string>;
|
||||||
|
facets?: OrganizationStats["facets"];
|
||||||
|
rootName: (id: string) => string;
|
||||||
|
},
|
||||||
|
): FilterDim[] {
|
||||||
|
const facet = (list: { value: string; n: number }[] | undefined, chosen: string, label: (x: string) => string) => {
|
||||||
|
const opts: FilterOptionN[] = (list ?? []).map((x) => ({ value: x.value, label: label(x.value), n: x.n }));
|
||||||
|
if (chosen && !opts.some((o) => o.value === chosen)) opts.push({ value: chosen, label: label(chosen), n: 0 });
|
||||||
|
return opts;
|
||||||
|
};
|
||||||
|
const entries = (o: Record<string, string>) => Object.entries(o).map(([value, label]) => ({ value, label }));
|
||||||
|
const actionLabel = (x: string) => ORGANIZE_ACTION_LABEL[x as keyof typeof ORGANIZE_ACTION_LABEL] ?? x;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
options: entries(ctx.statusLabel).filter((o) => o.value !== "queued" && o.value !== "running"),
|
||||||
|
},
|
||||||
|
{ key: "kind", label: "类型", options: entries(KIND_LABEL) },
|
||||||
|
{
|
||||||
|
key: "action",
|
||||||
|
label: "整理方式",
|
||||||
|
options: ctx.facets ? facet(ctx.facets.action, v.action ?? "", actionLabel) : entries(ORGANIZE_ACTION_LABEL),
|
||||||
|
},
|
||||||
|
{ key: "root", label: "媒体库目录", options: facet(ctx.facets?.libraryRootId, v.root ?? "", ctx.rootName) },
|
||||||
|
{ key: "category", label: "分类", options: facet(ctx.facets?.category, v.category ?? "", (x) => x) },
|
||||||
|
{ key: "trigger", label: "来源", options: entries(ctx.triggerLabel) },
|
||||||
|
{ key: "time", label: "时间", options: entries(TIME_LABEL) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已选条件 → 标签(按维度顺序;作品、需要处理、本次整理的记录也算条件) */
|
||||||
|
export function filterTags(
|
||||||
|
v: Values,
|
||||||
|
dims: FilterDim[],
|
||||||
|
extra: { entityTitle?: string | null; entityId?: string | null; pinned?: number },
|
||||||
|
): FilterTag[] {
|
||||||
|
const tags: FilterTag[] = [];
|
||||||
|
if (extra.pinned) tags.push({ key: "ids", dimLabel: "本次整理", label: `${extra.pinned} 条` });
|
||||||
|
if (extra.entityId) tags.push({ key: "mediaEntityId", dimLabel: "作品", label: extra.entityTitle || "已选作品" });
|
||||||
|
if (v.attention === "1") tags.push({ key: "attention", dimLabel: "只看", label: "需要处理" });
|
||||||
|
for (const d of dims) {
|
||||||
|
const value = v[d.key];
|
||||||
|
if (!value) continue;
|
||||||
|
tags.push({ key: d.key, dimLabel: d.label, label: d.options.find((o) => o.value === value)?.label ?? value });
|
||||||
|
}
|
||||||
|
if (v.q) tags.push({ key: "q", dimLabel: "关键词", label: v.q });
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧链接兼容:整理页曾分「整理队列」「整理记录」两个标签页。
|
||||||
|
* ?tab=queue → 打开队列面板(?queue=1);?tab=records 或其它值 → 去掉。不需要改时返回 null。
|
||||||
|
*/
|
||||||
|
export function normalizeLegacyParams(params: URLSearchParams): URLSearchParams | null {
|
||||||
|
const tab = params.get("tab");
|
||||||
|
if (tab === null) return null;
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.delete("tab");
|
||||||
|
if (tab === "queue") next.set("queue", "1");
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 队列按钮上的数:0 不写;超过一页写 N+ */
|
||||||
|
export function queueCountLabel(n: number, more: boolean): string | null {
|
||||||
|
if (!n) return null;
|
||||||
|
return more ? `${n}+` : String(n);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Check, ChevronDown, X } from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||||
|
import { cn } from "~/lib/utils.ts";
|
||||||
|
import type { FilterDim, FilterTag } from "./record-filter-model.ts";
|
||||||
|
|
||||||
|
// 整理记录筛选条:一行可横向滚动的维度按钮(单选弹层),下面一行已选条件标签(点一下移除)+「清除」。
|
||||||
|
// 外观与资源快速筛选(components/search/quick-filter-bar.tsx)一致。
|
||||||
|
|
||||||
|
export function RecordFilterChips({
|
||||||
|
dims,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
dims: FilterDim[];
|
||||||
|
values: Partial<Record<FilterDim["key"], string>>;
|
||||||
|
onChange: (key: FilterDim["key"], value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="筛选维度"
|
||||||
|
className="scroll-bleed -mx-1 flex min-w-0 gap-1.5 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]"
|
||||||
|
>
|
||||||
|
{dims
|
||||||
|
// 没有可选值又没选时不显示(比如还没有带分类的记录);随筛选变化按钮不跳动,只有一个值也照常显示
|
||||||
|
.filter((d) => d.options.length > 0 || !!values[d.key])
|
||||||
|
.map((d) => (
|
||||||
|
<DimButton key={d.key} dim={d} value={values[d.key] ?? ""} onChange={(x) => onChange(d.key, x)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DimButton({ dim, value, onChange }: { dim: FilterDim; value: string; onChange: (v: string) => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const pick = (x: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
onChange(x);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Chip label={dim.label} active={!!value} />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-56 p-1" aria-label={dim.label}>
|
||||||
|
<div className="flex max-h-[min(60dvh,22rem)] flex-col overflow-y-auto">
|
||||||
|
<OptionRow on={!value} label="不限" onClick={() => pick("")} />
|
||||||
|
{dim.options.map((o) => (
|
||||||
|
<OptionRow
|
||||||
|
key={o.value}
|
||||||
|
on={value === o.value}
|
||||||
|
label={o.label}
|
||||||
|
n={o.n}
|
||||||
|
onClick={() => pick(value === o.value ? "" : o.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionRow({ on, label, n, onClick }: { on: boolean; label: string; n?: number; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={on}
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm text-foreground transition-colors duration-micro hover:bg-foreground/[0.06] md:py-1.5"
|
||||||
|
>
|
||||||
|
<span aria-hidden className="grid h-4 w-4 shrink-0 place-items-center">
|
||||||
|
{on ? <Check className="h-3.5 w-3.5" /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
{n ? <span className="text-meta text-muted-foreground tabular-nums">{n}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 维度按钮;Popover 的 asChild 需要转发 ref 与事件,所以透传其余属性 */
|
||||||
|
function Chip({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: React.ComponentProps<"button"> & { label: string; active: 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]",
|
||||||
|
active
|
||||||
|
? "pill-selected text-foreground"
|
||||||
|
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已选条件:每个一个可移除标签,末尾一个「清除」 */
|
||||||
|
export function RecordFilterTags({
|
||||||
|
tags,
|
||||||
|
onRemove,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
tags: FilterTag[];
|
||||||
|
onRemove: (key: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
if (!tags.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
|
{tags.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(t.key)}
|
||||||
|
aria-label={`移除筛选 ${t.dimLabel} ${t.label}`}
|
||||||
|
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-full bg-foreground/[0.06] pr-1.5 pl-2.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.1]"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{t.dimLabel}</span>
|
||||||
|
<span className="max-w-40 truncate">{t.label}</span>
|
||||||
|
<X className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Button variant="ghost" size="sm" className="h-7" onClick={onClear}>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ describe("整理记录列表:整理方式、分类、分组", () => {
|
|||||||
"移动",
|
"移动",
|
||||||
"复制",
|
"复制",
|
||||||
"硬链接",
|
"硬链接",
|
||||||
"原地入库",
|
"原地整理",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ describe("库内状态文案", () => {
|
|||||||
it("未收录 / 已建档 / 已收录 / 正在下载;收录版本满足与缺失", () => {
|
it("未收录 / 已建档 / 已收录 / 正在下载;收录版本满足与缺失", () => {
|
||||||
expect(inventoryBadge(inv({})).label).toBe("不在媒体库");
|
expect(inventoryBadge(inv({})).label).toBe("不在媒体库");
|
||||||
expect(inventoryBadge(inv({ state: "tracked" })).label).toBe("在媒体库 · 未下载");
|
expect(inventoryBadge(inv({ state: "tracked" })).label).toBe("在媒体库 · 未下载");
|
||||||
expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已入库 · 2 个版本");
|
expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已整理 · 2 个版本");
|
||||||
expect(inventoryBadge(inv({ state: "downloading", downloadingCount: 1 })).label).toBe("正在下载 1 个");
|
expect(inventoryBadge(inv({ state: "downloading", downloadingCount: 1 })).label).toBe("正在下载 1 个");
|
||||||
expect(
|
expect(
|
||||||
slotLine(inv({ state: "owned", slotTotal: 2, slotSatisfied: 1, missing: [{ slotId: ID, name: "2160p" }] })),
|
slotLine(inv({ state: "owned", slotTotal: 2, slotSatisfied: 1, missing: [{ slotId: ID, name: "2160p" }] })),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function inventoryBadge(inv: WorkInventory): { label: string; tone: Statu
|
|||||||
case "downloading":
|
case "downloading":
|
||||||
return { label: `正在下载 ${inv.downloadingCount} 个`, tone: "warning" };
|
return { label: `正在下载 ${inv.downloadingCount} 个`, tone: "warning" };
|
||||||
case "owned":
|
case "owned":
|
||||||
return { label: `已入库 · ${inv.versionCount} 个版本`, tone: "success" };
|
return { label: `已整理 · ${inv.versionCount} 个版本`, tone: "success" };
|
||||||
default:
|
default:
|
||||||
return { label: "在媒体库 · 未下载", tone: "neutral" };
|
return { label: "在媒体库 · 未下载", tone: "neutral" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function SubscriptionMenu({
|
|||||||
open={confirmReset}
|
open={confirmReset}
|
||||||
onOpenChange={setConfirmReset}
|
onOpenChange={setConfirmReset}
|
||||||
title="重置这个订阅的进度?"
|
title="重置这个订阅的进度?"
|
||||||
description="取消还没提交、或下载任务已不在的计划,清掉延后记录,然后重新评估。仍在下载的任务、已入库文件和总集数保留。"
|
description="取消还没提交、或下载任务已不在的计划,清掉延后记录,然后重新评估。仍在下载的任务、已整理文件和总集数保留。"
|
||||||
confirmLabel="重置"
|
confirmLabel="重置"
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
ops.reset(sub.id).catch((e) => {
|
ops.reset(sub.id).catch((e) => {
|
||||||
@@ -216,7 +216,7 @@ export function DeleteSubscriptionsDialog({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={ids.length > 1 ? `删除 ${ids.length} 个订阅?` : "删除这个订阅?"}
|
title={ids.length > 1 ? `删除 ${ids.length} 个订阅?` : "删除这个订阅?"}
|
||||||
description="不再搜索和追更。已入库文件、收录版本和下载记录保留,可在订阅历史里重新订阅。"
|
description="不再搜索和追更。已整理文件、收录版本和下载记录保留,可在订阅历史里重新订阅。"
|
||||||
confirmLabel="删除"
|
confirmLabel="删除"
|
||||||
destructive
|
destructive
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function slotBadge(s: SubscriptionSlotSummary): ToneLabel {
|
|||||||
if (s.inflight > 0) return { label: `在途 ${s.inflight}`, tone: "neutral" };
|
if (s.inflight > 0) return { label: `在途 ${s.inflight}`, tone: "neutral" };
|
||||||
if (s.missing > 0) return { label: `缺 ${s.missing}`, tone: "destructive" };
|
if (s.missing > 0) return { label: `缺 ${s.missing}`, tone: "destructive" };
|
||||||
if (s.total !== null && s.total > 0 && s.have >= s.total) return { label: "已齐", tone: "success" };
|
if (s.total !== null && s.total > 0 && s.have >= s.total) return { label: "已齐", tone: "success" };
|
||||||
return { label: s.have > 0 ? `已入库 ${s.have}` : "未开始", tone: "neutral" };
|
return { label: s.have > 0 ? `已整理 ${s.have}` : "未开始", tone: "neutral" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 名称列表折叠:≤max 个名 + "+N";空 = emptyLabel */
|
/** 名称列表折叠:≤max 个名 + "+N";空 = emptyLabel */
|
||||||
@@ -121,7 +121,7 @@ export function cellKind(c: CoverageCell): CellKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CELL_LABEL: Record<CellKind, string> = {
|
export const CELL_LABEL: Record<CellKind, string> = {
|
||||||
have: "已入库",
|
have: "已整理",
|
||||||
upgradable: "可升级",
|
upgradable: "可升级",
|
||||||
inflight: "在途",
|
inflight: "在途",
|
||||||
deferred: "延后",
|
deferred: "延后",
|
||||||
@@ -429,9 +429,9 @@ export function draftToParams(
|
|||||||
// ── 日历(设计 §4)──
|
// ── 日历(设计 §4)──
|
||||||
|
|
||||||
export const CALENDAR_STATE_LABEL: Record<CalendarEntryState, string> = {
|
export const CALENDAR_STATE_LABEL: Record<CalendarEntryState, string> = {
|
||||||
have: "已入库",
|
have: "已整理",
|
||||||
partial: "部分入库",
|
partial: "部分整理",
|
||||||
missing: "未入库",
|
missing: "未整理",
|
||||||
upcoming: "未播出",
|
upcoming: "未播出",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function ProgressBlock({ x, className }: { x: Pick<Item, "progress" | "mo
|
|||||||
"bg-[repeating-linear-gradient(90deg,var(--color-foreground)_0_4px,transparent_4px_8px)] opacity-20",
|
"bg-[repeating-linear-gradient(90deg,var(--color-foreground)_0_4px,transparent_4px_8px)] opacity-20",
|
||||||
)}
|
)}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`已入库 ${x.progress.have},在途 ${x.progress.inflight},总 ${x.progress.total ?? "未知"}`}
|
aria-label={`已整理 ${x.progress.have},在途 ${x.progress.inflight},总 ${x.progress.total ?? "未知"}`}
|
||||||
>
|
>
|
||||||
{unknown ? null : (
|
{unknown ? null : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function SlotPanel({
|
|||||||
disabled={setOrder.isPending}
|
disabled={setOrder.isPending}
|
||||||
renderExtra={(r, i) => (
|
renderExtra={(r, i) => (
|
||||||
<span className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums max-sm:w-full max-sm:pl-9">
|
<span className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums max-sm:w-full max-sm:pl-9">
|
||||||
<span>已入库 {r.summary.have}</span>
|
<span>已整理 {r.summary.have}</span>
|
||||||
<span className={r.summary.missing ? "text-destructive" : undefined}>缺 {r.summary.missing}</span>
|
<span className={r.summary.missing ? "text-destructive" : undefined}>缺 {r.summary.missing}</span>
|
||||||
<span>在途 {r.summary.inflight}</span>
|
<span>在途 {r.summary.inflight}</span>
|
||||||
{i === 0 ? null : <span>{r.autoFill ? "自动补全" : "只登记缺失"}</span>}
|
{i === 0 ? null : <span>{r.autoFill ? "自动补全" : "只登记缺失"}</span>}
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ export function SubscriptionFormDialog({
|
|||||||
<FormField
|
<FormField
|
||||||
label="洗版"
|
label="洗版"
|
||||||
htmlFor={f("upgrade")}
|
htmlFor={f("upgrade")}
|
||||||
hint="已入库的低于规则组最高档时继续找更好的,拿到最高档即完成"
|
hint="已整理的低于规则组最高档时继续找更好的,拿到最高档即完成"
|
||||||
>
|
>
|
||||||
<Pick
|
<Pick
|
||||||
id={f("upgrade")}
|
id={f("upgrade")}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function SubtitleDialog({
|
|||||||
) : targets.error ? (
|
) : targets.error ? (
|
||||||
<ErrorState error={targets.error} onRetry={() => void targets.refetch()} />
|
<ErrorState error={targets.error} onRetry={() => void targets.refetch()} />
|
||||||
) : !list.length ? (
|
) : !list.length ? (
|
||||||
<EmptyState title="媒体库里还没有这部作品的视频" description="整理入库后才能补字幕。" />
|
<EmptyState title="媒体库里还没有这部作品的视频" description="整理后才能补字幕。" />
|
||||||
) : (
|
) : (
|
||||||
<div className="min-w-0 space-y-4">
|
<div className="min-w-0 space-y-4">
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "upload" | "online")}>
|
<Tabs value={tab} onValueChange={(v) => setTab(v as "upload" | "online")}>
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ const DOWNLOAD: Record<string, Labeled> = {
|
|||||||
paused: { label: "已暂停", tone: "warning" },
|
paused: { label: "已暂停", tone: "warning" },
|
||||||
stalled: { label: "无进展", tone: "warning" },
|
stalled: { label: "无进展", tone: "warning" },
|
||||||
completed: { label: "下载完成", tone: "success" },
|
completed: { label: "下载完成", tone: "success" },
|
||||||
importing: { label: "入库中", tone: "neutral" },
|
importing: { label: "整理中", tone: "neutral" },
|
||||||
imported: { label: "已入库", tone: "success" },
|
imported: { label: "已整理", tone: "success" },
|
||||||
failed: { label: "失败", tone: "destructive" },
|
failed: { label: "失败", tone: "destructive" },
|
||||||
removed: { label: "已移除", tone: "neutral" },
|
removed: { label: "已移除", tone: "neutral" },
|
||||||
missing: { label: "丢失", tone: "destructive" },
|
missing: { label: "丢失", tone: "destructive" },
|
||||||
|
|||||||
@@ -717,7 +717,7 @@ export default function FilesPage() {
|
|||||||
key: "scrape",
|
key: "scrape",
|
||||||
label: "刷新元数据",
|
label: "刷新元数据",
|
||||||
icon: RefreshCw,
|
icon: RefreshCw,
|
||||||
disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件",
|
disabled: selected.every((e) => e.asset) ? null : "只对已整理的文件",
|
||||||
onSelect: () => act("scrape", selected),
|
onSelect: () => act("scrape", selected),
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
plain
|
plain
|
||||||
icon={FolderTree}
|
icon={FolderTree}
|
||||||
title="还没有入库文件"
|
title="还没有整理过的文件"
|
||||||
description="收录版本满足后,文件位置显示在这里。"
|
description="收录版本满足后,文件位置显示在这里。"
|
||||||
className="py-8"
|
className="py-8"
|
||||||
/>
|
/>
|
||||||
@@ -608,7 +608,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
|
|||||||
hideBelow: "sm",
|
hideBelow: "sm",
|
||||||
cell: (u) =>
|
cell: (u) =>
|
||||||
covered.has(u.id) ? (
|
covered.has(u.id) ? (
|
||||||
<StatusBadge tone="success">已入库</StatusBadge>
|
<StatusBadge tone="success">已整理</StatusBadge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">—</span>
|
||||||
),
|
),
|
||||||
@@ -711,10 +711,10 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
|||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
const season = seasonUnitOf(u.seasonNumber!);
|
const season = seasonUnitOf(u.seasonNumber!);
|
||||||
if (covered.has(u.id) || (season && covered.has(season.id)))
|
if (covered.has(u.id) || (season && covered.has(season.id)))
|
||||||
return { label: "已入库", tone: "success", have: true };
|
return { label: "已整理", tone: "success", have: true };
|
||||||
const air = finalOf(u, "airDate")?.value ?? u.airDate;
|
const air = finalOf(u, "airDate")?.value ?? u.airDate;
|
||||||
if (typeof air === "string" && air > today()) return { label: "未播出", tone: "neutral", have: false };
|
if (typeof air === "string" && air > today()) return { label: "未播出", tone: "neutral", have: false };
|
||||||
return { label: sub ? "缺失" : "未入库", tone: sub ? "destructive" : "neutral", have: false };
|
return { label: sub ? "缺失" : "未整理", tone: sub ? "destructive" : "neutral", have: false };
|
||||||
};
|
};
|
||||||
const haveIn = (n: number) => episodes.filter((u) => u.seasonNumber === n && statusOf(u)?.have).length;
|
const haveIn = (n: number) => episodes.filter((u) => u.seasonNumber === n && statusOf(u)?.have).length;
|
||||||
const initial =
|
const initial =
|
||||||
@@ -747,7 +747,7 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
|||||||
description={
|
description={
|
||||||
sub
|
sub
|
||||||
? "库内状态按订阅的覆盖矩阵;标题与简介按语言回退链取值"
|
? "库内状态按订阅的覆盖矩阵;标题与简介按语言回退链取值"
|
||||||
: "没有订阅时只看是否已入库;标题与简介按语言回退链取值"
|
: "没有订阅时只看是否已整理;标题与简介按语言回退链取值"
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" disabled={refresh.isPending} onClick={refreshFull}>
|
<Button variant="outline" size="sm" disabled={refresh.isPending} onClick={refreshFull}>
|
||||||
@@ -817,7 +817,7 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
|||||||
})(),
|
})(),
|
||||||
summary: (
|
summary: (
|
||||||
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
|
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
|
||||||
已入库 {have}/{total ?? "?"}
|
已整理 {have}/{total ?? "?"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ function WorksView() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "org",
|
key: "org",
|
||||||
header: "最近入库",
|
header: "最近整理",
|
||||||
hideBelow: "lg",
|
hideBelow: "lg",
|
||||||
cell: (r) => <span className="text-meta text-muted-foreground">{relTime(r.lastOrganizedAt)}</span>,
|
cell: (r) => <span className="text-meta text-muted-foreground">{relTime(r.lastOrganizedAt)}</span>,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ function InventoryPanel({ d }: { d: TmdbMediaDetail }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
{inv.versionCount} 个版本已入库
|
{inv.versionCount} 个版本已整理
|
||||||
{inv.downloadingCount ? ` · ${inv.downloadingCount} 个正在下载` : ""}
|
{inv.downloadingCount ? ` · ${inv.downloadingCount} 个正在下载` : ""}
|
||||||
{inv.subscribed ? " · 已订阅" : ""}
|
{inv.subscribed ? " · 已订阅" : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -284,7 +284,7 @@ function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
|
|||||||
});
|
});
|
||||||
const seasons = [...d.seasons].sort((a, b) => (a.seasonNumber || 999) - (b.seasonNumber || 999));
|
const seasons = [...d.seasons].sort((a, b) => (a.seasonNumber || 999) - (b.seasonNumber || 999));
|
||||||
return (
|
return (
|
||||||
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "不在媒体库"}>
|
<Panel title="季与集" description={d.localEntityId ? "本地一列是已整理集数" : "不在媒体库"}>
|
||||||
<PaginatedList rows={seasons} label="季列表" resetKey={d.externalId} className="space-y-2">
|
<PaginatedList rows={seasons} label="季列表" resetKey={d.externalId} className="space-y-2">
|
||||||
{(s) => (
|
{(s) => (
|
||||||
<SeasonSection
|
<SeasonSection
|
||||||
@@ -299,7 +299,7 @@ function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
|
|||||||
summary:
|
summary:
|
||||||
d.localEntityId && s.localHave !== null ? (
|
d.localEntityId && s.localHave !== null ? (
|
||||||
<StatusBadge tone={s.localHave >= s.episodeCount && s.episodeCount > 0 ? "success" : "neutral"}>
|
<StatusBadge tone={s.localHave >= s.episodeCount && s.episodeCount > 0 ? "success" : "neutral"}>
|
||||||
已入库 {s.localHave}/{s.episodeCount}
|
已整理 {s.localHave}/{s.episodeCount}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
) : undefined,
|
) : undefined,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function OrganizeRecordPage() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link to="/console/organize?tab=records">
|
<Link to="/console/organize">
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
全部记录
|
全部记录
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { OrganizationStatus, OrganizationTrigger, PublicOrganizationRecord } from "@umt/contracts";
|
import type {
|
||||||
|
OrganizationStats,
|
||||||
|
OrganizationStatus,
|
||||||
|
OrganizationTrigger,
|
||||||
|
PublicOrganizationRecord,
|
||||||
|
} from "@umt/contracts";
|
||||||
import { EventType } from "@umt/contracts";
|
import { EventType } from "@umt/contracts";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
@@ -16,18 +21,20 @@ import {
|
|||||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
import { useFileRecords } from "~/api/files.ts";
|
import { useFileRecords } from "~/api/files.ts";
|
||||||
|
import { useEntityDetail } from "~/api/inventory.ts";
|
||||||
import {
|
import {
|
||||||
ACTION_LABEL,
|
ACTION_LABEL,
|
||||||
RECORD_STATUS_LABEL,
|
RECORD_STATUS_LABEL,
|
||||||
RECORD_STATUS_TONE,
|
RECORD_STATUS_TONE,
|
||||||
type RecordFilters,
|
type RecordFilters,
|
||||||
TRIGGER_LABEL,
|
TRIGGER_LABEL,
|
||||||
|
useLibraryRoots,
|
||||||
useRecordStats,
|
useRecordStats,
|
||||||
useRecords,
|
useRecords,
|
||||||
} from "~/api/records.ts";
|
} from "~/api/records.ts";
|
||||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
import { FilterBar } from "~/components/console/filter-bar.tsx";
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||||
@@ -35,8 +42,15 @@ import { useQueryState } from "~/components/console/use-query-state.ts";
|
|||||||
import { Checkbox } from "~/components/files/file-list.tsx";
|
import { Checkbox } from "~/components/files/file-list.tsx";
|
||||||
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
|
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
|
||||||
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
|
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
|
||||||
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
|
import { OrganizeQueueButton } from "~/components/organize/organize-queue.tsx";
|
||||||
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx";
|
import { attentionLine, RecordAttentionActions } from "~/components/organize/record-attention.tsx";
|
||||||
|
import {
|
||||||
|
buildDims,
|
||||||
|
filterTags,
|
||||||
|
normalizeLegacyParams,
|
||||||
|
RECORD_FILTER_KEYS,
|
||||||
|
} from "~/components/organize/record-filter-model.ts";
|
||||||
|
import { RecordFilterChips, RecordFilterTags } from "~/components/organize/record-filters.tsx";
|
||||||
import {
|
import {
|
||||||
canReorganize,
|
canReorganize,
|
||||||
episodeRange,
|
episodeRange,
|
||||||
@@ -54,7 +68,6 @@ import { SourceSelection } from "~/components/organize/source-selection.tsx";
|
|||||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
|
||||||
import { reasonLine } from "~/lib/reasons.ts";
|
import { reasonLine } from "~/lib/reasons.ts";
|
||||||
import { useSse } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
@@ -65,12 +78,11 @@ import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
|||||||
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
|
// 单条与批量的重新整理(走整理向导)和删除(可选源文件 / 媒体库文件进回收站)。
|
||||||
// 需要处理(2026-10-06 去掉待处理中心):下载完识别不到、目标冲突、被排除、卡住 / 失败的记录在这里就地处理,
|
// 需要处理(2026-10-06 去掉待处理中心):下载完识别不到、目标冲突、被排除、卡住 / 失败的记录在这里就地处理,
|
||||||
// 「需要处理」筛选只看这些;行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 忽略),一键执行。
|
// 「需要处理」筛选只看这些;行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 忽略),一键执行。
|
||||||
|
// 界面第二轮:整理页只有整理记录一页;整理队列是标题栏的「队列 N」按钮,点开右侧面板(?queue=1,旧链接 ?tab=queue 兼容)。
|
||||||
|
// 筛选按 MP 做成维度按钮(状态、类型、整理方式、媒体库目录、分类、来源、时间)+ 可移除的条件标签;作品条件从行菜单「只看这个作品」来。
|
||||||
|
|
||||||
type Rec = PublicOrganizationRecord;
|
type Rec = PublicOrganizationRecord;
|
||||||
|
|
||||||
const KIND_LABEL = { movie: "电影", series: "剧集", none: "未识别" } as const;
|
|
||||||
const TIME_LABEL = { today: "今天", "7d": "最近 7 天", "30d": "最近 30 天" } as const;
|
|
||||||
|
|
||||||
function sinceOf(t: string): string | undefined {
|
function sinceOf(t: string): string | undefined {
|
||||||
if (t === "today") {
|
if (t === "today") {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
@@ -111,7 +123,7 @@ function StatusCell({ r, onReorganize }: { r: Rec; onReorganize?: () => void })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 整理方式(移动 / 复制 / 硬链接 / 原地入库…):中性小标签,不用状态色 */
|
/** 整理方式(移动 / 复制 / 硬链接 / 原地整理…):中性小标签,不用状态色 */
|
||||||
function ActionTag({ action }: { action: Rec["action"] }) {
|
function ActionTag({ action }: { action: Rec["action"] }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-tight border border-border px-1.5 py-px text-meta whitespace-nowrap text-muted-foreground">
|
<span className="inline-flex items-center rounded-tight border border-border px-1.5 py-px text-meta whitespace-nowrap text-muted-foreground">
|
||||||
@@ -276,20 +288,18 @@ function RowMenu({
|
|||||||
|
|
||||||
/** 按状态计数:只列非零项,点一下按该状态筛选 */
|
/** 按状态计数:只列非零项,点一下按该状态筛选 */
|
||||||
function StatsLine({
|
function StatsLine({
|
||||||
filters,
|
stats: s,
|
||||||
status,
|
status,
|
||||||
onStatus,
|
onStatus,
|
||||||
attention,
|
attention,
|
||||||
onAttention,
|
onAttention,
|
||||||
}: {
|
}: {
|
||||||
filters: Omit<RecordFilters, "status">;
|
stats: OrganizationStats | undefined;
|
||||||
status: string;
|
status: string;
|
||||||
onStatus: (s: string) => void;
|
onStatus: (s: string) => void;
|
||||||
attention: boolean;
|
attention: boolean;
|
||||||
onAttention: (on: boolean) => void;
|
onAttention: (on: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const stats = useRecordStats({ ...filters, attention: undefined });
|
|
||||||
const s = stats.data;
|
|
||||||
if (!s || (s.total === 0 && !s.attention)) return null;
|
if (!s || (s.total === 0 && !s.attention)) return null;
|
||||||
const parts: { key: OrganizationStatus | "running"; label: string; n: number; tone?: string }[] = [
|
const parts: { key: OrganizationStatus | "running"; label: string; n: number; tone?: string }[] = [
|
||||||
{ key: "succeeded", label: "成功", n: s.succeeded },
|
{ key: "succeeded", label: "成功", n: s.succeeded },
|
||||||
@@ -346,27 +356,24 @@ function StatsLine({
|
|||||||
|
|
||||||
export default function OrganizePage() {
|
export default function OrganizePage() {
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const f = useQueryState(["status", "trigger", "kind", "time", "q", "group", "attention"] as const);
|
const f = useQueryState(RECORD_FILTER_KEYS);
|
||||||
const { set: setFilter, values: v } = f;
|
const { set: setFilter, values: v } = f;
|
||||||
const [qInput, setQInput] = useState(v.q);
|
const [qInput, setQInput] = useState(v.q);
|
||||||
const entityId = params.get("mediaEntityId") ?? undefined;
|
const entityId = params.get("mediaEntityId") ?? undefined;
|
||||||
const legacyRecords = !!(
|
|
||||||
entityId ||
|
|
||||||
params.get("ids") ||
|
|
||||||
v.q ||
|
|
||||||
v.trigger ||
|
|
||||||
v.kind ||
|
|
||||||
v.time ||
|
|
||||||
v.status ||
|
|
||||||
v.attention
|
|
||||||
);
|
|
||||||
const attention = v.attention === "1";
|
const attention = v.attention === "1";
|
||||||
const tab = params.get("tab") === "records" || (!params.get("tab") && legacyRecords) ? "records" : "queue";
|
// 旧链接:?tab=queue → 打开队列面板;?tab=records → 去掉
|
||||||
const setTab = (next: string) =>
|
useEffect(() => {
|
||||||
|
const next = normalizeLegacyParams(params);
|
||||||
|
if (next) setParams(next, { replace: true });
|
||||||
|
}, [params, setParams]);
|
||||||
|
const queueOpen = params.get("queue") === "1" || params.get("tab") === "queue";
|
||||||
|
const setQueueOpen = (open: boolean) =>
|
||||||
setParams(
|
setParams(
|
||||||
(previous) => {
|
(previous) => {
|
||||||
const updated = new URLSearchParams(previous);
|
const updated = new URLSearchParams(previous);
|
||||||
updated.set("tab", next);
|
updated.delete("tab");
|
||||||
|
if (open) updated.set("queue", "1");
|
||||||
|
else updated.delete("queue");
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
@@ -379,10 +386,12 @@ export default function OrganizePage() {
|
|||||||
const byIds = useFileRecords({ ids }, ids.length > 0);
|
const byIds = useFileRecords({ ids }, ids.length > 0);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
// 只在关键词真变了时写 URL:否则这次延时写入会带着旧的查询参数,把刚点的维度条件冲掉
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (qInput.trim() === v.q) return;
|
||||||
const t = setTimeout(() => setFilter("q", qInput.trim()), 400);
|
const t = setTimeout(() => setFilter("q", qInput.trim()), 400);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [qInput, setFilter]);
|
}, [qInput, v.q, setFilter]);
|
||||||
// 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变
|
// 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变
|
||||||
const since = useMemo(() => sinceOf(v.time), [v.time]);
|
const since = useMemo(() => sinceOf(v.time), [v.time]);
|
||||||
const base: Omit<RecordFilters, "status"> = {
|
const base: Omit<RecordFilters, "status"> = {
|
||||||
@@ -391,6 +400,9 @@ export default function OrganizePage() {
|
|||||||
organizeOnly: true,
|
organizeOnly: true,
|
||||||
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
|
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
|
||||||
kind: (v.kind || undefined) as RecordFilters["kind"],
|
kind: (v.kind || undefined) as RecordFilters["kind"],
|
||||||
|
action: (v.action || undefined) as RecordFilters["action"],
|
||||||
|
libraryRootId: v.root || undefined,
|
||||||
|
category: v.category || undefined,
|
||||||
since,
|
since,
|
||||||
mediaEntityId: entityId,
|
mediaEntityId: entityId,
|
||||||
q: v.q || undefined,
|
q: v.q || undefined,
|
||||||
@@ -398,6 +410,8 @@ export default function OrganizePage() {
|
|||||||
// 统计里的"进行中"= 排队 + 执行中;点它时列表按执行中筛
|
// 统计里的"进行中"= 排队 + 执行中;点它时列表按执行中筛
|
||||||
const status = (v.status || undefined) as OrganizationStatus | undefined;
|
const status = (v.status || undefined) as OrganizationStatus | undefined;
|
||||||
const records = useRecords({ ...base, status });
|
const records = useRecords({ ...base, status });
|
||||||
|
const stats = useRecordStats({ ...base, attention: undefined });
|
||||||
|
const roots = useLibraryRoots();
|
||||||
useSse([EventType.organizationChanged], () => {
|
useSse([EventType.organizationChanged], () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["records"] });
|
void qc.invalidateQueries({ queryKey: ["records"] });
|
||||||
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
|
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
|
||||||
@@ -412,7 +426,46 @@ export default function OrganizePage() {
|
|||||||
resetKey: JSON.stringify({ ...base, status, ids }),
|
resetKey: JSON.stringify({ ...base, status, ids }),
|
||||||
});
|
});
|
||||||
const rows = paged.items;
|
const rows = paged.items;
|
||||||
const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q || attention) || !!entityId || pinned;
|
const filtered =
|
||||||
|
!!(v.status || v.trigger || v.kind || v.time || v.q || v.action || v.root || v.category || attention) ||
|
||||||
|
!!entityId ||
|
||||||
|
pinned;
|
||||||
|
|
||||||
|
const dims = buildDims(v, {
|
||||||
|
statusLabel: RECORD_STATUS_LABEL,
|
||||||
|
triggerLabel: TRIGGER_LABEL,
|
||||||
|
facets: stats.data?.facets,
|
||||||
|
rootName: (id) => roots.data?.items.find((r) => r.id === id)?.name ?? "已移除的目录",
|
||||||
|
});
|
||||||
|
const entity = useEntityDetail(entityId);
|
||||||
|
const tags = filterTags(v, dims, {
|
||||||
|
entityId,
|
||||||
|
entityTitle: entity.data?.entity.title ?? allRows?.find((r) => r.media?.entityId === entityId)?.media?.title,
|
||||||
|
pinned: ids.length,
|
||||||
|
});
|
||||||
|
const removeTag = (key: string) => {
|
||||||
|
if (key === "ids" || key === "mediaEntityId") {
|
||||||
|
setParams(
|
||||||
|
(previous) => {
|
||||||
|
const updated = new URLSearchParams(previous);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "q") setQInput("");
|
||||||
|
setFilter(key as (typeof RECORD_FILTER_KEYS)[number], "");
|
||||||
|
};
|
||||||
|
// 清除:去掉全部条件,保留分组方式与队列面板
|
||||||
|
const clearAll = () => {
|
||||||
|
setQInput("");
|
||||||
|
const keep: Record<string, string> = {};
|
||||||
|
if (v.group) keep.group = v.group;
|
||||||
|
if (queueOpen) keep.queue = "1";
|
||||||
|
setParams(keep);
|
||||||
|
};
|
||||||
|
|
||||||
const [selecting, setSelecting] = useState(false);
|
const [selecting, setSelecting] = useState(false);
|
||||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||||
@@ -577,7 +630,12 @@ export default function OrganizePage() {
|
|||||||
onOpen={() => navigate(`/console/organize/${r.id}`)}
|
onOpen={() => navigate(`/console/organize/${r.id}`)}
|
||||||
onReorganize={() => reorganize([r])}
|
onReorganize={() => reorganize([r])}
|
||||||
onDelete={() => setToDelete([r])}
|
onDelete={() => setToDelete([r])}
|
||||||
onOnlyEntity={() => r.media && setParams({ tab: "records", mediaEntityId: r.media.entityId })}
|
onOnlyEntity={() =>
|
||||||
|
r.media &&
|
||||||
|
setParams(
|
||||||
|
v.group ? { mediaEntityId: r.media.entityId, group: v.group } : { mediaEntityId: r.media.entityId },
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -589,19 +647,18 @@ export default function OrganizePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="整理"
|
title="整理记录"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{tab === "records" ? (
|
<OrganizeQueueButton open={queueOpen} onOpenChange={setQueueOpen} />
|
||||||
<Button
|
<Button
|
||||||
variant={selecting ? "secondary" : "ghost"}
|
variant={selecting ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
||||||
>
|
>
|
||||||
{selecting ? <X /> : <SquareCheck />}
|
{selecting ? <X /> : <SquareCheck />}
|
||||||
{selecting ? "退出批量" : "批量"}
|
{selecting ? "退出批量" : "批量"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
|
||||||
<Button size="sm" onClick={() => setChoosingSource(true)}>
|
<Button size="sm" onClick={() => setChoosingSource(true)}>
|
||||||
<FolderInput />
|
<FolderInput />
|
||||||
手动整理
|
手动整理
|
||||||
@@ -609,148 +666,93 @@ export default function OrganizePage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
<FilterBar
|
||||||
<TabsList aria-label="整理视图">
|
className="mb-2"
|
||||||
<TabsTrigger value="queue">整理队列</TabsTrigger>
|
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
|
||||||
<TabsTrigger value="records">整理记录</TabsTrigger>
|
actions={
|
||||||
</TabsList>
|
<Button
|
||||||
<TabsContent value="queue">
|
variant={grouped ? "secondary" : "ghost"}
|
||||||
<OrganizeQueue />
|
size="sm"
|
||||||
</TabsContent>
|
aria-pressed={grouped}
|
||||||
<TabsContent value="records">
|
onClick={() => setGrouping(grouped ? "none" : "entity")}
|
||||||
<FilterBar
|
|
||||||
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
|
|
||||||
onReset={
|
|
||||||
filtered
|
|
||||||
? () => {
|
|
||||||
setQInput("");
|
|
||||||
setParams(v.group ? { tab: "records", group: v.group } : { tab: "records" });
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant={grouped ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
aria-pressed={grouped}
|
|
||||||
onClick={() => setGrouping(grouped ? "none" : "entity")}
|
|
||||||
>
|
|
||||||
<ListTree />
|
|
||||||
按作品分组
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<FilterSelect
|
<ListTree />
|
||||||
label="状态"
|
按作品分组
|
||||||
value={v.status}
|
</Button>
|
||||||
onChange={(x) => setFilter("status", x)}
|
}
|
||||||
options={Object.entries(RECORD_STATUS_LABEL)
|
>
|
||||||
.filter(([value]) => value !== "queued" && value !== "running")
|
<RecordFilterChips dims={dims} values={v} onChange={(key, value) => setFilter(key, value)} />
|
||||||
.map(([value, label]) => ({ value, label }))}
|
</FilterBar>
|
||||||
/>
|
<RecordFilterTags tags={tags} onRemove={removeTag} onClear={clearAll} />
|
||||||
<FilterSelect
|
{pinned ? null : (
|
||||||
label="类型"
|
<StatsLine
|
||||||
value={v.kind}
|
stats={stats.data}
|
||||||
onChange={(x) => setFilter("kind", x)}
|
status={v.status}
|
||||||
options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))}
|
onStatus={(s) => setFilter("status", s)}
|
||||||
/>
|
attention={attention}
|
||||||
<FilterSelect
|
onAttention={(on) => setFilter("attention", on ? "1" : "")}
|
||||||
label="来源"
|
/>
|
||||||
value={v.trigger}
|
)}
|
||||||
onChange={(x) => setFilter("trigger", x)}
|
{selecting ? (
|
||||||
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
|
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
||||||
/>
|
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
|
||||||
<FilterSelect
|
<Button variant="ghost" size="sm" onClick={pickAll}>
|
||||||
label="时间"
|
{allPicked ? "取消全选" : "全选"}
|
||||||
value={v.time}
|
</Button>
|
||||||
allLabel="不限"
|
<span className="ml-auto flex flex-wrap gap-2">
|
||||||
onChange={(x) => setFilter("time", x)}
|
<Button size="sm" variant="outline" disabled={!selReorg} onClick={() => reorganize(selRows)}>
|
||||||
options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))}
|
<Wand2 />
|
||||||
/>
|
{selReorg && selReorg < selRows.length ? `重新整理 ${selReorg} 条` : "重新整理"}
|
||||||
{entityId ? (
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setParams({ tab: "records" })}>
|
<Button
|
||||||
只看该作品
|
size="sm"
|
||||||
<X />
|
variant="outline"
|
||||||
</Button>
|
className="text-destructive"
|
||||||
) : null}
|
disabled={!selDeletable.length}
|
||||||
{pinned ? (
|
onClick={() => setToDelete(selDeletable)}
|
||||||
<Button variant="outline" size="sm" onClick={() => setParams({ tab: "records" })}>
|
>
|
||||||
本次整理的 {ids.length} 条
|
<Trash2 />
|
||||||
<X />
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</span>
|
||||||
</FilterBar>
|
</div>
|
||||||
{pinned ? null : (
|
) : null}
|
||||||
<StatsLine
|
<DataTable
|
||||||
filters={base}
|
columns={columns}
|
||||||
status={v.status}
|
rows={rows}
|
||||||
onStatus={(s) => setFilter("status", s)}
|
pagination={paged.pagination}
|
||||||
attention={attention}
|
rowKey={(r) => r.id}
|
||||||
onAttention={(on) => setFilter("attention", on ? "1" : "")}
|
loading={pinned ? byIds.isPending : records.isPending}
|
||||||
/>
|
error={pinned ? byIds.error : records.error}
|
||||||
)}
|
onRetry={() => void (pinned ? byIds.refetch() : records.refetch())}
|
||||||
{selecting ? (
|
onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))}
|
||||||
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
|
selectedKey={null}
|
||||||
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
|
hasMore={!pinned && records.hasNextPage}
|
||||||
<Button variant="ghost" size="sm" onClick={pickAll}>
|
onLoadMore={() => void records.fetchNextPage()}
|
||||||
{allPicked ? "取消全选" : "全选"}
|
loadingMore={records.isFetchingNextPage}
|
||||||
</Button>
|
groupBy={
|
||||||
<span className="ml-auto flex flex-wrap gap-2">
|
grouped
|
||||||
<Button size="sm" variant="outline" disabled={!selReorg} onClick={() => reorganize(selRows)}>
|
? {
|
||||||
<Wand2 />
|
// 未识别的记录互不相干,各自一行
|
||||||
{selReorg && selReorg < selRows.length ? `重新整理 ${selReorg} 条` : "重新整理"}
|
key: (r) => r.media?.entityId ?? `none:${r.id}`,
|
||||||
</Button>
|
label: (_k, g) => <GroupLabel rows={g} />,
|
||||||
<Button
|
meta: (_k, g) => <GroupMeta rows={g} />,
|
||||||
size="sm"
|
flat: (_k, g) => g.length === 1,
|
||||||
variant="outline"
|
defaultCollapsed: true,
|
||||||
className="text-destructive"
|
storageKey: "organize-records-entity",
|
||||||
disabled={!selDeletable.length}
|
}
|
||||||
onClick={() => setToDelete(selDeletable)}
|
: undefined
|
||||||
>
|
}
|
||||||
<Trash2 />
|
empty={{
|
||||||
删除
|
icon: FileStack,
|
||||||
</Button>
|
title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
||||||
</span>
|
description: attention
|
||||||
</div>
|
? "识别不到作品、目标冲突或整理失败的记录会出现在这里。"
|
||||||
) : null}
|
: filtered
|
||||||
<DataTable
|
? undefined
|
||||||
columns={columns}
|
: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
||||||
rows={rows}
|
}}
|
||||||
pagination={paged.pagination}
|
/>
|
||||||
rowKey={(r) => r.id}
|
|
||||||
loading={pinned ? byIds.isPending : records.isPending}
|
|
||||||
error={pinned ? byIds.error : records.error}
|
|
||||||
onRetry={() => void (pinned ? byIds.refetch() : records.refetch())}
|
|
||||||
onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))}
|
|
||||||
selectedKey={null}
|
|
||||||
hasMore={!pinned && records.hasNextPage}
|
|
||||||
onLoadMore={() => void records.fetchNextPage()}
|
|
||||||
loadingMore={records.isFetchingNextPage}
|
|
||||||
groupBy={
|
|
||||||
grouped
|
|
||||||
? {
|
|
||||||
// 未识别的记录互不相干,各自一行
|
|
||||||
key: (r) => r.media?.entityId ?? `none:${r.id}`,
|
|
||||||
label: (_k, g) => <GroupLabel rows={g} />,
|
|
||||||
meta: (_k, g) => <GroupMeta rows={g} />,
|
|
||||||
flat: (_k, g) => g.length === 1,
|
|
||||||
defaultCollapsed: true,
|
|
||||||
storageKey: "organize-records-entity",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
empty={{
|
|
||||||
icon: FileStack,
|
|
||||||
title: attention ? "没有需要处理的整理记录" : filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
|
|
||||||
description: attention
|
|
||||||
? "识别不到作品、目标冲突或整理失败的记录会出现在这里。"
|
|
||||||
: filtered
|
|
||||||
? undefined
|
|
||||||
: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
{choosingSource ? (
|
{choosingSource ? (
|
||||||
<SourceSelection
|
<SourceSelection
|
||||||
onClose={() => setChoosingSource(false)}
|
onClose={() => setChoosingSource(false)}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const TOTAL_SOURCE: Record<string, string> = {
|
|||||||
tmdb: "TMDB",
|
tmdb: "TMDB",
|
||||||
injected: "注入",
|
injected: "注入",
|
||||||
manual: "手动",
|
manual: "手动",
|
||||||
covered_floor: "按已入库",
|
covered_floor: "按已整理",
|
||||||
previous: "沿用",
|
previous: "沿用",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export default function LoginPage() {
|
|||||||
<section className="hidden lg:block">
|
<section className="hidden lg:block">
|
||||||
<p className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">媒体自动化</p>
|
<p className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">媒体自动化</p>
|
||||||
<h1 className="mt-3 max-w-xl font-display text-display-sm leading-tight font-semibold text-foreground">
|
<h1 className="mt-3 max-w-xl font-display text-display-sm leading-tight font-semibold text-foreground">
|
||||||
订阅、下载、整理入库
|
订阅、下载、整理
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-lg text-lead text-muted-foreground">
|
<p className="mt-4 max-w-lg text-lead text-muted-foreground">
|
||||||
每一步都有记录。拿不准的事项进待处理,等你决定。
|
每一步都有记录。拿不准的事项进待处理,等你决定。
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
|
|
||||||
### 5.1 行内 hover 操作(桌面)
|
### 5.1 行内 hover 操作(桌面)
|
||||||
|
|
||||||
鼠标悬停或键盘焦点所在行(Tab 进入按钮,Esc 回列表),在行尾浮出 2–3 个图标按钮(带 tooltip 与 aria-label,盖在修改时间列上),其余留在 ⋯:未入库视频/目录 → `整理` `识别` `刮削`;已入库 → `打开作品` `刷新元数据` `重命名`;未完成的视频 → `识别` `重命名` `移动到`;其他 → `重命名` `移动到`。窄屏卡片不依赖悬停,只用 ⋯。
|
鼠标悬停或键盘焦点所在行(Tab 进入按钮,Esc 回列表),在行尾浮出 2–3 个图标按钮(带 tooltip 与 aria-label,盖在修改时间列上),其余留在 ⋯:未整理视频/目录 → `整理` `识别` `刮削`;已整理 → `打开作品` `刷新元数据` `重命名`;未完成的视频 → `识别` `重命名` `移动到`;其他 → `重命名` `移动到`。窄屏卡片不依赖悬停,只用 ⋯。
|
||||||
|
|
||||||
面包屑照 MP「本地 > MEDIA > 亚洲剧集」:`下载目录 / 媒体库`(说明,不可点)> 登记目录 > 各级子目录;窄屏去掉类型并折叠。
|
面包屑照 MP「本地 > MEDIA > 亚洲剧集」:`下载目录 / 媒体库`(说明,不可点)> 登记目录 > 各级子目录;窄屏去掉类型并折叠。
|
||||||
|
|
||||||
|
|||||||
@@ -24,17 +24,18 @@
|
|||||||
| Approval | 审批(名词);按钮用"批准 / 拒绝" | 授权记录 |
|
| Approval | 审批(名词);按钮用"批准 / 拒绝" | 授权记录 |
|
||||||
| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) |
|
| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) |
|
||||||
| DownloadTask | 下载任务 | 下载单 |
|
| DownloadTask | 下载任务 | 下载单 |
|
||||||
| Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 |
|
| Organization record | 整理记录;动作叫"整理",结果状态叫「已整理」;整理页只有这一页,排队与执行中的任务在标题栏「队列 N」面板里(不叫「整理队列」标签页) | 转移、入库记录 |
|
||||||
|
| 本工具对文件的处理(识别、元数据、NFO 与图片、按规则归到媒体库目录) | 整理;状态「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中」,数量写「已整理 x/y」;方式叫「整理方式」;之后的动作写「整理后…」(整理后刮削、整理后删源);只登记不搬的叫「原地整理」。2026-10-06 起「入库」不再表示处理状态:媒体服务器(Emby / Jellyfin / Plex)有没有扫到由它自己决定,本工具不判断;真要说媒体服务器时写「媒体服务器已扫描」。`check:copy` 拦「已入库 / 未入库 / 入库后 / 入库方式…」等搭配;代码标识符(`imported`、`media_asset`、`verifyAndImport`)不改 | 入库、已入库、未入库、部分入库、入库失败、入库中、入库方式、入库后、原地入库、核验入库 |
|
||||||
| 在目录里就地写 NFO 与图片(scrapeMedia,SCR-1 / SCR-2) | 刮削;文件管理菜单「刮削…」,作品详情「重新刮削」「覆盖重写」;覆盖方式叫「更新 / 只补缺失 / 全部覆盖」(更新 = 只替换本工具写过且没被改过的文件);认不准作品时"选一个再刮削";只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) |
|
| 在目录里就地写 NFO 与图片(scrapeMedia,SCR-1 / SCR-2) | 刮削;文件管理菜单「刮削…」,作品详情「重新刮削」「覆盖重写」;覆盖方式叫「更新 / 只补缺失 / 全部覆盖」(更新 = 只替换本工具写过且没被改过的文件);认不准作品时"选一个再刮削";只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) |
|
||||||
| 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move |
|
| 整理方式 | 复制 / 移动 / 硬链接 / 原地整理 | Copy / Move、原地入库 |
|
||||||
| Library root | 媒体库目录 | 库根、媒体库根、根目录 |
|
| Library root | 媒体库目录 | 库根、媒体库根、根目录 |
|
||||||
| 文件管理可浏览的目录(库根 + 下载器路径映射) | 登记目录 | 登记根、根 |
|
| 文件管理可浏览的目录(库根 + 下载器路径映射) | 登记目录 | 登记根、根 |
|
||||||
| MediaAsset / AssetLocation | 文件 / 已入库文件;位置叫"文件位置" | 资产、资产位置、媒体文件记录 |
|
| MediaAsset / AssetLocation | 文件 / 已整理文件;位置叫"文件位置";文件管理快速筛选「已整理」,目录副行「n 个已整理 / 未整理」 | 资产、资产位置、媒体文件记录、已入库文件 |
|
||||||
| ExternalIdentity | 外部身份(TMDB/TVDB/IMDb ID) | 外部 ID 映射、外部标识 |
|
| ExternalIdentity | 外部身份(TMDB/TVDB/IMDb ID) | 外部 ID 映射、外部标识 |
|
||||||
| 未确认身份的文件/资源 | 未识别 | 待识别、待识别池 |
|
| 未确认身份的文件/资源 | 未识别 | 待识别、待识别池 |
|
||||||
| identity_ambiguous / 整理时认不出作品的待处理事项 | 没识别出作品;主按钮"指定作品" | 身份歧义、识别有歧义、无法确定作品、待确认身份 |
|
| identity_ambiguous / 整理时认不出作品的待处理事项 | 没识别出作品;主按钮"指定作品" | 身份歧义、识别有歧义、无法确定作品、待确认身份 |
|
||||||
| 作品登记进本地库(不订阅) | 加入媒体库;状态"在媒体库 · 未下载"/"不在媒体库" | 建档、已建档、未收录 |
|
| 作品登记进本地库(不订阅) | 加入媒体库;状态"在媒体库 · 未下载"/"不在媒体库" | 建档、已建档、未收录 |
|
||||||
| 已完成下载任务的整理状态(下载面板) | 待整理 / 整理中 / 已入库 / 部分入库 / 入库失败;失败原因用原因码表的一句话 | 未整理、导入失败、转移失败 |
|
| 已完成下载任务的整理状态(下载面板) | 待整理 / 整理中 / 已整理 / 部分整理 / 整理失败;失败原因用原因码表的一句话 | 未整理(下载面板里用「待整理」)、导入失败、转移失败、已入库、入库失败 |
|
||||||
| 本工具提交并跟踪的下载任务(managed) | 本工具管理;反面叫"外部任务";筛选"来源:本工具 / 其他" | 纳管、未纳管、接管 |
|
| 本工具提交并跟踪的下载任务(managed) | 本工具管理;反面叫"外部任务";筛选"来源:本工具 / 其他" | 纳管、未纳管、接管 |
|
||||||
| 媒体库目录里的 `.uhd-root` | 标记文件;按钮"检查目录";失败说"目录不可访问或未挂载" | 哨兵、哨兵文件 |
|
| 媒体库目录里的 `.uhd-root` | 标记文件;按钮"检查目录";失败说"目录不可访问或未挂载" | 哨兵、哨兵文件 |
|
||||||
| 作品 / 版本 / 文件 / 外部身份的本地记录总称 | 不单独命名 | 账本、本地账本 |
|
| 作品 / 版本 / 文件 / 外部身份的本地记录总称 | 不单独命名 | 账本、本地账本 |
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
原地入库为 v0.3.14 的待部署改动;当前生产 v0.3.13 的整理方式仍为复制/移动,独立就地刮削继续可用。
|
原地入库为 v0.3.14 的待部署改动;当前生产 v0.3.13 的整理方式仍为复制/移动,独立就地刮削继续可用。
|
||||||
|
|
||||||
整理页分为「整理队列」与「整理记录」。队列显示等待、执行和等待继续的后台入库任务;记录显示复制/移动/原地入库的处理结果。文件管理的改名、删除、还原账本继续保存,通用记录接口仍可查询。
|
整理页只有「整理记录」一页(界面第二轮,2026-10-06)。整理队列学 MP 做成标题栏的「队列 N」按钮(N = 排队 + 执行中,含整理后的刮削,0 不写),点开右侧面板看队列;面板开关在 URL(`?queue=1`),旧链接 `?tab=queue` 打开面板、`?tab=records` 直接去掉。记录显示复制/移动/硬链接/原地整理的结果。文件管理的改名、删除、还原账本继续保存,通用记录接口仍可查询。
|
||||||
|
|
||||||
|
口径:本工具只负责「整理」——识别、元数据、NFO 与图片、按规则归到媒体库目录;媒体服务器有没有扫到由它自己决定,界面不写「入库」(见 `docs/design/glossary.md`)。
|
||||||
|
|
||||||
|
筛选(对照 MP 的维度按钮 + 条件标签):状态、类型、整理方式、媒体库目录、分类、来源、时间各一个维度按钮(单选;整理方式 / 媒体库目录 / 分类只列有记录的值并带条数,来自 `GET /organization-records/stats` 的 `facets`,按其余条件计数),已选条件(含作品、需要处理、关键词、本次整理的记录)排成可移除标签 +「清除」;按作品分组照旧。接口参数:`action`(copy / move / hardlink / in_place)、`libraryRootId`、`category`(冻结计划 `classification.category`),测试 `apps/server/test/acquisition-organize/records-filter.test.ts`。
|
||||||
|
|
||||||
实现:
|
实现:
|
||||||
- 列表 / 统计 / 详情:`apps/server/src/modules/acquisition-organize/routes/organization.ts`(筛选 `recordFilters`,通配 `likePattern`);公开字段在 `services/organize.ts` 的 `loadRecordsPublic`。
|
- 列表 / 统计 / 详情:`apps/server/src/modules/acquisition-organize/routes/organization.ts`(筛选 `recordFilters`,通配 `likePattern`);公开字段在 `services/organize.ts` 的 `loadRecordsPublic`。
|
||||||
@@ -104,7 +108,7 @@ MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue`
|
|||||||
| 整理方式 复制 / 移动 | 已有 | 列表「方式」列;整理页聚焦复制/移动结果 |
|
| 整理方式 复制 / 移动 | 已有 | 列表「方式」列;整理页聚焦复制/移动结果 |
|
||||||
| 软链接、Rclone 复制 / 移动 | 不做 | 整理服务不支持这几种方式 |
|
| 软链接、Rclone 复制 / 移动 | 不做 | 整理服务不支持这几种方式 |
|
||||||
| 大小 | 新增 | 逐文件源大小之和(`sizeBytes`) |
|
| 大小 | 新增 | 逐文件源大小之和(`sizeBytes`) |
|
||||||
| 状态 成功 / 失败 + 失败原因 | 已有,改 | 支持「部分成功」;执行中任务在队列查看;成功不加徽章;失败原因走 `reasons.ts` 一句话,原码进详情「技术信息」 |
|
| 状态 成功 / 失败 + 失败原因 | 已有,改 | 支持「部分成功」;执行中任务在「队列」面板查看;成功不加徽章;失败原因走 `reasons.ts` 一句话,原码进详情「技术信息」 |
|
||||||
| 时间 | 已有,改 | 相对时间,悬停看完整时间 |
|
| 时间 | 已有,改 | 相对时间,悬停看完整时间 |
|
||||||
| 来源 下载器 / 手动 / 自动 | 新增下载器 | 来源列加下载器名称;详情加「下载任务」一行,种子 hash 进「技术信息」 |
|
| 来源 下载器 / 手动 / 自动 | 新增下载器 | 来源列加下载器名称;详情加「下载任务」一行,种子 hash 进「技术信息」 |
|
||||||
| 关键字搜索(标题、源、目标;`*` `?` 通配) | 已有,补 | 补通配、逐文件的源 / 目标路径、种子 hash |
|
| 关键字搜索(标题、源、目标;`*` `?` 通配) | 已有,补 | 补通配、逐文件的源 / 目标路径、种子 hash |
|
||||||
@@ -116,7 +120,7 @@ MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue`
|
|||||||
| 分页、每页条数 | 已有(不同) | 共享分页组件,10/20/50/100 条和翻页;游标按需加载,无总数时显示「已加载」 |
|
| 分页、每页条数 | 已有(不同) | 共享分页组件,10/20/50/100 条和翻页;游标按需加载,无总数时显示「已加载」 |
|
||||||
| 列排序 | 不做 | 按时间倒序;分组模式下按作品聚合 |
|
| 列排序 | 不做 | 按时间倒序;分组模式下按作品聚合 |
|
||||||
| 分组模式(按标题) | 新增 | 「按作品分组」,组头可折叠 |
|
| 分组模式(按标题) | 新增 | 「按作品分组」,组头可折叠 |
|
||||||
| 筛选写进地址栏 | 新增 | 状态 / 类型 / 来源 / 时间 / 关键字 / 分组都在 URL,刷新不丢 |
|
| 筛选写进地址栏 | 新增 | 状态 / 类型 / 整理方式 / 媒体库目录 / 分类 / 来源 / 时间 / 关键字 / 分组都在 URL,刷新不丢 |
|
||||||
| 单条:重新整理 | 已有,补 | 详情已有;列表行菜单新增。走共用弹窗(配置、展开预览、加入队列) |
|
| 单条:重新整理 | 已有,补 | 详情已有;列表行菜单新增。走共用弹窗(配置、展开预览、加入队列) |
|
||||||
| 单条:智能助手整理 | 已有(不同) | 「让智能体排查」:失败 / 部分成功的记录在行菜单和详情里 |
|
| 单条:智能助手整理 | 已有(不同) | 「让智能体排查」:失败 / 部分成功的记录在行菜单和详情里 |
|
||||||
| 单条:删除(只删记录 / 连源文件 / 连媒体库文件 / 全部) | 新增 | 确认框里两个勾选项,先核对再执行,写清删几个文件、多大、哪些删不了、会不会被自动整理再处理 |
|
| 单条:删除(只删记录 / 连源文件 / 连媒体库文件 / 全部) | 新增 | 确认框里两个勾选项,先核对再执行,写清删几个文件、多大、哪些删不了、会不会被自动整理再处理 |
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const episodeMappingApi = {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/media-entities/{id}/episode-structure",
|
path: "/media-entities/{id}/episode-structure",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
summary: "某套季集结构(TMDB 默认季或剧集组)的季卡片与每集,带本地已入库情况(纯读)",
|
summary: "某套季集结构(TMDB 默认季或剧集组)的季卡片与每集,带本地已整理情况(纯读)",
|
||||||
params: IdParam,
|
params: IdParam,
|
||||||
query: em.EpisodeStructureQuery,
|
query: em.EpisodeStructureQuery,
|
||||||
response: em.EpisodeStructureSeasons,
|
response: em.EpisodeStructureSeasons,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const executionApi = {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/acquisition-plans",
|
path: "/acquisition-plans",
|
||||||
scope: "organize.execute",
|
scope: "organize.execute",
|
||||||
summary: "独立手动下载:不要求订阅,支持未知资源确认、下载器与目录选择,以及本次正常或洗版入库",
|
summary: "独立手动下载:不要求订阅,支持未知资源确认、下载器与目录选择,以及本次正常整理或洗版",
|
||||||
body: execution.ManualAcquisitionRequest,
|
body: execution.ManualAcquisitionRequest,
|
||||||
response: execution.ManualAcquisitionResult,
|
response: execution.ManualAcquisitionResult,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const libraryApi = {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/library/{id}/versions",
|
path: "/library/{id}/versions",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对",
|
summary: "作品的已整理版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对",
|
||||||
params: IdParam,
|
params: IdParam,
|
||||||
query: libraryVersions.LibraryVersionsQuery,
|
query: libraryVersions.LibraryVersionsQuery,
|
||||||
response: libraryVersions.LibraryVersions,
|
response: libraryVersions.LibraryVersions,
|
||||||
@@ -163,7 +163,7 @@ export const libraryApi = {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/download-cache/cleanup",
|
path: "/download-cache/cleanup",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)",
|
summary: "立即按阈值清理下载缓存(只清已核验整理且满足保留/H&R 的纳管任务)",
|
||||||
body: z.object({ downloaderInstanceId: z.uuid().optional() }),
|
body: z.object({ downloaderInstanceId: z.uuid().optional() }),
|
||||||
response: z.object({ report: z.record(z.string(), z.unknown()) }),
|
response: z.object({ report: z.record(z.string(), z.unknown()) }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export const organizeApi = {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/files/organize",
|
path: "/files/organize",
|
||||||
scope: "organize.execute",
|
scope: "organize.execute",
|
||||||
summary: "按作品分组整理入库(每组走 planImport,与 /organization-records 同实现)",
|
summary: "按作品分组整理(每组走 planImport,与 /organization-records 同实现)",
|
||||||
body: files.FilesOrganizeRequest,
|
body: files.FilesOrganizeRequest,
|
||||||
response: files.FilesOrganizeResult,
|
response: files.FilesOrganizeResult,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const rulesApi = {
|
|||||||
path: "/subscriptions/calendar",
|
path: "/subscriptions/calendar",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
summary:
|
summary:
|
||||||
"订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与入库状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天",
|
"订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与整理状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天",
|
||||||
query: z.object({
|
query: z.object({
|
||||||
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
@@ -41,7 +41,7 @@ export const rulesApi = {
|
|||||||
method: "post",
|
method: "post",
|
||||||
path: "/subscriptions/{id}/reset",
|
path: "/subscriptions/{id}/reset",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已入库资产保留",
|
summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已整理资产保留",
|
||||||
params: IdParam,
|
params: IdParam,
|
||||||
response: policy.SubscriptionResetResult,
|
response: policy.SubscriptionResetResult,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
|||||||
"organize.plan": d(
|
"organize.plan": d(
|
||||||
"organize.plan",
|
"organize.plan",
|
||||||
"生成整理计划",
|
"生成整理计划",
|
||||||
"生成并保存整理计划:冻结源/目标路径、整理方式与前置条件;不执行。下载完成后整理入库前用;只生成计划不动文件",
|
"生成并保存整理计划:冻结源/目标路径、整理方式与前置条件;不执行。下载完成后整理前用;只生成计划不动文件",
|
||||||
"preview",
|
"preview",
|
||||||
"OrganizePlan",
|
"OrganizePlan",
|
||||||
"organize.read",
|
"organize.read",
|
||||||
|
|||||||
@@ -340,6 +340,12 @@ export const OrganizationListQuery = z.object({
|
|||||||
attention: z
|
attention: z
|
||||||
.union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")])
|
.union([z.boolean(), z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1")])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/** 整理方式(复制 / 移动 / 硬链接 / 原地整理) */
|
||||||
|
action: z.enum(["copy", "move", "hardlink", "in_place"]).optional(),
|
||||||
|
/** 目标媒体库目录 */
|
||||||
|
libraryRootId: Id.optional(),
|
||||||
|
/** 冻结计划里的二级分类(如「欧美电影」) */
|
||||||
|
category: z.string().min(1).max(100).optional(),
|
||||||
});
|
});
|
||||||
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
|
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
|
||||||
|
|
||||||
@@ -356,6 +362,17 @@ export const OrganizationStats = z.object({
|
|||||||
cancelled: NonNegInt,
|
cancelled: NonNegInt,
|
||||||
/** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */
|
/** 需要处理(同列表 attention=true 的口径,不受状态筛选影响) */
|
||||||
attention: NonNegInt,
|
attention: NonNegInt,
|
||||||
|
/**
|
||||||
|
* 筛选维度的可选值与条数(只列非零项):每个维度按其余条件计数(不含本维度自己的条件),
|
||||||
|
* 供整理记录的维度按钮列选项。libraryRootId 的 value 是媒体库目录 id。
|
||||||
|
*/
|
||||||
|
facets: z
|
||||||
|
.object({
|
||||||
|
action: z.array(z.object({ value: z.string(), n: NonNegInt })),
|
||||||
|
libraryRootId: z.array(z.object({ value: z.string(), n: NonNegInt })),
|
||||||
|
category: z.array(z.object({ value: z.string(), n: NonNegInt })),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
export type OrganizationStats = z.infer<typeof OrganizationStats>;
|
export type OrganizationStats = z.infer<typeof OrganizationStats>;
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export type ReasonText = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const REASONS: Record<string, ReasonText> = {
|
const REASONS: Record<string, ReasonText> = {
|
||||||
in_place_failed: { text: "原地入库未完成", action: "在整理记录里查看未登记的文件" },
|
in_place_failed: { text: "原地整理未完成", action: "在整理记录里查看未登记的文件" },
|
||||||
in_place_outside_library: { text: "文件不在所选媒体库目录内", action: "先登记该目录为媒体库,或改用复制/移动" },
|
in_place_outside_library: { text: "文件不在所选媒体库目录内", action: "先登记该目录为媒体库,或改用复制/移动" },
|
||||||
in_place_cannot_replace: { text: "原地入库不替换已有版本", action: "洗版请使用复制/移动入库" },
|
in_place_cannot_replace: { text: "原地整理不替换已有版本", action: "洗版改用复制/移动整理" },
|
||||||
source_config_mismatch: { text: "文件或媒体类型不属于所选整理来源", action: "检查来源目录和媒体类型" },
|
source_config_mismatch: { text: "文件或媒体类型不属于所选整理来源", action: "检查来源目录和媒体类型" },
|
||||||
classification_unavailable: { text: "暂时无法确定媒体分类", action: "检查 TMDB 连接或选择固定分类后重新整理" },
|
classification_unavailable: { text: "暂时无法确定媒体分类", action: "检查 TMDB 连接或选择固定分类后重新整理" },
|
||||||
identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" },
|
identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" },
|
||||||
@@ -25,7 +25,7 @@ const REASONS: Record<string, ReasonText> = {
|
|||||||
hnr_unsatisfied: { text: "做种时间还不够,不能移动", action: "改用复制,或等做种完成" },
|
hnr_unsatisfied: { text: "做种时间还不够,不能移动", action: "改用复制,或等做种完成" },
|
||||||
hnr_unknown: { text: "不确定做种要求是否满足", action: "改用复制,或确认站点规则" },
|
hnr_unknown: { text: "不确定做种要求是否满足", action: "改用复制,或确认站点规则" },
|
||||||
orphan_sidecar: { text: "字幕等附属文件找不到对应视频" },
|
orphan_sidecar: { text: "字幕等附属文件找不到对应视频" },
|
||||||
no_main_video: { text: "没有可入库的主视频" },
|
no_main_video: { text: "没有可整理的主视频" },
|
||||||
below_min_size: { text: "文件小于最小体积,没有识别", action: "需要时在设置里调低最小体积" },
|
below_min_size: { text: "文件小于最小体积,没有识别", action: "需要时在设置里调低最小体积" },
|
||||||
too_small: { text: "文件小于最小体积" },
|
too_small: { text: "文件小于最小体积" },
|
||||||
source_path_unknown: { text: "下载器没有报告保存路径", action: "在下载器里确认任务还在" },
|
source_path_unknown: { text: "下载器没有报告保存路径", action: "在下载器里确认任务还在" },
|
||||||
@@ -55,7 +55,7 @@ const REASONS: Record<string, ReasonText> = {
|
|||||||
blocked_by_conflict: { text: "有冲突,暂不整理" },
|
blocked_by_conflict: { text: "有冲突,暂不整理" },
|
||||||
transfer_failed: { text: "文件没有整理成功", action: "在整理记录里查看失败的文件" },
|
transfer_failed: { text: "文件没有整理成功", action: "在整理记录里查看失败的文件" },
|
||||||
main_failed: { text: "主文件整理失败,附属文件一起跳过" },
|
main_failed: { text: "主文件整理失败,附属文件一起跳过" },
|
||||||
not_imported_kind: { text: "这类文件不入库" },
|
not_imported_kind: { text: "这类文件不整理" },
|
||||||
target_root_not_managed: { text: "目标目录不是本工具管理的媒体库目录", action: "在设置 → 媒体库与目录里检查" },
|
target_root_not_managed: { text: "目标目录不是本工具管理的媒体库目录", action: "在设置 → 媒体库与目录里检查" },
|
||||||
download_removed: { text: "下载任务已从下载器删除" },
|
download_removed: { text: "下载任务已从下载器删除" },
|
||||||
// FIX-7:智能体汇报与工具结果里常见的码(能力错误、下载计划的空间判定、站点登录、待处理类型)
|
// FIX-7:智能体汇报与工具结果里常见的码(能力错误、下载计划的空间判定、站点登录、待处理类型)
|
||||||
@@ -100,7 +100,7 @@ const REASONS: Record<string, ReasonText> = {
|
|||||||
rejected: { text: "下载器拒绝了这个种子" },
|
rejected: { text: "下载器拒绝了这个种子" },
|
||||||
source_failed: { text: "从站点取种子失败", action: "检查站点登录后重新下载" },
|
source_failed: { text: "从站点取种子失败", action: "检查站点登录后重新下载" },
|
||||||
// SCR-1 / SCR-2 刮削(刮削记录、逐文件结果)
|
// SCR-1 / SCR-2 刮削(刮削记录、逐文件结果)
|
||||||
scrape_no_files: { text: "作品没有已入库文件", action: "到文件管理里对目录刮削" },
|
scrape_no_files: { text: "作品没有已整理文件", action: "到文件管理里对目录刮削" },
|
||||||
scrape_no_metadata: { text: "没有可用的元数据", action: "指定 TMDB 条目后重新刮削" },
|
scrape_no_metadata: { text: "没有可用的元数据", action: "指定 TMDB 条目后重新刮削" },
|
||||||
scrape_root_read_only: { text: "只登记模式的媒体库目录,不写文件" },
|
scrape_root_read_only: { text: "只登记模式的媒体库目录,不写文件" },
|
||||||
scrape_root_offline: { text: "目录不可访问或未挂载" },
|
scrape_root_offline: { text: "目录不可访问或未挂载" },
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (videos.length === 0 && input.files.length > 0)
|
if (videos.length === 0 && input.files.length > 0)
|
||||||
conflicts.push({ kind: "no_main_video", blocking: true, detail: "没有可入库的主视频" });
|
conflicts.push({ kind: "no_main_video", blocking: true, detail: "没有可整理的主视频" });
|
||||||
|
|
||||||
// 主视频
|
// 主视频
|
||||||
for (const v of videos) {
|
for (const v of videos) {
|
||||||
|
|||||||
@@ -44,9 +44,35 @@ function findIssues(text) {
|
|||||||
push("省略号应为 …", /\.\.\.|……/g);
|
push("省略号应为 …", /\.\.\.|……/g);
|
||||||
push("括号内有中文应用全角括号", new RegExp(`\\([^()]*[${CJK}][^()]*\\)`, "g"));
|
push("括号内有中文应用全角括号", new RegExp(`\\([^()]*[${CJK}][^()]*\\)`, "g"));
|
||||||
push("引号应为「」", /[“”]/g);
|
push("引号应为「」", /[“”]/g);
|
||||||
|
if (termScope) push("处理状态写「整理」不写「入库」(见 glossary)", TERM_RUKU);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 术语:我们只负责整理(元数据、NFO、图片、归到目录),媒体服务器有没有扫到不归本工具判断,
|
||||||
|
// 所以表示处理状态的「已入库 / 未入库 / 入库方式 / 入库后…」一律写「整理」。只抓处理状态语境的搭配,
|
||||||
|
// 单独的「入库」(如搜索关键词)不算。
|
||||||
|
const TERM_RUKU =
|
||||||
|
/(已|未|部分|核验|验证|整理|登记|原地|复制|移动|最近|新|没有|没|不|补|按集|临时作品)入库|入库(失败|中|方式|后|版本|时间|文件|位置|状态|任务)/g;
|
||||||
|
// 其它线负责、术语替换待协调者合并的文件(清单见 .local/term-replacements.md);替换完成后删掉这一项。
|
||||||
|
const TERM_PENDING = new Set([
|
||||||
|
"apps/web/app/components/agent/agent-conversation.tsx",
|
||||||
|
"apps/web/app/components/agent/mock.ts",
|
||||||
|
"apps/web/app/components/agent/quick-goals.ts",
|
||||||
|
"apps/web/app/api/agent.ts",
|
||||||
|
"apps/web/app/api/tasks.ts",
|
||||||
|
"apps/web/app/components/console/nav.ts",
|
||||||
|
"apps/web/app/components/downloads/business-tasks.tsx",
|
||||||
|
"apps/web/app/components/settings/automation.tsx",
|
||||||
|
"apps/web/app/components/settings/instances.tsx",
|
||||||
|
"apps/web/app/components/settings/library.tsx",
|
||||||
|
"apps/web/app/components/settings/organize-sources.tsx",
|
||||||
|
"apps/web/app/components/tasks/labels.ts",
|
||||||
|
"apps/web/app/routes/console/home.tsx",
|
||||||
|
"apps/web/app/routes/console/tasks.$id.tsx",
|
||||||
|
"apps/server/src/modules/agent/report.ts",
|
||||||
|
]);
|
||||||
|
let termScope = true;
|
||||||
|
|
||||||
/** 能确定的自动修正;占位符 PH 视为非中文、非字母数字 */
|
/** 能确定的自动修正;占位符 PH 视为非中文、非字母数字 */
|
||||||
function fixText(text) {
|
function fixText(text) {
|
||||||
if (!CJK_RE.test(text)) return text;
|
if (!CJK_RE.test(text)) return text;
|
||||||
@@ -195,6 +221,7 @@ function allowed(lines, line) {
|
|||||||
|
|
||||||
function processFile(file) {
|
function processFile(file) {
|
||||||
const src = readFileSync(file, "utf8");
|
const src = readFileSync(file, "utf8");
|
||||||
|
termScope = !TERM_PENDING.has(relative(ROOT, file));
|
||||||
if (/copy-allow-file/.test(src.split("\n").slice(0, 5).join("\n"))) return { issues: [], changed: false };
|
if (/copy-allow-file/.test(src.split("\n").slice(0, 5).join("\n"))) return { issues: [], changed: false };
|
||||||
let ast;
|
let ast;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user