- SiteReleaseDetail.page: description, MediaInfo/BDInfo, screenshots,
IMDb / Douban / TMDB ids, file list, tags, uploader, plus a parse probe
(labels found) for layout-change diagnosis. Generic NexusPHP rules
(field label -> next sibling) starting from the MovieClaw detail
selectors; site overrides under umt.detail.page (HHClub: div labels).
M-Team builds the same structure from descr BBCode + mediainfo.
- Fetched only on demand (drawer open without cache, "复核详情",
release.inspect); content in kv_cache region site.detail for the
site detail cache period (>= 24 h); within the window the site is not
requested again. External ids persist in source_release.raw._detail,
which list snapshots do not overwrite.
- Screenshots only as proxy paths /api/v1/img/site-detail/{rid}/{i}:
public-address check on every hop, no site credentials, magic-byte
image check, 8 MB cap. Poster proxy shares the same helper.
- Identity: site ids count as explicit-id evidence; when TMDB is
queried, a TMDB id goes straight to details and an IMDb id to
/find (no title search on a hit). Douban only for local recall.
- Release drawer "站点详情页" section; fixtures and tests for HHClub div
layout, rowhead layout, BBCode, cache/proxy/identity end to end.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
161 lines
6.8 KiB
TypeScript
161 lines
6.8 KiB
TypeScript
/**
|
|
* M-Team API 解析。
|
|
*
|
|
* 迁移自 MoviePilot e6820a8 `app/modules/indexer/spider/mtorrent.py` `MTorrentSpider`:
|
|
* - `_movie_category/_tv_category/_music_category/_labels` → 同名常量
|
|
* - `__get_params` → `mteamSearchParams`
|
|
* - `__parse_result` → `parseMteamResult`
|
|
* - `__find_imdbid / __get_downloadvolumefactor / __get_uploadvolumefactor` → 同名函数
|
|
* - `_pageurl` → `page_url`
|
|
*
|
|
* 与 MP 的差异:
|
|
* 1. 不生成 `[base64(json 含 x-api-key)]genDlToken` 形式的 enclosure(凭据不得进入候选对象,迁移账本 MT3),
|
|
* 下载时由 adapter 现场调用 genDlToken(行为同 MP `_resolve_indirect_download_url`)。
|
|
* 2. 额外输出 `category_id`(原始分类 ID),`category` 仍是 MP 的媒体类型投影。
|
|
* 3. 额外保留 `status` 原文到 raw,便于核验。
|
|
*/
|
|
import type { MediaKind, MpMediaType, SpiderTorrent } from "../spider/spider.ts";
|
|
|
|
export const MOVIE_CATEGORY = ["401", "419", "420", "421", "439", "405", "404"];
|
|
export const TV_CATEGORY = ["403", "402", "435", "438", "404", "405"];
|
|
export const MUSIC_CATEGORY = ["434", "406"];
|
|
|
|
export const LABELS: Record<string, string> = {
|
|
"0": "",
|
|
"1": "DIY",
|
|
"2": "国配",
|
|
"3": "DIY 国配",
|
|
"4": "中字",
|
|
"5": "DIY 中字",
|
|
"6": "国配 中字",
|
|
"7": "DIY 国配 中字",
|
|
};
|
|
|
|
export const PAGE_SIZE = 100;
|
|
|
|
/** M-Team 种子 JSON(字段名按公开 API,待真实账号核验)。 */
|
|
export interface MteamTorrentJson {
|
|
id: string | number;
|
|
name?: string | null;
|
|
smallDescr?: string | null;
|
|
category?: string | number | null;
|
|
size?: string | number | null;
|
|
createdDate?: string | null;
|
|
imdb?: string | null;
|
|
douban?: string | null;
|
|
labels?: string | number | null;
|
|
labelsNew?: string[] | null;
|
|
descr?: string | null;
|
|
mediainfo?: string | null;
|
|
status?: {
|
|
seeders?: string | number | null;
|
|
leechers?: string | number | null;
|
|
timesCompleted?: string | number | null;
|
|
discount?: string | null;
|
|
discountEndTime?: string | null;
|
|
promotionRule?: { discount?: string | null; endTime?: string | null } | null;
|
|
mallSingleFree?: { status?: string | null; endDate?: string | null } | null;
|
|
} | null;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
/** MP `__get_params`(page 为 0 起) */
|
|
export function mteamSearchParams(keyword: string | null | undefined, mtype: MediaKind | undefined, page = 0) {
|
|
let categories: string[];
|
|
if (!mtype) categories = [];
|
|
else if (mtype === "tv") categories = TV_CATEGORY;
|
|
else if (mtype === "music") categories = MUSIC_CATEGORY;
|
|
else categories = MOVIE_CATEGORY;
|
|
// mtorrent 搜索 imdb 需要输入完整 imdb 链接(MP 注释引 wiki.m-team.cc/zh-tw/imdbtosearch)
|
|
const kw = keyword?.startsWith("tt") ? `https://www.imdb.com/title/${keyword}` : (keyword ?? null);
|
|
return { keyword: kw, categories, pageNumber: Number(page) + 1, pageSize: PAGE_SIZE, visible: 1 };
|
|
}
|
|
|
|
/** MP `__find_imdbid` */
|
|
export function findImdbid(imdb: string | null | undefined): string {
|
|
return imdb ? (/tt\d+/.exec(imdb)?.[0] ?? "") : "";
|
|
}
|
|
|
|
/** MP `__get_downloadvolumefactor` */
|
|
export function getDownloadVolumeFactor(discount: string | null | undefined): number {
|
|
const map: Record<string, number> = { FREE: 0, PERCENT_50: 0.5, PERCENT_70: 0.3, _2X_FREE: 0, _2X_PERCENT_50: 0.5 };
|
|
return discount ? (map[discount] ?? 1) : 1;
|
|
}
|
|
|
|
/** MP `__get_uploadvolumefactor` */
|
|
export function getUploadVolumeFactor(discount: string | null | undefined): number {
|
|
const map: Record<string, number> = { _2X: 2.0, _2X_FREE: 2.0, _2X_PERCENT_50: 2.0 };
|
|
return discount ? (map[discount] ?? 1) : 1;
|
|
}
|
|
|
|
/**
|
|
* MP `time_tools.format_timestamp`:非纯数字字符串原样返回;Unix 秒 → 本地墙钟 "YYYY-MM-DD HH:MM:SS"。
|
|
* MP 用进程本地时区(容器默认 Asia/Shanghai);这里固定 +08:00,与 adapter 按 `umt.timezone`(+08:00)转 UTC 配套。
|
|
*/
|
|
function formatTimestamp(v: string | number | null | undefined): string | undefined {
|
|
if (v == null) return undefined;
|
|
if (typeof v === "string" && !/^\d+$/.test(v)) return v;
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return String(v);
|
|
const d = new Date(Math.trunc(n) * 1000 + 8 * 3600_000);
|
|
const p = (x: number) => String(x).padStart(2, "0");
|
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
|
|
}
|
|
|
|
function toInt(v: unknown): number {
|
|
const n = Number.parseInt(String(v ?? "0") || "0", 10);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
/** MP `__parse_result` */
|
|
export function parseMteamResult(results: MteamTorrentJson[] | null | undefined, webUrl: string): SpiderTorrent[] {
|
|
const out: SpiderTorrent[] = [];
|
|
if (!results) return out;
|
|
const url = webUrl.endsWith("/") ? webUrl : `${webUrl}/`;
|
|
for (const result of results) {
|
|
const categoryValue = result.category == null ? undefined : String(result.category);
|
|
let category: MpMediaType;
|
|
if (categoryValue && MUSIC_CATEGORY.includes(categoryValue)) category = "音乐";
|
|
else if (categoryValue && TV_CATEGORY.includes(categoryValue) && !MOVIE_CATEGORY.includes(categoryValue))
|
|
category = "电视剧";
|
|
else if (categoryValue && MOVIE_CATEGORY.includes(categoryValue)) category = "电影";
|
|
else category = "未知";
|
|
let labels: string[] = [];
|
|
if (result.labelsNew && result.labelsNew.length > 0) labels = result.labelsNew.map(String);
|
|
else {
|
|
const v = LABELS[String(result.labels || "0")] ?? "";
|
|
if (v) labels = v.split(/\s+/);
|
|
}
|
|
const status = result.status ?? {};
|
|
const torrent: SpiderTorrent = {
|
|
title: result.name ?? undefined,
|
|
description: result.smallDescr ?? undefined,
|
|
size: toInt(result.size),
|
|
seeders: toInt(status.seeders),
|
|
peers: toInt(status.leechers),
|
|
grabs: toInt(status.timesCompleted),
|
|
downloadvolumefactor: getDownloadVolumeFactor(status.discount),
|
|
uploadvolumefactor: getUploadVolumeFactor(status.discount),
|
|
page_url: `${url}detail/${result.id}`,
|
|
imdbid: findImdbid(result.imdb),
|
|
labels,
|
|
category,
|
|
};
|
|
const pubdate = formatTimestamp(result.createdDate);
|
|
if (pubdate) torrent.pubdate = pubdate;
|
|
if (categoryValue) torrent.category_id = categoryValue;
|
|
if (status.discountEndTime) torrent.freedate = formatTimestamp(status.discountEndTime);
|
|
// 全站促销规则(当前馒头只有下载促销)
|
|
if (status.promotionRule) {
|
|
torrent.downloadvolumefactor = getDownloadVolumeFactor(status.promotionRule.discount ?? "NORMAL");
|
|
if (status.promotionRule.endTime) torrent.freedate = formatTimestamp(status.promotionRule.endTime);
|
|
}
|
|
if (status.mallSingleFree && status.mallSingleFree.status === "ONGOING") {
|
|
torrent.downloadvolumefactor = getDownloadVolumeFactor("FREE");
|
|
if (status.mallSingleFree.endDate) torrent.freedate = formatTimestamp(status.mallSingleFree.endDate);
|
|
}
|
|
out.push(torrent);
|
|
}
|
|
return out;
|
|
}
|