Files
UHD_Media_Tool/packages/adapters/src/sites/spiders/rousi.ts
T

176 lines
6.6 KiB
TypeScript

/**
* 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<string, string> = {
"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<string, string | number> {
const params: Record<string, string | number> = { 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);
},
};