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:
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();
|
||||
|
||||
Reference in new issue
Block a user