From ecfb57d9ee2c476b11d996121d131e3e5e2960e7 Mon Sep 17 00:00:00 2001 From: Rhilip Date: Sun, 13 Sep 2026 19:40:56 +0800 Subject: [PATCH 01/40] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E8=B0=83=E7=A0=94=E7=9B=AE=E5=BD=95=20.research-flood?= =?UTF-8?q?/.research-iyuu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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 From 2cc347ec1f2771e875e99dbc36d6b9eb712f8bdb Mon Sep 17 00:00:00 2001 From: Rhilip Date: Sun, 13 Sep 2026 19:54:32 +0800 Subject: [PATCH 02/40] =?UTF-8?q?feat(iyuu):=20P0=20=E7=BA=AF=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E6=A8=A1=E5=9D=97=E2=80=94=E2=80=94IYUU=20=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E6=98=A0=E5=B0=84=20+=20=E5=8F=8C=E8=B7=AF=E7=BA=BF?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - siteMapping: IYUU 104 站 ↔ 本地站点 id 全量映射(84 同名 + 20 差异,实测对账)与 iyuuSiteToLocal() - template: download_page 模板渲染({} → torrent_id + 静态凭据变量替换);{sign}/{cuhash} 等动态变量模板侧不实现,标记 unsupported - resolver: 双路线解析 B(本地适配器 getTorrentDownloadRequestConfig,stub 构造)优先 / A(模板渲染)兜底;缺失/不支持变量收集供跳过与引导补配置 - types: IYUUSite/IYUUReseedHit/IYUUDownloadCredentials(凭据仅含静态可配置项) - 验证:pnpm check + tsx 脚本 24 断言全过(映射/渲染/双路线/unsupported) --- src/packages/iyuu/index.ts | 10 +++ src/packages/iyuu/resolver.ts | 109 +++++++++++++++++++++++++ src/packages/iyuu/siteMapping.ts | 135 +++++++++++++++++++++++++++++++ src/packages/iyuu/template.ts | 82 +++++++++++++++++++ src/packages/iyuu/types.ts | 74 +++++++++++++++++ 5 files changed, 410 insertions(+) create mode 100644 src/packages/iyuu/index.ts create mode 100644 src/packages/iyuu/resolver.ts create mode 100644 src/packages/iyuu/siteMapping.ts create mode 100644 src/packages/iyuu/template.ts create mode 100644 src/packages/iyuu/types.ts diff --git a/src/packages/iyuu/index.ts b/src/packages/iyuu/index.ts new file mode 100644 index 000000000..9ff2d75da --- /dev/null +++ b/src/packages/iyuu/index.ts @@ -0,0 +1,10 @@ +/** + * IYUU 辅种中心接入包(纯函数模块,P0) + * - siteMapping:IYUU 站名 ↔ 本地站点 id 映射(104 站全量) + * - template:download_page 模板渲染与缺失变量收集(A 路线兜底) + * - resolver:双路线解析(B=本地适配器优先,A=模板兜底) + */ +export * from "./types"; +export * from "./siteMapping"; +export * from "./template"; +export * from "./resolver"; diff --git a/src/packages/iyuu/resolver.ts b/src/packages/iyuu/resolver.ts new file mode 100644 index 000000000..57dfa0190 --- /dev/null +++ b/src/packages/iyuu/resolver.ts @@ -0,0 +1,109 @@ +/** + * 双路线解析:把 IYUU 查询命中({sid, torrent_id})解析为最终种子下载请求。 + * + * - 路线 B(推荐):构造 stub ITorrent → 本地站点适配器 getTorrentDownloadRequestConfig() + * (复用站点 cookie/UA/协议/sign 等全部既有逻辑;覆盖 IYUU 104 站,见 siteMapping) + * - 路线 A(兜底):IYUU download_page 模板 + 凭据渲染(未来 IYUU 新增站且本地未适配时) + */ +import type { AxiosRequestConfig } from "axios"; +import type { ITorrent, TSiteID } from "@ptd/site"; +import { iyuuSiteToLocal } from "./siteMapping"; +import { iyuuProtocol, renderDownloadPage } from "./template"; +import type { IYUUReseedHit, IYUUDownloadCredentials } from "./types"; + +/** 本地站点适配器窄接口(便于依赖注入与单测) */ +export interface IYUUResolveSiteInstance { + getTorrentDownloadRequestConfig(torrent: ITorrent): Promise; +} + +/** 解析结果 */ +export interface IYUUResolveResult { + /** 解析到的本地站点 id(未映射时可能为空) */ + 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 IYUUResolveOptions { + /** 本地站点适配器实例(可注入 mock) */ + siteInstance?: IYUUResolveSiteInstance; + /** IYUU 查询命中项 */ + hit: IYUUReseedHit; + /** IYUU 站点表条目(模板兜底用;B 路线无需) */ + iyuuSite?: { site: string; download_page: string; base_url: string; is_https: 0 | 1 | 2 }; + /** A 路线凭据 */ + creds?: IYUUDownloadCredentials; +} + +/** 默认 stub 构造:仅 site/id 必填,link/url 留空由适配器自行补全(如 hdsky 由 id 拼详情页) */ +export function buildStubTorrent(siteId: TSiteID, hit: IYUUReseedHit): ITorrent { + return { + site: siteId, + id: hit.torrent_id, + title: "", + // link 空串促使适配器进入"重新获取"分支;url 由适配器或后续路由按需补全 + link: "", + }; +} + +/** + * 双路线解析:B 优先(本地适配器),B 失败/未映射 → A(模板渲染),凭据缺失则返回 missing。 + */ +export async function resolveTorrentDownload(options: IYUUResolveOptions): Promise { + const { hit, iyuuSite } = options; + + // 先尝试路线 B:本地适配器 + if (options.siteInstance) { + const localSiteId = iyuuSite ? iyuuSiteToLocal(iyuuSite.site) : undefined; + if (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 (!iyuuSite) { + return { siteId: localSiteId, method: "B", error }; + } + const fallback = await renderTemplateFallback(iyuuSite, hit, options.creds); + return { siteId: localSiteId, ...fallback, error }; + } + } + } + + // 路线 A:模板渲染(或提示未适配) + if (!iyuuSite) { + return { method: "A", error: "该 IYUU 站点未映射到本地站点且未提供模板信息" }; + } + return renderTemplateFallback(iyuuSite, hit, options.creds); +} + +async function renderTemplateFallback( + iyuuSite: { site: string; download_page: string; base_url: string; is_https: 0 | 1 | 2 }, + hit: IYUUReseedHit, + creds: IYUUDownloadCredentials = {}, +): Promise> { + const { url, missing, unsupported } = renderDownloadPage(iyuuSite.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 = `${iyuuProtocol(iyuuSite)}${iyuuSite.base_url}/${url}`; + return { + method: "A", + url: fullUrl, + config: { method: "GET", url: fullUrl, responseType: "arraybuffer" }, + }; +} diff --git a/src/packages/iyuu/siteMapping.ts b/src/packages/iyuu/siteMapping.ts new file mode 100644 index 000000000..cccd70930 --- /dev/null +++ b/src/packages/iyuu/siteMapping.ts @@ -0,0 +1,135 @@ +/** + * IYUU 站名 ↔ 本地站点 id 映射表(实测对账,2026-09-13) + * @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 藏宝阁 +}; + +/** + * 同名站: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", + "dmhy", + "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/iyuu/template.ts b/src/packages/iyuu/template.ts new file mode 100644 index 000000000..10baceab1 --- /dev/null +++ b/src/packages/iyuu/template.ts @@ -0,0 +1,82 @@ +/** + * IYUU download_page 模板渲染(A 路线:IYUU 模板兜底) + * 变量语义见 docs/iyuu-integration-plan.md §4(源码对应 DriverPthome::parseReplace 等) + */ +import type { 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}}`)); +} + +/** 由 IYUU 站点条目标线程协议前缀(is_https: 0=http、1=https、2=http(s)) */ +export function iyuuProtocol(iyuuSite: { is_https: 0 | 1 | 2 }): "http://" | "https://" { + return iyuuSite.is_https === 0 ? "http://" : "https://"; +} diff --git a/src/packages/iyuu/types.ts b/src/packages/iyuu/types.ts new file mode 100644 index 000000000..792a9064b --- /dev/null +++ b/src/packages/iyuu/types.ts @@ -0,0 +1,74 @@ +/** + * IYUU 辅种中心相关类型定义 + * @see https://doc.iyuu.cn/reference/site_list + * @see https://doc.iyuu.cn/reference/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[]; + }; +} + +/** + * download_page 模板可出现的**静态可配置**凭据变量。 + * 注意:{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(≈ passkey) */ + torrentPass?: string; + /** {torrent_key}:zhuque 用户 torrent_key */ + torrentKey?: string; + /** {rsskey}:hdpost/monikadesign 用户 rsskey */ + rsskey?: string; +} From 666e57adc23d522c7e1fa30a158fa0c0cdbcf11a Mon Sep 17 00:00:00 2001 From: Rhilip Date: Sun, 13 Sep 2026 20:10:21 +0800 Subject: [PATCH 03/40] =?UTF-8?q?feat(iyuu):=20P0=20=E5=90=8E=E5=8D=8A?= =?UTF-8?q?=E2=80=94=E2=80=94offscreen=20=E4=B8=AD=E5=BF=83=E5=B0=81?= =?UTF-8?q?=E8=A3=85=20+=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=E3=80=8CIYUU=20?= =?UTF-8?q?=E8=BE=85=E7=A7=8D=E3=80=8D=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storage: 新增 iyuu 键(token/heldSites/sidSha1+7天过期/站点表缓存 TTL 24h;token 不进备份) - messages 协议: getIyuusConfig/setIyuusConfig/iyuuFetchSites/iyuuReportExisting/iyuuQueryReseed/iyuuDeriveHeldSites - offscreen/utils/iyuu.ts: 中心三接口封装(axios,Token 头)+ sid_sha1 过期自动重汇报 + 持有站点自动推导(metadata.sites ∩ 104 站) - nativeMessaging 白名单登记 6 条;offscreen 注册 - 设置页: SetBase 新增「IYUU 辅种」窗口(token 配置、拉取站点表、推导/编辑持有站、汇报拿 sid_sha1);zh_CN/en 词条 - 验证: pnpm check + build:dist 通过(prettier 后复查) --- .../background/utils/nativeMessaging.ts | 7 + src/entries/messages.ts | 15 ++ src/entries/offscreen/offscreen.ts | 1 + src/entries/offscreen/utils/iyuu.ts | 190 +++++++++++++++++ src/entries/options/plugins/router.ts | 6 + .../views/Settings/SetBase/IyuuWindow.vue | 194 ++++++++++++++++++ src/entries/shared/types.ts | 1 + src/entries/shared/types/storages/iyuu.ts | 34 +++ src/entries/storage.ts | 2 + src/locales/en.json | 21 ++ src/locales/zh_CN.json | 21 ++ 11 files changed, 492 insertions(+) create mode 100644 src/entries/offscreen/utils/iyuu.ts create mode 100644 src/entries/options/views/Settings/SetBase/IyuuWindow.vue create mode 100644 src/entries/shared/types/storages/iyuu.ts diff --git a/src/entries/background/utils/nativeMessaging.ts b/src/entries/background/utils/nativeMessaging.ts index c8ce3a521..29259c832 100644 --- a/src/entries/background/utils/nativeMessaging.ts +++ b/src/entries/background/utils/nativeMessaging.ts @@ -49,6 +49,13 @@ const ALLOWED_METHODS = new Set([ "updateKeepUploadTask", "deleteKeepUploadTask", "clearKeepUploadTasks", + // IYUU reseed center + "getIyuusConfig", + "setIyuusConfig", + "iyuuFetchSites", + "iyuuReportExisting", + "iyuuQueryReseed", + "iyuuDeriveHeldSites", ]); // ── Module-scoped state ────────────────────────────────────────────── diff --git a/src/entries/messages.ts b/src/entries/messages.ts index 6abeb457a..fe8b61276 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 } from "@ptd/iyuu"; // 可序列化的种子信息,用于辅种检测 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,18 @@ 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[] }; + // 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/offscreen.ts b/src/entries/offscreen/offscreen.ts index c9a761146..1a3914fac 100644 --- a/src/entries/offscreen/offscreen.ts +++ b/src/entries/offscreen/offscreen.ts @@ -9,3 +9,4 @@ import "./utils/backup.ts"; import "./utils/socialInformation.ts"; import "./utils/socialRecommendations.ts"; import "./utils/keepUploadTask.ts"; +import "./utils/iyuu.ts"; diff --git a/src/entries/offscreen/utils/iyuu.ts b/src/entries/offscreen/utils/iyuu.ts new file mode 100644 index 000000000..cc9d2742b --- /dev/null +++ b/src/entries/offscreen/utils/iyuu.ts @@ -0,0 +1,190 @@ +/** + * 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 } from "@/shared/types.ts"; +import type { TSiteID } from "@ptd/site"; +import { iyuuSiteToLocal } from "@ptd/iyuu"; +import type { IYUUReseedHit } from "@ptd/iyuu"; + +const STORAGE_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 getConfig(): Promise { + return ((await sendMessage("getExtStorage", STORAGE_KEY)) as IIyuuStorageSchema) ?? {}; +} + +async function saveConfig(patch: Partial): Promise { + const current = await getConfig(); + await sendMessage("setExtStorage", { key: STORAGE_KEY, value: { ...current, ...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) { + throw new Error(data.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; +} diff --git a/src/entries/options/plugins/router.ts b/src/entries/options/plugins/router.ts index a213c663c..e4c2384f1 100644 --- a/src/entries/options/plugins/router.ts +++ b/src/entries/options/plugins/router.ts @@ -44,6 +44,12 @@ export const setBaseChildren: RouteRecordRaw[] = [ meta: { icon: "mdi-multimedia" }, component: () => import("../views/Settings/SetBase/SocialInformationWindow.vue"), }, + { + path: "iyuu", + name: "SetBaseIyuu", + meta: { icon: "mdi-vector-link", usesGlobalSave: false }, + component: () => import("../views/Settings/SetBase/IyuuWindow.vue"), + }, ] as const; export const routes: RouteRecordRaw[] = [ diff --git a/src/entries/options/views/Settings/SetBase/IyuuWindow.vue b/src/entries/options/views/Settings/SetBase/IyuuWindow.vue new file mode 100644 index 000000000..ca67dfbfc --- /dev/null +++ b/src/entries/options/views/Settings/SetBase/IyuuWindow.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/src/entries/shared/types.ts b/src/entries/shared/types.ts index 5f5e43d8f..1b91ca6f9 100644 --- a/src/entries/shared/types.ts +++ b/src/entries/shared/types.ts @@ -15,6 +15,7 @@ export * from "./types/storages/metadata.ts"; export * from "./types/storages/runtime.ts"; export * from "./types/storages/other.ts"; export * from "./types/storages/keepUploadTask.ts"; +export * from "./types/storages/iyuu.ts"; export interface IRestoreOptions { fields?: TBackupFields[]; // 需要恢复的字段 diff --git a/src/entries/shared/types/storages/iyuu.ts b/src/entries/shared/types/storages/iyuu.ts new file mode 100644 index 000000000..cc8071074 --- /dev/null +++ b/src/entries/shared/types/storages/iyuu.ts @@ -0,0 +1,34 @@ +/** + * IYUU 辅种中心配置存储 + * 注意:token 为敏感凭据,明文存本地 storage,**不进备份**(backup fields 不含 iyuu) + */ + +/** IYUU 站点表缓存条目(轻量内联,避免依赖 @ptd/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; +} + +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[]; + }; +} diff --git a/src/entries/storage.ts b/src/entries/storage.ts index c703de7cc..05db1732a 100644 --- a/src/entries/storage.ts +++ b/src/entries/storage.ts @@ -6,6 +6,7 @@ import { TUserInfoStorageSchema, TSearchResultSnapshotStorageSchema, TKeepUploadTaskStorageSchema, + IIyuuStorageSchema, } from "@/shared/types.ts"; export interface IExtensionStorageSchema { @@ -17,6 +18,7 @@ export interface IExtensionStorageSchema { userInfo: TUserInfoStorageSchema; // 用于存储用户信息 searchResultSnapshot: TSearchResultSnapshotStorageSchema; // 用于存储搜索结果快照 keepUploadTask: TKeepUploadTaskStorageSchema; // 用于存储辅种任务 + iyuu: IIyuuStorageSchema; // 用于存储 IYUU 辅种中心配置 } export type TExtensionStorageKey = keyof IExtensionStorageSchema; diff --git a/src/locales/en.json b/src/locales/en.json index 954a7c6e4..624e6fc50 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -620,12 +620,33 @@ "tab": { "backup": "Backup & Restore", "download": "Download", + "iyuu": "IYUU Reseed", "native-bridge": "Native Bridge", "search-entity": "@:common.search", "social-information": "Social Rating", "ui": "Option UI", "user-info": "UserInfo" }, + "iyuu": { + "tokenTitle": "IYUU Token", + "tokenLabel": "IYUU token", + "tokenHint": "Token from iyuu.cn to access the IYUU reseed center API (stored locally, not included in backups)", + "saveToken": "Save token", + "tokenSaved": "Token saved", + "heldTitle": "Held Sites", + "heldHint": "Auto-derive held IYUU sites from your locally configured sites (local sites ∩ 104 IYUU sites). You can edit the list before reporting.", + "fetchSites": "Fetch site table", + "sitesLoaded": "Loaded IYUU site table ({count} sites)", + "deriveHeld": "Derive held sites", + "deriveDone": "Derived {count} held sites; {unmatched} local sites not in IYUU", + "saveHeld": "Save held list", + "heldSaved": "Held list saved", + "noHeldSites": "No held sites yet — derive or add them first", + "report": "Report and get sid_sha1", + "reportDone": "Reported, sid_sha1 prefix {sha1}…", + "reportDoneWithUnmatched": "Reported (sid_sha1 prefix {sha1}…), sites without IYUU sid: {unmatched}", + "expiresAt": "Expires at" + }, "ui": { "allowDragLink": "Allow dragging links to the sidebar button", "allowExceptionSites": "Allow setting exception sites without sidebar (enable in site settings)", diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json index 3920b629f..ea75b65c3 100644 --- a/src/locales/zh_CN.json +++ b/src/locales/zh_CN.json @@ -620,12 +620,33 @@ "tab": { "backup": "备份恢复", "download": "下载", + "iyuu": "IYUU 辅种", "native-bridge": "原生通信桥", "search-entity": "@:common.search", "social-information": "媒体评分", "ui": "界面UI", "user-info": "用户信息" }, + "iyuu": { + "tokenTitle": "IYUU Token", + "tokenLabel": "IYUU token", + "tokenHint": "在 iyuu.cn 获取的 token,用于调用 IYUU 辅种中心 API(明文存本地,不参与备份)", + "saveToken": "保存 token", + "tokenSaved": "token 已保存", + "heldTitle": "已持有站点", + "heldHint": "自动从「本机已配置的站点」推导持有的 IYUU 站点(本地站点 ∩ IYUU 104 站),可手动增删后汇报。", + "fetchSites": "拉取站点表", + "sitesLoaded": "已加载 IYUU 站点表({count} 站)", + "deriveHeld": "推导已持有站点", + "deriveDone": "已推导 {count} 个持有站点,{unmatched} 个本地站点不在 IYUU 中", + "saveHeld": "保存持有列表", + "heldSaved": "持有列表已保存", + "noHeldSites": "暂无已持有站点,请先推导或手动添加", + "report": "汇报并获取 sid_sha1", + "reportDone": "汇报成功,sid_sha1 前缀 {sha1}…", + "reportDoneWithUnmatched": "汇报成功(sid_sha1 前缀 {sha1}…),以下站点未匹配到 IYUU sid:{unmatched}", + "expiresAt": "过期时间" + }, "ui": { "allowDragLink": "允许拖拽链接到侧边栏按钮", "allowExceptionSites": "允许设置不启用侧边栏的例外站点(启用后需在站点设置中对应关闭)", From 8c2d8ce747c7c545efdd697726e985058c7481a7 Mon Sep 17 00:00:00 2001 From: Rhilip Date: Sun, 13 Sep 2026 20:15:10 +0800 Subject: [PATCH 04/40] =?UTF-8?q?refactor(iyuu):=20IIyuuStorageSchema=20?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=20metadata=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IMetadataPiniaStorageSchema 新增 iyuu? 字段(IIyuuSiteCacheEntry/IIyuuStorageSchema 定义移入 metadata.ts,删除独立 storages/iyuu.ts) - storage.ts 移除独立 iyuu 键;offscreen 读写改为 metadata 子对象(读最新→补丁→整体合并写回,避免覆盖其他字段) - options metadata store state 补 iyuu: {} 默认值(persistWebExt 整体持久化时不丢 offscreen 写入) - 注意:media 备份(BackupFields.metadata)将包含 iyuu.token,与既有 cookie 备份先例一致 --- src/entries/offscreen/utils/iyuu.ts | 20 ++++++++--- src/entries/options/stores/metadata.ts | 3 ++ src/entries/shared/types.ts | 1 - src/entries/shared/types/storages/iyuu.ts | 34 ------------------- src/entries/shared/types/storages/metadata.ts | 34 +++++++++++++++++++ src/entries/storage.ts | 2 -- 6 files changed, 52 insertions(+), 42 deletions(-) delete mode 100644 src/entries/shared/types/storages/iyuu.ts diff --git a/src/entries/offscreen/utils/iyuu.ts b/src/entries/offscreen/utils/iyuu.ts index cc9d2742b..6d17a0d90 100644 --- a/src/entries/offscreen/utils/iyuu.ts +++ b/src/entries/offscreen/utils/iyuu.ts @@ -7,12 +7,14 @@ import axios from "axios"; import { onMessage, sendMessage } from "@/messages.ts"; -import type { IIyuuStorageSchema, IIyuuSiteCacheEntry } from "@/shared/types.ts"; +import type { IIyuuStorageSchema, IIyuuSiteCacheEntry, IMetadataPiniaStorageSchema } from "@/shared/types.ts"; import type { TSiteID } from "@ptd/site"; import { iyuuSiteToLocal } from "@ptd/iyuu"; import type { IYUUReseedHit } from "@ptd/iyuu"; -const STORAGE_KEY = "iyuu" as const; +/** 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"; @@ -21,13 +23,21 @@ 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 sendMessage("getExtStorage", STORAGE_KEY)) as IIyuuStorageSchema) ?? {}; + return (await getMetadata())?.[IYUU_KEY] ?? {}; } +/** 合并写:读最新 metadata → 补丁 iyuu 子对象 → 整体写回(避免覆盖其他字段) */ async function saveConfig(patch: Partial): Promise { - const current = await getConfig(); - await sendMessage("setExtStorage", { key: STORAGE_KEY, value: { ...current, ...patch } }); + const metadata = (await getMetadata()) ?? ({} as IMetadataPiniaStorageSchema); + await sendMessage("setExtStorage", { + key: METADATA_KEY, + value: { ...metadata, [IYUU_KEY]: { ...(metadata[IYUU_KEY] ?? {}), ...patch } }, + }); } /** 拼接 token 请求头;未配置 token 时抛错 */ 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/shared/types.ts b/src/entries/shared/types.ts index 1b91ca6f9..5f5e43d8f 100644 --- a/src/entries/shared/types.ts +++ b/src/entries/shared/types.ts @@ -15,7 +15,6 @@ export * from "./types/storages/metadata.ts"; export * from "./types/storages/runtime.ts"; export * from "./types/storages/other.ts"; export * from "./types/storages/keepUploadTask.ts"; -export * from "./types/storages/iyuu.ts"; export interface IRestoreOptions { fields?: TBackupFields[]; // 需要恢复的字段 diff --git a/src/entries/shared/types/storages/iyuu.ts b/src/entries/shared/types/storages/iyuu.ts deleted file mode 100644 index cc8071074..000000000 --- a/src/entries/shared/types/storages/iyuu.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * IYUU 辅种中心配置存储 - * 注意:token 为敏感凭据,明文存本地 storage,**不进备份**(backup fields 不含 iyuu) - */ - -/** IYUU 站点表缓存条目(轻量内联,避免依赖 @ptd/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; -} - -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[]; - }; -} diff --git a/src/entries/shared/types/storages/metadata.ts b/src/entries/shared/types/storages/metadata.ts index b42a11e9c..43fe7024c 100644 --- a/src/entries/shared/types/storages/metadata.ts +++ b/src/entries/shared/types/storages/metadata.ts @@ -150,4 +150,38 @@ 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[]; + }; } diff --git a/src/entries/storage.ts b/src/entries/storage.ts index 05db1732a..c703de7cc 100644 --- a/src/entries/storage.ts +++ b/src/entries/storage.ts @@ -6,7 +6,6 @@ import { TUserInfoStorageSchema, TSearchResultSnapshotStorageSchema, TKeepUploadTaskStorageSchema, - IIyuuStorageSchema, } from "@/shared/types.ts"; export interface IExtensionStorageSchema { @@ -18,7 +17,6 @@ export interface IExtensionStorageSchema { userInfo: TUserInfoStorageSchema; // 用于存储用户信息 searchResultSnapshot: TSearchResultSnapshotStorageSchema; // 用于存储搜索结果快照 keepUploadTask: TKeepUploadTaskStorageSchema; // 用于存储辅种任务 - iyuu: IIyuuStorageSchema; // 用于存储 IYUU 辅种中心配置 } export type TExtensionStorageKey = keyof IExtensionStorageSchema; From 731d06b29ae990371e1811809987dd679818e57b Mon Sep 17 00:00:00 2001 From: Rhilip Date: Sun, 13 Sep 2026 20:49:11 +0800 Subject: [PATCH 05/40] =?UTF-8?q?feat(iyuu):=20P1=20=E8=BE=85=E7=A7=8D?= =?UTF-8?q?=E5=80=99=E9=80=89=E8=A7=A3=E6=9E=90=E4=B8=8E=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=EF=BC=88keep-upload=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=20+=20=E4=B8=8B=E8=BD=BD=E5=99=A8=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=9F=A5=E5=85=B6=E4=BB=96=E7=AB=99=E8=BE=85=E7=A7=8D?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../background/utils/nativeMessaging.ts | 2 + src/entries/messages.ts | 9 +- src/entries/offscreen/utils/iyuu.ts | 161 +++++++++++- .../views/Overview/KeepUploadTask/Index.vue | 230 +++++++++++++++++- .../Overview/MyClient/TorrentDetailDialog.vue | 209 +++++++++++++++- src/locales/en.json | 32 +++ src/locales/zh_CN.json | 32 +++ src/packages/iyuu/types.ts | 31 +++ 8 files changed, 701 insertions(+), 5 deletions(-) diff --git a/src/entries/background/utils/nativeMessaging.ts b/src/entries/background/utils/nativeMessaging.ts index 29259c832..236373a3b 100644 --- a/src/entries/background/utils/nativeMessaging.ts +++ b/src/entries/background/utils/nativeMessaging.ts @@ -56,6 +56,8 @@ const ALLOWED_METHODS = new Set([ "iyuuReportExisting", "iyuuQueryReseed", "iyuuDeriveHeldSites", + "iyuuResolveHits", + "iyuuScanForReseed", ]); // ── Module-scoped state ────────────────────────────────────────────── diff --git a/src/entries/messages.ts b/src/entries/messages.ts index fe8b61276..5556983d9 100644 --- a/src/entries/messages.ts +++ b/src/entries/messages.ts @@ -27,7 +27,7 @@ import type { TorrentQueueDirection, TorrentSpeedLimit, } from "@ptd/downloader"; -import type { IYUUReseedHit } from "@ptd/iyuu"; +import type { IYUUDownloadCredentials, IYUUReseedCandidate, IYUUReseedHit } from "@ptd/iyuu"; // 可序列化的种子信息,用于辅种检测 export interface ITorrentInfoForVerification { @@ -210,6 +210,13 @@ interface ProtocolMap extends TMessageMap { 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?: Map; + }): IYUUReseedCandidate[]; + /** 批量辅种扫描:下载器已完成种子 hash 分批查 IYUU 并解析候选 */ + iyuuScanForReseed(downloaderId: string): IYUUReseedCandidate[]; // 2.8 Lightweight list queries (for CLI discovery) getSiteList(): Array<{ id: string; name: string; url: string; offline: boolean }>; diff --git a/src/entries/offscreen/utils/iyuu.ts b/src/entries/offscreen/utils/iyuu.ts index 6d17a0d90..efc47de4e 100644 --- a/src/entries/offscreen/utils/iyuu.ts +++ b/src/entries/offscreen/utils/iyuu.ts @@ -9,8 +9,11 @@ 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/iyuu"; -import type { IYUUReseedHit } from "@ptd/iyuu"; +import { iyuuSiteToLocal, resolveTorrentDownload } from "@ptd/iyuu"; +import type { IYUUReseedCandidate, IYUUReseedHit } from "@ptd/iyuu"; + +import { getDownloaderInstance } from "./download.ts"; +import { getSiteInstance } from "./site.ts"; /** IYUU 配置并入 metadata storage 的 iyuu 子对象 */ const METADATA_KEY = "metadata" as const; @@ -198,3 +201,157 @@ async function deriveHeldSids(config: IIyuuStorageSchema): Promise { const derived = await iyuuDeriveHeldSites(); return derived.sidList; } + +// ── 辅种候选解析与批量扫描(P1) ───────────────────────── + +/** 批量查询命中 → 解析为辅种候选(B 路线优先,A 兜底;不可注入项标 error) */ +export async function iyuuResolveHits( + hits: Array<{ sid: number; torrent_id: number; info_hash?: string }>, + sources?: Map, +): Promise { + if (!hits.length) return []; + const sites = await iyuuFetchSites(); + const siteById = new Map(sites.map((s) => [s.id, s])); + const candidates: IYUUReseedCandidate[] = []; + + for (const hit of hits) { + const iyuuSite = siteById.get(hit.sid); + if (!iyuuSite) continue; + + const source = hit.info_hash ? sources?.get(hit.info_hash) : undefined; + const base = { + sourceInfoHash: hit.info_hash ?? "", + sourceName: source?.name, + sourceSavePath: source?.savePath, + sourceSize: source?.size, + torrentId: hit.torrent_id, + }; + + 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; + } + + try { + const siteInstance = await getSiteInstance<"public">(localSiteId); + const result = await resolveTorrentDownload({ + siteInstance, + hit: { sid: hit.sid, torrent_id: hit.torrent_id, info_hash: hit.info_hash ?? "" }, + iyuuSite, + }); + + // B 路线成功:config.baseURL + url 拼全下载链接 + if (result.method === "B" && result.config?.url) { + candidates.push({ + ...base, + siteId: localSiteId, + siteName, + downloadUrl: axios.getUri(result.config), + method: "B", + status: "ready", + }); + continue; + } + + // B 失败走 A 兜底:模板渲染出完整链接且无缺失/动态变量 → 可注入 + if (result.url && result.missing?.length === 0 && result.unsupported?.length === 0) { + candidates.push({ + ...base, + siteId: localSiteId, + siteName, + downloadUrl: result.url, + method: "A", + status: "ready", + error: result.error, // 保留 B 尝试失败原因作备注 + }); + continue; + } + + candidates.push({ + ...base, + siteId: localSiteId, + siteName, + method: result.method, + status: "error", + error: + result.error ?? + ([...(result.missing ?? []), ...(result.unsupported ?? [])].join(", ") || + "下载链接解析失败(详情页需重新获取或模板变量缺失)"), + }); + } catch (e) { + candidates.push({ + ...base, + siteId: localSiteId, + siteName, + status: "error", + error: e instanceof Error ? e.message : String(e), + }); + } + } + + 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: "下载器不存在或未配置" }, + ]; + } + + 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: "该下载器没有已完成的种子", + }, + ]; + } + + 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/views/Overview/KeepUploadTask/Index.vue b/src/entries/options/views/Overview/KeepUploadTask/Index.vue index 86b671eeb..ab98a05c4 100644 --- a/src/entries/options/views/Overview/KeepUploadTask/Index.vue +++ b/src/entries/options/views/Overview/KeepUploadTask/Index.vue @@ -1,7 +1,8 @@