/** * SPDX-License-Identifier: GPL-3.0-only * @mp-source app/modules/indexer/spider/milkie.py @ 3b156a5 * 对照 MoviePilot v3.2.4 的公开 Milkie API 行为实现。搜索结果不保存含 API Key 的下载地址。 */ import { SiteError } from "../types.ts"; import { apiRequest, businessError, downloadTorrentFile, isoWithZoneToUtc, isPlainObject, siteBase } from "./common.ts"; import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent, SpiderSearchQuery } from "./types.ts"; export function milkieSearchParams(query: SpiderSearchQuery): Record { const params: Record = { pi: query.page ?? 0, ps: 100, oby: "created_at", odir: "desc" }; if (query.keyword) { params.query = query.keyword; const category = query.mtype ? { movie: "1", tv: "2", music: "3" }[query.mtype] : undefined; if (category) params.categories = category; } return params; } /** Milkie 资源 ID 可能为 UUID;不使用其它站点的数字 ID 提取规则。 */ function milkieId(siteId: string, value: unknown): string { const id = String(value ?? ""); if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new SiteError(siteId, "not_found", "Milkie 资源 ID 非法"); return id; } export function parseMilkieResult(rows: unknown[], def: SpecializedSiteDefinition): SpecializedTorrent[] { return rows.flatMap((row): SpecializedTorrent[] => { if (!isPlainObject(row) || row.id == null || !row.releaseName) return []; const id = milkieId(def.id, row.id); const category = { "1": "电影", "2": "电视剧", "3": "音乐" } as const; const result: SpecializedTorrent = { title: String(row.releaseName), description: id, site_release_id: id, page_url: `${siteBase(def)}browse/${encodeURIComponent(id)}`, category: category[String(row.category) as keyof typeof category] ?? "未知", downloadvolumefactor: 0, uploadvolumefactor: 1, }; if (row.category != null) result.category_id = String(row.category); for (const [source, target] of [ ["size", "size"], ["seeders", "seeders"], ["leechers", "peers"], ["downloaded", "grabs"], ] as const) { const value = row[source] == null ? NaN : Number(row[source]); if (Number.isFinite(value)) result[target] = value; } const utc = isoWithZoneToUtc(row.createdAt); if (utc) result.pubdate_utc = utc; else if (typeof row.createdAt === "string") result.pubdate = row.createdAt; if (isPlainObject(row.externals) && typeof row.externals.imdb === "string") result.imdbid = row.externals.imdb; return [result]; }); } export const milkieSpider: SpecializedSpider = { parser: "Milkie", credential: "apiKey", pageSize: () => 100, async search(ctx, query) { const key = (await ctx.secret("apiKey", true))!; const payload = await apiRequest(ctx, `${siteBase(ctx.def)}api/v1/torrents`, { headers: { "x-milkie-auth": key }, query: milkieSearchParams(query), }); if (!isPlainObject(payload) || !Array.isArray(payload.torrents)) throw businessError(ctx, "搜索", "Milkie 响应不含 torrents 列表"); return parseMilkieResult(payload.torrents, ctx.def); }, async resolveDownload(ctx, siteReleaseId) { const key = (await ctx.secret("apiKey", true))!; const id = milkieId(ctx.def.id, siteReleaseId); const url = new URL(`api/v1/torrents/${encodeURIComponent(id)}/torrent`, siteBase(ctx.def)); url.searchParams.set("key", key); return downloadTorrentFile(ctx, url.href); }, };