feat(subscriptions): private rule groups, MP quality presets, save path, reset and calendar API

- include/exclude sync to a per-subscription private rule group (rule_group.hard, migration 0036);
  evaluation merges chain hard conditions; private groups hidden from GET /rules, soft-deleted with
  the subscription and rebuilt on resubscribe
- MP resolution / quality / effect map to presets written onto the subscription's own slots
  (default slot template on create); UHD / Atmos go to the private group include
- downloadSavePath mapped to the downloader path and passed as qB savepath; libraryRootId used
  for the first organize when the work has no path binding; keywords appended to active search
- POST /subscriptions/{id}/reset releases stale in-flight plans, clears deferrals, re-evaluates
- GET /subscriptions/calendar from episode air dates; refreshMetadata writes TMDB season
  episode air dates for tracked seasons

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:14:40 +08:00
1 parent 52402864d6
commit 70d56645fe
20 files changed
+1663 -44

No files matched your search

@@ -1,11 +1,12 @@
import type { CandidateSeason } from "@umt/adapters";
import type { CandidateSeason, TmdbSeasonDetail } from "@umt/adapters";
import { sql } from "@umt/db";
import { resolveEpisodeTotal } from "@umt/domain";
import type { Exec } from "./common.ts";
// W5-C 季总集数账本(content_unit(kind=season).episode_total):refreshMetadata 用 TMDB seasons[].episodeCount 刷新。
// 规则在 domain resolveEpisodeTotal:只升;TMDB 回退不低于已覆盖最高集;人工锁定不改;未公布(0)保持未知。
// 季单元不存在时按元数据建(confirmed);不预建集单元(集单元仍由发布/文件懒建,provisional 不代表总数)。
// 季单元不存在时按元数据建(confirmed)。集单元:追踪中订阅范围内的季按 TMDB 季详情建或补播出日期(confirmed,
// 订阅日历与覆盖矩阵的未播出判定用);其余集单元仍由发布/文件懒建。
export interface SeasonTotalsResult {
seasons: { season: number; total: number | null; known: boolean; changed: boolean }[];
@@ -80,3 +81,89 @@ export async function applySeasonTotals(
out.activated = Number(act.numUpdatedRows ?? 0);
return out;
}
/**
* 追踪中订阅需要播出日期的季:订阅季(接续后续季时含之后各季);订阅没指定季时取最新两季。最多 6 季。
* 没有追踪中的订阅返回空(不额外请求 TMDB)。
*/
export async function trackedSeasonsForAirDates(
exec: Exec,
entityId: string,
known: readonly number[],
): Promise<number[]> {
const subs = await exec
.selectFrom("subscription as s")
.leftJoin("content_unit as u", "u.id", "s.season_unit_id")
.select(["s.season_scope", "u.season_number"])
.where("s.media_entity_id", "=", entityId)
.where("s.media_kind", "<>", "movie")
.where("s.status", "in", ["active", "pending", "paused"])
.execute();
if (!subs.length) return [];
const all = [...new Set(known.filter((n) => n >= 1))].sort((a, b) => a - b);
const want = new Set<number>();
for (const s of subs) {
if (s.season_number === null || s.season_number === undefined) {
for (const n of all.slice(-2)) want.add(n);
continue;
}
want.add(s.season_number);
if (s.season_scope === "following_seasons") for (const n of all) if (n > s.season_number) want.add(n);
}
return [...want]
.sort((a, b) => b - a)
.slice(0, 6)
.sort((a, b) => a - b);
}
/**
* TMDB 季详情 → 集单元播出日期:按(作品, 季, 集)找未退役的集单元,没有就建(confirmed,挂季单元);
* 播出日期只在 TMDB 给了值且不同时改写;标题只补空。返回改动的集数。
*/
export async function applyEpisodeAirDates(exec: Exec, entityId: string, detail: TmdbSeasonDetail): Promise<number> {
const season = detail.seasonNumber;
if (season < 1 || !detail.episodes.length) return 0;
const seasonUnit = await ensureSeason(exec, entityId, season, detail.name);
const units = await exec
.selectFrom("content_unit")
.select(["id", "episode_number", sql<string | null>`to_char(air_date, 'YYYY-MM-DD')`.as("air"), "title"])
.where("media_entity_id", "=", entityId)
.where("kind", "=", "episode")
.where("season_number", "=", season)
.where("status", "!=", "retired")
.orderBy("created_at")
.execute();
let n = 0;
for (const e of detail.episodes.slice(0, 500)) {
if (e.episodeNumber < 1) continue;
const air = e.airDate && /^\d{4}-\d{2}-\d{2}/.test(e.airDate) ? e.airDate.slice(0, 10) : null;
const hit = units.find((u) => u.episode_number === e.episodeNumber);
if (hit) {
const patch = {
...(air && air !== hit.air ? { air_date: air } : {}),
...(!hit.title && e.name ? { title: e.name } : {}),
};
if (Object.keys(patch).length) {
await exec.updateTable("content_unit").set(patch).where("id", "=", hit.id).execute();
n++;
}
continue;
}
await exec
.insertInto("content_unit")
.values({
media_entity_id: entityId,
parent_unit_id: seasonUnit,
kind: "episode",
season_number: season,
episode_number: e.episodeNumber,
status: "confirmed",
display_order: e.episodeNumber,
title: e.name,
air_date: air,
})
.execute();
n++;
}
return n;
}
@@ -1,4 +1,4 @@
import { type MetadataCandidate, TmdbError } from "@umt/adapters";
import { type MetadataCandidate, TmdbError, type TmdbSeasonDetail } from "@umt/adapters";
import type { EntityMetadataField, FieldOverrideInput, FinalFieldValue, MetadataSource } from "@umt/contracts";
import { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts";
@@ -6,7 +6,12 @@ import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, type Exec } from "./common.ts";
import { resolveEntityId, seedAliases } from "./entities.ts";
import { applySeasonTotals, type SeasonTotalsResult } from "./metadata-totals.ts";
import {
applyEpisodeAirDates,
applySeasonTotals,
type SeasonTotalsResult,
trackedSeasonsForAirDates,
} from "./metadata-totals.ts";
// refreshMetadata:字段级刷新(REQ §7.3)。来源值写 metadata_field;最终值 = 覆盖(锁)> 已锁字段保持 > 来源值。
// 只更新 media_entity 的展示字段与别名,不触碰 path_binding(路径绑定不随元数据变化)。
@@ -96,6 +101,22 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
}
throw e;
}
// 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新
const airSeasons: TmdbSeasonDetail[] = [];
if (ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
const want = await trackedSeasonsForAirDates(
ctx.db,
id,
detail.seasons.map((x) => x.seasonNumber),
);
for (const n of want) {
try {
airSeasons.push(await tmdb.season(ident.external_id, n));
} catch (e) {
ctx.logger.debug({ entityId: id, season: n, err: { message: (e as Error).message } }, "季详情获取失败");
}
}
}
const now = rt.now();
return ctx.db.transaction().execute(async (trx) => {
await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now);
@@ -113,6 +134,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
ident.entity_type === "tv" && detail.seasons?.length
? await applySeasonTotals(trx, id, detail.seasons, now)
: null;
for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd);
await trx.updateTable("media_entity").set({ last_metadata_refresh_at: now }).where("id", "=", id).execute();
return { status: "refreshed" as const, ...r, ...(totals ? { seasons: totals.seasons } : {}) };
});
@@ -158,7 +158,15 @@ export async function searchSubscription(
keywords.push(v);
if (keywords.length >= SEARCH_MAX_KEYWORDS) break;
}
const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number };
const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number; keywords?: string[] };
// 订阅的搜索关键词(MP keyword)追加在标题/别名之后,不占 SEARCH_MAX_KEYWORDS;结果同样经识别 → 评估
// (订阅私有规则组的包含 / 排除在评估时过滤,与 RSS 来的资源同一口径)
for (const k of params.keywords ?? []) {
const v = typeof k === "string" ? k.trim() : "";
if (!v || seen.has(v.toLowerCase())) continue;
seen.add(v.toLowerCase());
keywords.push(v);
}
let sq = db.selectFrom("site").selectAll().where("enabled", "=", true).where("health_status", "!=", "failing");
if (params.siteIds?.length) sq = sq.where("id", "in", params.siteIds);
const sites = await sq.orderBy("name").execute();