diff --git a/.gitignore b/.gitignore index f07831eec..aa4169e43 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules # 本地调研/设计文档(不入库) docs +.research-flood +.research-iyuu ## Build files dist diff --git a/src/entries/background/utils/nativeMessaging.ts b/src/entries/background/utils/nativeMessaging.ts index c8ce3a521..b5608b795 100644 --- a/src/entries/background/utils/nativeMessaging.ts +++ b/src/entries/background/utils/nativeMessaging.ts @@ -49,6 +49,19 @@ const ALLOWED_METHODS = new Set([ "updateKeepUploadTask", "deleteKeepUploadTask", "clearKeepUploadTasks", + // IYUU reseed center + "getIyuusConfig", + "setIyuusConfig", + "iyuuFetchSites", + "iyuuReportExisting", + "iyuuQueryReseed", + "iyuuDeriveHeldSites", + "iyuuResolveHits", + "iyuuScanForReseed", + "crossSeedScanForReseed", + "crossSeedScanTorrents", + "nexusValidateApi", + "reseedDecisionRecord", ]); // ── Module-scoped state ────────────────────────────────────────────── diff --git a/src/entries/messages.ts b/src/entries/messages.ts index 6abeb457a..2582ccd06 100644 --- a/src/entries/messages.ts +++ b/src/entries/messages.ts @@ -27,6 +27,7 @@ import type { TorrentQueueDirection, TorrentSpeedLimit, } from "@ptd/downloader"; +import type { IYUUReseedHit, ICrossSeedCandidate } from "@ptd/crossSeed"; // 可序列化的种子信息,用于辅种检测 export interface ITorrentInfoForVerification { @@ -56,6 +57,8 @@ import { IKeepUploadTask, TKeepUploadTaskKey, BridgeStatus, + IIyuuStorageSchema, + IIyuuSiteCacheEntry, } from "@/shared/types.ts"; import { isDebug } from "~/helper.ts"; @@ -196,6 +199,50 @@ interface ProtocolMap extends TMessageMap { deleteKeepUploadTask(taskId: TKeepUploadTaskKey): void; clearKeepUploadTasks(): void; + // 2.8 IYUU 辅种中心 ( utils/iyuu ) + getIyuusConfig(): IIyuuStorageSchema | undefined; + setIyuusConfig(data: Partial): void; + /** 拉取并缓存 IYUU 站点表(TTL 24h) */ + iyuuFetchSites(): IIyuuSiteCacheEntry[]; + /** 汇报已持有站点,返回 sid_sha1(写入缓存,7 天有效) */ + iyuuReportExisting(sidList: number[]): string; + /** 查询辅种:给定本地 infohash 列表,返回各站命中 */ + iyuuQueryReseed(hashes: string[]): Record; + /** 从本地已配置站点自动推导持有站点(本地 id → IYUU sid) */ + iyuuDeriveHeldSites(): { localIds: TSiteID[]; sidList: number[]; unmatched: TSiteID[] }; + /** 批量把 IYUU 查询命中解析为辅种候选(B 路线优先,A 兜底;不可注入项标 error) */ + iyuuResolveHits(data: { + hits: Array<{ sid: number; torrent_id: number; info_hash?: string }>; + sources?: Record; + }): ICrossSeedCandidate[]; + /** 批量辅种扫描:下载器已完成种子 hash 分批查 IYUU 并解析候选 */ + iyuuScanForReseed(downloaderId: string): ICrossSeedCandidate[]; + /** 多源聚合扫描(crossSeed):IYUU 中心 + NexusPHP pieces-hash 直查 + 本地文件树对比 */ + crossSeedScanForReseed(data: { + downloaderId: string; + options?: { + enableIyuus?: boolean; + enableNexus?: boolean; + enableLocal?: boolean; + /** 仅扫描指定 infohash 子集 */ + hashes?: string[]; + }; + }): ICrossSeedCandidate[]; + /** 多源聚合扫描(指定种子集合,可跨下载器;MyClient 勾选/详情单查) */ + crossSeedScanTorrents(data: { + torrents: Array<{ clientId: string; infoHash: string; name: string; savePath: string; totalSize: number }>; + options?: { enableIyuus?: boolean; enableNexus?: boolean; enableLocal?: boolean }; + }): ICrossSeedCandidate[]; + /** 验证 NexusPHP pieces-hash 接口是否存在(非 HTTP 404 即视为可达) */ + nexusValidateApi(data: { apiUrl: string; passkey?: string }): { ok: boolean; status?: number; error?: string }; + /** 记录候选已推送(decision 持久化,跨扫描去重) */ + reseedDecisionRecord(data: { + siteId: string; + torrentId: number; + infoHash?: string; + decision?: "injected" | "matched"; + }): boolean; + // 2.8 Lightweight list queries (for CLI discovery) getSiteList(): Array<{ id: string; name: string; url: string; offline: boolean }>; getDownloaderList(): Array<{ id: string; name: string; type: string; enabled: boolean; address: string }>; diff --git a/src/entries/offscreen/adapter/indexdb.ts b/src/entries/offscreen/adapter/indexdb.ts index 4c8bf02aa..d7fcc2954 100644 --- a/src/entries/offscreen/adapter/indexdb.ts +++ b/src/entries/offscreen/adapter/indexdb.ts @@ -1,7 +1,7 @@ import { openDB, type IDBPDatabase } from "idb"; import { IPtdDBSchemaV1, IPtdDBSchema, IPtdDBSchemaV2 } from "@/shared/types.ts"; -export const ptdIndexDb = openDB("ptd", 3, { +export const ptdIndexDb = openDB("ptd", 4, { upgrade(db, oldVersion) { if (oldVersion < 1) { const dbV1 = db as unknown as IDBPDatabase; @@ -14,5 +14,8 @@ export const ptdIndexDb = openDB("ptd", 3, { if (oldVersion < 3) { db.createObjectStore("favicon"); } + if (oldVersion < 4) { + db.createObjectStore("reseed_decision", { keyPath: "key" }); + } }, }); diff --git a/src/entries/offscreen/offscreen.ts b/src/entries/offscreen/offscreen.ts index c9a761146..03ab9f322 100644 --- a/src/entries/offscreen/offscreen.ts +++ b/src/entries/offscreen/offscreen.ts @@ -9,3 +9,5 @@ import "./utils/backup.ts"; import "./utils/socialInformation.ts"; import "./utils/socialRecommendations.ts"; import "./utils/keepUploadTask.ts"; +import "./utils/iyuu.ts"; +import "./utils/crossSeed.ts"; diff --git a/src/entries/offscreen/utils/crossSeed.ts b/src/entries/offscreen/utils/crossSeed.ts new file mode 100644 index 000000000..76f72cbf0 --- /dev/null +++ b/src/entries/offscreen/utils/crossSeed.ts @@ -0,0 +1,515 @@ +/** + * crossSeed 聚合扫描:IYUU 中心 / NexusPHP pieces-hash 直查 / 本地文件树对比 三源统一入口。 + * 候选统一为 ICrossSeedCandidate(带 source 标识),UI 只消费候选数组,与单源时代完全兼容。 + */ +import axios from "axios"; + +import { onMessage, sendMessage } from "@/messages.ts"; +import type { IMetadataPiniaStorageSchema, IReseedDecision } from "@/shared/types.ts"; +import type { TSiteID } from "@ptd/site"; +import type { CTorrent } from "@ptd/downloader"; +import { getRemoteTorrentFile } from "@ptd/downloader"; +import { + mapNexusHits, + nexusQueryPiecesHash, + assessLocalCandidate, + fuzzySizeDoesMatch, + normalizeClientFiles, + piecesHashFromInfoPieces, + type ICrossSeedCandidate, + type ICrossSeedLocalSeed, + type ILocalSeedForMatch, + type INexusSiteConfig, + type TCrossSeedSourceKind, + type TLocalMatchMode, +} from "@ptd/crossSeed"; + +import { getDownloaderInstance } from "./download.ts"; +import { getSiteInstance } from "./site.ts"; +import { iyuuQueryReseed, iyuuResolveHits } from "./iyuu.ts"; +import { ptdIndexDb } from "../adapter/indexdb.ts"; + +export interface ICrossSeedScanOptions { + enableIyuus?: boolean; + enableNexus?: boolean; + enableLocal?: boolean; + /** 仅扫描指定 infohash 子集(勾选场景;缺省扫描该下载器全部已完成种子) */ + hashes?: string[]; +} + +// ── 决策持久化(跨扫描去重) ──────────────────────────── + +export function reseedDecisionKey(siteId: string, torrentId: number): string { + return `${siteId}:${torrentId}`; +} + +/** 记录候选已推送(decision 持久化,避免重复 snatch/重复注入) */ +export async function recordReseedDecision( + siteId: string, + torrentId: number, + infoHash?: string, + decision: "injected" | "matched" = "injected", +): Promise { + await ( + await ptdIndexDb + ).put("reseed_decision", { + key: reseedDecisionKey(siteId, torrentId), + siteId, + torrentId, + infoHash, + decision, + time: Date.now(), + }); +} + +onMessage("reseedDecisionRecord", async ({ data: { siteId, torrentId, infoHash, decision } }) => { + await recordReseedDecision(siteId, torrentId, infoHash, decision); + return true; +}); + +/** 聚合结果收尾:按 siteId+torrentId 去重(同站同种子跨源重复保留首个)并标注已推送 */ +async function finalizeCandidates(candidates: ICrossSeedCandidate[]): Promise { + const db = await ptdIndexDb; + const decisions = await db.getAll("reseed_decision"); + const decisionByKey = new Map(decisions.map((d) => [d.key, d] as const)); + const seen = new Map(); + for (const c of candidates) { + const key = reseedDecisionKey(c.siteId, c.torrentId); + if (seen.has(key)) continue; + seen.set(key, { ...c, injected: decisionByKey.has(key) || Boolean(c.injected) }); + } + return [...seen.values()]; +} + +/** + * 读取全局辅种方案开关(config.reseed.enableIyuus/enableNexus/enableLocal)。 + * 显式传入 options 时优先使用(便于 CLI/调试覆盖)。 + */ +async function resolveSourceOptions( + options: ICrossSeedScanOptions, +): Promise>> { + const config = (await sendMessage("getExtStorage", "config")) as + { reseed?: Partial> } | undefined; + const reseed = config?.reseed ?? {}; + return { + enableIyuus: options.enableIyuus ?? reseed.enableIyuus ?? true, + enableNexus: options.enableNexus ?? reseed.enableNexus ?? true, + enableLocal: options.enableLocal ?? reseed.enableLocal ?? false, + }; +} + +/** + * 多源聚合扫描:取下载器已完成种子 → 按启用的源并行查询并合并候选。 + * 源级失败隔离:某源异常不影响其余源结果(以 error 候选提示)。 + */ +export async function crossSeedScanForReseed( + downloaderId: string, + options: ICrossSeedScanOptions = {}, +): Promise { + const { enableIyuus, enableNexus, enableLocal } = await resolveSourceOptions(options); + + const instance = await getDownloaderInstance(downloaderId); + if (!instance) { + return [errorCandidate("下载器不存在或未配置", "iyuu")]; + } + const torrents = await instance.getAllTorrents(); + const subset = options.hashes ? new Set(options.hashes) : undefined; + const completed = torrents.filter((t) => t.isCompleted && t.infoHash && (!subset || subset.has(t.infoHash))); + if (!completed.length) { + return [errorCandidate("该下载器没有已完成的种子", "iyuu")]; + } + + const seeds: ICrossSeedLocalSeed[] = completed.map((t) => ({ + infoHash: t.infoHash, + name: t.name, + savePath: t.savePath, + size: t.totalSize, + clientId: t.clientId, + })); + + const results: ICrossSeedCandidate[] = []; + + if (enableIyuus) { + try { + results.push(...(await scanIyuuSource(seeds))); + } catch (e) { + results.push(errorCandidate(messageOf(e), "iyuu")); + } + } + + if (enableNexus) { + try { + results.push(...(await scanNexusSource(seeds))); + } catch (e) { + results.push(errorCandidate(messageOf(e), "nexusphp")); + } + } + + if (enableLocal) { + try { + // 单下载器场景:文件列表走同一个下载器实例 + results.push( + ...(await scanLocalSource(seeds as ILocalSeedWithClient[], async (clientId) => + clientId === downloaderId ? instance : null, + )), + ); + } catch (e) { + results.push(errorCandidate(messageOf(e), "local")); + } + } + + return await finalizeCandidates(results); +} + +/** + * 多源聚合扫描(指定种子集合,可跨下载器):IYUU/NexusPHP/Local 按开关执行。 + * 用于 MyClient 勾选种子批量扫描与下载器详情单查。 + */ +export async function crossSeedScanTorrents( + input: Array<{ clientId: string; infoHash: string; name: string; savePath: string; totalSize: number }>, + options: ICrossSeedScanOptions = {}, +): Promise { + const { enableIyuus, enableNexus, enableLocal } = await resolveSourceOptions(options); + const seeds: ILocalSeedWithClient[] = input.map((t) => ({ + infoHash: t.infoHash, + name: t.name, + savePath: t.savePath, + size: t.totalSize, + clientId: t.clientId, + })); + if (!seeds.length) { + return [errorCandidate("未选择需要扫描的种子", "iyuu")]; + } + + const results: ICrossSeedCandidate[] = []; + + if (enableIyuus) { + try { + results.push(...(await scanIyuuSource(seeds))); + } catch (e) { + results.push(errorCandidate(messageOf(e), "iyuu")); + } + } + + if (enableNexus) { + try { + results.push(...(await scanNexusSource(seeds))); + } catch (e) { + results.push(errorCandidate(messageOf(e), "nexusphp")); + } + } + + if (enableLocal) { + try { + results.push(...(await scanLocalSource(seeds, getDownloaderInstance))); + } catch (e) { + results.push(errorCandidate(messageOf(e), "local")); + } + } + + return await finalizeCandidates(results); +} + +onMessage("crossSeedScanTorrents", async ({ data: { torrents, options } }) => { + return await crossSeedScanTorrents(torrents, options); +}); + +onMessage("crossSeedScanForReseed", async ({ data: { downloaderId, options } }) => { + return await crossSeedScanForReseed(downloaderId, options); +}); + +// ── IYUU 中心源 ───────────────────────────────────────── + +async function scanIyuuSource(seeds: ICrossSeedLocalSeed[]): Promise { + // 与 iyuuScanForReseed 相同:hash 分批(100/批)查中心 → 统一候选解析 + const hashes = seeds.map((s) => s.infoHash); + const hits: Array<{ sid: number; torrent_id: number; info_hash: string }> = []; + const BATCH = 100; + for (let i = 0; i < hashes.length; i += BATCH) { + const resp = await iyuuQueryReseed(hashes.slice(i, i + BATCH)); + for (const [hash, item] of Object.entries(resp)) { + for (const h of item.torrent ?? []) { + hits.push({ sid: h.sid, torrent_id: h.torrent_id, info_hash: hash }); + } + } + } + const sources: Record = {}; + for (const s of seeds) { + sources[s.infoHash] = { name: s.name, savePath: s.savePath, size: s.size }; + } + return await iyuuResolveHits(hits, sources); +} + +// ── NexusPHP pieces-hash 直查源 ───────────────────────── + +/** 读取设置页录入的已启用 Nexus 站点配置 */ +async function enabledNexusConfigs(): Promise { + const metadata = (await sendMessage("getExtStorage", "metadata")) as IMetadataPiniaStorageSchema | undefined; + const nexusSites = metadata?.iyuu?.nexusSites ?? {}; + const configs: INexusSiteConfig[] = []; + for (const [siteId, c] of Object.entries(nexusSites)) { + if (!c.enabled || !c.passkey) continue; + // 接口地址留空时使用默认路径:站点实例基址 + /api/pieces-hash(host 与完整 url 由站点定义拼接) + let apiUrl = c.apiUrl; + if (!apiUrl) { + try { + const inst = await getSiteInstance<"public">(siteId as TSiteID); + const base = (inst as unknown as { url?: string }).url ?? ""; + apiUrl = `${base.replace(/\/+$/, "")}/api/pieces-hash`; + } catch (e) { + continue; + } + } + configs.push({ siteId: siteId as TSiteID, apiUrl, passkey: c.passkey, enabled: true }); + } + return configs; +} + +/** + * Nexus 源:本地种子 pieces_hash(sha1(info.pieces))→ 对每个已配置的 nexus 站点直查 → 候选。 + * 本地 pieces 由调用方前置计算(gatherPiecesHash / 下载器扩展能力);聚合层对缺失 pieces 的 + * 种子给出提示候选而非静默跳过。 + */ +async function scanNexusSource(seeds: ICrossSeedLocalSeed[]): Promise { + const configs = await enabledNexusConfigs(); + if (!configs.length) { + return [errorCandidate("未配置 NexusPHP pieces-hash 站点(设置 → IYUU 辅种中心 → NexusPHP 直查)", "nexusphp")]; + } + + const withPieces = seeds.filter((s) => s.piecesHash); + if (!withPieces.length) { + return [ + errorCandidate("NexusPHP 直查需要本地种子 pieces_hash(当前扫描未提供种子文件/下载器导出能力)", "nexusphp"), + ]; + } + + const results: ICrossSeedCandidate[] = []; + for (const config of configs) { + const hits = await nexusQueryPiecesHash( + config.apiUrl, + config.passkey, + withPieces.map((s) => s.piecesHash!), + ); + for (const hit of mapNexusHits(hits)) { + const seed = withPieces.find((s) => s.piecesHash === hit.piecesHash); + if (!seed) continue; + results.push(...(await buildSourceCandidates(config, seed, hit.torrentId, "nexusphp"))); + } + } + return results; +} + +// ── NexusPHP 接口可用性验证 ───────────────────────────── + +export interface INexusVerifyResult { + ok: boolean; + status?: number; + error?: string; +} + +/** 验证 pieces-hash 接口是否存在:任何非 HTTP 404 的响应(含 401/400/500)都视为接口可达 */ +export async function nexusVerifyApi(apiUrl: string, passkey?: string): Promise { + try { + const resp = await axios.post( + apiUrl, + { pieces_hash: [] }, + { + params: passkey ? { passkey } : undefined, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + timeout: 15e3, + // 不按状态码抛错:由调用方判断 + validateStatus: () => true, + }, + ); + if (resp.status === 404) { + return { ok: false, status: 404, error: "接口不存在(HTTP 404)" }; + } + return { ok: true, status: resp.status }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +onMessage("nexusValidateApi", async ({ data: { apiUrl, passkey } }) => { + return await nexusVerifyApi(apiUrl, passkey); +}); + +// ── Local 文件树对比源 ────────────────────────────────── + +/** Local 源配置:目标站列表 + 匹配模式(读取设置页录入) */ +async function localConfig(): Promise<{ sites: TSiteID[]; matchMode: TLocalMatchMode; searchLimit: number }> { + const metadata = (await sendMessage("getExtStorage", "metadata")) as IMetadataPiniaStorageSchema | undefined; + const iyuu = metadata?.iyuu ?? {}; + const sites = (iyuu.localSites ?? []).filter((id) => id); + const matchMode: TLocalMatchMode = iyuu.localMatchMode ?? "strict"; + return { sites, matchMode, searchLimit: iyuu.localSearchLimit ?? 10 }; +} + +type ILocalSeedWithClient = ICrossSeedLocalSeed & { clientId: string }; + +type TClientInstance = NonNullable>>; + +/** + * Local 源(参照 cross-seed torrent-based 主路径): + * 本地种子文件树(getTorrentFiles,按种子 clientId 取下载器实例)充当 searchee → 目标站站内搜索同名候选 + * → snatch 候选 .torrent(带站点 cookie)→ parse-torrent 解析文件树/pieces_hash + * → assessLocalCandidate 决策(hash 去重 / fuzzySize / matchMode 文件树 / pieces 强化) + * → 命中构建候选(B 路线注入链接)。风控:每站搜索次数上限 searchLimit、串行执行。 + */ +async function scanLocalSource( + seeds: ILocalSeedWithClient[], + instanceFor: (clientId: string) => Promise, +): Promise { + const config = await localConfig(); + if (!config.sites.length) { + return [errorCandidate("未配置本地对比目标站(设置 → 辅种 → IYUU 辅种中心 → 本地对比)", "local")]; + } + + const metadata = (await sendMessage("getExtStorage", "metadata")) as IMetadataPiniaStorageSchema | undefined; + const siteNameMap = metadata?.siteNameMap ?? {}; + const infoHashesToExclude = new Set(seeds.map((s) => s.infoHash)); + const results: ICrossSeedCandidate[] = []; + let searched = 0; + + for (const siteId of config.sites) { + let siteError: string | undefined; + const siteName = siteNameMap[siteId] ?? siteId; + let siteInstance: Awaited>>; + try { + siteInstance = await getSiteInstance<"public">(siteId); + } catch (e) { + results.push(errorCandidate(`站点 ${siteName} 初始化失败:${messageOf(e)}`, "local")); + continue; + } + + for (const s of seeds) { + if (searched >= config.searchLimit) break; + searched++; + + // 按种子来源下载器取实例(勾选跨下载器场景) + const inst = await instanceFor(s.clientId); + if (!inst) continue; + + // 1) 本地文件树(下载器 reports 绝对路径 → 归一化为种子相对路径) + let files: Array<{ path: string; size: number }>; + try { + files = (await inst.getTorrentFiles(s.infoHash)).map((f) => ({ path: f.path, size: f.size })); + } catch { + continue; // 该客户端不支持文件列表,跳过此种子 + } + const seed: ILocalSeedForMatch = { + infoHash: s.infoHash, + size: s.size, + files: normalizeClientFiles(files, s.savePath), + }; + + // 2) 站内搜索(同名候选)+ fuzzySize 预过滤 + let candidates: Array<{ id: string | number; title: string; size?: number; link?: string; url?: string }>; + try { + const sr = await siteInstance.getSearchResult(s.name, {}); + candidates = sr.data ?? []; + } catch (e) { + siteError = messageOf(e); + continue; + } + for (const cand of candidates) { + if (!cand.link) continue; + if (cand.size && !fuzzySizeDoesMatch(s.size, cand.size)) continue; + + // 3) snatch 候选 .torrent 并解析 + try { + const candTorrent = { ...cand, site: siteId } as unknown as Parameters< + typeof siteInstance.getTorrentDownloadLink + >[0]; + const link = await siteInstance.getTorrentDownloadLink(candTorrent); + const reqConfig = await siteInstance.getTorrentDownloadRequestConfig(candTorrent); + reqConfig.url = link; + reqConfig.responseType = "arraybuffer"; + const parsed = await getRemoteTorrentFile(reqConfig); + const parsedInfo = parsed.info as unknown as { + name?: string; + length?: number; + files?: Array<{ path?: string[]; length: number }>; + pieces?: Uint8Array; + }; + + const cFiles = parsedInfo.files?.length + ? parsedInfo.files.map((f) => ({ path: (f.path ?? []).join("/"), size: f.length })) + : [{ path: String(parsedInfo.name ?? cand.title), size: parsedInfo.length ?? cand.size ?? 0 }]; + const cSize = cFiles.reduce((acc, f) => acc + f.size, 0); + const cHash = parsedInfo.pieces ? await piecesHashFromInfoPieces(parsedInfo.pieces) : undefined; + + // 4) 决策(cross-seed decide 顺序 + pieces 强化层) + const decision = assessLocalCandidate({ + seed, + candidate: { + infoHash: (parsed as unknown as { infoHash?: string }).infoHash, + files: cFiles, + size: cSize, + piecesHash: cHash, + }, + infoHashesToExclude, + matchMode: config.matchMode, + }); + if ( + decision.decision === "MATCH" || + decision.decision === "MATCH_SIZE_ONLY" || + decision.decision === "MATCH_PARTIAL" + ) { + results.push( + ...(await buildSourceCandidates( + { siteId, siteName }, + { infoHash: s.infoHash, name: s.name, savePath: s.savePath, size: s.size, clientId: s.clientId }, + Number(cand.id), + "local", + decision.progress, + )), + ); + } + } catch { + // 单个候选 snatch/解析失败不影响其他候选 + } + } + } + + if (!results.length && siteError) { + results.push(errorCandidate(`站点 ${siteName} 搜索失败:${siteError}`, "local")); + } + } + + return results; +} + +// ── 公共工具 ───────────────────────────────────────────── + +/** 统一候选构建(懒加载:扫描阶段不获取详情页/下载链接,发送时由 downloadTorrent 走站点适配器 B 路线) */ +async function buildSourceCandidates( + site: { siteId: TSiteID; siteName?: string }, + seed: ICrossSeedLocalSeed, + torrentId: number, + source: TCrossSeedSourceKind, + progress?: number, +): Promise { + return [ + { + sourceInfoHash: seed.infoHash, + sourceName: seed.name, + sourceSavePath: seed.savePath, + sourceSize: seed.size, + torrentId, + siteId: site.siteId, + siteName: site.siteName || site.siteId, + status: "ready", + source, + progress, + }, + ]; +} + +function errorCandidate(error: string, source: TCrossSeedSourceKind): ICrossSeedCandidate { + return { sourceInfoHash: "", siteId: "", siteName: "", torrentId: 0, status: "error", error, source }; +} + +function messageOf(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/src/entries/offscreen/utils/iyuu.ts b/src/entries/offscreen/utils/iyuu.ts new file mode 100644 index 000000000..aa2cdea7a --- /dev/null +++ b/src/entries/offscreen/utils/iyuu.ts @@ -0,0 +1,337 @@ +/** + * IYUU 辅种中心 offscreen 处理 + * @see https://doc.iyuu.cn/reference/site_list + * @see https://doc.iyuu.cn/reference/reseed_index + * @see https://doc.iyuu.cn/reference/site_report_existing + */ +import axios from "axios"; + +import { onMessage, sendMessage } from "@/messages.ts"; +import type { IIyuuStorageSchema, IIyuuSiteCacheEntry, IMetadataPiniaStorageSchema } from "@/shared/types.ts"; +import type { TSiteID } from "@ptd/site"; +import { iyuuSiteToLocal } from "@ptd/crossSeed"; +import type { IYUUReseedHit, ICrossSeedCandidate } from "@ptd/crossSeed"; + +import { getDownloaderInstance } from "./download.ts"; + +/** IYUU 配置并入 metadata storage 的 iyuu 子对象 */ +const METADATA_KEY = "metadata" as const; +const IYUU_KEY = "iyuu" as const; +const API_BASE = "https://2025.iyuu.cn"; +const VERSION = "1.0.0"; + +/** sid_sha1 有效期:7 天 */ +const SID_SHA1_TTL_MS = 7 * 24 * 60 * 60 * 1000; +/** 站点表缓存 TTL:24 小时 */ +const SITES_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +async function getMetadata(): Promise { + return (await sendMessage("getExtStorage", METADATA_KEY)) as IMetadataPiniaStorageSchema | undefined; +} + +async function getConfig(): Promise { + return (await getMetadata())?.[IYUU_KEY] ?? {}; +} + +/** 合并写:读最新 metadata → 补丁 iyuu 子对象 → 整体写回(避免覆盖其他字段) */ +async function saveConfig(patch: Partial): Promise { + const metadata = (await getMetadata()) ?? ({} as IMetadataPiniaStorageSchema); + await sendMessage("setExtStorage", { + key: METADATA_KEY, + value: { ...metadata, [IYUU_KEY]: { ...(metadata[IYUU_KEY] ?? {}), ...patch } }, + }); +} + +/** 拼接 token 请求头;未配置 token 时抛错 */ +function tokenHeaders(token: string): Record { + return { Token: token }; +} + +onMessage("getIyuusConfig", async () => { + return await getConfig(); +}); + +onMessage("setIyuusConfig", async ({ data }) => { + await saveConfig(data); +}); + +// ── 站点表 ────────────────────────────────────────────── + +async function fetchSitesRemote(token: string): Promise { + const { data } = await axios.get<{ + code: number; + data?: { sites?: IIyuuSiteCacheEntry[] }; + msg?: string; + }>(`${API_BASE}/reseed/sites/index`, { headers: tokenHeaders(token), timeout: 30e3 }); + if (data.code !== 0 || !data.data?.sites) { + throw new Error(data.msg || "IYUU 站点列表获取失败"); + } + return data.data.sites; +} + +/** 拉取站点表(带缓存 TTL 24h;token 未配置抛错提示) */ +export async function iyuuFetchSites(): Promise { + const config = await getConfig(); + if (!config.token) { + throw new Error("请先在设置页配置 IYUU token"); + } + if (config.sitesCache && Date.now() - config.sitesCache.fetchedAt < SITES_CACHE_TTL_MS) { + return config.sitesCache.sites; + } + const sites = await fetchSitesRemote(config.token); + await saveConfig({ sitesCache: { fetchedAt: Date.now(), sites } }); + return sites; +} + +onMessage("iyuuFetchSites", iyuuFetchSites); + +// ── 汇报持有站点 → sid_sha1 ───────────────────────────── + +/** 汇报持有站点(sid 列表),返回 sid_sha1 并缓存(7 天) */ +export async function iyuuReportExisting(sidList: number[]): Promise { + const config = await getConfig(); + if (!config.token) { + throw new Error("请先在设置页配置 IYUU token"); + } + const { data } = await axios.post<{ code: number; data?: { sid_sha1?: string }; msg?: string }>( + `${API_BASE}/reseed/sites/reportExisting`, + { sid_list: sidList }, + { headers: tokenHeaders(config.token), timeout: 30e3 }, + ); + if (data.code !== 0 || !data.data?.sid_sha1) { + throw new Error(data.msg || "IYUU 站点汇报失败"); + } + await saveConfig({ sidSha1: data.data.sid_sha1, sidSha1ExpiresAt: Date.now() + SID_SHA1_TTL_MS }); + return data.data.sid_sha1; +} + +onMessage("iyuuReportExisting", async ({ data: sidList }) => { + return await iyuuReportExisting(sidList); +}); + +// ── 查询辅种 ──────────────────────────────────────────── + +/** + * 查询辅种:给定本地 infohash 列表,返回各站命中 {sid, torrent_id, info_hash}。 + * sid_sha1 缺失/过期时,用已保存的 heldSites 自动重新汇报。 + */ +export async function iyuuQueryReseed(hashes: string[]): Promise> { + const config = await getConfig(); + if (!config.token) { + throw new Error("请先在设置页配置 IYUU token"); + } + let sidSha1 = config.sidSha1; + if (!sidSha1 || !config.sidSha1ExpiresAt || Date.now() > config.sidSha1ExpiresAt) { + const sidList = await deriveHeldSids(config); + sidSha1 = await iyuuReportExisting(sidList); + } + + const body = new URLSearchParams({ + hash: JSON.stringify(hashes), + sid_sha1: sidSha1, + timestamp: String(Math.floor(Date.now() / 1000)), + version: VERSION, + }); + const { data } = await axios.post<{ + code: number; + data?: Record; + msg?: string; + }>(`${API_BASE}/reseed/index/index`, body, { + headers: { ...tokenHeaders(config.token), "Content-Type": "application/x-www-form-urlencoded" }, + timeout: 60e3, + }); + if (data.code !== 0) { + const msg = data.msg ?? ""; + // 该批 hash 无命中时 IYUU 中心会返回业务错误文案(如「未查询到可辅种数据」), + // 这属于正常空结果而非致命错误:直接按空结果返回,避免整批扫描被单批无命中中断。 + if (/未查询到可辅种数据|暂无.*辅种|没有.*辅种/.test(msg)) { + return {}; + } + throw new Error(msg || "IYUU 辅种查询失败"); + } + const result: Record = {}; + for (const [hash, item] of Object.entries(data.data ?? {})) { + result[hash] = { torrent: item?.torrent ?? [] }; + } + return result; +} + +onMessage("iyuuQueryReseed", async ({ data: hashes }) => { + return await iyuuQueryReseed(hashes); +}); + +// ── 持有站点自动推导 ──────────────────────────────────── + +/** 由本地已配置站点(metadata.sites keys)推导 IYUU 持有站点 */ +export async function iyuuDeriveHeldSites(): Promise<{ + localIds: TSiteID[]; + sidList: number[]; + unmatched: TSiteID[]; +}> { + const metadata = (await sendMessage("getExtStorage", "metadata")) as { sites?: Record } | undefined; + const localIds = Object.keys(metadata?.sites ?? {}); + + const sites = await iyuuFetchSites(); + const localToSid = new Map(); + for (const site of sites) { + const local = iyuuSiteToLocal(site.site); + if (local) { + localToSid.set(local, site.id); + } + } + + const held = localIds.filter((id) => localToSid.has(id)); + return { + localIds: held, + sidList: held.map((id) => localToSid.get(id)!), + unmatched: localIds.filter((id) => !localToSid.has(id)), + }; +} + +onMessage("iyuuDeriveHeldSites", iyuuDeriveHeldSites); + +/** 由配置中的 heldSites(本地 id)推导 sid 列表(无则从 metadata 推导) */ +async function deriveHeldSids(config: IIyuuStorageSchema): Promise { + if (config.heldSites?.length) { + const sites = await iyuuFetchSites(); + const localToSid = new Map(); + for (const site of sites) { + const local = iyuuSiteToLocal(site.site); + if (local) { + localToSid.set(local, site.id); + } + } + return config.heldSites.map((id) => localToSid.get(id)).filter((sid): sid is number => sid !== undefined); + } + const derived = await iyuuDeriveHeldSites(); + return derived.sidList; +} + +// ── 辅种候选解析与批量扫描(P1) ───────────────────────── + +export type IYUUResolveSourceInfo = { name: string; savePath: string; size: number }; + +/** + * 来源信息统一规范化:offscreen 内部直接传 Map;经消息传输时 Map 会因 JSON 序列化退化为 + * 普通对象(导致 .get is not a function),消息端传 Record,这里统一转回 Map。 + */ +function toSourceMap( + sources: Map | Record | undefined, +): Map { + if (!sources) return new Map(); + return sources instanceof Map ? sources : new Map(Object.entries(sources)); +} + +/** 批量查询命中 → 解析为辅种候选(B 路线优先,A 兜底;不可注入项标 error) */ +export async function iyuuResolveHits( + hits: Array<{ sid: number; torrent_id: number; info_hash?: string }>, + sources?: Map | Record, +): Promise { + if (!hits.length) return []; + const sourceMap = toSourceMap(sources); + const sites = await iyuuFetchSites(); + const siteById = new Map(sites.map((s) => [s.id, s])); + const candidates: ICrossSeedCandidate[] = []; + + for (const hit of hits) { + const iyuuSite = siteById.get(hit.sid); + if (!iyuuSite) continue; + + const source = hit.info_hash ? sourceMap.get(hit.info_hash) : undefined; + const base: ICrossSeedCandidate = { + sourceInfoHash: hit.info_hash ?? "", + sourceName: source?.name, + sourceSavePath: source?.savePath, + sourceSize: source?.size, + torrentId: hit.torrent_id, + siteId: "", + siteName: "", + status: "ready", + source: "iyuu", + }; + + const localSiteId = iyuuSiteToLocal(iyuuSite.site); + const siteName = iyuuSite.nickname || localSiteId || iyuuSite.site; + + // 未映射到本地站点的命中无法注入(downloadTorrent 依赖站点适配器),直接标 error + if (!localSiteId) { + candidates.push({ + ...base, + siteId: "", + siteName, + status: "error", + error: `IYUU 站点 ${iyuuSite.site} 未映射到本地站点(模板侧 ${iyuuSite.download_page} 不注入)`, + }); + continue; + } + + // 懒加载:假定 IYUU 中心返回信息有效,扫描阶段不获取详情页/下载链接; + // 实际发送(downloadTorrent)时由站点适配器 B 路线(site + torrent_id)构建真实下载链接。 + candidates.push({ ...base, siteId: localSiteId, siteName, status: "ready" }); + } + + return candidates; +} + +/** + * 批量辅种扫描:取下载器内已完成种子 → hash 分批(100/批)查 IYUU → 解析候选。 + * 仅返回 ready 候选(调用方可直接注入),error 项同样携带以便 UI 展示失败原因。 + */ +export async function iyuuScanForReseed(downloaderId: string): Promise { + const instance = await getDownloaderInstance(downloaderId); + if (!instance) { + return [ + { + sourceInfoHash: "", + siteId: "", + siteName: "", + torrentId: 0, + status: "error", + error: "下载器不存在或未配置", + source: "iyuu", + }, + ]; + } + + const torrents = await instance.getAllTorrents(); + const completed = torrents.filter((t) => t.isCompleted && t.infoHash); + if (!completed.length) { + return [ + { + sourceInfoHash: "", + siteId: "", + siteName: "", + torrentId: 0, + status: "error", + error: "该下载器没有已完成的种子", + source: "iyuu", + }, + ]; + } + + const sources = new Map( + completed.map((t) => [t.infoHash, { name: t.name, savePath: t.savePath, size: t.totalSize }]), + ); + + const hits: Array<{ sid: number; torrent_id: number; info_hash: string }> = []; + const BATCH = 100; + for (let i = 0; i < completed.length; i += BATCH) { + const batch = completed.slice(i, i + BATCH); + const resp = await iyuuQueryReseed(batch.map((t) => t.infoHash)); + for (const [hash, item] of Object.entries(resp)) { + for (const t of item.torrent ?? []) { + hits.push({ sid: t.sid, torrent_id: t.torrent_id, info_hash: hash }); + } + } + } + + return await iyuuResolveHits(hits, sources); +} + +onMessage("iyuuResolveHits", async ({ data: { hits, sources } }) => { + return await iyuuResolveHits(hits, sources); +}); + +onMessage("iyuuScanForReseed", async ({ data: downloaderId }) => { + return await iyuuScanForReseed(downloaderId); +}); diff --git a/src/entries/options/components/SiteFavicon/Index.vue b/src/entries/options/components/SiteFavicon/Index.vue index 6fbfeefb2..912eb0245 100644 --- a/src/entries/options/components/SiteFavicon/Index.vue +++ b/src/entries/options/components/SiteFavicon/Index.vue @@ -42,7 +42,15 @@ const binds = { diff --git a/src/entries/options/plugins/router.ts b/src/entries/options/plugins/router.ts index a213c663c..cd916065a 100644 --- a/src/entries/options/plugins/router.ts +++ b/src/entries/options/plugins/router.ts @@ -20,6 +20,12 @@ export const setBaseChildren: RouteRecordRaw[] = [ meta: { icon: "mdi-download-network" }, component: () => import("../views/Settings/SetBase/DownloadWindow.vue"), }, + { + path: "reseed", + name: "SetBaseReseed", + meta: { icon: "mdi-sword-cross", usesGlobalSave: false }, + component: () => import("../views/Settings/SetBase/ReseedWindow.vue"), + }, { path: "user-info", name: "SetBaseUserInfo", diff --git a/src/entries/options/stores/config.ts b/src/entries/options/stores/config.ts index f20f1bd3f..741dfe7ef 100644 --- a/src/entries/options/stores/config.ts +++ b/src/entries/options/stores/config.ts @@ -318,6 +318,15 @@ export const useConfigStore = defineStore("config", { triggerThreshold: 2, extensionDuration: 3, }, + + reseed: { + enabled: true, + showKeepUploadTask: true, + + enableIyuus: true, + enableNexus: true, + enableLocal: false, + }, }), getters: { uiTheme(): Exclude { diff --git a/src/entries/options/stores/metadata.ts b/src/entries/options/stores/metadata.ts index 2aad93c73..bf95b4fe8 100644 --- a/src/entries/options/stores/metadata.ts +++ b/src/entries/options/stores/metadata.ts @@ -54,6 +54,9 @@ export const useMetadataStore = defineStore("metadata", { siteHostMap: {}, siteNameMap: {}, + + // IYUU 辅种中心配置(offscreen 侧读写;此处为持久化默认值) + iyuu: {}, }), getters: { diff --git a/src/entries/options/views/Layout/Navigation.vue b/src/entries/options/views/Layout/Navigation.vue index f4608d6f6..c40504a48 100644 --- a/src/entries/options/views/Layout/Navigation.vue +++ b/src/entries/options/views/Layout/Navigation.vue @@ -1,10 +1,11 @@ diff --git a/src/entries/options/views/Settings/SetBase/NexusSiteDialog.vue b/src/entries/options/views/Settings/SetBase/NexusSiteDialog.vue new file mode 100644 index 000000000..f8c43a59f --- /dev/null +++ b/src/entries/options/views/Settings/SetBase/NexusSiteDialog.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/src/entries/options/views/Settings/SetBase/ReseedWindow.vue b/src/entries/options/views/Settings/SetBase/ReseedWindow.vue new file mode 100644 index 000000000..5c1ce4313 --- /dev/null +++ b/src/entries/options/views/Settings/SetBase/ReseedWindow.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/entries/shared/types/storages/config.ts b/src/entries/shared/types/storages/config.ts index 74f77f164..3643d3b3e 100644 --- a/src/entries/shared/types/storages/config.ts +++ b/src/entries/shared/types/storages/config.ts @@ -256,4 +256,20 @@ export interface IConfigPiniaStorageSchema { triggerThreshold: number; // 触发阈值(周),默认 2 extensionDuration: number; // 延长时长(月),默认 3 }; + + // 辅种(reseed)功能总控 + reseed: { + // 辅种功能总开关:false 时隐藏侧边栏「辅种任务」入口并停用相关功能动作 + enabled: boolean; + // 是否在侧边栏显示「辅种任务」页面(在 enabled 基础上进一步控制) + showKeepUploadTask: boolean; + + // 以下为各辅种命中源开关(扫描时按开关决定启用哪些源) + // IYUU - 基于特征码的索引工具(中心化索引) + enableIyuus: boolean; + // NexusPHP pieces-hash 直查 + enableNexus: boolean; + // 本地文件树对比(LocalCrossSeed) + enableLocal: boolean; + }; } diff --git a/src/entries/shared/types/storages/indexdb.ts b/src/entries/shared/types/storages/indexdb.ts index f81ed1eab..21120ec5e 100644 --- a/src/entries/shared/types/storages/indexdb.ts +++ b/src/entries/shared/types/storages/indexdb.ts @@ -24,9 +24,24 @@ export interface IPtdDBSchemaV2 extends IPtdDBSchemaV1 { }; } +/** 辅种决策记录(跨扫描去重:已判定/已推送的候选) */ +export interface IReseedDecision { + /** 主键:`:` */ + key: string; + siteId: string; + torrentId: number; + infoHash?: string; + decision: "injected" | "matched"; + time: number; +} + export interface IPtdDBSchema extends IPtdDBSchemaV2 { favicon: { key: TSiteKey; value: string; }; + reseed_decision: { + key: string; + value: IReseedDecision; + }; } diff --git a/src/entries/shared/types/storages/metadata.ts b/src/entries/shared/types/storages/metadata.ts index b42a11e9c..ed8e8489f 100644 --- a/src/entries/shared/types/storages/metadata.ts +++ b/src/entries/shared/types/storages/metadata.ts @@ -150,4 +150,57 @@ export interface IMetadataPiniaStorageSchema { // 站点 ID 到站点名称的映射表 siteNameMap: Record; + + // IYUU 辅种中心配置(并入 metadata 随整体备份;token 为敏感凭据,备份时注意) + iyuu?: IIyuuStorageSchema; +} + +/** IYUU 站点表缓存条目 */ +export interface IIyuuSiteCacheEntry { + id: number; // IYUU sid + site: string; + nickname: string; + base_url: string; + download_page: string; + details_page: string; + is_https: 0 | 1 | 2; + cookie_required: 0 | 1; +} + +/** IYUU 辅种中心配置存储 */ +export interface IIyuuStorageSchema { + /** IYUU token(iyuu.cn 获取) */ + token?: string; + + /** 已持有站点(本地 TSiteID 列表,设置页勾选/自动推导结果) */ + heldSites?: string[]; + + /** 站点汇报得到的 sid_sha1(7 天有效,站点列表不变可复用) */ + sidSha1?: string; + sidSha1ExpiresAt?: number; + + /** IYUU 站点表缓存(TTL 24h) */ + sitesCache?: { + fetchedAt: number; + sites: IIyuuSiteCacheEntry[]; + }; + + /** NexusPHP pieces-hash 直查站点配置(跨源辅种方案之一) */ + nexusSites?: Record< + string, + { + /** 完整接口地址;留空时默认使用站点定义基址 + /api/pieces-hash */ + apiUrl?: string; + /** 用户 passkey(query 参数认证) */ + passkey?: string; + enabled?: boolean; + } + >; + + /** LocalCrossSeed:本地文件树对比目标站列表(本地 TSiteID) */ + localSites?: string[]; + /** LocalCrossSeed 匹配模式:strict / flexible / partial(默认 strict) */ + localMatchMode?: "strict" | "flexible" | "partial"; + /** LocalCrossSeed 每次扫描搜索的种子数上限(风控,默认 10) */ + localSearchLimit?: number; } diff --git a/src/locales/en.json b/src/locales/en.json index 954a7c6e4..dc7c24a2f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -105,6 +105,31 @@ "torrentCount": "Torrents: ", "updateError": "Update failed", "updateSuccess": "Updated successfully", + "iyuu": { + "scan": "Batch Reseed Scan", + "chooseDownloader": "Select downloader", + "startScan": "Start scan", + "scanning": "Scanning completed torrents for reseed candidates\u2026", + "scanError": "Scan failed: {reason}", + "noResult": "No reseed candidates found (make sure a Token is configured and sites are reported in Settings \u2192 IYUU Reseed Center)", + "columnSite": "Site", + "columnTitle": "Title", + "columnSize": "Size", + "columnStatus": "Status", + "statusReady": "Ready", + "statusError": "Failed", + "statusInjected": "Pushed", + "statusPartial": "Partial match (missing files will be filled by the downloader after injection)", + "selectAll": "Select all ready candidates", + "groupHint": "Select multiple candidates and 'Push Reseed' directly to the chosen downloader (download links are resolved at send time); or create reseed tasks grouped by source torrent.", + "push": "Push Reseed ({count})", + "pushPartial": "Pushed {ok}, skipped {skipped} already-pushed, failed {fail} (last reason: {reason})", + "pushSuccess": "Pushed {count} torrents to the downloader", + "pushError": "Push failed: {reason}", + "createTask": "Create reseed task ({count})", + "createSuccess": "Created {count} reseed tasks", + "createError": "Failed to create reseed tasks" + }, "warning": { "item1": "Please confirm that the download client has disabled 'auto start download' option before reseeding.", "item2": "The assistant only performs simple verification on torrent files, reseeding success is not guaranteed!", @@ -152,6 +177,27 @@ "resumeSuccess": "Resumed successfully", "viewRaw": "View Raw" }, + "iyuuScan": { + "btnTitle": "Batch Reseed Scan", + "selectAll": "Select all ready candidates", + "columnSite": "Site", + "columnSize": "Size", + "columnStatus": "Status", + "columnTitle": "Title", + "dialogTitle": "Batch Reseed ({count} torrents)", + "inject": "Send selected to source downloaders ({count})", + "injectError": "Failed to send: {reason}", + "injectHint": "Selected candidates are downloaded to each source torrent's downloader and save path", + "injectPartial": "Sent {ok}, skipped {skipped} already-pushed, failed {fail} (last reason: {reason})", + "injectSuccess": "Sent {count} torrents to source downloaders", + "noResult": "No reseed candidates found for the selected torrents (make sure a Token is configured and sites are reported in Settings \u2192 IYUU Reseed Center)", + "queryError": "Query failed: {reason}", + "scanning": "Querying reseed candidates for the selected torrents\u2026", + "statusError": "Failed", + "statusReady": "Ready", + "statusInjected": "Pushed", + "statusPartial": "Partial match (missing files will be filled by the downloader after injection)" + }, "autoRefresh": { "btnTitle": "Auto-Refresh", "clientSuspended": "{name} failed 3 times in a row. Auto-refresh suspended. Click the chip to resume.", @@ -205,6 +251,24 @@ "removeTracker": "Remove", "removeTrackerFailure": "Failed to remove tracker", "removeTrackerSuccess": "Tracker removed", + "reseed": "Find reseeds on other sites", + "reseedColumnSite": "Site", + "reseedColumnSize": "Size", + "reseedColumnStatus": "Status", + "reseedSelectAll": "Select all ready candidates", + "reseedColumnTitle": "Title", + "reseedDialogTitle": "Reseed on other sites", + "reseedInject": "Send to this downloader ({count})", + "reseedInjectError": "Failed to send: {reason}", + "reseedInjectHint": "Selected candidates are downloaded to the original torrent's save path", + "reseedInjectSuccess": "Reseed tasks added to the download queue", + "reseedInjectSkipped": "Pushed {ok}, skipped {skipped} already-pushed", + "reseedNoResult": "No matching resources found on other sites (make sure a Token is configured and sites are reported in Settings \u2192 IYUU Reseed Center)", + "reseedQueryError": "Query failed: {reason}", + "reseedStatusError": "Failed", + "reseedStatusReady": "Ready", + "reseedStatusInjected": "Pushed", + "reseedStatusPartial": "Partial match (missing files will be filled by the downloader after injection)", "title": "Torrent Details", "trackerColumnLastAnnounce": "Last Announce", "trackerColumnLeeches": "Leeches", @@ -620,12 +684,87 @@ "tab": { "backup": "Backup & Restore", "download": "Download", + "reseed": "Reseed", "native-bridge": "Native Bridge", "search-entity": "@:common.search", "social-information": "Social Rating", "ui": "Option UI", "user-info": "UserInfo" }, + "reseed": { + "globalTitle": "Global Reseed Config", + "enabledLabel": "Enable reseed features", + "enabledHint": "When off, the 'Reseed Tasks' entry is hidden from the sidebar and reseed actions are disabled.", + "showKeepUploadTaskLabel": "Show reseed tasks", + "showKeepUploadTaskHint": "Show the 'Reseed Tasks' page in the sidebar (also requires reseed features enabled).", + "sourcesTitle": "Reseed Source Switches", + "sourcesDisabledHint": "All reseed sources are disabled. Enable at least one in the 'Reseed Source Switches' section above.", + "iyuuCardTitle": "IYUU - piece-hash based index service", + "enableLocalLabel": "Enable LocalCrossSeed (local file-tree compare)", + "enableLocalHint": "Search candidates on target sites using completed torrents' file trees and compare; matches can be reseeded.", + "enableNexusLabel": "Enable NexusPHP pieces-hash direct query", + "enableNexusHint": "Match across sites via each site's own /api/pieces-hash endpoint using local pieces_hash.", + "enableIyuusLabel": "Enable IYUU index query", + "enableIyuusHint": "Match across sites via the IYUU center (iyuu.cn) piece-hash index; requires the Token below." + }, + "iyuu": { + "tokenTitle": "IYUU Token", + "tokenLabel": "IYUU token", + "tokenHint": "Token from iyuu.cn to access the IYUU reseed center API (stored in plaintext locally, included with the metadata backup)", + "saveToken": "Save token", + "tokenSaved": "Token saved", + "heldTitle": "Held Sites", + "heldHint": "Checking a site saves the held list automatically; then click 'Save & Report' to obtain sid_sha1 (valid 7 days, reusable while sites are unchanged).", + "fetchSites": "Fetch site table", + "sitesLoaded": "Loaded IYUU site table ({count} sites)", + "fetchHint": "Click 'Fetch site table' to load all IYUU sites before checking.", + "deriveHeld": "Derive held sites", + "deriveDone": "Derived {count} held sites; {unmatched} local sites not in IYUU", + "columnSite": "IYUU Site", + "columnNote": "Mapped Site", + "unmapped": "Unmapped", + "noHeldSites": "No held sites yet — check some in the site table first", + "report": "Save & Report", + "reportDone": "Reported, sid_sha1 prefix {sha1}… (valid for 7 days)", + "expiresAt": "Expires at", + "nexusTitle": "NexusPHP pieces-hash Direct Query", + "nexusHint": "Configure passkey for NexusPHP sites exposing /api/pieces-hash; once enabled the extension can match seeds across sites directly via local pieces_hash (no third-party center). Leave the endpoint empty to use the site base URL + /api/pieces-hash.", + "nexusWarn": "Some NexusPHP sites do not support this feature due to their architecture; it is normal for them to have no effect.", + "nexusAddExtra": "Add extra possibly-supported sites", + "nexusRowsHint": "{count}/{total} added sites shown (NexusPHP schema or configured)", + "nexusDialogTitle": "Add Extra Site", + "nexusDialogHint": "Pick an added site, configure its pieces-hash endpoint and passkey. On save the endpoint is verified (any response other than HTTP 404 counts as reachable).", + "nexusDialogSiteLabel": "Site", + "nexusDialogApiLabel": "Endpoint URL", + "nexusDialogApiHint": "Leave empty to use the site base URL + /api/pieces-hash", + "nexusDialogPasskeyLabel": "Passkey", + "nexusDialogSave": "Verify & Save", + "nexusDialogChooseSite": "Please choose a site first", + "nexusDialogPasskeyRequired": "Passkey is required", + "nexusVerifyOk": "Endpoint verified (HTTP {status})", + "nexusVerifyFailed": "Endpoint verification failed: {reason}", + "nexusSiteColumn": "Site", + "nexusUrlColumn": "Endpoint URL", + "nexusPasskeyColumn": "Passkey", + "nexusPasskeyPlaceholder": "User passkey for this site", + "nexusEnabledColumn": "Enabled", + "nexusEnabledRequiresPasskey": "Enter this site's passkey first to enable", + "nexusNoNexusSites": "None of your added sites use the NexusPHP schema. Click 'Add extra possibly-supported sites' to configure others manually.", + "nexusNoLocalSites": "No local sites configured yet. Add sites in Settings \u2192 Sites first.", + "nexusSave": "Save Nexus Config", + "nexusSaved": "Nexus config saved", + "nexusSavedHint": "After saving, multi-source scans will query this site via pieces-hash.", + "localTitle": "Local File-Tree Compare (LocalCrossSeed)", + "localHint": "Inspired by cross-seed: use the completed torrents' file trees (name+size) in your downloader as searchees, search matching candidates on the checked sites, snatch the site-issued .torrent and compare by match mode (strict exact path / flexible size-only / partial ratio); matches can be reseeded directly to that site.", + "localMatchModeLabel": "Match Mode", + "localMatchModeStrict": "Strict (exact path & size)", + "localMatchModeFlexible": "Flexible (size-only, tolerates renames)", + "localMatchModePartial": "Partial (size ratio, can top up)", + "localSearchLimitLabel": "Max searched torrents per scan", + "localSave": "Save Local Config", + "localSaved": "Local config saved", + "localNoSites": "No selectable sites yet." + }, "ui": { "allowDragLink": "Allow dragging links to the sidebar button", "allowExceptionSites": "Allow setting exception sites without sidebar (enable in site settings)", @@ -1095,6 +1234,11 @@ "search": "Search", "site": "Site", "sortIndex": "Priority", + "source": { + "iyuu": "IYUU", + "nexusphp": "Nexus", + "local": "Local" + }, "test": "Test", "time": { "ago": " Ago", diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json index 3920b629f..63dcc1202 100644 --- a/src/locales/zh_CN.json +++ b/src/locales/zh_CN.json @@ -105,6 +105,31 @@ "torrentCount": "种子数:", "updateError": "更新失败", "updateSuccess": "更新成功", + "iyuu": { + "scan": "批量辅种扫描", + "chooseDownloader": "选择下载器", + "startScan": "开始扫描", + "scanning": "正在扫描已完成种子的辅种候选…", + "scanError": "扫描失败:{reason}", + "noResult": "未发现可辅种的候选(请确认已在「设置 → IYUU 辅种中心」中配置 Token 并完成站点汇报)", + "columnSite": "站点", + "columnTitle": "标题", + "columnSize": "大小", + "columnStatus": "状态", + "statusReady": "可辅种", + "statusError": "失败", + "statusInjected": "已推送", + "statusPartial": "部分匹配(注入后由下载器补齐缺失文件)", + "selectAll": "全选可辅种候选", + "groupHint": "可多选候选后直接「推送辅种」到所选下载器(下载链接在发送时获取);也可按来源资源分组创建辅种任务。", + "push": "推送辅种({count})", + "pushPartial": "已推送 {ok} 个,跳过已推送 {skipped} 个,失败 {fail} 个(最后原因:{reason})", + "pushSuccess": "已推送 {count} 个种子到下载器", + "pushError": "推送失败:{reason}", + "createTask": "创建辅种任务({count})", + "createSuccess": "已创建 {count} 个辅种任务", + "createError": "创建辅种任务失败" + }, "warning": { "item1": "辅种前请确认下载服务器已关闭类似于「自动开始下载」的选项(如果有)。", "item2": "助手仅对种子文件做简单验证,不保证辅种成功,请自行斟酌是否要使用辅种功能!", @@ -152,6 +177,27 @@ "resumeSuccess": "开始成功", "viewRaw": "查看原始数据" }, + "iyuuScan": { + "btnTitle": "批量辅种扫描", + "selectAll": "全选可辅种候选", + "columnSite": "站点", + "columnSize": "大小", + "columnStatus": "状态", + "columnTitle": "标题", + "dialogTitle": "批量辅种扫描({count} 个种子)", + "inject": "注入勾选到来源下载器({count})", + "injectError": "发送失败:{reason}", + "injectHint": "勾选的候选将下载到来源种子所在下载器与保存目录", + "injectPartial": "已发送 {ok} 个,跳过已推送 {skipped} 个,失败 {fail} 个(最后原因:{reason})", + "injectSuccess": "已发送 {count} 个种子到来源下载器", + "noResult": "所选种子在其他站未发现可辅种的资源(请确认已在「设置 → IYUU 辅种中心」配置 Token 并完成站点汇报)", + "queryError": "查询失败:{reason}", + "scanning": "正在查询所选种子的辅种候选…", + "statusError": "失败", + "statusReady": "可辅种", + "statusInjected": "已推送", + "statusPartial": "部分匹配(注入后由下载器补齐缺失文件)" + }, "autoRefresh": { "btnTitle": "自动刷新", "clientSuspended": "{name} 刷新失败 3 次,已暂停自动刷新。点击下载器标签可恢复。", @@ -205,6 +251,24 @@ "removeTracker": "删除", "removeTrackerFailure": "删除 Tracker 失败", "removeTrackerSuccess": "已删除 Tracker", + "reseed": "查其他站辅种", + "reseedColumnSite": "站点", + "reseedColumnSize": "大小", + "reseedColumnStatus": "状态", + "reseedSelectAll": "全选可辅种候选", + "reseedColumnTitle": "标题", + "reseedDialogTitle": "其他站辅种", + "reseedInject": "注入到本下载器({count})", + "reseedInjectError": "发送失败:{reason}", + "reseedInjectHint": "勾选的候选将下载并保存到原种子所在目录", + "reseedInjectSuccess": "其他站辅种任务已加入下载队列", + "reseedInjectSkipped": "已推送 {ok} 个,跳过已推送 {skipped} 个", + "reseedNoResult": "其他站未发现同资源(请确认已在「设置 → IYUU 辅种中心」配置 Token 并完成站点汇报)", + "reseedQueryError": "查询失败:{reason}", + "reseedStatusError": "失败", + "reseedStatusReady": "可辅种", + "reseedStatusInjected": "已推送", + "reseedStatusPartial": "部分匹配(注入后由下载器补齐缺失文件)", "title": "种子详情", "trackerColumnLastAnnounce": "上次通告", "trackerColumnLeeches": "下载数", @@ -620,12 +684,87 @@ "tab": { "backup": "备份恢复", "download": "下载", + "reseed": "辅种", "native-bridge": "原生通信桥", "search-entity": "@:common.search", "social-information": "媒体评分", "ui": "界面UI", "user-info": "用户信息" }, + "reseed": { + "globalTitle": "全局辅种配置", + "enabledLabel": "启用辅种功能", + "enabledHint": "关闭后侧边栏不显示「辅种任务」,辅种相关动作停用。", + "showKeepUploadTaskLabel": "显示辅种任务", + "showKeepUploadTaskHint": "在侧边栏显示「辅种任务」页面(需同时开启辅种功能)。", + "sourcesTitle": "辅种方案开关", + "sourcesDisabledHint": "所有辅种方案均已关闭,请在下方「辅种方案开关」中启用至少一项。", + "iyuuCardTitle": "IYUU - 基于特征码的索引工具", + "enableLocalLabel": "启用本地文件树对比(LocalCrossSeed)", + "enableLocalHint": "用下载器已完成种子的文件树在目标站搜索候选并比对,命中即可辅种。", + "enableNexusLabel": "启用 NexusPHP pieces-hash 直查", + "enableNexusHint": "通过站点自身的 /api/pieces-hash 接口,用本地种子 pieces_hash 跨站匹配。", + "enableIyuusLabel": "启用 IYUU 索引查询", + "enableIyuusHint": "通过 IYUU 中心(iyuu.cn)的特征码索引跨站匹配,需要下方 Token。" + }, + "iyuu": { + "tokenTitle": "IYUU Token", + "tokenLabel": "IYUU token", + "tokenHint": "在 iyuu.cn 获取的 token,用于调用 IYUU 辅种中心 API(明文存本地,随 metadata 一起备份)", + "saveToken": "保存 token", + "tokenSaved": "token 已保存", + "heldTitle": "已持有站点", + "heldHint": "勾选站点即自动保存持有清单;完成勾选后点击「保存并汇报」获取 sid_sha1(7 天有效,站点不变可复用)。", + "fetchSites": "拉取站点表", + "sitesLoaded": "已加载 IYUU 站点表({count} 站)", + "fetchHint": "点击「拉取站点表」加载 IYUU 全部站点后再勾选。", + "deriveHeld": "推导已持有站点", + "deriveDone": "已推导 {count} 个持有站点,{unmatched} 个本地站点不在 IYUU 中", + "columnSite": "IYUU 站点", + "columnNote": "插件映射站点", + "unmapped": "未映射", + "noHeldSites": "暂无已持有站点,请先在站点表勾选", + "report": "保存并汇报", + "reportDone": "汇报成功,sid_sha1 前缀 {sha1}…(7 天内有效)", + "expiresAt": "过期时间", + "nexusTitle": "NexusPHP pieces-hash 直查", + "nexusHint": "对支持 /api/pieces-hash 接口的 NexusPHP 站配置 passkey 后,插件可用本地种子的 pieces_hash 直接跨站匹配(无需第三方中心)。接口地址留空时使用站点配置基址 + /api/pieces-hash。", + "nexusWarn": "部分 NexusPHP 站点因架构原因并不支持该功能,未生效属正常情况。", + "nexusAddExtra": "添加额外可能支持的站点", + "nexusRowsHint": "当前 {count}/{total} 个已添加站点展示(NexusPHP schema 或已配置)", + "nexusDialogTitle": "添加额外站点", + "nexusDialogHint": "选择现有已添加站点,配置其 pieces-hash 接口与 passkey;保存时会验证接口是否存在(非 HTTP 404 即视为可达)。", + "nexusDialogSiteLabel": "站点", + "nexusDialogApiLabel": "接口地址", + "nexusDialogApiHint": "留空时使用站点配置基址 + /api/pieces-hash", + "nexusDialogPasskeyLabel": "Passkey", + "nexusDialogSave": "验证并保存", + "nexusDialogChooseSite": "请先选择站点", + "nexusDialogPasskeyRequired": "Passkey 不能为空", + "nexusVerifyOk": "接口验证通过(HTTP {status})", + "nexusVerifyFailed": "接口验证失败:{reason}", + "nexusSiteColumn": "站点", + "nexusUrlColumn": "接口地址", + "nexusPasskeyColumn": "Passkey", + "nexusPasskeyPlaceholder": "对应用户 passkey", + "nexusEnabledColumn": "启用", + "nexusEnabledRequiresPasskey": "请先填写该站的 Passkey 后才能启用", + "nexusNoNexusSites": "当前添加的站点中没有 schema 为 NexusPHP 的站点,点击「添加额外可能支持的站点」可手动配置其他站。", + "nexusNoLocalSites": "暂无可配置的本地站点,请先在「站点」页添加站点。", + "nexusSave": "保存 Nexus 配置", + "nexusSaved": "Nexus 配置已保存", + "nexusSavedHint": "保存后,多源扫描将对该站启用 pieces-hash 直查。", + "localTitle": "本地文件树对比(LocalCrossSeed)", + "localHint": "参照 cross-seed:用下载器已完成种子的文件树(名称+大小)为目标,在勾选的站点站内搜索同名候选,抓取站点签发的 .torrent 后按匹配模式比对(strict 精确路径 / flexible 只比大小 / partial 按占比),命中即可直接辅种到该站。", + "localMatchModeLabel": "匹配模式", + "localMatchModeStrict": "Strict(严格路径与大小)", + "localMatchModeFlexible": "Flexible(只比大小,容忍改名)", + "localMatchModePartial": "Partial(按大小占比,可差量补齐)", + "localSearchLimitLabel": "每次扫描搜索种子数上限", + "localSave": "保存本地对比配置", + "localSaved": "本地对比配置已保存", + "localNoSites": "暂无可选择的站点。" + }, "ui": { "allowDragLink": "允许拖拽链接到侧边栏按钮", "allowExceptionSites": "允许设置不启用侧边栏的例外站点(启用后需在站点设置中对应关闭)", @@ -1092,6 +1231,11 @@ "search": "搜索", "site": "站点", "sortIndex": "优先级", + "source": { + "iyuu": "IYUU", + "nexusphp": "Nexus", + "local": "本地" + }, "test": "测试", "time": { "ago": "前", diff --git a/src/packages/crossSeed/compute.ts b/src/packages/crossSeed/compute.ts new file mode 100644 index 000000000..cb8a942da --- /dev/null +++ b/src/packages/crossSeed/compute.ts @@ -0,0 +1,15 @@ +/** + * pieces 哈希计算(nexus 源直查、local 源精确比对的公共原语)。 + * pieces_hash = sha1(torrent info.pieces) —— 与 infohash 不同,不受 announce/private 影响, + * 跨站同文件种子的 pieces 串一致,故可作跨站辅种匹配键。 + */ +export async function sha1Hex(data: Uint8Array | ArrayBuffer): Promise { + const source: BufferSource = data instanceof Uint8Array ? (data as unknown as BufferSource) : (data as ArrayBuffer); + const digest = await crypto.subtle.digest("SHA-1", source); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** 由 torrent 解析出的 info.pieces(Uint8Array/Buffer)计算 pieces_hash */ +export async function piecesHashFromInfoPieces(pieces: Uint8Array): Promise { + return sha1Hex(pieces); +} diff --git a/src/packages/crossSeed/index.ts b/src/packages/crossSeed/index.ts new file mode 100644 index 000000000..7475482ab --- /dev/null +++ b/src/packages/crossSeed/index.ts @@ -0,0 +1,17 @@ +/** + * crossSeed:统一辅种命中源抽象包(含原 @ptd/iyuu 全部内容) + * - types:统一候选模型 + 各类输入/配置类型 + * - template / resolver:双路线下载解析(B=本地适配器,A=模板兜底),跨源通用 + * - compute:pieces_hash(sha1(info.pieces))计算 + * - nexusphp:NexusPHP /api/pieces-hash 站点直查 + * - local:本地文件树 ↔ 候选种子布局比对算法(cross-seed decide 模型) + * - siteMapping / iyuuCenter:IYUU 辅种中心(站名映射表 + 中心协议类型,三源之一) + */ +export * from "./types"; +export * from "./template"; +export * from "./compute"; +export * from "./resolver"; +export * from "./nexusphp"; +export * from "./local"; +export * from "./siteMapping"; +export * from "./iyuuCenter"; diff --git a/src/packages/crossSeed/iyuuCenter.ts b/src/packages/crossSeed/iyuuCenter.ts new file mode 100644 index 000000000..376ca29ba --- /dev/null +++ b/src/packages/crossSeed/iyuuCenter.ts @@ -0,0 +1,51 @@ +/** + * IYUU 辅种中心特有类型(原 @ptd/iyuu/types.ts 的 IYUU 中心部分并入 crossSeed)。 + * 统一候选/凭据类型见 ./types;此处仅保留 IYUU 中心协议类型。 + * @see https://doc.iyuu.cn/reference/site_list、reseed_index + */ + +/** + * IYUU 站点表条目(GET /reseed/sites/index 的响应元素精简) + */ +export interface IYUUSite { + /** IYUU 站点数字 id(sid) */ + id: number; + /** IYUU 站名(如 m-team / hdsky) */ + site: string; + /** 站点昵称(如 馒头 / 天空) */ + nickname: string; + /** 域名(如 api.m-team.cc) */ + base_url: string; + /** 下载链接模板:{} 或 {id} = torrent_id;其余如 {passkey}/{uid}/{hash} 为凭据变量 */ + download_page: string; + /** 详情页模板(亦含变量) */ + details_page: string; + /** 0=http、1=https、2=http(s) 皆可 */ + is_https: 0 | 1 | 2; + /** 1=下载种子需要带 cookie */ + cookie_required: 0 | 1; +} + +/** + * 查询辅种命中项(POST /reseed/index/index 响应 data[<本地hash>].torrent[] 元素) + */ +export interface IYUUReseedHit { + /** IYUU 站点 id */ + sid: number; + /** 该站在此处的种子 id(用于填充 download_page 模板的 {}) */ + torrent_id: number; + /** + * 该站签发的种子 infohash。 + * 注意:与本地种子不同(private=1 + announce 进 info dict,私有站跨站 infohash 必不同),仅用于去重/展示。 + */ + info_hash: string; +} + +/** + * 查询辅种响应:data[<本地种子 infohash>] = 该 hash 可辅种的各站列表 + */ +export interface IYUUReseedResponse { + [localInfoHash: string]: { + torrent: IYUUReseedHit[]; + }; +} diff --git a/src/packages/crossSeed/local.ts b/src/packages/crossSeed/local.ts new file mode 100644 index 000000000..ada79cc85 --- /dev/null +++ b/src/packages/crossSeed/local.ts @@ -0,0 +1,225 @@ +/** + * 本地文件树对比源(LocalCrossSeed)——参照 cross-seed/cross-seed 的 decide.ts 决策模型实现。 + * + * 事实(2026-09 源码级调研,docs/cross-seed-research.md): + * - cross-seed **从不计算 piece hash**,匹配 = 候选 .torrent 文件树(name+size)与 searchee 文件树比对; + * - 决策顺序:SAME/EXISTS hash 去重 → fuzzySize 总大小校验 → 按 matchMode 文件树比对 → 决策; + * - matchMode:strict(严格路径+长度)/ flexible(只比大小,容忍改名)/ partial(按大小占比)。 + * + * 本项目在 cross-seed 模型之上叠加一层「pieces_hash == sha1(info.pieces)」精确比对: + * 同内容种子(跨站 private/announce 不同)pieces 串一致,允许 strict 之外的精确强化。 + */ +import type { ICrossSeedFileRef } from "./types"; + +/** 本地匹配决策(对齐 cross-seed Decision 的子集) */ +export type TLocalMatchDecision = + | "MATCH" // 文件树完全一致(strict / pieces 一致) + | "MATCH_SIZE_ONLY" // 只比大小(flexible) + | "MATCH_PARTIAL" // 大小占比达标(partial) + | "NO_MATCH" // 不匹配 + | "INFO_HASH_ALREADY_EXISTS" // 该 hash 已在本机(跳过注入) + | "SAME_INFO_HASH"; // 与本地种子同 hash(候选即本地) + +export type TLocalMatchMode = "strict" | "flexible" | "partial"; + +export interface ILocalCandidate { + infoHash?: string; + name?: string; + /** 候选种子文件树(解析 .torrent 得到;单文件种子路径归一为种子名) */ + files?: ICrossSeedFileRef[]; + /** 候选种子总大小(可来自搜索结果或 .torrent) */ + size?: number; + /** 候选 pieces_hash(可选;提供时 strict 判定直接按 pieces 精确命中) */ + piecesHash?: string; +} + +export interface ILocalSeedForMatch { + infoHash?: string; + files?: ICrossSeedFileRef[]; + size?: number; + piecesHash?: string; +} + +export interface IAssessLocalOptions { + seed: ILocalSeedForMatch; + candidate: ILocalCandidate; + /** 本地已知 infohash 集合(避免重复注入已在本机的种子) */ + infoHashesToExclude?: Set; + matchMode?: TLocalMatchMode; + /** fuzzy 总大小阈值(默认 ±2%,对齐 cross-seed fuzzySizeThreshold) */ + fuzzySizeThreshold?: number; + /** partial 模式最低字节占比(默认 98%,= 1 - fuzzySizeThreshold) */ + minSizeRatio?: number; +} + +export interface IAssessLocalResult { + decision: TLocalMatchDecision; + /** 决策原因(人工可读) */ + reason?: string; + /** partial 模式预计进度(0-100) */ + progress?: number; +} + +/** + * 文件布局比对:路径集合一致且每个路径长度一致。 + * - 单文件种子需归一化路径(单文件种子中路径通常为种子名) + * - 多文件种子路径为相对路径,直接比对即可 + */ +export function filesLayoutMatch(a: ICrossSeedFileRef[], b: ICrossSeedFileRef[]): boolean { + if (a.length !== b.length) return false; + const sizeByPath = new Map(a.map((f) => [f.path, f.size])); + return b.every((f) => sizeByPath.get(f.path) === f.size); +} + +/** 归一化:计算两文件清单的签名(用于快速前置过滤避免全量比对) */ +export function filesLayoutSignature(files: ICrossSeedFileRef[]): string { + const total = files.reduce((acc, f) => acc + f.size, 0); + const joined = files + .map((f) => f.path) + .sort() + .join("\n"); + return `${files.length}|${total}|${joined.length}`; +} + +/** + * 下载器文件清单归一化:把绝对路径(含 savePath 前缀)转为种子根相对路径。 + * 对齐 cross-seed:searchee.files[].path 为相对路径,candidate 的 .torrent files 亦为相对路径。 + */ +export function normalizeClientFiles(files: Array<{ path: string; size: number }>, savePath = ""): ICrossSeedFileRef[] { + const root = savePath.replace(/\\/g, "/").replace(/\/+$/, ""); + return files.map((f) => { + const norm = f.path.replace(/\\/g, "/"); + const rel = root && norm.startsWith(root) ? norm.slice(root.length).replace(/^\//, "") : norm; + return { path: rel || norm.split("/").pop() || "", size: f.size }; + }); +} + +/** 总大小 fuzzy 校验(对齐 cross-seed fuzzySizeDoesMatch,默认 ±2%) */ +export function fuzzySizeDoesMatch(sizeA: number, sizeB: number, threshold = 0.02): boolean { + const max = Math.max(sizeA, sizeB); + if (max === 0) return sizeA === sizeB; + return Math.abs(sizeA - sizeB) / max <= threshold; +} + +/** flexible:只比大小集合(重命名/换组文件也能命中) */ +export function compareFileTreesIgnoringNames(a: ICrossSeedFileRef[], b: ICrossSeedFileRef[]): boolean { + if (a.length !== b.length) return false; + const sizesA = a.map((f) => f.size).sort((x, y) => x - y); + const sizesB = b.map((f) => f.size).sort((x, y) => x - y); + return sizesA.every((s, i) => s === sizesB[i]); +} + +/** partial:候选文件大小能在 searchee 中配上的字节数占比 */ +export function compareFileTreesPartial(a: ICrossSeedFileRef[], b: ICrossSeedFileRef[]): { ratio: number } { + const sizesB = b.map((f) => f.size); + const matchedBytes = a.reduce((acc, file) => { + const idx = sizesB.indexOf(file.size); + if (idx >= 0) { + sizesB.splice(idx, 1); + return acc + file.size; + } + return acc; + }, 0); + const total = Math.max( + b.reduce((acc, f) => acc + f.size, 0), + 1, + ); + return { ratio: matchedBytes / total }; +} + +/** + * 决策入口(对齐 cross-seed decide.ts 检查顺序): + * 1) hash 去重(SAME_INFO_HASH / INFO_HASH_ALREADY_EXISTS) + * 2) fuzzy 总大小校验 + * 3) pieces_hash 精确命中(本项目强化层) + * 4) 按 matchMode 文件树比对(strict / flexible / partial) + */ +export function assessLocalCandidate(options: IAssessLocalOptions): IAssessLocalResult { + const { + seed, + candidate, + infoHashesToExclude, + matchMode = "strict", + fuzzySizeThreshold = 0.02, + minSizeRatio = 0.98, + } = options; + + // 1. hash 去重 + if (seed.infoHash && candidate.infoHash) { + if (seed.infoHash === candidate.infoHash) { + return { decision: "SAME_INFO_HASH", reason: "候选与本地种子同 infohash" }; + } + if (infoHashesToExclude?.has(candidate.infoHash)) { + return { decision: "INFO_HASH_ALREADY_EXISTS", reason: "该 infohash 已在本机存在" }; + } + } + + // 2. 总大小 fuzzy 校验(无 size 信息时跳过) + if ( + seed.size != null && + candidate.size != null && + !fuzzySizeDoesMatch(seed.size, candidate.size, fuzzySizeThreshold) + ) { + return { decision: "NO_MATCH", reason: `总大小超出 fuzzy 阈值(±${fuzzySizeThreshold * 100}%)` }; + } + + // 3. pieces_hash 精确命中(本项目强化层) + if (seed.piecesHash && candidate.piecesHash) { + if (seed.piecesHash === candidate.piecesHash) { + return { decision: "MATCH", reason: "pieces_hash 一致(sha1(info.pieces))", progress: 100 }; + } + return { decision: "NO_MATCH", reason: "pieces_hash 不一致" }; + } + + // 4. 文件树比对 + if (!seed.files || !candidate.files) { + if (candidate.files === undefined && seed.files === undefined) { + // 双方均无文件树时仅凭 size 判定(罕见) + return seed.size != null && candidate.size != null + ? { decision: "MATCH_SIZE_ONLY", reason: "无文件树信息,仅总大小匹配", progress: 100 } + : { decision: "NO_MATCH", reason: "缺少文件树与大小信息" }; + } + return { decision: "NO_MATCH", reason: "缺少一方文件树信息" }; + } + + if (matchMode === "flexible") { + if (compareFileTreesIgnoringNames(seed.files, candidate.files)) { + return { decision: "MATCH_SIZE_ONLY", reason: "按大小匹配(flexible,容忍改名)", progress: 100 }; + } + return { decision: "NO_MATCH", reason: "flexible 大小集合不匹配" }; + } + + if (matchMode === "partial") { + const { ratio } = compareFileTreesPartial(seed.files, candidate.files); + if (ratio >= minSizeRatio) { + return { + decision: "MATCH_PARTIAL", + reason: `大小占比 ${(ratio * 100).toFixed(1)}% ≥ ${(minSizeRatio * 100).toFixed(1)}%`, + progress: Math.round(ratio * 100), + }; + } + return { decision: "NO_MATCH", reason: `大小占比 ${(ratio * 100).toFixed(1)}% 不足` }; + } + + if (filesLayoutMatch(seed.files, candidate.files)) { + return { decision: "MATCH", reason: "文件树完全一致(strict)", progress: 100 }; + } + return { decision: "NO_MATCH", reason: "文件树不一致(strict)" }; +} + +/** + * 综合判定(兼容旧接口):layout/pieces 任一层命中即可;无任何可比信息返回 false。 + * @deprecated 使用 assessLocalCandidate(含决策与原因) + */ +export function matchLocalToSiteTorrent( + local: { files?: ICrossSeedFileRef[]; piecesHash?: string }, + siteTorrent: { files?: ICrossSeedFileRef[]; piecesHash?: string }, +): boolean { + if (local.piecesHash && siteTorrent.piecesHash) { + return local.piecesHash === siteTorrent.piecesHash; + } + if (local.files && siteTorrent.files) { + return filesLayoutMatch(local.files, siteTorrent.files); + } + return false; +} diff --git a/src/packages/crossSeed/nexusphp.ts b/src/packages/crossSeed/nexusphp.ts new file mode 100644 index 000000000..9d35bbec9 --- /dev/null +++ b/src/packages/crossSeed/nexusphp.ts @@ -0,0 +1,61 @@ +/** + * NexusPHP pieces-hash 站点直查源。 + * @see docs/nexusphp-pieces-hash-research.md(2023-07-26 随 v1.8.5 引入;POST /api/pieces-hash) + * 协议:POST {apiUrl}?passkey=,JSON body { "pieces_hash": ["<40位sha1>", ...] } + * 返回:{ "code": 0, "data": { "": , ... } };单次上限 100 个。 + */ +import axios from "axios"; + +/** 单次查询上限(服务端 getPiecesHashCache 硬限制 100) */ +export const NEXUS_PIECES_HASH_BATCH = 100; + +export interface INexusPiecesHashResp { + code?: number; + data?: Record; + msg?: string; +} + +/** + * 批量查询 pieces_hash → torrent_id 映射(自动按 100/批拆分)。 + * 某一批无命中(业务文案「未查询到可辅种数据」等)按空结果处理,不中断整体。 + */ +export async function nexusQueryPiecesHash( + apiUrl: string, + passkey: string, + hashes: string[], +): Promise> { + if (!hashes.length) return {}; + const out: Record = {}; + for (let i = 0; i < hashes.length; i += NEXUS_PIECES_HASH_BATCH) { + const batch = hashes.slice(i, i + NEXUS_PIECES_HASH_BATCH); + const { data } = await axios.post( + apiUrl, + { pieces_hash: batch }, + { + params: { passkey }, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + timeout: 30e3, + }, + ); + if (data.code !== undefined && data.code !== 0) { + const msg = data.msg ?? ""; + // 该批无命中属于正常空结果 + if (/未查询到可辅种数据|暂无.*辅种|没有.*辅种/.test(msg)) { + continue; + } + throw new Error(msg || "NexusPHP pieces-hash 查询失败"); + } + Object.assign(out, data.data ?? {}); + } + return out; +} + +/** 把 pieces_hash 命中映射为统一候选构建输入(站点上下文化在聚合层完成) */ +export interface INexusPiecesHashHit { + piecesHash: string; + torrentId: number; +} + +export function mapNexusHits(result: Record): INexusPiecesHashHit[] { + return Object.entries(result).map(([piecesHash, torrentId]) => ({ piecesHash, torrentId })); +} diff --git a/src/packages/crossSeed/resolver.ts b/src/packages/crossSeed/resolver.ts new file mode 100644 index 000000000..fef46c325 --- /dev/null +++ b/src/packages/crossSeed/resolver.ts @@ -0,0 +1,113 @@ +/** + * 双路线下载解析(跨源通用):把「命中记录({torrent_id})+ 模板站点」解析为最终种子下载请求。 + * + * - 路线 B(推荐):构造 stub ITorrent → 本地站点适配器 getTorrentDownloadRequestConfig() + * (复用站点 cookie/UA/协议/sign 等全部既有逻辑) + * - 路线 A(兜底):download_page 模板 + 凭据渲染(无本地适配器/适配器失败时) + * + * 与本地站点的映射由调用方注入(localSiteId),本包不感知 IYUU/站名的映射细节。 + */ +import type { AxiosRequestConfig } from "axios"; +import type { TSiteID } from "@ptd/site"; + +import { siteTemplateProtocol, renderDownloadPage } from "./template"; +import type { + ICrossSeedResolveSiteInstance, + ICrossSeedTemplateSite, + ITorrentLike, + IYUUDownloadCredentials, +} from "./types"; + +/** 解析结果 */ +export interface ICrossSeedResolveResult { + /** 解析到的本地站点 id(B 路线使用) */ + siteId?: TSiteID; + /** 采用的路线;A 且 missing/unsupported 非空表示应跳过该站 */ + method: "B" | "A"; + /** 最终下载请求配置(B 为适配器输出;A 为渲染 URL 构造的 GET) */ + config?: AxiosRequestConfig; + /** A 路线渲染的原始下载链接 */ + url?: string; + /** A 路线缺失的静态凭据变量(应跳过该站) */ + missing?: string[]; + /** A 路线不可用的动态变量(sign/cuhash,模板侧不实现;应跳过该站走本地适配器) */ + unsupported?: string[]; + /** 失败原因(B 尝试失败但未走 A 时) */ + error?: string; +} + +export interface ICrossSeedResolveOptions { + /** 本地站点适配器实例(可注入 mock) */ + siteInstance?: ICrossSeedResolveSiteInstance; + /** 命中项(torrent_id 必填) */ + hit: { torrent_id: number; info_hash?: string }; + /** 模板站点条目(A 路线兜底用;含 download_page 模板) */ + templateSite?: ICrossSeedTemplateSite; + /** 本地站点映射(B 路线需要;由调用方按站点规则解析) */ + localSiteId?: TSiteID; + /** A 路线凭据 */ + creds?: IYUUDownloadCredentials; +} + +/** 默认 stub 构造:仅 site/id 必填,link/url 留空由适配器自行补全(如 hdsky 由 id 拼详情页) */ +export function buildStubTorrent(siteId: TSiteID, hit: { torrent_id: number }): ITorrentLike { + return { + site: siteId, + id: hit.torrent_id, + title: "", + // link 空串促使适配器进入"重新获取"分支;url 由适配器或后续路由按需补全 + link: "", + }; +} + +/** + * 双路线解析:B 优先(本地适配器),B 失败/未映射 → A(模板渲染),凭据缺失则返回 missing。 + */ +export async function resolveCrossSeedTorrent(options: ICrossSeedResolveOptions): Promise { + const { hit, templateSite, localSiteId } = options; + + // 先尝试路线 B:本地适配器 + if (options.siteInstance && localSiteId) { + try { + const config = await options.siteInstance.getTorrentDownloadRequestConfig(buildStubTorrent(localSiteId, hit)); + return { siteId: localSiteId, method: "B", config }; + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + // 路由 B 失败(详情页解析失败/站点要求额外上下文)时记下原因,继续尝试 A + if (!templateSite) { + return { siteId: localSiteId, method: "B", error }; + } + const fallback = await renderTemplateFallback(templateSite, hit, options.creds); + return { siteId: localSiteId, ...fallback, error }; + } + } + + // 路线 A:模板渲染(或提示未适配) + if (!templateSite) { + return { method: "A", error: "未映射到本地站点且未提供模板信息" }; + } + return renderTemplateFallback(templateSite, hit, options.creds); +} + +/** 兼容旧导出名(@ptd/iyuu 时代 resolveTorrentDownload) */ +export { resolveCrossSeedTorrent as resolveTorrentDownload }; + +async function renderTemplateFallback( + templateSite: ICrossSeedTemplateSite, + hit: { torrent_id: number }, + creds: IYUUDownloadCredentials = {}, +): Promise> { + const { url, missing, unsupported } = renderDownloadPage(templateSite.download_page, hit.torrent_id, creds); + if (unsupported.length > 0) { + return { method: "A", unsupported, url, missing }; + } + if (missing.length > 0) { + return { method: "A", missing, url }; + } + const fullUrl = `${siteTemplateProtocol(templateSite)}${templateSite.base_url}/${url}`; + return { + method: "A", + url: fullUrl, + config: { method: "GET", url: fullUrl, responseType: "arraybuffer" }, + }; +} diff --git a/src/packages/crossSeed/siteMapping.ts b/src/packages/crossSeed/siteMapping.ts new file mode 100644 index 000000000..c5fd0d857 --- /dev/null +++ b/src/packages/crossSeed/siteMapping.ts @@ -0,0 +1,136 @@ +/** + * IYUU 站名 ↔ 本地站点 id 映射表(实测对账,2026-09-13) + * 原 @ptd/iyuu/siteMapping.ts 并入 crossSeed(IYUU 中心为三源之一)。 + * @see docs/iyuu-integration-plan.md §5 + */ +import type { TSiteID } from "@ptd/site"; + +/** + * 命名差异站:IYUU 站名 → 本地 definitions basename(20 项,本地文件已核验存在) + */ +export const IYUU_SITE_NAME_DIFFS: Record = { + "m-team": "mteam", // api.m-team.cc 馒头 + torrentccf: "tccf", // et8.org 他吹吹风 + ttg: "totheglory", // totheglory.im 听听歌 + ssd: "springsunday", // springsunday.net 春天 + upxin: "hdupt", // pt.upxin.net(HDU) + oshen: "oshenpt", // oshen.win 奥申 + byr: "byrbt", // byr.pt 北邮人 + pt: "sjtu", // pt.sjtu.edu.cn 葡萄 + pt0ffcc: "freefarm", // pt.0ff.cc 自由农场 + shadowflow: "starspace", // star-space.net 影 + qingwapt: "qingwa", // qingwapt.com 青蛙 + hdkyl: "hdkylin", // hdkyl.in 麒麟 + gtkpw: "ptgtk", // pt.gtk.pw GTK + ptlover: "afun", // ptlover.cc AFun + bilibili: "railgunpt", // bilibili.download + gamegamept: "ggpt", // gamegamept.com GGPT + myptcc: "mypt", // cc.mypt.cc 我的PT(CC) + duckboobee: "march", // duckboobee.org March + eastgame: "tlfbits", // pt.eastgame.org 吐鲁番 + cangbaoge: "cbg", // cangbao.ge 藏宝阁 + dmhy: "u2", // u2.dmhy.org U2(IYUU 的 dmhy 实为 U2 私站,非公开动漫花园) +}; + +/** + * 同名站:IYUU 站名 == 本地 definitions basename(84 项,实测对账一致) + */ +export const IYUU_SAME_NAME_SITES: readonly TSiteID[] = [ + "keepfrds", + "pthome", + "hdsky", + "tjupt", + "pter", + "hdhome", + "btschool", + "ourbits", + "nanyangpt", + "hdcity", + "nicept", + "52pt", + "soulvoice", + "chdbits", + "ptsbao", + "hdarea", + "hdtime", + "1ptba", + "hd4fans", + "opencd", + "joyhd", + "discfan", + "dicmusic", + "skyeysnow", + "hdroute", + "haidan", + "hdfans", + "dragonhd", + "hitpt", + "greatposterwall", + "hdpost", + "hudbt", + "audiences", + "piggo", + "wintersakura", + "hhanclub", + "hdvideo", + "ptchina", + "zhuque", + "zmpt", + "rousi", + "monikadesign", + "cyanbug", + "ubits", + "pandapt", + "carpt", + "agsvpt", + "ptvicomo", + "xingtan", + "ilolicon", + "okpt", + "crabpt", + "hddolby", + "kamept", + "ptcafe", + "yemapt", + "ptlgs", + "lemonhd", + "raingfh", + "njtupt", + "ptzone", + "hdclone", + "kufei", + "xingyunge", + "cspt", + "tmpt", + "htpt", + "sewerpt", + "longpt", + "hdbao", + "13city", + "luckpt", + "ptskit", + "playletpt", + "novahd", + "lajidui", + "hxpt", + "dubhe", + "tangpt", + "muxuege", + "zrpt", + "siqi", + "baozi", +]; + +/** + * IYUU 站名 → 本地站点 id 全量映射(104 = 84 同名 + 20 差异) + * 覆盖 IYUU 全部已适配站点;未收录的站名自然返回 undefined(走模板兜底或提示)。 + */ +export const IYUU_SITE_TO_LOCAL: Readonly> = Object.freeze({ + ...IYUU_SITE_NAME_DIFFS, + ...Object.fromEntries(IYUU_SAME_NAME_SITES.map((id) => [id, id])), +} as Record); + +/** 由 IYUU 站名解析本地站点 id;未收录时返回 undefined */ +export function iyuuSiteToLocal(siteName: string): TSiteID | undefined { + return IYUU_SITE_TO_LOCAL[siteName]; +} diff --git a/src/packages/crossSeed/template.ts b/src/packages/crossSeed/template.ts new file mode 100644 index 000000000..85be377fc --- /dev/null +++ b/src/packages/crossSeed/template.ts @@ -0,0 +1,85 @@ +/** + * download_page 模板渲染(A 路线兜底,跨源通用)。 + * 变量语义见 docs/iyuu-integration-plan.md §4(源码对应 DriverPthome::parseReplace 等)。 + */ +import type { ICrossSeedTemplateSite, IYUUDownloadCredentials } from "./types"; + +/** + * 模板变量 → 凭据字段映射(仅静态可配置凭据)。 + * 注意:{sign}(hdsky)、{cuhash}(hdcity)等需要详情页动态提取的变量**不在模板侧实现** + * —— 此类站点本地站点适配器已覆盖(B 路线),模板兜底遇到即标记 unsupported 跳过。 + */ +const TEMPLATE_VAR_TO_CRED_KEY: Record = { + passkey: "passkey", + uid: "uid", + hash: "downHash", + downhash: "downHash", + downHash: "downHash", + authkey: "authkey", + torrent_pass: "torrentPass", + torrent_key: "torrentKey", + rsskey: "rsskey", +}; + +/** 需要动态获取、模板侧不实现的变量(出现即该站 A 路线不可用) */ +const UNSUPPORTED_DYNAMIC_VARS = ["sign", "cuhash"] as const; + +/** + * 渲染 download_page 模板。 + * - {} 与 {id} 替换为 torrent_id(必填,无缺失语义) + * - 静态凭据变量:已提供则替换;缺失则保留原 token 并记入 missing(调用方应跳过该站) + * - 动态变量(sign/cuhash):不渲染,记入 unsupported(调用方应跳过该站) + */ +export function renderDownloadPage( + template: string, + torrentId: number, + creds: IYUUDownloadCredentials = {}, +): { url: string; missing: string[]; unsupported: string[] } { + let url = template.replace(/\{\}/g, String(torrentId)).replace(/\{id\}/g, String(torrentId)); + const missing: string[] = []; + const unsupported: string[] = []; + + for (const rawVar of UNSUPPORTED_DYNAMIC_VARS) { + if (url.includes(`{${rawVar}}`)) { + unsupported.push(rawVar); + } + } + + for (const [rawVar, credKey] of Object.entries(TEMPLATE_VAR_TO_CRED_KEY)) { + const token = `{${rawVar}}`; + if (!url.includes(token)) continue; + + const value = creds[credKey]; + if (value === undefined || value === "") { + missing.push(token); + } else { + url = url.replaceAll(token, String(value)); + } + } + + return { url, missing, unsupported }; +} + +/** 收集模板中出现的静态凭据变量(用于设置页引导补配置;不含动态变量) */ +export function collectTemplateVars(template: string): string[] { + const vars: string[] = []; + for (const rawVar of Object.keys(TEMPLATE_VAR_TO_CRED_KEY)) { + if (template.includes(`{${rawVar}}`)) { + vars.push(rawVar); + } + } + return vars; +} + +/** 该模板是否包含动态变量(sign/cuhash),即 A 路线不可用 */ +export function hasUnsupportedDynamicVars(template: string): boolean { + return UNSUPPORTED_DYNAMIC_VARS.some((rawVar) => template.includes(`{${rawVar}}`)); +} + +/** 由模板站点条目标线程协议前缀(is_https: 0=http、1=https、2=http(s)) */ +export function siteTemplateProtocol(templateSite: { is_https: 0 | 1 | 2 }): "http://" | "https://" { + return templateSite.is_https === 0 ? "http://" : "https://"; +} + +/** 兼容旧导出名(@ptd/iyuu 时代) */ +export { siteTemplateProtocol as iyuuProtocol }; diff --git a/src/packages/crossSeed/types.ts b/src/packages/crossSeed/types.ts new file mode 100644 index 000000000..4256bc3f3 --- /dev/null +++ b/src/packages/crossSeed/types.ts @@ -0,0 +1,129 @@ +/** + * crossSeed:统一辅种命中源抽象(IYUU 中心 / NexusPHP pieces-hash 直查 / 本地文件树对比) + * + * 候选模型(ICrossSeedCandidate)为三类命中源的统一输出; + * UI 只消费候选数组,注入链路(站点适配器 B 路线 / 模板 A 兜底)见 resolver.ts。 + */ +import type { TSiteID } from "@ptd/site"; +import type { AxiosRequestConfig } from "axios"; + +/** 辅种命中来源类型 */ +export type TCrossSeedSourceKind = "iyuu" | "nexusphp" | "local"; + +/** + * 统一辅种候选:一条「本地种子 → 某站可辅种种子」的解析结果(供 UI 勾选/注入)。 + * 是 @ptd/iyuu 旧 IYUUReseedCandidate 的跨源泛化(新增 source 字段标识命中来源)。 + */ +export interface ICrossSeedCandidate { + /** 来源本地种子 infohash */ + sourceInfoHash: string; + /** 来源本地种子标题(同资源,仅展示用) */ + sourceName?: string; + /** 来源本地种子保存目录(注入时 savePath 复用) */ + sourceSavePath?: string; + /** 来源本地种子大小 */ + sourceSize?: number; + + /** 解析到的本地站点 id(未映射时为空串) */ + siteId: TSiteID; + /** 站点显示名(IYUU 昵称/本地站名/接口站名回退) */ + siteName: string; + /** 该站在此处的种子 id */ + torrentId: number; + + /** 解析出的完整下载链接(B 路线适配器输出/A 路线模板渲染) */ + downloadUrl?: string; + /** 采用的解析路线 */ + method?: "B" | "A"; + + /** ready=可注入;error=解析失败(原因见 error) */ + status: "ready" | "error"; + error?: string; + + /** 命中来源:iyuu(中心索引)/ nexusphp(站直查)/ local(本地文件树比对) */ + source: TCrossSeedSourceKind; + + /** partial 匹配预计可并入进度(0-100;仅 partial/local 命中携带) */ + progress?: number; + /** 该候选已在本地决策表中记录为已推送(跨扫描去重) */ + injected?: boolean; +} + +/** 被扫描的本地种子(聚合扫描的输入下一级) */ +export interface ICrossSeedLocalSeed { + infoHash: string; + name: string; + savePath: string; + size: number; + /** 来源下载器 id(多下载器种子集合扫描时用于获取文件列表等) */ + clientId?: string; + /** 本地文件清单(local 源比对用;来自下载器 getTorrentFiles) */ + files?: ICrossSeedFileRef[]; + /** 本地种子 pieces_hash(nexus 源直查用,sha1(info.pieces)) */ + piecesHash?: string; +} + +/** 文件引用(local 源布局比对的最小单位) */ +export interface ICrossSeedFileRef { + path: string; + size: number; +} + +/** + * 通用模板站点信息(download_page 模板 A 路线兜底所需)。 + * 与 IYUU 站点表结构同构,跨源复用(NexusPHP/local 源未映射场景也可用)。 + */ +export interface ICrossSeedTemplateSite { + site: string; + download_page: string; + base_url: string; + is_https: 0 | 1 | 2; +} + +/** + * download_page 模板可出现的**静态可配置**凭据变量(A 路线兜底)。 + * 注意:{sign}(hdsky)、{cuhash}(hdcity)等需要详情页动态提取的变量不在模板侧实现, + * 此类站点由本地站点适配器(B 路线)覆盖;模板兜底遇到即标记 unsupported。 + * @see docs/iyuu-integration-plan.md §4 + */ +export interface IYUUDownloadCredentials { + /** {passkey}:站点用户 passkey(多数 NexusPHP 站) */ + passkey?: string; + /** {uid}:站点用户 uid(pthome/hdhome/hddolby 等) */ + uid?: string | number; + /** {hash}/{downhash}/{downHash}:用户下载鉴权 hash */ + downHash?: string; + /** {authkey}:Gazelle 系 authkey(dicmusic/greatposterwall) */ + authkey?: string; + /** {torrent_pass}:Gazelle 系 torrent_pass */ + torrentPass?: string; + /** {torrent_key}:zhuque 用户 torrent_key */ + torrentKey?: string; + /** {rsskey}:hdpost/monikadesign 用户 rsskey */ + rsskey?: string; +} + +/** NexusPHP pieces-hash 站点直查配置(设置页录入,存 metadata.iyuu.nexusSites) */ +export interface INexusSiteConfig { + /** 本地站点 id(B 路线下载解析与候选 siteId 使用) */ + siteId: TSiteID; + /** 完整接口地址,如 https://site/api/pieces-hash */ + apiUrl: string; + /** 用户 passkey(query 参数认证) */ + passkey: string; + enabled?: boolean; +} + +/** 下载请求配置的窄接口(便于依赖注入与单元测试,对应站点适配器方法) */ +export interface ICrossSeedResolveSiteInstance { + getTorrentDownloadRequestConfig(torrent: ITorrentLike): Promise; +} + +/** resolver 依赖的最小种子对象(仅 site/id 必填) */ +export interface ITorrentLike { + site: TSiteID; + id: number | string; + title: string; + link?: string; + url?: string; +} diff --git a/src/packages/downloader/entity/Transmission.ts b/src/packages/downloader/entity/Transmission.ts index 01db0c4e4..e2cbec6b5 100644 --- a/src/packages/downloader/entity/Transmission.ts +++ b/src/packages/downloader/entity/Transmission.ts @@ -155,6 +155,12 @@ interface AddTorrentResponse extends TransmissionBaseResponse { hashString: string; name: string; }; + /** 种子已存在时返回;视为添加成功但不执行后续附加设置 */ + "torrent-duplicate"?: { + id: number; + hashString: string; + name: string; + }; }; } @@ -449,10 +455,19 @@ export default class Transmission extends AbstractBittorrentClient("torrent-add", addTorrentOptions); - const torrentId = data.arguments["torrent-added"].id; + // Transmission 在种子已存在时返回 torrent-duplicate:视为添加成功, + // 但不执行后续的 labels / 上传限速等 torrent-set 附加设置(种子已存在,避免重复配置)。 + const addedInfo = data.arguments["torrent-added"] ?? data.arguments["torrent-duplicate"]; + const isDuplicate = Boolean(data.arguments["torrent-duplicate"]); + if (!addedInfo) { + addResult.success = false; + addResult.message = data; + return addResult; + } + const torrentId = addedInfo.id; // Transmission 3.0 以上才支持label - if (!supportLabelAtAdd && labels) { + if (!isDuplicate && !supportLabelAtAdd && labels) { try { await this.request("torrent-set", { ids: torrentId, @@ -461,8 +476,8 @@ export default class Transmission extends AbstractBittorrentClient 0) { + // 设置上传速度限制 - 必须在添加后使用 torrent-set(重复添加的种子跳过) + if (!isDuplicate && options.uploadSpeedLimit && options.uploadSpeedLimit > 0) { try { await this.request("torrent-set", { ids: torrentId, diff --git a/src/packages/site/schemas/NexusPHP.ts b/src/packages/site/schemas/NexusPHP.ts index 6de614dd3..4a090f4cf 100644 --- a/src/packages/site/schemas/NexusPHP.ts +++ b/src/packages/site/schemas/NexusPHP.ts @@ -1012,7 +1012,11 @@ export default class NexusPHP extends PrivateSite { } public override async getTorrentDownloadLink(torrent: ITorrent): Promise { - // 如果没有 link 属性,则尝试以 (url->)id->link 的方式生成 + // 如果没有 link 属性,则尝试以 (url->)id->link 的方式生成。 + // 站点若配置了详情页下载链接选择器(如 hdsky,链接带 passkey/sign), + // 优先由基类(AbstractBittorrentSite)从详情页提取真实下载链接——IYUU 懒加载等无 link 场景 + // 裸拼 download.php?id= 会缺凭据导致 "Invalid Torrent From Server";提取失败或无选择器时 + // 回退到原有裸拼兜底。 if (!torrent.link) { if (!torrent.id && torrent.url) { const urlMatch = torrent.url.match(/[?&]id=(\d+)/); @@ -1022,6 +1026,18 @@ export default class NexusPHP extends PrivateSite { } if (torrent.id) { + if (this.metadata?.detail?.selectors?.link) { + torrent.url ??= `${this.url.replace(/\/+$/, "")}/details.php?id=${torrent.id}`; + try { + const extracted = await super.getTorrentDownloadLink(torrent); + if (extracted && /[?&](passkey|sign|downhash|t)=/.test(extracted)) { + return extracted; // 详情页提取到带凭据的下载链接 + } + } catch { + // 详情页提取失败,回退下方裸拼 + } + } + const mockRequestConfig = torrent.url?.startsWith("http") ? { url: torrent.url } : { baseURL: this.url }; torrent.link = this.fixLink(`/download.php?id=${torrent.id}`, mockRequestConfig); }