/** * SPDX-License-Identifier: GPL-3.0-only * 移植自 MoviePilot(https://github.com/jxxghp/MoviePilot,GPLv3),本文件按 GPLv3 条款提供。 * @mp-source app/modules/indexer/spider/rousi.py @ 3b156a5 * * Rousi.pro PeerGo 兼容 API(parser "RousiPro",Bearer 个人 API Key,需 torrent:read + torrent:download)。 * 函数对照 `RousiSpider`: * - `_movie_category/_tv_category/_music_category` → `ROUSI_*_CATEGORY` * - `get_search_page_size` → `pageSize`(100) * - `__get_params` → `rousiSearchParams`;`__get_category_names_by_ids` → `rousiCategoryNamesByIds` * - `__process_response` → `rousiSpider.search` 错误分支;`__parse_result` → `parseRousiResult` * - `__get_download_url`(GET 详情接口,result=data.download_url)→ `rousiSpider.resolveDownload`(现场换票,不生成含 key 的 enclosure) */ import type { MpMediaType } from "../spider/spider.ts"; import { extractDomain, normalizeDatetime } from "../user-parsers/common.ts"; import { apiRequest, businessError, checkReleaseId, downloadTorrentFile, isoWithZoneToUtc, isPlainObject, resolveIndirectDownloadUrl, toFloat, toInt, } from "./common.ts"; import type { SpecializedSiteDefinition, SpecializedSpider, SpecializedTorrent, SpiderSearchQuery } from "./types.ts"; /** API 不支持多分类搜索,每次只使用一个分类 */ export const ROUSI_MOVIE_CATEGORY = "movie"; export const ROUSI_TV_CATEGORY = "tv"; export const ROUSI_MUSIC_CATEGORY = "music"; const ROUSI_ID_TO_NAME: Record = { "1": "movie", "2": "tv", "3": "documentary", "4": "animation", "5": "music", "6": "variety", }; /** 站点 origin(MP `https://%s` % extract_domain(domain)) */ export function rousiBase(def: SpecializedSiteDefinition): string { return (def.umt.apiBaseUrl ?? `https://${extractDomain(def.domain)}`).replace(/\/+$/, ""); } /** MP `__get_category_names_by_ids` */ export function rousiCategoryNamesByIds(cat: string | null | undefined): string[] | null { if (!cat) return null; const ids = cat .split(",") .map((c) => c.trim()) .filter(Boolean); const names = ids.filter((id) => id in ROUSI_ID_TO_NAME).map((id) => ROUSI_ID_TO_NAME[id]!); return names.length ? names : null; } /** MP `__get_params` */ export function rousiSearchParams(q: SpiderSearchQuery): Record { const params: Record = { page: Number(q.page ?? 0) + 1, page_size: 100 }; if (q.keyword) params.keyword = q.keyword; if (q.cat) { const names = rousiCategoryNamesByIds(q.cat); if (names) params.category = names[0]!; } else if (q.mtype) { if (q.mtype === "movie") params.category = ROUSI_MOVIE_CATEGORY; else if (q.mtype === "tv") params.category = ROUSI_TV_CATEGORY; else if (q.mtype === "music") params.category = ROUSI_MUSIC_CATEGORY; } return params; } export interface RousiItem { id: number | string; title?: string; subtitle?: string; category?: string | { slug?: string; name?: string } | null; size?: number | string; seeders?: number | string; leechers?: number | string; downloads?: number | string; created_at?: string; promotion?: { is_active?: boolean; down_multiplier?: number; up_multiplier?: number; until?: string } | null; [k: string]: unknown; } /** MP `__parse_result` */ export function parseRousiResult(results: RousiItem[] | null | undefined, def: SpecializedSiteDefinition) { const torrents: SpecializedTorrent[] = []; if (!results?.length) return torrents; const base = rousiBase(def); for (const result of results) { const rawCat = result.category; let catVal: string | null | undefined = null; let category: MpMediaType = "未知"; if (rawCat && typeof rawCat === "object") catVal = rawCat.slug || rawCat.name; else if (typeof rawCat === "string") catVal = rawCat; if (catVal) { catVal = String(catVal).toLowerCase(); if (catVal === ROUSI_MOVIE_CATEGORY) category = "电影"; else if (catVal === ROUSI_TV_CATEGORY) category = "电视剧"; else if (catVal === ROUSI_MUSIC_CATEGORY) category = "音乐"; } // API 后端已处理全站促销优先级,直接使用返回的 promotion 数据 let dl = 1.0; let ul = 1.0; let freedate: string | undefined; let freedateUtc: string | undefined; const promotion = result.promotion; if (promotion?.is_active) { dl = toFloat(promotion.down_multiplier ?? 1.0); ul = toFloat(promotion.up_multiplier ?? 1.0); if (promotion.until) { freedate = normalizeDatetime(promotion.until) ?? undefined; freedateUtc = isoWithZoneToUtc(promotion.until); } } const id = result.id; const t: SpecializedTorrent = { title: result.title, description: result.subtitle, size: toInt(result.size), seeders: toInt(result.seeders), peers: toInt(result.leechers), grabs: toInt(result.downloads), downloadvolumefactor: dl, uploadvolumefactor: ul, page_url: `${base}/torrents/${id}`, labels: [], category, site_release_id: String(id), }; const pubdate = normalizeDatetime(result.created_at); if (pubdate) t.pubdate = pubdate; const pubUtc = isoWithZoneToUtc(result.created_at); if (pubUtc) t.pubdate_utc = pubUtc; if (freedate) t.freedate = freedate; if (freedateUtc) t.freedate_utc = freedateUtc; if (catVal) t.category_id = catVal; torrents.push(t); } return torrents; } export const rousiSpider: SpecializedSpider = { parser: "RousiPro", credential: "apiKey", pageSize: () => 100, async search(ctx, q) { const apikey = await ctx.secret("apiKey", true); const data = await apiRequest(ctx, `${rousiBase(ctx.def)}/api/v1/torrents`, { method: "GET", headers: { Authorization: `Bearer ${apikey}`, Accept: "application/json" }, query: rousiSearchParams(q), }); if (!isPlainObject(data)) throw businessError(ctx, "搜索", "搜索响应结构无效"); if (data.code === 0) { const d = isPlainObject(data.data) ? data.data : {}; return parseRousiResult((d.torrents as RousiItem[]) ?? [], ctx.def); } throw businessError(ctx, "搜索", data.message); }, async resolveDownload(ctx, siteReleaseId) { const apikey = await ctx.secret("apiKey", true); const id = checkReleaseId(ctx.def.id, siteReleaseId); // MP __get_download_url:method=get, cookie=False, Bearer, result=data.download_url const url = await resolveIndirectDownloadUrl(ctx, `${rousiBase(ctx.def)}/api/v1/torrents/${id}`, { method: "get", header: { Authorization: `Bearer ${apikey}`, Accept: "application/json" }, result: "data.download_url", }); return downloadTorrentFile(ctx, url); }, };