diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82427015..98c9bd5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, refactor/6.5] pull_request: - branches: [main] + branches: [main, refactor/6.5] workflow_dispatch: permissions: @@ -30,9 +30,9 @@ jobs: name: Node 22 full check runs-on: ubuntu-latest outputs: - should_release: ${{ steps.push_release.outputs.should_release || steps.manual_release.outputs.should_release }} - version: ${{ steps.push_release.outputs.version || steps.manual_release.outputs.version }} - tag: ${{ steps.push_release.outputs.tag || steps.manual_release.outputs.tag }} + should_release: ${{ steps.push_release.outputs.should_release }} + version: ${{ steps.push_release.outputs.version }} + tag: ${{ steps.push_release.outputs.tag }} steps: - uses: actions/checkout@v4 with: @@ -42,6 +42,9 @@ jobs: node-version: 22 cache: npm - run: npm ci + - name: Verify 6.5 integration branch contains current main + if: github.ref == 'refs/heads/refactor/6.5' || github.base_ref == 'refactor/6.5' + run: node scripts/check-refactor-sync.mjs - run: npm run check - name: Verify tracked userscript is current run: git diff --exit-code -- JHS.user.js @@ -52,15 +55,39 @@ jobs: id: push_release if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: node scripts/release-contract.mjs --base-ref "${{ github.event.before }}" --github-output "$GITHUB_OUTPUT" - - name: Select recovery release - id: manual_release - if: github.event_name == 'workflow_dispatch' - run: node scripts/release-contract.mjs --force-release --github-output "$GITHUB_OUTPUT" + + browser-smoke: + name: Browser smoke + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - name: Install browser test dependencies + working-directory: tests/browser + run: npm ci + - name: Install ephemeral Chromium + working-directory: tests/browser + run: npx playwright install chromium --with-deps + - name: Run deterministic browser smoke + run: npm run check:browser + - name: Run visual regression gate + run: npm run check:visual + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: output/playwright/ + retention-days: 14 release: name: Publish immutable release - needs: [node20, check] - if: needs.check.outputs.should_release == 'true' + needs: [node20, check, browser-smoke] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check.outputs.should_release == 'true' runs-on: ubuntu-latest permissions: contents: write @@ -76,11 +103,7 @@ jobs: with: node-version: 22 - name: Revalidate release identity and extract notes - if: github.event_name == 'push' run: node scripts/release-contract.mjs --base-ref "${{ github.event.before }}" --notes-file /tmp/release-body.md - - name: Revalidate recovery identity and extract notes - if: github.event_name == 'workflow_dispatch' - run: node scripts/release-contract.mjs --force-release --notes-file /tmp/release-body.md - name: Validate immutable tag and release state id: release_state env: diff --git a/.gitignore b/.gitignore index 2ce027b1..f184d9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules/ dist/ +output/playwright/ outputs/ .learnings/ CLAUDE.md +.zcode/ +*.bundle +.playwright-cli/ +tests/browser/debug.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 9542bc97..fc583750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ ## [Unreleased] -### Fixed +## [6.5.0](../../compare/v6.4.1...v6.5.0) - 2026-09-19 + +### 新增 + +- JavDB 和 JavBus 的搜索、列表页支持跨全部分页批量收藏、批量标记已下载。即使从第 2 页开始,也会回到第 1 页完整扫描当前搜索结果。 + +### 主要调整 + +- 重做 FC2 和 123AV-FC2 详情页。影片信息、剧照、资源、评论和相关清单分区显示,手机端也能正常使用;关闭详情后,未完成的请求不会再修改已经关闭的窗口。 +- 调整列表筛选。“全部”现在会显示收藏、已下载和已看作品,只排除真正被屏蔽的内容;原来四个容易造成重复过滤的显示开关已经移除。 +- 重做热播和 Top250 页面。筛选、排序、状态操作、批量操作和 FC2 详情都能正常使用;榜单排序只影响当前页面,不再改动普通列表的排序设置。 +- 调整新作品中心。手动移除只表示“不再显示这条新作”,不会再产生一条空的影片状态记录;同时修复待处理数量、分页位置和窄屏布局。 +- 设置页、快捷开关和移动端入口现在读取和保存同一份设置。支持实时生效的开关会同步到已打开的界面,保存失败时会恢复原来的状态。 +- 统一详情、磁力、历史记录、新作品和设置弹窗的按钮与布局,补齐手机端触摸尺寸、键盘操作、焦点返回和深色模式样式。 +- WebDAV 备份可以跨安装恢复,但不会把当前安装中的 WebDAV、123 或 115 凭证带到另一台设备;导入时保留目标设备原有凭证。 + +### 问题修复 + +- 修复 FC2 来源和番号识别错误,避免磁力、离线任务或状态操作落到另一部影片;修复 JavBus、搜索结果和榜单中的 FC2 链接偶尔打不开。 +- 修复图片查看器、悬停预览、详情窗口和遮罩的层级问题。重复打开详情时只保留一个查看器,方向键、窗口缩放和分层 Escape 关闭可以正常工作。 +- 修复 123/115 在服务已关闭、授权已变化或窗口已关闭后仍继续提交的问题;阻止连点重复提交,并区分“云端提交失败”和“本地记录保存失败”。 +- 修复设置、列表筛选、自动翻页、翻译、DMM 预览、评论和相关清单中的旧请求回写问题。关闭功能或切换页面后,迟到结果不会把旧内容重新显示出来。 +- 修复热播空白、Top250 封面加载失败、榜单原生搜索结果与真实榜单同时出现,以及周期、类型和年份切换异常的问题。 +- 修复鉴定记录跨页全选只处理当前页、详情窗口被鉴定记录遮挡,以及历史记录和新作品窗口分页被内容挤出可视区域的问题。 +- 修复外部标题、评论关键词和确认提示可被当作 HTML 渲染的问题;修复旧凭证迁移、清空凭证和损坏密文可能覆盖有效数据的问题。 + +### 开发与发布 -- 重新整理 FC2 与 123AV-FC2 详情页:影片信息、剧照、资源、评论和相关清单现在各有固定位置,关闭页面后旧请求不会再改动界面;同时补回来源链接、高清/字幕/日期标记与磁力筛选,123AV 详情也能加载对应的 JavDB 磁力。 -- 修复 FC2 详情来源识别、局部重试、标题右键关闭和移动端资源显示问题;更多磁力来源现在可以收起并复用已加载结果,空截图或空外部资源也不会留下多余区域。 -- FC2 详情重新提供 JavDB 账号的“想看”操作,并与 JHS 本地收藏分开;剧照放大后支持左右切换并默认居中显示。 -- 修复鉴定记录表头全选只处理当前分页的问题;现在会选择当前搜索和筛选条件下的全部记录,支持跨页保留、逐项排除,并让批量提示与实际处理数量一致。 -- 修复列表页“全部”仍被旧状态显示设置二次过滤的问题;收藏、已下载和已看作品会正常出现在“全部”中,硬屏蔽内容仍统一收在“屏蔽项”。 +- 6.5 重做了内部启动、模块依赖、网络请求和功能生命周期,目标是减少插件互相影响、重复监听和关闭后继续运行的问题。 +- 发布检查新增浏览器回归、视觉回归和启动性能门禁,自动检查失败时会阻止发布。 ## [6.4.1](../../compare/v6.4.0...v6.4.1) - 2026-08-23 @@ -626,7 +649,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - 增加 `escapeHtml()` 修复多处 XSS 风险。 - 加密存储敏感凭据。 -[Unreleased]: ../../compare/v6.4.0...HEAD +[Unreleased]: ../../compare/v6.5.0...HEAD [6.2.1]: ../../compare/v6.2.0...v6.2.1 [6.2.0]: ../../compare/v6.1.1...v6.2.0 [6.1.1]: ../../compare/v6.1.0...v6.1.1 diff --git a/JHS.user.js b/JHS.user.js index 27945aa5..3ca739c2 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name JHS // @namespace https://sleazyfork.org/zh-CN/scripts/578503-jhs-ya -// @version 6.4.1 +// @version 6.5.0 // @author JHS Contributors // @description JAV Helper Suite:为 JavDB / JavBus 提供浏览、收藏、筛选、资源检索、数据备份与统计增强。 // @license MIT @@ -62,6 +62,7 @@ // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue +// @grant GM_deleteValue // @grant GM_openInTab // @grant unsafeWindow // @run-at document-idle @@ -69,327 +70,22 @@ // @updateURL https://raw.githubusercontent.com/Teamper/JHS/main/JHS.user.js // ==/UserScript== -(() => { - var __defProp = Object.defineProperty; - var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; - var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); - var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); - - // src/main.js - var JAVDB_HOST_PATTERN = /^(?:[a-z0-9-]+\.)?javdb(?:[a-z0-9-]*)\.com$/i; - var JAVBUS_HOST_MARKERS = ["javbus", "javsee", "seejav"]; - function normalizeLocation(locationLike = window.location) { - if (locationLike instanceof URL) return locationLike; - if ("string" === typeof locationLike) return new URL(locationLike); - return new URL(locationLike.href || `${locationLike.protocol}//${locationLike.hostname}${locationLike.pathname || "/"}${locationLike.search || ""}`); - } - __name(normalizeLocation, "normalizeLocation"); - function detectSite(locationLike = window.location) { - const locationUrl = normalizeLocation(locationLike); - const hostname = locationUrl.hostname.toLowerCase().replace(/\.$/, ""); - const isJavDB = JAVDB_HOST_PATTERN.test(hostname); - const isJavBus = JAVBUS_HOST_MARKERS.some(((marker) => hostname.includes(marker))); - const is123Pan = "123pan.com" === hostname || hostname.endsWith(".123pan.com"); - const isJavTrailers = "javtrailers.com" === hostname || hostname.endsWith(".javtrailers.com"); - const isSubtitleCat = "subtitlecat.com" === hostname || hostname.endsWith(".subtitlecat.com"); - const site = isJavDB ? "javdb" : isJavBus ? "javbus" : is123Pan ? "123pan" : isJavTrailers ? "javtrailers" : isSubtitleCat ? "subtitlecat" : "unknown"; - return { site, hostname, isJavDB, isJavBus, is123Pan, isJavTrailers, isSubtitleCat }; - } - __name(detectSite, "detectSite"); - function isHitShowPage(locationLike = window.location) { - const locationUrl = normalizeLocation(locationLike); - return "/advanced_search" === locationUrl.pathname && "1" === locationUrl.searchParams.get("handlePlayback"); - } - __name(isHitShowPage, "isHitShowPage"); - function isNormalListPage(locationLike = window.location, hasMovieList = null) { - const locationUrl = normalizeLocation(locationLike); - const hasList = null === hasMovieList ? "undefined" != typeof $ && $(".movie-list").length > 0 : Boolean(hasMovieList); - return !isHitShowPage(locationUrl) && (hasList || locationUrl.pathname.includes("advanced_search")); - } - __name(isNormalListPage, "isNormalListPage"); - function isListPage(locationLike = window.location, hasMovieList = null) { - return isHitShowPage(locationLike) || isNormalListPage(locationLike, hasMovieList); - } - __name(isListPage, "isListPage"); - var HOUR = 60 * 60 * 1e3; - var DAY = 24 * HOUR; - var CACHE_TTL = Object.freeze({ magnet: 6 * HOUR, screenshot: 7 * DAY, screenshotNegative: 12 * HOUR, match115: HOUR, externalDetail: DAY }); - var _ProviderError = class _ProviderError extends Error { - constructor(provider, code, message, options = {}) { - super(message, { cause: options.cause }); - this.name = "ProviderError"; - this.provider = provider; - this.code = code; - this.status = options.status || 0; - this.url = options.url || ""; - this.retryable = Boolean(options.retryable); - } - }; - __name(_ProviderError, "ProviderError"); - var ProviderError = _ProviderError; - async function mapLimit(items, concurrency = 4, mapper) { - const results = new Array(items.length); - let cursor = 0; - const worker = /* @__PURE__ */ __name(async () => { - while (cursor < items.length) { - const index = cursor++; - results[index] = await mapper(items[index], index); - } - }, "worker"); - await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), items.length) }, worker)); - return results; - } - __name(mapLimit, "mapLimit"); - function parseNumberSetting(value, fallback, { min = -Infinity, max = Infinity } = {}) { - if (null == value || "" === String(value).trim()) return fallback; - const number = Number(value); - return Number.isFinite(number) && number >= min && number <= max ? number : fallback; - } - __name(parseNumberSetting, "parseNumberSetting"); - function parseTaskTimestamp(value) { - if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.getTime() : null; - if ("number" == typeof value) return Number.isFinite(value) && value >= 0 ? value : null; - if ("string" != typeof value) return null; - const text = value.trim(); - if (!text) return null; - if (/^\d{13,16}$/.test(text)) { - const timestamp2 = Number(text); - return Number.isFinite(timestamp2) ? timestamp2 : null; - } - const match = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/); - if (match) { - const parts = match.slice(1).map(Number), date = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]); - return date.getFullYear() === parts[0] && date.getMonth() === parts[1] - 1 && date.getDate() === parts[2] && date.getHours() === parts[3] && date.getMinutes() === parts[4] && date.getSeconds() === parts[5] ? date.getTime() : null; - } - const timestamp = new Date(text).getTime(); - return Number.isFinite(timestamp) ? timestamp : null; - } - __name(parseTaskTimestamp, "parseTaskTimestamp"); - function shouldSkipStopped(lastPublishTime, ruleHours, now = Date.now()) { - const hours = parseNumberSetting(ruleHours, 0, { min: 0 }), publishedAt = parseTaskTimestamp(lastPublishTime), nowAt = parseTaskTimestamp(now); - return hours > 0 && null != publishedAt && null != nowAt && nowAt >= publishedAt && nowAt - publishedAt >= 36e5 * hours; - } - __name(shouldSkipStopped, "shouldSkipStopped"); - function selectLatestPublishTime(values) { - let latestValue = null, latestAt = -Infinity; - for (const value of values) { - const timestamp = parseTaskTimestamp(value); - null != timestamp && timestamp > latestAt && (latestAt = timestamp, latestValue = value); - } - return latestValue; - } - __name(selectLatestPublishTime, "selectLatestPublishTime"); - function normalizeHttpUrl(value, baseUrl = window.location.href) { - if (!value) return null; - try { - const url = new URL(String(value), baseUrl); - return ["http:", "https:"].includes(url.protocol) ? url.href : null; - } catch (error) { - clog.debug("外部 URL 无效,已忽略", error); - return null; - } - } - __name(normalizeHttpUrl, "normalizeHttpUrl"); - function normalizeBtihHash(value) { - const hash = String(value || "").trim(); - return /^(?:[a-f\d]{40}|[a-z2-7]{32})$/i.test(hash) ? hash.toUpperCase() : null; - } - __name(normalizeBtihHash, "normalizeBtihHash"); - function parseCarNumberText(text) { - const tokens = String(text || "").split(/[\s,,;;]+/).map(((item) => normalizeCarNum(item))).filter(Boolean); - const valid = tokens.filter(((item) => /^(?:FC2-)?[A-Z\d]+(?:-[A-Z\d]+)+$/i.test(item))); - return { recognized: tokens.length, values: [...new Set(valid.map(((item) => item.toUpperCase())))], invalid: tokens.filter(((item) => !valid.includes(item))) }; - } - __name(parseCarNumberText, "parseCarNumberText"); - function buildFallbackCarUrl(carNum, baseUrl = "https://javdb.com") { - return `${baseUrl}/search?q=${encodeURIComponent(carNum)}`; - } - __name(buildFallbackCarUrl, "buildFallbackCarUrl"); - async function safePlay(mediaElement, { context = "视频", notify = false, message = "当前视频源无法播放" } = {}) { - if (!mediaElement || "function" != typeof mediaElement.play) { - clog.warn(`${context}播放失败:媒体元素不可用`); - notify && show.error(message); - return false; - } - try { - await mediaElement.play(); - return true; - } catch (error) { - clog.warn(`${context}播放失败`, error); - const name = error?.name || ""; - notify && !["NotAllowedError", "AbortError"].includes(name) && show.error(message); - return false; - } - } - __name(safePlay, "safePlay"); - var e; - var t; - var n = Object.defineProperty; - var a = /* @__PURE__ */ __name((e2) => { - throw TypeError(e2); - }, "a"); - var i = /* @__PURE__ */ __name((e2, t2, a2) => ((e3, t3, a3) => t3 in e3 ? n(e3, t3, { - enumerable: true, - configurable: true, - writable: true, - value: a3 - }) : e3[t3] = a3)(e2, "symbol" != typeof t2 ? t2 + "" : t2, a2), "i"); - var s = /* @__PURE__ */ __name((e2, t2, n2) => (((e3, t3, n3) => { - t3.has(e3) || a("Cannot " + n3); - })(e2, t2, "access private method"), n2), "s"); - var o = window.location.href; - var siteContext = detectSite(window.location); - var r = siteContext.isJavDB; - var l = siteContext.isJavBus; - var c = o.includes("/search?q") || o.includes("/search/") || o.includes("/users/"); - var d = "filter"; - var h = "favorite"; - var g = "hasDown"; - var p = "hasWatch"; - var m = "屏蔽"; - var u = "已屏蔽"; - var v = "收藏"; - var b = "已收藏"; - var y = "已下载"; - var k = "已观看"; - var C = "no"; - var _ = "yes"; - var T = "javdb"; - var I = "javbus"; - var B = "actor"; - var P = "actress"; - var D = "censored"; - var A = "uncensored"; - var L = [{ - id: "video-mhb", - quality: "dmb_w", - text: "旧视频源-中画质宽版 (404p)", - canSelect: false - }, { - id: "video-mhb", - quality: "sm_s", - text: "旧视频源-低画质 (240p)", - canSelect: false - }, { - id: "video-mhb", - quality: "dm_s", - text: "旧视频源-中画质 (360p)", - canSelect: false - }, { - id: "video-mhb", - quality: "dmb_s", - text: "旧视频源-中画质 (480p)", - canSelect: false - }, { - id: "video-mhb", - quality: "mhb_w", - text: "旧视频源-高画质宽版 (404p)", - canSelect: false - }, { - id: "video-mmb", - quality: "mmb", - text: "中画质 (432p)", - canSelect: true - }, { - id: "video-mhb", - quality: "mhb", - text: "高画质 (576p)", - canSelect: true - }, { - id: "video-hmb", - quality: "hmb", - text: "HD (720p)", - canSelect: true - }, { - id: "video-hhb", - quality: "hhb", - text: "FullHD (1080p)", - canSelect: true - }, { - id: "video-hhbs", - quality: "hhbs", - text: "FullHD (1080p60fps)", - canSelect: true - }, { - id: "video-4k", - quality: "4k", - text: "4K (2160p)", - canSelect: true - }, { - id: "video-4ks", - quality: "4ks", - text: "4K (2160p60fps)", - canSelect: true - }]; - function escapeHtml(e2) { - const t2 = document.createElement("span"); - return t2.textContent = e2, t2.innerHTML; - } - __name(escapeHtml, "escapeHtml"); - var CURRENT_DATA_VERSION = 2; - function normalizeCarNum(value) { - if ("string" != typeof value) return null; - let carNum = value.trim(); - if (!carNum || ["undefined", "null"].includes(carNum.toLowerCase())) return null; - carNum = carNum.normalize("NFKC").replace(/[‐‑‒–—―﹘﹣-]/g, "-").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toUpperCase(); - return carNum ? tryCanonicalizeSimpleCarNum(carNum) : null; - } - __name(normalizeCarNum, "normalizeCarNum"); - var SIMPLE_CAR_PREFIXES = /* @__PURE__ */ new Set(["ABC", "ABP", "ADN", "ATID", "BF", "CAWD", "DLDSS", "DVAJ", "FSDSS", "HEYZO", "HMN", "IPX", "IPZZ", "JUQ", "JUL", "JUX", "MEYD", "MIAA", "MIDE", "MIDV", "MIMK", "MIRD", "NIMA", "PRED", "RBD", "SDDE", "SONE", "SSIS", "SSNI", "STARS", "URE", "VEC", "WAAA", "WANZ", "XVSR"]); - function tryCanonicalizeSimpleCarNum(value) { - const match = String(value || "").match(/^([A-Z]{2,8})(\d{2,7})$/); - return match && SIMPLE_CAR_PREFIXES.has(match[1]) ? `${match[1]}-${match[2]}` : value; - } - __name(tryCanonicalizeSimpleCarNum, "tryCanonicalizeSimpleCarNum"); - function firstValidCarNum(...candidates) { - for (const candidate of candidates) { - const carNum = normalizeCarNum(candidate); - if (carNum) return carNum; - } - return null; - } - __name(firstValidCarNum, "firstValidCarNum"); - function assertPageInfoContract(pageInfo) { - if (!pageInfo || "object" != typeof pageInfo || Array.isArray(pageInfo)) - throw new TypeError("getPageInfo() contract broken: expected object"); - return pageInfo; - } - __name(assertPageInfoContract, "assertPageInfoContract"); - var M = ""; - window.location.href.includes("hideNav=1") && (M = "\n .navbar-default {\n display: none !important;\n }\n body {\n padding-top:0px!important;\n }\n "); - var j = ""; - window.location.href.includes("hideNav=1") && (j = "\n .main-nav,#search-bar-container {\n display: none !important;\n }\n \n html {\n padding-top:0px!important;\n }\n "); - function H(e2) { - if (e2) if (e2.includes(" -`; - } - __name(buildThemeCss, "buildThemeCss"); - async function applyTheme() { - const mode = await storageManager.getSetting("themeMode", "light"); - let resolved = "light"; - if ("dark" === mode) resolved = "dark"; - else if ("auto" === mode) resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - document.documentElement.setAttribute("data-jhs-theme", resolved); - } - __name(applyTheme, "applyTheme"); - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (() => { - storageManager.getSetting("themeMode", "light").then(((e2) => { - "auto" === e2 && applyTheme(); - })); - })); - function buildUiPrimitivesCss() { - return ` +`}d(Oo,"buildThemeCss");function Vt(o="light"){let e="light";o==="dark"?e="dark":o==="auto"&&(e=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),document.documentElement.setAttribute("data-jhs-theme",e)}d(Vt,"applyThemeMode");function zo(o){let e=window.matchMedia("(prefers-color-scheme: dark)");o.listen(e,"change",()=>{let t=globalThis.settingsService?.snapshot?.().themeMode??storageManager.getSettingSync("themeMode","light");t==="auto"&&Vt(t)})}d(zo,"initializeThemeRuntime");function Uo(){return` `; - } - __name(buildUiPrimitivesCss, "buildUiPrimitivesCss"); - function renderStateView(container, { type = "empty", title = "", description = "", actionLabel = "", onAction = null } = {}) { - const root = container?.jquery ? container : $(container), state = $('
').addClass(`jhs-state--${type}`).attr("role", "error" === type ? "alert" : "status"), content = $('
'); - title && content.append($('

').text(title)), description && content.append($('

').text(description)); - if (actionLabel && "function" == typeof onAction) content.append($('').text(actionLabel).on("click", onAction)); - return state.append(content), root.empty().append(state), state; - } - __name(renderStateView, "renderStateView"); - function initializeUiAccessibility() { - const selector = "button.jhs-btn, a.jhs-btn[role='button'], .card-btn, .jhs-icon-btn, [class*='jhs-'] button, [class*='jhs-'] a[role='button']"; - const enhance = /* @__PURE__ */ __name((e2) => { - const t2 = e2.nodeType === Node.ELEMENT_NODE && e2.matches?.(selector) ? [e2] : []; - const n2 = e2.querySelectorAll ? [...e2.querySelectorAll(selector)] : []; - [...t2, ...n2].forEach(((e3) => { - if (e3.hasAttribute("aria-label") || e3.hasAttribute("aria-labelledby") || e3.textContent.trim()) return; - const t3 = e3.getAttribute("title") || e3.getAttribute("data-tip"); - t3 && e3.setAttribute("aria-label", t3); - })); - }, "enhance"); - enhance(document); - const pending = /* @__PURE__ */ new Set(); - let scheduled = false; - const flush = /* @__PURE__ */ __name(() => { - scheduled = false; - const all = [...pending], roots = all.filter(((e2) => !all.some(((t2) => t2 !== e2 && t2.contains?.(e2))))); - pending.clear(), roots.forEach(enhance); - }, "flush"); - new MutationObserver(((records) => { - records.forEach(((record) => record.addedNodes.forEach(((node) => { - node.nodeType === Node.ELEMENT_NODE && pending.add(node); - })))); - pending.size && !scheduled && (scheduled = true, queueMicrotask(flush)); - })).observe(document.documentElement, { - childList: true, - subtree: true - }); - } - __name(initializeUiAccessibility, "initializeUiAccessibility"); - var _JhsSelect = class _JhsSelect { - constructor(select) { - this.source = $(select); - if (!this.source.length || _JhsSelect.instances.has(this.source[0])) return _JhsSelect.instances.get(this.source[0]); - const initiallyHidden = this.source.hasClass("jhs-is-hidden") || "none" === this.source[0].style.display; - this.control = $('
'); - this.trigger = $(''); - this.menu = $(''); - this.source.wrap(this.control), this.control = this.source.parent(), this.control.append(this.trigger, this.menu), this.source.addClass("jhs-select-source-native").attr({ "aria-hidden": "true", tabindex: "-1" }), initiallyHidden && this.control.addClass("jhs-is-hidden"), _JhsSelect.instances.set(this.source[0], this), this.render(), this.bind(), this.refresh(); - } - static enhance(root = document) { - const scope = $(root), selects = scope.is("select.jhs-select-source") ? scope : scope.find("select.jhs-select-source"); - selects.each(((_2, select) => new _JhsSelect(select))); - return selects; - } - static get(select) { - const element = $(select)[0]; - return element ? _JhsSelect.instances.get(element) || new _JhsSelect(element) : null; - } - static setValue(select, value, emit = false) { - const instance = _JhsSelect.get(select); - if (!instance) return; - instance.source.val(value), emit ? instance.emitChange() : instance.refresh(); - } - static refresh(select) { - _JhsSelect.get(select)?.refresh(); - } - static refreshAll(root = document) { - _JhsSelect.enhance(root), $(root).find("select.jhs-select-source").each(((_2, select) => _JhsSelect.refresh(select))); - } - static setVisible(select, visible) { - const instance = _JhsSelect.get(select); - instance?.control.toggleClass("jhs-is-hidden", !visible); - } - static closeAll(except = null) { - $(".jhs-select-control.is-open").each(((_2, control) => { - const source = $(control).children("select.jhs-select-source")[0], instance = source && _JhsSelect.instances.get(source); - instance && instance !== except && instance.close(); - })); - } - render() { - this.menu.empty(); - const appendOption = /* @__PURE__ */ __name((option, target) => { - const button = $(''); - button.attr({ "data-value": option.value, "aria-checked": option.selected ? "true" : "false" }).prop("disabled", option.disabled).text(option.text), target.append(button); - }, "appendOption"); - this.source.children().each(((_2, child) => { - if ("OPTGROUP" === child.tagName) { - const group = $('
').attr("aria-label", child.label), label = $('
').text(child.label); - group.append(label), $(child).children("option").each(((_3, option) => appendOption(option, group))), this.menu.append(group); - } else "OPTION" === child.tagName && appendOption(child, this.menu); - })); - } - bind() { - this.trigger.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(), this.source.prop("disabled") || (this.control.hasClass("is-open") ? this.close() : this.open()); - })).on("keydown", ((event) => { - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(), this.open("ArrowUp" === event.key || "End" === event.key ? "last" : "selected"); - })); - this.menu.on("click", ".jhs-select-option", ((event) => { - event.preventDefault(), this.choose($(event.currentTarget)); - })).on("keydown", ".jhs-select-option", ((event) => { - const items = this.options(), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), this.close(true); - if ("Tab" === event.key) return void this.close(); - if (["Enter", " "].includes(event.key)) return event.preventDefault(), this.choose($(event.currentTarget)); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })); - this.source.on("change.jhsSelect", (() => this.refresh())), $(document).on("click.jhsSelect", ((event) => { - $(event.target).closest(this.control).length || this.close(); - })); - } - options() { - return this.menu.find(".jhs-select-option:not(:disabled)"); - } - open(focus = "selected") { - _JhsSelect.closeAll(this), this.control.addClass("is-open"), this.menu.addClass("is-open"), this.trigger.attr("aria-expanded", "true"); - const items = this.options(), selected = items.filter('[aria-checked="true"]'); - ("last" === focus ? items.last() : selected.length ? selected.first() : items.first()).trigger("focus"); - } - close(focus = false) { - this.control.removeClass("is-open"), this.menu.removeClass("is-open"), this.trigger.attr("aria-expanded", "false"), focus && this.trigger.trigger("focus"); - } - choose(item) { - if (item.prop("disabled")) return; - this.source.val(item.attr("data-value")), this.emitChange(), this.close(true); - } - /** 派发一次真实原生 change,同时兼容宿主与 jQuery 监听器。 */ - emitChange() { - this.source[0]?.dispatchEvent(new Event("change", { - bubbles: true - })); - } - refresh() { - const selected = this.source.find("option:selected").first(), value = this.source.val(); - this.trigger.find(".jhs-select-value").text(selected.text()), this.trigger.prop("disabled", this.source.prop("disabled")), this.menu.find(".jhs-select-option").attr("aria-checked", "false").filter(((_2, item) => $(item).attr("data-value") === String(value ?? ""))).attr("aria-checked", "true"); - } - }; - __name(_JhsSelect, "JhsSelect"); - __publicField(_JhsSelect, "instances", /* @__PURE__ */ new WeakMap()); - var JhsSelect = _JhsSelect; - var N = ` + .jhs-state-actions { container:jhs-state-actions / inline-size; width:100%; min-width:0; flex:1 1 344px; } + .jhs-state-actions__buttons { display:flex; flex-wrap:wrap; gap:var(--jhs-space-2); } + .jhs-state-actions .jhs-state-action { min-width:80px; width:auto; padding:0 var(--jhs-space-3); } + .jhs-state-action[aria-pressed="true"] { border-color:currentColor; box-shadow:inset 0 0 0 1px currentColor; } + .jhs-state-actions[data-jhs-state-count="2"] { width:auto; flex:0 1 auto; container-type:normal; } + .jhs-fc2-toolbar > .jhs-state-actions { grid-column:1 / -1; flex-basis:100%; } + @container jhs-state-actions (max-width:479px) { + .jhs-state-actions[data-jhs-state-count="4"] .jhs-state-actions__buttons { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); } + } + .jhs-detail-btn-row { gap:var(--jhs-space-4); } + :is(.jhs-detail-owned-slot,.jhs-review-panel,.jhs-related-panel) { background:var(--jhs-surface); color:var(--jhs-text); } + .jhs-detail-btn-row > :not(.jhs-state-actions) { gap:var(--jhs-space-2); } + .jhs-btn[aria-busy="true"] { cursor:progress; } + .jhs-table .tabulator-cell .jhs-btn { min-height:var(--jhs-control-height-sm); font-size:var(--jhs-font-size-xs); padding:0 var(--jhs-space-2); } + .jhs-dialog.layui-layer { display:flex; flex-direction:column; box-sizing:border-box; overflow:hidden; border-radius:var(--jhs-radius-md); } + .jhs-dialog > .layui-layer-title { flex:0 0 auto; padding-right:52px; } + .jhs-dialog > .layui-layer-content { min-height:0; min-width:0; flex:1 1 auto; overflow:auto; } + .jhs-dialog[data-jhs-dialog-body="table"] > .layui-layer-content { display:flex; flex-direction:column; overflow:hidden; } + .jhs-dialog[data-jhs-dialog-body="media"] > .layui-layer-content { display:flex; flex-direction:column; overflow:hidden; } + .jhs-dialog > .layui-layer-content > iframe { min-height:0; width:100%; max-width:100%; } + .jhs-dialog > .layui-layer-btn { flex:0 0 auto; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:var(--jhs-space-2); padding:var(--jhs-space-4); } + .jhs-dialog > .layui-layer-btn > a { display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box; min-height:var(--jhs-control-height); margin:0; padding:0 var(--jhs-space-3); border-radius:var(--jhs-radius-sm); font-size:var(--jhs-font-size-sm); } + .jhs-dialog .layui-layer-setwin { top:0; right:0; } + .jhs-dialog .layui-layer-setwin .layui-layer-close { display:block; width:44px; height:44px; margin:0; background-position:center; } + :is(.jhs-ui,.jhs-dialog) label:has(> :is(input[type="checkbox"],.jhs-switch,.mini-switch)) { display:inline-flex; align-items:center; min-height:var(--jhs-control-height); gap:var(--jhs-space-2); } + @media (max-width:767px), (pointer:coarse) { + :is(.jhs-btn,.jhs-filter-btn,.jhs-select-trigger,.jhs-field,.jhs-input,.jhs-select,.jhs-icon-btn,.card-btn), .jhs-table .tabulator-cell .jhs-btn, + .jhs-dialog > .layui-layer-btn > a { min-height:var(--jhs-touch-target); } + :is(.jhs-ui,.jhs-dialog) label:has(> :is(input[type="checkbox"],.jhs-switch,.mini-switch)) { min-height:var(--jhs-touch-target); } + .jhs-dialog .layui-layer-close { width:44px!important; height:44px!important; } + .jhs-dialog .layui-layer-close::before,.jhs-dialog .layui-layer-close::after { top:21px; left:13px; } + } +`}d(Uo,"buildUiPrimitivesCss");function Oe(o,{type:e="empty",title:t="",description:s="",actionLabel:a="",onAction:i=null}={}){let r=o?.jquery?o:$(o),n=$('
').addClass(`jhs-state--${e}`).attr("role",e==="error"?"alert":"status"),l=$('
');return t&&l.append($('

').text(t)),s&&l.append($('

').text(s)),a&&typeof i=="function"&&l.append($('').text(a).on("click",i)),n.append(l),r.empty().append(n),n}d(Oe,"renderStateView");function Fo(o){if(!o||typeof o.observe!="function")throw new TypeError("UI accessibility requires an app LifecycleScope");let e="button.jhs-btn, a.jhs-btn[role='button'], .card-btn, .jhs-icon-btn, [class*='jhs-'] button, [class*='jhs-'] a[role='button']",t=d(r=>{let n=r.nodeType===Node.ELEMENT_NODE&&r.matches?.(e)?[r]:[],l=r.querySelectorAll?[...r.querySelectorAll(e)]:[];[...n,...l].forEach((c=>{if(c.hasAttribute("aria-label")||c.hasAttribute("aria-labelledby")||c.textContent.trim())return;let h=c.getAttribute("title")||c.getAttribute("data-tip");h&&c.setAttribute("aria-label",h)}))},"enhance");t(document);let s=new Set,a=!1,i=d(()=>{a=!1;let r=[...s],n=r.filter((l=>!r.some((c=>c!==l&&c.contains?.(l)))));s.clear(),n.forEach(t)},"flush");return o.observe(document.documentElement,(r=>{r.forEach((n=>n.addedNodes.forEach((l=>{l.nodeType===Node.ELEMENT_NODE&&s.add(l)})))),s.size&&!a&&(a=!0,queueMicrotask(i))}),{childList:!0,subtree:!0})}d(Fo,"initializeUiAccessibility");var re=class re{static _ensureGlobalClickHandler(){re._globalClickBound||(re._globalClickBound=!0,$(document).on("click.jhsSelect",(e=>{$(e.target).closest(".jhs-select-control").length||re.closeAll()})))}constructor(e){if(this.source=$(e),!this.source.length)return;let t=re.instances.get(this.source[0]);if(t)return t;let s=this.source.hasClass("jhs-is-hidden")||this.source[0].style.display==="none";this.control=$('
'),this.trigger=$(''),this.menu=$(''),this.source.wrap(this.control),this.control=this.source.parent(),this.control.append(this.trigger,this.menu),this.source.addClass("jhs-select-source-native").attr({"aria-hidden":"true",tabindex:"-1"}),s&&this.control.addClass("jhs-is-hidden"),re.instances.set(this.source[0],this),this.render(),this.bind(),this.refresh()}static enhance(e=document){let t=$(e),s=t.is("select.jhs-select-source")?t:t.find("select.jhs-select-source");return s.each(((a,i)=>new re(i))),s}static get(e){let t=$(e)[0];return t?re.instances.get(t)||new re(t):null}static setValue(e,t,s=!1){let a=re.get(e);a&&(a.source.val(t),s?a.emitChange():a.refresh())}static refresh(e){re.get(e)?.refresh()}static refreshAll(e=document){re.enhance(e),$(e).find("select.jhs-select-source").each(((t,s)=>re.refresh(s)))}static setVisible(e,t){re.get(e)?.control.toggleClass("jhs-is-hidden",!t)}static closeAll(e=null){$(".jhs-select-control.is-open").each(((t,s)=>{let a=$(s).children("select.jhs-select-source")[0],i=a&&re.instances.get(a);i&&i!==e&&i.close()}))}render(){this.menu.empty();let e=d((t,s)=>{let a=$('');a.attr({"data-value":t.value,"aria-checked":t.selected?"true":"false"}).prop("disabled",t.disabled).text(t.text),s.append(a)},"appendOption");this.source.children().each(((t,s)=>{if(s.tagName==="OPTGROUP"){let a=$('
').attr("aria-label",s.label),i=$('
').text(s.label);a.append(i),$(s).children("option").each(((r,n)=>e(n,a))),this.menu.append(a)}else s.tagName==="OPTION"&&e(s,this.menu)}))}bind(){this.trigger.on("click",(e=>{e.preventDefault(),e.stopPropagation(),this.source.prop("disabled")||(this.control.hasClass("is-open")?this.close():this.open())})).on("keydown",(e=>{["ArrowDown","ArrowUp","Home","End"].includes(e.key)&&(e.preventDefault(),this.open(e.key==="ArrowUp"||e.key==="End"?"last":e.key==="Home"?"first":"selected"))})),this.menu.on("click",".jhs-select-option",(e=>{e.preventDefault(),this.choose($(e.currentTarget))})).on("keydown",".jhs-select-option",(e=>{let t=this.options(),s=t.index(e.currentTarget);if(e.key==="Escape")return e.preventDefault(),e.stopPropagation(),this.close(!0);if(e.key==="Tab")return void this.close(!0);if(["Enter"," "].includes(e.key))return e.preventDefault(),this.choose($(e.currentTarget));if(!["ArrowDown","ArrowUp","Home","End"].includes(e.key))return;e.preventDefault();let a=e.key==="Home"?0:e.key==="End"?t.length-1:e.key==="ArrowDown"?(s+1)%t.length:(s-1+t.length)%t.length;t.eq(a).trigger("focus")})),this.source.on("change.jhsSelect",(()=>this.refresh())),re._ensureGlobalClickHandler()}options(){return this.menu.find(".jhs-select-option:not(:disabled)")}open(e="selected"){re.closeAll(this),this.control.addClass("is-open"),this.menu.addClass("is-open"),this.trigger.attr("aria-expanded","true");let t=this.options(),s=t.filter('[aria-checked="true"]');(e==="last"?t.last():e==="first"?t.first():s.length?s.first():t.first()).trigger("focus")}close(e=!1){this.control.removeClass("is-open"),this.menu.removeClass("is-open"),this.trigger.attr("aria-expanded","false"),e&&this.trigger.trigger("focus")}choose(e){e.prop("disabled")||(this.source.val(e.attr("data-value")),this.emitChange(),this.close(!0))}emitChange(){this.source[0]?.dispatchEvent(new Event("change",{bubbles:!0}))}refresh(){let e=this.source.find("option:selected").first(),t=this.source.val();this.trigger.find(".jhs-select-value").text(e.text()),this.trigger.prop("disabled",this.source.prop("disabled")),this.menu.find(".jhs-select-option").attr("aria-checked","false").filter(((s,a)=>$(a).attr("data-value")===String(t??""))).attr("aria-checked","true")}};d(re,"JhsSelect"),lt(re,"instances",new WeakMap),lt(re,"_globalClickBound",!1);var K=re;function Vd(){return` `; - var E = ` +`}d(Vd,"buildJavBusCss");function Hd(){return` `; - var F = ` +`}d(Hd,"buildJavDbCss");var qd=` `; - H(buildThemeCss()); - l && H(N), r && H(E); - H(F); - H(buildUiPrimitivesCss()); - initializeUiAccessibility(); - function createIndexedMap(items, key) { - return new Map(items.filter(((item) => item && item[key])).map(((item) => [item[key], item]))); - } - __name(createIndexedMap, "createIndexedMap"); - function createStatusMap(items) { - const statusMap = { - [d]: /* @__PURE__ */ new Set(), - [h]: /* @__PURE__ */ new Set(), - [g]: /* @__PURE__ */ new Set(), - [p]: /* @__PURE__ */ new Set() - }; - items.forEach(((item) => { - if (!item?.carNum) return; - const flags = normalizeStateFlags(item.stateFlags); - flags.blocked && statusMap[d].add(item.carNum), flags.favorite && statusMap[h].add(item.carNum), flags.downloaded && statusMap[g].add(item.carNum), flags.watched && statusMap[p].add(item.carNum); - })); - return statusMap; - } - __name(createStatusMap, "createStatusMap"); - function groupDuplicateItems(items, key) { - const counts = /* @__PURE__ */ new Map(); - items.forEach(((item) => { - const value = item && item[key]; - value && counts.set(value, (counts.get(value) || 0) + 1); - })); - return Array.from(counts.entries()).filter(((item) => item[1] > 1)); - } - __name(groupDuplicateItems, "groupDuplicateItems"); - function dedupeByKey(items, key) { - const seen = /* @__PURE__ */ new Map(), list = []; - let changed = false; - for (const item of items) { - const value = item && item[key]; - if (!value) { - list.push(item); - continue; - } - if (seen.has(value)) { - Object.assign(seen.get(value), item), changed = true; - } else seen.set(value, item), list.push(item); - } - return { - list, - changed - }; - } - __name(dedupeByKey, "dedupeByKey"); - var STATE_FLAG_NAMES = Object.freeze(["favorite", "downloaded", "watched", "blocked"]); - var LEGACY_STATUS_TO_FLAG = Object.freeze({ [h]: "favorite", [g]: "downloaded", [p]: "watched", [d]: "blocked" }); - function createEmptyStateFlags() { - return { favorite: false, downloaded: false, watched: false, blocked: false }; - } - __name(createEmptyStateFlags, "createEmptyStateFlags"); - function stateFlagsFromLegacyStatus(status) { - const flags = createEmptyStateFlags(), flag = LEGACY_STATUS_TO_FLAG[status]; - return flag && (flags[flag] = true), flags; - } - __name(stateFlagsFromLegacyStatus, "stateFlagsFromLegacyStatus"); - function normalizeStateFlags(flags) { - const normalized = createEmptyStateFlags(); - return STATE_FLAG_NAMES.forEach(((name) => normalized[name] = true === flags?.[name])), normalized; - } - __name(normalizeStateFlags, "normalizeStateFlags"); - function projectLegacyStatus(flags) { - const normalized = normalizeStateFlags(flags); - return normalized.blocked ? d : normalized.watched ? p : normalized.downloaded ? g : normalized.favorite ? h : ""; - } - __name(projectLegacyStatus, "projectLegacyStatus"); - function syncLegacyStatus(record) { - return record.stateFlags = normalizeStateFlags(record.stateFlags), record.status = projectLegacyStatus(record.stateFlags), record; - } - __name(syncLegacyStatus, "syncLegacyStatus"); - function hasAnyState(flags) { - const normalized = normalizeStateFlags(flags); - return STATE_FLAG_NAMES.some(((name) => normalized[name])); - } - __name(hasAnyState, "hasAnyState"); - function legacyActionToFlag(actionType) { - return LEGACY_STATUS_TO_FLAG[actionType] || null; - } - __name(legacyActionToFlag, "legacyActionToFlag"); - function mergeCanonicalCarRecords(records) { - const groups = /* @__PURE__ */ new Map(), collisions = [], unknownStatuses = []; - records.filter(Boolean).forEach(((record) => { - const original = record.carNum, carNum = normalizeCarNum(original); - if (!carNum) return; - const item = { ...record, carNum, stateFlags: record.stateFlags ? normalizeStateFlags(record.stateFlags) : stateFlagsFromLegacyStatus(record.status) }; - record.stateFlags || !record.status || LEGACY_STATUS_TO_FLAG[record.status] || unknownStatuses.push({ carNum, status: record.status }); - const group = groups.get(carNum) || []; - group.push({ original, item }), groups.set(carNum, group); - })); - const list = []; - for (const [carNum, group] of groups) { - const sorted = group.map(((entry) => entry.item)).sort(((left, right) => String(left.updateDate || "").localeCompare(String(right.updateDate || "")))); - const merged = {}, flags = createEmptyStateFlags(); - sorted.forEach(((record) => { - Object.entries(record).forEach((([key, value]) => null != value && "" !== value && "stateFlags" !== key && "status" !== key && (merged[key] = value))); - STATE_FLAG_NAMES.forEach(((name) => flags[name] = flags[name] || record.stateFlags[name])); - })); - const createDates = sorted.map(((item) => item.createDate)).filter(Boolean).sort(), updateDates = sorted.map(((item) => item.updateDate)).filter(Boolean).sort(); - createDates.length && (merged.createDate = createDates[0]), updateDates.length && (merged.updateDate = updateDates.at(-1)); - merged.carNum = carNum, merged.stateFlags = flags, syncLegacyStatus(merged), list.push(merged); - const originals = [...new Set(group.map(((entry) => entry.original)).filter(Boolean))]; - originals.length > 1 && collisions.push({ carNum, originals, count: group.length }); - } - return { list, collisions, unknownStatuses }; - } - __name(mergeCanonicalCarRecords, "mergeCanonicalCarRecords"); - e = /* @__PURE__ */ new WeakSet(), t = /* @__PURE__ */ __name(async function(e2, t2, n2) { - let a2; - if (Array.isArray(e2)) a2 = [...e2]; - else { - if (a2 = await this.forage.getItem(t2) || [], a2.includes(e2)) { - const t3 = `${e2} ${n2}已存在`; - throw show.error(t3), new Error(t3); - } - a2.push(e2); - } - return await this._setItemAndInvalidate(t2, a2), a2; - }, "t"); - var _StorageManager = class _StorageManager { - constructor() { - var t2, s2, o2; - if (t2 = this, (s2 = e).has(t2) ? a("Cannot add the same private member more than once") : s2 instanceof WeakSet ? s2.add(t2) : s2.set(t2, o2), i(this, "car_list_key", "car_list"), i(this, "filter_keyword_title_key", "filter_keyword_title"), i(this, "filter_keyword_review_key", "filter_keyword_review"), i(this, "setting_key", "setting"), i(this, "blacklist_key", "blacklist"), i(this, "blacklist_car_list_key", "blacklist_car_list"), i(this, "third_party_cache_key", "third_party_ttl_cache"), i(this, "favorite_actresses_key", "favorite_actresses"), i(this, "highlighted_tags_key", "highlighted_tags"), i(this, "_actressLock", Promise.resolve()), i(this, "forage", localforage.createInstance({ - driver: localforage.INDEXEDDB, - name: "JAV-JHS", - version: 1, - storeName: "appData" - })), i(this, "cacheCarList", null), i(this, "cacheBlacklist", null), i(this, "cacheTitleFilterKeyword", null), i(this, "cacheFavoriteActresses", null), i(this, "cache_filter_actor_actress_car_list", null), i(this, "cacheSettingObj", null), i(this, "cacheCarMap", null), i(this, "cacheStatusMap", null), i(this, "cacheBlacklistMap", null), i(this, "_pendingReads", /* @__PURE__ */ new Map()), i(this, "_cacheGenerations", /* @__PURE__ */ new Map()), i(this, "_cacheStats", { hits: 0, misses: 0 }), _StorageManager.instance) throw new Error("StorageManager已被实例化过了!"); - _StorageManager.instance = this; - } - async getDataVersion() { - return await this.forage.getItem("data_version") || 0; - } - async setDataVersion(e2) { - await this.forage.setItem("data_version", e2); - } - _getCacheGeneration(e2) { - return this._cacheGenerations.get(e2) || 0; - } - _invalidateRead(e2) { - this._cacheGenerations.set(e2, this._getCacheGeneration(e2) + 1); - this._pendingReads.delete(e2); - } - /** 合并同一存储键的并发读取,并阻止失效前的旧读取回填缓存。 */ - async _readCached(e2, t2, n2) { - if (null !== this[e2]) return this[e2]; - const a2 = this._pendingReads.get(t2); - if (a2) return a2; - const i2 = this._getCacheGeneration(t2), s2 = this.forage.getItem(t2).then((a3) => { - const s3 = a3 || n2; - return i2 === this._getCacheGeneration(t2) && null === this[e2] && (this[e2] = s3), s3; - }).finally(() => { - this._pendingReads.get(t2) === s2 && this._pendingReads.delete(t2); - }); - return this._pendingReads.set(t2, s2), s2; - } - _invalidateCache(e2 = null) { - const t2 = [this.car_list_key, this.blacklist_key, this.filter_keyword_title_key, this.favorite_actresses_key, this.blacklist_car_list_key, this.setting_key]; - (null === e2 ? t2 : t2.includes(e2) ? [e2] : []).forEach(((e3) => this._invalidateRead(e3))); - (!e2 || e2 === this.car_list_key) && (this.cacheCarList = null, this.cacheCarMap = null, this.cacheStatusMap = null); - (!e2 || e2 === this.blacklist_key) && (this.cacheBlacklist = null, this.cacheBlacklistMap = null); - (!e2 || e2 === this.filter_keyword_title_key) && (this.cacheTitleFilterKeyword = null); - (!e2 || e2 === this.favorite_actresses_key) && (this.cacheFavoriteActresses = null); - (!e2 || e2 === this.blacklist_car_list_key) && (this.cache_filter_actor_actress_car_list = null); - (!e2 || e2 === this.setting_key) && (this.cacheSettingObj = null); - } - async _setItemAndInvalidate(e2, t2) { - await this.forage.setItem(e2, t2); - this._invalidateCache(e2); - } - async withActressLock(fn) { - let release; - const lock = new Promise((r2) => release = r2); - const prev = this._actressLock; - this._actressLock = this._actressLock.then(() => lock); - await prev; - try { - return await fn(); - } finally { - release(); - } - } - async _rawUpdateFavoriteActress(e2) { - const t2 = await this.getFavoriteActressList(); - const { starId: n2, name: a2, allName: i2, avatar: s2, lastCheckTime: o2, newVideoList: r2, lastPublishTime: l2, actressType: c2, remark: d2 } = e2; - if (!n2) throw new Error("缺失starId"); - let h2 = t2.find(((e3) => e3.starId === n2)); - if (!h2) return clog.error("未找到演员信息", n2, a2), false; - a2 && (h2.name = a2), i2 && (h2.allName = i2), s2 && (h2.avatar = s2), null != c2 && (h2.actressType = c2), o2 && (h2.lastCheckTime = o2), r2 && (h2.newVideoList = r2), l2 && (h2.lastPublishTime = l2), d2 && (h2.remark = d2), h2.updateDate = utils.getNowStr(), await this._setItemAndInvalidate(this.favorite_actresses_key, t2); - return true; - } - async getCarList() { - return this._readCached("cacheCarList", this.car_list_key, []); - } - async getCarMap() { - if (null === this.cacheCarMap) { - const e2 = await this.getCarList(); - this.cacheCarMap = createIndexedMap(e2, "carNum"); - } - return this.cacheCarMap; - } - async getStatusMap() { - if (null === this.cacheStatusMap) { - const e2 = await this.getCarList(); - this.cacheStatusMap = createStatusMap(e2); - } - return this.cacheStatusMap; - } - async getCar(e2) { - return (await this.getCarMap()).get(normalizeCarNum(e2)); - } - _saveSingleCar(e2, t2) { - let { carNum: n2, url: a2, names: i2, actionType: s2, publishTime: o2, starId: r2 } = e2; - if (!n2) throw show.error("番号为空!"), new Error("番号为空!"); - if (!a2) throw show.error("url为空!"), new Error("url为空!"); - a2.includes("http") || (a2 = window.location.origin + a2), i2 && (i2 = i2.trim()); - let l2 = t2.find(((e3) => e3.carNum === n2)); - if (l2) i2 && (l2.names = i2), a2 && (l2.url = a2), o2 && (l2.publishTime = o2), l2.updateDate = utils.getNowStr(); - else { - let e3 = utils.getNowStr(); - l2 = { - carNum: n2, - url: a2, - names: i2, - status: "", - createDate: e3, - updateDate: e3, - publishTime: o2 - }, r2 && (l2.starId = r2), t2.push(l2); - } - switch (s2) { - case d: - if (l2.status === d) { - const e4 = `${n2} 已在屏蔽列表中`; - throw show.error(e4), new Error(e4); - } - l2.status = d; - break; - case h: - if (l2.status === h) { - const e4 = `${n2} 已在收藏列表中`; - throw show.error(e4), new Error(e4); - } - l2.status = h; - break; - case g: - if (l2.status === g) { - const e4 = `${n2} 已标记为已下载`; - throw show.error(e4), new Error(e4); - } - l2.status = g; - break; - case p: - if (l2.status === p) { - const e4 = `${n2} 已标记为已观看`; - throw show.error(e4), new Error(e4); - } - l2.status = p; - break; - default: - const e3 = "actionType错误, 请联系作者更正: " + s2; - throw show.error(e3), new Error(e3); - } - } - async saveCar(e2) { - const carNum = normalizeCarNum(e2.carNum), flag = legacyActionToFlag(e2.actionType); - if (!carNum) throw show.error("番号为空!"), new Error("番号为空!"); - if (!e2.url) throw show.error("url为空!"), new Error("url为空!"); - if (!flag) throw show.error("actionType错误, 请联系作者更正: " + e2.actionType), new Error("actionType错误, 请联系作者更正: " + e2.actionType); - const existing = await this.getCar(carNum); - if (normalizeStateFlags(existing?.stateFlags)[flag]) { - const messages = { blocked: "已在屏蔽列表中", favorite: "已在收藏列表中", downloaded: "已标记为已下载", watched: "已标记为已观看" }, message = `${carNum} ${messages[flag]}`; - throw show.error(message), new Error(message); - } - return stateService.patch(carNum, { [flag]: true }, { type: "legacy-save", record: { ...e2, carNum } }); - } - async updateCarInfo(e2) { - let { carNum: t2, url: n2, names: a2, actionType: i2, publishTime: s2, remark: o2 } = e2; - if (!t2) throw show.error("番号为空!"), new Error("番号为空!"); - if (!n2) throw show.error("url为空!"), new Error("url为空!"); - a2 && (a2 = a2.trim()); - t2 = normalizeCarNum(t2); - const l2 = await this.getCar(t2); - if (!l2) { - const e3 = "数据不存在: " + t2; - throw show.error(e3), new Error(e3); - } - const flag = legacyActionToFlag(i2); - if (!flag) { - const e3 = "actionType错误, 请联系作者更正: " + i2; - throw show.error(e3), new Error(e3); - } - return stateService.patch(t2, { [flag]: true }, { type: "legacy-update", record: { carNum: t2, names: a2, url: n2, remark: o2, publishTime: s2 } }); - } - async saveCarList(e2) { - if (!e2 || !Array.isArray(e2) || 0 === e2.length) throw show.error("记录列表为空!"), new Error("记录列表为空!"); - const existing = await this.getCarMap(), seen = /* @__PURE__ */ new Set(), groups = /* @__PURE__ */ new Map(); - for (const item of e2) { - const carNum = normalizeCarNum(item.carNum), flag = legacyActionToFlag(item.actionType); - if (!carNum) throw show.error("番号为空!"), new Error("番号为空!"); - if (!item.url) throw show.error("url为空!"), new Error("url为空!"); - if (!flag) throw new Error("actionType错误, 请联系作者更正: " + item.actionType); - const duplicateKey = `${carNum}:${flag}`; - if (seen.has(duplicateKey) || normalizeStateFlags(existing.get(carNum)?.stateFlags)[flag]) throw new Error(`${carNum} 状态已存在`); - seen.add(duplicateKey); - const group = groups.get(flag) || []; - group.push({ ...item, carNum }), groups.set(flag, group); - } - for (const [flag, records] of groups) await stateService.patch(records.map(((item) => item.carNum)), { [flag]: true }, { type: "legacy-batch-save", records }); - } - async removeNewVideoList(e2) { - return this.withActressLock(async () => { - const targets = new Set((Array.isArray(e2) ? e2 : [e2]).map(normalizeCarNum).filter(Boolean)); - const t2 = await this.getFavoriteActressList(), changed = /* @__PURE__ */ new Set(); - const a2 = t2.map(((t3) => { - if (!t3.newVideoList || !Array.isArray(t3.newVideoList)) return t3; - const a3 = t3.newVideoList.filter(((t4) => { - const a4 = normalizeCarNum("string" == typeof t4 ? t4 : t4.carNum), i2 = targets.has(a4); - return i2 && (clog.log("移除关联女优新作品", t4.name, a4), changed.add(a4)), !i2; - })); - return { ...t3, newVideoList: a3 }; - })); - changed.size && await this._setItemAndInvalidate(this.favorite_actresses_key, a2); - return { changed: [...changed] }; - }); - } - async removeCar(e2) { - const result = await stateService.remove(e2); - return result.changed.length ? true : (show.error(`${e2} 不存在`), false); - } - async batchRemoveCars(e2) { - const result = await stateService.remove(e2); - return result.changed.length || false; - } - async getBlacklist() { - return this._readCached("cacheBlacklist", this.blacklist_key, []); - } - async getBlacklistMap() { - if (null === this.cacheBlacklistMap) { - const e2 = await this.getBlacklist(); - this.cacheBlacklistMap = createIndexedMap(e2, "starId"); - } - return this.cacheBlacklistMap; - } - async addBlacklistItem(e2) { - let { starId: t2, name: n2, allName: a2, role: i2, movieType: s2, url: o2 } = e2; - if (!t2) throw new Error("缺失starId"); - if (!n2) throw new Error("缺失name"); - if (!i2) throw new Error("缺失role"); - const r2 = await this.getBlacklist(), l2 = r2.find(((e3) => e3.starId === t2)); - if (l2) l2.url = o2, l2.role = i2, l2.movieType = s2, clog.log("更新黑名单演员信息", l2); - else { - const e3 = { - starId: t2, - name: n2, - allName: a2 || [n2], - createTime: utils.getNowStr(), - role: i2, - movieType: s2, - url: o2 - }; - r2.push(e3), clog.log("增加黑名单演员信息", e3); - } - await this._setItemAndInvalidate(this.blacklist_key, r2); - } - async updateBlacklistItem(e2) { - if (!e2 || !e2.starId) throw new Error("参数不全"); - const t2 = await this.getBlacklist(), n2 = t2.find(((t3) => t3.starId === e2.starId)); - if (!n2) throw new Error(`未找到黑名单演员信息:${e2.name} ${e2.starId}`); - e2.checkTime && (n2.checkTime = e2.checkTime), e2.lastPublishTime && (n2.lastPublishTime = e2.lastPublishTime), await this._setItemAndInvalidate(this.blacklist_key, t2); - } - async deleteBlacklistItem(e2) { - const t2 = await this.getBlacklist(), n2 = t2.filter(((t3) => t3.starId !== e2)); - t2.length !== n2.length && await this._setItemAndInvalidate(this.blacklist_key, n2); - } - async getBlacklistCarList() { - return this._readCached("cache_filter_actor_actress_car_list", this.blacklist_car_list_key, []); - } - async batchSaveBlacklistCarList(e2) { - const t2 = await this.getBlacklistCarList(), n2 = JSON.parse(JSON.stringify(t2)); - let a2 = false, i2 = []; - for (const s2 of e2) { - n2.find(((e3) => e3.carNum === s2.carNum)) || (this._saveSingleCar(s2, n2), clog.log(`屏蔽演员番号: ${escapeHtml(s2.names)} ${escapeHtml(s2.carNum)}`), a2 = true, i2.push(s2.carNum)); - } - if (a2) { - await this._setItemAndInvalidate(this.blacklist_car_list_key, n2); - const result = await this.removeNewVideoList(i2); - result.changed.length && await window.jhsEventBus?.emit?.("new-video-changed", { - reason: "blacklist-car-removed", - carNums: result.changed - }); - await window.cleanCache_filter_actor_actress_car_list(); - } - return { changed: i2.map(normalizeCarNum).filter(Boolean) }; - } - async removeBlacklistCarList(e2) { - const t2 = await this.getBlacklistCarList(), n2 = t2.filter(((t3) => t3.starId !== e2)); - n2.length !== t2.length && (await this._setItemAndInvalidate(this.blacklist_car_list_key, n2), window.cleanCache_filter_actor_actress_car_list()); - } - async getFavoriteActressList() { - return this._readCached("cacheFavoriteActresses", this.favorite_actresses_key, []); - } - async addFavoriteActressList(e2) { - return this.withActressLock(async () => { - const t2 = await this.getFavoriteActressList(); - let n2 = 0; - for (const a2 of e2) { - let { starId: e3, name: i2, allName: s2, avatar: o2, lastCheckTime: r2, lastPublishTime: l2, actressType: c2 } = a2; - if (!e3) throw new Error("缺失starId"); - if (!i2) throw new Error("缺失name"); - s2 || (s2 = [i2]); - const d2 = "(無碼)"; - if (!c2) { - c2 = i2.includes(d2) || s2.some(((e4) => e4.includes(d2))) ? A : D; - } - i2 = i2.replace(d2, ""), s2 = s2.map(((e4) => e4.replace(d2, ""))); - let h2 = t2.find(((t3) => t3.starId === e3)); - if (h2) { - h2.avatar && h2.avatar.includes("https") || o2 && (clog.log(o2), h2.avatar = o2, clog.log(`补全女优头像: ${escapeHtml(i2)}`), n2++), !h2.actressType && c2 && (h2.actressType = c2, clog.log(`补全女优类别: ${escapeHtml(i2)} ${escapeHtml(c2)}`), n2++), h2.name.includes(d2) && (h2.name = i2, h2.allName = s2, clog.log(`更正女优名字: ${escapeHtml(i2)} ${escapeHtml(s2)}`), n2++); - continue; - } - const g2 = utils.getNowStr(); - t2.push({ - starId: e3, - name: i2, - allName: s2, - avatar: o2, - lastCheckTime: r2, - lastPublishTime: l2, - createDate: g2, - updateDate: g2, - actressType: c2 - }), clog.log(`同步JavDB已收藏的演员: ${escapeHtml(i2)}`), n2++; - } - return n2 > 0 ? await this._setItemAndInvalidate(this.favorite_actresses_key, t2) : clog.log("信息已记录, 无需要进行同步收藏的演员"), n2; - }); - } - async removeFavoriteActress(e2) { - return this.withActressLock(async () => { - const t2 = await this.getFavoriteActressList(), n2 = t2.length, a2 = t2.filter(((t3) => t3.starId !== e2)); - return a2.length === n2 ? (clog.error(`移除演员失败, ${e2} 不存在`), false) : (await this._setItemAndInvalidate(this.favorite_actresses_key, a2), true); - }); - } - async updateFavoriteActress(e2) { - return this.withActressLock(() => this._rawUpdateFavoriteActress(e2)); - } - async getHighlightedTags() { - return await this.forage.getItem(this.highlighted_tags_key) || []; - } - async setHighlightedTags(e2) { - return await this.forage.setItem(this.highlighted_tags_key, e2); - } - async saveTitleFilterKeyword(n2) { - if (await s(this, e, t).call(this, n2, this.filter_keyword_title_key, "标题关键词"), Array.isArray(n2)) return null; - return this.withActressLock(async () => { - const a2 = await this.getFavoriteActressList(); - let i2 = false; - const o2 = a2.map(((e2) => { - if (!e2.newVideoList || !Array.isArray(e2.newVideoList)) return e2; - const t2 = e2.newVideoList.filter(((t3) => { - const s2 = "string" == typeof t3 ? t3 : t3.carNum, a3 = s2.startsWith(n2); - return a3 && (clog.log("移除关联女优新作品", e2.name, s2), i2 = true), !a3; - })); - return { - ...e2, - newVideoList: t2 - }; - })); - i2 && await this._setItemAndInvalidate(this.favorite_actresses_key, o2); - }); - } - async getTitleFilterKeyword() { - return this._readCached("cacheTitleFilterKeyword", this.filter_keyword_title_key, []); - } - async getReviewFilterKeywordList() { - return await this.forage.getItem(this.filter_keyword_review_key) || []; - } - async saveReviewFilterKeyword(n2) { - return s(this, e, t).call(this, n2, this.filter_keyword_review_key, "评论关键词"); - } - async getSetting(e2 = null, t2) { - let n2 = await this._readCached("cacheSettingObj", this.setting_key, {}); - if (null === e2) return n2; - if (!Object.prototype.hasOwnProperty.call(n2, e2)) return t2; - const a2 = n2[e2]; - return "true" === a2 || "false" === a2 ? "true" === a2.toLowerCase() : "string" != typeof a2 || "" === a2.trim() || isNaN(Number(a2)) ? a2 : Number(a2); - } - getSettingSync(e2, t2) { - if (!this.cacheSettingObj) return t2; - if (!Object.prototype.hasOwnProperty.call(this.cacheSettingObj, e2)) return t2; - const n2 = this.cacheSettingObj[e2]; - return "true" === n2 || "false" === n2 ? "true" === n2.toLowerCase() : "string" != typeof n2 || "" === n2.trim() || isNaN(Number(n2)) ? n2 : Number(n2); - } - async saveSetting(e2) { - e2 ? (await this._setItemAndInvalidate(this.setting_key, e2), await window.clean_cacheSettingObj()) : show.error("设置对象为空"); - } - async saveSettingItem(e2, t2) { - if (!e2) return void show.error("key 不能为空"); - await navigator.locks.request("jhs_setting_lock", async () => { - let n2 = await this.getSetting(); - n2[e2] = t2, await this.saveSetting(n2); - }); - } - async importData(e2) { - validatePortableData(e2); - await hasPortableUserData(this) && await this.createSnapshot("导入前自动备份", "auto-import"); - const validKeys = /* @__PURE__ */ new Set([...PORTABLE_DATA_KEYS, "data_version"]), writes = []; - for (const key in e2) { - if (!validKeys.has(key)) { - clog.warn(`[导入] 跳过未知数据键: ${key}`); - continue; - } - writes.push("data_version" === key ? this.setDataVersion(e2[key]) : this._setItemAndInvalidate(key, e2[key])); - } - await Promise.all(writes), this._invalidateCache(), await runDataMigrations(this), await window.stateService?.recoverPendingTransaction(); - } - async exportPortableData() { - const data = { data_version: await this.getDataVersion() }; - for (const key of PORTABLE_DATA_KEYS) { - const value = await this.forage.getItem(key); - null != value && (data[key] = value); - } - return data; - } - async exportData() { - const data = await this.exportPortableData(); - if (Object.keys(data).length <= 1) throw new Error("没有可导出的数据"); - return data; - } - async getThirdPartyCache() { - return await this.forage.getItem(this.third_party_cache_key) || {}; - } - async setThirdPartyCache(e2) { - await this.forage.setItem(this.third_party_cache_key, e2 || {}); - } - async clearThirdPartyCache() { - await this.forage.removeItem(this.third_party_cache_key); - } - async deleteCachedRequest(key) { - const cache = await this.getThirdPartyCache(); - Object.prototype.hasOwnProperty.call(cache, key) && (delete cache[key], await this.setThirdPartyCache(cache)); - } - async cachedRequest(e2, t2, n2) { - const a2 = Date.now(), i2 = await this.getThirdPartyCache(), s2 = i2[e2]; - if (s2 && s2.time && a2 - s2.time < (s2.ttl || t2)) return this._cacheStats.hits++, s2.data; - this._cacheStats.misses++; - const loaded = await n2(), customTtl = loaded && "object" === typeof loaded && "__jhsCacheTtl" in loaded ? loaded.__jhsCacheTtl : t2; - const o2 = loaded && "object" === typeof loaded && "__jhsCacheTtl" in loaded ? loaded.data : loaded; - if (void 0 === o2 || null === o2) return o2; - return i2[e2] = { - time: a2, - ttl: customTtl, - data: o2 - }, await this.setThirdPartyCache(i2), o2; - } - getCacheHitStats() { - const e2 = this._cacheStats.hits + this._cacheStats.misses; - return { - hits: this._cacheStats.hits, - misses: this._cacheStats.misses, - total: e2, - rate: e2 > 0 ? (this._cacheStats.hits / e2 * 100).toFixed(1) + "%" : "N/A" - }; - } - _groupDuplicateItems(e2, t2) { - return groupDuplicateItems(e2, t2); - } - _dedupeByKey(e2, t2) { - return dedupeByKey(e2, t2); - } - async inspectDataHealth() { - const [e2, t2, n2, a2] = await Promise.all([this.getCarList(), this.getFavoriteActressList(), this.getBlacklist(), this.getBlacklistCarList()]), i2 = await this.getCarMap(), s2 = { - checkedAt: utils.getNowStr(), - totals: { - carList: e2.length, - favoriteActresses: t2.length, - blacklist: n2.length, - blacklistCarList: a2.length - }, - fixable: [], - readonly: [] - }, o2 = /* @__PURE__ */ __name((e3, t3, n3) => s2.fixable.push({ - type: e3, - message: t3, - count: n3 - }), "o"), r2 = /* @__PURE__ */ __name((e3, t3, n3) => s2.readonly.push({ - type: e3, - message: t3, - count: n3 - }), "r"); - const l2 = this._groupDuplicateItems(e2, "carNum"), c2 = this._groupDuplicateItems(t2, "starId"), d2 = this._groupDuplicateItems(n2, "starId"); - l2.length && o2("duplicate-car", "重复番号记录", l2.length), c2.length && o2("duplicate-actress", "重复收藏演员", c2.length), d2.length && o2("duplicate-blacklist", "重复黑名单演员", d2.length); - const h2 = e2.filter(((e3) => e3 && e3.actress)).length, g2 = a2.filter(((e3) => e3 && e3.actress)).length, p2 = t2.filter(((e3) => e3 && Object.prototype.hasOwnProperty.call(e3, "dbId"))).length, m2 = n2.filter(((e3) => e3 && ("key" in e3 || "recordTime" in e3 || "isActor" in e3))).length; - h2 + g2 + p2 + m2 > 0 && o2("legacy-fields", "旧字段残留", h2 + g2 + p2 + m2); - const u2 = t2.filter(((e3) => e3 && e3.name && !Array.isArray(e3.allName))).length + n2.filter(((e3) => e3 && e3.name && !Array.isArray(e3.allName))).length; - u2 && o2("invalid-all-name", "演员别名不是数组", u2); - let f = 0; - t2.forEach(((e3) => { - Array.isArray(e3?.newVideoList) && (f += e3.newVideoList.filter(((e4) => { - const t3 = "string" == typeof e4 ? e4 : e4.carNum; - return i2.has(t3); - })).length); - })), f && o2("stored-new-video", "新作品列表中已有鉴定记录", f); - const v2 = e2.filter(((e3) => e3 && e3.carNum && !e3.url)).length; - v2 && r2("missing-url", "番号记录缺失 url,需要人工确认来源", v2); - const b2 = t2.filter(((e3) => e3 && !e3.starId)).length + n2.filter(((e3) => e3 && !e3.starId)).length; - b2 && r2("missing-star-id", "演员缺失 starId,需要人工确认身份", b2); - const w = new Set(n2.map(((e3) => e3 && e3.starId)).filter(Boolean)), y2 = a2.filter(((e3) => e3 && e3.starId && !w.has(e3.starId))).length; - return y2 && r2("orphan-blacklist-car", "黑名单作品找不到关联演员", y2), s2; - } - async repairDataHealth() { - let e2 = 0, t2 = await this.getCarList(), n2 = await this.getFavoriteActressList(), a2 = await this.getBlacklist(), i2 = await this.getBlacklistCarList(); - const s2 = this._dedupeByKey(t2, "carNum"); - s2.changed && (t2 = s2.list, e2++); - const o2 = this._dedupeByKey(n2, "starId"); - o2.changed && (n2 = o2.list, e2++); - const r2 = this._dedupeByKey(a2, "starId"); - r2.changed && (a2 = r2.list, e2++); - t2 = t2.map(((t3) => { - if (!t3) return t3; - let n3 = false; - return void 0 !== t3.actress && (t3.names = t3.actress, delete t3.actress, n3 = true), n3 && e2++, t3; - })), i2 = i2.map(((t3) => { - if (!t3) return t3; - let n3 = false; - return void 0 !== t3.actress && (t3.names = t3.actress, delete t3.actress, n3 = true), Object.prototype.hasOwnProperty.call(t3, "type") && (delete t3.type, n3 = true), n3 && e2++, t3; - })), n2 = n2.map(((t3) => { - if (!t3) return t3; - let n3 = false; - return Object.prototype.hasOwnProperty.call(t3, "dbId") && (t3.starId || (t3.starId = t3.dbId), delete t3.dbId, n3 = true), t3.name && !Array.isArray(t3.allName) && (t3.allName = t3.allName ? [t3.allName] : [t3.name], n3 = true), n3 && e2++, t3; - })), a2 = a2.map(((t3) => { - if (!t3) return t3; - let n3 = false; - return Object.prototype.hasOwnProperty.call(t3, "isActor") && (t3.role || (t3.role = t3.isActor ? B : P), delete t3.isActor, n3 = true), Object.prototype.hasOwnProperty.call(t3, "recordTime") && (t3.createTime || (t3.createTime = t3.recordTime), delete t3.recordTime, n3 = true), Object.prototype.hasOwnProperty.call(t3, "key") && (delete t3.key, n3 = true), t3.name && !Array.isArray(t3.allName) && (t3.allName = t3.allName ? [t3.allName] : [t3.name], n3 = true), n3 && e2++, t3; - })); - const l2 = new Set(t2.filter(((e3) => e3 && e3.carNum)).map(((e3) => e3.carNum))); - n2 = n2.map(((t3) => { - if (!Array.isArray(t3?.newVideoList)) return t3; - const n3 = t3.newVideoList.filter(((e3) => { - const n4 = "string" == typeof e3 ? e3 : e3.carNum; - return !l2.has(n4); - })); - return n3.length !== t3.newVideoList.length && (t3 = { - ...t3, - newVideoList: n3 - }, e2++), t3; - })), await this._setItemAndInvalidate(this.car_list_key, t2), await this._setItemAndInvalidate(this.favorite_actresses_key, n2), await this._setItemAndInvalidate(this.blacklist_key, a2), await this._setItemAndInvalidate(this.blacklist_car_list_key, i2); - return { - fixedGroups: e2, - report: await this.inspectDataHealth() - }; - } - /** 数据迁移: 将旧版扁平键名重命名为新格式 */ - async merge_table_name() { - let e2 = "filter_actor_actress_info_list", t2 = await this.forage.getItem(e2) || []; - t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.blacklist_key, t2)), await this.forage.removeItem(e2), e2 = "favorite_actresses_info_list", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.favorite_actresses_key, t2)), await this.forage.removeItem(e2), e2 = "car_list_filter_actor_actress", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.blacklist_car_list_key, t2)), await this.forage.removeItem(e2), e2 = "title_filter_keyword", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.filter_keyword_title_key, t2)), await this.forage.removeItem(e2), e2 = "review_filter_keyword", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.filter_keyword_review_key, t2)), await this.forage.removeItem(e2), e2 = "highlightedTags", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.highlighted_tags_key, t2)), await this.forage.removeItem(e2); - } - async clean_no_url_blacklist() { - const [e2, t2] = await Promise.all([this.getBlacklistCarList(), this.getBlacklist()]); - if (e2.length && !e2[0].actress) return; - const n2 = new Set(t2.map(((e3) => e3.name))), a2 = e2.filter(((e3) => !e3.actress || n2.has(e3.actress))); - e2.length !== a2.length && (clog.debug("清理 blacklistCarList 前", e2.length), clog.debug("清理 blacklistCarList 后", a2.length), await this._setItemAndInvalidate(this.blacklist_car_list_key, a2)); - const i2 = new Set(a2.map(((e3) => e3.actress))); - let s2 = t2.filter(((e3) => i2.has(e3.name))); - s2 = s2.map(((e3) => { - const { key: t3, recordTime: n3, ...a3 } = e3, i3 = a3; - return void 0 !== n3 && (i3.createTime = n3), i3; - })), (t2.length !== s2.length || t2.some(((e3) => "key" in e3 || "recordTime" in e3))) && (clog.debug("清理 Blacklist 前", t2.length), clog.debug("清理 Blacklist 后", s2.length), await this._setItemAndInvalidate(this.blacklist_key, s2)); - } - async async_merge_other() { - const e2 = await this.getSetting(); - let t2 = false; - const n2 = { - enableCheckFilterActorActress: "enableCheckBlacklist", - checkIntervalTime_filterActorActress: "checkBlacklist_intervalTime", - checkIntervalTime_ruleTime: "checkNewVideo_ruleTime", - checkIntervalTime_newVideo: "checkNewVideo_intervalTime", - checkIntervalTime_favoriteActress: "checkFavoriteActress_IntervalTime" - }; - for (const a2 in n2) { - const i2 = n2[a2]; - Object.prototype.hasOwnProperty.call(e2, a2) && (e2[i2] = e2[a2], delete e2[a2], t2 = true); - } - e2.checkFilterTime && (delete e2.checkFilterTime, t2 = true), e2.checkFilterConcurrencyCount && (delete e2.checkFilterConcurrencyCount, t2 = true), e2.checkFilterSleep && (delete e2.checkFilterSleep, t2 = true), t2 && (await this.saveSetting(e2), clog.debug("配置数据已更正")); - } - /** 数据迁移: 补全黑名单条目缺失的 role/starId/allName/movieType 字段 */ - async merge_blacklist() { - const e2 = await this.getBlacklist(); - if (!e2 || 0 === e2.length) return; - let t2 = false; - const n2 = e2.map(((e3) => { - let n3 = false; - if (Object.prototype.hasOwnProperty.call(e3, "isActor") && !e3.role && (e3.role = e3.isActor ? B : P, delete e3.isActor, n3 = true), !e3.starId && e3.url) try { - const t3 = new URL(e3.url).pathname, a3 = t3.split("/").filter(((e4) => "" !== e4.trim())).pop(); - e3.starId !== a3 && (e3.starId = a3, n3 = true); - } catch (a3) { - clog.error("提取url-starId发生错误", e3.url, a3); - } - if (e3.allName || (e3.allName = e3.name ? [e3.name] : [], n3 = true), e3.movieType || (e3.movieType = D, n3 = true), e3.url && e3.url.includes("sort_type")) { - const t3 = new URL(e3.url); - t3.searchParams.delete("sort_type"), e3.url = t3.toString(), clog.debug("去除黑名单地址sort_type参数"); - } - return n3 && (t2 = true), e3; - })); - t2 && (clog.debug("更正 Blacklist 数据结构"), await this._setItemAndInvalidate(this.blacklist_key, n2)); - const a2 = await this.getBlacklistCarList(); - t2 = false; - const i2 = a2.map(((n3) => { - if (!n3.starId) { - let a3 = e2.find(((e3) => e3.name === n3.actress)); - a3 && (n3.starId = a3.starId), t2 = true; - } - return n3.type && (delete n3.type, t2 = true), n3; - })); - t2 && (clog.debug("更正 blacklistCarList 数据结构"), await this._setItemAndInvalidate(this.blacklist_car_list_key, i2)); - } - async merge_favoriteActress() { - const e2 = await this.getFavoriteActressList(); - if (!e2 || 0 === e2.length) return; - let t2 = false; - const n2 = e2.map(((e3) => { - let n3 = false; - return e3.dbId && (e3.starId = e3.dbId, delete e3.dbId, n3 = true), n3 && (t2 = true), e3; - })); - t2 && (clog.debug("更正 favoriteActressesInfoList 数据结构"), await this._setItemAndInvalidate(this.favorite_actresses_key, n2)); - } - async merge_tow_car_list_table() { - const e2 = await this.getBlacklistCarList(), t2 = await this.getCarList(); - let n2 = false; - const a2 = e2.map(((e3) => { - let t3 = false; - return void 0 !== e3.actress && (e3.names = e3.actress, delete e3.actress, t3 = true), t3 && (n2 = true), e3; - })); - n2 && (clog.debug("更正 blacklistCarList 数据结构 actress->names"), await this._setItemAndInvalidate(this.blacklist_car_list_key, a2)), n2 = false; - const i2 = t2.map(((e3) => { - let t3 = false; - return void 0 !== e3.actress && (e3.names = e3.actress, delete e3.actress, t3 = true), t3 && (n2 = true), e3; - })); - n2 && (clog.debug("更正 carList 数据结构 actress->names"), await this._setItemAndInvalidate(this.car_list_key, i2)); - } - /* ───── 快照管理 ───── */ - _snapshotKey() { - return "snapshots"; - } - _snapshotMetaKeys() { - return ["snapshots", "data_version"]; - } - async _getSnapshots() { - return await this.forage.getItem(this._snapshotKey()) || []; - } - async _saveSnapshots(e2) { - await this._setItemAndInvalidate(this._snapshotKey(), e2); - } - async _withSnapshotLock(e2) { - return await navigator.locks.request("jhs_snapshot_lock", async () => await e2()); - } - async createSnapshot(e2 = "", t2 = "manual") { - return this._withSnapshotLock(async () => { - const n2 = await this._getSnapshots(), a2 = await this.exportData(); - for (const i3 of this._snapshotMetaKeys()) delete a2[i3]; - let i2 = 0; - for (const s3 of Object.values(a2)) Array.isArray(s3) ? i2 += s3.length : "object" == typeof s3 && s3 && i2++; - const s2 = { - id: "snap_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8), - name: e2 || utils.getNowStr(), - source: t2, - time: utils.getNowStr(), - itemCount: i2, - data: a2, - kind: "snapshot" - }; - n2.push(s2), n2.length > 10 && n2.splice(0, n2.length - 10); - return await this._saveSnapshots(n2), clog.log(`创建快照: ${s2.name} (${t2})`), s2; - }); - } - async getSnapshotList() { - return (await this._getSnapshots()).map(((e2) => ({ id: e2.id, name: e2.name, source: e2.source, time: e2.time, itemCount: e2.itemCount, kind: e2.kind || "snapshot", targetDataVersion: e2.targetDataVersion, appVersion: e2.appVersion }))); - } - async getSnapshot(e2) { - return (await this._getSnapshots()).find(((t2) => t2.id === e2)) || null; - } - async deleteSnapshot(e2) { - return this._withSnapshotLock(async () => { - const t2 = await this._getSnapshots(), n2 = t2.filter(((t3) => t3.id !== e2)); - if (n2.length === t2.length) return void clog.warn("删除快照失败, ID不存在: " + e2); - await this._saveSnapshots(n2), clog.log("删除快照: " + e2); - }); - } - async restoreSnapshot(e2) { - const t2 = await this.getSnapshot(e2); - if (!t2) throw new Error("快照不存在: " + e2); - if (!t2.data || "object" != typeof t2.data) throw new Error("快照数据损坏"); - const n2 = { ...t2.data }; - for (const a2 of this._snapshotMetaKeys()) delete n2[a2]; - await this.importData(n2); - clog.log("已恢复快照: " + t2.name); - return t2; - } - /* ───── 差异对比引擎 ───── */ - _stableStringify(e2) { - if (null === e2 || "object" != typeof e2) return JSON.stringify(e2); - if (Array.isArray(e2)) return "[" + e2.map(((e3) => this._stableStringify(e3))).join(",") + "]"; - return "{" + Object.keys(e2).sort().map(((t2) => JSON.stringify(t2) + ":" + this._stableStringify(e2[t2]))).join(",") + "}"; - } - diffData(e2, t2) { - const n2 = /* @__PURE__ */ new Set([...Object.keys(e2), ...Object.keys(t2)]), a2 = {}, i2 = { added: 0, removed: 0, modified: 0, unchanged: 0 }; - for (const o2 of n2) { - const r2 = e2[o2], l2 = t2[o2]; - if (void 0 === r2 && void 0 === l2) continue; - if (void 0 === r2) { - const e3 = Array.isArray(l2) ? l2.length : 1; - a2[o2] = { status: "added", oldCount: 0, newCount: e3, added: Array.isArray(l2) ? l2 : [], removed: [], modified: [] }, i2.added++; - } else if (void 0 === l2) { - const t3 = Array.isArray(r2) ? r2.length : 1; - a2[o2] = { status: "removed", oldCount: t3, newCount: 0, added: [], removed: Array.isArray(r2) ? r2 : [], modified: [] }, i2.removed++; - } else if (Array.isArray(r2) && Array.isArray(l2)) { - const e3 = this._diffArrays(r2, l2, o2); - a2[o2] = { status: e3.status, oldCount: r2.length, newCount: l2.length, ...e3 }, i2[e3.status]++; - } else if ("object" == typeof r2 && "object" == typeof l2 && !Array.isArray(r2) && !Array.isArray(l2)) { - const e3 = this._diffObjects(r2, l2); - a2[o2] = { status: e3.status, oldCount: Object.keys(r2).length, newCount: Object.keys(l2).length, added: [], removed: [], modified: e3.changes }, i2[e3.status]++; - } else r2 === l2 ? (a2[o2] = { status: "unchanged", oldCount: 1, newCount: 1, added: [], removed: [], modified: [] }, i2.unchanged++) : (a2[o2] = { status: "modified", oldCount: 1, newCount: 1, added: [], removed: [], modified: [{ key: o2, changes: { _value: [r2, l2] } }] }, i2.modified++); - } - return { summary: i2, stores: a2 }; - } - _getArrayKey(e2) { - return "car_list" === e2 || "blacklist_car_list" === e2 ? "carNum" : "blacklist" === e2 || "favorite_actresses" === e2 ? "starId" : null; - } - _diffArrays(e2, t2, n2) { - const a2 = this._getArrayKey(n2); - if (!a2) { - const n3 = this._stableStringify(e2), i3 = this._stableStringify(t2); - if (n3 === i3) return { status: "unchanged", added: [], removed: [], modified: [] }; - const s3 = new Set(e2.map(((e3) => this._stableStringify(e3)))), o3 = new Set(t2.map(((e3) => this._stableStringify(e3)))); - return { status: "modified", added: t2.filter(((e3) => !s3.has(this._stableStringify(e3)))), removed: e2.filter(((e3) => !o3.has(this._stableStringify(e3)))), modified: [] }; - } - const i2 = new Map(e2.map(((e3) => [e3[a2], e3]))), s2 = new Map(t2.map(((e3) => [e3[a2], e3]))), o2 = [], r2 = [], l2 = []; - for (const [c3, d2] of s2) { - const t3 = i2.get(c3); - if (!t3) o2.push(d2); - else { - const e3 = this._diffObjects(t3, d2); - "unchanged" !== e3.status && l2.push({ key: c3, changes: e3.changes }); - } - } - for (const [c3] of i2) s2.has(c3) || r2.push(i2.get(c3)); - const c2 = o2.length + r2.length + l2.length; - return { status: 0 === c2 ? "unchanged" : "modified", added: o2, removed: r2, modified: l2 }; - } - _diffObjects(e2, t2) { - const n2 = /* @__PURE__ */ new Set([...Object.keys(e2), ...Object.keys(t2)]), a2 = {}; - let i2 = 0; - for (const s2 of n2) { - const o2 = e2[s2], r2 = t2[s2]; - this._stableStringify(o2) !== this._stableStringify(r2) && (a2[s2] = [o2, r2], i2++); - } - return { status: 0 === i2 ? "unchanged" : "modified", changes: a2 }; - } - }; - __name(_StorageManager, "StorageManager"); - var StorageManager = _StorageManager; - var PORTABLE_DATA_KEYS = Object.freeze(["car_list", "filter_keyword_title", "filter_keyword_review", "setting", "blacklist", "blacklist_car_list", "third_party_ttl_cache", "favorite_actresses", "highlighted_tags", "activity_log", "offline_history", "new_video_decisions"]); - var PORTABLE_ARRAY_KEYS = /* @__PURE__ */ new Set(["car_list", "filter_keyword_title", "filter_keyword_review", "blacklist", "blacklist_car_list", "favorite_actresses", "highlighted_tags", "offline_history"]); - var PORTABLE_OBJECT_KEYS = /* @__PURE__ */ new Set(["setting", "third_party_ttl_cache", "activity_log", "new_video_decisions"]); - function validatePortableData(data) { - if (!data || "object" != typeof data || Array.isArray(data)) throw new TypeError("备份数据格式无效"); - const version = Number(data.data_version || 0); - if (!Number.isInteger(version) || version < 0) throw new TypeError("备份数据版本无效"); - if (version > CURRENT_DATA_VERSION) throw new Error("数据来自更新版本的 JHS,当前版本无法安全读取"); - for (const key of PORTABLE_ARRAY_KEYS) if (null != data[key] && !Array.isArray(data[key])) throw new TypeError(`备份字段 ${key} 必须为数组`); - for (const key of PORTABLE_OBJECT_KEYS) if (null != data[key] && ("object" != typeof data[key] || Array.isArray(data[key]))) throw new TypeError(`备份字段 ${key} 必须为对象`); - return version; - } - __name(validatePortableData, "validatePortableData"); - async function hasPortableUserData(storage) { - for (const key of PORTABLE_DATA_KEYS) { - const value = await storage.forage.getItem(key); - if (Array.isArray(value) ? value.length : value && "object" == typeof value ? Object.keys(value).length : null != value) return true; - } - return false; - } - __name(hasPortableUserData, "hasPortableUserData"); - async function ensureV2MigrationSnapshot(storage) { - if (!await hasPortableUserData(storage)) return null; - const snapshots = await storage._getSnapshots(), existing = snapshots.find(((item) => "migration-snapshot" === item.kind && 2 === item.targetDataVersion)); - if (existing) return existing; - const data = await storage.exportPortableData(), snapshot = { - id: "migration_v2_" + Date.now(), - name: "6.4.0-migration-backup", - source: "migration", - kind: "migration-snapshot", - targetDataVersion: 2, - appVersion: "6.4.0", - createdAt: (/* @__PURE__ */ new Date()).toISOString(), - time: utils.getNowStr(), - itemCount: Object.values(data).reduce(((sum, value) => sum + (Array.isArray(value) ? value.length : value && "object" == typeof value ? 1 : 0)), 0), - data - }; - return snapshots.push(snapshot), snapshots.length > 10 && snapshots.splice(0, snapshots.length - 10), await storage._saveSnapshots(snapshots), snapshot; - } - __name(ensureV2MigrationSnapshot, "ensureV2MigrationSnapshot"); - async function migrateLegacyStorage(storage) { - await storage.merge_table_name(), await storage.clean_no_url_blacklist(), await storage.async_merge_other(), await storage.merge_blacklist(), await storage.merge_favoriteActress(), await storage.merge_tow_car_list_table(); - } - __name(migrateLegacyStorage, "migrateLegacyStorage"); - async function migrateStateFlags(storage) { - await ensureV2MigrationSnapshot(storage); - const cars = await storage.forage.getItem(storage.car_list_key) || [], result = mergeCanonicalCarRecords(cars); - await storage._setItemAndInvalidate(storage.car_list_key, result.list); - const actresses = await storage.forage.getItem(storage.favorite_actresses_key) || []; - const migratedActresses = actresses.map(((actress) => { - if (!Array.isArray(actress.newVideoList)) return actress; - const newVideoList = actress.newVideoList.map(((item) => "string" == typeof item ? normalizeCarNum(item) : { - ...item, - carNum: normalizeCarNum(item.carNum) - })).filter(((item) => "string" == typeof item ? item : item.carNum)); - return { ...actress, newVideoList }; - })); - await storage._setItemAndInvalidate(storage.favorite_actresses_key, migratedActresses); - const warnings = await storage.forage.getItem("data_health_warnings") || []; - result.collisions.length && warnings.push({ type: "canonical-collision", createdAt: (/* @__PURE__ */ new Date()).toISOString(), items: result.collisions }); - result.unknownStatuses.length && warnings.push({ type: "unknown-legacy-status", createdAt: (/* @__PURE__ */ new Date()).toISOString(), items: result.unknownStatuses }); - warnings.length && await storage.forage.setItem("data_health_warnings", warnings); - } - __name(migrateStateFlags, "migrateStateFlags"); - var DATA_MIGRATIONS = Object.freeze({ 1: migrateLegacyStorage, 2: migrateStateFlags }); - async function runDataMigrations(storage) { - let version = await storage.getDataVersion(); - if (version > CURRENT_DATA_VERSION) throw new Error("数据来自更新版本的 JHS,当前版本无法安全读取"); - for (let target = version + 1; target <= CURRENT_DATA_VERSION; target++) { - const migration = DATA_MIGRATIONS[target]; - if (!migration) throw new Error(`缺少数据迁移: ${target - 1} → ${target}`); - await migration(storage), await storage.setDataVersion(target), version = target; - } - return version; - } - __name(runDataMigrations, "runDataMigrations"); - var U = "https://jdforrepam.com/api"; - var javDbMovieIdRequests = /* @__PURE__ */ new Map(); - function O() { - const e2 = "jhs_review_ts", t2 = "jhs_review_sign", n2 = Math.floor(Date.now() / 1e3); - if (n2 - (localStorage.getItem(e2) || 0) <= 20) return localStorage.getItem(t2); - const a2 = `${n2}.lpw6vgqzsp.${md5(`${n2}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; - return localStorage.setItem(e2, n2), localStorage.setItem(t2, a2), a2; - } - __name(O, "O"); - async function resolveJavDbMovieId(carNum) { - const normalized = normalizeCarNum(carNum); - if (!normalized) return null; - if (javDbMovieIdRequests.has(normalized)) return javDbMovieIdRequests.get(normalized); - const request = storageManager.cachedRequest(`javdb-movie-id:${normalized}`, 7 * 864e5, (async () => { - const response = await gmHttp.get(`${U}/v2/search`, { - q: normalized, - page: 1, - type: "movie", - limit: 20, - movie_type: "all", - from_recent: "false", - movie_filter_by: "all", - movie_sort_by: "relevance" - }, { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - host: "jdforrepam.com", - jdsignature: await O() - }); - if (!Array.isArray(response?.data?.movies)) throw new Error(response?.message || "JavDB 番号解析失败"); - const match = response.data.movies.find(((movie) => normalizeCarNum(movie.number) === normalized)); - return match?.id ? { __jhsCacheTtl: 7 * 864e5, data: { movieId: String(match.id) } } : { __jhsCacheTtl: 6 * 36e5, data: { miss: true } }; - })).then(((value) => value?.miss ? null : value?.movieId || null)); - javDbMovieIdRequests.set(normalized, request); - try { - return await request; - } finally { - javDbMovieIdRequests.get(normalized) === request && javDbMovieIdRequests.delete(normalized); - } - } - __name(resolveJavDbMovieId, "resolveJavDbMovieId"); - async function markJavDbWantWatch(movieId) { - const id = String(movieId || "").trim(), encryptedToken = localStorage.getItem("jhs_appAuthorization"), token = encryptedToken ? await decryptData(encryptedToken) : ""; - if (!token) { - const error = new Error("请先登录 JavDB 账号"); - throw error.code = "LOGIN_REQUIRED", error; - } - if (!id) throw new Error("JavDB 影片 ID 无效"); - const boundary = "----jhs-javdb-want-watch", body = [["status", "want_watch"], ["score", "0"], ["content", ""]].map((([name, value]) => `--${boundary}\r -Content-Disposition: form-data; name="${name}"\r -\r -${value}\r -`)).join("") + `--${boundary}--\r -`; - try { - const response = await gmHttp.gmRequest("POST", `${U}/v1/movies/${encodeURIComponent(id)}/reviews`, body, {}, { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - authorization: `Bearer ${token}`, - jdsignature: await O(), - "content-type": `multipart/form-data; boundary=${boundary}` - }); - if (0 === response?.success) throw response; - await storageManager.deleteCachedRequest(`movie-detail:${id}`); - return response; - } catch (error) { - if (401 === error?.status || "JWTVerificationError" === error?.action || /未登录|登录|unauthorized|jwt/i.test(error?.message || "")) { - localStorage.removeItem("jhs_appAuthorization"); - const loginError = new Error("JavDB 登录已失效,请重新登录"); - throw loginError.code = "LOGIN_REQUIRED", loginError; - } - throw error instanceof Error ? error : new Error(error?.message || "加入 JavDB 想看失败"); - } - } - __name(markJavDbWantWatch, "markJavDbWantWatch"); - var R = /* @__PURE__ */ __name(async (e2, t2 = 1, n2 = 20) => { - let a2 = `${U}/v1/movies/${e2}/reviews`, i2 = { - jdSignature: await O() - }; - return await storageManager.cachedRequest(`reviews:${e2}:${t2}:${n2}`, 864e5, (async () => { - const e3 = await gmHttp.get(a2, { - page: t2, - sort_by: "hotly", - limit: n2 - }, i2); - if (!e3?.data?.reviews) throw new Error(e3?.message || "获取评论失败"); - return e3.data.reviews; - })); - }, "R"); - var V = /* @__PURE__ */ __name(async (e2) => { - let t2 = `${U}/v4/movies/${e2}`, n2 = { - jdSignature: await O() - }; - const a2 = await storageManager.cachedRequest(`movie-detail:${e2}`, 6048e5, (async () => { - const e3 = await gmHttp.get(t2, null, n2); - if (!e3.data) throw new Error(e3.message || "获取视频详情失败"); - return e3; - })); - if (!a2.data) throw show.error("获取视频详情失败: " + a2.message), new Error(a2.message); - const i2 = a2.data.movie, s2 = i2.preview_images, o2 = []; - return s2.forEach(((e3) => { - o2.push(e3.large_url.replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")); - })), { - movieId: i2.id, - actors: i2.actors, - duration: i2.duration, - title: i2.origin_title, - carNum: i2.number, - score: i2.score, - releaseDate: i2.release_date, - watchedCount: i2.watched_count, - imgList: o2 - }; - }, "V"); - var K = /* @__PURE__ */ __name(async (e2, t2 = 1, n2 = 20) => { - let a2 = `${U}/v1/lists/related?movie_id=${e2}&page=${t2}&limit=${n2}`, i2 = { - jdSignature: await O() - }; - const s2 = await storageManager.cachedRequest(`related:${e2}:${t2}:${n2}`, 864e5, (async () => { - const e3 = await gmHttp.get(a2, null, i2); - if (!e3?.data?.lists) throw new Error(e3?.message || "获取相关清单失败"); - return e3; - })), o2 = []; - return s2.data.lists.forEach(((e3) => { - o2.push({ - relatedId: e3.id, - name: e3.name, - movieCount: e3.movies_count, - collectionCount: e3.collections_count, - viewCount: e3.views_count, - createTime: utils.formatDate(e3.created_at) - }); - })), o2; - }, "K"); - var W = /* @__PURE__ */ __name(async (e2 = "daily", t2 = "high_score") => { - let n2 = `${U}/v1/rankings/playback?period=${e2}&filter_by=${t2}`, a2 = { - jdSignature: await O() - }; - return (await gmHttp.get(n2, null, a2)).data.movies; - }, "W"); - var q = /* @__PURE__ */ __name(async (e2 = "all", t2 = "", n2 = 1, a2 = 40) => { - let i2 = `${U}/v1/movies/top?start_rank=1&type=${e2}&type_value=${t2}&ignore_watched=false&page=${n2}&limit=${a2}`; - const l2 = localStorage.getItem("jhs_appAuthorization"), c2 = l2 ? await decryptData(l2) : ""; - let s2 = { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - host: "jdforrepam.com", - authorization: "Bearer " + c2, - jdsignature: await O() - }; - return await gmHttp.get(i2, null, s2); - }, "q"); - var _Utils = class _Utils { - constructor() { - return i(this, "intervalContainer", {}), i(this, "waitSequence", 0), i(this, "mimeTypes", { - txt: "text/plain", - html: "text/html", - css: "text/css", - csv: "text/csv", - json: "application/json", - xml: "application/xml", - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", - pdf: "application/pdf", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - zip: "application/zip", - rar: "application/x-rar-compressed", - "7z": "application/x-7z-compressed", - mp3: "audio/mpeg", - wav: "audio/wav", - mp4: "video/mp4", - webm: "video/webm", - ogg: "audio/ogg" - }), i(this, "timers", /* @__PURE__ */ new Map()), i(this, "insertStyle", ((e2) => { - const t2 = (Array.isArray(e2) ? e2 : [e2]).filter(Boolean); - if (0 === t2.length) return; - const n2 = t2.map(((e3) => e3.replace(/^\s*]*>/i, "").replace(/<\/style>?\s*$/i, ""))).filter(Boolean).join("\n"), a2 = document.createElement("style"); - n2 && (a2.textContent = n2, document.head.append(a2)); - })), i(this, "layerIndexStack", []), _Utils.instance || (_Utils.instance = this), _Utils.instance; - } - importResource(e2) { - let t2; - e2.indexOf("css") >= 0 ? (t2 = document.createElement("link"), t2.setAttribute("rel", "stylesheet"), t2.href = e2) : (t2 = document.createElement("script"), t2.setAttribute("type", "text/javascript"), t2.src = e2), document.documentElement.appendChild(t2); - } - openPage(e2, t2, n2, a2) { - n2 = n2 ?? true; - const navigation = a2 && (Object.prototype.hasOwnProperty.call(a2, "event") || Object.prototype.hasOwnProperty.call(a2, "newTab")) ? a2 : { event: a2 }, event = navigation.event; - const destination = new URL(e2, window.location.origin), carNum = normalizeCarNum(t2), isMovieDetail = /^\/v\/[^/]+/.test(destination.pathname); - isMovieDetail && carNum && destination.searchParams.set("jhsCarNum", carNum); - if (navigation.newTab || event && (event.ctrlKey || event.metaKey || 1 === event.button)) return void GM_openInTab(destination.href, { - insert: 0 - }); - destination.pathname.includes("/actors/") || destination.pathname.includes("/star/") || destination.searchParams.set("hideNav", "1"); - layer.open({ - type: 2, - title: t2, - content: destination.href, - scrollbar: false, - shadeClose: n2, - area: this.getDialogArea("workspace"), - isOutAnim: false, - anim: -1, - success: /* @__PURE__ */ __name((e3, t3) => { - this.setupEscClose(t3); - }, "success") - }); - } - _handleGlobalEscKey(e2) { - if ("Escape" !== e2.key && 27 !== e2.keyCode) return; - if (0 === this.layerIndexStack.length) return; - const t2 = this.layerIndexStack[this.layerIndexStack.length - 1], n2 = $(`#layui-layer${t2}`); - let a2 = false; - if (n2.find(".viewer-container").length > 0) a2 = true; - else { - const e3 = n2.find(`#layui-layer-iframe${t2}`)[0]; - if (e3 && e3.contentDocument) try { - $(e3.contentDocument).find(".viewer-container").length > 0 && (a2 = true); - } catch (i2) { - clog.warn("无法检查跨域 iframe 内的 .viewer-container"); - } - } - a2 || (this.layerIndexStack.pop(), layer.close(t2)); - } - setupEscClose(e2) { - var t2; - this._boundHandler || (this._boundHandler = this._handleGlobalEscKey.bind(this), $(document).off("keydown.globalLayerEsc"), $(document).on("keydown.globalLayerEsc", this._boundHandler)), -1 === this.layerIndexStack.indexOf(e2) && this.layerIndexStack.push(e2); - const n2 = $(`#layui-layer-iframe${e2}`), a2 = `keydown.layerEsc${e2}`; - try { - const e3 = null == (t2 = n2[0]) ? void 0 : t2.contentDocument; - if (e3) { - if ("yes" === n2.attr("data-esc-bound")) return; - $(e3).off(a2), $(e3).on(a2, this._boundHandler), n2.attr("data-esc-bound", "yes"); - } - } catch (i2) { - clog.error("iframe监听失败 (跨域或未加载完毕):", i2); - } - } - async closePage(options = {}) { - if ("yes" !== await storageManager.getSetting("needClosePage", "yes")) return false; - const root = options?.root, parseIndex = /* @__PURE__ */ __name((element) => { - const id = element?.id || "", match = /^layui-layer(\d+)$/.exec(id); - return match ? Number(match[1]) : null; - }, "parseIndex"); - let layerIndex = Number.isInteger(options?.layerIndex) ? options.layerIndex : null; - if (null === layerIndex && root) { - const element = root.jquery ? root[0] : root.nodeType ? root : null, layerElement = element?.matches?.(".layui-layer") ? element : element?.closest?.(".layui-layer"); - layerIndex = parseIndex(layerElement); - } - if (null === layerIndex && window.frameElement) layerIndex = parseIndex(window.frameElement.closest?.(".layui-layer")); - const ownerWindow = window.parent && window.parent !== window ? window.parent : window, ownerLayer = ownerWindow.layer || globalThis.layer; - if (null !== layerIndex && "function" == typeof ownerLayer?.close) return ownerLayer.close(layerIndex), true; - if (window.opener && !window.opener.closed) return window.close(), true; - return false; - } - loopDetector(e2, t2, n2 = 20, a2 = 1e4, i2 = true) { - const s2 = ++this.waitSequence; - let o2 = null, r2 = null, l2 = null, c2 = false; - const d2 = /* @__PURE__ */ __name(() => { - o2?.disconnect(), clearTimeout(r2), clearTimeout(l2), clearInterval(this.intervalContainer[s2]?.fallback), delete this.intervalContainer[s2]; - }, "d"), h2 = /* @__PURE__ */ __name((e3) => { - if (c2) return; - c2 = true, d2(), e3 && t2 && t2(); - }, "h"), g2 = /* @__PURE__ */ __name(() => { - if (c2) return; - e2() && h2(true); - }, "g"), p2 = /* @__PURE__ */ __name(() => { - c2 || (clearTimeout(r2), r2 = setTimeout(g2, Math.max(0, n2))); - }, "p"); - this.intervalContainer[s2] = {}; - if (e2()) return void h2(true); - if ("function" == typeof MutationObserver && document.documentElement) o2 = new MutationObserver(p2), o2.observe(document.documentElement, { childList: true, subtree: true, characterData: true }); - else this.intervalContainer[s2].fallback = setInterval(g2, Math.max(100, n2)); - l2 = setTimeout((() => { - if (c2) return; - let t3 = false; - try { - t3 = e2(); - } catch (e3) { - clog.error("DOM 等待条件执行失败", e3); - } - h2(t3 || i2); - }), Math.max(0, a2)); - } - rightClick(e2, t2, n2) { - let a2; - "string" == typeof e2 ? a2 = document.querySelector(e2) : e2 instanceof HTMLElement && (a2 = e2), a2 || (clog.warn("rightClick(), 容器无效或未提供,将使用 document.body 进行全局委托。"), a2 = document.body), "string" == typeof t2 && "" !== t2.trim() ? a2.addEventListener("contextmenu", ((e3) => { - const a3 = e3.target.closest(t2); - a3 && n2(e3, a3); - })) : clog.error("rightClick(), 必须提供有效的 targetSelector。"); - } - q(e2, t2, n2, a2) { - let o2; - if (this.isMobileMode()) { - o2 = layer.confirm(t2, { - title: "提示", - btn: ["确定", "取消"], - shade: 0, - zIndex: JHS_Z_INDEX.layer - }, (function() { - n2 && n2(), layer.close(o2); - }), (function() { - a2 && a2(); - })); - } else { - let i2, s2; - e2 ? (i2 = e2.clientX - 130, s2 = e2.clientY - 120) : (i2 = window.innerWidth / 2 - 120, s2 = window.innerHeight / 2 - 120); - o2 = layer.confirm(t2, { - offset: [s2, i2], - title: "提示", - btn: ["确定", "取消"], - shade: 0, - zIndex: JHS_Z_INDEX.layer - }, (function() { - n2 && n2(), layer.close(o2); - }), (function() { - a2 && a2(); - })); - } - } - getNowStr(e2 = "-", t2 = ":", n2 = null) { - let a2; - a2 = n2 ? new Date(n2) : /* @__PURE__ */ new Date(); - const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); - return `${[i2, s2, o2].join(e2)} ${[r2, l2, c2].join(t2)}`; - } - formatDate(e2, t2 = "-", n2 = ":") { - let a2; - if (e2 instanceof Date) a2 = e2; - else { - if ("string" != typeof e2) throw new Error("Invalid date input: must be Date object or date string"); - if (a2 = new Date(e2), isNaN(a2.getTime())) throw new Error("Invalid date string"); - } - const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); - return `${[i2, s2, o2].join(t2)} ${[r2, l2, c2].join(n2)}`; - } - getHourDifference(e2, t2) { - const n2 = e2.getTime(), a2 = t2.getTime(), i2 = Math.abs(a2 - n2) / 36e5; - return Math.floor(i2); - } - download(e2, t2) { - show.info("开始请求下载..."); - const n2 = t2.split(".").pop().toLowerCase(); - let a2, i2 = this.mimeTypes[n2] || "application/octet-stream"; - if (e2 instanceof Blob) a2 = e2; - else if (e2 instanceof ArrayBuffer || ArrayBuffer.isView(e2)) a2 = new Blob([e2], { - type: i2 - }); - else if ("string" == typeof e2 && e2.startsWith("data:")) { - const t3 = atob(e2.split(",")[1]), n3 = new ArrayBuffer(t3.length), s3 = new Uint8Array(n3); - for (let e3 = 0; e3 < t3.length; e3++) s3[e3] = t3.charCodeAt(e3); - a2 = new Blob([s3], { - type: i2 - }); - } else a2 = new Blob([e2], { - type: i2 - }); - const s2 = URL.createObjectURL(a2), o2 = document.createElement("a"); - o2.href = s2, o2.download = t2, document.body.appendChild(o2), o2.click(), setTimeout((() => { - document.body.removeChild(o2), URL.revokeObjectURL(s2); - }), 100); - } - smoothScrollToTop(e2 = 500) { - return new Promise(((t2) => { - const n2 = performance.now(), a2 = window.pageYOffset; - window.requestAnimationFrame(/* @__PURE__ */ __name((function i2(s2) { - const o2 = s2 - n2, r2 = Math.min(o2 / e2, 1), l2 = r2 < 0.5 ? 4 * r2 * r2 * r2 : 1 - Math.pow(-2 * r2 + 2, 3) / 2; - window.scrollTo(0, a2 * (1 - l2)), r2 < 1 ? window.requestAnimationFrame(i2) : t2(); - }), "i")); - })); - } - isUrl(e2) { - try { - return new URL(e2), true; - } catch (t2) { - return false; - } - } - setHrefParam(e2, t2) { - const n2 = new URL(window.location.href); - n2.searchParams.set(e2, t2), window.history.pushState({}, "", n2.toString()); - } - getUrlParam(e2, t2) { - const n2 = e2.split("?")[1]; - if (!n2) return null; - const a2 = new RegExp(`(?:^|&)${t2}=([^&]*)`), i2 = n2.match(a2); - let s2 = ""; - return i2 && i2[1] && (s2 = decodeURIComponent(i2[1].replace(/\+/g, " "))), s2 ? "true" === s2 || "false" === s2 ? "true" === s2.toLowerCase() : "string" != typeof s2 || "" === s2.trim() || isNaN(Number(s2)) ? s2 : Number(s2) : s2; - } - getResponsiveArea(e2) { - const t2 = window.innerWidth; - return this.isMobileMode() ? ["100%", "90%"] : t2 >= 1200 ? e2 || this.getDefaultArea() : ["70%", "90%"]; - } - /** 按用途返回具有固定上限和安全边距的弹窗尺寸。 */ - getDialogArea(e2 = "md") { - const t2 = { - sm: [480, 640], - md: [720, 700], - lg: [1040, 760], - xl: [1320, 860], - workspace: [1440, 960] - }, n2 = t2[e2] || t2.md, a2 = window.innerWidth <= 768 ? 16 : "workspace" === e2 ? 32 : 64, i2 = Math.max(320, Math.min(n2[0], window.innerWidth - a2)), s2 = Math.max(320, Math.min(n2[1], window.innerHeight - a2)); - return [`${i2}px`, `${s2}px`]; - } - getDefaultArea() { - return ["85%", "90%"]; - } - isMobile() { - const e2 = navigator.userAgent.toLowerCase(); - return ["iphone", "ipod", "ipad", "android", "blackberry", "windows phone", "nokia", "webos", "opera mini", "mobile", "mobi", "tablet"].some(((t2) => e2.includes(t2))); - } - isMobileMode() { - const e2 = storageManager.getSettingSync("mobileMode", "auto"); - return "on" === e2 || "off" !== e2 && (this.isMobile() || window.innerWidth < 768); - } - async copyToClipboard(e2, t2) { - const text = String(t2 ?? ""); - let copied = false; - try { - if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text), copied = true; - else throw new Error("Clipboard API unavailable"); - } catch (clipboardError) { - const textarea = document.createElement("textarea"), activeElement = document.activeElement; - textarea.value = text, textarea.setAttribute("readonly", ""), textarea.style.position = "fixed", textarea.style.opacity = "0", document.body.appendChild(textarea), textarea.select(); - try { - copied = true === document.execCommand("copy"); - if (!copied) throw clipboardError; - } catch (fallbackError) { - clog.error("复制失败:", fallbackError), show.error("复制失败,请手动复制"); - } finally { - textarea.remove(), activeElement?.focus?.(); - } - } - return copied && show.info(`${e2}已复制到剪贴板, ${text}`), copied; - } - htmlTo$dom(e2) { - const t2 = new DOMParser(); - return $(t2.parseFromString(e2, "text/html")); - } - isHidden(e2) { - const t2 = e2.jquery ? e2[0] : e2; - return !t2 || (t2.offsetWidth <= 0 && t2.offsetHeight <= 0 || "none" === window.getComputedStyle(t2).display); - } - time(e2 = "default", t2 = "s", n2 = 2) { - if (this.timers.has(e2)) { - const t3 = this.timers.get(e2), n3 = performance.now() - t3.startTime; - let a2, i2; - return "s" === t3.unit ? (a2 = (n3 / 1e3).toFixed(t3.precision), i2 = "秒") : (a2 = n3.toFixed(t3.precision), i2 = "毫秒"), this.timers.delete(e2), `${e2}: ${a2}${i2}`; - } - this.timers.set(e2, { - startTime: performance.now(), - unit: t2, - precision: n2 - }); - } - sleep(e2 = 1e3) { - return new Promise(((t2) => setTimeout(t2, e2))); - } - /** 创建可取消的尾沿防抖函数。 */ - debounce(callback, wait = 200) { - let timer = null; - const debounced = /* @__PURE__ */ __name(function(...args) { - const context = this; - clearTimeout(timer), timer = setTimeout((() => { - timer = null, callback.apply(context, args); - }), Math.max(0, wait)); - }, "debounced"); - return debounced.cancel = () => { - clearTimeout(timer), timer = null; - }, debounced; - } - genericSort(e2, t2, n2 = true) { - if (!Array.isArray(e2) || 0 === e2.length) return []; - if (!Array.isArray(t2) || 0 === t2.length) return [...e2]; - const i2 = /* @__PURE__ */ __name((e3) => { - if (e3 instanceof Date) return e3; - if ("string" == typeof e3) { - const t3 = new Date(e3); - if (!isNaN(t3.getTime())) return t3; - } - return e3; - }, "i"); - const getVal = /* @__PURE__ */ __name((e3, t3) => null != t3 ? "function" == typeof t3 ? t3(e3) : e3 && "object" == typeof e3 ? e3[t3] : void 0 : e3, "getVal"); - const nulls = [], nonNulls = []; - for (const item of e2) { - let hasNull = false; - for (const s2 of t2) { - const val = getVal(item, s2.key); - if (null == val || void 0 === val) { - hasNull = true; - break; - } - } - hasNull ? nulls.push(item) : nonNulls.push(item); - } - return nonNulls.sort(((e3, a2) => { - for (const s2 of t2) { - const { key: t3, order: o2 = "asc" } = s2; - let r2 = getVal(e3, t3), l2 = getVal(a2, t3); - const c2 = i2(r2), d2 = i2(l2); - let h2 = c2 instanceof Date && d2 instanceof Date ? c2.getTime() - d2.getTime() : "number" == typeof r2 && "number" == typeof l2 ? r2 - l2 : "string" == typeof r2 && "string" == typeof l2 ? r2.localeCompare(l2) : String(r2).localeCompare(String(l2)); - "desc" === o2 && (h2 *= -1); - if (0 !== h2) return h2; - } - return 0; - })), n2 ? [...nonNulls, ...nulls] : [...nulls, ...nonNulls]; - } - async retry(e2, t2 = 3) { - let n2 = 0; - for (; n2 < t2; ) try { - const t3 = await e2(); - return n2 > 0 && clog.debug(`[重试] 请求成功,共发起 ${n2 + 1} 次。`), t3; - } catch (a2) { - const e3 = a2 instanceof Error ? a2.message : "object" == typeof a2 ? JSON.stringify(a2) : String(a2); - if (a2?._cfBlocked || a2?._circuitBroken || e3.includes("Just a moment") || e3.includes("重定向") || e3.toLowerCase().includes("404 not found")) throw a2; - if (n2++, n2 === t2) throw clog.debug(`[重试] 达到最大重试次数 (${t2}),最终失败:`, a2), a2; - await this.sleep(500 * n2), clog.debug(`[重试] 请求失败,准备第 ${n2 + 1} 次重试, 错误信息: ${e3}`); - } - } - }; - __name(_Utils, "Utils"); - var Utils = _Utils; - unsafeWindow.utils = window.utils = new Utils(), unsafeWindow.gmHttp = window.gmHttp = new class { - constructor() { - this._circuitBreakers = /* @__PURE__ */ new Map(); - this._domainStats = /* @__PURE__ */ new Map(); - } - _getDomain(e2) { - try { - return new URL(e2).hostname; - } catch { - return "unknown"; - } - } - _isCloudflareChallenge(e2, status = 0) { - if ("string" != typeof e2 || !e2) return false; - const text = e2.toLowerCase(); - const hasChallengeTitle = /]*>\s*just a moment(?:\.\.\.)?\s*<\/title>/i.test(e2); - const hasChallengeForm = /id=["']challenge-form["']/i.test(e2); - const hasCfChl = text.includes("cf-chl-") || text.includes("cf_chl_opt"); - const hasChallengePlatform = text.includes("/cdn-cgi/challenge-platform/") || text.includes("challenge-platform"); - const blockedStatus = 403 === status || 429 === status || 503 === status; - return hasChallengeTitle || hasChallengeForm && (hasCfChl || hasChallengePlatform) || blockedStatus && hasCfChl && hasChallengePlatform; - } - _checkCircuitBreaker(e2) { - const t2 = this._circuitBreakers.get(e2); - if (!t2) return null; - if ("open" === t2.state) { - const n2 = Date.now() - t2.openTime; - return n2 < t2.cooldownMs ? { state: "open", remaining: Math.ceil((t2.cooldownMs - n2) / 1e3) } : (t2.state = "half-open", t2.failCount = 0, t2.probing = false, null); - } - if ("half-open" === t2.state && t2.probing) return { state: "half-open", remaining: 0 }; - return null; - } - isDomainCircuitBroken(e2) { - return this._checkCircuitBreaker(this._getDomain(e2)); - } - /* domainStats.count 统计请求数(含重试),非独立请求数 */ - _recordSuccess(e2) { - let t2 = this._circuitBreakers.get(e2); - t2 && (t2.state = "closed", t2.failCount = 0, t2.probing = false); - let n2 = this._domainStats.get(e2); - n2 || (n2 = { count: 0, errors: 0, lastUsed: 0 }, this._domainStats.set(e2, n2)), n2.count++, n2.lastUsed = Date.now(); - } - _recordFailure(e2) { - let t2 = this._circuitBreakers.get(e2); - t2 || (t2 = { state: "closed", failCount: 0, openTime: 0, cooldownMs: 6e4, threshold: 3 }, this._circuitBreakers.set(e2, t2)), t2.failCount++, ("half-open" === t2.state || t2.failCount >= (t2.threshold || 3)) && (t2.state = "open", t2.openTime = Date.now(), t2.probing = false, clog.warn(`[熔断] ${e2} 连续失败 ${t2.failCount} 次,已熔断 ${t2.cooldownMs / 1e3} 秒`)); - let n2 = this._domainStats.get(e2); - n2 || (n2 = { count: 0, errors: 0, lastUsed: 0 }, this._domainStats.set(e2, n2)), n2.count++, n2.errors++, n2.lastUsed = Date.now(); - } - getCircuitBreakerStatus() { - const e2 = {}; - return this._circuitBreakers.forEach(((t2, n2) => { - e2[n2] = { ...t2 }; - })), e2; - } - resetCircuitBreaker(e2) { - this._circuitBreakers.delete(e2); - } - resetAllCircuitBreakers() { - this._circuitBreakers.clear(); - } - getDomainStats() { - const e2 = {}; - return this._domainStats.forEach(((t2, n2) => { - e2[n2] = { ...t2 }; - })), e2; - } - clearDomainStats() { - this._domainStats.clear(); - } - async get(e2, t2 = {}, n2 = {}, a2, i2 = {}) { - return this.gmRequest("GET", e2, null, t2, n2, a2, i2); - } - post(e2, t2 = {}, n2 = {}) { - n2 = { - "Content-Type": "application/json", - ...n2 - }; - let a2 = JSON.stringify(t2); - return this.gmRequest("POST", e2, a2, null, n2); - } - async gmRequest(e2, t2, n2 = {}, a2 = {}, i2 = {}, s2 = false, requestOptions = {}) { - if (a2 && Object.keys(a2).length) { - const e3 = new URLSearchParams(a2).toString(); - t2 += (t2.includes("?") ? "&" : "?") + e3; - } - const o2 = this._getDomain(t2), [m2, r2, b2, k2] = await Promise.all([storageManager.getSetting("httpTimeout", 5e3), storageManager.getSetting("httpRetryCount", 3), storageManager.getSetting("circuitBreakerThreshold", 3), storageManager.getSetting("circuitBreakerCooldown", 6e4)]); - let u2 = this._circuitBreakers.get(o2); - u2 || (u2 = { state: "closed", failCount: 0, openTime: 0, cooldownMs: k2, threshold: b2, probing: false }, this._circuitBreakers.set(o2, u2)); - const w = this._checkCircuitBreaker(o2); - if (w) { - const e3 = new Error(`站点 ${o2} 已熔断,${w.remaining}秒后重试`); - throw e3._circuitBroken = true, e3; - } - return n2 || (n2 = void 0), await utils.retry(() => { - const c2 = this._checkCircuitBreaker(o2); - if (c2) { - const t3 = new Error(`站点 ${o2} 已熔断,${c2.remaining}秒后重试`); - return t3._circuitBroken = true, Promise.reject(t3); - } - "half-open" === u2.state && (u2.probing = true); - return new Promise(((a3, r3) => { - GM_xmlhttpRequest({ - method: e2, - url: t2, - headers: i2, - timeout: m2, - data: n2, - ...requestOptions.cookiePartitionTopLevelSite ? { - cookiePartition: { topLevelSite: requestOptions.cookiePartitionTopLevelSite } - } : {}, - onload: /* @__PURE__ */ __name((e3) => { - try { - if (404 === e3.status && requestOptions.ignoreNotFound) return void a3(null); - if (this._isCloudflareChallenge(e3.responseText, e3.status)) { - this._recordFailure(o2); - const n3 = new Error(`Cloudflare challenge blocked: ${t2}`); - return n3._cfBlocked = true, n3.status = e3.status, n3.requestUrl = t2, n3.finalUrl = e3.finalUrl, n3.cfDiagnostics = { status: e3.status, requestUrl: t2, finalUrl: e3.finalUrl, contentLength: e3.responseText?.length || 0 }, void r3(n3); +`,Vo=!1;function Ho(o,e=""){if(Vo)return;let t=d((s,a)=>o?.register?o.register(s,a.replace(/^\s*\n '); - unsafeWindow.loading = window.loading = function() { - const e2 = document.createElement("div"); - e2.className = "loading-container"; - const t2 = document.createElement("div"); - return t2.className = "loading-animation", e2.appendChild(t2), document.body.appendChild(e2), { - close: /* @__PURE__ */ __name(() => { - e2 && e2.parentNode && e2.parentNode.removeChild(e2); - }, "close") - }; - }, (function() { - const e2 = /* @__PURE__ */ __name((e3, t2, n2, a2, i2) => { - let s2; - "object" == typeof n2 ? s2 = n2 : (s2 = "object" == typeof a2 ? a2 : i2 || {}, s2.gravity = n2 || "top", s2.position = "string" == typeof a2 ? a2 : "center"), s2.gravity && "center" !== s2.gravity || (s2.offset = { - y: "calc(50vh - 150px)" - }); - const o2 = "var(--jhs-status-fav)", r2 = "var(--jhs-status-fav-tint)", l2 = "var(--jhs-status-down)", c2 = "var(--jhs-status-down-tint)", d2 = "var(--jhs-status-filter)", h2 = "var(--jhs-status-filter-tint)", g2 = { - borderRadius: "12px", - padding: "12px 16px", - boxShadow: "0 4px 6px rgba(0,0,0,0.1)", - minWidth: "150px", - textAlign: "center", - zIndex: JHS_Z_INDEX.debug - }, p2 = { - text: e3, - duration: 2500, - close: false, - gravity: "top", - position: "center", - style: { - info: { - ...g2, - background: `${o2}`, - color: "var(--jhs-status-fav-on)" - }, - success: { - ...g2, - background: `${l2}`, - color: "var(--jhs-status-down-on)" - }, - error: { - ...g2, - background: `${d2}`, - color: "var(--jhs-status-filter-on)" - } - }[t2], - stopOnFocus: true, - oldestFirst: false, - ...s2 - }; - -1 === p2.duration && (p2.close = true); - const m2 = Toastify(p2); - return m2.showToast(), m2.closeShow = () => { - m2.toastElement.remove(); - }, m2; - }, "e"); - unsafeWindow.show = window.show = { - ok: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "success", n2, a2, i2), "ok"), - error: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "error", n2, a2, i2), "error"), - info: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "info", n2, a2, i2), "info") - }; - })(), (function() { - function e2(e3 = 10) { - setTimeout((() => { - const e4 = document.querySelectorAll(".layui-layer-shade").length; - document.documentElement.style.overflow = e4 > 0 ? "hidden" : ""; - }), e3); - } - __name(e2, "e"); - document.head.insertAdjacentHTML("beforeend", "\n \n "), window.showImageViewer = function(t2, n2 = "", options = {}) { - let a2 = null, i2 = false; - "string" == typeof t2 || t2 instanceof String ? (a2 = $('
').append(`${n2}`).appendTo("body"), i2 = true) : a2 = $(t2); - const galleryRoot = options.galleryRoot ? $(options.galleryRoot) : null, viewerHost = galleryRoot?.length ? galleryRoot : a2, selectedImage = "string" == typeof t2 || t2 instanceof String ? a2.find("img")[0] : a2[0], galleryImages = viewerHost.find("img").addBack("img").toArray(), initialViewIndex = Math.max(0, galleryImages.indexOf(selectedImage)), hasGallery = galleryImages.length > 1; - const s2 = { - zIndex: JHS_Z_INDEX.viewer, - navbar: false, - initialViewIndex, - zoomOnWheel: false, - zoomRatio: 0.1, - toggleOnDblclick: false, - toolbar: { - prev: hasGallery ? 1 : 0, - zoomIn: 1, - zoomOut: 1, - reset: 1, - next: hasGallery ? 1 : 0, - rotateLeft: 0, - rotateRight: 0, - flipHorizontal: 0, - flipVertical: 0 - }, - title: false, - keyboard: false, - viewed() { - o2.zoomTo(1.4); - const x = (o2.viewerData.width - o2.imageData.width) / 2, y2 = (o2.viewerData.height - o2.imageData.height) / 2; - o2.moveTo(x, y2); - }, - shown() { - i2 && a2.remove(), document.documentElement.style.overflow = "hidden", document.body.style.overflow = "hidden", o2.handleKeydown = function(t3) { - if (hasGallery && "ArrowLeft" === t3.key) return t3.preventDefault(), t3.stopPropagation(), void o2.prev(); - if (hasGallery && "ArrowRight" === t3.key) return t3.preventDefault(), t3.stopPropagation(), void o2.next(); - "Escape" !== t3.key && " " !== t3.key || (t3.preventDefault(), t3.stopPropagation(), o2.destroy(), document.removeEventListener("keydown", o2.handleKeydown), document.documentElement.style.overflow = "", document.body.style.overflow = "", e2()); - }, document.addEventListener("keydown", o2.handleKeydown); - }, - hidden() { - o2 && o2.handleKeydown && document.removeEventListener("keydown", o2.handleKeydown), o2.destroy(), document.documentElement.style.overflow = "", document.body.style.overflow = "", e2(); - } - }, o2 = new Viewer(viewerHost[0], s2); - o2.show(); - }; - })(), window.ImageHoverPreview = class { - constructor(config = {}) { - this.config = { - selector: ".hover-preview", - dataAttribute: "data-full", - maxWidth: 1e3, - maxHeight: 1e3, - offsetX: 20, - offsetY: 20, - zIndex: JHS_Z_INDEX.tooltip, - transition: 0.2, - hideDelay: 100, - loadedUrlLimit: 128, - autoAdjustPosition: true, - ...config - }; - this.config.loadedUrlLimit = Math.max(1, Number(this.config.loadedUrlLimit) || 128); - this.preview = null; - this.currentTarget = null; - this.timer = null; - this.imgElement = null; - this.pendingImage = null; - this.placement = null; - this.pointer = null; - this.animationFrame = null; - this.loadGeneration = 0; - this.destroyed = false; - this.currentUrl = null; - this.pendingUrl = null; - this.loadedUrls = /* @__PURE__ */ new Map(); - this.eventsBound = false; - this.onMouseEnter = (event) => this.handleMouseEnter(event); - this.onMouseLeave = (event) => this.handleMouseLeave(event); - this.onMouseMove = (event) => this.handleMouseMove(event); - this.onDocumentOver = (event) => this.handleDocumentOver(event); - this.onDocumentOut = (event) => this.handleDocumentOut(event); - this.onDocumentMove = (event) => this.handleDocumentMove(event); - this.init(); - } - init() { - if (utils.isMobileMode()) return; - this.injectStyles(), this.createPreviewElement(), this.bindEvents(); - } - injectStyles() { - if (document.getElementById("jhs-image-hover-preview-style")) return; - const style = document.createElement("style"); - style.id = "jhs-image-hover-preview-style"; - style.textContent = ` + 100% { + transform: translateX(40px); + } + } + + `),document.head.insertAdjacentHTML("beforeend",""),window.loading=function(){let r=document.createElement("div");r.className="loading-container",r.setAttribute("role","status"),r.setAttribute("aria-live","polite"),r.setAttribute("aria-label","处理中"),r.setAttribute("aria-busy","true");let n=document.createElement("div");return n.className="loading-animation",r.appendChild(n),document.body.appendChild(r),{close:d(()=>{r&&r.parentNode&&r.parentNode.removeChild(r)},"close")}},(function(){let r=d((n,l,c,h,u)=>{let p;typeof c=="object"?(p=c,p.gravity||(p.gravity="top"),p.position||(p.position="center")):(p=typeof h=="object"?h:u||{},p.gravity=c||"top",p.position=typeof h=="string"?h:"center"),p.gravity&&p.gravity!=="center"||(p.offset={y:"calc(50vh - 150px)"});let g="var(--jhs-status-fav)",m="var(--jhs-status-fav-tint)",f="var(--jhs-status-down)",v="var(--jhs-status-down-tint)",b="var(--jhs-status-filter)",w="var(--jhs-status-filter-tint)",S={borderRadius:"12px",padding:"12px 16px",boxShadow:"0 4px 6px rgba(0,0,0,0.1)",minWidth:"150px",textAlign:"center",zIndex:J.debug},j={text:n,duration:2500,close:!1,gravity:"top",position:"center",style:{info:{...S,background:`${g}`,color:"var(--jhs-status-fav-on)"},success:{...S,background:`${f}`,color:"var(--jhs-status-down-on)"},error:{...S,background:`${b}`,color:"var(--jhs-status-filter-on)"}}[l],stopOnFocus:!0,oldestFirst:!1,...p};j.duration===-1&&(j.close=!0);let k=Toastify(j);return k.showToast(),k.closeShow=()=>{k.toastElement.remove()},k},"e");window.show={ok:d((n,l="center",c,h)=>r(n,"success",l,c,h),"ok"),error:d((n,l="center",c,h)=>r(n,"error",l,c,h),"error"),info:d((n,l="center",c,h)=>r(n,"info",l,c,h),"info")}})(),(function(){let r=null;document.head.insertAdjacentHTML("beforeend",``),document.head.insertAdjacentHTML("beforeend",` + + `),window.showImageViewer=function(n,l="",c={}){let h=null,u=!1;if(typeof n=="string"||n instanceof String){let U=document.createElement("div"),X=document.createElement("img");U.className="temporary-container jhs-layout-c8be1ccb",X.src=String(n),X.alt=String(l),U.appendChild(X),document.body.appendChild(U),h=$(U),u=!0}else h=$(n);let p=h.is("img")?h[0]:h.find("img")[0],g=p?.closest(".jhs-fc2-workspace"),m=g?.querySelector('[data-jhs-slot="gallery"]'),f=m?.contains(p)?$(m):c.galleryRoot?$(c.galleryRoot):null,v=f?.length?f:h,b=v.find("img").addBack("img").toArray(),w=Math.max(0,b.indexOf(p)),S=b.length>1;if(!p||!v.length||!p.isConnected||o.disposed)return void(u&&h.remove());r?.close(!1);let j=p.closest(".layui-layer"),k=p.closest("button,a,[tabindex]")||document.activeElement,x=g&&j?.querySelector(".layui-layer-content"),_=new te("ui:image-viewer"),E=document.documentElement.style.overflow,L=document.body.style.overflow,M,P,z,O,H=g?.inert,Q=x?.classList.contains("jhs-image-viewer-owner"),se={close(U=!0){if(!_.disposed){_.dispose(),P?.();try{M?.destroy()}finally{z?.remove(),x&&(x.classList.toggle("jhs-image-viewer-owner",Q),g.inert=H),u&&h.remove(),r===se&&(r=null,document.documentElement.style.overflow=document.querySelector(".layui-layer-shade")?"hidden":j?"":E,document.body.style.overflow=L,U&&k?.isConnected&&k.focus?.({preventScroll:!0}))}}}};r=se,P=o.addCleanup(()=>se.close(!1));let ge=[...document.querySelectorAll(".layui-layer")].reduce((U,X)=>{let ie=window.getComputedStyle(X),xt=Number(ie.zIndex);return ie.display!=="none"&&Number.isFinite(xt)?Math.max(U,xt+1):U},J.viewer);if(x){z=document.createElement("div"),z.className="jhs-image-viewer-host",z.style.zIndex=String(ge);let U=document.createElement("div");U.hidden=!0,b.forEach(X=>{let ie=document.createElement("img");ie.src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'/%3E",ie.setAttribute("data-jhs-viewer-source",X.currentSrc||X.src),ie.alt=X.alt,U.appendChild(ie)}),z.appendChild(U),x.classList.add("jhs-image-viewer-owner"),x.appendChild(z),g.inert=!0,v=$(U)}let Ee=d(U=>U<0?M?.prev(!!g):M?.next(!!g),"navigate"),De={zIndex:ge,...g?{className:`jhs-fc2-image-viewer jhs-ui${x?" viewer-in":""}`,inline:!!x,zIndexInline:ge,minWidth:0,minHeight:0,button:!1,transition:!1,initialCoverage:.9,ready:d(()=>{if(_.disposed||!M?.viewer)return;let U=M.viewer,X=document.createElement("div");X.className="jhs-image-viewer-toolbar",U.setAttribute("role","dialog"),U.setAttribute("aria-label","图片预览");let ie=d((ye,Ed,Dd,Nd,Ld=U)=>{let ot=document.createElement("button");return ot.type="button",ot.className=`jhs-btn jhs-btn--secondary ${Dd}`,ot.setAttribute("aria-label",ye),ot.textContent=Ed,_.listen(ot,"click",Po=>{Po.preventDefault(),Po.stopPropagation(),Nd()}),Ld.appendChild(ot),ot},"button"),xt=ie("关闭图片","×","jhs-image-viewer-close",()=>se.close());S&&(ie("上一张","‹","jhs-image-viewer-nav jhs-image-viewer-prev",()=>Ee(-1)),ie("下一张","›","jhs-image-viewer-nav jhs-image-viewer-next",()=>Ee(1))),ie("放大图片","放大","",()=>M.zoom(.1),X),ie("缩小图片","缩小","",()=>M.zoom(-.1),X),ie("适应窗口","适应","",()=>M.reset(),X),O=document.createElement("span"),O.className="jhs-image-viewer-count",O.setAttribute("aria-live","polite"),X.appendChild(O),M.footer.appendChild(X);let us=-1/0;if(_.listen(U,"wheel",ye=>{!S||ye.ctrlKey||!ye.deltaY||(ye.preventDefault(),ye.stopPropagation(),!(ye.timeStamp-us<180)&&(us=ye.timeStamp,Ee(Math.sign(ye.deltaY))))},{passive:!1}),x&&typeof ResizeObserver<"u"){let ye=new ResizeObserver(()=>{M?.ready&&!_.disposed&&M.resize()});ye.observe(z),_.ownObserver(ye)}xt.focus({preventScroll:!0})},"bindFc2Controls")}:{},...x?{url:"data-jhs-viewer-source"}:{},navbar:!1,initialViewIndex:w,zoomOnWheel:!1,zoomRatio:.1,toggleOnDblclick:!1,toolbar:g?!1:{prev:S?1:0,zoomIn:1,zoomOut:1,reset:1,next:S?1:0,rotateLeft:0,rotateRight:0,flipHorizontal:0,flipVertical:0},title:!1,keyboard:!1,viewed(){if(_.disposed)return;if(g){M.resize(),O&&(O.textContent=`${M.index+1} / ${b.length}`);return}M.zoomTo(1.4);let U=(M.viewerData.width-M.imageData.width)/2,X=(M.viewerData.height-M.imageData.height)/2;M.moveTo(U,X)},shown(){_.disposed||(document.documentElement.style.overflow="hidden",document.body.style.overflow="hidden")},hidden(){se.close()}};try{_.listen(document,"keydown",U=>{if(S&&U.key==="ArrowLeft")return U.preventDefault(),U.stopPropagation(),void Ee(-1);if(S&&U.key==="ArrowRight")return U.preventDefault(),U.stopPropagation(),void Ee(1);if(g&&U.key==="Tab"&&M?.viewer){let X=[...M.viewer.querySelectorAll("button")],ie=X.indexOf(document.activeElement);U.preventDefault(),X[(ie+(U.shiftKey?-1:1)+X.length)%X.length]?.focus();return}(U.key==="Escape"||!g&&U.key===" ")&&(U.preventDefault(),U.stopPropagation(),se.close())},!0),_.observe(document.body,()=>{(!p.isConnected||j&&!j.isConnected)&&se.close(!1)},{childList:!0,subtree:!0}),M=new Viewer(v[0],De),M.show()}catch(U){throw se.close(!1),U}}})(),window.ImageHoverPreview=class{constructor(r={}){this.scope=new te(`ui:image-hover:${Date.now()}`),this.config={selector:".hover-preview",dataAttribute:"data-full",maxWidth:1e3,maxHeight:1e3,offsetX:20,offsetY:20,zIndex:J.hoverPreview,owner:null,resolveOwner:null,zIndexStrategy:"fixed",transition:.2,hideDelay:100,loadedUrlLimit:128,autoAdjustPosition:!0,...r},this.config.loadedUrlLimit=Math.max(1,Number(this.config.loadedUrlLimit)||128),this.preview=null,this.currentTarget=null,this.timer=null,this.imgElement=null,this.pendingImage=null,this.placement=null,this.pointer=null,this.animationFrame=null,this.loadGeneration=0,this.destroyed=!1,this.currentUrl=null,this.pendingUrl=null,this.loadedUrls=new Map,this.eventsBound=!1,this.overlayObserver=null,this.onMouseEnter=n=>this.handleMouseEnter(n),this.onMouseLeave=n=>this.handleMouseLeave(n),this.onMouseMove=n=>this.handleMouseMove(n),this.onDocumentOver=n=>this.handleDocumentOver(n),this.onDocumentOut=n=>this.handleDocumentOut(n),this.onDocumentMove=n=>this.handleDocumentMove(n),this.onDocumentScroll=()=>this.handleDocumentScroll(),this.suppressUntilMouseMove=!1,this.init()}init(){utils.isMobileMode()||(this.injectStyles(),this.createPreviewElement(),this.bindEvents())}injectStyles(){if(document.getElementById("jhs-image-hover-preview-style"))return;let r=document.createElement("style");r.id="jhs-image-hover-preview-style",r.textContent=` .image-hover-preview { position: fixed; display: block; @@ -4151,167 +1747,7 @@ ${value}\r transform: translate(-50%, -50%); } .image-hover-preview__error { padding: var(--jhs-space-3); color: var(--jhs-danger); } - `; - document.head.appendChild(style); - } - createPreviewElement() { - this.preview = document.createElement("div"); - this.preview.className = "image-hover-preview"; - this.preview.style.zIndex = String(this.config.zIndex); - this.preview.style.setProperty("--jhs-hover-transition", `${this.config.transition}s`); - document.body.appendChild(this.preview); - } - bindEvents() { - if (this.eventsBound || this.destroyed) return; - document.addEventListener("mouseover", this.onDocumentOver), document.addEventListener("mouseout", this.onDocumentOut), document.addEventListener("mousemove", this.onDocumentMove), this.eventsBound = true; - } - findTarget(event) { - const target = event.target; - return target?.closest ? target.closest(this.config.selector) : null; - } - handleDocumentOver(event) { - const target = this.findTarget(event); - target && (!event.relatedTarget || !target.contains(event.relatedTarget)) && this.handleMouseEnter(event, target); - } - handleDocumentOut(event) { - const target = this.findTarget(event); - target && (!event.relatedTarget || !target.contains(event.relatedTarget)) && this.handleMouseLeave(); - } - handleDocumentMove(event) { - if (!this.currentTarget) return; - (event.target === this.currentTarget || this.currentTarget.contains(event.target)) && this.handleMouseMove(event); - } - handleMouseEnter(event, delegatedTarget = event.currentTarget) { - if (this.destroyed || !this.preview) return; - clearTimeout(this.timer); - this.timer = null; - this.currentTarget = delegatedTarget; - this.pointer = { - x: event.clientX, - y: event.clientY - }; - this.placement = null; - const source = this.currentTarget.getAttribute(this.config.dataAttribute) || this.currentTarget.src; - if (!source) return; - if (source === this.currentUrl && this.imgElement) return this.showCurrentPreview(); - if (source === this.pendingUrl) return void this.preview.classList.add("active"); - const cached = this.loadedUrls.get(source); - if (cached) return this.loadedUrls.delete(source), this.loadedUrls.set(source, cached), void this.commitPreview(source, cached); - const generation = ++this.loadGeneration; - this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null); - this.pendingUrl = source; - this.currentUrl ? this.showCurrentPreview() : (this.preview.replaceChildren(), this.preview.classList.add("loading", "active")); - const image = new Image(); - this.pendingImage = image; - image.onload = () => { - if (this.destroyed || generation !== this.loadGeneration || !this.preview) return; - const { width, height } = this.calculateImageSize(image); - const cachedImage = { width, height }; - this.loadedUrls.set(source, cachedImage); - for (; this.loadedUrls.size > this.config.loadedUrlLimit; ) this.loadedUrls.delete(this.loadedUrls.keys().next().value); - this.pendingImage = null, this.pendingUrl = null, this.commitPreview(source, cachedImage); - }; - image.onerror = () => { - if (this.destroyed || generation !== this.loadGeneration || !this.preview) return; - this.pendingImage = null, this.pendingUrl = null; - if (this.currentUrl) return void this.preview.classList.add("active"); - const error = document.createElement("div"); - error.className = "image-hover-preview__error"; - error.textContent = "图片加载失败"; - this.preview.replaceChildren(error); - this.preview.classList.remove("loading"); - this.preview.classList.add("active"); - }; - image.src = source; - } - commitPreview(source, cached) { - if (!this.preview) return; - const previewImage = document.createElement("img"); - previewImage.src = source, previewImage.alt = "预览图", this.preview.replaceChildren(previewImage), this.imgElement = previewImage, this.currentUrl = source, this.preview.style.width = `${cached.width}px`, this.preview.style.height = `${cached.height}px`, this.preview.classList.remove("loading"), this.showCurrentPreview(); - } - showCurrentPreview() { - if (!this.preview || !this.pointer || !this.imgElement) return; - const width = this.preview.offsetWidth, height = this.preview.offsetHeight; - this.placement = this.choosePlacement(this.pointer.x, this.pointer.y, width, height), this.preview.classList.add("active"), this.schedulePosition(); - } - calculateImageSize(image) { - const viewportPadding = 8; - const availableWidth = Math.max(1, window.innerWidth - 2 * (viewportPadding + this.config.offsetX)); - const availableHeight = Math.max(1, window.innerHeight - 2 * (viewportPadding + this.config.offsetY)); - const maxWidth = Math.min(this.config.maxWidth, availableWidth); - const maxHeight = Math.min(this.config.maxHeight, availableHeight); - const scale = Math.min(1, maxWidth / image.naturalWidth, maxHeight / image.naturalHeight); - return { - width: Math.max(1, Math.round(image.naturalWidth * scale)), - height: Math.max(1, Math.round(image.naturalHeight * scale)) - }; - } - choosePlacement(x, y2, width, height) { - if (!this.config.autoAdjustPosition) return { - horizontal: "right", - vertical: "bottom" - }; - const rightSpace = window.innerWidth - x - this.config.offsetX; - const leftSpace = x - this.config.offsetX; - const bottomSpace = window.innerHeight - y2 - this.config.offsetY; - const topSpace = y2 - this.config.offsetY; - return { - horizontal: rightSpace >= width || rightSpace >= leftSpace ? "right" : "left", - vertical: bottomSpace >= height || bottomSpace >= topSpace ? "bottom" : "top" - }; - } - handleMouseMove(event) { - if (!this.currentTarget || !this.preview?.classList.contains("active")) return; - this.pointer = { - x: event.clientX, - y: event.clientY - }; - this.schedulePosition(); - } - schedulePosition() { - if (this.animationFrame || !this.pointer || !this.placement) return; - this.animationFrame = requestAnimationFrame((() => { - this.animationFrame = null; - this.applyPosition(); - })); - } - applyPosition() { - if (!this.preview || !this.pointer || !this.placement) return; - const padding = 8; - const width = this.preview.offsetWidth; - const height = this.preview.offsetHeight; - let left = "right" === this.placement.horizontal ? this.pointer.x + this.config.offsetX : this.pointer.x - width - this.config.offsetX; - let top = "bottom" === this.placement.vertical ? this.pointer.y + this.config.offsetY : this.pointer.y - height - this.config.offsetY; - left = Math.min(Math.max(padding, left), Math.max(padding, window.innerWidth - width - padding)); - top = Math.min(Math.max(padding, top), Math.max(padding, window.innerHeight - height - padding)); - this.preview.style.left = `${left}px`; - this.preview.style.top = `${top}px`; - } - handleMouseLeave() { - if (!this.preview) return; - clearTimeout(this.timer), this.timer = setTimeout((() => this.hidePreview()), this.config.hideDelay); - } - hidePreview() { - if (!this.preview) return; - ++this.loadGeneration, this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null), this.pendingImage = null, this.pendingUrl = null, this.preview.classList.remove("active", "loading"), this.currentTarget = null, this.pointer = null, this.placement = null, this.timer = null; - } - destroy() { - if (this.destroyed) return; - this.destroyed = true; - ++this.loadGeneration; - clearTimeout(this.timer); - this.animationFrame && cancelAnimationFrame(this.animationFrame); - this.animationFrame = null; - this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null); - this.pendingImage = null; - this.eventsBound && (document.removeEventListener("mouseover", this.onDocumentOver), document.removeEventListener("mouseout", this.onDocumentOut), document.removeEventListener("mousemove", this.onDocumentMove), this.eventsBound = false); - this.loadedUrls.clear(); - this.preview?.remove(); - this.preview = null; - this.currentTarget = null; - } - }, (async function() { - document.head.insertAdjacentHTML("beforeend", ` + `,document.head.appendChild(r)}createPreviewElement(){this.preview=document.createElement("div"),this.preview.className="image-hover-preview",this.preview.style.zIndex=String(this.resolvePreviewZIndex()),this.preview.style.setProperty("--jhs-hover-transition",`${this.config.transition}s`),document.body.appendChild(this.preview)}resolveOwnerElement(){let r=typeof this.config.resolveOwner=="function"?this.config.resolveOwner():this.config.owner;return r?.jquery?r[0]:r}resolvePreviewZIndex(){let r=Number(this.config.zIndex)||J.hoverPreview;if(this.config.zIndexStrategy!=="owner"&&!this.resolveOwnerElement())return r;let n=this.resolveOwnerElement();for(;n;){let l=typeof getComputedStyle=="function"?getComputedStyle(n).zIndex:"",c=Number(n.style?.zIndex||l);if(Number.isFinite(c)&&c>0){r=c+1;break}n=n.parentElement}return r}refreshOwnerZIndex(){if(this.preview&&(this.config.zIndexStrategy==="owner"||this.resolveOwnerElement())){let r=String(this.resolvePreviewZIndex());this.preview.style.zIndex!==r&&(this.preview.style.zIndex=r)}}isOwnerUncovered(){let r=this.resolveOwnerElement();if(!r)return this.config.zIndexStrategy!=="owner";if(!r.isConnected||!this.currentTarget?.isConnected||!r.contains(this.currentTarget))return!1;let n=r.closest(".layui-layer"),l=Number(n&&window.getComputedStyle(n).zIndex)||0;return![...document.querySelectorAll(".layui-layer, .viewer-container, .fancybox-container, .loading-container")].some(c=>{if(c===n||c.contains(r))return!1;let h=window.getComputedStyle(c);return!c.getClientRects().length||h.display==="none"||h.visibility==="hidden"?!1:!c.matches(".layui-layer")||Number(h.zIndex)>=l})}watchOwnerOverlays(){!this.resolveOwnerElement()||this.overlayObserver||(this.overlayObserver=this.scope.observe(document.body,()=>{this.isOwnerUncovered()?this.refreshOwnerZIndex():this.hidePreview()},{childList:!0,subtree:!0,attributes:!0,attributeFilter:["class","style","hidden"]}))}bindEvents(){this.eventsBound||this.destroyed||(this.scope.listen(document,"mouseover",this.onDocumentOver),this.scope.listen(document,"mouseout",this.onDocumentOut),this.scope.listen(document,"mousemove",this.onDocumentMove),this.scope.listen(document,"scroll",this.onDocumentScroll,{capture:!0,passive:!0}),this.scope.listen(document,"wheel",this.onDocumentScroll,{passive:!0}),this.eventsBound=!0)}handleDocumentScroll(){this.destroyed||!this.currentTarget||(this.suppressUntilMouseMove=!0,clearTimeout(this.timer),this.timer=null,this.hidePreview())}findTarget(r){let n=r.target;return n?.closest?n.closest(this.config.selector):null}handleDocumentOver(r){if(this.suppressUntilMouseMove)return;let n=this.findTarget(r);n&&(!r.relatedTarget||!n.contains(r.relatedTarget))&&this.handleMouseEnter(r,n)}handleDocumentOut(r){let n=this.findTarget(r);n&&(!r.relatedTarget||!n.contains(r.relatedTarget))&&this.handleMouseLeave()}handleDocumentMove(r){this.suppressUntilMouseMove=!1,this.currentTarget&&(r.target===this.currentTarget||this.currentTarget.contains(r.target))&&this.handleMouseMove(r)}handleMouseEnter(r,n=r.currentTarget){if(this.destroyed||!this.preview)return;if(this.refreshOwnerZIndex(),clearTimeout(this.timer),this.timer=null,this.currentTarget=n,!this.isOwnerUncovered())return this.hidePreview();this.watchOwnerOverlays(),this.pointer={x:r.clientX,y:r.clientY},this.placement=null;let l=this.currentTarget.getAttribute(this.config.dataAttribute)||this.currentTarget.src;if(!l)return;if(l===this.currentUrl&&this.imgElement)return this.showCurrentPreview();if(l===this.pendingUrl)return void this.preview.classList.add("active");let c=this.loadedUrls.get(l);if(c)return++this.loadGeneration,this.pendingImage&&(this.pendingImage.onload=null,this.pendingImage.onerror=null),this.pendingImage=null,this.pendingUrl=null,this.loadedUrls.delete(l),this.loadedUrls.set(l,c),void this.commitPreview(l,c);let h=++this.loadGeneration;this.pendingImage&&(this.pendingImage.onload=null,this.pendingImage.onerror=null),this.pendingUrl=l,this.currentUrl?this.showCurrentPreview():(this.preview.replaceChildren(),this.preview.classList.add("loading","active"));let u=new Image;this.pendingImage=u,u.onload=()=>{if(this.destroyed||h!==this.loadGeneration||!this.preview)return;let{width:p,height:g}=this.calculateImageSize(u),m={width:p,height:g};for(this.loadedUrls.set(l,m);this.loadedUrls.size>this.config.loadedUrlLimit;)this.loadedUrls.delete(this.loadedUrls.keys().next().value);this.pendingImage=null,this.pendingUrl=null,this.commitPreview(l,m)},u.onerror=()=>{if(this.destroyed||h!==this.loadGeneration||!this.preview)return;if(this.pendingImage=null,this.pendingUrl=null,this.currentUrl)return void this.preview.classList.add("active");let p=document.createElement("div");p.className="image-hover-preview__error",p.textContent="图片加载失败",this.preview.replaceChildren(p),this.preview.classList.remove("loading"),this.preview.classList.add("active")},u.src=l}commitPreview(r,n){if(!this.preview)return;let l=document.createElement("img");l.src=r,l.alt="预览图",this.preview.replaceChildren(l),this.imgElement=l,this.currentUrl=r,this.preview.style.width=`${n.width}px`,this.preview.style.height=`${n.height}px`,this.preview.classList.remove("loading"),this.showCurrentPreview()}showCurrentPreview(){if(!this.preview||!this.pointer||!this.imgElement)return;if(!this.isOwnerUncovered())return this.hidePreview();let r=this.preview.offsetWidth,n=this.preview.offsetHeight;this.placement=this.choosePlacement(this.pointer.x,this.pointer.y,r,n),this.preview.classList.add("active"),this.schedulePosition()}calculateImageSize(r){let l=Math.max(1,window.innerWidth-2*(8+this.config.offsetX)),c=Math.max(1,window.innerHeight-2*(8+this.config.offsetY)),h=Math.min(this.config.maxWidth,l),u=Math.min(this.config.maxHeight,c),p=Math.min(1,h/r.naturalWidth,u/r.naturalHeight);return{width:Math.max(1,Math.round(r.naturalWidth*p)),height:Math.max(1,Math.round(r.naturalHeight*p))}}choosePlacement(r,n,l,c){if(!this.config.autoAdjustPosition)return{horizontal:"right",vertical:"bottom"};let h=window.innerWidth-r-this.config.offsetX,u=r-this.config.offsetX,p=window.innerHeight-n-this.config.offsetY,g=n-this.config.offsetY;return{horizontal:h>=l||h>=u?"right":"left",vertical:p>=c||p>=g?"bottom":"top"}}handleMouseMove(r){!this.currentTarget||!this.preview?.classList.contains("active")||(this.pointer={x:r.clientX,y:r.clientY},this.schedulePosition())}schedulePosition(){this.animationFrame||!this.pointer||!this.placement||(this.animationFrame=requestAnimationFrame((()=>{this.animationFrame=null,this.applyPosition()})))}applyPosition(){if(!this.preview||!this.pointer||!this.placement)return;let r=8,n=this.preview.offsetWidth,l=this.preview.offsetHeight,c=this.placement.horizontal==="right"?this.pointer.x+this.config.offsetX:this.pointer.x-n-this.config.offsetX,h=this.placement.vertical==="bottom"?this.pointer.y+this.config.offsetY:this.pointer.y-l-this.config.offsetY;c=Math.min(Math.max(r,c),Math.max(r,window.innerWidth-n-r)),h=Math.min(Math.max(r,h),Math.max(r,window.innerHeight-l-r)),this.preview.style.left=`${c}px`,this.preview.style.top=`${h}px`}handleMouseLeave(){this.preview&&(clearTimeout(this.timer),this.timer=setTimeout((()=>this.hidePreview()),this.config.hideDelay))}hidePreview(){this.preview&&(clearTimeout(this.timer),this.overlayObserver&&(this.scope.releaseObserver(this.overlayObserver),this.overlayObserver=null),++this.loadGeneration,this.pendingImage&&(this.pendingImage.onload=null,this.pendingImage.onerror=null),this.pendingImage=null,this.pendingUrl=null,this.preview.classList.remove("active","loading"),this.currentTarget=null,this.pointer=null,this.placement=null,this.timer=null)}destroy(){this.destroyed||(this.destroyed=!0,++this.loadGeneration,clearTimeout(this.timer),this.animationFrame&&cancelAnimationFrame(this.animationFrame),this.animationFrame=null,this.pendingImage&&(this.pendingImage.onload=null,this.pendingImage.onerror=null),this.pendingImage=null,this.eventsBound&&(this.scope.dispose(),this.eventsBound=!1),this.overlayObserver=null,this.loadedUrls.clear(),this.preview?.remove(),this.preview=null,this.currentTarget=null)}},(function(){document.head.insertAdjacentHTML("beforeend",` - `); - document.head.insertAdjacentHTML("beforeend", ``); - const e2 = { - base: { - label: "信息", - background: "var(--jhs-status-fav-tint)", - borderLeftColor: "var(--jhs-accent)" - }, - warn: { - label: "警告", - background: "var(--jhs-status-watch-tint)", - borderLeftColor: "var(--jhs-status-watch)" - }, - error: { - label: "错误", - background: "var(--jhs-status-filter-tint)", - borderLeftColor: "var(--jhs-status-filter)" - }, - debug: { - label: "调试", - background: "var(--jhs-surface-2)", - borderLeftColor: "var(--jhs-border-strong)" - } - }, t2 = { - base: ["base", "warn", "error"], - warn: ["warn"], - error: ["error"], - debug: ["base", "warn", "error", "debug"] - }, n2 = await storageManager.getSetting("clogMsgCount", 2e3), a2 = "jhs_clog_maximize", i2 = "jhs_clog_expand", s2 = "jhs_clog_filter"; - const _o = class _o { - constructor() { - const t3 = localStorage.getItem(s2); - this.currentFilter = t3 && e2[t3] ? t3 : "base", this.logs = [], this.isInitialized = false, this.userScrolledUp = false; - } - tryInitialize() { - return "loading" !== document.readyState && (this.isInitialized || (this.init(), this.isInitialized = true), true); - } - init() { - this.createContainer(), this.bindEvents(), this.checkInitialMaximizeState(), this.checkInitialCollapseState(); - } - createContainer() { - this.container = document.createElement("div"), this.container.className = "console-logger-container jhs-ui", this.container.style.display = "none", this.toggleBtn = document.createElement("button"), this.toggleBtn.type = "button", this.toggleBtn.setAttribute("aria-label", "展开或收起运行日志"), this.toggleBtn.className = "console-logger-toggle collapsed", this.container.appendChild(this.toggleBtn), window.matchMedia?.("(max-width: 768px)").matches && (this.toggleBtn.hidden = true, this.toggleBtn.style.display = "none"), this.window = document.createElement("div"), this.window.className = "console-logger-window collapsed"; - const t3 = document.createElement("div"); - t3.className = "console-logger-header"; - const n3 = document.createElement("div"); - n3.className = "console-logger-title", n3.textContent = "JHS Console"; - const a3 = document.createElement("div"); - a3.className = "console-logger-controls", this.maximizeBtn = document.createElement("button"), this.maximizeBtn.textContent = "", this.maximizeBtn.classList.add("console-logger-maximize-toggle"), a3.appendChild(this.maximizeBtn); - const i3 = document.createElement("button"); - i3.textContent = "清空", i3.addEventListener("click", (() => this.clear())), a3.appendChild(i3), t3.appendChild(n3), t3.appendChild(a3), this.filtersContainer = document.createElement("div"), this.filtersContainer.className = "console-logger-filters", this.filterButtonGroup = document.createElement("div"), this.filterButtonGroup.className = "console-logger-filter-group", this.filtersContainer.appendChild(this.filterButtonGroup), this.scrollToBottomBtn = document.createElement("button"), this.scrollToBottomBtn.className = "console-logger-scroll-to-bottom", this.scrollToBottomBtn.textContent = "到底部", this.filtersContainer.appendChild(this.scrollToBottomBtn), this.content = document.createElement("div"), this.content.className = "console-logger-content jhs-scrollbar", this.window.appendChild(t3), this.window.appendChild(this.filtersContainer), this.window.appendChild(this.content), this.container.appendChild(this.window), document.body.appendChild(this.container), Object.keys(e2).forEach(((t4) => { - const n4 = document.createElement("button"); - n4.type = "button", n4.className = "jhs-btn console-logger-filter", t4 === this.currentFilter && n4.classList.add("active"), n4.textContent = e2[t4].label, n4.dataset.type = t4, n4.addEventListener("click", (() => this.setFilter(t4))), this.filterButtonGroup.appendChild(n4); - })); - } - bindEvents() { - this.toggleBtn.addEventListener("click", (() => { - this.toggleExpandCollapsed(); - })), this.maximizeBtn.addEventListener("click", (() => this.toggleMaximize())), this.scrollToBottomBtn.addEventListener("click", (() => { - this.content.scrollTop = this.content.scrollHeight, this.userScrolledUp = false; - })), this.content.addEventListener("scroll", (() => { - const e3 = this.content.scrollHeight - this.content.clientHeight <= this.content.scrollTop + 5; - this.userScrolledUp = !e3; - })), this.content.addEventListener("wheel", ((e3) => { - const t3 = 0 === this.content.scrollTop, n3 = this.content.scrollHeight - this.content.clientHeight <= this.content.scrollTop + 1; - (t3 && e3.deltaY < 0 || n3 && e3.deltaY > 0) && (e3.preventDefault(), e3.stopPropagation()); - }), { - passive: false - }); - } - toggleExpandCollapsed() { - const e3 = this.window.classList.toggle("collapsed"); - this.toggleBtn.classList.toggle("collapsed"), e3 ? localStorage.setItem(i2, "no") : (localStorage.setItem(i2, "yes"), this.reRenderAllLogs()); - } - checkInitialCollapseState() { - const e3 = localStorage.getItem(i2); - e3 && "no" !== e3 ? (this.window.classList.toggle("collapsed"), this.toggleBtn.classList.toggle("collapsed"), setTimeout((() => { - this.content.scrollTop = this.content.scrollHeight; - }), 0)) : (this.window.classList.add("collapsed"), this.toggleBtn.classList.add("collapsed")); - } - checkInitialMaximizeState() { - "maximized" === localStorage.getItem(a2) && (this.window.classList.add("maximized"), this.maximizeBtn.classList.add("active")); - } - toggleMaximize() { - const e3 = this.window.classList.toggle("maximized"); - this.maximizeBtn.classList.toggle("active", e3), e3 ? localStorage.setItem(a2, "maximized") : localStorage.setItem(a2, "minimized"), this.window.classList.contains("collapsed") || (this.content.scrollTop = this.content.scrollHeight); - } - addLog(t3, a3 = "base", ...i3) { - const s3 = this.tryInitialize(); - let o3, r2 = []; - e2[a3] ? (o3 = a3, r2 = i3) : (o3 = "base", r2 = [a3, ...i3]), o3 = e2[o3] ? o3 : "base"; - const l2 = [t3, ...r2]; - let c2 = "msg"; - const d2 = []; - l2.forEach(((e3) => { - if ("[object Error]" === Object.prototype.toString.call(e3)) d2.push(String(e3)); - else if ("object" == typeof e3 && null !== e3) try { - d2.push("
" + JSON.stringify(e3, null, 2)), c2 = "json"; - } catch (t4) { - d2.push(String(e3)), c2 = "msg"; - } - else d2.push(String(e3)); - })); - let h2 = d2.join(" "); - h2 = h2.replace(/(?:(?:https?|ftp):\/\/|www\.|(?:\/\/))[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]/gi, ((e3) => { - const t4 = e3.startsWith("http") || e3.startsWith("ftp"), n3 = e3.startsWith("//"), a4 = e3.startsWith("www."); - let i4 = e3; - return n3 ? i4 = `http:${e3}` : !t4 && a4 && (i4 = `http://${e3}`), `${escapeHtml(e3)}`; - })); - const g2 = { - message: h2, - messageType: c2, - type: o3, - timestamp: /* @__PURE__ */ new Date(), - id: Date.now() + Math.random() - }; - if (this.logs.push(g2), this.logs.length > n2) { - const e3 = this.logs[0]; - if (s3) { - const t4 = this.content.querySelector(`.console-logger-entry[data-id="${e3.id}"]`); - t4 && (this.logs.shift(), this.content.removeChild(t4)); - } - } - s3 && this.renderLog(g2); - } - log(...e3) { - const [t3, ...n3] = e3; - setTimeout((() => { - this.addLog(t3, "base", ...n3); - }), 0); - } - error(...e3) { - const [t3, ...n3] = e3; - console.error(...e3), setTimeout((() => { - this.addLog(t3, "error", ...n3); - }), 0); - } - warn(...e3) { - const [t3, ...n3] = e3; - setTimeout((() => { - this.addLog(t3, "warn", ...n3); - }), 0); - } - debug(...e3) { - const [t3, ...n3] = e3; - setTimeout((() => { - this.addLog(t3, "debug", ...n3); - }), 0); - } - renderLog(e3) { - if ("none" === this.container.style.display) return; - if (this.window.classList.contains("collapsed")) return; - if (!(t2[this.currentFilter] || []).includes(e3.type)) return; - const n3 = this._createLogElement(e3); - this.content.appendChild(n3), this.window.classList.contains("collapsed") || this.userScrolledUp || (this.content.scrollTop = this.content.scrollHeight); - } - reRenderAllLogs() { - "none" !== this.container.style.display && (this.window.classList.contains("collapsed") || setTimeout((() => { - if (this.content.innerHTML = "", 0 === this.logs.length) return; - const e3 = t2[this.currentFilter] || [], n3 = document.createDocumentFragment(); - this.logs.forEach(((t3) => { - if (e3.includes(t3.type)) { - const e4 = this._createLogElement(t3); - n3.appendChild(e4); + `);let r={base:{label:"信息",background:"var(--jhs-status-fav-tint)",borderLeftColor:"var(--jhs-accent)"},warn:{label:"警告",background:"var(--jhs-status-watch-tint)",borderLeftColor:"var(--jhs-status-watch)"},error:{label:"错误",background:"var(--jhs-status-filter-tint)",borderLeftColor:"var(--jhs-status-filter)"},debug:{label:"调试",background:"var(--jhs-surface-2)",borderLeftColor:"var(--jhs-border-strong)"}},n={base:["base","warn","error"],warn:["warn"],error:["error"],debug:["base","warn","error","debug"]},l=t,c="jhs_clog_maximize",h="jhs_clog_expand",u="jhs_clog_filter",g=class g{constructor(){let f=localStorage.getItem(u);this.currentFilter=f&&r[f]?f:"base",this.logs=[],this.isInitialized=!1,this.userScrolledUp=!1}tryInitialize(){return document.readyState!=="loading"&&(this.isInitialized||(this.init(),this.isInitialized=!0),!0)}init(){this.createContainer(),this.bindEvents(),this.checkInitialMaximizeState(),this.checkInitialCollapseState()}createContainer(){this.container=document.createElement("div"),this.container.className="console-logger-container jhs-ui",this.container.style.display="none",this.toggleBtn=document.createElement("button"),this.toggleBtn.type="button",this.toggleBtn.setAttribute("aria-label","展开或收起运行日志"),this.toggleBtn.className="console-logger-toggle collapsed",this.container.appendChild(this.toggleBtn),window.matchMedia?.("(max-width: 767px)").matches&&(this.toggleBtn.hidden=!0,this.toggleBtn.style.display="none"),this.window=document.createElement("div"),this.window.className="console-logger-window collapsed";let f=document.createElement("div");f.className="console-logger-header";let v=document.createElement("div");v.className="console-logger-title",v.textContent="JHS Console";let b=document.createElement("div");b.className="console-logger-controls",this.maximizeBtn=document.createElement("button"),this.maximizeBtn.textContent="",this.maximizeBtn.classList.add("console-logger-maximize-toggle"),b.appendChild(this.maximizeBtn);let w=document.createElement("button");w.textContent="清空",w.addEventListener("click",(()=>this.clear())),b.appendChild(w),f.appendChild(v),f.appendChild(b),this.filtersContainer=document.createElement("div"),this.filtersContainer.className="console-logger-filters",this.filterButtonGroup=document.createElement("div"),this.filterButtonGroup.className="console-logger-filter-group",this.filtersContainer.appendChild(this.filterButtonGroup),this.scrollToBottomBtn=document.createElement("button"),this.scrollToBottomBtn.className="console-logger-scroll-to-bottom",this.scrollToBottomBtn.textContent="到底部",this.filtersContainer.appendChild(this.scrollToBottomBtn),this.content=document.createElement("div"),this.content.className="console-logger-content jhs-scrollbar",this.window.appendChild(f),this.window.appendChild(this.filtersContainer),this.window.appendChild(this.content),this.container.appendChild(this.window),document.body.appendChild(this.container),Object.keys(r).forEach((S=>{let j=document.createElement("button");j.type="button",j.className="jhs-btn console-logger-filter",S===this.currentFilter&&j.classList.add("active"),j.textContent=r[S].label,j.dataset.type=S,j.addEventListener("click",(()=>this.setFilter(S))),this.filterButtonGroup.appendChild(j)}))}bindEvents(){this.toggleBtn.addEventListener("click",(()=>{this.toggleExpandCollapsed()})),this.maximizeBtn.addEventListener("click",(()=>this.toggleMaximize())),this.scrollToBottomBtn.addEventListener("click",(()=>{this.content.scrollTop=this.content.scrollHeight,this.userScrolledUp=!1})),this.content.addEventListener("scroll",(()=>{let f=this.content.scrollHeight-this.content.clientHeight<=this.content.scrollTop+5;this.userScrolledUp=!f})),this.content.addEventListener("wheel",(f=>{let v=this.content.scrollTop===0,b=this.content.scrollHeight-this.content.clientHeight<=this.content.scrollTop+1;(v&&f.deltaY<0||b&&f.deltaY>0)&&(f.preventDefault(),f.stopPropagation())}),{passive:!1})}toggleExpandCollapsed(){let f=this.window.classList.toggle("collapsed");this.toggleBtn.classList.toggle("collapsed"),f?localStorage.setItem(h,"no"):(localStorage.setItem(h,"yes"),this.reRenderAllLogs())}checkInitialCollapseState(){let f=localStorage.getItem(h);f&&f!=="no"?(this.window.classList.toggle("collapsed"),this.toggleBtn.classList.toggle("collapsed"),setTimeout((()=>{this.content.scrollTop=this.content.scrollHeight}),0)):(this.window.classList.add("collapsed"),this.toggleBtn.classList.add("collapsed"))}checkInitialMaximizeState(){localStorage.getItem(c)==="maximized"&&(this.window.classList.add("maximized"),this.maximizeBtn.classList.add("active"))}toggleMaximize(){let f=this.window.classList.toggle("maximized");this.maximizeBtn.classList.toggle("active",f),f?localStorage.setItem(c,"maximized"):localStorage.setItem(c,"minimized"),this.window.classList.contains("collapsed")||(this.content.scrollTop=this.content.scrollHeight)}addLog(f,v="base",...b){return this._appendLog(!1,f,v,...b)}addHtmlLog(f,v="base",...b){return this._appendLog(!0,f,v,...b)}_appendLog(f,v,b="base",...w){let S=this.tryInitialize(),j,k=[];r[b]?(j=b,k=w):(j="base",k=[b,...w]),j=r[j]?j:"base";let x=[v,...k],_="msg",E=[];x.forEach((P=>{if(Object.prototype.toString.call(P)==="[object Error]")E.push(String(P));else if(typeof P=="object"&&P!==null)try{E.push("
"+JSON.stringify(P,null,2)),_="json"}catch{E.push(String(P)),_="msg"}else E.push(String(P))}));let L=E.join(" ");f&&(L=L.replace(/(?:(?:https?|ftp):\/\/|www\.|(?:\/\/))[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]/gi,(P=>{let z=P.startsWith("http")||P.startsWith("ftp"),O=P.startsWith("//"),H=P.startsWith("www."),Q=P;return O?Q=`http:${P}`:!z&&H&&(Q=`http://${P}`),`${C(P)}`})));let M={message:L,messageType:_,type:j,html:f,timestamp:new Date,id:Date.now()+Math.random()};if(this.logs.push(M),this.logs.length>l){this.logs.shift();let P=this.content.querySelector(".console-logger-entry");P&&P.remove()}S&&this.renderLog(M)}log(...f){let[v,...b]=f;setTimeout((()=>{this.addLog(v,"base",...b)}),0)}html(...f){let[v,...b]=f;setTimeout((()=>{this.addHtmlLog(v,"base",...b)}),0)}htmlDebug(...f){let[v,...b]=f;setTimeout((()=>{this.addHtmlLog(v,"debug",...b)}),0)}error(...f){let[v,...b]=f;console.error(...f),setTimeout((()=>{this.addLog(v,"error",...b)}),0)}warn(...f){let[v,...b]=f;setTimeout((()=>{this.addLog(v,"warn",...b)}),0)}debug(...f){let[v,...b]=f;setTimeout((()=>{this.addLog(v,"debug",...b)}),0)}renderLog(f){if(this.container.style.display==="none"||this.window.classList.contains("collapsed")||!(n[this.currentFilter]||[]).includes(f.type))return;let v=this._createLogElement(f);this.content.appendChild(v),this.window.classList.contains("collapsed")||this.userScrolledUp||(this.content.scrollTop=this.content.scrollHeight)}reRenderAllLogs(){this.container.style.display!=="none"&&(this.window.classList.contains("collapsed")||setTimeout((()=>{if(this.content.innerHTML="",this.logs.length===0)return;let f=n[this.currentFilter]||[],v=document.createDocumentFragment();this.logs.forEach((b=>{if(f.includes(b.type)){let w=this._createLogElement(b);v.appendChild(w)}})),this.content.appendChild(v),this.content.scrollTop=this.content.scrollHeight}),0))}_createLogElement(f){let v=document.createElement("div");v.className="console-logger-entry",v.dataset.type=f.type,v.dataset.id=f.id;let b=r[f.type]||r.base;v.style.borderLeft="3px solid "+b.borderLeftColor,v.style.background=b.background;let w=(f.timestamp instanceof Date?f.timestamp:new Date(f.timestamp)).toTimeString().split(" ")[0],S=document.createElement("span");S.className="console-logger-timestamp",S.textContent=`[${w}]`;let j=document.createElement("span");if(j.className="console-logger-message",j.dataset.type=f.messageType,f.html)j.innerHTML=f.message;else{let k=f.message.replace(//gi,` +`),x=/(?:(?:https?|ftp):\/\/|www\.|(?:\/\/))[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]/gi,_=0,E;for(;E=x.exec(k);){E.index>_&&j.appendChild(document.createTextNode(k.slice(_,E.index)));let L=E[0],M=L;L.startsWith("//")?M=`http:${L}`:L.startsWith("www.")&&(M=`http://${L}`);let P=document.createElement("a");P.href=M,P.target="_blank",P.textContent=L,j.appendChild(P),_=E.index+E[0].length}_{v.dataset.type===f?v.classList.add("active"):v.classList.remove("active")})),this.reRenderAllLogs())}clear(){this.logs=[],this.content.innerHTML=""}openDialog(){if(!this.tryInitialize()||!this.window||typeof layer>"u")return;let f=document.createElement("div"),v=this.window.parentNode,b=this.window.getAttribute("style");f.className="jhs-ui jhs-logger-dialog",f.style.height="100%",this.window.classList.remove("collapsed","maximized"),Object.assign(this.window.style,{width:"100%",height:"100%",border:"0",borderRadius:"0",boxShadow:"none"}),this.reRenderAllLogs(),layer.open({type:1,title:"JHS 运行日志",content:f.outerHTML,area:utils.getDialogArea("md"),anim:-1,success:d(w=>{$(w).find(".jhs-logger-dialog")[0]?.appendChild(this.window),this.content.scrollTop=this.content.scrollHeight},"success"),end:d(()=>{v?.appendChild(this.window),b==null?this.window.removeAttribute("style"):this.window.setAttribute("style",b),this.window.classList.add("collapsed"),this.toggleBtn.classList.add("collapsed")},"end")})}show(){(this.isInitialized&&this.container||this.tryInitialize()&&this.container)&&(this.container.style.display="",this.reRenderAllLogs())}hide(){this.isInitialized&&this.container&&(this.container.style.display="none")}lowZIndex(){this.isInitialized&&this.container&&(this.container.style.zIndex=String(J.debugLow))}highZIndex(){this.isInitialized&&this.container&&(this.container.style.zIndex=String(J.debug))}};d(g,"o");let p=g;try{unsafeWindow.parent!==unsafeWindow&&unsafeWindow.parent.clog&&typeof unsafeWindow.parent.clog.log=="function"?s=unsafeWindow.parent.clog:s=new p}catch(m){console.error("创建日志控制台出现异常",m),s=new p}(function(){let m=d(()=>window.clog||console,"resolveClog");o.listen(window,"error",(function(f){let v=f.filename,b=f.message;typeof v=="string"&&(v.includes("javdb")||v.includes("javbus"))||m().error(`[全局 Error 异常捕获] ${b} 来源: ${v??"未知"}`)})),o.listen(window,"unhandledrejection",(function(f){let v=m(),b=f.reason,w=b?.message??"";if(["NotAllowedError","AbortError","NotSupportedError"].includes(b?.name)||w.includes("play()")||w.includes("The element has no supported sources"))return v.warn("[全局媒体播放异常] 当前媒体源无法播放",b),void f.preventDefault();if(w.includes("1005")&&w.includes("fc2ppvdb"))return;let S=`[全局 Promise 异常捕获] ${b.message||b}`;v.error(S,b),f.preventDefault()}))})(),o.listen(document,"mousedown",(m=>{let f=window.clog;if(!f.isInitialized||!f.container)return;let v=m.target,b=[".console-logger-container",".layui-layer-shade",".loading-container"].join(",");v.closest(b)?f.highZIndex():f.lowZIndex()}))})(),(function(){function r(l,c,h){let u=(function(E){let L=document.createElement("div");L.classList.add("js-tooltip");let M=document.createElement("div");return M.innerHTML=C(E),L.appendChild(M),document.body.appendChild(L),L})(c);u.style.display="block";let p=l.getBoundingClientRect(),g=u.getBoundingClientRect();u.style.display="none";let m=window.innerWidth,f=window.innerHeight,v,b,w=h,S=d(E=>E>=8&&E+g.height<=f-8,"h"),j=d(E=>E>=8&&E+g.width<=m-8,"g"),k=p.left+p.width/2-g.width/2,x=p.top+p.height/2-g.height/2;switch(h){case"top":b=p.top-g.height-0,b<8&&S(p.bottom+0)&&(b=p.bottom+0,w="bottom");break;case"bottom":b=p.bottom+0,b+g.height>f-8&&S(p.top-g.height-0)&&(b=p.top-g.height-0,w="top");break;case"left":v=p.left-g.width-0,v<8&&j(p.right+0)&&(v=p.right+0,w="right");break;case"right":v=p.right+0,v+g.width>m-8&&j(p.left-g.width-0)&&(v=p.left-g.width-0,w="left")}w==="top"||w==="bottom"?(v=k,v<8?v=8:v+g.width>m-8&&(v=m-g.width-8)):(w==="left"||w==="right")&&(b=x,b<8?b=8:b+g.height>f-8&&(b=f-g.height-8)),u.style.left=`${v}px`,u.style.top=`${b}px`,u.classList.add("is-active"),l.tooltipElement=u}d(r,"e"),document.head.insertAdjacentHTML("beforeend",` + \n "); - const t2 = "[data-tip-top], [data-tip-bottom], [data-tip-left], [data-tip-right], [data-tip]"; - document.addEventListener("mouseover", ((n2) => { - const a2 = n2.target.closest(t2); - if (a2 && !a2.tooltipElement) { - let t3, n3 = "top"; - if (a2.hasAttribute("data-tip-bottom") ? (t3 = a2.getAttribute("data-tip-bottom"), n3 = "bottom") : a2.hasAttribute("data-tip-left") ? (t3 = a2.getAttribute("data-tip-left"), n3 = "left") : a2.hasAttribute("data-tip-right") ? (t3 = a2.getAttribute("data-tip-right"), n3 = "right") : a2.hasAttribute("data-tip-top") ? (t3 = a2.getAttribute("data-tip-top"), n3 = "top") : a2.hasAttribute("data-tip") && (t3 = a2.getAttribute("data-tip"), n3 = "top"), !t3) return; - a2.hoverTimeout = setTimeout((() => { - a2.matches(":hover") && !a2.tooltipElement && e2(a2, t3, n3); - }), 50); - } - })), document.addEventListener("mouseout", ((e3) => { - const n2 = e3.target.closest(t2); - var a2; - n2 && (n2.hoverTimeout && (clearTimeout(n2.hoverTimeout), n2.hoverTimeout = null), n2.contains(e3.relatedTarget) || n2.tooltipElement && ((a2 = n2.tooltipElement) && a2.parentNode && a2.remove(), n2.tooltipElement = null)); - })); - })(); - var _PluginManager = class _PluginManager { - constructor() { - this.plugins = /* @__PURE__ */ new Map(); - this._errorLog = []; - this._lastTimings = []; - this._startedAt = performance.now(); - this._registrationMs = 0; - this._cssMs = 0; - this._immediateMs = 0; - this._readyMs = 0; - this._idlePending = 0; - this._idleCompleted = 0; - this._disabledPluginsPromise = null; - } - register(e2) { - if ("function" != typeof e2) throw new Error("插件必须是一个类"); - const a2 = performance.now(); - const t2 = new e2(); - t2.pluginManager = this; - const n2 = t2.getName(); - if (this.plugins.has(n2)) throw new Error(`插件"${n2}"已注册`); - this.plugins.set(n2, t2); - this._registrationMs += performance.now() - a2; - } - getBean(e2) { - return this.plugins.get(e2); - } - _addError(e2, t2, n2) { - this._errorLog.push({ - time: (/* @__PURE__ */ new Date()).toISOString(), - plugin: e2, - phase: t2, - message: n2?.message || String(n2), - stack: n2?.stack || "" - }); - this._errorLog.length > 200 && this._errorLog.shift(); - } - getErrorLog() { - return [...this._errorLog]; - } - clearErrorLog() { - this._errorLog = []; - } - getTimings() { - return [...this._lastTimings]; - } - getPluginNames() { - return Array.from(this.plugins.keys()); - } - getStartupReport() { - return { - registeredPlugins: this.plugins.size, - registrationMs: this._registrationMs, - cssMs: this._cssMs, - immediateMs: this._immediateMs, - readyMs: this._readyMs, - idlePending: this._idlePending, - idleCompleted: this._idleCompleted - }; - } - async _getDisabledPlugins() { - return this._disabledPluginsPromise || (this._disabledPluginsPromise = (async () => { - try { - const e2 = await storageManager.getSetting("disabledPlugins", "[]"); - return new Set(JSON.parse(e2).filter(((name) => !["SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin"].includes(name)))); - } catch (e2) { - return /* @__PURE__ */ new Set(); - } - })()); - } - async processCss() { - const a2 = performance.now(); - const t2 = await this._getDisabledPlugins(); - const m2 = utils.isMobileMode(); - const s2 = await Promise.all(Array.from(this.plugins).map((async ([e2, n2]) => { - try { - if (t2.has(e2)) return { name: e2, status: "disabled" }; - if (m2 && "function" == typeof n2.shouldSkipOnMobile && n2.shouldSkipOnMobile()) return { name: e2, status: "skipped" }; - if ("function" == typeof n2.initCss) { - const t3 = await n2.initCss(); - return { - name: e2, - status: "fulfilled", - css: t3 - }; - } - return { name: e2, status: "skipped" }; - } catch (a3) { - return clog.error(`插件 ${e2} 加载 CSS 失败`, a3), this._addError(e2, "initCss", a3), { - name: e2, - status: "rejected", - error: a3 - }; - } - }))); - const o2 = s2.map(((e2) => e2.css)).filter(Boolean); - o2.length > 0 && utils.insertStyle(o2); - this._cssMs = performance.now() - a2; - } - async _runPlugin(e2) { - const t2 = performance.now(); - try { - if ("function" == typeof e2.plugin.handle) await e2.plugin.handle(); - e2.timing.elapsed = performance.now() - t2; - e2.timing.status = "ok"; - } catch (n2) { - e2.timing.elapsed = performance.now() - t2; - e2.timing.status = "error"; - e2.timing.error = n2?.message || String(n2); - clog.error(`插件 ${e2.name} 执行失败`, n2); - this._addError(e2.name, "idle" === e2.mode ? "handle-idle" : "handle", n2); - } - } - _scheduleIdle(e2) { - const t2 = /* @__PURE__ */ __name(() => e2().catch(((e3) => { - clog.error("[JHS] 空闲插件执行失败:", e3); - })), "t"); - "function" == typeof requestIdleCallback ? requestIdleCallback(t2, { timeout: 1500 }) : setTimeout(t2, 100); - } - async _runIdlePlugins(e2) { - for (const t2 of e2) { - t2.startedAt = performance.now(); - await this._runPlugin(t2); - this._idlePending--; - this._idleCompleted++; - } - } - async processPlugins() { - const e2 = await this._getDisabledPlugins(), t2 = utils.isMobileMode(), n2 = [], a2 = [], i2 = []; - for (const [s3, o2] of this.plugins) { - const r2 = "function" == typeof o2.getStartupMode && "idle" === o2.getStartupMode() ? "idle" : "immediate"; - const l2 = { name: s3, elapsed: 0, status: "pending", startupMode: r2 }; - if (e2.has(s3)) l2.status = "disabled", i2.push(l2); - else if (t2 && "function" == typeof o2.shouldSkipOnMobile && o2.shouldSkipOnMobile()) l2.status = "skipped-mobile", i2.push(l2); - else { - const e3 = { name: s3, plugin: o2, mode: r2, timing: l2, startedAt: 0 }; - "idle" === r2 ? (l2.status = "pending-idle", a2.push(e3)) : n2.push(e3), i2.push(l2); - } - } - this._lastTimings = i2; - const s2 = performance.now(); - await Promise.all(n2.map(((e3) => { - e3.startedAt = performance.now(); - return this._runPlugin(e3); - }))); - this._immediateMs = performance.now() - s2; - for (const e3 of n2) { - if ("function" != typeof e3.plugin.afterPluginsReady) continue; - try { - await e3.plugin.afterPluginsReady(); - } catch (t3) { - clog.error(`插件 ${e3.name} 完成初始化后执行失败`, t3), this._addError(e3.name, "after-ready", t3); - } - } - this._readyMs = performance.now() - this._startedAt; - this._idlePending = a2.length; - a2.length && this._scheduleIdle((() => this._runIdlePlugins(a2))); - } - }; - __name(_PluginManager, "PluginManager"); - var PluginManager = _PluginManager; - var _BasePlugin = class _BasePlugin { - constructor() { - if (!_BasePlugin._sharedIcons) { - i(this, "pluginManager", null), i(this, "settingSvg", ''), i(this, "editSvg", ''), i(this, "deleteSvg", ''), i(this, "checkSvg", ''), i(this, "actressSvg", ''), i(this, "newSvg", ''), i(this, "refreshSvg", ''), i(this, "blacklistSvg", ''), i(this, "copySvg", ''), i(this, "titleSvg", ''), i(this, "carNumSvg", ''), i(this, "downSvg", ''), i(this, "handleSvg", ''), i(this, "siteSvg", ''), i(this, "videoSvg", ''), i(this, "screenSvg", ''), i(this, "recoveryVideoSvg", ''); - const e2 = ["settingSvg", "editSvg", "deleteSvg", "checkSvg", "actressSvg", "newSvg", "refreshSvg", "blacklistSvg", "copySvg", "titleSvg", "carNumSvg", "downSvg", "handleSvg", "siteSvg", "videoSvg", "screenSvg", "recoveryVideoSvg"]; - _BasePlugin._sharedIcons = Object.fromEntries(e2.map(((e3) => [e3, this[e3]]))); - Object.assign(_BasePlugin.prototype, _BasePlugin._sharedIcons); - e2.forEach(((e3) => delete this[e3])); - } - i(this, "pluginManager", null); - } - getName() { - throw new Error(`${this.constructor.name} 未显示getName()`); - } - getBean(e2) { - return this.pluginManager.getBean(e2); - } - async initCss() { - return ""; - } - async handle() { - } - getStartupMode() { - return "immediate"; - } - shouldSkipOnMobile() { - return false; - } - getPageInfo() { - let carNum = null, t2, n2, a2, i2, s2 = window.location.href; - if (r) { - const params = new URL(s2).searchParams, injectedCarNum = normalizeCarNum(params.get("jhsCarNum")); - const copyCarNum = normalizeCarNum($('.column-video-info a[data-clipboard-text][title*="番"], .video-detail a[data-clipboard-text][title*="番"]').first().attr("data-clipboard-text")) || normalizeCarNum($('a[title="複製番號"]').attr("data-clipboard-text")); - let panelCarNum = null; - $(".column-video-info .panel-block, .video-detail .panel-block").each((function() { - const label = $(this).find("strong, .label").first().text().trim(); - if (panelCarNum || !/(?:番号|番號|^ID)\s*[::]?/i.test(label)) return; - panelCarNum = normalizeCarNum($(this).find("[data-clipboard-text]").first().attr("data-clipboard-text")) || normalizeCarNum($(this).find(".value").first().text()); - })); - const fallbackCarNum = normalizeCarNum($("#video_id, .video-id, .video-title strong").first().text()); - carNum = firstValidCarNum(injectedCarNum, copyCarNum, panelCarNum, fallbackCarNum); - t2 = s2.split("?")[0].split("#")[0], n2 = $(".female").prev().map(((e2, t3) => $(t3).text())).get().join(" "), a2 = $(".male").prev().map(((e2, t3) => $(t3).text())).get().join(" "), i2 = $('strong:contains("日期:")').parent(".panel-block").find(".value").text().trim(); - } - l && (t2 = s2.split("?")[0], carNum = normalizeCarNum(t2.split("/").filter(Boolean).pop().replace(/_\d{4}-\d{2}-\d{2}$/, "")), n2 = $('span[onmouseover*="star_"] a').map(((e2, t3) => $(t3).text())).get().join(" "), a2 = "", i2 = $('span.header:contains("發行日期:")').parent("p").text().trim().replace("發行日期:", "").trim()); - return assertPageInfoContract({ - carNum, - url: t2, - actress: n2, - actors: a2, - publishTime: i2 - }); - } - getActressId() { - const e2 = o.match(/\/actors\/([^/?]+)/); - return e2 && e2.length > 1 ? e2[1] : null; - } - getActressPageInfo() { - let e2 = window.location.href; - if (!e2.includes("/actors/") && !e2.includes("/star/")) throw new Error("接口调用错误, 非演员详情页"); - let t2 = [], n2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - n2.length && n2.text().trim().split(",").forEach(((e3) => { - t2.push(e3.trim()); - })); - let a2 = $(".section-meta:not(:contains('影片'))"); - a2.length && a2.text().trim().split(",").forEach(((e3) => { - t2.push(e3.trim()); - })); - let i2 = $(".section-meta:contains('男優')").length > 0 ? B : P, s2 = D; - t2.some(((e3) => e3.includes("無碼"))) && (s2 = A), e2.includes("uncensored") && (s2 = A); - let o2 = null, c2 = null; - const d2 = new URL(e2); - if (r) { - c2 = d2.pathname.split("/").filter(((e4) => "" !== e4.trim())).pop(); - const e3 = d2.searchParams; - e3.delete("sort_type"), e3.delete("page"), o2 = d2.toString(); - } else if (l) { - const t3 = "/star/", n3 = e2.split(t3); - if (n3.length < 2) throw new Error("提取演员url失败"); - const a3 = n3[0]; - c2 = n3[1].split("/")[0], o2 = a3 + t3 + c2; - } - return { - starId: c2, - name: t2[0], - allName: t2, - role: i2, - movieType: s2, - blacklistUrl: o2 - }; - } - getSelector(e2) { - const t2 = e2 || (r ? T : l ? I : null), n2 = { - javdb: { - boxSelector: ".movie-list", - itemSelector: ".movie-list .item", - coverImgSelector: ".cover img", - requestDomItemSelector: ".movie-list .item", - nextPageSelector: ".pagination-next" - }, - javbus: { - boxSelector: ".masonry", - itemSelector: ".masonry .item", - coverImgSelector: ".masonry .movie-box .photo-frame img", - requestDomItemSelector: "#waterfall .item", - nextPageSelector: "#next" - } - }; - if (!t2 || !n2[t2]) throw new Error("类型错误: 无法确定选择器类型 (JavDb 或 JavBus)"); - return n2[t2]; - } - parseMovieId(e2) { - return e2.split("/").pop().split(/[?#]/)[0]; - } - getBoxCarInfo(e2) { - var t2, n2, a2; - const i2 = e2.find("a"), s2 = i2.attr("href"); - let o2 = null, r2 = null, l2 = null; - const c2 = e2.find(".video-title"); - if (c2.length > 0) { - const n3 = c2.find("strong"); - if (n3.length > 0 && (o2 = n3.text().trim()), r2 = null == (t2 = i2.attr("title")) ? void 0 : t2.trim(), !r2) { - const e3 = c2.text().trim(); - r2 = o2 && e3.includes(o2) ? e3.replace(o2, "").trim() : e3; - } - l2 = e2.find(".meta").text().trim(); - } - if (!o2) { - const t3 = e2.find("img"); - t3.length > 0 && (r2 = (null == (n2 = t3.attr("title")) ? void 0 : n2.trim()) || (null == (a2 = t3.attr("data-title")) ? void 0 : a2.trim())); - const i3 = e2.find("date").map(((e3, t4) => $(t4).text().trim())).get(), s3 = /* @__PURE__ */ __name((e3) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e3), "s"); - l2 = i3.find(s3) || null, o2 = i3.find(((e3) => !s3(e3))) || null; - } - if (!o2) { - const t3 = "提取番号信息失败: carNum 为空"; - throw clog.error("Error in getBoxCarInfo:", t3, "Box Element:", e2.get(0)), show.error(t3), new Error(t3); - } - return { - carNum: o2, - url: s2 || "", - title: r2 || "", - publishTime: l2 || "" - }; - } - getBoxCarInfoList(e2 = null) { - if (e2 || (e2 = $(this.getSelector().itemSelector)), 0 === e2.length) return clog.error("获取当前列表页所有item的番号信息失败!"), []; - const t2 = []; - return e2.each(((e3, n2) => { - const a2 = $(n2); - try { - const e4 = this.getBoxCarInfo(a2); - t2.push(e4); - } catch (i2) { - clog.error("[getBoxCarInfoList] 提取单个 boxCar 信息失败:", i2.message, "元素索引:", e3); - } - })), t2; - } - checkDuplicateCarNumbers(e2, t2) { - if (!e2 || 0 === e2.length || !t2 || 0 === t2.length) return false; - const n2 = new Set(e2.map(((e3) => e3.carNum)).filter(((e3) => e3))); - if (0 === n2.size) return false; - let a2 = 0; - for (let i2 = 0; i2 < t2.length; i2++) { - const e3 = t2[i2] ? t2[i2].carNum : null; - if (e3 && n2.has(e3)) { - if (a2++, a2 >= 2) return clog.warn("警告: 检测到连续番号信息重复, 该类别可能已被限制页码。"), true; - } else a2 = 0; - } - return false; - } - }; - __name(_BasePlugin, "BasePlugin"); - var BasePlugin = _BasePlugin; - function normalizeJavStoreAssetUrl(value, baseUrl = "https://javstore.net") { - if (!value) return null; - try { - const url = new URL(String(value), baseUrl), hostname = url.hostname.toLowerCase(); - if (!["http:", "https:"].includes(url.protocol)) return null; - "http:" === url.protocol && ("javstore.net" === hostname || hostname.endsWith(".javstore.net")) && (url.protocol = "https:"); - return url.href; - } catch { - return null; - } - } - __name(normalizeJavStoreAssetUrl, "normalizeJavStoreAssetUrl"); - function parseJavStoreSearch($searchPage, carNum, baseUrl = "https://javstore.net") { - const normalizedCarNum = normalizeCarNum(carNum); - if (!normalizedCarNum) return []; - return $searchPage.find('a[href$="-pn.html"]').filter(((index, element) => $(element).text().trim().toUpperCase().includes(normalizedCarNum.toUpperCase()))).map(((index, element) => new URL($(element).attr("href"), baseUrl).href)).get(); - } - __name(parseJavStoreSearch, "parseJavStoreSearch"); - function parseJavStorePreview($detailPage, detailUrl) { - const previewHref = $detailPage.find("a").filter(((index, element) => "CLICK HERE!" === $(element).text().trim())).first().attr("href"); - if (!previewHref) return null; - const previewUrl = normalizeJavStoreAssetUrl(previewHref, detailUrl); - return previewUrl ? previewUrl.replace(".th", "") : null; - } - __name(parseJavStorePreview, "parseJavStorePreview"); - function parseJavDbActorList($page, baseUrl) { - const challengeText = $page.find("title, body").text(); - if (/Just a moment|cf-chl-|Cloudflare/i.test(challengeText)) return { state: "challenge", isEmpty: false, actors: [], nextUrl: null }; - const container = $page.find("#actors").first(); - if (!container.length) return { state: "invalid", isEmpty: false, actors: [], nextUrl: null }; - const actors = [], boxes = container.find(".actor-box").toArray(); - try { - for (const box of boxes) { - const $actor = $(box).find("a").first(), title = $actor.attr("title"), href = $actor.attr("href"); - if (!title || !href) throw new Error("演员卡缺少身份字段"); - const allName = title.split(",").map(((name) => name.trim())).filter(Boolean), actorUrl = new URL(href, baseUrl); - const starId = actorUrl.pathname.split("/").filter(Boolean).pop() || ""; - if (!starId || !allName.length) throw new Error("演员卡身份字段无效"); - actors.push({ - starId, - name: allName[0], - allName, - avatar: $actor.find("img").attr("src"), - actressType: $actor.find(".info").text().trim().includes("無碼") ? A : D, - lastCheckTime: null, - lastUpdateTime: null - }); - } - const nextHref = $page.find(".pagination-next").attr("href"), nextUrl = nextHref ? new URL(nextHref, baseUrl).href : null; - return { state: "valid", isEmpty: 0 === boxes.length, actors, nextUrl }; - } catch (error) { - return { state: "invalid", isEmpty: false, actors: [], nextUrl: null }; - } - } - __name(parseJavDbActorList, "parseJavDbActorList"); - function parseDetailPage($page, selectors) { - const challengeText = $page.find("title, body").text(); - const isChallenge = /Just a moment|cf-chl-|Cloudflare/i.test(challengeText); - const hasContainer = $page.find(selectors.boxSelector).length > 0; - return { - state: isChallenge ? "challenge" : hasContainer ? "valid" : "invalid", - items: hasContainer ? $page.find(selectors.requestDomItemSelector) : null, - isEmpty: hasContainer && 0 === $page.find(selectors.requestDomItemSelector).length - }; - } - __name(parseDetailPage, "parseDetailPage"); - function parse123AvCards($page, baseUrl = "https://123av.com") { - const items = /* @__PURE__ */ new Map(); - $page.find(".card").each(((index, element) => { - const $card = $(element); - const $link = $card.find('a.card__link[href*="/cn/v/fc2-ppv-"]').first(); - const href = $link.attr("href"), text = $link.text().trim(); - const numberMatch = text.match(/FC2-PPV-(\d+)/i); - if (!href || !numberMatch) return; - const carNum = `FC2-${numberMatch[1]}`; - const detailUrl = new URL(href, baseUrl); - detailUrl.hash = ""; - items.set(carNum, { - imgSrc: $card.find("img.card__img[src]").first().attr("src") || "", - carNum, - href: detailUrl.href, - title: text.replace(/^FC2-PPV-\d+\s*[—–-]\s*/i, "").trim(), - preview: $card.find(".card__poster[data-preview]").first().attr("data-preview") || null - }); - })); - return [...items.values()]; - } - __name(parse123AvCards, "parse123AvCards"); - function merge123AvCards(cardLists) { - const items = /* @__PURE__ */ new Map(); - cardLists.flat().forEach(((item) => items.set(item.carNum, item))); - return [...items.values()]; - } - __name(merge123AvCards, "merge123AvCards"); - function parse123AvSourceMaxPage($page, baseUrl = "https://123av.com") { - const lastHref = $page.find('a[rel="last"]').first().attr("href"); - if (lastHref) { - const page = Number.parseInt(new URL(lastHref, baseUrl).searchParams.get("page"), 10); - if (Number.isFinite(page)) return page; - } - const inputMax = Number.parseInt($page.find("input.pager__input[max]").first().attr("max"), 10); - if (Number.isFinite(inputMax)) return inputMax; - const totalMatch = $page.find(".pager__total").first().text().match(/(\d[\d,]*)/); - return totalMatch ? Number.parseInt(totalMatch[1].replaceAll(",", ""), 10) : null; - } - __name(parse123AvSourceMaxPage, "parse123AvSourceMaxPage"); - function parse123AvVideoInfo($page, url) { - const idMatch = new URL(url).pathname.match(/fc2-ppv-(\d+)/i); - const id = idMatch ? idMatch[1] : null; - const rawTitle = $page.find("h1.watch__title").first().text().trim(); - const title = id ? rawTitle.replace(new RegExp(`^FC2-PPV-${id}\\s*[—–-]\\s*`, "i"), "").trim() : rawTitle; - const pageText = $page.find("body").text(); - const publishDate = pageText.match(/发布日期\s*(\d{4}-\d{2}-\d{2})/)?.[1] || ""; - return { id, publishDate, title, moviePoster: null }; - } - __name(parse123AvVideoInfo, "parse123AvVideoInfo"); - var _DetailPagePlugin = class _DetailPagePlugin extends BasePlugin { - getName() { - return "DetailPagePlugin"; - } - constructor() { - super(); - } - handle() { - window.isDetailPage && $(".video-meta-panel a").each((function() { - const e2 = $(this).attr("href"); - e2 && (e2.startsWith("http://") || e2.startsWith("https://") || e2.startsWith("/")) && $(this).attr("target", "_blank"); - })); - } - }; - __name(_DetailPagePlugin, "DetailPagePlugin"); - var DetailPagePlugin = _DetailPagePlugin; - var Z = /* @__PURE__ */ __name((e2, t2) => { - if (!e2 || 0 === e2.length) return null; - const n2 = new Set(e2); - if (n2.has(t2)) return t2; - const a2 = L.map(((e3) => e3.quality)).reverse(); - for (const i2 of a2) if (n2.has(i2)) return i2; - return e2[0]; - }, "Z"); - var ee = "jhs_dmm_video"; - var _DmmPreviewParser = class _DmmPreviewParser { - constructor(e2) { - this.carNum = e2, this.lastError = null; - } - _checkCache() { - const e2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; - return e2[this.carNum] ? (clog.debug("缓存中存在预览视频信息", e2[this.carNum]), e2[this.carNum]) : null; - } - _updateCache(e2) { - const t2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; - t2[this.carNum] = e2, clog.debug("成功解析出预览视频并已缓存:", e2), localStorage.setItem(ee, JSON.stringify(t2)); - } - async _searchContentIds() { - const e2 = this.carNum, t2 = e2.replace(/-/g, ""), n2 = [{ - keyword: e2.replace("-", "00"), - name: "00-替换关键词" - }, { - keyword: e2, - name: "原始番号关键词" - }, { - keyword: t2, - name: "无连字符关键词" - }], a2 = e2.toLowerCase(); - let hadSuccessfulRequest = false; - for (const o2 of n2) { - const { keyword: e3, name: n3 } = o2, i3 = e3.toLowerCase(); - clog.debug(`--- 尝试使用 ${n3} (${e3}) 进行 API 搜索 ---`); - const r2 = `https://api.dmm.com/affiliate/v3/ItemList?${new URLSearchParams({ - api_id: "UrwskPfkqQ0DuVry2gYL", - affiliate_id: "10278-996", - output: "json", - site: "FANZA", - sort: "match", - keyword: e3 - }).toString()}`; - let l2; - try { - l2 = await gmHttp.get(r2); - hadSuccessfulRequest = true; - } catch (s2) { - this.lastError = new ProviderError("dmm", "HTTP_ERROR", `DMM API 请求失败: ${s2.message || s2}`, { - cause: s2, - url: r2, - status: s2?.status, - retryable: true - }), clog.error(`API 请求失败,跳过 ${n3}:`, this.lastError); - continue; - } - if (!l2 || !l2.result || !l2.result.result_count) { - clog.debug("API 返回无结果,尝试下一个关键词。"); - continue; - } - const c2 = []; - for (const s2 of l2.result.items) { - if (c2.length >= 2) break; - const e4 = s2.content_id || "", o3 = s2.maker_product || ""; - (e4.includes(i3.replace("-", "")) || a2 === o3.toLowerCase() || e4.includes(t2.toLowerCase())) && (c2.push({ - serviceCode: s2.service_code, - floorCode: s2.floor_code, - contentId: e4, - pageUrl: s2.URL - }), clog.debug(`[${n3}] cid|makerProduct 匹配成功:`, e4, o3)); - } - if (c2.length > 0) { - clog.debug(`--- 成功通过 ${n3} 找到 Content IDs ---`); - const t3 = $("#fanzaBtn"); - let a3 = `https://www.dmm.co.jp/search/=/searchstr=${e3}`, i4 = "single"; - c2.length > 1 ? (t3.attr("href", a3), t3.append('多结果'), t3.css("backgroundColor", "var(--jhs-status-down)"), i4 = "multiple") : (a3 = c2[0].pageUrl, t3.attr("href", a3), t3.css("backgroundColor", "var(--jhs-status-down)")); - const s2 = "jhs_other_site_dmm", o3 = localStorage.getItem(s2) ? JSON.parse(localStorage.getItem(s2)) : {}; - return o3[this.carNum] = { - type: i4, - url: a3 - }, localStorage.setItem(s2, JSON.stringify(o3)), c2; - } - clog.debug(`[${n3}] API 返回结果数 ${l2.result.result_count},但无精确匹配的 Content ID。`); - } - hadSuccessfulRequest && (this.lastError = null); - clog.warn("所有关键词尝试均未找到匹配的Content ID, 解析Dmm视频失败"); - const i2 = $("#fanzaBtn"); - return i2.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), i2.attr("title", "未查询到, 点击前往搜索页"), i2.css("backgroundColor", "var(--jhs-status-filter)"), null; - } - async _extractTrailerLinks({ contentId: e2, serviceCode: t2, floorCode: n2 }) { - const a2 = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${e2}/mtype=AhRVShI_/service=${t2}/floor=${n2}/mode=/`, i2 = await gmHttp.get(a2, null, { - "accept-language": "ja-JP,ja;q=0.9", - Cookie: "age_check_done=1" - }); - if ("string" != typeof i2) throw clog.error(i2), new ProviderError("dmm", "PARSE_ERROR", "解析播放页内容失败, 非文本内容", { - url: a2 - }); - if (i2.includes("このサービスはお住まいの地域からは")) throw new ProviderError("dmm", "REGION_BLOCKED", "DMM 预览源不可用,请将 DMM 域名分流到日本 IP", { - url: a2 - }); - const s2 = i2.match(/const\s+args\s+=\s+(.*);/); - if (!s2) throw new ProviderError("dmm", "PARSE_ERROR", "未在脚本中找到 const args = ... 变量", { - url: a2 - }); - let o2; - try { - ({ bitrates: o2 } = JSON.parse(s2[1])); - } catch (d2) { - throw new ProviderError("dmm", "PARSE_ERROR", `解析播放器脚本 JSON 失败: ${d2.message}`, { - cause: d2, - url: a2 - }); - } - const r2 = {}, l2 = L.map(((e3) => e3.quality)).join("|"), c2 = new RegExp(`(${l2})\\.mp4$`); - if (!Array.isArray(o2)) throw clog.error("解析画质链接失败: bitrates 字段不是一个数组或不存在"), new ProviderError("dmm", "PARSE_ERROR", "解析画质链接失败: bitrates 字段不是一个数组或不存在", { - url: a2 - }); - clog.debug("原始数据返回:", o2); - for (const h2 of o2) { - const e3 = null == h2 ? void 0 : h2.src; - if (!e3 || "string" != typeof e3 || !e3.endsWith(".mp4")) continue; - const t3 = e3.match(c2); - let n3 = ""; - t3 && t3[1] && (n3 = t3[1]), n3 && !r2[n3] && (r2[n3] = e3); - } - if (0 === Object.keys(r2).length) throw new ProviderError("dmm", "PARSE_ERROR", "未找到匹配要求的预览画质视频", { - url: a2 - }); - return r2; - } - async fetchVideo() { - const carNum = normalizeCarNum(this.carNum); - if (!carNum) return clog.warn("跳过 DMM 解析:番号不可用"), null; - this.carNum = carNum; - const e2 = this._checkCache(); - if (e2) return e2; - let t2; - try { - const e3 = this.carNum.toLowerCase(); - if (e3.startsWith("heyzo") || /^(n\d+|\d+(-\d+)*)$/.test(e3) || /^n\d+$/.test(e3)) return clog.debug("无码番号类型,取消 DMM 解析"), null; - if (this.carNum.includes("VR-")) return clog.debug("VR 类型,取消 DMM 解析"), null; - t2 = await this._searchContentIds(); - } catch (n2) { - this.lastError = n2 instanceof ProviderError ? n2 : new ProviderError("dmm", "PARSE_ERROR", n2.message || String(n2), { - cause: n2 - }), clog.error("DMM API 搜索失败:", this.lastError); - const e3 = $("#fanzaBtn"); - return e3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), e3.attr("title", "未查询到, 点击前往搜索页"), e3.css("backgroundColor", "var(--jhs-status-filter)"), null; - } - if (!t2 || 0 === t2.length) return null; - try { - const e3 = await Promise.any(t2.map(((e4) => this._extractTrailerLinks(e4)))); - return this._updateCache(e3), e3; - } catch (a2) { - const e3 = a2.errors || [a2]; - this.lastError = e3.find(((e4) => "REGION_BLOCKED" === e4?.code)) || e3.find(((e4) => e4 instanceof ProviderError)) || new ProviderError("dmm", "PARSE_ERROR", e3[0]?.message || String(e3[0]), { - cause: e3[0] - }), clog.error(`解析失败: ${this.lastError.message}`, e3); - const t3 = $("#fanzaBtn"); - return t3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), t3.attr("title", "未查询到, 点击前往搜索页"), t3.css("backgroundColor", "var(--jhs-status-filter)"), null; - } - } - }; - __name(_DmmPreviewParser, "DmmPreviewParser"); - var DmmPreviewParser = _DmmPreviewParser; - async function fetchDmmPreview(carNum) { - const parser = new DmmPreviewParser(carNum), sources = await parser.fetchVideo(); - return { - sources, - error: parser.lastError - }; - } - __name(fetchDmmPreview, "fetchDmmPreview"); - var _PreviewVideoPlugin = class _PreviewVideoPlugin extends BasePlugin { - getName() { - return "PreviewVideoPlugin"; - } - async initCss() { - return ".jhs-dmm-preview-player{display:none;width:100%;height:auto}.jhs-dmm-preview-player.is-active{display:block}.jhs-native-preview-hidden{display:none!important}"; - } - async handle() { - if (!isDetailPage) return; - const trigger = $(".preview-video-container"), openVideo = /* @__PURE__ */ __name(() => { - utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => { - this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))); - })); - }, "openVideo"); - trigger.off("click.jhsVideo").on("click.jhsVideo", openVideo); - await storageManager.getSetting("enableLoadPreviewVideo", _) !== _ || o.includes("autoPlay=1") || this.initDmm(); - const url = window.location.href; - (url.includes("gallery-1") || url.includes("gallery-2")) && openVideo(), url.includes("autoPlay=1") && trigger.length > 0 && trigger[0].click(); - } - async initDmm() { - try { - const { sources } = await this.getDmmPreview(); - if (!sources) return; - const $video = $("#preview-video"), video = $video[0]; - if (video) return; - clog.debug("JavDB没有视频播放元素, 开始创建..."); - const cover = $(".column-video-cover img").attr("src"); - $(".preview-images").prepend(` - - 预告片 - - - `), $(".preview-video-container").off("click.jhsVideo").on("click.jhsVideo", (() => { - utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))))); - })); - } catch (error) { - clog.error("预加载 DMM 失败:", error); - } - } - /** 复用单次 DMM 请求,避免预加载和点击处理重复抓取。 */ - getDmmPreview() { - if (this.dmmPreviewPromise) return this.dmmPreviewPromise; - this.dmmPreviewPromise = fetchDmmPreview(this.getPageInfo().carNum).then(((result) => { - (result.error?.retryable || "HTTP_ERROR" === result.error?.code) && (this.dmmPreviewPromise = null); - return result; - }), ((error) => { - this.dmmPreviewPromise = null; - throw error; - })); - return this.dmmPreviewPromise; - } - /** 创建与 JavDB HLS 生命周期完全隔离的 DMM 播放器。 */ - createDmmPlayer($nativeVideo) { - const $host = $nativeVideo.parent(), existing = $host.find("#jhs-preview-video"); - if (existing.length) return existing; - const $player = $(''); - return $nativeVideo.after($player), $player; - } - /** 销毁 JHS 播放器并把播放权完整交回 JavDB。 */ - async restoreNativePlayer($nativeVideo, nativeVideo, notify = false) { - const $dmmVideo = $nativeVideo.parent().find("#jhs-preview-video"), dmmVideo = $dmmVideo[0]; - dmmVideo && (dmmVideo.pause(), $dmmVideo.removeAttr("src"), dmmVideo.load(), $dmmVideo.remove()); - $nativeVideo.removeClass("jhs-native-preview-hidden"); - return safePlay(nativeVideo, { - context: "JavDB 原生预览回退", - notify - }); - } - async handleVideo() { - const $nativeVideo = $("#preview-video"); - if (!$nativeVideo.length) return; - const $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = $nativeVideo[0], muted = localStorage.getItem("jhs_videoMuted"); - void safePlay(nativeVideo, { - context: "JavDB 原生预览", - notify: false - }); - const dmmEnabled = await storageManager.getSetting("enableLoadPreviewVideo", _) !== C, dmmResult = dmmEnabled ? await this.getDmmPreview() : { - sources: null, - error: null - }, { sources, error } = dmmResult, $toolbar = $("
").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
").addClass("jhs-video-quality-list").attr({ - role: "group", - "aria-label": "视频画质" - }); - $host.find("#video-bottom-toolbar").remove(); - let dmmPlayed = false, $dmmVideo = null, dmmVideo = null; - if (sources) { - const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = Z(Object.keys(sources), preferredQuality), source = sources[selectedQuality]; - const currentTime = nativeVideo.currentTime; - $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = !muted || "yes" === muted, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { - localStorage.setItem("jhs_videoMuted", dmmVideo.muted ? "yes" : "no"); - })), $dmmVideo.attr("src", source), dmmVideo.load(), dmmVideo.currentTime = currentTime, $dmmVideo.addClass("is-active"); - dmmPlayed = await safePlay(dmmVideo, { - context: "JavDB 高画质预览", - notify: false - }); - if (!dmmPlayed && !dmmVideo.muted) dmmVideo.muted = true, dmmPlayed = await safePlay(dmmVideo, { - context: "JavDB 高画质预览静音重试", - notify: false - }); - dmmPlayed ? (nativeVideo.pause(), $nativeVideo.addClass("jhs-native-preview-hidden")) : ($dmmVideo.removeClass("is-active"), await this.restoreNativePlayer($nativeVideo, nativeVideo, true)); - dmmPlayed && L.forEach(((quality) => { - const qualitySource = sources[quality.quality]; - if (!qualitySource) return; - const active = dmmPlayed && selectedQuality === quality.quality; - $qualityList.append($(``)); - })); - } - $toolbar.append($qualityList); - const $actions = $("
").addClass("jhs-toolbar"); - $actions.append('', '', ''), $toolbar.append($actions), $host.append($toolbar), sources || await safePlay(nativeVideo, { - context: "JavDB 预览视频", - notify: true, - message: "REGION_BLOCKED" === error?.code ? error.message : "当前视频源无法播放" - }); - $toolbar.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (event) => { - const $button = $(event.currentTarget); - if ($button.hasClass("active")) return; - try { - if (!dmmVideo) return; - const currentTime = dmmVideo.currentTime, previousSource = $dmmVideo.attr("src"); - $dmmVideo.attr("src", $button.data("video-src")), dmmVideo.load(), dmmVideo.currentTime = currentTime; - const played = await safePlay(dmmVideo, { - context: "JavDB 画质切换", - notify: false - }); - if (played) $toolbar.find(".jhs-video-quality-btn").removeClass("active").attr("aria-pressed", "false"), $button.addClass("active").attr("aria-pressed", "true"); - else { - previousSource && ($dmmVideo.attr("src", previousSource), dmmVideo.load(), dmmVideo.currentTime = currentTime); - const restored = previousSource && await safePlay(dmmVideo, { - context: "JavDB 画质切换回退", - notify: false - }); - restored || await this.restoreNativePlayer($nativeVideo, nativeVideo, true); - } - } catch (playbackError) { - clog.error("切换画质失败:", playbackError); - } - })), $("#speed-btn").off("click.jhsVideo").on("click.jhsVideo", (() => { - dmmVideo && (dmmVideo.currentTime += 10); - })), $toolbar.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo", "#speed-btn", ((event) => (event.preventDefault(), this.getBean("DetailPageButtonPlugin").filterOne(event)))), $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getBean("DetailPageButtonPlugin").filterOne(event))), $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getBean("DetailPageButtonPlugin").favoriteOne(event))); - } - }; - __name(_PreviewVideoPlugin, "PreviewVideoPlugin"); - var PreviewVideoPlugin = _PreviewVideoPlugin; - var _JavTrailersPlugin = class _JavTrailersPlugin extends BasePlugin { - getName() { - return "JavTrailersPlugin"; - } - constructor() { - super(), this.hasBand = false; - } - handle() { - let e2 = window.location.href; - if (!e2.includes("handle=1")) return; - if ($("h1:contains('Page not found')").length) { - clog.log("番号无法匹配, 跳搜索"); - let t3 = e2.split("?")[0].split("video/")[1].toLowerCase().replace("00", "-"); - return void (window.location.href = "/search/" + encodeURIComponent(t3) + window.location.search); - } - let t2 = $(".videos-list .video-link").toArray(); - if (t2.length) { - const n2 = e2.split("?")[0].split("search/")[1].toLowerCase(), a2 = t2.find(((e3) => $(e3).find(".vid-title").text().toLowerCase().includes(n2))); - if (a2) return void (window.location.href = $(a2).attr("href") + window.location.search); - } - this.handlePlayJavTrailers(), $("#videoPlayerContainer").on("click", (() => { - this.handlePlayJavTrailers(); - })), window.addEventListener("message", ((e3) => { - let t3 = document.getElementById("vjs_video_3_html5_api"); - t3 && (t3.currentTime += 5); - })); - } - handlePlayJavTrailers() { - this.hasBand || (utils.loopDetector((() => 0 !== $("#vjs_video_3_html5_api").length), (() => { - setTimeout((() => { - this.hasBand = true; - let e2 = document.getElementById("vjs_video_3_html5_api"); - clog.debug(e2), safePlay(e2, { - context: "JavTrailers 预览" - }), e2.currentTime = 5, e2.addEventListener("timeupdate", (function() { - e2.currentTime >= 14 && e2.currentTime < 16 && (e2.currentTime += 2); - })), $("#vjs_video_3_html5_api").css({ - position: "fixed", - width: "100vw", - height: "100vh", - objectFit: "cover", - zIndex: String(JHS_Z_INDEX.debug) - }), $(".vjs-control-bar").css({ - position: "fixed", - bottom: "20px", - zIndex: String(JHS_Z_INDEX.debug) - }); - }), 100); - })), utils.loopDetector((() => $("#vjs_video_3 canvas").length > 0), (() => { - 0 !== $("#vjs_video_3 canvas").length && $("#vjs_video_3 canvas").css({ - position: "fixed", - width: "100vw", - height: "100vh", - objectFit: "cover", - top: "0", - right: "0", - zIndex: String(JHS_Z_INDEX.debug - 1) - }); - }))); - } - }; - __name(_JavTrailersPlugin, "JavTrailersPlugin"); - var JavTrailersPlugin = _JavTrailersPlugin; - var _SubTitleCatPlugin = class _SubTitleCatPlugin extends BasePlugin { - getName() { - return "SubTitleCatPlugin"; - } - handle() { - $(".t-banner-inner").hide(), $("#navbar").hide(); - let e2 = new URLSearchParams(window.location.search).get("search").toLowerCase(), t2 = $(".sub-table tr td a").toArray(), n2 = 0; - t2.forEach(((t3) => { - let a3 = $(t3); - a3.text().toLowerCase().includes(e2) ? n2++ : a3.parent().parent().hide(); - })), 0 === n2 && show.error("该番号无字幕!"); - const a2 = $(".sec-title"), i2 = a2.html().replace(/^\d+/, n2); - a2.html(i2); - } - }; - __name(_SubTitleCatPlugin, "SubTitleCatPlugin"); - var SubTitleCatPlugin = _SubTitleCatPlugin; - var _Fc2Plugin = class _Fc2Plugin extends BasePlugin { - getName() { - return "Fc2Plugin"; - } - async initCss() { - return ``; - } - async handle() { - const fc2Href = "/advanced_search?type=3&score_min=0&d=1"; - $('.navbar-item:contains("FC2")').attr("href", fc2Href), $('.tabs a:contains("FC2")').attr("href", fc2Href); - if (o.includes("advanced_search?type=3")) $("h2.section-title").contents().first().replaceWith("Fc2PPV"), $(".section .container > .box").remove(); - if (!o.includes("collection_codes?movieId")) return; - const params = new URLSearchParams(window.location.search), movieId = params.get("movieId"), carNum = params.get("carNum"), url = params.get("url"), explicitSource = params.get("source"), source = ["fc2", "123av"].includes(explicitSource) ? explicitSource : await this.resolveFc2Source({ url }), host = $("section").first().empty(); - if (!carNum || !url) return void host.append($('
').text("FC2 详情参数不完整")); - const context = this.mountFc2Detail(host, { movieId, carNum, url, source, mode: "page" }); - $(window).off("pagehide.jhsFc2Detail").one("pagehide.jhsFc2Detail", (() => context.destroy())); - } - openFc2Dialog(movieId, carNum, url, { source = "fc2" } = {}) { - let context = null; - return layer.open({ - type: 1, - title: carNum, - content: '
', - area: utils.getDialogArea("workspace"), - skin: "movie-detail-layer", - scrollbar: false, - shadeClose: true, - success: /* @__PURE__ */ __name((layerRoot, layerIndex) => { - context = this.mountFc2Detail($(layerRoot).find(".jhs-fc2-dialog-host"), { movieId, carNum, url, source, layerIndex, mode: "dialog" }), utils.setupEscClose(layerIndex); - }, "success"), - end: /* @__PURE__ */ __name(() => context?.destroy(), "end") - }); - } - /** 统一挂载 FC2 与 123AV-FC2 详情。 */ - mountFc2Detail(host, options) { - const target = $(host), previous = target.data("jhsFc2Context"); - previous?.destroy?.(), target.empty(); - const shell = createFc2DetailShell(options).appendTo(target), context = createFc2DetailContext(shell, options); - target.data("jhsFc2Context", context), this.initializeWorkspace(context); - return context; - } - initializeWorkspace(context) { - const summary = $('
正在加载影片信息…
'), toolbar = $(''); - [["filterBtn", "jhs-btn--filter", m], ["favoriteBtn", "jhs-btn--fav", v], ["hasDownBtn", "jhs-btn--down", y], ["hasWatchBtn", "jhs-btn--watch", k]].forEach((([id, className, label]) => toolbar.append($('').attr("id", id).addClass(className).find("span").text(label).end()))); - toolbar.append('', '', ''), summary.find(".jhs-fc2-summary__body").append(toolbar), context.getSlot("summary").append(summary); - const gallery = $(''), screenshot = $('
'); - gallery.on(`click${context.namespace}`, ".jhs-fc2-gallery-item", ((event) => { - const image = $(event.currentTarget).find("img")[0]; - image && showImageViewer(image, "", { galleryRoot: gallery[0] }); - })); - context.getSlot("gallery").append(gallery, screenshot); - const resources = $('
'), nativeGroup = this.createResourceGroup("站内磁力", "native-magnets"), sitesGroup = this.createResourceGroup("第三方站点", "other-sites"), hubGroup = this.createResourceGroup("更多磁力来源", "magnet-hub"), hubButton = $(''); - let magnetHubPromise = null; - hubGroup.find('[data-jhs-role="magnet-hub"]').append(hubButton, '
'), resources.append(nativeGroup, sitesGroup, hubGroup), context.getSlot("resources").append(resources); - toolbar.on(`click${context.namespace}`, '[data-jhs-action="subtitlecat"]', ((event) => utils.openPage(`https://subtitlecat.com/index.php?search=${encodeURIComponent(context.carNum)}`, context.carNum, false, event))), toolbar.on(`click${context.namespace}`, '[data-jhs-action="xunlei"]', (() => this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(context.carNum))); - hubButton.on(`click${context.namespace}`, (async () => { - if (!context.isAlive()) return; - const box = hubGroup.find('[data-jhs-role="magnet-hub-content"]'), expanded = "true" === hubButton.attr("aria-expanded"); - if (expanded) return hubGroup.addClass("is-collapsed"), void hubButton.attr("aria-expanded", "false").text("展开磁力搜索"); - magnetHubPromise || (magnetHubPromise = this.getBean("MagnetHubPlugin").createMagnetHub(context.carNum, { root: context.root })); - const hub = await magnetHubPromise; - if (context.isAlive() && !box.children().length) box.append(hub); - if (context.isAlive()) hubGroup.removeClass("is-collapsed"), hubButton.attr("aria-expanded", "true").text("收起磁力搜索"), box[0]?.scrollIntoView?.({ block: "nearest" }); - })); - detailStateController.bind({ root: context.root, layerIndex: context.layerIndex ?? null, carNum: context.carNum, activityType: "fc2-state", getRecord: /* @__PURE__ */ __name(() => ({ carNum: context.carNum, url: context.url, fc2Source: context.source, names: context.root.find('[data-jhs-role="actress-data"]').text(), publishTime: context.root.find('[data-jhs-role="publish-time"]').text() }), "getRecord") }); - void this.getBean("FilterTitleKeywordPlugin").bindDetailRoot(context.root, { layerIndex: context.layerIndex ?? null }); - void this.getBean("OtherSitePlugin").loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: false, isActive: context.isAlive }).then(((box) => { - if (context.isAlive() && !box) sitesGroup.remove(); - })).catch(((error) => { - context.isAlive() && sitesGroup.remove(), clog.error("FC2 外部站点加载失败", error); - })); - void this.getBean("ScreenShotPlugin").loadInto(screenshot, context.carNum.replace("FC2-", ""), { isActive: context.isAlive }).then(((result) => { - if (context.isAlive() && !result && !screenshot.children().length) screenshot.remove(); - })); - "123av" === context.source ? void this.getBean("Fc2By123AvPlugin").loadDetail(context, context.url) : void this.loadNativeDetail(context); - } - createResourceGroup(title, role) { - return $('

').find("h3").text(title).end().find("div").attr("data-jhs-role", role).end(); - } - setState(target, message, retry = null) { - const host = $(target).empty(), state = $('
').text(message); - retry && state.addClass("is-error").append(" ", $('').on("click", retry)), host.append(state); - } - async loadNativeDetail(context) { - const movieIdPromise = Promise.resolve(context.movieId); - this.configureJavDbWantButton(context, movieIdPromise), await Promise.allSettled([this.fetchAndRenderNativeDetail(context), this.fetchAndRenderNativeMagnets(context), this.mountPanels(context, movieIdPromise)]); - } - /** 绑定当前工作区自己的 JavDB“想看”操作。 */ - async configureJavDbWantButton(context, movieIdPromise) { - const button = context.root.find('[data-jhs-action="javdb-want"]'); - try { - const movieId = await movieIdPromise; - if (!context.isAlive()) return; - if (!movieId) return void button.prop("disabled", true).text("JavDB 暂无对应作品"); - button.prop("disabled", false).text("JavDB 想看").off(`click${context.namespace}`).on(`click${context.namespace}`, (() => void this.submitJavDbWant(context, movieId, button))); - } catch (error) { - context.isAlive() && button.prop("disabled", false).text("JavDB 关联失败,重试").off(`click${context.namespace}`).on(`click${context.namespace}`, (() => void this.configureJavDbWantButton(context, resolveJavDbMovieId(context.carNum)))), clog.error("FC2 JavDB 想看关联失败", error); - } - } - async submitJavDbWant(context, movieId, button) { - if (!context.isAlive() || button.data("jhsBusy") || "true" === button.attr("aria-pressed")) return; - button.data("jhsBusy", true).attr({ "aria-busy": "true", "aria-disabled": "true" }).text("正在加入想看…"); - try { - await markJavDbWantWatch(movieId); - if (!context.isAlive()) return; - button.attr({ "aria-pressed": "true", "aria-disabled": "false" }).text("已加入 JavDB 想看"), show.ok("已加入 JavDB 想看"); - } catch (error) { - if (!context.isAlive()) return; - if ("LOGIN_REQUIRED" === error?.code) { - button.attr("aria-disabled", "false").text("JavDB 想看"); - const loginPlugin = this.getBean("TOP250Plugin"); - return loginPlugin?.openLoginDialog({ onSuccess: /* @__PURE__ */ __name(() => this.submitJavDbWant(context, movieId, button), "onSuccess") }); - } - button.attr("aria-disabled", "false").text("JavDB 想看"), show.error(error?.message || "加入 JavDB 想看失败"), clog.error("加入 JavDB 想看失败", error); - } finally { - context.isAlive() && button.removeData("jhsBusy").removeAttr("aria-busy"); - } - } - async fetchAndRenderNativeDetail(context) { - try { - const movie = await V(context.movieId); - if (!context.isAlive()) return; - this.renderSummary(context, movie), this.renderGallery(context, movie.imgList || []), await this.getBean("TranslatePlugin").translate(movie.carNum || context.carNum, false, { root: context.root }); - } catch (error) { - context.isAlive() && this.setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.fetchAndRenderNativeDetail(context))), clog.error("FC2 详情加载失败", error); - } - } - renderSummary(context, movie) { - const body = context.root.find('[data-jhs-role="summary-content"]').empty(), title = $('

'); - title.find("strong").text(movie.title || "无标题"), body.append(title); - const meta = $('
'); - [`番号:${movie.carNum || context.carNum}`, `发行:${movie.releaseDate || "未知"}`, `评分:${Number.isFinite(Number(movie.score)) ? movie.score : "无"}`, `时长:${Number.isFinite(Number(movie.duration)) ? movie.duration + " 分钟" : "无"}`].forEach(((value) => meta.append($("").text(value)))), body.append(meta); - const actors = $('
主演:
'), actressNames = []; - (movie.actors || []).forEach(((actor) => { - actors.append($("").addClass("jhs-fc2-actor").attr({ href: `/actors/${encodeURIComponent(actor.id)}`, target: "_blank", rel: "noopener noreferrer" }).text(actor.name || "未知演员")), 0 === actor.gender && actressNames.push(actor.name); - })); - movie.actors?.length || actors.append($("").text("暂无演员信息")), body.append(actors, this.createSourceLinks(context), $('').text(actressNames.join(" ")), $('').text(movie.releaseDate || "")); - } - createSourceLinks(context) { - const id = String(context.carNum || "").replace(/^FC2-(?:PPV-)?/i, ""), links = $(''), values = [["123av" === context.source ? "123AV 原页面" : "JavDB 原页面", normalizeHttpUrl(context.url)], ["FC2PPVDB", `https://fc2ppvdb.com/articles/${encodeURIComponent(id)}`], ["FC2 市场", `https://adult.contents.fc2.com/article/${encodeURIComponent(id)}/`]]; - values.forEach((([label, href]) => href && links.append($("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({ href, target: "_blank", rel: "noopener noreferrer" }).text(label)))); - return links; - } - renderGallery(context, images) { - const urls = [...new Set((images || []).map(((url) => normalizeHttpUrl(url))).filter(Boolean))], grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); - if (!urls.length) return this.setState(grid, "暂无剧照"); - preview.append($("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); - urls.forEach(((url, index) => grid.append($('').attr("aria-label", `查看剧照 ${index + 1}`).append($("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" }))))); - } - async fetchAndRenderNativeMagnets(context, movieId = context.movieId) { - const host = context.root.find('[data-jhs-role="native-magnets"]'); - this.setState(host, "正在加载站内磁力…"); - try { - if (!movieId) return this.setState(host, "JavDB 暂无对应作品"); - const response = await gmHttp.get(`${U}/v1/movies/${movieId}/magnets`, null, { jdSignature: await O() }), magnets = response?.data?.magnets || []; - if (!context.isAlive()) return; - host.empty(); - if (!magnets.length) return this.setState(host, "暂无站内磁力"); - const highlighter = this.getBean("HighlightMagnetPlugin"), assessments = []; - magnets.forEach(((item) => { - const hash = normalizeBtihHash(item.hash); - if (!hash) return; - const magnet = `magnet:?xt=urn:btih:${hash}`, assessment = highlighter.assessMagnet({ title: item.name, hasHdTag: !!item.hd, hasSubtitleTag: !!item.cnsub, date: item.created_at, seeders: Number(item.seeders) || 0 }), row = $('
').attr("data-jhs-high-quality", String(assessment.highQuality)), info = $('
'), actions = $('
'), tags = $('
'); - assessments.push(assessment), tags.append($("").addClass("jhs-badge").attr("title", `磁力质量评分 ${assessment.score.total}`).text(`${assessment.grade} ${assessment.score.total}`)), item.hd && tags.append('高清'), item.cnsub && tags.append('字幕'); - info.append($("").attr("href", magnet).text(item.name || magnet), $("
").addClass("jhs-fc2-meta").text(`${(Number(item.size || 0) / 1024).toFixed(2)} GB · ${Number(item.files_count) || 0} 个文件${item.created_at ? ` · ${item.created_at}` : ""}`), tags), actions.append($('').attr("data-clipboard-text", magnet), $('').attr({ "data-resource": magnet, "data-jhs-offline-owner": "fc2" })), host.append(row.append(info, actions)); - })); - await this.bindNativeMagnetFilter(context, host, assessments.some(((item) => item.highQuality))); - } catch (error) { - context.isAlive() && this.setState(host, "站内磁力加载失败", (() => void this.fetchAndRenderNativeMagnets(context, movieId))), clog.error("FC2 磁力加载失败", error); - } - } - async bindNativeMagnetFilter(context, host, hasMatch) { - const section = context.getSection("resources"), actions = section.find(".jhs-fc2-section__actions"), old = actions.find('[data-jhs-action="filter-native-magnets"]'); - old.remove(); - const button = $(''), apply = /* @__PURE__ */ __name((enabled) => { - host.find(".jhs-fc2-magnet-item").show(); - enabled && hasMatch && host.find('.jhs-fc2-magnet-item[data-jhs-high-quality="false"]').hide(); - button.attr("aria-pressed", String(enabled && hasMatch)).text(hasMatch ? enabled ? "显示全部磁力" : "过滤低质量" : "暂无可过滤项").prop("disabled", !hasMatch); - }, "apply"); - actions.append(button), apply(await storageManager.getSetting("enableMagnetsFilter", _) === _), button.on(`click${context.namespace}`, (async () => { - const enabled = "true" !== button.attr("aria-pressed"); - apply(enabled), await storageManager.saveSettingItem("enableMagnetsFilter", enabled ? _ : C); - })); - } - async mountPanels(context, movieIdPromise) { - try { - const movieId = await movieIdPromise; - if (!context.isAlive()) return; - if (!movieId) return this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"), this.setState(context.getSlot("reviews"), "JavDB 暂无对应作品"), this.setState(context.getSlot("related"), "JavDB 暂无对应作品"); - this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); - await Promise.allSettled([this.getBean("ReviewPlugin").showReview(movieId, context.getSlot("reviews"), { ownedSection: context.getSection("reviews"), isActive: context.isAlive }), this.getBean("RelatedPlugin").showRelated(context.getSlot("related"), movieId, { ownedSection: context.getSection("related"), isActive: context.isAlive })]); - } catch (error) { - if (!context.isAlive()) return; - this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); - const retry = /* @__PURE__ */ __name(() => void this.mountPanels(context, resolveJavDbMovieId(context.carNum)), "retry"); - this.setState(context.getSlot("reviews"), "评论关联失败", retry), this.setState(context.getSlot("related"), "相关清单关联失败", retry), clog.error("FC2 JavDB 关联失败", error); - } - } - clearOwnedPanel(context, name) { - context.getSlot(name).empty(), context.getSection(name).find(".jhs-fc2-section__actions").empty(); - } - async resolveFc2Source(record = {}) { - if (["fc2", "123av"].includes(record.fc2Source)) return record.fc2Source; - try { - return new URL(record.url, window.location.origin).hostname === new URL(await this.getBean("OtherSitePlugin").getAv123Url()).hostname ? "123av" : "fc2"; - } catch (error) { - return "fc2"; - } - } - async openFc2Page(movieId, carNum, url, navigation = { newTab: true }, { source = "fc2" } = {}) { - const baseUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(); - utils.openPage(`${baseUrl}/users/collection_codes?movieId=${movieId || ""}&carNum=${encodeURIComponent(carNum)}&url=${encodeURIComponent(url)}&source=${encodeURIComponent(source)}`, carNum, true, navigation); - } - }; - __name(_Fc2Plugin, "Fc2Plugin"); - var Fc2Plugin = _Fc2Plugin; - var _HighlightMagnetPlugin = class _HighlightMagnetPlugin extends BasePlugin { - async handle() { - window.isDetailPage && jhsEventBus.on("magnet-items-updated", (() => { - void storageManager.getSetting("enableMagnetsFilter", _).then(((enabled) => enabled === _ ? this.doFilterMagnet() : this.showAll())); - })); - } - async initCss() { - return ``; - } - getName() { - return "HighlightMagnetPlugin"; - } - doFilterMagnet() { - this.handleDb(), this.handleBus(); - } - /** 给磁力行注入评分徽章(幂等:已有则跳过) */ - injectScoreBadge(el, title) { - try { - if (el.find(".jhs-magnet-score").length > 0) return; - const score = calcMagnetScore({ title: title || "", seeders: 0 }); - const total = score.total; - const label = total >= 70 ? "高" : total >= 40 ? "中" : "低"; - const color = total >= 70 ? "var(--jhs-status-down)" : total >= 40 ? "var(--jhs-status-watch)" : "var(--jhs-surface-2)"; - const onColor = total >= 70 ? "var(--jhs-status-down-on)" : total >= 40 ? "var(--jhs-status-watch-on)" : "var(--jhs-text-muted)"; - const tip = `分辨率:${score.resolution}/25 字幕:${score.subtitle}/20 做种:${score.seeders}/35 新鲜度:${score.freshness}/15`; - const badge = $(`${label} ${total}`).css({ color: onColor, backgroundColor: color }); - el.append(badge); - } catch (e2) { - clog.debug("磁力评分徽章注入失败,已忽略", e2); - } - } - getQualitySignals(title, hasSubtitleTag = false) { - const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); - return { resolution, subtitle, recognized: !!resolution || subtitle, highQuality: "4k" === resolution || "2160p" === resolution || subtitle }; - } - /** 返回与 DOM 无关的磁力质量结果,供自有详情工作区复用。 */ - assessMagnet({ title = "", hasHdTag = false, hasSubtitleTag = false, date = null, seeders = 0 } = {}) { - const signals = this.getQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; - const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); - return { ...signals, highQuality, score, grade: score.total >= 70 ? "高" : score.total >= 40 ? "中" : "低" }; - } - updateFilterHint(hasMatch) { - $("#enable-magnets-filter").removeClass("do-hide").attr("data-tip", hasMatch ? "仅显示识别到的高质量或字幕磁力" : "未识别到可过滤项,当前未隐藏磁力"); - } - handleDb() { - if (!r) return; - let e2 = $("#magnets-content .name"); - if (0 === e2.length) return void this.updateFilterHint(false); - let n2 = false; - e2.each(((e3, a2) => { - const i2 = $(a2), s2 = i2.text().toLowerCase(), o2 = this.getQualitySignals(s2); - const row = i2.parent().parent().parent(); - row.removeClass("high-quality").show(); - row.addClass("magnet-row"), s2.includes("4k") && i2.css("color", "var(--jhs-status-filter-text)"), o2.highQuality && (n2 = true, row.addClass("high-quality")); - this.injectScoreBadge(i2, i2.text()); - })), n2 && $("#magnets-content .magnet-row").not(".high-quality").hide(), this.updateFilterHint(n2); - } - handleBus() { - if (l && isDetailPage) { - const e2 = $("#magnet-table tr"); - let n2 = false; - e2.each(((e3, a2) => { - const i2 = $(a2), s2 = i2.find("td:first-child"), o2 = s2.find("a:first-child"), r2 = s2.find("a:nth-child(2)"), l2 = o2.text().toLowerCase(); - i2.removeClass("high-quality").show(); - l2.includes("4k") && o2.css("color", "var(--jhs-status-filter-text)"); - this.getQualitySignals(l2, r2.length > 0 && r2.text().includes("字幕")).highQuality && (n2 = true, i2.addClass("high-quality")); - this.injectScoreBadge(o2, o2.text()); - })); - n2 && e2.each(((e3, t2) => { - const n3 = $(t2); - n3.hasClass("high-quality") || n3.hide(); - })), this.updateFilterHint(n2); - } - } - showAll() { - $("#enable-magnets-filter").removeClass("do-hide").removeAttr("data-tip"); - if (r) { - $("#magnets-content .item").toArray().forEach(((e2) => $(e2).show())); - } - l && $("#magnet-table tr").toArray().forEach(((e2) => $(e2).show())); - } - }; - __name(_HighlightMagnetPlugin, "HighlightMagnetPlugin"); - var HighlightMagnetPlugin = _HighlightMagnetPlugin; - var _FoldCategoryPlugin = class _FoldCategoryPlugin extends BasePlugin { - getName() { - return "FoldCategoryPlugin"; - } - async initCss() { - const e2 = await storageManager.getSetting(); - return ` + + `);let n="[data-tip-top], [data-tip-bottom], [data-tip-left], [data-tip-right], [data-tip]";o.listen(document,"mouseover",(l=>{let c=l.target.closest(n);if(c&&!c.tooltipElement){let h,u="top";if(c.hasAttribute("data-tip-bottom")?(h=c.getAttribute("data-tip-bottom"),u="bottom"):c.hasAttribute("data-tip-left")?(h=c.getAttribute("data-tip-left"),u="left"):c.hasAttribute("data-tip-right")?(h=c.getAttribute("data-tip-right"),u="right"):c.hasAttribute("data-tip-top")?(h=c.getAttribute("data-tip-top"),u="top"):c.hasAttribute("data-tip")&&(h=c.getAttribute("data-tip"),u="top"),!h)return;c.hoverTimeout=setTimeout((()=>{c.matches(":hover")&&!c.tooltipElement&&r(c,h,u)}),50)}})),o.listen(document,"mouseout",(l=>{let c=l.target.closest(n);var h;c&&(c.hoverTimeout&&(clearTimeout(c.hoverTimeout),c.hoverTimeout=null),c.contains(l.relatedTarget)||c.tooltipElement&&((h=c.tooltipElement)&&h.parentNode&&h.remove(),c.tooltipElement=null))}))})();let a=window.loading,i=window.show;return dt=Object.freeze({loading:a,show:i,clog:s}),window.loading=dt.loading,window.show=dt.show,window.clog=dt.clog,dt}d(ll,"initializeLoggerRuntime");function cl(o=window){let e={$:o.jQuery,Tabulator:o.Tabulator,layer:o.layer,md5:o.md5,Toastify:o.Toastify,localforage:o.localforage,Viewer:o.Viewer},t=Object.entries(e).filter(([,s])=>s==null).map(([s])=>s);if(t.length)throw new Error(`Missing userscript vendor runtime: ${t.join(", ")}`);return Object.freeze(e)}d(cl,"getVendorRuntime");var ch=new Set(["star","genre","maker","actress","series","tag","search","director","studio","label"]),Yi=class Yi{constructor(e=document,t=window.location){this.site="javbus",this.document=e,this.location=t}resolveFirstPageUrl(e){try{let t=new URL(e),s=t.pathname.split("/").filter(Boolean),a=s[s.length-1],i=s[s.length-2];if(s.length>=2&&/^\d+$/.test(a)&&(i==="page"||s.length>=3&&ch.has(s[s.length-3]))){let n=i==="page"?s.slice(0,-2):s.slice(0,-1);t.pathname=n.length?"/"+n.join("/"):"/"}return t.href}catch{return e}}detectRoute(){return this.locateNativeMagnets()?"detail":this.locateListRoot()?"list":"other"}readMovieRef(){let e=this.document.querySelector(".info p span, [data-car-number]")?.textContent?.trim()??null;return e?Object.freeze({carNum:e,url:this.location.href,site:"javbus"}):null}locateListRoot(){return this.document.querySelector(".masonry")}locateListItems(){return[...this.locateListRoot()?.querySelectorAll(":scope > .item, :scope > .movie-box")??[]]}getListContainer(){return this.locateListRoot()?.parentElement??null}getListLayoutContainer(){return this.document.querySelector(".container-fluid .row")}createOwnedListRoot(e=[]){let t=this.document.createElement("div");return t.classList.add("masonry",...e),t}locateDetailRoot(){return this.locateNativeMagnets()?.closest(".container")??this.document.querySelector(".container .row.movie")?.parentElement??null}locateDetailSlots(){let e=this.locateDetailRoot();return Object.freeze({summary:e?.querySelector('[data-jhs-slot="summary-actions"]')??e?.querySelector(".row.movie")??null,resources:this.locateNativeMagnets(),reviews:e?.querySelector('[data-jhs-slot="reviews"]')??this.document.querySelector("#reviews"),related:e?.querySelector('[data-jhs-slot="related"]')})}locateNativeGallery(){return this.document.querySelector("#sample-waterfall, .sample-box")}locateNativeMagnets(){return this.document.querySelector("#magnet-table")}getDetailResourceBoundary(){let e=this.locateNativeMagnets(),t=this.locateDetailRoot();return!t||!e?null:Object.freeze({site:"javbus",hostRoot:t,controller:e,observeRoot:e.parentElement,resourceRoot:e,resourceRegion:[...t.children].find(s=>s===e||s.contains(e))||e,rows:d(()=>[...e.querySelectorAll("tr")].filter(s=>s.querySelector('td a[href^="magnet:"],td a[href^="ed2k:"]')),"rows"),sortSelect:null,getResource:d(s=>s.querySelector('td a[href^="magnet:"],td a[href^="ed2k:"]')?.getAttribute("href")||"","getResource"),getTitleTarget:d(s=>s.querySelector("td:first-child a:first-child"),"getTitleTarget"),hasSubtitleTag:d(s=>[...s.querySelectorAll("td:first-child a")].slice(1).some(a=>a.textContent?.includes("字幕")),"hasSubtitleTag"),getActionTarget(s){let a=[...s.querySelectorAll(".buttons,.actions,.btn-group")].filter(n=>n.closest("td")).at(-1);return a||s.querySelector('td a[href^="magnet:"],td a[href^="ed2k:"]')?.closest("td")||null},actionTargetRequiresWrapper:d(s=>![...s.querySelectorAll(".buttons,.actions,.btn-group")].some(a=>a.closest("td")),"actionTargetRequiresWrapper")})}};d(Yi,"JavBusHostAdapter");var _s=Yi;var dh=new Set(["ABC","ABP","ADN","ATID","BF","CAWD","DLDSS","DVAJ","FSDSS","HEYZO","HMN","IPX","IPZZ","JUQ","JUL","JUX","MEYD","MIAA","MIDE","MIDV","MIMK","MIRD","NIMA","PRED","RBD","SDDE","SONE","SSIS","SSNI","STARS","URE","VEC","WAAA","WANZ","XVSR"]);function ee(o){if(typeof o!="string")return null;let e=o.trim();if(!e||["undefined","null"].includes(e.toLowerCase()))return null;e=e.normalize("NFKC").replace(/[‐‑‒–—―﹘﹣-]/g,"-").replace(/[\s_]+/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").toUpperCase();let t=e.match(/^([A-Z]{2,8})(\d{2,7})$/);return t&&dh.has(t[1])?`${t[1]}-${t[2]}`:e||null}d(ee,"normalizeMovieCarNum");function dl(o,e="https://javdb.com"){try{return new URL(String(o||""),e).pathname.match(/^\/v\/([^/]+)\/?$/)?.[1]||null}catch{return null}}d(dl,"extractJavDbMovieId");var Zi=class Zi{constructor(e=document,t=window.location){this.site="javdb",this.document=e,this.location=t}resolveFirstPageUrl(e){try{let t=new URL(e);return t.searchParams.delete("page"),t.href}catch{return e}}detectRoute(){return this.location.pathname.startsWith("/v/")||this.location.pathname.startsWith("/movies/")?"detail":this.location.pathname==="/users/collection_codes"?"owned-detail":this.location.pathname==="/advanced_search"&&["handlePlayback","handleTop"].some((e=>new URLSearchParams(this.location.search).get(e)==="1"))||this.locateListRoot()?"list":"other"}readMovieRef(){let e=this.document.querySelector(".panel-block.first-block .value, [data-car-number]")?.textContent?.trim()??null;return e?Object.freeze({carNum:e,url:this.location.href,site:"javdb"}):null}locateListRoot(){return this.document.querySelector(".movie-list")}locateListItems(){return[...this.locateListRoot()?.querySelectorAll(":scope > .item")??[]]}getListContainer(){return this.locateListRoot()?.parentElement??null}getListLayoutContainer(){return this.document.querySelector("section .container")}createOwnedListRoot(e=[]){let t=this.document.createElement("div");return t.classList.add("movie-list","h","cols-4","vcols-8",...e),t}mountOwnedListRoot(e,t){for(let s of[...e.children])s.matches(".movie-list, nav.pagination")&&s.remove();e.append(t)}locateDetailRoot(){return this.document.querySelector(".video-detail")??this.document.querySelector(".movie-panel-info")?.closest(".container")??this.document.querySelector("main")}locateDetailSlots(){let e=this.locateDetailRoot();return Object.freeze({summary:e?.querySelector('[data-jhs-slot="summary-actions"]')??this.document.querySelector(".movie-panel-info"),resources:this.document.querySelector("#magnets-content"),reviews:e?.querySelector('[data-jhs-slot="reviews"]')??this.document.querySelector("#reviews"),related:e?.querySelector('[data-jhs-slot="related"]')})}locateNativeGallery(){return this.document.querySelector(".tile-images, .preview-images")}locateNativeMagnets(){return this.document.querySelector("#magnets-content")}getDetailResourceBoundary(){let e=this.locateNativeMagnets(),t=e?.closest('[data-controller="magnet-sort"]'),s=this.locateDetailRoot();if(!s||!t||!e)return null;let a=[...s.children].find(i=>i===t||i.contains(t))||t;return Object.freeze({site:"javdb",hostRoot:s,controller:t,observeRoot:t,resourceRoot:e,resourceRegion:a,rows:d(()=>[...e.children].filter(i=>i.matches(".item")),"rows"),sortSelect:t.querySelector('select[data-action*="magnet-sort#sort"]'),prepareLayout(){e.setAttribute("data-jhs-magnets","true"),a.setAttribute("data-jhs-host-region","resources");let i=e.closest(".message.video-panel, .video-panel")||t;i.setAttribute("data-jhs-resource-surface","true");let r=e.closest(".message-body");r&&i.contains(r)&&r.setAttribute("data-jhs-resource-body","true");for(let n of e.children){if(!n.matches(".item"))continue;let l=n.querySelector(":scope > .magnet-name"),c=n.querySelector(":scope > .date"),h=n.querySelector(":scope > .buttons");!l||!h||(n.setAttribute("data-jhs-magnet-row","true"),l.setAttribute("data-jhs-magnet-part","info"),c?.setAttribute("data-jhs-magnet-part","date"),h.setAttribute("data-jhs-magnet-part","actions"))}},getResource(i){return i.querySelector('.copy-to-clipboard[data-clipboard-text^="magnet:"]')?.getAttribute("data-clipboard-text")||i.querySelector('.magnet-name a[href^="magnet:"]')?.getAttribute("href")||""},getActionTarget:d(i=>i.querySelector(":scope > .buttons"),"getActionTarget"),actionTargetRequiresWrapper:d(()=>!1,"actionTargetRequiresWrapper"),getTitleTarget:d(i=>i.querySelector(".name"),"getTitleTarget"),hasSubtitleTag:d(()=>!1,"hasSubtitleTag")})}parseActorMovies(e,t){if(typeof e!="string")throw new TypeError("JavDB actor page must be HTML text");let s=new DOMParser().parseFromString(e,"text/html"),a=s.querySelector("title")?.textContent??s.body?.textContent??"";if(/Just a moment|cf-chl-|Cloudflare/i.test(a))throw new TypeError("JavDB actor page is a challenge page");let i=[...s.querySelectorAll(".movie-list .item")];return Object.freeze(i.flatMap(r=>{let n=r.querySelector(".video-title"),l=n?.querySelector("strong")?.textContent?.trim()??"",c=ee(l);if(!c)return[];let h=r.querySelector("img")?.getAttribute("src")??"",u=r.querySelector("a[href]")?.getAttribute("href")??"",p=r.querySelector(".score .value, .score")?.textContent?.trim()??"",g=r.querySelector(".score .count, .meta .count")?.textContent?.trim()??"";return[Object.freeze({carNum:c,title:(n?.textContent??"").replace(l,"").trim(),coverUrl:h?new URL(h,t).href.replace("thumbs","covers"):null,url:u?new URL(u,t).href:null,publishTime:r.querySelector(".meta")?.textContent?.trim()||null,score:Number.parseFloat(p)||0,voteCount:Number.parseInt(g.replace(/[^\d]/g,""))||0})]}))}parseActorCollection(e,t){if(typeof e!="string")throw new TypeError("JavDB actor collection must be HTML text");let s=new DOMParser().parseFromString(e,"text/html"),a=s.querySelector("title")?.textContent??s.body?.textContent??"";if(/Just a moment|cf-chl-|Cloudflare/i.test(a))return Object.freeze({state:"challenge",isEmpty:!1,actors:Object.freeze([]),nextUrl:null});let i=s.querySelector("#actors");if(!i)return Object.freeze({state:"invalid",isEmpty:!1,actors:Object.freeze([]),nextUrl:null});try{let r=[...i.querySelectorAll(".actor-box")],n=r.map(h=>{let u=h.querySelector("a[title][href]"),p=u?.getAttribute("title")??"",g=u?.getAttribute("href")??"",m=u?.querySelector("img")?.getAttribute("src")??"",f=p.split(",").map(w=>w.trim()).filter(Boolean),v=new URL(g,t),b=v.pathname.split("/").filter(Boolean).pop()??"";if(!b||!f.length)throw new TypeError("演员卡身份字段无效");return Object.freeze({starId:b,name:f[0],allName:Object.freeze(f),avatar:m?new URL(m,t).href:null,actressType:u?.querySelector(".info")?.textContent?.trim().includes("無碼")?"uncensored":"censored",lastCheckTime:null,lastUpdateTime:null})}),l=s.querySelector(".pagination-next")?.getAttribute("href"),c=l?new URL(l,t).href:null;return Object.freeze({state:"valid",isEmpty:r.length===0,actors:Object.freeze(n),nextUrl:c})}catch{return Object.freeze({state:"invalid",isEmpty:!1,actors:Object.freeze([]),nextUrl:null})}}};d(Zi,"JavDbHostAdapter");var $s=Zi;var hh=new Set(["system","feature"]),uh=new Set(["eager","idle","on-command","on-demand"]),ph=new Set(["builtin-public","custom-public","user-local"]),gh=new Set(["bronze","silver"]);function ht(o,e){if(typeof o!="string"||o.trim()==="")throw new TypeError(`${e} must be a non-empty string`)}d(ht,"requireNonEmptyString");function hl(o,e){if(!Array.isArray(o))throw new TypeError(`${e} must be an array`)}d(hl,"requireArray");function At(o,e){hl(o,e);let t=o;for(let s of t)ht(s,`${e} item`);if(new Set(t).size!==t.length)throw new TypeError(`${e} cannot contain duplicates`)}d(At,"requireUniqueStrings");function Xi(o,e){hl(o,e);let t=o;if(t.some(s=>typeof s!="symbol"))throw new TypeError(`${e} must contain dependency tokens`);if(new Set(t).size!==t.length)throw new TypeError(`${e} cannot contain duplicate tokens`)}d(Xi,"requireUniqueTokens");function Ie(o){if(ht(o.id,"Feature id"),!hh.has(String(o.kind)))throw new TypeError("Feature kind must be system or feature");if(!uh.has(String(o.startup)))throw new TypeError("Feature startup mode is invalid");for(let e of["sites","routes","contributes","providesCommands"])At(o[e],e);if(Xi(o.requires,"requires"),typeof o.activate!="function")throw new TypeError("Feature activate must be a function");if(typeof o.disableable!="boolean")throw new TypeError("Feature disableable must be explicit");if(o.kind==="system"&&o.disableable!==!1)throw new TypeError("System features cannot be disableable");return Object.freeze({...o})}d(Ie,"defineFeature");function ul(o){if(ht(o.id,"Contribution id"),ht(o.featureId,"Contribution featureId"),ht(o.legacyPluginId,"Contribution legacyPluginId"),At(o.sites,"sites"),At(o.routes,"routes"),At(o.surfaces,"surfaces"),Xi(o.requires,"requires"),typeof o.plugin!="function")throw new TypeError("Contribution plugin must be a class");if(!o.order||typeof o.order!="object")throw new TypeError("Contribution order must be explicit");return Object.freeze({...o,sites:Object.freeze([...o.sites]),routes:Object.freeze([...o.routes]),surfaces:Object.freeze([...o.surfaces]),order:Object.freeze({...o.order})})}d(ul,"defineContribution");function W(o){if(ht(o.id,"Integration id"),!ph.has(String(o.trustClass)))throw new TypeError("Integration trustClass is invalid");if(!gh.has(String(o.quality)))throw new TypeError("Integration quality is invalid");At(o.hosts,"hosts"),At(o.capabilities,"capabilities"),Xi(o.requires,"requires");let e=o.hosts,t=o.capabilities;if(e.length===0||t.length===0)throw new TypeError("Integration hosts and capabilities cannot be empty");for(let s of e){let a=String(s);if(a!==a.toLowerCase()||new URL(`https://${a}`).hostname!==a)throw new TypeError(`Integration host is invalid: ${a}`)}if(o.cachePolicy===void 0)throw new TypeError("Integration cachePolicy must be explicit");if(o.cachePolicy!=="none"){if(!o.cachePolicy||typeof o.cachePolicy!="object"||Array.isArray(o.cachePolicy))throw new TypeError("Integration cachePolicy must be none or a capability map");let s=o.cachePolicy,a=t.filter(r=>!Object.hasOwn(s,String(r))),i=Object.keys(s).filter(r=>!t.includes(r));if(a.length||i.length)throw new TypeError(`Integration cachePolicy mismatch (missing: ${a.join(", ")||"none"}; extra: ${i.join(", ")||"none"})`);for(let[r,n]of Object.entries(s))ht(n,`cachePolicy.${r}`)}if(typeof o.createClient!="function"||typeof o.createAdapter!="function")throw new TypeError("Integration client and adapter factories are required");if(o.createHostAdapter!==null&&typeof o.createHostAdapter!="function")throw new TypeError("Integration createHostAdapter must be a function or explicit null");return Object.freeze({...o})}d(W,"defineIntegration");function G(o,e){return Symbol.for(`jhs.${o}.${e}`)}d(G,"createToken");var Z=Object.freeze({navigation:G("port","navigation"),http:G("port","http"),storage:G("port","storage"),dialog:G("port","dialog"),style:G("port","style"),host:G("port","host"),javdbHost:G("port","javdb-host"),javbusHost:G("port","javbus-host")}),y=Object.freeze({diagnostics:G("service","diagnostics"),urlPolicy:G("service","url-policy"),navigation:G("service","navigation"),http:G("service","http"),storage:G("service","storage"),webdav:G("service","webdav"),credential:G("service","credential"),dialog:G("service","dialog"),settings:G("service","settings"),movie:G("service","movie"),actressInfo:G("service","actress-info"),imageSearch:G("service","image-search"),review:G("service","review"),related:G("service","related"),magnet:G("service","magnet"),screenshot:G("service","screenshot"),translation:G("service","translation"),subtitle:G("service","subtitle"),account:G("service","account"),offline:G("service","offline"),cache:G("service","cache"),state:G("service","state"),profile:G("service","profile")}),Qe=Object.freeze({command:G("registry","command"),feature:G("registry","feature"),provider:G("registry","provider"),settings:G("registry","settings"),integration:G("registry","integration")}),xe=Object.freeze({externalDetail:"external-detail-v1"});var er=class er{constructor(e){this.hostAdapter=e,this.root=null,this.slots=Object.freeze({}),this.mounted=new Map,this.created=new Set}mount(){return this.root=this.hostAdapter.locateDetailRoot(),this.root&&this.ensureOwnedSlots(),this.slots=Object.freeze({...this.hostAdapter.locateDetailSlots()}),this}ensureOwnedSlots(){if(!this.root)return;let e=this.root.ownerDocument,t=this.root.querySelector(':scope > [data-jhs-slot="summary-actions"]');t||(t=e.createElement("div"),t.className="jhs-detail-owned-slot jhs-detail-owned-slot--summary-actions",t.setAttribute("data-jhs-slot","summary-actions"),this.root.append(t),this.created.add(t));let s=this.root.querySelector(':scope > [data-jhs-slot-group="post-resource"]');s||(s=e.createElement("div"),s.className="jhs-detail-post-resource",s.setAttribute("data-jhs-slot-group","post-resource"),this.root.append(s),this.created.add(s));for(let a of["reviews","related"]){if(s.querySelector(`[data-jhs-slot="${a}"]`))continue;let i=e.createElement("section");i.className=`jhs-detail-owned-slot jhs-detail-owned-slot--${a}`,i.setAttribute("data-jhs-slot",a),s.append(i),this.created.add(i)}}mountPanel(e,t){let s=this.slots[e];return s?(s.append(t),this.mounted.set(e,t),!0):!1}dispose(){for(let e of this.mounted.values())e.remove();for(let e of this.created)e.remove();this.mounted.clear(),this.created.clear(),this.root=null,this.slots=Object.freeze({})}};d(er,"HostedDetailSurface");var Ts=er;var tr=class tr{constructor(e){this.hostAdapter=e.hostAdapter,this.scope=e.scope,this.enabledContributions=new Set(e.enabledContributions),this.surface=new Ts(this.hostAdapter),this.movieRef=null}start(){return this.scope.assertActive(),this.surface.mount(),this.movieRef=this.hostAdapter.readMovieRef(),Object.freeze({movieRef:this.movieRef,contributions:[...this.enabledContributions]})}dispose(){this.surface.dispose(),this.scope.dispose()}};d(tr,"DetailController");var As=tr;var pl=Ie({id:"detail",kind:"feature",disableable:!0,sites:["javdb","javbus"],routes:["detail","owned-detail"],startup:"eager",requires:[Z.host,y.movie,y.review,y.related,y.magnet,y.screenshot],contributes:["detail.javdb-native","detail.javbus-native","detail.workspace","detail.fc2-owned","detail.fc2-navigation","detail.fc2-lookup","detail.cover-state-actions","detail.page-state-actions","detail.javdb-preview","detail.javbus-images","detail.javbus-preview","detail.reviews","detail.related","detail.native-magnets","detail.external-magnets","detail.screenshot","detail.external-sites"],providesCommands:[],activate:d((o,e)=>{if(e.route==="owned-detail")return{dispose:d(()=>{},"dispose")};let t=new As({hostAdapter:o[Z.host],scope:e.scope,enabledContributions:e.enabledContributions});return t.start(),{dispose:d(()=>t.dispose(),"dispose")}},"activate")});var Pt=d((o,e,t)=>Ie({id:o,kind:"feature",disableable:!0,sites:e,routes:[],startup:"eager",requires:[],contributes:t,providesCommands:[],activate:d(()=>({}),"activate")}),"feature"),gl=Object.freeze([Pt("list",["javdb","javbus"],["list.core","list.auto-page","list.fold-category","list.actions"]),Pt("library",["javdb","javbus"],["library.history","library.keyword-filter","library.state-actions","library.blacklist","library.favorite-actresses"]),Pt("discovery",["javdb","javbus"],["discovery.hit-show","discovery.top250","discovery.new-video","discovery.scheduler"]),Pt("external-bridge",["javdb","javbus","123pan","javtrailers","subtitlecat"],["external-bridge.translation","external-bridge.115-match","external-bridge.offline","external-bridge.123pan","external-bridge.javtrailers","external-bridge.subtitle"]),Pt("identity",["javdb","javbus"],["identity.javdb-navigation","identity.javbus-navigation","identity.image-search","identity.actress-info"]),Pt("compatibility",["javdb","javbus"],["compatibility.enhancements"])]);var ut=null;function ml(o){if(typeof o!="function")throw new TypeError("Settings UI owner must be a function");if(ut&&ut!==o)throw new Error("Settings UI owner is already registered");return ut=o,()=>{ut===o&&(ut=null)}}d(ml,"registerSettingsUiOwner");function fl(o){if(!ut)throw new Error("Settings UI owner is not ready");return ut(o)}d(fl,"openSettingsUi");var vl=Object.freeze([Ie({id:"settings",kind:"system",disableable:!1,sites:[],routes:[],startup:"on-command",requires:[y.diagnostics,y.profile],contributes:["settings.core"],providesCommands:["settings.open"],activate:d(()=>({commands:{"settings.open":d(()=>fl(),"settings.open")}}),"activate")}),Ie({id:"diagnostics",kind:"system",disableable:!1,sites:[],routes:[],startup:"eager",requires:[y.diagnostics],contributes:[],providesCommands:["diagnostics.export"],activate:d(o=>({commands:{"diagnostics.export":d(()=>o[y.diagnostics].exportSnapshot(),"diagnostics.export")}}),"activate")}),Ie({id:"responsive-shell",kind:"system",disableable:!1,sites:[],routes:[],startup:"eager",requires:[y.profile],contributes:["responsive-shell.bottom-bar"],providesCommands:[],activate:d(o=>({profile:o[y.profile].current()}),"activate")}),Ie({id:"stats",kind:"system",disableable:!1,sites:["javdb","javbus"],routes:[],startup:"idle",requires:[y.diagnostics],contributes:["stats.dashboard"],providesCommands:[],activate:d(o=>({diagnostics:o[y.diagnostics]}),"activate")})]);var bl=Object.freeze([...vl,pl,...gl]);var wl=Object.freeze({AutoPagePlugin:["ListPagePlugin"],BlacklistPlugin:["TaskPlugin","SettingPlugin","ListPagePlugin"],BusNavBarPlugin:["SearchByImagePlugin"],CompatibilityEnhancementsPlugin:["ListPagePlugin"],CoverButtonPlugin:["ListPagePlugin","ScreenShotPlugin"],DetailPageButtonPlugin:["DetailWorkspacePlugin","MagnetHubPlugin","HighlightMagnetPlugin"],Fc2NavigationPlugin:["Fc2Plugin"],Fc2Plugin:["DetailPageButtonPlugin","MagnetHubPlugin","FilterTitleKeywordPlugin","OtherSitePlugin","Fc2By123AvPlugin","TOP250Plugin","ScreenShotPlugin"],HistoryPlugin:["UnifiedOfflinePlugin","ListPagePlugin","Fc2Plugin"],HitShowPlugin:["ListPageButtonPlugin","ListPagePlugin","CoverButtonPlugin"],ListPageButtonPlugin:["NewVideoPlugin","BlacklistPlugin","ListPagePlugin"],ListPagePlugin:["HistoryPlugin","ListPageButtonPlugin","CoverButtonPlugin","AutoPagePlugin","BusImgPlugin","Fc2Plugin"],MobileBottomBarPlugin:["ListPagePlugin","DetailPageButtonPlugin","ListPageButtonPlugin","NewVideoPlugin","BlacklistPlugin","SettingPlugin","HighlightMagnetPlugin","MagnetHubPlugin","HistoryPlugin"],NavBarPlugin:["SearchByImagePlugin"],NewVideoPlugin:["TaskPlugin","SettingPlugin"],PreviewVideoPlugin:["DetailPageButtonPlugin"],SettingPlugin:["CoverButtonPlugin","TaskPlugin","OtherSitePlugin","ListPagePlugin","TranslatePlugin","ActressInfoPlugin","ScreenShotPlugin","NewVideoPlugin","BlacklistPlugin","BusImgPlugin"],StatsPlugin:["NewVideoPlugin","ListPagePlugin"],TaskPlugin:["BlacklistPlugin"],TranslatePlugin:["ListPagePlugin"],TOP250Plugin:["HitShowPlugin","ListPageButtonPlugin"],UnifiedOfflinePlugin:["OneTwoThreeOfflinePlugin"]});var sr=class sr extends I{constructor(){super(...arguments),this._generation=0}getName(){return"ActressInfoPlugin"}async handle(){let e=this.getRuntimeService("settings"),t=await this.getRuntimeService("scope")(),s=d(a=>{a.detail?.names?.includes("enableLoadActressInfo")&&(e.snapshot().enableLoadActressInfo==="no"?this.unmount():this.mount().catch((r=>clog.error("演员信息重新挂载失败",r))))},"onSettingsChanged");e.addEventListener("settings.changed",s),t.addCleanup((()=>e.removeEventListener("settings.changed",s))),await this.mount()}async mount(){if(this.getRuntimeService("settings").snapshot().enableLoadActressInfo==="no")return;this._generation++;let e=window.location.pathname;e.startsWith("/v/")||e.startsWith("/movies/")?await this.handleDetailPage():e.startsWith("/actors/")&&await this.handleStarPage()}unmount(){this._generation++,$(".actress-info").remove()}isStillActive(e){return e===this._generation&&this.getRuntimeService("settings").snapshot().enableLoadActressInfo!=="no"}async initCss(){return``}async handleDetailPage(){if($(".actress-info").length>0)return;let e=this._generation,t=$(".female"),s=t.prev().map(((n,l)=>$(l).text().trim())).get();if(!s.length)return;let a=[];for(let n of s){let l=null;try{l=await this.searchInfo(n)}catch(h){clog.error("演员资料查询失败",n,h)}if(!this.isStillActive(e))return;let c=$('
');if(l){c.append($("").text(`${n}:`));let h=$("").attr({href:l.url,target:"_blank",rel:"noopener noreferrer"}).addClass("jhs-layout-9813a0dd");h.append($("").addClass("info-tag").text(`${l.birthday} ${l.age}`.trim()),$("").addClass("info-tag").text(`${l.height} ${l.weight}`.trim()),$("").addClass("info-tag").text(`${l.threeSizeText} ${l.braSize}`.trim())),c.append(h)}else{let h=this.getRuntimeService("actressInfo").profileUrl(n);c.append($("").attr({href:h,target:"_blank",rel:"noopener noreferrer"}).append($("").text(`${n}:`)))}a.push(c)}if(!this.isStillActive(e))return;let i=$("strong").filter(((n,l)=>/^(?:演員|演员)\s*:?$/.test($(l).text().trim()))).first().parent(),r=t.first().closest(".panel-block");i.length?i.after(...a):r.after(...a)}async handleStarPage(){if($(".actress-info").length>0)return;let e=this._generation,t=[],s=$(".actor-section-name");s.length&&s.text().trim().split(",").forEach((l=>t.push(l.trim())));let a=$(".section-meta:not(:contains('影片'))");if(a.length&&a.text().trim().split(",").forEach((l=>t.push(l.trim()))),!t.length)return;let i=null;for(let l of t){try{i=await this.searchInfo(l)}catch(c){clog.error("演员资料查询失败",l,c)}if(!this.isStillActive(e))return;if(i)break}let r=$('
');if(!i)r.text("无此相关演员信息");else{let l=$('
'),c=$('
');l.append($("").addClass("jhs-layout-dd5a75f6").text(`出生日期: ${i.birthday}`),$("").addClass("jhs-layout-d4a09a0d").text(`年龄: ${i.age}`),$("").addClass("jhs-layout-d4a09a0d").text(`身高: ${i.height}`)),c.append($("").addClass("jhs-layout-dd5a75f6").text(`体重: ${i.weight}`),$("").addClass("jhs-layout-d4a09a0d").text(`三围: ${i.threeSizeText}`),$("").addClass("jhs-layout-d4a09a0d").text(`罩杯: ${i.braSize}`)),r.append(l,c)}if(!this.isStillActive(e))return;let n=i?$("").addClass("actress-info").attr({href:i.url,target:"_blank",rel:"noopener noreferrer"}).append(r):r.addClass("actress-info");s.parent().append(n)}async searchInfo(e){let t=await this.getRuntimeService("scope")();return this.getRuntimeService("actressInfo").lookup(e,{scope:t})}};d(sr,"ActressInfoPlugin");var Ps=sr;var ar=class ar extends I{constructor(){super(...arguments),A(this,"isUploading",!1)}getName(){return"SearchByImagePlugin"}async initCss(){return` - `; - } - async handle() { - window.isListPage && (o.includes("advanced_search") || (this.highlightTag(), utils.loopDetector((() => $("#waitCheckBtn").length), (() => { - this.createFoldBtn(); - }), 1, 1e4, true), $("#tags .tag-category .tag-expand").each(((e2, t2) => { - $(t2).parent().hasClass("collapse") && t2.click(); - })))); - } - highlightTag() { - (async () => { - const e2 = await storageManager.getHighlightedTags(); - e2 && e2.forEach(((e3) => { - $(`#tags a.tag:contains(${e3})`).addClass("highlighted"), $(`.tags a.tag:contains(${e3})`).addClass("highlighted"); - })); - })().catch(((error) => clog.error("分类高亮恢复失败", error))), $("#tags a.tag, .tags a.tag").hover((function() { - const e2 = $(this), t2 = $(''); - e2.append(t2), t2.fadeIn(0); - }), (function() { - $(this).find(".highlight-btn").fadeOut(0, (function() { - $(this).remove(); - })); - })), $(document).on("click", ".highlight-btn", (async function(e2) { - e2.stopPropagation(), e2.preventDefault(); - const t2 = $(this).closest("a.tag"), n2 = t2.clone(); - n2.find(".highlight-btn").remove(); - const a2 = n2.text().trim().replace(/\s*\(\d+\)$/, ""); - let i2 = await storageManager.getHighlightedTags(); - i2.includes(a2) ? (i2 = i2.filter(((e3) => e3 !== a2)), t2.removeClass("highlighted")) : (i2.push(a2), t2.addClass("highlighted")), await storageManager.setHighlightedTags(i2); - })); - } - async createFoldBtn() { - let t2 = $("#tags"), n2 = $("#tags dl div.tag.is-info").map((function() { - return $(this).text().replaceAll("\n", "").replaceAll(" ", ""); - })).get().join(" "); - if (!n2) return; - $(".tabs").append(` -
-
已选分类: ${n2}
- -
- `); - let a2 = $("h2.section-title"); - if (a2.length > 0 && (a2.append('\n
\n \n
\n '), t2 = $("section > div > div.box")), !t2) return; - let i2 = $("#foldCategoryBtn"), s2 = localStorage.getItem("jhs_foldCategory") === _, [o2, r2] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; - i2.find("span").text(o2).end().find("i").attr("class", r2), window.location.href.includes("noFold=1") || t2[s2 ? "hide" : "show"](), i2.on("click", (async (e2) => { - e2.preventDefault(), s2 = !s2, localStorage.setItem("jhs_foldCategory", s2 ? _ : C); - const [n3, a3] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; - i2.find("span").text(n3).end().find("i").attr("class", a3), t2[s2 ? "hide" : "show"](); - })); - } - }; - __name(_FoldCategoryPlugin, "FoldCategoryPlugin"); - var FoldCategoryPlugin = _FoldCategoryPlugin; - var _ActressInfoPlugin = class _ActressInfoPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "apiUrl", "https://ja.wikipedia.org/wiki/"); - } - getName() { - return "ActressInfoPlugin"; - } - async handle() { - "yes" === await storageManager.getSetting("enableLoadActressInfo", "yes") && await this.loadActressInfo(); - } - async loadActressInfo() { - await Promise.all([this.handleDetailPage(), this.handleStarPage()]); - } - async initCss() { - return "\n \n "; - } - async handleDetailPage() { - if ($(".actress-info").length > 0) return; - let e2 = $(".female").prev().map(((e3, t3) => $(t3).text().trim())).get(); - if (!e2.length) return; - const t2 = "jhs_actress_info", n2 = localStorage.getItem(t2) ? JSON.parse(localStorage.getItem(t2)) : {}; - let a2 = null, i2 = ""; - for (let o2 = 0; o2 < e2.length; o2++) { - let t3 = e2[o2]; - if (a2 = n2[t3], !a2) try { - a2 = await this.searchInfo(t3), a2 && (n2[t3] = a2); - } catch (s2) { - clog.error("该名称查询失败,尝试其它名称"); - } - let r2 = ""; - r2 = a2 ? ` - - ` : ` `, i2 += r2; - } - $('strong:contains("演員")').parent().after(i2), localStorage.setItem(t2, JSON.stringify(n2)); - } - async handleStarPage() { - if ($(".actress-info").length > 0) return; - let e2 = [], t2 = $(".actor-section-name"); - t2.length && t2.text().trim().split(",").forEach(((t3) => { - e2.push(t3.trim()); - })); - let n2 = $(".section-meta:not(:contains('影片'))"); - if (n2.length && n2.text().trim().split(",").forEach(((t3) => { - e2.push(t3.trim()); - })), !e2.length) return; - const a2 = "jhs_actress_info", i2 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}; - let s2 = null; - for (let l2 = 0; l2 < e2.length; l2++) { - let t3 = e2[l2]; - if (s2 = i2[t3], s2) break; - try { - s2 = await this.searchInfo(t3); - } catch (r2) { - clog.error("该名称查询失败,尝试其它名称"); - } - if (s2) break; - } - s2 && e2.forEach(((e3) => { - i2[e3] = s2; - })); - let o2 = '
无此相关演员信息
'; - s2 && (o2 = ` - -
-
- 出生日期: ${s2.birthday} - 年龄: ${s2.age} - 身高: ${s2.height} -
-
- 体重: ${s2.weight} - 三围: ${s2.threeSizeText} - 罩杯: ${s2.braSize} -
-
-
- `), t2.parent().append(o2), localStorage.setItem(a2, JSON.stringify(i2)); - } - async searchInfo(e2) { - "三上悠亞" === e2 && (e2 = "三上悠亜"); - let t2 = this.apiUrl + e2; - const n2 = await gmHttp.get(t2), a2 = new DOMParser(), i2 = $(a2.parseFromString(n2, "text/html")); - let s2 = i2.find('a[title="誕生日"]').parent().parent().find("td").text().trim(), o2 = i2.find("th:contains('現年齢')").parent().find("td").text().trim() ? parseInt(i2.find("th:contains('現年齢')").parent().find("td").text().trim()) + "岁" : "", r2 = i2.find('tr:has(a[title="身長"]) td').text().trim().split(" ")[0] + "cm", l2 = i2.find('tr:has(a[title="体重"]) td').text().trim().split("/")[1].trim(); - return "― kg" === l2 && (l2 = ""), { - birthday: s2, - age: o2, - height: r2, - weight: l2, - threeSizeText: i2.find('a[title="スリーサイズ"]').closest("tr").find("td").text().replace("cm", "").trim(), - braSize: i2.find('th:contains("ブラサイズ")').next("td").contents().first().text().trim(), - url: t2 - }; - } - }; - __name(_ActressInfoPlugin, "ActressInfoPlugin"); - var ActressInfoPlugin = _ActressInfoPlugin; - var _HitShowPlugin = class _HitShowPlugin extends BasePlugin { - constructor() { - super(), i(this, "$contentBox", $(".section .container")), i(this, "loadGeneration", 0); - } - getName() { - return "HitShowPlugin"; - } - async initCss() { - return ``; - } - async handle() { - $('a[href*="rankings/playback"]').on("click", ((e2) => { - e2.preventDefault(), e2.stopPropagation(), window.location.href = "/advanced_search?handlePlayback=1&period=daily"; - })), await this.handlePlayback(); - } - hookPage() { - let e2 = $("h2.section-title"); - e2.contents().first().replaceWith("热播"), e2.addClass("jhs-hitshow-title"), e2.parent(".jhs-hitshow-heading").length || e2.wrap('
'), $(".empty-message").remove(), $(".section .container .box").remove(), $(".movie-list.jhs-hitshow-list").remove(), this.$contentBox.append('
'); - } - async handlePlayback() { - if (!isHitShowPage()) return; - const period = new URLSearchParams(window.location.search).get("period"), generation = ++this.loadGeneration; - this.hookPage(), this.toolBar(period); - const loadingObj = loading(); - let loadingClosed = false; - try { - const movies = await this.fetchPlaybackWithRetry(period); - if (generation !== this.loadGeneration) return; - $(".movie-list").html(this.markDataListHtml(movies)); - await this.initializeRenderedList(); - await this.getBean("ListPageButtonPlugin").sortItems(); - loadingObj.close(), loadingClosed = true; - void this.loadScore(movies, generation).then((async () => { - if (generation === this.loadGeneration && "rateCount" === localStorage.getItem("jhs_sortMethod")) await this.getBean("ListPageButtonPlugin").sortItems(); - })).catch(((error) => clog.error("热播评分补全失败", error))); - } catch (error) { - clog.error("所有重试尝试均失败,无法获取数据。", error); - } finally { - loadingClosed || loadingObj.close(); - } - } - async fetchPlaybackWithRetry(period) { - let lastError; - for (let attempt = 1; attempt <= 3; attempt++) try { - return await W(period); - } catch (error) { - lastError = error; - if (attempt < 3) clog.error(`获取热播数据失败 (第 ${attempt} 次重试)`, error), await new Promise(((resolve) => setTimeout(resolve, 1e3))); - } - throw lastError; - } - async initializeRenderedList() { - const listPage = this.getBean("ListPagePlugin"); - listPage.replaceHdImg(), await listPage.doFilter(), listPage.applyVisibility(), listPage.bindMovieDetailNavigation(listPage.getSelector().boxSelector); - this.getBean("CoverButtonPlugin").addSvgBtn(); - } - toolBar(e2) { - $("#jhs-hitshow-period").remove(); - let t2 = ` - - `; - $(".jhs-hitshow-heading").append(t2); - } - getStarRating(e2) { - let t2 = ""; - const n2 = Math.floor(e2); - for (let a2 = 0; a2 < n2; a2++) t2 += ''; - for (let a2 = 0; a2 < 5 - n2; a2++) t2 += ''; - return t2; - } - async loadScore(movies, generation = this.loadGeneration) { - if (0 === movies.length) return; - const cacheKey = "jhs_score_info"; - let cache = {}; - try { - cache = JSON.parse(localStorage.getItem(cacheKey) || "{}"); - } catch (error) { - clog.warn("评分缓存解析失败,将重新建立缓存", error); - } - const queue = [...movies], workers = Array.from({ length: Math.min(4, queue.length) }, (() => this.scoreWorker(queue, cache, generation))); - await Promise.all(workers), localStorage.setItem(cacheKey, JSON.stringify(cache)); - } - async scoreWorker(queue, cache, generation) { - for (; ; ) { - const movie = queue.shift(); - if (!movie) return; - try { - if (generation !== this.loadGeneration) return; - const id = movie.id; - if (!$(`#score_${id}`).length || $(`#${id}`).is(":hidden")) continue; - if (cache[id]) { - const cached = this.normalizeScoreData(cache[id]); - this.appendScore(id, cached.score, cached.watchedCount); - continue; - } - const result = await V(id); - if (generation !== this.loadGeneration) return; - const score = Number(result.score), watchedCount = Number(result.watchedCount); - this.appendScore(id, score, watchedCount), cache[id] = { score: Number.isFinite(score) ? score : 0, watchedCount: Number.isFinite(watchedCount) ? watchedCount : 0 }; - } catch (error) { - $(`#${movie.id}`).attr("data-jhs-rate-count", "0"), clog.error(`解析评分数据失败 | 编号: ${movie.number} -`, `错误详情: ${error.message} -`, error.stack ? `调用栈: -${error.stack}` : ""); - } - } - } - normalizeScoreData(value) { - const html = "string" == typeof value ? value : String(value?.html || ""), score = Number(value?.score ?? (html.match(/([\d.]+)分/) || [0, 0])[1]), watchedCount = Number(value?.watchedCount ?? (html.match(/由(\d+)人/) || [0, 0])[1]); - return { score: Number.isFinite(score) ? score : 0, watchedCount: Number.isFinite(watchedCount) ? watchedCount : 0 }; - } - appendScore(e2, score, watchedCount = 0) { - const safeScore = Math.min(5, Math.max(0, Number(score) || 0)), safeCount = Math.max(0, Number(watchedCount) || 0), card = $(`#${e2}`), target = $(`#score_${e2}`); - card.attr("data-jhs-rate-count", String(safeCount)); - if (!target.length || "" !== target.text().trim()) return; - const value = $(''), stars = $('').html(this.getStarRating(safeScore)); - value.append(stars, document.createTextNode(` ${safeScore}分,由${safeCount}人评价`)), target.hide().empty().append(value).slideDown(500); - } - markDataListHtml(e2) { - let t2 = ""; - return e2.forEach(((e3, index) => { - const coverUrl = normalizeHttpUrl(String(e3.cover_url || "").replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")); - t2 += ` - - `; - })), t2; - } - }; - __name(_HitShowPlugin, "HitShowPlugin"); - var HitShowPlugin = _HitShowPlugin; - var me = "jhs_appAuthorization"; - var _Top250Plugin = class _Top250Plugin extends BasePlugin { - constructor() { - super(), i(this, "has_cnsub", ""), i(this, "$contentBox", $(".section .container")), i(this, "movies", []); - } - getName() { - return "TOP250Plugin"; - } - async handle() { - $('.main-tabs ul li:contains("猜你喜歡")').html('Top250'), $('a[href*="rankings/top"]').on("click", ((e2) => { - e2.preventDefault(), e2.stopPropagation(); - const t2 = $(e2.target), n2 = (t2.is("a") ? t2 : t2.closest("a")).attr("href"); - let a2 = n2.includes("?") ? n2.split("?")[1] : n2; - const i2 = new URLSearchParams(a2); - this.checkLogin(e2, i2); - })), await this.handleTop(); - } - hookPage() { - $("h2.section-title").contents().first().replaceWith("Top250"), $(".empty-message").remove(), $(".section .container .box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append('
'), this.$contentBox.append('
'), this.renderPagination(); - } - renderPagination() { - const e2 = new URLSearchParams(window.location.search); - let t2 = parseInt(e2.get("page")) || 1; - this.$contentBox.append(((e3) => { - const t3 = e3 >= 5; - let n2 = ""; - for (let a2 = 1; a2 <= 5; a2++) { - n2 += `
  • `; - } - return ` - - `; - })(t2)), this.$contentBox.on("click", ".pagination-link, .pagination-previous, .pagination-next", ((t3) => { - t3.preventDefault(); - const n2 = parseInt($(t3.currentTarget).data("page")); - !isNaN(n2) && n2 > 0 && ((t4) => { - e2.set("page", t4), window.history.pushState({}, "", "?" + e2.toString()), window.location.reload(); - })(n2); - })); - } - async handleTop() { - if (!window.location.href.includes("handleTop=1")) return; - const e2 = new URLSearchParams(window.location.search); - let t2 = e2.get("handleType") || "all", n2 = e2.get("type_value") || ""; - this.has_cnsub = e2.get("has_cnsub") || ""; - let a2 = e2.get("page") || 1; - this.toolBar(t2, n2, a2), this.hookPage(); - let i2 = $(".movie-list"); - i2.html(""); - let s2 = loading(); - let o2 = false; - for (let l2 = 1; l2 <= 3 && !o2; l2++) try { - const e3 = await q(t2, n2, a2, 50); - let r2 = e3.success, l3 = e3.message, c2 = e3.action; - if (1 === r2) { - let t3 = e3.data.movies; - if (0 === t3.length) return show.error("无数据"), void s2.close(); - this.movies = t3; - const n3 = t3.filter(((e4) => "1" === this.has_cnsub ? e4.has_cnsub : "0" !== this.has_cnsub || !e4.has_cnsub)), a3 = this.getBean("HitShowPlugin"); - let r3 = a3.markDataListHtml(n3); - i2.html(r3), await a3.initializeRenderedList(), await a3.loadScore(n3), o2 = true; - } else clog.error(e3), i2.html(`

    ${escapeHtml(l3)}

    `), show.error(l3), "JWTVerificationError" === c2 && (await localStorage.removeItem(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o2 = true; - } catch (r2) { - l2 < 3 ? (clog.error(`获取Top数据失败 (第 ${l2} 次重试):`, r2), await new Promise(((e3) => setTimeout(e3, 1e3)))) : (clog.error("所有重试尝试均失败,无法获取Top数据。", r2), i2.html("

    无法加载数据,请稍后再试。

    ")); - } finally { - (o2 || 3 === l2) && s2.close(); - } - } - toolBar(e2, t2, n2) { - "5" === n2.toString() && $(".pagination-next").remove(), $(".pagination-ellipsis").closest("li").remove(), $(".pagination-list li .pagination-link").each((function() { - parseInt($(this).text()) > 5 && $(this).closest("li").remove(); - })); - let years = ""; - for (let year = (/* @__PURE__ */ new Date()).getFullYear(); year >= 2008; year--) years += `${year}`; - const typeLink = /* @__PURE__ */ __name((value, label, type = "video_type") => `${label}`, "typeLink"); - const html = `
    `; - this.$contentBox.append(html), $("button[data-cnsub-value]").on("click", (async (event) => { - const value = $(event.currentTarget).data("cnsub-value"); - this.has_cnsub = value.toString(), $("button[data-cnsub-value]").removeClass("active").attr("aria-pressed", "false"), $(event.currentTarget).addClass("active").attr("aria-pressed", "true"), $(".jhs-top250-filters a").each(((index, element) => { - const link = $(element), url = new URL(link.attr("href"), window.location.origin); - url.searchParams.set("has_cnsub", value), link.attr("href", url.toString()); - })); - const movies = this.movies.filter(((movie) => "1" === this.has_cnsub ? movie.has_cnsub : "0" !== this.has_cnsub || !movie.has_cnsub)), hitShow = this.getBean("HitShowPlugin"); - $(".movie-list").html(hitShow.markDataListHtml(movies)), await hitShow.initializeRenderedList(), void hitShow.loadScore(movies).catch(((error) => clog.error("Top250 评分加载失败", error))); - })); - } - async checkLogin(e2, t2) { - if (!localStorage.getItem(me)) return show.error("该类别依赖移动端接口,请先完成登录"), void this.openLoginDialog(); - let n2 = "all", a2 = "", i2 = t2.get("t") || ""; - /^y\d+$/.test(i2) ? (n2 = "year", a2 = i2.substring(1)) : "" !== i2 && (n2 = "video_type", a2 = i2); - let s2 = `/advanced_search?handleTop=1&handleType=${n2}&type_value=${a2}`; - e2 && (e2.ctrlKey || e2.metaKey) ? GM_openInTab(window.location.origin + s2, { - insert: 0 - }) : window.location.href = s2; - } - openLoginDialog({ onSuccess = null } = {}) { - layer.open({ - type: 1, - title: "JavDB", - closeBtn: 1, - area: utils.getResponsiveArea(["360px", "auto"]), - shadeClose: false, - content: ` - -
    -
    - + .search-img-site-btns-container { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 15px; + } + .search-img-site-btn { + display: flex; + align-items: center; + padding: 8px 12px; + background-color: var(--jhs-surface-2); + border-radius: var(--jhs-radius-sm); + text-decoration: none; + color: var(--jhs-text); + transition: all 0.2s; + font-size: 14px; + border: 1px solid var(--jhs-border); + } + .search-img-site-btn:hover { + background-color: var(--jhs-border); + transform: translateY(-2px); + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + .search-img-site-btn img { + width: 16px; + height: 16px; + margin-right: 6px; + } + .search-img-site-btn span { + white-space: nowrap; + } + + `}open(e){this.getRuntimeService("dialog").open({type:1,title:"以图识图",content:` +
    +
    +
    +

    拖拽图片到此处 或 点击按钮选择图片

    +

    也可以直接 Ctrl+V 粘贴图片或 图片URL

    + + +
    -
    - + +
    - placeholder="密码" - onfocus="this.style.borderColor='var(--jhs-accent)'; this.style.background='var(--jhs-surface)'" - onblur="this.style.borderColor='var(--jhs-border-strong)'; this.style.background='var(--jhs-surface-2)'" class="jhs-field"> +
    + +
    + +
    - -
    - `, - success: /* @__PURE__ */ __name((e2, t2) => { - $("#loginBtn").click((function() { - const e3 = $("#username").val(), n2 = $("#password").val(); - if (!e3 || !n2) return void show.error("请输入用户名和密码"); - let a2 = loading(); - (async (e4, t3) => { - let n3 = `${U}/v1/sessions?username=${encodeURIComponent(e4)}&password=${encodeURIComponent(t3)}&device_uuid=04b9534d-5118-53de-9f87-2ddded77111e&device_name=iPhone&device_model=iPhone&platform=ios&system_version=17.4&app_version=official&app_version_number=1.9.29&app_channel=official`, a3 = { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - "content-type": "multipart/form-data; boundary=--dio-boundary-2210433284", - jdsignature: await O() - }; - return await gmHttp.post(n3, null, a3); - })(e3, n2).then((async (e4) => { - let n3 = e4.success; - if (0 === n3) show.error(e4.message); - else { - if (1 !== n3) throw clog.error("登录失败", e4), new Error(e4.message); - { - let n4 = e4.data.token; - await localStorage.setItem(me, await encryptData(n4)), show.ok("登录成功"), layer.close(t2), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; - } - } - })).catch(((e4) => { - clog.error("登录异常:", e4), show.error(e4.message); - })).finally((() => { - a2.close(); - })); - })); - }, "success") - }); - } - }; - __name(_Top250Plugin, "Top250Plugin"); - var Top250Plugin = _Top250Plugin; - var _NavBarPlugin = class _NavBarPlugin extends BasePlugin { - getName() { - return "NavBarPlugin"; - } - async initCss() { - return "\n .highlight-red {\n /* 核心要求:高亮红色文本 */\n color: var(--jhs-status-filter) !important;\n \n /* 建议:增加字体加粗,效果更明显 */\n font-weight: bold;\n \n /* 建议:增加背景色,效果更突出 */\n /* background-color: yellow; */ \n}\n "; - } - handle() { - if (this.margeNav(), this.hookSearch(), this.hookOldSearch(), this.toggleOtherNavItem(), $(window).resize(this.toggleOtherNavItem), JhsSelect.enhance("#search-box"), window.location.href.includes("/search")) { - const e2 = new URLSearchParams(window.location.search); - let t2 = e2.get("q"), n2 = e2.get("f"); - $("#search-keyword").val(t2), n2 && JhsSelect.setValue("#search-type", n2), t2 && this.highlightKeyword(t2); - } - } - highlightKeyword(e2) { - const t2 = e2.trim(); - if (!t2) return; - const n2 = t2.toLowerCase(); - $(".video-title strong, .actor-box strong").each((function() { - const e3 = $(this); - e3.text().toLowerCase().includes(n2) && e3.addClass("highlight-red"); - })); - } - hookSearch() { - $("#navbar-menu-hero").after('\n \n '), $("#search-keyword").on("paste", ((e2) => { - const t2 = e2.originalEvent.clipboardData.items; - for (let n2 = 0; n2 < t2.length; n2++) if (-1 !== t2[n2].type.indexOf("image")) { - const e3 = t2[n2].getAsFile(); - $("#search-keyword").blur(); - const a2 = this.getBean("SearchByImagePlugin"); - return void a2.open((() => { - a2.handleImageFile(e3), a2.resetSearchUI(); - })); - } - })).on("keypress", ((e2) => { - "Enter" === e2.key && setTimeout((() => { - $("#search-btn").click(); - }), 0); - })), $("#search-btn").on("click", ((e2) => { - let t2 = $("#search-keyword").val(), n2 = $("#search-type").val(); - "" !== t2 && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + t2 + "&f=" + n2 : window.open("/search?q=" + t2 + "&f=" + n2)); - })), $("#search-img-btn").on("click", (() => { - this.getBean("SearchByImagePlugin").open(); - })); - } - hookOldSearch() { - const e2 = document.querySelector(".search-image"); - if (!e2) return; - const t2 = e2.cloneNode(true); - e2.parentNode.replaceChild(t2, e2), $("#button-search-image").attr("data-tooltip", "以图识图"), $(".search-image").on("click", ((e3) => { - this.getBean("SearchByImagePlugin").open(); - })); - } - margeNav() { - $('a[href*="/feedbacks/new"]').remove(), $('a[href*="theporndude.com"]').remove(), $('a.navbar-link[href="/makers"]').parent().after('\n \n '); - } - toggleOtherNavItem() { - let e2 = $("#search-box"), t2 = $("#search-bar-container"); - $(window).width() < 1600 && $(window).width() > 1023 && (e2.hide(), t2.show()), $(window).width() > 1600 && (e2.show(), t2.hide()); - } - }; - __name(_NavBarPlugin, "NavBarPlugin"); - var NavBarPlugin = _NavBarPlugin; - var _StorageQueue = class _StorageQueue { - constructor() { - this.queue = Promise.resolve(); - } - addTask(e2) { - const task = this.queue.then((() => e2())); - return this.queue = task.catch(((e3) => { - clog.error("执行异步队列任务失败:", e3); - })), task; - } - async waitAllFinished() { - return this.queue; - } - }; - __name(_StorageQueue, "StorageQueue"); - var StorageQueue = _StorageQueue; - var _OtherSitePlugin = class _OtherSitePlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "siteConfigs", [{ - id: "javTrailersBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavTrailersUrl(), "getBaseUrl"), - itemSelector: ".videos-list .video-link", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find("p.card-text").text(), "findCarNumOrTitle") - }, { - id: "123AvBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => `${await this.getAv123Url()}/cn`, "getBaseUrl"), - itemSelector: ".card", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search?keyword=${encodeURIComponent(t2)}`, "searchPath"), - requestOptions: { cookiePartitionTopLevelSite: "https://123av.com" }, - getDetailPageHref: /* @__PURE__ */ __name((e2, t2) => { - const href = e2.find('a.card__link[href*="/cn/v/"]').first().attr("href"); - return href ? new URL(href, t2).href : null; - }, "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find(".card__link").first().text(), "findCarNumOrTitle"), - matches: /* @__PURE__ */ __name((text, carNum) => text.replace(/FC2-PPV-/gi, "FC2-").toLowerCase().includes(carNum.toLowerCase()), "matches") - }, { - id: "jableBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getjableUrl(), "getBaseUrl"), - itemSelector: "#list_videos_videos_list_search_result .detail .title a", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}/`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") - }, { - id: "avgleBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getAvgleUrl(), "getBaseUrl"), - itemSelector: ".text-secondary", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/vod/search.html?wd=${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") - }, { - id: "missAvBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getMissAvUrl(), "getBaseUrl"), - itemSelector: ".text-secondary", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") - }, { - id: "supJavBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getSupJavUrl(), "getBaseUrl"), - itemSelector: ".posts post", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/?s=${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2, t2, n2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.attr("title"), "findCarNumOrTitle") - }, { - id: "javDbBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavDbUrl(), "getBaseUrl"), - itemSelector: ".movie-list .item", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search?q=${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.find("a").attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find(".video-title").text(), "findCarNumOrTitle"), - condition: /* @__PURE__ */ __name((e2) => l, "condition") - }, { - id: "javBusBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavBusUrl(), "getBaseUrl"), - itemSelector: ".container h3", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2, t2, n2) => `${t2}/${n2}`, "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle"), - condition: /* @__PURE__ */ __name((e2) => r && e2 && !e2.includes("FC2"), "condition") - }, { - id: "fanzaBtn", - noHandle: true, - initUrl: /* @__PURE__ */ __name((e2) => `https://www.dmm.co.jp/search/=/searchstr=${e2}`, "initUrl"), - condition: /* @__PURE__ */ __name((e2) => e2 && !e2.includes("FC2"), "condition") - }]), i(this, "settingCache", null), i(this, "lastFetchTime", 0), i(this, "CACHE_DURATION", 1e4); - } - getName() { - return "OtherSitePlugin"; - } - async initCss() { - return ` - `; - } - async handle() { - isDetailPage && await this.loadOtherSite(null, null, { - autoDetect: false - }); - } - async loadOtherSite(e2, t2, n2 = {}) { - if ("yes" !== await storageManager.getSetting("enableLoadOtherSite", "yes")) return; - const root = n2.root ? $(n2.root) : $(document), target = n2.target ? $(n2.target) : root.find(".movie-panel-info,.container .info").first(); - if (!target.length || n2.isActive && !n2.isActive()) return; - root.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); - e2 = normalizeCarNum(e2) || this.getPageInfo().carNum; - const enabled = this.getEnabledSites(), configs = this.siteConfigs.map(((config) => ({ ...config, sourceCarNum: t2 }))), view = { root, target, configs, carNum: e2, isActive: "function" === typeof n2.isActive ? n2.isActive : () => true }; - const box = $('
    '), list = box.find(".jhs-site-list"), settings = $('
    '); - configs.forEach(((config) => { - if (config.condition && false === config.condition(config.sourceCarNum)) return; - const button = $('').attr("data-jhs-site-id", config.id).toggleClass("jhs-is-hidden", !enabled.includes(config.id)); - button.find("span").text(config.id.replace("Btn", "")), list.append(button); - })); - list.append('', ''), target.append(box, settings), view.box = box, view.settings = settings; - if (!e2) return box.find(".site-btn").removeAttr("href").attr({ "aria-disabled": "true", title: "番号不可用" }), box.find('[data-jhs-role="detect-sites"]').prop("disabled", true), this.renderSettingsArea(view), this.setupEventListeners(view), void clog.warn("跳过第三方站点解析:番号不可用"); - box.find('[data-jhs-site-id="javTrailersBtn"]').on("click", ((event) => { - event.preventDefault(); - const original = $(event.currentTarget).attr("href"), destination = event.ctrlKey || event.metaKey ? original : original + "?handle=1"; - utils.openPage(destination, e2, false, event); - })), await Promise.all(configs.map((async (config) => { - config.condition && false === config.condition(config.sourceCarNum) || await this.prepareSiteLink(e2, config, view); - }))), this.renderSettingsArea(view), this.setupEventListeners(view), box.find('[data-jhs-role="detect-sites"]').off("click").on("click", ((event) => { - event.preventDefault(), this.detectOtherSites(e2, view); - })), n2.autoDetect && await this.detectOtherSites(e2, view); - return box; - } - async prepareSiteLink(e2, t2, view = { root: $(document), isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { - const n2 = view.root.find(`[data-jhs-site-id="${t2.id}"],#${t2.id}`).first(); - if (!(e2 = normalizeCarNum(e2))) return n2.removeAttr("href").attr({ "aria-disabled": "true", title: "番号不可用" }), void this.setSiteState(n2, "idle"); - if (t2.initUrl) return void (n2.attr("href", t2.initUrl(e2)), this.setSiteState(n2, "idle"), n2.attr("title", "点击前往外部搜索页")); - try { - const a2 = await t2.getBaseUrl(), i2 = t2.searchPath(a2, e2); - view.isActive() && (n2.attr("href", i2), n2.attr("title", "点击前往外部搜索页;点击检测按钮后才自动检测"), this.setSiteState(n2, "idle")); - } catch (a2) { - view.isActive() && (n2.attr("title", "外部站点地址未配置或不可用"), this.setSiteState(n2, "domain-error")); - } - } - async detectOtherSites(e2, view = { root: $(document), configs: this.siteConfigs, isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { - const t2 = view.root.find('[data-jhs-role="detect-sites"],#detectOtherSiteBtn').first(), n2 = t2.text(); - if (!(e2 = normalizeCarNum(e2))) return t2.prop("disabled", true), void clog.warn("跳过第三方站点检测:番号不可用"); - return t2.text("检测中").prop("disabled", true).addClass("is-checking"), await Promise.all(view.configs.map((async (t3) => { - t3.condition && false === t3.condition(t3.sourceCarNum) || await this.handleSite(e2, t3, view); - }))), view.isActive() && t2.text(n2).prop("disabled", false).removeClass("is-checking"); - } - setSiteState(e2, t2) { - e2.removeClass("is-checking is-available is-unavailable is-domain-error"), "idle" !== t2 && e2.addClass(`is-${t2}`); - } - async handleSite(e2, t2, view = { root: $(document), isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { - const n2 = view.root.find(`[data-jhs-site-id="${t2.id}"],#${t2.id}`).first(); - n2.removeAttr("href").find(".site-tag").remove(), this.setSiteState(n2, "checking"); - if (t2.initUrl && n2.attr("href", t2.initUrl(e2)), t2.noHandle && true === t2.noHandle) { - const t3 = "jhs_other_site_dmm", a2 = (localStorage.getItem(t3) ? JSON.parse(localStorage.getItem(t3)) : {})[e2]; - a2 ? (n2.attr("href", a2.url), "multiple" === a2.type && n2.append('多结果'), this.setSiteState(n2, "available")) : this.setSiteState(n2, "idle"); - } else try { - if (n2.attr("href")) return void this.setSiteState(n2, "idle"); - if (utils.isHidden(n2)) return; - const a2 = "jhs_other_site", i2 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}, s2 = e2 + "_" + t2.id.replace("Btn", ""), o2 = i2[s2], m2 = Date.now(); - if (o2 && o2.time && m2 - o2.time < 864e5) return void (n2.attr("href", o2.url), "multiple" === o2.type && n2.append('多结果'), this.setSiteState(n2, "available")); - const r2 = await t2.getBaseUrl(), l2 = t2.searchPath(r2, e2); - n2.attr("href", l2); - const _breaker = gmHttp.isDomainCircuitBroken(l2); - if (_breaker) { - n2.attr("title", `站点已熔断,${_breaker.remaining}秒后重试`), this.setSiteState(n2, "domain-error"); - return; - } - const c2 = await storageManager.cachedRequest(`other-site:${t2.id}:${e2}`, 864e5, (() => gmHttp.get(l2, null, t2.headers, true, t2.requestOptions || {}))); - if (!view.isActive()) return; - const d2 = utils.htmlTo$dom(c2), h2 = []; - d2.find(t2.itemSelector).each(((n3, a3) => { - const i3 = $(a3); - const itemText = t2.findCarNumOrTitle(i3); - if (t2.matches ? !t2.matches(itemText, e2) : !itemText.toLowerCase().includes(e2.toLowerCase())) return; - let s3 = t2.getDetailPageHref(i3, r2, e2); - if (!s3) throw new Error("解析href失败"); - s3.includes("http") || (s3 = r2 + (s3.startsWith("/") ? s3 : "/" + s3)), h2.push(s3); - })); - let g2 = "", p2 = null; - if (1 === h2.length) { - let e3 = h2[0]; - n2.attr("href", e3), this.setSiteState(n2, "available"), p2 = { - type: "single", - url: e3, - time: m2 - }; - } else h2.length > 1 ? (n2.attr("href", l2), g2 += '多结果', this.setSiteState(n2, "available"), p2 = { - type: "multiple", - url: l2, - time: m2 - }) : (n2.attr("href", l2), n2.attr("title", "未查询到, 点击前往搜索页"), this.setSiteState(n2, "unavailable")); - if (p2) { - const e3 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}; - e3[s2] = p2, localStorage.setItem(a2, JSON.stringify(e3)); - } - g2 && n2.append(g2); - } catch (a2) { - const e3 = String(a2), i2 = t2.id.replace("Btn", ""); - a2._circuitBroken ? (n2.attr("title", e3), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源跳过, ${i2} 已熔断`)) : a2?._cfBlocked ? (n2.attr("title", "请求失败:Cloudflare 安全检查。"), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源失败, ${i2} 需Cloudflare安全检查`)) : e3.includes("重定向") ? (n2.attr("title", "域名失效"), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源失败, ${i2} 域名被重定向`)) : e3.includes("404 Page Not Found") ? (n2.attr("title", "未查询到, 点击前往搜索页"), this.setSiteState(n2, "unavailable")) : (clog.error(a2), n2.attr("title", "请求失败。"), this.setSiteState(n2, "unavailable"), clog.warn(`检测第三方资源失败, ${i2}`)); - } - } - async getSettingCache() { - const e2 = Date.now(); - return (!this.settingCache || e2 - this.lastFetchTime > this.CACHE_DURATION) && (this.settingCache = await storageManager.getSetting(), this.lastFetchTime = e2), this.settingCache; - } - async getMissAvUrl() { - return (await this.getSettingCache()).missAvUrl || "https://missav.live"; - } - async getjableUrl() { - return (await this.getSettingCache()).jableUrl || "https://jable.tv"; - } - async getAvgleUrl() { - return (await this.getSettingCache()).avgleUrl || "https://jav.rs"; - } - async getJavTrailersUrl() { - return (await this.getSettingCache()).javTrailersUrl || "https://javtrailers.com"; - } - async getAv123Url() { - return (await this.getSettingCache()).av123Url || "https://123av.com"; - } - async getJavDbUrl() { - return (await this.getSettingCache()).javDbUrl || "https://javdb.com"; - } - async getJavBusUrl() { - return (await this.getSettingCache()).javBusUrl || "https://www.javbus.com"; - } - async getSupJavUrl() { - return (await this.getSettingCache()).supJavUrl || "https://supjav.com"; - } - getEnabledSites() { - const fallback = this.siteConfigs.map(((site) => site.id)); - try { - const raw = localStorage.getItem("jhs_enabled_sites"); - if (!raw) return fallback; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(((id) => fallback.includes(id))) : fallback; - } catch (error) { - return clog.warn("外部站点配置损坏,已回退默认值", error), fallback; - } - } - saveEnabledSites(e2) { - localStorage.setItem("jhs_enabled_sites", JSON.stringify(e2)); - } - renderSettingsArea(view = { root: $(document), configs: this.siteConfigs }) { - const enabled = this.getEnabledSites(), target = view.root.find('[data-jhs-role="site-checkboxes"],#siteCheckboxes').first().empty(); - view.configs.forEach(((config) => { - const input = $('').attr("data-site-id", config.id).prop("checked", enabled.includes(config.id)); - target.append($('').append(input, $("").text(config.id.replace("Btn", "")))); - })); - } - setupEventListeners(view = { root: $(document), configs: this.siteConfigs, carNum: null }) { - const $settingsArea = view.root.find("[data-jhs-other-site-settings],#settingsArea").first(); - view.root.find('[data-jhs-role="site-settings"],#settingSiteBtn').off("click.jhsOtherSite").on("click.jhsOtherSite", (() => { - $settingsArea.toggleClass("jhs-is-hidden"); - })), $settingsArea.off("change.jhsOtherSite").on("change.jhsOtherSite", 'input[type="checkbox"]', (async (event) => { - const siteId = $(event.currentTarget).attr("data-site-id"); - try { - if (event.currentTarget.checked) { - view.root.find(`[data-jhs-site-id="${siteId}"],#${siteId}`).removeClass("jhs-is-hidden"); - const carNum = view.carNum || this.getPageInfo().carNum, site = view.configs.find(((item) => item.id === siteId)); - site && await this.prepareSiteLink(carNum, site, view); - } else view.root.find(`[data-jhs-site-id="${siteId}"],#${siteId}`).addClass("jhs-is-hidden"); - this.saveEnabledSites($settingsArea.find('input[type="checkbox"]:checked').map(((index, input) => $(input).attr("data-site-id"))).get()); - } catch (error) { - clog.warn(`外部站点 ${siteId || "unknown"} 状态更新失败`, error); - } - })); - } - }; - __name(_OtherSitePlugin, "OtherSitePlugin"); - var OtherSitePlugin = _OtherSitePlugin; - var _BusDetailPagePlugin = class _BusDetailPagePlugin extends BasePlugin { - getName() { - return "BusDetailPagePlugin"; - } - async initCss() { - if (!window.isDetailPage) return ""; - $("h4:contains('推薦')").hide(); - } - async handle() { - if (window.location.href.includes("/star/")) { - const e2 = $(".avatar-box"); - if (e2.length > 0) { - let t2 = e2.parent(); - t2.css("position", "initial"), t2.insertBefore(t2.parent()); - } - } - $(".genre a").each((function() { - const e2 = $(this).attr("href"); - e2 && (e2.startsWith("http://") || e2.startsWith("https://") || e2.startsWith("/")) && $(this).attr("target", "_blank"); - })), this.addCopyCarNumBtn(); - } - addCopyCarNumBtn() { - let e2 = null; - const t2 = document.querySelectorAll("span.header"); - for (const n2 of t2) if ("識別碼:" === n2.textContent.trim()) { - e2 = n2; - break; - } - if (e2) { - const t3 = e2.nextElementSibling; - if (t3 && "SPAN" === t3.tagName) { - const e3 = t3.textContent.trim(), n2 = document.createElement("button"); - n2.type = "button", n2.className = "jhs-btn jhs-btn--secondary jhs-copy-car-number", n2.textContent = "复制", n2.addEventListener("click", (async function(t4) { - t4.preventDefault(); - await utils.copyToClipboard("番号", e3) && (() => { - this.textContent = "已复制", setTimeout((() => { - this.textContent = "复制"; - }), 1500); - })(); - })), t3.parentNode.insertBefore(n2, t3.nextSibling); - } - } - } - }; - __name(_BusDetailPagePlugin, "BusDetailPagePlugin"); - var BusDetailPagePlugin = _BusDetailPagePlugin; - var _DetailPageButtonPlugin = class _DetailPageButtonPlugin extends BasePlugin { - getName() { - return "DetailPageButtonPlugin"; - } - constructor() { - super(), this.answerCount = 1, this.stateBinding = null; - } - async handle() { - this.hideVideoControls(), window.isDetailPage && (await this.createMenuBtn(), await this.autoRemoveNewVideoMark()); - } - async autoRemoveNewVideoMark() { - try { - const e2 = await storageManager.getSetting("autoRemoveNewVideoMarkAfterBrowse", C); - if (e2 !== _) return; - const t2 = this.getPageInfo(); - if (!t2.carNum) return; - await stateService.removeFromNewVideoList([t2.carNum], "browse"); - } catch (e2) { - clog.error("自动移除新作品标记失败:", e2); - } - } - async createMenuBtn() { - const e2 = this.getPageInfo(), t2 = e2.carNum, n2 = ` -
    -
    - - - - +
    +

    请选择识图网站:

    +
    +
    -
    - - - - -
    - `; - const workspaceSlot = this.getBean("DetailWorkspacePlugin")?.getSlot("summary-actions"); - workspaceSlot?.length ? workspaceSlot.append(n2) : r ? $(".tabs").after(n2) : l && $("#mag-submit-show").before(n2), $("#magnetSearchBtn").on("click", (async () => { - let t3 = await this.getBean("MagnetHubPlugin").createMagnetHub(e2.carNum); - layer.open({ - type: 1, - title: "磁力搜索 " + e2.carNum, - content: '
    ', - area: utils.getResponsiveArea(["60%", "80%"]), - scrollbar: false, - success: /* @__PURE__ */ __name(() => { - $("#magnetHubBox").append(t3); - }, "success") - }); - })); - const a2 = this.getBean("HighlightMagnetPlugin"), i2 = await storageManager.getSetting("enableMagnetsFilter", _); - $("#magnets-span").text(i2 === _ ? "关闭磁力过滤" : "开启磁力过滤"), i2 === _ && a2.doFilterMagnet(), $("#enable-magnets-filter").on("click", ((e3) => { - let t3 = $("#magnets-span"); - "关闭磁力过滤" === t3.text() ? (a2.showAll(), t3.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a2.doFilterMagnet(), t3.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); - })), $("#search-subtitle-btn").on("click", ((e3) => utils.openPage(`https://subtitlecat.com/index.php?search=${t2}`, t2, false, e3))), $("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(t2))); - if (!t2) { - $("#filterBtn, #favoriteBtn, #hasDownBtn, #hasWatchBtn, #magnetSearchBtn, #xunLeiSubtitleBtn, #search-subtitle-btn").prop("disabled", true).attr("title", "番号不可用"); - return void clog.warn("详情操作不可用:番号不可用"); - } - this.stateBinding = detailStateController.bind({ root: document, carNum: t2, activityType: "detail-state", getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord") }); - } - async showStatus(e2) { - return detailStateController.render({ root: document, carNum: e2 }); - } - getStateRecord() { - const info = this.getPageInfo(); - return { carNum: info.carNum, url: info.url, names: info.actress, publishTime: info.publishTime }; - } - getStateBinding() { - if (this.stateBinding) return this.stateBinding; - const info = this.getPageInfo(); - return this.stateBinding = { root: document, layerIndex: null, carNum: normalizeCarNum(info.carNum), getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord"), activityType: "detail-state", selectors: {} }; - } - async favoriteOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "favorite", event); - } - async hasDownOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "downloaded", event); - } - async hasWatchOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "watched", event); - } - searchXunLeiSubtitle(e2) { - let t2 = loading(); - gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${e2}`).then(((t3) => { - let n2 = t3.data; - n2 && 0 !== n2.length ? layer.open({ - type: 1, - title: "迅雷字幕", - content: '\n
    \n
    \n
    \n ', - scrollbar: false, - area: utils.getResponsiveArea(["60%", "70%"]), - anim: -1, - success: /* @__PURE__ */ __name((t4, a2) => { - new Tabulator("#xunlei-table-container", { - layout: "fitColumns", - placeholder: "暂无数据", - virtualDom: true, - data: n2, - responsiveLayout: "collapse", - responsiveLayoutCollapse: true, - columnDefaults: { - headerHozAlign: "center", - hozAlign: "center" - }, - columns: [{ - title: "文件名", - field: "name", - headerSort: false, - responsive: 0 - }, { - title: "类型", - field: "ext", - headerSort: false, - responsive: 0 - }, { - title: "操作", - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((t5, n3, a3) => { - const i2 = t5.getData(); - return a3((() => { - const n4 = t5.getElement().querySelector(".subtitle-preview-btn"), a4 = t5.getElement().querySelector(".subtitle-download-btn"); - n4 && n4.addEventListener("click", (async (t6) => { - let n5 = i2.url, a5 = e2 + "." + i2.ext; - this.previewSubtitle(n5, a5); - })), a4 && a4.addEventListener("click", (async (t6) => { - let n5 = i2.url, a5 = e2 + "." + i2.ext, s2 = await gmHttp.get(n5); - utils.download(s2, a5); - })); - })), '\n \n \n '; - }, "formatter") - }], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "所有", - page_size: "每页行数" - } - } - } - }), utils.setupEscClose(a2); - }, "success") - }) : show.error("迅雷中找不到相关字幕!"); - })).catch(((e3) => { - clog.error(e3), show.error(e3); - })).finally((() => { - t2.close(); - })); - } - async filterOne(e2, t2) { - e2 && e2.preventDefault(); - return detailStateController.requestToggle(this.getStateBinding(), "blocked", e2); - } - hideVideoControls() { - $(document).on("mouseenter", "#preview-video", (function() { - $(this).prop("controls", true); - })); - } - async previewSubtitle(e2, t2) { - if (!e2) return void clog.error("未提供文件URL"); - const n2 = e2.split(".").pop().toLowerCase(); - if ("ass" === n2 || "srt" === n2) try { - let a2 = await gmHttp.get(e2), i2 = "字幕预览"; - "ass" === n2 ? i2 = "ASS字幕预览 - " + t2 : "srt" === n2 && (i2 = "SRT字幕预览 - " + t2); - const s2 = a2.split("\n"); - let o2 = ""; - const r2 = String(s2.length).length; - s2.forEach(((e3, t3) => { - const n3 = String(t3 + 1).padStart(r2, " "); - o2 += `${n3}. ${e3} -`; - })); - const l2 = o2; - layer.open({ - type: 1, - title: i2, - area: utils.getResponsiveArea(["80%", "80%"]), - scrollbar: false, - content: `
    ${l2}
    `, - btn: ["下载", "关闭"], - btn1: /* @__PURE__ */ __name(function(e3, n3, i3) { - return utils.download(a2, t2), false; - }, "btn1") - }); - } catch (a2) { - show.error(`预览失败: ${a2.message}`), clog.error("预览字幕文件出错:", a2); - } - else show.error("仅支持预览ASS和SRT字幕文件"); - } - }; - __name(_DetailPageButtonPlugin, "DetailPageButtonPlugin"); - var DetailPageButtonPlugin = _DetailPageButtonPlugin; - var _HistoryPlugin = class _HistoryPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectAll", false), i(this, "historyExcludedCarNums", /* @__PURE__ */ new Set()), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", false); - } - getName() { - return "HistoryPlugin"; - } - async initCss() { - return ` - `; - } - handleResize() { - $(".navbar-search").is(":hidden") ? ($(".historyBtnBox").show(), $(".miniHistoryBtnBox").hide()) : ($(".historyBtnBox").hide(), $(".miniHistoryBtnBox").show()); - } - async handle() { - r && ($(".navbar-end").prepend(''), $(".navbar-search").css("margin-left", "0").before('\n \n '), this.handleResize(), $(window).resize((() => { - this.handleResize(); - })), $("#historyBtn,#miniHistoryBtn").on("click", ((e2) => this.openHistory()))), l && await this.createBusButton(); - } - async createBusButton() { - const ready = await new Promise(((resolve) => { - const startedAt = Date.now(), timer = setInterval((() => { - if ($("#setting-btn").length && $("#top-right-box").length) return clearInterval(timer), resolve(true); - Date.now() - startedAt >= 2500 && (clearInterval(timer), resolve(false)); - }), 25); - })); - if (!ready) return void clog.warn("鉴定记录入口未创建:JavBus 顶部工具区未就绪"); - $("#top-right-box").append(''), $("#historyBtn,#miniHistoryBtn").on("click", ((e2) => this.openHistory())); - } - openHistory() { - let e2 = ` -
    -
    - - - -
    -
    - - - - - -
    -
    -
    - `; - e2 = e2.replace('
    { - const root = $(e3); - this.historyRoot = root, root.find("#allSelectBox").prepend(''), this.resetHistorySelection(false), JhsSelect.enhance(root); - await this.loadTableData(), root.on("click.jhsHistory", "#clearSearchbtn", (async (e4) => { - root.find("#searchCarNum").val(""), JhsSelect.setValue(root.find("#dataType"), "all"), await this.reloadTable(), root.find("#allSelectBox").hide(); - })).on("focusout keydown", "#searchCarNum", (async (e4) => { - if ("focusout" === e4.type || "Enter" === e4.key) { - if ("Enter" === e4.key && e4.preventDefault(), "keydown" === e4.type && "Enter" !== e4.key) return; - await this.reloadTable(); - } - })).on("click", ".table-link-param", (async (e4) => { - let t2 = $(e4.currentTarget); - root.find("#searchCarNum").val(t2.text()), await this.reloadTable(); - })).on("change", "#dataType", (async () => { - await this.reloadTable(); - })).on("click", "[data-history-view]", (async (event) => { - const view = $(event.currentTarget).data("history-view"); - root.find("[data-history-view]").removeClass("active"), $(event.currentTarget).addClass("active"), await this.showHistoryView(view); - })).on("click", ".jhs-undo-activity", (async (event) => { - const result = await stateService.undoTransaction($(event.currentTarget).data("transaction")); - show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); - })).on("click", ".jhs-copy-offline", (async (event) => { - await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); - })).on("click", ".jhs-retry-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find(((entry) => entry.id === id)); - item && await this.getBean("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: true, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); - })).on("click", ".jhs-open-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find(((entry) => entry.id === id)), provider = this.getBean("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); - url && window.open(url, "_blank", "noopener,noreferrer"); - })).on("click", ".jhs-delete-offline", (async (event) => { - await stateService.removeOfflineHistory($(event.currentTarget).data("id")), await this.renderOfflineHistory(); - })), this.bindHistoryActions(root); - }, "success"), - end: /* @__PURE__ */ __name(() => { - this.resetHistorySelection(), this.historyRoot?.off(".jhsHistory"), this.historyRoot = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null); - }, "end") - }); - } - async showHistoryView(view) { - const stateView = "state" === view; - this.resetHistorySelection(), this.historyRoot?.find("#filterBox,#allSelectBox").toggle(stateView), this.tableObj?.destroy(), this.tableObj = null; - return stateView ? this.loadTableData() : "activity" === view ? this.renderActivityHistory() : this.renderOfflineHistory(); - } - async renderActivityHistory() { - const log = await stateService.getActivityLog(), host = this.historyRoot.find("#table-container").empty(); - if (!log.entries.length) return void host.html('
    暂无操作记录
    '); - log.entries.slice().reverse().forEach(((entry) => { - const reverted = entry.changes.filter(((change) => "reverted" === change.undoState)).length, conflicts = entry.changes.filter(((change) => "conflict" === change.undoState)).length; - host.append($('
    ').append($("").text(`${entry.type} · ${entry.changes.length} 项`), $("

    ").text(`${new Date(entry.createdAt).toLocaleString()} · 已撤销 ${reverted} · 冲突 ${conflicts}`), $("

    ").text(entry.changes.map(((change) => change.carNum)).join("、")), $('').attr("data-transaction", entry.id).prop("disabled", "committed" !== entry.commitState || reverted === entry.changes.length))); - })); - } - async renderOfflineHistory() { - const history = await stateService.getOfflineHistory(), host = this.historyRoot.find("#table-container").empty(); - if (!history.length) return void host.html('
    暂无离线任务
    '); - history.slice().reverse().forEach(((item) => { - const actions = $('
    ').append($('').attr("data-resource", item.resource), $('').attr("data-id", item.id), $('').attr("data-id", item.id), $('').attr("data-id", item.id)); - host.append($('
    ').append($("").text(`${item.providerName || item.providerId} · ${item.status}`), $("

    ").text(`${item.carNum || "未关联番号"} · ${new Date(item.createdAt).toLocaleString()}${item.retryOf ? ` · 重试自 ${item.retryOf}` : ""}`), $("

    ").text(item.errorMessage || item.resource), actions)); - })); - } - async reloadTable(resetSelection = true) { - resetSelection && this.resetHistorySelection(), await this.tableObj?.setPage(1); - } - normalizeHistoryCarNum(carNum) { - return normalizeCarNum(carNum) || String(carNum || "").trim().toUpperCase(); - } - /** 清空 History 的全选、排除项和当前页选择。 */ - resetHistorySelection(deselectRows = true) { - this.historySelectAll = false, this.historyExcludedCarNums.clear(); - if (deselectRows && this.tableObj) { - this.historySelectionSyncing = true; - try { - this.tableObj.deselectRow(); - } finally { - this.historySelectionSyncing = false; - } - } - this.updateHistorySelectionUi(); - } - createHistorySelectAllCheckbox() { - const checkbox = document.createElement("input"); - return checkbox.type = "checkbox", checkbox.className = "jhs-history-select-all", checkbox.setAttribute("aria-label", "选择当前筛选条件下的全部记录"), checkbox.addEventListener("change", (() => { - checkbox.checked ? (this.historySelectAll = true, this.historyExcludedCarNums.clear(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); - })), checkbox; - } - syncHistoryPageSelection() { - if (!this.tableObj || !this.historySelectAll) return void this.updateHistorySelectionUi(); - const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.map(((item) => item.carNum)).filter(((carNum) => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(carNum)))); - this.historySelectionSyncing = true; - try { - this.tableObj.deselectRow(), selectedIds.length && this.tableObj.selectRow(selectedIds); - } finally { - this.historySelectionSyncing = false; - } - this.updateHistorySelectionUi(); - } - updateHistoryRowSelection(row, selected) { - if (!this.historySelectAll || this.historySelectionSyncing) return void this.updateHistorySelectionUi(); - const carNum = this.normalizeHistoryCarNum(row?.getData?.().carNum); - carNum && (selected ? this.historyExcludedCarNums.delete(carNum) : this.historyExcludedCarNums.add(carNum)), this.updateHistorySelectionUi(); - } - getHistorySelectionSummary() { - if (this.historySelectAll) { - const excluded = this.historyExcludedCarNums.size, selected = Math.max(0, this.historyFilteredCount - excluded); - return { - count: selected, - text: excluded ? `已选择当前筛选结果 ${selected} 条,已排除 ${excluded} 条` : `已选择当前筛选结果 ${selected} 条` - }; - } - const count = this.tableObj?.getSelectedData?.().length || 0; - return { - count, - text: `已选择当前页 ${count} 条` - }; - } - updateHistorySelectionUi() { - if (!this.historyRoot) return; - const summary = this.getHistorySelectionSummary(), batchBox = this.historyRoot.find("#allSelectBox"), filterBox = this.historyRoot.find("#filterBox"); - this.historyRoot.find("#historySelectionSummary").text(summary.text), summary.count > 0 ? (filterBox.hide(), batchBox.show()) : (filterBox.show(), batchBox.hide()); - this.historyRoot.find(".jhs-history-select-all").each(((index, element) => { - element.checked = this.historySelectAll, element.indeterminate = this.historySelectAll ? this.historyExcludedCarNums.size > 0 : summary.count > 0; - })); - } - /** 解析批量操作实际要处理的当前页或完整筛选结果。 */ - async getHistoryBatchSelection() { - if (!this.historySelectAll) return this.tableObj?.getSelectedData?.() || []; - const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map(((item) => this.normalizeHistoryCarNum(item.carNum)))); - for (const carNum of this.historyExcludedCarNums) available.has(carNum) || this.historyExcludedCarNums.delete(carNum); - return this.historyFilteredCount = rows.length, rows.filter(((item) => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(item.carNum)))); - } - bindHistoryActions(root) { - root.on("click.jhsHistory", (function(e2) { - if (e2.target.closest(".sub-btns-toggle")) { - const button = e2.target.closest(".sub-btns-toggle"), t2 = button.closest(".sub-btns").querySelector(".sub-btns-menu"); - root.find(".sub-btns-menu.show").each(((index, e3) => { - e3 !== t2 && (e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false")); - })), t2.classList.toggle("show"), button.setAttribute("aria-expanded", String(t2.classList.contains("show"))); - } else root.find(".sub-btns-menu.show").each(((index, e3) => { - e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false"); - })); - })), root.on("keydown.jhsHistory", ".sub-btns", ((e2) => { - const menu = $(e2.currentTarget).find(".sub-btns-menu"), items = menu.find('[role="menuitem"]'), current = items.index(document.activeElement); - if ("Escape" === e2.key) return e2.preventDefault(), menu.removeClass("show"), $(e2.currentTarget).find(".sub-btns-toggle").attr("aria-expanded", "false").trigger("focus"); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e2.key) || !menu.hasClass("show")) return; - e2.preventDefault(); - const next = "Home" === e2.key ? 0 : "End" === e2.key ? items.length - 1 : "ArrowDown" === e2.key ? (current + 1 + items.length) % items.length : (current - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })), root.on("click.jhsHistory", ".history-deleteBtn, .history-filterBtn, .history-favoriteBtn, .history-hasDownBtn, .history-hasWatchBtn, .history-detailBtn", ((e2) => { - e2.preventDefault(), e2.stopPropagation(); - const t2 = $(e2.currentTarget), n2 = t2.closest(".action-btns"), a2 = n2.attr("data-car-num"), i2 = n2.attr("data-href"), s2 = /* @__PURE__ */ __name(async (actionType) => { - try { - const flag = legacyActionToFlag(actionType); - await stateService.toggle(a2, flag, { type: "history-state", record: { carNum: a2, url: i2 } }), await this.reloadTable(); - } catch (s3) { - clog.error("历史记录操作失败:", s3), show.error("操作失败"); - } - }, "s"); - if (t2.hasClass("history-filterBtn")) { - const record = this.tableObj?.getRow(a2)?.getData(), isBlocked = normalizeStateFlags(record?.stateFlags).blocked; - isBlocked ? void s2(d) : utils.q(e2, `是否屏蔽${a2}?`, (() => s2(d))); - } else t2.hasClass("history-favoriteBtn") ? void s2(h) : t2.hasClass("history-hasDownBtn") ? void s2(g) : t2.hasClass("history-hasWatchBtn") ? void s2(p) : t2.hasClass("history-deleteBtn") ? this.handleDelete(e2, a2) : t2.hasClass("history-detailBtn") && void this.handleClickDetail(e2, { - carNum: a2, - url: i2 - }).catch(((error) => clog.error("历史详情打开失败", error))); - })), root.on("click.jhsHistory", ".multiple-history-deleteBtn, .multiple-history-filterBtn, .multiple-history-favoriteBtn, .multiple-history-hasDownBtn, .multiple-history-hasWatchBtn", (async (e2) => { - e2.preventDefault(), e2.stopPropagation(); - const t2 = $(e2.currentTarget); - let n2 = await this.getHistoryBatchSelection(), a2 = "", i2 = ""; - t2.hasClass("multiple-history-filterBtn") ? (a2 = "屏蔽", i2 = d) : t2.hasClass("multiple-history-favoriteBtn") ? (a2 = "收藏", i2 = h) : t2.hasClass("multiple-history-hasDownBtn") ? (a2 = "已下载", i2 = g) : t2.hasClass("multiple-history-hasWatchBtn") ? (a2 = "已观看", i2 = p) : t2.hasClass("multiple-history-deleteBtn") && (a2 = "移除", i2 = "delete"); - if (!n2.length) return void show.info("请先选择要处理的记录"); - const selectionText = this.historySelectAll ? this.historyExcludedCarNums.size ? `当前筛选结果中已选择 ${n2.length} 条,排除 ${this.historyExcludedCarNums.size} 条` : `当前筛选结果中已选择全部 ${n2.length} 条` : `当前页已选择 ${n2.length} 条`; - utils.q(e2, `${selectionText},是否标记为${a2}?`, (async () => { - let e3 = loading(); - try { - if ("delete" === i2) { - const e4 = n2.map(((e5) => e5.carNum)), t3 = await stateService.remove(e4); - if (!t3.changed.length) return void show.error("提供的番号中没有一个存在于列表中。"); - show.ok(`已成功删除 ${t3.changed.length} 个番号`); - } else { - const flag = legacyActionToFlag(i2); - await stateService.patch(n2.map(((item) => item.carNum)), { [flag]: true }, { type: "history-batch-state", records: n2 }), show.ok("操作成功"); - } - this.resetHistorySelection(), await this.reloadTable(false); - } catch (t3) { - clog.error(t3), show.error("操作失败,请稍后重试"); - } finally { - e3.close(); - } - })); - })); - } - /** 返回应用当前搜索、状态筛选和排序后的完整 History 数据。 */ - async getFilteredHistoryData(sorters = this.historySorters) { - let a2 = await storageManager.getCarList(); - this.allCount = a2.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a2.forEach(((e2) => { - const flags = normalizeStateFlags(e2.stateFlags); - flags.blocked && this.filterCount++, flags.favorite && this.favoriteCount++, flags.downloaded && this.hasDownCount++, flags.watched && this.hasWatchCount++, hasAnyState(flags) || this.waitCheckCount++; - })), this.historyRoot.find('#dataType option[value="all"]').text(`所有 (${this.allCount})`), this.historyRoot.find('#dataType option[value="waitCheck"]').text(`待鉴定 (${this.waitCheckCount})`), this.historyRoot.find('#dataType option[value="filter"]').text(`${u} (${this.filterCount})`), this.historyRoot.find('#dataType option[value="favorite"]').text(`${b} (${this.favoriteCount})`), this.historyRoot.find('#dataType option[value="hasDown"]').text(`${y} (${this.hasDownCount})`), this.historyRoot.find('#dataType option[value="hasWatch"]').text(`${k} (${this.hasWatchCount})`); - const i2 = this.historyRoot.find("#dataType").val(); - const flagByFilter = { filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" }; - let s2 = "all" === i2 ? a2 : "waitCheck" === i2 ? a2.filter(((e2) => !hasAnyState(e2.stateFlags))) : a2.filter(((e2) => normalizeStateFlags(e2.stateFlags)[flagByFilter[i2]])); - const o2 = this.historyRoot.find("#searchCarNum").val().trim(); - if (o2) { - let e2 = o2.toLowerCase().replace("-c", "").replace("-uc", "").replace("-4k", ""); - s2 = s2.filter(((t2) => { - const n2 = t2.carNum.toLowerCase().includes(e2); - const a3 = (t2.names ? t2.names : "").toLowerCase().includes(e2); - return n2 || a3; - })); - } - if (sorters && sorters.length > 0) { - const e2 = sorters[0], t2 = e2.field, a3 = e2.dir; - s2.sort(((e3, n2) => { - const i3 = e3[t2], s3 = n2[t2], o3 = null == i3 || "" === i3, r2 = null == s3 || "" === s3; - return o3 && !r2 ? 1 : !o3 && r2 ? -1 : o3 && r2 ? 0 : i3 < s3 ? "asc" === a3 ? -1 : 1 : i3 > s3 ? "asc" === a3 ? 1 : -1 : 0; - })); - } - return s2; - } - async getDataList(e2, t2, n2) { - this.historySorters = Array.isArray(n2) ? n2.map(((sorter) => ({ - ...sorter - }))) : []; - const rows = await this.getFilteredHistoryData(this.historySorters), r2 = rows.length, l2 = Math.ceil(r2 / t2), c2 = (e2 - 1) * t2, m2 = c2 + t2; - return this.historyFilteredCount = r2, { - maxPage: l2, - dataList: rows.slice(c2, m2), - totalCount: r2 - }; - } - async loadTableData() { - this.tableObj = new Tabulator(this.historyRoot.find("#table-container").get(0), { - layout: "fitColumns", - placeholder: "暂无数据", - virtualDom: true, - pagination: true, - paginationMode: "remote", - sortMode: "remote", - ajaxURL: "queryRealm", - dataLoader: false, - ajaxRequestFunc: /* @__PURE__ */ __name(async (e2, t2, n2) => { - const a2 = n2.page, i2 = n2.size, s2 = n2.sort; - return await this.getDataList(a2, i2, s2); - }, "ajaxRequestFunc"), - dataReceiveParams: { - last_page: "maxPage", - last_row: "totalCount", - data: "dataList" - }, - paginationSize: 50, - paginationSizeSelector: [50, 100, 1e3], - paginationCounter: /* @__PURE__ */ __name((e2, t2, n2, a2, i2) => `共 ${a2} 条记录`, "paginationCounter"), - responsiveLayout: "collapse", - responsiveLayoutCollapse: true, - columnDefaults: { - headerHozAlign: "center", - hozAlign: "center" - }, - selectableRowsPersistence: false, - index: "carNum", - columns: [{ - formatter: "rowSelection", - titleFormatter: /* @__PURE__ */ __name(() => this.createHistorySelectAllCheckbox(), "titleFormatter"), - hozAlign: "center", - headerSort: false, - responsive: 0, - width: 40, - cellClick: /* @__PURE__ */ __name((e2, t2) => { - t2.getRow().toggleSelect(); - }, "cellClick") - }, { - title: "番号", - field: "carNum", - width: 120, - sorter: "string", - responsive: 0, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const a2 = e2.getData().carNum, i2 = a2.indexOf("-"); - if (-1 === i2) return a2; - return `${a2.substring(i2 + 1)}`; - }, "formatter") - }, { - title: "演员", - field: "names", - minWidth: 200, - sorter: "string", - responsive: 5, - headerSort: true, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => (e2.getData().names || "").split(" ").filter(((e3) => "" !== e3.trim())).map(((e3) => ``)).join(" "), "formatter") - }, { - title: "创建时间", - field: "createDate", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "修改时间", - field: "updateDate", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "发行时间", - field: "publishTime", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "来源", - field: "url", - width: 80, - sorter: "string", - responsive: 5, - hozAlign: "left", - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - let a2 = e2.getData().url; - return a2 ? `${a2.includes("javdb") ? "JavDB" : a2.includes("javbus") ? "JavBus" : a2.includes("123av") ? "123AV" : "其他"}` : ""; - }, "formatter") - }, { - title: "状态", - field: "stateFlags", - width: 220, - sorter: false, - responsive: 1, - headerSort: false, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const flags = normalizeStateFlags(e2.getData().stateFlags), badges = [[flags.blocked, "filter", u], [flags.favorite, "fav", b], [flags.downloaded, "down", y], [flags.watched, "watch", k]].filter(((item) => item[0])).map(((item) => `${item[2]}`)); - return badges.join(" ") || '待鉴定'; - }, "formatter") - }, { - title: "备注", - field: "remark", - width: 100, - sorter: "string", - responsive: 6 - }, { - title: "操作", - sorter: "string", - minWidth: 150, - cssClass: "action-cell-dropdown", - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const a2 = e2.getData(); - return n2((() => { - var t3; - null == (t3 = e2.getElement().querySelector(".history-editBtn")) || t3.addEventListener("click", ((e3) => { - this.editRecord(a2); - })); - })), ` -
    - -
    - - -
    -
    `; - }, "formatter") - }], - initialSort: [{ - column: "updateDate", - dir: "desc" - }], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "所有", - page_size: "每页行数" - } - } - } - }), this.tableObj.on("dataProcessed", (() => { - this.historySelectAll ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); - })), this.tableObj.on("rowSelected", ((row) => { - this.updateHistoryRowSelection(row, true); - })), this.tableObj.on("rowDeselected", ((row) => { - this.updateHistoryRowSelection(row, false); - })), this.tableObj.on("rowSelectionChanged", (() => { - this.updateHistorySelectionUi(); - })), this.tableObj.on("rowDblClick", (function(e2, t2) { - t2.toggleSelect(); - })); - } - handleDelete(e2, t2) { - utils.q(e2, `是否移除${t2}?`, (async () => { - await stateService.remove(t2), this.getBean("ListPagePlugin").showCarNumBox(t2), await this.reloadTable(); - })); - } - async handleClickDetail(e2, t2) { - if (r) if (t2.carNum.includes("FC2-")) { - const e3 = this.parseMovieId(t2.url); - const plugin = this.getBean("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); - plugin.openFc2Dialog(e3, t2.carNum, t2.url, { source }); - } else { - if (!t2.url) return void window.open("/search?q=" + t2.carNum, "_blank"); - utils.openPage(t2.url, t2.carNum, false, e2); - } - if (l) { - let n2 = t2.url; - if (n2.includes("javdb")) if (t2.carNum.includes("FC2-")) { - const e3 = this.parseMovieId(n2); - const plugin = this.getBean("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); - await plugin.openFc2Page(e3, t2.carNum, n2, { newTab: true }, { source }); - } else window.open(n2, "_blank"); - else utils.openPage(t2.url, t2.carNum, false, e2); - } - } - async editRecord(e2) { - const t2 = e2.carNum, n2 = e2.names || "", a2 = e2.url || "", flags = normalizeStateFlags(e2.stateFlags), s2 = e2.remark || ""; - let editRoot = $(); - const c2 = ` -
    -
    - - -
    -
    - - -
    -
    状态: - - - - -
    -
    - - -
    -
    - - -
    -
    - `; - layer.open({ - type: 1, - title: `编辑记录: ${t2}`, - area: utils.getDialogArea("sm"), - content: c2, - btn: ["保存", "取消"], - success: /* @__PURE__ */ __name((e3, t3) => { - editRoot = $(e3); - const n3 = /* @__PURE__ */ __name((e4) => { - e4.css("height", "auto"), e4.css("height", e4[0].scrollHeight + 15 + "px"); - }, "n"), a3 = editRoot.find("#edit-names"); - a3.on("input", (function() { - n3($(this)); - })), n3(a3); - const i2 = editRoot.find("#edit-remark"); - i2.on("input", (function() { - n3($(this)); - })), n3(i2); - }, "success"), - yes: /* @__PURE__ */ __name(async (t3) => { - const n3 = editRoot.find("#edit-names").val().trim(), i2 = editRoot.find("#edit-url").val().trim(), s3 = editRoot.find("#edit-remark").val().trim(), nextFlags = { - favorite: editRoot.find("#edit-favorite").prop("checked"), - downloaded: editRoot.find("#edit-downloaded").prop("checked"), - watched: editRoot.find("#edit-watched").prop("checked"), - blocked: editRoot.find("#edit-blocked").prop("checked") - }; - const save = /* @__PURE__ */ __name(async () => { - await stateService.patch(e2.carNum, nextFlags, { type: "history-edit", replaceMetadata: true, record: { ...e2, names: n3, url: i2, remark: s3 } }), this.tableObj.setData(), layer.close(t3); - }, "save"); - if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${e2.carNum}?`, (() => void save())), false; - await save(); - }, "yes") - }); - } - }; - __name(_HistoryPlugin, "HistoryPlugin"); - var HistoryPlugin = _HistoryPlugin; - var _ReviewPlugin = class _ReviewPlugin extends BasePlugin { - getName() { - return "ReviewPlugin"; - } - async initCss() { - return ` - `; - } - async handle() { - if (!window.isDetailPage) return; - if (r) { - const movieId = this.parseMovieId(window.location.href); - const workspace = this.getBean("DetailWorkspacePlugin"); - await Promise.all([this.showReview(movieId, workspace?.getSlot("reviews")), this.getBean("RelatedPlugin").showRelated(workspace?.getSlot("related"), movieId)]); - } - if (l) { - const carNumber = this.getPageInfo().carNum; - if (!carNumber) return void clog.warn("跳过 JavBus 评论解析:番号不可用"); - const movieId = await resolveJavDbMovieId(carNumber); - movieId && await this.showReview(movieId, this.getBean("DetailWorkspacePlugin")?.getSlot("reviews")); - } - } - async showReview(movieId, target, options = {}) { - const isActive = "function" === typeof options.isActive ? options.isActive : () => true; - const enabled = await storageManager.getSetting("enableLoadReview", _), host = target?.length ? target : this.getBean("DetailWorkspacePlugin")?.getSlot("reviews") || $("#magnets-content"); - if (!isActive() || !host?.length) return $(); - const existing = host.children('[data-jhs-panel="reviews"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); - if (existing.length) return existing; - const panel = $('
    ').attr("data-jhs-movie-id", String(movieId)); - const header = $('

    评论

    '); - const toggle = $(''); - const state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1 }; - header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="reviews"]').first().append(toggle) : panel.append(header), panel.append('
    ', ''), host.append(panel), this.bindRightClickFilter(); - state.isActive = isActive; - this.updateToggle(toggle, enabled === _); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const expanded = "展开" === toggle.find(".toggle-text").text(); - this.updateToggle(toggle, expanded), panel.find(".jhs-review-container, .jhs-review-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayReviews(state), storageManager.saveSettingItem("enableLoadReview", expanded ? _ : C); - })); - enabled === _ ? await this.fetchAndDisplayReviews(state) : panel.find(".jhs-review-container, .jhs-review-footer").hide(); - return panel; - } - updateToggle(toggle, expanded) { - toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); - } - async fetchAndDisplayReviews(state) { - if (state.loading || !state.isActive?.()) return; - state.loading = true; - const { movieId, panel } = state, container = panel.find(".jhs-review-container"), footer = panel.find(".jhs-review-footer"); - container.empty().append($('
    ').text("获取评论中...")), footer.empty(); - const pageSize = await storageManager.getSetting("reviewCount", 20); - let reviews; - try { - reviews = await R(movieId, 1, pageSize); - } catch (error) { - error.toString().includes("簽名已過期") && show.error("生成签名失败, 请检查系统时间及时区是否正确!"), clog.error("获取评论失败:", error), clog.error("获取评论失败:", error); - state.loading = false; - return void this.renderRetry(container, "获取评论失败", (() => this.fetchAndDisplayReviews(state))); - } - if (!state.isActive?.()) return void (state.loading = false); - state.loading = false, state.loaded = true; - container.empty(); - if (!reviews.length) return void container.append($('
    ').text("无评论")); - const keywords = await storageManager.getReviewFilterKeywordList(); - await this.displayReviews(state, reviews, container, keywords); - reviews.length === pageSize ? this.bindLoadMore(state, pageSize, keywords, container, footer) : footer.append($('
    ').text("已加载全部评论")); - } - renderRetry(container, message, retry) { - container.empty(); - const state = $('
    ').append(document.createTextNode(`${message} `)); - const button = $('').on("click", retry); - state.append(button), container.append(state); - } - bindLoadMore(state, pageSize, keywords, container, footer) { - const button = $(''), end = $('
    已加载全部评论
    ').hide(); - footer.empty().append(button, end); - button.on("click", (async () => { - const nextPage = state.page + 1; - button.text("加载中...").prop("disabled", true); - try { - const reviews = await R(state.movieId, nextPage, pageSize); - if (!state.isActive?.()) return; - state.page = nextPage; - await this.displayReviews(state, reviews, container, keywords), reviews.length < pageSize ? (button.remove(), end.show()) : button.text("加载更多评论").prop("disabled", false); - } catch (error) { - clog.error("加载更多评论失败:", error), button.text("加载失败,请重试").prop("disabled", false); - } - })); - } - async displayReviews(state, reviews, container, keywords) { - if (!reviews.length) return; - const filter = keywords.length > 0 ? new RegExp(keywords.map(((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("|")) : null; - for (const review of reviews) { - const content = String(review.content || ""); - if (filter?.test(content)) continue; - const item = $('
    '), meta = $('
    '), body = $('
    '); - meta.append($("").addClass("jhs-review-author").text(review.username || "匿名用户")); - const stars = $(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); - for (let index = 0; index < score; index++) stars.append(''); - meta.append( - stars, - $("").text(utils.formatDate(review.created_at)), - $("").text(`点赞:${Number(review.likes_count) || 0}`), - $("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`) - ); - await this.appendReviewContent(body, content), item.append(meta, body), container.append(item); - } - } - appendReviewContent(container, content) { - const linkPattern = /ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+/g; - let cursor = 0, match; - while (match = linkPattern.exec(content)) { - match.index > cursor && container.append(document.createTextNode(content.slice(cursor, match.index))); - this.appendLinkControls(container, match[0]), cursor = match.index + match[0].length; - } - cursor < content.length && container.append(document.createTextNode(content.slice(cursor))); - } - appendLinkControls(container, value) { - const isEd2k = value.startsWith("ed2k://"), isMagnet = value.startsWith("magnet:"), label = isEd2k ? "ED2K 链接" : isMagnet ? "Magnet 链接" : "打开链接"; - const isResource = isMagnet || isEd2k, wrapper = $(isResource ? '' : ''); - const main = $(''); - const open = isEd2k ? $('').text(label).on("click", (() => utils.copyToClipboard(label, value))) : $("").addClass("jhs-review-link").attr({ - href: value, - target: "_blank", - rel: "noopener noreferrer" - }).text(label); - const copy = $('').on("click", (() => utils.copyToClipboard(label, value))); - main.append(open, copy), wrapper.append(main); - if (isResource) { - const actions = $(''); - actions.append(``); - wrapper.append(actions); - } - container.append(wrapper); - } - bindRightClickFilter() { - $(document).off("contextmenu.jhsReviewFilter", ".review-content").on("contextmenu.jhsReviewFilter", ".review-content", (async (event) => { - if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; - const text = window.getSelection().toString(); - text && (event.preventDefault(), await utils.q(event, `是否将 '${text}' 加入评论区关键词?`, (async () => { - await storageManager.saveReviewFilterKeyword(text), show.ok("操作成功, 刷新页面后生效"); - }))); - })); - } - }; - __name(_ReviewPlugin, "ReviewPlugin"); - var ReviewPlugin = _ReviewPlugin; - var _FilterTitleKeywordPlugin = class _FilterTitleKeywordPlugin extends BasePlugin { - getName() { - return "FilterTitleKeywordPlugin"; - } - async handle() { - if (!isDetailPage) return; - await this.bindDetailRoot(document); - } - async bindDetailRoot(root, { layerIndex = null } = {}) { - if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; - const host = $(root), selector = r ? ".title strong, .current-title" : l ? "h3" : ".current-title, .origin-title, .jhs-detail-title"; - host.off("contextmenu.jhsTitleFilter", selector).on("contextmenu.jhsTitleFilter", selector, ((e2) => { - const t2 = window.getSelection().toString(); - if (t2) { - e2.preventDefault(); - let n2 = { - clientX: e2.clientX, - clientY: e2.clientY + 80 - }; - utils.q(n2, `是否屏蔽标题关键词 ${t2}?`, (async () => { - await storageManager.saveTitleFilterKeyword(t2), await jhsEventBus.emit("filter-rules-changed", { scope: "title-keyword" }), utils.closePage({ root: host, layerIndex }); - })); - } - })); - } - }; - __name(_FilterTitleKeywordPlugin, "FilterTitleKeywordPlugin"); - var FilterTitleKeywordPlugin = _FilterTitleKeywordPlugin; - var _BlacklistPlugin = class _BlacklistPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "blacklistSearchDebounced", null), i(this, "taskStatusUnsubscribe", null); - } - getName() { - return "BlacklistPlugin"; - } - async initCss() { - return ``; - } - async addBlacklist(e2) { - let t2 = { - clientX: e2.clientX, - clientY: e2.clientY + 80 - }; - const n2 = $("#addBlacklistBtn span").text().includes("已加入"); - let a2, i2; - if (o.includes("/tags")) { - const e3 = new URL(o); - e3.searchParams.delete("page"); - const t3 = $("#jhs-check-tag").text().trim(); - a2 = { - starId: "no-" + t3, - name: "虚拟演员-" + t3, - allName: ["虚拟演员"], - role: "虚拟演员", - movieType: t3, - blacklistUrl: e3.toString() - }, i2 = `是否将分类 ${t3} 加入到黑名单中?`, n2 && (i2 = `分类 ${t3} 已在黑名单中, 是否从当前页开始追加屏蔽?`); - } else a2 = this.getActressPageInfo(), i2 = `是否将该演员 ${a2.name} 加入到黑名单中?`, n2 && (i2 = `演员 ${a2.name} 已在黑名单中, 是否从当前页开始追加屏蔽?`); - const { starId: s2, name: r2, allName: c2, role: d2, movieType: h2, blacklistUrl: g2 } = a2; - if (o.includes("page") && !o.includes("page=1") && (i2 += "
    注意: 当前页面非第一页, 屏蔽数据将从此页面开始"), l) { - const e3 = o.split("/star/")[1].split("/"); - if (e3.length > 1) { - parseInt(e3[1]) > 1 && (i2 += "
    注意: 当前页面非第一页, 屏蔽数据将从此页面开始"); - } - } - utils.q(t2, i2, (async () => { - const e3 = this.getBean("TaskPlugin"); - navigator.locks.request(e3.singleTaskKey, { - ifAvailable: true - }, (async (e4) => { - if (clog.debug("获取锁", e4), e4) { - this.loadObj = loading(); - try { - await storageManager.addBlacklistItem({ - starId: s2, - name: r2, - allName: c2, - role: d2, - movieType: h2, - url: g2 - }), await this.filterActorVideo(r2, s2, null, l ? I : T); - const e5 = show.ok(`屏蔽结束,是否跳转到最后一页: ${this.lastPageLink}`, { - duration: -1, - close: true, - onClick: /* @__PURE__ */ __name(() => { - e5.closeShow(), window.location.href = this.lastPageLink; - }, "onClick") - }); - } catch (t3) { - clog.error(t3); - const e5 = show.error("发生错误, 是否填转到解析失败的那一页? (点击并跳转)", { - duration: -1, - close: true, - onClick: /* @__PURE__ */ __name(() => { - e5.closeShow(), window.location.href = this.nextPageLink; - }, "onClick") - }); - } finally { - this.loadObj.close(); - } - } else show.error("当前有定时任务在后台执行中, 无法发起此操作"); - })).catch(((e4) => { - clog.error("锁任务出现错误:", e4); - })); - })); - } - async resetBtnTip() { - const e2 = this.getBean("TaskPlugin"), t2 = localStorage.getItem(e2.lastCheckBlacklistTimeKey) || "无", n2 = await storageManager.getSetting("checkBlacklist_intervalTime", 12); - this.checkBlacklist_ruleTime = await storageManager.getSetting("checkBlacklist_ruleTime", 8760), $("#checkBlacklistBtn").attr("data-tip", `上次整批检测: ${t2}; 检测间隔时间: ${n2}小时`); - } - async openBlacklistDialog() { - const e2 = this.getBean("TaskPlugin"), t2 = await storageManager.getSetting(); - let n2 = ` -
    -
    -
    - - -
    -
    - - - - - + `,area:utils.isMobileMode()?utils.getResponsiveArea():["40%","80%"],success:d(async t=>{this.initEventListeners(),e&&e()},"success"),end:d(()=>{$(document).off("paste.searchImg")},"end")})}initEventListeners(){let e=$("#upload-area"),t=$("#image-file"),s=$("#select-image-btn"),a=$("#preview-area"),i=$("#preview-image"),r=$("#action-btns"),n=$("#handle-btn"),l=$("#cancel-btn"),c=$("#url-input-container"),h=$("#image-url"),u=$("#search-results"),p=$("#search-img-site-btns-container");e.on("dragover",(g=>{g.preventDefault(),e.addClass("highlight")})).on("dragleave",(()=>{e.removeClass("highlight")})).on("drop",(g=>{g.preventDefault(),e.removeClass("highlight"),g.originalEvent.dataTransfer.files&&g.originalEvent.dataTransfer.files[0]&&(this.handleImageFile(g.originalEvent.dataTransfer.files[0]),this.resetSearchUI())})),s.on("click",(()=>{t.trigger("click")})),t.on("change",(g=>{let m=g.target;m.files&&m.files[0]&&(this.handleImageFile(m.files[0]),this.resetSearchUI())})),$(document).on("paste.searchImg",(async g=>{let m=g.originalEvent.clipboardData.items;for(let v=0;v{let g=i.attr("src");if(g){if(!this.isUploading){this.isUploading=!0;try{let m=await this.searchByImage(g);if(!m)return;r.hide(),u.show(),p.empty();let f=this.getRuntimeService("storage"),v="jhs_selectedSites",b=JSON.parse(f.getLocal(v)||"{}");m.targets.forEach((w=>{let S=b[w.name]!==!1,j=$('').attr({href:w.url,title:w.name}),k=$('').attr("data-site-name",w.name).prop("checked",S);j.append(k,$("").attr({src:w.iconUrl,alt:w.name}),$("").text(w.name)),p.append(j)})),p.on("change",".site-checkbox",(w=>{let S=$(w.currentTarget),j=S.data("site-name");b[j]=S.is(":checked"),f.setLocal(v,JSON.stringify(b))})),p.show()}finally{this.isUploading=!1}}}else show.info("请粘贴或上传图片")})),l.on("click",(()=>{a.hide(),c.hide(),t.val(""),h.val("")})),h.on("change",(()=>{utils.isUrl(h.val())&&(i.attr("src",h.val()),a.show())})),$("#openAll").on("click",(()=>{$(".search-img-site-btn").each(((g,m)=>{$(m).find(".site-checkbox").is(":checked")&&window.open($(m).attr("href"))}))}))}resetSearchUI(){$("#action-btns").show(),$("#search-results").hide(),$("#search-img-site-btns-container").hide().empty()}handleImageFile(e){let t=document.getElementById("preview-image"),s=document.getElementById("preview-area"),a=document.getElementById("url-input-container");if(!t||!s||!a)return;if(!e.type.match("image.*"))return void show.info("请选择图片文件");let i=new FileReader;i.onload=r=>{t.src=String(r.target?.result||""),s.style.display="block",a.style.display="none",$("#handle-btn")[0]?.click()},i.readAsDataURL(e)}async searchByImage(e){let t=loading();try{e.startsWith("data:")&&show.info("开始上传图片...");let s=await this.getRuntimeService("scope")();return await this.getRuntimeService("imageSearch").resolve(e,{scope:s})}catch(s){show.error(`搜索失败: ${s instanceof Error?s.message:String(s)}`),clog.error("搜索失败:",s)}finally{t.close()}}};d(ar,"SearchByImagePlugin");var Is=ar;async function $e(o,e=4,t){let s=new Array(o.length),a=0,i=d(async()=>{for(;a=t&&a<=s?a:e}d(ae,"parseNumberSetting");function _e(o){if(o instanceof Date)return Number.isFinite(o.getTime())?o.getTime():null;if(typeof o=="number")return Number.isFinite(o)&&o>=0?o:null;if(typeof o!="string")return null;let e=o.trim();if(!e)return null;if(/^\d{13,16}$/.test(e)){let a=Number(e);return Number.isFinite(a)?a:null}let t=e.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/);if(t){let a=t.slice(1).map(Number),i=new Date(a[0],a[1]-1,a[2],a[3],a[4],a[5]);return i.getFullYear()===a[0]&&i.getMonth()===a[1]-1&&i.getDate()===a[2]&&i.getHours()===a[3]&&i.getMinutes()===a[4]&&i.getSeconds()===a[5]?i.getTime():null}let s=new Date(e).getTime();return Number.isFinite(s)?s:null}d(_e,"parseTaskTimestamp");function pt(o,e,t=Date.now()){let s=ae(e,0,{min:0}),a=_e(o),i=_e(t);return s>0&&a!=null&&i!=null&&i>=a&&i-a>=36e5*s}d(pt,"shouldSkipStopped");function Kt(o){let e=null,t=-1/0;for(let s of o){let a=_e(s);a!=null&&a>t&&(t=a,e=s)}return e}d(Kt,"selectLatestPublishTime");function gt(o,e=window.location.href){if(!o)return null;try{let t=new URL(String(o),e);return["http:","https:"].includes(t.protocol)?t.href:null}catch(t){return clog.debug("外部 URL 无效,已忽略",t),null}}d(gt,"normalizeHttpUrl");function ir(o,e=window.location.href){return gt(o,e)?.replace(/^https:\/\/[^/]+\/rhe951l4q(?=\/)/i,"https://c0.jdbstatic.com")??null}d(ir,"normalizeJavdbMediaUrl");function yl(o){let e=String(o||"").trim();return/^(?:[a-f\d]{40}|[a-z2-7]{32})$/i.test(e)?e.toUpperCase():null}d(yl,"normalizeBtihHash");function jl(o){let e=String(o||"").split(/[\s,,;;]+/).map((s=>T(s))).filter((s=>typeof s=="string")),t=e.filter((s=>/^(?:FC2-)?[A-Z\d]+(?:-[A-Z\d]+)+$/i.test(s)));return{recognized:e.length,values:[...new Set(t.map((s=>s.toUpperCase())))],invalid:e.filter((s=>!t.includes(s)))}}d(jl,"parseCarNumberText");function Sl(o,e="https://javdb.com"){return`${e}/search?q=${encodeURIComponent(String(o??""))}`}d(Sl,"buildFallbackCarUrl");async function de(o,{context:e="视频",notify:t=!1,message:s="当前视频源无法播放"}={}){if(!o||typeof o.play!="function")return clog.warn(`${e}播放失败:媒体元素不可用`),t&&show.error(s),!1;try{return await o.play(),!0}catch(a){clog.warn(`${e}播放失败`,a);let i=a&&typeof a=="object"&&"name"in a?String(a.name):"";return t&&!["NotAllowedError","AbortError"].includes(i)&&show.error(s),!1}}d(de,"safePlay");var Re=Object.freeze({all:"全部",waitCheck:"待鉴定",favorite:"收藏",hasDown:"下载",hasWatch:"已看",blockedItems:"屏蔽项",favoriteUndownloaded:"收藏未下载",favoriteUnwatched:"收藏未观看",downloadedUnwatched:"下载未观看",recent7d:"最近 7 天"}),mt=Object.freeze(["all","waitCheck","favorite","hasDown","hasWatch"]),Gt=Object.freeze(["blockedItems","favoriteUndownloaded","favoriteUnwatched","downloadedUnwatched","recent7d"]),mh=new Set([...mt,...Gt]);function Te(o){return o==="filter"?"blockedItems":typeof o=="string"&&mh.has(o)?o:"waitCheck"}d(Te,"normalizeQuickFilterKey");function ft(o,e={}){return!!(o.blocked||e.keyword||e.actorBlacklist||e.actressBlacklist)}d(ft,"isHardHidden");function rr(o,e,{visibilityReasons:t={},recent:s=!1}={}){let a=Te(o),i=ft(e,t);return a==="all"?!0:a==="blockedItems"?i:i?!1:a==="waitCheck"?!oe(e):a==="favorite"?!!e.favorite:a==="hasDown"?!!e.downloaded:a==="hasWatch"?!!e.watched:a==="favoriteUndownloaded"?!!e.favorite&&!e.downloaded:a==="favoriteUnwatched"?!!e.favorite&&!e.watched:a==="downloadedUnwatched"?!!e.downloaded&&!e.watched:a==="recent7d"&&s}d(rr,"matchesQuickFilter");function xl({filter:o,flags:e,visibilityReasons:t,recent:s}){return rr(o,e,{visibilityReasons:t,recent:s})}d(xl,"shouldShowItem");var kl=Object.freeze(["native-javdb","native-javbus","u9a9","u3c3","sukebei","btsow"]);function nr(o,e){return!o||!String(o.magnet||"").startsWith("magnet:")?null:{title:String(o.title||""),magnet:o.magnet,size:o.size||"",date:o.date||"",seeders:Number(o.seeders)||0,leechers:Number(o.leechers)||0,source:e,files:Array.isArray(o.files)?o.files:[]}}d(nr,"normalizeMagnetResult");function Cl(o){let e=new URL(o).searchParams.get("xt")?.match(/^urn:btih:([a-z2-7]{32}|[a-f\d]{40})$/i)?.[1];return e?e.toUpperCase():null}d(Cl,"extractInfoHash");function Es(o){let e=new Map;return o.forEach((t=>{let s=Cl(t.magnet)||`${t.source}:${t.magnet}`,a=e.get(s);a?a.sources=[...new Set([...a.sources||[a.source],t.source])]:e.set(s,{...t,sources:[t.source]})})),[...e.values()]}d(Es,"deduplicateMagnetResults");var cr=class cr{constructor(e=[]){this.sources=new Map,e.forEach((t=>this.register(t)))}register(e){if(!e?.id||!e.name||typeof e.search!="function"||typeof e.targetUrl!="function")throw new TypeError("Invalid magnet provider");return this.sources.set(e.id,{enabled:!0,priority:100,...e}),this}get(e){return this.sources.get(e)||null}getEnabledSources(){return[...this.sources.values()].filter((e=>e.enabled)).sort(((e,t)=>e.priority-t.priority))}};d(cr,"MagnetSourceRegistry");var Qt=cr;function Rs(o){let e=new URL(o);if(e.protocol!=="https:")throw new TypeError("Source URL must use https");return e.origin}d(Rs,"validateHttpsBaseUrl");function bt(o){let e=["id","name","enabled","priority","searchUrlTemplate","targetUrlTemplate","parserType","rowSelector","titleSelector","magnetSelector","sizeSelector","dateSelector","seedersSelector","leechersSelector","resultsPath","titlePath","hashPath","magnetPath","sizePath","datePath","seedersPath"];if(Object.keys(o).some((t=>!e.includes(t))))throw new TypeError("Unsupported custom source field");if(!["torrent-table","magnet-links","json"].includes(o.parserType))throw new TypeError("Unsupported parser type");if(!String(o.name||"").trim())throw new TypeError("Source name is required");if(Rs(o.searchUrlTemplate.replace("{keyword}","test")),Rs((o.targetUrlTemplate||o.searchUrlTemplate).replace("{keyword}","test")),o.parserType==="torrent-table"&&(!o.rowSelector?.trim()||!o.magnetSelector?.trim()))throw new TypeError("表格来源必须填写结果行选择器和磁力选择器");if(o.parserType==="json"&&(!o.resultsPath?.trim()||!o.magnetPath?.trim()&&!o.hashPath?.trim()))throw new TypeError("JSON 来源必须填写结果数组路径,并填写磁力路径或哈希路径");return{...o,name:o.name.trim(),targetUrlTemplate:o.targetUrlTemplate||o.searchUrlTemplate}}d(bt,"validateCustomMagnetSource");function _l(o,e=[],t=[],s=[]){let a=`${o.title||""} ${(o.files||[]).join(" ")}`,i=d((u,p)=>u.type==="regex"?new RegExp(u.pattern,"i").test(p):p.toLowerCase().includes(u.pattern.toLowerCase()),"matches"),r=e.filter((u=>u.enabled&&i(u,a))),n=!1,l=0,c=[];return[...t.map((u=>({...u,value:o.title||""}))),...s.map((u=>({...u,value:(o.files||[]).join(" ")})))].filter((u=>u.enabled)).forEach((u=>{i(u,u.value)&&(c.push(u.id||u.pattern),u.action==="hide"?n=!0:l+=Number(u.penalty)||0)})),{...o,tags:r.map((u=>u.name)),customTagWeight:r.reduce(((u,p)=>u+(Number(p.weight)||0)),0),hidden:n,filterPenalty:l,filteredReasons:c}}d(_l,"applyMagnetRules");function or(o,e){let t=new Map;return $(o).find('a[href^="magnet:"],[data-clipboard-text^="magnet:"]').each(((s,a)=>{let i=$(a),r=i.attr("href")||i.attr("data-clipboard-text");if(!r)return;let n=i.closest(".item, .magnet-name, tr, .panel-block"),l=n.find(".name, .magnet-name, .title").first().text().trim()||i.text().trim()||"本站磁力",c=nr({title:l,magnet:r,size:n.find(".meta, .size").first().text().trim()},e);c&&t.set(Cl(r)||r,c)})),[...t.values()]}d(or,"parseNativeMagnets");function vt(o,e){return String(e||"").split(".").filter(Boolean).reduce(((t,s)=>t?.[s]),o)}d(vt,"readJsonPath");function lr(o,e,t){if(o=bt(o),o.parserType==="json")return(vt(e,o.resultsPath)||[]).map((i=>nr({title:vt(i,o.titlePath),magnet:o.magnetPath?vt(i,o.magnetPath):`magnet:?xt=urn:btih:${vt(i,o.hashPath)}`,size:vt(i,o.sizePath),date:vt(i,o.datePath),seeders:vt(i,o.seedersPath)},`custom:${t}`))).filter(Boolean);let s=utils.htmlTo$dom(e);return(o.parserType==="magnet-links"?s.find('a[href^="magnet:"]'):s.find(o.rowSelector)).map(((i,r)=>{let n=$(r),l=o.parserType==="magnet-links"?n:n.find(o.magnetSelector).first();return nr({title:o.parserType==="magnet-links"?n.text().trim():n.find(o.titleSelector).first().text().trim(),magnet:l.attr("href")||l.attr("data-magnet"),size:n.find(o.sizeSelector).text().trim(),date:n.find(o.dateSelector).text().trim(),seeders:n.find(o.seedersSelector).text().trim(),leechers:n.find(o.leechersSelector).text().trim()},`custom:${t}`)})).get().filter(Boolean)}d(lr,"parseCustomMagnetResponse");var Ns=Object.freeze([{id:"native-javdb",name:"JavDB 本站",type:"本站资源",domain:"javdb.com",priority:10,enabled:!0},{id:"native-javbus",name:"JavBus 本站",type:"本站资源",domain:"javbus.com",priority:11,enabled:!0}]);function It(o){if(!o.name?.trim()||!o.pattern?.trim())throw new TypeError("规则名称和匹配内容不能为空");if(o.type==="regex")try{new RegExp(o.pattern)}catch{throw new TypeError("正则表达式无效")}return{...o,name:o.name.trim(),pattern:o.pattern.trim()}}d(It,"validateRule");function $l(o,e=null){let t=o.parserType||"magnet-links",s={id:e?.id||`source-${Date.now()}`,name:String(o.name||"").trim(),enabled:!!o.enabled,priority:Number(o.priority)||100,searchUrlTemplate:String(o.searchUrlTemplate||"").trim(),targetUrlTemplate:String(o.targetUrlTemplate||o.searchUrlTemplate||"").trim(),parserType:t};if(!s.name)throw new TypeError("来源名称不能为空");return t==="torrent-table"&&Object.assign(s,{rowSelector:o.rowSelector,titleSelector:o.titleSelector,magnetSelector:o.magnetSelector,sizeSelector:o.sizeSelector,dateSelector:o.dateSelector,seedersSelector:o.seedersSelector,leechersSelector:o.leechersSelector}),t==="json"&&Object.assign(s,{resultsPath:o.resultsPath,titlePath:o.titlePath,magnetPath:o.magnetPath,hashPath:o.hashPath,sizePath:o.sizePath,datePath:o.datePath,seedersPath:o.seedersPath}),bt(s)}d($l,"buildCustomMagnetSource");function Yt(o,e=!1){return be(o,e)}d(Yt,"parseBooleanSetting");function Ds(o,e,t={}){let s=Number(o);if(!Number.isFinite(s))return e;let a=Math.round(s);return t.min!=null&&at.max?t.max:a}d(Ds,"parseFiniteNumberSetting");function Ye(o,e){if(["enable123Offline","enable115Offline","enable115Match","enable115LoginRedirect"].includes(o))return Yt(e,!1);if(o==="offlineProviderMode")return["ask","123","115"].includes(String(e))?String(e):"ask";if(o==="oneOneFiveConcurrency")return Ds(e,4,{min:1,max:10});if(o==="oneOneFiveCacheMinutes")return Ds(e,60,{min:1,max:1440});throw new TypeError(`未知云盘设置: ${o}`)}d(Ye,"normalizeCloudSetting");var dr=class dr{constructor(e=storageManager){this.storage=e}async getArray(e){let t=await this.storage.getSetting(e,"[]");if(Array.isArray(t))return t;try{let s=JSON.parse(t||"[]");return Array.isArray(s)?s:[]}catch{return[]}}async saveArray(e,t){if(!Array.isArray(t))throw new TypeError("配置必须是数组");return await this.storage.saveSettingItem(e,JSON.stringify(t)),t}async updateArray(e,t){if(typeof this.storage.updateSetting=="function"){let i;return await this.storage.updateSetting(r=>{let n=r[e],l;if(Array.isArray(n))l=n;else try{let c=JSON.parse(n||"[]");l=Array.isArray(c)?c:[]}catch{l=[]}if(i=t(l),!Array.isArray(i))throw new TypeError("配置必须是数组");r[e]=JSON.stringify(i)}),i}let s=await this.getArray(e),a=t(s);return await this.saveArray(e,a),a}getMagnetSources(){return this.getArray("customMagnetSources")}saveMagnetSources(e){return e.forEach(bt),this.saveArray("customMagnetSources",e)}getMagnetTagRules(){return this.getArray("magnetTagRules")}saveMagnetTagRules(e){return e.forEach(It),this.saveArray("magnetTagRules",e)}getMagnetFilterRules(){return this.getArray("magnetFilterRules")}saveMagnetFilterRules(e){return e.forEach(It),this.saveArray("magnetFilterRules",e)}async getBuiltInSources(){return await this.getArray("magnetBuiltInSources")}saveBuiltInSources(e){return e.forEach((t=>{if(!kl.includes(t.id))throw new TypeError("未知的内置磁力源");if(t.baseUrl&&Rs(t.baseUrl),t.priority!=null&&(!Number.isFinite(Number(t.priority))||Number(t.priority)<1))throw new TypeError("来源优先级无效")})),this.saveArray("magnetBuiltInSources",e)}async getScreenshotSettings(){return{mode:await this.storage.getSetting("screenshotMode","auto"),providers:await this.getArray("screenshotProviders")}}async saveScreenshotMode(e){return await this.storage.saveSettingItem("screenshotMode",e),e}async saveScreenshotProviders(e){return this.saveArray("screenshotProviders",e)}async saveScreenshotSettings(e){return typeof this.storage.patch=="function"?this.storage.patch({screenshotMode:e.mode,screenshotProviders:JSON.stringify(e.providers)}):(await this.saveScreenshotMode(e.mode),await this.saveScreenshotProviders(e.providers),e)}async getCloudSettings(){return{enable123Offline:Yt(await this.storage.getSetting("enable123Offline",!0),!0),enable115Offline:Yt(await this.storage.getSetting("enable115Offline",!1),!1),enable115Match:Yt(await this.storage.getSetting("enable115Match",!1),!1),enable115LoginRedirect:Yt(await this.storage.getSetting("enable115LoginRedirect",!1),!1),providerMode:Ye("offlineProviderMode",await this.storage.getSetting("offlineProviderMode","ask")),concurrency:Ds(await this.storage.getSetting("oneOneFiveConcurrency",4),4,{min:1,max:10}),cacheMinutes:Ds(await this.storage.getSetting("oneOneFiveCacheMinutes",60),60,{min:1,max:1440})}}async saveCloudSetting(e,t){let a={enable123Offline:"enable123Offline",enable115Offline:"enable115Offline",enable115Match:"enable115Match",enable115LoginRedirect:"enable115LoginRedirect",offlineProviderMode:"offlineProviderMode",oneOneFiveConcurrency:"oneOneFiveConcurrency",oneOneFiveCacheMinutes:"oneOneFiveCacheMinutes"}[e];if(!a)throw new TypeError(`未知云盘设置: ${e}`);let i=Ye(e,t);return await this.storage.saveSettingItem(a,i),i}async saveCloudSettings(e){let t={enable123Offline:Ye("enable123Offline",e.enable123Offline),enable115Offline:Ye("enable115Offline",e.enable115Offline),enable115Match:Ye("enable115Match",e.enable115Match),enable115LoginRedirect:Ye("enable115LoginRedirect",e.enable115LoginRedirect),offlineProviderMode:Ye("offlineProviderMode",e.providerMode||"ask"),oneOneFiveConcurrency:Ye("oneOneFiveConcurrency",e.concurrency),oneOneFiveCacheMinutes:Ye("oneOneFiveCacheMinutes",e.cacheMinutes)};if(typeof this.storage.patch=="function")return this.storage.patch(t);for(let[s,a]of Object.entries(t))await this.storage.saveSettingItem(s,a);return t}async exportConfig(){return{customMagnetSources:await this.getMagnetSources(),magnetTagRules:await this.getMagnetTagRules(),magnetFilterRules:await this.getMagnetFilterRules(),magnetBuiltInSources:await this.getBuiltInSources(),screenshot:await this.getScreenshotSettings()}}async importConfig(e){let t;try{t=JSON.parse(e)}catch(a){throw new TypeError(`配置格式错误:${a instanceof Error?a.message:String(a)}`)}if(!t||typeof t!="object"||Array.isArray(t))throw new TypeError("配置格式错误:根节点必须是对象");let s=[];if(Object.hasOwn(t,"customMagnetSources")){if(!Array.isArray(t.customMagnetSources))throw new TypeError("自定义磁力源必须是数组");t.customMagnetSources.forEach(bt),s.push(()=>this.saveMagnetSources(t.customMagnetSources))}if(Object.hasOwn(t,"magnetTagRules")){if(!Array.isArray(t.magnetTagRules))throw new TypeError("标签规则必须是数组");t.magnetTagRules.forEach(It),s.push(()=>this.saveMagnetTagRules(t.magnetTagRules))}if(Object.hasOwn(t,"magnetFilterRules")){if(!Array.isArray(t.magnetFilterRules))throw new TypeError("过滤规则必须是数组");t.magnetFilterRules.forEach(It),s.push(()=>this.saveMagnetFilterRules(t.magnetFilterRules))}if(Object.hasOwn(t,"magnetBuiltInSources")){if(!Array.isArray(t.magnetBuiltInSources))throw new TypeError("内置磁力源配置必须是数组");s.push(()=>this.saveBuiltInSources(t.magnetBuiltInSources))}if(Object.hasOwn(t,"screenshot")){if(!t.screenshot||!["auto","manual"].includes(t.screenshot.mode)||!Array.isArray(t.screenshot.providers))throw new TypeError("截图配置无效");s.push(()=>this.saveScreenshotSettings(t.screenshot))}for(let a of s)await a();return t}};d(dr,"ResourceSettingsService");var rt=dr;var Ls=Object.freeze([Object.freeze({id:"javstore",name:"JavStore",domain:"javstore.net",priority:10,enabled:!0}),Object.freeze({id:"projectjav",name:"ProjectJav",domain:"projectjav.com",priority:20,enabled:!1,implemented:!1}),Object.freeze({id:"18av",name:"18AV",domain:"18av.mm-cg.com",priority:30,enabled:!1,implemented:!1})]);var hr="x7k9p3",Tl="jhs_credential_install_secret",Al="AES:",Pl="";function ur(){let o=globalThis.localStorage?.getItem?.(Tl)||Pl;if(o)return o;let e=crypto.getRandomValues(new Uint8Array(32));return o=Rl(e.buffer),Pl=o,globalThis.localStorage?.setItem?.(Tl,o),o}d(ur,"getInstallationSecret");async function Il(o){let e=new TextEncoder,t=await crypto.subtle.importKey("raw",e.encode(`${o}.jhs.v1`),{name:"PBKDF2"},!1,["deriveKey"]);return crypto.subtle.deriveKey({name:"PBKDF2",salt:e.encode("jhs-backup"),iterations:1e5,hash:"SHA-256"},t,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}d(Il,"getEncryptionKey");function Rl(o){let e=new Uint8Array(o),t=32768,s="";for(let a=0;anew TextDecoder().decode(await crypto.subtle.decrypt({name:"AES-GCM",iv:a},await Il(n),i)),"decrypt");try{return await r(e)}catch(n){if(!t||e===hr)throw n;return r(hr)}}d(Ze,"decryptData");async function Xt(o){return Ze(o)}d(Xt,"decryptPortableBackup");async function Dl(o,e=ur()){if(!o||!o.startsWith(Al))return o;try{return await Ze(o.slice(Al.length),e)}catch{return console?.warn?.("[凭证] 凭证解密失败,按原文返回"),o}}d(Dl,"decryptCredential");var vh="zh-cn",bh=Object.freeze({"zh-cn":Object.freeze({pagination:Object.freeze({first:"首页",first_title:"首页",last:"尾页",last_title:"尾页",prev:"上一页",prev_title:"上一页",next:"下一页",next_title:"下一页",all:"全部",page_size:"每页行数"})})});function ze(o,e,t){if(typeof o!="function")throw new TypeError("Tabulator runtime is required");(typeof e=="string"?globalThis.document?.querySelector(e):e)?.classList?.add("jhs-table");let a=(t.paginationSizeSelector??[20,50,100,1e3]).filter(i=>Number.isInteger(i)&&i>0);return new o(e,{layout:"fitColumns",responsiveLayout:"collapse",pagination:!0,paginationMode:"local",height:"100%",paginationSize:a[0]??20,...t,paginationSizeSelector:a,locale:t.locale??vh,langs:t.langs??bh})}d(ze,"createJhsTable");var Ue=d(o=>o instanceof Error?o.message:String(o),"errorMessage");async function Nl(o){if(typeof o?.getProfile=="function")return o.getProfile();let e=await storageManager.getSetting();return{url:e.webDavUrl,username:e.webDavUsername,password:await Dl(e.webDavPassword||"")}}d(Nl,"resolveWebDavProfile");async function Ll(o){try{let e=document.createElement("input");e.type="file",e.accept=".json";let t=d(()=>e.remove(),"cleanup");e.onchange=async s=>{let a=s.currentTarget.files?.[0];if(!a)return void t();let i=new FileReader;i.onload=async r=>{t();try{let n=String(r.currentTarget.result||""),l=JSON.parse(n);if(!l||typeof l!="object"||Array.isArray(l))throw new Error("文件内容不是有效的数据对象");let c=loading();try{let h=await storageManager.exportData(),u=await storageManager.diffData(h,l);c.close(),o(u,l,null)}catch(h){c.close(),clog.error(h),show.error("差异分析失败: "+Ue(h))}}catch(n){clog.error(n),show.error("导入失败:文件内容不是有效的JSON格式 "+Ue(n))}},i.onerror=()=>{t(),show.error("读取文件时出错")},i.readAsText(a)},document.body.appendChild(e),e.click(),setTimeout(t,3e5)}catch(e){clog.error(e),show.error("导入数据时出错: "+Ue(e))}}d(Ll,"importSettingData");async function Bl(o,e){let t=await Nl(e),s=t.url;if(!s)return void show.error("请填写webDav服务地址并保存后, 再试此功能");let a=t.username;if(!a)return void show.error("请填写webDav用户名并保存后, 再试此功能");if(!t.password)return void show.error("请填写webDav密码并保存后, 再试此功能");let i=loading();try{let r=t.password,n=utils.getNowStr("_","_")+".json",l=JSON.stringify(await storageManager.exportData());l=await El(l),await e.createClient({url:s,username:a,password:r}).backup(o,n,l),show.ok("备份完成")}catch(r){clog.error(r),show.error(Ue(r))}finally{i.close()}}d(Bl,"backupDataByWebDav");async function Ml(o,e,t){let s=await Nl(t),a=s.url;if(!a)return void show.error("请填写webDav服务地址并保存后, 再试此功能");let i=s.username;if(!i)return void show.error("请填写webDav用户名并保存后, 再试此功能");if(!s.password)return void show.error("请填写webDav密码并保存后, 再试此功能");let r=loading();try{let n=s.password,l=t.createClient({url:a,username:i,password:n}),c=await l.getBackupList(o);e(c,l,"WebDav")}catch(n){clog.error(n),show.error(`发生错误: ${Ue(n)}`)}finally{r.close()}}d(Ml,"backupListBtnByWebDav");function wh(o,e,t,s,a,i){let r=d(c=>{let h=["B","KB","MB","GB","TB","PB"],u=0,p=c;for(;p>=1024&&u!c||c.length===0?'
    暂无数据
    ':c.map((h,u)=>` +
    +
    ${C(h.name)}
    +
    ${r(h.size)} · ${utils.getNowStr("-",":",h.createTime)}
    +
    + + +
    - -
    -
    -
    - `; - layer.open({ - type: 1, - title: "演员黑名单", - content: n2, - scrollbar: false, - area: utils.getDialogArea("xl"), - anim: -1, - success: /* @__PURE__ */ __name(async (t3) => { - const dialog = $(t3).find(".layui-layer-content > div").first().addClass("jhs-blacklist-layout").removeAttr("style"), toolbar = dialog.children("div").first().addClass("jhs-blacklist-toolbar").removeAttr("style"); - toolbar.children("div").addClass("jhs-blacklist-toolbar__group").removeAttr("style"), toolbar.find("select,input,a").removeAttr("style"), dialog.find("#table-container").removeAttr("style"); - dialog.find("#table-container").before('
    '), JhsSelect.enhance(t3); - this.renderTaskStatus(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.renderTaskStatus())); - await this.loadTableData(); - const content = $(t3).find(".layui-layer-content"), search = content.find("#searchValue"); - this.blacklistSearchDebounced = utils.debounce((() => void this.reloadTable()), 200), content.on("click", "#cleanQueryBtn", (async () => { - search.val(""), JhsSelect.setValue("#dataType", "", false), JhsSelect.setValue("#statusType", "", false), JhsSelect.setValue("#urlType", "", false), await this.reloadTable(); - })).on("input", "#searchValue", this.blacklistSearchDebounced).on("change", "#dataType,#statusType,#urlType", (async () => { - await this.reloadTable(); - })).on("click", "#toSetting", (() => { - this.getBean("SettingPlugin").openSettingDialog("task-panel", (() => { - $("#setting-blacklist").css({ - border: "1px solid var(--jhs-status-filter)" - }); - })); - })).on("click", ".open-url", ((e3) => { - e3.preventDefault(); - const t4 = $(e3.currentTarget), n3 = t4.attr("data-url"), a2 = t4.attr("data-name"); - utils.openPage(n3, a2, true, e3); - })).on("click", "#checkBlacklistBtn", ((event) => { - const button = $(event.currentTarget), label = button.find("span").last(), previous = label.text(); - if (button.attr("aria-busy") === "true") return; - button.attr("aria-busy", "true").prop("disabled", true), label.text("检测中…"), navigator.locks.request(e2.singleTaskKey, { ifAvailable: true }, (async (lock) => { - lock ? await e2.checkBlacklist(true) : show.error("后台任务正在运行,请稍后再试"); - })).catch(((error) => { - clog.error("锁任务出现错误:", error), e2.isNetworkBlocked(error) && show.error(error.message || "任务执行失败"); - })).finally((() => { - button.removeAttr("aria-busy").prop("disabled", false), label.text(previous); - })); - })); - }, "success"), - end: /* @__PURE__ */ __name(async () => { - this.blacklistSearchDebounced?.cancel?.(), this.blacklistSearchDebounced = null, this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null), await jhsEventBus.emit("blacklist-rules-changed"); - }, "end") - }); - } - renderTaskStatus() { - const container = $("#blacklist-task-status"); - if (!container.length) return; - const snapshot = this.getBean("TaskPlugin").getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"); - container.empty().append($('').text(`黑名单:${labels[snapshot.state]}`), $('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)); - } - async reloadTable() { - if (!this.tableObj) return; - const e2 = await this.getTableData(); - this.tableObj.setData(e2); - } - async getTableData() { - const e2 = this.getBean("TaskPlugin"), t2 = await storageManager.getBlacklist(), n2 = await storageManager.getBlacklistCarList(), a2 = String($("#searchValue").val() || "").trim().toLocaleLowerCase(), i2 = $("#statusType").val(), s2 = $("#dataType"), o2 = s2.val(), r2 = $("#urlType").val(), l2 = t2.length; - let c2 = 0, d2 = 0; - const h2 = t2.map(((t3) => { - t3.role === B ? c2++ : t3.role === P && d2++; - let n3 = false; - return n3 = shouldSkipStopped(t3.lastPublishTime, this.checkBlacklist_ruleTime), { - ...t3, - isUnCheck: n3 - }; - })).filter(((item) => { - const aliases = Array.isArray(item.allName) ? item.allName.join(" ") : item.allName || "", searchable = `${item.name || ""} ${aliases} ${item.starId || ""}`.toLocaleLowerCase(), searchMatch = !a2 || searchable.includes(a2), statusMatch = !i2 || "normal" === i2 && !item.isUnCheck || "stop" === i2 && item.isUnCheck, roleMatch = !o2 || item.role === o2, hasCategory = item.url.includes("t="), urlMatch = !r2 || "hasT" === r2 && hasCategory || "noT" === r2 && !hasCategory; - return searchMatch && statusMatch && roleMatch && urlMatch; - })); - s2.html(` - - - - `), JhsSelect.setValue(s2, o2, false); - const g2 = /* @__PURE__ */ new Map(); - for (const m2 of n2) { - const e3 = m2.starId; - g2.has(e3) || g2.set(e3, []), g2.get(e3).push(m2); - } - const p2 = h2.map(((e3) => { - const t3 = e3.starId, n3 = g2.get(t3) || []; - return { - ...e3, - carList: n3, - count: n3.length - }; - })); - return this.currentCarCount = p2.reduce(((e3, t3) => e3 + (t3.count || 0)), 0), p2; - } - async loadTableData() { - this.checkBlacklist_ruleTime = parseNumberSetting(await storageManager.getSetting("checkBlacklist_ruleTime"), 8760, { min: 0 }); - const e2 = await this.getTableData(), placeholder = document.createElement("div"); - renderStateView(placeholder, { type: "empty", title: "没有符合当前筛选条件的黑名单记录" }); - this.tableObj = new Tabulator("#table-container", { - layout: "fitColumns", - placeholder, - virtualDom: true, - data: e2, - pagination: true, - paginationMode: "local", - paginationSize: 20, - paginationSizeSelector: [20, 50, 100, 1e3, true], - paginationCounter: /* @__PURE__ */ __name((e3, t2, n2, a2, i2) => `演员: ${a2}    番号总数: ${this.currentCarCount} `, "paginationCounter"), - responsiveLayout: "collapse", - responsiveLayoutCollapse: true, - columnDefaults: { - headerHozAlign: "center", - hozAlign: "center" - }, - index: "starId", - columns: [{ - title: "演员", - field: "name", - sorter: "string", - minWidth: 100, - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - const a2 = e3.getData(), url = normalizeHttpUrl(a2.url), link = document.createElement("a"); - link.className = "open-url", link.textContent = String(a2.name || ""), link.dataset.name = String(a2.name || ""); - return url ? (link.href = url, link.dataset.url = url, link.target = "_blank", link.rel = "noopener noreferrer") : (link.href = "#", link.setAttribute("aria-disabled", "true")), link; - }, "formatter") - }, { - title: "性别角色", - field: "role", - sorter: "string", - width: 120, - responsive: 5, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - const a2 = e3.getData().role; - let i2 = a2; - return a2 === B ? i2 = "男演员" : a2 === P && (i2 = "女演员"), i2; - }, "formatter") - }, { - title: "影视类别", - field: "movieType", - sorter: "string", - width: 120, - responsive: 5, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - const a2 = e3.getData().movieType; - let i2 = a2; - return a2 === D ? i2 = "有码" : a2 === A && (i2 = "无码"), i2; - }, "formatter") - }, { - title: "屏蔽类型", - field: "url", - sorter: "string", - minWidth: 120, - responsive: 4, - visible: r, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - let a2 = e3.getData().url.includes("t="); - return `${a2 ? "按所选分类屏蔽" : "未筛选分类"}`; - }, "formatter") - }, { - title: "番号数量", - field: "count", - sorter: "number", - width: 170, - responsive: 1 - }, { - title: "创建时间", - field: "createTime", - sorter: "string", - width: 170, - responsive: 5 - }, { - title: "最后发行时间", - field: "lastPublishTime", - sorter: "string", - width: 170, - responsive: 1 - }, { - title: "状态", - field: "isUnCheck", - sorter: "string", - width: 120, - responsive: 1, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - let a2 = "", i2 = "继续检测"; - return e3.getData().isUnCheck && (a2 = `停更${this.checkBlacklist_ruleTime / 24 / 365}年以上, 下轮任务不再进行检测`, i2 = "停更跳过"), `${i2}`; - }, "formatter") - }, { - title: "操作", - sorter: "string", - cssClass: "action-cell-dropdown", - minWidth: 150, - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - const a2 = e3.getData(); - return n2((() => { - var t3, n3; - null == (t3 = e3.getElement().querySelector(".delete-btn")) || t3.addEventListener("click", ((e4) => { - const t4 = a2.name, n4 = a2.starId; - t4 ? n4 ? utils.q(e4, `是否移除对 ${t4} 的屏蔽?`, (async () => { - await storageManager.removeBlacklistCarList(n4), await storageManager.deleteBlacklistItem(n4), show.info("操作成功"), await this.reloadTable(); - })) : show.error("获取starId失败") : show.error("获取名称失败"); - })), null == (n3 = e3.getElement().querySelector(".keyword-btn")) || n3.addEventListener("click", ((e4) => { - const t4 = a2.carList.reduce(((e5, t5) => { - const n5 = t5.carNum.split("-")[0] + "-"; - return e5[n5] = (e5[n5] || 0) + 1, e5; - }), {}), n4 = Object.entries(t4).map((([e5, t5]) => ({ - prefix: e5, - count: t5 - }))).sort(((e5, t5) => t5.count - e5.count)); - clog.debug(n4); - })); - })), ''; - }, "formatter") - }], - initialSort: [{ - column: "createTime", - dir: "desc" - }], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "全部", - page_size: "每页行数" - } - } - } - }); - } - async filterAllVideo(e2, t2, page = 1, processed = 0) { - let n2, a2; - if (t2 ? (l && t2.find(".avatar-box").length > 0 && t2.find(".avatar-box").parent().remove(), n2 = t2.find(this.getSelector().requestDomItemSelector), a2 = t2.find(this.getSelector().nextPageSelector).attr("href")) : (n2 = $(this.getSelector().itemSelector), a2 = $(this.getSelector().nextPageSelector).attr("href")), a2 && 0 === n2.length) throw show.error("解析列表失败"), new Error("解析列表失败"); - for (const s2 of n2) { - const t3 = $(s2), { carNum: n3, url: a3, publishTime: o2 } = this.getBean("ListPagePlugin").findCarNumAndHref(t3); - if (a3 && n3) try { - await stateService.patch(n3, { blocked: true }, { type: "actor-page-block", record: { carNum: n3, url: a3, names: e2, publishTime: o2 } }), clog.log("屏蔽演员番号", e2, n3); - } catch (i2) { - clog.error(`保存失败 [${n3}]:`, i2); - } - } - processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); - if (a2) { - clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); - const t3 = await gmHttp.get(a2), n3 = new DOMParser(), i2 = $(n3.parseFromString(t3, "text/html")); - await this.filterAllVideo(e2, i2, page + 1, processed); - } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); - } - async batchSaveAllVideos(e2, t2, pageDom = null, page = 1, processed = 0) { - let n2, a2; - pageDom ? (n2 = pageDom.find(this.getSelector().requestDomItemSelector), a2 = pageDom.find(this.getSelector().nextPageSelector).attr("href")) : (n2 = $(this.getSelector().itemSelector), a2 = $(this.getSelector().nextPageSelector).attr("href")); - if (a2 && 0 === n2.length) throw show.error("解析列表失败"), new Error("解析列表失败"); - for (const i2 of n2) { - const n3 = $(i2), { carNum: a3, url: o2, publishTime: r2 } = this.getBean("ListPagePlugin").findCarNumAndHref(n3); - if (o2 && a3) try { - const flag = legacyActionToFlag(t2); - flag && await stateService.patch(a3, { [flag]: true }, { type: "actor-page-batch-state", record: { carNum: a3, url: o2, names: e2, publishTime: r2 } }), clog.log("批量操作", e2, a3, t2); - } catch (s2) { - clog.error(`保存失败 [${a3}]:`, s2); - } - } - processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); - if (a2) { - clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); - const i2 = await gmHttp.get(a2), s2 = new DOMParser(), o2 = $(s2.parseFromString(i2, "text/html")); - await this.batchSaveAllVideos(e2, t2, o2, page + 1, processed); - } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); - } - async filterActorVideo(e2, t2, n2, site = l ? I : T, page = 1, processed = 0) { - let { nextPageLink: a2, recordCount } = await this.parseAndSaveFilterInfo(n2, e2, t2, site); - processed += recordCount, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已屏蔽 ${processed} 个番号`); - if (this.nextPageLink = a2, a2) { - let n3; - this.lastPageLink = a2; - clog.log("正在请求下一页内容:", a2); - const i2 = await gmHttp.get(a2); - n3 = utils.htmlTo$dom(i2); - await this.filterActorVideo(e2, t2, n3, site, page + 1, processed); - } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 新增 ${processed} 个番号`); - } - async parseAndSaveFilterInfo(e2, t2, n2, site) { - if (![T, I].includes(site)) throw new Error(`未知黑名单来源站点: ${site}`); - const page = e2 || $(document), selector = this.getSelector(site); - site === I && page.find(".avatar-box").length > 0 && page.find(".avatar-box").parent().remove(); - const pageState = parseDetailPage(page, { - boxSelector: site === I ? `${selector.boxSelector}, #waterfall` : selector.boxSelector, - requestDomItemSelector: e2 ? selector.requestDomItemSelector : selector.itemSelector - }), nextPageLink = page.find(selector.nextPageSelector).attr("href"); - if ("valid" !== pageState.state) throw new Error(`黑名单作品页面无效: ${pageState.state}`); - if (pageState.isEmpty && nextPageLink) throw new Error("黑名单作品空页面包含下一页"); - const records = [], publishTimes = []; - for (const item of pageState.items) { - const element = $(item), { carNum, url, publishTime } = this.getBean("ListPagePlugin").findCarNumAndHref(element); - publishTime && publishTimes.push(publishTime), url && carNum && records.push({ - carNum, - url, - names: t2, - actionType: d, - starId: n2, - publishTime - }); - } - await storageManager.batchSaveBlacklistCarList(records); - return { - nextPageLink, - lastPublishTime: selectLatestPublishTime(publishTimes), - recordCount: records.length - }; - } - }; - __name(_BlacklistPlugin, "BlacklistPlugin"); - var BlacklistPlugin = _BlacklistPlugin; - var _ListPageButtonPlugin = class _ListPageButtonPlugin extends BasePlugin { - getName() { - return "ListPageButtonPlugin"; - } - async handle() { - if (!window.isListPage) return; - await this.createMenuBtn(), this.bindEvent(); - const e2 = await storageManager.getSetting("autoPage"), t2 = this.supportsLiveSorting(); - $("#sort-toggle-btn").prop("disabled", e2 === _ && !t2).attr("title", e2 === _ && !t2 ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"), (e2 !== _ || t2) && await this.sortItems(); - } - async createMenuBtn() { - if (r) { - const e2 = o.includes("/actors/"); - let t2 = $(".main-tabs, .tabs"), n2 = "加入黑名单", a2 = "jhs-btn--filter", s2 = null; - if (e2) { - t2 = $(".toolbar, .section-addition").filter(":last"); - const e3 = await storageManager.getBlacklist(), i2 = this.getActressPageInfo(); - e3.find(((e4) => e4.starId === i2.starId)) && (n2 = "已加入黑名单", a2 = "jhs-btn--muted"); - } else o.includes("/tags") && utils.loopDetector((() => $("#jhs-check-tag").text().trim()), (async () => { - const e3 = $("#addBlacklistBtn"); - e3.attr("data-tip", "将当前分类标签加入到黑名单, 后续有作品更新也会纳入屏蔽中"); - const t3 = $("#jhs-check-tag").text().trim(); - if (!t3) return; - const n3 = "no-" + t3, a3 = await storageManager.getBlacklist(); - s2 = a3.find(((e4) => e4.starId === n3)), s2 && (e3.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); - })); - const r2 = o.includes("advanced_search"); - r2 && (t2 = $("h2.section-title")); - const l2 = localStorage.getItem("jhs_sortMethod"), d2 = "当前排序方式: " + ("rateCount" === l2 ? "评价人数" : "date" === l2 ? "时间" : "默认"); - t2.append(` -
    - - ${e2 ? ` - - - - - ` : ""} - ${o.includes("/tags") ? ` - - ` : ""}
    -
    - - - ${c ? "" : this.sortMenuHtml(l2 || "default", d2)} -
    - `); - } - if (l) { - const e2 = o.includes("/star/"); - let t2 = "加入黑名单", n2 = "jhs-btn--filter"; - if (e2) { - const e3 = await storageManager.getBlacklist(), a3 = this.getActressPageInfo(); - e3.find(((e4) => e4.starId === a3.starId)) && (t2 = "已加入黑名单", n2 = "jhs-btn--muted"); - } - const a2 = localStorage.getItem("jhs_sortMethod") || "default"; - $(".masonry").parent().prepend(` -
    - - ${e2 ? ` - - - - - ` : ''} - ${this.sortMenuHtml(a2)} -
    - `); - } - $("#waitCheckBtn > span").text("开始鉴定"); - const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); - newVideoLabel.length && newVideoLabel.empty().append(document.createTextNode("新作品 ("), newVideoCount, document.createTextNode(")")); - } - /** 构建与原排序值兼容的 JHS 菜单。 */ - sortMenuHtml(method, title = "选择列表排序方式") { - const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, current = labels[method] || labels.default; - return `
    `; - } - bindEvent() { - $("#waitCheckBtn").on("click", ((e3) => { - void this.openWaitCheck(e3).catch(((error) => clog.error("待鉴定列表打开失败", error))); - })), $("#newVideoBtn").on("click", ((e3) => { - this.getBean("NewVideoPlugin").openDialog(); - })), $("#blacklistBtn").on("click", ((e3) => { - this.getBean("BlacklistPlugin").openBlacklistDialog(); - })), this.bindSortMenu(); - const e2 = this.getBean("BlacklistPlugin"); - $("#addBlacklistBtn").on("click", (async (t2) => { - await e2.addBlacklist(t2); - })), $("#filterAllVideo").on("click", (async (t2) => { - let n2 = { - clientX: t2.clientX, - clientY: t2.clientY + 80 - }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键屏蔽视频列表?", (async () => { - this.loadObj = loading(); - try { - await e2.filterAllVideo(i2); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); - } - })); - })), $("#favoriteAllVideo").on("click", (async (t2) => { - let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键收藏所有可见作品?", (async () => { - this.loadObj = loading(); - try { - await e2.batchSaveAllVideos(i2, h); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); - } - })); - })), $("#hasDownAllVideo").on("click", (async (t2) => { - let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键已下载所有可见作品?", (async () => { - this.loadObj = loading(); - try { - await e2.batchSaveAllVideos(i2, g); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); - } - })); - })); - } - /** 绑定排序 popover 的选择与键盘交互。 */ - bindSortMenu() { - const control = $(".jhs-sort-control"), toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = /* @__PURE__ */ __name((focus = false) => { - menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), focus && toggle.trigger("focus"); - }, "close"); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const open = !menu.hasClass("is-open"); - menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); - })); - menu.on("click", ".jhs-sort-option", ((event) => { - const item = $(event.currentTarget), method = item.data("sort-method"); - localStorage.setItem("jhs_sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), $("#jhs-sort-current").text(item.text()), close(true), void this.sortItems().catch(((error) => clog.error("列表排序失败", error))); - })).on("keydown", ".jhs-sort-option", ((event) => { - const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), close(true); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })); - $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", ((event) => { - $(event.target).closest(control).length || close(); - })); - } - async sortItems() { - const e2 = this.supportsLiveSorting(); - if (!e2 && (o.includes("handle") || o.includes("advanced_search"))) return; - const s2 = await storageManager.getSetting("autoPage"); - if (c || s2 === _ && !e2) return; - const t2 = localStorage.getItem("jhs_sortMethod"); - if (!t2) return; - const i2 = this.getSelector(), d2 = $(i2.boxSelector), h2 = $(i2.itemSelector); - h2.each((function(e3) { - $(this).attr("data-original-index") || $(this).attr("data-original-index", e3); - })); - const items = h2.get().map(((element, index) => { - const card = $(element), originalIndex = Number(card.attr("data-original-index")) || 0; - if ("default" === t2) return { element, key: originalIndex, originalIndex, index }; - if ("rateCount" === t2) { - const explicit = Number(card.attr("data-jhs-rate-count")), match = card.find(".score").text().replaceAll(",", "").match(/(?:由\s*)?(\d+)\s*人(?:评价)?/); - return { element, key: Number.isFinite(explicit) ? explicit : match ? Number(match[1]) : 0, originalIndex, index }; - } - const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter((function() { - return /^\d{4}-\d{1,2}-\d{1,2}$/.test($(this).text().trim()); - })).first().text().trim(), timestamp = Date.parse(value); - return { element, key: Number.isFinite(timestamp) ? timestamp : 0, originalIndex, index }; - })); - items.sort(((e3, n2) => "default" === t2 ? e3.key - n2.key : n2.key - e3.key || e3.originalIndex - n2.originalIndex || e3.index - n2.index)); - const sortedElements = items.map(((item) => item.element)); - "default" === t2 ? $(sortedElements).appendTo(d2) : d2.empty().append(sortedElements); - } - isHitShowPage() { - return isHitShowPage(window.location); - } - isFc2ListPage() { - return r && "/advanced_search" === window.location.pathname && "3" === new URLSearchParams(window.location.search).get("type"); - } - supportsLiveSorting() { - return this.isHitShowPage() || this.isFc2ListPage(); - } - async openWaitCheck() { - let e2 = this.getSelector(); - const t2 = await storageManager.getSetting("waitCheckCount", 5); - let a2 = 0; - const listPage = this.getBean("ListPagePlugin"); - for (const element of $(e2.itemSelector).toArray()) { - if (a2 >= t2) break; - const item = $(element), flags = normalizeStateFlags(JSON.parse(item.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(item.attr("data-jhs-visibility") || "{}"); - if (hasAnyState(flags) || isHardHidden(flags, visibilityReasons)) continue; - await listPage.openMovieDetail(item, { autoplay: true, newTab: false }), a2++; - } - 0 === a2 && show.info("没有需鉴定的视频"); - } - }; - __name(_ListPageButtonPlugin, "ListPageButtonPlugin"); - var ListPageButtonPlugin = _ListPageButtonPlugin; - var _e = /* @__PURE__ */ __name(async (e2, t2 = "ja", n2 = "zh-CN") => { - if (!e2) throw new Error("翻译文本不能为空"); - const a2 = "https://translate-pa.googleapis.com/v1/translate?" + new URLSearchParams({ - "params.client": "gtx", - dataTypes: "TRANSLATION", - key: "AIzaSyDLEeFI5OtFBwYBIoK_jj5m32rZK5CkCXA", - "query.sourceLanguage": t2, - "query.targetLanguage": n2, - "query.text": e2 - }), i2 = await fetch(a2); - if (!i2.ok) throw new Error(`${i2.status} ${i2.statusText}`); - return (await i2.json()).translation; - }, "_e"); - var Te = { - IS_FILTERED: { - text: u, - color: "var(--jhs-status-filter)", - on: "var(--jhs-status-filter-on)", - reasonType: "单番号屏蔽", - isCounted: true, - countKey: "currentPageFilterCount" - }, - IS_FAVORITE: { - text: b, - color: "var(--jhs-status-fav)", - on: "var(--jhs-status-fav-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageFavoriteCount" - }, - IS_HAS_DOWN: { - text: y, - color: "var(--jhs-status-down)", - on: "var(--jhs-status-down-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageHasDownCount" - }, - IS_HAS_WATCH: { - text: k, - color: "var(--jhs-status-watch)", - on: "var(--jhs-status-watch-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageHasWatchCount" - }, - IS_KEYWORD_FILTER: { - text: "关键词屏蔽", - color: "var(--jhs-status-filter)", - on: "var(--jhs-status-filter-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageKeywordFilterCount" - }, - IS_ACTOR_FILTER: { - text: "男演员屏蔽", - color: "var(--jhs-status-filter)", - on: "var(--jhs-status-filter-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageActorFilterCount" - }, - IS_ACTRESS_FILTER: { - text: "女演员屏蔽", - color: "var(--jhs-status-filter)", - on: "var(--jhs-status-filter-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageActorFilterCount" - }, - IS_WAIT_CHECK: { - text: "", - color: "", - on: "", - reasonType: "", - isCounted: true, - countKey: "currentPageWaitCheckCount" - } - }; - var QUICK_FILTER_LABELS = Object.freeze({ - all: "全部", - waitCheck: "待鉴定", - favorite: "收藏", - hasDown: "下载", - hasWatch: "已看", - blockedItems: "屏蔽项", - favoriteUndownloaded: "收藏未下载", - favoriteUnwatched: "收藏未观看", - downloadedUnwatched: "下载未观看", - recent7d: "最近 7 天" - }); - var PRIMARY_QUICK_FILTERS = Object.freeze(["all", "waitCheck", "favorite", "hasDown", "hasWatch"]); - var SECONDARY_QUICK_FILTERS = Object.freeze(["blockedItems", "favoriteUndownloaded", "favoriteUnwatched", "downloadedUnwatched", "recent7d"]); - var VALID_QUICK_FILTERS = /* @__PURE__ */ new Set([...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS]); - function normalizeQuickFilterKey(value) { - if ("filter" === value) return "blockedItems"; - return VALID_QUICK_FILTERS.has(value) ? value : "waitCheck"; - } - __name(normalizeQuickFilterKey, "normalizeQuickFilterKey"); - function isHardHidden(flags, visibilityReasons = {}) { - return Boolean(flags.blocked || visibilityReasons.keyword || visibilityReasons.actorBlacklist || visibilityReasons.actressBlacklist); - } - __name(isHardHidden, "isHardHidden"); - function matchesQuickFilter(filter, flags, { visibilityReasons = {}, recent = false } = {}) { - const normalizedFilter = normalizeQuickFilterKey(filter), hardHidden = isHardHidden(flags, visibilityReasons); - if ("blockedItems" === normalizedFilter) return hardHidden; - if (hardHidden) return false; - if ("all" === normalizedFilter) return true; - if ("waitCheck" === normalizedFilter) return !hasAnyState(flags); - if ("favorite" === normalizedFilter) return !!flags.favorite; - if ("hasDown" === normalizedFilter) return !!flags.downloaded; - if ("hasWatch" === normalizedFilter) return !!flags.watched; - if ("favoriteUndownloaded" === normalizedFilter) return !!flags.favorite && !flags.downloaded; - if ("favoriteUnwatched" === normalizedFilter) return !!flags.favorite && !flags.watched; - if ("downloadedUnwatched" === normalizedFilter) return !!flags.downloaded && !flags.watched; - return "recent7d" === normalizedFilter && recent; - } - __name(matchesQuickFilter, "matchesQuickFilter"); - function shouldShowItem({ filter, flags, visibilityReasons, recent }) { - return matchesQuickFilter(filter, flags, { visibilityReasons, recent }); - } - __name(shouldShowItem, "shouldShowItem"); - var _ListPagePlugin = class _ListPagePlugin extends BasePlugin { - async initCss() { - return ``; - } - constructor() { - super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), i(this, "currentPageTotalCount", 0), i(this, "cache", null), i(this, "translationPending", /* @__PURE__ */ new Map()), i(this, "filterContext", null), i(this, "pendingItems", /* @__PURE__ */ new Set()), i(this, "processTimer", null), i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), i(this, "_debouncedTranslateWrite", null), i(this, "itemIndex", /* @__PURE__ */ new Map()), i(this, "recountFrame", null); - } - getName() { - return "ListPagePlugin"; - } - async handle() { - if (!window.isListPage || isHitShowPage()) return; - const refreshAll = /* @__PURE__ */ __name(async () => { - this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); - const e2 = this.getBean("HistoryPlugin"); - e2.tableObj && e2.tableObj.setData(); - }, "refreshAll"); - jhsEventBus.on("legacy-refresh", refreshAll), jhsEventBus.on("blacklist-rules-changed", refreshAll), jhsEventBus.on("filter-rules-changed", refreshAll), jhsEventBus.on("settings-changed", refreshAll), jhsEventBus.on("car-state-changed", (async (payload) => { - this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); - const items = this.getIndexedItems(payload.carNums || []); - items.length && (await this.doFilterItems(items), this.applyVisibility(items)); - const history = this.getBean("HistoryPlugin"); - history.tableObj && history.tableObj.setData(); - })), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(); - } - async createQuickFilter() { - if ($("#jhs-quick-filter").length) return; - const e2 = this.getSelector(), primaryHtml = PRIMARY_QUICK_FILTERS.map(((filter) => ``)).join(""), secondaryHtml = SECONDARY_QUICK_FILTERS.map(((filter, index) => `${1 === index ? '' : ""}`)).join(""), t2 = `
    -
    ${primaryHtml}
    -
    - - + `).join(""),"renderCards"),l="jhs-backup-card-list";i.open({type:1,title:t+"备份文件",content:`
    ${n(o)}
    `,area:utils.getResponsiveArea(["800px","70%"]),anim:-1,success:d(c=>{let h=$(c).find(`#${l}`);h.on("click",".jhs-backup-btn",async u=>{let p=$(u.currentTarget),g=p.data("action"),m=p.data("idx"),f=o[m];if(f){if(g==="delete")i.confirm(`是否删除 ${f.name} ?`,{icon:3,title:"提示",btn:["确定","取消"]},async v=>{i.close(v);let b=loading();try{await e.deleteFile(f.fileId),o=await e.getBackupList(s),h.html(n(o)),i.alert("删除成功")}catch(w){clog.error(w),show.error(`发生错误: ${Ue(w)}`)}finally{b.close()}});else if(g==="download"){let v=loading();try{let b=await Xt(await e.getFileContent(f.fileId));utils.download(b,f.name)}catch(b){clog.error(b),show.error("下载失败: "+b)}finally{v.close()}}else if(g==="import"){let v=loading();try{let b=await e.getFileContent(f.fileId);b=await Xt(b);let w=JSON.parse(b),S=await storageManager.exportData(),j=await storageManager.diffData(S,w);v.close(),a(j,null,w)}catch(b){v.close(),clog.error(b),show.error("预览失败: "+Ue(b))}}}})},"success")})}d(wh,"openFileListDialogMobile");function Ol(o,e,t,s,a,i){if(utils.isMobileMode()){wh(o,e,t,s,a,i);return}i.open({type:1,title:t+"备份文件",content:` +
    +
    -
    `; - r ? $(e2.boxSelector).before(t2) : l && $(".masonry").before(t2); - const root = $("#jhs-quick-filter"), toggle = root.find(".jhs-quick-filter__toggle"), menu = root.find(".jhs-quick-filter__menu"), closeMenu = /* @__PURE__ */ __name((restoreFocus = false) => { - menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), restoreFocus && toggle.trigger("focus"); - }, "closeMenu"); - root.on("click", ".jhs-segmented__item", ((event) => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))).on("keydown", ".jhs-segmented__item", ((event) => { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const tabs = root.find(".jhs-segmented__item"), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; - tabs.eq(next).trigger("click").trigger("focus"); - })).on("click", ".jhs-filter-option", ((event) => { - this.setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(true); - })).on("keydown", ".jhs-filter-option", ((event) => { - const items = menu.find(".jhs-filter-option"), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), closeMenu(true); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const open = !menu.hasClass("is-open"); - menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && (menu.find('[aria-checked="true"]').first().length ? menu.find('[aria-checked="true"]').first() : menu.find(".jhs-filter-option").first()).trigger("focus"); - })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", ((event) => { - $(event.target).closest(root).length || closeMenu(); - })); - this.setQuickFilter(await storageManager.getSetting("defaultQuickFilterTab", "waitCheck")); - } - applyVisibility(items = null) { - const e2 = this.activeQuickFilter || "waitCheck", t2 = this.getSelector().itemSelector; - (items ? $(items) : $(t2)).each((function() { - const t3 = $(this), flags = normalizeStateFlags(JSON.parse(t3.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t3.attr("data-jhs-visibility") || "{}"), recent = "yes" === t3.attr("data-jhs-recent"); - shouldShowItem({ filter: e2, flags, visibilityReasons, recent }) ? t3.show() : t3.hide(); - })); - } - setQuickFilter(filter, { syncUi = true } = {}) { - this.activeQuickFilter = normalizeQuickFilterKey(filter), this.applyVisibility(), syncUi && this.syncQuickFilterUi(); - } - syncQuickFilterUi() { - const filter = normalizeQuickFilterKey(this.activeQuickFilter), isPrimary = PRIMARY_QUICK_FILTERS.includes(filter), root = $("#jhs-quick-filter"), tabs = root.find(".jhs-segmented__item"), options = root.find(".jhs-filter-option"); - tabs.removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }); - isPrimary ? tabs.filter(`[data-jhs-filter="${filter}"]`).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }) : tabs.first().attr("tabindex", "0"); - options.attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); - root.find(".jhs-quick-filter__label").text(isPrimary ? "更多筛选" : `筛选:${QUICK_FILTER_LABELS[filter]}`); - $(".jhs-mobile-filter-label").text(`筛选:${QUICK_FILTER_LABELS[filter]}`), $(".jhs-mobile-filter-option").attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); - } - rememberTagExpand() { - if (!window.location.href.includes("actors")) return; - const e2 = $(".tag-expand"); - if (0 === e2.length) return; - const t2 = "jhs_tag_expand", n2 = "true" === localStorage.getItem(t2), a2 = $(".actor-tags .content"); - n2 && a2.hasClass("collapse") && e2[0].click(), e2.on("click", (function() { - const e3 = !$(".actor-tags .content").hasClass("collapse"); - clog.debug("触发"), localStorage.setItem(t2, e3.toString()); - })); - } - checkDom() { - if (!window.isListPage || isHitShowPage()) return; - const e2 = this.getSelector(), t2 = document.querySelector(e2.boxSelector); - if (!t2) return void clog.error("没有找到容器节点!"); - const a2 = new MutationObserver(((records) => { - for (const record of records) { - this.removeIndexedItems(record.removedNodes); - for (const node of record.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - node.matches?.(e2.itemSelector) && "true" !== node.dataset.jhsProcessed && this.pendingItems.add(node), node.querySelectorAll?.(e2.itemSelector).forEach(((item) => { - "true" !== item.dataset.jhsProcessed && this.pendingItems.add(item); - })); - } - } - this.pendingItems.size && (this.processTimer && clearTimeout(this.processTimer), this.processTimer = setTimeout((() => { - const items = [...this.pendingItems].filter(((item) => item.isConnected && "true" !== item.dataset.jhsProcessed)); - this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items).catch(((error) => clog.error("列表增量处理失败", error))); - }), 100)); - })); - a2.observe(t2, { - childList: true, - subtree: false - }); - } - async processAddedItems(items) { - const selector = this.getSelector(), covers = items.flatMap(((item) => [...item.querySelectorAll(selector.coverImgSelector)])); - this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getBean("ListPageButtonPlugin").sortItems(), await this.getBean("CoverButtonPlugin").addSvgBtn(items), items.forEach(((item) => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: false }), this.getBean("AutoPagePlugin").checkLoad(); - } - rebuildItemIndex() { - this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); - } - indexItems(items) { - items.forEach(((item) => { - try { - const key = normalizeCarNum(this.findCarNumAndHref($(item)).carNum); - if (!key) return; - const indexed = this.itemIndex.get(key) || /* @__PURE__ */ new Set(); - indexed.add(item), this.itemIndex.set(key, indexed); - } catch (error) { - clog.debug("列表项索引跳过无效卡片", error); - } - })); - } - removeIndexedItems(nodes) { - const removed = /* @__PURE__ */ new Set(); - Array.from(nodes || []).forEach(((node) => { - node.nodeType === Node.ELEMENT_NODE && (removed.add(node), node.querySelectorAll?.(this.getSelector().itemSelector).forEach(((item) => removed.add(item)))); - })); - if (!removed.size) return; - this.itemIndex.forEach(((items, key) => { - items.forEach(((item) => { - removed.has(item) && items.delete(item); - })), items.size || this.itemIndex.delete(key); - })); - } - getIndexedItems(carNums) { - const result = /* @__PURE__ */ new Set(); - carNums.map(normalizeCarNum).forEach(((key) => { - const items = this.itemIndex.get(key); - items?.forEach(((item) => item.isConnected ? result.add(item) : items.delete(item))), items && !items.size && this.itemIndex.delete(key); - })); - return [...result]; - } - fixBusTitleBox(items = null) { - if (!l) return; - (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((e2) => { - var t2; - let n2 = $(e2); - if (n2.find(".avatar-box").length > 0) return; - const a2 = (null == (t2 = n2.find("img").attr("title")) ? void 0 : t2.trim()) || ""; - n2.find(".photo-info span:first").contents().first().wrap(`${a2}`), n2.find("br").remove(); - })); - } - cleanRepeatId() { - if (!l) return; - $("#waterfall_h").removeAttr("id").attr("id", "no-page"); - const e2 = $('[id="waterfall"]'); - 0 !== e2.length && e2.each((function() { - const e3 = $(this); - if (!e3.hasClass("masonry")) { - e3.children().insertAfter(e3), e3.remove(); - } - })); - } - async doFilter() { - return this.doFilterItems(); - } - async doFilterItems(items = null) { - if (!window.isListPage) return; - let e2 = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); - e2.length && (await this.filterMovieList(e2), l && setTimeout((() => { - this.getBean("BusImgPlugin").logImageHeightsByRow().catch(((e3) => clog.error("JavBus图片高度修正失败", e3))); - }))); - } - async yieldListFrame() { - await new Promise(((e2) => { - window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e2))) : setTimeout(e2); - })); - } - findMatchedTitleKeyword(e2, t2, n2) { - for (const a2 of e2) if (t2.includes(a2) || n2.startsWith(a2)) return a2; - return null; - } - async getFilterContext() { - if (this.filterContext) return this.filterContext; - const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), stateService.getActivityLog()]), actorCarNumToNameMap = /* @__PURE__ */ new Map(), actressCarNumToNameMap = /* @__PURE__ */ new Map(), recentCarNums = /* @__PURE__ */ new Set(); - const cutoff = Date.now() - 7 * 864e5; - activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState && change.fields?.some(((field) => field.startsWith("stateFlags."))))).forEach(((change) => recentCarNums.add(change.carNum))))); - for (const item of blacklistCars) { - const role = blacklistMap.get(item.starId)?.role; - if (!role) { - clog.error("黑名单数据源丢失演员信息", item); - continue; - } - const target = role === B ? actorCarNumToNameMap : actressCarNumToNameMap, carNum = normalizeCarNum(item.carNum); - target.has(carNum) || target.set(carNum, item.names); - } - return this.filterContext = { titleKeywords, settings, carMap, recentCarNums, actorCarNumToNameMap, actressCarNumToNameMap }; - } - collectCurrentPageSummary() { - const summary = { total: 0, pending: 0, blockedItems: 0, favorite: 0, downloaded: 0, watched: 0, debug: { manualBlocked: 0, keywordBlocked: 0, actorBlocked: 0, actressBlocked: 0 } }; - $(this.getSelector().itemSelector).each(((e2, item) => { - const card = $(item); - if (l && card.find(".avatar-box").length > 0) return; - const flags = normalizeStateFlags(JSON.parse(card.attr("data-jhs-flags") || "{}")), reasons = JSON.parse(card.attr("data-jhs-visibility") || "{}"), hardHidden = isHardHidden(flags, reasons); - summary.total++, flags.favorite && summary.favorite++, flags.downloaded && summary.downloaded++, flags.watched && summary.watched++, hardHidden && summary.blockedItems++, !hasAnyState(flags) && !hardHidden && summary.pending++, flags.blocked && summary.debug.manualBlocked++, reasons.keyword && summary.debug.keywordBlocked++, reasons.actorBlacklist && summary.debug.actorBlocked++, reasons.actressBlacklist && summary.debug.actressBlocked++; - })); - return summary; - } - getCurrentPageSummary() { - return this.collectCurrentPageSummary(); - } - recountStatuses() { - const summary = this.collectCurrentPageSummary(); - this.currentPageFilterCount = summary.debug.manualBlocked, this.currentPageFavoriteCount = summary.favorite, this.currentPageHasDownCount = summary.downloaded, this.currentPageHasWatchCount = summary.watched, this.currentPageKeywordFilterCount = summary.debug.keywordBlocked, this.currentPageActorFilterCount = summary.debug.actorBlocked + summary.debug.actressBlocked, this.currentPageWaitCheckCount = summary.pending, this.currentPageTotalCount = summary.total; - return summary; - } - scheduleRecount() { - if (this.recountFrame) return; - const schedule = window.requestAnimationFrame || ((callback) => setTimeout(callback, 0)); - this.recountFrame = schedule((() => { - this.recountFrame = null, this.recountStatuses(); - })); - } - async translateListItems(e2) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; - await mapLimit(e2, 3, (async (item, index) => { - try { - index > 0 && index % 8 == 0 && await this.yieldListFrame(), await this.translate(item); - } catch (error) { - clog.error("列表标题翻译失败", error); - } - })); - } - async filterMovieList(e2) { - utils.time("累计耗费时间"), utils.time("读取数据耗时"); - const { titleKeywords: n2, settings: s2, carMap: m2, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v2 } = await this.getFilterContext(), o2 = utils.time("读取数据耗时"); - utils.time("组装数据耗时"); - const b2 = utils.time("组装数据耗时"), P2 = (null == s2 ? void 0 : s2.tagPosition) || "rightTop"; - const O2 = n2.filter(((e3) => e3)); - this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); - const R2 = []; - for (let n3 = 0; n3 < e2.length; n3++) { - n3 > 0 && n3 % 12 == 0 && await this.yieldListFrame(); - let t2 = $(e2[n3]); - if (l && t2.find(".avatar-box").length > 0) continue; - const { carNum: a2, title: i2 } = this.findCarNumAndHref(t2), record = m2.get(a2), flags = normalizeStateFlags(record?.stateFlags), actorFiltered = f.has(a2), actressFiltered = v2.has(a2), keyword = this.findMatchedTitleKeyword(O2, i2, a2), visibilityReasons = { keyword: !!keyword, actorBlacklist: actorFiltered, actressBlacklist: actressFiltered }; - const hardHidden = isHardHidden(flags, visibilityReasons); - t2.attr("data-jhs-flags", JSON.stringify(flags)).attr("data-jhs-visibility", JSON.stringify(visibilityReasons)).attr("data-jhs-recent", recent.has(a2) ? _ : C).attr("data-jhs-tag-position", P2); - const signature = JSON.stringify({ flags, visibilityReasons, P: P2 }); - if (t2.attr("data-jhs-state-signature") !== signature) { - t2.attr("data-jhs-state-signature", signature), t2.find(".jhs-status-tags").remove(); - const badgeDefs = [ - [flags.blocked, Te.IS_FILTERED, "单番号屏蔽"], - [flags.favorite, Te.IS_FAVORITE, ""], - [flags.downloaded, Te.IS_HAS_DOWN, ""], - [flags.watched, Te.IS_HAS_WATCH, ""], - [visibilityReasons.keyword, Te.IS_KEYWORD_FILTER, keyword || "未知"], - [visibilityReasons.actorBlacklist, Te.IS_ACTOR_FILTER, f.get(a2) || ""], - [visibilityReasons.actressBlacklist, Te.IS_ACTRESS_FILTER, v2.get(a2) || ""] - ].filter(((item) => item[0])); - if (badgeDefs.length) { - const box = $(``); - badgeDefs.forEach((([, definition, tip]) => { - const badge = $(`${escapeHtml(definition.text)}`); - badge.css({ color: definition.on, backgroundColor: definition.color }), box.append(badge); - })); - if (r) t2.find(".tags").append(box); - else if (l) { - const host = t2.find(".item-tag"); - host.length ? host.append(box) : t2.find(".photo-info > span > div").append(box); - } - } - } - hardHidden || R2.push(t2); - } - this.scheduleRecount(), void this.translateListItems(R2).catch(((e3) => clog.error("列表页翻译任务失败", e3))); - const D2 = utils.time("处理页面耗时"), A2 = utils.time("累计耗费时间"); - clog.log(` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ${o2}${b2}
    ${D2}${A2}
    项目数量项目数量
    屏蔽单番号${this.currentPageFilterCount}收藏${this.currentPageFavoriteCount}
    屏蔽演员${this.currentPageActorFilterCount}已下载${this.currentPageHasDownCount}
    屏蔽关键词${this.currentPageKeywordFilterCount}已观看${this.currentPageHasWatchCount}
    待鉴定${this.currentPageWaitCheckCount}
    总数${this.currentPageTotalCount}
    - `); - } - async bindClick() { - let e2 = this.getSelector(); - this.bindMovieDetailNavigation(e2.boxSelector), $(e2.boxSelector).on("click", ".item video", (async (e3) => { - const t2 = e3.currentTarget; - t2.paused ? await safePlay(t2, { - context: "列表视频", - notify: true - }) : t2.pause(), e3.preventDefault(), e3.stopPropagation(); - })), $(e2.boxSelector).on("contextmenu", ".item img, .item video", (async (e3) => { - try { - e3.preventDefault(); - const t2 = $(e3.target).closest(".item"), { carNum: n2, url: a2, publishTime: i2, fc2Source } = this.findCarNumAndHref(t2); - let s2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"), o2 = ""; - s2.length && (o2 = s2.text().trim().split(",")[0].replace("(無碼)", "")), utils.q(e3, `是否屏蔽番号 ${n2}?`, (async () => { - try { - o2 || (o2 = await this.parseActressName(a2)), await stateService.patch(n2, { blocked: true }, { record: { carNum: n2, url: a2, names: o2, publishTime: i2, fc2Source } }), show.ok("操作成功"); - } catch (s3) { - clog.error("屏蔽操作失败:", s3), show.error("操作失败"); - } - })); - } catch (t2) { - clog.error("右键菜单处理失败:", t2); - } - })); - } - /** 从任意列表卡片进入统一详情导航。 */ - async openMovieDetail(item, { event = null, autoplay = false, newTab = false } = {}) { - const card = item?.jquery ? item : $(item), { carNum, aHref, fc2Source } = this.findCarNumAndHref(card); - if (!carNum || !aHref) return; - const shouldOpenTab = newTab || !!event && (event.ctrlKey || event.metaKey || 1 === event.button); - if (carNum.includes("FC2-")) { - const movieId = this.parseMovieId(aHref); - return shouldOpenTab ? this.getBean("Fc2Plugin").openFc2Page(movieId, carNum, aHref, { event, newTab: true }, { source: fc2Source }) : this.getBean("Fc2Plugin").openFc2Dialog(movieId, carNum, aHref, { source: fc2Source }); - } - const destination = new URL(aHref, window.location.origin); - autoplay && destination.searchParams.set("autoPlay", "1"), utils.openPage(destination.href, carNum, true, { event, newTab: shouldOpenTab }), this.$currentImage = null; - } - /** 为宿主与合成列表统一绑定左键、修饰键和中键导航。 */ - bindMovieDetailNavigation(container) { - const root = $(container), selector = ".item img, .item .video-title"; - root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, ((event) => { - if ("auxclick" === event.type && 1 !== event.button || "click" === event.type && event.button && 0 !== event.button) return; - if (event.shiftKey || event.altKey || $(event.target).closest("div.meta-buttons,[class^='jhs-match-']").length) return; - event.preventDefault(), event.stopPropagation(); - void this.openMovieDetail($(event.currentTarget).closest(".item"), { event }).catch(((error) => clog.error("打开影片详情失败", error))); - })); - } - async parseActressName(e2) { - let t2 = null; - if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { - clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e2); - const n2 = await gmHttp.get(e2), a2 = utils.htmlTo$dom(n2); - r ? t2 = a2.find(".female").prev().map(((e3, t3) => $(t3).text())).get().join(" ") : l && (t2 = a2.find('span[onmouseover*="star_"] a').map(((e3, t3) => $(t3).text())).get().join(" ")), clog.debug("解析到名称:", t2); - } - return t2; - } - findCarNumAndHref(e2) { - var t2, n2; - let a2, i2, s2, o2 = e2.find("a"), r2 = o2.attr("href"), l2 = e2.find(".video-title"); - if (l2.length > 0) { - let t3 = l2.find("strong"); - t3.length > 0 && (a2 = t3.text().trim()), i2 = o2.attr("title") ? o2.attr("title").trim() : a2 ? l2.text().replace(a2, "").trim() : l2.text().trim(), s2 = e2.find(".meta").text().trim(); - } - if (!a2) { - let o3 = e2.find("img"); - r2 && o3.length > 0 && (i2 = (null == (t2 = o3.attr("title")) ? void 0 : t2.trim()) || (null == (n2 = o3.attr("data-title")) ? void 0 : n2.trim())); - const l3 = /* @__PURE__ */ __name((e3) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e3), "l"); - s2 = e2.find("date").map(((e3, t3) => $(t3).text().trim())).get().find(l3), a2 = e2.find("date").map(((e3, t3) => $(t3).text().trim())).get().find(((e3) => !l3(e3))); - } - if (!a2) { - const e3 = "提取番号信息失败"; - throw show.error(e3), new Error(e3); - } - return { - carNum: normalizeCarNum(a2), - aHref: r2, - url: r2, - title: i2, - publishTime: s2, - fc2Source: ["fc2", "123av"].includes(e2.attr("data-jhs-fc2-source")) ? e2.attr("data-jhs-fc2-source") : "fc2" - }; - } - showCarNumBox(e2) { - const t2 = $(".movie-list .item").toArray().find(((t3) => $(t3).find(".video-title strong").text() === e2)); - if (t2) { - const n2 = $(t2); - n2.attr("data-hide") === "yes" && (n2.show(), n2.removeAttr("data-hide")); - } - } - _replaceSingleHdImg(e2) { - if ("true" === e2.dataset.hdReplaced) return; - if (r) { - const isJavdbCdn = /jdbstatic\.com|javdb\.com/i.test(e2.src); - if (isJavdbCdn) { - const originalSrc = e2.src; - e2.src = e2.src.replace("thumbs", "covers"); - e2.dataset.hdReplaced = "true"; - e2.title = ""; - e2.onerror = function() { - if (this.src !== originalSrc) { - this.src = originalSrc; - this.onerror = null; - } - }; - } - } else if (l) { - const t2 = /\/(imgs|pics)\/(thumb|thumbs)\//, n2 = /(\.jpg|\.jpeg|\.png)$/i; - t2.test(e2.src) ? (e2.src = e2.src.replace(t2, "/$1/cover/").replace(n2, "_b$1"), e2.dataset.hdReplaced = "true", e2.dataset.title = e2.title, e2.title = "") : /ps(\.jpg|\.jpeg|\.png)$/i.test(e2.src) && (e2.src = e2.src.replace(/ps(\.jpg|\.jpeg|\.png)$/i, "pl$1"), e2.dataset.hdReplaced = "true", e2.dataset.title = e2.title, e2.title = ""); - } - } - replaceHdImg(e2) { - if (e2 && "string" == typeof e2.jquery && (e2 = e2.toArray()), e2 || (e2 = document.querySelectorAll(this.getSelector().coverImgSelector)), !e2.length) return; - const t2 = Array.from(e2).filter(((e3) => "true" !== e3.dataset.hdReplaced && "true" !== e3.dataset.jhsHdObserved)); - if ("IntersectionObserver" in window && !this.hdImageObserver) this.hdImageObserver = new IntersectionObserver(((entries) => { - entries.forEach(((entry) => { - entry.isIntersecting && (this.hdImageObserver.unobserve(entry.target), delete entry.target.dataset.jhsHdObserved, this._replaceSingleHdImg(entry.target)); - })); - }), { rootMargin: "200px" }); - for (const image of t2) this.hdEagerRemaining > 0 ? (this.hdEagerRemaining--, this._replaceSingleHdImg(image)) : this.hdImageObserver ? (image.dataset.jhsHdObserved = "true", this.hdImageObserver.observe(image)) : this._replaceSingleHdImg(image); - storageManager.getSetting("hoverBigImg", C).then(((e3) => { - e3 === _ && (window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ - selector: this.getSelector().coverImgSelector - })); - })); - } - getTranslationCache() { - if (this.cache && "object" == typeof this.cache && !Array.isArray(this.cache)) return this.cache; - try { - this.cache = JSON.parse(localStorage.getItem("jhs_translate") || "{}"); - } catch (error) { - clog.warn("列表翻译缓存无法解析,已忽略旧缓存", error), this.cache = {}; - } - return this.cache && "object" == typeof this.cache && !Array.isArray(this.cache) ? this.cache : this.cache = {}; - } - scheduleTranslationWrite() { - this._debouncedTranslateWrite && clearTimeout(this._debouncedTranslateWrite), this._debouncedTranslateWrite = setTimeout((() => { - localStorage.setItem("jhs_translate", JSON.stringify(this.getTranslationCache())); - }), 500); - } - applyTranslatedTitle(e2, t2, n2) { - const a2 = e2.find(".video-title"); - r ? (a2.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(n2) || (this.textContent = " " + t2 + " "); - })), a2.attr("title", t2)) : a2.text(t2), e2.attr("data-jhs-translation-key", n2); - } - async translate(e2) { - let t2, n2, a2 = e2.find(".video-title"); - if (r ? (t2 = a2.contents().filter(((e3, t3) => 3 === t3.nodeType && "" !== t3.textContent.trim())).text().trim(), n2 = e2.find(".video-title strong").text().trim()) : (t2 = (e2.find("img").attr("data-title") || "").trim(), n2 = (e2.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t2 || !n2) return; - const cache = this.getTranslationCache(); - if (cache[n2]) return void this.applyTranslatedTitle(e2, cache[n2], n2); - let pending = this.translationPending.get(n2); - if (!pending) { - pending = _e(t2).then(((translated) => (cache[n2] = translated, this.scheduleTranslationWrite(), translated))).finally((() => this.translationPending.delete(n2))), this.translationPending.set(n2, pending); - } - this.applyTranslatedTitle(e2, await pending, n2); - } - async revertTranslation() { - $(this.getSelector().itemSelector).toArray().forEach(((e2) => { - let t2 = $(e2); - const n2 = t2.find(".box").attr("title") || t2.find(".video-title").attr("title") || t2.find("img").attr("data-title"); - let a2; - r && (a2 = t2.find(".video-title strong").text().trim()); - const i2 = t2.find(".video-title"); - i2.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(a2) || (this.textContent = " " + n2 + " "); - })), i2.removeAttr("title"); - })); - } - addJumpPageControl() { - const e2 = "gemini-jump-page-control"; - if ($("#" + e2).length > 0) return; - if (0 === $(".pagination-link.is-current").length) return; - const t2 = utils.getUrlParam(o, "page") || 1, n2 = $('', { - id: "jumpPageInput", - placeholder: "页码", - min: "1", - value: t2 + 1 - }), a2 = $(' + + + `},"formatter")}]})},"success")})}d(Ol,"openFileListDialog");async function zl(){try{let o=JSON.stringify(await storageManager.exportData()),e=`${utils.getNowStr("_","_")}.json`;utils.download(o,e),show.ok("数据导出成功")}catch(o){clog.error(o),show.error("导出数据时出错: "+Ue(o))}}d(zl,"exportSettingData");function Ul(o,e,t,s){let a;return t?a=` .container-fluid .row{ max-width: 1000px !important; - min-width: ${containerWidth}%; + min-width: ${o}%; margin: auto auto; } @@ -9466,23 +2160,19 @@ ${error.stack}` : ""); } .masonry { - grid-template-columns: repeat(${containerColumns}, minmax(0, 1fr)); + grid-template-columns: repeat(${e}, minmax(0, 1fr)); } - `; - } else { - base = ` + `:a=` section .container{ max-width: 1000px !important; - min-width: ${containerWidth}%; + min-width: ${o}%; } .movie-list, .movie-list.v{ - grid-template-columns: repeat(${containerColumns}, minmax(0, 1fr)); + grid-template-columns: repeat(${e}, minmax(0, 1fr)); } - `; - } - return ` + `,` - `; - } - __name(buildSettingCss, "buildSettingCss"); - async function applyImageMode() { - $("#verticalImgStyle").remove(); - if (await storageManager.getSetting("enableVerticalModel", C) === _) { - let e2 = "100% 50% !important"; - window.location.href.includes("/advanced_search?type=100") && (e2 = "50% 50% !important"); - const t2 = ` + `}d(Ul,"buildSettingCss");async function yh(o=null,e){if($("#verticalImgStyle").remove(),(e===void 0?await storageManager.getSetting("enableVerticalModel",ne):e)===R){let s="100% 50% !important";window.location.href.includes("/advanced_search?type=100")&&(s="50% 50% !important");let a=` .cover { - min-height: 350px !important; + aspect-ratio: 3 / 4.26; overflow: hidden !important; - padding-top: 142% !important; } .cover img { + width: 100%; + height: 100%; object-fit: cover !important; - object-position: ${e2}; + object-position: ${s}; } /* bus的 */ .masonry .movie-box img { - min-height: 500px !important; + aspect-ratio: 3 / 4.26; object-fit: cover !important; object-position: top right; } - `; - $("\n "; - } - open(e2) { - layer.open({ - type: 1, - title: "以图识图", - content: '\n
    \n
    \n
    \n

    拖拽图片到此处 或 点击按钮选择图片

    \n

    也可以直接 Ctrl+V 粘贴图片或 图片URL

    \n
    \n \n \n
    \n \n
    \n \n
    \n \n
    \n \n
    \n \n \n
    \n \n
    \n

    请选择识图网站:

    \n
    \n
    \n
    \n \n
    \n ', - area: utils.isMobileMode() ? utils.getResponsiveArea() : ["40%", "80%"], - success: /* @__PURE__ */ __name(async (t2) => { - this.initEventListeners(), e2 && e2(); - }, "success"), - end: /* @__PURE__ */ __name(() => { - $(document).off("paste.searchImg"); - }, "end") - }); - } - initEventListeners() { - const e2 = $("#upload-area"), t2 = $("#image-file"), n2 = $("#select-image-btn"), a2 = $("#preview-area"), i2 = $("#preview-image"), s2 = $("#action-btns"), o2 = $("#handle-btn"), r2 = $("#cancel-btn"), l2 = $("#url-input-container"), c2 = $("#image-url"), d2 = $("#search-results"), h2 = $("#search-img-site-btns-container"); - e2.on("dragover", ((t3) => { - t3.preventDefault(), e2.addClass("highlight"); - })).on("dragleave", (() => { - e2.removeClass("highlight"); - })).on("drop", ((t3) => { - t3.preventDefault(), e2.removeClass("highlight"), t3.originalEvent.dataTransfer.files && t3.originalEvent.dataTransfer.files[0] && (this.handleImageFile(t3.originalEvent.dataTransfer.files[0]), this.resetSearchUI()); - })), n2.on("click", (() => { - t2.trigger("click"); - })), t2.on("change", ((e3) => { - e3.target.files && e3.target.files[0] && (this.handleImageFile(e3.target.files[0]), this.resetSearchUI()); - })), $(document).on("paste.searchImg", (async (e3) => { - const t3 = e3.originalEvent.clipboardData.items; - for (let a3 = 0; a3 < t3.length; a3++) if (-1 !== t3[a3].type.indexOf("image")) { - const e4 = t3[a3].getAsFile(); - return this.handleImageFile(e4), void this.resetSearchUI(); - } - const n3 = e3.originalEvent.clipboardData.getData("text"); - n3 && utils.isUrl(n3) && (l2.show(), c2.val(n3), i2.attr("src", n3), a2.show(), this.resetSearchUI()); - })), o2.on("click", (async () => { - const e3 = i2.attr("src"); - if (e3) { - if (!this.isUploading) { - this.isUploading = true; - try { - const t3 = await this.searchByImage(e3); - s2.hide(), d2.show(), h2.empty(); - const n3 = "jhs_selectedSites", a3 = JSON.parse(localStorage.getItem(n3) || "{}"); - this.siteList.forEach(((e4) => { - const n4 = e4.url.replace("{占位符}", encodeURIComponent(t3)), i3 = false !== a3[e4.name]; - h2.append(` - - - ${e4.name} - ${e4.name} - - `); - })), h2.on("change", ".site-checkbox", (function() { - const e4 = $(this).data("site-name"); - a3[e4] = $(this).is(":checked"), localStorage.setItem(n3, JSON.stringify(a3)); - })), h2.show(); - } finally { - this.isUploading = false; - } - } - } else show.info("请粘贴或上传图片"); - })), r2.on("click", (() => { - a2.hide(), l2.hide(), t2.val(""), c2.val(""); - })), c2.on("change", (() => { - utils.isUrl(c2.val()) && (i2.attr("src", c2.val()), a2.show()); - })), $("#openAll").on("click", (() => { - $(".search-img-site-btn").each((function() { - $(this).find(".site-checkbox").is(":checked") && window.open($(this).attr("href")); - })); - })); - } - resetSearchUI() { - $("#action-btns").show(), $("#search-results").hide(), $("#search-img-site-btns-container").hide().empty(); - } - handleImageFile(e2) { - const t2 = document.getElementById("preview-image"), n2 = document.getElementById("preview-area"), a2 = document.getElementById("url-input-container"); - if (!e2.type.match("image.*")) return void show.info("请选择图片文件"); - const i2 = new FileReader(); - i2.onload = (e3) => { - t2.src = e3.target.result, n2.style.display = "block", a2.style.display = "none", $("#handle-btn")[0].click(); - }, i2.readAsDataURL(e2); - } - async searchByImage(e2) { - let t2 = loading(); - try { - let t3 = e2; - if (e2.startsWith("data:")) { - show.info("开始上传图片..."); - const n2 = await (async function(e3) { - var t4; - const n3 = e3.match(/^data:(.+);base64,(.+)$/); - if (!n3 || n3.length < 3) throw new Error("无效的Base64图片数据"); - const a2 = n3[1], i2 = n3[2], s2 = atob(i2), o2 = new Array(s2.length); - for (let g2 = 0; g2 < s2.length; g2++) o2[g2] = s2.charCodeAt(g2); - const r2 = new Uint8Array(o2), l2 = new Blob([r2], { - type: a2 - }), c2 = new FormData(); - c2.append("image", l2); - const d2 = await fetch("https://api.imgur.com/3/image", { - method: "POST", - headers: { - Authorization: "Client-ID d70305e7c3ac5c6" - }, - body: c2 - }), h2 = await d2.json(); - if (h2.success && h2.data && h2.data.link) return h2.data.link; - throw new Error((null == (t4 = h2.data) ? void 0 : t4.error) || "上传到Imgur失败"); - })(e2); - if (!n2) return void show.error("上传失败"); - t3 = n2; - } - return t3; - } catch (n2) { - show.error(`搜索失败: ${n2.message}`), clog.error("搜索失败:", n2); - } finally { - t2.close(); - } - } - }; - __name(_SearchByImagePlugin, "SearchByImagePlugin"); - var SearchByImagePlugin = _SearchByImagePlugin; - var _BusNavBarPlugin = class _BusNavBarPlugin extends BasePlugin { - getName() { - return "BusNavBarPlugin"; - } - handle() { - $("#navbar > div > div > span").append('\n \n '), $("#search-img-btn").on("click", (() => { - this.getBean("SearchByImagePlugin").open(); - })); - } - }; - __name(_BusNavBarPlugin, "BusNavBarPlugin"); - var BusNavBarPlugin = _BusNavBarPlugin; - var _RelatedPlugin = class _RelatedPlugin extends BasePlugin { - getName() { - return "RelatedPlugin"; - } - async initCss() { - return ` - `; - } - async showRelated(target, movieId, options = {}) { - const isActive = "function" === typeof options.isActive ? options.isActive : () => true; - const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getBean("DetailWorkspacePlugin")?.getSlot("related"); - if (!movieId) return void show.error("未传入movieId"); - if (!isActive() || !host?.length) return $(); - const existing = host.children('[data-jhs-panel="related"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); - if (existing.length) return existing; - const panel = $('').attr("data-jhs-movie-id", String(movieId)), header = $('

    相关清单

    '), toggle = $(''), state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1 }; - header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="related"]').first().append(toggle) : panel.append(header), panel.append('', ''), host.append(panel), state.isActive = isActive; - this.updateToggle(toggle, enabled === _); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const expanded = "展开" === toggle.find(".toggle-text").text(); - this.updateToggle(toggle, expanded), panel.find(".jhs-related-container, .jhs-related-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayRelateds(state), storageManager.saveSettingItem("enableLoadRelated", expanded ? _ : C); - })); - enabled === _ ? await this.fetchAndDisplayRelateds(state) : panel.find(".jhs-related-container, .jhs-related-footer").hide(); - return panel; - } - updateToggle(toggle, expanded) { - toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); - } - async fetchAndDisplayRelateds(state) { - if (state.loading || !state.isActive?.()) return; - state.loading = true; - const { movieId, panel } = state, container = panel.find(".jhs-related-container"), footer = panel.find(".jhs-related-footer"); - container.empty().append($('
    ').text("获取清单中...")), footer.empty(); - let related; - try { - related = await K(movieId, 1, 20); - } catch (error) { - clog.error("获取清单失败:", error); - state.loading = false; - return void this.renderRetry(container, (() => this.fetchAndDisplayRelateds(state))); - } - if (!state.isActive?.()) return void (state.loading = false); - state.loading = false, state.loaded = true; - container.empty(); - if (!related.length) return void container.append($('
    ').text("无清单")); - this.displayRelateds(state, related, container), 20 === related.length ? this.bindLoadMore(state, container, footer) : footer.append($('
    ').text("已加载全部清单")); - } - renderRetry(container, retry) { - container.empty(); - const state = $('
    ').append(document.createTextNode("获取清单失败 ")); - state.append($('').on("click", retry)), container.append(state); - } - bindLoadMore(state, container, footer) { - const button = $(''), end = $('').hide(); - footer.empty().append(button, end); - button.on("click", (async () => { - const nextPage = state.page + 1; - button.text("加载中...").prop("disabled", true); - try { - const related = await K(state.movieId, nextPage, 20); - if (!state.isActive?.()) return; - state.page = nextPage; - this.displayRelateds(state, related, container), related.length < 20 ? (button.remove(), end.show()) : button.text("加载更多清单").prop("disabled", false); - } catch (error) { - clog.error("加载更多清单失败:", error), button.text("加载失败,请重试").prop("disabled", false); - } - })); - } - displayRelateds(state, related, container) { - related.forEach(((item) => { - const row = $(''), title = $("").addClass("jhs-related-title").attr({ - href: `/lists/${encodeURIComponent(item.relatedId)}`, - target: "_blank", - rel: "noopener noreferrer" - }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createTime || "未知"}`); - meta.append( - $("").text(`视频:${Number(item.movieCount) || 0}`), - $("").text(`收藏:${Number(item.collectionCount) || 0}`), - $("").text(`查看:${Number(item.viewCount) || 0}`), - time - ), row.append(heading, meta), container.append(row); - })); - } - }; - __name(_RelatedPlugin, "RelatedPlugin"); - var RelatedPlugin = _RelatedPlugin; - var _WantAndWatchedVideosPlugin = class _WantAndWatchedVideosPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "flag", null); - } - getName() { - return "WantAndWatchedVideosPlugin"; - } - async handle() { - window.location.href.includes("/want_watch_videos") && ($("h3").append(''), $("#wantWatchBtn").on("click", ((e2) => { - this.flag = "favorite", this.importWantWatchVideos(e2, "是否将想看的影片导入到 JHS 收藏?"); - }))), window.location.href.includes("/watched_videos") && ($("h3").append(''), $("#wantWatchBtn").on("click", ((e2) => { - this.flag = "watched", this.importWantWatchVideos(e2, "是否将看过的影片导入到 JHS 已观看?"); - }))); - } - importWantWatchVideos(e2, t2) { - utils.q(null, `${t2}
    执行此功能前请记得备份数据`, (async () => { - let e3 = loading(); - try { - const result = await this.parseMovieList(); - show.ok(`导入完成:成功 ${result.imported},失败 ${result.failed},共 ${result.pages} 页`); - } catch (t3) { - clog.error(t3), show.error(`导入失败:${t3.message || t3}`); - } finally { - e3.close(); - } - })); - } - async parseMovieList(e2 = null, result = { imported: 0, failed: 0, pages: 0 }) { - let t2, n2; - e2 ? (t2 = e2.find(this.getSelector().itemSelector), n2 = e2.find(".pagination-next").attr("href")) : (t2 = $(this.getSelector().itemSelector), n2 = $(".pagination-next").attr("href")); - result.pages++, show.info(`正在导入第 ${result.pages} 页`); - for (const i2 of t2) { - const e3 = $(i2), t3 = e3.find("a").attr("href"), n3 = e3.find(".video-title strong").text().trim(), s2 = e3.find(".meta").text().trim(); - if (t3 && n3) try { - this.flag && await stateService.patch(n3, { [this.flag]: true }, { type: "javdb-list-import", record: { carNum: n3, url: t3, names: "", publishTime: s2 } }), result.imported++; - } catch (a2) { - result.failed++, clog.error(`保存失败 [${n3}]:`, a2); - } - } - if (!n2) return result; - await utils.sleep(1e3); - const html = await gmHttp.get(new URL(n2, window.location.href).href), nextPage = utils.htmlTo$dom(html); - return this.parseMovieList(nextPage, result); - } - }; - __name(_WantAndWatchedVideosPlugin, "WantAndWatchedVideosPlugin"); - var WantAndWatchedVideosPlugin = _WantAndWatchedVideosPlugin; - var _CoverButtonPlugin = class _CoverButtonPlugin extends BasePlugin { - getName() { - return "CoverButtonPlugin"; - } - async initCss() { - return ` - `; - } - async handle() { - window.isListPage && (this.addSvgBtn(), await this.bindClick()); - } - /** 构建卡片工具和三个卡片内 popover。 */ - buildToolBox() { - return ` -
    - - -
    - - +

    缓存命中率

    +
    +
    `)}d(Zl,"injectPluginMgmtPanel");function Xl(){let o=$(".side-menu-item").parent();o.length&&!o.find('[data-panel="snapshot-panel"]').length&&o.append('');let e=$(".content-panel").parent();if(!e.length||$("#snapshot-panel").length)return;e.append('

    恢复点

    创建、下载或恢复本地数据快照。

    快照保存当前全部数据状态,可用于恢复。最多保留 10 个,超出自动清理最旧的。

    ')}d(Xl,"injectSnapshotPanel");function ec(){let o=$(".side-menu-item").parent();o.length&&!o.find('[data-panel="network-panel"]').length&&o.append('');let e=$(".content-panel").parent();if(!e.length||$("#network-panel").length)return;e.append(`
    +

    外部请求

    配置熔断规则并查看站点健康状态。

    +

    熔断器配置

    连续请求失败达到阈值后,自动停止对该站点的请求,避免拖慢整体体验。

    +
    熔断阈值(次):
    +
    冷却时间(秒):
    +
    +

    站点健康状态

    各外部站点的熔断状态和请求统计。

    +

    域名使用统计

    脚本实际请求过的域名及次数。

    +
    `)}d(ec,"injectNetworkPanel");function tc(){if($("#resource-sources-panel").length)return;$(".content-panel").last().after(`
    +

    磁力来源

    聚合多个来源搜索磁力结果,优先级数字越小越靠前。

    自定义来源

    +

    磁力规则

    使用可视化规则为结果添加标签或过滤低质量内容。

    标签规则

    过滤规则

    +

    截图来源

    自动选择会按优先级依次尝试可用来源。

    +
    高级 · 导入 / 导出配置

    高级功能:错误修改可能导致自定义来源不可用,保存前会校验配置。

    +
    +

    统一离线服务

    115 状态:未知

    服务不可用时会在提交前显示原因。
    +

    番号列表导入

    支持换行、空格、逗号分隔番号。必须先解析预览,再确认导入。

    `),$(".jhs-mobile-sidebar,.setting-sidebar").first().append('')}d(tc,"injectResourceSourcesPanel");function Os(o=void 0,e={}){let t=o?Hl(o,e):null;return` +
    +
    + ${t?t.prop("outerHTML"):'
    '} +
    +
    + +
    +
    `}d(Os,"buildQuickSettingHtml");async function Rt(o){let e=o.getNetworkDiagnostics(),t=e.circuitBreakers,s=e.domainStats,a=await storageManager.getSetting("circuitBreakerThreshold",3),i=await storageManager.getSetting("circuitBreakerCooldown",6e4);$("#circuitBreakerThreshold").each(((l,c)=>{c.dataset.jhsHydrated||(c.value=String(a),c.dataset.jhsHydrated="1")})),$("#circuitBreakerCooldownSec").each(((l,c)=>{c.dataset.jhsHydrated||(c.value=String(Math.round(i/1e3)),c.dataset.jhsHydrated="1")}));let r=Object.entries(t);if(r.length){let l='';for(let[c,h]of r){let u=h.state==="open"?"熔断":h.state==="half-open"?"半开":"正常",p=h.state==="open"?`剩余${Math.ceil((Number(h.cooldownMs)-(Date.now()-Number(h.openTime)))/1e3)}秒`:"";l+=``}l+="
    域名状态失败次数操作
    ${C(c)}${u} ${p}${Number(h.failCount)||0}
    ",$("#site-health-table").html(l)}else $("#site-health-table").html('

    暂无熔断记录

    ');let n=Object.entries(s).sort(((l,c)=>c[1].count-l[1].count));if(n.length){let l='';for(let[c,h]of n){let u=h.lastUsed?new Date(h.lastUsed).toLocaleTimeString():"-";l+=``}l+="
    域名请求数错误数最后使用
    ${C(c)}${h.count}${h.errors}${u}
    ",l+=`

    共 ${n.length} 个域名

    `,$("#domain-stats-table").html(l)}else $("#domain-stats-table").html('

    暂无统计数据

    ');$(".reset-breaker").off("click").on("click",(l=>{let c=$(l.target).data("domain");o.resetCircuitBreaker(c),show.ok(`已重置 ${c} 的熔断状态`),Rt(o)})),$("#resetAllBreakersBtn").off("click").on("click",(()=>{o.resetAllCircuitBreakers(),show.ok("已重置全部熔断状态"),Rt(o)})),$("#clearDomainStatsBtn").off("click").on("click",(()=>{o.clearDomainStats(),show.ok("已清空域名统计"),Rt(o)}))}d(Rt,"renderNetworkPanel");async function ss(){let o=await storageManager.getSnapshotList(),e={manual:"手动创建","auto-import":"导入前自动","auto-repair":"修复前自动","auto-restore":"恢复前自动"};if(o.length===0)return void $("#snapshot-list").html('
    暂无快照,点击上方按钮创建
    ');$("#snapshot-list").find(".tabulator").length&&$("#snapshot-list").empty();let t=ze(globalThis.Tabulator,"#snapshot-list",{pagination:!1,layout:"fitColumns",placeholder:"暂无数据",data:o,columnDefaults:{headerHozAlign:"center",hozAlign:"center"},columns:[{title:"名称",field:"name",width:200,headerSort:!1},{title:"来源",field:"source",width:100,headerSort:!1,formatter:d(s=>e[s.getValue()]||s.getValue(),"formatter")},{title:"时间",field:"time",width:170,headerSort:!1},{title:"数据量",field:"itemCount",width:80,headerSort:!1},{title:"操作",minWidth:220,headerSort:!1,formatter:d((s,a,i)=>{let r=s.getData();return i((()=>{let n=s.getElement().querySelector(".snap-restore"),l=s.getElement().querySelector(".snap-download"),c=s.getElement().querySelector(".snap-delete");n&&n.addEventListener("click",(async h=>{utils.q(h,`恢复到快照「${C(r.name)}」? 当前数据会自动备份。`,(async()=>{let u=loading();try{await storageManager.restoreSnapshot(r.id),show.ok("恢复成功, 页面将刷新"),setTimeout(()=>location.reload(),1e3)}catch(p){clog.error(p),show.error("恢复失败: "+(p instanceof Error?p.message:String(p)))}finally{u.close()}}))})),l&&l.addEventListener("click",(async h=>{let u=loading();try{let p=await storageManager.getSnapshot(r.id);if(!p)throw new Error("快照不存在");utils.download(JSON.stringify(p.data),`snapshot_${String(r.name||"snapshot").replace(/[\\/:*?"<>|]/g,"_")}.json`),show.ok("下载成功")}catch(p){show.error("下载失败: "+(p instanceof Error?p.message:String(p)))}finally{u.close()}})),c&&c.addEventListener("click",(async h=>{utils.q(h,`删除快照「${C(r.name)}」?`,(async()=>{try{await storageManager.deleteSnapshot(r.id),show.ok("已删除"),ss()}catch(u){clog.error(u),show.error("删除失败: "+(u instanceof Error?u.message:String(u)))}}))}))})),' '},"formatter")}]})}d(ss,"renderSnapshotPanel");function sc(o,e,t=null,s){let a=o.summary,i=[];for(let[c,h]of Object.entries(o.stores)){if(h.status==="unchanged")continue;let u={store:c,status:h.status,oldCount:h.oldCount,newCount:h.newCount,added:h.added.length,removed:h.removed.length,modified:h.modified.length};i.push(u)}let r='
    ';if(r+='
    ',r+=`
    ${a.added}新增数据源
    `,r+=`
    ${a.removed}缺失数据源
    `,r+=`
    ${a.modified}有变更
    `,r+=`
    ${a.unchanged}无变化
    `,r+="
    ",i.length>0){r+='
    ';let c={added:"新增",removed:"缺失",modified:"变更",unchanged:"无变化"};for(let h of i)r+=``;r+="
    数据源状态当前导入新增删除修改
    ${C(h.store)}${c[h.status]||h.status}${h.oldCount}${h.newCount}${h.added||"-"}${h.removed||"-"}${h.modified||"-"}
    "}else r+='
    数据完全一致,无需导入
    ';r+='
    导入将覆盖当前数据,建议先创建快照备份
    ',r+="
    ";let n=i.length>0,l=s.open({type:1,title:"数据差异预览",content:r,area:utils.getResponsiveArea(["700px","auto"]),btn:n?["确认导入","取消"]:["知道了","取消"],anim:-1,yes:d(async c=>{if(!n)return void s.close(c);let h=loading();try{await storageManager.createSnapshot("导入前自动备份","auto-import"),t?(await storageManager.importData(t),show.ok("导入成功!"),setTimeout(()=>location.reload(),1e3)):e&&(await storageManager.importData(e),show.ok("导入成功!"),setTimeout(()=>location.reload(),1e3))}catch(u){clog.error(u),show.error("导入失败: "+(u instanceof Error?u.message:String(u)))}finally{h.close()}},"yes")})}d(sc,"showDiffPreview");async function xr(o,e=null){let t=o.exportSnapshot(),s=Ge(await storageManager.getSetting("disabledPlugins","[]")),a=t.legacyPlugins,i=d(S=>{let j=new Set(S);return a.filter((k=>j.has(k)||j.has($t(k))))},"effectiveDisabledNames"),{categories:r,pluginMeta:n}=Gl(),l=new Map((t.legacyPluginDescriptors||[]).map(S=>[S.name,S])),c=new Set(a),h="";for(let[S,j]of Object.entries(r)){let k=j.plugins.filter(x=>c.has(x));if(k.length){h+='
    ',h+=`

    ${C(j.label)}

    `;for(let x of k){let _=l.get(x)?.disableable===!1,E=s.includes($t(x)),L=n[x]?.[0]||x;h+='
    ',h+=`${C(L)}`,_?h+='核心':h+=``,h+="
    "}h+="
    "}}$("#plugin-mgmt-list").html(h);let u=i(s),p=a.length-u.length;$("#pm-total").text(a.length),$("#pm-enabled").text(p),$("#pm-disabled").text(u.length),$(".pm-toggle").off("change").on("change",async S=>{let j=$(S.target),k=j.data("plugin"),x=j.is(":checked"),_=!x,E=Ge(await storageManager.getSetting("disabledPlugins","[]")),L=$t(k);if(x?E=E.filter(P=>P!==k&&P!==L):E.includes(L)||E.push(L),!e)throw new Error("SettingsService is unavailable");let M=d(P=>{let z=t.legacyPlugins,O=i(P);$("#pm-total").text(z.length),$("#pm-enabled").text(z.length-O.length),$("#pm-disabled").text(O.length)},"renderState");M(E);try{await e.set("disabledPlugins",JSON.stringify(E)),show.ok(`插件 "${k}" 已${x?"启用":"禁用"},刷新后生效`)}catch(P){j.prop("checked",_);let z=Ge(await storageManager.getSetting("disabledPlugins","[]"));M(z),clog.error("插件状态保存失败,已恢复",P),show.error(`插件 "${k}" 状态保存失败,已恢复`)}});let g=t.legacyStartup,m=t.legacyTimings,f=d(S=>Number.isFinite(S)?S.toFixed(1):"0.0","formatMs"),v=g?`
    就绪: ${f(g.readyMs)} ms注册: ${f(g.registrationMs)} ms样式: ${f(g.cssMs)} ms即时插件: ${f(g.immediateMs)} ms空闲任务: ${g.idleCompleted}/${g.idleCompleted+g.idlePending}

    就绪耗时不包含 @require 下载及浏览器脚本解析时间。

    `:"";if(m.length){let S=[...m].sort(((k,x)=>x.elapsed-k.elapsed)),j='';for(let k of S){let x=k.status==="disabled"?"is-muted":k.elapsed>500?"is-slow":k.elapsed>200?"is-warning":"",_=k.status==="disabled"?"已禁用":k.status==="error"?"错误":k.status==="pending-idle"?"等待空闲":k.status==="skipped-mobile"?"移动端跳过":"正常";j+=``}j+="
    插件阶段耗时(ms)状态
    ${C(k.name)}${k.startupMode==="idle"?"空闲":"即时"}${k.elapsed.toFixed(1)}${_}
    ",$("#plugin-timing-table").html(v+j)}else $("#plugin-timing-table").html(v+'

    暂无数据,刷新页面后自动采集。

    ');let b=t.errors.filter(S=>S.source==="legacy-plugin");if(b.length){let S='';for(let j of[...b].reverse())S+=``;S+="
    时间插件阶段错误信息
    ${C(String(j.timestamp||"").substring(11,19))}${C(j.plugin)}${C(j.phase)}${C(j.message)}
    ",$("#plugin-error-log").html(S)}else $("#plugin-error-log").text("无错误记录");let w=storageManager.getCacheHitStats();$("#cache-hit-stats").html(`
    命中: ${w.hits}未命中: ${w.misses}总计: ${w.total}命中率: ${w.rate}
    `)}d(xr,"renderPluginMgmtPanel");async function as(){let o=$("#health-data-display");if(o.length){o.text("体检中...");try{let e=await storageManager.inspectDataHealth(),t=e.fixable.reduce(((i,r)=>i+r.count),0),s=e.readonly.reduce(((i,r)=>i+r.count),0),a=d(i=>i.length?i.map((r=>`
  • ${C(r.message)}:${r.count}
  • `)).join(""):"
  • 无
  • ","i");o.html(` +
    +
    番号记录:${e.totals.carList}
    +
    收藏演员:${e.totals.favoriteActresses}
    +
    黑名单演员:${e.totals.blacklist}
    +
    黑名单作品:${e.totals.blacklistCarList}
    -
    - - +
    体检时间:${C(e.checkedAt)};可修复问题 ${t} 项,只读问题 ${s} 项。
    +
    +

    可安全修复

      ${a(e.fixable)}
    +

    仅报告

      ${a(e.readonly)}
    -
    - - + `;this.getRuntimeService("dialog").open({type:1,title:"演员黑名单",ui:{body:"table"},content:a,scrollbar:!1,area:utils.getDialogArea("xl"),anim:-1,success:d(async i=>{let r=$(i).find(".layui-layer-content > div").first().addClass("jhs-blacklist-layout").removeAttr("style");this.blacklistRoot=r;let n=r.children("div").first().addClass("jhs-blacklist-toolbar").removeAttr("style");n.children("div").addClass("jhs-blacklist-toolbar__group").removeAttr("style"),n.find("select,input,a").removeAttr("style"),r.find("#table-container").removeAttr("style"),r.find("#table-container").before('
    '),K.enhance(i),e||r.find("#checkBlacklistBtn").prop("disabled",!0).attr("title","后台任务功能已禁用"),this.renderTaskStatus(),this.taskStatusUnsubscribe?.(),this.taskStatusUnsubscribe=cc().on("task-status-changed",(()=>this.renderTaskStatus())),await this.loadTableData();let l=$(i).find(".layui-layer-content"),c=l.find("#searchValue");this.blacklistSearchDebounced=utils.debounce((()=>{this.reloadTable()}),200),l.on("click","#cleanQueryBtn",(async()=>{c.val(""),K.setValue(this.blacklistRoot.find("#dataType"),"",!1),K.setValue(this.blacklistRoot.find("#statusType"),"",!1),K.setValue(this.blacklistRoot.find("#urlType"),"",!1),await this.reloadTable()})).on("input","#searchValue",this.blacklistSearchDebounced).on("change","#dataType,#statusType,#urlType",(async()=>{await this.reloadTable()})).on("click","#toSetting",(()=>{this.getOptionalDependency("SettingPlugin")?.openSettingDialog?.("task-panel",(()=>{$("#setting-blacklist").css({border:"1px solid var(--jhs-status-filter)"})}))})).on("click",".open-url",(h=>{h.preventDefault();let u=$(h.currentTarget),p=u.attr("data-url"),g=u.attr("data-name");utils.openPage(p,g,!0,h)})).on("click","#checkBlacklistBtn",(h=>{let u=$(h.currentTarget),p=u.find("span").last(),g=p.text();!e||u.attr("aria-busy")==="true"||(u.attr("aria-busy","true").prop("disabled",!0),p.text("检测中…"),navigator.locks.request(e.singleTaskKey,{ifAvailable:!0},(async m=>{m?await e.checkBlacklist(!0):show.error("后台任务正在运行,请稍后再试")})).catch((m=>{clog.error("锁任务出现错误:",m),e.isNetworkBlocked(m)&&show.error(m.message||"任务执行失败")})).finally((()=>{u.removeAttr("aria-busy").prop("disabled",!1),p.text(g)})))}))},"success"),end:d(async()=>{this.blacklistRoot=null,this.blacklistSearchDebounced?.cancel?.(),this.blacklistSearchDebounced=null,this.taskStatusUnsubscribe?.(),this.taskStatusUnsubscribe=null,this.tableObj&&(this.tableObj.destroy(),this.tableObj=null),await cc().emit("blacklist-rules-changed")},"end")})}renderTaskStatus(){let e=(this.blacklistRoot||$()).find("#blacklist-task-status");if(!e.length)return;let t=this.getOptionalDependency("TaskPlugin");if(!t)return void e.empty().text("后台任务功能已禁用");let s=t.getTaskStatusSnapshot("blacklist"),a={idle:"正常",running:"运行中",pending:"等待下一次任务检查",due:"待运行"},i=d(n=>n?new Date(n).toLocaleString():"无","format"),r=s.state;e.empty().append($('').text(`黑名单:${a[r]||a.idle}`),$('').text(`上次完成 ${i(s.completedAt)};下次检查 ${s.nextAt?i(s.nextAt):"立即"}`))}async reloadTable(){if(!this.tableObj)return;let e=await this.getTableData();this.tableObj.setData(e)}async getTableData(){let e=this.blacklistRoot||$(),t=await storageManager.getBlacklist(),s=await storageManager.getBlacklistCarList(),a=String(e.find("#searchValue").val()||"").trim().toLocaleLowerCase(),i=e.find("#statusType").val(),r=e.find("#dataType"),n=r.val(),l=e.find("#urlType").val(),c=t.length,h=0,u=0,p=t.map(f=>{f.role===Me?h++:f.role===st&&u++;let v=!1;return v=pt(f.lastPublishTime,this.checkBlacklist_ruleTime),{...f,isUnCheck:v}}).filter(f=>{let v=Array.isArray(f.allName)?f.allName.join(" "):f.allName||"",b=`${f.name||""} ${v} ${f.starId||""}`.toLocaleLowerCase(),w=!a||b.includes(a),S=!i||i==="normal"&&!f.isUnCheck||i==="stop"&&f.isUnCheck,j=!n||f.role===n,k=f.url.includes("t=");return w&&S&&j&&(!l||l==="hasT"&&k||l==="noT"&&!k)});r.html(` + + + + `),K.setValue(r,n,!1);let g=new Map;for(let f of s){let v=f.starId;g.has(v)||g.set(v,[]),g.get(v).push(f)}let m=p.map(f=>{let v=f.starId,b=g.get(v)||[];return{...f,carList:b,count:b.length}});return this.currentCarCount=m.reduce(((f,v)=>f+(v.count||0)),0),m}async loadTableData(){this.checkBlacklist_ruleTime=ae(await storageManager.getSetting("checkBlacklist_ruleTime"),8760,{min:0});let e=await this.getTableData(),t=document.createElement("div");Oe(t,{type:"empty",title:"没有符合当前筛选条件的黑名单记录"});let s=(this.blacklistRoot||$()).find("#table-container").get(0);this.tableObj=ze(globalThis.Tabulator,s,{layout:"fitColumns",placeholder:t,virtualDom:!0,data:e,pagination:!0,paginationMode:"local",paginationSize:20,paginationSizeSelector:[20,50,100,1e3],paginationCounter:d((a,i,r,n,l)=>`演员: ${n}    番号总数: ${this.currentCarCount} `,"paginationCounter"),responsiveLayout:"collapse",responsiveLayoutCollapse:!0,columnDefaults:{headerHozAlign:"center",hozAlign:"center"},index:"starId",columns:[{title:"演员",field:"name",sorter:"string",minWidth:100,responsive:0,headerSort:!1,formatter:d((a,i,r)=>{let n=a.getData(),l=gt(n.url),c=document.createElement("a");return c.className="open-url",c.textContent=String(n.name||""),c.dataset.name=String(n.name||""),l?(c.href=l,c.dataset.url=l,c.target="_blank",c.rel="noopener noreferrer"):(c.href="#",c.setAttribute("aria-disabled","true")),c},"formatter")},{title:"性别角色",field:"role",sorter:"string",width:120,responsive:5,formatter:d((a,i,r)=>{let n=a.getData().role,l=n;return n===Me?l="男演员":n===st&&(l="女演员"),l},"formatter")},{title:"影视类别",field:"movieType",sorter:"string",width:120,responsive:5,formatter:d((a,i,r)=>{let n=a.getData().movieType,l=n;return n===Je?l="有码":n===Ke&&(l="无码"),l},"formatter")},{title:"屏蔽类型",field:"url",sorter:"string",minWidth:120,responsive:4,visible:N,formatter:d((a,i,r)=>{let n=a.getData().url.includes("t=");return`${n?"按所选分类屏蔽":"未筛选分类"}`},"formatter")},{title:"番号数量",field:"count",sorter:"number",width:170,responsive:1},{title:"创建时间",field:"createTime",sorter:"string",width:170,responsive:5},{title:"最后发行时间",field:"lastPublishTime",sorter:"string",width:170,responsive:1},{title:"状态",field:"isUnCheck",sorter:"string",width:120,responsive:1,formatter:d((a,i,r)=>{let n="",l="继续检测";return a.getData().isUnCheck&&(n=`停更${this.checkBlacklist_ruleTime/24/365}年以上, 下轮任务不再进行检测`,l="停更跳过"),`${l}`},"formatter")},{title:"操作",sorter:"string",cssClass:"action-cell-dropdown",minWidth:150,responsive:0,headerSort:!1,formatter:d((a,i,r)=>{let n=a.getData();return r((()=>{var l,c;(l=a.getElement().querySelector(".delete-btn"))==null||l.addEventListener("click",(h=>{let u=n.name,p=n.starId;u?p?utils.q(h,`是否移除对 ${C(u)} 的屏蔽?`,(async()=>{await storageManager.removeBlacklistCarList(p),await storageManager.deleteBlacklistItem(p),show.info("操作成功"),await this.reloadTable()})):show.error("获取starId失败"):show.error("获取名称失败")})),(c=a.getElement().querySelector(".keyword-btn"))==null||c.addEventListener("click",(h=>{let u=n.carList.reduce(((g,m)=>{let f=m.carNum.split("-")[0]+"-";return g[f]=(g[f]||0)+1,g}),{}),p=Object.entries(u).map((([g,m])=>({prefix:g,count:m}))).sort(((g,m)=>m.count-g.count));clog.debug(p)}))})),''},"formatter")}],initialSort:[{column:"createTime",dir:"desc"}]})}async filterAllVideo(e,{filter:t=this.getOptionalDependency("ListPagePlugin")?.activeQuickFilter||"waitCheck",confirm:s=!0,root:a=null}={}){let i=Te(t),r=Re[i],n=i==="all"?"将处理当前搜索全部分页的所有作品(包括屏蔽项)并加入黑名单。":`将处理当前搜索全部分页中符合「${r}」筛选的作品并加入黑名单。`;if(s&&!await new Promise(u=>utils.q(null,n,()=>u(!0),()=>u(!1))))return{cancelled:!0};let l=Fs();if(!l)return show.error("已有批量任务正在执行"),{cancelled:!0,busy:!0};let c=d(()=>(this.blacklistRoot||$()).find("#checkBlacklistMsg"),"statusHost");try{$("#favoriteAllVideo, #hasDownAllVideo, #filterAllVideo").attr("aria-disabled","true").addClass("jhs-batch-busy");let h=await this.getRuntimeService("scope")(),u=this.getOptionalDependency("ListPagePlugin"),p=typeof u?.createEvaluationContext=="function"?await u.createEvaluationContext():is({}),g=d(()=>!Dt(l)||!!h?.disposed,"isCancelled"),m=await Us({startDom:a?$(a):$(document),currentUrl:a?null:window.location.href,firstPageUrl:a?null:this.getRuntimeService("host")?.resolveFirstPageUrl?.(window.location.href)??window.location.href,itemSelector:this.getSelector().requestDomItemSelector,nextPageSelector:this.getSelector().nextPageSelector,fetchHtml:d(async b=>Ve(this.getRuntimeService("http"),b,h),"fetchHtml"),parseItem:d(b=>Ce(b),"parseItem"),evaluate:d(b=>rs({carNum:b.carNum,title:b.title||""},p,{filter:i}),"evaluate"),isCancelled:g,onProgress:d(({page:b,scanned:w,matched:S})=>{let j=c();j.length&&j.text(`正在扫描第 ${b} 页 · 已扫描 ${w} · 匹配 ${S} 个番号`)},"onProgress")});if(g())return{cancelled:!0};let f=0;for(let b=0;bS.carNum),{blocked:!0},{type:"actor-page-block",records:w.map(S=>({carNum:S.carNum,url:S.url||"",names:e,publishTime:S.publishTime||"",fc2Source:S.fc2Source}))}),f+=w.length,clog.log("一键屏蔽进度",`已屏蔽 ${f}/${m.length} 个番号`)}let v=c();return v.length&&v.text(`处理完成 · 共屏蔽 ${f} 个番号`),{matched:m.length,updated:f}}finally{Dt(l)&&Vs(l),$("#favoriteAllVideo, #hasDownAllVideo, #filterAllVideo").removeAttr("aria-disabled").removeClass("jhs-batch-busy")}}async filterActorVideo(e,t,s,a=B?je:Be,i=1,r=0,n=new Set){let{nextPageLink:l,recordCount:c}=await this.parseAndSaveFilterInfo(s,e,t,a);if(r+=c,(this.blacklistRoot||$()).find("#checkBlacklistMsg").text(`正在处理第 ${i} 页 · 已屏蔽 ${r} 个番号`),this.nextPageLink=l,l&&i<200&&!n.has(l)){n.add(l);let h;this.lastPageLink=l,clog.log("正在请求下一页内容:",l);let u=await this.getRuntimeService("scope")(),p=await Ve(this.getRuntimeService("http"),l,u);h=utils.htmlTo$dom(p),await this.filterActorVideo(e,t,h,a,i+1,r,n)}else i>=200?((this.blacklistRoot||$()).find("#checkBlacklistMsg").text(`已达到页数上限(200 页),已停止 · 已屏蔽 ${r} 个番号`),clog.warn("演员视频分页扫描已达上限,已停止")):(this.blacklistRoot||$()).find("#checkBlacklistMsg").text(`处理完成 · ${i} 页 · 新增 ${r} 个番号`)}async parseAndSaveFilterInfo(e,t,s,a){if(![Be,je].includes(a))throw new Error(`未知黑名单来源站点: ${a}`);let i=e||$(document),r=this.getSelector(a);a===je&&i.find(".avatar-box").length>0&&i.find(".avatar-box").parent().remove();let n=Hs(i,{boxSelector:a===je?`${r.boxSelector}, #waterfall`:r.boxSelector,requestDomItemSelector:e?r.requestDomItemSelector:r.itemSelector}),l=i.find(r.nextPageSelector).attr("href");if(n.state!=="valid")throw new Error(`黑名单作品页面无效: ${n.state}`);if(n.isEmpty&&l)throw new Error("黑名单作品空页面包含下一页");let c=[],h=[];for(let u of n.items){let p=$(u),{carNum:g,url:m,publishTime:f}=Ce(p);f&&h.push(f),m&&g&&c.push({carNum:g,url:m,names:t,actionType:ce,starId:s,publishTime:f})}return await storageManager.batchSaveBlacklistCarList(c),{nextPageLink:l,lastPublishTime:Kt(h),recordCount:c.length}}};d(_r,"BlacklistPlugin");var qs=_r;var $r=class $r extends I{getName(){return"FilterTitleKeywordPlugin"}async handle(){isDetailPage&&await this.bindDetailRoot(document)}async bindDetailRoot(e,{layerIndex:t=null}={}){if(await storageManager.getSetting("enableTitleSelectFilter",R)!==R)return;let s=$(e),a=N?".title strong, .current-title":B?"h3":".current-title, .origin-title, .jhs-detail-title";s.off("contextmenu.jhsTitleFilter",a).on("contextmenu.jhsTitleFilter",a,(i=>{let r=window.getSelection()?.toString()||"";if(r){i.preventDefault();let n={clientX:i.clientX,clientY:i.clientY+80};utils.q(n,`是否屏蔽标题关键词 ${C(r)}?`,(async()=>{let l=r.replace(/\r?\n+/g," ").replace(/\s{2,}/g," ").trim().slice(0,120);l&&await storageManager.saveTitleFilterKeyword(l),await F?.emit("filter-rules-changed",{scope:"title-keyword"}),utils.closePage({root:s,layerIndex:t})}))}}))}};d($r,"FilterTitleKeywordPlugin");var Ws=$r;function Js(o,{retry:e=null,tone:t="neutral",extraClass:s=""}={}){let a=globalThis.$,i=a('
    ').text(o);return s&&i.addClass(s),t==="error"&&i.addClass("is-error"),e&&i.append(" ",a('').on("click",e)),i}d(Js,"createPanelState");function dc(o="正在加载…"){return Js(o)}d(dc,"createPanelLoading");function hc(o,e=null){return Js(o,{retry:e,tone:"error"})}d(hc,"createPanelError");function Tr(o){try{let e=new URL(String(o||""));return["http:","https:"].includes(e.protocol)?e.href:null}catch{return null}}d(Tr,"normalizeUrl");function he(o,e,t=null){let s=globalThis.$,a=s(o).empty(),i=Js(e,{retry:t,tone:t?"error":"neutral",extraClass:"jhs-fc2-state"});a.append(i)}d(he,"renderFc2State");function ns(o,e,t=null){let s=globalThis.$,a=[...new Set((e||[]).map(Tr).filter(Boolean))],i=o.root.find('[data-jhs-role="gallery-grid"]').empty(),r=o.root.find('[data-jhs-role="main-preview"]').empty();o.galleryUrls=new Set(a);let n=Tr(t);if(n&&r.append(s("").attr({src:n,alt:`${o.carNum} 概览`,loading:"eager"})),!a.length)return he(i,"暂无剧照");a.forEach((c,h)=>i.append(s('').attr("aria-label",`查看剧照 ${h+1}`).append(s("").addClass("jhs-fc2-gallery__image").attr({src:c,alt:`剧照 ${h+1}`,loading:"lazy"}))));let l=o.root.find('[data-jhs-role="screenshot"]');l.find("img").each((c,h)=>{let u=h.getAttribute("src");u&&o.galleryUrls.has(u)&&l.empty()})}d(ns,"renderFc2Gallery");function Ks(o,e){let t=globalThis.$,s=t(''),a=e.sourceUrls({carNum:o.carNum},["fc2ppvdb","fc2content"]);return[[o.source==="123av"?"123AV 原页面":"JavDB 原页面",Tr(o.url)],...a.map(r=>[r.providerId==="fc2ppvdb"?"FC2PPVDB":"FC2 市场",r.url])].forEach(([r,n])=>n&&s.append(t("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({href:n,target:"_blank",rel:"noopener noreferrer"}).text(r))),s}d(Ks,"createFc2SourceLinks");var Ar=class Ar extends I{constructor(){super(...arguments),this.$contentBox=null,this.$listRoot=null,this.urlParams=new URLSearchParams(window.location.search),this.currentPage=Math.max(1,parseInt(this.urlParams.get("page")||"1",10)||1),this.maxPage=null,this._queryGeneration=0,this.keyword=this.urlParams.get("keyword")||null}getName(){return"Fc2By123AvPlugin"}async resolveMovieId(e){let t=await this.getRuntimeService("scope")();return(await this.getRuntimeService("movie").resolve({carNum:e},{scope:t}))?.movieId||null}async handle(){$("#navbar-menu-hero > div > div:nth-child(1) > div > a:nth-child(4)").after('123Av-Fc2'),$('.tabs li:contains("FC2")').after('
  • 123Av-Fc2
  • '),Y.includes("/advanced_search?type=100")&&(this.hookPage(),await this.handleQuery())}hookPage(){let e=this.getRuntimeService("host"),t=e.locateListRoot?.(),s=e.getListContainer?.();if(!t||!s)throw new Error("JavDB 列表容器不可用");this.$contentBox=$(s),this.$listRoot=$(e.createOwnedListRoot(["jhs-123av-list","jhs-layout-d2c171b1"]));let a=$("h2.section-title");a.contents().first().replaceWith("123Av"),a.css("marginBottom","0"),a.append(` +
    + + + +
    + `),$("#search-123av-keyword").val(this.keyword),$("#search-123av-btn").on("click",(async()=>{let i=String($("#search-123av-keyword").val()||"").trim();i&&(this.keyword=i,utils.setHrefParam("keyword",i),this.currentPage=1,this.maxPage=null,utils.setHrefParam("page",1),await this.handleQuery())})),$("#clear-123av-btn").on("click",(async()=>{$("#search-123av-keyword").val(""),this.keyword="",utils.setHrefParam("keyword",""),this.currentPage=1,this.maxPage=null,utils.setHrefParam("page",1),$(".page-box").show(),await this.handleQuery()})),$(".empty-message").remove(),$("#foldCategoryBtn").remove(),this.$contentBox.children(".box").remove(),$("#sort-toggle-btn").remove(),this.$contentBox.append(this.$listRoot),this.$contentBox.append('
    '),utils.setHrefParam("page",this.currentPage),$(".page-box").append(` + + `),$(document).on("click",".pagination-link",(i=>{i.preventDefault(),this.currentPage=parseInt($(i.target).data("page")),utils.setHrefParam("page",this.currentPage),this.renderPagination(),this.handleQuery()})),$(".pagination-previous").on("click",(i=>{i.preventDefault(),this.currentPage>1&&(this.currentPage--,utils.setHrefParam("page",this.currentPage),this.renderPagination(),this.handleQuery())})),$(".pagination-next").on("click",(i=>{i.preventDefault(),this.currentPage<(this.maxPage??0)&&(this.currentPage++,utils.setHrefParam("page",this.currentPage),this.renderPagination(),this.handleQuery())}))}renderPagination(){let e=$(".pagination-list");e.empty();let t=this.maxPage??1,s=Math.max(1,this.currentPage-2),a=Math.min(t,this.currentPage+2);this.currentPage<=3?a=Math.min(6,t):this.currentPage>=t-2&&(s=Math.max(t-5,1)),s>1&&(e.append('
  • '),s>2&&e.append('
  • …
  • '));for(let i=s;i<=a;i++){let r=i===this.currentPage?" is-current":"";e.append(`
  • `)}a…'),e.append(`
  • `))}async handleQuery(){let e=loading(),t=++this._queryGeneration;try{$(".page-box").show();let s=await this.getRuntimeService("scope")();if(t!==this._queryGeneration)return;let a=await this.getRuntimeService("movie").catalog("av123",{page:this.currentPage,keyword:this.keyword||""},{scope:s});if(t!==this._queryGeneration)return;let i=a.items;this.maxPage=Math.max(1,Number(a.maxPage)||1),this.currentPage=Math.min(Math.max(1,this.currentPage),this.maxPage),this.renderPagination(),i.length===0&&(clog.log(i),show.error("无结果"),clog.error("123AV 获取数据失败"));let r=this.markDataListHtml(i);this.$listRoot?.html(r),await utils.smoothScrollToTop()}catch(s){clog.error(s)}finally{e.close()}}async loadDetail(e,t){let s=this.loadSummary(e,t),a=this.getImgList(e.carNum),i=this.getActressInfo(e.carNum);a.then((r=>e.isAlive()&&ns(e,r,null))).catch((r=>e.isAlive()&&he(e.root.find('[data-jhs-role="gallery-grid"]'),"剧照加载失败",(()=>{this.reloadImages(e)})))),i.then((async r=>{await s.catch((()=>null)),e.isAlive()&&this.render123AvActress(e,r)})).catch((r=>clog.error("FC2 演员信息加载失败",r))),await Promise.allSettled([s,a,i])}async loadSummary(e,t){try{let s=await this.get123AvVideoInfo(e.carNum,t);return e.isAlive()?(this.render123AvSummary(e,s),s):null}catch(s){throw e.isAlive()&&he(e.root.find('[data-jhs-role="summary-content"]'),"影片信息加载失败",(()=>{this.loadSummary(e,t)})),clog.error("123AV 详情加载失败",s),s}}render123AvSummary(e,t){let s=e.root.find('[data-jhs-role="summary-content"]').empty(),a=$('

    ');a.find("strong").text(t.title||"无标题"),s.append(a,$('
    ').append($("").text(`番号:${e.carNum}`),$("").text(`发行:${t.publishDate||"未知"}`)),'
    主演:正在加载演员…
    ','
    ',Ks(e,this.getRuntimeService("movie")),$('').text(t.publishDate||""))}async get123AvVideoInfo(e,t){let s=await this.getRuntimeService("scope")(),a=await this.getRuntimeService("movie").detail({carNum:e,url:t,providerId:"av123"},{scope:s});return{title:a?.title||"",publishDate:a?.releaseDate||"",moviePoster:null}}async getActressInfo(e){let t=await this.getRuntimeService("scope")();return this.getRuntimeService("movie").people("fc2ppvdb",{carNum:e},{scope:t})}async getImgList(e){let t=await this.getRuntimeService("scope")();return(await this.getRuntimeService("movie").images("fc2content",{carNum:e},{scope:t})).map((s=>s.url))}async reloadImages(e){try{let t=await this.getImgList(e.carNum);e.isAlive()&&ns(e,t,null)}catch{e.isAlive()&&he(e.root.find('[data-jhs-role="gallery-grid"]'),"剧照加载失败",(()=>{this.reloadImages(e)}))}}render123AvActress(e,t){let s=e.root.find('[data-jhs-role="actors"]').empty().append("主演:");t.actors.length?t.actors.forEach((a=>s.append($("").addClass("jhs-fc2-actor").attr({href:a.url,target:"_blank",rel:"noopener noreferrer"}).text(a.name)))):s.append($("").text("暂无演员信息")),e.root.find('[data-jhs-role="actress-data"]').remove(),e.root.find(".jhs-fc2-summary__body").append($('').text(t.actors.map((a=>a.name)).join(" "))),t.seller&&e.root.find('[data-jhs-role="seller"]').empty().append("卖家:",t.seller.url?$("").attr({href:t.seller.url,target:"_blank",rel:"noopener noreferrer"}).text(t.seller.name):document.createTextNode(t.seller.name))}markDataListHtml(e){let t="";return e.forEach((s=>{let a=s.url,i=s.imageUrl;a&&(t+=` - `; - })), t2; - } - }; - __name(_Fc2By123AvPlugin, "Fc2By123AvPlugin"); - var Fc2By123AvPlugin = _Fc2By123AvPlugin; - var MAGNET_SOURCE_IDS = Object.freeze(["native-javdb", "native-javbus", "u9a9", "u3c3", "sukebei", "btsow"]); - function normalizeMagnetResult(result, source) { - if (!result || !String(result.magnet || "").startsWith("magnet:")) return null; - return { title: String(result.title || ""), magnet: result.magnet, size: result.size || "", date: result.date || "", seeders: Number(result.seeders) || 0, leechers: Number(result.leechers) || 0, source, files: Array.isArray(result.files) ? result.files : [] }; - } - __name(normalizeMagnetResult, "normalizeMagnetResult"); - function extractInfoHash(magnet) { - const hash = new URL(magnet).searchParams.get("xt")?.match(/^urn:btih:([a-z2-7]{32}|[a-f\d]{40})$/i)?.[1]; - return hash ? hash.toUpperCase() : null; - } - __name(extractInfoHash, "extractInfoHash"); - function deduplicateMagnetResults(results) { - const unique = /* @__PURE__ */ new Map(); - results.forEach(((result) => { - const key = extractInfoHash(result.magnet) || `${result.source}:${result.magnet}`; - const existing = unique.get(key); - existing ? existing.sources = [.../* @__PURE__ */ new Set([...existing.sources || [existing.source], result.source])] : unique.set(key, { ...result, sources: [result.source] }); - })); - return [...unique.values()]; - } - __name(deduplicateMagnetResults, "deduplicateMagnetResults"); - var _MagnetSourceRegistry = class _MagnetSourceRegistry { - constructor(sources = []) { - this.sources = /* @__PURE__ */ new Map(); - sources.forEach(((source) => this.register(source))); - } - register(source) { - if (!source?.id || !source.name || "function" !== typeof source.search || "function" !== typeof source.targetUrl) throw new TypeError("Invalid magnet provider"); - this.sources.set(source.id, { enabled: true, priority: 100, ...source }); - return this; - } - get(id) { - return this.sources.get(id) || null; - } - getEnabledSources() { - return [...this.sources.values()].filter(((source) => source.enabled)).sort(((a2, b2) => a2.priority - b2.priority)); - } - }; - __name(_MagnetSourceRegistry, "MagnetSourceRegistry"); - var MagnetSourceRegistry = _MagnetSourceRegistry; - function validateHttpsBaseUrl(value) { - const url = new URL(value); - if ("https:" !== url.protocol) throw new TypeError("Source URL must use https"); - return url.origin; - } - __name(validateHttpsBaseUrl, "validateHttpsBaseUrl"); - function validateCustomMagnetSource(config) { - const allowed = ["id", "name", "enabled", "priority", "searchUrlTemplate", "targetUrlTemplate", "parserType", "rowSelector", "titleSelector", "magnetSelector", "sizeSelector", "dateSelector", "seedersSelector", "leechersSelector", "resultsPath", "titlePath", "hashPath", "magnetPath", "sizePath", "datePath", "seedersPath"]; - if (Object.keys(config).some(((key) => !allowed.includes(key)))) throw new TypeError("Unsupported custom source field"); - if (!["torrent-table", "magnet-links", "json"].includes(config.parserType)) throw new TypeError("Unsupported parser type"); - if (!String(config.name || "").trim()) throw new TypeError("Source name is required"); - validateHttpsBaseUrl(config.searchUrlTemplate.replace("{keyword}", "test")); - validateHttpsBaseUrl((config.targetUrlTemplate || config.searchUrlTemplate).replace("{keyword}", "test")); - if ("torrent-table" === config.parserType && (!config.rowSelector?.trim() || !config.magnetSelector?.trim())) throw new TypeError("表格来源必须填写结果行选择器和磁力选择器"); - if ("json" === config.parserType && (!config.resultsPath?.trim() || !config.magnetPath?.trim() && !config.hashPath?.trim())) throw new TypeError("JSON 来源必须填写结果数组路径,并填写磁力路径或哈希路径"); - return { ...config, name: config.name.trim(), targetUrlTemplate: config.targetUrlTemplate || config.searchUrlTemplate }; - } - __name(validateCustomMagnetSource, "validateCustomMagnetSource"); - function applyMagnetRules(result, tagRules = [], titleFilters = [], fileFilters = []) { - const text = `${result.title || ""} ${(result.files || []).join(" ")}`; - const matches = /* @__PURE__ */ __name((rule, value) => "regex" === rule.type ? new RegExp(rule.pattern, "i").test(value) : value.toLowerCase().includes(rule.pattern.toLowerCase()), "matches"); - const tags = tagRules.filter(((rule) => rule.enabled && matches(rule, text))); - let hidden = false, penalty = 0; - const filteredReasons = []; - [...titleFilters.map(((rule) => ({ ...rule, value: result.title || "" }))), ...fileFilters.map(((rule) => ({ ...rule, value: (result.files || []).join(" ") })))].filter(((rule) => rule.enabled)).forEach(((rule) => { - if (!matches(rule, rule.value)) return; - filteredReasons.push(rule.id || rule.pattern); - "hide" === rule.action ? hidden = true : penalty += Number(rule.penalty) || 0; - })); - return { ...result, tags: tags.map(((rule) => rule.name)), customTagWeight: tags.reduce(((sum, rule) => sum + (Number(rule.weight) || 0)), 0), hidden, filterPenalty: penalty, filteredReasons }; - } - __name(applyMagnetRules, "applyMagnetRules"); - function parseNativeMagnets(root, source) { - const results = /* @__PURE__ */ new Map(); - $(root).find('a[href^="magnet:"],[data-clipboard-text^="magnet:"]').each(((index, element) => { - const node = $(element), magnet = node.attr("href") || node.attr("data-clipboard-text"); - if (!magnet) return; - const container = node.closest(".item, .magnet-name, tr, .panel-block"), title = container.find(".name, .magnet-name, .title").first().text().trim() || node.text().trim() || "本站磁力"; - const result = normalizeMagnetResult({ title, magnet, size: container.find(".meta, .size").first().text().trim() }, source); - result && results.set(extractInfoHash(magnet) || magnet, result); - })); - return [...results.values()]; - } - __name(parseNativeMagnets, "parseNativeMagnets"); - function readJsonPath(value, path) { - return String(path || "").split(".").filter(Boolean).reduce(((current, key) => current?.[key]), value); - } - __name(readJsonPath, "readJsonPath"); - function parseCustomMagnetResponse(config, payload, sourceId) { - config = validateCustomMagnetSource(config); - if ("json" === config.parserType) return (readJsonPath(payload, config.resultsPath) || []).map(((item) => normalizeMagnetResult({ title: readJsonPath(item, config.titlePath), magnet: config.magnetPath ? readJsonPath(item, config.magnetPath) : `magnet:?xt=urn:btih:${readJsonPath(item, config.hashPath)}`, size: readJsonPath(item, config.sizePath), date: readJsonPath(item, config.datePath), seeders: readJsonPath(item, config.seedersPath) }, `custom:${sourceId}`))).filter(Boolean); - const root = utils.htmlTo$dom(payload), rows = "magnet-links" === config.parserType ? root.find('a[href^="magnet:"]') : root.find(config.rowSelector); - return rows.map(((index, element) => { - const row = $(element), magnetNode = "magnet-links" === config.parserType ? row : row.find(config.magnetSelector).first(); - return normalizeMagnetResult({ title: "magnet-links" === config.parserType ? row.text().trim() : row.find(config.titleSelector).first().text().trim(), magnet: magnetNode.attr("href") || magnetNode.attr("data-magnet"), size: row.find(config.sizeSelector).text().trim(), date: row.find(config.dateSelector).text().trim(), seeders: row.find(config.seedersSelector).text().trim(), leechers: row.find(config.leechersSelector).text().trim() }, `custom:${sourceId}`); - })).get().filter(Boolean); - } - __name(parseCustomMagnetResponse, "parseCustomMagnetResponse"); - function calcMagnetScore(e2) { - let t2 = 0; - const n2 = e2.seeders || 0; - const seedersScore = n2 >= 50 ? 35 : n2 >= 10 ? 25 : n2 >= 1 ? 15 : 3; - t2 += seedersScore; - const a2 = (e2.title || "").toLowerCase(), resolution = String(e2.resolution || "").toLowerCase(); - const resolutionScore = /4k|2160p/.test(resolution) || /4k|2160p/.test(a2) ? 25 : /1080p/.test(resolution) || /1080p/.test(a2) ? 20 : /720p/.test(resolution) || /720p/.test(a2) ? 15 : 5; - t2 += resolutionScore; - const subtitleScore = e2.hasSubtitle || /-c\b|-uc\b|chinese|中字|字幕/.test(a2) ? 20 : 0; - t2 += subtitleScore; - const i2 = e2.date ? _daysSince(e2.date) : 999; - const freshnessScore = i2 <= 7 ? 15 : i2 <= 30 ? 12 : i2 <= 90 ? 8 : 3; - t2 += freshnessScore; - const completenessScore = /sample|预告|trailer/.test(a2) ? -15 : 0; - t2 += completenessScore; - return { total: Math.max(0, Math.min(100, t2)), seeders: seedersScore, resolution: resolutionScore, subtitle: subtitleScore, freshness: freshnessScore, completeness: completenessScore }; - } - __name(calcMagnetScore, "calcMagnetScore"); - function _daysSince(e2) { - try { - const t2 = new Date(e2); - if (isNaN(t2.getTime())) return 999; - return Math.max(0, Math.floor((Date.now() - t2.getTime()) / 864e5)); - } catch (t2) { - return 999; - } - } - __name(_daysSince, "_daysSince"); - var _MagnetHubPlugin = class _MagnetHubPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "sourceRegistry", new MagnetSourceRegistry()), i(this, "searchEngines", []); - } - async initializeSources() { - const settings = new ResourceSettingsService(), overrides = await settings.getBuiltInSources(), custom = await settings.getMagnetSources(); - const configured = /* @__PURE__ */ __name((id) => ({ ...BUILT_IN_MAGNET_SOURCES.find(((source) => source.id === id)) || {}, ...overrides.find(((source) => source.id === id)) || {} }), "configured"); - const baseUrl = /* @__PURE__ */ __name((id, fallback) => String(configured(id).baseUrl || fallback).replace(/\/$/, ""), "baseUrl"); - this.sourceRegistry = new MagnetSourceRegistry([ - { - name: "JavDB 本站", - id: "native-javdb", - applicable: r, - enabled: r, - priority: 1, - search: /* @__PURE__ */ __name(async (keyword, root = document) => parseNativeMagnets(root?.jquery ? root[0] : root, "javdb"), "search"), - targetUrl: /* @__PURE__ */ __name(() => window.location.href, "targetUrl") - }, - { - name: "JavBus 本站", - id: "native-javbus", - applicable: l, - enabled: l, - priority: 2, - search: /* @__PURE__ */ __name(async (keyword, root = document) => parseNativeMagnets(root?.jquery ? root[0] : root, "javbus"), "search"), - targetUrl: /* @__PURE__ */ __name(() => window.location.href, "targetUrl") - }, - { - name: "U9A9", - id: "u9a9", - url: "https://u9a9.com/?type=2&search={keyword}", - targetPage: "https://u9a9.com/?type=2&search={keyword}", - priority: 10, - search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("u9a9", `${baseUrl("u9a9", "https://u9a9.com")}/?type=2&search={keyword}`, keyword), "search"), - targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("u9a9", "https://u9a9.com")}/?type=2&search=${encodeURIComponent(keyword)}`, "targetUrl") - }, - { - name: "U3C3", - id: "u3c3", - url: "https://u3c3.com/?search2=a8lr16lo&search={keyword}", - targetPage: "https://u3c3.com/?search2=a8lr16lo&search={keyword}", - priority: 20, - search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("u3c3", `${baseUrl("u3c3", "https://u3c3.com")}/?search2=a8lr16lo&search={keyword}`, keyword), "search"), - targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("u3c3", "https://u3c3.com")}/?search2=a8lr16lo&search=${encodeURIComponent(keyword)}`, "targetUrl") - }, - { - name: "Sukebei", - id: "sukebei", - url: "https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}", - targetPage: "https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}", - priority: 30, - search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("sukebei", `${baseUrl("sukebei", "https://sukebei.nyaa.si")}/?f=0&c=0_0&q={keyword}`, keyword), "search"), - targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("sukebei", "https://sukebei.nyaa.si")}/?f=0&c=0_0&q=${encodeURIComponent(keyword)}`, "targetUrl") - }, - { name: "BTSOW", id: "btsow", priority: 40, search: /* @__PURE__ */ __name((keyword) => this.searchBtsow(keyword, baseUrl("btsow", "https://btsow.lol")), "search"), targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("btsow", "https://btsow.lol")}/search/${encodeURIComponent(keyword)}`, "targetUrl") } - ].map(((source) => { - const config = configured(source.id), applicable = source.applicable ?? true; - return { ...source, ...config, enabled: applicable && (config.enabled ?? source.enabled ?? true), search: source.search, targetUrl: source.targetUrl }; - }))); - custom.filter(((source) => source.enabled)).forEach(((config) => this.sourceRegistry.register({ ...config, id: `custom:${config.id}`, search: /* @__PURE__ */ __name((keyword) => this.searchCustomSource(config, keyword), "search"), targetUrl: /* @__PURE__ */ __name((keyword) => config.targetUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)), "targetUrl") }))); - const enabled = this.sourceRegistry.getEnabledSources().map(((source) => ({ ...source, targetPage: source.targetUrl("{keyword}").replace("%7Bkeyword%7D", "{keyword}") }))); - this.searchEngines = enabled.length ? [{ id: "all", name: "全部", priority: 0, targetPage: "#", search: /* @__PURE__ */ __name((keyword) => this.searchAllSources(enabled, keyword), "search") }, ...enabled] : []; - } - getName() { - return "MagnetHubPlugin"; - } - async initCss() { - return ``}async handle(){let e="/advanced_search?type=3&score_min=0&d=1";if($('.navbar-item:contains("FC2")').attr("href",e),$('.tabs a:contains("FC2")').attr("href",e),Y.includes("advanced_search?type=3")&&($("h2.section-title").contents().first().replaceWith("Fc2PPV"),$(".section .container > .box").remove()),!Y.includes("collection_codes?movieId"))return;let t=new URLSearchParams(window.location.search),s=t.get("movieId"),a=t.get("carNum"),i=t.get("url"),r=t.get("source"),n=$("section").first().empty();if(!a||!i)return void n.append($('
    ').text("FC2 详情参数不完整"));let l=s&&s!=="search"?s:await this.resolveMovieIdForRecord(a,i),c=["fc2","123av"].includes(r||"")?r:await this.resolveFc2Source({url:i}),h=this.mountFc2Detail(n,{movieId:l,carNum:a,url:i,source:c,mode:"page"});$(window).off("pagehide.jhsFc2Detail").one("pagehide.jhsFc2Detail",(()=>h.destroy()))}openFc2Dialog(e,t,s,{source:a=""}={}){a=["fc2","123av"].includes(a)?a:"";let i=null;return this.getRuntimeService("dialog").open({type:1,title:t,content:'
    ',ui:{size:"workspace",body:"scroll"},area:utils.getDialogArea("workspace"),skin:"movie-detail-layer",scrollbar:!1,shadeClose:!0,success:d((r,n)=>{i=this.mountFc2Detail($(r).find(".jhs-fc2-dialog-host"),{movieId:e,carNum:t,url:s,source:a,layerIndex:n,mode:"dialog"}),utils.setupEscClose(n)},"success"),end:d(()=>i?.destroy(),"end")})}mountFc2Detail(e,t){let s=$(e);s.data("jhsFc2Context")?.destroy?.(),s.empty();let i=bc(t).appendTo(s),r=wc(i,t);return s.data("jhsFc2Context",r),r.translationGeneration=0,r.screenshotGeneration=0,r.otherSiteGeneration=0,this.initializeWorkspace(r),this.bindFc2FeatureLifecycle(r),r}initializeWorkspace(e){let t=$('
    正在加载影片信息…
    '),s=$('');s.append(Nt()),s.append('','',''),t.find(".jhs-fc2-summary__body").append(s),e.getSlot("summary").append(t);let a=$(''),i=$('
    ');a.on(`click${e.namespace}`,".jhs-fc2-gallery-item",(f=>{let v=$(f.currentTarget).find("img")[0];v&&globalThis.showImageViewer(v,"",{galleryRoot:a[0]})})),e.getSlot("gallery").append(a,i);let r=$('
    '),n=this.createResourceGroup("站内磁力","native-magnets"),l=this.createResourceGroup("第三方站点","other-sites"),c=this.createResourceGroup("更多磁力来源","magnet-hub"),h=$(''),u=null;c.find('[data-jhs-role="magnet-hub"]').append(h,'
    '),r.append(n,l,c),e.getSlot("resources").append(r),s.on(`click${e.namespace}`,'[data-jhs-action="subtitlecat"]',(f=>{let v=this.getRuntimeService("movie").sourceUrls({carNum:e.carNum},["subtitlecat"])[0]?.url;v&&utils.openPage(v,e.carNum,!1,f)}));let p=this.getOptionalDependency("DetailPageButtonPlugin");p?s.on(`click${e.namespace}`,'[data-jhs-action="xunlei"]',(()=>p.searchXunLeiSubtitle(e.carNum))):s.find('[data-jhs-action="xunlei"]').remove();let g=this.getOptionalDependency("MagnetHubPlugin");g||c.remove(),h.on(`click${e.namespace}`,(async()=>{if(!g||!e.isAlive())return;let f=c.find('[data-jhs-role="magnet-hub-content"]');if(h.attr("aria-expanded")==="true")return c.addClass("is-collapsed"),void h.attr("aria-expanded","false").text("展开磁力搜索");u||(u=g.createMagnetHub({movieContext:e.movieContext,root:e.root}));let b=await u;e.isAlive()&&!f.children().length&&f.append(b),e.isAlive()&&(c.removeClass("is-collapsed"),h.attr("aria-expanded","true").text("收起磁力搜索"),f[0]?.scrollIntoView?.({block:"nearest"}))})),this.getDetailStateController().bind({root:e.root,layerIndex:e.layerIndex??null,carNum:e.carNum,activityType:"fc2-state",getRecord:d(()=>({carNum:e.carNum,url:e.url,fc2Source:e.source,names:e.root.find('[data-jhs-role="actress-data"]').text(),publishTime:e.root.find('[data-jhs-role="publish-time"]').text()}),"getRecord")}),e.source==="123av"?this.load123AvDetail(e):this.loadNativeDetail(e);let m=this.getOptionalDependency("FilterTitleKeywordPlugin");m&&Promise.resolve().then((()=>m.bindDetailRoot(e.root,{layerIndex:e.layerIndex??null}))).catch((f=>clog.error("FC2 关键词过滤初始化失败",f))),this.mountFc2OtherSites(e,l,this.getOptionalDependency("OtherSitePlugin")),this.loadFc2Screenshot(e)}bindFc2FeatureLifecycle(e){let t=this.getRuntimeService("settings"),s=d(a=>{let i=a.detail?.names;if(i?.length&&(i.includes("enableLoadScreenShot")&&(t.snapshot().enableLoadScreenShot==="no"?(e.screenshotGeneration=(e.screenshotGeneration||0)+1,this.unmountFc2Screenshot(e)):this.loadFc2Screenshot(e)),i.includes("translateTitle")&&((t.snapshot().translateTitle??R)===R?this.applyFc2Translation(e):(e.translationGeneration=(e.translationGeneration||0)+1,this.revertFc2Translation(e))),i.includes("enableMagnetsFilter")&&e.magnetFilterApply?.((t.snapshot().enableMagnetsFilter??R)===R),i.includes("enableLoadOtherSite"))){let r=e.getSection("resources").find('[data-jhs-role="other-sites"]').closest(".jhs-fc2-resource-group");t.snapshot().enableLoadOtherSite==="no"?(e.otherSiteGeneration=(e.otherSiteGeneration||0)+1,this.unmountFc2OtherSites(e,r)):this.mountFc2OtherSites(e,r,this.getOptionalDependency("OtherSitePlugin"))}},"handler");t.addEventListener("settings.changed",s),e.addObserver({disconnect:d(()=>t.removeEventListener("settings.changed",s),"disconnect")})}loadFc2Screenshot(e){let t=this.getRuntimeService("screenshot"),s=this.getRuntimeService("settings"),a=e.root.find('[data-jhs-role="screenshot"]');if(!t.isEnabled(s.snapshot()))return void a.empty();let i=(e.screenshotGeneration||0)+1;e.screenshotGeneration=i,Promise.resolve().then((()=>this.getRuntimeService("scope")())).then(r=>Zs({target:a,carNum:e.carNum.replace("FC2-",""),screenshot:t,settings:s.snapshot(),scope:r,isActive:d(()=>e.isAlive()&&s.snapshot().enableLoadScreenShot!=="no"&&i===e.screenshotGeneration,"isActive"),isDuplicate:d(n=>!!e.galleryUrls?.has(n),"isDuplicate")})).then(r=>{if(!(!e.isAlive()||i!==e.screenshotGeneration)&&s.snapshot().enableLoadScreenShot==="no")return void a.empty()}).catch(r=>{!e.isAlive()||i!==e.screenshotGeneration||(a.empty(),clog.error("FC2 剧照初始化失败",r))})}unmountFc2Screenshot(e){e.screenshotGeneration=(e.screenshotGeneration||0)+1,e.root.find('[data-jhs-role="screenshot"]').empty()}applyFc2Translation(e){let t=this.getRuntimeService("settings");if(!e.isAlive()||(t.snapshot().translateTitle??R)!==R)return;let s=(e.translationGeneration||0)+1;e.translationGeneration=s,Promise.resolve().then((()=>this.getRuntimeService("scope")())).then(a=>Ys({root:e.root,carNum:e.carNum,translation:this.getRuntimeService("translation"),scope:a,isActive:d(()=>e.isAlive()&&(t.snapshot().translateTitle??R)===R&&s===e.translationGeneration,"isActive")})).catch((a=>clog.error("FC2 标题翻译失败",a)))}revertFc2Translation(e){e.root.find(".translated-title").remove()}mountFc2OtherSites(e,t,s){if(!s)return void t.remove();let a=this.getRuntimeService("settings"),i=(e.otherSiteGeneration||0)+1;e.otherSiteGeneration=i,t.length&&t.show(),Promise.resolve().then((()=>s.loadOtherSite(e.carNum.replace("FC2-",""),e.carNum,{root:e.root,target:t.find('[data-jhs-role="other-sites"]'),autoDetect:!1,isActive:d(()=>e.isAlive()&&a.snapshot().enableLoadOtherSite!=="no"&&i===e.otherSiteGeneration,"isActive")}))).then(r=>{!e.isAlive()||i!==e.otherSiteGeneration||(r?t.show():t.hide())}).catch(r=>{!e.isAlive()||i!==e.otherSiteGeneration||(t.show(),he(e.root.find('[data-jhs-role="other-sites"]'),"外部站点加载失败"),clog.error("FC2 外部站点加载失败",r))})}unmountFc2OtherSites(e,t){e.otherSiteGeneration=(e.otherSiteGeneration||0)+1,e.root.find("[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(),t.length&&t.hide()}createResourceGroup(e,t){return $('

    ').find("h3").text(e).end().find("div").attr("data-jhs-role",t).end()}async loadNativeDetail(e){let t=Promise.resolve(e.movieId);this.configureJavDbWantButton(e,t),await Promise.allSettled([this.fetchAndRenderNativeDetail(e),this.fetchAndRenderNativeMagnets(e),this.mountPanels(e,t)]),e.isAlive()&&this.applyFc2Translation(e)}async load123AvDetail(e){let t=this.getOptionalDependency("Fc2By123AvPlugin");if(!t)return void he(e.getSlot("summary"),"123AV 详情功能已禁用");let s=t.resolveMovieId(e.carNum);this.configureJavDbWantButton(e,s),this.mountPanels(e,s),s.then((a=>{if(e.isAlive())return this.fetchAndRenderNativeMagnets(e,a)})).catch((a=>{e.isAlive()&&he(e.root.find('[data-jhs-role="native-magnets"]'),"站内磁力关联失败",(()=>{this.load123AvMagnets(e)})),clog.error("123AV 磁力关联失败",a)})),await t.loadDetail(e,e.url),e.isAlive()&&this.applyFc2Translation(e)}async load123AvMagnets(e){let t=this.getOptionalDependency("Fc2By123AvPlugin");if(!t)throw new Error("123AV 详情功能已禁用");let s=await t.resolveMovieId(e.carNum);return this.fetchAndRenderNativeMagnets(e,s)}async configureJavDbWantButton(e,t){let s=e.root.find('[data-jhs-action="javdb-want"]');try{let a=await t;if(!e.isAlive())return;if(!a)return void s.prop("disabled",!0).text("JavDB 暂无对应作品");try{let i=await fc(a);if(!e.isAlive())return;if(i)return void s.prop("disabled",!0).attr("aria-pressed","true").text("已在 JavDB 想看")}catch(i){clog.warn("读取 JavDB 想看状态失败,保留手动操作",i)}s.prop("disabled",!1).text("JavDB 想看").off(`click${e.namespace}`).on(`click${e.namespace}`,(()=>{this.submitJavDbWant(e,a,s)}))}catch(a){e.isAlive()&&s.prop("disabled",!1).text("JavDB 关联失败,重试").off(`click${e.namespace}`).on(`click${e.namespace}`,(()=>{this.configureJavDbWantButton(e,this.resolveMovieId(e.carNum))})),clog.error("FC2 JavDB 想看关联失败",a)}}async submitJavDbWant(e,t,s){if(!(!e.isAlive()||s.data("jhsBusy")||s.attr("aria-pressed")==="true")){s.data("jhsBusy",!0).attr({"aria-busy":"true","aria-disabled":"true"}).text("正在加入想看…");try{if(await mc(t),!e.isAlive())return;s.attr({"aria-pressed":"true","aria-disabled":"false"}).text("已加入 JavDB 想看"),show.ok("已加入 JavDB 想看")}catch(a){if(!e.isAlive())return;let i=a;if(i?.code==="LOGIN_REQUIRED")return s.attr("aria-disabled","false").text("JavDB 想看"),this.getOptionalDependency("TOP250Plugin")?.openLoginDialog({onSuccess:d(()=>this.submitJavDbWant(e,t,s),"onSuccess")});s.attr("aria-disabled","false").text("JavDB 想看"),show.error(i?.message||"加入 JavDB 想看失败"),clog.error("加入 JavDB 想看失败",a)}finally{e.isAlive()&&s.removeData("jhsBusy").removeAttr("aria-busy")}}}async fetchAndRenderNativeDetail(e){try{let t=await this.getRuntimeService("scope")(),s=await this.getRuntimeService("movie").detail({movieId:e.movieId,carNum:e.carNum,providerId:"javdb"},{scope:t});if(!s)throw new Error("JavDB 影片详情不存在");if(!e.isAlive())return;this.renderSummary(e,s),ns(e,s.imageUrls||[],s.coverUrl||null)}catch(t){e.isAlive()&&he(e.root.find('[data-jhs-role="summary-content"]'),"影片信息加载失败",(()=>{this.fetchAndRenderNativeDetail(e)})),clog.error("FC2 详情加载失败",t)}}renderSummary(e,t){let s=e.root.find('[data-jhs-role="summary-content"]').empty(),a=$('

    ');a.find("strong").text(t.title||"无标题"),s.append(a),t.originalTitle&&t.originalTitle!==t.title&&s.append($('
    ').text(t.originalTitle));let i=$('
    ');[`番号:${t.carNum||e.carNum}`,`发行:${t.releaseDate||"未知"}`,`评分:${Number.isFinite(Number(t.score))?t.score:"无"}`,`时长:${Number.isFinite(Number(t.duration))?t.duration+" 分钟":"无"}`].forEach((l=>i.append($("").text(l)))),s.append(i);let r=[],n=$('
    主演:
    ');(t.actors||[]).forEach((l=>{n.append($("").addClass("jhs-fc2-actor").attr({href:`/actors/${encodeURIComponent(l.id)}`,target:"_blank",rel:"noopener noreferrer"}).text(l.name||"未知演员")),l.gender===0&&r.push(l.name)})),t.actors?.length||n.append($("").text("暂无演员信息")),s.append(n,Ks(e,this.getRuntimeService("movie")),$('').text(r.join(" ")),$('').text(t.releaseDate||""))}async fetchAndRenderNativeMagnets(e,t=e.movieId){let s=e.root.find('[data-jhs-role="native-magnets"]');he(s,"正在加载站内磁力…");try{if(!t)return he(s,"JavDB 暂无对应作品");let a=await this.getRuntimeService("scope")(),i=await this.getRuntimeService("magnet").listNative({movieId:t,providerId:"javdb"},{scope:a});if(!e.isAlive())return;if(s.empty(),!i.length)return he(s,"暂无站内磁力");let r=[],n=this.getRuntimeService("magnet");i.forEach((l=>{let c=yl(l.hash);if(!c)return;let h=`magnet:?xt=urn:btih:${c}`,u=n.assess({title:l.title,hasHdTag:l.hasHdTag,hasSubtitleTag:l.hasSubtitleTag,date:l.createdAt,seeders:l.seeders}),p=$('
    ').attr("data-jhs-high-quality",String(u.highQuality)),g=$('
    '),m=$('
    '),f=$('
    ');r.push(u),f.append($("").addClass("jhs-badge").attr("title",`磁力质量评分 ${u.score.total}`).text(`${u.grade} ${u.score.total}`)),l.hasHdTag&&f.append('高清'),l.hasSubtitleTag&&f.append('字幕'),g.append($("").attr("href",h).text(l.title||h),$("
    ").addClass("jhs-fc2-meta").text(`${(Number(l.sizeMb||0)/1024).toFixed(2)} GB · ${Number(l.fileCount)||0} 个文件${l.createdAt?` · ${l.createdAt}`:""}`),f),m.append($('').attr("data-clipboard-text",h),$('').attr({"data-resource":h,"data-jhs-offline-owner":"fc2"})),s.append(p.append(g,m))})),await this.bindNativeMagnetFilter(e,s,r.some((l=>l.highQuality)))}catch(a){e.isAlive()&&he(s,"站内磁力加载失败",(()=>{this.fetchAndRenderNativeMagnets(e,t)})),clog.error("FC2 磁力加载失败",a)}}async bindNativeMagnetFilter(e,t,s){let a=e.getSection("resources"),i=a.find(".jhs-fc2-section__actions");i.find('[data-jhs-action="filter-native-magnets"]').remove();let n=$(''),l=d(u=>{t.find(".jhs-fc2-magnet-item").show(),u&&s&&t.find('.jhs-fc2-magnet-item[data-jhs-high-quality="false"]').hide(),n.attr("aria-pressed",String(u&&s)).text(s?u?"显示全部磁力":"过滤低质量":"暂无可过滤项").prop("disabled",!s)},"apply"),c=this.getRuntimeService("settings"),h=Fe({settings:c,key:"enableMagnetsFilter",fallback:"no",apply:d(u=>l(u===R),"apply"),onError:d(u=>{clog.error("磁力过滤设置保存失败,已恢复",u),show.error("磁力过滤设置保存失败,已恢复原设置")},"onError")});e.magnetFilterApply=l,i.append(n),l((c.snapshot().enableMagnetsFilter??R)===R),n.on(`click${e.namespace}`,(async()=>{let u=n.attr("aria-pressed")!=="true";await h(u?R:"no")}))}async mountPanels(e,t){try{let s=await t;if(!e.isAlive())return;if(!s)return this.clearOwnedPanel(e,"reviews"),this.clearOwnedPanel(e,"related"),he(e.getSlot("reviews"),"JavDB 暂无对应作品"),he(e.getSlot("related"),"JavDB 暂无对应作品");this.clearOwnedPanel(e,"reviews"),this.clearOwnedPanel(e,"related");let a=d(()=>this.getRuntimeService("scope")(),"scope"),i=new Bt({related:this.getRuntimeService("related"),settings:this.getRuntimeService("settings"),scope:a}),r=new Mt({review:this.getRuntimeService("review"),settings:this.getRuntimeService("settings"),storage:this.getRuntimeService("storage"),scope:a});await Promise.allSettled([r.show(s,e.getSlot("reviews"),{ownedSection:e.getSection("reviews"),isActive:e.isAlive,ownCleanup:d(n=>e.addObserver({disconnect:n}),"ownCleanup")}),i.show(e.getSlot("related"),s,{ownedSection:e.getSection("related"),isActive:e.isAlive,ownCleanup:d(n=>e.addObserver({disconnect:n}),"ownCleanup")})])}catch(s){if(!e.isAlive())return;this.clearOwnedPanel(e,"reviews"),this.clearOwnedPanel(e,"related");let a=d(()=>{this.mountPanels(e,this.resolveMovieId(e.carNum))},"retry");he(e.getSlot("reviews"),"评论关联失败",a),he(e.getSlot("related"),"相关清单关联失败",a),clog.error("FC2 JavDB 关联失败",s)}}clearOwnedPanel(e,t){e.getSlot(t).empty(),e.getSection(t).find(".jhs-fc2-section__actions").empty()}async resolveFc2Source(e={}){if(e.fc2Source&&["fc2","123av"].includes(e.fc2Source))return e.fc2Source;try{let t=new URL(e.url||"",window.location.origin);return this.getRuntimeService("movie").matchesProviderUrl("av123",t.href)?"123av":t.origin===window.location.origin?"fc2":""}catch{return""}}createFc2PageUrl(e,t,s,{source:a=""}={}){if(!String(e||"").trim())throw new TypeError("FC2 owned detail requires movieId");let i=new URL("/users/collection_codes",window.location.origin);return i.searchParams.set("movieId",String(e).trim()),i.searchParams.set("carNum",t),i.searchParams.set("url",s),i.searchParams.set("source",a),i.href}async openFc2Page(e,t,s,a={newTab:!0},{source:i=""}={}){i=["fc2","123av"].includes(i)?i:"";let r=String(e||"").trim();if(!r)return void utils.openPage(s,t,!0,a);utils.openPage(this.createFc2PageUrl(r,t,s,{source:i}),t,!0,a)}};d(Lr,"Fc2Plugin");var Xs=Lr;var Br=class Br extends I{constructor(){super(),this.$contentBox=null,this.$listRoot=null,this.loadGeneration=0}getName(){return"HitShowPlugin"}async initCss(){return""}async handle(){$('a[href*="rankings/playback"]').on("click",(e=>{if(e.ctrlKey||e.metaKey||e.button===1)return void window.open("/advanced_search?handlePlayback=1&period=daily","_blank");e.preventDefault(),e.stopPropagation(),window.location.href="/advanced_search?handlePlayback=1&period=daily"})),await this.handlePlayback()}hookPage(){let e=this.getRuntimeService("host"),t=e.getListContainer?.()??e.getListLayoutContainer?.();if(!t||!e.createOwnedListRoot)throw new Error("JavDB 列表容器不可用");this.$contentBox=$(t),this.$listRoot=$(e.createOwnedListRoot(["jhs-hitshow-list"]));let s=$("h2.section-title");s.length||(s=$("

    ").addClass("section-title").prependTo(this.$contentBox)),s.contents().first().replaceWith("热播"),s.addClass("jhs-hitshow-title"),s.parent(".jhs-hitshow-heading").length||s.wrap('
    '),$(".empty-message").remove(),this.$contentBox.children(".box").remove(),e.mountOwnedListRoot(t,this.$listRoot[0])}async handlePlayback(){if(!We())return;let e=new URLSearchParams(window.location.search).get("period"),t=++this.loadGeneration,s=loading(),a=!1;try{this.hookPage(),this.toolBar(e),await this.getOptionalDependency("ListPageButtonPlugin")?.mountOwnedRankingControls?.()?.catch?.(r=>clog.error("热播操作按钮挂载失败",r));let i=await this.fetchPlaybackWithRetry(e);if(t!==this.loadGeneration)return;if(!i.length)return void this.renderState("当前周期暂无热播数据");this.$listRoot.html(this.markDataListHtml(i)),await this.initializeRenderedList(),$("#jhs-hitshow-period").length||$(".jhs-hitshow-heading").length&&this.toolBar(e),s.close(),a=!0,this.loadScore(i,t).catch((r=>clog.error("热播评分补全失败",r)))}catch(i){clog.error("所有重试尝试均失败,无法获取数据。",i),this.$listRoot?.length?this.renderState("热播数据加载失败,请稍后重试",!0):show.error("热播页面初始化失败")}finally{a||s.close()}}renderState(e,t=!1){if(!this.$listRoot?.length)return;let s=$("
    ").addClass(`jhs-hitshow-state${t?" jhs-hitshow-state--error":""}`).attr("role",t?"alert":"status"),a=$("

    ").text(e);s.append(a),t&&s.append($('').text("重新加载").on("click",(()=>{this.handlePlayback()}))),this.$listRoot.empty().append(s)}async fetchPlaybackWithRetry(e){let t;for(let s=1;s<=3;s++)try{let a=await this.getRuntimeService("scope")();return await this.getRuntimeService("movie").rankings({period:e,scope:a})}catch(a){t=a,s<3&&(clog.error(`获取热播数据失败 (第 ${s} 次重试)`,a),await new Promise((i=>setTimeout(i,1e3))))}throw t}async initializeRenderedList(){let e=this.getOptionalDependency("ListPagePlugin");if(e){let t=e.advanceListGeneration?.()??null,s=this.getRuntimeService("settings").snapshot().hoverBigImg;e.configureHoverPreview(s==="yes"?"yes":"no"),e.replaceHdImg(),await e.doFilter(t||void 0),await e.createQuickFilter?.(),t!==null&&e.reconcileListItems?e.reconcileListItems(null,t):e.applyVisibility(),e.rebuildItemIndex?.(),e.bindMovieDetailNavigation(e.getSelector().boxSelector),await e.bindClick?.()}await this.getOptionalDependency("CoverButtonPlugin")?.addSvgBtn?.(),await F?.emit("list-items-added",{items:this.$listRoot?.find(".item").toArray()??[]},{broadcast:!1})}toolBar(e){$("#jhs-hitshow-period").remove();let t=e==="weekly"?"weekly":e==="monthly"?"monthly":"daily",s=d((l,c)=>`${c}`,"tab"),a=` + + `,i=$(".jhs-hitshow-heading");if(!i.length)return;let r=i.children("h2.section-title");r.length?r.after(a):i.append(a);let n=$("#jhs-hitshow-period");n.off("keydown.jhsPeriod").on("keydown.jhsPeriod",(l=>{if(!["ArrowLeft","ArrowRight","Home","End"].includes(l.key))return;l.preventDefault();let c=n.find('[role="tab"]'),h=c.index(l.target),u=l.key==="Home"?0:l.key==="End"?c.length-1:l.key==="ArrowRight"?(h+1)%c.length:(h-1+c.length)%c.length;c.get(u)?.focus()}))}getStarRating(e){let t="",s=Math.floor(e);for(let a=0;a';for(let a=0;a<5-s;a++)t+='';return t}async loadScore(e,t=this.loadGeneration){if(e.length===0)return;let s="jhs_hitshow_scores_v1",a=await storageManager.cachedRequest(s,6048e5,async()=>({})),i=a&&typeof a=="object"&&!Array.isArray(a)?{...a}:{},r=[...e],n=Array.from({length:Math.min(4,r.length)},(()=>this.scoreWorker(r,i,t)));if(await Promise.all(n),t===this.loadGeneration&&await storageManager.cacheSet(s,i,6048e5),t===this.loadGeneration){let l=this.getOptionalDependency("ListPageButtonPlugin");(l?.activeSortMethod?.()??"default")==="rateCount"&&await l?.sortItems?.()}}async scoreWorker(e,t,s){for(;;){let a=e.shift();if(!a)return;try{if(s!==this.loadGeneration)return;let i=a.movieId??a.id;if(!$(`#score_${i}`).length)continue;if(t[i]){let h=this.normalizeScoreData(t[i]);this.appendScore(i,h.score,h.watchedCount);continue}let r=await this.getRuntimeService("scope")(),n=await this.getRuntimeService("movie").detail({movieId:i,providerId:"javdb"},{scope:r});if(!n)throw new Error("JavDB 影片详情不存在");if(s!==this.loadGeneration)return;let l=Number(n.score),c=Number(n.watchedCount);this.appendScore(i,l,c),t[i]={score:Number.isFinite(l)?l:0,watchedCount:Number.isFinite(c)?c:0}}catch(i){let r=a.movieId??a.id,n=a.carNum??a.number,l=i instanceof Error?i:new Error(String(i));$(`#${r}`).attr("data-jhs-rate-count","0"),clog.error(`解析评分数据失败 | 编号: ${n} +`,`错误详情: ${l.message} +`,l.stack?`调用栈: +${l.stack}`:"")}}}normalizeScoreData(e){let t=e&&typeof e=="object"?e:{},s=typeof e=="string"?e:String(t.html||""),a=Number(t.score??(s.match(/([\d.]+)分/)||[0,0])[1]),i=Number(t.watchedCount??(s.match(/由(\d+)人/)||[0,0])[1]);return{score:Number.isFinite(a)?a:0,watchedCount:Number.isFinite(i)?i:0}}appendScore(e,t,s=0){let a=Math.min(5,Math.max(0,Number(t)||0)),i=Math.max(0,Number(s)||0),r=$(`#${e}`),n=$(`#score_${e}`);if(r.attr("data-jhs-rate-count",String(i)),!n.length||n.text().trim()!=="")return;let l=$(''),c=$('').html(this.getStarRating(a));l.append(c,document.createTextNode(` ${a}分,由${i}人评价`)),n.hide().empty().append(l).slideDown(500)}markDataListHtml(e,{thumbnailFirst:t=!0}={}){let s="";return e.forEach(((a,i)=>{let r=a.movieId??a.id,n=a.carNum??a.number,l=a.title??a.origin_title,c=a.releaseDate??a.release_date,h=ir(a.coverUrl??a.cover_url),u=ir(a.thumbUrl??a.thumb_url),p=u??(h?h.replace("/covers/","/thumbs/"):null),g=t?p??h:h??p,m=h??p??"",f=a.hasSubtitle??a.has_cnsub,v=Number(a.magnetCount??a.magnets_count),b=a.newMagnets??a.new_magnets;s+=` + + `})),s}};d(Br,"HitShowPlugin");var ea=Br;var Mr=class Mr extends I{getName(){return"JavTrailersPlugin"}constructor(){super(),this.hasBand=!1}async handle(){let e=window.location.href;if(!e.includes("handle=1"))return;if($("h1:contains('Page not found')").length){clog.log("番号无法匹配, 跳搜索");let a=e.split("?")[0].split("video/")[1].toLowerCase().replace("00","-");return void(window.location.href="/search/"+encodeURIComponent(a)+window.location.search)}let t=$(".videos-list .video-link").toArray();if(t.length){let a=e.split("?")[0].split("search/")[1].toLowerCase(),i=t.find((r=>$(r).find(".vid-title").text().toLowerCase().includes(a)));if(i)return void(window.location.href=$(i).attr("href")+window.location.search)}let s=await this.getRuntimeService("scope")();this.handlePlayJavTrailers(s),this.bindPlaybackControls(s)}bindPlaybackControls(e){let t=$("#videoPlayerContainer"),s=d(()=>this.handlePlayJavTrailers(e),"replay");t.off("click.jhsJavTrailers").on("click.jhsJavTrailers",s),e.addCleanup((()=>t.off("click.jhsJavTrailers",s))),e.listen(window,"message",(()=>{let a=document.getElementById("vjs_video_3_html5_api");a&&(a.currentTime+=5)}))}handlePlayJavTrailers(e){if(this.hasBand||e.signal.aborted)return;let t=utils.loopDetector((()=>$("#vjs_video_3_html5_api").length!==0),(()=>{e.signal.aborted||e.ownTimeout(setTimeout((()=>{if(e.signal.aborted)return;this.hasBand=!0;let a=document.getElementById("vjs_video_3_html5_api");a&&(clog.debug(a),de(a,{context:"JavTrailers 预览"}),a.currentTime=5,e.listen(a,"timeupdate",(function(){a.currentTime>=14&&a.currentTime<16&&(a.currentTime+=2)})),$("#vjs_video_3_html5_api").css({position:"fixed",width:"100vw",height:"100vh",objectFit:"cover",zIndex:String(J.debug)}),$(".vjs-control-bar").css({position:"fixed",bottom:"20px",zIndex:String(J.debug)}))}),100))}),20,1e4,!0,e),s=utils.loopDetector((()=>$("#vjs_video_3 canvas").length>0),(()=>{e.signal.aborted||$("#vjs_video_3 canvas").length!==0&&$("#vjs_video_3 canvas").css({position:"fixed",width:"100vw",height:"100vh",objectFit:"cover",top:"0",right:"0",zIndex:String(J.debug-1)})}),20,1e4,!0,e);e.addCleanup(t),e.addCleanup(s)}};d(Mr,"JavTrailersPlugin");var ta=Mr;var sa=Object.freeze({magnet:216e5,screenshot:6048e5,screenshotNegative:432e5,match115:36e5,externalDetail:864e5}),Or=class Or extends Error{constructor(e,t,s,a={}){super(s,{cause:a.cause}),this.name="ProviderError",this.provider=e,this.code=t,this.status=a.status||0,this.url=a.url||"",this.retryable=!!a.retryable}};d(Or,"ProviderError");var we=Or;function ls(o){let e=0,t=Number(o.seeders)||0,s=t>=50?35:t>=10?25:t>=1?15:3;e+=s;let a=String(o.title||"").toLowerCase(),i=String(o.resolution||"").toLowerCase(),r=/4k|2160p/.test(i)||/4k|2160p/.test(a)?25:/1080p/.test(i)||/1080p/.test(a)?20:/720p/.test(i)||/720p/.test(a)?15:5;e+=r;let n=o.hasSubtitle||/-c\b|-uc\b|chinese|中字|字幕/.test(a)?20:0;e+=n;let l=Lh(o.date),c=l<=7?15:l<=30?12:l<=90?8:3;e+=c;let h=/sample|预告|trailer/.test(a)?-15:0;return e+=h,{total:Math.max(0,Math.min(100,e)),seeders:s,resolution:r,subtitle:n,freshness:c,completeness:h}}d(ls,"calcMagnetScore");function Lh(o){try{let e=new Date(String(o||""));return Number.isNaN(e.getTime())?999:Math.max(0,Math.floor((Date.now()-e.getTime())/864e5))}catch{return 999}}d(Lh,"daysSince");function zr(o,e=!1){let t=String(o||"").toLowerCase(),s=/(?:4k|2160p|1080p|720p)/.exec(t)?.[0]||"",a=e||/(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(t);return{resolution:s,subtitle:a,recognized:!!s||a,highQuality:s==="4k"||s==="2160p"||a}}d(zr,"getMagnetQualitySignals");function yc({title:o="",hasHdTag:e=!1,hasSubtitleTag:t=!1,date:s=null,seeders:a=0}={}){let i=zr(o,t),r=e||i.highQuality,n=ls({title:o,date:s,seeders:a,resolution:e&&!i.resolution?"1080p":i.resolution,hasSubtitle:i.subtitle});return{...i,highQuality:r,score:n,grade:n.total>=70?"高":n.total>=40?"中":"低"}}d(yc,"assessMagnetQuality");function wt(o,e="other"){if(!o||typeof o!="object"||Array.isArray(o))return null;let t=o,s=T(t.carNum);return s?Object.freeze({...t,carNum:s,movieId:typeof t.movieId=="string"&&t.movieId.trim()?t.movieId.trim():null,surface:t.surface||e}):null}d(wt,"normalizeMovieContext");function jc(o,e="other"){let t=wt(o,e);if(!t)throw new TypeError("Movie context requires a valid carNum");return t}d(jc,"createMovieContext");function aa(o){let e=o?.nodeType?o:o?.[0]||null;for(;e;){let s=(typeof globalThis.$=="function"?globalThis.$(e):null)?.data?.("jhsMovieContext")||e.__jhsMovieContext,a=wt(s);if(a)return a;e=e.parentElement||null}return null}d(aa,"readOwnedMovieContext");function Ur(o={}){let e=wt(o.explicitContext);if(e)return{context:e,source:"explicit"};let t=wt(o.ownedResolver?.()??aa(o.trigger),"other");if(t)return{context:t,source:t.surface==="fc2-dialog"?"fc2-workspace":"owned-surface"};let s=wt(o.listResolver?.(),"list-item");if(s)return{context:s,source:"list-item"};let a=wt(o.nativeResolver?.(),"native-detail");if(a)return{context:a,source:"native-detail"};let i=wt(o.legacyResolver?.(),"other");return i?(o.logger?.("movie-context source=legacy-fallback",i),{context:i,source:"legacy-fallback"}):{context:null,source:"missing"}}d(Ur,"resolveMovieContext");var Fr=class Fr extends I{constructor(){super(...arguments),this.sourceRegistry=new Qt,this.searchEngines=[]}async initializeSources(){let e=new rt,t=this.getRuntimeService("magnet"),s=await e.getBuiltInSources(),a=await e.getMagnetSources(),i=t.getBuiltInSources(),r=[...Ns,...i],n=d(h=>({...r.find(u=>u.id===h)||{},...s.find(u=>u.id===h)||{}}),"configured"),l=i.map((h=>{let u=n(h.id),p=String(u.baseUrl||h.baseUrl).replace(/\/$/,"");return{...h,...u,search:d(async g=>t.searchSource(h.id,g,{baseUrl:p,scope:await this.getRuntimeService("scope")()}),"search"),targetUrl:d(g=>t.getSourceTargetUrl(h.id,g,{baseUrl:p}),"targetUrl")}}));this.sourceRegistry=new Qt([{name:"JavDB 本站",id:"native-javdb",applicable:N,enabled:N,priority:1,search:d(async(h,u=document)=>or(u?.jquery?u[0]:u,"javdb"),"search"),targetUrl:d(()=>window.location.href,"targetUrl")},{name:"JavBus 本站",id:"native-javbus",applicable:B,enabled:B,priority:2,search:d(async(h,u=document)=>or(u?.jquery?u[0]:u,"javbus"),"search"),targetUrl:d(()=>window.location.href,"targetUrl")},...l].map((h=>{let u=n(h.id),p=h.applicable??!0;return{...h,...u,enabled:p&&(u.enabled??h.enabled??!0),search:h.search,targetUrl:h.targetUrl}}))),a.filter(h=>h.enabled).forEach(h=>this.sourceRegistry.register({...h,id:`custom:${h.id}`,search:d(u=>this.searchCustomSource(h,u),"search"),targetUrl:d(u=>h.targetUrlTemplate.replaceAll("{keyword}",encodeURIComponent(u)),"targetUrl")}));let c=this.sourceRegistry.getEnabledSources().map((h=>({...h,targetPage:h.targetUrl("{keyword}").replace("%7Bkeyword%7D","{keyword}")})));this.searchEngines=c.length?[{id:"all",name:"全部",priority:0,targetPage:"#",targetUrl:d(()=>"#","targetUrl"),search:d(h=>this.searchAllSources(c,h),"search")},...c]:[]}getName(){return"MagnetHubPlugin"}async initCss(){return``; - } - async createMagnetHub(e2, options = {}) { - await this.initializeSources(); - e2 = e2.replace("FC2-", ""); - const root = options.root ? $(options.root) : $(document), engines = [...this.searchEngines]; - const t2 = $('
    '), n2 = $('
    '), a2 = "jhs_magnetHub_selectedEngine", i2 = localStorage.getItem(a2); - const o2 = $('
    '); - let currentEngine = engines.find(((engine) => engine.id === i2)) || engines[0] || null; - if (!currentEngine) return t2.append($('
    ').text("暂无可用磁力来源,请前往设置启用来源")); - engines.forEach(((engine) => o2.append($('').attr("data-engine", engine.id).text(engine.name).toggleClass("active", engine.id === currentEngine.id)))); - const target = $('原网页').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e2))).toggle("all" !== currentEngine.id); - n2.append(o2), n2.append(target), o2.find(".magnet-tab.active").attr({ "aria-selected": "true", tabindex: "0" }), t2.append(n2); - const r2 = $('
    '); - return t2.append(r2), t2.on("click", ".magnet-tab", ((n3) => { - const i3 = $(n3.target).data("engine"); - currentEngine = engines.find(((e3) => e3.id === i3)), t2.find('[data-jhs-role="magnet-target"]').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e2))).toggle("all" !== currentEngine.id), localStorage.setItem(a2, i3), t2.find(".magnet-tab").removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }), $(n3.target).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }), this.searchEngine(r2, currentEngine, e2, root); - })), t2.on("keydown", ".magnet-tab", ((e3) => { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e3.key)) return; - e3.preventDefault(); - const n3 = t2.find(".magnet-tab"), a3 = n3.index(e3.currentTarget); - let i3 = "Home" === e3.key ? 0 : "End" === e3.key ? n3.length - 1 : "ArrowRight" === e3.key ? (a3 + 1) % n3.length : (a3 - 1 + n3.length) % n3.length; - n3.eq(i3).trigger("click").trigger("focus"); - })), this.searchEngine(r2, currentEngine, e2, root), t2; - } - async searchEngine(e2, t2, n2, root = $(document)) { - e2.html(`
    正在从 ${escapeHtml(t2.name)} 搜索 "${escapeHtml(n2)}"...
    `); - const a2 = `${t2.name}_${n2}`; - if (t2.search) try { - return void this.displayResults(e2, await this.applyRuntimeRules(deduplicateMagnetResults(await t2.search(n2, root))), t2.name); - } catch (error) { - clog.error(`磁力源 ${t2.name} 请求失败`, error); - return void e2.html(`
    ${escapeHtml(t2.name)} 请求失败
    `); - } - if (t2.parseHtml) try { - const i2 = t2.url.replace("{keyword}", encodeURIComponent(n2)), s2 = await storageManager.cachedRequest(`magnet:${t2.id}:${n2}`, 216e5, (() => gmHttp.get(i2).then(((e3) => t2.parseHtml.call(this, e3, n2))))); - return void this.displayResults(e2, s2, t2.name); - } catch (s2) { - return void e2.html(`
    解析 ${escapeHtml(t2.name)} 结果失败: ${escapeHtml(s2.message)}
    `); - } - t2.parseJson && await t2.parseJson.call(this, e2, t2, n2, a2); - } - async searchTorrentSource(source, template, keyword) { - const url = template.replace("{keyword}", encodeURIComponent(keyword)); - return storageManager.cachedRequest(`magnet:${source}:${keyword}`, CACHE_TTL.magnet, (async () => { - const html = await gmHttp.get(url); - return this.parseTorrentList(html, keyword).map(((item) => ({ ...item, source, files: [] }))); - })); - } - async searchCustomSources(keyword) { - const configs = JSON.parse(await storageManager.getSetting("customMagnetSources", "[]")); - const enabled = configs.filter(((config) => config.enabled)).map(validateCustomMagnetSource); - const groups = await mapLimit(enabled, 4, (async (config) => { - const url = config.searchUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)); - try { - const payload = await storageManager.cachedRequest(`magnet:custom:${config.id}:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.get(url))); - const parsed = "json" === config.parserType && "string" === typeof payload ? JSON.parse(payload) : payload; - return parseCustomMagnetResponse(config, parsed, config.id); - } catch (cause) { - clog.error(`自定义磁力源 ${config.name} 失败`, new ProviderError(config.id, cause._cfBlocked ? "CF_BLOCKED" : "HTTP_ERROR", cause.message, { cause, url, status: cause.status })); - return []; - } - })); - return deduplicateMagnetResults(groups.flat()); - } - async searchCustomSource(config, keyword) { - const url = config.searchUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)); - const payload = await storageManager.cachedRequest(`magnet:custom:${config.id}:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.get(url))); - return parseCustomMagnetResponse(config, "json" === config.parserType && "string" === typeof payload ? JSON.parse(payload) : payload, config.id); - } - async searchAllSources(sources, keyword) { - const groups = await mapLimit(sources, 3, (async (source) => { - try { - return await source.search(keyword); - } catch (error) { - clog.warn(`磁力源 ${source.name} 聚合失败`, error); - return []; - } - })); - return deduplicateMagnetResults(groups.flat()); - } - async searchBtsow(keyword, baseUrl = "https://btsow.lol") { - const payload = await storageManager.cachedRequest(`magnet:btsow:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.gmRequest("POST", `${baseUrl}/search`, JSON.stringify([{ search: keyword }, 50, 1]), {}, { "Content-Type": "application/json" }))); - const value = "string" === typeof payload ? JSON.parse(payload) : payload; - return (value?.data || []).map(((item) => normalizeMagnetResult({ title: item.name, magnet: `magnet:?xt=urn:btih:${item.hash}`, size: `${(Number(item.size) / 1073741824).toFixed(2)} GB`, date: utils.formatDate(new Date(1e3 * item.lastUpdateTime)) }, "btsow"))).filter(Boolean); - } - async applyRuntimeRules(results) { - const service = new ResourceSettingsService(), [tags, filters] = await Promise.all([service.getMagnetTagRules(), service.getMagnetFilterRules()]); - return results.map(((result) => applyMagnetRules(result, tags, filters.filter(((rule) => (rule.target || "title") === "title")), filters.filter(((rule) => rule.target === "file"))))).filter(((result) => !result.hidden)); - } - async displayResults(e2, t2, n2) { - function a2(e3) { - const t3 = e3.text(); - e3.addClass("copied").text("已复制"), setTimeout((() => { - e3.removeClass("copied").text(t3); - }), 2e3); - } - __name(a2, "a"); - e2.empty(), 0 !== t2.length ? (t2.forEach(((e3) => { - const base = this.calcMagnetScore(e3); - e3._score = { ...base, total: Math.max(0, Math.min(100, base.total + (e3.customTagWeight || 0) + (e3.filterPenalty || 0))) }; - })), t2.sort(((e3, t3) => t3._score.total - e3._score.total)), t2.forEach(((t3) => { - const n3 = t3._score ? t3._score.total : 0, a3 = n3 >= 80 ? "高" : n3 >= 60 ? "中" : "低", i2 = t3._score ? `做种:${t3._score.seeders}/35 分辨率:${t3._score.resolution}/25 字幕:${t3._score.subtitle}/20 新鲜度:${t3._score.freshness}/15 完整性:${t3._score.completeness}/5` : ""; - const safeTitle = escapeHtml(t3.title), safeMagnet = escapeHtml(t3.magnet), safeSize = escapeHtml(String(t3.size || "未知")), safeDate = escapeHtml(String(t3.date || "未知")); - const item = $(` + `}async createMagnetHub(e,t={}){await this.initializeSources();let s=typeof e=="string"?null:e,a=typeof e=="string"?jc({carNum:e}):s?.movieContext;if(!a?.carNum)return $('
    ').append($('
    ').text("无法确定影片身份,未加载磁力"));let i=a.carNum.replace("FC2-",""),r=t.root??s?.root?$(t.root??s?.root):$(document),n=[...this.searchEngines],l=this.getRuntimeService("storage"),c=$('
    '),h=$('
    '),u="jhs_magnetHub_selectedEngine",p=l.getLocal(u);c.data("jhsMovieContext",a);let g=$('
    '),m=n.find((w=>w.id===p))||n[0];if(!m)return c.append($('
    ').text("暂无可用磁力来源,请前往设置启用来源"));let f=m;n.forEach((w=>g.append($('').attr("data-engine",w.id).text(w.name).toggleClass("active",w.id===f.id))));let v=$('原网页').attr("href",(f.targetPage||"#").replace("{keyword}",encodeURIComponent(i))).toggle(f.id!=="all");h.append(g),h.append(v),g.find(".magnet-tab.active").attr({"aria-selected":"true",tabindex:"0"}),c.append(h);let b=$('
    ');return c.append(b),c.on("click",".magnet-tab",(w=>{let S=$(w.target).data("engine");f=n.find((j=>j.id===S))||f,f&&(c.find('[data-jhs-role="magnet-target"]').attr("href",(f.targetPage||"#").replace("{keyword}",encodeURIComponent(i))).toggle(f.id!=="all"),l.setLocal(u,S),c.find(".magnet-tab").removeClass("active").attr({"aria-selected":"false",tabindex:"-1"}),$(w.target).addClass("active").attr({"aria-selected":"true",tabindex:"0"}),this.searchEngine(b,f,i,r))})),c.on("keydown",".magnet-tab",(w=>{if(!["ArrowLeft","ArrowRight","Home","End"].includes(w.key))return;w.preventDefault();let S=c.find(".magnet-tab"),j=S.index(w.currentTarget),k=w.key==="Home"?0:w.key==="End"?S.length-1:w.key==="ArrowRight"?(j+1)%S.length:(j-1+S.length)%S.length;S.eq(k).trigger("click").trigger("focus")})),this.searchEngine(b,f,i,r),c}async searchEngine(e,t,s,a=$(document)){e.html(`
    正在从 ${C(t.name)} 搜索 "${C(s)}"...
    `);let i=`${t.name}_${s}`;if(t.search)try{return void this.displayResults(e,await this.applyRuntimeRules(Es(await t.search(s,a))),t.name)}catch(r){return clog.error(`磁力源 ${t.name} 请求失败`,r),void e.html(`
    ${C(t.name)} 请求失败
    `)}if(t.parseHtml)try{let r=t.url.replace("{keyword}",encodeURIComponent(s)),n=await this.requestSource(t.id,r,{ttlMs:216e5}),l=t.parseHtml.call(this,n,s);return void this.displayResults(e,l,t.name)}catch(r){return void e.html(`
    解析 ${C(t.name)} 结果失败: ${C(r instanceof Error?r.message:String(r))}
    `)}t.parseJson&&await t.parseJson.call(this,e,t,s,i)}async searchCustomSources(e){let s=JSON.parse(await storageManager.getSetting("customMagnetSources","[]")).filter(i=>i.enabled).map(bt),a=await $e(s,4,(async i=>{let r=i.searchUrlTemplate.replaceAll("{keyword}",encodeURIComponent(e));try{let n=await this.requestSource(i.id,r,{ttlMs:sa.magnet,custom:!0,responseType:i.parserType==="json"?"json":"text"}),l=i.parserType==="json"&&typeof n=="string"?JSON.parse(n):n;return lr(i,l,i.id)}catch(n){let l=n;return clog.error(`自定义磁力源 ${i.name} 失败`,new we(i.id,l.code||"HTTP_ERROR",l.message||String(n),{cause:n,url:r,status:l.status,retryable:l.retryable})),[]}}));return Es(a.flat())}async searchCustomSource(e,t){let s=e.searchUrlTemplate.replaceAll("{keyword}",encodeURIComponent(t)),a=await this.requestSource(e.id,s,{ttlMs:sa.magnet,custom:!0,responseType:e.parserType==="json"?"json":"text"});return lr(e,e.parserType==="json"&&typeof a=="string"?JSON.parse(a):a,e.id)}async searchAllSources(e,t){let s=await $e(e,3,(async a=>{try{return await a.search(t)}catch(i){return clog.warn(`磁力源 ${a.name} 聚合失败`,i),[]}}));return Es(s.flat())}async requestSource(e,t,s={}){let a=await this.getRuntimeService("scope")();return(await this.getRuntimeService("http").request({providerId:`magnet:${e}`,method:s.method||"GET",url:t,body:s.body,headers:s.headers,responseType:s.responseType||"text",cacheScope:s.method&&s.method!=="GET"?"none":"public",ttlMs:s.ttlMs??sa.magnet,urlPolicy:s.custom?{trustClass:"custom-public"}:{trustClass:"builtin-public",hosts:s.hosts||[new URL(t).hostname]}},a)).data}async applyRuntimeRules(e){let t=new rt,[s,a]=await Promise.all([t.getMagnetTagRules(),t.getMagnetFilterRules()]);return e.map((i=>_l(i,s,a.filter((r=>(r.target||"title")==="title")),a.filter((r=>r.target==="file"))))).filter((i=>!i.hidden))}async displayResults(e,t,s){function a(i){let r=i.text();i.addClass("copied").text("已复制"),setTimeout((()=>{i.removeClass("copied").text(r)}),2e3)}d(a,"a"),e.empty(),t.length!==0?(t.forEach((i=>{let r=this.calcMagnetScore(i);i._score={...r,total:Math.max(0,Math.min(100,r.total+(i.customTagWeight||0)+(i.filterPenalty||0)))}})),t.sort(((i,r)=>r._score.total-i._score.total)),t.forEach((i=>{let r=i._score?i._score.total:0,n=r>=80?"高":r>=60?"中":"低",l=i._score?`做种:${i._score.seeders}/35 分辨率:${i._score.resolution}/25 字幕:${i._score.subtitle}/20 新鲜度:${i._score.freshness}/15 完整性:${i._score.completeness}/5`:"",c=C(i.title),h=C(i.magnet),u=C(String(i.size||"未知")),p=C(String(i.date||"未知")),g=$(`
    - ${a3} ${n3} - ${safeTitle} + ${n} ${r} + ${c}
    - 大小: ${safeSize} - 做种: ${t3.seeders || "—"} - 日期: ${safeDate} + 大小: ${u} + 做种: ${i.seeders||"—"} + 日期: ${p}
    - +
    - `); - t3.tags?.length && item.find(".magnet-info").after($("
    ").addClass("magnet-tags").append(t3.tags.map(((tag) => $("").addClass("jhs-badge").text(tag))))); - const copyBox = item.find(".magnet-copy"); - item.find(".copy-btn").removeClass("magnet-hub-btn").addClass("jhs-btn--secondary"); - copyBox.append(``); - item.appendTo(e2); - })), e2.on("click", ".copy-btn", (async function() { - const e3 = $(this), t3 = e3.data("magnet"); - await utils.copyToClipboard("磁力链接", t3) && a2(e3); - }))) : e2.append('
    没有找到相关结果
    '); - } - parseTorrentList(e2, t2) { - const n2 = utils.htmlTo$dom(e2), a2 = []; - return n2.find(".torrent-list tbody tr").each(((e3, n3) => { - const i2 = $(n3); - if (i2.text().includes("置顶")) return; - const s2 = i2.find("td:nth-child(2) a").attr("title") || i2.find("td:nth-child(2) a").text().trim(); - if (!s2.toLowerCase().includes(t2.toLowerCase())) return; - const o2 = i2.find("td:nth-child(3) a[href^='magnet:']").attr("href"), r2 = i2.find("td:nth-child(4)").text().trim(), l2 = i2.find("td:nth-child(5)").text().trim(), c2 = parseInt(i2.find("td:nth-child(6)").text().trim()) || 0, d2 = parseInt(i2.find("td:nth-child(7)").text().trim()) || 0; - o2 && a2.push({ - title: s2, - magnet: o2, - size: r2, - date: l2, - seeders: c2, - leechers: d2 - }); - })), a2; - } - calcMagnetScore(e2) { - return calcMagnetScore(e2); - } - }; - __name(_MagnetHubPlugin, "MagnetHubPlugin"); - var MagnetHubPlugin = _MagnetHubPlugin; - var _ScreenshotProviderRegistry = class _ScreenshotProviderRegistry { - constructor(providers = []) { - this.providers = /* @__PURE__ */ new Map(); - providers.forEach(((provider) => this.register(provider))); - } - register(provider) { - if (!provider?.id || !provider.name || "function" !== typeof provider.getScreenshot) throw new TypeError("Invalid screenshot provider"); - this.providers.set(provider.id, { enabled: true, priority: 100, ...provider }); - return this; - } - get(id) { - return this.providers.get(id) || null; - } - getEnabledProviders() { - return [...this.providers.values()].filter(((provider) => provider.enabled)).sort(((a2, b2) => a2.priority - b2.priority)); - } - async first(carNum) { - for (const provider of this.getEnabledProviders()) { - try { - const result = await provider.getScreenshot(carNum); - if (result?.url) return result; - } catch (error) { - clog.warn(`截图源 ${provider.name} 请求失败`, error); - } - } - return null; - } - }; - __name(_ScreenshotProviderRegistry, "ScreenshotProviderRegistry"); - var ScreenshotProviderRegistry = _ScreenshotProviderRegistry; - var _ScreenShotPlugin = class _ScreenShotPlugin extends BasePlugin { - constructor() { - super(...arguments), this.providerRegistry = new ScreenshotProviderRegistry(); - } - async initializeProviders() { - const settings = await new ResourceSettingsService().getScreenshotSettings(), configured = /* @__PURE__ */ __name((id) => settings.providers.find(((provider) => provider.id === id)) || {}, "configured"); - this.providerRegistry = new ScreenshotProviderRegistry([ - { id: "javstore", name: "JavStore", priority: 10, getScreenshot: /* @__PURE__ */ __name((carNum) => this.getCachedProviderScreenshot("javstore", carNum, (() => this.getJavStoreScreenShot(carNum))), "getScreenshot") }, - { id: "projectjav", name: "ProjectJav", enabled: false, priority: 20, getScreenshot: /* @__PURE__ */ __name(async () => null, "getScreenshot") }, - { id: "18av", name: "18AV", enabled: false, priority: 30, getScreenshot: /* @__PURE__ */ __name(async () => null, "getScreenshot") } - ].map(((provider) => ({ ...provider, ...configured(provider.id), enabled: !["projectjav", "18av"].includes(provider.id) && (configured(provider.id).enabled ?? provider.enabled ?? true), getScreenshot: provider.getScreenshot })))); - return settings.mode; - } - getName() { - return "ScreenShotPlugin"; - } - async initCss() { - return ``; - } - async handle() { - await this.loadScreenShot(); - } - async loadScreenShot() { - if (!isDetailPage) return; - if ("yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return; - let e2 = this.getPageInfo().carNum; - r && $(".preview-images .tile-item").first().before('
    正在加载缩略图
    '), l && $("#sample-waterfall .sample-box:first").after('
    正在加载缩略图
    '); - const mode = await this.initializeProviders(); - if ("manual" === mode) return $(".screen-container").text("请选择截图来源"), void this.renderProviderTabs(e2); - try { - const t2 = await this.getScreenshotFromInitializedProviders(e2); - t2 ? (this.addImg("缩略图", t2), clog.log("加载缩略图:", t2)) : this.showErrorFallback(e2, null); - } catch (t2) { - this.showErrorFallback(e2, t2); - } - } - renderProviderTabs(carNum) { - const tabs = $('
    '); - this.providerRegistry.providers.forEach(((provider) => tabs.append($('').prop("disabled", !provider.enabled).attr("data-provider", provider.id).text(provider.name)))); - $(".screen-container").before(tabs); - tabs.on("click", "button:not(:disabled)", (async (event) => { - const provider = this.providerRegistry.get($(event.currentTarget).data("provider")); - $(".screen-container").text(`${provider.name} 加载中…`); - try { - const result = await provider.getScreenshot(carNum); - result?.url ? this.addImg(`${provider.name} 缩略图`, result.url) : $(".screen-container").text(`${provider.name} 无结果`); - } catch (error) { - $(".screen-container").text(`${provider.name} 请求失败`); - clog.error("截图源请求失败", error); - } - })); - } - async getScreenshot(e2) { - e2 = normalizeCarNum(e2); - if (!e2) throw clog.warn("跳过缩略图解析:番号不可用"), new Error("缩略图番号不可用"); - await this.initializeProviders(); - return this.getScreenshotFromInitializedProviders(e2); - } - async getScreenshotFromInitializedProviders(e2) { - e2 = normalizeCarNum(e2); - if (!e2) throw new Error("缩略图番号不可用"); - localStorage.removeItem("jhs_screenShot"); - let n2; - try { - n2 = await this.providerRegistry.first(e2); - } catch (i2) { - throw clog.error("获取缩略图资源失败:", n2, i2), i2; - } - if (!n2) return null; - let url = n2.url, a2 = url.indexOf("https://"); - return -1 !== a2 && (url = url.substring(a2)), clog.log(`缩略图获取成功 (${n2.source}):`, url), url; - } - async getCachedProviderScreenshot(provider, carNum, loader) { - const value = await storageManager.cachedRequest(`screenshot:${provider}:${carNum}`, CACHE_TTL.screenshot, (async () => { - const loadedUrl = await loader(), url2 = "javstore" === provider ? normalizeJavStoreAssetUrl(loadedUrl) : loadedUrl; - return url2 ? { __jhsCacheTtl: CACHE_TTL.screenshot, data: { url: url2 } } : { __jhsCacheTtl: CACHE_TTL.screenshotNegative, data: { miss: true } }; - })); - if (!value || value.miss) return null; - const cachedUrl = "string" === typeof value ? value : value.url, url = "javstore" === provider ? normalizeJavStoreAssetUrl(cachedUrl) : cachedUrl; - return url ? { url, source: provider, detailUrl: null } : null; - } - async getJavStoreScreenShot(e2) { - const t2 = `https://javstore.net/search?q=${encodeURIComponent(e2)}`; - clog.debug("JavStore 搜索地址:", t2); - let n2 = await gmHttp.get(t2, {}, {}, false, { ignoreNotFound: true }); - if (!n2) return clog.debug("JavStore 搜索页未获取:", t2), null; - const a2 = utils.htmlTo$dom(n2); - const i2 = parseJavStoreSearch(a2, e2); - if (!i2.length) return clog.debug("JavStore, 查询番号无结果:", t2), null; - for (const e3 of i2) { - const t3 = e3; - clog.debug("JavStore 候选详情:", t3); - const n3 = await gmHttp.get(t3, {}, {}, false, { ignoreNotFound: true }); - if (!n3) { - clog.debug("JavStore 详情页未获取:", t3); - continue; - } - const a3 = parseJavStorePreview(utils.htmlTo$dom(n3), t3); - if (!a3) { - clog.debug("JavStore 详情页没有 CLICK HERE!:", t3); - continue; - } - return clog.debug("JavStore 预览图:", a3), a3; - } - return clog.debug("JavStore, 所有候选均无有效预览图:", t2), null; - } - addImg(e2, t2) { - const url = normalizeJavStoreAssetUrl(t2); - url && (r && $(".screen-container").html(`${e2}`), l && $(".screen-container").html(`
    ${e2}
    `), $(".screen-container").on("click", ((e3) => { - e3.stopPropagation(), e3.preventDefault(), showImageViewer(e3.currentTarget); - }))); - } - showErrorFallback(e2, t2) { - var n2; - clog.error("获取缩略图失败:", null == (n2 = null == t2 ? void 0 : t2.message) ? void 0 : n2.substring(0, 100)); - const a2 = `jhs-screenshot-message${l ? " jhs-screenshot-message--bus" : ""}`; - if (!(e2 = normalizeCarNum(e2))) return void $(".screen-container").empty().append($("
    ").addClass(a2).text("无法获取番号,缩略图未加载")); - const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(e2)}`; - $(".screen-container").html(`
    获取缩略图失败

    点击重试 或 前往确认`).off("click", ".retry-link").off("click", ".check-link").on("click", ".retry-link", (async (t3) => { - t3.stopPropagation(), t3.preventDefault(), $(".screen-container").html(`
    正在重新加载...
    `); - try { - const t4 = await this.getScreenshot(e2); - this.addImg("缩略图", t4); - } catch (n3) { - this.showErrorFallback(e2, n3); - } - })).on("click", ".check-link", (async (t3) => { - t3.stopPropagation(), t3.preventDefault(), window.open(searchUrl, "_blank"); - })); - } - /** 将截图状态与结果限制在指定容器内,供自有详情工作区使用。 */ - async loadInto(target, carNum, { isActive = /* @__PURE__ */ __name(() => true, "isActive") } = {}) { - const host = $(target); - if (!host.length || "yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return host.empty(), null; - const mode = await this.initializeProviders(), renderMessage = /* @__PURE__ */ __name((message) => isActive() && host.empty().append($("
    ").addClass("jhs-panel-state").text(message)), "renderMessage"); - if ("manual" === mode) { - host.empty(); - const tabs = $('
    '), result = $('
    ').text("请选择截图来源"); - this.providerRegistry.providers.forEach(((provider) => tabs.append($('').prop("disabled", !provider.enabled).attr("data-provider", provider.id).text(provider.name)))); - host.append(tabs, result), tabs.on("click", "button:not(:disabled)", (async (event) => { - const provider = this.providerRegistry.get($(event.currentTarget).data("provider")); - result.text(`${provider.name} 加载中…`); - try { - const loaded = await provider.getScreenshot(normalizeCarNum(carNum)); - if (!isActive()) return; - loaded?.url ? this.renderInto(result, loaded.url, `${provider.name} 缩略图`) : result.text(`${provider.name} 无结果`); - } catch (error) { - isActive() && result.text(`${provider.name} 请求失败`), clog.error("截图源请求失败", error); - } - })); - return host; - } - renderMessage("正在加载缩略图…"); - try { - const url = await this.getScreenshotFromInitializedProviders(carNum); - if (!isActive()) return null; - if (!url) return host.empty(), null; - return this.renderInto(host, url, "缩略图"), url; - } catch (error) { - if (!isActive()) return null; - host.empty(); - const state = $('
    ').text("缩略图加载失败 "), retry = $(''); - retry.on("click", (() => void this.loadInto(host, carNum, { isActive }))), host.append(state.append(retry)); - return clog.error("缩略图加载失败", error), null; - } - } - renderInto(target, url, alt) { - const host = $(target), image = $("").attr({ src: normalizeJavStoreAssetUrl(url), alt, loading: "lazy" }).addClass("jhs-fc2-gallery__image"), button = $('').attr("aria-label", `查看${alt}大图`).append(image); - host.empty().append(button).off("click.jhsScreenshot").on("click.jhsScreenshot", ".jhs-fc2-screenshot-thumbnail", ((event) => { - event.preventDefault(), event.stopPropagation(), showImageViewer(image[0]); - })); - } - }; - __name(_ScreenShotPlugin, "ScreenShotPlugin"); - var ScreenShotPlugin = _ScreenShotPlugin; - var _FavoriteActressesPlugin = class _FavoriteActressesPlugin extends BasePlugin { - getName() { - return "FavoriteActressesPlugin"; - } - async handle() { - this.bindEvent(), await this.highlightActress(), this.replaceActressAvatar(); - } - async highlightActress() { - if (!isDetailPage) return; - if (await storageManager.getSetting("enableFavoriteActresses", _) !== _) return; - const e2 = await storageManager.getFavoriteActressList(); - if (!e2 || 0 === e2.length) return; - const t2 = /* @__PURE__ */ new Set(); - e2.forEach(((e3) => { - e3.starId && t2.add(String(e3.starId).trim()); - })), 0 !== t2.size && $(".female").prev().each(((e3, n2) => { - const a2 = $(n2), i2 = a2.attr("href"); - let s2 = null; - if (i2) { - const e4 = (i2.endsWith("/") ? i2.slice(0, -1) : i2).split("/"), t3 = e4[e4.length - 1]; - t3 && (s2 = t3.trim()); - } - let o2 = false; - s2 && (o2 = t2.has(s2)), o2 && (a2.addClass("highlighted"), a2.attr("title", "高亮已收藏演员, 可在设置-基础配置中关闭")); - })); - } - async removeActorFromStorage(e2) { - await storageManager.removeFavoriteActress(e2) && (clog.log("移除演员成功"), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(e2) } }))); - } - bindEvent() { - const e2 = /\/actors\/(\w+)\/(collect|uncollect)/; - $(document).on("confirm:complete", 'a[href*="/actors/"][href*="/uncollect"]', (async (t2) => { - const [n2] = t2.detail; - if (!n2) return; - const a2 = $(t2.currentTarget).attr("href").match(e2), i2 = a2 ? a2[1] : null; - i2 && await this.removeActorFromStorage(i2); - })), $("#button-collect-actor").click((async (t2) => { - const n2 = $("#button-collect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; - let i2 = [], s2 = $(".actor-section-name"); - s2.length && s2.text().trim().split(",").forEach(((e3) => { - i2.push(e3.trim()); - })); - let o2 = $(".section-meta:not(:contains('影片'))"); - if (o2.length && o2.text().trim().split(",").forEach(((e3) => { - i2.push(e3.trim()); - })), !i2.length) return void clog.error("获取演员名称失败"); - const r2 = i2[0]; - if (!a2) return void clog.error("无法获取演员ID进行收藏操作。"); - const l2 = ($(".avatar").first().css("background-image") || "").replace(/^url\(["']?|["']?\)$/g, ""), c2 = { - starId: a2, - name: r2, - allName: i2, - avatar: l2 - }; - 1 === await storageManager.addFavoriteActressList([c2]) ? (clog.log(`收藏演员成功: ${r2} (ID: ${a2})`), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(a2) } }))) : clog.log(`收藏演员失败: ${r2} (ID: ${a2})`); - })), $("#button-uncollect-actor").click((async (t2) => { - const n2 = $("#button-uncollect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; - a2 ? await this.removeActorFromStorage(a2) : clog.error("无法获取演员ID进行取消收藏操作。"); - })); - } - async replaceActressAvatar() { - const e2 = this.getActressId(); - if (!e2) return; - const t2 = (await storageManager.getFavoriteActressList()).find(((t3) => t3.starId === e2)); - if (t2 && t2.avatar) { - const e3 = `url('${t2.avatar}')`; - let n2 = $(".avatar").first(); - if (0 === n2.length) { - const e4 = '
    '; - $(".section-columns").prepend(e4), n2 = $(".avatar").first(); - } - if (0 === n2.length) return; - n2.css("background-image").trim().toLowerCase() !== e3.trim().toLowerCase() && (n2.css("background-image", e3), n2.css("background-size", "cover"), n2.css("background-position", "top center"), n2.css("background-repeat", "no-repeat")); - } - } - }; - __name(_FavoriteActressesPlugin, "FavoriteActressesPlugin"); - var FavoriteActressesPlugin = _FavoriteActressesPlugin; - var _BusImgPlugin = class _BusImgPlugin extends BasePlugin { - getName() { - return "BusImgPlugin"; - } - handle() { - } - async getVisibleImageItems(e2, t2) { - let n2 = []; - const a2 = document.querySelectorAll(e2); - for (const i2 of a2) { - if (!utils.isHidden(i2)) { - const e3 = i2.querySelector(t2); - if (!(e3 instanceof HTMLImageElement)) continue; - e3.style.removeProperty("height"); - let a3 = e3.offsetHeight; - a3 > 0 && n2.push({ - element: i2, - imgElement: e3, - height: a3 - }); - } - } - return n2; - } - async logImageHeightsByRow() { - if (await storageManager.getSetting("enableVerticalModel", C) === _) return; - const e2 = this.getSelector().itemSelector, t2 = await storageManager.getSetting("containerColumns", 5), n2 = await this.getVisibleImageItems(e2, "img"); - if (0 === n2.length) return; - const a2 = []; - for (let i2 = 0; i2 < n2.length; i2++) { - const e3 = Math.floor(i2 / t2); - a2[e3] || (a2[e3] = []), a2[e3].push(n2[i2]); - } - a2.forEach(((e3, t3) => { - const n3 = e3.map(((e4) => e4.height)); - if (n3.length < 2) return; - const a3 = Math.min(...n3), i2 = Math.max(...n3); - let s2 = 0; - i2 - a3 > 50 && (s2 = a3, e3.forEach(((e4) => { - if (e4.height !== s2) { - const t4 = `${s2}px`; - e4.imgElement.style.setProperty("height", t4, "important"); - } - }))); - })); - } - }; - __name(_BusImgPlugin, "BusImgPlugin"); - var BusImgPlugin = _BusImgPlugin; - var _TranslatePlugin = class _TranslatePlugin extends BasePlugin { - getName() { - return "TranslatePlugin"; - } - async initCss() { - return "\n "; - } - handle() { - isDetailPage && this.translate(); - } - async translate(e2, t2 = true, options = {}) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; - l && (t2 = false); - const root = options.root ? $(options.root) : $(document); - let n2 = root.find(".origin-title").first(); - if (n2.length || (n2 = root.find(".current-title").first()), n2.length || (n2 = root.find("h3").first()), !n2.length) return; - const a2 = n2.text().trim(); - if (!a2) return void show.error("获取标题失败, 无法进行翻译"); - let i2 = n2.nextAll(".translated-title").first(); - i2.length || (i2 = $('
    ').insertAfter(n2)), i2.removeClass("is-error").text("翻译中..."); - e2 || (e2 = this.getPageInfo().carNum); - const s2 = "string" == typeof e2 ? e2.trim() : "", o2 = s2 && "undefined" !== s2 ? s2 : a2; - let r2 = {}; - try { - const e3 = localStorage.getItem("jhs_translate"); - e3 && (r2 = JSON.parse(e3) || {}); - } catch (l2) { - clog.warn("翻译缓存无法解析,已忽略旧缓存", l2); - } - if (r2[o2]) return void i2.text(r2[o2]); - try { - const e3 = await _e(a2, "ja", "zh-CN"); - if (!n2[0]?.isConnected) return; - i2.text(e3), r2[o2] = e3, localStorage.setItem("jhs_translate", JSON.stringify(r2)); - } catch (l2) { - clog.error("翻译失败:", l2), i2.addClass("is-error").text(`翻译失败: ${l2.message || String(l2)}`); - } - } - }; - __name(_TranslatePlugin, "TranslatePlugin"); - var TranslatePlugin = _TranslatePlugin; - var _TaskPlugin = class _TaskPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), i(this, "taskConfig", null), i(this, "storageQueue", new StorageQueue()), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"), i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), i(this, "taskTimer", null), i(this, "taskRunning", false), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), i(this, "settingsHandler", null), i(this, "taskConfigDirty", false), i(this, "configLoadPromise", null), i(this, "configLoadQueued", false), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", false), i(this, "activeTasks", /* @__PURE__ */ new Set()); - } - getName() { - return "TaskPlugin"; - } - getStartupMode() { - return "idle"; - } - async limitConcurrency(e2, t2, n2, a2) { - let i2 = 0, s2 = false; - const o2 = Math.max(1, Math.min(t2, e2.length)), r2 = Array.from({ length: o2 }, (async () => { - for (; !s2; ) { - const t3 = i2++; - if (t3 >= e2.length) return; - try { - await a2(e2[t3]); - } catch (e3) { - if (this.isNetworkBlocked(e3)) throw s2 = true, e3; - throw e3; - } - const o3 = e2.length - i2; - o3 > 0 && (clog.debug(`剩余任务数: ${o3}`), await utils.sleep(n2)); - } - })); - const l2 = await Promise.allSettled(r2), c2 = l2.find(((e3) => "rejected" === e3.status)); - if (c2) throw c2.reason; - } - isNetworkBlocked(e2) { - return true === e2?._cfBlocked || true === e2?._circuitBroken; - } - createTaskResult(extra = {}) { - return { attempted: false, completed: false, fatal: false, success: 0, networkFailed: 0, parseFailed: 0, aborted: 0, skippedInterval: 0, skippedStopped: 0, ...extra }; - } - async runBackgroundTask(name, runner) { - try { - return await runner(); - } catch (error) { - if (this.isNetworkBlocked(error)) throw error; - return clog.error(`${name}执行失败,继续后续后台任务`, error), this.createTaskResult({ attempted: true, error }); - } - } - isUnnecessaryCheck(e2, t2) { - if (!t2) throw new Error("未传入checkIntervalTime"); - t2 = parseInt(t2); - return utils.getHourDifference(new Date(e2), /* @__PURE__ */ new Date()) < t2; - } - getTaskSchedule(name) { - const schedules = { - blacklist: { completedKey: this.lastCheckBlacklistTimeKey, attemptKey: this.lastCheckBlacklistAttemptKey, nextKey: this.lastCheckBlacklistNextKey, intervalSetting: "checkBlacklist_intervalTime", defaultInterval: 12 }, - favoriteActress: { completedKey: this.lastCheckFavoriteActressTimeKey, attemptKey: this.lastCheckFavoriteActressAttemptKey, nextKey: this.lastCheckFavoriteActressNextKey, intervalSetting: "checkFavoriteActress_IntervalTime", defaultInterval: 24 }, - newVideo: { completedKey: this.lastCheckNewVideoTimeKey, attemptKey: this.lastCheckNewVideoAttemptKey, nextKey: this.lastCheckNewVideoNextKey, intervalSetting: "checkNewVideo_intervalTime", defaultInterval: 12 } - }; - if (!schedules[name]) throw new Error(`未知任务调度: ${name}`); - return schedules[name]; - } - getTaskScheduleState(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); - return { ...schedule, completed, attempt, pending: null != attempt && (null == completed || attempt > completed) }; - } - /** 返回当前标签页可证明的任务状态,不写入调度数据。 */ - getTaskStatusSnapshot(name) { - const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(localStorage.getItem(state.nextKey)); - const nextAt = null == storedNext ? state.pending && null != state.attempt ? state.attempt + 3e5 : 0 : storedNext, now = Date.now(); - let status = "idle"; - if (this.activeTasks.has(name)) status = "running"; - else if (state.pending && now < nextAt) status = "pending"; - else if (null == storedNext || now >= nextAt) status = "due"; - return { name, state: status, completedAt: state.completed, attemptAt: state.attempt, nextAt, isPending: state.pending, pendingUntil: state.pending ? nextAt : null }; - } - async emitTaskStatus(name, phase) { - try { - await globalThis.jhsEventBus?.emit?.("task-status-changed", { taskName: name, phase }, { broadcast: false }); - } catch (error) { - clog.error(`任务状态通知失败: ${name}/${phase}`, error); - } - } - async emitNewVideoChanged(reason, carNums = []) { - try { - await globalThis.jhsEventBus?.emit?.("new-video-changed", { reason, carNums: carNums.map(normalizeCarNum).filter(Boolean) }); - } catch (error) { - clog.error("新作品状态通知失败", error); - } - } - async withActiveTask(name, runner) { - this.activeTasks.add(name); - try { - await this.emitTaskStatus(name, "started"); - return await runner(); - } finally { - this.activeTasks.delete(name), await this.emitTaskStatus(name, "finished"); - } - } - async getLatestTaskInterval(name) { - const schedule = this.getTaskSchedule(name); - storageManager._invalidateCache?.(storageManager.setting_key); - return parseNumberSetting(await storageManager.getSetting(schedule.intervalSetting, schedule.defaultInterval), schedule.defaultInterval, { min: Number.EPSILON }); - } - async shouldStartTask(name, force = false) { - if (force) return true; - const schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); - const completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); - let next = parseTaskTimestamp(localStorage.getItem(schedule.nextKey)); - if (null == next) { - const pending = null != attempt && (null == completed || attempt > completed); - next = pending ? attempt + 3e5 : null == completed ? 0 : completed + 36e5 * interval; - localStorage.setItem(schedule.nextKey, String(next)); - } - return Date.now() >= next; - } - beginTaskAttempt(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)); - const now = Math.floor(Date.now() / 1e3) * 1e3, attempt = null == completed ? now : Math.max(now, completed + 1e3); - localStorage.setItem(schedule.attemptKey, String(attempt)), localStorage.setItem(schedule.nextKey, String(attempt + 3e5)); - return attempt; - } - async finalizeTask(name, completed) { - const schedule = this.getTaskSchedule(name); - if (!completed) return localStorage.setItem(schedule.nextKey, String(Date.now() + 3e5)); - const attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); - localStorage.setItem(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), localStorage.setItem(schedule.nextKey, String(completedAt + 36e5 * interval)); - } - async recalculateSchedules() { - await this.loadConfig(); - for (const [name, interval] of [["blacklist", this.taskConfig.checkBlacklist_intervalTime], ["favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime], ["newVideo", this.taskConfig.checkNewVideo_intervalTime]]) { - const state = this.getTaskScheduleState(name); - if (state.pending) continue; - localStorage.setItem(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); - } - } - async invalidateConfig(recalculate = false) { - this.taskConfigDirty = true; - if (!recalculate) return; - if (this.configRefreshPromise) return this.configRefreshQueued = true, this.configRefreshPromise; - return this.configRefreshPromise = (async () => { - do { - this.configRefreshQueued = false, await this.recalculateSchedules(); - } while (this.configRefreshQueued); - })().finally((() => { - this.configRefreshPromise = null; - })), this.configRefreshPromise; - } - handle() { - if (!window.isListPage) return; - this.visibilityHandler || (this.visibilityHandler = () => { - document.hidden ? this.clearSchedule() : this.scheduleTask(0); - }, this.pageHideHandler = () => this.clearSchedule(), document.addEventListener("visibilitychange", this.visibilityHandler), window.addEventListener("pagehide", this.pageHideHandler)); - this.settingsHandler || (this.settingsHandler = async () => { - try { - storageManager._invalidateCache?.(storageManager.setting_key), await this.invalidateConfig(true), this.scheduleTask(0); - } catch (error) { - clog.error("任务设置刷新失败", error); - } - }, globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler)); - return document.hidden ? void 0 : this.runAndSchedule(); - } - clearSchedule() { - this.taskTimer && (clearTimeout(this.taskTimer), this.taskTimer = null); - } - scheduleTask(e2 = 3e5) { - if (!window.isListPage || document.hidden) return void this.clearSchedule(); - this.clearSchedule(), this.taskTimer = setTimeout((() => { - this.taskTimer = null, void this.runAndSchedule(); - }), e2); - } - async runAndSchedule() { - if (this.taskRunning || !window.isListPage || document.hidden) return; - this.taskRunning = true; - try { - await this.doTask(); - } finally { - this.taskRunning = false, this.scheduleTask(); - } - } - async doTask() { - if (!window.isListPage) return; - await this.loadConfig(), this.javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(); - return navigator.locks.request(this.singleTaskKey, { - ifAvailable: true - }, (async (e2) => { - if (e2) { - if (window.isListPage && (this.taskConfig.enableCheckBlacklist === _ ? await this.runBackgroundTask("黑名单整批检测", (() => this.checkBlacklist())) : clog.warn("自动检测屏蔽黑名单-禁用"), !l)) { - if (this.taskConfig.enableCheckFavoriteActress === _) { - $('a[href*="/users/profile"]').length > 0 ? await this.runBackgroundTask("演员收藏同步", (() => this.checkFavoriteActress())) : clog.debug("未登录 JavDB,跳过自动同步演员"); - } else clog.warn("自动同步已收藏的演员-禁用"); - this.taskConfig.enableCheckNewVideo === _ ? await this.runBackgroundTask("新作品整批检测", (() => this.checkNewVideo())) : clog.warn("自动检测已收藏演员的最新作品-禁用"); - } - } else clog.debug("争夺任务锁失败, 跳过执行"); - })).catch(((e2) => { - this.isNetworkBlocked(e2) ? clog.warn(`后台检测已停止: ${e2.message}`) : clog.error("锁任务出现错误:", e2); - })); - } - async loadConfig() { - if (this.configLoadPromise) return this.configLoadQueued = true, this.configLoadPromise; - return this.configLoadPromise = (async () => { - do { - this.configLoadQueued = false; - const e2 = await storageManager.getSetting(), nextConfig = { - checkConcurrencyCount: parseNumberSetting(e2.checkConcurrencyCount, 2, { min: 2, max: 5 }), - checkRequestSleep: parseNumberSetting(e2.checkRequestSleep, 100, { min: 0, max: 3e3 }), - enableCheckBlacklist: e2.enableCheckBlacklist || _, - checkBlacklist_intervalTime: parseNumberSetting(e2.checkBlacklist_intervalTime, 12, { min: Number.EPSILON }), - checkBlacklist_ruleTime: parseNumberSetting(e2.checkBlacklist_ruleTime, 8760, { min: 0 }), - enableCheckFavoriteActress: e2.enableCheckFavoriteActress || _, - checkFavoriteActress_IntervalTime: parseNumberSetting(e2.checkFavoriteActress_IntervalTime, 24, { min: Number.EPSILON }), - enableCheckNewVideo: e2.enableCheckNewVideo || _, - checkNewVideo_intervalTime: parseNumberSetting(e2.checkNewVideo_intervalTime, 12, { min: Number.EPSILON }), - checkNewVideo_ruleTime: parseNumberSetting(e2.checkNewVideo_ruleTime, 8760, { min: 0 }) - }; - this.taskConfig = nextConfig, this.taskConfigDirty = false; - } while (this.configLoadQueued || this.taskConfigDirty); - return this.taskConfig; - })().finally((() => { - this.configLoadPromise = null; - })), this.configLoadPromise; - } - /** 确保所有任务入口均已具备配置和站点地址。 */ - async ensureReady() { - (!this.taskConfig || this.taskConfigDirty) && await this.loadConfig(), this.javDbUrl || (this.javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl()); - if (!this.javDbUrl) throw new Error("JavDB 地址未配置"); - } - async resolveBlacklistSite(url) { - try { - const target = new URL(url), otherSite = this.getBean("OtherSitePlugin"), [javDbUrl, javBusUrl] = await Promise.all([otherSite?.getJavDbUrl?.(), otherSite?.getJavBusUrl?.()]); - for (const [configuredUrl, site2] of [[javDbUrl, T], [javBusUrl, I]]) { - try { - if (configuredUrl && target.hostname === new URL(configuredUrl).hostname) return site2; - } catch (error) { - clog.warn(`忽略无效的站点配置: ${configuredUrl}`, error); - } - } - const { site } = detectSite(url); - return [T, I].includes(site) ? site : null; - } catch (error) { - return null; - } - } - async checkBlacklist(force = false) { - await this.ensureReady(); - const result = this.createTaskResult({ skippedHost: 0 }); - if (!await this.shouldStartTask("blacklist", force)) return clog.debug("检测黑名单未到整批执行时间"), result; - this.beginTaskAttempt("blacklist"), result.attempted = true; - return this.withActiveTask("blacklist", async () => { - let blockedError = null, finalized = false; - try { - let items = await storageManager.getBlacklist(); - items = items.sort(((left, right) => left.createTime < right.createTime ? 1 : left.createTime > right.createTime ? -1 : 0)); - const concurrency = this.taskConfig.checkConcurrencyCount, sleep = this.taskConfig.checkRequestSleep, interval = this.taskConfig.checkBlacklist_intervalTime, rule = this.taskConfig.checkBlacklist_ruleTime; - const eligible = [], currentHostname = new URL(window.location.href).hostname; - for (const item of items) { - let itemUrl; - try { - itemUrl = new URL(item.url); - } catch (error) { - result.parseFailed++, clog.error("黑名单地址无效:", item.url, error); - continue; - } - if (currentHostname !== itemUrl.hostname) { - result.skippedHost++; - continue; - } - if (!force && item.checkTime && this.isUnnecessaryCheck(item.checkTime, interval)) result.skippedInterval++; - else if (shouldSkipStopped(item.lastPublishTime, rule)) result.skippedStopped++; - else { - const site = await this.resolveBlacklistSite(itemUrl.href); - site ? eligible.push({ item, site }) : (result.parseFailed++, clog.error(`不支持的黑名单来源站点: ${itemUrl.hostname}`)); - } - } - clog.log(`检测屏蔽黑名单, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); - const blacklistPlugin = this.getBean("BlacklistPlugin"); - try { - await this.limitConcurrency(eligible, concurrency, sleep, (async (entry) => { - const { item, site } = entry; - const { starId, name, url } = item; - let responseText; - try { - clog.log("正在检屏黑名单演员:", name, url), $("#checkBlacklistMsg").text(`正在检屏黑名单演员: ${name} ${url}`), responseText = await gmHttp.get(url); - } catch (error) { - result.networkFailed++; - if (this.isNetworkBlocked(error)) throw error; - return void clog.error("检测屏蔽演员网络错误:", url, error); - } - try { - const page = utils.htmlTo$dom(responseText); - await this.storageQueue.addTask((async () => { - const parsed = await blacklistPlugin.parseAndSaveFilterInfo(page, name, starId, site); - await storageManager.updateBlacklistItem({ starId, name, checkTime: utils.getNowStr(), lastPublishTime: parsed.lastPublishTime }); - })), result.success++; - } catch (error) { - result.parseFailed++, clog.error("解析或保存黑名单演员失败:", url, error); - } - })), await this.storageQueue.waitAllFinished(); - } catch (error) { - if (!this.isNetworkBlocked(error)) throw error; - blockedError = error, result.aborted = Math.max(0, eligible.length - result.success - result.parseFailed - result.networkFailed); - } - const completed = 0 === result.parseFailed + result.networkFailed + result.aborted; - result.completed = completed, result.fatal = !!blockedError, await this.finalizeTask("blacklist", completed), finalized = true, this.renderBlacklistResult(result, completed); - try { - await this.getBean("BlacklistPlugin").resetBtnTip(); - } catch (error) { - clog.error("刷新黑名单检测提示失败", error); - } - if (blockedError) throw blockedError; - return result; - } catch (error) { - finalized || await this.finalizeTask("blacklist", false), result.completed = false, result.fatal = this.isNetworkBlocked(error); - if (result.fatal) throw error; - return result.parseFailed++, clog.error("黑名单整批检测失败", error), result; - } - }); - } - renderBlacklistResult(result, completed) { - const retry = completed ? "" : ",5 分钟后补偿未完成项", message = `黑名单整批检测:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval},异站跳过 ${result.skippedHost}${result.aborted ? `,未执行 ${result.aborted}` : ""}${retry}`; - $("#checkBlacklistMsg").text(message), clog.log(message); - } - async checkFavoriteActress(force = false) { - await this.ensureReady(); - const result = this.createTaskResult({ pages: 0, actorCount: 0 }); - if (!await this.shouldStartTask("favoriteActress", force)) return clog.debug("同步收藏演员未到整批执行时间"), result; - this.beginTaskAttempt("favoriteActress"), result.attempted = true; - return this.withActiveTask("favoriteActress", async () => { - try { - const actors = [], sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); - actors.length > 0 && await storageManager.addFavoriteActressList(actors), result.success = actors.length, result.actorCount = actors.length, result.pages = sync.pages; - await this.finalizeTask("favoriteActress", true), result.completed = true, clog.log("所有演员信息已收集, 总计数量:", actors.length), $("#checkNewVideoMsg").text(`完整同步完成:演员 ${actors.length},页面 ${sync.pages}`); - actors.length > 0 && await this.emitNewVideoChanged("favorite-actress-sync"); - return result; - } catch (error) { - result.completed = false, error?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(error), await this.finalizeTask("favoriteActress", false), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", error); - if (result.fatal) throw error; - return result; - } - }); - } - async scrapeActorInfo(startUrl, target = []) { - const expected = new URL("/users/collection_actors", this.javDbUrl), visitedUrls = /* @__PURE__ */ new Set(); - let currentUrl = new URL(startUrl, this.javDbUrl), pages = 0; - for (; currentUrl; ) { - try { - if (currentUrl.origin !== expected.origin || currentUrl.pathname !== expected.pathname) throw new Error(`收藏演员分页地址越界: ${currentUrl.href}`); - if (visitedUrls.has(currentUrl.href)) throw new Error(`收藏演员分页循环: ${currentUrl.href}`); - if (pages >= 200) throw new Error("收藏演员分页超过 200 页"); - visitedUrls.add(currentUrl.href), pages++, clog.log(`正在抓取页面: ${currentUrl.href}`), $("#checkNewVideoMsg").text(`正在解析已收藏的演员: ${currentUrl.href}`); - let responseText; - try { - responseText = await gmHttp.get(currentUrl.href); - } catch (error) { - throw error._taskNetwork = true, error; - } - const page = utils.htmlTo$dom(responseText), parsed = parseJavDbActorList(page, currentUrl.href); - if ("valid" !== parsed.state) throw Object.assign(new Error(`收藏演员页面无效: ${parsed.state}`), { _taskParse: true }); - if (parsed.isEmpty && parsed.nextUrl) throw Object.assign(new Error("收藏演员空页面包含下一页"), { _taskParse: true }); - target.push(...parsed.actors), currentUrl = parsed.nextUrl ? new URL(parsed.nextUrl, currentUrl.href) : null; - } catch (error) { - error._taskNetwork || Object.prototype.hasOwnProperty.call(error, "_taskParse") || (error._taskParse = true); - throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, error), error; - } - } - return { actors: target, pages }; - } - async checkNewVideo(force = false) { - await this.ensureReady(); - const result = this.createTaskResult(); - if (!await this.shouldStartTask("newVideo", force)) return clog.debug("检测新作品未到整批执行时间"), result; - this.beginTaskAttempt("newVideo"), result.attempted = true; - return this.withActiveTask("newVideo", async () => { - let blockedError = null, finalized = false; - try { - const actresses = await storageManager.getFavoriteActressList(), sorted = utils.genericSort(actresses, [{ - key: /* @__PURE__ */ __name((actress) => actress.newVideoList?.length ?? 0, "key"), - order: "desc" - }, { - key: "lastPublishTime", - order: "desc" - }]), concurrency = this.taskConfig.checkConcurrencyCount, sleep = this.taskConfig.checkRequestSleep, interval = this.taskConfig.checkNewVideo_intervalTime, rule = this.taskConfig.checkNewVideo_ruleTime; - const eligible = []; - for (const actress of sorted) { - if (!force && actress.lastCheckTime && this.isUnnecessaryCheck(actress.lastCheckTime, interval)) result.skippedInterval++; - else if (shouldSkipStopped(actress.lastPublishTime, rule)) result.skippedStopped++; - else eligible.push(actress); - } - clog.log(`检测最新作品, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); - if (eligible.length > 0) { - const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map(((item) => item.carNum))); - try { - await this.limitConcurrency(eligible, concurrency, sleep, (async (actress) => { - const { name, starId } = actress, url = `${this.javDbUrl}/actors/${starId}?t=d`; - try { - clog.log("正在检测最新作品, 演员:", name, url), $("#checkNewVideoMsg").text(`正在检测最新作品, 演员: ${name}`); - const responseText = await gmHttp.get(url), page = utils.htmlTo$dom(responseText); - try { - await this.storageQueue.addTask((async () => this.parsePage(page, T, starId, name, titleKeywords, blacklistSet))), result.success++; - } catch (error) { - result.parseFailed++, clog.error("解析或保存演员作品失败:", url, error); - } - } catch (error) { - if (this.isNetworkBlocked(error)) throw result.networkFailed++, error; - result.networkFailed++, clog.error("检测演员信息发生网络错误:", url, error); - } - })), await this.storageQueue.waitAllFinished(); - } catch (error) { - if (!this.isNetworkBlocked(error)) throw error; - blockedError = error, result.fatal = true, result.aborted = Math.max(0, eligible.length - result.success - result.parseFailed - result.networkFailed), clog.warn(`网络阻断,本轮停止,未执行 ${result.aborted}`); - } - } - const completed = 0 === result.parseFailed + result.networkFailed + result.aborted; - result.completed = completed, await this.finalizeTask("newVideo", completed), finalized = true, clog.log('检测最新作品---结束'), this.renderCheckResult(result, completed ? actresses.length ? "整批检测结束" : "收藏为空,整批检测完成" : "整批检测未完成,5 分钟后补偿未完成项"); - result.success > 0 && await this.emitNewVideoChanged("task-completed"); - if (blockedError) throw blockedError; - return result; - } catch (error) { - finalized || await this.finalizeTask("newVideo", false), result.completed = false, result.fatal = this.isNetworkBlocked(error); - if (result.fatal) throw error; - return result.parseFailed++, this.renderCheckResult(result, "整批检测失败,5 分钟后补偿"), clog.error("新作品整批检测失败", error), result; - } - }); - } - renderCheckResult(result, prefix = "检测结束") { - const message = `${prefix}:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval}${result.aborted ? `,未执行 ${result.aborted}` : ""}`; - $("#checkNewVideoMsg").text(message), clog.log(message); - } - async parsePage(e2, site, t2, n2, a2, i2) { - const selector = this.getSelector(site); - site === I && e2.find(".avatar-box").length > 0 && e2.find(".avatar-box").parent().remove(); - const pageState = parseDetailPage(e2, { - boxSelector: site === I ? `${selector.boxSelector}, #waterfall` : selector.boxSelector, - requestDomItemSelector: selector.requestDomItemSelector - }); - if ("valid" !== pageState.state) throw clog.error("新作品检测-解析列表失败"), new Error("新作品检测-解析列表失败"); - const s2 = pageState.items, o2 = e2.find(selector.nextPageSelector).attr("href"); - if (0 === s2.length && o2) throw new Error("新作品检测-空列表包含下一页"); - if (0 === s2.length) return await storageManager.updateFavoriteActress({ - starId: t2, - lastCheckTime: utils.getNowStr(), - newVideoList: [] - }), 0; - let c2 = []; - const publishTimes = []; - for (const m2 of s2) { - const e3 = $(m2), { carNum: s3, url: o3, title: r2, publishTime: l2 } = this.getBean("ListPagePlugin").findCarNumAndHref(e3); - l2 && publishTimes.push(l2); - if (!s3) continue; - a2.find(((e4) => r2.includes(e4) || s3.includes(e4))) || (i2.has(s3) || (() => { - let coverUrl = e3.find("img").attr("src") || ""; - if (coverUrl && !coverUrl.startsWith("http")) { - coverUrl = coverUrl.startsWith("/") ? this.javDbUrl + coverUrl : this.javDbUrl + "/" + coverUrl; - } - let url = o3 || ""; - if (url && !url.startsWith("http")) { - url = url.startsWith("/") ? this.javDbUrl + url : this.javDbUrl + "/" + url; - } - const scoreText = e3.find(".score .value, .score").text().trim(); - const score = parseFloat(scoreText) || 0; - const voteMatch = scoreText.match(/由(\d+)人/); - let voteCount = 0; - if (voteMatch) { - voteCount = parseInt(voteMatch[1]); - } else { - const voteText = e3.find(".score .count, .meta .count").text().trim(); - voteCount = parseInt(voteText.replace(/[^\d]/g, "")) || 0; - } - c2.push({ carNum: s3, coverUrl, title: r2 || "", publishTime: l2 || "", score, voteCount, url }); - })()); - } - const d2 = selectLatestPublishTime(publishTimes), h2 = await storageManager.getCarMap(), p2 = c2.filter(((e3) => !h2.has(e3.carNum))); - p2.length > 0 && clog.log(`检测出新作品, ${n2}, 共${p2.length}部`), await storageManager.updateFavoriteActress({ - starId: t2, - lastCheckTime: utils.getNowStr(), - newVideoList: p2, - lastPublishTime: d2 - }); - return p2.length; - } - async checkOneNewVideo(e2) { - await this.ensureReady(); - const t2 = await storageManager.getTitleFilterKeyword(), n2 = await storageManager.getBlacklistCarList(), a2 = new Set(n2.map(((e3) => e3.carNum))), { lastCheckTime: i2, name: s2, starId: o2 } = e2; - let r2 = `${this.javDbUrl}/actors/${o2}?t=d`; - const l2 = $("#checkNewVideoMsg"); - try { - clog.log("正在检测最新作品, 演员:", s2, r2), l2.text(`正在检测最新作品, 演员: ${s2}`); - const e3 = await gmHttp.get(r2), n3 = utils.htmlTo$dom(e3); - await this.parsePage(n3, T, o2, s2, t2, a2), clog.log('检测最新作品---结束'), l2.text("检测完毕"); - await this.emitNewVideoChanged("single-actress-check"); - } catch (c2) { - clog.error("检测屏蔽演员信息, 发生错误:", r2, c2), show.error("检测屏蔽演员信息, 发生错误:" + c2, "bottom", "right"), l2.text(`检测屏蔽演员信息, 发生错误: ${r2}`); - } - } - }; - __name(_TaskPlugin, "TaskPlugin"); - var TaskPlugin = _TaskPlugin; - var tt = [{ - name: "jsDelivr (全球CDN)", - json: "https://cdn.jsdelivr.net/gh/gfriends/gfriends/Filetree.json", - base: "https://cdn.jsdelivr.net/gh/gfriends/gfriends/Content/" - }, { - name: "GitHub Raw (备用)", - json: "https://raw.githubusercontent.com/gfriends/gfriends/master/Filetree.json", - base: "https://raw.githubusercontent.com/gfriends/gfriends/master/Content/" - }]; - var nt = "jhs_img_cdn_index"; - var at = parseInt(localStorage.getItem(nt) || "0", 10); - (at >= tt.length || at < 0) && (at = 0); - var it = tt[at].json; - var st = tt[at].base; - var ot = "filetreeStore"; - var rt = "filetree_data"; - var lt = { - db: null, - async open() { - return this.db ? this.db : new Promise(((e2, t2) => { - const n2 = indexedDB.open("GfriendsAvatarDB", 1); - n2.onupgradeneeded = (e3) => { - this.db = e3.target.result, this.db.objectStoreNames.contains(ot) || this.db.createObjectStore(ot); - }, n2.onsuccess = (t3) => { - this.db = t3.target.result, e2(this.db); - }, n2.onerror = (e3) => { - clog.error("IndexedDB open error:", e3.target.errorCode), t2(new Error("Failed to open IndexedDB")); - }; - })); - }, - async get(e2) { - return await this.open(), new Promise(((t2) => { - const n2 = this.db.transaction([ot], "readonly").objectStore(ot).get(e2); - n2.onsuccess = () => t2(n2.result), n2.onerror = () => t2(null); - })); - }, - async set(e2, t2) { - return await this.open(), new Promise(((n2, a2) => { - const i2 = this.db.transaction([ot], "readwrite").objectStore(ot).put(t2, e2); - i2.onsuccess = () => n2(), i2.onerror = (e3) => { - clog.error("IndexedDB set error:", e3.target.errorCode), a2(new Error("Failed to write to IndexedDB")); - }; - })); - } - }; - var ct = null; - var dt = null; - function ht(e2) { - if (!e2 || !e2.Content) return null; - const t2 = {}, n2 = e2.Content; - for (const a2 in n2) { - const e3 = encodeURIComponent(a2); - for (const i2 in n2[a2]) { - let s2 = i2.replace(/\.jpg$/i, "").split("-")[0]; - s2.startsWith("AI-Fix-") && (s2 = s2.substring(7)); - const o2 = s2.toLowerCase().trim(); - if (o2.length > 0) { - const s3 = n2[a2][i2], r2 = s3.indexOf("?"); - let l2, c2 = ""; - r2 > -1 ? (l2 = encodeURIComponent(s3.substring(0, r2)), c2 = s3.substring(r2)) : l2 = encodeURIComponent(s3); - const d2 = `${st}${e3}/${l2}${c2}`; - t2[o2] || (t2[o2] = []), t2[o2].includes(d2) || t2[o2].push(d2); - } - } - } - return t2; - } - __name(ht, "ht"); - async function gt(e2) { - let t2 = loading(); - try { - await (async function() { - if (ct && dt) return ct; - let e3 = null; - try { - e3 = await lt.get(rt); - } catch (a3) { - clog.error("读取 IndexedDB 失败:", a3); - } - if (e3 && e3.Content && (ct = e3, dt = ht(e3), dt)) return ct; - show.info("正在载入头像数据源..."); - const t3 = await fetch(it); - if (!t3.ok) throw new Error(`请求头像源失败: ${t3.status}`); - const n3 = await t3.json(); - if (n3 && n3.Content) { - ct = n3, dt = ht(n3); - try { - await lt.set(rt, n3), clog.debug("载入头像数据源并写入缓存成功!"); - } catch (a3) { - clog.error(a3), show.error("头像数据源写入缓存失败,可能磁盘已满或其他权限问题。"); - } - return ct; - } - clog.error(n3); - throw new Error("解析头像数据源失败"); - })(); - } catch (i2) { - return show.error(i2), []; - } finally { - t2.close(); - } - if (!dt) return []; - const n2 = /* @__PURE__ */ new Set(), a2 = e2.map(((e3) => e3.toLowerCase().trim())).filter(((e3) => e3.length > 0)); - if (0 === a2.length) return []; - for (const s2 of a2) { - const e3 = dt[s2]; - e3 && e3.forEach(((e4) => n2.add(e4))); - } - return Array.from(n2); - } - __name(gt, "gt"); - function aggregateNewVideoRecords(actresses, carMap, decisions, now = Date.now()) { - const grouped = /* @__PURE__ */ new Map(); - for (const actress of actresses) { - if (!Array.isArray(actress.newVideoList)) continue; - for (const raw of actress.newVideoList) { - const item = "object" == typeof raw ? raw : {}, carNum = normalizeCarNum("string" == typeof raw ? raw : raw.carNum); - if (!carNum) continue; - const existing = grouped.get(carNum) || { carNum, coverUrl: "", title: "", publishTime: "", actresses: [], starIds: [], categories: /* @__PURE__ */ new Set(), score: 0, voteCount: 0, url: "", isVr: false }; - existing.coverUrl || (existing.coverUrl = item.coverUrl || ""), existing.title || (existing.title = item.title || ""), existing.publishTime = [existing.publishTime, item.publishTime || ""].sort().at(-1), existing.score = Math.max(existing.score, Number(item.score) || 0), existing.voteCount = Math.max(existing.voteCount, Number(item.voteCount) || 0), existing.url || (existing.url = item.url || ""); - existing.isVr || (existing.isVr = true === item.isVr || /(^|[^A-Z])VR([^A-Z]|$)/i.test(`${item.title || ""} ${(item.tags || []).join?.(" ") || ""} ${(item.categories || []).join?.(" ") || ""}`)); - actress.name && !existing.actresses.includes(actress.name) && existing.actresses.push(actress.name), actress.starId && !existing.starIds.includes(actress.starId) && existing.starIds.push(actress.starId), actress.actressType && existing.categories.add(actress.actressType), grouped.set(carNum, existing); - } - } - return [...grouped.values()].map(((item) => { - const record = carMap.get(item.carNum), flags = normalizeStateFlags(record?.stateFlags), decision = decisions[item.carNum] || null, decisionState = !decision ? "pending" : "snoozed" === decision.action && decision.until && Date.parse(decision.until) <= now ? "pending" : decision.action; - return { ...item, actressName: item.actresses.join("、"), starId: item.starIds[0] || "", categories: [...item.categories], flags, decision, decisionState }; - })); - } - __name(aggregateNewVideoRecords, "aggregateNewVideoRecords"); - var _NewVideoPlugin = class _NewVideoPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "currentPage", 1), i(this, "pageSize", 30), i(this, "nvCurrentPage", 1), i(this, "nvPageSize", 60), i(this, "nvFlatListCache", []), i(this, "nvAllItemsMap", /* @__PURE__ */ new Map()), i(this, "nvActressesCache", []), i(this, "nvCarMapCache", /* @__PURE__ */ new Map()), i(this, "nvSortBy", "publishTime_desc"), i(this, "nvSelected", /* @__PURE__ */ new Set()), i(this, "nvDecisionsCache", {}), i(this, "nvCoverCache", /* @__PURE__ */ new Map()), i(this, "nvActorCoverRequests", /* @__PURE__ */ new Map()), i(this, "nvRenderGeneration", 0), i(this, "nvSearchDebounced", null), i(this, "nvInvalidationTimer", null), i(this, "nvWorkspaceReloadPromise", null), i(this, "nvWorkspaceReloadDirty", false), i(this, "nvWorkspaceMounted", false), i(this, "nvEventUnsubscribe", null), i(this, "taskStatusUnsubscribe", null), i(this, "nvJavDbUrl", ""), i(this, "nvRuleTime", 8760); - } - getName() { - return "NewVideoPlugin"; - } - getStartupMode() { - return "idle"; - } - async initCss() { - return ` + `);i.tags?.length&&g.find(".magnet-info").after($("
    ").addClass("magnet-tags").append(i.tags.map((f=>$("").addClass("jhs-badge").text(f)))));let m=g.find(".magnet-copy");g.find(".copy-btn").removeClass("magnet-hub-btn").addClass("jhs-btn--secondary"),m.append(``),g.appendTo(e)})),e.on("click",".copy-btn",(async i=>{let r=$(i.currentTarget),n=r.data("magnet");await utils.copyToClipboard("磁力链接",n)&&a(r)}))):e.append('
    没有找到相关结果
    ')}calcMagnetScore(e){return ls(e)}};d(Fr,"MagnetHubPlugin");var ia=Fr;var Vr=class Vr extends I{constructor(){super(...arguments),this.mountGeneration=0,this._mountGenerations=new WeakMap,this.siteConfigs=[{id:"javTrailersBtn"},{id:"123AvBtn",providerId:"av123"},{id:"jableBtn"},{id:"avgleBtn"},{id:"missAvBtn"},{id:"supJavBtn"},{id:"javDbBtn",condition:d(()=>B,"condition")},{id:"javBusBtn",condition:d(e=>!!(N&&e&&!e.includes("FC2")),"condition")},{id:"fanzaBtn",providerId:"dmm",noHandle:!0,condition:d(e=>!!(e&&!e.includes("FC2")),"condition")}]}getName(){return"OtherSitePlugin"}async getSiteConfigs(){let e=await this.getSettingCache(),t=this.getRuntimeService("movie").externalSites(e);return this.siteConfigs.map(s=>({...t.find((a=>a.id===s.id))||{},...s}))}async initCss(){return` + `}async handle(){let e=this.getRuntimeService("settings"),t=await this.getRuntimeService("scope")(),s=d(a=>{a.detail?.names?.includes("enableLoadOtherSite")&&(e.snapshot().enableLoadOtherSite==="no"?this.unmount():this.mount().catch((r=>clog.error("外部站点重新挂载失败",r))))},"onSettingsChanged");e.addEventListener("settings.changed",s),t.addCleanup((()=>e.removeEventListener("settings.changed",s))),await this.mount()}async mount(){isDetailPage&&this.getRuntimeService("settings").snapshot().enableLoadOtherSite!=="no"&&await this.loadOtherSite(null,null,{autoDetect:!1})}unmount(){this.mountGeneration++,this._mountGenerations.set(document,(this._mountGenerations.get(document)||0)+1),$("[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove()}async loadOtherSite(e,t,s={}){let a=this.getRuntimeService("settings");if(a.snapshot().enableLoadOtherSite==="no")return;let i=s.root?.[0]||null,r=i?(this._mountGenerations.get(i)||0)+1:++this.mountGeneration;i&&this._mountGenerations.set(i,r);let n=d(()=>(i?r===this._mountGenerations.get(i):r===this.mountGeneration)&&(typeof s.isActive=="function"?s.isActive():!0)&&a.snapshot().enableLoadOtherSite!=="no","isActive"),l=s.root?$(s.root):$(document),c=s.target?$(s.target):$(this.getRuntimeService("host").locateDetailSlots().summary);if(!c.length||!n())return;l.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(),e=T(e)||this.getPageInfo().carNum;let h=this.getEnabledSites(),u=(await this.getSiteConfigs()).map((v=>({...v,sourceCarNum:t})));if(!n())return;let p={root:l,target:c,configs:u,carNum:e,isActive:n},g=$('
    '),m=g.find(".jhs-site-list"),f=$('
    ');if(u.forEach((v=>{if(v.condition&&v.condition(v.sourceCarNum)===!1)return;let b=$('').attr("data-jhs-site-id",v.id).toggleClass("jhs-is-hidden",!h.includes(v.id));b.find("span").text(v.id.replace("Btn","")),m.append(b)})),m.append('',''),c.append(g,f),p.box=g,p.settings=f,!e)return g.find(".site-btn").removeAttr("href").attr({"aria-disabled":"true",title:"番号不可用"}),g.find('[data-jhs-role="detect-sites"]').prop("disabled",!0),this.renderSettingsArea(p),this.setupEventListeners(p),void clog.warn("跳过第三方站点解析:番号不可用");if(g.find('[data-jhs-site-id="javTrailersBtn"]').on("click",(v=>{v.preventDefault();let b=$(v.currentTarget).attr("href"),w=v.ctrlKey||v.metaKey?b:b+"?handle=1";utils.openPage(w,e,!1,v)})),await $e(u,4,(async v=>{v.condition&&v.condition(v.sourceCarNum)===!1||await this.prepareSiteLink(e,v,p)})),!!n())return this.renderSettingsArea(p),this.setupEventListeners(p),g.find('[data-jhs-role="detect-sites"]').off("click").on("click",(v=>{v.preventDefault(),this.detectOtherSites(e,p)})),s.autoDetect&&await this.detectOtherSites(e,p),g}async prepareSiteLink(e,t,s={root:$(document),configs:[],isActive:d(()=>!0,"isActive")}){let a=s.root.find(`[data-jhs-site-id="${t.id}"],#${t.id}`).first();if(!(e=T(e)))return a.removeAttr("href").attr({"aria-disabled":"true",title:"番号不可用"}),void this.setSiteState(a,"idle");if(t.providerId){let i=this.getRuntimeService("movie").searchUrl(t.providerId,{carNum:e});return void(i?(a.attr("href",i),a.attr("title","点击前往外部搜索页"),this.setSiteState(a,"idle")):(a.attr("title","外部站点地址不可用"),this.setSiteState(a,"domain-error")))}try{s.isActive?.()!==!1&&t.searchUrl&&(a.attr("href",t.searchUrl(e)),a.attr("title","点击前往外部搜索页;点击检测按钮后才自动检测"),this.setSiteState(a,"idle"))}catch{s.isActive?.()!==!1&&(a.attr("title","外部站点地址未配置或不可用"),this.setSiteState(a,"domain-error"))}}async detectOtherSites(e,t={root:$(document),configs:this.siteConfigs,isActive:d(()=>!0,"isActive")}){let s=t.root.find('[data-jhs-role="detect-sites"],#detectOtherSiteBtn').first(),a=s.text();return(e=T(e))?(s.text("检测中").prop("disabled",!0).addClass("is-checking"),await $e(t.configs,4,(async i=>{i.condition&&i.condition(i.sourceCarNum)===!1||await this.handleSite(e,i,t)})),t.isActive?.()!==!1&&s.text(a).prop("disabled",!1).removeClass("is-checking")):(s.prop("disabled",!0),void clog.warn("跳过第三方站点检测:番号不可用"))}setSiteState(e,t){e.removeClass("is-checking is-available is-unavailable is-domain-error"),t!=="idle"&&e.addClass(`is-${t}`)}async handleSite(e,t,s={root:$(document),configs:[],isActive:d(()=>!0,"isActive")}){let a=s.root.find(`[data-jhs-site-id="${t.id}"],#${t.id}`).first();if(a.removeAttr("href").find(".site-tag").remove(),this.setSiteState(a,"checking"),t.noHandle&&t.noHandle===!0){a.attr("href",this.getRuntimeService("movie").searchUrl(t.providerId,{carNum:e})||"");let i="jhs_other_site_dmm",r=this.getRuntimeService("storage").getLocal(i),n=null;try{let l=r?JSON.parse(r):{};n=l&&typeof l=="object"?l[e]:null}catch{n=null}n?(a.attr("href",n.url),n.type==="multiple"&&a.append('多结果'),this.setSiteState(a,"available")):this.setSiteState(a,"idle")}else if(t.providerId)try{let i=await this.getRuntimeService("scope")(),r=await this.getRuntimeService("movie").resolve({carNum:e,providerId:t.providerId},{scope:i});if(s.isActive?.()===!1)return;let n=this.getRuntimeService("movie").searchUrl(t.providerId,{carNum:e});a.attr("href",r?.url||n||""),this.setSiteState(a,r?.url?"available":"unavailable"),r?.url||a.attr("title","未查询到, 点击前往搜索页")}catch{s.isActive?.()!==!1&&(a.attr("title","请求失败。"),this.setSiteState(a,"unavailable"),clog.warn(`检测第三方资源失败, ${t.id.replace("Btn","")}`))}else try{if(a.attr("href"))return void this.setSiteState(a,"idle");if(utils.isHidden(a))return;let i="jhs_other_site",r=this.getRuntimeService("storage"),n=r.getLocal(i),l=n?JSON.parse(n):{},c=e+"_"+t.id.replace("Btn",""),h=l[c],u=Date.now();if(h&&h.time&&u-h.time<864e5)return a.attr("href",h.url),h.type==="multiple"&&a.append('多结果'),void this.setSiteState(a,"available");let p=await this.getRuntimeService("scope")(),g=await this.getRuntimeService("movie").searchExternalSite(t.id,e,{settings:await this.getSettingCache(),scope:p}),m=g.searchUrl;if(a.attr("href",m),s.isActive?.()===!1)return;let f=g.matches,v="",b=null;if(f.length===1){let w=f[0];a.attr("href",w),this.setSiteState(a,"available"),b={type:"single",url:w,time:u}}else f.length>1?(a.attr("href",m),v+='多结果',this.setSiteState(a,"available"),b={type:"multiple",url:m,time:u}):(a.attr("href",m),a.attr("title","未查询到, 点击前往搜索页"),this.setSiteState(a,"unavailable"));if(b){let w=r.getLocal(i),S=w?JSON.parse(w):{};S[c]=b,r.setLocal(i,JSON.stringify(S))}v&&a.append(v)}catch(i){let r=String(i),n=t.id.replace("Btn",""),l=i?.code;l==="CIRCUIT_OPEN"?(a.attr("title",r),this.setSiteState(a,"domain-error"),clog.warn(`检测第三方资源跳过, ${n} 已熔断`)):l==="CF_BLOCKED"?(a.attr("title","请求失败:Cloudflare 安全检查。"),this.setSiteState(a,"domain-error"),clog.warn(`检测第三方资源失败, ${n} 需Cloudflare安全检查`)):l==="INVALID_URL"?(a.attr("title","域名失效"),this.setSiteState(a,"domain-error"),clog.warn(`检测第三方资源失败, ${n} 域名或重定向无效`)):l==="NOT_FOUND"?(a.attr("title","未查询到, 点击前往搜索页"),this.setSiteState(a,"unavailable")):(clog.error(i),a.attr("title","请求失败。"),this.setSiteState(a,"unavailable"),clog.warn(`检测第三方资源失败, ${n}`))}}async getSettingCache(){return this.getRuntimeService("settings").snapshot()}async getMissAvUrl(){return(await this.getSiteConfigs()).find(e=>e.id==="missAvBtn")?.baseUrl||""}async getjableUrl(){return(await this.getSiteConfigs()).find(e=>e.id==="jableBtn")?.baseUrl||""}async getAvgleUrl(){return(await this.getSiteConfigs()).find(e=>e.id==="avgleBtn")?.baseUrl||""}async getJavTrailersUrl(){return(await this.getSiteConfigs()).find(e=>e.id==="javTrailersBtn")?.baseUrl||""}async getAv123Url(){return this.getRuntimeService("movie").providerOrigin("av123")||""}async getJavDbUrl(){return(await this.getSiteConfigs()).find(e=>e.id==="javDbBtn")?.baseUrl||""}async getJavBusUrl(){return(await this.getSiteConfigs()).find(e=>e.id==="javBusBtn")?.baseUrl||""}async getSupJavUrl(){return(await this.getSiteConfigs()).find(e=>e.id==="supJavBtn")?.baseUrl||""}getEnabledSites(){let e=this.siteConfigs.map((t=>t.id));try{let t=this.getRuntimeService("storage").getLocal("jhs_enabled_sites");if(!t)return e;let s=JSON.parse(t);return Array.isArray(s)?s.filter((a=>e.includes(a))):e}catch(t){return clog.warn("外部站点配置损坏,已回退默认值",t),e}}saveEnabledSites(e){this.getRuntimeService("storage").setLocal("jhs_enabled_sites",JSON.stringify(e))}renderSettingsArea(e={root:$(document),configs:this.siteConfigs}){let t=this.getEnabledSites(),s=e.root.find('[data-jhs-role="site-checkboxes"],#siteCheckboxes').first().empty();e.configs.forEach((a=>{let i=$('').attr("data-site-id",a.id).prop("checked",t.includes(a.id));s.append($('').append(i,$("").text(a.id.replace("Btn",""))))}))}setupEventListeners(e={root:$(document),configs:this.siteConfigs,carNum:null}){let t=e.root.find("[data-jhs-other-site-settings],#settingsArea").first();e.root.find('[data-jhs-role="site-settings"],#settingSiteBtn').off("click.jhsOtherSite").on("click.jhsOtherSite",(()=>{t.toggleClass("jhs-is-hidden")})),t.off("change.jhsOtherSite").on("change.jhsOtherSite",'input[type="checkbox"]',(async s=>{let a=$(s.currentTarget).attr("data-site-id");try{if(s.currentTarget.checked){e.root.find(`[data-jhs-site-id="${a}"],#${a}`).removeClass("jhs-is-hidden");let i=e.carNum||this.getPageInfo().carNum,r=e.configs.find((n=>n.id===a));r&&await this.prepareSiteLink(i,r,e)}else e.root.find(`[data-jhs-site-id="${a}"],#${a}`).addClass("jhs-is-hidden");this.saveEnabledSites(t.find('input[type="checkbox"]:checked').map(((i,r)=>$(r).attr("data-site-id"))).get())}catch(i){clog.warn(`外部站点 ${a||"unknown"} 状态更新失败`,i)}}))}};d(Vr,"OtherSitePlugin");var ra=Vr;var Hr=class Hr extends I{getName(){return"RelatedPlugin"}async handle(){if(!window.isDetailPage||!N)return;let e=new URL(window.location.href).pathname.split("/").filter(Boolean).pop();e&&await this.showRelated(this.getHostedSlot("related"),e)}getHostedSlot(e){let t=this.getRuntimeService("host").locateDetailSlots()[e];return t?$(t):$()}async showRelated(e,t,s={}){return new Bt({related:this.getRuntimeService("related"),settings:this.getRuntimeService("settings"),scope:d(()=>this.getRuntimeService("scope")(),"scope")}).show(e?.length?e:this.getHostedSlot("related"),t,s)}};d(Hr,"RelatedPlugin");var na=Hr;var qr=class qr extends I{getName(){return"ReviewPlugin"}async handle(){if(window.isDetailPage){if(N){let e=this.parseMovieId(window.location.href);await this.showReview(e,this.getHostedSlot("reviews"))}if(B){let e=this.getPageInfo().carNum;if(!e)return void clog.warn("跳过 JavBus 评论解析:番号不可用");let t=await this.getRuntimeService("scope")(),s=this.getRuntimeService("settings"),a=!1,i=d(async()=>{if(!(a||t?.disposed||s.snapshot().enableLoadReview==="no")){a=!0;try{let r=await this.getRuntimeService("movie").resolve({carNum:e},{scope:t});r?.movieId&&!t?.disposed&&s.snapshot().enableLoadReview!=="no"&&await this.showReview(r.movieId,this.getHostedSlot("reviews"))}finally{a=!1}}},"mount");t?.listen?.(s,"settings.changed",r=>{r.detail?.names?.includes("enableLoadReview")&&i().catch(n=>clog.error("加载评论失败",n))}),await i()}}}getHostedSlot(e){let t=this.getRuntimeService("host").locateDetailSlots()[e];return t?$(t):$()}async showReview(e,t,s={}){return new Mt({review:this.getRuntimeService("review"),settings:this.getRuntimeService("settings"),storage:this.getRuntimeService("storage"),scope:d(()=>this.getRuntimeService("scope")(),"scope")}).show(e,t?.length?t:this.getHostedSlot("reviews"),s)}};d(qr,"ReviewPlugin");var oa=qr;var Wr="jhs_appAuthorization",ca=class ca extends I{constructor(){super(),A(this,"has_cnsub",""),A(this,"$contentBox",null),A(this,"$listRoot",null),A(this,"movies",[]),A(this,"loginDialogOpen",!1)}getName(){return"TOP250Plugin"}async initCss(){return""}async handle(){let e=$('.main-tabs ul li:contains("猜你喜歡")').length?$('.main-tabs ul li:contains("猜你喜歡")'):$('.main-tabs ul li:contains("猜你喜欢")');e.length&&e.html('Top250'),$('a[href*="rankings/top"]').off("click.jhsTop250").on("click.jhsTop250",(t=>{t.preventDefault(),t.stopPropagation();let s=$(t.target),a=(s.is("a")?s:s.closest("a")).attr("href");if(!a)return;let i=a.includes("?")?a.split("?")[1]:a,r=new URLSearchParams(i);this.checkLogin(t,r)})),await this.handleTop()}hookPage(){let e=this.getRuntimeService("host"),t=e.getListContainer?.()??e.getListLayoutContainer?.();if(!t||!e.createOwnedListRoot)throw new Error("JavDB 列表容器不可用");this.$contentBox=$(t),this.$listRoot=$(e.createOwnedListRoot(["jhs-top250-list","jhs-layout-d2c171b1"]));let s=$("h2.section-title");s.length||(s=$("

    ").addClass("section-title").prependTo(this.$contentBox)),s.contents().first().replaceWith("Top250"),$(".empty-message").remove(),this.$contentBox.children(".box").remove(),$("#sort-toggle-btn").remove(),this.$contentBox.children(".jhs-top250-filters").remove(),this.$contentBox.children(".tool-box.jhs-layout-d2c171b1").remove(),this.$contentBox.children("nav.pagination").remove(),this.$contentBox.append('
    '),e.mountOwnedListRoot(t,this.$listRoot[0])}renderPagination(){let e=new URLSearchParams(window.location.search),t=parseInt(e.get("page")||"",10)||1;this.$contentBox.append((s=>{let a=s>=5,i="";for(let r=1;r<=5;r++)i+=`
  • `;return` + + `})(t)),this.$contentBox.on("click",".pagination-link, .pagination-previous, .pagination-next",(s=>{s.preventDefault();let a=parseInt(String($(s.currentTarget).data("page")),10);!isNaN(a)&&a>0&&(i=>{e.set("page",String(i)),window.history.pushState({},"","?"+e.toString()),window.location.reload()})(a)}))}async handleTop(){if(!window.location.href.includes("handleTop=1"))return;let e=this.getOptionalDependency("HitShowPlugin");if(!e)return void show.info("热播列表功能已禁用");let t=new URLSearchParams(window.location.search),s=t.get("handleType")||"all",a=t.get("type_value")||"";this.has_cnsub=t.get("has_cnsub")||"";let i=Number(t.get("page"))||1;this.hookPage(),this.toolBar(s,a),this.renderPagination(),await this.getOptionalDependency("ListPageButtonPlugin")?.mountOwnedRankingControls?.($(".jhs-top250-filters"))?.catch?.(c=>clog.error("Top250 操作按钮挂载失败",c));let r=this.$listRoot;r.html("");let n=loading(),l=!1;for(let c=1;c<=3&&!l;c++)try{let h=await vc(s,a,i,50),u=h.success,p=h.action;if(u===1){let g=h.data.movies;if(g.length===0)return show.error("无数据"),void n.close();this.movies=g;let m=g.filter((v=>this.has_cnsub==="1"?v.has_cnsub:this.has_cnsub!=="0"||!v.has_cnsub)),f=e.markDataListHtml(m,{thumbnailFirst:!0});r.html(f),await e.initializeRenderedList(),await e.loadScore(m),l=!0}else{if(clog.error(h),r.html(`

    ${C(h.message)}

    `),show.error(h.message),p==="JWTVerificationError"){let g=this.getRuntimeService("credential");try{g?.remove&&await g.remove(Wr)}catch(m){clog.warn("删除失效 JavDB Token 失败,仍继续打开登录框",m)}this.openLoginDialog({onSuccess:d(()=>this.handleTop(),"onSuccess")})}l=!0}}catch(h){c<3?(clog.error(`获取Top数据失败 (第 ${c} 次重试):`,h),await new Promise((u=>setTimeout(u,1e3)))):(clog.error("所有重试尝试均失败,无法获取Top数据。",h),r.html("

    无法加载数据,请稍后再试。

    "))}finally{(l||c===3)&&n.close()}}toolBar(e,t){let s="";for(let n=new Date().getFullYear();n>=2008;n--)s+=`${n}`;let a=d((n,l,c="video_type")=>`${l}`,"typeLink"),i=`
    `,r=this.$contentBox.children("h2.section-title").first();r.length?r.after(i):this.$contentBox.prepend(i),$("button[data-cnsub-value]").on("click",(n=>{let l=String($(n.currentTarget).data("cnsub-value")),c=new URLSearchParams(window.location.search);c.set("handleTop","1"),c.set("handleType",e),c.set("type_value",t),c.set("has_cnsub",l),c.delete("page"),window.location.href=`/advanced_search?${c.toString()}`}))}async checkLogin(e,t){let s=this.getRuntimeService("credential");if(!s?.get||!await s.get(Wr))return show.error("该类别依赖移动端接口,请先完成登录"),void this.openLoginDialog();let a="all",i="",r=t.get("t")||"";/^y\d+$/.test(r)?(a="year",i=r.substring(1)):r!==""&&(a="video_type",i=r);let n=`/advanced_search?handleTop=1&handleType=${a}&type_value=${i}`;e&&(e.ctrlKey||e.metaKey)?GM_openInTab(window.location.origin+n,{insert:0}):window.location.href=n}openLoginDialog({onSuccess:e=null}={}){if(this.loginDialogOpen)return null;this.loginDialogOpen=!0;let t=this.getRuntimeService("dialog"),s=this.getRuntimeService("account"),a=this.getRuntimeService("scope");try{return t.open({type:1,title:"JavDB",closeBtn:1,area:utils.getResponsiveArea(["360px","auto"]),shadeClose:!1,content:` + +
    +
    + +
    + +
    + +
    + + +
    + `,success:d((i,r)=>{$("#loginBtn").click((async()=>{let n=$("#username").val(),l=$("#password").val();if(!n||!l)return void show.error("请输入用户名和密码");let c=loading();s.login("javdb",{username:n,password:l},{scope:await a()}).then((async h=>{if(!h.success)show.error(h.message);else{let u=this.getRuntimeService("credential");if(!u?.set)throw new Error("凭证服务不可用");await u.set(Wr,h.token),show.ok("登录成功"),t.close(r),typeof e=="function"?await e():window.location.href="/advanced_search?handleTop=1&period=daily"}})).catch((h=>{clog.error("登录异常:",h),show.error(h instanceof Error?h.message:String(h))})).finally((()=>{c.close()}))}))},"success"),end:d(()=>{this.loginDialogOpen=!1},"end")})}catch(i){throw this.loginDialogOpen=!1,i}}};d(ca,"Top250Plugin"),lt(ca,"legacyPluginId","TOP250Plugin");var la=ca;var Jr=class Jr extends I{getName(){return"FavoriteActressesPlugin"}getStartupMode(){return"idle"}bindImmediateEvents(){this.bindEvent()}async handle(){await this.highlightActress(),this.replaceActressAvatar()}async highlightActress(){if(!isDetailPage||await storageManager.getSetting("enableFavoriteActresses",R)!==R)return;let e=await storageManager.getFavoriteActressList();if(!e||e.length===0)return;let t=new Set;e.forEach((s=>{s.starId&&t.add(String(s.starId).trim())})),t.size!==0&&$(".female").prev().each(((s,a)=>{let i=$(a),r=i.attr("href"),n=null;if(r){let c=(r.endsWith("/")?r.slice(0,-1):r).split("/"),h=c[c.length-1];h&&(n=h.trim())}let l=!1;n&&(l=t.has(n)),l&&(i.addClass("highlighted"),i.attr("title","高亮已收藏演员, 可在设置-基础配置中关闭"))}))}async removeActorFromStorage(e){await storageManager.removeFavoriteActress(e)&&(clog.log("移除演员成功"),document.dispatchEvent(new CustomEvent("actress-state-changed",{detail:{starId:String(e)}})))}bindEvent(){let e=/\/actors\/(\w+)\/(collect|uncollect)/;$(document).on("confirm:complete",'a[href*="/actors/"][href*="/uncollect"]',(async t=>{let[s]=t.detail;if(!s)return;let a=$(t.currentTarget).attr("href").match(e),i=a?a[1]:null;i&&await this.removeActorFromStorage(i)})),$("#button-collect-actor").click((async t=>{let s=$("#button-collect-actor").attr("href").match(e),a=s?s[1]:null,i=[],r=$(".actor-section-name");r.length&&r.text().trim().split(",").forEach((u=>{i.push(u.trim())}));let n=$(".section-meta:not(:contains('影片'))");if(n.length&&n.text().trim().split(",").forEach((u=>{i.push(u.trim())})),!i.length)return void clog.error("获取演员名称失败");let l=i[0];if(!a)return void clog.error("无法获取演员ID进行收藏操作。");let c=($(".avatar").first().css("background-image")||"").replace(/^url\(["']?|["']?\)$/g,""),h={starId:a,name:l,allName:i,avatar:c};await storageManager.addFavoriteActressList([h])===1?(clog.log(`收藏演员成功: ${l} (ID: ${a})`),document.dispatchEvent(new CustomEvent("actress-state-changed",{detail:{starId:String(a)}}))):clog.log(`收藏演员失败: ${l} (ID: ${a})`)})),$("#button-uncollect-actor").click((async t=>{let s=$("#button-uncollect-actor").attr("href").match(e),a=s?s[1]:null;a?await this.removeActorFromStorage(a):clog.error("无法获取演员ID进行取消收藏操作。")}))}async replaceActressAvatar(){let e=this.getActressId();if(!e)return;let t=(await storageManager.getFavoriteActressList()).find((s=>s.starId===e));if(t&&t.avatar){let s=`url('${t.avatar}')`,a=$(".avatar").first();if(a.length===0&&($(".section-columns").prepend('
    '),a=$(".avatar").first()),a.length===0)return;a.css("background-image").trim().toLowerCase()!==s.trim().toLowerCase()&&(a.css("background-image",s),a.css("background-size","cover"),a.css("background-position","top center"),a.css("background-repeat","no-repeat"))}}};d(Jr,"FavoriteActressesPlugin");var da=Jr;var Kr=class Kr extends I{getName(){return"BusImgPlugin"}handle(){}async getVisibleImageItems(e,t){let s=[],a=document.querySelectorAll(e);for(let i of a)if(!utils.isHidden(i)){let r=i.querySelector(t);if(!(r instanceof HTMLImageElement))continue;r.style.removeProperty("height");let n=r.offsetHeight;n>0&&s.push({element:i,imgElement:r,height:n})}return s}async logImageHeightsByRow(e={}){let t=e.vertical??e.enableVerticalModel??await storageManager.getSetting("enableVerticalModel",ne),s=e.columns??e.containerColumns??await storageManager.getSetting("containerColumns",5);if(t===R)return;let a=this.getSelector().itemSelector,i=Number(s)||5,r=await this.getVisibleImageItems(a,"img");if(r.length===0)return;let n=[];for(let l=0;l{let h=l.map((m=>m.height));if(h.length<2)return;let u=Math.min(...h),p=Math.max(...h),g=0;p-u>50&&(g=u,l.forEach((m=>{if(m.height!==g){let f=`${g}px`;m.imgElement.style.setProperty("height",f,"important")}})))}))}};d(Kr,"BusImgPlugin");var ha=Kr;var Ot=d((o,e)=>{if(!o||o.length===0)return null;let t=new Set(o);if(e&&t.has(e))return e;let s=at.map((a=>a.quality)).reverse();for(let a of s)if(t.has(a))return a;return o[0]||null},"Z"),Gr="jhs_dmm_video",Bh=30*864e5,Sc=500;function Qr(o){if(!o)return{};try{let e=JSON.parse(o);return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}}d(Qr,"parseJsonMap");function Yr(o){let e=Date.now();for(let s of Object.keys(o)){let a=o[s];(!a||a.time&&e-a.time>Bh)&&delete o[s]}let t=Object.keys(o);if(t.length>Sc){t.sort(((s,a)=>(o[s].time||0)-(o[a].time||0)));for(let s of t.slice(0,t.length-Sc))delete o[s]}return o}d(Yr,"pruneDmmCache");function Ae(o){return o?.enablePreviewVideo!=="no"}d(Ae,"isPreviewEnabled");function cs(o){return o?.enableLoadPreviewVideo!=="no"}d(cs,"isDmmEnabled");function xc(o){return Ae(o)}d(xc,"canUsePreview");function zt(o){return Ae(o)&&cs(o)}d(zt,"canUseDmmPreview");function ua(o){return zt(o)}d(ua,"canUseCardPreview");var en=class en{constructor(e,t,s,a){this.carNum=e||"",this.storage=t,this.movie=s,this.scope=a,this.lastError=null}_checkCache(){let e=Yr(Qr(this.storage.getLocal(Gr)));return e[this.carNum]?(clog.debug("缓存中存在预览视频信息",e[this.carNum]),e[this.carNum]):null}_updateCache(e){let t=Yr(Qr(this.storage.getLocal(Gr)));t[this.carNum]={...e,time:Date.now()},clog.debug("成功解析出预览视频并已缓存:",e),this.storage.setLocal(Gr,JSON.stringify(t))}async _fetchRemote(){let e=await this.movie.preview("dmm",{carNum:this.carNum},{scope:this.scope}),t=$("#fanzaBtn");if(!e.sources)return clog.warn("所有关键词尝试均未找到匹配的Content ID, 解析Dmm视频失败"),t.attr("href",e.searchUrl).attr("title","未查询到, 点击前往搜索页").css("backgroundColor","var(--jhs-status-filter)"),null;t.attr("href",e.pageUrl).css("backgroundColor","var(--jhs-status-down)"),e.matchType==="multiple"&&t.append('多结果');let s="jhs_other_site_dmm",a=Yr(Qr(this.storage.getLocal(s)));return a[this.carNum]={type:e.matchType,url:e.pageUrl,time:Date.now()},this.storage.setLocal(s,JSON.stringify(a)),e.sources}async fetchVideo(){let e=T(this.carNum);if(!e)return clog.warn("跳过 DMM 解析:番号不可用"),null;this.carNum=e;let t=this._checkCache();if(t)return t;try{let s=this.carNum.toLowerCase();if(s.startsWith("heyzo")||/^(n\d+|\d+(-\d+)*)$/.test(s)||/^n\d+$/.test(s))return clog.debug("无码番号类型,取消 DMM 解析"),null;if(this.carNum.includes("VR-"))return clog.debug("VR 类型,取消 DMM 解析"),null;let a=await this._fetchRemote();return a?(this._updateCache(a),a):null}catch(s){let a=s;this.lastError=s instanceof we?s:new we("dmm",a?.code||"PARSE_ERROR",a?.message||String(s),{cause:s,retryable:a?.retryable===!0}),clog.error("DMM API 搜索失败:",this.lastError);let i=$("#fanzaBtn");return i.attr("href",this.movie.searchUrl("dmm",{carNum:this.carNum})),i.attr("title","未查询到, 点击前往搜索页"),i.css("backgroundColor","var(--jhs-status-filter)"),null}}};d(en,"DmmPreviewParser");var Zr=en;async function Xr(o,e,t,s){let a=new Zr(o,e,t,s);return{sources:await a.fetchVideo(),error:a.lastError}}d(Xr,"fetchDmmPreview");async function pa(o,e,t,s,a){return cs(a)?Xr(o,e,t,s):{sources:null,error:null}}d(pa,"fetchDmmPreviewIfEnabled");var tn=class tn extends I{constructor(){super(...arguments),this._busPreviewGeneration=0}getName(){return"BusPreviewVideoPlugin"}async initCss(){return` + .bus-preview-modal { position:fixed; inset:0; z-index:var(--jhs-z-modal); display:flex; align-items:center; justify-content:center; visibility:hidden; opacity:0; background:rgba(0,0,0,.95); transition:opacity var(--jhs-motion-base) var(--jhs-ease); } + .bus-preview-modal.is-open { visibility:visible; opacity:1; } + .bus-preview-modal-content { position:relative; display:flex; max-width:95%; max-height:95%; flex-direction:column; align-items:center; gap:var(--jhs-space-3); } + .video-player-wrapper { position:relative; width:80vw; max-width:100%; max-height:85vh; aspect-ratio:16/9; background:#000; } + .video-player-wrapper #preview-video { position:absolute; inset:0; } + `}initModal(e){if($("#bus-preview-modal").length===0){$("body").append(` +
    +
    +
    +
    + `);let t=$("#bus-preview-modal");t.on("click",(s=>{s.target instanceof Element&&s.target.id==="bus-preview-modal"&&this.closeVideoModal()})),e.listen(document,"keydown",(s=>{s instanceof KeyboardEvent&&s.key==="Escape"&&t.hasClass("is-open")&&this.closeVideoModal()})),e.addCleanup((()=>{t.off(),t.remove()}))}}closeVideoModal(){let e=$("#preview-video");e.length>0&&e[0].pause(),$("#bus-preview-modal").removeClass("is-open")}unmountPreview(){this._busPreviewMounted=!1,this.closeVideoModal(),$("#bus-preview-modal").remove(),$(".preview-video-container").off("click.jhsBusPreview").remove()}async handle(){if(!isDetailPage)return;let e=this.getRuntimeService("settings");if(this._busScope||(this._busScope=await this.getRuntimeService("scope")()),!this._settingsListenerBound){this._settingsListenerBound=!0;let t=d(s=>{s.detail?.names?.some(i=>i==="enablePreviewVideo"||i==="enableLoadPreviewVideo")&&this.reconfigure()},"onSettingsChanged");e.addEventListener("settings.changed",t),this._busScope.addCleanup((()=>{e.removeEventListener("settings.changed",t),this._settingsListenerBound=!1}))}this.reconfigure()}reconfigure(){this._busPreviewGeneration++;let e=this.getRuntimeService("settings").snapshot();if(!zt(e))return void this.unmountPreview();this.mountPreview()}mountPreview(){if(this._busPreviewMounted)return;this._busPreviewMounted=!0;let e=this._busScope;this.initModal(e);let t=$("#sample-waterfall .sample-box .photo-frame img:first").attr("src"),s=$(` + `);$("#sample-waterfall").prepend(s);let a=!1,i=$(".preview-video-container");i.on("click",(async r=>{if(r.preventDefault(),r.stopPropagation(),a)show.info("正在加载中, 勿重复点击");else{a=!0;try{await this.handleVideo()}finally{a=!1}}})),window.location.href.includes("autoPlay=1")&&i.trigger("click")}async handleVideo(){let e=this._busPreviewGeneration,t=$("#bus-preview-modal"),s=t.find(".bus-preview-modal-content"),a=$("#preview-video");if(e!==this._busPreviewGeneration||!Ae(this.getRuntimeService("settings").snapshot()))return;if(a.length>0)return t.addClass("is-open"),void await de(a[0],{context:"JavBus 预览视频",notify:!0});let i=this.getPageInfo().carNum,r=await this.getRuntimeService("scope")(),{sources:n,error:l}=await pa(i,this.getRuntimeService("storage"),this.getRuntimeService("movie"),r,this.getRuntimeService("settings").snapshot());e!==this._busPreviewGeneration||!Ae(this.getRuntimeService("settings").snapshot())||(n&&Object.keys(n).length!==0?(await this.createVideoPlayerAndControls(n,s),a=$("#preview-video"),a.length>0?(t.addClass("is-open"),await de(a[0],{context:"JavBus 预览视频",notify:!0,message:l?.code==="REGION_BLOCKED"?l.message:"当前视频源无法播放"})):show.error("视频播放器创建失败。")):show.error(l?.code==="REGION_BLOCKED"?l.message:"未找到可用的视频源。"))}async createVideoPlayerAndControls(e,t){let s=this.getRuntimeService("settings").snapshot().videoQuality;s=Ot(Object.keys(e),s);let a=e[s],i=$('
    '),r=$(''),n=$(document.createElement("source")).attr("src",a),l=$('
    ');r.append(n),i.append(r),t.empty().append(i,l);let c=$("#preview-video"),h=c.find("source"),u=t.find(".jhs-video-quality-list");if(!c.length||!h.length)return;let p=this.getRuntimeService("settings"),g=c[0],m=p.snapshot().videoMuted;g.muted=m==null||m===!0,c.off("volumechange.jhsVideo").on("volumechange.jhsVideo",(()=>{p.set("videoMuted",g.muted).catch((v=>clog.error("保存视频静音设置失败",v)))})),at.forEach((v=>{let b=e[v.quality];if(b){let w=s===v.quality,S=$('');w&&S.addClass("active"),S.attr({"data-quality":v.quality,"data-video-src":b,"aria-pressed":w?"true":"false"}).text(v.text),u.append(S)}}));let f=u.find(".jhs-video-quality-btn");u.off("click.jhsVideo").on("click.jhsVideo",".jhs-video-quality-btn",(async v=>{try{let b=$(v.currentTarget);if(b.hasClass("active"))return;let w=b.attr("data-video-src");h.attr("src",w);let S=g.currentTime;g.load(),g.currentTime=S,await de(g,{context:"JavBus 画质切换",notify:!0})&&(f.removeClass("active").attr("aria-pressed","false"),b.addClass("active").attr("aria-pressed","true"))}catch(b){clog.error("切换画质失败:",b)}}))}};d(tn,"BusPreviewVideoPlugin");var ga=tn;var sn=class sn extends I{constructor(){super(...arguments),this.previewGeneration=0}getName(){return"CoverButtonPlugin"}async initCss(){return` + `}async handle(){if(!window.isListPage)return;let e=await this.getRuntimeService("scope")(),t=this.getRuntimeService("settings"),s=d(a=>{let i=a.detail?.names||[];i.some(r=>r==="enablePreviewVideo"||r==="enableLoadPreviewVideo")&&(this.previewGeneration++,ua(t.snapshot())?this.addSvgBtn().catch((r=>clog.error("卡片预览重新挂载失败",r))):($('[id$="_preview_video"]').each((r,n)=>{n.pause?.(),$(n).parent().remove()}),this.enableSvgBtn())),i.some(r=>["enableScreenSvg","enableVideoSvg","enableHandleSvg","enableSiteSvg","enableCopySvg"].includes(r))&&this.enableSvgBtn()},"onSettingsChanged");this._settingsListenerBound||(this._settingsListenerBound=!0,t.addEventListener("settings.changed",s),e.addCleanup((()=>{t.removeEventListener("settings.changed",s),this._settingsListenerBound=!1}))),this.addSvgBtn(),await this.bindClick(e)}buildToolBox(){return` +
    + + +
    + + +
    +
    + + +
    +
    + + +
    +
    `}async addSvgBtn(e=null){this.getOptionalDependency("ListPagePlugin")&&((e?$(e).toArray():$(this.getSelector().itemSelector).toArray()).forEach((t=>{let s=$(t);if(s.find(".tool-box").length||B&&s.find(".avatar-box").length)return;let a=N?s.find(".tags").first():s.find(".photo-info").first();a.length&&a.append(this.buildToolBox())})),this.enableSvgBtn(e))}async enableSvgBtn(e=null){let t=this.getRuntimeService("settings").snapshot(),{enableScreenSvg:s=R,enableVideoSvg:a=R,enablePreviewVideo:i=R,enableHandleSvg:r=R,enableSiteSvg:n=R,enableCopySvg:l=R}=t,c=e?$(e):$(document);[{selector:".screenSvg",enabled:s===R&&this.getOptionalDependency("ScreenShotPlugin")?R:"no"},{selector:".videoSvg",enabled:a===R&&ua(t)?R:"no"},{selector:".handleSvg",enabled:r},{selector:".siteSvg",enabled:n},{selector:".copySvg",enabled:l}].forEach((({selector:h,enabled:u})=>{c.find(h).toggle(u===R)}))}closeCardMenus(e=!1){let t=$(".jhs-card-menu.is-open"),s=t.siblings(".jhs-card-menu-trigger");t.removeClass("is-open"),s.attr("aria-expanded","false"),e&&s.first().trigger("focus")}async bindClick(e){this.getSelector();let t=this.getOptionalDependency("ListPagePlugin");if(!t)return;let s=$(document);s.off(".jhsCoverButton"),e.addCleanup((()=>s.off(".jhsCoverButton"))),s.on("click.jhsCoverButton",".jhs-card-menu-trigger",(h=>{h.preventDefault(),h.stopPropagation();let u=$(h.currentTarget),p=u.siblings(".jhs-card-menu"),g=!p.hasClass("is-open");this.closeCardMenus(),p.toggleClass("is-open",g),u.attr("aria-expanded",String(g)),g&&p.children().first().trigger("focus")})).on("keydown.jhsCoverButton",".jhs-card-menu [role='menuitem']",(h=>{let u=$(h.currentTarget).closest(".jhs-card-menu"),p=u.find("[role='menuitem']"),g=p.index(h.currentTarget);if(h.key==="Escape")return h.preventDefault(),this.closeCardMenus(!0);if(!["ArrowDown","ArrowUp","Home","End"].includes(h.key))return;h.preventDefault();let m=h.key==="Home"?0:h.key==="End"?p.length-1:h.key==="ArrowDown"?(g+1)%p.length:(g-1+p.length)%p.length;p.eq(m).trigger("focus")})).on("click.jhsCoverButton",(h=>{$(h.target).closest(".more-tools-container").length||this.closeCardMenus()})),s.on("click.jhsCoverButton",".videoSvg",(h=>{h.preventDefault(),$('.videoSvg[title!="播放视频"]').each(((g,m)=>{let f=$(m),v=f.closest(".item"),b=v.find("img"),{carNum:w}=t.findCarNumAndHref(v);this.showImg(f,b,w),f.html(this.videoSvg).attr({title:"播放视频","aria-label":"播放视频"})}));let u=$(h.target).closest(".item"),p=u.find(".videoSvg");if(p.attr("title")==="播放视频"){p.html(this.recoveryVideoSvg).attr({title:"切回封面","aria-label":"切回封面"});let{carNum:g}=t.findCarNumAndHref(u),m=u.find("img");if(!m.length)return void show.error("没有找到图片");this.showVideo(p,m,g).catch((f=>clog.error("卡片预览视频打开失败",f)))}})),s.on("click.jhsCoverButton",".screenSvg",(async h=>{h.preventDefault();let u=loading();try{let p=$(h.currentTarget).closest(".item"),{carNum:g}=t.findCarNumAndHref(p);g=g.replace("FC2-","");let m=this.getOptionalDependency("ScreenShotPlugin");if(!m)throw new Error("剧照功能已禁用");let f=await m.getScreenshot(g,{allowWhenDisabled:!0});u.close(),globalThis.showImageViewer(f)}catch(p){clog.error("图片预览出错:",p),show.error("图片预览出错:"+p)}finally{u.close()}})),s.on("click.jhsCoverButton",".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn",(h=>{h.preventDefault(),h.stopPropagation();try{let u=$(h.currentTarget),p=u.closest(".item"),{carNum:g,url:m,publishTime:f,fc2Source:v}=t.findCarNumAndHref(p),b=d(async w=>{try{let S=await t.parseActressName(m),j=ke(w);if(!j)throw new Error("不支持的状态操作");await this.getRuntimeService("state").patch(g,{[j]:!0},{type:"list-card-state",record:{carNum:g,url:m,names:S,publishTime:f,fc2Source:v}}),show.ok("操作成功")}catch(S){clog.error("保存操作失败:",S),show.error("操作失败")}},"r");u.hasClass("filterBtn")?utils.q(h,`是否屏蔽${C(g)}?`,(()=>b(ce))):u.hasClass("favoriteBtn")?b(ue):u.hasClass("hasDownBtn")?b(pe):u.hasClass("hasWatchBtn")&&b(me),this.closeCardMenus()}catch(u){clog.error("按钮点击处理失败:",u)}}));let a=this.getRuntimeService("settings").snapshot(),i=this.getRuntimeService("movie"),r=i.externalSiteOrigin("missAvBtn",a),n=i.externalSiteOrigin("jableBtn",a),l=i.externalSiteOrigin("avgleBtn",a),c=i.providerOrigin("av123")||"";$(this.getSelector().itemSelector).each(((h,u)=>{let p=$(u),{carNum:g}=t.findCarNumAndHref(p);p.find(".site-jable").attr({href:`${n}/search/${g}/`,target:"_blank",rel:"noopener noreferrer"}),p.find(".site-avgle").attr({href:`${l}/vod/search.html?wd=${g}`,target:"_blank",rel:"noopener noreferrer"}),p.find(".site-miss-av").attr({href:`${r}/search/${g}`,target:"_blank",rel:"noopener noreferrer"}),p.find(".site-123-av").attr({href:`${c}/cn/search?keyword=${encodeURIComponent(g)}`,target:"_blank",rel:"noopener noreferrer"})})),s.on("click.jhsCoverButton",".site-jable, .site-avgle, .site-miss-av, .site-123-av",(h=>{try{h.preventDefault(),h.stopPropagation();let u=$(h.currentTarget),p=u.closest(".item"),{carNum:g}=t.findCarNumAndHref(p),m=null;if(u.hasClass("site-jable")?m=`${n}/search/${g}/`:u.hasClass("site-avgle")?m=`${l}/vod/search.html?wd=${g}`:u.hasClass("site-miss-av")?m=`${r}/search/${g}`:u.hasClass("site-123-av")&&(m=`${c}/cn/search?keyword=${encodeURIComponent(g)}`),!m)return;h.ctrlKey||h.metaKey?GM_openInTab(m,{insert:0}):window.open(m),this.closeCardMenus()}catch(u){clog.error("站点按钮处理失败:",u)}})),s.on("click.jhsCoverButton",".titleSvg, .carNumSvg, .downSvg",(h=>{h.preventDefault(),h.stopPropagation();let u=$(h.currentTarget).closest(".item"),{carNum:p,title:g}=t.findCarNumAndHref(u),m=u.find(B?".photo-frame img":".cover img");$(h.currentTarget).hasClass("titleSvg")?utils.copyToClipboard("标题",g):$(h.currentTarget).hasClass("carNumSvg")?utils.copyToClipboard("番号",p):$(h.currentTarget).hasClass("downSvg")&&fetch(m.attr("src")).then((f=>f.blob())).then((f=>utils.download(f,p+" "+g+".jpg"))),this.closeCardMenus()}))}showImg(e,t,s){e.html(this.videoSvg).attr({title:"播放视频","aria-label":"播放视频"});let a=$(`#${`${s}_preview_video`}`);a.length>0&&(a[0].pause(),a.parent().hide()),t.show(),t.removeClass("loading"),t.next(".loading-spinner").remove()}async showVideo(e,t,s){let a=this.getRuntimeService("settings").snapshot();if(!ua(a))return show.error("预览视频已关闭");let i=this.previewGeneration,r=`${s}_preview_video`,n=$(`#${r}`);if(n.length>0)return n.parent().show(),await de(n[0],{context:"列表卡片预览",notify:!0}),void t.hide();t.addClass("loading"),t.after('
    ');let l=t.attr("data-full")||t.attr("src"),c=await this.getRuntimeService("scope")(),{sources:h,error:u}=await pa(s,this.getRuntimeService("storage"),this.getRuntimeService("movie"),c,a);if(i!==this.previewGeneration||!Ae(this.getRuntimeService("settings").snapshot()))return void this.showImg(e,t,s);if(!h)return show.error(u?.code==="REGION_BLOCKED"?u.message:"未解析到视频"),void this.showImg(e,t,s);let p=this.getRuntimeService("settings").snapshot().videoQuality;p=Ot(Object.keys(h),p);let g=h[p],m=document.createElement("div"),f=document.createElement("video");B||(m.className="jhs-layout-d543acf8"),f.src=g,f.poster=l||"",f.id=r,f.controls=!0,f.loop=!0,f.muted=!0,f.playsInline=!0,f.className="jhs-layout-a38a0e50",m.appendChild(f),t.parent().append(m),t.hide(),t.removeClass("loading"),t.next(".loading-spinner").remove(),n=$(f);let v=n[0];v.load(),v.muted=!1,await de(v,{context:"列表卡片预览",notify:!0,message:u?.code==="REGION_BLOCKED"?u.message:"当前视频源无法播放"}),n.trigger("focus")}};d(sn,"CoverButtonPlugin");var ma=sn;var an=class an extends I{constructor(){super(...arguments),this.previewGeneration=0,this.previewSession=null}getName(){return"PreviewVideoPlugin"}async initCss(){return".jhs-dmm-preview-player{display:none;width:100%;height:auto}.jhs-dmm-preview-player.is-active{display:block}.jhs-native-preview-hidden{display:none!important}"}async handle(){if(!isDetailPage)return;let e=this.getRuntimeService("settings");if(this.lifecycleScope||(this.lifecycleScope=await this.getRuntimeService("scope")()),!this._settingsListenerBound){this._settingsListenerBound=!0;let t=d(s=>{s.detail?.names?.some(i=>i==="enablePreviewVideo"||i==="enableLoadPreviewVideo")&&this.reconfigure()},"onSettingsChanged");e.addEventListener("settings.changed",t),this.lifecycleScope.addCleanup((()=>{e.removeEventListener("settings.changed",t),this._settingsListenerBound=!1,this.unmountPreview()}))}this.reconfigure()}reconfigure(){this.previewGeneration++;let e=this.getRuntimeService("settings").snapshot();if(!xc(e))return void this.unmountPreview();this.mountPreview(),zt(e)?this.initDmm(this.lifecycleScope).catch((t=>clog.error("预加载 DMM 失败",t))):(this.unmountDmmPlayer(),$(".fancybox-content #preview-video").length&&!$("#video-bottom-toolbar").length&&this.handleVideo().catch(t=>clog.error("恢复原生预览操作失败",t)))}mountPreview(){if(this._previewMounted)return;this._previewMounted=!0,$(".preview-video-container").removeClass("jhs-native-preview-hidden");let e=$(".preview-video-container"),t=d(()=>{this.cancelOpenWait?.(),this.cancelOpenWait=utils.loopDetector((()=>$(".fancybox-content #preview-video").length>0),(()=>{this.handleVideo().catch((a=>clog.error("预览视频处理失败",a)))}),20,1e4,!0,this.lifecycleScope)},"openVideo");e.off("click.jhsVideo").on("click.jhsVideo",t),this.lifecycleScope.addCleanup((()=>e.off("click.jhsVideo",t)));let s=window.location.href;(s.includes("gallery-1")||s.includes("gallery-2"))&&t(),s.includes("autoPlay=1")&&e.length>0&&e[0].click()}unmountDmmPlayer(){this.dmmRequestScope?.dispose(),$("[data-jhs-dmm-trigger]").remove();let e=$("#jhs-preview-video"),t=e[0],s=this.previewSession?.native;s&&t&&this.isSessionOpen(this.previewSession)&&(this.seekVideo(s,t.currentTime),!t.paused&&Ae(this.getRuntimeService("settings").snapshot())?de(s,{context:"恢复原生预览",notify:!1}).then(()=>{this.previewSession?.native!==s&&s.pause()}):s.pause()),t&&(t.pause(),e.removeAttr("src"),t.load(),e.remove()),$("#video-bottom-toolbar .jhs-video-quality-list").empty(),$("#preview-video").removeClass("jhs-native-preview-hidden"),this.dmmPreviewPromise=null,this.previewSession&&!s&&this.disposePreviewSession()}unmountPreview(){this._previewMounted=!1,this.cancelOpenWait?.(),this.disposePreviewSession(),this.unmountDmmPlayer(),$("#video-bottom-toolbar").remove(),$(".preview-video-container").addClass("jhs-native-preview-hidden")}unmountJhsPreview(){this.disposePreviewSession(),$("[data-jhs-dmm-trigger]").remove();let e=$("#jhs-preview-video"),t=e[0];t&&(t.pause(),e.removeAttr("src"),t.load(),e.remove()),$("#video-bottom-toolbar").remove(),$("#preview-video").removeClass("jhs-native-preview-hidden"),$(".preview-video-container").addClass("jhs-native-preview-hidden")}async initDmm(e){let t=this.previewGeneration,s=this.getRuntimeService("settings");try{let{sources:a}=await this.getDmmPreview(e);if(t!==this.previewGeneration||e?.disposed||!Ae(s.snapshot())||!cs(s.snapshot())||!a)return;if($(".fancybox-content #preview-video").length){await this.handleVideo();return}if($("#preview-video")[0])return;clog.debug("JavDB没有视频播放元素, 开始创建...");let n=$(".column-video-cover img").attr("src");if($("[data-jhs-dmm-trigger]").length)return;let l=$('');l.append($('').attr("src",n||"")),$(".preview-images").first().prepend(l),l.on("click.jhsVideo",()=>this.openDmmDialog())}catch(a){clog.error("预加载 DMM 失败:",a)}}getDmmPreview(e=this.lifecycleScope){if(this.dmmPreviewPromise)return this.dmmPreviewPromise;let t=new te("javdb-dmm-request");this.dmmRequestScope=t;let s,a=Promise.resolve(e||this.getRuntimeService("scope")()).then(i=>{if(i?.disposed||t.disposed)throw new Error("预览请求已取消");return s=i?.addCleanup?.(()=>t.dispose()),Xr(this.getPageInfo().carNum,this.getRuntimeService("storage"),this.getRuntimeService("movie"),t)}).then((i=>(this.dmmPreviewPromise===a&&(i.error?.retryable||i.error?.code==="HTTP_ERROR")&&(this.dmmPreviewPromise=null),i)),(i=>{throw this.dmmPreviewPromise===a&&(this.dmmPreviewPromise=null),i})).finally(()=>{t.dispose(),typeof s=="function"&&s()});return this.dmmPreviewPromise=a,a}createDmmPlayer(e){let t=e.length?e.parent():$(this.previewSession?.host),s=t.find("#jhs-preview-video");if(s.length)return s;let a=$('');return t.append(a),a}isSessionOpen(e){if(!e||e.scope.disposed||!e.host.isConnected||e.native&&!e.host.contains(e.native))return!1;for(let t=e.host;t;t=t.parentElement)if(t.getAttribute("aria-hidden")==="true"||getComputedStyle(t).display==="none")return!1;return!0}seekVideo(e,t){let s=d(()=>{try{e.currentTime=Math.max(0,Math.min(t||0,Number.isFinite(e.duration)?e.duration:1/0))}catch{}},"seek");s();let a=this.previewSession;if(a?.seekCleanups?.get(e)?.(),!e.readyState&&a){a.seekCleanups||(a.seekCleanups=new Map);let i=d(()=>{l(),this.isSessionOpen(a)&&s()},"loaded"),r=d(()=>l(),"failed"),n=setTimeout(r,5e3),l=a.scope.addCleanup(()=>{clearTimeout(n),e.removeEventListener("loadedmetadata",i),e.removeEventListener("error",r),a.seekCleanups.delete(e)});e.addEventListener("loadedmetadata",i,{once:!0}),e.addEventListener("error",r,{once:!0}),a.seekCleanups.set(e,l)}}disposePreviewSession(){let e=this.previewSession;if(this.previewSession=null,!e)return;this.dmmRequestScope?.dispose(),this.dmmPreviewPromise=null,e.scope.dispose(),$(e.host).find("#video-bottom-toolbar").remove();let t=e.host.querySelector("#jhs-preview-video");t&&(t.pause(),t.removeAttribute("src"),t.load(),t.remove()),e.native?.classList.remove("jhs-native-preview-hidden"),e.native?.pause(),e.dialogId!=null&&this.getRuntimeService("dialog").close(e.dialogId)}openDmmDialog(){if(!zt(this.getRuntimeService("settings").snapshot())||this.previewSession)return;let e=$('
    ');this.getRuntimeService("dialog").open({type:1,title:"预告片",ui:{body:"media"},content:e,area:utils.getDialogArea("lg"),success:d((t,s)=>{this.handleVideo(e[0],s).catch(a=>clog.error("预览失败",a))},"success"),end:d(()=>{this.previewSession?.host===e[0]&&this.disposePreviewSession()},"end")})}waitForDmmReady(e){let t=this.previewSession?.scope.signal;return t?.aborted||e.error?Promise.resolve(!1):e.readyState>=3?Promise.resolve(!0):new Promise(s=>{let a=d(l=>{clearTimeout(n),e.removeEventListener("canplay",i),e.removeEventListener("error",r),t?.removeEventListener("abort",r),s(l)},"finish"),i=d(()=>a(!0),"loaded"),r=d(()=>a(!1),"failed"),n=setTimeout(r,1e4);e.addEventListener("canplay",i,{once:!0}),e.addEventListener("error",r,{once:!0}),t?.addEventListener("abort",r,{once:!0})})}async restoreNativePlayer(e,t,s=!1,a=!0){if(!t||!this.isSessionOpen(this.previewSession))return!1;let i=e.parent().find("#jhs-preview-video"),r=i[0];return r&&(r.pause(),i.removeAttr("src"),r.load(),i.remove()),e.removeClass("jhs-native-preview-hidden"),a?de(t,{context:"JavDB 原生预览回退",notify:s}):(t.pause(),!0)}async handleVideo(e=null,t=null){let s=$(".fancybox-content #preview-video"),a=e||s[0]?.closest(".fancybox-content");if(!a||!Ae(this.getRuntimeService("settings").snapshot())||getComputedStyle(a).display==="none")return;if(this.previewSession?.host!==a){this.disposePreviewSession();let L=new te("javdb-preview");this.previewSession={host:a,native:s[0]||null,scope:L,dialogId:t},L.observe(document.body,()=>{this.previewSession?.host===a&&!this.isSessionOpen(this.previewSession)&&this.disposePreviewSession()},{childList:!0,subtree:!0,attributes:!0,attributeFilter:["class","style","aria-hidden"]})}let i=this.previewSession;if(i.generation===this.previewGeneration)return;i.generation=this.previewGeneration;let r=d(()=>this.previewSession===i&&this.isSessionOpen(i)&&u===this.previewGeneration&&Ae(this.getRuntimeService("settings").snapshot()),"alive"),n=this.getRuntimeService("settings"),l=$(a),c=s[0],h=n.snapshot().videoMuted,u=this.previewGeneration,p=cs(this.getRuntimeService("settings").snapshot()),g=p?await this.getDmmPreview():{sources:null,error:null};if(!r())return;let{sources:m,error:f}=g,v=$("
    ").attr("id","video-bottom-toolbar").addClass("jhs-video-toolbar"),b=$("
    ").addClass("jhs-video-quality-list").attr({role:"group","aria-label":"视频画质"});l.find("#video-bottom-toolbar").remove();let w=!1,S=null,j=null;if(m){let L=this.getRuntimeService("settings").snapshot().videoQuality,M=Ot(Object.keys(m),L),P=m[M],z=c?.currentTime||0,O=!c||!c.paused;S=this.createDmmPlayer(s),j=S[0],j.muted=h==null||h===!0,S.off("volumechange.jhsVideo").on("volumechange.jhsVideo",(()=>{n.set("videoMuted",j.muted).catch(Q=>clog.error("保存视频静音设置失败",Q))})),S.attr("src",P),j.load(),this.seekVideo(j,z),S.addClass("is-active");let H=await this.waitForDmmReady(j);if(!r()){j.pause();return}if(w=H&&(!O||await de(j,{context:"JavDB 高画质预览",notify:!1})),!r()){j.pause();return}if(H&&!w&&!j.muted&&(j.muted=!0,w=await de(j,{context:"JavDB 高画质预览静音重试",notify:!1})),!r()){j.pause();return}if(w?(c?.pause(),s.addClass("jhs-native-preview-hidden")):(S.removeClass("is-active"),await this.restoreNativePlayer(s,c,!0,O)),!r())return;if(!w&&!c){show.error("当前视频源无法播放"),this.disposePreviewSession();return}w&&at.forEach((Q=>{let se=m[Q.quality];if(!se)return;let ge=w&&M===Q.quality;b.append($('').toggleClass("active",ge).attr({"data-quality":Q.quality,"data-video-src":se,"aria-pressed":String(ge)}).text(Q.text))}))}v.append(b);let k=$("
    ").addClass("jhs-toolbar");if(this.getOptionalDependency("DetailPageButtonPlugin")&&k.append(Nt({actions:["blocked","favorite"],ids:{blocked:"video-filterBtn",favorite:"video-favoriteBtn"}})),k.append(''),!r())return;v.append(k),l.append(v),v.off("click.jhsVideo").on("click.jhsVideo",".jhs-video-quality-btn",(async L=>{let M=$(L.currentTarget);if(!M.hasClass("active"))try{if(!j?.isConnected||!r())return;let P=j.currentTime,z=S.attr("src"),O=!j.paused;S.attr("src",M.data("video-src")),j.load(),this.seekVideo(j,P);let H=await this.waitForDmmReady(j);if(!r())return;let Q=H&&(!O||await de(j,{context:"JavDB 画质切换",notify:!1}));if(!r())return;if(Q)v.find(".jhs-video-quality-btn").removeClass("active").attr("aria-pressed","false"),M.addClass("active").attr("aria-pressed","true");else{z&&(S.attr("src",z),j.load(),j.currentTime=P);let se=z&&await this.waitForDmmReady(j)&&(!O||await de(j,{context:"JavDB 画质切换回退",notify:!1}));if(!r())return;se||await this.restoreNativePlayer(s,c,!0,O)}}catch(P){clog.error("切换画质失败:",P)}})),$("#speed-btn").off("click.jhsVideo").on("click.jhsVideo",(()=>{let L=l.find("#jhs-preview-video.is-active")[0]||c;L?.isConnected&&this.isSessionOpen(i)&&this.seekVideo(L,L.currentTime+10)})),v.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo","#speed-btn",(L=>(L.preventDefault(),this.getOptionalDependency("DetailPageButtonPlugin")?.filterOne?.(L)))),$("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo",(L=>this.getOptionalDependency("DetailPageButtonPlugin")?.filterOne?.(L))),$("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo",(L=>this.getOptionalDependency("DetailPageButtonPlugin")?.favoriteOne?.(L)));let _=this.getOptionalDependency("DetailPageButtonPlugin"),E=_?.getDetailStateController?.();if(E){v.find("#video-filterBtn,#video-favoriteBtn").off("click.jhsVideo");let L=E.bind({root:v[0],closeRoot:document.documentElement,carNum:this.getPageInfo().carNum,layerIndex:utils.getOwningLayerIndex({root:document.documentElement}),getRecord:d(()=>_.getStateRecord(),"getRecord"),selectors:{blocked:"#video-filterBtn",favorite:"#video-favoriteBtn"}});i.stateCleanup?.(),i.stateCleanup=F?.on("car-state-changed",M=>{if(M.carNums?.includes(L.carNum)&&this.isSessionOpen(i))return E.render(L)}),i.stateCleanup&&i.scope.addCleanup(i.stateCleanup)}}};d(an,"PreviewVideoPlugin");var fa=an;function kc(o){if(typeof o=="string")return new DOMParser().parseFromString(o,"text/html");if(typeof o?.querySelectorAll=="function")return o;let e=o?.[0]??o?.get?.(0);if(typeof e?.querySelectorAll=="function")return e;throw new TypeError("JavStore document is invalid")}d(kc,"resolveDocument");function ds(o,e="https://javstore.net"){if(!o)return null;try{let t=new URL(String(o),e),s=t.hostname.toLowerCase();return["http:","https:"].includes(t.protocol)?(t.protocol==="http:"&&(s==="javstore.net"||s.endsWith(".javstore.net"))&&(t.protocol="https:"),t.href):null}catch{return null}}d(ds,"normalizeJavStoreAssetUrl");function Cc(o,e,t="https://javstore.net"){let s=ee(e);if(!s)return[];if(typeof o?.find=="function"){let a=globalThis.jQuery??globalThis.$??o.constructor;return o.find('a[href$="-pn.html"]').filter(((i,r)=>a(r).text().trim().toUpperCase().includes(s.toUpperCase()))).map(((i,r)=>new URL(a(r).attr("href"),t).href)).get()}return[...kc(o).querySelectorAll('a[href$="-pn.html"]')].filter(a=>a.textContent?.trim().toUpperCase().includes(s.toUpperCase())).map(a=>new URL(a.getAttribute("href")||"",t).href)}d(Cc,"parseJavStoreSearch");function _c(o,e){if(typeof o?.find=="function"){let a=globalThis.jQuery??globalThis.$??o.constructor,r=o.find("a").filter(((l,c)=>a(c).text().trim()==="CLICK HERE!")).first().attr("href");if(!r)return null;let n=ds(r,e);return n?n.replace(".th",""):null}let t=[...kc(o).querySelectorAll("a")].find(a=>a.textContent?.trim()==="CLICK HERE!")?.getAttribute("href");if(!t)return null;let s=ds(t,e);return s?s.replace(".th",""):null}d(_c,"parseJavStorePreview");var rn=class rn extends I{getName(){return"ScreenShotPlugin"}getScreenshotService(){return this.getRuntimeService("screenshot")}getSettingsSnapshot(){return this.getRuntimeService("settings")?.snapshot?.()??{}}async initCss(){return""}async handle(){if(!isDetailPage)return;let e=this.getRuntimeService("settings"),t=await this.getRuntimeService("scope")(),s=d(a=>{a.detail?.names?.includes("enableLoadScreenShot")&&(e.snapshot().enableLoadScreenShot==="no"?this.unmountHosted():this.loadScreenShot().catch(r=>clog.error("长缩略图重新加载失败",r)))},"onSettingsChanged");e.addEventListener("settings.changed",s),t.addCleanup((()=>e.removeEventListener("settings.changed",s))),this.loadScreenShot().catch(a=>clog.error("长缩略图加载失败",a))}unmountHosted(){$(".screen-container, .jhs-screenshot-providers").remove()}async loadScreenShot(){if(!isDetailPage||!this.getScreenshotService().isEnabled(this.getSettingsSnapshot()))return;let t=this.getPageInfo().carNum;N&&$(".preview-images .tile-item").first().before('
    正在加载缩略图
    '),B&&$("#sample-waterfall .sample-box:first").after('
    正在加载缩略图
    ');try{let s=await this.getScreenshot(t);s?(this.addImg("缩略图",s),clog.log("加载缩略图:",s)):this.showErrorFallback(t,null)}catch(s){this.showErrorFallback(t,s)}}async getScreenshot(e){if(e=T(e),!e)throw clog.warn("跳过缩略图解析:番号不可用"),new Error("缩略图番号不可用");let t=arguments[1]||{},s=this.getScreenshotService(),a=this.getSettingsSnapshot();if(!t.allowWhenDisabled&&!s.isEnabled(a))return clog.warn("长缩略图功能已关闭,跳过请求"),null;let i=await this.getRuntimeService("scope")(),r=await s.resolve({carNum:e},{scope:i,settings:a,allowWhenDisabled:t.allowWhenDisabled===!0});return(Array.isArray(r)?r[0]:r)?.url||null}addImg(e,t){let s=ds(t);if(!s)return;let a=$(".screen-container").empty(),i=$("").attr({src:s,alt:e,loading:"lazy"});N&&a.append(i.addClass("jhs-layout-cad980f4")),B&&a.append($('
    ').append(i.attr("title",e).addClass("jhs-layout-d4a575e8"))),a.on("click",(r=>{r.stopPropagation(),r.preventDefault(),globalThis.showImageViewer(r.currentTarget)}))}showErrorFallback(e,t){let s=t instanceof Error?t.message:"";t&&clog.error("获取缩略图失败:",s.substring(0,100));let a=`jhs-screenshot-message${B?" jhs-screenshot-message--bus":""}`,i=T(e);if(!i)return void $(".screen-container").empty().append($("
    ").addClass(a).text("无法获取番号,缩略图未加载"));let r=this.getScreenshotService().getSearchUrl({carNum:i}),n=$(".screen-container").empty(),l=$("
    ").addClass(a).text(t instanceof Error?"获取缩略图失败":"暂无缩略图结果"),c=$('点击重试');n.append(l,$("
    "),c),r&&n.append(document.createTextNode(" 或 "),$('前往确认').attr("href",r)),n.off("click",".retry-link").off("click",".check-link").on("click",".retry-link",(async h=>{h.stopPropagation(),h.preventDefault(),n.empty().append($("
    ").addClass(a).text("正在重新加载..."));try{let u=await this.getScreenshot(i);u?this.addImg("缩略图",u):this.showErrorFallback(i,null)}catch(u){this.showErrorFallback(i,u)}})).on("click",".check-link",(h=>{h.stopPropagation(),h.preventDefault(),window.open(r,"_blank")}))}async loadInto(e,t,{isActive:s=d(()=>!0,"isActive")}={}){let a=$(e);if(!a.length||!this.getScreenshotService().isEnabled(this.getSettingsSnapshot()))return a.empty(),null;let i=await this.getRuntimeService("scope")();return Zs({target:a,carNum:t,screenshot:this.getScreenshotService(),settings:this.getSettingsSnapshot(),scope:i,isActive:s})}renderInto(e,t,s){let a=$(e),i=$("").attr({src:ds(t),alt:s,loading:"lazy"}).addClass("jhs-fc2-gallery__image"),r=$('').attr("aria-label",`查看${s}大图`).append(i);a.empty().append(r).off("click.jhsScreenshot").on("click.jhsScreenshot",".jhs-fc2-screenshot-thumbnail",(n=>{n.preventDefault(),n.stopPropagation(),globalThis.showImageViewer(i[0])}))}};d(rn,"ScreenShotPlugin");var va=rn;var $c="jhs_img_cdn_index";function Mh(o,e,t,s=Date.now()){let a=new Map;for(let i of o)if(Array.isArray(i.newVideoList))for(let r of i.newVideoList){let n=typeof r=="object"?r:{},l=T(typeof r=="string"?r:r.carNum);if(!l)continue;let c=a.get(l)||{carNum:l,coverUrl:"",title:"",publishTime:"",actresses:[],starIds:[],categories:new Set,score:0,voteCount:0,url:"",isVr:!1};c.coverUrl||(c.coverUrl=n.coverUrl||""),c.title||(c.title=n.title||""),c.publishTime=[c.publishTime,n.publishTime||""].sort().at(-1),c.score=Math.max(c.score,Number(n.score)||0),c.voteCount=Math.max(c.voteCount,Number(n.voteCount)||0),c.url||(c.url=n.url||""),c.isVr||(c.isVr=n.isVr===!0||/(^|[^A-Z])VR([^A-Z]|$)/i.test(`${n.title||""} ${(n.tags||[]).join?.(" ")||""} ${(n.categories||[]).join?.(" ")||""}`)),i.name&&!c.actresses.includes(i.name)&&c.actresses.push(i.name),i.starId&&!c.starIds.includes(i.starId)&&c.starIds.push(i.starId),i.actressType&&c.categories.add(i.actressType),a.set(l,c)}return[...a.values()].map((i=>{let r=e.get(i.carNum),n=q(r?.stateFlags),l=t[i.carNum]||null,c=l?l.action==="snoozed"&&l.until&&Date.parse(l.until)<=s?"pending":l.action:"pending";return{...i,actressName:i.actresses.join("、"),starId:i.starIds[0]||"",categories:[...i.categories],flags:n,decision:l,decisionState:c}}))}d(Mh,"aggregateNewVideoRecords");var nn=class nn extends I{constructor(){super(...arguments),A(this,"currentPage",1),A(this,"pageSize",30),A(this,"nvCurrentPage",1),A(this,"nvPageSize",60),A(this,"nvFlatListCache",[]),A(this,"nvAllItemsMap",new Map),A(this,"nvActressesCache",[]),A(this,"nvCarMapCache",new Map),A(this,"nvSortBy","publishTime_desc"),A(this,"nvSelected",new Set),A(this,"nvDecisionsCache",{}),A(this,"nvCoverCache",new Map),A(this,"nvActorCoverRequests",new Map),A(this,"nvRenderGeneration",0),A(this,"nvSearchDebounced",null),A(this,"nvInvalidationTimer",null),A(this,"nvWorkspaceReloadPromise",null),A(this,"nvWorkspaceReloadDirty",!1),A(this,"nvWorkspaceMounted",!1),A(this,"nvEventUnsubscribe",null),A(this,"taskStatusUnsubscribe",null),A(this,"nvCoverPreview",null),A(this,"nvWorkspaceRoot",null),A(this,"nvJavDbUrl",""),A(this,"nvRuleTime",8760),A(this,"avatarSources",[]),A(this,"avatarSourceIndex",0)}getName(){return"NewVideoPlugin"}getStartupMode(){return"idle"}async initCss(){return` - `; - } - async handle() { - this.nvEventUnsubscribe || (this.nvEventUnsubscribe = jhsEventBus.on("new-video-changed", (() => this.scheduleWorkspaceReload()))); - this.taskStatusUnsubscribe || (this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.isWorkspaceMounted() && this.renderTaskStatuses()))); - await this.showNewVideoCount(); - } - isWorkspaceMounted() { - return this.nvWorkspaceMounted && $(".newVideoToolBox").length > 0; - } - scheduleWorkspaceReload() { - this.nvWorkspaceReloadDirty = true; - this.nvInvalidationTimer || (this.nvInvalidationTimer = setTimeout((() => { - this.nvInvalidationTimer = null, void this.flushWorkspaceReload(); - }), 0)); - } - async flushWorkspaceReload() { - if (this.nvWorkspaceReloadPromise) return this.nvWorkspaceReloadPromise; - return this.nvWorkspaceReloadPromise = (async () => { - do { - this.nvWorkspaceReloadDirty = false, await this.showNewVideoCount(); - this.isWorkspaceMounted() && await this.reloadNewVideoWorkspaceData({ preservePage: true }); - } while (this.nvWorkspaceReloadDirty); - })().catch(((error) => clog.error("新作品数据刷新失败", error))).finally((() => { - this.nvWorkspaceReloadPromise = null; - })), this.nvWorkspaceReloadPromise; - } - getPendingNewVideoCount(e2, t2) { - return Array.isArray(e2?.newVideoList) ? new Set(e2.newVideoList.map(((item) => normalizeCarNum("string" == typeof item ? item : item.carNum))).filter(((carNum) => carNum && !t2.has(carNum) && !this.isDecisionHidden(carNum)))).size : 0; - } - isDecisionHidden(carNum) { - const decision = this.nvDecisionsCache[normalizeCarNum(carNum)]; - if (!decision) return false; - return "ignored" === decision.action || "snoozed" === decision.action && (!decision.until || Date.parse(decision.until) > Date.now()); - } - async getPendingNewVideoTotal() { - const e2 = await storageManager.getCarMap(), keys = /* @__PURE__ */ new Set(); - this.nvDecisionsCache = await stateService.getNewVideoDecisions(); - (await storageManager.getFavoriteActressList()).forEach(((actress) => Array.isArray(actress.newVideoList) && actress.newVideoList.forEach(((item) => { - const carNum = normalizeCarNum("string" == typeof item ? item : item.carNum); - carNum && !e2.has(carNum) && !this.isDecisionHidden(carNum) && keys.add(carNum); - })))); - return keys.size; - } - async showNewVideoCount() { - const e2 = await this.getPendingNewVideoTotal(); - $("#newVideoCount").text(`${e2}`); - } - async resetBtnTip() { - const e2 = this.getBean("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = localStorage.getItem(e2.lastCheckFavoriteActressTimeKey) || "无", a2 = t2.checkFavoriteActress_IntervalTime, i2 = localStorage.getItem(e2.lastCheckNewVideoTimeKey) || "无", s2 = t2.checkNewVideo_intervalTime; - $("#checkFavoriteActress").attr("data-tip", `上次完整同步: ${n2}; 检测间隔时间: ${a2}小时`), $("#checkNewVideo").attr("data-tip", `上次整批检测: ${i2}; 检测间隔时间: ${s2}小时`); - } - async openDialog() { - this.cleanupNewVideoWorkspace(), this._viewMode = "list" === localStorage.getItem("jhs_newVideoViewMode") ? "list" : "actress", this.currentPage = 1, this.nvCurrentPage = 1, this.nvSelected = /* @__PURE__ */ new Set(), this.nvCoverCache = /* @__PURE__ */ new Map(), this.nvActorCoverRequests = /* @__PURE__ */ new Map(), this.nvRenderGeneration++; - const e2 = this.getBean("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = localStorage.getItem(e2.lastCheckFavoriteActressTimeKey) || "无", a2 = t2.checkFavoriteActress_IntervalTime, i2 = localStorage.getItem(e2.lastCheckNewVideoTimeKey) || "无", s2 = t2.checkNewVideo_intervalTime; - let o2 = ` + `}async handle(){this.initializeLocalState(),this.nvEventUnsubscribe||(this.nvEventUnsubscribe=F.on("new-video-changed",(()=>this.scheduleWorkspaceReload()))),this.taskStatusUnsubscribe||(this.taskStatusUnsubscribe=F.on("task-status-changed",(()=>this.isWorkspaceMounted()&&this.renderTaskStatuses()))),await this.showNewVideoCount()}initializeLocalState(){this.avatarSources=this.getRuntimeService("actressInfo").getAvatarSources();let e=parseInt(this.getRuntimeService("storage").getLocal($c)||"0",10);this.avatarSourceIndex=Number.isInteger(e)&&e>=0&&e0}scheduleWorkspaceReload(){this.nvWorkspaceReloadDirty=!0,this.nvInvalidationTimer||(this.nvInvalidationTimer=setTimeout((()=>{this.nvInvalidationTimer=null,this.flushWorkspaceReload()}),0))}async flushWorkspaceReload(){return this.nvWorkspaceReloadPromise?this.nvWorkspaceReloadPromise:(this.nvWorkspaceReloadPromise=(async()=>{do this.nvWorkspaceReloadDirty=!1,await this.showNewVideoCount(),this.isWorkspaceMounted()&&await this.reloadNewVideoWorkspaceData({preservePage:!0});while(this.nvWorkspaceReloadDirty)})().catch((e=>clog.error("新作品数据刷新失败",e))).finally((()=>{this.nvWorkspaceReloadPromise=null})),this.nvWorkspaceReloadPromise)}getPendingNewVideoCount(e,t){return Array.isArray(e?.newVideoList)?new Set(e.newVideoList.map((s=>T(typeof s=="string"?s:s.carNum))).filter((s=>s&&!t.has(s)&&!this.isDecisionHidden(s)))).size:0}isDecisionHidden(e){let t=this.nvDecisionsCache[T(e)];return t?["ignored","dismissed"].includes(t.action)||t.action==="snoozed"&&(!t.until||Date.parse(t.until)>Date.now()):!1}async getPendingNewVideoTotal(){let e=await storageManager.getCarMap(),t=new Set;return this.nvDecisionsCache=await this.getRuntimeService("state").getNewVideoDecisions(),(await storageManager.getFavoriteActressList()).forEach((s=>Array.isArray(s.newVideoList)&&s.newVideoList.forEach((a=>{let i=T(typeof a=="string"?a:a.carNum);i&&!oe(q(e.get(i)?.stateFlags))&&!this.isDecisionHidden(i)&&t.add(i)})))),t.size}async showNewVideoCount(){let e=await this.getPendingNewVideoTotal();$("#newVideoCount").text(`${e}`)}async resetBtnTip(){let e=this.getRuntimeService("storage"),t=this.getOptionalDependency("TaskPlugin"),s=await storageManager.getSetting(),a=t?e.getLocal(t.lastCheckFavoriteActressTimeKey)||"无":"任务已禁用",i=s.checkFavoriteActress_IntervalTime,r=t?e.getLocal(t.lastCheckNewVideoTimeKey)||"无":"任务已禁用",n=s.checkNewVideo_intervalTime;$("#checkFavoriteActress").attr("data-tip",`上次完整同步: ${a}; 检测间隔时间: ${i}小时`),$("#checkNewVideo").attr("data-tip",`上次整批检测: ${r}; 检测间隔时间: ${n}小时`)}async openDialog(){let e=this.getRuntimeService("storage");this.cleanupNewVideoWorkspace(),this._viewMode=e.getLocal("jhs_newVideoViewMode")==="list"?"list":"actress",this.currentPage=1,this.nvCurrentPage=1,this.nvSelected=new Set,this.nvCoverCache=new Map,this.nvActorCoverRequests=new Map,this.nvRenderGeneration++;let t=this.getOptionalDependency("TaskPlugin"),s=await storageManager.getSetting(),a=t?e.getLocal(t.lastCheckFavoriteActressTimeKey)||"无":"任务已禁用",i=s.checkFavoriteActress_IntervalTime,r=t?e.getLocal(t.lastCheckNewVideoTimeKey)||"无":"任务已禁用",n=s.checkNewVideo_intervalTime,l=`
    `; - layer.open({ - type: 1, - title: '新作品检测', - content: o2, - scrollbar: false, - area: utils.getDialogArea("workspace"), - anim: -1, - success: /* @__PURE__ */ __name(async (e3, t3) => { - this.nvWorkspaceMounted = true, JhsSelect.enhance(e3), this.bindClick(), this.applyViewMode(), this.renderTaskStatuses(), await this.reloadNewVideoWorkspaceData(), utils.setupEscClose(t3); - }, "success"), - end: /* @__PURE__ */ __name(() => this.cleanupNewVideoWorkspace(), "end") - }); - } - cleanupNewVideoWorkspace() { - this.nvSearchDebounced?.cancel?.(), this.nvSearchDebounced = null, this.nvWorkspaceMounted = false, this.nvRenderGeneration++, this.nvSelected.clear(), this.nvCoverCache = /* @__PURE__ */ new Map(), this.nvActorCoverRequests = /* @__PURE__ */ new Map(), this.nvAllItemsMap.clear(), this.nvFlatListCache = [], this.nvActressesCache = [], this.nvCarMapCache = /* @__PURE__ */ new Map(), this.nvDecisionsCache = {}, this.nvCurrentPageItems = []; - } - bindClick() { - const taskPlugin = this.getBean("TaskPlugin"); - $("#reLoad").on("click", (() => { - void this.reloadNewVideoWorkspaceData(), $("#checkNewVideoMsg").text(""); - })), $("#new-video-list-container").on("click", ".nv-card__link", (async (e2) => { - const t2 = $(e2.currentTarget).closest(".nv-card").attr("data-car"); - if (!t2) return; - try { - const enabled = await storageManager.getSetting("autoRemoveNewVideoMarkAfterBrowse", C); - if (enabled !== _) return; - await stateService.removeFromNewVideoList([t2], "browse"); - } catch (n2) { - clog.error("移除新作品标记失败:", n2); - } - })), $("#toSetting").on("click", ((e2) => { - this.getBean("SettingPlugin").openSettingDialog("task-panel", (() => { - $("#setting-checkFavoriteActress").css({ - border: "1px solid var(--jhs-status-filter)" - }), $("#setting-checkNewVideo").css({ - border: "1px solid var(--jhs-status-filter)" - }); - })); - })); - $("#checkFavoriteActress").on("click", ((event) => { - void this.runManualTask($(event.currentTarget), "同步中…", (async () => { - if (!$('a[href*="/users/profile"]').length) return void show.error("未登录 JavDB,同步失败"); - await taskPlugin.checkFavoriteActress(true); - })); - })), $("#checkNewVideo").on("click", ((event) => { - void this.runManualTask($(event.currentTarget), "检测中…", (() => taskPlugin.checkNewVideo(true))); - })), $("#paramActressType").on("change", ((e2) => { - this.currentPage = 1, this.nvRenderGeneration++, "actress" === this._viewMode && this.renderActressCards(); - })), $("#paramSortBy").on("change", ((e2) => { - this.currentPage = 1, this.nvRenderGeneration++, "actress" === this._viewMode && this.renderActressCards(); - })), $("#nvSortBy").on("change", ((e2) => { - this.nvSortBy = $("#nvSortBy").val(), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); - })), $("#nvCategoryFilter,#nvStateFilter,#nvDecisionFilter").on("change", ((e2) => { - this.nvCurrentPage = 1, this.nvRenderGeneration++, "list" === this._viewMode && this.renderNewVideoList(); - })), this.nvSearchDebounced = utils.debounce((() => { - this.nvCurrentPage = 1, this.nvRenderGeneration++, "list" === this._viewMode && this.renderNewVideoList(); - }), 200), $("#nvSearch").on("input", this.nvSearchDebounced), $(".jhs-new-video-view").on("click", '[role="tab"]', ((event) => { - this.setViewMode($(event.currentTarget).data("view")); - })).on("keydown", '[role="tab"]', ((event) => { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const tabs = $(event.delegateTarget).find('[role="tab"]'), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; - tabs.eq(next).trigger("click").trigger("focus"); - })), this.bindBatchActions(); - } - async runManualTask(button, busyLabel, runner) { - if (button.attr("aria-busy") === "true") return; - const label = button.find("span").last(), previous = label.text(); - button.attr("aria-busy", "true").prop("disabled", true), label.text(busyLabel); - try { - await navigator.locks.request(this.getBean("TaskPlugin").singleTaskKey, { ifAvailable: true }, (async (lock) => { - if (!lock) return void show.error("后台任务正在运行,请稍后再试"); - await runner(); - })); - } catch (error) { - clog.error("手动任务执行失败", error), this.getBean("TaskPlugin").isNetworkBlocked(error) && show.error(error.message || "任务执行失败"); - } finally { - button.removeAttr("aria-busy").prop("disabled", false), label.text(previous), this.renderTaskStatuses(); - } - } - setViewMode(mode) { - if (!["actress", "list"].includes(mode) || mode === this._viewMode) return; - this._viewMode = mode, localStorage.setItem("jhs_newVideoViewMode", mode), "list" === mode ? this.nvCurrentPage = 1 : this.currentPage = 1, this.nvRenderGeneration++, this.applyViewMode(), this.renderCurrentView(); - } - applyViewMode() { - const list = "list" === this._viewMode; - $("#actress-card-container").toggle(!list), $("#actress-pagination").toggle(!list), $("#new-video-list-container").toggle(list), $("#new-video-list-footer").toggle(list), JhsSelect.setVisible("#paramSortBy", !list), JhsSelect.setVisible("#nvSortBy", list), JhsSelect.setVisible("#paramActressType", !list), JhsSelect.setVisible("#nvCategoryFilter", list), JhsSelect.setVisible("#nvStateFilter", list), JhsSelect.setVisible("#nvDecisionFilter", list), $("#nvSearch").toggleClass("jhs-is-hidden", !list), $(".jhs-new-video-view [role='tab']").each(((index, tab) => { - const active = $(tab).data("view") === this._viewMode; - $(tab).attr({ "aria-selected": String(active), tabindex: active ? "0" : "-1" }).toggleClass("active", active); - })); - } - renderCurrentView() { - return "list" === this._viewMode ? this.renderNewVideoList() : this.renderActressCards(); - } - loadData() { - this.currentPage = 1; - return this.reloadNewVideoWorkspaceData(); - } - async reloadNewVideoWorkspaceData({ preservePage = false } = {}) { - if (!this.isWorkspaceMounted()) return; - const generation = ++this.nvRenderGeneration, container = "list" === this._viewMode ? $("#new-video-list-container") : $("#actress-card-container"); - renderStateView(container, { type: "loading", title: "加载中" }); - try { - const [actresses, carMap, decisions, javDbUrl, ruleTime] = await Promise.all([storageManager.getFavoriteActressList(), storageManager.getCarMap(), stateService.getNewVideoDecisions(), this.getBean("OtherSitePlugin").getJavDbUrl(), storageManager.getSetting("checkNewVideo_ruleTime", 8760)]); - if (!this.isWorkspaceMounted() || generation !== this.nvRenderGeneration) return; - this.nvActressesCache = actresses, this.nvCarMapCache = carMap, this.nvDecisionsCache = decisions, this.nvJavDbUrl = javDbUrl, this.nvRuleTime = parseNumberSetting(ruleTime, 8760, { min: 0 }); - const items = aggregateNewVideoRecords(actresses, carMap, decisions), nextMap = /* @__PURE__ */ new Map(); - items.forEach(((item) => nextMap.set(normalizeCarNum(item.carNum), { ...item, carNum: normalizeCarNum(item.carNum) }))), this.nvAllItemsMap = nextMap; - for (const carNum of [...this.nvSelected]) nextMap.has(carNum) || this.nvSelected.delete(carNum); - preservePage || (this.currentPage = 1, this.nvCurrentPage = 1), await this.renderCurrentView(), this.renderTaskStatuses(); - } catch (error) { - if (generation !== this.nvRenderGeneration || !this.isWorkspaceMounted()) return; - clog.error("加载新作品工作区失败", error), renderStateView(container, { type: "error", title: "加载失败", description: error.message || String(error), actionLabel: "重试", onAction: /* @__PURE__ */ __name(() => this.reloadNewVideoWorkspaceData({ preservePage: true }), "onAction") }); - } - } - renderTaskStatuses() { - const container = $("#jhs-task-status-list"); - if (!container.length) return; - const taskPlugin = this.getBean("TaskPlugin"), names = { favoriteActress: "演员同步", newVideo: "新作品", blacklist: "黑名单" }, labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"); - container.empty(), ["favoriteActress", "newVideo", "blacklist"].forEach(((name) => { - const snapshot = taskPlugin.getTaskStatusSnapshot(name), item = $('
    '); - item.append($('').text(`${names[name]}:${labels[snapshot.state]}`)), item.append($('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)), container.append(item); - })); - } - async renderActressCards() { - const e2 = $("#actress-card-container"); - if (!e2.length) return; - let t2 = [...this.nvActressesCache]; - const n2 = $("#paramActressType").val(); - "all" !== n2 && (t2 = t2.filter(((e3) => e3.actressType === n2))); - const _carSet = this.nvCarMapCache; - const _newVideoCount = /* @__PURE__ */ __name((e3) => this.getPendingNewVideoCount(e3, _carSet), "_newVideoCount"); - const sortBy = $("#paramSortBy").val(); - const sortMap = { - "lastPublishTime_desc": [{ key: "lastPublishTime", order: "desc" }], - "lastPublishTime_asc": [{ key: "lastPublishTime", order: "asc" }], - "lastCheckTime_desc": [{ key: "lastCheckTime", order: "desc" }], - "lastCheckTime_asc": [{ key: "lastCheckTime", order: "asc" }], - "newVideoCount_desc": [{ key: _newVideoCount, order: "desc" }], - "newVideoCount_asc": [{ key: _newVideoCount, order: "asc" }] - }; - const defaultSort = [{ - key: _newVideoCount, - order: "desc" - }, { - key: "lastPublishTime", - order: "desc" - }]; - const sortedActresses = utils.genericSort(t2, sortMap[sortBy] || defaultSort); - const totalCount = sortedActresses.length, totalPages = Math.ceil(totalCount / this.pageSize), pageStart = (this.currentPage - 1) * this.pageSize, pageEnd = pageStart + this.pageSize; - totalPages > 0 && this.currentPage > totalPages && (this.currentPage = totalPages); - const safePageStart = (this.currentPage - 1) * this.pageSize, pageActresses = sortedActresses.slice(safePageStart, safePageStart + this.pageSize), javDbUrl = this.nvJavDbUrl, taskPlugin = this.getBean("TaskPlugin"), ruleTime = this.nvRuleTime; - if (0 === pageActresses.length) { - renderStateView(e2, { type: "empty", title: this.nvActressesCache.length ? "没有符合当前筛选条件的演员" : "暂无收藏演员" }); - return void this.renderPagination(totalCount, totalPages); - } - const cards = pageActresses.map(((actress) => { - const allNames = Array.isArray(actress.allName) ? actress.allName.join(",") : ""; - const name = String(actress.name || ""), remark = String(actress.remark || ""), starId = String(actress.starId || ""); - const newVideoCount = this.getPendingNewVideoCount(actress, _carSet), latestPublishTime = actress.lastPublishTime || ""; - const profileUrl = normalizeHttpUrl(`/actors/${encodeURIComponent(starId)}?t=d`, javDbUrl), avatarUrl = normalizeHttpUrl(actress.avatar, javDbUrl) || "https://c0.jdbstatic.com/images/actor_unknow.jpg"; - const isPaused = shouldSkipStopped(latestPublishTime, ruleTime); - let typeLabel = "未知", typeClass = "is-unknown"; - actress.actressType === A ? (typeLabel = "无码", typeClass = "is-uncensored") : actress.actressType === D && (typeLabel = "有码", typeClass = "is-censored"); - const publishText = String(latestPublishTime || "暂无记录"), noteText = isPaused ? `停更 ${ruleTime / 24 / 365} 年以上,下轮任务不再检测` : remark || "暂无备注"; - const card = $('
    ').toggleClass("is-paused", isPaused).attr("data-starId", starId), badges = $('
    '); - badges.append($('').attr("data-tip", `最新作品数量: ${newVideoCount}`).text(`${newVideoCount} 新`), $('').addClass(typeClass).text(typeLabel)), isPaused && badges.append('停更'); - const profile = $('').attr("href", profileUrl || "#"), identity = $("").append($('').text(name), $('').attr("title", allNames).text(allNames || "暂无别名")); - profile.append($('').attr({ src: avatarUrl, alt: allNames }), identity); - const meta = $('
    ').append($('
    最近作品
    ').append($("
    ").attr("title", publishText).text(publishText)), $('
    上次检测
    ').append($("
    ").text(String(actress.lastCheckTime || "暂无记录")))), actions = $('
    '); - const check = $('').attr("data-starId", starId).prepend(this.checkSvg), remove = $('').attr({ "aria-label": `取消收藏 ${name}`, "data-starId": starId }).append(this.deleteSvg), edit = $('').attr("data-starId", starId).prepend(this.editSvg); - actions.append(check, remove, $('
    •••
    ').find(".actress-card__menu-popover").append(edit).end()); - return card.append(badges, profile, meta, $('

    ').attr("title", noteText).text(noteText), actions)[0]; - })); - e2.empty().append(cards), $(".btn-delete-actress").off("click").on("click", ((e3) => { - e3.preventDefault(); - const t3 = $(e3.currentTarget).attr("data-starId"), n3 = sortedActresses.find(((e4) => e4.starId === t3)); - utils.q(e3, `是否取消收藏 ${n3.name}?`, (async () => { - let e4 = `${await this.getBean("OtherSitePlugin").getJavDbUrl()}/actors/${t3}/uncollect`; - const n4 = document.querySelector("meta[name=csrf-token]").content, a2 = await gmHttp.post(e4, null, { - "x-csrf-token": n4 - }); - a2.includes("removeClass") ? (await storageManager.removeFavoriteActress(t3), await jhsEventBus.emit("new-video-changed", { reason: "favorite-actress-removed" })) : (show.error("移除失败"), clog.error("移除失败,返回值:", a2)); - })); - })), $(".btn-edit-actress").off("click").on("click", ((e3) => { - e3.preventDefault(); - const t3 = $(e3.currentTarget).attr("data-starId"), n3 = sortedActresses.find(((e4) => e4.starId === t3)); - n3 ? this.editActress(n3) : show.error(`未找到 starId 为 ${t3} 的女优记录。`); - })), $(".btn-check-actress").off("click").on("click", ((e3) => { - e3.preventDefault(); - const button = $(e3.currentTarget), starId = button.attr("data-starId"), actress = sortedActresses.find(((item) => item.starId === starId)); - void this.runManualTask(button, "检测中…", (() => taskPlugin.checkOneNewVideo(actress))); - })), $(".actress-card__menu").on("keydown", ((event) => { - if ("Escape" !== event.key) return; - event.preventDefault(); - const details = $(event.currentTarget); - details.prop("open", false).find("summary").trigger("focus"); - })).on("click", "[role='menuitem']", ((event) => { - $(event.currentTarget).closest("details").prop("open", false); - })), this.renderPagination(totalCount, totalPages); - } - async getNewVideoFlatList() { - const category = $("#nvCategoryFilter").val() || "all", stateFilter = $("#nvStateFilter").val() || "pending", decisionFilter = $("#nvDecisionFilter").val() || "pending", query = String($("#nvSearch").val() || "").trim().toUpperCase(); - return [...this.nvAllItemsMap.values()].filter(((item) => { - const categoryMatch = "all" === category || "vr" === category ? "all" === category || item.isVr : "unknown" === category ? 0 === item.categories.length : item.categories.includes(category); - const stateMatch = "all" === stateFilter || "pending" === stateFilter ? "all" === stateFilter || !hasAnyState(item.flags) : !!item.flags[stateFilter]; - const decisionMatch = "all" === decisionFilter || item.decisionState === decisionFilter; - const searchMatch = !query || `${item.carNum} ${item.title} ${item.actressName}`.toUpperCase().includes(query); - return categoryMatch && stateMatch && decisionMatch && searchMatch; - })).sort(((left, right) => (right.publishTime || "").localeCompare(left.publishTime || ""))); - } - getActorCoverRequest(starId, requestMap) { - const existing = requestMap.get(starId); - if (existing) return existing; - const request = gmHttp.get(`${this.nvJavDbUrl}/actors/${starId}?t=d`).then(((html) => { - const page = utils.htmlTo$dom(html), covers = /* @__PURE__ */ new Map(); - page.find(".movie-list .item").each(((index, element) => { - const item = $(element), rawCarNum = item.find(".video-title strong").text().trim(), carNum = normalizeCarNum(rawCarNum), rawCover = item.find("img").attr("src") || ""; - if (!carNum || !rawCover) return; - const coverUrl = new URL(rawCover, this.nvJavDbUrl).href.replace("thumbs", "covers"), title = item.find(".video-title").text().replace(rawCarNum, "").trim(); - covers.set(carNum, { coverUrl, title }); - })); - return covers; - })).finally((() => { - requestMap.get(starId) === request && requestMap.delete(starId); - })); - return requestMap.set(starId, request), request; - } - async loadCoverForItems(items, generation = this.nvRenderGeneration) { - return this.hydrateVisibleCovers(items, generation); - } - async hydrateVisibleCovers(items, generation) { - const coverCache = this.nvCoverCache, requestMap = this.nvActorCoverRequests, starIds = [...new Set(items.filter(((item) => !item.coverUrl && !coverCache.has(normalizeCarNum(item.carNum)) && item.starId)).map(((item) => item.starId)))]; - await mapLimit(starIds, 3, (async (starId) => { - try { - const covers = await this.getActorCoverRequest(starId, requestMap); - if (generation !== this.nvRenderGeneration || coverCache !== this.nvCoverCache || !this.isWorkspaceMounted()) return; - covers.forEach(((value, carNum) => { - coverCache.set(normalizeCarNum(carNum), value.coverUrl); - const card = $(".nv-card").filter(((_2, element) => normalizeCarNum($(element).attr("data-car")) === normalizeCarNum(carNum))); - if (!card.length) return; - const image = $('').attr({ src: value.coverUrl, "data-full": value.coverUrl }).on("error", (function() { - $(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden"); - })); - card.find(".nv-placeholder").replaceWith(image), card.find(".nv-card__empty").addClass("jhs-is-hidden"), value.title && card.attr("title", value.title); - })); - window.imageHoverPreviewObj?.bindEvents?.(); - } catch (error) { - clog.warn(`获取演员封面失败: ${starId}`, error); - } - })); - } - async renderNewVideoList() { - const container = $("#new-video-list-container"), generation = this.nvRenderGeneration; - if (!container.length) return; - const items = await this.getNewVideoFlatList(); - if (generation !== this.nvRenderGeneration || !this.isWorkspaceMounted()) return; - this.nvFlatListCache = items, this.nvSortBy = $("#nvSortBy").val() || this.nvSortBy; - const totalPages = Math.ceil(items.length / this.nvPageSize); - this.nvCurrentPage = totalPages ? Math.min(this.nvCurrentPage, totalPages) : 1; - if (!items.length) { - const query = String($("#nvSearch").val() || "").trim(), filtered = ["nvCategoryFilter", "nvStateFilter", "nvDecisionFilter"].some(((id) => $("#" + id).val() !== ("nvCategoryFilter" === id ? "all" : "pending"))); - if (!this.nvAllItemsMap.size) renderStateView(container, { type: "empty", title: "暂无新作品记录" }); - else if (query) renderStateView(container, { type: "empty", title: `没有找到“${query}”的作品`, actionLabel: "清除搜索", onAction: /* @__PURE__ */ __name(() => { - $("#nvSearch").val(""), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); - }, "onAction") }); - else if (filtered) renderStateView(container, { type: "empty", title: "没有符合当前筛选条件的作品", actionLabel: "清除筛选", onAction: /* @__PURE__ */ __name(() => { - JhsSelect.setValue("#nvCategoryFilter", "all", false), JhsSelect.setValue("#nvStateFilter", "pending", false), JhsSelect.setValue("#nvDecisionFilter", "pending", false), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); - }, "onAction") }); - else renderStateView(container, { type: "empty", title: "暂无待处理的新作品" }); - return void this.renderBatchBar(); - } - this.nvRenderPage(generation), this.renderBatchBar(); - } - selectedItems() { - return [...this.nvSelected].map(((carNum) => this.nvAllItemsMap.get(normalizeCarNum(carNum)))).filter(Boolean); - } - applyProcessedSelection(result) { - (result?.changed || []).map(normalizeCarNum).filter(Boolean).forEach(((carNum) => this.nvSelected.delete(carNum))), this.renderBatchBar(); - } - async runBatchMutation(mutate, successLabel) { - const items = this.selectedItems(); - if (!items.length) return void show.info("请先选择作品"); - try { - const result = await mutate(items); - this.applyProcessedSelection(result), result?.changed?.length ? show.ok(`${successLabel} ${result.changed.length} 个番号`) : show.info("没有需要更新的项目"); - } catch (error) { - clog.error("批量操作失败", error), show.error(`批量操作失败: ${error.message || error}`); - } - } - bindBatchActions() { - const footer = $("#new-video-list-footer"); - footer.off(".jhsNvBatch").on("click.jhsNvBatch", "#nvSelectPage", (() => { - (this.nvCurrentPageItems || []).forEach(((item) => this.nvSelected.add(normalizeCarNum(item.carNum)))), $("#new-video-list-container .nv-select").prop("checked", true), this.renderBatchBar(); - })).on("click.jhsNvBatch", "#nvClearSelection", (() => { - this.nvSelected.clear(), $("#new-video-list-container .nv-select").prop("checked", false), this.renderBatchBar(); - })).on("click.jhsNvBatch", "#batchMarkFavorite,#batchMarkWatched,#batchMarkDownloaded", ((event) => { - const flag = { batchMarkFavorite: "favorite", batchMarkWatched: "watched", batchMarkDownloaded: "downloaded" }[event.currentTarget.id]; - void this.runBatchMutation(((items) => stateService.patch(items.map(((item) => item.carNum)), { [flag]: true }, { type: "new-video-batch-state", records: items.map(((item) => ({ carNum: item.carNum, url: item.url || `/search?q=${encodeURIComponent(item.carNum)}`, names: item.actressName, publishTime: item.publishTime }))) })), "已处理"); - })).on("click.jhsNvBatch", "#batchIgnore", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), "ignored")), "已忽略"))).on("click.jhsNvBatch", "#batchSnooze", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), "snoozed", new Date(Date.now() + 7 * 864e5).toISOString())), "已暂缓"))).on("click.jhsNvBatch", "#batchRestore", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), null)), "已恢复"))).on("click.jhsNvBatch", "#batchRemoveFromNewVideo", ((event) => { - const items = this.selectedItems(); - items.length && utils.q(event, `确认将 ${items.length} 个作品从新作列表移除?
    不会删除作品状态记录。`, (() => void this.runBatchMutation(((selected) => stateService.removeFromNewVideoList(selected.map(((item) => item.carNum)), "manual")), "已移除"))); - })); - } - renderBatchBar() { - const footer = $("#new-video-list-footer"); - if (!footer.length) return; - const actresses = new Set(this.nvFlatListCache.flatMap(((item) => item.actresses || [item.actressName])).filter(Boolean)), selected = this.nvSelected.size, visibleSelected = this.nvFlatListCache.filter(((item) => this.nvSelected.has(normalizeCarNum(item.carNum)))).length; - if (!selected) return void footer.html(`
    共 ${this.nvFlatListCache.length} 个番号,涉及 ${actresses.size} 位演员
    `); - footer.html(`
    已选择 ${selected} 项${visibleSelected !== selected ? `,当前结果中 ${visibleSelected} 项` : ""}
    更多 ▾
    `); - } - nvSortList(e2) { - const t2 = this.nvSortBy || "publishTime_desc", [n2, a2] = t2.split("_"); - return e2.slice().sort(((e3, t3) => { - let i2 = 0; - switch (n2) { - case "publishTime": - i2 = (e3.publishTime || "").localeCompare(t3.publishTime || ""); - break; - case "actress": - i2 = (e3.actressName || "").localeCompare(t3.actressName || ""); - break; - case "carNum": - i2 = (e3.carNum || "").localeCompare(t3.carNum || ""); - break; - case "voteCount": - i2 = (e3.voteCount || 0) - (t3.voteCount || 0); - break; - } - return "desc" === a2 ? -i2 : i2; - })); - } - nvRenderPage(generation = this.nvRenderGeneration) { - const e2 = this.nvFlatListCache; - if (!e2 || 0 === e2.length) return; - const t2 = this.nvSortList(e2), n2 = this.nvPageSize, a2 = (this.nvCurrentPage - 1) * n2, i2 = a2 + n2, s2 = t2.slice(a2, i2), o2 = Math.ceil(t2.length / n2), r2 = this.nvJavDbUrl; - this.nvCurrentPageItems = s2; - if (generation !== this.nvRenderGeneration) return; - const l2 = $("#new-video-list-container"); - let c2 = ""; - c2 += '
    '; - for (const n3 of s2) { - const key = normalizeCarNum(n3.carNum), e3 = escapeHtml(key), t3 = escapeHtml(n3.title || key), cachedCover = this.nvCoverCache.get(key), a3 = escapeHtml((cachedCover || n3.coverUrl || "").replace("thumbs", "covers")), i3 = escapeHtml(n3.url || `${r2}/search?q=${encodeURIComponent(key)}`); - let o3 = `番号: ${e3}\\n演员: ${escapeHtml(n3.actressName)}\\n发行: ${n3.publishTime || "未知"}`; - n3.voteCount && (o3 += `\\n评价人数: ${n3.voteCount}`); - const l3 = n3.voteCount ? `${n3.voteCount}人评价` : ""; - c2 += ``; - } - c2 += "
    "; - if (o2 > 1) { - c2 += '
    '; - this.nvCurrentPage > 1 && (c2 += ``); - let e3 = Math.max(1, this.nvCurrentPage - 2), n3 = Math.min(o2, e3 + 4); - n3 - e3 < 4 && (e3 = Math.max(1, n3 - 4)); - for (let t3 = e3; t3 <= n3; t3++) c2 += ``; - this.nvCurrentPage < o2 && (c2 += ``), c2 += `第 ${this.nvCurrentPage}/${o2} 页,共 ${t2.length} 条`, c2 += "
    "; - } - l2.html(c2), l2.find(".nv-cover-img").on("error", (function() { - $(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden"); - })), l2.find(".nv-select").on("change", ((event) => { - const carNum = normalizeCarNum(event.currentTarget.value); - event.currentTarget.checked ? this.nvSelected.add(carNum) : this.nvSelected.delete(carNum), this.renderBatchBar(); - })), l2.find(".pagination-btn").off("click").on("click", ((e3) => { - const n3 = parseInt($(e3.currentTarget).data("nvpage")); - n3 >= 1 && n3 <= o2 && n3 !== this.nvCurrentPage && (this.nvCurrentPage = n3, this.nvRenderGeneration++, this.nvRenderPage(this.nvRenderGeneration), this.renderBatchBar(), l2.scrollTop(0)); - })), window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ - selector: ".nv-cover-img", - dataAttribute: "data-full" - }), void this.hydrateVisibleCovers(s2, generation); - } - async editActress(e2) { - const t2 = String(e2.name || ""), n2 = normalizeHttpUrl(e2.avatar, this.nvJavDbUrl) || "", a2 = String(e2.remark || ""), i2 = Array.isArray(e2.allName) ? e2.allName.join(",") : "", s2 = Array.isArray(e2.newVideoList) ? e2.newVideoList.map(((e3) => "string" == typeof e3 ? e3 : e3.carNum)).join(",") : "", o2 = String(e2.starId || ""), l2 = e2.actressType || "", safe = /* @__PURE__ */ __name((value) => escapeHtml(String(value || "")), "safe"), c2 = ` +
    `;this.getRuntimeService("dialog").open({type:1,title:'新作品检测',content:l,scrollbar:!1,ui:{size:"workspace",body:"scroll"},area:utils.getDialogArea("workspace"),anim:-1,success:d(async(c,h)=>{this.nvWorkspaceMounted=!0,this.nvWorkspaceRoot=c,K.enhance(c),this.bindClick(),this.applyViewMode(),this.renderTaskStatuses(),await this.reloadNewVideoWorkspaceData(),utils.setupEscClose(h)},"success"),end:d(()=>this.cleanupNewVideoWorkspace(),"end")})}cleanupNewVideoWorkspace(){this.nvSearchDebounced?.cancel?.(),this.nvSearchDebounced=null,this.nvWorkspaceMounted=!1,this.nvWorkspaceRoot=null,this.nvRenderGeneration++,this.nvSelected.clear(),this.nvCoverCache=new Map,this.nvActorCoverRequests=new Map,this.nvCoverPreview?.destroy?.(),this.nvCoverPreview=null,this.nvAllItemsMap.clear(),this.nvFlatListCache=[],this.nvActressesCache=[],this.nvCarMapCache=new Map,this.nvDecisionsCache={},this.nvCurrentPageItems=[]}bindClick(){let e=this.getOptionalDependency("TaskPlugin");e||$("#checkFavoriteActress,#checkNewVideo").prop("disabled",!0).attr("title","后台任务功能已禁用"),$("#reLoad").on("click",(()=>{this.reloadNewVideoWorkspaceData(),$("#checkNewVideoMsg").text("")})),$("#new-video-list-container").on("click",".nv-card__link",(async t=>{let s=$(t.currentTarget).closest(".nv-card").attr("data-car");if(s)try{if(await storageManager.getSetting("autoRemoveNewVideoMarkAfterBrowse",ne)!==R)return;await this.getRuntimeService("state").removeFromNewVideoList([s],"browse")}catch(a){clog.error("移除新作品标记失败:",a)}})),$("#toSetting").on("click",(t=>{this.getOptionalDependency("SettingPlugin")?.openSettingDialog?.("task-panel",(()=>{$("#setting-checkFavoriteActress").css({border:"1px solid var(--jhs-status-filter)"}),$("#setting-checkNewVideo").css({border:"1px solid var(--jhs-status-filter)"})}))})),$("#checkFavoriteActress").on("click",(t=>{this.runManualTask($(t.currentTarget),"同步中…",(async()=>{if(!$('a[href*="/users/profile"]').length)return void show.error("未登录 JavDB,同步失败");await e?.checkFavoriteActress?.(!0)}))})),$("#checkNewVideo").on("click",(t=>{this.runManualTask($(t.currentTarget),"检测中…",(()=>e?.checkNewVideo?.(!0)))})),$("#paramActressType").on("change",(t=>{this.currentPage=1,this.nvRenderGeneration++,this._viewMode==="actress"&&this.renderActressCards()})),$("#paramSortBy").on("change",(t=>{this.currentPage=1,this.nvRenderGeneration++,this._viewMode==="actress"&&this.renderActressCards()})),$("#nvSortBy").on("change",(t=>{this.nvSortBy=$("#nvSortBy").val(),this.nvCurrentPage=1,this.nvRenderGeneration++,this.renderNewVideoList()})),$("#nvCategoryFilter,#nvStateFilter,#nvDecisionFilter").on("change",(t=>{this.nvCurrentPage=1,this.nvRenderGeneration++,this._viewMode==="list"&&this.renderNewVideoList()})),this.nvSearchDebounced=utils.debounce((()=>{this.nvCurrentPage=1,this.nvRenderGeneration++,this._viewMode==="list"&&this.renderNewVideoList()}),200),$("#nvSearch").on("input",this.nvSearchDebounced),$(".jhs-new-video-view").on("click",'[role="tab"]',(t=>{this.setViewMode($(t.currentTarget).data("view"))})).on("keydown",'[role="tab"]',(t=>{if(!["ArrowLeft","ArrowRight","Home","End"].includes(t.key))return;t.preventDefault();let s=$(t.delegateTarget).find('[role="tab"]'),a=s.index(t.currentTarget),i=t.key==="Home"?0:t.key==="End"?s.length-1:t.key==="ArrowRight"?(a+1)%s.length:(a-1+s.length)%s.length;s.eq(i).trigger("click").trigger("focus")})),this.bindBatchActions()}async runManualTask(e,t,s){if(e.attr("aria-busy")==="true")return;let a=this.getOptionalDependency("TaskPlugin");if(!a)return void show.info("后台任务功能已禁用");let i=e.find("span").last(),r=i.text();e.attr("aria-busy","true").prop("disabled",!0),i.text(t);try{await navigator.locks.request(a.singleTaskKey,{ifAvailable:!0},(async n=>{if(!n)return void show.error("后台任务正在运行,请稍后再试");await s()}))}catch(n){clog.error("手动任务执行失败",n),a.isNetworkBlocked(n)&&show.error(n.message||"任务执行失败")}finally{e.removeAttr("aria-busy").prop("disabled",!1),i.text(r),this.renderTaskStatuses()}}setViewMode(e){!["actress","list"].includes(e)||e===this._viewMode||(this._viewMode=e,this.getRuntimeService("storage").setLocal("jhs_newVideoViewMode",e),e==="list"?this.nvCurrentPage=1:this.currentPage=1,this.nvRenderGeneration++,this.applyViewMode(),this.renderCurrentView())}applyViewMode(){let e=this._viewMode==="list";$("#nv-pagination-bar").toggle(e),$("#actress-card-container").toggle(!e),$("#actress-pagination").toggle(!e),$("#new-video-list-container").toggle(e),$("#new-video-list-footer").toggle(e),K.setVisible("#paramSortBy",!e),K.setVisible("#nvSortBy",e),K.setVisible("#paramActressType",!e),K.setVisible("#nvCategoryFilter",e),K.setVisible("#nvStateFilter",e),K.setVisible("#nvDecisionFilter",e),$("#nvSearch").toggleClass("jhs-is-hidden",!e),$(".jhs-new-video-view [role='tab']").each(((t,s)=>{let a=$(s).data("view")===this._viewMode;$(s).attr({"aria-selected":String(a),tabindex:a?"0":"-1"}).toggleClass("active",a)}))}renderCurrentView(){return this._viewMode==="list"?this.renderNewVideoList():this.renderActressCards()}loadData(){return this.currentPage=1,this.reloadNewVideoWorkspaceData()}async reloadNewVideoWorkspaceData({preservePage:e=!1}={}){if(!this.isWorkspaceMounted())return;let t=++this.nvRenderGeneration,s=this._viewMode==="list"?$("#new-video-list-container"):$("#actress-card-container");$("#nv-pagination-bar").remove(),Oe(s,{type:"loading",title:"加载中"});try{let a=await storageManager.getSetting(),[i,r,n,l]=await Promise.all([storageManager.getFavoriteActressList(),storageManager.getCarMap(),this.getRuntimeService("state").getNewVideoDecisions(),storageManager.getSetting("checkNewVideo_ruleTime",8760)]),c=this.getRuntimeService("movie").externalSiteOrigin("javDbBtn",a);if(!this.isWorkspaceMounted()||t!==this.nvRenderGeneration)return;this.nvActressesCache=i,this.nvCarMapCache=r,this.nvDecisionsCache=n,this.nvJavDbUrl=c,this.nvRuleTime=ae(l,8760,{min:0});let h=Mh(i,r,n),u=new Map;h.forEach((p=>u.set(T(p.carNum),{...p,carNum:T(p.carNum)}))),this.nvAllItemsMap=u;for(let p of[...this.nvSelected])u.has(p)||this.nvSelected.delete(p);e||(this.currentPage=1,this.nvCurrentPage=1),await this.renderCurrentView(),this.renderTaskStatuses()}catch(a){if(t!==this.nvRenderGeneration||!this.isWorkspaceMounted())return;clog.error("加载新作品工作区失败",a),Oe(s,{type:"error",title:"加载失败",description:a.message||String(a),actionLabel:"重试",onAction:d(()=>this.reloadNewVideoWorkspaceData({preservePage:!0}),"onAction")})}}renderTaskStatuses(){let e=$("#jhs-task-status-list");if(!e.length)return;let t=this.getOptionalDependency("TaskPlugin"),s={favoriteActress:"演员同步",newVideo:"新作品",blacklist:"黑名单"},a={idle:"正常",running:"运行中",pending:"等待下一次任务检查",due:"待运行"},i=d(r=>r?new Date(r).toLocaleString():"无","format");if(!t)return void e.empty().text("后台任务功能已禁用");e.empty(),["favoriteActress","newVideo","blacklist"].forEach((r=>{let n=t.getTaskStatusSnapshot(r),l=$('
    ');l.append($('').text(`${s[r]}:${a[n.state]}`)),l.append($('').text(`上次完成 ${i(n.completedAt)};下次检查 ${n.nextAt?i(n.nextAt):"立即"}`)),e.append(l)}))}async renderActressCards(){let e=$("#actress-card-container");if(!e.length)return;let t=[...this.nvActressesCache],s=$("#paramActressType").val();s!=="all"&&(t=t.filter((j=>j.actressType===s)));let a=this.nvCarMapCache,i=d(j=>this.getPendingNewVideoCount(j,a),"_newVideoCount"),r=$("#paramSortBy").val(),n={lastPublishTime_desc:[{key:"lastPublishTime",order:"desc"}],lastPublishTime_asc:[{key:"lastPublishTime",order:"asc"}],lastCheckTime_desc:[{key:"lastCheckTime",order:"desc"}],lastCheckTime_asc:[{key:"lastCheckTime",order:"asc"}],newVideoCount_desc:[{key:i,order:"desc"}],newVideoCount_asc:[{key:i,order:"asc"}]},l=[{key:i,order:"desc"},{key:"lastPublishTime",order:"desc"}],c=utils.genericSort(t,n[r]||l),h=c.length,u=Math.ceil(h/this.pageSize),p=(this.currentPage-1)*this.pageSize,g=p+this.pageSize;u>0&&this.currentPage>u&&(this.currentPage=u);let m=(this.currentPage-1)*this.pageSize,f=c.slice(m,m+this.pageSize),v=this.nvJavDbUrl,b=this.getOptionalDependency("TaskPlugin"),w=this.nvRuleTime;if(f.length===0)return Oe(e,{type:"empty",title:this.nvActressesCache.length?"没有符合当前筛选条件的演员":"暂无收藏演员"}),void this.renderPagination(h,u);let S=f.map((j=>{let k=Array.isArray(j.allName)?j.allName.join(","):"",x=String(j.name||""),_=String(j.remark||""),E=String(j.starId||""),L=this.getPendingNewVideoCount(j,a),M=j.lastPublishTime||"",P=gt(`/actors/${encodeURIComponent(E)}?t=d`,v),z=gt(j.avatar,v)||this.getRuntimeService("actressInfo").placeholderUrl("javdb"),O=pt(M,w),H="未知",Q="is-unknown";j.actressType===Ke?(H="无码",Q="is-uncensored"):j.actressType===Je&&(H="有码",Q="is-censored");let se=String(M||"暂无记录"),ge=O?`停更 ${w/24/365} 年以上,下轮任务不再检测`:_||"暂无备注",Ee=$('
    ').toggleClass("is-paused",O).attr("data-starId",E),nt=$('
    ');nt.append($('').attr("data-tip",`最新作品数量: ${L}`).text(`${L} 新`),$('').addClass(Q).text(H)),O&&nt.append('停更');let De=$('').attr("href",P||"#"),U=$("").append($('').text(x),$('').attr("title",k).text(k||"暂无别名"));De.append($('').attr({src:z,alt:k}),U);let X=$('
    ').append($('
    最近作品
    ').append($("
    ").attr("title",se).text(se)),$('
    上次检测
    ').append($("
    ").text(String(j.lastCheckTime||"暂无记录")))),ie=$('
    '),xt=$('').attr("data-starId",E).prepend(this.checkSvg),us=$('').attr({"aria-label":`取消收藏 ${x}`,"data-starId":E}).append(this.deleteSvg),ye=$('').attr("data-starId",E).prepend(this.editSvg);return ie.append(xt,us,$('
    •••
    ').find(".actress-card__menu-popover").append(ye).end()),Ee.append(nt,De,X,$('

    ').attr("title",ge).text(ge),ie)[0]}));e.empty().append(S),$(".btn-delete-actress").off("click").on("click",(j=>{j.preventDefault();let k=$(j.currentTarget).attr("data-starId"),x=c.find((_=>_.starId===k));utils.q(j,`是否取消收藏 ${C(x.name)}?`,(async()=>{let _=this.getRuntimeService("movie").externalSiteOrigin("javDbBtn",await storageManager.getSetting()),E=document.querySelector("meta[name=csrf-token]").content,L=await this.getRuntimeService("actressInfo").uncollect("javdb",{actorId:k,baseUrl:_,csrfToken:E},{scope:await this.getRuntimeService("scope")()});L.success?(await storageManager.removeFavoriteActress(k),await F.emit("new-video-changed",{reason:"favorite-actress-removed"})):(show.error("移除失败"),clog.error("移除失败,返回值:",L))}))})),$(".btn-edit-actress").off("click").on("click",(j=>{j.preventDefault();let k=$(j.currentTarget).attr("data-starId"),x=c.find((_=>_.starId===k));x?this.editActress(x):show.error(`未找到 starId 为 ${k} 的女优记录。`)})),$(".btn-check-actress").off("click").on("click",(j=>{j.preventDefault();let k=$(j.currentTarget),x=k.attr("data-starId"),_=c.find((E=>E.starId===x));this.runManualTask(k,"检测中…",(()=>b?.checkOneNewVideo?.(_)))})),$(".actress-card__menu").on("keydown",(j=>{if(j.key!=="Escape")return;j.preventDefault(),$(j.currentTarget).prop("open",!1).find("summary").trigger("focus")})).on("click","[role='menuitem']",(j=>{$(j.currentTarget).closest("details").prop("open",!1)})),this.renderPagination(h,u)}async getNewVideoFlatList(){let e=$("#nvCategoryFilter").val()||"all",t=$("#nvStateFilter").val()||"pending",s=$("#nvDecisionFilter").val()||"pending",a=String($("#nvSearch").val()||"").trim().toUpperCase();return[...this.nvAllItemsMap.values()].filter((i=>{let r=e==="all"||e==="vr"?e==="all"||i.isVr:e==="unknown"?i.categories.length===0:i.categories.includes(e),n=t==="all"||t==="pending"?t==="all"||!oe(i.flags):!!i.flags[t],l=s==="all"||i.decisionState===s,c=!a||`${i.carNum} ${i.title} ${i.actressName}`.toUpperCase().includes(a);return r&&n&&l&&c})).sort(((i,r)=>(r.publishTime||"").localeCompare(i.publishTime||"")))}getActorCoverRequest(e,t){let s=t.get(e);if(s)return s;let a=this.getRuntimeService("scope")().then((i=>this.getRuntimeService("actressInfo").movies("javdb",{actorId:e,baseUrl:this.nvJavDbUrl},{scope:i}))).then((i=>{let r=new Map;return i.forEach((n=>n.carNum&&n.coverUrl&&r.set(T(n.carNum),{coverUrl:n.coverUrl,title:n.title}))),r})).finally((()=>{t.get(e)===a&&t.delete(e)}));return t.set(e,a),a}async loadCoverForItems(e,t=this.nvRenderGeneration){return this.hydrateVisibleCovers(e,t)}async hydrateVisibleCovers(e,t){let s=this.nvCoverCache,a=this.nvActorCoverRequests,i=[...new Set(e.filter((r=>!r.coverUrl&&!s.has(T(r.carNum))&&r.starId)).map((r=>r.starId)))];await $e(i,3,(async r=>{try{let n=await this.getActorCoverRequest(r,a);if(t!==this.nvRenderGeneration||s!==this.nvCoverCache||!this.isWorkspaceMounted())return;n.forEach(((l,c)=>{s.set(T(c),l.coverUrl);let h=$(".nv-card").filter(((p,g)=>T($(g).attr("data-car"))===T(c)));if(!h.length)return;let u=$('').attr({src:l.coverUrl,"data-full":l.coverUrl}).on("error",(function(){$(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden")}));h.find(".nv-placeholder").replaceWith(u),h.find(".nv-card__empty").addClass("jhs-is-hidden"),l.title&&h.attr("title",l.title)}))}catch(n){clog.warn(`获取演员封面失败: ${r}`,n)}}))}async renderNewVideoList(){let e=$("#new-video-list-container"),t=this.nvRenderGeneration;if(!e.length)return;let s=await this.getNewVideoFlatList();if(t!==this.nvRenderGeneration||!this.isWorkspaceMounted())return;$("#nv-pagination-bar").remove(),this.nvFlatListCache=s,this.nvSortBy=$("#nvSortBy").val()||this.nvSortBy;let a=Math.ceil(s.length/this.nvPageSize);if(this.nvCurrentPage=a?Math.min(this.nvCurrentPage,a):1,!s.length){let i=String($("#nvSearch").val()||"").trim(),r=["nvCategoryFilter","nvStateFilter","nvDecisionFilter"].some((n=>$("#"+n).val()!==(n==="nvCategoryFilter"?"all":"pending")));return this.nvAllItemsMap.size?i?Oe(e,{type:"empty",title:`没有找到“${i}”的作品`,actionLabel:"清除搜索",onAction:d(()=>{$("#nvSearch").val(""),this.nvCurrentPage=1,this.nvRenderGeneration++,this.renderNewVideoList()},"onAction")}):r?Oe(e,{type:"empty",title:"没有符合当前筛选条件的作品",actionLabel:"清除筛选",onAction:d(()=>{K.setValue("#nvCategoryFilter","all",!1),K.setValue("#nvStateFilter","pending",!1),K.setValue("#nvDecisionFilter","pending",!1),this.nvCurrentPage=1,this.nvRenderGeneration++,this.renderNewVideoList()},"onAction")}):Oe(e,{type:"empty",title:"暂无待处理的新作品"}):Oe(e,{type:"empty",title:"暂无新作品记录"}),void this.renderBatchBar()}this.nvRenderPage(t),this.renderBatchBar()}selectedItems(){return[...this.nvSelected].map((e=>this.nvAllItemsMap.get(T(e)))).filter(Boolean)}applyProcessedSelection(e){(e?.changed||[]).map(T).filter(Boolean).forEach((t=>this.nvSelected.delete(t))),this.renderBatchBar()}async runBatchMutation(e,t){let s=this.selectedItems();if(!s.length)return void show.info("请先选择作品");try{let a=await e(s);this.applyProcessedSelection(a),a?.changed?.length?show.ok(`${t} ${a.changed.length} 个番号`):show.info("没有需要更新的项目")}catch(a){clog.error("批量操作失败",a),show.error(`批量操作失败: ${a.message||a}`)}}bindBatchActions(){$("#new-video-list-footer").off(".jhsNvBatch").on("click.jhsNvBatch","#nvSelectPage",(()=>{(this.nvCurrentPageItems||[]).forEach((t=>this.nvSelected.add(T(t.carNum)))),$("#new-video-list-container .nv-select").prop("checked",!0),this.renderBatchBar()})).on("click.jhsNvBatch","#nvClearSelection",(()=>{this.nvSelected.clear(),$("#new-video-list-container .nv-select").prop("checked",!1),this.renderBatchBar()})).on("click.jhsNvBatch","#batchMarkFavorite,#batchMarkWatched,#batchMarkDownloaded",(t=>{let s={batchMarkFavorite:"favorite",batchMarkWatched:"watched",batchMarkDownloaded:"downloaded"}[t.currentTarget.id];this.runBatchMutation((a=>this.getRuntimeService("state").patch(a.map((i=>i.carNum)),{[s]:!0},{type:"new-video-batch-state",records:a.map((i=>({carNum:i.carNum,url:i.url||`/search?q=${encodeURIComponent(i.carNum)}`,names:i.actressName,publishTime:i.publishTime})))})),"已处理")})).on("click.jhsNvBatch","#batchIgnore",(()=>{this.runBatchMutation((t=>this.getRuntimeService("state").setNewVideoDecision(t.map((s=>s.carNum)),"ignored")),"已忽略")})).on("click.jhsNvBatch","#batchSnooze",(()=>{this.runBatchMutation((t=>this.getRuntimeService("state").setNewVideoDecision(t.map((s=>s.carNum)),"snoozed",new Date(Date.now()+7*864e5).toISOString())),"已暂缓")})).on("click.jhsNvBatch","#batchRestore",(()=>{this.runBatchMutation((t=>this.getRuntimeService("state").setNewVideoDecision(t.map((s=>s.carNum)),null)),"已恢复")})).on("click.jhsNvBatch","#batchRemoveFromNewVideo",(t=>{let s=this.selectedItems();s.length&&utils.q(t,`确认将 ${s.length} 个作品从新作列表移除?
    不会删除作品状态记录。`,(()=>{this.runBatchMutation((a=>this.getRuntimeService("state").removeFromNewVideoList(a.map((i=>i.carNum)),"manual")),"已移除")}))}))}renderBatchBar(){let e=$("#new-video-list-footer");if(!e.length)return;let t=new Set(this.nvFlatListCache.flatMap((i=>i.actresses||[i.actressName])).filter(Boolean)),s=this.nvSelected.size,a=this.nvFlatListCache.filter((i=>this.nvSelected.has(T(i.carNum)))).length;if(!s)return void e.html(`
    共 ${this.nvFlatListCache.length} 个番号,涉及 ${t.size} 位演员
    `);e.html(`
    已选择 ${s} 项${a!==s?`,当前结果中 ${a} 项`:""}
    更多 ▾
    `)}nvSortList(e){let t=this.nvSortBy||"publishTime_desc",[s,a]=t.split("_");return e.slice().sort(((i,r)=>{let n=0;switch(s){case"publishTime":n=(i.publishTime||"").localeCompare(r.publishTime||"");break;case"actress":n=(i.actressName||"").localeCompare(r.actressName||"");break;case"carNum":n=(i.carNum||"").localeCompare(r.carNum||"");break;case"voteCount":n=(i.voteCount||0)-(r.voteCount||0);break}return a==="desc"?-n:n}))}nvRenderPage(e=this.nvRenderGeneration){let t=this.nvFlatListCache;if(!t||t.length===0)return;let s=this.nvSortList(t),a=this.nvPageSize,i=(this.nvCurrentPage-1)*a,r=i+a,n=s.slice(i,r),l=Math.ceil(s.length/a),c=this.nvJavDbUrl;if(this.nvCurrentPageItems=n,e!==this.nvRenderGeneration)return;$("#nv-pagination-bar").remove();let h=$("#new-video-list-container"),u="";u+='
    ';for(let p of n){let g=T(p.carNum),m=C(g),f=C(p.title||g),v=this.nvCoverCache.get(g),b=C((v||p.coverUrl||"").replace("thumbs","covers")),w=C(p.url||`${c}/search?q=${encodeURIComponent(g)}`),S=`番号: ${m}\\n演员: ${C(p.actressName)}\\n发行: ${C(String(p.publishTime||"未知"))}`;p.voteCount&&(S+=`\\n评价人数: ${p.voteCount}`);let j=p.voteCount?`${p.voteCount}人评价`:"";u+=`"}if(u+="
    ",l>1){u+='
    ',this.nvCurrentPage>1&&(u+=``);let p=Math.max(1,this.nvCurrentPage-2),g=Math.min(l,p+4);g-p<4&&(p=Math.max(1,g-4));for(let m=p;m<=g;m++)u+=``;this.nvCurrentPage下一页`),u+=`第 ${this.nvCurrentPage}/${l} 页,共 ${s.length} 条`,u+="
    "}h.html(u),h.find(".nv-cover-img").on("error",(function(){$(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden")})),h.find(".nv-select").on("change",(p=>{let g=T(p.currentTarget.value);p.currentTarget.checked?this.nvSelected.add(g):this.nvSelected.delete(g),this.renderBatchBar()})),h.find(".pagination-btn").off("click").on("click",(p=>{let g=parseInt($(p.currentTarget).data("nvpage"));g>=1&&g<=l&&g!==this.nvCurrentPage&&(this.nvCurrentPage=g,this.nvRenderGeneration++,this.nvRenderPage(this.nvRenderGeneration),this.renderBatchBar(),h.scrollTop(0))})),h.find("#nv-pagination-bar").insertAfter(h),this.nvCoverPreview??(this.nvCoverPreview=new window.ImageHoverPreview({selector:".nv-cover-img",dataAttribute:"data-full",owner:this.nvWorkspaceRoot,resolveOwner:d(()=>this.nvWorkspaceRoot,"resolveOwner"),zIndexStrategy:"owner"})),this.hydrateVisibleCovers(n,e)}async editActress(e){let t=this.getRuntimeService("dialog"),s=null,a=String(e.name||""),i=gt(e.avatar,this.nvJavDbUrl)||"",r=String(e.remark||""),n=Array.isArray(e.allName)?e.allName.join(","):"",l=Array.isArray(e.newVideoList)?e.newVideoList.map((g=>typeof g=="string"?g:g.carNum)).join(","):"",c=String(e.starId||""),h=e.actressType||"",u=d(g=>C(String(g||"")),"safe"),p=`
    - Avatar Preview
    -
    - `; - layer.open({ - type: 1, - title: `编辑女优: ${safe(t2)} (${safe(o2)})`, - area: utils.getDialogArea("sm"), - content: c2, - btn: ["保存", "取消"], - success: /* @__PURE__ */ __name((e3, t3) => { - JhsSelect.enhance(e3); - const n3 = /* @__PURE__ */ __name((e4) => { - e4.css("height", "auto"), e4.css("height", e4[0].scrollHeight + 15 + "px"); - }, "n"); - $("#edit-actress-avatar").on("input", (function() { - const e4 = $(this).val(); - $("#edit-avatar-preview").attr("src", e4); - })); - const a3 = $("#edit-actress-allname"); - a3.on("input", (function() { - n3($(this)); - })), n3(a3); - const i3 = $("#edit-actress-newvideolist"); - i3.on("input", (function() { - n3($(this)); - })), n3(i3), $("#search-avatar-btn").on("click", (async () => { - await this.searchAvatar(); - })), $("#select-cdn-btn").on("click", (async () => { - await (async function() { - const e4 = at, t4 = tt.map(((t5, n5) => ` -
    "; if (o > 1) { @@ -671,40 +579,42 @@ class NewVideoPlugin extends BasePlugin { l.html(c), l.find(".nv-cover-img").on("error", (function() { $(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden"); })), l.find(".nv-select").on("change", (event => { const carNum = normalizeCarNum(event.currentTarget.value); event.currentTarget.checked ? this.nvSelected.add(carNum) : this.nvSelected.delete(carNum), this.renderBatchBar(); })), l.find(".pagination-btn").off("click").on("click", (e => { const n = parseInt($(e.currentTarget).data("nvpage")); n >= 1 && n <= o && n !== this.nvCurrentPage && (this.nvCurrentPage = n, this.nvRenderGeneration++, this.nvRenderPage(this.nvRenderGeneration), this.renderBatchBar(), l.scrollTop(0)); - })), window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ - selector: ".nv-cover-img", dataAttribute: "data-full" - }), void this.hydrateVisibleCovers(s, generation); + })), l.find("#nv-pagination-bar").insertAfter(l), this.nvCoverPreview ??= new window.ImageHoverPreview({ selector: ".nv-cover-img", dataAttribute: "data-full", owner: this.nvWorkspaceRoot, resolveOwner: () => this.nvWorkspaceRoot, zIndexStrategy: "owner" }), void this.hydrateVisibleCovers(s, generation); } async editActress(e) { + const dialog = this.getRuntimeService("dialog"); + /** @type {any} */ let editRoot = null; const t = String(e.name || ""), n = normalizeHttpUrl(e.avatar, this.nvJavDbUrl) || "", a = String(e.remark || ""), i = Array.isArray(e.allName) ? e.allName.join(",") : "", s = Array.isArray(e.newVideoList) ? e.newVideoList.map((e => "string" == typeof e ? e : e.carNum)).join(",") : "", o = String(e.starId || ""), l = e.actressType || "", safe = value => escapeHtml(String(value || "")), c = `\n
    \n
    \n Avatar Preview\n
    \n \n \n
    \n \n \n
    \n
    \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n `; - layer.open({ + dialog.open({ type: 1, title: `编辑女优: ${safe(t)} (${safe(o)})`, - area: utils.getDialogArea("sm"), + ui: { size: "sm", body: "scroll" }, area: utils.getDialogArea("sm"), content: c, btn: [ "保存", "取消" ], success: (e, t) => { + editRoot = $(e); + this._editActressRoot = editRoot; JhsSelect.enhance(e); const n = e => { e.css("height", "auto"), e.css("height", e[0].scrollHeight + 15 + "px"); }; - $("#edit-actress-avatar").on("input", (function() { + editRoot.find("#edit-actress-avatar").on("input", (function() { const e = $(this).val(); - $("#edit-avatar-preview").attr("src", e); + editRoot.find("#edit-avatar-preview").attr("src", e); })); - const a = $("#edit-actress-allname"); + const a = editRoot.find("#edit-actress-allname"); a.on("input", (function() { n($(this)); })), n(a); - const i = $("#edit-actress-newvideolist"); + const i = editRoot.find("#edit-actress-newvideolist"); i.on("input", (function() { n($(this)); - })), n(i), $("#search-avatar-btn").on("click", (async () => { + })), n(i), editRoot.find("#search-avatar-btn").on("click", (async () => { await this.searchAvatar(); - })), $("#select-cdn-btn").on("click", (async () => { - await async function() { - const e = at, t = tt.map(((t, n) => `\n \n `)).join(""), n = `\n
    \n

    请选择头像数据源 (当前: ${tt[e].name}):

    \n ${t}\n

    切换源会清除本地缓存的数据,并在下次搜索时重新加载。

    \n
    \n `; - layer.open({ + })), editRoot.find("#select-cdn-btn").on("click", (async () => { + await (async () => { + const e = this.avatarSourceIndex, t = this.avatarSources.map(((t, n) => `\n \n `)).join(""), n = `\n
    \n

    请选择头像数据源 (当前: ${this.avatarSources[e]?.name || "无可用来源"}):

    \n ${t}\n

    切换后将在下次搜索时使用所选来源。

    \n
    \n `; + dialog.open({ type: 1, title: "选择 CDN 源", area: utils.getResponsiveArea([ "400px", "auto" ]), @@ -715,23 +625,17 @@ class NewVideoPlugin extends BasePlugin { }, yes: async e => { const t = $('input[name="cdn-source"]:checked').val(), n = parseInt(t, 10); - if (n !== at) { - at = n, localStorage.setItem(nt, n.toString()), it = tt[n].json, st = tt[n].base, - ct = null, dt = null; - try { - await lt.set(rt, null); - } catch (a) { - clog.error("清除 IndexedDB 缓存失败:", a); - } - show.ok(`CDN 源已切换为: ${tt[n].name}`), layer.close(e); - } else layer.close(e); + if (n !== this.avatarSourceIndex && this.avatarSources[n]) { + this.avatarSourceIndex = n, this.getRuntimeService("storage").setLocal(AVATAR_SOURCE_INDEX_KEY, n.toString()), + show.ok(`CDN 源已切换为: ${this.avatarSources[n].name}`), dialog.close(e); + } else dialog.close(e); } }); - }(); + })(); })), utils.setupEscClose(t); }, yes: async t => { - const n = $("#edit-actress-avatar").val().trim(), a = $("#edit-actress-name").val().trim(), i = $("#edit-actress-allname").val().trim(), s = $("#edit-actress-newvideolist").val().trim(), o = $("#edit-remark").val().trim(), r = $("#actressType").val(); + const root = editRoot || $(document), n = root.find("#edit-actress-avatar").val().trim(), a = root.find("#edit-actress-name").val().trim(), i = root.find("#edit-actress-allname").val().trim(), s = root.find("#edit-actress-newvideolist").val().trim(), o = root.find("#edit-remark").val().trim(), r = root.find("#actressType").val(); if (!a) return show.error("主名称不能为空"), !1; const l = i.split(/[\uff0c,]/).map((e => e.trim())).filter((e => e.length > 0)), c = s.split(/[\uff0c,]/).map((e => e.trim())).filter((e => e.length > 0)); e.avatar = n, e.name = a, e.allName = l, e.newVideoList = c, e.actressType = r, @@ -740,7 +644,7 @@ class NewVideoPlugin extends BasePlugin { await storageManager.updateFavoriteActress(e); await jhsEventBus.emit("new-video-changed", { reason: "favorite-actress-edited" }); show.ok(`女优 ${a} 信息已更新`); - layer.close(t); + dialog.close(t); } catch(err) { show.error("修改失败: " + (err.message || err)); } @@ -769,12 +673,14 @@ class NewVideoPlugin extends BasePlugin { })); } async searchAvatar() { - const e = $("#edit-actress-name"), t = $("#edit-actress-allname"), n = e.val().trim(), a = t.val().trim().split(/[\uff0c,]/).map((e => e.trim())).filter((e => e.length > 0)); + const dialog = this.getRuntimeService("dialog"); + const root = this._editActressRoot || $(document), e = root.find("#edit-actress-name"), t = root.find("#edit-actress-allname"), n = e.val().trim(), a = t.val().trim().split(/[\uff0c,]/).map((e => e.trim())).filter((e => e.length > 0)); if (n && a.unshift(n), 0 === a.length) return void show.error("请先填写女优主名称或别名进行搜索。"); const i = loading("正在搜索头像..."); let s = []; try { - s = await gt(a); + const source = this.avatarSources[this.avatarSourceIndex], scope = await this.getRuntimeService("scope")(); + s = source ? await this.getRuntimeService("actressInfo").searchAvatars(a, source.id, { scope }) : []; } catch (c) { return void show.error(`头像数据加载或搜索失败: ${c.message || c}`); } finally { @@ -789,7 +695,7 @@ class NewVideoPlugin extends BasePlugin { candidate.append($("\"\"").attr("src", url)), candidate.append($('载入中')), avatarList.append(candidate); })); let l = 0; - layer.open({ + dialog.open({ type: 1, title: `选择女优头像 (${s.length} 张)`, area: utils.getResponsiveArea([ "900px", "85%" ]), @@ -805,13 +711,13 @@ class NewVideoPlugin extends BasePlugin { wrapper.remove(), l++; const e = s.length - l; i.text(`点击图片即可选择(已移除 ${l} 张错误图片,剩余 ${e} 张)`), 0 === e && (show.error("所有搜索到的头像链接均已失效,无法选择。"), - layer.close(t)); + dialog.close(t)); })), this.complete && (this.naturalWidth > 0 ? image.trigger("load") : image.trigger("error")); })), candidates.on("click", (function() { const candidate = $(this), url = candidate.attr("data-url"); - $("#edit-actress-avatar").val(url), $("#edit-avatar-preview").attr("src", url), candidates.attr("aria-pressed", "false"), + root.find("#edit-actress-avatar").val(url), root.find("#edit-avatar-preview").attr("src", url), candidates.attr("aria-pressed", "false"), candidate.attr("aria-pressed", "true"), setTimeout((() => { - layer.close(t); + dialog.close(t); }), 150); })), utils.setupEscClose(t); } diff --git a/src/plugins/new-video/task.js b/src/plugins/new-video/task.js index f8ae3011..896b7563 100644 --- a/src/plugins/new-video/task.js +++ b/src/plugins/new-video/task.js @@ -1,14 +1,44 @@ -class TaskPlugin extends BasePlugin { +// @ts-check + +import { I, T, _, escapeHtml, l, normalizeCarNum } from "../../core/constants.js"; +import { parseNumberSetting, parseTaskTimestamp, selectLatestPublishTime, shouldSkipStopped } from "../../core/feature-helpers.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { readListItem } from "../../core/list-item-reader.js"; +import { detectSite } from "../../core/site-context.js"; +import { parseDetailPage } from "../../integrations/host-list/parser.js"; +import { StorageQueue } from "../../core/storage-queue.js"; + +/** @typedef {"blacklist" | "favoriteActress" | "newVideo"} TaskName */ +/** @typedef {Record} TaskRecord */ +/** @typedef {{ attempted: boolean, completed: boolean, fatal: boolean, success: number, networkFailed: number, parseFailed: number, aborted: number, skippedInterval: number, skippedStopped: number, [key: string]: any }} TaskResult */ + +export class TaskPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), - i(this, "taskConfig", null), i(this, "storageQueue", new StorageQueue), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), - i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"), - i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), - i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), - i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), - i(this, "taskTimer", null), i(this, "taskRunning", !1), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), - i(this, "settingsHandler", null), i(this, "taskConfigDirty", !1), i(this, "configLoadPromise", null), i(this, "configLoadQueued", !1), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", !1), - i(this, "activeTasks", new Set); + super(...arguments); + this.singleTaskKey = "checkNewActressActorFilterCar"; + /** @type {any} */ this.taskConfig = null; + this.storageQueue = new StorageQueue(); + this.lastCheckFavoriteActressTimeKey = "jhs_time_checkFavoriteActress"; + this.lastCheckBlacklistTimeKey = "jhs_time_checkBlacklist"; + this.lastCheckNewVideoTimeKey = "jhs_time_checkNewVideo"; + this.lastCheckFavoriteActressAttemptKey = "jhs_time_checkFavoriteActress_attempt"; + this.lastCheckFavoriteActressNextKey = "jhs_time_checkFavoriteActress_next"; + this.lastCheckBlacklistAttemptKey = "jhs_time_checkBlacklist_attempt"; + this.lastCheckBlacklistNextKey = "jhs_time_checkBlacklist_next"; + this.lastCheckNewVideoAttemptKey = "jhs_time_checkNewVideo_attempt"; + this.lastCheckNewVideoNextKey = "jhs_time_checkNewVideo_next"; + /** @type {ReturnType | null} */ this.taskTimer = null; + this.taskRunning = !1; + /** @type {null | (() => void)} */ this.visibilityHandler = null; + /** @type {null | (() => void)} */ this.pageHideHandler = null; + /** @type {any} */ this.lifecycleScope = null; + /** @type {null | (() => Promise)} */ this.settingsHandler = null; + this.taskConfigDirty = !1; + /** @type {Promise | null} */ this.configLoadPromise = null; + this.configLoadQueued = !1; + /** @type {Promise | null} */ this.configRefreshPromise = null; + this.configRefreshQueued = !1; + /** @type {Set} */ this.activeTasks = new Set(); } getName() { return "TaskPlugin"; @@ -16,6 +46,7 @@ class TaskPlugin extends BasePlugin { getStartupMode() { return "idle"; } + /** @param {any[]} e @param {number} t @param {number} n @param {(item: any) => Promise} a */ async limitConcurrency(e, t, n, a) { let i = 0, s = !1; const o = Math.max(1, Math.min(t, e.length)), r = Array.from({ length: o }, (async () => { @@ -29,18 +60,40 @@ class TaskPlugin extends BasePlugin { throw e; } const o = e.length - i; - o > 0 && (clog.debug(`剩余任务数: ${o}`), await utils.sleep(n)); + o > 0 && (clog.htmlDebug(`剩余任务数: ${o}`), await utils.sleep(n)); } })); - const l = await Promise.allSettled(r), c = l.find((e => "rejected" === e.status)); + const l = await Promise.allSettled(r), c = l.find((/** @type {PromiseSettledResult} */ e) => "rejected" === e.status); if (c) throw c.reason; } + /** @param {any} e */ isNetworkBlocked(e) { - return !0 === e?._cfBlocked || !0 === e?._circuitBroken; - } + return [ "CF_BLOCKED", "CIRCUIT_OPEN", "ABORTED" ].includes(e?.code) || !0 === e?._cfBlocked || !0 === e?._circuitBroken; + } + /** 通过统一 HttpService 抓取当前配置宿主的 HTML。 */ + /** @param {string} url @param {string} site */ + async requestHostPage(url, site) { + const target = new URL(url), builtinHost = site === T ? "javdb.com" : site === I ? "javbus.com" : null; + const isBuiltin = builtinHost && (target.hostname === builtinHost || target.hostname.endsWith(`.${builtinHost}`)); + const urlPolicy = isBuiltin + ? { trustClass: "builtin-public", hosts: [builtinHost], expectedOrigin: target.origin } + : { trustClass: "custom-public", expectedOrigin: target.origin }; + try { + const response = await this.getRuntimeService("http").request({ + providerId: `host-task:${site}`, method: "GET", url: target.href, responseType: "text", cacheScope: "none", + timeout: this.taskConfig?.httpTimeout, retryCount: Math.max(0, (this.taskConfig?.httpRetryCount ?? 1) - 1), + circuitThreshold: this.taskConfig?.circuitBreakerThreshold, circuitCooldownMs: this.taskConfig?.circuitBreakerCooldown, + urlPolicy, + }, await this.getRuntimeService("scope")()); + if (typeof response.data !== "string") throw new TypeError("宿主页面响应不是 HTML 文本"); + return response.data; + } catch (error) { const taskError = /** @type {any} */ (error); taskError._taskNetwork = true; throw taskError; } + } + /** @param {Record} [extra] @returns {TaskResult} */ createTaskResult(extra = {}) { return { attempted: !1, completed: !1, fatal: !1, success: 0, networkFailed: 0, parseFailed: 0, aborted: 0, skippedInterval: 0, skippedStopped: 0, ...extra }; } + /** @param {string} name @param {() => Promise} runner */ async runBackgroundTask(name, runner) { try { return await runner(); @@ -49,12 +102,15 @@ class TaskPlugin extends BasePlugin { return clog.error(`${name}执行失败,继续后续后台任务`, error), this.createTaskResult({ attempted: !0, error }); } } + /** @param {string | number | Date} e @param {string | number} t */ isUnnecessaryCheck(e, t) { if (!t) throw new Error("未传入checkIntervalTime"); - t = parseInt(t); + t = parseInt(String(t)); return utils.getHourDifference(new Date(e), new Date) < t; } + /** @param {TaskName} name */ getTaskSchedule(name) { + /** @type {Record} */ const schedules = { blacklist: { completedKey: this.lastCheckBlacklistTimeKey, attemptKey: this.lastCheckBlacklistAttemptKey, nextKey: this.lastCheckBlacklistNextKey, intervalSetting: "checkBlacklist_intervalTime", defaultInterval: 12 }, favoriteActress: { completedKey: this.lastCheckFavoriteActressTimeKey, attemptKey: this.lastCheckFavoriteActressAttemptKey, nextKey: this.lastCheckFavoriteActressNextKey, intervalSetting: "checkFavoriteActress_IntervalTime", defaultInterval: 24 }, @@ -63,13 +119,15 @@ class TaskPlugin extends BasePlugin { if (!schedules[name]) throw new Error(`未知任务调度: ${name}`); return schedules[name]; } + /** @param {TaskName} name */ getTaskScheduleState(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)), attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)); return { ...schedule, completed, attempt, pending: null != attempt && (null == completed || attempt > completed) }; } /** 返回当前标签页可证明的任务状态,不写入调度数据。 */ + /** @param {TaskName} name */ getTaskStatusSnapshot(name) { - const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(localStorage.getItem(state.nextKey)); + const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(this.getRuntimeService("storage").getLocal(state.nextKey)); const nextAt = null == storedNext ? state.pending && null != state.attempt ? state.attempt + 3e5 : 0 : storedNext, now = Date.now(); let status = "idle"; if (this.activeTasks.has(name)) status = "running"; @@ -77,20 +135,23 @@ class TaskPlugin extends BasePlugin { else if (null == storedNext || now >= nextAt) status = "due"; return { name, state: status, completedAt: state.completed, attemptAt: state.attempt, nextAt, isPending: state.pending, pendingUntil: state.pending ? nextAt : null }; } + /** @param {string} name @param {string} phase */ async emitTaskStatus(name, phase) { try { - await globalThis.jhsEventBus?.emit?.("task-status-changed", { taskName: name, phase }, { broadcast: !1 }); + await (/** @type {any} */ (globalThis)).jhsEventBus?.emit?.("task-status-changed", { taskName: name, phase }, { broadcast: !1 }); } catch (error) { clog.error(`任务状态通知失败: ${name}/${phase}`, error); } } + /** @param {string} reason @param {unknown[]} [carNums] */ async emitNewVideoChanged(reason, carNums = []) { try { - await globalThis.jhsEventBus?.emit?.("new-video-changed", { reason, carNums: carNums.map(normalizeCarNum).filter(Boolean) }); + await (/** @type {any} */ (globalThis)).jhsEventBus?.emit?.("new-video-changed", { reason, carNums: carNums.map(normalizeCarNum).filter(Boolean) }); } catch (error) { clog.error("新作品状态通知失败", error); } } + /** @param {TaskName} name @param {() => Promise} runner */ async withActiveTask(name, runner) { this.activeTasks.add(name); try { @@ -100,41 +161,45 @@ class TaskPlugin extends BasePlugin { this.activeTasks.delete(name), await this.emitTaskStatus(name, "finished"); } } + /** @param {TaskName} name */ async getLatestTaskInterval(name) { const schedule = this.getTaskSchedule(name); storageManager._invalidateCache?.(storageManager.setting_key); return parseNumberSetting(await storageManager.getSetting(schedule.intervalSetting, schedule.defaultInterval), schedule.defaultInterval, { min: Number.EPSILON }); } + /** @param {TaskName} name @param {boolean} [force] */ async shouldStartTask(name, force = !1) { if (force) return !0; - const schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); - const completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); - let next = parseTaskTimestamp(localStorage.getItem(schedule.nextKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); + const completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)), attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)); + let next = parseTaskTimestamp(storage.getLocal(schedule.nextKey)); if (null == next) { const pending = null != attempt && (null == completed || attempt > completed); next = pending ? attempt + 3e5 : null == completed ? 0 : completed + 36e5 * interval; - localStorage.setItem(schedule.nextKey, String(next)); + storage.setLocal(schedule.nextKey, String(next)); } return Date.now() >= next; } + /** @param {TaskName} name */ beginTaskAttempt(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)); const now = Math.floor(Date.now() / 1e3) * 1e3, attempt = null == completed ? now : Math.max(now, completed + 1e3); - localStorage.setItem(schedule.attemptKey, String(attempt)), localStorage.setItem(schedule.nextKey, String(attempt + 3e5)); + storage.setLocal(schedule.attemptKey, String(attempt)), storage.setLocal(schedule.nextKey, String(attempt + 3e5)); return attempt; } + /** @param {TaskName} name @param {boolean} completed */ async finalizeTask(name, completed) { - const schedule = this.getTaskSchedule(name); - if (!completed) return localStorage.setItem(schedule.nextKey, String(Date.now() + 3e5)); - const attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); - localStorage.setItem(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), localStorage.setItem(schedule.nextKey, String(completedAt + 36e5 * interval)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name); + if (!completed) return storage.setLocal(schedule.nextKey, String(Date.now() + 3e5)); + const attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); + storage.setLocal(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), storage.setLocal(schedule.nextKey, String(completedAt + 36e5 * interval)); } async recalculateSchedules() { await this.loadConfig(); - for (const [name, interval] of [ [ "blacklist", this.taskConfig.checkBlacklist_intervalTime ], [ "favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime ], [ "newVideo", this.taskConfig.checkNewVideo_intervalTime ] ]) { + for (const [name, interval] of /** @type {Array<[TaskName, number]>} */ ([ [ "blacklist", this.taskConfig.checkBlacklist_intervalTime ], [ "favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime ], [ "newVideo", this.taskConfig.checkNewVideo_intervalTime ] ])) { const state = this.getTaskScheduleState(name); if (state.pending) continue; - localStorage.setItem(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); + this.getRuntimeService("storage").setLocal(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); } } async invalidateConfig(recalculate = !1) { @@ -149,24 +214,26 @@ class TaskPlugin extends BasePlugin { this.configRefreshPromise = null; })), this.configRefreshPromise; } - handle() { + async handle() { if (!window.isListPage) return; + this.lifecycleScope ||= await this.getRuntimeService("scope")(); this.visibilityHandler || (this.visibilityHandler = () => { document.hidden ? this.clearSchedule() : this.scheduleTask(0); - }, this.pageHideHandler = () => this.clearSchedule(), document.addEventListener("visibilitychange", this.visibilityHandler), - window.addEventListener("pagehide", this.pageHideHandler)); + }, this.pageHideHandler = () => this.clearSchedule(), this.lifecycleScope.listen(document, "visibilitychange", this.visibilityHandler), + this.lifecycleScope.listen(window, "pagehide", this.pageHideHandler), this.lifecycleScope.addCleanup((() => this.clearSchedule()))); this.settingsHandler || (this.settingsHandler = async () => { try { storageManager._invalidateCache?.(storageManager.setting_key), await this.invalidateConfig(!0), this.scheduleTask(0); } catch (error) { clog.error("任务设置刷新失败", error); } - }, globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler)); + }, this.lifecycleScope.addCleanup((/** @type {any} */ (globalThis)).jhsEventBus?.on?.("settings-changed", this.settingsHandler) || (() => {}))); return document.hidden ? void 0 : this.runAndSchedule(); } clearSchedule() { this.taskTimer && (clearTimeout(this.taskTimer), this.taskTimer = null); } + /** @param {number} [e] */ scheduleTask(e = 3e5) { if (!window.isListPage || document.hidden) return void this.clearSchedule(); this.clearSchedule(), this.taskTimer = setTimeout((() => { @@ -182,7 +249,7 @@ class TaskPlugin extends BasePlugin { } async doTask() { if (!window.isListPage) return; - await this.loadConfig(), this.javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(); + await this.loadConfig(), this.javDbUrl = await this.getConfiguredSiteOrigin("javDbBtn"); return navigator.locks.request(this.singleTaskKey, { ifAvailable: !0 }, (async e => { @@ -214,7 +281,11 @@ class TaskPlugin extends BasePlugin { checkFavoriteActress_IntervalTime: parseNumberSetting(e.checkFavoriteActress_IntervalTime, 24, { min: Number.EPSILON }), enableCheckNewVideo: e.enableCheckNewVideo || _, checkNewVideo_intervalTime: parseNumberSetting(e.checkNewVideo_intervalTime, 12, { min: Number.EPSILON }), - checkNewVideo_ruleTime: parseNumberSetting(e.checkNewVideo_ruleTime, 8760, { min: 0 }) + checkNewVideo_ruleTime: parseNumberSetting(e.checkNewVideo_ruleTime, 8760, { min: 0 }), + httpTimeout: parseNumberSetting(e.httpTimeout, 5e3, { min: 1000, max: 120e3 }), + httpRetryCount: parseNumberSetting(e.httpRetryCount, 3, { min: 0, max: 5 }), + circuitBreakerThreshold: parseNumberSetting(e.circuitBreakerThreshold, 3, { min: 1, max: 20 }), + circuitBreakerCooldown: parseNumberSetting(e.circuitBreakerCooldown, 6e4, { min: 1000, max: 36e5 }) }; this.taskConfig = nextConfig, this.taskConfigDirty = !1; } while (this.configLoadQueued || this.taskConfigDirty); @@ -225,12 +296,17 @@ class TaskPlugin extends BasePlugin { } /** 确保所有任务入口均已具备配置和站点地址。 */ async ensureReady() { - (!this.taskConfig || this.taskConfigDirty) && await this.loadConfig(), this.javDbUrl || (this.javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl()); + (!this.taskConfig || this.taskConfigDirty) && await this.loadConfig(), this.javDbUrl || (this.javDbUrl = await this.getConfiguredSiteOrigin("javDbBtn")); if (!this.javDbUrl) throw new Error("JavDB 地址未配置"); } + /** @param {string} siteId */ + async getConfiguredSiteOrigin(siteId) { + return this.getRuntimeService("movie").externalSiteOrigin(siteId, await storageManager.getSetting()); + } + /** @param {string} url */ async resolveBlacklistSite(url) { try { - const target = new URL(url), otherSite = this.getBean("OtherSitePlugin"), [ javDbUrl, javBusUrl ] = await Promise.all([ otherSite?.getJavDbUrl?.(), otherSite?.getJavBusUrl?.() ]); + const target = new URL(url), [ javDbUrl, javBusUrl ] = await Promise.all([ this.getConfiguredSiteOrigin("javDbBtn"), this.getConfiguredSiteOrigin("javBusBtn") ]); for (const [ configuredUrl, site ] of [ [ javDbUrl, T ], [ javBusUrl, I ] ]) { try { if (configuredUrl && target.hostname === new URL(configuredUrl).hostname) return site; @@ -245,15 +321,17 @@ class TaskPlugin extends BasePlugin { } } async checkBlacklist(force = !1) { - await this.ensureReady(); const result = this.createTaskResult({ skippedHost: 0 }); + const blacklistPlugin = this.getOptionalDependency("BlacklistPlugin"); + if (!blacklistPlugin) return clog.warn("黑名单功能已禁用,跳过自动检测"), result; + await this.ensureReady(); if (!await this.shouldStartTask("blacklist", force)) return clog.debug("检测黑名单未到整批执行时间"), result; this.beginTaskAttempt("blacklist"), result.attempted = !0; return this.withActiveTask("blacklist", async () => { let blockedError = null, finalized = !1; try { let items = await storageManager.getBlacklist(); - items = items.sort(((left, right) => left.createTime < right.createTime ? 1 : left.createTime > right.createTime ? -1 : 0)); + items = items.sort(((/** @type {TaskRecord} */ left, /** @type {TaskRecord} */ right) => left.createTime < right.createTime ? 1 : left.createTime > right.createTime ? -1 : 0)); const concurrency = this.taskConfig.checkConcurrencyCount, sleep = this.taskConfig.checkRequestSleep, interval = this.taskConfig.checkBlacklist_intervalTime, rule = this.taskConfig.checkBlacklist_ruleTime; const eligible = [], currentHostname = new URL(window.location.href).hostname; for (const item of items) { @@ -275,15 +353,14 @@ class TaskPlugin extends BasePlugin { site ? eligible.push({ item, site }) : (result.parseFailed++, clog.error(`不支持的黑名单来源站点: ${itemUrl.hostname}`)); } } - clog.log(`检测屏蔽黑名单, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); - const blacklistPlugin = this.getBean("BlacklistPlugin"); + clog.html(`检测屏蔽黑名单, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); try { - await this.limitConcurrency(eligible, concurrency, sleep, (async entry => { + await this.limitConcurrency(eligible, concurrency, sleep, (async (/** @type {TaskRecord} */ entry) => { const {item, site} = entry; const {starId, name, url} = item; let responseText; try { - clog.log("正在检屏黑名单演员:", name, url), $("#checkBlacklistMsg").text(`正在检屏黑名单演员: ${name} ${url}`), responseText = await gmHttp.get(url); + clog.log("正在检屏黑名单演员:", name, url), $("#checkBlacklistMsg").text(`正在检屏黑名单演员: ${name} ${url}`), responseText = await this.requestHostPage(url, site); } catch (error) { result.networkFailed++; if (this.isNetworkBlocked(error)) throw error; @@ -305,7 +382,7 @@ class TaskPlugin extends BasePlugin { } const completed = 0 === result.parseFailed + result.networkFailed + result.aborted; result.completed = completed, result.fatal = !!blockedError, await this.finalizeTask("blacklist", completed), finalized = !0, this.renderBlacklistResult(result, completed); - try { await this.getBean("BlacklistPlugin").resetBtnTip(); } catch (error) { clog.error("刷新黑名单检测提示失败", error); } + try { await blacklistPlugin.resetBtnTip(); } catch (error) { clog.error("刷新黑名单检测提示失败", error); } if (blockedError) throw blockedError; return result; } catch (error) { @@ -315,6 +392,7 @@ class TaskPlugin extends BasePlugin { } }); } + /** @param {TaskResult} result @param {boolean} completed */ renderBlacklistResult(result, completed) { const retry = completed ? "" : ",5 分钟后补偿未完成项", message = `黑名单整批检测:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval},异站跳过 ${result.skippedHost}${result.aborted ? `,未执行 ${result.aborted}` : ""}${retry}`; $("#checkBlacklistMsg").text(message), clog.log(message); @@ -326,36 +404,43 @@ class TaskPlugin extends BasePlugin { this.beginTaskAttempt("favoriteActress"), result.attempted = !0; return this.withActiveTask("favoriteActress", async () => { try { - const actors = [], sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); + /** @type {TaskRecord[]} */ + const actors = []; + const sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); actors.length > 0 && await storageManager.addFavoriteActressList(actors), result.success = actors.length, result.actorCount = actors.length, result.pages = sync.pages; await this.finalizeTask("favoriteActress", !0), result.completed = !0, clog.log("所有演员信息已收集, 总计数量:", actors.length), $("#checkNewVideoMsg").text(`完整同步完成:演员 ${actors.length},页面 ${sync.pages}`); actors.length > 0 && await this.emitNewVideoChanged("favorite-actress-sync"); return result; } catch (error) { - result.completed = !1, error?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(error), await this.finalizeTask("favoriteActress", !1), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", error); + const taskError = /** @type {any} */ (error); + result.completed = !1, taskError?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(taskError), await this.finalizeTask("favoriteActress", !1), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", taskError); if (result.fatal) throw error; return result; } }); } + /** @param {string} startUrl @param {TaskRecord[]} [target] */ async scrapeActorInfo(startUrl, target = []) { const expected = new URL("/users/collection_actors", this.javDbUrl), visitedUrls = new Set; - let currentUrl = new URL(startUrl, this.javDbUrl), pages = 0; + /** @type {URL | null} */ + let currentUrl = new URL(startUrl, this.javDbUrl); + let pages = 0; for (;currentUrl; ) { try { if (currentUrl.origin !== expected.origin || currentUrl.pathname !== expected.pathname) throw new Error(`收藏演员分页地址越界: ${currentUrl.href}`); if (visitedUrls.has(currentUrl.href)) throw new Error(`收藏演员分页循环: ${currentUrl.href}`); if (pages >= 200) throw new Error("收藏演员分页超过 200 页"); visitedUrls.add(currentUrl.href), pages++, clog.log(`正在抓取页面: ${currentUrl.href}`), $("#checkNewVideoMsg").text(`正在解析已收藏的演员: ${currentUrl.href}`); - let responseText; - try { responseText = await gmHttp.get(currentUrl.href); } catch (error) { throw error._taskNetwork = !0, error; } - const page = utils.htmlTo$dom(responseText), parsed = parseJavDbActorList(page, currentUrl.href); + const parsed = /** @type {any} */ (await this.getRuntimeService("actressInfo").collection("javdb", { baseUrl: this.javDbUrl, pageUrl: currentUrl.href }, { scope: await this.getRuntimeService("scope")() })); if ("valid" !== parsed.state) throw Object.assign(new Error(`收藏演员页面无效: ${parsed.state}`), { _taskParse: !0 }); if (parsed.isEmpty && parsed.nextUrl) throw Object.assign(new Error("收藏演员空页面包含下一页"), { _taskParse: !0 }); target.push(...parsed.actors), currentUrl = parsed.nextUrl ? new URL(parsed.nextUrl, currentUrl.href) : null; } catch (error) { - error._taskNetwork || Object.prototype.hasOwnProperty.call(error, "_taskParse") || (error._taskParse = !0); - throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, error), error; + const requestCodes = [ "NETWORK_ERROR", "TIMEOUT", "AUTH_REQUIRED", "RATE_LIMITED", "CF_BLOCKED", "CIRCUIT_OPEN", "ABORTED" ]; + const taskError = /** @type {any} */ (error); + requestCodes.includes(taskError?.code) && (taskError._taskNetwork = !0); + taskError._taskNetwork || Object.prototype.hasOwnProperty.call(taskError, "_taskParse") || (taskError._taskParse = !0); + throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, taskError), taskError; } } return { actors: target, pages }; @@ -369,7 +454,7 @@ class TaskPlugin extends BasePlugin { let blockedError = null, finalized = !1; try { const actresses = await storageManager.getFavoriteActressList(), sorted = utils.genericSort(actresses, [ { - key: actress => actress.newVideoList?.length ?? 0, + key: (/** @type {TaskRecord} */ actress) => actress.newVideoList?.length ?? 0, order: "desc" }, { key: "lastPublishTime", @@ -381,17 +466,17 @@ class TaskPlugin extends BasePlugin { else if (shouldSkipStopped(actress.lastPublishTime, rule)) result.skippedStopped++; else eligible.push(actress); } - clog.log(`检测最新作品, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); + clog.html(`检测最新作品, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); if (eligible.length > 0) { - const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map((item => item.carNum))); + const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map((/** @type {TaskRecord} */ item) => item.carNum)); try { - await this.limitConcurrency(eligible, concurrency, sleep, (async actress => { + await this.limitConcurrency(eligible, concurrency, sleep, (async (/** @type {TaskRecord} */ actress) => { const {name, starId} = actress, url = `${this.javDbUrl}/actors/${starId}?t=d`; try { clog.log("正在检测最新作品, 演员:", name, url), $("#checkNewVideoMsg").text(`正在检测最新作品, 演员: ${name}`); - const responseText = await gmHttp.get(url), page = utils.htmlTo$dom(responseText); + const movies = await this.getRuntimeService("actressInfo").movies("javdb", { actorId: starId, baseUrl: this.javDbUrl }, { scope: await this.getRuntimeService("scope")(), ttlMs: 0 }); try { - await this.storageQueue.addTask((async () => this.parsePage(page, T, starId, name, titleKeywords, blacklistSet))), result.success++; + await this.storageQueue.addTask((async () => this.parseActorMovies(movies, starId, name, titleKeywords, blacklistSet))), result.success++; } catch (error) { result.parseFailed++, clog.error("解析或保存演员作品失败:", url, error); } @@ -406,7 +491,7 @@ class TaskPlugin extends BasePlugin { } } const completed = 0 === result.parseFailed + result.networkFailed + result.aborted; - result.completed = completed, await this.finalizeTask("newVideo", completed), finalized = !0, clog.log('检测最新作品---结束'), this.renderCheckResult(result, completed ? actresses.length ? "整批检测结束" : "收藏为空,整批检测完成" : "整批检测未完成,5 分钟后补偿未完成项"); + result.completed = completed, await this.finalizeTask("newVideo", completed), finalized = !0, clog.html('检测最新作品---结束'), this.renderCheckResult(result, completed ? actresses.length ? "整批检测结束" : "收藏为空,整批检测完成" : "整批检测未完成,5 分钟后补偿未完成项"); result.success > 0 && await this.emitNewVideoChanged("task-completed"); if (blockedError) throw blockedError; return result; @@ -417,10 +502,12 @@ class TaskPlugin extends BasePlugin { } }); } + /** @param {TaskResult} result @param {string} [prefix] */ renderCheckResult(result, prefix = "检测结束") { const message = `${prefix}:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval}${result.aborted ? `,未执行 ${result.aborted}` : ""}`; $("#checkNewVideoMsg").text(message), clog.log(message); } + /** @param {any} e @param {string} site @param {string} t @param {string} n @param {string[]} a @param {Set} i */ async parsePage(e, site, t, n, a, i) { const selector = this.getSelector(site); site === I && e.find(".avatar-box").length > 0 && e.find(".avatar-box").parent().remove(); @@ -439,10 +526,10 @@ class TaskPlugin extends BasePlugin { let c = []; const publishTimes = []; for (const m of s) { - const e = $(m), {carNum: s, url: o, title: r, publishTime: l} = this.getBean("ListPagePlugin").findCarNumAndHref(e); + const e = $(m), {carNum: s, url: o, title: r, publishTime: l} = readListItem(e); l && publishTimes.push(l); if (!s) continue; - a.find((e => r.includes(e) || s.includes(e))) || (i.has(s) || (() => { + a.find((/** @type {string} */ e) => r.includes(e) || s.includes(e)) || (i.has(s) || (() => { let coverUrl = e.find("img").attr("src") || ""; if (coverUrl && !coverUrl.startsWith("http")) { coverUrl = coverUrl.startsWith("/") ? this.javDbUrl + coverUrl : this.javDbUrl + "/" + coverUrl; @@ -464,25 +551,41 @@ class TaskPlugin extends BasePlugin { c.push({ carNum: s, coverUrl: coverUrl, title: r || "", publishTime: l || "", score: score, voteCount: voteCount, url: url }); })()); } - const d = selectLatestPublishTime(publishTimes), h = await storageManager.getCarMap(), p = c.filter((e => !h.has(e.carNum))); - p.length > 0 && clog.log(`检测出新作品, ${n}, 共${p.length}部`), + const d = selectLatestPublishTime(publishTimes), h = await storageManager.getCarMap(), decisions = await this.getRuntimeService("state")?.getNewVideoDecisions?.() || {}, p = c.filter((/** @type {TaskRecord} */ e) => !h.has(e.carNum)), fresh = p.filter((/** @type {TaskRecord} */ e) => decisions[e.carNum]?.action !== "dismissed"); + fresh.length > 0 && clog.html(`检测出新作品, ${escapeHtml(n)}, 共${fresh.length}部`), await storageManager.updateFavoriteActress({ starId: t, lastCheckTime: utils.getNowStr(), newVideoList: p, lastPublishTime: d }); - return p.length; + return fresh.length; + } + /** @param {TaskRecord[]} items @param {string} starId @param {string} name @param {string[]} titleKeywords @param {Set} blacklistSet */ + async parseActorMovies(items, starId, name, titleKeywords, blacklistSet) { + if (!items.length) return await storageManager.updateFavoriteActress({ starId, lastCheckTime: utils.getNowStr(), newVideoList: [] }), 0; + const publishTimes = items.map((/** @type {TaskRecord} */ item) => item.publishTime).filter(Boolean), candidates = items.filter((/** @type {TaskRecord} */ item) => { + if (!item.carNum || blacklistSet.has(item.carNum)) return false; + return !titleKeywords.some((/** @type {string} */ keyword) => item.title?.includes(keyword) || item.carNum.includes(keyword)); + }).map((/** @type {TaskRecord} */ item) => ({ + carNum: item.carNum, coverUrl: item.coverUrl || "", title: item.title || "", publishTime: item.publishTime || "", + score: Number(item.score) || 0, voteCount: Number(item.voteCount) || 0, url: item.url || "", + })); + const latestPublishTime = selectLatestPublishTime(publishTimes), carMap = await storageManager.getCarMap(), decisions = await this.getRuntimeService("state")?.getNewVideoDecisions?.() || {}, freshItems = candidates.filter((/** @type {TaskRecord} */ item) => !carMap.has(item.carNum)), fresh = freshItems.filter((/** @type {TaskRecord} */ item) => decisions[item.carNum]?.action !== "dismissed"); + fresh.length > 0 && clog.html(`检测出新作品, ${escapeHtml(name)}, 共${fresh.length}部`); + await storageManager.updateFavoriteActress({ starId, lastCheckTime: utils.getNowStr(), newVideoList: freshItems, lastPublishTime: latestPublishTime }); + return fresh.length; } + /** @param {TaskRecord} e */ async checkOneNewVideo(e) { await this.ensureReady(); - const t = await storageManager.getTitleFilterKeyword(), n = await storageManager.getBlacklistCarList(), a = new Set(n.map((e => e.carNum))), {lastCheckTime: i, name: s, starId: o} = e; + const t = await storageManager.getTitleFilterKeyword(), n = await storageManager.getBlacklistCarList(), a = new Set(n.map((/** @type {TaskRecord} */ e) => e.carNum)), {lastCheckTime: i, name: s, starId: o} = e; let r = `${this.javDbUrl}/actors/${o}?t=d`; const l = $("#checkNewVideoMsg"); try { clog.log("正在检测最新作品, 演员:", s, r), l.text(`正在检测最新作品, 演员: ${s}`); - const e = await gmHttp.get(r), n = utils.htmlTo$dom(e); - await this.parsePage(n, T, o, s, t, a), clog.log('检测最新作品---结束'), + const movies = await this.getRuntimeService("actressInfo").movies("javdb", { actorId: o, baseUrl: this.javDbUrl }, { scope: await this.getRuntimeService("scope")(), ttlMs: 0 }); + await this.parseActorMovies(movies, o, s, t, a), clog.html('检测最新作品---结束'), l.text("检测完毕"); await this.emitNewVideoChanged("single-actress-check"); } catch (c) { diff --git a/src/plugins/offline/unified-offline.js b/src/plugins/offline/unified-offline.js index 8a66f253..2b69578b 100644 --- a/src/plugins/offline/unified-offline.js +++ b/src/plugins/offline/unified-offline.js @@ -1,53 +1,113 @@ +// @ts-check + +import { parseBooleanSetting } from "../../core/feature-helpers.js"; + +import { l, r } from "../../core/constants.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { readListItem } from "../../core/list-item-reader.js"; +import { readOwnedMovieContext, resolveMovieContext as resolveMovieContextCore } from "../../core/movie-context.js"; +import { getDetailResourceAdapter } from "../../ui/detail/detail-resource-adapter.js"; + +/** @typedef {any} JQueryHandle Legacy jQuery runtime handle. */ +/** @typedef {{ available: boolean, authState: string, reason: string }} ProviderAvailability */ +/** @typedef {{ id: string, name: string, capabilities: string[], isEnabled: () => boolean | Promise, getAvailability: (options: { force: boolean }) => Promise, submit: (resource: string, info?: any, options?: {isActive?: () => boolean}) => Promise, openUrl?: () => string, retryPolicy?: { automaticAttempts: number } }} OfflineProvider */ +/** @typedef {{ provider: OfflineProvider, availability: ProviderAvailability }} OfflineCandidate */ +/** @typedef {{ preventDefault: () => void, stopPropagation: () => void, currentTarget: EventTarget }} JQueryClickEvent */ + +/** Compatibility wrapper for VM-loaded legacy fixtures; production uses the shared resolver. */ +/** @param {Record} options */ +function resolveOfflineMovieContext(options) { + if (typeof resolveMovieContextCore === "function") return resolveMovieContextCore(options); + const explicit = options.explicitContext; + if (explicit?.carNum) return { context: explicit, source: "explicit" }; + const owned = typeof readOwnedMovieContext === "function" ? readOwnedMovieContext(options.trigger) : null; + if (owned) return { context: owned, source: "owned-surface" }; + for (const [source, resolver] of [["list-item", options.listResolver], ["native-detail", options.nativeResolver], ["legacy-fallback", options.legacyResolver]]) { + const value = resolver?.(); + if (value?.carNum) return { context: value, source }; + } + return { context: null, source: "missing" }; +} + class OfflineProviderRegistry { - constructor() { this.providers = new Map, this.availabilityCache = new Map, this.positiveTtl = 3e5, this.negativeTtl = 2e4; } + constructor() { + /** @type {Map} */ this.providers = new Map; + /** @type {Map} */ this.availabilityCache = new Map; + /** @type {string[]} */ this.unavailableReasons = []; + this.positiveTtl = 3e5, this.negativeTtl = 2e4; + } + /** @param {OfflineProvider} provider */ register(provider) { if (!provider?.id || !Array.isArray(provider.capabilities) || "function" != typeof provider.submit || "function" != typeof provider.getAvailability) throw new TypeError("Invalid offline provider"); return this.providers.set(provider.id, provider), provider; } + /** @param {string} resource @param {{ force?: boolean }} [options] */ async getCandidates(resource, { force = !1 } = {}) { - const type = /^ed2k:/i.test(resource) ? "ed2k" : /^magnet:/i.test(resource) ? "magnet" : "unknown", candidates = []; + const type = /^ed2k:/i.test(resource) ? "ed2k" : /^magnet:/i.test(resource) ? "magnet" : "unknown", candidates = /** @type {OfflineCandidate[]} */ ([]); + const reasons = /** @type {string[]} */ ([]); for (const provider of this.providers.values()) { - if (!provider.capabilities.includes(type) || !await provider.isEnabled()) continue; + if (!provider.capabilities.includes(type)) { reasons.push(`${provider.name}:不支持 ${type === "unknown" ? "该资源格式" : type.toUpperCase()}`); continue; } + if (!await provider.isEnabled()) { reasons.push(`${provider.name}:未启用`); continue; } const availability = await this.getAvailability(provider, force); - [ "ready", "unknown" ].includes(availability.authState) && candidates.push({ provider, availability }); + if ([ "ready", "unknown" ].includes(availability.authState)) candidates.push({ provider, availability }); + else reasons.push(`${provider.name}:${availability.reason || "授权不可用"}`); } + this.unavailableReasons = reasons; return candidates; } + getUnavailableReason() { + const reasons = [...this.unavailableReasons]; + if (!this.providers.has("123")) reasons.unshift("123 云盘:授权桥接插件未加载或已禁用"); + return reasons.join(";") || "离线服务插件未加载或已禁用"; + } + /** @param {OfflineProvider} provider @param {boolean} [force] */ async getAvailability(provider, force = !1) { const cached = this.availabilityCache.get(provider.id); - const ttl = [ "ready", "unknown" ].includes(cached?.value?.authState) ? this.positiveTtl : this.negativeTtl; + const ttl = cached && [ "ready", "unknown" ].includes(cached.value.authState) ? this.positiveTtl : this.negativeTtl; if (!force && cached && Date.now() - cached.time < ttl) return cached.value; const value = await provider.getAvailability({ force }); return this.availabilityCache.set(provider.id, { time: Date.now(), value }), value; } + /** @param {string} id @param {ProviderAvailability} value */ updateAvailability(id, value) { this.availabilityCache.set(id, { time: Date.now(), value }); } } -class UnifiedOfflinePlugin extends BasePlugin { +export class UnifiedOfflinePlugin extends BasePlugin { constructor() { super(), this.registry = new OfflineProviderRegistry, this.BUTTON_COOLDOWN_MS = 1800; } getName() { return "UnifiedOfflinePlugin"; } async initCss() { return ''; } async handle() { if (!(r || l)) return; - this.registerProviders(), this.bindSubmit(), window.isDetailPage && (this.injectNativeButtons(), jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); + const scope = await this.getRuntimeService("scope")(); + this.lifecycleScope = scope; + this.registerProviders(scope), this.bindSubmit(), scope.addCleanup((() => $(document).off(".jhsUnifiedOffline"))); + if (window.isDetailPage) this.injectNativeButtons(), jhsEventBus && scope.addCleanup(jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); } - registerProviders() { - const one23 = this.getBean("OneTwoThreeOfflinePlugin"); - one23 && this.registry.register({ id: "123", name: "123 云盘", capabilities: [ "magnet" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting("enable123Offline", !0), getAvailability: async () => one23.getStoredToken() ? { available: !0, authState: "ready", reason: "授权已同步" } : { available: !1, authState: "token-missing", reason: "尚未同步 123 授权" }, submit: async resource => { const token = one23.getStoredToken(); if (!token) throw Object.assign(new Error("尚未同步 123 授权"), { code: "TOKEN_MISSING" }); const resolved = await one23.resolveMagnet(resource, token); return one23.submitTask(resolved, token); }, openUrl: () => "https://yun.123pan.com" }); - this.registry.register({ id: "115", name: "115", capabilities: [ "magnet", "ed2k" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting("enable115Offline", !1), getAvailability: async () => ({ available: !0, authState: "unknown", reason: "提交时确认登录状态" }), submit: resource => new OneOneFiveClient().addOffline(resource), openUrl: () => "https://115.com" }); - window.offlineProviderRegistry = this.registry; + /** @param {any} scope */ + registerProviders(scope) { + const one23 = this.getOptionalDependency("OneTwoThreeOfflinePlugin"), offline = this.getRuntimeService("offline"); + one23 && this.registry.register({ id: "123", name: "123 云盘", capabilities: [ "magnet" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: async () => parseBooleanSetting(this.getRuntimeService("settings").snapshot().enable123Offline ?? true, false), getAvailability: async () => await one23.getStoredToken() ? { available: !0, authState: "ready", reason: "授权已同步" } : { available: !1, authState: "token-missing", reason: "尚未同步 123 授权" }, submit: async (resource, _info, options = {}) => { + const token = await one23.getStoredToken(); + if (scope?.disposed || options.isActive?.() === false) throw Object.assign(new Error("所属界面已关闭,未提交任务"), { code: "SUBMIT_CANCELLED" }); + if (!parseBooleanSetting(this.getRuntimeService("settings").snapshot().enable123Offline ?? true, false)) throw Object.assign(new Error("123 离线服务已关闭,未提交任务"), { code: "SERVICE_DISABLED" }); + if (!token) throw Object.assign(new Error("尚未同步 123 授权"), { code: "TOKEN_MISSING" }); + return offline.submitWithIntegration("pan123", resource, { token, scope }); + }, openUrl: () => offline.getIntegrationHomeUrl("pan123") }); + this.registry.register({ id: "115", name: "115", capabilities: [ "magnet", "ed2k" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: async () => parseBooleanSetting(this.getRuntimeService("settings").snapshot().enable115Offline ?? false, false), getAvailability: async () => ({ available: !0, authState: "unknown", reason: "提交时确认登录状态" }), submit: (/** @type {string} */ resource) => offline.submitWithIntegration("one115", resource, { scope }), openUrl: () => offline.getIntegrationHomeUrl("one115") }); + (/** @type {any} */ (window)).offlineProviderRegistry = this.registry; } bindSubmit() { - $(document).off("click.jhsUnifiedOffline", ".jhs-offline-btn").on("click.jhsUnifiedOffline", ".jhs-offline-btn", (async event => { + $(document).off("click.jhsUnifiedOffline", ".jhs-offline-btn").on("click.jhsUnifiedOffline", ".jhs-offline-btn", (async (/** @type {JQueryClickEvent} */ event) => { event.preventDefault(), event.stopPropagation(); const button = $(event.currentTarget), resource = button.attr("data-resource") || button.attr("data-magnet") || button.closest(".magnet-result,.item,td").find('a[href^="magnet:"],a[href^="ed2k:"]').first().attr("href"); resource ? await this.submitResource(event, resource, button) : show.error("未找到可提交资源"); })); } injectNativeButtons() { - const adapter = getDetailResourceAdapter(); + const adapter = getDetailResourceAdapter(this.getRuntimeService("host")); if (!adapter) return; - adapter.rows().forEach((row => { + adapter.rows().forEach(((/** @type {Element} */ row) => { const resource = adapter.getResource(row), target = adapter.getActionTarget(row); if (!resource || !target?.length || $(row).closest(".magnet-container,.jhs-review-panel,.movie-detail-container").length) return; const owner = `native-${adapter.site}`; @@ -57,41 +117,107 @@ class UnifiedOfflinePlugin extends BasePlugin { button.attr("data-resource", resource), target.append(button); })); } + /** @param {unknown} event @param {OfflineCandidate[]} candidates @returns {Promise} */ async chooseCandidate(event, candidates) { if (1 === candidates.length) return candidates[0]; const mode = await storageManager.getSetting("offlineProviderMode", "ask"), preferred = candidates.find((candidate => candidate.provider.id === mode)); if (preferred) return preferred; return new Promise((resolve => { + const dialog = this.getRuntimeService("dialog"); const content = $('

    选择离线服务

    '), toolbar = content.find(".jhs-toolbar"); - candidates.forEach((candidate => toolbar.append($("").text(`${candidate.provider.name} · ${"ready" === candidate.availability.authState ? "已就绪" : "状态未知"}`).on("click", (() => { layer.close(index), resolve(candidate); }))))); - const index = layer.open({ type: 1, title: "选择离线服务", content, area: utils.getDialogArea("sm"), cancel: () => resolve(null) }); + candidates.forEach((candidate => toolbar.append($("").text(`${candidate.provider.name} · ${"ready" === candidate.availability.authState ? "已就绪" : "状态未知"}`).on("click", (() => { resolve(candidate), dialog.close(index); }))))); + const index = dialog.open({ type: 1, title: "选择离线服务", content, area: utils.getDialogArea("sm"), cancel: () => resolve(null), end: () => resolve(null) }); })); } - getVideoInfo(button) { - if (window.isDetailPage) return this.getPageInfo(); - const item = button?.closest?.(".item"); - return item?.length ? this.getBean("ListPagePlugin").findCarNumAndHref(item) : this.getPageInfo(); + /** Resolve the movie identity from the action's owning surface. */ + /** @param {JQueryHandle} button @param {unknown} [explicitContext] */ + getVideoInfo(button, explicitContext = null) { + const trigger = button?.[0] || button; + const item = this.getRuntimeService("host")?.locateListItems?.().find((/** @type {Element} */ card) => trigger && card.contains(trigger)); + const result = resolveOfflineMovieContext({ + explicitContext, + trigger: button, + listResolver: () => item ? readListItem(item) : null, + nativeResolver: () => window.isDetailPage ? this.getPageInfo() : null, + legacyResolver: () => this.getPageInfo?.(), + logger: (/** @type {string} */ message) => clog.warn(message), + }); + return result.context; } + /** @param {any} info @param {{root: any, layerIndex: number | null}} closeContext */ + async markDownloadedAndClose(info, closeContext) { + if (!info?.carNum) return !1; + try { + const state = this.getRuntimeService("state"); + await state.patch(info.carNum, { downloaded: !0 }, { type: "offline-mark-downloaded", record: { ...info, names: info.actress || info.names || "" } }); + const verified = await state.getState?.(info.carNum); + if (verified && verified.stateFlags?.downloaded !== true) throw new Error("状态回读未确认已下载"); + } catch (error) { + clog.error("离线任务标记已下载失败", error), show.error("离线已提交,但标记已下载失败"); + return !1; + } + try { + const preference = this.getRuntimeService("settings").snapshot().needClosePage ?? "yes"; + if (preference !== "yes" && preference !== true) return !0; + const closed = await utils.closePage(closeContext); + if (!closed) throw new Error("未找到可关闭的详情页"); + return !0; + } catch (error) { + clog.error("离线任务完成后关闭详情页失败", error), show.error("已标记下载,但无法自动关闭"); + return !1; + } + } + /** @param {unknown} event @param {string} resource @param {JQueryHandle} [button] @param {any} [context] @param {string | null} [retryOf] @param {{ forceAvailabilityRefresh?: boolean, preferredProviderId?: string }} [options] */ async submitResource(event, resource, button = $(), context = null, retryOf = null, options = {}) { - if (button.hasClass("loading")) return; - const candidates = await this.registry.getCandidates(resource, { force: !!options.forceAvailabilityRefresh }); - if (!candidates.length) return void show.error("没有已启用且支持该资源的离线服务,请检查授权与设置"); - const selected = candidates.find((candidate => candidate.provider.id === options.preferredProviderId)) || await this.chooseCandidate(event, candidates); - if (!selected) return; - const info = context || this.getVideoInfo(button), original = button.text(), restoreButton = () => { - if (!button[0]?.isConnected) return; + if (this.lifecycleScope?.disposed || button.hasClass("loading")) return; + const original = button.text(), trigger = button[0] || /** @type {any} */ (event)?.currentTarget; + const initiallyConnected = !!trigger?.isConnected; + const restoreButton = () => { button.removeClass("loading").removeAttr("aria-busy aria-disabled").text(original); }; - let submitted = !1; + let submitted = false; + /** @type {OfflineCandidate | null} */ let selected = null; + /** @type {any} */ let info = null; try { - button.addClass("loading").attr({ "aria-busy": "true", "aria-disabled": "true" }).text("提交中"), await selected.provider.submit(resource, info), this.registry.updateAvailability(selected.provider.id, { available: !0, authState: "ready", reason: "最近提交成功" }); - await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "submitted", retryOf }), submitted = !0, - button.text("已提交"), show.ok(`${selected.provider.name} 离线任务已创建`), utils.q(event, "是否将该作品标记为已下载?", (async () => { info?.carNum && await stateService.patch(info.carNum, { downloaded: !0 }, { type: "offline-mark-downloaded", record: { ...info, names: info.actress || info.names || "" } }); })); + button.addClass("loading").attr({ "aria-busy": "true", "aria-disabled": "true" }).text("提交中"); + let candidates = await this.registry.getCandidates(resource, { force: !!options.forceAvailabilityRefresh }); + if (!candidates.length && !options.forceAvailabilityRefresh && !this.lifecycleScope?.disposed && (!initiallyConnected || trigger.isConnected)) candidates = await this.registry.getCandidates(resource, { force: !0 }); + if (!candidates.length) return void show.error(`无法离线:${this.registry.getUnavailableReason?.() || "没有已启用且支持该资源的离线服务,请检查授权与设置"}`); + if (this.lifecycleScope?.disposed || initiallyConnected && !trigger.isConnected) return; + selected = candidates.find((candidate => candidate.provider.id === options.preferredProviderId)) || await this.chooseCandidate(event, candidates); + if (!selected) return; + if (!await selected.provider.isEnabled()) return void show.error("所选离线服务已关闭,未提交任务"); + if (this.lifecycleScope?.disposed || initiallyConnected && !trigger.isConnected) return; + info = this.getVideoInfo(button, context); + const detailRoot = trigger || null; + if (!info) return void show.error("无法确定影片身份,未执行离线操作"); + const closeContext = { root: detailRoot, layerIndex: utils.getOwningLayerIndex({ root: detailRoot }) }; + button.text("提交中"); + await selected.provider.submit(resource, info, { isActive: () => !this.lifecycleScope?.disposed && (!initiallyConnected || trigger.isConnected) }); + submitted = true; + this.registry.updateAvailability(selected.provider.id, { available: !0, authState: "ready", reason: "最近提交成功" }); + try { + await this.getRuntimeService("state").appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "submitted", retryOf }); + } catch (error) { + clog.error("离线任务已创建,但本地记录保存失败", error); + show.error("任务已创建,但本地记录保存失败,请勿重复提交"); + } + if (!initiallyConnected || trigger.isConnected) { + button.text("已提交"); + show.ok(`${selected.provider.name} 离线任务已创建`); + utils.q(event, "是否将该作品标记为已下载?", (() => { void this.markDownloadedAndClose(info, closeContext); })); + } } catch (error) { - const code = error?.code || ("TOKEN_EXPIRED" === error ? "TOKEN_EXPIRED" : "SUBMIT_FAILED"); - [ "LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING" ].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: !1, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: error.message || String(error) }); - restoreButton(); - submitted || await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "failed", errorCode: code, errorMessage: error?.message || String(error), retryOf }), show.error(`${selected.provider.name} 离线失败:${error?.message || error}`); + if (submitted) { clog.error("离线任务已创建,后续界面更新失败", error); return; } + const errorRecord = /** @type {{ code?: string, message?: string }} */ (error), code = errorRecord?.code || ("TOKEN_EXPIRED" === error ? "TOKEN_EXPIRED" : "SUBMIT_FAILED"), message = errorRecord?.message || String(error); + if (code === "SUBMIT_CANCELLED") return; + if (code === "SERVICE_DISABLED") return void show.error(message); + if (selected) { + [ "AUTH_REQUIRED", "LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING" ].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: !1, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: message }); + try { await this.getRuntimeService("state").appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "failed", errorCode: code, errorMessage: message, retryOf }); } + catch (historyError) { clog.error("离线失败记录保存失败", historyError); } + } + show.error(`${selected?.provider.name || ""} 离线失败:${message}`); } finally { submitted ? setTimeout(restoreButton, this.BUTTON_COOLDOWN_MS) : restoreButton(); } } } diff --git a/src/plugins/one-one-five/client.js b/src/plugins/one-one-five/client.js index b3bbb2a6..4edf1ef4 100644 --- a/src/plugins/one-one-five/client.js +++ b/src/plugins/one-one-five/client.js @@ -1,43 +1,11 @@ -class OneOneFiveClient { - constructor(http = gmHttp) { this.http = http; } - async checkLogin() { - try { const result = await this.http.get("https://webapi.115.com/offine/downpath"); return Boolean(result?.state && result?.data?.length); } catch (cause) { throw new ProviderError("115", "LOGIN_REQUIRED", "115 未登录", { cause }); } - } - async search(keyword, offset = 0, limit = 50) { - const result = await this.http.get(`https://webapi.115.com/files/search?search_value=${encodeURIComponent(keyword)}&offset=${offset}&limit=${limit}`); - return (result?.data || []).map((item => ({ folderId: item.pid || item.cid || "", fileId: item.fid || null, videoId: item.pc || item.pick_code || "", name: item.n || item.file_name || "", size: Number(item.s || item.size) || 0, createTime: item.t || item.create_time || "", isVideo: /\.(mp4|mkv|avi|mov|flv|wmv|ts|m2ts)$/i.test(item.n || item.file_name || "") }))).filter((item => item.isVideo)); - } - async getOfflineInfo() { return this.http.get(`https://115.com/?ct=offline&ac=space&_=${Date.now()}`); } - async addOffline(magnet, folderId = "") { - if (!/^magnet:/i.test(magnet) && !/^ed2k:/i.test(magnet)) throw new TypeError("Unsupported offline URL"); - const info = await this.getOfflineInfo(); - if (!info || !info.sign) throw new ProviderError("115", "LOGIN_REQUIRED", "115 未登录或离线空间信息获取失败"); - const body = new URLSearchParams({ url: magnet, wp_path_id: folderId, uid: String(info.uid || ""), sign: info.sign || "", time: String(info.time || "") }).toString(); - const result = await this.http.gmRequest("POST", "https://115.com/web/lixian/?ct=lixian&ac=add_task_url", body, {}, { "Content-Type": "application/x-www-form-urlencoded" }); - const parsed = "string" == typeof result ? (() => { try { return JSON.parse(result); } catch { return { state: !1, error_msg: /login|登录|sign in|未授权|授权|expire|expired|token|cookie/i.test(result) ? "115 未登录" : "115 返回异常响应" }; } })() : result; - if (!parsed || parsed.state === !1) { - const message = String(parsed?.error_msg || parsed?.error || parsed?.msg || ""); - const code = this.classifyAddOfflineError(message); - throw new ProviderError("115", code, message || "115 离线任务创建失败", { response: parsed }); - } - return parsed; - } - classifyAddOfflineError(message) { - const text = String(message).toLowerCase(); - if (/未登录|请登录|登录|login|sign|授权|过期|token|cookie|uid|身份|auth|expire|needlogin|need login/i.test(text)) return "LOGIN_REQUIRED"; - if (/已存在|重复|exists|duplicate|already|same|conflict|exist/i.test(text)) return "TASK_EXISTS"; - return "ADD_TASK_FAILED"; - } - async rename(fileId, newName) { - const body = new URLSearchParams({ fid: fileId, file_name: newName }).toString(); - return this.http.gmRequest("POST", "https://webapi.115.com/files/edit", body, {}, { "Content-Type": "application/x-www-form-urlencoded" }); - } -} +// @ts-check + +import { normalizeCarNum } from "../../core/constants.js"; -function normalize115Keyword(carNum) { const normalized = normalizeCarNum(carNum); return normalized?.replace(/^FC2-/i, "") || null; } -function build115PlayUrl(match) { return match?.videoId ? `https://115.com/?ct=play&pickcode=${encodeURIComponent(match.videoId)}` : null; } -function format115Size(bytes) { const value = Number(bytes) || 0; if (!value) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"], index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); return `${(value / 1024 ** index).toFixed(index ? 2 : 0)} ${units[index]}`; } -function preview115Rename(fileName, carNum, options = {}) { +export function normalize115Keyword(/** @type {unknown} */ carNum) { const normalized = normalizeCarNum(carNum); return normalized?.replace(/^FC2-/i, "") || null; } +export function format115Size(/** @type {unknown} */ bytes) { const value = Number(bytes) || 0; if (!value) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"], index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); return `${(value / 1024 ** index).toFixed(index ? 2 : 0)} ${units[index]}`; } +/** @param {string} fileName @param {string} carNum @param {{uppercase?: boolean, keepTitle?: boolean, keepSuffix?: boolean, maxLength?: number}} [options] */ +export function preview115Rename(fileName, carNum, options = {}) { const extension = fileName.match(/\.[^.]+$/)?.[0] || "", tags = fileName.match(/-(?:U|UC|C|4K|8K|H265|HEVC|CN|CHS|CHT)\b/gi) || []; let base = options.uppercase === false ? carNum : carNum.toUpperCase(); if (options.keepTitle) base += ` ${fileName.replace(extension, "").replace(/^.*?\s+/, "")}`; diff --git a/src/plugins/one-one-five/plugins.js b/src/plugins/one-one-five/plugins.js index e3b39083..38b422ae 100644 --- a/src/plugins/one-one-five/plugins.js +++ b/src/plugins/one-one-five/plugins.js @@ -1,56 +1,111 @@ -class OneOneFiveMatchPlugin extends BasePlugin { - constructor() { super(), this.observer = null, this.unsubscribeItems = null, this.pendingCards = new Set, this.flushTimer = null, this.client = new OneOneFiveClient, this.concurrency = 4, this.cacheMinutes = 60; } +// @ts-check + +import { escapeHtml, normalizeCarNum } from "../../core/constants.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { mapLimit } from "../../core/feature-helpers.js"; +import { LifecycleScope } from "../../core/lifecycle-scope.js"; +import { parseBooleanSetting } from "../../core/feature-helpers.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { format115Size, normalize115Keyword, preview115Rename } from "./client.js"; + +export class OneOneFiveMatchPlugin extends BasePlugin { + constructor() { super(), this.observer = null, this.unsubscribeItems = null, this.pendingCards = new Set, this.flushTimer = null, this.lifecycleScope = null, this.concurrency = 4, this.cacheMinutes = 60; } getName() { return "OneOneFiveMatchPlugin"; } async handle() { - if (!await storageManager.getSetting("enable115Match", !1)) return; - if (!isDetailPage) return this.setupListMatching(); + this.parentScope = await this.getRuntimeService("scope")(); + const settings = this.getRuntimeService("settings"); + this.parentScope.listen(settings, "settings.changed", (/** @type {any} */ event) => { + if (event.detail?.names?.some((/** @type {string} */ name) => ["enable115Match", "oneOneFiveConcurrency", "oneOneFiveCacheMinutes"].includes(name))) void this.configureMatching().catch(error => clog.error("115 匹配设置更新失败", error)); + }); + this.parentScope.addCleanup(() => { this.matchGeneration++; this.stopMatching(); }); + await this.configureMatching(); + } + async configureMatching() { + const generation = this.matchGeneration = (this.matchGeneration || 0) + 1; + this.stopMatching(); + const enabled = parseBooleanSetting(this.getRuntimeService("settings").snapshot().enable115Match, false); + if (!enabled || this.parentScope.disposed || generation !== this.matchGeneration) return; + const scope = this.lifecycleScope = new LifecycleScope(`115-match:${generation}`); + this.releaseMatchScope = this.parentScope.addCleanup(() => scope.dispose()); + await this.mountMatching(scope); + } + stopMatching() { + this.releaseMatchScope?.(); this.releaseMatchScope = null; + this.lifecycleScope?.dispose(); this.lifecycleScope = null; + this.unsubscribeItems?.(); this.unsubscribeItems = null; + this.observer?.disconnect(); this.observer = null; + if (this.flushTimer) clearTimeout(this.flushTimer); + this.flushTimer = null; this.pendingCards.clear(); + $(".jhs-115-match,.jhs-115-list-match").remove(); + $("[data-jhs115-observed],[data-jhs115-state]").removeAttr("data-jhs115-observed data-jhs115-state"); + } + /** @param {LifecycleScope} scope */ + async mountMatching(scope) { + const hostAdapter = this.getRuntimeService("host"), offline = this.getRuntimeService("offline"); + if (!isDetailPage) { + await this.setupListMatching(hostAdapter, scope); + if (scope.disposed) return; + scope.ownObserver(this.observer), scope.addCleanup((() => { + this.unsubscribeItems?.(), this.unsubscribeItems = null, this.flushTimer && clearTimeout(this.flushTimer), this.flushTimer = null, this.pendingCards.clear(); + })); + return; + } const carNum = this.getPageInfo().carNum, keyword = normalize115Keyword(carNum); if (!keyword) return; - const host = $(".movie-panel-info,.container .info").first(); host.append('
    115匹配:匹配中
    '); + const host = $(hostAdapter.locateDetailSlots().summary); host.append('
    115匹配:匹配中
    '); try { - const cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60), matches = await storageManager.cachedRequest(`115match:${carNum}`, cacheMinutes * 6e4, (() => new OneOneFiveClient().search(keyword))); + const cacheMinutes = Math.max(1, Number(this.getRuntimeService("settings").snapshot().oneOneFiveCacheMinutes ?? 60) || 60); + if (scope.disposed || this.lifecycleScope !== scope) return; + const matches = await offline.searchFiles("one115", keyword, { scope, ttlMs: cacheMinutes * 6e4 }); + if (scope.disposed || this.lifecycleScope !== scope) return; const box = $(".jhs-115-match").empty().append("115匹配:"); if (!matches.length) return void box.append(document.createTextNode("未匹配 "), $('').on("click", (() => location.reload()))); - matches.forEach((match => { const row = $(''), playUrl = build115PlayUrl(match); playUrl ? row.append($("").addClass("jhs-btn jhs-btn--secondary").attr({ href: playUrl, target: "_blank" }).text(`${match.name} (${format115Size(match.size)})`)) : row.append($("").text(`${match.name} (${format115Size(match.size)}) · 不可播放`)); match.fileId && row.append($("").data("match", match)); box.append(row); })); - box.on("click", ".jhs-115-rename", (event => this.renameWithPreview(event, $(event.currentTarget).data("match"), carNum))); - } catch (error) { const box = $(".jhs-115-match").empty().append("115匹配:", document.createTextNode("未登录或请求失败 ")); box.append('去登录', $('').on("click", (() => location.reload()))); clog.error("115 匹配失败", error); } + matches.forEach(((/** @type {any} */ match) => { const row = $(''), playUrl = offline.getPlayUrl("one115", match); playUrl ? row.append($("").addClass("jhs-btn jhs-btn--secondary").attr({ href: playUrl, target: "_blank" }).text(`${match.name} (${format115Size(match.size)})`)) : row.append($("").text(`${match.name} (${format115Size(match.size)}) · 不可播放`)); match.fileId && row.append($("").data("match", match)); box.append(row); })); + box.on("click", ".jhs-115-rename", ((/** @type {MouseEvent} */ event) => this.renameWithPreview(event, $(event.currentTarget).data("match"), carNum))); + } catch (error) { if (scope.disposed || this.lifecycleScope !== scope) return; const box = $(".jhs-115-match").empty().append("115匹配:", document.createTextNode("未登录或请求失败 ")); box.append($("").addClass("jhs-btn jhs-btn--ghost").attr({ href: offline.getIntegrationHomeUrl("one115"), target: "_blank" }).text("去登录"), $('').on("click", (() => location.reload()))); clog.error("115 匹配失败", error); } } - async setupListMatching() { - this.concurrency = Math.max(1, Math.min(10, Number(await storageManager.getSetting("oneOneFiveConcurrency", 4)) || 4)), this.cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60); + async setupListMatching(/** @type {any} */ hostAdapter, /** @type {LifecycleScope} */ scope) { + if (!jhsEventBus) throw new Error("JHS EventBus 尚未初始化"); + this.concurrency = Math.max(1, Math.min(10, Number(this.getRuntimeService("settings").snapshot().oneOneFiveConcurrency ?? 4) || 4)), this.cacheMinutes = Math.max(1, Number(this.getRuntimeService("settings").snapshot().oneOneFiveCacheMinutes ?? 60) || 60); + if (scope.disposed || this.lifecycleScope !== scope) return; this.observer = new IntersectionObserver((entries => { + if (scope.disposed || this.lifecycleScope !== scope) return; entries.forEach((entry => entry.isIntersecting && (this.observer.unobserve(entry.target), this.pendingCards.add(entry.target)))); this.pendingCards.size && this.scheduleFlush(); }), { rootMargin: "200px" }); - this.registerCards($(".movie-list .item,.masonry .item").get()), this.unsubscribeItems = jhsEventBus.on("list-items-added", (payload => this.registerCards(payload.items || []))); + this.registerCards($(hostAdapter.locateListRoot()).find(".item").get()), this.unsubscribeItems = jhsEventBus.on("list-items-added", ((/** @type {any} */ payload) => this.registerCards(payload.items || []))); } - registerCards(cards) { - cards.forEach((card => { "true" !== card.dataset.jhs115Observed && "matched" !== card.dataset.jhs115State && (card.dataset.jhs115Observed = "true", this.observer.observe(card)); })); + registerCards(/** @type {HTMLElement[]} */ cards) { + cards.forEach((card => { "true" !== card.dataset.jhs115Observed && "matched" !== card.dataset.jhs115State && (card.dataset.jhs115Observed = "true", this.observer?.observe(card)); })); } scheduleFlush() { this.flushTimer || (this.flushTimer = setTimeout((async () => { - const cards = [ ...this.pendingCards ]; - this.pendingCards.clear(), this.flushTimer = null, await mapLimit(cards, this.concurrency, (card => this.matchCard(card))); + const cards = [ ...this.pendingCards ], scope = this.lifecycleScope; + this.pendingCards.clear(), this.flushTimer = null, await mapLimit(cards, this.concurrency, (card => scope && !scope.disposed && this.lifecycleScope === scope ? this.matchCard(card) : undefined)); }), 50)); } - async matchCard(element, force = !1) { + async matchCard(/** @type {HTMLElement} */ element, /** @type {boolean} */ force = !1) { + const scope = this.lifecycleScope; + if (!scope || scope.disposed) return; const card = $(element), carNum = normalizeCarNum(card.find(".video-title strong").first().text()); - if (!carNum || "pending" === element.dataset.jhs115State && !force) return; - const cacheKey = `115match:${carNum}`; + if (!carNum || "pending" === element.dataset.jhs115State) return; try { - element.dataset.jhs115State = "pending", force && await storageManager.deleteCachedRequest(cacheKey); - const matches = await storageManager.cachedRequest(cacheKey, this.cacheMinutes * 6e4, (() => this.client.search(normalize115Keyword(carNum)))); + element.dataset.jhs115State = "pending"; + const offline = this.getRuntimeService("offline"), matches = await offline.searchFiles("one115", normalize115Keyword(carNum), { scope, ttlMs: this.cacheMinutes * 6e4, force }); + if (scope.disposed || this.lifecycleScope !== scope || !element.isConnected) return; card.find(".jhs-115-list-match").remove(); const badge = $("").text(matches.length ? `匹配${matches.length}个` : "未匹配").data("matches", matches); card.find(".video-title").first().prepend(badge), element.dataset.jhs115State = "matched"; - badge.on("click", (() => { if (!matches.length) return this.matchCard(element, !0); if (1 === matches.length) return window.open(build115PlayUrl(matches[0]), "_blank"); const links = matches.map((match => `${escapeHtml(match.name)}`)).join("
    "); layer.open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea([ "560px", "auto" ]) }); })); + badge.on("click", ((/** @type {MouseEvent} */ event) => { event.preventDefault(); event.stopPropagation(); if (scope.disposed) return; if (!matches.length) return this.matchCard(element, !0); if (1 === matches.length) return window.open(offline.getPlayUrl("one115", matches[0]), "_blank"); const links = matches.map(((/** @type {any} */ match) => `${escapeHtml(match.name)}`)).join("
    "); this.getRuntimeService("dialog").open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea([ "560px", "auto" ]) }); })); } catch (error) { - element.dataset.jhs115State = "failed", card.find(".jhs-115-list-match").remove(), card.find(".video-title").first().prepend($('').one("click", (() => this.matchCard(element, !0)))), clog.warn("115 单卡匹配失败", error); + if (scope.disposed || this.lifecycleScope !== scope || !element.isConnected) return; + element.dataset.jhs115State = "failed", card.find(".jhs-115-list-match").remove(), card.find(".video-title").first().prepend($('').on("click", ((/** @type {MouseEvent} */ event) => { event.preventDefault(); event.stopPropagation(); if (!scope.disposed) void this.matchCard(element, !0); }))), clog.warn("115 单卡匹配失败", error); } } destroy() { - this.unsubscribeItems?.(), this.observer?.disconnect(), this.flushTimer && clearTimeout(this.flushTimer), this.pendingCards.clear(); + this.matchGeneration = (this.matchGeneration || 0) + 1; this.stopMatching(); } - renameWithPreview(event, match, carNum) { + renameWithPreview(/** @type {Event} */ event, /** @type {any} */ match, /** @type {string} */ carNum) { const nextName = preview115Rename(match.name, carNum, { uppercase: !0, keepSuffix: !0 }); - utils.q(event, `确认重命名?
    ${escapeHtml(match.name)}
    → ${escapeHtml(nextName)}`, (async () => { await new OneOneFiveClient().rename(match.fileId, nextName); show.ok("重命名完成"); })); + utils.q(event, `确认重命名?
    ${escapeHtml(match.name)}
    → ${escapeHtml(nextName)}`, (async () => { try { await this.getRuntimeService("offline").renameFile("one115", match.fileId, nextName, { scope: this.lifecycleScope }), show.ok("重命名完成"); } catch (error) { clog.error("115 重命名失败", error), show.error("重命名失败:" + (error instanceof Error ? error.message : String(error))); } })); } } diff --git a/src/plugins/one-two-three/offline.js b/src/plugins/one-two-three/offline.js index f9221964..b31c3d9f 100644 --- a/src/plugins/one-two-three/offline.js +++ b/src/plugins/one-two-three/offline.js @@ -1,29 +1,38 @@ -class OneTwoThreeOfflinePlugin extends BasePlugin { +// @ts-check + +import { BasePlugin } from "../../core/plugin-manager.js"; +import { decryptData, encryptData } from "../../core/credential-crypto.js"; + +export class OneTwoThreeOfflinePlugin extends BasePlugin { constructor() { super(...arguments), this.tokenKey = "jhs_123pan_author_token", this.tokenMetaKey = "jhs_123pan_author_token_meta", - this.syncTimer = null, this.syncFallbackMs = 3e5; + this.syncTimer = null, this.syncFallbackMs = 3e5, this.syncGeneration = 0; } getName() { return "OneTwoThreeOfflinePlugin"; } async handle() { - "yun.123pan.com" === window.location.hostname && this.startTokenSync(); + if ("yun.123pan.com" !== window.location.hostname) return; + this.startTokenSync(await this.getRuntimeService("scope")()); } - startTokenSync() { + /** @param {any} scope */ + startTokenSync(scope) { this.syncTokenOnce(), this.syncTimer && clearInterval(this.syncTimer), this.syncTimer = setInterval((() => this.syncTokenOnce()), this.syncFallbackMs); const e = () => this.syncTokenOnce(); - window.addEventListener("storage", e), window.addEventListener("focus", e), document.addEventListener("visibilitychange", (() => { + scope.addCleanup((() => { this.syncTimer && clearInterval(this.syncTimer), this.syncTimer = null; })); + scope.listen(window, "storage", e), scope.listen(window, "focus", e), scope.listen(document, "visibilitychange", (() => { document.hidden || this.syncTokenOnce(); })); } getTokenFrom123Pan() { - let e = (localStorage.getItem("authorToken") || "").trim(); + const storage = this.getRuntimeService("storage"); + let e = (storage.getLocal("authorToken") || "").trim(); if (e) return { token: e, source: "authorToken" }; try { - const t = JSON.parse(localStorage.getItem("userInfo") || "{}"); + const t = JSON.parse(storage.getLocal("userInfo") || "{}"); if (t.authorToken || t.token) return { token: (t.authorToken || t.token || "").trim(), source: t.authorToken ? "userInfo.authorToken" : "userInfo.token" @@ -44,92 +53,38 @@ class OneTwoThreeOfflinePlugin extends BasePlugin { source: "" }; } - syncTokenOnce() { - const e = this.getTokenFrom123Pan(); + async syncTokenOnce() { + const generation = ++this.syncGeneration; + const storage = this.getRuntimeService("storage"), e = this.getTokenFrom123Pan(); if (!e.token) return; - const t = GM_getValue(this.tokenKey, ""), n = GM_getValue(this.tokenMetaKey, null); - if (t === e.token && n && n.source === e.source) return; - GM_setValue(this.tokenKey, e.token), GM_setValue(this.tokenMetaKey, { + const secretKey = `${this.tokenKey}_secret`; + let secret = storage.getValue(secretKey, ""); + if (!secret) secret = crypto.randomUUID?.() || `${Date.now()}-${crypto.getRandomValues(new Uint32Array(4)).join("-")}`, storage.setValue(secretKey, secret); + const t = storage.getValue(this.tokenKey, ""), current = await this.getStoredToken(), n = storage.getValue(this.tokenMetaKey, null); + if (current === e.token && t.startsWith("AES:") && n && n.source === e.source) return; + const encrypted = "AES:" + await encryptData(e.token, secret); + const latest = this.getTokenFrom123Pan(); + if (generation !== this.syncGeneration || latest.token !== e.token || latest.source !== e.source || storage.getValue(this.tokenKey, "") !== t || storage.getValue(secretKey, "") !== secret) return; + storage.setValue(this.tokenKey, encrypted), storage.setValue(this.tokenMetaKey, { source: e.source, updatedAt: (new Date).toISOString() - }), t !== e.token && show.info(`123 云盘授权已更新:${e.source}`); + }), current !== e.token && show.info(`123 云盘授权已更新:${e.source}`); } - getStoredToken() { - return GM_getValue(this.tokenKey, ""); + async getStoredToken() { + const storage = this.getRuntimeService("storage"), value = storage.getValue(this.tokenKey, ""), secret = storage.getValue(`${this.tokenKey}_secret`, ""); + if (typeof value !== "string") return ""; + if (!value.startsWith("AES:")) return value; + if (typeof secret !== "string" || !secret) return ""; + try { return await decryptData(value.slice(4), secret, false); } + catch { return ""; } } - clearStoredToken(e) { - GM_setValue(this.tokenKey, ""), GM_setValue(this.tokenMetaKey, { + clearStoredToken(/** @type {string} */ e) { + this.syncGeneration++; + const storage = this.getRuntimeService("storage"); + storage.setValue(this.tokenKey, ""), storage.setValue(this.tokenMetaKey, { source: "cleared", reason: e, updatedAt: (new Date).toISOString() }); } - isTokenExpiredError(e) { - const msg = e instanceof Error ? e.message : "object" == typeof e && e ? e.message || "" : String(e || ""); - return "TOKEN_EXPIRED" === e || "TOKEN_EXPIRED" === msg || msg.toLowerCase().includes("token is expired"); - } - assertApiResult(e, t) { - if (0 === e.code) return; - const n = e.message || e.msg || t || "请求失败"; - throw /token is expired/i.test(n) ? "TOKEN_EXPIRED" : n; - } - /* 依赖 gmRequest 在非 2xx 时 reject 对象上附加 status 属性 */ - async resolveMagnet(e, t) { - try { - const n = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/resolve"), { urls: e }, { - Authorization: "Bearer " + t, - "App-Version": "3", - platform: "web", - Origin: "https://yun.123pan.com", - Referer: "https://yun.123pan.com/" - }); - return this.assertApiResult(n, "解析失败"), n.data && n.data.list && n.data.list.length > 0 ? n.data.list[0] : Promise.reject(n.message || `解析失败 (${n.code})`); - } catch (a) { - if (a && 401 === a.status) throw "TOKEN_EXPIRED"; - throw this.isTokenExpiredError(a) ? "TOKEN_EXPIRED" : a.message ? "响应解析失败: " + a.message : String(a); - } - } - async submitTask(e, t) { - if (!e.files || 0 === e.files.length) throw "没有可建立离线的文件"; - const n = e.files.map((e => e.id)), a = e.files.reduce(((e, t) => e + (t.size || 0)), 0); - try { - const i = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/submit"), { - resource_list: [{ resource_id: e.id, select_file_id: n }] - }, { - Authorization: "Bearer " + t, - "App-Version": "3", - platform: "web" - }); - return this.assertApiResult(i, "提交失败"), { fileCount: n.length, totalSize: a }; - } catch (i) { - if (i && 401 === i.status) throw "TOKEN_EXPIRED"; - throw this.isTokenExpiredError(i) ? "TOKEN_EXPIRED" : i.message ? "响应解析失败: " + i.message : String(i); - } - } - /** CRC32-IEEE (poly 0xEDB88320) — 与 Go crc32.ChecksumIEEE 一致 */ - _crc32(e) { - const t = new Array(256); - for (let n = 0; n < 256; n++) { - let a = n; - for (let i = 0; i < 8; i++) a = 1 & a ? 3988292384 ^ a >>> 1 : a >>> 1; - t[n] = a; - } - let n = 4294967295; - for (let a = 0; a < e.length; a++) n = t[(n ^ e.charCodeAt(a)) & 255] ^ n >>> 8; - return (n ^ 4294967295) >>> 0; - } - /** 为 123 云盘 API URL 附加签名查询参数(与 Go signPath 算法一致) */ - _signUrl(e) { - const t = [ "a", "d", "e", "f", "g", "h", "l", "m", "y", "i", "j", "n", "o", "p", "k", "q", "r", "s", "t", "u", "b", "c", "v", "w", "s", "z" ]; - const n = Math.round(1e7 * Math.random()); - const a = new Date; - const i = new Date(a.getTime() + 6e4 * a.getTimezoneOffset() + 288e5); - const s = `${i.getFullYear()}${String(i.getMonth() + 1).padStart(2, "0")}${String(i.getDate()).padStart(2, "0")}${String(i.getHours()).padStart(2, "0")}${String(i.getMinutes()).padStart(2, "0")}`; - let o = ""; - for (let r = 0; r < s.length; r++) o += t[parseInt(s[r])]; - const r = this._crc32(o), l = Math.floor(i.getTime() / 1e3); - const c = `${l}|${n}|${new URL(e).pathname}|web|3|${r}`; - const u = this._crc32(c), d = new URL(e); - return d.searchParams.set(String(r), `${l}-${n}-${u}`), d.toString(); - } } diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 60f261f8..1e40942b 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -1,47 +1,153 @@ -const DEFAULT_JAVDB_PLUGINS = [ - ListPagePlugin, AutoPagePlugin, Fc2Plugin, FoldCategoryPlugin, ListPageButtonPlugin, - HistoryPlugin, SettingPlugin, NavBarPlugin, HitShowPlugin, Top250Plugin, - SearchByImagePlugin, CoverButtonPlugin, Fc2By123AvPlugin, DetailPagePlugin, DetailWorkspacePlugin, ReviewPlugin, - RelatedPlugin, DetailPageButtonPlugin, HighlightMagnetPlugin, PreviewVideoPlugin, FilterTitleKeywordPlugin, - ActressInfoPlugin, OtherSitePlugin, TranslatePlugin, WantAndWatchedVideosPlugin, - MagnetHubPlugin, ScreenShotPlugin, BlacklistPlugin, FavoriteActressesPlugin, NewVideoPlugin, - TaskPlugin, StatsPlugin, MobileBottomBarPlugin - , OneOneFiveMatchPlugin, UnifiedOfflinePlugin, CompatibilityEnhancementsPlugin -]; +// @ts-check -const DEFAULT_JAVBUS_PLUGINS = [ - ListPagePlugin, ListPageButtonPlugin, SettingPlugin, - HistoryPlugin, AutoPagePlugin, SearchByImagePlugin, BusNavBarPlugin, CoverButtonPlugin, - BusImgPlugin, BusDetailPagePlugin, DetailWorkspacePlugin, DetailPageButtonPlugin, ReviewPlugin, - FilterTitleKeywordPlugin, HighlightMagnetPlugin, BusPreviewVideoPlugin, MagnetHubPlugin, ScreenShotPlugin, - OtherSitePlugin, TranslatePlugin, BlacklistPlugin, TaskPlugin, StatsPlugin, MobileBottomBarPlugin - , OneOneFiveMatchPlugin, UnifiedOfflinePlugin, CompatibilityEnhancementsPlugin -]; +import { defineContribution } from "../contracts/manifests.js"; +import { PORT, REGISTRY, SERVICE } from "../contracts/tokens.js"; +import { LEGACY_PLUGIN_DEPENDENCY_MAP } from "./dependency-map.js"; +import { ActressInfoPlugin } from "./avatar/actress-info.js"; +import { SearchByImagePlugin } from "./avatar/search-by-image.js"; +import { SettingPlugin } from "./backup/setting.js"; +import { BlacklistPlugin } from "./blacklist/blacklist.js"; +import { FilterTitleKeywordPlugin } from "./blacklist/filter-title-keyword.js"; +import { Fc2By123AvPlugin } from "./external-search/fc2-by-123av.js"; +import { Fc2Plugin } from "./external-search/fc2.js"; +import { HitShowPlugin } from "./external-search/hit-show.js"; +import { JavTrailersPlugin } from "./external-search/javtrailers.js"; +import { MagnetHubPlugin } from "./external-search/magnet-hub.js"; +import { OtherSitePlugin } from "./external-search/other-site.js"; +import { RelatedPlugin } from "./external-search/related.js"; +import { ReviewPlugin } from "./external-search/review.js"; +import { Top250Plugin } from "./external-search/top250.js"; +import { FavoriteActressesPlugin } from "./favorite/favorite-actresses.js"; +import { BusImgPlugin } from "./image-viewer/bus-img.js"; +import { BusPreviewVideoPlugin } from "./image-viewer/bus-preview-video.js"; +import { CoverButtonPlugin } from "./image-viewer/cover-button.js"; +import { PreviewVideoPlugin } from "./image-viewer/preview-video.js"; +import { ScreenShotPlugin } from "./image-viewer/screenshot.js"; +import { NewVideoPlugin } from "./new-video/new-video.js"; +import { TaskPlugin } from "./new-video/task.js"; +import { UnifiedOfflinePlugin } from "./offline/unified-offline.js"; +import { OneOneFiveMatchPlugin } from "./one-one-five/plugins.js"; +import { OneTwoThreeOfflinePlugin } from "./one-two-three/offline.js"; +import { StatsPlugin } from "./stats/stats.js"; +import { AutoPagePlugin } from "./status/auto-page.js"; +import { BusDetailPagePlugin } from "./status/bus-detail-page.js"; +import { BusNavBarPlugin } from "./status/bus-nav-bar.js"; +import { CompatibilityEnhancementsPlugin } from "./status/compat-enhancements.js"; +import { DetailPageButtonPlugin } from "./status/detail-page-button.js"; +import { DetailPagePlugin } from "./status/detail-page.js"; +import { DetailWorkspacePlugin } from "./status/detail-workspace.js"; +import { Fc2NavigationPlugin } from "./status/fc2-navigation.js"; +import { FoldCategoryPlugin } from "./status/fold-category.js"; +import { HighlightMagnetPlugin } from "./status/highlight-magnet.js"; +import { HistoryPlugin } from "./status/history.js"; +import { ListPageButtonPlugin } from "./status/list-page-button.js"; +import { ListPagePlugin } from "./status/list-page.js"; +import { MobileBottomBarPlugin } from "./status/mobile-bottom-bar.js"; +import { NavBarPlugin } from "./status/nav-bar.js"; +import { WantAndWatchedVideosPlugin } from "./status/want-and-watched-videos.js"; +import { SubTitleCatPlugin } from "./subtitle/subtitle-cat.js"; +import { TranslatePlugin } from "./translate/translate.js"; -const DEFAULT_SHARED_PLUGIN_RULES = [ - { - shouldRegister: context => context.isJavDB || context.isJavBus || context.is123Pan, - plugins: [ OneTwoThreeOfflinePlugin ] - }, - { - shouldRegister: context => context.isJavTrailers, - plugins: [ JavTrailersPlugin ] - }, - { - shouldRegister: context => context.isSubtitleCat, - plugins: [ SubTitleCatPlugin ] - } -]; +const manifest = (id, featureId, plugin, sites, order, requires = [], options = {}) => defineContribution({ + id, featureId, legacyPluginId: plugin.legacyPluginId ?? plugin.name, plugin, sites, order, requires, + routes: options.routes ?? (featureId === "detail" ? ["detail", "owned-detail"] : []), + surfaces: options.surfaces ?? (featureId === "detail" ? ["detail-page"] : []), +}); -function registerPluginGroup(pluginManager, plugins) { - plugins.forEach((pluginClass => pluginManager.register(pluginClass))); +export const legacyContributionManifests = Object.freeze([ + manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [PORT.host, SERVICE.translation, SERVICE.http, SERVICE.storage, SERVICE.state, SERVICE.settings]), + manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }, [SERVICE.http, SERVICE.settings]), + manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related, SERVICE.state], { routes: ["list", "detail", "owned-detail"], surfaces: ["list-card", "detail-page"] }), + manifest("detail.fc2-navigation", "detail", Fc2NavigationPlugin, ["javdb"], { javdb: 4 }, [PORT.host], { routes: ["list"], surfaces: ["list-card"] }), + manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 5 }, [SERVICE.settings]), + manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), + manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog, SERVICE.movie, SERVICE.settings, SERVICE.state]), + manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [PORT.host, SERVICE.diagnostics, SERVICE.profile, SERVICE.webdav, SERVICE.dialog, SERVICE.storage, SERVICE.settings, SERVICE.cache, SERVICE.http, SERVICE.offline, SERVICE.magnet, SERVICE.movie, SERVICE.state, SERVICE.translation, REGISTRY.settings]), + manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }, [SERVICE.movie]), + manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [PORT.host, SERVICE.movie, SERVICE.settings, SERVICE.cache]), + manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [PORT.host, SERVICE.dialog, SERVICE.account, SERVICE.credential]), + manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog, SERVICE.storage, SERVICE.imageSearch]), + manifest("detail.cover-state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }, [SERVICE.storage, SERVICE.settings, SERVICE.movie, SERVICE.state], { routes: ["list", "detail", "owned-detail"], surfaces: ["list-card", "detail-page"] }), + manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [PORT.host, SERVICE.movie, SERVICE.translation, SERVICE.settings]), + manifest("detail.javdb-native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), + manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }, [PORT.host]), + manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), + manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), + manifest("detail.page-state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state, SERVICE.settings]), + manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [PORT.host, SERVICE.settings]), + manifest("detail.javdb-preview", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }, [SERVICE.storage, SERVICE.settings, SERVICE.movie, SERVICE.dialog]), + manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), + manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo, SERVICE.settings]), + manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie, SERVICE.storage, SERVICE.settings]), + manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), + manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http, SERVICE.state]), + manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }, [SERVICE.storage, SERVICE.http, SERVICE.magnet]), + manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot, SERVICE.settings]), + manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [PORT.host, SERVICE.dialog, SERVICE.storage, SERVICE.http, SERVICE.state]), + manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), + manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog, SERVICE.storage, SERVICE.actressInfo, SERVICE.movie, SERVICE.state]), + manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }, [SERVICE.storage, SERVICE.http, SERVICE.actressInfo, SERVICE.movie, SERVICE.state]), + manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog, SERVICE.movie, SERVICE.state]), + manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [PORT.host, SERVICE.settings, SERVICE.profile]), + manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host, SERVICE.dialog, SERVICE.offline, SERVICE.settings]), + manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [PORT.host, SERVICE.dialog, SERVICE.offline, SERVICE.state, SERVICE.settings]), + manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }, [SERVICE.state]), + manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), + manifest("detail.javbus-images", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), + manifest("detail.javbus-native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), + manifest("detail.javbus-preview", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings, SERVICE.storage, SERVICE.movie]), + manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }, [SERVICE.storage]), + manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), + manifest("external-bridge.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }), +]); + +const contributionIds = new Set(); +const legacyPluginIds = new Set(); +for (const contribution of legacyContributionManifests) { + if (contributionIds.has(contribution.id)) throw new Error(`Duplicate contribution id: ${contribution.id}`); + if (legacyPluginIds.has(contribution.legacyPluginId)) throw new Error(`Duplicate legacy plugin contribution: ${contribution.legacyPluginId}`); + contributionIds.add(contribution.id); + legacyPluginIds.add(contribution.legacyPluginId); } -function registerSitePlugins(pluginManager, locationLike = window.location) { - const context = detectSite(locationLike); - DEFAULT_SHARED_PLUGIN_RULES.forEach((rule => { - rule.shouldRegister(context) && registerPluginGroup(pluginManager, rule.plugins); - })); - context.isJavDB && registerPluginGroup(pluginManager, DEFAULT_JAVDB_PLUGINS); - context.isJavBus && registerPluginGroup(pluginManager, DEFAULT_JAVBUS_PLUGINS); +/** @param {import("../core/plugin-manager.js").PluginManager} pluginManager @param {import("../app/feature-runtime.js").FeatureRuntime} featureRuntime @param {string} site */ +export function registerSitePlugins(pluginManager, featureRuntime, site) { + pluginManager.setDependencyDeclarations(LEGACY_PLUGIN_DEPENDENCY_MAP); + featureRuntime.setContributionManifests?.(legacyContributionManifests); + pluginManager.setCatalogDescriptors(legacyContributionManifests + .filter((item) => item.sites.includes(site)) + .map((item) => ({ name: item.legacyPluginId, disableable: featureRuntime.isFeatureDisableable(item.featureId) }))); + legacyContributionManifests + .filter((item) => item.sites.includes(site) && featureRuntime.isContributionEnabled(item.featureId, item.id, item.legacyPluginId)) + .sort((left, right) => Number(left.order[site]) - Number(right.order[site])) + .forEach((item) => { + const dependencies = featureRuntime.resolveDeclaredDependencies(item.requires); + const runtimeServices = {}; + runtimeServices.scope = () => featureRuntime.getContributionScope(item.featureId, item.id, item.legacyPluginId); + const runtimeNames = new Map([ + [PORT.host, "host"], [SERVICE.diagnostics, "diagnostics"], [SERVICE.review, "review"], + [SERVICE.related, "related"], [SERVICE.movie, "movie"], [SERVICE.magnet, "magnet"], + [SERVICE.settings, "settings"], [SERVICE.cache, "cache"], [SERVICE.http, "http"], + [SERVICE.profile, "profile"], + [SERVICE.actressInfo, "actressInfo"], + [SERVICE.imageSearch, "imageSearch"], + [SERVICE.screenshot, "screenshot"], + [SERVICE.translation, "translation"], + [SERVICE.subtitle, "subtitle"], + [SERVICE.account, "account"], + [SERVICE.webdav, "webdav"], + [SERVICE.credential, "credential"], + [SERVICE.storage, "storage"], + [SERVICE.state, "state"], + [SERVICE.offline, "offline"], + [SERVICE.dialog, "dialog"], + [REGISTRY.settings, "settingsRegistry"], + ]); + for (const token of item.requires) { + const name = runtimeNames.get(token); + if (!name) throw new Error(`Legacy contribution ${item.id} has no runtime name for ${String(token)}`); + runtimeServices[name] = dependencies[token]; + } + pluginManager.register(item.plugin, runtimeServices, { disableable: featureRuntime.isFeatureDisableable(item.featureId) }); + }); } diff --git a/src/plugins/stats/stats.js b/src/plugins/stats/stats.js index 2bb3cd3d..86e6fd74 100644 --- a/src/plugins/stats/stats.js +++ b/src/plugins/stats/stats.js @@ -1,4 +1,19 @@ -class StatsPlugin extends BasePlugin { +// @ts-check + +import { escapeHtml } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; +import { StatsRepository, computeLibraryStats } from "../../features/stats/stats-repository.js"; + +/** @typedef {Record} StatsRecord */ + +export class StatsPlugin extends BasePlugin { + constructor() { + super(...arguments); + /** @type {StatsRepository | null} */ + this.statsRepository = null; + } + getStatsRepository() { return this.statsRepository ||= new StatsRepository({ storage: storageManager, state: this.getRuntimeService("state") }); } getName() { return "StatsPlugin"; } async initCss() { return ` @@ -26,51 +41,61 @@ class StatsPlugin extends BasePlugin { $("#newVideoBtn").after(e), $("#statsBtn").on("click", (() => this.openDialog())); } async openDialog() { - const cars = await storageManager.getCarList(), actresses = await storageManager.getFavoriteActressList(), blacklist = await storageManager.getBlacklist(), activity = await stateService.getActivityLog(), total = cars.length; - const counts = { manualBlocked: 0, favorite: 0, hasDown: 0, hasWatch: 0, pending: 0 }; - cars.forEach((car => { const flags = normalizeStateFlags(car.stateFlags); flags.blocked && counts.manualBlocked++, flags.favorite && counts.favorite++, flags.downloaded && counts.hasDown++, flags.watched && counts.hasWatch++, hasAnyState(flags) || counts.pending++; })); + const diagnostics = this.getRuntimeService("diagnostics").exportSnapshot(); + const { cars, actresses, blacklist, activity } = await this.getStatsRepository().loadLibrarySnapshot(); + const stats = computeLibraryStats(cars), total = stats.total; const actressCounts = new Map; - cars.forEach((car => { - const names = String(car.names || "").split(/[\s,,、]+/).filter(Boolean); + cars.forEach(((/** @type {StatsRecord} */ car) => { + const names = String(car.names || "").replace(/([一-鿿])\s+(?=[一-鿿])/g, "$1、").split(/[,,、]+/).map((name => name.trim())).filter(Boolean); if (car.starId) { const key = `id:${car.starId}`, current = actressCounts.get(key) || { starId: car.starId, name: names[0] || car.starId, count: 0 }; current.count++, actressCounts.set(key, current); } else names.forEach((name => { const key = `name:${name}`, current = actressCounts.get(key) || { starId: "", name, count: 0 }; current.count++, actressCounts.set(key, current); })); })); - const topActresses = [ ...actressCounts.values() ].sort(((left, right) => right.count - left.count || left.name.localeCompare(right.name))).slice(0, 10), topValue = topActresses[0]?.count || 1, javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(); - const pending = counts.pending, counter = this.getBean("NewVideoPlugin"), newVideos = counter ? await counter.getPendingNewVideoTotal() : 0, pageSummary = this.getBean("ListPagePlugin").getCurrentPageSummary(); + const topActresses = [ ...actressCounts.values() ].sort(((left, right) => right.count - left.count || left.name.localeCompare(right.name))).slice(0, 10), topValue = topActresses[0]?.count || 1, javDbUrl = this.getRuntimeService("movie").externalSiteOrigin("javDbBtn", await storageManager.getSetting()); + const pending = stats.pending, counter = this.getOptionalDependency("NewVideoPlugin"), listPage = this.getOptionalDependency("ListPagePlugin"), newVideos = counter ? await counter.getPendingNewVideoTotal() : 0, pageSummary = listPage?.getCurrentPageSummary?.() || { blockedItems: 0 }; const metrics = [ { label: "总记录", value: total, action: null }, - { label: "收藏", value: counts.favorite, action: null }, - { label: "下载", value: counts.hasDown, action: null }, - { label: "已看", value: counts.hasWatch, action: null }, - { label: "手动屏蔽", value: counts.manualBlocked, action: null }, + { label: "收藏", value: stats.favoriteRaw, action: null }, + { label: "下载", value: stats.downloadedRaw, action: null }, + { label: "已看", value: stats.watchedRaw, action: null }, + { label: "手动屏蔽", value: stats.blocked, action: null }, { label: "未鉴定", value: pending, action: null }, { label: "收藏演员", value: actresses.length, action: null }, { label: "黑名单演员", value: blacklist.length, action: null }, { label: "新作品待处理", value: newVideos, action: "new-video" } + , { label: "活跃功能", value: diagnostics.activeFeatures.length, action: null } + , { label: "运行错误", value: diagnostics.errors.length, action: null } + ]; + /** @type {Array<[string, number, number, string]>} 状态分布:只统计未屏蔽记录,分母为未屏蔽总数。 */ + const statusRows = [ + [ "收藏", stats.favoriteEffective, stats.unblocked, "var(--jhs-status-fav)" ], + [ "下载", stats.downloadedEffective, stats.unblocked, "var(--jhs-status-down)" ], + [ "已看", stats.watchedEffective, stats.unblocked, "var(--jhs-status-watch)" ], + [ "手动屏蔽", stats.blocked, stats.total, "var(--jhs-status-filter)" ], + [ "未鉴定", stats.pending, stats.unblocked, "var(--jhs-border-strong)" ], ]; - const statusRows = [ [ "收藏", counts.favorite, "var(--jhs-status-fav)" ], [ "下载", counts.hasDown, "var(--jhs-status-down)" ], [ "已看", counts.hasWatch, "var(--jhs-status-watch)" ], [ "手动屏蔽", counts.manualBlocked, "var(--jhs-status-filter)" ], [ "未鉴定", pending, "var(--jhs-border-strong)" ] ]; - const row = (label, value, max, color, href = "") => `
    ${href ? `${escapeHtml(label)}` : `${escapeHtml(label)}`}${value}${max === total && total ? ` (${Math.round(value / total * 100)}%)` : ""}
    `; - const trend = days => { const cutoff = Date.now() - days * 864e5, result = { identified: 0, downloaded: 0, watched: 0 }; activity.entries.filter((entry => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach((entry => entry.changes.filter((change => "reverted" !== change.undoState)).forEach((change => { const before = normalizeStateFlags(change.before?.stateFlags), after = normalizeStateFlags(change.after?.stateFlags); !hasAnyState(before) && hasAnyState(after) && result.identified++, !before.downloaded && after.downloaded && result.downloaded++, !before.watched && after.watched && result.watched++; })))); return result; }, trend7 = trend(7), trend30 = trend(30); + const row = (/** @type {string} */ label, /** @type {number} */ value, /** @type {number} */ max, /** @type {string} */ color, /** @type {string} */ href = "", /** @type {boolean} */ showPercent = false) => `
    ${href ? `${escapeHtml(label)}` : `${escapeHtml(label)}`}${value}${showPercent && max ? ` (${Math.round(value / max * 100)}%)` : ""}
    `; + const trend = (/** @type {number} */ days) => { const cutoff = Date.now() - days * 864e5, result = { identified: 0, downloaded: 0, watched: 0 }; activity.entries.filter(((/** @type {StatsRecord} */ entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((/** @type {StatsRecord} */ entry) => entry.changes.filter(((/** @type {StatsRecord} */ change) => "reverted" !== change.undoState)).forEach(((/** @type {StatsRecord} */ change) => { const before = normalizeStateFlags(change.before?.stateFlags), after = normalizeStateFlags(change.after?.stateFlags); !hasAnyState(before) && hasAnyState(after) && result.identified++, !before.downloaded && after.downloaded && result.downloaded++, !before.watched && after.watched && result.watched++; })))); return result; }, trend7 = trend(7), trend30 = trend(30); const coverageNote = activity.coverageStart ? `活动记录仅覆盖自 ${escapeHtml(activity.coverageStart)} 起` : "仅统计 6.4.0 及之后产生的操作记录"; - const renderMetric = metric => metric.action + const renderMetric = (/** @type {StatsRecord} */ metric) => metric.action ? `` : `
    ${metric.value}${metric.label}
    `; const dialogHtml = `

    全库概览

    ${metrics.map(renderMetric).join("")}

    当前页面

    ${renderMetric({ label: "屏蔽项", value: pageSummary.blockedItems, action: "filter", filter: "blockedItems" })}
    -

    状态分布

    ${statusRows.map((item => row(item[0], item[1], total, item[2]))).join("")}
    +

    状态分布

    ${statusRows.map((item => row(item[0], item[1], item[2], item[3], "", true))).join("")}

    活动趋势

    ${coverageNote}

    ${trend7.identified}近 7 天新增鉴定
    ${trend7.downloaded}近 7 天标记下载
    ${trend7.watched}近 7 天标记观看
    ${trend30.identified}近 30 天新增鉴定
    ${trend30.downloaded}近 30 天标记下载
    ${trend30.watched}近 30 天标记观看
    ${topActresses.length ? `

    Top 10 演员

    ${topActresses.map((item => row(item.name, item.count, topValue, "var(--jhs-accent)", new URL(item.starId ? `/actors/${encodeURIComponent(item.starId)}` : `/search?q=${encodeURIComponent(item.name)}`, javDbUrl).href))).join("")}
    ` : ""}
    `; - layer.open({ type: 1, title: "统计", content: dialogHtml, scrollbar: !1, area: utils.getDialogArea("lg"), anim: -1, success: (layerElement, layerIndex) => { - $(layerElement).find(".jhs-stats__bar").each((function() { $(this).css({ "--jhs-value": `${$(this).data("width")}%`, "--jhs-bar": $(this).data("color") }); })); - $(layerElement).find("button.jhs-stats__metric[data-action]").on("click", (event => { + const dialog = this.getRuntimeService("dialog"); + dialog.open({ type: 1, title: "统计", content: dialogHtml, scrollbar: !1, ui: { size: "lg", body: "scroll" }, area: utils.getDialogArea("lg"), anim: -1, success: (/** @type {Element} */ layerElement, /** @type {number} */ layerIndex) => { + $(layerElement).find(".jhs-stats__bar").each(((/** @type {number} */ _index, /** @type {Element} */ element) => { $(element).css({ "--jhs-value": `${$(element).data("width")}%`, "--jhs-bar": $(element).data("color") }); })); + $(layerElement).find("button.jhs-stats__metric[data-action]").on("click", ((/** @type {MouseEvent} */ event) => { const metric = $(event.currentTarget), action = metric.data("action"); - layer.close(layerIndex); - if ("new-video" === action) return this.getBean("NewVideoPlugin").openDialog(); - if ("filter" === action) this.getBean("ListPagePlugin").setQuickFilter(metric.data("filter")); + dialog.close(layerIndex); + if ("new-video" === action) return counter?.openDialog?.(); + if ("filter" === action) listPage?.setQuickFilter?.(metric.data("filter")); })); utils.setupEscClose(layerIndex); } }); diff --git a/src/plugins/status/auto-page.js b/src/plugins/status/auto-page.js index c48ec2ef..7dd250ec 100644 --- a/src/plugins/status/auto-page.js +++ b/src/plugins/status/auto-page.js @@ -1,7 +1,28 @@ -class AutoPagePlugin extends BasePlugin { +// @ts-check + +import { C, _, l, o, r } from "../../core/constants.js"; +import { requestHostPage } from "../../core/host-page-request.js"; +import { LifecycleScope } from "../../core/lifecycle-scope.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class AutoPagePlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "preloadDistance", 500), i(this, "currentPage", this.getInitialPageNumber()), - i(this, "pageItems", []); + super(...arguments); + this.preloadDistance = 500; + this.currentPage = this.getInitialPageNumber(); + /** @type {Array<{ page: number, top: number, url: string }>} */ + this.pageItems = []; + /** @type {boolean} */ this.started = false; + /** @type {import("../../core/lifecycle-scope.js").LifecycleScope | null} */ this.liveScope = null; + /** @type {number} */ this.generation = 0; + /** @type {HTMLElement | undefined} */ + this.container = void 0; + /** @type {HTMLDivElement | undefined} */ + this.loader = void 0; + /** @type {string | null} */ + this.nextUrl = null; + this.hasMore = false; + this.isLoading = false; } getName() { return "AutoPagePlugin"; @@ -10,7 +31,45 @@ class AutoPagePlugin extends BasePlugin { return "\n \n "; } async handle() { - await this.waterfall(); + const settings = this.getRuntimeService("settings"), scope = await this.getRuntimeService("scope")(); + const onSettingsChanged = (/** @type {any} */ event) => { + const names = /** @type {string[] | undefined} */ (event.detail?.names); + if (!names?.includes("autoPage")) return; + this.reconfigure(); + }; + settings.addEventListener("settings.changed", onSettingsChanged); + scope.addCleanup((() => settings.removeEventListener("settings.changed", onSettingsChanged))); + scope.addCleanup((() => this.stop())); + this.reconfigure(); + } + /** 总开关 live 生命周期:OFF→stop,ON→start(不刷新页面)。 */ + reconfigure() { + const enabled = this.getRuntimeService("settings").snapshot().autoPage !== "no"; + if (enabled) return this.start(); + return this.stop(); + } + start() { + if (this.started) return this.waterfallPromise || (this.waterfallPromise = this.waterfall().finally((() => { this.waterfallPromise = null; }))); + this.started = true; + this.liveScope?.dispose(); + this.liveScope = new LifecycleScope("autopage:live"); + this.generation++; + this.waterfallPromise = this.waterfall().finally((() => { this.waterfallPromise = null; })); + return this.waterfallPromise; + } + /** 真正 stop:释放 live scope(scroll listener/定时器/请求全部随之 dispose),并使在途请求作废。 */ + stop() { + this.started = false; + this.generation++; + this.liveScope?.dispose(); + this.liveScope = null; + this.nextUrl = null; + this.hasMore = false; + this.isLoading = false; + this.loader?.remove(); + this.loader = undefined; + this.container = undefined; + this.pageItems = []; } getInitialPageNumber() { if (l) { @@ -24,38 +83,50 @@ class AutoPagePlugin extends BasePlugin { return 1; } async waterfall() { + if (!this.started || !this.liveScope) return; if (await this.shouldDisablePaging()) return; + if (!this.started || !this.liveScope || this.liveScope.disposed) return; + const scope = this.liveScope; const e = this.getSelector(); - if (this.container = document.querySelector(e.boxSelector), !this.container) return void clog.error("没有找到容器节点,停止瀑布流!"); - this.loader = document.createElement("div"), this.loader.className = "jhs-scroll", - this.container.parentNode.insertBefore(this.loader, this.container.nextSibling), + const container = /** @type {HTMLElement | null} */ (document.querySelector(e.boxSelector)); + if (!container || !container.parentNode) return void clog.error("没有找到容器节点,停止瀑布流!"); + this.container = container; + const loader = document.createElement("div"); + this.loader = loader, loader.className = "jhs-scroll", + container.parentNode.insertBefore(loader, container.nextSibling), this.pageItems.push({ page: this.currentPage, top: 0, url: window.location.href - }), this.loader.addEventListener("click", (() => { - this.loader.classList.contains("waterfall-error") && void this.loadNextPage().catch((error => clog.error("瀑布流重试失败", error))); + }), loader.addEventListener("click", (() => { + loader.classList.contains("waterfall-error") && void this.loadNextPage().catch((error => clog.error("瀑布流重试失败", error))); })), (() => { let t = !1; - window.addEventListener("scroll", (() => { + scope.listen(window, "scroll", (() => { t || (t = !0, requestAnimationFrame((() => { this.checkLoad(), this.checkScrollPosition(), t = !1; }))); })); })(); - const t = document.querySelector(e.nextPageSelector); - this.nextUrl = null == t ? void 0 : t.href, this.hasMore = !!this.nextUrl, setTimeout((() => { + const t = /** @type {HTMLAnchorElement | null} */ (document.querySelector(e.nextPageSelector)); + this.nextUrl = t?.href ?? null, this.hasMore = !!this.nextUrl, scope.ownTimeout(setTimeout((() => { this.checkLoad(); - }), 1e3), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); + }), 1e3)), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); } async loadNextPage() { var e; - if (await storageManager.getSetting("autoPage", _) === C) return void this.setState("waterfall-loading", ""); - if (this.isLoading || !this.nextUrl) return; + if (!this.started) return; + if (this.getRuntimeService("settings").snapshot().autoPage === "no") return void this.setState("waterfall-loading", ""); + if (this.isLoading || !this.nextUrl || !this.container) return; + // 列表功能被禁用属永久性失败:在发起网络请求前终止并清空翻页游标,避免滚动每帧重抓 + const listPage = this.getOptionalDependency("ListPagePlugin"); + if (!listPage) return this.nextUrl = null, this.hasMore = !1, void this.setState("waterfall-error", "列表功能已禁用,无法继续翻页"); this.isLoading = !0, this.setState("waterfall-loading", "加载中..."); - const t = this.getSelector(); + const t = this.getSelector(), generation = this.generation, scope = this.liveScope; try { - const i = await gmHttp.get(this.nextUrl); + if (!scope || scope.disposed || generation !== this.generation) return; + const i = await requestHostPage(this.getRuntimeService("http"), this.nextUrl, scope); + if (!this.started || !this.liveScope || this.liveScope.disposed || generation !== this.generation) return; clog.log("请求下一页内容:", this.nextUrl); const s = utils.htmlTo$dom(i); l && s.find(".avatar-box").length > 0 && s.find(".avatar-box").parent().remove(); @@ -63,20 +134,20 @@ class AutoPagePlugin extends BasePlugin { const d = this.getBoxCarInfoList(), h = this.getBoxCarInfoList(c); if (this.checkDuplicateCarNumbers(d, h)) return this.nextUrl = null, this.hasMore = !1, void this.setState("waterfall-error", "翻页内容出现重复数据, 页码受JavDB限制, 已停止瀑布流"); + if (!this.started || !this.liveScope || this.liveScope.disposed || generation !== this.generation) return; const g = this.container.scrollHeight; this.pageItems.push({ page: this.currentPage + 1, top: g, url: this.nextUrl }); - const p = this.getBean("ListPagePlugin"); let m = s.find(this.getSelector().coverImgSelector); - p.replaceHdImg(m), $(this.getSelector().boxSelector).append(c), this.nextUrl = null == (e = s.find(t.nextPageSelector)) ? void 0 : e.attr("href"), + listPage.replaceHdImg(m), $(this.getSelector().boxSelector).append(c), this.nextUrl = null == (e = s.find(t.nextPageSelector)) ? void 0 : e.attr("href"), this.hasMore = !!this.nextUrl; let u = s.find(".pagination"); $(".pagination").replaceWith(u), this.setState("waterfall-loading", ""), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); } catch (n) { - clog.error("加载失败:", n), this.setState("waterfall-error", "加载失败,点击重试"); + this.started && this.loader && this.setState("waterfall-error", "加载失败,点击重试"), clog.error("加载失败:", n); } finally { this.isLoading = !1; } @@ -93,17 +164,20 @@ class AutoPagePlugin extends BasePlugin { } checkLoad() { if (!this.loader) return; + // 错误态只允许点击重试,滚动不得绕过重试门槛形成请求风暴 + if (this.loader.classList.contains("waterfall-error")) return; this.loader.getBoundingClientRect().top < window.innerHeight + this.preloadDistance && void this.loadNextPage().catch((error => clog.error("瀑布流自动加载失败", error))); } async shouldDisablePaging() { if (!window.isListPage) return !0; - const enabled = await storageManager.getSetting("autoPage", _); - return enabled !== _ || [ "search?q", "handlePlayback=1", "handleTop=1", "/want_watch_videos", "/watched_videos", "/advanced_search?type=100" ].some((e => o.includes(e))); + const enabled = this.getRuntimeService("settings").snapshot().autoPage; + return enabled === "no" || [ "search?q", "handlePlayback=1", "handleTop=1", "/want_watch_videos", "/watched_videos", "/advanced_search?type=100" ].some((e => o.includes(e))); } - updatePageUrl(e) { + updatePageUrl(/** @type {string} */ e) { window.history.replaceState({}, "", e), l && (document.title = document.title.replace(/第\d+頁/, `第${this.currentPage}頁`)); } - setState(e, t) { + setState(/** @type {string} */ e, /** @type {string} */ t) { + if (!this.loader) return; this.loader.className = `jhs-scroll ${e}`, this.loader.textContent = t; } } diff --git a/src/plugins/status/bus-detail-page.js b/src/plugins/status/bus-detail-page.js index 88d2514a..20fa375c 100644 --- a/src/plugins/status/bus-detail-page.js +++ b/src/plugins/status/bus-detail-page.js @@ -1,4 +1,8 @@ -class BusDetailPagePlugin extends BasePlugin { +// @ts-check + +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class BusDetailPagePlugin extends BasePlugin { getName() { return "BusDetailPagePlugin"; } @@ -14,9 +18,10 @@ class BusDetailPagePlugin extends BasePlugin { t.css("position", "initial"), t.insertBefore(t.parent()); } } - $(".genre a").each((function() { - const e = $(this).attr("href"); - e && (e.startsWith("http://") || e.startsWith("https://") || e.startsWith("/")) && $(this).attr("target", "_blank"); + $(".genre a").each(((/** @type {number} */ _index, /** @type {HTMLAnchorElement} */ element) => { + const node = $(element), e = node.attr("href"); + if (!e) return; + try { ["http:", "https:"].includes(new URL(e, window.location.href).protocol) && node.attr("target", "_blank"); } catch {} })), this.addCopyCarNumBtn(); } addCopyCarNumBtn() { @@ -37,7 +42,7 @@ class BusDetailPagePlugin extends BasePlugin { this.textContent = "复制"; }), 1500); })(); - })), t.parentNode.insertBefore(n, t.nextSibling); + })), t.parentNode?.insertBefore(n, t.nextSibling); } } } diff --git a/src/plugins/status/bus-nav-bar.js b/src/plugins/status/bus-nav-bar.js index 5e4bf628..787edfcd 100644 --- a/src/plugins/status/bus-nav-bar.js +++ b/src/plugins/status/bus-nav-bar.js @@ -1,11 +1,16 @@ -class BusNavBarPlugin extends BasePlugin { +// @ts-check + +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class BusNavBarPlugin extends BasePlugin { getName() { return "BusNavBarPlugin"; } handle() { + if (!this.getOptionalDependency("SearchByImagePlugin")) return; $("#navbar > div > div > span").append('\n \n '), $("#search-img-btn").on("click", (() => { - this.getBean("SearchByImagePlugin").open(); + this.getOptionalDependency("SearchByImagePlugin")?.open?.(); })); } } diff --git a/src/plugins/status/compat-enhancements.js b/src/plugins/status/compat-enhancements.js index 96c04630..221408b2 100644 --- a/src/plugins/status/compat-enhancements.js +++ b/src/plugins/status/compat-enhancements.js @@ -1,42 +1,56 @@ -class CompatibilityEnhancementsPlugin extends BasePlugin { +// @ts-check + +import { siteContext, escapeHtml } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; + +/** @typedef {any} JQueryHandle Legacy jQuery runtime handle. */ +/** @typedef {{ preventDefault: () => void, currentTarget: EventTarget }} JQueryClickEvent */ + +export class CompatibilityEnhancementsPlugin extends BasePlugin { getName() { return "CompatibilityEnhancementsPlugin"; } + getStartupMode() { return "idle"; } async initCss() { if (!siteContext.isJavDB) return ""; return ``; } + bindImmediateEvents() { + $(document).off("actress-state-changed.jhsActress").on("actress-state-changed.jhsActress", (async () => { $(".jhs-actress-state-container").remove(); await this.decorateActresses(); })); + } async handle() { await this.decorateActresses(); - $(document).off("actress-state-changed.jhsActress").on("actress-state-changed.jhsActress", (async () => { $(".jhs-actress-state-container").remove(); await this.decorateActresses(); })); if (isDetailPage) await this.addRemoveRecord(); this.linkCommentImages(); } async addRemoveRecord() { const carNum = this.getPageInfo().carNum; if (!carNum) return; - if (!(await storageManager.getCarList()).some((item => item.carNum === carNum))) return; + if (!(await storageManager.getCarList()).some(((/** @type {{ carNum?: string }} */ item) => item.carNum === carNum))) return; const button = $(''); $(".jhs-detail-btn-row,.movie-info-container,.container .info").first().append(button); - button.on("click", (event => utils.q(event, `确定移除 ${carNum} 的鉴定记录?`, (async () => { await stateService.remove(carNum); button.remove(); this.getBean("ListPagePlugin")?.showCarNumBox?.(carNum); show.ok("鉴定记录已移除"); })))); + button.on("click", ((/** @type {Event} */ event) => utils.q(event, `确定移除 ${escapeHtml(carNum)} 的鉴定记录?`, (async () => { await this.getRuntimeService("state").remove(carNum); button.remove(); this.getOptionalDependency("ListPagePlugin")?.showCarNumBox?.(carNum); show.ok("鉴定记录已移除"); })))); } async decorateActresses() { - const favorites = new Set((await storageManager.getFavoriteActressList()).map((item => String(item.starId)))); - const blacklist = new Set((await storageManager.getBlacklist()).map((item => String(item.starId || item.id)))); + const favorites = new Set((await storageManager.getFavoriteActressList()).map(((/** @type {{ starId?: string }} */ item) => String(item.starId)))); + const blacklist = new Set((await storageManager.getBlacklist()).map(((/** @type {{ starId?: string, id?: string }} */ item) => String(item.starId || item.id)))); this.decorateCurrentActressProfile(favorites, blacklist); this.decorateActressCards(favorites, blacklist); } + /** @param {Set} favorites @param {Set} blacklist */ decorateCurrentActressProfile(favorites, blacklist) { const match = window.location.pathname.match(/^\/(?:actors|star)\/([^/?#]+)\/?$/); if (!match) return; const host = $(".actor-section-name,.star-name,h1.title").first(); if (!host.length) return; this.renderActressState(host, decodeURIComponent(match[1]), favorites, blacklist, "jhs-actress-profile-state"); } + /** @param {Set} favorites @param {Set} blacklist */ decorateActressCards(favorites, blacklist) { - $(".actor-box a[href], .actress-card a[href], [data-actress-card] a[href]").each(((index, element) => { + $(".actor-box a[href], .actress-card a[href], [data-actress-card] a[href]").each(((/** @type {number} */ index, /** @type {Element} */ element) => { const identity = getActressIdentityFromLink(element); if (!identity) return; const card = $(element).closest(".actor-box,.actress-card,[data-actress-card]"); this.renderActressState(card, identity.starId, favorites, blacklist, "jhs-actress-card-state"); })); } + /** @param {JQueryHandle} host @param {string} starId @param {Set} favorites @param {Set} blacklist @param {string} className */ renderActressState(host, starId, favorites, blacklist, className) { if (host.children(".jhs-actress-state-container").length) return; const container = $(``); @@ -46,18 +60,22 @@ class CompatibilityEnhancementsPlugin extends BasePlugin { } linkCommentImages() { const images = $(".preview-images img,#sample-waterfall img,.movie-gallery img"); if (!images.length) return; - $(".review-content").each(((index, element) => this.linkCommentImageTextNodes(element, images.length))); - $(document).off("click.jhsCommentImage", ".jhs-comment-image-link").on("click.jhsCommentImage", ".jhs-comment-image-link", (event => { event.preventDefault(); const image = images.eq(Number($(event.currentTarget).data("image-index"))); image.length && showImageViewer(image[0]); })); + $(".review-content").each(((/** @type {number} */ index, /** @type {Element} */ element) => this.linkCommentImageTextNodes(element, images.length))); + $(document).off("click.jhsCommentImage", ".jhs-comment-image-link").on("click.jhsCommentImage", ".jhs-comment-image-link", ((/** @type {JQueryClickEvent} */ event) => { event.preventDefault(); const image = images.eq(Number($(event.currentTarget).data("image-index"))); image.length && (/** @type {any} */ (globalThis)).showImageViewer(image[0]); })); } + /** @param {Element} element @param {number} imageCount */ linkCommentImageTextNodes(element, imageCount) { - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT), nodes = []; - while (walker.nextNode()) if (!$(walker.currentNode.parentElement).closest("a,button,code,pre,textarea,.jhs-comment-image-link").length && /(?:图|圖片|图片)\s*[一二三四五六七八九十\d]+/i.test(walker.currentNode.nodeValue || "")) nodes.push(walker.currentNode); + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT), nodes = /** @type {Text[]} */ ([]); + while (walker.nextNode()) { + const textNode = /** @type {Text} */ (walker.currentNode); + if (!$(textNode.parentElement).closest("a,button,code,pre,textarea,.jhs-comment-image-link").length && /(?:图|圖片|图片)\s*[一二三四五六七八九十\d]+/i.test(textNode.nodeValue || "")) nodes.push(textNode); + } nodes.forEach((textNode => { const text = textNode.nodeValue || "", pattern = /(?:图|圖片|图片)\s*([一二三四五六七八九十\d]+)/gi, fragment = document.createDocumentFragment(); let cursor = 0, match; while ((match = pattern.exec(text))) { match.index > cursor && fragment.append(document.createTextNode(text.slice(cursor, match.index))); - const chinese = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 }, index = (chinese[match[1]] || Number(match[1])) - 1; + const chinese = /** @type {Record} */ ({ 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 }), index = (chinese[match[1]] || Number(match[1])) - 1; if (index >= 0 && index < imageCount) { const link = document.createElement("a"); link.href = "#"; link.className = "jhs-comment-image-link"; link.dataset.imageIndex = String(index); link.textContent = match[0]; fragment.append(link); } else fragment.append(document.createTextNode(match[0])); cursor = match.index + match[0].length; } @@ -67,6 +85,7 @@ class CompatibilityEnhancementsPlugin extends BasePlugin { } } +/** @param {Element} element */ function getActressIdentityFromLink(element) { const link = $(element); if (link.closest('.toolbar,.tabs,.buttons,.pagination,.filter,.filters,nav,header,[role="tablist"]').length) return null; diff --git a/src/plugins/status/detail-page-button.js b/src/plugins/status/detail-page-button.js index 15765fb3..4a1dd8ea 100644 --- a/src/plugins/status/detail-page-button.js +++ b/src/plugins/status/detail-page-button.js @@ -1,12 +1,36 @@ -class DetailPageButtonPlugin extends BasePlugin { +// @ts-check + +import { createStateActions } from "../../ui/detail/state-actions.js"; + +import { C, _, escapeHtml, k, l, m, normalizeCarNum, r, v, y } from "../../core/constants.js"; +import { DetailStateController } from "../../core/detail-state-controller.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { createJhsTable } from "../../ui/table/create-jhs-table.js"; +import { createLatestSettingWriter } from "../../ui/settings/setting-binding-controller.js"; + +/** @typedef {MouseEvent} ActionEvent */ +/** @typedef {{ url?: string, extension?: string, [key: string]: any }} SubtitleRecord */ + +export class DetailPageButtonPlugin extends BasePlugin { getName() { return "DetailPageButtonPlugin"; } constructor() { - super(), this.answerCount = 1, this.stateBinding = null; + super(), this.answerCount = 1; + /** @type {any} */ this.stateBinding = null; + /** @type {DetailStateController | null} */ this.detailStateController = null; + } + getDetailStateController() { + return this.detailStateController ||= new DetailStateController(this.getRuntimeService("state")); } async handle() { - this.hideVideoControls(), window.isDetailPage && (await this.createMenuBtn(), await this.autoRemoveNewVideoMark()); + const scope = await this.getRuntimeService("scope")(); + this.hideVideoControls(scope), window.isDetailPage && (await this.createMenuBtn(), await this.autoRemoveNewVideoMark()); + if (window.isDetailPage) scope.addCleanup(jhsEventBus?.on("car-state-changed", payload => { + const carNum = this.getPageInfo().carNum; + if (payload.carNums?.includes(carNum)) return this.showStatus(carNum); + }) || (() => {})); } async autoRemoveNewVideoMark() { try { @@ -14,15 +38,18 @@ class DetailPageButtonPlugin extends BasePlugin { if (e !== _) return; const t = this.getPageInfo(); if (!t.carNum) return; - await stateService.removeFromNewVideoList([ t.carNum ], "browse"); + await this.getRuntimeService("state").removeFromNewVideoList([ t.carNum ], "browse"); } catch (e) { clog.error("自动移除新作品标记失败:", e); } } async createMenuBtn() { - const e = this.getPageInfo(), t = e.carNum, n = `\n
    \n
    \n \n \n \n \n
    \n \n
    \n \n \n \n \n
    \n
    \n `; - const workspaceSlot = this.getBean("DetailWorkspacePlugin")?.getSlot("summary-actions"); + const e = this.getPageInfo(), t = e.carNum, n = $(`\n
    \n
    \n
    \n \n \n \n \n
    \n
    \n `); + n.find("[data-jhs-state-slot]").replaceWith(createStateActions()); + const workspaceSlot = this.getOptionalDependency("DetailWorkspacePlugin")?.getSlot?.("summary-actions"); workspaceSlot?.length ? workspaceSlot.append(n) : r ? $(".tabs").after(n) : l && $("#mag-submit-show").before(n), $("#magnetSearchBtn").on("click", (async () => { - let t = await this.getBean("MagnetHubPlugin").createMagnetHub(e.carNum); - layer.open({ + const magnetHub = this.getOptionalDependency("MagnetHubPlugin"); + if (!magnetHub) return void show.info("磁力搜索功能已禁用"); + let t = await magnetHub.createMagnetHub(e.carNum); + this.getRuntimeService("dialog").open({ type: 1, title: "磁力搜索 " + e.carNum, content: '
    ', @@ -33,22 +60,34 @@ class DetailPageButtonPlugin extends BasePlugin { } }); })); - const a = this.getBean("HighlightMagnetPlugin"), i = await storageManager.getSetting("enableMagnetsFilter", _); - $("#magnets-span").text(i === _ ? "关闭磁力过滤" : "开启磁力过滤"), i === _ && a.doFilterMagnet(), - $("#enable-magnets-filter").on("click", (e => { - let t = $("#magnets-span"); - "关闭磁力过滤" === t.text() ? (a.showAll(), t.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a.doFilterMagnet(), - t.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); - })), $("#search-subtitle-btn").on("click", (e => utils.openPage(`https://subtitlecat.com/index.php?search=${t}`, t, !1, e))), + const a = this.getOptionalDependency("HighlightMagnetPlugin"), settings = this.getRuntimeService("settings"), i = settings.snapshot().enableMagnetsFilter ?? _; + a || $("#enable-magnets-filter").remove(), $("#magnets-span").text(i === _ ? "关闭磁力过滤" : "开启磁力过滤"), i === _ && a?.doFilterMagnet?.(); + const writeMagnetFilter = createLatestSettingWriter({ settings, key: "enableMagnetsFilter", fallback: C, apply: (value) => { + const filtering = value === _; + const label = $("#magnets-span"); + if (filtering) { a?.doFilterMagnet?.(); label.text("关闭磁力过滤"); } + else { a?.showAll?.(); label.text("开启磁力过滤"); } + }, onError: (error) => { + clog.error("磁力过滤设置保存失败,已恢复", error), show.error("磁力过滤设置保存失败,已恢复原设置"); + } }); + $("#enable-magnets-filter").on("click", (async (/** @type {ActionEvent} */ e) => { + if (!a) return; + const wasFiltering = "关闭磁力过滤" === $("#magnets-span").text(); + await writeMagnetFilter(!wasFiltering ? _ : C); + })), $("#search-subtitle-btn").on("click", ((/** @type {ActionEvent} */ e) => { + const target = this.getRuntimeService("movie").sourceUrls({ carNum: t }, ["subtitlecat"])[0]?.url; + if (target) utils.openPage(target, t, !1, e); + })), $("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(t))); if (!t) { $("#filterBtn, #favoriteBtn, #hasDownBtn, #hasWatchBtn, #magnetSearchBtn, #xunLeiSubtitleBtn, #search-subtitle-btn").prop("disabled", !0).attr("title", "番号不可用"); return void clog.warn("详情操作不可用:番号不可用"); } - this.stateBinding = detailStateController.bind({ root: document, carNum: t, activityType: "detail-state", getRecord: () => this.getStateRecord() }); + this.stateBinding = this.getDetailStateController().bind({ root: document, carNum: t, activityType: "detail-state", getRecord: () => this.getStateRecord() }); } + /** @param {string} e */ async showStatus(e) { - return detailStateController.render({ root: document, carNum: e }); + return this.getDetailStateController().render({ root: document, carNum: e }); } getStateRecord() { const info = this.getPageInfo(); @@ -59,28 +98,34 @@ class DetailPageButtonPlugin extends BasePlugin { const info = this.getPageInfo(); return this.stateBinding = { root: document, layerIndex: null, carNum: normalizeCarNum(info.carNum), getRecord: () => this.getStateRecord(), activityType: "detail-state", selectors: {} }; } + /** @param {ActionEvent} event */ async favoriteOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "favorite", event); + return this.getDetailStateController().requestToggle(this.getStateBinding(), "favorite", event); } + /** @param {ActionEvent} event */ async hasDownOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "downloaded", event); + return this.getDetailStateController().requestToggle(this.getStateBinding(), "downloaded", event); } + /** @param {ActionEvent} event */ async hasWatchOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "watched", event); + return this.getDetailStateController().requestToggle(this.getStateBinding(), "watched", event); } - searchXunLeiSubtitle(e) { + /** @param {string} e */ + async searchXunLeiSubtitle(e) { + const dialog = this.getRuntimeService("dialog"), subtitle = this.getRuntimeService("subtitle"), scope = await this.getRuntimeService("scope")(); let t = loading(); - gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${e}`).then((t => { - let n = t.data; - n && 0 !== n.length ? layer.open({ + try { + const n = await subtitle.search("xunlei", { carNum: e }, { scope }); + n && 0 !== n.length ? dialog.open({ type: 1, title: "迅雷字幕", content: '\n
    \n
    \n
    \n ', scrollbar: !1, area: utils.getResponsiveArea([ "60%", "70%" ]), anim: -1, - success: (t, a) => { - new Tabulator("#xunlei-table-container", { + success: (/** @type {unknown} */ t, /** @type {number} */ a) => { + createJhsTable((/** @type {any} */ (globalThis)).Tabulator, "#xunlei-table-container", { + pagination: !1, layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, @@ -98,89 +143,79 @@ class DetailPageButtonPlugin extends BasePlugin { responsive: 0 }, { title: "类型", - field: "ext", + field: "extension", headerSort: !1, responsive: 0 }, { title: "操作", responsive: 0, headerSort: !1, - formatter: (t, n, a) => { + formatter: (/** @type {any} */ t, /** @type {any} */ n, /** @type {(callback: () => void) => void} */ a) => { const i = t.getData(); return a((() => { const n = t.getElement().querySelector(".subtitle-preview-btn"), a = t.getElement().querySelector(".subtitle-download-btn"); - n && n.addEventListener("click", (async t => { - let n = i.url, a = e + "." + i.ext; - this.previewSubtitle(n, a); - })), a && a.addEventListener("click", (async t => { - let n = i.url, a = e + "." + i.ext, s = await gmHttp.get(n); + n && n.addEventListener("click", (async (/** @type {Event} */ t) => { + const a = e + "." + i.extension; + this.previewSubtitle(i, a); + })), a && a.addEventListener("click", (async (/** @type {Event} */ t) => { + const a = e + "." + i.extension, s = await subtitle.download("xunlei", i, { scope }); utils.download(s, a); })); })), '\n \n \n '; } - } ], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "所有", - page_size: "每页行数" - } - } - } + } ] }), utils.setupEscClose(a); } }) : show.error("迅雷中找不到相关字幕!"); - })).catch((e => { + } catch (e) { clog.error(e), show.error(e); - })).finally((() => { + } finally { t.close(); - })); + } } + /** @param {ActionEvent | null} e @param {unknown} t */ async filterOne(e, t) { e && e.preventDefault(); - return detailStateController.requestToggle(this.getStateBinding(), "blocked", e); + return this.getDetailStateController().requestToggle(this.getStateBinding(), "blocked", e); } - hideVideoControls() { - $(document).on("mouseenter", "#preview-video", (function() { - $(this).prop("controls", !0); + /** @param {import("../../core/lifecycle-scope.js").LifecycleScope} scope */ + hideVideoControls(scope) { + const documentRoot = $(document); + documentRoot.off("mouseenter.jhsDetailVideo").on("mouseenter.jhsDetailVideo", "#preview-video", ((/** @type {Event} */ event) => { + $(event.currentTarget).prop("controls", !0); })); + scope.addCleanup((() => documentRoot.off("mouseenter.jhsDetailVideo"))); } - async previewSubtitle(e, t) { - if (!e) return void clog.error("未提供文件URL"); - const n = e.split(".").pop().toLowerCase(); + /** @param {SubtitleRecord} subtitle @param {string} t */ + async previewSubtitle(subtitle, t) { + if (!subtitle?.url) return void clog.error("未提供文件URL"); + const n = String(subtitle.extension || "").toLowerCase(); if ("ass" === n || "srt" === n) try { - let a = await gmHttp.get(e), i = "字幕预览"; + const dialog = this.getRuntimeService("dialog"); + const scope = await this.getRuntimeService("scope")(); + let a = await this.getRuntimeService("subtitle").download("xunlei", subtitle, { scope }), i = "字幕预览"; "ass" === n ? i = "ASS字幕预览 - " + t : "srt" === n && (i = "SRT字幕预览 - " + t); const s = a.split("\n"); let o = ""; const r = String(s.length).length; - s.forEach(((e, t) => { + s.forEach(((/** @type {string} */ e, /** @type {number} */ t) => { const n = String(t + 1).padStart(r, " "); - o += `${n}. ${e}\n`; + o += `${n}. ${escapeHtml(e)}\n`; })); const l = o; - layer.open({ + dialog.open({ type: 1, title: i, area: utils.getResponsiveArea([ "80%", "80%" ]), scrollbar: !1, content: `
    ${l}
    `, btn: [ "下载", "关闭" ], - btn1: function(e, n, i) { + btn1: function(/** @type {number} */ e, /** @type {unknown} */ n, /** @type {unknown} */ i) { return utils.download(a, t), !1; } }); } catch (a) { - show.error(`预览失败: ${a.message}`), clog.error("预览字幕文件出错:", a); + show.error(`预览失败: ${a instanceof Error ? a.message : String(a)}`), clog.error("预览字幕文件出错:", a); } else show.error("仅支持预览ASS和SRT字幕文件"); } } diff --git a/src/plugins/status/detail-page.js b/src/plugins/status/detail-page.js index 739b0f32..50969424 100644 --- a/src/plugins/status/detail-page.js +++ b/src/plugins/status/detail-page.js @@ -1,4 +1,8 @@ -class DetailPagePlugin extends BasePlugin { +// @ts-check + +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class DetailPagePlugin extends BasePlugin { getName() { return "DetailPagePlugin"; } @@ -6,9 +10,10 @@ class DetailPagePlugin extends BasePlugin { super(); } handle() { - window.isDetailPage && ($(".video-meta-panel a").each((function() { - const e = $(this).attr("href"); - e && (e.startsWith("http://") || e.startsWith("https://") || e.startsWith("/")) && $(this).attr("target", "_blank"); + isDetailPage && ($(".video-meta-panel a").each(((/** @type {number} */ _index, /** @type {HTMLAnchorElement} */ element) => { + const node = $(element), e = node.attr("href"); + if (!e) return; + try { ["http:", "https:"].includes(new URL(e, window.location.href).protocol) && node.attr("target", "_blank"); } catch {} }))); } } diff --git a/src/plugins/status/detail-workspace.js b/src/plugins/status/detail-workspace.js index 86786a43..6a3446f6 100644 --- a/src/plugins/status/detail-workspace.js +++ b/src/plugins/status/detail-workspace.js @@ -1,46 +1,21 @@ -/** 返回当前详情页的宿主资源边界;调用者不得重挂载这些节点。 */ -function getDetailResourceAdapter() { - if (!window.isDetailPage) return null; - if (r) { - const hostRoot = $(".video-detail").first(), controller = hostRoot.find('[data-controller="magnet-sort"]').first(), resourceRoot = controller.find("#magnets-content").first(); - if (!hostRoot.length || !controller.length || !resourceRoot.length) return null; - const resourceRegion = controller.closest(hostRoot.children()).first(); - return { - site: "javdb", hostRoot, controller, observeRoot: controller, resourceRoot, resourceRegion, - rows: () => resourceRoot.children(".item").toArray(), - sortSelect: controller.find('select[data-action*="magnet-sort#sort"]').first(), - getResource(row) { - const item = $(row); - return item.find('.copy-to-clipboard[data-clipboard-text^="magnet:"]').first().attr("data-clipboard-text") || item.find('.magnet-name a[href^="magnet:"]').first().attr("href") || ""; - }, - getActionTarget: row => $(row).children(".buttons").first() - }; - } - if (l) { - const hostRoot = $(".container").filter(((_, element) => $(element).find("#magnet-table").length > 0)).first(), resourceRoot = hostRoot.find("#magnet-table").first(); - if (!hostRoot.length || !resourceRoot.length) return null; - const resourceRegion = resourceRoot.closest(hostRoot.children()).first(), observeRoot = resourceRoot.parent(); - return { - site: "javbus", hostRoot, controller: resourceRoot, observeRoot, resourceRoot, resourceRegion, - rows: () => resourceRoot.find("tr").filter(((_, row) => $(row).find('td a[href^="magnet:"],td a[href^="ed2k:"]').length > 0)).toArray(), - sortSelect: $(), - getResource: row => $(row).find('td a[href^="magnet:"],td a[href^="ed2k:"]').first().attr("href") || "", - getActionTarget(row) { - const item = $(row), stableActions = item.find(".buttons,.actions,.btn-group").filter(((_, element) => $(element).closest("td").length > 0)).last(); - if (stableActions.length) return stableActions; - const resourceCell = item.find('td:has(a[href^="magnet:"]),td:has(a[href^="ed2k:"])').first(); - let actions = resourceCell.children(".jhs-offline-actions").first(); - return actions.length || (actions = $('').appendTo(resourceCell)), actions; - } - }; - } - return null; -} +// @ts-check + +import { jhsEventBus } from "../../core/event-bus.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { JhsSelect } from "../../core/ui-primitives.js"; +import { getDetailResourceAdapter } from "../../ui/detail/detail-resource-adapter.js"; + +/** @typedef {any} JQueryHandle Legacy jQuery runtime handle. */ /** 非破坏性详情工作区:仅标记宿主稳定块,并为 JHS 自有内容提供固定插槽。 */ -class DetailWorkspacePlugin extends BasePlugin { +export class DetailWorkspacePlugin extends BasePlugin { constructor() { - super(), this.hostRoot = null, this.resourceObserver = null, this.scheduledResourceFrame = null; + super(); + /** @type {JQueryHandle | null} */ this.hostRoot = null; + /** @type {any} */ this.resourceObserver = null; + /** @type {number | null} */ this.scheduledResourceFrame = null; + /** @type {(() => void) | null} */ this.cancelScheduledResourceFrame = null; + /** @type {any} */ this.lifecycleScope = null; } getName() { return "DetailWorkspacePlugin"; } async initCss() { @@ -55,31 +30,53 @@ class DetailWorkspacePlugin extends BasePlugin { .jhs-detail-workspace .jhs-detail-btn-row { display:flex; flex-wrap:wrap; gap:var(--jhs-space-2); margin-top:var(--jhs-space-4); } .jhs-detail-workspace [data-jhs-section="gallery"] .jhs-detail-workspace__content { overflow-x:auto; } .jhs-detail-host-workspace { color:var(--jhs-text); } - .jhs-detail-owned-slot { min-width:0; padding:var(--jhs-space-5) 0; border-top:1px solid var(--jhs-border); } + .jhs-detail-owned-slot { min-width:0; box-sizing:border-box; padding:var(--jhs-space-4); border:1px solid var(--jhs-border); border-radius:var(--jhs-radius-md); background:var(--jhs-surface); } .jhs-detail-owned-slot:empty { display:none; } - .jhs-detail-owned-slot--summary-actions { padding:var(--jhs-space-3) 0 var(--jhs-space-5); border-top:0; } - .jhs-detail-post-resource { min-width:0; } + .jhs-detail-owned-slot--summary-actions { margin-block:var(--jhs-space-5); } + .jhs-detail-post-resource { display:grid; min-width:0; gap:var(--jhs-space-5); margin-block:var(--jhs-space-5); } .jhs-detail-host-workspace .jhs-detail-btn-row { margin:0!important; } .jhs-detail-host-action { display:inline-flex!important; min-height:var(--jhs-control-height)!important; align-items:center!important; justify-content:center!important; padding:0 var(--jhs-space-3)!important; border:1px solid var(--jhs-border)!important; border-radius:var(--jhs-radius-sm)!important; background:var(--jhs-surface)!important; color:var(--jhs-text)!important; box-shadow:none!important; font:inherit!important; font-size:var(--jhs-font-size-sm)!important; font-weight:600!important; line-height:1!important; text-decoration:none!important; } .jhs-detail-host-action:hover { border-color:var(--jhs-accent)!important; background:var(--jhs-surface-2)!important; color:var(--jhs-accent)!important; } .jhs-offline-actions { display:inline-flex; align-items:center; gap:var(--jhs-space-2); margin-left:var(--jhs-space-2); vertical-align:middle; } - @media (max-width:767px) { .jhs-detail-owned-slot { padding:var(--jhs-space-4) 0; } } + [data-jhs-workspace-site="javdb"] [data-jhs-magnets] { container:jhs-magnets / inline-size; min-width:0; } + [data-jhs-workspace-site="javdb"] [data-jhs-host-region="resources"] { margin:0; min-width:0; } + [data-jhs-workspace-site="javdb"] [data-jhs-host-region="resources"] > .column { padding:0; min-width:0; } + [data-jhs-workspace-site="javdb"] [data-jhs-resource-surface] { margin:0; padding:var(--jhs-space-4); box-sizing:border-box; min-width:0; border:1px solid var(--jhs-border); border-radius:var(--jhs-radius-md); background:var(--jhs-surface); color:var(--jhs-text); } + [data-jhs-workspace-site="javdb"] [data-jhs-resource-body] { padding:0; border:0; background:transparent; color:inherit; } + [data-jhs-workspace-site="javdb"] [data-jhs-magnets] > .magnet-sort { margin-bottom:var(--jhs-space-3); } + [data-jhs-workspace-site="javdb"] #magnets-content [data-jhs-magnet-row] { display:grid; grid-template-columns:minmax(0,1fr); grid-template-areas:"info" "date" "actions"; gap:var(--jhs-space-2); align-items:center; margin:0; padding:var(--jhs-space-3); box-sizing:border-box; border-bottom:1px solid var(--jhs-border); background:transparent; } + [data-jhs-workspace-site="javdb"] #magnets-content [data-jhs-magnet-row]:last-child { border-bottom:0; } + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part="info"] .name { color:var(--jhs-text); font-size:var(--jhs-font-size-md); font-weight:600; } + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part="date"] { color:var(--jhs-text-muted); font-size:var(--jhs-font-size-sm); } + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part] { width:auto!important; min-width:0; max-width:100%; margin:0!important; padding:0; } + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part="info"] { grid-area:info; overflow-wrap:anywhere; } + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part="date"] { grid-area:date; white-space:nowrap; } + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part="actions"] { grid-area:actions; display:flex; flex-wrap:nowrap; align-items:center; gap:var(--jhs-space-2); } + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part="actions"] > :is(a,button) { display:inline-flex; flex:0 0 auto; align-items:center; justify-content:center; box-sizing:border-box; height:var(--jhs-control-height)!important; min-height:var(--jhs-control-height)!important; margin:0!important; padding:0 var(--jhs-space-3)!important; border:1px solid var(--jhs-border); border-radius:var(--jhs-radius-sm)!important; background:var(--jhs-surface); color:var(--jhs-text); font-size:var(--jhs-font-size-sm); line-height:1!important; } + @container jhs-magnets (min-width:768px) { + [data-jhs-workspace-site="javdb"] #magnets-content [data-jhs-magnet-row] { grid-template-columns:minmax(0,1fr) max-content max-content; grid-template-areas:"info date actions"; gap:var(--jhs-space-4); } + } + @container jhs-magnets (max-width:767px) { + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part="actions"] > :is(a,button) { height:44px!important; min-height:44px!important; } + } + @media (pointer:coarse) { + [data-jhs-workspace-site="javdb"] [data-jhs-magnet-part="actions"] > :is(a,button) { height:44px!important; min-height:44px!important; } + } `; } async handle() { if (!window.isDetailPage) return; - utils.loopDetector((() => !!this.getHostAdapter()), (() => this.ensureWorkspace()), 40, 2500, !0); + this.lifecycleScope = await this.getRuntimeService("scope")(); + const cancel = utils.loopDetector((() => !!this.getHostAdapter()), (() => this.ensureWorkspace()), 40, 2500, !0, this.lifecycleScope); + this.lifecycleScope.addCleanup(cancel); + this.lifecycleScope.addCleanup((() => { + this.cancelScheduledResourceFrame?.(), this.scheduledResourceFrame = null, this.cancelScheduledResourceFrame = null; + })); } getHostAdapter() { - if (r) { - const root = $(".video-detail").first(); - return root.length ? { site: "javdb", root } : null; - } - if (l) { - const root = $(".container").filter(((_, element) => $(element).find("#magnet-table,.screencap,.info").length > 0)).first(); - return root.length ? { site: "javbus", root } : null; - } - return null; + const host = this.getRuntimeService("host"), root = host?.locateDetailRoot?.(); + if (!root) return null; + return { site: host.site || "unknown", root: $(root) }; } ensureWorkspace() { const adapter = this.getHostAdapter(); @@ -89,13 +86,14 @@ class DetailWorkspacePlugin extends BasePlugin { root.attr({ "data-jhs-workspace-ready": "true", "data-jhs-workspace-site": adapter.site }).addClass("jhs-detail-host-workspace jhs-ui"); if ("javdb" === adapter.site) { root.children("h2,.video-meta-panel").attr("data-jhs-host-region", "summary"); - root.children(".columns").filter(((_, element) => $(element).find(".tile-images,.preview-images").length > 0)).attr("data-jhs-host-region", "gallery"); - root.children(".columns").filter(((_, element) => $(element).find("#magnets-content").length > 0)).attr("data-jhs-host-region", "resources"); + root.children(".columns").filter(((/** @type {number} */ _, /** @type {Element} */ element) => $(element).find(".tile-images,.preview-images").length > 0)).attr("data-jhs-host-region", "gallery"); + root.children(".columns").filter(((/** @type {number} */ _, /** @type {Element} */ element) => $(element).find("#magnets-content").length > 0)).attr("data-jhs-host-region", "resources"); this.normalizeHostActions(root.find(".video-meta-panel").first()); } else { root.children("h3,.row.movie").attr("data-jhs-host-region", "summary"); - root.children().filter(((_, element) => $(element).is("#mag-submit-show,#mag-submit") || $(element).find("#magnet-table").length > 0)).attr("data-jhs-host-region", "resources"); - root.children().filter(((_, element) => $(element).is("#sample-waterfall") || $(element).find("#sample-waterfall").length > 0)).attr("data-jhs-host-region", "gallery"); + const resource = getDetailResourceAdapter(this.getRuntimeService("host")); + resource?.resourceRegion?.attr("data-jhs-host-region", "resources"); + root.children().filter(((/** @type {number} */ _, /** @type {Element} */ element) => $(element).is("#sample-waterfall") || $(element).find("#sample-waterfall").length > 0)).attr("data-jhs-host-region", "gallery"); this.normalizeHostActions(root.find(".info").first()); } this.ensureOwnedSlots(root), this.adoptExistingOwnedPanels(root); @@ -103,6 +101,7 @@ class DetailWorkspacePlugin extends BasePlugin { this.hostRoot = root, this.ensureOwnedSlots(root), this.placeOwnedSlots(), this.bindResourceLifecycle(); return root; } + /** @param {string} name */ getSlot(name) { return this.ensureWorkspace().find(`[data-jhs-slot="${name}"]`).first(); } @@ -116,7 +115,7 @@ class DetailWorkspacePlugin extends BasePlugin { } /** 只移动 JHS 自有插槽,将其固定在稳定宿主锚点旁。 */ placeOwnedSlots() { - const root = this.hostRoot, resource = getDetailResourceAdapter(); + const root = this.hostRoot, resource = getDetailResourceAdapter(this.getRuntimeService("host")); if (!root?.length) return; this.ensureOwnedSlots(root); const summaryActions = root.children('[data-jhs-slot="summary-actions"]').first(), postResource = root.children('[data-jhs-slot-group="post-resource"]').first(); @@ -124,80 +123,56 @@ class DetailWorkspacePlugin extends BasePlugin { summaryRegion.length && summaryActions.insertAfter(summaryRegion); resource?.resourceRegion?.length && postResource.insertAfter(resource.resourceRegion); } + /** @param {JQueryHandle} root */ adoptExistingOwnedPanels(root) { [ [ ".jhs-detail-btn-row", "summary-actions" ], [ ".jhs-related-panel", "related" ], [ ".jhs-review-panel", "reviews" ] ].forEach((([ selector, slot ]) => { const target = root.find(`[data-jhs-slot="${slot}"]`).first(); - root.find(selector).filter(((_, element) => !$(element).closest("[data-jhs-slot]").length)).each(((_, element) => target.append(element))); + root.find(selector).filter(((/** @type {number} */ _, /** @type {Element} */ element) => !$(element).closest("[data-jhs-slot]").length)).each(((/** @type {number} */ _, /** @type {Element} */ element) => target.append(element))); })); } + /** @param {JQueryHandle} info */ normalizeHostActions(info) { const labels = new Set([ "想看", "看过", "看過", "存入清单", "存入清單", "下载", "下載", "订正", "訂正" ]); - info.find("a, button").filter((function() { return !$(this).is(".jhs-btn, [id^='jhs-']") && labels.has($(this).text().replace(/\s+/g, " ").trim()); })).addClass("jhs-detail-host-action"); + info.find("a, button").filter(((/** @type {number} */ _, /** @type {Element} */ element) => !$(element).is(".jhs-btn, [id^='jhs-']") && labels.has($(element).text().replace(/\s+/g, " ").trim()))).addClass("jhs-detail-host-action"); } + /** @param {MutationRecord} record */ isJhsOnlyMutation(record) { if ($(record.target).closest(".jhs-offline-actions,.jhs-select-control,.jhs-magnet-score").length) return !0; const nodes = [ ...record.addedNodes, ...record.removedNodes ].filter((node => node.nodeType === Node.ELEMENT_NODE)); - return nodes.length > 0 && nodes.every((node => node.matches?.(".jhs-offline-btn,.jhs-offline-actions,.jhs-magnet-score,.jhs-select-control") || node.closest?.(".jhs-offline-actions,.jhs-select-control"))); + return nodes.length > 0 && nodes.every((node => { + const element = /** @type {Element} */ (node); + return element.matches?.(".jhs-offline-btn,.jhs-offline-actions,.jhs-magnet-score,.jhs-select-control") || element.closest?.(".jhs-offline-actions,.jhs-select-control"); + })); } bindResourceLifecycle() { - const adapter = getDetailResourceAdapter(); + const adapter = getDetailResourceAdapter(this.getRuntimeService("host")); if (!adapter) return; if (this.resourceObserver && this.resourceObserver.root === adapter.observeRoot[0]) return void this.scheduleResourceUpdate(); - this.resourceObserver?.disconnect?.(); - const observer = new MutationObserver((records => { records.every((record => this.isJhsOnlyMutation(record))) || this.scheduleResourceUpdate(); })); - observer.root = adapter.observeRoot[0], observer.observe(adapter.observeRoot[0], { childList: !0, subtree: !0 }), this.resourceObserver = observer, + this.resourceObserver && this.lifecycleScope?.releaseObserver(this.resourceObserver); + if (!this.lifecycleScope) return; + const observer = this.lifecycleScope.observe(adapter.observeRoot[0], ((/** @type {MutationRecord[]} */ records) => { records.every((record => this.isJhsOnlyMutation(record))) || this.scheduleResourceUpdate(); }), { childList: !0, subtree: !0 }); + observer.root = adapter.observeRoot[0], this.resourceObserver = observer, adapter.sortSelect.length && adapter.sortSelect.addClass("jhs-select-source") && JhsSelect.enhance(adapter.controller), this.scheduleResourceUpdate(); } scheduleResourceUpdate() { - if (this.scheduledResourceFrame) return; - const schedule = window.requestAnimationFrame || (callback => setTimeout(callback)); + if (null !== this.scheduledResourceFrame) return; + const usesAnimationFrame = "function" == typeof window.requestAnimationFrame; + const schedule = /** @type {(callback: FrameRequestCallback) => number} */ (usesAnimationFrame ? window.requestAnimationFrame.bind(window) : (callback => Number(setTimeout(callback)))); this.scheduledResourceFrame = schedule((() => { - this.scheduledResourceFrame = null; - const adapter = getDetailResourceAdapter(); + this.scheduledResourceFrame = null, this.cancelScheduledResourceFrame = null; + const adapter = getDetailResourceAdapter(this.getRuntimeService("host")); if (!adapter) return; + adapter.prepareLayout?.(); this.placeOwnedSlots(); adapter.sortSelect.length && (adapter.sortSelect.addClass("jhs-select-source"), JhsSelect.enhance(adapter.controller), JhsSelect.refresh(adapter.sortSelect)); + if (!jhsEventBus) return; void jhsEventBus.emit("magnet-items-updated", { site: adapter.site, resourceRoot: adapter.resourceRoot[0], rows: adapter.rows() }, { broadcast: !1 }); })); + this.cancelScheduledResourceFrame = () => { + null !== this.scheduledResourceFrame && (usesAnimationFrame ? window.cancelAnimationFrame?.(this.scheduledResourceFrame) : clearTimeout(this.scheduledResourceFrame)); + }; } } /** 创建 FC2 自有详情壳,所有异步模块只写入固定插槽。 */ -function createFc2DetailShell({ carNum = "", source = "fc2", mode = "dialog" } = {}) { - const workspace = $('
    ').attr({ - "data-jhs-fc2-source": source, - "data-jhs-fc2-mode": mode, - "data-jhs-car-num": normalizeCarNum(carNum) || "" - }); - const definitions = [ [ "summary", "影片概览" ], [ "gallery", "预览与剧照" ], [ "resources", "资源" ], [ "reviews", "评论" ], [ "related", "相关清单" ] ]; - definitions.forEach((([ name, title ]) => { - const section = $('
    ').attr("data-jhs-section", name); - const header = $('
    '), heading = $("

    ").text(title), actions = $('
    ').attr("data-jhs-section-actions", name); - section.append(header.append(heading, actions), $('
    ').attr("data-jhs-slot", name)), workspace.append(section); - })); - return workspace; -} - -/** 创建只属于单个 FC2 详情实例的生命周期和插槽上下文。 */ -function createFc2DetailContext(root, options = {}) { - const workspace = $(root).is(".jhs-fc2-workspace") ? $(root) : $(root).find(".jhs-fc2-workspace").first(); - let destroyed = !1; - const namespace = `.jhsFc2Detail${Date.now()}${Math.random().toString(36).slice(2)}`, observers = new Set(); - const context = { - ...options, - root: workspace, - workspace, - namespace, - observers, - getSlot: name => workspace.find(`[data-jhs-slot="${name}"]`).first(), - getSection: name => workspace.find(`[data-jhs-section="${name}"]`).first(), - isAlive: () => !destroyed && workspace[0]?.isConnected !== !1, - addObserver(observer) { observer && observers.add(observer); return observer; }, - destroy() { - if (destroyed) return; - destroyed = !0, workspace.off(namespace).find("*").off(namespace), observers.forEach((observer => observer.disconnect?.())), observers.clear(), workspace.removeData("jhsFc2Context"); - } - }; - workspace.data("jhsFc2Context", context); - return context; -} +/** @param {{ carNum?: string, source?: string, mode?: string }} [options] */ diff --git a/src/plugins/status/fc2-navigation.js b/src/plugins/status/fc2-navigation.js new file mode 100644 index 00000000..264c1a33 --- /dev/null +++ b/src/plugins/status/fc2-navigation.js @@ -0,0 +1,142 @@ +// @ts-check + +import { jhsEventBus } from "../../core/event-bus.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { readListItem } from "../../core/list-item-reader.js"; + +/** @typedef {any} JQueryHandle Legacy jQuery runtime handle. */ + +/** + * FC2 list-card navigation lives outside list.core so the FC2 path keeps + * working when the generic list plugin is disabled. It owns: + * - FC2 card identification on JavDB list pages + * - primary-anchor ownership without rewriting native hrefs + * - normal click -> FC2 dialog + * - Ctrl / Meta / middle click -> FC2 owned detail + * - source resolution (explicit -> URL inference -> empty) + */ +export class Fc2NavigationPlugin extends BasePlugin { + getName() { return "Fc2NavigationPlugin"; } + + async handle() { + if (!window.isListPage) return; + const fc2 = this.getOptionalDependency("Fc2Plugin"); + if (!fc2) return; + const host = this.getRuntimeService("host"); + const scope = await this.getRuntimeService("scope")(); + const initialRoot = host?.locateListRoot?.() ?? null; + let boundRoot = null; + // JHS 自渲染榜单页(热播/Top250)在启动时还没有列表根,这里必须支持延迟挂载: + // 列表渲染后(list-items-added)再保护卡片并绑定对话框导航,否则 FC2 点击被统一详情导航吞掉。 + const ensureAttached = async () => { + const current = host?.locateListRoot?.(); + if (!current) return; + const root = $(current).first(); + if (!root.length) return; + await this.protectFc2Navigation(root, fc2).catch((error) => clog.warn("FC2 导航保护初始化失败", error)); + this.bindFc2Navigation(root, fc2); + if (boundRoot !== current) { + boundRoot = current; + scope.addCleanup(() => { + root.off(".jhsFc2Navigation"); + root.find('.item a[data-jhs-fc2-primary="true"]').off(".jhsFc2Navigation"); + }); + } + }; + await ensureAttached(); + + // 6.5: protect FC2 cards added after initial render (AutoPage waterfall pages, filter re-render). + // Prefer the list-items-added event; keep a MutationObserver fallback so FC2 navigation keeps + // working when list.core is disabled. + let reprotectTimer = null; + const reprotect = () => { + if (reprotectTimer != null) return; + reprotectTimer = setTimeout(() => { + reprotectTimer = null; + void ensureAttached().catch((error) => clog.warn("FC2 动态导航保护失败", error)); + }, 80); + }; + scope.addCleanup(() => { + if (reprotectTimer != null) { clearTimeout(reprotectTimer); reprotectTimer = null; } + }); + const unsubscribeItems = jhsEventBus.on("list-items-added", (payload) => { if (payload?.items?.length) reprotect(); }); + scope.addCleanup(unsubscribeItems); + if (initialRoot && typeof MutationObserver !== "undefined") { + scope.observe(initialRoot, (records) => { + if (records.some((record) => record.addedNodes.length)) reprotect(); + }, { childList: true, subtree: false }); + } + } + + /** @param {JQueryHandle} root @param {any} fc2 */ + async protectFc2Navigation(root, fc2) { + for (const element of root.find(".item").toArray()) { + const item = $(element); + if (item.attr("data-jhs-fc2-protected") === "true") continue; + try { + const { carNum, aHref, fc2Source } = readListItem(item); + if (!carNum?.includes("FC2-") || !aHref) continue; + const source = fc2Source || (await fc2.resolveFc2Source({ url: aHref })) || ""; + const primaryAnchor = item.find("a").first(); + if (!primaryAnchor.length) continue; + item.attr({ + "data-jhs-original-url": aHref, + "data-jhs-fc2-source": source, + "data-jhs-fc2-protected": "true", + }); + primaryAnchor.attr("data-jhs-fc2-primary", "true"); + } catch (error) { + clog.warn("FC2 导航保护初始化失败", error); + } + } + } + + /** @param {JQueryHandle} root @param {any} fc2 */ + bindFc2Navigation(root, fc2) { + const selector = '.item a[data-jhs-fc2-primary="true"]'; + const handleNavigation = async (/** @type {any} */ event) => { + if ("auxclick" === event.type && 1 !== event.button || "click" === event.type && event.button && 0 !== event.button) return; + if (event.shiftKey || event.altKey || $(event.target).closest("div.meta-buttons,[class^='jhs-match-']").length) return; + const item = $(event.currentTarget).closest(".item"); + const shouldOpenTab = Boolean(event.ctrlKey || event.metaKey || 1 === event.button); + const fallbackToNative = (href, carNum, error) => { + clog.error("打开 FC2 详情失败,回退原始链接", error); + if (shouldOpenTab) utils.openPage(href, carNum, !0, { event, newTab: true }); + else window.location.href = href; + }; + let carNum; + let aHref; + let fc2Source; + try { + const record = readListItem(item); + carNum = record.carNum; + aHref = item.attr("data-jhs-original-url") || record.aHref; + fc2Source = record.fc2Source; + } catch (error) { + aHref = item.attr("data-jhs-original-url") || item.find("a").first().attr("href"); + carNum = item.find(".video-title strong").first().text().trim(); + if (!aHref) return; + event.preventDefault(); + event.stopPropagation(); + fallbackToNative(aHref, carNum, error); + return; + } + if (!carNum?.includes("FC2-") || !aHref) return; + event.preventDefault(); + event.stopPropagation(); + try { + const movieId = await fc2.resolveMovieIdForRecord(carNum, aHref); + const source = fc2Source || (await fc2.resolveFc2Source({ url: aHref })) || ""; + if (shouldOpenTab) { + await fc2.openFc2Page(movieId, carNum, aHref, { event, newTab: true }, { source }); + } else { + fc2.openFc2Dialog(movieId, carNum, aHref, { source }); + } + } catch (error) { fallbackToNative(aHref, carNum, error); } + }; + root.off("click.jhsFc2Navigation auxclick.jhsFc2Navigation", selector); + root.find(selector).each((/** @type {number} */ _index, /** @type {Element} */ element) => { + $(element).off("click.jhsFc2Navigation auxclick.jhsFc2Navigation").on("click.jhsFc2Navigation auxclick.jhsFc2Navigation", handleNavigation); + }); + } +} diff --git a/src/plugins/status/fold-category.js b/src/plugins/status/fold-category.js index b153ac5d..c1e5d8ec 100644 --- a/src/plugins/status/fold-category.js +++ b/src/plugins/status/fold-category.js @@ -1,4 +1,10 @@ -class FoldCategoryPlugin extends BasePlugin { +// @ts-check + +import { o } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { createLatestSettingWriter } from "../../ui/settings/setting-binding-controller.js"; + +export class FoldCategoryPlugin extends BasePlugin { getName() { return "FoldCategoryPlugin"; } @@ -7,50 +13,66 @@ class FoldCategoryPlugin extends BasePlugin { return `\n \n `; } async handle() { + const scope = await this.getRuntimeService("scope")(); window.isListPage && (o.includes("advanced_search") || (this.highlightTag(), utils.loopDetector((() => $("#waitCheckBtn").length), (() => { this.createFoldBtn(); - }), 1, 1e4, !0), $("#tags .tag-category .tag-expand").each(((e, t) => { + }), 1, 1e4, !0, scope), $("#tags .tag-category .tag-expand").each(((/** @type {number} */ e, /** @type {HTMLElement} */ t) => { $(t).parent().hasClass("collapse") && t.click(); })))); } + /** @param {HTMLElement} element 读取标签名:剥掉高亮按钮与 “(计数)” 后缀,与点击写入口径一致 */ + readTagName(element) { + return $(element).clone().find(".highlight-btn").remove().end().text().trim().replace(/\s*\(\d+\)$/, ""); + } highlightTag() { (async () => { - const e = await storageManager.getHighlightedTags(); - e && e.forEach((e => { - $(`#tags a.tag:contains(${e})`).addClass("highlighted"), $(`.tags a.tag:contains(${e})`).addClass("highlighted"); + const e = await storageManager.getHighlightedTags(), tags = $("#tags a.tag, .tags a.tag"); + // 精确匹配:不用 :contains(子串误匹配 + 特殊字符会破坏选择器) + e && e.forEach(((/** @type {string} */ tag) => { + tags.filter(((/** @type {number} */ _, /** @type {HTMLElement} */ element) => this.readTagName(element) === tag)).addClass("highlighted"); })); - })().catch((error => clog.error("分类高亮恢复失败", error))), $("#tags a.tag, .tags a.tag").hover((function() { - const e = $(this), t = $(''); + })().catch((error => clog.error("分类高亮恢复失败", error))); + // 委托绑定:瀑布流后加载的卡片同样获得高亮按钮 + $(document).off(".jhsHighlight").on("mouseenter.jhsHighlight", "#tags a.tag, .tags a.tag", ((/** @type {MouseEvent} */ event) => { + const e = $(event.currentTarget); + if (e.find(".highlight-btn").length) return; + const t = $(''); e.append(t), t.fadeIn(0); - }), (function() { - $(this).find(".highlight-btn").fadeOut(0, (function() { - $(this).remove(); - })); - })), $(document).on("click", ".highlight-btn", (async function(e) { + })).on("mouseleave.jhsHighlight", "#tags a.tag, .tags a.tag", ((/** @type {MouseEvent} */ event) => { + const button = $(event.currentTarget).find(".highlight-btn"); + button.fadeOut(0, (() => button.remove())); + })).on("click.jhsHighlight", ".highlight-btn", (async (/** @type {MouseEvent} */ e) => { e.stopPropagation(), e.preventDefault(); - const t = $(this).closest("a.tag"), n = t.clone(); - n.find(".highlight-btn").remove(); - const a = n.text().trim().replace(/\s*\(\d+\)$/, ""); + const t = $(e.currentTarget).closest("a.tag"), a = this.readTagName(t[0]); let i = await storageManager.getHighlightedTags(); - i.includes(a) ? (i = i.filter((e => e !== a)), t.removeClass("highlighted")) : (i.push(a), + i.includes(a) ? (i = i.filter(((/** @type {string} */ e) => e !== a)), t.removeClass("highlighted")) : (i.push(a), t.addClass("highlighted")), await storageManager.setHighlightedTags(i); })); } async createFoldBtn() { - let t = $("#tags"), n = $("#tags dl div.tag.is-info").map((function() { - return $(this).text().replaceAll("\n", "").replaceAll(" ", ""); + let t = $("#tags"), n = $("#tags dl div.tag.is-info").map(((/** @type {number} */ _index, /** @type {Element} */ element) => { + return $(element).text().replaceAll("\n", "").replaceAll(" ", ""); })).get().join(" "); if (!n) return; - $(".tabs").append(`\n
    \n
    已选分类: ${n}
    \n \n\n
    \n `); + // 折叠按钮用 class 而非 id:两种挂载位置(.tabs / h2.section-title)可能同时出现,重复 id 会让第二个按钮失效 + $(".tabs").append(`\n
    \n
    已选分类: ${n}
    \n \n\n
    \n `); let a = $("h2.section-title"); - if (a.length > 0 && (a.append('\n
    \n \n
    \n '), + if (a.length > 0 && (a.append('\n
    \n \n
    \n '), t = $("section > div > div.box")), !t) return; - let i = $("#foldCategoryBtn"), s = localStorage.getItem("jhs_foldCategory") === _, [o, r] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; - i.find("span").text(o).end().find("i").attr("class", r), window.location.href.includes("noFold=1") || t[s ? "hide" : "show"](), - i.on("click", (async e => { - e.preventDefault(), s = !s, localStorage.setItem("jhs_foldCategory", s ? _ : C); - const [n, a] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; - i.find("span").text(n).end().find("i").attr("class", a), t[s ? "hide" : "show"](); + const settings = this.getRuntimeService("settings"); + let i = $(".jhs-fold-category-btn"), s = settings.snapshot().foldCategoryCollapsed === !0, [label0, icon0] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; + i.find("span").text(label0).end().find("i").attr("class", icon0), window.location.href.includes("noFold=1") || t[s ? "hide" : "show"](); + const createFoldWriter = createLatestSettingWriter({ settings, key: "foldCategoryCollapsed", fallback: false, apply: (value) => { + s = value === true; + const [label, icon] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; + i.find("span").text(label).end().find("i").attr("class", icon); + t[s ? "hide" : "show"](); + }, onError: (error) => { + clog.error("分类折叠设置保存失败,已恢复", error), show.error("分类折叠设置保存失败,已恢复原设置"); + } }); + i.on("click", (async (/** @type {MouseEvent} */ e) => { + e.preventDefault(); + await createFoldWriter(!s); })); } } diff --git a/src/plugins/status/highlight-magnet.js b/src/plugins/status/highlight-magnet.js index fbd036e5..1fa2ed55 100644 --- a/src/plugins/status/highlight-magnet.js +++ b/src/plugins/status/highlight-magnet.js @@ -1,8 +1,36 @@ -class HighlightMagnetPlugin extends BasePlugin { +// @ts-check + +import { _ } from "../../core/constants.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { calcMagnetScore, getMagnetQualitySignals } from "../../core/magnet-quality.js"; + +export class HighlightMagnetPlugin extends BasePlugin { async handle() { - window.isDetailPage && jhsEventBus.on("magnet-items-updated", (() => { - void storageManager.getSetting("enableMagnetsFilter", _).then((enabled => enabled === _ ? this.doFilterMagnet() : this.showAll())); - })); + if (!window.isDetailPage) return; + const settings = this.getRuntimeService("settings"), scope = await this.getRuntimeService("scope")(); + if (!this._settingsListenerBound) { + this._settingsListenerBound = true; + const onSettingsChanged = (/** @type {any} */ event) => { + const names = /** @type {string[] | undefined} */ (event.detail?.names); + if (!names?.includes("enableMagnetsFilter")) return; + this.reconfigure(); + }; + settings.addEventListener("settings.changed", onSettingsChanged); + scope.addCleanup((() => { + settings.removeEventListener("settings.changed", onSettingsChanged); + this._settingsListenerBound = false; + })); + } + scope.addCleanup(jhsEventBus?.on("magnet-items-updated", (() => this.reconfigure())) || (() => {})); + this.reconfigure(); + } + /** 唯一 reconfigure:settings.changed 与 magnet-items-updated 共用;同时同步本地按钮文案/aria。 */ + reconfigure() { + const enabled = (this.getRuntimeService("settings").snapshot().enableMagnetsFilter ?? _) === _; + enabled ? this.doFilterMagnet() : this.showAll(); + $("#magnets-span").text(enabled ? "关闭磁力过滤" : "开启磁力过滤"); + $("#enable-magnets-filter").attr("aria-pressed", String(enabled)); } async initCss() { return ``; @@ -11,10 +39,22 @@ class HighlightMagnetPlugin extends BasePlugin { return "HighlightMagnetPlugin"; } doFilterMagnet() { - this.handleDb(), this.handleBus(); + const boundary = this.getRuntimeService("host")?.getDetailResourceBoundary?.(); + if (!boundary) return void this.updateFilterHint(!1); + const rows = boundary.rows(); + /** @type {any[]} */ const validRows = []; + let hasMatch = !1; + rows.forEach(((/** @type {any} */ row) => { + const titleTarget = boundary.getTitleTarget(row); + if (!titleTarget) return; + const target = $(titleTarget), title = target.text().toLowerCase(), signals = this.getQualitySignals(title, boundary.hasSubtitleTag(row)); + $(row).removeClass("high-quality").show().addClass("magnet-row"), title.includes("4k") && target.css("color", "var(--jhs-status-filter-text)"), + signals.highQuality && (hasMatch = !0, $(row).addClass("high-quality")), this.injectScoreBadge(target, target.text()), validRows.push(row); + })); + hasMatch && validRows.forEach((row => $(row).hasClass("high-quality") || $(row).hide())), this.updateFilterHint(hasMatch); } /** 给磁力行注入评分徽章(幂等:已有则跳过) */ - injectScoreBadge(el, title) { + injectScoreBadge(/** @type {any} */ el, /** @type {string} */ title) { try { if (el.find(".jhs-magnet-score").length > 0) return; const score = calcMagnetScore({ title: title || "", seeders: 0 }); @@ -27,56 +67,19 @@ class HighlightMagnetPlugin extends BasePlugin { el.append(badge); } catch (e) { clog.debug("磁力评分徽章注入失败,已忽略", e); } } - getQualitySignals(title, hasSubtitleTag = !1) { - const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); - return { resolution, subtitle, recognized: !!resolution || subtitle, highQuality: "4k" === resolution || "2160p" === resolution || subtitle }; - } - /** 返回与 DOM 无关的磁力质量结果,供自有详情工作区复用。 */ - assessMagnet({ title = "", hasHdTag = !1, hasSubtitleTag = !1, date = null, seeders = 0 } = {}) { - const signals = this.getQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; - const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); - return { ...signals, highQuality, score, grade: score.total >= 70 ? "高" : score.total >= 40 ? "中" : "低" }; + getQualitySignals(/** @type {string} */ title, /** @type {boolean} */ hasSubtitleTag = !1) { + return getMagnetQualitySignals(title, hasSubtitleTag); } - updateFilterHint(hasMatch) { + updateFilterHint(/** @type {boolean} */ hasMatch) { $("#enable-magnets-filter").removeClass("do-hide").attr("data-tip", hasMatch ? "仅显示识别到的高质量或字幕磁力" : "未识别到可过滤项,当前未隐藏磁力"); } - handleDb() { - if (!r) return; - let e = $("#magnets-content .name"); - if (0 === e.length) return void this.updateFilterHint(!1); - let n = !1; - e.each(((e, a) => { - const i = $(a), s = i.text().toLowerCase(), o = this.getQualitySignals(s); - const row = i.parent().parent().parent(); - row.removeClass("high-quality").show(); - row.addClass("magnet-row"), s.includes("4k") && i.css("color", "var(--jhs-status-filter-text)"), - o.highQuality && (n = !0, row.addClass("high-quality")); - this.injectScoreBadge(i, i.text()); - })), n && $("#magnets-content .magnet-row").not(".high-quality").hide(), this.updateFilterHint(n); - } - handleBus() { - if (l && isDetailPage) { - const e = $("#magnet-table tr"); - let n = !1; - e.each(((e, a) => { - const i = $(a), s = i.find("td:first-child"), o = s.find("a:first-child"), r = s.find("a:nth-child(2)"), l = o.text().toLowerCase(); - i.removeClass("high-quality").show(); - l.includes("4k") && o.css("color", "var(--jhs-status-filter-text)"); - this.getQualitySignals(l, r.length > 0 && r.text().includes("字幕")).highQuality && (n = !0, - i.addClass("high-quality")); - this.injectScoreBadge(o, o.text()); - })); - n && e.each(((e, t) => { - const n = $(t); - n.hasClass("high-quality") || n.hide(); - })), this.updateFilterHint(n); - } - } showAll() { $("#enable-magnets-filter").removeClass("do-hide").removeAttr("data-tip"); - if (r) { - $("#magnets-content .item").toArray().forEach((e => $(e).show())); - } - l && $("#magnet-table tr").toArray().forEach((e => $(e).show())); + const boundary = this.getRuntimeService("host")?.getDetailResourceBoundary?.(); + boundary?.rows().forEach(((/** @type {any} */ row) => { + $(row).removeClass("high-quality").show(); + const title = boundary.getTitleTarget(row); + title && $(title).css("color", "").find(".jhs-magnet-score").remove(); + })); } } diff --git a/src/plugins/status/history.js b/src/plugins/status/history.js index f56a7c33..55ffe211 100644 --- a/src/plugins/status/history.js +++ b/src/plugins/status/history.js @@ -1,20 +1,127 @@ -class HistoryPlugin extends BasePlugin { +// @ts-check + +import { b, d, g, h, k, l, m, normalizeCarNum, p, r, u, v, y, escapeHtml } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { hasAnyState, legacyActionToFlag, normalizeStateFlags } from "../../core/state-model.js"; +import { JhsSelect } from "../../core/ui-primitives.js"; +import { HistorySelectionModel } from "../../features/history/history-selection-model.js"; +import { HistoryRepository } from "../../features/history/history-repository.js"; +import { createJhsTable } from "../../ui/table/create-jhs-table.js"; + +/** @typedef {Record} HistoryRecord */ +/** @typedef {any} JQueryHandle */ +/** @typedef {any} TableHandle */ + +/** + * Pure DOM builders for the History table and edit dialog. Imported/legacy data + * fields (carNum, names, url, remark) are treated as text — never interpolated + * into HTML or attributes — so hostile backup records stay inert. + */ +/** Build the carNum cell: clickable prefix button + remainder. @param {string} [carNum] */ +export function buildCarNumCell(carNum) { + const value = String(carNum || ""), i = value.indexOf("-"); + if (-1 === i) return document.createTextNode(value); + const wrapper = document.createElement("span"), button = document.createElement("button"); + button.type = "button"; + button.className = "jhs-btn jhs-btn--ghost jhs-btn--sm table-link-param"; + button.textContent = value.substring(0, i + 1); + wrapper.appendChild(button), wrapper.appendChild(document.createTextNode(value.substring(i + 1))); + return wrapper; +} + +/** Build the actress names cell with one clickable button per name. @param {string} [names] */ +export function buildNamesCell(names) { + const wrapper = document.createElement("span"); + String(names || "").split(" ").filter((name) => "" !== name.trim()).forEach((name) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "jhs-btn jhs-btn--ghost jhs-btn--sm table-link-param"; + button.textContent = name; + wrapper.appendChild(button); + }); + return wrapper; +} + +/** Build the per-row action buttons; carNum/url go into dataset, never into HTML attributes. @param {Record} record */ +export function buildActionButtons(record) { + const container = document.createElement("div"); + container.className = "action-btns"; + container.dataset.carNum = String(record.carNum || ""); + container.dataset.href = record.url ? String(record.url) : ""; + const detail = document.createElement("button"); + detail.type = "button", detail.className = "jhs-btn jhs-btn--secondary history-detailBtn"; + const detailSpan = document.createElement("span"); detailSpan.textContent = "查看"; detail.appendChild(detailSpan); + container.appendChild(detail); + const subBtns = document.createElement("div"); + subBtns.className = "sub-btns"; + const toggle = document.createElement("button"); + toggle.type = "button", toggle.className = "jhs-btn jhs-btn--ghost sub-btns-toggle"; + toggle.setAttribute("aria-haspopup", "menu"), toggle.setAttribute("aria-expanded", "false"); + const toggleSpan = document.createElement("span"); toggleSpan.textContent = "更多操作"; toggle.appendChild(toggleSpan); + subBtns.appendChild(toggle); + const menu = document.createElement("div"); + menu.className = "sub-btns-menu", menu.setAttribute("role", "menu"); + const menuItems = [ [ "history-editBtn", "编辑", "" ], [ "history-deleteBtn", "移除", "jhs-btn--danger" ], [ "history-hasWatchBtn", k, "" ], [ "history-hasDownBtn", y, "" ], [ "history-favoriteBtn", v, "" ], [ "history-filterBtn", m, "" ] ]; + for (const [ cls, label, extra ] of menuItems) { + const item = document.createElement("button"); + item.type = "button", item.className = `jhs-btn jhs-btn--ghost ${cls}`, item.setAttribute("role", "menuitem"); + extra && item.classList.add(extra); + item.textContent = label; + menu.appendChild(item); + } + subBtns.appendChild(menu), container.appendChild(subBtns); + return container; +} + +/** Static edit-record form; values are back-filled via .val()/.text() so imported data stays inert. @param {{favorite?: boolean, downloaded?: boolean, watched?: boolean, blocked?: boolean}} flags */ +export function buildEditRecordForm(flags) { + return `\n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    状态:\n \n \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n `; +} + +export class HistoryPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectAll", !1), - i(this, "historyExcludedCarNums", new Set), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), - i(this, "historySelectionSyncing", !1); + super(...arguments); + /** @type {TableHandle | null} */ this.tableObj = null; + /** @type {JQueryHandle | null} */ this.historyRoot = null; + this.historySelectionModel = new HistorySelectionModel(); + /** @type {HistoryRepository | null} */ this._historyRepository = null; + /** @type {HistoryRecord[]} */ this.historySorters = []; + this.historyFilteredCount = 0; + this.historySelectionSyncing = !1; + this.allCount = 0; + this.filterCount = 0; + this.favoriteCount = 0; + this.hasDownCount = 0; + this.hasWatchCount = 0; + this.waitCheckCount = 0; } getName() { return "HistoryPlugin"; } + /** @param {string} value */ + getSourceLabel(value) { + if (!value) return ""; + const movie = this.getRuntimeService("movie"), settings = this.getRuntimeService("settings").snapshot(); + if (movie.matchesProviderUrl("av123", value)) return "123AV"; + try { + const origin = new URL(value).origin; + if (origin === movie.externalSiteOrigin("javDbBtn", settings)) return "JavDB"; + if (origin === movie.externalSiteOrigin("javBusBtn", settings)) return "JavBus"; + } catch {} + return value.includes("javdb") ? "JavDB" : value.includes("javbus") ? "JavBus" : value.includes("123av") ? "123AV" : "其他"; + } + get historyRepository() { + return this._historyRepository ||= new HistoryRepository({ storage: storageManager, state: this.getRuntimeService("state") }); + } async initCss() { return ` `; } handleResize() { - $(".navbar-search").is(":hidden") ? ($(".historyBtnBox").show(), $(".miniHistoryBtnBox").hide()) : ($(".historyBtnBox").hide(), + // 按钮已被命令栏收走时,宿主导航中的空壳容器保持隐藏 + const hasButton = (/** @type {number} */ _index, /** @type {HTMLElement} */ element) => element.children.length > 0; + $(".navbar-search").is(":hidden") ? ($(".historyBtnBox").filter(hasButton).show(), $(".historyBtnBox").filter(((/** @type {number} */ index, /** @type {HTMLElement} */ element) => !hasButton(index, element))).hide(), $(".miniHistoryBtnBox").hide()) : ($(".historyBtnBox").hide(), $(".miniHistoryBtnBox").show()); } async handle() { @@ -35,63 +144,69 @@ class HistoryPlugin extends BasePlugin { $(".navbar-search").css("margin-left", "0").before('\n \n '), this.handleResize(), $(window).resize((() => { this.handleResize(); - })), $("#historyBtn,#miniHistoryBtn").on("click", (e => this.openHistory()))), l && await this.createBusButton(); + })), $("#historyBtn,#miniHistoryBtn").on("click", ((/** @type {any} */ e) => this.openHistory()))), l && void this.createBusButton().catch((error) => clog.warn("鉴定记录入口创建失败", error)); } async createBusButton() { - const ready = await new Promise((resolve => { + const ready = await new Promise((/** @type {(value: boolean) => void} */ resolve) => { const startedAt = Date.now(), timer = setInterval((() => { if ($("#setting-btn").length && $("#top-right-box").length) return clearInterval(timer), resolve(!0); Date.now() - startedAt >= 2500 && (clearInterval(timer), resolve(!1)); }), 25); - })); + }); if (!ready) return void clog.warn("鉴定记录入口未创建:JavBus 顶部工具区未就绪"); $("#top-right-box").append(''), - $("#historyBtn,#miniHistoryBtn").on("click", (e => this.openHistory())); + $("#historyBtn,#miniHistoryBtn").on("click", ((/** @type {any} */ e) => this.openHistory())); } openHistory() { - let e = `\n
    \n
    \n \n \n \n
    \n
    \n \n \n \n \n \n
    \n
    \n
    \n `; + let e = `\n
    \n
    \n \n \n \n
    \n
    \n \n \n \n \n \n
    \n
    \n
    \n `; e = e.replace('
    { + success: async (/** @type {any} */ e) => { const root = $(e); this.historyRoot = root, root.find("#allSelectBox").prepend(''), this.resetHistorySelection(!1), JhsSelect.enhance(root); - await this.loadTableData(), root.on("click.jhsHistory", "#clearSearchbtn", (async e => { + await this.loadTableData(), root.on("click.jhsHistory", "#clearSearchbtn", (async (/** @type {any} */ e) => { root.find("#searchCarNum").val(""), JhsSelect.setValue(root.find("#dataType"), "all"), await this.reloadTable(), root.find("#allSelectBox").hide(); - })).on("focusout keydown", "#searchCarNum", (async e => { + })).on("focusout.jhsHistory keydown.jhsHistory", "#searchCarNum", (async (/** @type {any} */ e) => { if ("focusout" === e.type || "Enter" === e.key) { if ("Enter" === e.key && e.preventDefault(), "keydown" === e.type && "Enter" !== e.key) return; await this.reloadTable(); } - })).on("click", ".table-link-param", (async e => { + })).on("click.jhsHistory", ".table-link-param", (async (/** @type {any} */ e) => { let t = $(e.currentTarget); root.find("#searchCarNum").val(t.text()), await this.reloadTable(); - })).on("change", "#dataType", (async () => { + })).on("change.jhsHistory", "#dataType", (async () => { await this.reloadTable(); - })).on("click", "[data-history-view]", (async event => { + })).on("click.jhsHistory", "[data-history-view]", (async (/** @type {any} */ event) => { const view = $(event.currentTarget).data("history-view"); root.find("[data-history-view]").removeClass("active"), $(event.currentTarget).addClass("active"), await this.showHistoryView(view); - })).on("click", ".jhs-undo-activity", (async event => { - const result = await stateService.undoTransaction($(event.currentTarget).data("transaction")); - show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); - })).on("click", ".jhs-copy-offline", (async event => { + })).on("click", ".jhs-undo-activity", (async (/** @type {any} */ event) => { + try { + const result = await this.historyRepository.undo($(event.currentTarget).data("transaction")); + show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); + } catch (error) { + clog.error("撤销失败:", error), show.error("撤销失败,请稍后重试"); + } + })).on("click", ".jhs-copy-offline", (async (/** @type {any} */ event) => { await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); - })).on("click", ".jhs-retry-offline", (async event => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find((entry => entry.id === id)); - item && await this.getBean("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: !0, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); - })).on("click", ".jhs-open-offline", (async event => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find((entry => entry.id === id)), provider = this.getBean("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); + })).on("click", ".jhs-retry-offline", (async (/** @type {any} */ event) => { + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((/** @type {HistoryRecord} */ entry) => entry.id === id); + const offline = this.getOptionalDependency("UnifiedOfflinePlugin"); + item && offline && await offline.submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: !0, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); + })).on("click", ".jhs-open-offline", (async (/** @type {any} */ event) => { + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((/** @type {HistoryRecord} */ entry) => entry.id === id), provider = this.getOptionalDependency("UnifiedOfflinePlugin")?.registry?.providers?.get(item?.providerId), url = provider?.openUrl?.(); url && window.open(url, "_blank", "noopener,noreferrer"); - })).on("click", ".jhs-delete-offline", (async event => { - await stateService.removeOfflineHistory($(event.currentTarget).data("id")), await this.renderOfflineHistory(); + })).on("click", ".jhs-delete-offline", (async (/** @type {any} */ event) => { + await this.historyRepository.removeOffline($(event.currentTarget).data("id")), await this.renderOfflineHistory(); })), this.bindHistoryActions(root); }, end: () => { @@ -99,36 +214,45 @@ class HistoryPlugin extends BasePlugin { } }); } + /** @param {string} view */ async showHistoryView(view) { const stateView = "state" === view; this.resetHistorySelection(), this.historyRoot?.find("#filterBox,#allSelectBox").toggle(stateView), this.tableObj?.destroy(), this.tableObj = null; return stateView ? this.loadTableData() : "activity" === view ? this.renderActivityHistory() : this.renderOfflineHistory(); } async renderActivityHistory() { - const log = await stateService.getActivityLog(), host = this.historyRoot.find("#table-container").empty(); + const log = await this.historyRepository.activity(), host = this.historyRoot.find("#table-container").empty(); if (!log.entries.length) return void host.html('
    暂无操作记录
    '); - log.entries.slice().reverse().forEach((entry => { - const reverted = entry.changes.filter((change => "reverted" === change.undoState)).length, conflicts = entry.changes.filter((change => "conflict" === change.undoState)).length; - host.append($("
    ").append($("").text(`${entry.type} · ${entry.changes.length} 项`), $("

    ").text(`${new Date(entry.createdAt).toLocaleString()} · 已撤销 ${reverted} · 冲突 ${conflicts}`), $("

    ").text(entry.changes.map((change => change.carNum)).join("、")), $("").attr("data-transaction", entry.id).prop("disabled", "committed" !== entry.commitState || reverted === entry.changes.length))); - })); + log.entries.slice().reverse().forEach((/** @type {HistoryRecord} */ entry) => { + const reverted = entry.changes.filter((/** @type {HistoryRecord} */ change) => "reverted" === change.undoState).length, conflicts = entry.changes.filter((/** @type {HistoryRecord} */ change) => "conflict" === change.undoState).length; + host.append($("
    ").append($("").text(`${entry.type} · ${entry.changes.length} 项`), $("

    ").text(`${new Date(entry.createdAt).toLocaleString()} · 已撤销 ${reverted} · 冲突 ${conflicts}`), $("

    ").text(entry.changes.map((/** @type {HistoryRecord} */ change) => change.carNum).join("、")), $("").attr("data-transaction", entry.id).prop("disabled", "committed" !== entry.commitState || reverted === entry.changes.length))); + }); } async renderOfflineHistory() { - const history = await stateService.getOfflineHistory(), host = this.historyRoot.find("#table-container").empty(); + const history = await this.historyRepository.offline(), host = this.historyRoot.find("#table-container").empty(); if (!history.length) return void host.html('
    暂无离线任务
    '); - history.slice().reverse().forEach((item => { - const actions = $("
    ").append($("").attr("data-resource", item.resource), $("").attr("data-id", item.id), $("").attr("data-id", item.id), $("").attr("data-id", item.id)); + history.slice().reverse().forEach((/** @type {HistoryRecord} */ item) => { + const offline = this.getOptionalDependency("UnifiedOfflinePlugin"), provider = offline?.registry?.providers?.get?.(item.providerId); + const actions = $("
    ").append($("").attr("data-resource", item.resource)); + if (offline && provider) actions.append($("").attr("data-id", item.id)); + if (provider && typeof provider.openUrl === "function") actions.append($("").attr("data-id", item.id)); + actions.append($("").attr("data-id", item.id)); host.append($("
    ").append($("").text(`${item.providerName || item.providerId} · ${item.status}`), $("

    ").text(`${item.carNum || "未关联番号"} · ${new Date(item.createdAt).toLocaleString()}${item.retryOf ? ` · 重试自 ${item.retryOf}` : ""}`), $("

    ").text(item.errorMessage || item.resource), actions)); - })); + }); } async reloadTable(resetSelection = !0) { resetSelection && this.resetHistorySelection(), await this.tableObj?.setPage(1); } + /** @param {unknown} carNum */ normalizeHistoryCarNum(carNum) { return normalizeCarNum(carNum) || String(carNum || "").trim().toUpperCase(); } + isHistoryAllFiltered() { + return "all-filtered" === this.historySelectionModel.mode; + } /** 清空 History 的全选、排除项和当前页选择。 */ resetHistorySelection(deselectRows = !0) { - this.historySelectAll = !1, this.historyExcludedCarNums.clear(); + this.historySelectionModel.clear(); if (deselectRows && this.tableObj) { this.historySelectionSyncing = !0; try { @@ -143,12 +267,12 @@ class HistoryPlugin extends BasePlugin { const checkbox = document.createElement("input"); return checkbox.type = "checkbox", checkbox.className = "jhs-history-select-all", checkbox.setAttribute("aria-label", "选择当前筛选条件下的全部记录"), checkbox.addEventListener("change", (() => { - checkbox.checked ? (this.historySelectAll = !0, this.historyExcludedCarNums.clear(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); + checkbox.checked ? (this.historySelectionModel.selectAllFiltered(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); })), checkbox; } syncHistoryPageSelection() { - if (!this.tableObj || !this.historySelectAll) return void this.updateHistorySelectionUi(); - const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.map((item => item.carNum)).filter((carNum => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(carNum)))); + if (!this.tableObj || !this.isHistoryAllFiltered()) return void this.updateHistorySelectionUi(); + const currentRows = /** @type {HistoryRecord[]} */ (this.tableObj.getData?.() || []), selectedIds = currentRows.filter((item => this.historySelectionModel.has(item))).map((item => item.carNum)); this.historySelectionSyncing = !0; try { this.tableObj.deselectRow(), selectedIds.length && this.tableObj.selectRow(selectedIds); @@ -157,20 +281,21 @@ class HistoryPlugin extends BasePlugin { } this.updateHistorySelectionUi(); } + /** @param {TableHandle} row @param {boolean} selected */ updateHistoryRowSelection(row, selected) { - if (!this.historySelectAll || this.historySelectionSyncing) return void this.updateHistorySelectionUi(); - const carNum = this.normalizeHistoryCarNum(row?.getData?.().carNum); - carNum && (selected ? this.historyExcludedCarNums.delete(carNum) : this.historyExcludedCarNums.add(carNum)), this.updateHistorySelectionUi(); + if (this.historySelectionSyncing) return void this.updateHistorySelectionUi(); + const item = row?.getData?.(); + item && this.historySelectionModel.set(item, selected), this.updateHistorySelectionUi(); } getHistorySelectionSummary() { - if (this.historySelectAll) { - const excluded = this.historyExcludedCarNums.size, selected = Math.max(0, this.historyFilteredCount - excluded); + if (this.isHistoryAllFiltered()) { + const excluded = this.historySelectionModel.excluded.size, selected = Math.max(0, this.historyFilteredCount - excluded); return { count: selected, text: excluded ? `已选择当前筛选结果 ${selected} 条,已排除 ${excluded} 条` : `已选择当前筛选结果 ${selected} 条` }; } - const count = this.tableObj?.getSelectedData?.().length || 0; + const count = this.historySelectionModel.selected.size; return { count, text: `已选择当前页 ${count} 条` @@ -180,68 +305,73 @@ class HistoryPlugin extends BasePlugin { if (!this.historyRoot) return; const summary = this.getHistorySelectionSummary(), batchBox = this.historyRoot.find("#allSelectBox"), filterBox = this.historyRoot.find("#filterBox"); this.historyRoot.find("#historySelectionSummary").text(summary.text), summary.count > 0 ? (filterBox.hide(), batchBox.show()) : (filterBox.show(), batchBox.hide()); - this.historyRoot.find(".jhs-history-select-all").each(((index, element) => { - element.checked = this.historySelectAll, element.indeterminate = this.historySelectAll ? this.historyExcludedCarNums.size > 0 : summary.count > 0; + this.historyRoot.find(".jhs-history-select-all").each(((/** @type {number} */ index, /** @type {HTMLInputElement} */ element) => { + element.checked = this.isHistoryAllFiltered(), element.indeterminate = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size > 0 : summary.count > 0; })); } /** 解析批量操作实际要处理的当前页或完整筛选结果。 */ async getHistoryBatchSelection() { - if (!this.historySelectAll) return this.tableObj?.getSelectedData?.() || []; - const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map((item => this.normalizeHistoryCarNum(item.carNum)))); - for (const carNum of this.historyExcludedCarNums) available.has(carNum) || this.historyExcludedCarNums.delete(carNum); - return this.historyFilteredCount = rows.length, rows.filter((item => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(item.carNum)))); + if (!this.isHistoryAllFiltered()) return this.historySelectionModel.values(this.tableObj?.getData?.() || []); + const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map((/** @type {HistoryRecord} */ item) => this.normalizeHistoryCarNum(item.carNum))); + for (const carNum of this.historySelectionModel.excluded) available.has(carNum) || this.historySelectionModel.excluded.delete(carNum); + return this.historyFilteredCount = rows.length, this.historySelectionModel.values(rows); } + /** @param {JQueryHandle} root */ bindHistoryActions(root) { - root.on("click.jhsHistory", (function(e) { + root.on("click.jhsHistory", (function(/** @type {any} */ e) { if (e.target.closest(".sub-btns-toggle")) { const button = e.target.closest(".sub-btns-toggle"), t = button.closest(".sub-btns").querySelector(".sub-btns-menu"); - root.find(".sub-btns-menu.show").each(((index, e) => { + root.find(".sub-btns-menu.show").each(((/** @type {number} */ index, /** @type {HTMLElement} */ e) => { e !== t && (e.classList.remove("show"), e.previousElementSibling?.setAttribute("aria-expanded", "false")); })), t.classList.toggle("show"), button.setAttribute("aria-expanded", String(t.classList.contains("show"))); - } else root.find(".sub-btns-menu.show").each(((index, e) => { + } else root.find(".sub-btns-menu.show").each(((/** @type {number} */ index, /** @type {HTMLElement} */ e) => { e.classList.remove("show"), e.previousElementSibling?.setAttribute("aria-expanded", "false"); })); - })), root.on("keydown.jhsHistory", ".sub-btns", (e => { + })), root.on("keydown.jhsHistory", ".sub-btns", ((/** @type {any} */ e) => { const menu = $(e.currentTarget).find(".sub-btns-menu"), items = menu.find('[role="menuitem"]'), current = items.index(document.activeElement); if ("Escape" === e.key) return e.preventDefault(), menu.removeClass("show"), $(e.currentTarget).find(".sub-btns-toggle").attr("aria-expanded", "false").trigger("focus"); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(e.key) || !menu.hasClass("show")) return; e.preventDefault(); const next = "Home" === e.key ? 0 : "End" === e.key ? items.length - 1 : "ArrowDown" === e.key ? (current + 1 + items.length) % items.length : (current - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })), root.on("click.jhsHistory", ".history-deleteBtn, .history-filterBtn, .history-favoriteBtn, .history-hasDownBtn, .history-hasWatchBtn, .history-detailBtn", (e => { + })), root.on("click.jhsHistory", ".history-deleteBtn, .history-filterBtn, .history-favoriteBtn, .history-hasDownBtn, .history-hasWatchBtn, .history-detailBtn", ((/** @type {any} */ e) => { e.preventDefault(), e.stopPropagation(); - const t = $(e.currentTarget), n = t.closest(".action-btns"), a = n.attr("data-car-num"), i = n.attr("data-href"), s = async actionType => { + const t = $(e.currentTarget), n = t.closest(".action-btns"), a = /** @type {string} */ (n.attr("data-car-num")), i = /** @type {string} */ (n.attr("data-href")), s = async (/** @type {string} */ actionType) => { try { const flag = legacyActionToFlag(actionType); - await stateService.toggle(a, flag, { type: "history-state", record: { carNum: a, url: i } }), await this.reloadTable(); + if (!flag) throw new TypeError(`无效历史状态操作: ${actionType}`); + await this.historyRepository.toggle(a, flag, { type: "history-state", record: { carNum: a, url: i } }), await this.reloadTable(); } catch (s) { clog.error("历史记录操作失败:", s), show.error("操作失败"); } }; if (t.hasClass("history-filterBtn")) { const record = this.tableObj?.getRow(a)?.getData(), isBlocked = normalizeStateFlags(record?.stateFlags).blocked; - isBlocked ? void s(d) : utils.q(e, `是否屏蔽${a}?`, (() => s(d))); + isBlocked ? void s(d) : utils.q(e, `是否屏蔽${escapeHtml(a)}?`, (() => s(d))); } else t.hasClass("history-favoriteBtn") ? void s(h) : t.hasClass("history-hasDownBtn") ? void s(g) : t.hasClass("history-hasWatchBtn") ? void s(p) : t.hasClass("history-deleteBtn") ? this.handleDelete(e, a) : t.hasClass("history-detailBtn") && void this.handleClickDetail(e, { carNum: a, url: i }).catch((error => clog.error("历史详情打开失败", error))); - })), root.on("click.jhsHistory", ".multiple-history-deleteBtn, .multiple-history-filterBtn, .multiple-history-favoriteBtn, .multiple-history-hasDownBtn, .multiple-history-hasWatchBtn", (async e => { + })), root.on("click.jhsHistory", ".multiple-history-deleteBtn, .multiple-history-filterBtn, .multiple-history-favoriteBtn, .multiple-history-hasDownBtn, .multiple-history-hasWatchBtn", (async (/** @type {any} */ e) => { e.preventDefault(), e.stopPropagation(); const t = $(e.currentTarget); - let n = await this.getHistoryBatchSelection(), a = "", i = ""; + /** @type {HistoryRecord[]} */ + let n = await this.getHistoryBatchSelection(); + let a = "", i = ""; t.hasClass("multiple-history-filterBtn") ? (a = "屏蔽", i = d) : t.hasClass("multiple-history-favoriteBtn") ? (a = "收藏", i = h) : t.hasClass("multiple-history-hasDownBtn") ? (a = "已下载", i = g) : t.hasClass("multiple-history-hasWatchBtn") ? (a = "已观看", i = p) : t.hasClass("multiple-history-deleteBtn") && (a = "移除", i = "delete"); if (!n.length) return void show.info("请先选择要处理的记录"); - const selectionText = this.historySelectAll ? this.historyExcludedCarNums.size ? `当前筛选结果中已选择 ${n.length} 条,排除 ${this.historyExcludedCarNums.size} 条` : `当前筛选结果中已选择全部 ${n.length} 条` : `当前页已选择 ${n.length} 条`; + const selectionText = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size ? `当前筛选结果中已选择 ${n.length} 条,排除 ${this.historySelectionModel.excluded.size} 条` : `当前筛选结果中已选择全部 ${n.length} 条` : `当前页已选择 ${n.length} 条`; utils.q(e, `${selectionText},是否标记为${a}?`, (async () => { let e = loading(); try { if ("delete" === i) { - const e = n.map((e => e.carNum)), t = await stateService.remove(e); + const e = n.map((/** @type {HistoryRecord} */ e) => e.carNum), t = await this.historyRepository.remove(e); if (!t.changed.length) return void show.error("提供的番号中没有一个存在于列表中。"); show.ok(`已成功删除 ${t.changed.length} 个番号`); } else { const flag = legacyActionToFlag(i); - await stateService.patch(n.map((item => item.carNum)), { [flag]: !0 }, { type: "history-batch-state", records: n }), show.ok("操作成功"); + if (!flag) throw new TypeError(`无效历史批量操作: ${i}`); + await this.historyRepository.patch(n.map((item => item.carNum)), { [flag]: !0 }, { type: "history-batch-state", records: n }), show.ok("操作成功"); } this.resetHistorySelection(), await this.reloadTable(!1); } catch (t) { @@ -253,41 +383,46 @@ class HistoryPlugin extends BasePlugin { })); } /** 返回应用当前搜索、状态筛选和排序后的完整 History 数据。 */ + /** @param {HistoryRecord[]} [sorters] @returns {Promise} */ async getFilteredHistoryData(sorters = this.historySorters) { - let a = await storageManager.getCarList(); + /** @type {HistoryRecord[]} */ + let a = await this.historyRepository.list(); this.allCount = a.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, - this.hasWatchCount = 0, this.waitCheckCount = 0, a.forEach((e => { + this.hasWatchCount = 0, this.waitCheckCount = 0, a.forEach((/** @type {HistoryRecord} */ e) => { const flags = normalizeStateFlags(e.stateFlags); flags.blocked && this.filterCount++, flags.favorite && this.favoriteCount++, flags.downloaded && this.hasDownCount++, flags.watched && this.hasWatchCount++, hasAnyState(flags) || this.waitCheckCount++; - })), this.historyRoot.find('#dataType option[value="all"]').text(`所有 (${this.allCount})`), this.historyRoot.find('#dataType option[value="waitCheck"]').text(`待鉴定 (${this.waitCheckCount})`), + }), this.historyRoot.find('#dataType option[value="all"]').text(`所有 (${this.allCount})`), this.historyRoot.find('#dataType option[value="waitCheck"]').text(`待鉴定 (${this.waitCheckCount})`), this.historyRoot.find('#dataType option[value="filter"]').text(`${u} (${this.filterCount})`), this.historyRoot.find('#dataType option[value="favorite"]').text(`${b} (${this.favoriteCount})`), this.historyRoot.find('#dataType option[value="hasDown"]').text(`${y} (${this.hasDownCount})`), this.historyRoot.find('#dataType option[value="hasWatch"]').text(`${k} (${this.hasWatchCount})`); - const i = this.historyRoot.find("#dataType").val(); + const i = String(this.historyRoot.find("#dataType").val() || "all"); + /** @type {Record} */ const flagByFilter = { filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" }; - let s = "all" === i ? a : "waitCheck" === i ? a.filter((e => !hasAnyState(e.stateFlags))) : a.filter((e => normalizeStateFlags(e.stateFlags)[flagByFilter[i]])); - const o = this.historyRoot.find("#searchCarNum").val().trim(); + const selectedFlag = flagByFilter[i]; + let s = "all" === i ? a : "waitCheck" === i ? a.filter((e => !hasAnyState(e.stateFlags))) : selectedFlag ? a.filter((e => normalizeStateFlags(e.stateFlags)[selectedFlag])) : a; + const o = String(this.historyRoot.find("#searchCarNum").val() || "").trim(); if (o) { let e = o.toLowerCase().replace("-c", "").replace("-uc", "").replace("-4k", ""); - s = s.filter((t => { + s = s.filter((/** @type {HistoryRecord} */ t) => { const n = t.carNum.toLowerCase().includes(e); const a = (t.names ? t.names : "").toLowerCase().includes(e); return n || a; - })); + }); } if (sorters && sorters.length > 0) { - const e = sorters[0], t = e.field, a = e.dir; - s.sort(((e, n) => { + const e = /** @type {HistoryRecord} */ (sorters[0]), t = e.field, a = e.dir; + s.sort(((/** @type {HistoryRecord} */ e, /** @type {HistoryRecord} */ n) => { const i = e[t], s = n[t], o = null == i || "" === i, r = null == s || "" === s; return o && !r ? 1 : !o && r ? -1 : o && r ? 0 : i < s ? "asc" === a ? -1 : 1 : i > s ? "asc" === a ? 1 : -1 : 0; })); } return s; } + /** @param {number} e @param {number} t @param {HistoryRecord[]} n */ async getDataList(e, t, n) { - this.historySorters = Array.isArray(n) ? n.map((sorter => ({ + this.historySorters = Array.isArray(n) ? n.map((/** @type {HistoryRecord} */ sorter) => ({ ...sorter - }))) : []; + })) : []; const rows = await this.getFilteredHistoryData(this.historySorters), r = rows.length, l = Math.ceil(r / t), c = (e - 1) * t, m = c + t; return this.historyFilteredCount = r, { maxPage: l, @@ -296,7 +431,7 @@ class HistoryPlugin extends BasePlugin { }; } async loadTableData() { - this.tableObj = new Tabulator(this.historyRoot.find("#table-container").get(0), { + this.tableObj = createJhsTable((/** @type {any} */ (globalThis)).Tabulator, this.historyRoot.find("#table-container").get(0), { layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, @@ -305,7 +440,7 @@ class HistoryPlugin extends BasePlugin { sortMode: "remote", ajaxURL: "queryRealm", dataLoader: !1, - ajaxRequestFunc: async (e, t, n) => { + ajaxRequestFunc: async (/** @type {any} */ e, /** @type {any} */ t, /** @type {HistoryRecord} */ n) => { const a = n.page, i = n.size, s = n.sort; return await this.getDataList(a, i, s); }, @@ -316,7 +451,7 @@ class HistoryPlugin extends BasePlugin { }, paginationSize: 50, paginationSizeSelector: [ 50, 100, 1e3 ], - paginationCounter: (e, t, n, a, i) => `共 ${a} 条记录`, + paginationCounter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n, /** @type {number} */ a, /** @type {any} */ i) => `共 ${a} 条记录`, responsiveLayout: "collapse", responsiveLayoutCollapse: !0, columnDefaults: { @@ -332,7 +467,7 @@ class HistoryPlugin extends BasePlugin { headerSort: !1, responsive: 0, width: 40, - cellClick: (e, t) => { + cellClick: (/** @type {any} */ e, /** @type {TableHandle} */ t) => { t.getRow().toggleSelect(); } }, { @@ -341,11 +476,7 @@ class HistoryPlugin extends BasePlugin { width: 120, sorter: "string", responsive: 0, - formatter: (e, t, n) => { - const a = e.getData().carNum, i = a.indexOf("-"); - if (-1 === i) return a; - return `${a.substring(i + 1)}`; - } + formatter: (/** @type {TableHandle} */ e) => buildCarNumCell(e.getData().carNum) }, { title: "演员", field: "names", @@ -353,7 +484,7 @@ class HistoryPlugin extends BasePlugin { sorter: "string", responsive: 5, headerSort: !0, - formatter: (e, t, n) => (e.getData().names || "").split(" ").filter((e => "" !== e.trim())).map((e => ``)).join(" ") + formatter: (/** @type {TableHandle} */ e) => buildNamesCell(e.getData().names) }, { title: "创建时间", field: "createDate", @@ -379,9 +510,9 @@ class HistoryPlugin extends BasePlugin { sorter: "string", responsive: 5, hozAlign: "left", - formatter: (e, t, n) => { + formatter: (/** @type {TableHandle} */ e, /** @type {any} */ t, /** @type {any} */ n) => { let a = e.getData().url; - return a ? `${a.includes("javdb") ? "JavDB" : a.includes("javbus") ? "JavBus" : a.includes("123av") ? "123AV" : "其他"}` : ""; + return a ? `${this.getSourceLabel(a)}` : ""; } }, { title: "状态", @@ -390,8 +521,11 @@ class HistoryPlugin extends BasePlugin { sorter: !1, responsive: 1, headerSort: !1, - formatter: (e, t, n) => { - const flags = normalizeStateFlags(e.getData().stateFlags), badges = [ [ flags.blocked, "filter", u ], [ flags.favorite, "fav", b ], [ flags.downloaded, "down", y ], [ flags.watched, "watch", k ] ].filter((item => item[0])).map((item => `${item[2]}`)); + formatter: (/** @type {TableHandle} */ e, /** @type {any} */ t, /** @type {any} */ n) => { + const flags = normalizeStateFlags(e.getData().stateFlags); + /** @type {Array<[boolean, string, string]>} */ + const badgeItems = [ [ flags.blocked, "filter", u ], [ flags.favorite, "fav", b ], [ flags.downloaded, "down", y ], [ flags.watched, "watch", k ] ]; + const badges = badgeItems.filter((item => item[0])).map((item => `${item[2]}`)); return badges.join(" ") || '待鉴定'; } }, { @@ -407,118 +541,102 @@ class HistoryPlugin extends BasePlugin { cssClass: "action-cell-dropdown", responsive: 0, headerSort: !1, - formatter: (e, t, n) => { - const a = e.getData(); - return n((() => { - var t; - null == (t = e.getElement().querySelector(".history-editBtn")) || t.addEventListener("click", (e => { + formatter: (/** @type {TableHandle} */ e, /** @type {any} */ t, /** @type {(callback: () => void) => void} */ n) => { + const a = e.getData(), element = buildActionButtons(a); + n((() => { + element.querySelector(".history-editBtn")?.addEventListener("click", ((/** @type {Event} */ ev) => { this.editRecord(a); })); - })), ` -
    - -
    - - -
    -
    `; + })); + return element; } } ], initialSort: [ { column: "updateDate", dir: "desc" - } ], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "所有", - page_size: "每页行数" - } - } - } - }), this.tableObj.on("dataProcessed", (() => { - this.historySelectAll ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); - })), this.tableObj.on("rowSelected", (row => { + } ] + }); + // 数据刷新时 Tabulator 会先取消当前页选择;该阶段不应写入“全选筛选结果”的排除集。 + this.tableObj.on("dataProcessing", (() => { + this.historySelectionSyncing = !0; + })); + this.tableObj.on("dataProcessed", (() => { + this.historySelectionSyncing = !1; + this.isHistoryAllFiltered() ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); + })), this.tableObj.on("rowSelected", ((/** @type {TableHandle} */ row) => { this.updateHistoryRowSelection(row, !0); - })), this.tableObj.on("rowDeselected", (row => { + })), this.tableObj.on("rowDeselected", ((/** @type {TableHandle} */ row) => { this.updateHistoryRowSelection(row, !1); })), this.tableObj.on("rowSelectionChanged", (() => { this.updateHistorySelectionUi(); - })), this.tableObj.on("rowDblClick", (function(e, t) { + })), this.tableObj.on("rowDblClick", (function(/** @type {any} */ e, /** @type {TableHandle} */ t) { t.toggleSelect(); })); } + /** @param {any} e @param {string} t */ handleDelete(e, t) { - utils.q(e, `是否移除${t}?`, (async () => { - await stateService.remove(t), this.getBean("ListPagePlugin").showCarNumBox(t), - await this.reloadTable(); + utils.q(e, `是否移除${escapeHtml(t)}?`, (async () => { + try { + await this.historyRepository.remove(t), this.getOptionalDependency("ListPagePlugin")?.showCarNumBox?.(t), + await this.reloadTable(); + } catch (error) { + clog.error("移除历史记录失败:", error), show.error("移除失败,请稍后重试"); + } })); } + /** @param {any} e @param {HistoryRecord} t */ async handleClickDetail(e, t) { - if (r) if (t.carNum.includes("FC2-")) { - const e = this.parseMovieId(t.url); - const plugin = this.getBean("Fc2Plugin"), source = await plugin.resolveFc2Source(t); - plugin.openFc2Dialog(e, t.carNum, t.url, { source }); - } else { + if (t.carNum.includes("FC2-")) { + const plugin = this.getOptionalDependency("Fc2Plugin"); + if (!plugin) return t.url ? void utils.openPage(t.url, t.carNum, !1, e) : void show.info("FC2 详情功能已禁用"); + const source = await plugin.resolveFc2Source(t), movieId = await plugin.resolveMovieIdForRecord(t.carNum, t.url); + if (r) plugin.openFc2Dialog(movieId, t.carNum, t.url, { source }); + else if (l) await plugin.openFc2Page(movieId, t.carNum, t.url, { newTab: !0 }, { source }); + return; + } + if (r) { if (!t.url) return void window.open("/search?q=" + t.carNum, "_blank"); utils.openPage(t.url, t.carNum, !1, e); } if (l) { - let n = t.url; - if (n.includes("javdb")) if (t.carNum.includes("FC2-")) { - const e = this.parseMovieId(n); - const plugin = this.getBean("Fc2Plugin"), source = await plugin.resolveFc2Source(t); - await plugin.openFc2Page(e, t.carNum, n, { newTab: !0 }, { source }); - } else window.open(n, "_blank"); else utils.openPage(t.url, t.carNum, !1, e); + // 无来源链接时不能退化为打开站点首页 + if (!t.url) return void show.info("该记录没有来源链接"); + t.url.includes("javdb") ? window.open(t.url, "_blank") : utils.openPage(t.url, t.carNum, !1, e); } } + /** @param {HistoryRecord} e */ async editRecord(e) { - const t = e.carNum, n = e.names || "", a = e.url || "", flags = normalizeStateFlags(e.stateFlags), s = e.remark || ""; + const carNum = e.carNum, names = e.names || "", url = e.url || "", remark = e.remark || "", flags = normalizeStateFlags(e.stateFlags); let editRoot = $(); - const c = `\n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    状态:\n \n \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n `; - layer.open({ + const c = buildEditRecordForm(flags); + const dialog = this.getRuntimeService("dialog"); + dialog.open({ type: 1, - title: `编辑记录: ${t}`, + title: `编辑记录: ${carNum}`, area: utils.getDialogArea("sm"), content: c, btn: [ "保存", "取消" ], - success: (e, t) => { - editRoot = $(e); - const n = e => { - e.css("height", "auto"), e.css("height", e[0].scrollHeight + 15 + "px"); - }, a = editRoot.find("#edit-names"); - a.on("input", (function() { - n($(this)); - })), n(a); - const i = editRoot.find("#edit-remark"); - i.on("input", (function() { - n($(this)); - })), n(i); + success: (/** @type {any} */ layerEl, /** @type {number} */ _index) => { + editRoot = $(layerEl); + editRoot.find("#edit-carNum").val(carNum); + editRoot.find("#edit-names").val(names); + editRoot.find("#edit-url").val(url); + editRoot.find("#edit-remark").val(remark); + const resize = (/** @type {JQueryHandle} */ el) => { + el.css("height", "auto"), el.css("height", el[0].scrollHeight + 15 + "px"); + }, namesField = editRoot.find("#edit-names"); + namesField.on("input", ((/** @type {any} */ event) => resize($(event.currentTarget)))), resize(namesField); + const remarkField = editRoot.find("#edit-remark"); + remarkField.on("input", ((/** @type {any} */ event) => resize($(event.currentTarget)))), resize(remarkField); }, - yes: async t => { - const n = editRoot.find("#edit-names").val().trim(), i = editRoot.find("#edit-url").val().trim(), s = editRoot.find("#edit-remark").val().trim(), nextFlags = { + yes: async (/** @type {number} */ index) => { + const nextNames = String(editRoot.find("#edit-names").val() || "").trim(), nextUrl = String(editRoot.find("#edit-url").val() || "").trim(), nextRemark = String(editRoot.find("#edit-remark").val() || "").trim(), nextFlags = { favorite: editRoot.find("#edit-favorite").prop("checked"), downloaded: editRoot.find("#edit-downloaded").prop("checked"), watched: editRoot.find("#edit-watched").prop("checked"), blocked: editRoot.find("#edit-blocked").prop("checked") }; const save = async () => { - await stateService.patch(e.carNum, nextFlags, { type: "history-edit", replaceMetadata: !0, record: { ...e, names: n, url: i, remark: s } }), this.tableObj.setData(), layer.close(t); + await this.historyRepository.patch(e.carNum, nextFlags, { type: "history-edit", replaceMetadata: !0, record: { ...e, names: nextNames, url: nextUrl, remark: nextRemark } }), this.tableObj.setData(), dialog.close(index); }; - if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${e.carNum}?`, (() => void save())), !1; + if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${escapeHtml(e.carNum)}?`, (() => void save())), !1; await save(); } }); diff --git a/src/plugins/status/list-page-button.js b/src/plugins/status/list-page-button.js index c432e8fa..828fa853 100644 --- a/src/plugins/status/list-page-button.js +++ b/src/plugins/status/list-page-button.js @@ -1,113 +1,173 @@ -class ListPageButtonPlugin extends BasePlugin { +// @ts-check + +import { _, c, g, h, l, o, r } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { isHitShowPage } from "../../core/site-context.js"; +import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; +import { isHardHidden } from "../../features/list/list-filters.js"; + +/** @typedef {import("../../core/lifecycle-scope.js").LifecycleScope} LifecycleScope */ +/** @typedef {{ starId?: string }} BlacklistRecord */ +/** @typedef {{ element: Element, key: number, originalIndex: number, index: number }} SortItem */ + +export class ListPageButtonPlugin extends BasePlugin { + constructor() { + super(); + /** @type {string | null} 页内排序覆盖:仅自有榜单页使用,初始固定“默认”,不写全局设置。 */ + this.ownedRankingSortOverride = null; + } getName() { return "ListPageButtonPlugin"; } async handle() { - if (!window.isListPage) return; - await this.createMenuBtn(), this.bindEvent(); - const e = await storageManager.getSetting("autoPage"), t = this.supportsLiveSorting(); - $("#sort-toggle-btn").prop("disabled", e === _ && !t).attr("title", e === _ && !t ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"), - (e !== _ || t) && await this.sortItems(); + // 热播/Top250 自渲染榜单由渲染方调用 mountHitShowControls 延迟挂载,启动期不注入页面 h2 + if (!window.isListPage || isHitShowPage() || window.location.search.includes("handleTop=1")) return; + const scope = await this.getRuntimeService("scope")(); + const settings = this.getRuntimeService("settings"); + await this.createMenuBtn(scope), this.bindEvent(); + const onSettingsChanged = (/** @type {any} */ event) => { + const names = /** @type {string[] | undefined} */ (event.detail?.names); + if (!names?.includes("autoPage")) return; + void this.syncSortUi().catch((/** @type {unknown} */ error) => clog.error("排序状态同步失败", error)); + }; + settings.addEventListener("settings.changed", onSettingsChanged); + scope.addCleanup((() => settings.removeEventListener("settings.changed", onSettingsChanged))); + await this.syncSortUi(); } - async createMenuBtn() { + /** 自渲染榜单页由渲染方在自有标题/筛选容器就绪后调用:按钮行挂进传入容器(缺省为热播标题),排序/批量能力与普通列表页一致。 @param {any} [target] */ + async mountOwnedRankingControls(target = null) { + const heading = target?.length ? target : $(".jhs-hitshow-heading"); + if (!heading.length || $("#waitCheckBtn").length) return; + const scope = await this.getRuntimeService("scope")(); + await this.createMenuBtn(scope, heading); + this.bindEvent(); + await this.syncSortUi(); + } + /** 根据 autoPage 与当前站点能力同步排序控件;AutoPage ON 且不支持 live sorting 时明确进入“默认(瀑布流)”。 */ + async syncSortUi() { + const settings = this.getRuntimeService("settings"); + const autoPage = settings.snapshot().autoPage ?? _; + const live = this.supportsLiveSorting(); + const toggle = $("#sort-toggle-btn"); + if (!toggle.length) return; + const menu = $(".jhs-sort-menu"); + if (autoPage === _ && !live) { + toggle.prop("disabled", true).attr("title", "瀑布流模式仅支持默认排序"); + $("#jhs-sort-current").text("默认(瀑布流)"); + menu.find(".jhs-sort-option").attr("aria-checked", "false"); + menu.find('[data-sort-method="default"]').attr("aria-checked", "true"); + await this.sortItems("default"); + return; + } + const method = this.activeSortMethod(); + const labels = { default: "默认", rateCount: "评价人数", date: "时间" }; + const current = Object.prototype.hasOwnProperty.call(labels, method) ? /** @type {Record} */ (labels)[/** @type {string} */ (method)] : labels.default; + toggle.prop("disabled", false).attr("title", "选择列表排序方式"); + $("#jhs-sort-current").text(current); + menu.find(".jhs-sort-option").attr("aria-checked", "false"); + menu.find(`[data-sort-method="${method}"]`).attr("aria-checked", "true"); + await this.sortItems(); + } + /** @param {LifecycleScope} scope @param {any} [target] 自渲染榜单传入自有标题容器,避免注入页面 h2。 */ + async createMenuBtn(scope, target = null) { + // 6.5 capability:功能被禁用时不渲染按钮,不留 disabled 死按钮。 + const hasNewVideo = Boolean(this.getOptionalDependency("NewVideoPlugin")), hasBlacklist = Boolean(this.getOptionalDependency("BlacklistPlugin")), hasListPage = Boolean(this.getOptionalDependency("ListPagePlugin")); if (r) { const e = o.includes("/actors/"); let t = $(".main-tabs, .tabs"), n = "加入黑名单", a = "jhs-btn--filter", s = null; if (e) { t = $(".toolbar, .section-addition").filter(":last"); const e = await storageManager.getBlacklist(), i = this.getActressPageInfo(); - e.find((e => e.starId === i.starId)) && (n = "已加入黑名单", a = "jhs-btn--muted"); + e.find((/** @type {BlacklistRecord} */ e) => e.starId === i.starId) && (n = "已加入黑名单", a = "jhs-btn--muted"); } else o.includes("/tags") && utils.loopDetector((() => $("#jhs-check-tag").text().trim()), (async () => { const e = $("#addBlacklistBtn"); e.attr("data-tip", "将当前分类标签加入到黑名单, 后续有作品更新也会纳入屏蔽中"); const t = $("#jhs-check-tag").text().trim(); if (!t) return; const n = "no-" + t, a = await storageManager.getBlacklist(); - s = a.find((e => e.starId === n)), s && (e.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); - })); + s = a.find((/** @type {BlacklistRecord} */ e) => e.starId === n), s && (e.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); + }), 20, 1e4, !0, scope); const r = o.includes("advanced_search"); - r && (t = $("h2.section-title")); - const l = localStorage.getItem("jhs_sortMethod"), d = "当前排序方式: " + ("rateCount" === l ? "评价人数" : "date" === l ? "时间" : "默认"); - t.append(`\n
    \n \n ${e ? `\n \n \n \n \n ` : ""}\n ${o.includes("/tags") ? `\n \n ` : ""}\n
    \n
    \n \n \n ${c ? "" : this.sortMenuHtml(l || "default", d)}\n
    \n `); + r && (t = target?.length ? target : $("h2.section-title")); + const initialSort = this.activeSortMethod(), d = "当前排序方式: " + ("rateCount" === initialSort ? "评价人数" : "date" === initialSort ? "时间" : "默认"); + t.append(`\n
    \n \n ${e && hasBlacklist ? `\n\n\n` : ""}\n ${hasListPage ? `\n\n\n` : ""}\n ${o.includes("/tags") && hasBlacklist ? `\n\n` : ""}\n
    \n
    \n ${hasNewVideo ? `` : ""}\n ${hasBlacklist ? `` : ""}\n ${c || !this.supportsSorting() ? "" : this.sortMenuHtml(initialSort, d)}\n
    \n `); } if (l) { const e = o.includes("/star/"); let t = "加入黑名单", n = "jhs-btn--filter"; if (e) { const e = await storageManager.getBlacklist(), a = this.getActressPageInfo(); - e.find((e => e.starId === a.starId)) && (t = "已加入黑名单", n = "jhs-btn--muted"); + e.find((/** @type {BlacklistRecord} */ e) => e.starId === a.starId) && (t = "已加入黑名单", n = "jhs-btn--muted"); } - const a = localStorage.getItem("jhs_sortMethod") || "default"; - $(".masonry").parent().prepend(`\n
    \n \n ${e ? ` \n \n \n \n \n ` : ''}\n ${this.sortMenuHtml(a)}\n
    \n `); + const a = this.activeSortMethod(); + $(".masonry").parent().prepend(`\n
    \n \n ${e && hasBlacklist ? ` \n \n \n ` : ""}${hasListPage ? ` \n \n \n ` : ""}${!e && hasBlacklist ? `` : ""}\n ${this.supportsSorting() ? this.sortMenuHtml(a) : ""}\n
    \n `); } $("#waitCheckBtn > span").text("开始鉴定"); const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); newVideoLabel.length && newVideoLabel.empty().append(document.createTextNode("新作品 ("), newVideoCount, document.createTextNode(")")); } - /** 构建与原排序值兼容的 JHS 菜单。 */ + /** @param {unknown} method @param {string} [title] 构建与原排序值兼容的 JHS 菜单。 */ sortMenuHtml(method, title = "选择列表排序方式") { - const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, current = labels[method] || labels.default; + const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, key = "string" === typeof method && method in labels ? /** @type {keyof typeof labels} */ (method) : "default", current = labels[key]; return `
    `; } bindEvent() { - $("#waitCheckBtn").on("click", (e => { - void this.openWaitCheck(e).catch((error => clog.error("待鉴定列表打开失败", error))); - })), $("#newVideoBtn").on("click", (e => { - this.getBean("NewVideoPlugin").openDialog(); - })), $("#blacklistBtn").on("click", (e => { - this.getBean("BlacklistPlugin").openBlacklistDialog(); + $("#waitCheckBtn").on("click", ((/** @type {any} */ e) => { + void this.openWaitCheck().catch((error => clog.error("待鉴定列表打开失败", error))); + })), $("#newVideoBtn").on("click", ((/** @type {any} */ e) => { + this.getOptionalDependency("NewVideoPlugin")?.openDialog?.(); + })), $("#blacklistBtn").on("click", ((/** @type {any} */ e) => { + this.getOptionalDependency("BlacklistPlugin")?.openBlacklistDialog?.(); })), this.bindSortMenu(); - const e = this.getBean("BlacklistPlugin"); - $("#addBlacklistBtn").on("click", (async t => { - await e.addBlacklist(t); - })), $("#filterAllVideo").on("click", (async t => { - let n = { - clientX: t.clientX, - clientY: t.clientY + 80 - }, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a.length) return void show.error("获取演员名称失败"); - let i = a.text().trim().split(",")[0]; - utils.q(n, "一键屏蔽视频列表?", (async () => { - this.loadObj = loading(); - try { - await e.filterAllVideo(i); - } catch (t) { - clog.error(t); - } finally { this.loadObj.close(); } - })); - })), $("#favoriteAllVideo").on("click", (async t => { - let n = {clientX: t.clientX, clientY: t.clientY + 80}, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a.length) return void show.error("获取演员名称失败"); - let i = a.text().trim().split(",")[0]; - utils.q(n, "一键收藏所有可见作品?", (async () => { - this.loadObj = loading(); - try { await e.batchSaveAllVideos(i, h); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } - })); - })), $("#hasDownAllVideo").on("click", (async t => { - let n = {clientX: t.clientX, clientY: t.clientY + 80}, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + // 6.5: capability 渲染在 createMenuBtn 完成,功能禁用时不渲染按钮;此处仅保留业务引用。 + const blacklist = this.getOptionalDependency("BlacklistPlugin"), listPage = this.getOptionalDependency("ListPagePlugin"); + $("#addBlacklistBtn").on("click", (async (/** @type {any} */ t) => { + await blacklist?.addBlacklist?.(t); + })), $("#filterAllVideo").on("click", (async (/** @type {any} */ t) => { + const a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); if (0 === a.length) return void show.error("获取演员名称失败"); - let i = a.text().trim().split(",")[0]; - utils.q(n, "一键已下载所有可见作品?", (async () => { - this.loadObj = loading(); - try { await e.batchSaveAllVideos(i, g); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } - })); + const i = a.text().trim().split(",")[0]; + this.loadObj = loading(); + try { + await blacklist?.filterAllVideo?.(i); + } catch (t) { + clog.error(t); + } finally { this.loadObj.close(); } + })), $("#favoriteAllVideo").on("click", (async (/** @type {any} */ t) => { + const scope = this.buildBatchScope(); + this.loadObj = loading(); + try { await listPage?.batchSaveAllVideos?.(scope, h); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } + })), $("#hasDownAllVideo").on("click", (async (/** @type {any} */ t) => { + const scope = this.buildBatchScope(); + this.loadObj = loading(); + try { await listPage?.batchSaveAllVideos?.(scope, g); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } })); } /** 绑定排序 popover 的选择与键盘交互。 */ bindSortMenu() { - const control = $(".jhs-sort-control"), toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = (focus = !1) => { + const control = $(".jhs-sort-control"); + if (!control.length) return; + const toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = (focus = !1) => { menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), focus && toggle.trigger("focus"); }; - toggle.on("click", (event => { + toggle.on("click", ((/** @type {any} */ event) => { event.preventDefault(), event.stopPropagation(); const open = !menu.hasClass("is-open"); menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); })); - menu.on("click", ".jhs-sort-option", (event => { + menu.on("click", ".jhs-sort-option", (async (/** @type {any} */ event) => { const item = $(event.currentTarget), method = item.data("sort-method"); - localStorage.setItem("jhs_sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), - $("#jhs-sort-current").text(item.text()), close(!0), void this.sortItems().catch((error => clog.error("列表排序失败", error))); - })).on("keydown", ".jhs-sort-option", (event => { + const previousItem = menu.find('.jhs-sort-option[aria-checked="true"]').first(); + const previousLabel = $("#jhs-sort-current").text(); + menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), + $("#jhs-sort-current").text(item.text()), close(!0); + try { + await this.selectSortMethod(method); + } catch (error) { + menu.find(".jhs-sort-option").attr("aria-checked", "false"), previousItem.attr("aria-checked", "true"), $("#jhs-sort-current").text(previousLabel); + clog.error("排序设置保存失败,已恢复", error), show.error("排序设置保存失败,已恢复原设置"); + } + })).on("keydown", ".jhs-sort-option", ((/** @type {any} */ event) => { const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), close(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; @@ -115,49 +175,85 @@ class ListPageButtonPlugin extends BasePlugin { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })); - $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", (event => { + $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", ((/** @type {any} */ event) => { $(event.target).closest(control).length || close(); })); } - async sortItems() { + /** @param {string} [methodOverride] */ + async sortItems(methodOverride) { const e = this.supportsLiveSorting(); - if (!e && (o.includes("handle") || o.includes("advanced_search"))) return; - const s = await storageManager.getSetting("autoPage"); - if (c || s === _ && !e) return; - const t = localStorage.getItem("jhs_sortMethod"); + if (!this.supportsSorting()) return; + const s = this.getRuntimeService("settings").snapshot().autoPage ?? _; + if (c || (s === _ && !e && methodOverride !== "default")) return; + const t = methodOverride || this.activeSortMethod(); if (!t) return; const i = this.getSelector(), d = $(i.boxSelector), h = $(i.itemSelector); - h.each((function(e) { - $(this).attr("data-original-index") || $(this).attr("data-original-index", e); + h.each(((/** @type {number} */ e, /** @type {Element} */ element) => { + $(element).attr("data-original-index") || $(element).attr("data-original-index", e); })); - const items = h.get().map(((element, index) => { + /** @type {SortItem[]} */ + const items = h.get().map(((/** @type {Element} */ element, /** @type {number} */ index) => { const card = $(element), originalIndex = Number(card.attr("data-original-index")) || 0; if ("default" === t) return { element, key: originalIndex, originalIndex, index }; if ("rateCount" === t) { const explicit = Number(card.attr("data-jhs-rate-count")), match = card.find(".score").text().replaceAll(",", "").match(/(?:由\s*)?(\d+)\s*人(?:评价)?/); return { element, key: Number.isFinite(explicit) ? explicit : match ? Number(match[1]) : 0, originalIndex, index }; } - const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter((function() { return /^\d{4}-\d{1,2}-\d{1,2}$/.test($(this).text().trim()); })).first().text().trim(), timestamp = Date.parse(value); + const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter(((/** @type {number} */ index, /** @type {Element} */ element) => /^\d{4}-\d{1,2}-\d{1,2}$/.test($(element).text().trim()))).first().text().trim(), timestamp = Date.parse(value); return { element, key: Number.isFinite(timestamp) ? timestamp : 0, originalIndex, index }; })); - items.sort(((e, n) => "default" === t ? e.key - n.key : n.key - e.key || e.originalIndex - n.originalIndex || e.index - n.index)); - const sortedElements = items.map((item => item.element)); + items.sort(((/** @type {SortItem} */ e, /** @type {SortItem} */ n) => "default" === t ? e.key - n.key : n.key - e.key || e.originalIndex - n.originalIndex || e.index - n.index)); + const sortedElements = items.map((/** @type {SortItem} */ item) => item.element); "default" === t ? $(sortedElements).appendTo(d) : d.empty().append(sortedElements); } isHitShowPage() { return isHitShowPage(window.location); } + /** 自渲染榜单页(热播/Top250):排序为页内状态,初始固定“默认”,与全局 sortMethod 解耦。 */ + isOwnedRankingPage() { + return this.isHitShowPage() || window.location.search.includes("handleTop=1"); + } + /** 当前生效的排序方式:自有榜单页取页内覆盖(缺省默认),普通列表页取全局设置。 */ + activeSortMethod() { + if (this.isOwnedRankingPage()) return this.ownedRankingSortOverride || "default"; + return this.getRuntimeService("settings").snapshot().sortMethod || "default"; + } + /** 应用一次排序选择:自有榜单页只改页内覆盖,普通列表页写全局设置。 */ + /** @param {string} method */ + async selectSortMethod(method) { + if (this.isOwnedRankingPage()) { + this.ownedRankingSortOverride = method; + await this.sortItems(method); + return; + } + await this.getRuntimeService("settings").set("sortMethod", method); + await this.sortItems(); + } isFc2ListPage() { return r && "/advanced_search" === window.location.pathname && "3" === new URLSearchParams(window.location.search).get("type"); } + supportsSorting() { + if (this.supportsLiveSorting()) return true; + if (r && (o.includes("handle") || o.includes("advanced_search"))) return false; + return true; + } supportsLiveSorting() { - return this.isHitShowPage() || this.isFc2ListPage(); + // 自有榜单页(热播/Top250)由渲染方加载评分数据,与 FC2 列表一样支持页内即时排序 + return this.isOwnedRankingPage() || this.isFc2ListPage(); + } + /** 构造批量任务范围:actor 页携带演员名,搜索/列表页不要求演员名且不写入搜索关键词。 */ + buildBatchScope() { + const isActorPage = r ? o.includes("/actors/") : o.includes("/star/"); + if (!isActorPage) return { kind: "search", displayName: "当前搜索条件", recordName: "" }; + const info = this.getActressPageInfo(); + return { kind: "actor", displayName: info?.name || "", recordName: info?.name || "" }; } async openWaitCheck() { let e = this.getSelector(); const t = await storageManager.getSetting("waitCheckCount", 5); let a = 0; - const listPage = this.getBean("ListPagePlugin"); + const listPage = this.getOptionalDependency("ListPagePlugin"); + if (!listPage) return void show.info("列表功能已禁用"); for (const element of $(e.itemSelector).toArray()) { if (a >= t) break; const item = $(element), flags = normalizeStateFlags(JSON.parse(item.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(item.attr("data-jhs-visibility") || "{}"); @@ -166,4 +262,4 @@ class ListPageButtonPlugin extends BasePlugin { } 0 === a && show.info("没有需鉴定的视频"); } -} +} \ No newline at end of file diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index 2245292b..8829ee25 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -1,16 +1,36 @@ -const _e = async (e, t = "ja", n = "zh-CN") => { - if (!e) throw new Error("翻译文本不能为空"); - const a = "https://translate-pa.googleapis.com/v1/translate?" + new URLSearchParams({ - "params.client": "gtx", - dataTypes: "TRANSLATION", - key: "AIzaSyDLEeFI5OtFBwYBIoK_jj5m32rZK5CkCXA", - "query.sourceLanguage": t, - "query.targetLanguage": n, - "query.text": e - }), i = await fetch(a); - if (!i.ok) throw new Error(`${i.status} ${i.statusText}`); - return (await i.json()).translation; -}, Te = { +// @ts-check + +import { B, C, _, b, escapeHtml, k, l, normalizeCarNum, o, r, u, y } from "../../core/constants.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { mapLimit, safePlay } from "../../core/feature-helpers.js"; +import { requestHostPage } from "../../core/host-page-request.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { readCardNames, readListItem } from "../../core/list-item-reader.js"; +import { isHitShowPage } from "../../core/site-context.js"; +import { hasAnyState, legacyActionToFlag, normalizeStateFlags } from "../../core/state-model.js"; +import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, isHardHidden, normalizeQuickFilterKey, shouldShowItem } from "../../features/list/list-filters.js"; +import { createListEvaluationContext, evaluateListItem, findMatchedTitleKeyword } from "../../features/list/list-evaluator.js"; +import { scanAllPages } from "../../features/list/batch-scanner.js"; +import { endBatchRun, isActiveBatchRun, isBatchRunCancelled, requestCancelBatchRun, tryBeginBatchRun } from "../../features/list/batch-coordinator.js"; + +/** @typedef {Record} ListRecord */ +/** @typedef {any} JQueryHandle */ +/** @returns {NonNullable} */ +function getListEventBus() { + if (!jhsEventBus) throw new Error("List EventBus 未初始化"); + return jhsEventBus; +} + +/** Normalize public list-card translation inputs (DOM Element or jQuery handle). */ +/** @param {any} item @returns {any} */ +function normalizeCard(item) { + return item?.jquery ? item : $(item); +} + +/** settings-changed 中需要触发列表全量重判定的 key;其余(主题/Preview/截图/布局等)由各自 Feature 精确处理。 */ +const LIST_EFFECT_KEYS = new Set([ "tagPosition", "defaultQuickFilterTab" ]); + +const Te = { IS_FILTERED: { text: u, color: "var(--jhs-status-filter)", @@ -77,79 +97,159 @@ const _e = async (e, t = "ja", n = "zh-CN") => { } }; -const QUICK_FILTER_LABELS = Object.freeze({ - all: "全部", waitCheck: "待鉴定", favorite: "收藏", hasDown: "下载", hasWatch: "已看", - blockedItems: "屏蔽项", favoriteUndownloaded: "收藏未下载", favoriteUnwatched: "收藏未观看", - downloadedUnwatched: "下载未观看", recent7d: "最近 7 天" -}), PRIMARY_QUICK_FILTERS = Object.freeze([ "all", "waitCheck", "favorite", "hasDown", "hasWatch" ]), -SECONDARY_QUICK_FILTERS = Object.freeze([ "blockedItems", "favoriteUndownloaded", "favoriteUnwatched", "downloadedUnwatched", "recent7d" ]), -VALID_QUICK_FILTERS = new Set([ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ]); - -/** 将旧筛选键收敛为当前唯一业务键。 */ -function normalizeQuickFilterKey(value) { - if ("filter" === value) return "blockedItems"; - return VALID_QUICK_FILTERS.has(value) ? value : "waitCheck"; -} - -/** 判断列表卡片是否因状态或规则被硬屏蔽。 */ -function isHardHidden(flags, visibilityReasons = {}) { - return Boolean(flags.blocked || visibilityReasons.keyword || visibilityReasons.actorBlacklist || visibilityReasons.actressBlacklist); -} - -function matchesQuickFilter(filter, flags, { visibilityReasons = {}, recent = !1 } = {}) { - const normalizedFilter = normalizeQuickFilterKey(filter), hardHidden = isHardHidden(flags, visibilityReasons); - if ("blockedItems" === normalizedFilter) return hardHidden; - if (hardHidden) return !1; - if ("all" === normalizedFilter) return !0; - if ("waitCheck" === normalizedFilter) return !hasAnyState(flags); - if ("favorite" === normalizedFilter) return !!flags.favorite; - if ("hasDown" === normalizedFilter) return !!flags.downloaded; - if ("hasWatch" === normalizedFilter) return !!flags.watched; - if ("favoriteUndownloaded" === normalizedFilter) return !!flags.favorite && !flags.downloaded; - if ("favoriteUnwatched" === normalizedFilter) return !!flags.favorite && !flags.watched; - if ("downloadedUnwatched" === normalizedFilter) return !!flags.downloaded && !flags.watched; - return "recent7d" === normalizedFilter && recent; -} - -function shouldShowItem({ filter, flags, visibilityReasons, recent }) { - return matchesQuickFilter(filter, flags, { visibilityReasons, recent }); -} - -class ListPagePlugin extends BasePlugin { +export class ListPagePlugin extends BasePlugin { async initCss() { - return ``; + return ``; } constructor() { - super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), - i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), - i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), - i(this, "currentPageTotalCount", 0), i(this, "cache", null), i(this, "translationPending", new Map), - i(this, "filterContext", null), i(this, "pendingItems", new Set), i(this, "processTimer", null), - i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), - i(this, "_debouncedTranslateWrite", null), i(this, "itemIndex", new Map), i(this, "recountFrame", null); + super(...arguments); + this.currentPageFilterCount = 0; + this.currentPageFavoriteCount = 0; + this.currentPageHasDownCount = 0; + this.currentPageHasWatchCount = 0; + this.currentPageKeywordFilterCount = 0; + this.currentPageActorFilterCount = 0; + this.currentPageWaitCheckCount = 0; + this.currentPageTotalCount = 0; + /** @type {any} */ this.filterContext = null; + /** @type {Set} */ this.pendingItems = new Set(); + /** @type {ReturnType | null} */ this.processTimer = null; + /** @type {IntersectionObserver | null} */ this.hdImageObserver = null; + /** @type {Map void>} */ this.hdPendingCleanups = new Map(); + /** @type {string | null} */ this.hoverPreviewState = null; + this.writeQueue = Promise.resolve(); + /** @type {Map>} */ this.itemIndex = new Map(); + /** @type {number | null} */ this.recountFrame = null; + /** @type {any} */ this.$currentImage = null; + /** @type {number} */ this.translationGeneration = 0; + /** @type {number} */ this.listGeneration = 0; + /** @type {number} */ this.filterRevision = 0; + this.refreshRunning = false; + this.refreshDirty = false; + this.refreshAllRequested = false; + this.refreshVisibilityRequested = false; + /** @type {Set} */ this.refreshItems = new Set(); + /** @type {Promise | null} */ this.refreshPromise = null; } getName() { return "ListPagePlugin"; } async handle() { - if (!window.isListPage || isHitShowPage()) return; - const refreshAll = async () => { - this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); - const e = this.getBean("HistoryPlugin"); - e.tableObj && e.tableObj.setData(); + if (!window.isListPage) return; + const scope = await this.getRuntimeService("scope")(); + const settingsService = this.getRuntimeService("settings"); + const onSettingsChanged = (/** @type {any} */ event) => { + const names = /** @type {string[] | undefined} */ (event.detail?.names) || []; + if (names.includes("hoverBigImg")) this.configureHoverPreview(settingsService.snapshot().hoverBigImg === "yes" ? "yes" : "no"); + }; + settingsService.addEventListener("settings.changed", onSettingsChanged); + scope.addCleanup((() => settingsService.removeEventListener("settings.changed", onSettingsChanged))); + const refreshAll = async (/** @type {any} */ payload = {}) => { + // 6.5 收敛:settings-changed 只对列表判定相关 key 全量刷新;主题/Preview/截图/布局 + // 等由各自 Feature 精确处理,不再触发 doFilter/applyVisibility/History setData。 + const changedNames = /** @type {string[] | undefined} */ (payload?.changedNames); + if (changedNames && !changedNames.some((name) => LIST_EFFECT_KEYS.has(name))) return; + await this.requestListRefresh({ reason: "event-refresh", full: true }); }; - jhsEventBus.on("legacy-refresh", refreshAll), jhsEventBus.on("blacklist-rules-changed", refreshAll), jhsEventBus.on("filter-rules-changed", refreshAll), jhsEventBus.on("settings-changed", refreshAll), - jhsEventBus.on("car-state-changed", (async payload => { - this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); + [ "legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed" ].forEach((type => scope.addCleanup(getListEventBus().on(type, refreshAll)))), + scope.addCleanup(getListEventBus().on("car-state-changed", (async payload => { const items = this.getIndexedItems(payload.carNums || []); - items.length && (await this.doFilterItems(items), this.applyVisibility(items)); - const history = this.getBean("HistoryPlugin"); - history.tableObj && history.tableObj.setData(); - })), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), - await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), + await this.requestListRefresh({ items, reason: "car-state-changed", full: true }); + }))); + // 自有榜单页没有宿主列表 DOM 可劫持,DOM 管线由 HitShowPlugin.initializeRenderedList 驱动; + // Top250(handleTop)与 v6.4.1 一致走完整管线(其自有列表在同步前缀阶段已创建); + // 但状态标记/黑名单/设置变更的刷新监听在两类页面都必须保持活跃,否则卡片标记永不更新。 + if (isHitShowPage()) return; + const hoverBigImg = settingsService.snapshot().hoverBigImg; + this.configureHoverPreview(hoverBigImg === _ ? "yes" : "no"); + this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(); + const revision = this.advanceListGeneration(); + await this.doFilter(revision), await this.createQuickFilter(), this.reconcileListItems(null, revision), await this.bindClick(), this.rememberTagExpand(), - $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: !1 }), - this.checkDom(); + $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await getListEventBus().emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: !1 }), + this.checkDom(scope), scope.addCleanup((() => { + this.processTimer && clearTimeout(this.processTimer), this.processTimer = null, this.pendingItems.clear(); + this.hdImageObserver?.disconnect(), this.hdImageObserver = null; + this.hdPendingCleanups.forEach((cleanup => cleanup())), this.hdPendingCleanups.clear(); + this.configureHoverPreview("no"); + this.recountFrame && (globalThis.cancelAnimationFrame?.(this.recountFrame) ?? clearTimeout(this.recountFrame)), this.recountFrame = null; + this.advanceListGeneration(), this.refreshDirty = false, this.refreshAllRequested = false, this.refreshVisibilityRequested = false, this.refreshItems.clear(); + $(this.getSelector().boxSelector).off(".jhsMovieDetail .jhsListVideo .jhsListMenu"), $("#jhs-quick-filter").off(), this.itemIndex.clear(); + })); + } + /** @returns {string} */ + advanceListGeneration() { + this.listGeneration += 1; + return this.captureListRevision(); + } + /** @returns {string} */ + captureListRevision() { + return `${this.listGeneration}:${this.filterRevision}`; + } + /** @param {string} revision */ + isCurrentListGeneration(revision) { + return revision === this.captureListRevision(); + } + /** @param {string} phase @param {number | null} [itemCount] */ + recordListPhase(phase, itemCount = null) { + const diagnostics = /** @type {any} */ (globalThis).__jhsBrowserDiagnostics; + if (!diagnostics) return; + const phases = diagnostics.listPhases ||= []; + phases.push({ phase, generation: this.listGeneration, filterRevision: this.filterRevision, activeQuickFilter: this.activeQuickFilter || "waitCheck", itemCount: itemCount ?? $(this.getSelector().itemSelector).length }); + phases.length > 200 && phases.splice(0, phases.length - 200); + } + /** @param {Element[] | null} items @param {string} revision */ + reconcileListItems(items, revision) { + if (!this.isCurrentListGeneration(revision)) return !1; + this.applyVisibility(items); + return !0; + } + /** Coalesce refresh requests and guarantee a final pass for the latest list revision. */ + /** @param {{items?: Element[] | null, reason?: string, full?: boolean, visibilityOnly?: boolean}} [options] */ + requestListRefresh({ items = null, reason = "refresh", full = false, visibilityOnly = false } = {}) { + (full || items !== null) && this.advanceListGeneration(); + this.refreshDirty = true; + if (full) this.refreshAllRequested = true; + else if (visibilityOnly) this.refreshVisibilityRequested = true; + else (items || []).forEach((item) => this.refreshItems.add(item)); + this.recordListPhase("refresh-request:" + reason); + if (this.refreshRunning) return this.refreshPromise; + this.refreshRunning = true; + const run = async () => { + try { + while (this.refreshDirty) { + const fullRefresh = this.refreshAllRequested, visibilityOnly = this.refreshVisibilityRequested && !fullRefresh, itemsToRefresh = [ ...this.refreshItems ]; + this.refreshDirty = false, this.refreshAllRequested = false, this.refreshVisibilityRequested = false, this.refreshItems.clear(); + const revision = this.captureListRevision(); + this.recordListPhase("refresh-start", fullRefresh ? null : itemsToRefresh.length); + let filtered; + if (fullRefresh) { + this.filterContext = null; + storageManager._invalidateCache(storageManager.car_list_key); + filtered = await this.doFilter(revision); + } else if (visibilityOnly) { + filtered = !0; + } else { + filtered = await this.doFilterItems(itemsToRefresh.filter((item) => item.isConnected), revision); + } + if (!this.isCurrentListGeneration(revision)) { + this.refreshDirty = true, this.refreshAllRequested = true; + continue; + } + filtered !== !1 && this.reconcileListItems(fullRefresh ? null : itemsToRefresh, revision); + if (fullRefresh) { + const history = this.getOptionalDependency("HistoryPlugin"); + history?.tableObj && history.tableObj.setData(); + } + this.recordListPhase("refresh-end"); + } + return !0; + } finally { + this.refreshRunning = false; + this.refreshPromise = null; + } + }; + return this.refreshPromise = run(); } async createQuickFilter() { if ($("#jhs-quick-filter").length) return; @@ -166,15 +266,15 @@ class ListPagePlugin extends BasePlugin { const root = $("#jhs-quick-filter"), toggle = root.find(".jhs-quick-filter__toggle"), menu = root.find(".jhs-quick-filter__menu"), closeMenu = (restoreFocus = !1) => { menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), restoreFocus && toggle.trigger("focus"); }; - root.on("click", ".jhs-segmented__item", (event => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))) - .on("keydown", ".jhs-segmented__item", (event => { + root.on("click", ".jhs-segmented__item", ((/** @type {any} */ event) => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))) + .on("keydown", ".jhs-segmented__item", ((/** @type {any} */ event) => { if (![ "ArrowLeft", "ArrowRight", "Home", "End" ].includes(event.key)) return; event.preventDefault(); const tabs = root.find(".jhs-segmented__item"), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; tabs.eq(next).trigger("click").trigger("focus"); - })).on("click", ".jhs-filter-option", (event => { + })).on("click", ".jhs-filter-option", ((/** @type {any} */ event) => { this.setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(!0); - })).on("keydown", ".jhs-filter-option", (event => { + })).on("keydown", ".jhs-filter-option", ((/** @type {any} */ event) => { const items = menu.find(".jhs-filter-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), closeMenu(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; @@ -182,24 +282,27 @@ class ListPagePlugin extends BasePlugin { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })); - toggle.on("click", (event => { + toggle.on("click", ((/** @type {any} */ event) => { event.preventDefault(), event.stopPropagation(); const open = !menu.hasClass("is-open"); menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && (menu.find('[aria-checked="true"]').first().length ? menu.find('[aria-checked="true"]').first() : menu.find(".jhs-filter-option").first()).trigger("focus"); - })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", (event => { + })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", ((/** @type {any} */ event) => { $(event.target).closest(root).length || closeMenu(); })); - this.setQuickFilter(await storageManager.getSetting("defaultQuickFilterTab", "waitCheck")); + this.activeQuickFilter || (this.activeQuickFilter = normalizeQuickFilterKey(await storageManager.getSetting("defaultQuickFilterTab", "waitCheck"))), this.applyVisibility(), this.syncQuickFilterUi(); } + /** @param {Element[] | null} [items] */ applyVisibility(items = null) { const e = this.activeQuickFilter || "waitCheck", t = this.getSelector().itemSelector; - (items ? $(items) : $(t)).each((function() { - const t = $(this), flags = normalizeStateFlags(JSON.parse(t.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t.attr("data-jhs-visibility") || "{}"), recent = "yes" === t.attr("data-jhs-recent"); + const elements = items ? $(items) : $(t); + this.recordListPhase("applyVisibility", elements.length), elements.each(((/** @type {number} */ index, /** @type {Element} */ element) => { + const t = $(element), flags = normalizeStateFlags(JSON.parse(t.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t.attr("data-jhs-visibility") || "{}"), recent = "yes" === t.attr("data-jhs-recent"); shouldShowItem({ filter: e, flags, visibilityReasons, recent }) ? t.show() : t.hide(); })); } + /** @param {unknown} filter @param {{ syncUi?: boolean }} [options] */ setQuickFilter(filter, { syncUi = !0 } = {}) { - this.activeQuickFilter = normalizeQuickFilterKey(filter), this.applyVisibility(), syncUi && this.syncQuickFilterUi(); + this.filterRevision += 1, this.activeQuickFilter = normalizeQuickFilterKey(filter), this.recordListPhase("setQuickFilter"), void this.requestListRefresh({ reason: "quick-filter", visibilityOnly: true })?.catch((/** @type {unknown} */ error) => clog.error("列表筛选刷新失败", error)), this.reconcileListItems(null, this.captureListRevision()), syncUi && this.syncQuickFilterUi(); } syncQuickFilterUi() { const filter = normalizeQuickFilterKey(this.activeQuickFilter), isPrimary = PRIMARY_QUICK_FILTERS.includes(filter), root = $("#jhs-quick-filter"), tabs = root.find(".jhs-segmented__item"), options = root.find(".jhs-filter-option"); @@ -213,48 +316,57 @@ class ListPagePlugin extends BasePlugin { if (!window.location.href.includes("actors")) return; const e = $(".tag-expand"); if (0 === e.length) return; - const t = "jhs_tag_expand", n = "true" === localStorage.getItem(t), a = $(".actor-tags .content"); + const storage = this.getRuntimeService("storage"), t = "jhs_tag_expand", n = "true" === storage.getLocal(t), a = $(".actor-tags .content"); n && a.hasClass("collapse") && e[0].click(), e.on("click", (function() { const e = !$(".actor-tags .content").hasClass("collapse"); - clog.debug("触发"), localStorage.setItem(t, e.toString()); + clog.debug("触发"), storage.setLocal(t, e.toString()); })); } - checkDom() { - if (!window.isListPage || isHitShowPage()) return; + /** @param {any} [scope] */ + checkDom(scope = null) { + // 自有榜单页(热播/Top250)由渲染方自行管理容器与增量,不走宿主容器探测 + if (!window.isListPage || isHitShowPage() || window.location.search.includes("handleTop=1")) return; const e = this.getSelector(), t = document.querySelector(e.boxSelector); if (!t) return void clog.error("没有找到容器节点!"); - const a = new MutationObserver((records => { + if (!scope) return; + scope.observe(t, ((/** @type {MutationRecord[]} */ records) => { for (const record of records) { - this.removeIndexedItems(record.removedNodes); + this.removeIndexedItems(record.removedNodes), record.addedNodes.length && (this.advanceListGeneration(), this.recordListPhase("dom-added", record.addedNodes.length)); for (const node of record.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; - node.matches?.(e.itemSelector) && "true" !== node.dataset.jhsProcessed && this.pendingItems.add(node), - node.querySelectorAll?.(e.itemSelector).forEach((item => { - "true" !== item.dataset.jhsProcessed && this.pendingItems.add(item); - })); + const element = /** @type {Element} */ (node); + element.matches?.(e.itemSelector) && "true" !== /** @type {HTMLElement} */ (element).dataset.jhsProcessed && this.pendingItems.add(element), + element.querySelectorAll?.(e.itemSelector).forEach((/** @type {Element} */ item) => { + "true" !== /** @type {HTMLElement} */ (item).dataset.jhsProcessed && this.pendingItems.add(item); + }); } } this.pendingItems.size && (this.processTimer && clearTimeout(this.processTimer), this.processTimer = setTimeout((() => { - const items = [ ...this.pendingItems ].filter((item => item.isConnected && "true" !== item.dataset.jhsProcessed)); - this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items).catch((error => clog.error("列表增量处理失败", error))); + const items = [ ...this.pendingItems ].filter((/** @type {Element} */ item) => item.isConnected && "true" !== /** @type {HTMLElement} */ (item).dataset.jhsProcessed), revision = this.captureListRevision(); + this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items, revision).catch((/** @type {unknown} */ error) => clog.error("列表增量处理失败", error)); }), 100)); - })); - a.observe(t, { + }), { childList: !0, subtree: !1 }); } - async processAddedItems(items) { - const selector = this.getSelector(), covers = items.flatMap((item => [ ...item.querySelectorAll(selector.coverImgSelector) ])); - this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), - await this.getBean("ListPageButtonPlugin").sortItems(), await this.getBean("CoverButtonPlugin").addSvgBtn(items), - items.forEach((item => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: !1 }), this.getBean("AutoPagePlugin").checkLoad(); + /** @param {Element[]} items @param {string} [revision] @returns {Promise} */ + async processAddedItems(items, revision = this.captureListRevision()) { + const selector = this.getSelector(), covers = items.flatMap((/** @type {Element} */ item) => [ ...item.querySelectorAll(selector.coverImgSelector) ]); + this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items); + const filtered = await this.requestListRefresh({ items, reason: "dom-added" }); + if (!filtered) return; + await this.getOptionalDependency("ListPageButtonPlugin")?.sortItems?.(), await this.getOptionalDependency("CoverButtonPlugin")?.addSvgBtn?.(items), + items.forEach((/** @type {Element} */ item) => /** @type {HTMLElement} */ (item).dataset.jhsProcessed = "true"), this.indexItems(items), await getListEventBus().emit("list-items-added", { items }, { broadcast: !1 }), this.getOptionalDependency("AutoPagePlugin")?.checkLoad?.(); } rebuildItemIndex() { - this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); + this.itemIndex.clear(); + const items = $(this.getSelector().itemSelector).toArray(); + this.indexItems(items), this.recordListPhase("rebuildItemIndex", items.length); } + /** @param {Element[]} items */ indexItems(items) { - items.forEach((item => { + items.forEach((/** @type {Element} */ item) => { try { const key = normalizeCarNum(this.findCarNumAndHref($(item)).carNum); if (!key) return; @@ -263,72 +375,86 @@ class ListPagePlugin extends BasePlugin { } catch (error) { clog.debug("列表项索引跳过无效卡片", error); } - })); + }); } + /** @param {NodeList | Node[]} nodes */ removeIndexedItems(nodes) { const removed = new Set; - Array.from(nodes || []).forEach((node => { - node.nodeType === Node.ELEMENT_NODE && (removed.add(node), node.querySelectorAll?.(this.getSelector().itemSelector).forEach((item => removed.add(item)))); - })); + Array.from(nodes || []).forEach((/** @type {Node} */ node) => { + const element = /** @type {Element} */ (node); + node.nodeType === Node.ELEMENT_NODE && (removed.add(element), element.querySelectorAll?.(this.getSelector().itemSelector).forEach((/** @type {Element} */ item) => removed.add(item))); + }); if (!removed.size) return; - this.itemIndex.forEach(((items, key) => { - items.forEach((item => { removed.has(item) && items.delete(item); })), items.size || this.itemIndex.delete(key); + this.itemIndex.forEach(((/** @type {Set} */ items, /** @type {string} */ key) => { + items.forEach((/** @type {Element} */ item) => { removed.has(item) && items.delete(item); }), items.size || this.itemIndex.delete(key); })); } + /** @param {unknown[]} carNums */ getIndexedItems(carNums) { const result = new Set; - carNums.map(normalizeCarNum).forEach((key => { + carNums.map(normalizeCarNum).forEach((/** @type {string | null} */ key) => { + if (!key) return; const items = this.itemIndex.get(key); - items?.forEach((item => item.isConnected ? result.add(item) : items.delete(item))), items && !items.size && this.itemIndex.delete(key); - })); + items?.forEach((/** @type {Element} */ item) => item.isConnected ? result.add(item) : items.delete(item)), items && !items.size && this.itemIndex.delete(key); + }); return [ ...result ]; } + /** @param {Element[] | null} [items] */ fixBusTitleBox(items = null) { if (!l) return; - (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach((e => { + (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach((/** @type {Element} */ e) => { var t; let n = $(e); if (n.find(".avatar-box").length > 0) return; const a = (null == (t = n.find("img").attr("title")) ? void 0 : t.trim()) || ""; n.find(".photo-info span:first").contents().first().wrap(`${a}`), n.find("br").remove(); - })); + }); } cleanRepeatId() { if (!l) return; $("#waterfall_h").removeAttr("id").attr("id", "no-page"); const e = $('[id="waterfall"]'); - 0 !== e.length && e.each((function() { - const e = $(this); + 0 !== e.length && e.each(((/** @type {number} */ index, /** @type {Element} */ element) => { + const e = $(element); if (!e.hasClass("masonry")) { e.children().insertAfter(e), e.remove(); } })); } - async doFilter() { - return this.doFilterItems(); + async doFilter(revision = this.captureListRevision()) { + return this.doFilterItems(null, revision); } - async doFilterItems(items = null) { - if (!window.isListPage) return; + /** @param {Element[] | null} [items] @param {string} [revision] */ + async doFilterItems(items = null, revision = this.captureListRevision()) { + if (!window.isListPage) return !1; let e = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); - e.length && (await this.filterMovieList(e), l && setTimeout((() => { - this.getBean("BusImgPlugin").logImageHeightsByRow().catch((e => clog.error("JavBus图片高度修正失败", e))); - }))); + if (!e.length) return !0; + const filtered = await this.filterMovieList(e, revision); + return filtered && l && setTimeout((() => { + this.getOptionalDependency("BusImgPlugin")?.logImageHeightsByRow?.(this.getRuntimeService("settings").snapshot()).catch((/** @type {unknown} */ e) => clog.error("JavBus图片高度修正失败", e)); + })), filtered; } async yieldListFrame() { - await new Promise((e => { + await new Promise((/** @type {(value: void) => void} */ e) => { window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e))) : setTimeout(e); - })); + }); } - findMatchedTitleKeyword(e, t, n) { - for (const a of e) if (t.includes(a) || n.startsWith(a)) return a; - return null; + /** 供页面展示与跨页批量操作共用的唯一判定上下文。 */ + async createEvaluationContext() { + return createListEvaluationContext(await this.getFilterContext()); } async getFilterContext() { if (this.filterContext) return this.filterContext; - const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([ storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), stateService.getActivityLog() ]), actorCarNumToNameMap = new Map, actressCarNumToNameMap = new Map, recentCarNums = new Set; + const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([ storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), this.getRuntimeService("state").getActivityLog() ]), actorCarNumToNameMap = new Map, actressCarNumToNameMap = new Map, recentCarNums = new Set; const cutoff = Date.now() - 7 * 864e5; - activity.entries.filter((entry => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach((entry => entry.changes.filter((change => "reverted" !== change.undoState && change.fields?.some((field => field.startsWith("stateFlags."))))).forEach((change => recentCarNums.add(change.carNum))))); + activity.entries + .filter((/** @type {ListRecord} */ entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff) + .forEach((/** @type {ListRecord} */ entry) => { + entry.changes + .filter((/** @type {ListRecord} */ change) => "reverted" !== change.undoState && change.fields?.some((/** @type {string} */ field) => field.startsWith("stateFlags."))) + .forEach((/** @type {ListRecord} */ change) => recentCarNums.add(change.carNum)); + }); for (const item of blacklistCars) { const role = blacklistMap.get(item.starId)?.role; if (!role) { @@ -342,7 +468,7 @@ class ListPagePlugin extends BasePlugin { } collectCurrentPageSummary() { const summary = { total: 0, pending: 0, blockedItems: 0, favorite: 0, downloaded: 0, watched: 0, debug: { manualBlocked: 0, keywordBlocked: 0, actorBlocked: 0, actressBlocked: 0 } }; - $(this.getSelector().itemSelector).each(((e, item) => { + $(this.getSelector().itemSelector).each(((/** @type {number} */ e, /** @type {Element} */ item) => { const card = $(item); if (l && card.find(".avatar-box").length > 0) return; const flags = normalizeStateFlags(JSON.parse(card.attr("data-jhs-flags") || "{}")), reasons = JSON.parse(card.attr("data-jhs-visibility") || "{}"), hardHidden = isHardHidden(flags, reasons); @@ -364,33 +490,51 @@ class ListPagePlugin extends BasePlugin { } scheduleRecount() { if (this.recountFrame) return; - const schedule = window.requestAnimationFrame || (callback => setTimeout(callback, 0)); + const schedule = window.requestAnimationFrame || ((/** @type {FrameRequestCallback} */ callback) => setTimeout(callback, 0)); this.recountFrame = schedule((() => { this.recountFrame = null, this.recountStatuses(); })); } + /** @param {(Element|JQueryHandle)[]} e */ async translateListItems(e) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; + if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) !== _) return; + let failed = 0; + /** @type {unknown} */ + let firstError = null; await mapLimit(e, 3, (async (item, index) => { - try { index > 0 && index % 8 == 0 && await this.yieldListFrame(), await this.translate(item); } catch (error) { clog.error("列表标题翻译失败", error); } + try { + if (index > 0 && index % 8 === 0) await this.yieldListFrame(); + await this.translate(normalizeCard(item)); + } catch (error) { + failed++; + firstError ??= error; + } })); + if (failed) { + clog.error(`列表标题翻译失败 ${failed} 项`, firstError); + } } - async filterMovieList(e) { + /** @param {Element[]} e @param {string} [revision] */ + async filterMovieList(e, revision = this.captureListRevision()) { + this.recordListPhase("doFilter-start", e.length); + if (!this.isCurrentListGeneration(revision)) return !1; + const activeFilter = this.activeQuickFilter || "waitCheck"; utils.time("累计耗费时间"), utils.time("读取数据耗时"); const {titleKeywords: n, settings: s, carMap: m, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v} = await this.getFilterContext(), o = utils.time("读取数据耗时"); + if (!this.isCurrentListGeneration(revision)) return !1; + const evaluationContext = createListEvaluationContext({ titleKeywords: n, settings: s, carMap: m, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v }); utils.time("组装数据耗时"); const b = utils.time("组装数据耗时"), P = (null == s ? void 0 : s.tagPosition) || "rightTop"; - const O = n.filter((e => e)); this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); const R = []; for (let n = 0; n < e.length; n++) { + if (!this.isCurrentListGeneration(revision)) return !1; n > 0 && n % 12 == 0 && await this.yieldListFrame(); let t = $(e[n]); if (l && t.find(".avatar-box").length > 0) continue; - const {carNum: a, title: i} = this.findCarNumAndHref(t), record = m.get(a), flags = normalizeStateFlags(record?.stateFlags), actorFiltered = f.has(a), actressFiltered = v.has(a), keyword = this.findMatchedTitleKeyword(O, i, a), visibilityReasons = { keyword: !!keyword, actorBlacklist: actorFiltered, actressBlacklist: actressFiltered }; - const hardHidden = isHardHidden(flags, visibilityReasons); + const {carNum: a, title: i} = this.findCarNumAndHref(t), {flags, visibilityReasons, hardHidden} = evaluateListItem({ carNum: a, title: i }, evaluationContext, { filter: activeFilter }), keyword = visibilityReasons.keyword ? findMatchedTitleKeyword(evaluationContext.titleKeywords, i, a) : null; t.attr("data-jhs-flags", JSON.stringify(flags)).attr("data-jhs-visibility", JSON.stringify(visibilityReasons)).attr("data-jhs-recent", recent.has(a) ? _ : C).attr("data-jhs-tag-position", P); const signature = JSON.stringify({ flags, visibilityReasons, P }); if (t.attr("data-jhs-state-signature") !== signature) { @@ -398,7 +542,7 @@ class ListPagePlugin extends BasePlugin { const badgeDefs = [ [ flags.blocked, Te.IS_FILTERED, "单番号屏蔽" ], [ flags.favorite, Te.IS_FAVORITE, "" ], [ flags.downloaded, Te.IS_HAS_DOWN, "" ], [ flags.watched, Te.IS_HAS_WATCH, "" ], [ visibilityReasons.keyword, Te.IS_KEYWORD_FILTER, keyword || "未知" ], [ visibilityReasons.actorBlacklist, Te.IS_ACTOR_FILTER, f.get(a) || "" ], [ visibilityReasons.actressBlacklist, Te.IS_ACTRESS_FILTER, v.get(a) || "" ] - ].filter((item => item[0])); + ].filter((/** @type {any[]} */ item) => item[0]); if (badgeDefs.length) { const box = $(``); badgeDefs.forEach((([, definition, tip]) => { @@ -410,182 +554,287 @@ class ListPagePlugin extends BasePlugin { } hardHidden || R.push(t); } - this.scheduleRecount(), void this.translateListItems(R).catch((e => clog.error("列表页翻译任务失败", e))); + this.scheduleRecount(), void this.translateListItems(R).catch((/** @type {unknown} */ e) => clog.error("列表页翻译任务失败", e)); + this.recordListPhase("doFilter-end", e.length); const D = utils.time("处理页面耗时"), A = utils.time("累计耗费时间"); - clog.log(`\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    ${o}${b}
    ${D}${A}
    项目数量项目数量
    屏蔽单番号${this.currentPageFilterCount}收藏${this.currentPageFavoriteCount}
    屏蔽演员${this.currentPageActorFilterCount}已下载${this.currentPageHasDownCount}
    屏蔽关键词${this.currentPageKeywordFilterCount}已观看${this.currentPageHasWatchCount}
    待鉴定${this.currentPageWaitCheckCount}
    总数${this.currentPageTotalCount}
    \n `); + clog.html(`\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    ${o}${b}
    ${D}${A}
    项目数量项目数量
    屏蔽单番号${this.currentPageFilterCount}收藏${this.currentPageFavoriteCount}
    屏蔽演员${this.currentPageActorFilterCount}已下载${this.currentPageHasDownCount}
    屏蔽关键词${this.currentPageKeywordFilterCount}已观看${this.currentPageHasWatchCount}
    待鉴定${this.currentPageWaitCheckCount}
    总数${this.currentPageTotalCount}
    \n `); + } + /** + * Batch-mark every item matching the current quick filter across all pages. + * 语义:全部 = 包含 hard-hidden 的真全集;其他筛选 = 全部分页中符合该筛选的项。 + * @param {{ kind?: "actor" | "search", displayName?: string, recordName?: string }} scope + * @param {string} flag @param {{ filter?: unknown, confirm?: boolean, root?: any }} [options] + */ + async batchSaveAllVideos(scope, flag, { filter = this.activeQuickFilter || "waitCheck", confirm = true, root = null } = {}) { + const stateFlag = legacyActionToFlag(flag); + if (!stateFlag) throw new TypeError(`不支持的状态操作: ${flag}`); + const normalized = normalizeQuickFilterKey(filter), filterLabel = QUICK_FILTER_LABELS[normalized]; + const actorScope = scope?.kind === "actor", recordName = actorScope ? String(scope.recordName || "") : ""; + // 自有榜单页(热播/Top250)的分页是自绘按钮,跨页抓取不可用:批量语义收敛为“当前榜单页” + const isOwnedRankingPage = isHitShowPage(window.location) || window.location.search.includes("handleTop=1"); + const confirmText = isOwnedRankingPage + ? ("all" === normalized + ? "将处理当前榜单页内的所有作品,包括屏蔽项。" + : `将处理当前榜单页内符合「${filterLabel}」筛选的作品。`) + : ("all" === normalized + ? "将处理当前搜索全部分页的所有作品,包括屏蔽项。" + : `将处理当前搜索全部分页中符合「${filterLabel}」筛选的作品。`); + if (confirm) { + const proceed = await new Promise((resolve) => utils.q(null, confirmText, () => resolve(true), () => resolve(false))); + if (!proceed) return { cancelled: true }; + } + // Single Flight:已有批量任务时不启动第二个任务,避免互相打断/污染进度。 + const run = tryBeginBatchRun(); + if (!run) { + show.error("已有批量任务正在执行"); + return { cancelled: true, busy: true }; + } + /** @type {any} */ let progressElement; + const setProgress = (/** @type {string} */ text) => { + const label = progressElement?.find(".jhs-batch-progress__label"); + label && label.length ? label.text(text) : clog.debug(text); + }; + const onProgress = (/** @type {{ page: number, scanned: number, matched: number }} */ {page, scanned, matched}) => { + setProgress(`已扫描 ${page} 页 · 匹配 ${matched} 项`); + clog.debug(`批量扫描第 ${page} 页 · 已扫描 ${scanned} · 匹配 ${matched}`); + }; + try { + const runtimeScope = await this.getRuntimeService("scope")(), context = await this.createEvaluationContext(); + const isCancelled = () => isBatchRunCancelled(run) || Boolean(runtimeScope?.disposed); + progressElement = this.showBatchProgress(run); + this.setBatchButtonsDisabled(true); + const records = await scanAllPages({ + startDom: root ? $(root) : $(document), + currentUrl: isOwnedRankingPage ? null : (root ? null : window.location.href), + firstPageUrl: isOwnedRankingPage ? null : (root ? null : (this.getRuntimeService("host")?.resolveFirstPageUrl?.(window.location.href) ?? window.location.href)), + itemSelector: this.getSelector().requestDomItemSelector, + nextPageSelector: this.getSelector().nextPageSelector, + fetchHtml: async (/** @type {string} */ url) => requestHostPage(this.getRuntimeService("http"), url, runtimeScope), + parseItem: (/** @type {any} */ item) => { + const parsed = readListItem(item); + return actorScope ? parsed : { ...parsed, names: readCardNames(item) }; + }, + evaluate: (/** @type {any} */ item) => evaluateListItem({ carNum: item.carNum, title: item.title || "" }, context, { filter: normalized }), + isCancelled, + onProgress, + }); + if (isCancelled()) { + progressElement?.remove(); + return { cancelled: true }; + } + // 写入阶段不可取消:禁用按钮并给出明确文案,避免出现半批数据状态。 + progressElement?.find("#jhs-batch-cancel").prop("disabled", true).attr("title", "正在写入,无法取消"); + setProgress("正在写入,无法取消…"); + let updated = 0; + for (let index = 0; index < records.length; index += 75) { + if (isCancelled()) break; + const chunk = records.slice(index, index + 75); + await this.getRuntimeService("state").patch(chunk.map((item) => item.carNum), { [stateFlag]: !0 }, { + type: "actor-page-batch-state", + records: chunk.map((item) => ({ carNum: item.carNum, url: item.url || "", names: item.names ?? recordName, publishTime: item.publishTime || "", fc2Source: item.fc2Source })), + }); + updated += chunk.length; + setProgress(`已更新 ${updated}/${records.length} 项`); + } + setProgress(`批量完成:匹配 ${records.length} 项 · 已更新 ${updated} 项`); + setTimeout(() => progressElement?.remove(), 1800); + return { matched: records.length, updated }; + } catch (error) { + clog.error("批量操作失败:", error); + setProgress("批量操作失败"); + progressElement?.addClass("jhs-batch-progress--error"); + setTimeout(() => progressElement?.remove(), 2500); + throw error; + } finally { + if (isActiveBatchRun(run)) endBatchRun(run); + this.setBatchButtonsDisabled(false); + } + } + /** 批量任务期间禁用/恢复所有批量入口(视觉禁用但保持可点击,点击时由 handler 提示并拒绝启动)。 */ + /** @param {boolean} disabled */ + setBatchButtonsDisabled(disabled) { + $("#favoriteAllVideo, #hasDownAllVideo, #filterAllVideo").attr("aria-disabled", String(disabled)).toggleClass("jhs-batch-busy", disabled); + } + /** 批量进度浮层(带取消按钮);返回可更新的状态元素。每次绑定当前 run 的取消 handler。 */ + /** @param {any} run */ + showBatchProgress(run) { + let element = $("#jhs-batch-progress"); + if (!element.length) { + element = $('
    ').appendTo("body"); + element.append(''); + } + element.find("#jhs-batch-cancel").off("click").on("click", () => requestCancelBatchRun(run)); + element.find(".jhs-batch-progress__label").remove().end().prepend($('').text("正在扫描…")); + return element; } async bindClick() { - let e = this.getSelector(); - this.bindMovieDetailNavigation(e.boxSelector), $(e.boxSelector).on("click", ".item video", (async e => { + const e = this.getSelector(); + this.bindMovieDetailNavigation(e.boxSelector), $(e.boxSelector).off("click.jhsListVideo").on("click.jhsListVideo", ".item video", (async (/** @type {any} */ e) => { const t = e.currentTarget; t.paused ? await safePlay(t, { context: "列表视频", notify: !0 }) : t.pause(), e.preventDefault(), e.stopPropagation(); - })), $(e.boxSelector).on("contextmenu", ".item img, .item video", (async e => { + })), $(e.boxSelector).off("contextmenu.jhsListMenu").on("contextmenu.jhsListMenu", ".item img, .item video", (async (/** @type {any} */ e) => { try { e.preventDefault(); const t = $(e.target).closest(".item"), {carNum: n, url: a, publishTime: i, fc2Source} = this.findCarNumAndHref(t); let s = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"), o = ""; - s.length && (o = s.text().trim().split(",")[0].replace("(無碼)", "")), utils.q(e, `是否屏蔽番号 ${n}?`, (async () => { + s.length && (o = s.text().trim().split(",")[0].replace("(無碼)", "")), utils.q(e, `是否屏蔽番号 ${escapeHtml(n)}?`, (async () => { try { - o || (o = await this.parseActressName(a)), await stateService.patch(n, { blocked: !0 }, { record: { carNum: n, url: a, names: o, publishTime: i, fc2Source } }), show.ok("操作成功"); + o || (o = await this.parseActressName(a)), await this.getRuntimeService("state").patch(n, { blocked: !0 }, { record: { carNum: n, url: a, names: o, publishTime: i, fc2Source } }), show.ok("操作成功"); } catch (s) { clog.error("屏蔽操作失败:", s), show.error("操作失败"); } })); } catch (t) { clog.error("右键菜单处理失败:", t); } })); } /** 从任意列表卡片进入统一详情导航。 */ + /** @param {any} item @param {{ event?: MouseEvent | null, autoplay?: boolean, newTab?: boolean }} [options] */ async openMovieDetail(item, { event = null, autoplay = !1, newTab = !1 } = {}) { - const card = item?.jquery ? item : $(item), {carNum, aHref, fc2Source} = this.findCarNumAndHref(card); + const card = item?.jquery ? item : $(item), {carNum, aHref} = this.findCarNumAndHref(card); if (!carNum || !aHref) return; const shouldOpenTab = newTab || !!event && (event.ctrlKey || event.metaKey || 1 === event.button); - if (carNum.includes("FC2-")) { - const movieId = this.parseMovieId(aHref); - return shouldOpenTab ? this.getBean("Fc2Plugin").openFc2Page(movieId, carNum, aHref, { event, newTab: !0 }, { source: fc2Source }) : this.getBean("Fc2Plugin").openFc2Dialog(movieId, carNum, aHref, { source: fc2Source }); - } + if (carNum.includes("FC2-")) return; const destination = new URL(aHref, window.location.origin); autoplay && destination.searchParams.set("autoPlay", "1"), utils.openPage(destination.href, carNum, !0, { event, newTab: shouldOpenTab }), this.$currentImage = null; } /** 为宿主与合成列表统一绑定左键、修饰键和中键导航。 */ + /** @param {any} container */ bindMovieDetailNavigation(container) { const root = $(container), selector = ".item img, .item .video-title"; - root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, (event => { + root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, ((/** @type {any} */ event) => { if ("auxclick" === event.type && 1 !== event.button || "click" === event.type && event.button && 0 !== event.button) return; if (event.shiftKey || event.altKey || $(event.target).closest("div.meta-buttons,[class^='jhs-match-']").length) return; event.preventDefault(), event.stopPropagation(); void this.openMovieDetail($(event.currentTarget).closest(".item"), { event }).catch((error => clog.error("打开影片详情失败", error))); })); } + /** @param {string} e */ async parseActressName(e) { let t = null; if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e); - const n = await gmHttp.get(e), a = utils.htmlTo$dom(n); - r ? t = a.find(".female").prev().map(((e, t) => $(t).text())).get().join(" ") : l && (t = a.find('span[onmouseover*="star_"] a').map(((e, t) => $(t).text())).get().join(" ")), + const scope = await this.getRuntimeService("scope")(); + const n = await requestHostPage(this.getRuntimeService("http"), e, scope), a = utils.htmlTo$dom(n); + r ? t = a.find(".female").prev().map(((/** @type {number} */ e, /** @type {Element} */ t) => $(t).text())).get().join(" ") : l && (t = a.find('span[onmouseover*="star_"] a').map(((/** @type {number} */ e, /** @type {Element} */ t) => $(t).text())).get().join(" ")), clog.debug("解析到名称:", t); } return t; } + /** @param {JQueryHandle} e */ findCarNumAndHref(e) { - var t, n; - let a, i, s, o = e.find("a"), r = o.attr("href"), l = e.find(".video-title"); - if (l.length > 0) { - let t = l.find("strong"); - t.length > 0 && (a = t.text().trim()), i = o.attr("title") ? o.attr("title").trim() : a ? l.text().replace(a, "").trim() : l.text().trim(), - s = e.find(".meta").text().trim(); - } - if (!a) { - let o = e.find("img"); - r && o.length > 0 && (i = (null == (t = o.attr("title")) ? void 0 : t.trim()) || (null == (n = o.attr("data-title")) ? void 0 : n.trim())); - const l = e => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e); - s = e.find("date").map(((e, t) => $(t).text().trim())).get().find(l), a = e.find("date").map(((e, t) => $(t).text().trim())).get().find((e => !l(e))); - } - if (!a) { - const e = "提取番号信息失败"; - throw show.error(e), new Error(e); - } - return { - carNum: normalizeCarNum(a), - aHref: r, - url: r, - title: i, - publishTime: s, - fc2Source: [ "fc2", "123av" ].includes(e.attr("data-jhs-fc2-source")) ? e.attr("data-jhs-fc2-source") : "fc2" - }; + try { return readListItem(e); } catch (error) { show.error("提取番号信息失败"); throw error; } } + /** @param {string} e */ showCarNumBox(e) { - const t = $(".movie-list .item").toArray().find((t => $(t).find(".video-title strong").text() === e)); + const t = this.getRuntimeService("host").locateListItems().find((/** @type {Element} */ t) => $(t).find(".video-title strong").text() === e); if (t) { const n = $(t); n.attr("data-hide") === "yes" && (n.show(), n.removeAttr("data-hide")); } } + /** @param {HTMLImageElement} e */ _replaceSingleHdImg(e) { if ("true" === e.dataset.hdReplaced) return; + const originalSrc = e.currentSrc || e.src; + let upgradedSrc = e.dataset.full || originalSrc; if (r) { - const isJavdbCdn = /jdbstatic\.com|javdb\.com/i.test(e.src); + const isJavdbCdn = /jdbstatic\.com|javdb\.com/i.test(originalSrc); if (isJavdbCdn) { - const originalSrc = e.src; - e.src = e.src.replace("thumbs", "covers"); + upgradedSrc = upgradedSrc.replace("thumbs", "covers"); + e.dataset.full = upgradedSrc; e.dataset.hdReplaced = "true"; e.title = ""; - e.onerror = function() { - if (this.src !== originalSrc) { - this.src = originalSrc; - this.onerror = null; - } - }; } } else if (l) { const t = /\/(imgs|pics)\/(thumb|thumbs)\//, n = /(\.jpg|\.jpeg|\.png)$/i; - t.test(e.src) ? (e.src = e.src.replace(t, "/$1/cover/").replace(n, "_b$1"), e.dataset.hdReplaced = "true", - e.dataset.title = e.title, e.title = "") : /ps(\.jpg|\.jpeg|\.png)$/i.test(e.src) && (e.src = e.src.replace(/ps(\.jpg|\.jpeg|\.png)$/i, "pl$1"), + t.test(originalSrc) ? (upgradedSrc = upgradedSrc.replace(t, "/$1/cover/").replace(n, "_b$1"), e.dataset.full = upgradedSrc, e.dataset.hdReplaced = "true", + e.dataset.title = e.title, e.title = "") : /ps(\.jpg|\.jpeg|\.png)$/i.test(originalSrc) && (upgradedSrc = upgradedSrc.replace(/ps(\.jpg|\.jpeg|\.png)$/i, "pl$1"), e.dataset.full = upgradedSrc, e.dataset.hdReplaced = "true", e.dataset.title = e.title, e.title = ""); } + if ("true" !== e.dataset.hdReplaced || upgradedSrc === originalSrc) return; + e.src = upgradedSrc; + e.onerror = function() { + if (this.src !== originalSrc) this.src = originalSrc; + this.onerror = null; + }; + } + /** 先完成当前缩略图请求,再升级为高清图,避免取消首屏可见内容。 @param {HTMLImageElement} image */ + _scheduleHdUpgrade(image) { + if ("true" === image.dataset.hdReplaced || "true" === image.dataset.jhsHdPending) return; + if (image.complete) return void this._replaceSingleHdImg(image); + image.dataset.jhsHdPending = "true"; + const finish = () => { + image.removeEventListener("load", finish), image.removeEventListener("error", finish), delete image.dataset.jhsHdPending, this.hdPendingCleanups.delete(image), this._replaceSingleHdImg(image); + }, cleanup = () => { + image.removeEventListener("load", finish), image.removeEventListener("error", finish), delete image.dataset.jhsHdPending; + }; + this.hdPendingCleanups.set(image, cleanup), image.addEventListener("load", finish, { once: !0 }), image.addEventListener("error", finish, { once: !0 }); } + /** @param {any} [e] */ replaceHdImg(e) { if (e && "string" == typeof e.jquery && (e = e.toArray()), e || (e = document.querySelectorAll(this.getSelector().coverImgSelector)), !e.length) return; - const t = Array.from(e).filter((e => "true" !== e.dataset.hdReplaced && "true" !== e.dataset.jhsHdObserved)); + const t = Array.from(/** @type {Iterable} */ (e)).filter((/** @type {HTMLImageElement} */ e) => "true" !== e.dataset.hdReplaced && "true" !== e.dataset.jhsHdObserved); if ("IntersectionObserver" in window && !this.hdImageObserver) this.hdImageObserver = new IntersectionObserver((entries => { entries.forEach((entry => { - entry.isIntersecting && (this.hdImageObserver.unobserve(entry.target), delete entry.target.dataset.jhsHdObserved, - this._replaceSingleHdImg(entry.target)); + const image = /** @type {HTMLImageElement} */ (entry.target); + entry.isIntersecting && (this.hdImageObserver?.unobserve(image), delete image.dataset.jhsHdObserved, + this._scheduleHdUpgrade(image)); })); }), { rootMargin: "200px" }); - for (const image of t) this.hdEagerRemaining > 0 ? (this.hdEagerRemaining--, this._replaceSingleHdImg(image)) : this.hdImageObserver ? (image.dataset.jhsHdObserved = "true", - this.hdImageObserver.observe(image)) : this._replaceSingleHdImg(image); - storageManager.getSetting("hoverBigImg", C).then((e => { - e === _ && (window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ - selector: this.getSelector().coverImgSelector - })); - })); - } - getTranslationCache() { - if (this.cache && "object" == typeof this.cache && !Array.isArray(this.cache)) return this.cache; - try { this.cache = JSON.parse(localStorage.getItem("jhs_translate") || "{}"); } catch (error) { - clog.warn("列表翻译缓存无法解析,已忽略旧缓存", error), this.cache = {}; + for (const image of t) image.decoding = "async", this.hdImageObserver ? (image.dataset.jhsHdObserved = "true", + this.hdImageObserver.observe(image)) : this._scheduleHdUpgrade(image); + } + /** hoverBigImg 唯一生命周期入口:ON→绑定,OFF→销毁。 */ + /** @param {string} enabled */ + configureHoverPreview(enabled) { + const runtimeWindow = /** @type {any} */ (window); + if (this.hoverPreviewState === enabled && ("no" === enabled || runtimeWindow.imageHoverPreviewObj)) return; + if (runtimeWindow.imageHoverPreviewObj) { + runtimeWindow.imageHoverPreviewObj.destroy?.(), runtimeWindow.imageHoverPreviewObj = null; } - return this.cache && "object" == typeof this.cache && !Array.isArray(this.cache) ? this.cache : this.cache = {}; - } - scheduleTranslationWrite() { - this._debouncedTranslateWrite && clearTimeout(this._debouncedTranslateWrite), this._debouncedTranslateWrite = setTimeout((() => { - localStorage.setItem("jhs_translate", JSON.stringify(this.getTranslationCache())); - }), 500); + if ("yes" === enabled) runtimeWindow.imageHoverPreviewObj = new ImageHoverPreview({ selector: this.getSelector().coverImgSelector }); + this.hoverPreviewState = enabled; } + /** @param {JQueryHandle} e @param {string} t @param {string} n */ applyTranslatedTitle(e, t, n) { const a = e.find(".video-title"); - r ? (a.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(n) || (this.textContent = " " + t + " "); + r ? (a.contents().each(((/** @type {number} */ index, /** @type {Node} */ node) => { + 3 !== node.nodeType || "" === (node.textContent || "").trim() || (node.textContent || "").includes(n) || (node.textContent = " " + t + " "); })), a.attr("title", t)) : a.text(t), e.attr("data-jhs-translation-key", n); } - async translate(e) { + /** 使在途列表翻译作废(Translate OFF 时调用)。 */ + invalidateTranslations() { + this.translationGeneration++; + } + /** @param {Element|JQueryHandle} input */ + async translate(input) { + const e = input?.jquery ? input : $(input); + if (!e.length) return; let t, n, a = e.find(".video-title"); - if (r ? (t = a.contents().filter(((e, t) => 3 === t.nodeType && "" !== t.textContent.trim())).text().trim(), + if (r ? (t = a.contents().filter(((/** @type {number} */ e, /** @type {Node} */ t) => 3 === t.nodeType && "" !== (t.textContent || "").trim())).text().trim(), n = e.find(".video-title strong").text().trim()) : (t = (e.find("img").attr("data-title") || "").trim(), n = (e.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t || !n) return; - const cache = this.getTranslationCache(); - if (cache[n]) return void this.applyTranslatedTitle(e, cache[n], n); - let pending = this.translationPending.get(n); - if (!pending) { - pending = _e(t).then((translated => (cache[n] = translated, this.scheduleTranslationWrite(), translated))).finally((() => this.translationPending.delete(n))), - this.translationPending.set(n, pending); - } - this.applyTranslatedTitle(e, await pending, n); + const generation = this.translationGeneration, settings = this.getRuntimeService("settings"); + const scope = await this.getRuntimeService("scope")(); + const translated = await this.getRuntimeService("translation").translate(t, { cacheAlias: n, scope }); + if (generation !== this.translationGeneration || (settings?.snapshot?.().translateTitle ?? _) !== _) return; + this.applyTranslatedTitle(e, translated, n); } async revertTranslation() { - $(this.getSelector().itemSelector).toArray().forEach((e => { + $(this.getSelector().itemSelector).toArray().forEach((/** @type {Element} */ e) => { let t = $(e); const n = t.find(".box").attr("title") || t.find(".video-title").attr("title") || t.find("img").attr("data-title"); + /** @type {string | undefined} */ let a; r && (a = t.find(".video-title strong").text().trim()); const i = t.find(".video-title"); - i.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(a) || (this.textContent = " " + n + " "); + i.contents().each(((/** @type {number} */ index, /** @type {Node} */ node) => { + 3 !== node.nodeType || !n || "" === (node.textContent || "").trim() || a && (node.textContent || "").includes(a) || (node.textContent = " " + n + " "); })), i.removeAttr("title"); - })); + }); } addJumpPageControl() { const e = "gemini-jump-page-control"; @@ -608,7 +857,7 @@ class ListPagePlugin extends BasePlugin { const t = new URL(window.location.href); t.searchParams.set("page", e.toString()), window.location.href = t.toString(); }; - a.on("click", s), n.on("keypress", (function(e) { + a.on("click", s), n.on("keypress", (function(/** @type {any} */ e) { 13 === e.which && (s(), e.preventDefault()); })); } diff --git a/src/plugins/status/mobile-bottom-bar.js b/src/plugins/status/mobile-bottom-bar.js index a077977d..88f05255 100644 --- a/src/plugins/status/mobile-bottom-bar.js +++ b/src/plugins/status/mobile-bottom-bar.js @@ -1,7 +1,16 @@ -class MobileBottomBarPlugin extends BasePlugin { +// @ts-check + +import { k, m, r, v, y } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { normalizeStateFlags } from "../../core/state-model.js"; +import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, normalizeQuickFilterKey } from "../../features/list/list-filters.js"; + +export class MobileBottomBarPlugin extends BasePlugin { constructor() { super(...arguments); this._fabGeneration = 0; + /** @type {Array<{ element: Element, parent: Node | null, next: Node | null }>} */ + this._commandBarSources = []; } getName() { return "MobileBottomBarPlugin"; @@ -35,6 +44,17 @@ class MobileBottomBarPlugin extends BasePlugin { user-select: none; -webkit-user-select: none; } + html.jhs-fab-mounted { + scroll-padding-bottom: calc(104px + env(safe-area-inset-bottom, 0px)); + } + #jhs-fab-safe-area { + display: block; + width: 100%; + height: calc(104px + env(safe-area-inset-bottom, 0px)); + clear: both; + visibility: hidden; + pointer-events: none; + } #jhs-fab:active { transform: scale(0.9); } @@ -177,13 +197,19 @@ class MobileBottomBarPlugin extends BasePlugin { .jhs-commandbar__left { flex-basis:100%; } .jhs-commandbar__right { margin-left:auto; } } - @media (max-width: 768px) { + @media (max-width: 767px) { .jhs-page-commandbar { display: none; } } `; } async handle() { - if (!utils.isMobileMode()) return; + const profile = this.getRuntimeService("profile"), scope = await this.getRuntimeService("scope")(); + scope.listen(profile, "profile.changed", () => this.syncSurfaces()); + this.syncSurfaces(); + } + mountBottomBar() { + if ($("#jhs-fab").length) return; + document.documentElement.classList.add("jhs-fab-mounted"); // 添加遮罩 const backdrop = $('
    ').appendTo("body"); // 添加菜单 @@ -191,59 +217,97 @@ class MobileBottomBarPlugin extends BasePlugin { $("body").append(menu); // 添加 FAB 按钮 const fab = $('').appendTo("body"); + $('').appendTo("body"); this.bindEvents(fab, backdrop); } + unmountBottomBar() { + this._fabGeneration++; + $("#jhs-fab, #jhs-fab-menu, #jhs-fab-safe-area, .jhs-fab-backdrop").remove(); + document.documentElement.classList.remove("jhs-fab-mounted"); + } async afterPluginsReady() { - this.buildCommandBar(); + this.syncSurfaces(); } - /** 将列表页分散的 JHS 控件收敛为单一命令栏。 */ - buildCommandBar() { - if (!window.isListPage || $("#jhs-page-commandbar").length) return; - const commandbar = $(` - `); - const listBox = r ? $(this.getSelector().boxSelector).first() : $(".masonry").first(); - if (!listBox.length) return void clog.warn("JHS 页面工具栏未创建:列表容器尚未就绪"); - listBox.before(commandbar); + /** 统一桌面/移动两套 UI Surface:compact 只保留 FAB,regular/wide 只保留桌面工具栏与设置入口。 */ + syncSurfaces() { + const compact = this.getRuntimeService("profile").current() === "compact"; + const setting = this.getOptionalDependency("SettingPlugin"); + if (compact) { + this.unmountDesktopCommandBar(); + setting?.unmountDesktopSettingNav?.(); + this.mountBottomBar(); + } else { + this.unmountBottomBar(); + this.mountDesktopCommandBar(); + setting?.mountDesktopSettingNav?.(); + } + } + mountDesktopCommandBar() { + if (!window.isListPage) return; + const existing = $("#jhs-page-commandbar"); + // 插件并发启动时,部分按钮可能晚于命令栏创建:已存在时补收后到的控件,而不是直接返回 + existing.length ? this.collectCommandControls(existing) : this.buildCommandBar(); + } + /** 将当前页面中尚未收拢的来源控件按类别放入命令栏(可重复调用)。 */ + collectCommandControls(/** @type {any} */ commandbar) { + const isCollected = (/** @type {string} */ selector) => { + const item = $(selector).first(); + return !item.length || !!item.closest("#jhs-page-commandbar").length; + }; + const remember = (/** @type {Element} */ element) => { + (this._commandBarSources ||= []).push({ element, parent: null, next: null }); + }; const left = commandbar.find(".jhs-commandbar__left"), right = commandbar.find(".jhs-commandbar__right"); - const primary = $('
    '); + if (!left.length || !right.length) return; + if (!commandbar.find(".jhs-commandbar__primary").length && !isCollected("#waitCheckBtn")) left.append('
    '); + const primary = commandbar.find(".jhs-commandbar__primary"); [ "#waitCheckBtn", "#newVideoBtn", "#historyBtn" ].forEach((selector => { + if (isCollected(selector)) return; const item = $(selector).first(); - item.length && item.attr("class", "jhs-btn jhs-btn--secondary").removeAttr("role tabindex").detach().appendTo(primary); + item.length && (remember(item[0]), item.attr("class", "jhs-btn jhs-btn--secondary").removeAttr("role tabindex").detach().appendTo(primary)); })); primary.children().length && left.append(primary); - const more = $('
    '); + if (!commandbar.find(".jhs-commandbar__more").length && ![ "#statsBtn", "#blacklistBtn" ].every(isCollected)) { + commandbar.find(".jhs-commandbar__left").append('
    '); + } + const more = commandbar.find(".jhs-commandbar__more"); [ "#statsBtn", "#blacklistBtn" ].forEach((selector => { + if (isCollected(selector)) return; const item = $(selector).first(); - item.length && item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(more.find(".jhs-commandbar__menu")); + item.length && (remember(item[0]), item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(more.find(".jhs-commandbar__menu"))); })); - more.find(".jhs-commandbar__menu").children().length && left.append(more); + more.find(".jhs-commandbar__menu").children().length && more.appendTo(left); const quickFilter = $("#jhs-quick-filter").first(); - quickFilter.length && left.append($('
    ').append(quickFilter.detach())); + quickFilter.length && !quickFilter.closest("#jhs-page-commandbar").length && (remember(quickFilter[0]), left.append($('
    ').append(quickFilter.detach()))); const contextItem = $("#addBlacklistBtn").first(); - contextItem.length && contextItem.attr("class", "jhs-btn jhs-btn--secondary").removeAttr("role tabindex").detach().appendTo($('
    ').appendTo(right)); + contextItem.length && !contextItem.closest("#jhs-page-commandbar").length && (remember(contextItem[0]), contextItem.attr("class", "jhs-btn jhs-btn--secondary").removeAttr("role tabindex").detach().appendTo($('
    ').appendTo(right))); const sort = $(".jhs-sort-control").first(); - if (sort.length) { + if (sort.length && !sort.closest("#jhs-page-commandbar").length) { const view = $(''); - sort.detach().appendTo(view), right.append(view); + remember(sort[0]), sort.detach().appendTo(view), right.append(view); } - const batch = $('
    '); + if (!commandbar.find(".jhs-commandbar__batch").length && ![ "#filterAllVideo", "#favoriteAllVideo", "#hasDownAllVideo" ].every(isCollected)) { + right.append('
    '); + } + const batch = commandbar.find(".jhs-commandbar__batch"); [ "#filterAllVideo", "#favoriteAllVideo", "#hasDownAllVideo" ].forEach((selector => { + if (isCollected(selector)) return; const item = $(selector).first(); - item.length && item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(batch.find(".jhs-commandbar__menu")); + item.length && (remember(item[0]), item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(batch.find(".jhs-commandbar__menu"))); })); - batch.find(".jhs-commandbar__menu").children().length && right.append(batch); - $(".jhs-list-btn-row").filter((function() { return !$(this).children().length; })).remove(); - commandbar.find(".jhs-commandbar__more, .jhs-commandbar__batch").each((function() { - const container = $(this), toggle = container.find(".jhs-commandbar__menu-toggle"), menu = container.find(".jhs-commandbar__menu"); - toggle.on("click", (event => { + batch.find(".jhs-commandbar__menu").children().length && batch.appendTo(right); + // 控件从隐藏 parking 或原始位置进入 commandbar;unmount 时统一移入隐藏 parking。 + commandbar.find(".jhs-commandbar__more, .jhs-commandbar__batch").each(((/** @type {number} */ index, /** @type {Element} */ element) => { + const container = $(element); + if (container.data("jhsCommandbarBound")) return; + container.data("jhsCommandbarBound", !0); + const toggle = container.find(".jhs-commandbar__menu-toggle"), menu = container.find(".jhs-commandbar__menu"); + toggle.on("click", ((/** @type {any} */ event) => { event.stopPropagation(); const open = !menu.hasClass("is-open"); commandbar.find(".jhs-commandbar__menu").removeClass("is-open"), commandbar.find(".jhs-commandbar__menu-toggle").attr("aria-expanded", "false"), menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.children().first().trigger("focus"); - })), menu.on("keydown", "[role='menuitem']", (event => { + })), menu.on("keydown", "[role='menuitem']", ((/** @type {any} */ event) => { const items = menu.find("[role='menuitem']"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), menu.removeClass("is-open"), toggle.attr("aria-expanded", "false").trigger("focus"); if ("Tab" === event.key) return menu.removeClass("is-open"), void toggle.attr("aria-expanded", "false"); @@ -255,33 +319,85 @@ class MobileBottomBarPlugin extends BasePlugin { menu.removeClass("is-open"), toggle.attr("aria-expanded", "false").trigger("focus"); })); })); - $(document).off("click.jhsCommandbar").on("click.jhsCommandbar", (event => { + $(document).off("click.jhsCommandbar").on("click.jhsCommandbar", ((/** @type {any} */ event) => { $(event.target).closest(".jhs-commandbar__more, .jhs-commandbar__batch").length || (commandbar.find(".jhs-commandbar__menu").removeClass("is-open"), commandbar.find(".jhs-commandbar__menu-toggle").attr("aria-expanded", "false")); })); - this.getBean("ListPagePlugin")?.syncQuickFilterUi(); + // 按钮被搬走后,隐藏宿主导航中的空壳容器,避免 hover 时出现空导航项 + $(".historyBtnBox").each(((/** @type {number} */ _index, /** @type {HTMLElement} */ element) => { + element.children.length || (element.style.display = "none"); + })); + this.getOptionalDependency("ListPagePlugin")?.syncQuickFilterUi?.(); + } + /** 收集当前页面中所有桌面工具栏来源控件(无论是否曾被 buildCommandBar 收拢)。 */ + collectDesktopCommandSources() { + const selectors = [ + "#waitCheckBtn", "#newVideoBtn", "#historyBtn", + "#statsBtn", "#blacklistBtn", "#jhs-quick-filter", "#addBlacklistBtn", + ".jhs-sort-control", "#filterAllVideo", "#favoriteAllVideo", "#hasDownAllVideo", + ]; + const collected = []; + for (const selector of selectors) { + const item = $(selector).first(); + if (!item.length) continue; + if (item.closest("#jhs-page-commandbar, #jhs-commandbar-parking").length) continue; + collected.push({ element: item[0], parent: null, next: null }); + } + return collected; + } + + /** 卸载桌面命令栏并把被收拢的控件移入隐藏 parking,避免 compact 下与 FAB 双 Surface 共存。 */ + unmountDesktopCommandBar() { + let parking = $("#jhs-commandbar-parking"); + if (!parking.length) { + parking = $('').appendTo("body"); + } + const existing = new Set((this._commandBarSources || []).map((source) => source.element)); + for (const source of this.collectDesktopCommandSources()) { + if (!existing.has(source.element)) this._commandBarSources.push(source); + } + const sources = [ ...(this._commandBarSources || []) ]; + for (const source of sources) { + const element = source.element; + if (element?.isConnected) parking.append(element); + } + this._commandBarSources = []; + $("#jhs-page-commandbar").remove(); + $(document).off("click.jhsCommandbar"); + } + /** 将列表页分散的 JHS 控件收敛为单一命令栏。 */ + buildCommandBar() { + if (!window.isListPage || $("#jhs-page-commandbar").length) return; + this._commandBarSources = []; + const commandbar = $(` + `); + const listBox = r ? $(this.getSelector().boxSelector).first() : $(".masonry").first(); + if (!listBox.length) return void clog.warn("JHS 页面工具栏未创建:列表容器尚未就绪"); + listBox.before(commandbar); + this.collectCommandControls(commandbar); } /** 获取详情页番号 */ getCarNum() { try { - const basePlugin = this.getBean("DetailPageButtonPlugin"); - if (basePlugin?.parseMovieId) return basePlugin.parseMovieId(location.href); - const el = document.querySelector(".header, #video_id, .video-id"); - if (el) return el.textContent.trim(); + return this.getRuntimeService("host").readMovieRef?.()?.carNum || null; } catch (e) { clog.debug("移动端详情番号解析失败,已回退", e); } return null; } createMenu() { - const item = (action, label, attributes = "") => ``, group = content => `
    ${content}
    `, divider = ''; + const item = (/** @type {string} */ action, /** @type {string} */ label, /** @type {string} */ attributes = "") => ``, group = (/** @type {string} */ content) => `
    ${content}
    `, divider = ''; + const hasListPageButton = !!this.getOptionalDependency("ListPageButtonPlugin"), hasListPage = !!this.getOptionalDependency("ListPagePlugin"), hasNewVideo = !!this.getOptionalDependency("NewVideoPlugin"), hasBlacklist = !!this.getOptionalDependency("BlacklistPlugin"), hasSetting = !!this.getOptionalDependency("SettingPlugin"), hasDetailPageButton = !!this.getOptionalDependency("DetailPageButtonPlugin"), hasHighlightMagnet = !!this.getOptionalDependency("HighlightMagnetPlugin"), hasMagnetHub = !!this.getOptionalDependency("MagnetHubPlugin"), hasHistory = !!this.getOptionalDependency("HistoryPlugin"); let items; if (window.isListPage) { - const sortMethod = localStorage.getItem("jhs_sortMethod") || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getBean("ListPagePlugin")?.activeQuickFilter), + const requestedSortMethod = this.getOptionalDependency("ListPageButtonPlugin")?.activeSortMethod?.() ?? this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? /** @type {keyof typeof sortLabels} */ (requestedSortMethod) : "default", sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getOptionalDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); - items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; + items = group((hasListPageButton ? item("check", "开始鉴定") : "") + (hasNewVideo ? item("newVideo", "新作品") : "") + (hasBlacklist ? item("blacklist", "黑名单") : "") + (hasHistory ? item("history", "鉴定记录") : "") + (hasListPageButton ? item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') : "") + (hasListPage ? item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"') : "")) + divider + group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")) + (hasListPage ? `` : "") + (hasListPageButton ? `` : ""); } else if (window.isDetailPage) { const statusDefs = [ { action: "filter", icon: m, label: "屏蔽", key: "filter" }, { action: "fav", icon: v, label: "收藏", key: "fav" }, { action: "down", icon: y, label: "已下载", key: "down" }, { action: "watch", icon: k, label: "已观看", key: "watch" } ]; - items = group(statusDefs.map((definition => item(definition.action, `${definition.icon}`, `aria-label="${definition.label}" aria-pressed="false" data-label="${definition.label}"`))).join("")) + divider + group(item("magnetFilter", "磁力过滤") + item("magnet", "磁力搜索") + item("subtitle", "字幕")) + divider + group(item("logger", "运行日志") + item("setting", "设置")); - } else items = group(item("logger", "运行日志") + item("setting", "设置")); + items = group(hasDetailPageButton ? statusDefs.map((definition => item(definition.action, `${definition.icon}`, `aria-label="${definition.label}" aria-pressed="false" data-label="${definition.label}"`))).join("") : "") + divider + group((hasHighlightMagnet ? item("magnetFilter", "磁力过滤") : "") + (hasMagnetHub ? item("magnet", "磁力搜索") : "") + (hasDetailPageButton ? item("subtitle", "字幕") : "")) + divider + group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")); + } else items = group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")); return $(``); } /** 刷新详情页菜单的状态指示 */ @@ -292,20 +408,19 @@ class MobileBottomBarPlugin extends BasePlugin { const car = await storageManager.getCar(carNum); const menu = $(".jhs-fab-menu"); const colors = { filter: "var(--jhs-status-filter)", fav: "var(--jhs-status-fav)", down: "var(--jhs-status-down)", watch: "var(--jhs-status-watch)" }; - const flags = normalizeStateFlags(car?.stateFlags), activeKeys = new Set([ - flags.blocked && "filter", flags.favorite && "fav", flags.downloaded && "down", flags.watched && "watch" - ].filter(Boolean)); - menu.find(".jhs-fab-status-dot").each(function () { - const key = $(this).data("status-key"); - const item = $(this).closest(".jhs-fab-menu-item"); + const flags = normalizeStateFlags(car?.stateFlags), activeKeys = new Set(); + flags.blocked && activeKeys.add("filter"), flags.favorite && activeKeys.add("fav"), flags.downloaded && activeKeys.add("down"), flags.watched && activeKeys.add("watch"); + menu.find(".jhs-fab-status-dot").each(((/** @type {number} */ index, /** @type {HTMLElement} */ element) => { + const key = String($(element).data("status-key") || ""), item = $(element).closest(".jhs-fab-menu-item"); if (activeKeys.has(key)) { - $(this).css({ background: colors[key] || "var(--jhs-border-strong)" }), item.attr("aria-pressed", "true"); + $(element).css({ background: colors[/** @type {keyof typeof colors} */ (key)] || "var(--jhs-border-strong)" }), item.attr("aria-pressed", "true"); } else { - $(this).css({ background: "var(--jhs-border-strong)" }), item.attr("aria-pressed", "false"); + $(element).css({ background: "var(--jhs-border-strong)" }), item.attr("aria-pressed", "false"); } - }); + })); } catch (e) { clog.warn("移动端详情状态刷新失败", e); } } + /** @param {any} fab @param {any} backdrop */ bindEvents(fab, backdrop) { const menu = $(".jhs-fab-menu"), filterMenu = menu.find(".jhs-mobile-filter-menu"), sortMenu = menu.find(".jhs-mobile-sort-menu"), filterTrigger = menu.find('[data-action="quickFilter"]'), sortTrigger = menu.find('[data-action="sort"]'), closeFilterMenu = (returnFocus = !1) => { menu.removeClass("jhs-fab-filter-open"), filterTrigger.attr("aria-expanded", "false"), returnFocus && filterTrigger.trigger("focus"); @@ -331,10 +446,10 @@ class MobileBottomBarPlugin extends BasePlugin { backdrop.addClass("jhs-fab-backdrop-visible"); // 刷新排序标签 if (window.isListPage) { - const sortMethod = localStorage.getItem("jhs_sortMethod") || "default"; - const sortLabel = { default: "默认", rateCount: "评价人数", date: "时间" }[sortMethod]; + const requestedSortMethod = this.getOptionalDependency("ListPageButtonPlugin")?.activeSortMethod?.() ?? this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }; + const sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? /** @type {keyof typeof sortLabels} */ (requestedSortMethod) : "default", sortLabel = sortLabels[sortMethod]; menu.find('[data-action="sort"]').text(`排序: ${sortLabel}`); - this.getBean("ListPagePlugin")?.syncQuickFilterUi(); + this.getOptionalDependency("ListPagePlugin")?.syncQuickFilterUi?.(); } // 刷新详情页状态 if (window.isDetailPage) void this.refreshDetailStatus().catch((error => clog.warn("移动端详情状态刷新失败", error))); @@ -343,19 +458,19 @@ class MobileBottomBarPlugin extends BasePlugin { const self = this; const items = menu.find(".jhs-fab-menu-item"); items.first().trigger("focus"); - items.each(function (i) { - const el = $(this); + items.each(((/** @type {number} */ i, /** @type {Element} */ element) => { + const el = $(element); setTimeout(() => { if (gen === self._fabGeneration) el.addClass("jhs-fab-item-visible"); }, 30 + i * 35); - }); + })); } }; // FAB 点击切换 fab.on("click", toggleMenu); // 遮罩点击关闭 backdrop.on("click", (() => closeMenu(!0))); - menu.on("keydown", ".jhs-fab-menu-item", (event => { + menu.on("keydown", ".jhs-fab-menu-item", ((/** @type {any} */ event) => { const items = menu.find(".jhs-fab-menu-item"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), closeMenu(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; @@ -363,30 +478,41 @@ class MobileBottomBarPlugin extends BasePlugin { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })); - filterMenu.on("keydown", ".jhs-mobile-filter-option", (event => { + filterMenu.on("keydown", ".jhs-mobile-filter-option", ((/** @type {any} */ event) => { const items = filterMenu.find(".jhs-mobile-filter-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), closeFilterMenu(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", ".jhs-mobile-filter-option", (event => { - event.stopPropagation(), this.getBean("ListPagePlugin").setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(!0); + })).on("click", ".jhs-mobile-filter-option", ((/** @type {any} */ event) => { + event.stopPropagation(), this.getOptionalDependency("ListPagePlugin")?.setQuickFilter?.($(event.currentTarget).data("jhs-filter")), closeMenu(!0); })); - sortMenu.on("keydown", ".jhs-mobile-sort-option", (event => { + sortMenu.on("keydown", ".jhs-mobile-sort-option", ((/** @type {any} */ event) => { const items = sortMenu.find(".jhs-mobile-sort-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), closeSortMenu(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", ".jhs-mobile-sort-option", (event => { + })).on("click", ".jhs-mobile-sort-option", (async (/** @type {any} */ event) => { event.stopPropagation(); const value = $(event.currentTarget).data("jhs-sort"); - localStorage.setItem("jhs_sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), void this.getBean("ListPageButtonPlugin")?.sortItems?.(), closeMenu(!0); + const previousItem = sortMenu.find('.jhs-mobile-sort-option[aria-checked="true"]').first(); + sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"); + try { + // 自有榜单页走页内覆盖(不写全局设置),普通列表页写全局 sortMethod + await this.getOptionalDependency("ListPageButtonPlugin")?.selectSortMethod?.(value); + } catch (error) { + sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), previousItem.attr("aria-checked", "true"); + clog.error("排序设置保存失败,已恢复", error), show.error("排序设置保存失败,已恢复原设置"); + return; + } + const sortLabels = /** @type {Record} */ ({ default: "默认", rateCount: "评价人数", date: "时间" }); + menu.find('[data-action="sort"]').text(`排序: ${sortLabels[String(value)] ?? value}`), closeMenu(!0); })); // 菜单项点击 - menu.on("click", ".jhs-fab-menu-item", (e) => { + menu.on("click", ".jhs-fab-menu-item", (/** @type {any} */ e) => { const action = $(e.currentTarget).data("action"); if ("quickFilter" === action) { e.stopPropagation(), menu.addClass("jhs-fab-filter-open"), filterTrigger.attr("aria-expanded", "true"); @@ -402,17 +528,21 @@ class MobileBottomBarPlugin extends BasePlugin { void this.handleAction(action).catch((error => clog.error(`移动端操作 ${action || "unknown"} 失败`, error))); }); } + /** @param {unknown} action */ async handleAction(action) { switch (action) { // 列表页操作 case "check": - await this.getBean("ListPageButtonPlugin")?.openWaitCheck?.(); + await this.getOptionalDependency("ListPageButtonPlugin")?.openWaitCheck?.(); break; case "newVideo": - this.getBean("NewVideoPlugin")?.openDialog(); + this.getOptionalDependency("NewVideoPlugin")?.openDialog?.(); break; case "blacklist": - this.getBean("BlacklistPlugin")?.openBlacklistDialog(); + this.getOptionalDependency("BlacklistPlugin")?.openBlacklistDialog?.(); + break; + case "history": + await this.getOptionalDependency("HistoryPlugin")?.openHistory?.(); break; case "sort": break; @@ -440,7 +570,7 @@ class MobileBottomBarPlugin extends BasePlugin { break; // 通用 case "setting": - await this.getBean("SettingPlugin")?.openQuickSetting(); + await this.getOptionalDependency("SettingPlugin")?.openQuickSetting(); break; case "logger": clog.openDialog?.(); diff --git a/src/plugins/status/nav-bar.js b/src/plugins/status/nav-bar.js index bfc3b0ff..24ce3a3a 100644 --- a/src/plugins/status/nav-bar.js +++ b/src/plugins/status/nav-bar.js @@ -1,4 +1,9 @@ -class NavBarPlugin extends BasePlugin { +// @ts-check + +import { BasePlugin } from "../../core/plugin-manager.js"; +import { JhsSelect } from "../../core/ui-primitives.js"; + +export class NavBarPlugin extends BasePlugin { getName() { return "NavBarPlugin"; } @@ -13,54 +18,62 @@ class NavBarPlugin extends BasePlugin { $("#search-keyword").val(t), n && JhsSelect.setValue("#search-type", n), t && this.highlightKeyword(t); } } - highlightKeyword(e) { + highlightKeyword(/** @type {string} */ e) { const t = e.trim(); if (!t) return; const n = t.toLowerCase(); - $(".video-title strong, .actor-box strong").each((function() { - const e = $(this); + $(".video-title strong, .actor-box strong").each(((/** @type {number} */ _index, /** @type {Element} */ element) => { + const e = $(element); e.text().toLowerCase().includes(n) && e.addClass("highlight-red"); })); } hookSearch() { - $("#navbar-menu-hero").after('\n \n '), - $("#search-keyword").on("paste", (e => { - const t = e.originalEvent.clipboardData.items; + const hasSearchByImage = !!this.getOptionalDependency("SearchByImagePlugin"); + $("#navbar-menu-hero").after('\n \n '), + $("#search-keyword").on("paste", ((/** @type {any} */ e) => { + const t = e.originalEvent?.clipboardData?.items || []; for (let n = 0; n < t.length; n++) if (-1 !== t[n].type.indexOf("image")) { const e = t[n].getAsFile(); $("#search-keyword").blur(); - const a = this.getBean("SearchByImagePlugin"); + const a = this.getOptionalDependency("SearchByImagePlugin"); + if (!a) return void show.info("以图识图功能已禁用"); return void a.open((() => { a.handleImageFile(e), a.resetSearchUI(); })); } - })).on("keypress", (e => { + })).on("keypress", ((/** @type {KeyboardEvent} */ e) => { "Enter" === e.key && setTimeout((() => { $("#search-btn").click(); }), 0); - })), $("#search-btn").on("click", (e => { + })), $("#search-btn").on("click", ((/** @type {MouseEvent} */ e) => { let t = $("#search-keyword").val(), n = $("#search-type").val(); - "" !== t && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + t + "&f=" + n : window.open("/search?q=" + t + "&f=" + n)); - })), $("#search-img-btn").on("click", (() => { - this.getBean("SearchByImagePlugin").open(); + // 关键词须编码:含 &/#/+ 的搜索词会截断或污染查询串 + "" !== t && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + encodeURIComponent(String(t)) + "&f=" + encodeURIComponent(String(n)) : window.open("/search?q=" + encodeURIComponent(String(t)) + "&f=" + encodeURIComponent(String(n)))); + })), hasSearchByImage && $("#search-img-btn").on("click", (() => { + this.getOptionalDependency("SearchByImagePlugin")?.open?.(); })); } hookOldSearch() { + const hasSearchByImage = !!this.getOptionalDependency("SearchByImagePlugin"); + if (!hasSearchByImage) return; const e = document.querySelector(".search-image"); if (!e) return; const t = e.cloneNode(!0); - e.parentNode.replaceChild(t, e), $("#button-search-image").attr("data-tooltip", "以图识图"), - $(".search-image").on("click", (e => { - this.getBean("SearchByImagePlugin").open(); + e.parentNode?.replaceChild(t, e), $("#button-search-image").attr("data-tooltip", "以图识图"), + hasSearchByImage && $(".search-image").on("click", ((/** @type {MouseEvent} */ e) => { + this.getOptionalDependency("SearchByImagePlugin")?.open?.(); })); } margeNav() { - $('a[href*="/feedbacks/new"]').remove(), $('a[href*="theporndude.com"]').remove(), - $('a.navbar-link[href="/makers"]').parent().after('\n \n '); + $('a[href*="/feedbacks/new"]').remove(), $('a[href*="theporndude.com"]').remove(); + const dropdown = $(''), links = dropdown.find(".navbar-dropdown"); + links.append($('反饋')); + this.getRuntimeService("movie").externalNavigationLinks().forEach(((/** @type {{url: string, label: string}} */ item) => links.append($("").addClass("navbar-item").attr({ href: item.url, rel: "nofollow noopener", target: "_blank" }).text(item.label)))); + $('a.navbar-link[href="/makers"]').parent().after(dropdown); } toggleOtherNavItem() { + // 覆盖所有宽度区间:>1600 显示 JHS 搜索框,其余(含 ≤1023 与 1600 整数宽)显示宿主搜索栏,避免双搜索框并存 let e = $("#search-box"), t = $("#search-bar-container"); - $(window).width() < 1600 && $(window).width() > 1023 && (e.hide(), t.show()), $(window).width() > 1600 && (e.show(), - t.hide()); + $(window).width() > 1600 ? (e.show(), t.hide()) : (e.hide(), t.show()); } } diff --git a/src/plugins/status/want-and-watched-videos.js b/src/plugins/status/want-and-watched-videos.js index 036b4309..ec8e22d8 100644 --- a/src/plugins/status/want-and-watched-videos.js +++ b/src/plugins/status/want-and-watched-videos.js @@ -1,4 +1,10 @@ -class WantAndWatchedVideosPlugin extends BasePlugin { +// @ts-check + +import { i } from "../../core/constants.js"; +import { requestHostPage } from "../../core/host-page-request.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class WantAndWatchedVideosPlugin extends BasePlugin { constructor() { super(...arguments), i(this, "flag", null); } @@ -7,26 +13,27 @@ class WantAndWatchedVideosPlugin extends BasePlugin { } async handle() { window.location.href.includes("/want_watch_videos") && ($("h3").append(''), - $("#wantWatchBtn").on("click", (e => { + $("#wantWatchBtn").on("click", ((/** @type {MouseEvent} */ e) => { this.flag = "favorite", this.importWantWatchVideos(e, "是否将想看的影片导入到 JHS 收藏?"); }))), window.location.href.includes("/watched_videos") && ($("h3").append(''), - $("#wantWatchBtn").on("click", (e => { + $("#wantWatchBtn").on("click", ((/** @type {MouseEvent} */ e) => { this.flag = "watched", this.importWantWatchVideos(e, "是否将看过的影片导入到 JHS 已观看?"); }))); } - importWantWatchVideos(e, t) { + importWantWatchVideos(/** @type {MouseEvent} */ e, /** @type {string} */ t) { utils.q(null, `${t}
    执行此功能前请记得备份数据`, (async () => { let e = loading(); try { const result = await this.parseMovieList(); show.ok(`导入完成:成功 ${result.imported},失败 ${result.failed},共 ${result.pages} 页`); } catch (t) { - clog.error(t), show.error(`导入失败:${t.message || t}`); + clog.error(t), show.error(`导入失败:${t instanceof Error ? t.message : String(t)}`); } finally { e.close(); } })); } + /** @param {any} [e] @param {{imported: number, failed: number, pages: number}} [result] @returns {Promise<{imported: number, failed: number, pages: number}>} */ async parseMovieList(e = null, result = { imported: 0, failed: 0, pages: 0 }) { let t, n; e ? (t = e.find(this.getSelector().itemSelector), n = e.find(".pagination-next").attr("href")) : (t = $(this.getSelector().itemSelector), @@ -35,14 +42,15 @@ class WantAndWatchedVideosPlugin extends BasePlugin { for (const i of t) { const e = $(i), t = e.find("a").attr("href"), n = e.find(".video-title strong").text().trim(), s = e.find(".meta").text().trim(); if (t && n) try { - this.flag && await stateService.patch(n, { [this.flag]: !0 }, { type: "javdb-list-import", record: { carNum: n, url: t, names: "", publishTime: s } }), result.imported++; + this.flag && await this.getRuntimeService("state").patch(n, { [this.flag]: !0 }, { type: "javdb-list-import", record: { carNum: n, url: t, names: "", publishTime: s } }), result.imported++; } catch (a) { result.failed++, clog.error(`保存失败 [${n}]:`, a); } } if (!n) return result; await utils.sleep(1e3); - const html = await gmHttp.get(new URL(n, window.location.href).href), nextPage = utils.htmlTo$dom(html); + const scope = await this.getRuntimeService("scope")(); + const html = await requestHostPage(this.getRuntimeService("http"), new URL(n, window.location.href), scope), nextPage = utils.htmlTo$dom(html); return this.parseMovieList(nextPage, result); } } diff --git a/src/plugins/subtitle/subtitle-cat.js b/src/plugins/subtitle/subtitle-cat.js index 97d80f03..b8456b57 100644 --- a/src/plugins/subtitle/subtitle-cat.js +++ b/src/plugins/subtitle/subtitle-cat.js @@ -1,15 +1,19 @@ -class SubTitleCatPlugin extends BasePlugin { +// @ts-check + +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class SubTitleCatPlugin extends BasePlugin { getName() { return "SubTitleCatPlugin"; } handle() { $(".t-banner-inner").hide(), $("#navbar").hide(); - let e = new URLSearchParams(window.location.search).get("search").toLowerCase(), t = $(".sub-table tr td a").toArray(), n = 0; - t.forEach((t => { + let e = (new URLSearchParams(window.location.search).get("search") || "").toLowerCase(), t = $(".sub-table tr td a").toArray(), n = 0; + t.forEach(((/** @type {Element} */ t) => { let a = $(t); a.text().toLowerCase().includes(e) ? n++ : a.parent().parent().hide(); })), 0 === n && show.error("该番号无字幕!"); - const a = $(".sec-title"), i = a.html().replace(/^\d+/, n); + const a = $(".sec-title"), i = String(a.html() || "").replace(/^\d+/, String(n)); a.html(i); } } diff --git a/src/plugins/translate/translate.js b/src/plugins/translate/translate.js index 90ce477d..927151a6 100644 --- a/src/plugins/translate/translate.js +++ b/src/plugins/translate/translate.js @@ -1,39 +1,64 @@ -class TranslatePlugin extends BasePlugin { +// @ts-check + +import { _, l } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { renderTranslatedTitle } from "../../ui/translation/title-translation.js"; + +export class TranslatePlugin extends BasePlugin { getName() { return "TranslatePlugin"; } async initCss() { return "\n "; } - handle() { - isDetailPage && this.translate(); + /** 标题翻译 live 生命周期:listener 只注册一次,OFF→revert,ON→re-apply。 */ + async handle() { + const settings = this.getRuntimeService("settings"), scope = await this.getRuntimeService("scope")(); + if (!this._settingsListenerBound) { + this._settingsListenerBound = true; + const onSettingsChanged = (/** @type {any} */ event) => { + const names = /** @type {string[] | undefined} */ (event.detail?.names); + if (!names?.includes("translateTitle")) return; + this.reconfigure(); + }; + settings.addEventListener("settings.changed", onSettingsChanged); + scope.addCleanup((() => { + settings.removeEventListener("settings.changed", onSettingsChanged); + this._settingsListenerBound = false; + })); + } + this.reconfigure(); + } + reconfigure() { + const enabled = (this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _; + if (enabled) void this.applyTranslation(); + else { + this.getOptionalDependency("ListPagePlugin")?.invalidateTranslations?.(); + this.revertTranslation(); + } + } + /** ON:详情页插入/刷新翻译标题;列表页翻译当前所有卡片。 */ + async applyTranslation() { + if (isDetailPage) return this.translate(); + if (!window.isListPage) return; + const listPage = this.getOptionalDependency("ListPagePlugin"); + if (!listPage?.translateListItems || typeof listPage.getSelector !== "function") return; + const items = $(listPage.getSelector().itemSelector).toArray(); + await listPage.translateListItems(items); } + /** OFF:详情页移除 JHS 翻译节点;列表页回退为原始标题。 */ + revertTranslation() { + $(".translated-title").remove(); + const listPage = this.getOptionalDependency("ListPagePlugin"); + listPage?.revertTranslation?.(); + } + /** @param {string | null} [e] @param {boolean} [t] @param {{root?: ParentNode}} [options] */ async translate(e, t = !0, options = {}) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; + const settings = this.getRuntimeService("settings"); + if ((settings.snapshot().translateTitle ?? _) !== _) return; l && (t = !1); - const root = options.root ? $(options.root) : $(document); - let n = root.find(".origin-title").first(); - if (n.length || (n = root.find(".current-title").first()), n.length || (n = root.find("h3").first()), !n.length) return; - const a = n.text().trim(); - if (!a) return void show.error("获取标题失败, 无法进行翻译"); - let i = n.nextAll(".translated-title").first(); - i.length || (i = $('
    ').insertAfter(n)), i.removeClass("is-error").text("翻译中..."); - e || (e = this.getPageInfo().carNum); - const s = "string" == typeof e ? e.trim() : "", o = s && "undefined" !== s ? s : a; - let r = {}; - try { - const e = localStorage.getItem("jhs_translate"); - e && (r = JSON.parse(e) || {}); - } catch (l) { - clog.warn("翻译缓存无法解析,已忽略旧缓存", l); - } - if (r[o]) return void i.text(r[o]); - try { - const e = await _e(a, "ja", "zh-CN"); - if (!n[0]?.isConnected) return; - i.text(e), r[o] = e, localStorage.setItem("jhs_translate", JSON.stringify(r)); - } catch (l) { - clog.error("翻译失败:", l), i.addClass("is-error").text(`翻译失败: ${l.message || String(l)}`); - } + const scope = await this.getRuntimeService("scope")(); + const carNum = e ?? this.getPageInfo().carNum; + await renderTranslatedTitle({ root: options.root, carNum, translation: this.getRuntimeService("translation"), scope, isActive: () => (settings.snapshot().translateTitle ?? _) === _ }); } } diff --git a/src/services/account-service.js b/src/services/account-service.js new file mode 100644 index 00000000..92e0e33f --- /dev/null +++ b/src/services/account-service.js @@ -0,0 +1,15 @@ +// @ts-check + +export class AccountService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { this.integrations = integrations; } + + /** @param {string} providerId @param {{username: string, password: string}} credentials @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async login(providerId, credentials, options = {}) { + const manifest = this.integrations.list("account.login").find((item) => item.id === providerId); + if (!manifest) throw new TypeError(`Account provider is unavailable: ${providerId}`); + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter?.login !== "function") throw new TypeError(`Account login is unavailable: ${providerId}`); + return adapter.login(credentials, options); + } +} diff --git a/src/services/actress-info-service.js b/src/services/actress-info-service.js new file mode 100644 index 00000000..5db3853e --- /dev/null +++ b/src/services/actress-info-service.js @@ -0,0 +1,75 @@ +// @ts-check + +export class ActressInfoService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations @param {import("./cache-service.js").CacheService} cache */ + constructor(integrations, cache) { this.integrations = integrations; this.cache = cache; } + + /** @param {string} name */ + profileUrl(name) { + for (const manifest of this.integrations.list("person.actress-info")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.profileUrl === "function") return adapter.profileUrl(name); + } + return ""; + } + + /** @param {string} name @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async lookup(name, options = {}) { + const normalized = String(name).trim() === "三上悠亞" ? "三上悠亜" : String(name).trim(); + if (!normalized) return null; + const key = `actress-info:${normalized}`; + const cached = await this.cache.get("actress-info", key, { scope: "public" }); + if (cached.hit) return cached.value; + const generation = this.cache.generation("actress-info"); + for (const manifest of this.integrations.list("person.actress-info")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.lookup !== "function") continue; + const result = await adapter.lookup(normalized, options); + await this.cache.set("actress-info", key, result, { scope: "public", ttlMs: 604_800_000, negative: !result, generation }); + return result; + } + return null; + } + /** @param {string} providerId @param {Record} actorRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, ttlMs?: number}} [options] */ + async movies(providerId, actorRef, options = {}) { + const manifest = this.integrations.list("actor.movies").find((item) => item.id === providerId); + if (!manifest) return []; + const adapter = this.integrations.getAdapter(manifest.id); + return typeof adapter?.listActorMovies === "function" ? adapter.listActorMovies(actorRef, options) : []; + } + /** @param {string} providerId @param {Record} query @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async collection(providerId, query, options = {}) { + const manifest = this.integrations.list("actor.collection").find((item) => item.id === providerId); + if (!manifest) throw new TypeError(`Actor collection provider is unavailable: ${providerId}`); + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter?.listActorCollection !== "function") throw new TypeError(`Actor collection is unavailable: ${providerId}`); + return adapter.listActorCollection(query, options); + } + /** @param {string} providerId @param {Record} actorRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async uncollect(providerId, actorRef, options = {}) { + const manifest = this.integrations.list("actor.uncollect").find((item) => item.id === providerId); + if (!manifest) throw new TypeError(`Actor provider is unavailable: ${providerId}`); + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter?.uncollectActor !== "function") throw new TypeError(`Actor uncollect is unavailable: ${providerId}`); + return adapter.uncollectActor(actorRef, options); + } + getAvatarSources() { + return Object.freeze(this.integrations.list("person.avatar-search").flatMap((manifest) => { + const adapter = this.integrations.getAdapter(manifest.id); + return typeof adapter?.getSources === "function" ? adapter.getSources() : []; + })); + } + /** @param {string[]} names @param {string} sourceId @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async searchAvatars(names, sourceId, options = {}) { + for (const manifest of this.integrations.list("person.avatar-search")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (adapter?.getSources?.().some((/** @type {{id: string}} */ source) => source.id === sourceId)) return adapter.searchAvatars(names, { ...options, sourceId }); + } + return []; + } + /** @param {string} providerId */ + placeholderUrl(providerId) { + const adapter = this.integrations.getAdapter(providerId); + return typeof adapter?.actorPlaceholderUrl === "function" ? adapter.actorPlaceholderUrl() : ""; + } +} diff --git a/src/services/cache-service.js b/src/services/cache-service.js new file mode 100644 index 00000000..3b508f73 --- /dev/null +++ b/src/services/cache-service.js @@ -0,0 +1,205 @@ +// @ts-check + +const PUBLIC_CACHE_MAX_ENTRIES = 500, PUBLIC_CACHE_MAX_AGE_MS = 30 * 864e5, PUBLIC_PRUNE_INTERVAL = 64; + +/** @param {string} value @returns {Promise} */ +async function digestKey(value) { + const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte => byte.toString(16).padStart(2, "0"))).join(""); +} + +export class CacheService { + /** @param {{namespaceVersion?: number, diagnostics?: import("./diagnostics-service.js").DiagnosticsService, storage?: import("./storage-service.js").StorageService}} [options] */ + constructor(options = {}) { + this.namespaceVersion = options.namespaceVersion ?? 2; + this.diagnostics = options.diagnostics ?? null; + this.storage = options.storage ?? null; + /** @type {Map} */ + this.publicCache = new Map(); + /** @type {Map>} */ + this.sessionCaches = new Map(); + /** @type {Map>} */ + this.publicReads = new Map(); + this.publicGenerations = new Map(); + this.publicNamespaces = new Set(["default"]); + this.publicWritesSincePrune = 0; + this.pruneScheduled = false; + this.pruneRunning = false; + /** @type {Promise | null} */ + this.prunePromise = null; + } + + get publicGeneration() { return this.publicGenerations.get("default") || 0; } + /** @param {string} namespace */ + generation(namespace) { return this.publicGenerations.get(namespace) || 0; } + get publicPrefix() { return this._publicPrefix("default"); } + _publicPrefix(namespace = "default") { return `jhs_http_public_cache:v${this.namespaceVersion}:${encodeURIComponent(namespace)}:`; } + + /** @param {string} scope @param {string} sessionScopeId */ + storeFor(scope, sessionScopeId = "") { + if (scope === "public") return this.publicCache; + if (scope === "session") { + if (!sessionScopeId) throw new TypeError("sessionScopeId is required for session cache"); + let cache = this.sessionCaches.get(sessionScopeId); + if (!cache) this.sessionCaches.set(sessionScopeId, cache = new Map()); + return cache; + } + return null; + } + + /** @param {any} namespace @param {any} key @param {any} policy */ + async get(namespace, key, policy) { + if (arguments.length === 2) policy = key, key = namespace, namespace = "default"; + namespace = String(namespace || "default"); + this.publicNamespaces.add(namespace); + const store = this.storeFor(policy.scope, policy.sessionScopeId), cacheKey = `${namespace}:${this.namespaceVersion}:${key}`, now = Date.now(), storage = this.storage; + const memoryEntry = store?.get(cacheKey); + if (memoryEntry && memoryEntry.expiresAt > now) { + this.diagnostics?.recordCache(true); + return { hit: true, value: memoryEntry.value, negative: memoryEntry.negative }; + } + if (memoryEntry) store?.delete(cacheKey); + if (policy.scope !== "public" || !storage) { + this.diagnostics?.recordCache(false); + return { hit: false, value: undefined, negative: false }; + } + const readKey = `${namespace}:${key}`; + const pending = this.publicReads.get(readKey); + if (pending) return pending; + const generation = this.generation(namespace); + const read = (async () => { + const persistedKey = this._publicPrefix(namespace) + await digestKey(key); + let entry; + try { entry = await storage.get(persistedKey); } catch { + this.diagnostics?.recordCache(false); + return { hit: false, value: undefined, negative: false }; + } + if (entry?.version !== this.namespaceVersion || !Number.isFinite(entry?.expiresAt) || entry.expiresAt <= now) { + if (entry) await storage.remove(persistedKey).catch(() => {}); + this.diagnostics?.recordCache(false); + return { hit: false, value: undefined, negative: false }; + } + if (generation !== this.generation(namespace)) { + this.diagnostics?.recordCache(false); + return { hit: false, value: undefined, negative: false }; + } + const normalized = { value: entry.value, expiresAt: entry.expiresAt, negative: entry.negative === true }; + this.publicCache.set(cacheKey, normalized); + this.diagnostics?.recordCache(true); + return { hit: true, value: normalized.value, negative: normalized.negative }; + })().finally(() => { + if (this.publicReads.get(readKey) === read) this.publicReads.delete(readKey); + }); + this.publicReads.set(readKey, read); + return read; + } + + /** @param {any} namespace @param {any} key @param {any} value @param {any} policy */ + async set(namespace, key, value, policy) { + if (arguments.length === 3) policy = value, value = key, key = namespace, namespace = "default"; + namespace = String(namespace || "default"); + this.publicNamespaces.add(namespace); + const ttlMs = Math.min(PUBLIC_CACHE_MAX_AGE_MS, Math.max(0, Number(policy.ttlMs) || 0)); + if (!ttlMs) return; + if (policy.scope === "public" && policy.generation != null && policy.generation !== this.generation(namespace)) return; + const entry = { value, expiresAt: Date.now() + ttlMs, negative: policy.negative ?? false }, store = this.storeFor(policy.scope, policy.sessionScopeId), cacheKey = `${namespace}:${this.namespaceVersion}:${key}`; + const storage = this.storage; + if (policy.scope !== "public" || !storage) { + store?.set(cacheKey, entry); + return; + } + if (policy.generation != null && policy.generation !== this.generation(namespace)) return; + const persistedKey = this._publicPrefix(namespace) + await digestKey(key); + if (policy.generation != null && policy.generation !== this.generation(namespace)) return; + try { + if (policy.generation != null && policy.generation !== this.generation(namespace)) return; + await storage.set(persistedKey, { version: this.namespaceVersion, ...entry, lastUsed: Date.now() }); + if (policy.generation != null && policy.generation !== this.generation(namespace)) { + await storage.remove(persistedKey).catch(() => {}); + return; + } + store?.set(cacheKey, entry); + this.publicWritesSincePrune++; + this.schedulePublicPrune(); + } catch { + // 持久缓存是可丢弃加速层,存储故障不得让真实网络请求失败。 + } + } + + /** Schedule bounded public-cache maintenance outside the request critical path. */ + schedulePublicPrune() { + if (!this.storage || this.publicWritesSincePrune < PUBLIC_PRUNE_INTERVAL || this.pruneScheduled || this.pruneRunning) return this.prunePromise; + this.pruneScheduled = true; + const run = async () => { + this.pruneScheduled = false; + this.pruneRunning = true; + this.publicWritesSincePrune = 0; + try { await this.prunePublic(); } + catch { /* 维护失败不影响缓存读写和真实网络请求。 */ } + finally { + this.pruneRunning = false; + this.prunePromise = null; + this.schedulePublicPrune(); + } + }; + this.prunePromise = Promise.resolve().then(run); + return this.prunePromise; + } + + /** Wait for a deferred prune so destructive cache operations can establish a clean boundary. */ + async waitForPrune() { + await this.prunePromise?.catch(() => {}); + } + + /** Remove expired entries and keep the persistent public namespace bounded. */ + async prunePublic() { + if (!this.storage) return; + let keys; + try { keys = await this.storage.keys(); } catch { return; } + const now = Date.now(), candidates = []; + for (const key of /** @type {string[]} */ (keys).filter((/** @param {string} item */ item) => item.startsWith("jhs_http_public_cache:v"))) { + const entry = await this.storage.get(key).catch(() => null); + if (!entry || entry.version !== this.namespaceVersion || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) { + await this.storage.remove(key).catch(() => {}); + continue; + } + candidates.push({ key, lastUsed: Number(entry.lastUsed) || 0 }); + } + if (candidates.length > PUBLIC_CACHE_MAX_ENTRIES) { + candidates.sort((left, right) => left.lastUsed - right.lastUsed); + for (const item of candidates.slice(0, candidates.length - PUBLIC_CACHE_MAX_ENTRIES)) await this.storage.remove(/** @type {any} */ (item).key).catch(() => {}); + } + } + + /** Clear the public L1 and persisted L2 namespace. */ + /** @param {string} namespace */ + async clearNamespace(namespace) { + namespace = String(namespace || "default"); + this.publicGenerations.set(namespace, this.generation(namespace) + 1); + for (const key of this.publicCache.keys()) if (key.startsWith(`${namespace}:`)) this.publicCache.delete(key); + for (const key of this.publicReads.keys()) if (key.startsWith(`${namespace}:`)) this.publicReads.delete(key); + await this.waitForPrune(); + const storage = this.storage; + if (!storage) return; + let keys; + try { keys = await storage.keys(); } catch { return; } + await Promise.all(keys.filter((/** @param {string} key */ key => key.startsWith(this._publicPrefix(namespace)))).map((/** @param {string} key */ key => storage.remove(key).catch(() => {})))); + } + + async clearAll() { + const namespaces = new Set(this.publicNamespaces); + for (const namespace of namespaces) await this.clearNamespace(namespace); + this.publicCache.clear(), this.publicReads.clear(), this.sessionCaches.clear(); + // Also remove persisted namespaces that were not touched in this tab. + // This keeps the settings "clear all" action a true boundary across + // tabs and avoids leaving stale v2 entries behind. + if (this.storage) { + try { + const storage = this.storage, prefix = `jhs_http_public_cache:v${this.namespaceVersion}:`, keys = await storage.keys(); + await Promise.all(keys.filter((/** @type {string} */ key) => key.startsWith(prefix)).map((/** @type {string} */ key) => storage.remove(key).catch(() => {}))); + } catch { /* cache cleanup remains best effort */ } + } + } + async clearPublic() { return this.clearAll(); } + async invalidateNamespace() { return this.clearAll(); } +} diff --git a/src/services/credential-service.js b/src/services/credential-service.js new file mode 100644 index 00000000..7f0103ca --- /dev/null +++ b/src/services/credential-service.js @@ -0,0 +1,124 @@ +// @ts-check +import { decryptData, encryptData } from "../core/credential-crypto.js"; +export const CREDENTIAL_KEYS = Object.freeze({ + installationSecret: "jhs_credential_install_secret", + webDavPassword: "jhs_webdav_password", + javdbToken: "jhs_appAuthorization", +}); + +export class CredentialService { + /** @param {{getValue: (key: string, fallback?: unknown) => unknown, setValue: (key: string, value: unknown) => unknown, removeValue: (key: string) => unknown}} storage @param {Storage} [legacyStorage] */ + constructor(storage, legacyStorage = globalThis.localStorage) { + this.storage = storage; + this.legacyStorage = legacyStorage; + /** @type {Map} */ this.cleanupValues = new Map(); + /** @type {Map} */ this.cleanupTargets = new Map(); + } + /** Read the legacy key without replacing shared key material. */ + async installationSecret() { + return String(await this.storage.getValue(CREDENTIAL_KEYS.installationSecret, "") || this.legacyStorage?.getItem?.(CREDENTIAL_KEYS.installationSecret) || "x7k9p3"); + } + generateSecret() { return btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))); } + + /** 历史密文认证失败不能降格为明文。 @param {string} value @param {string} [secret] */ + async decodeLegacy(value, secret) { + const payload = value.replace(/^AES:/, ""); + let candidate = value.startsWith("AES:"); + try { candidate ||= /^[A-Za-z0-9+/]+={0,2}$/.test(payload) && atob(payload).length >= 28; } catch {} + if (!candidate) return value; + return decryptData(payload, secret || await this.installationSecret()); + } + + /** @param {any} record @returns {Promise} */ + async decode(record) { + if (record && typeof record === "object") { + if (record.schema === 2 && typeof record.id === "string" && record.deleted === true && !Object.hasOwn(record, "secret") && !Object.hasOwn(record, "ciphertext")) return ""; + if (record.schema !== 2 || typeof record.id !== "string" || typeof record.secret !== "string" || typeof record.ciphertext !== "string") throw new Error("凭证格式无效"); + return decryptData(record.ciphertext, record.secret, false); + } + const value = String(record || ""); + if (!value) return ""; + return this.decodeLegacy(value); + } + /** @param {string} key @returns {Promise} */ + async get(key) { + try { return await this.decode(await this.storage.getValue(key, "")); } + catch { return ""; } + } + /** @param {string} key @param {string} value */ + async set(key, value) { + const plaintext = String(value || ""); + if (!plaintext) { await this.remove(key); return ""; } + const secret = this.generateSecret(); + const record = { schema: 2, id: crypto.randomUUID(), secret, ciphertext: await encryptData(plaintext, secret) }; + await this.storage.setValue(key, record); + const actual = await this.storage.getValue(key, ""); + if (JSON.stringify(actual) !== JSON.stringify(record) || await this.decode(actual) !== plaintext) throw new Error("凭证存储回读校验失败"); + return plaintext; + } + /** @param {string} key */ + async remove(key) { + const record = { schema: 2, id: crypto.randomUUID(), deleted: true }; + await this.storage.setValue(key, record); + const actual = await this.storage.getValue(key, ""); + if (JSON.stringify(actual) !== JSON.stringify(record) || await this.decode(actual) !== "") throw new Error("凭证删除回读校验失败"); + } + + /** @param {any} settings @param {{deferCleanup?: boolean}} [options] */ + async migrateLegacy(settings, options = {}) { + const legacySecret = this.legacyStorage?.getItem?.(CREDENTIAL_KEYS.installationSecret) || ""; + const decodeLegacy = (/** @type {string} */ value) => this.decodeLegacy(value, legacySecret); + const issues = []; + /** @type {Record} */ const settingValues = {}; + /** @type {string[]} */ const pageKeys = []; + const snapshot = settings?.snapshot?.() || {}; + const token = this.legacyStorage?.getItem?.(CREDENTIAL_KEYS.javdbToken) || ""; + const password = String(snapshot.webDavPassword || ""); + for (const [key, source] of [[CREDENTIAL_KEYS.javdbToken, token], [CREDENTIAL_KEYS.webDavPassword, password]]) { + let stored; + try { stored = await this.storage.getValue(key, ""); } + catch { issues.push({key, code:"CREDENTIAL_MIGRATION_READ_FAILED"}); continue; } + if (!stored && !source) continue; + if (stored && typeof stored === "object" && (key === CREDENTIAL_KEYS.javdbToken || !source || /** @type {any} */ (stored).deleted === true)) continue; + let value, sourceValue; + try { + value = stored ? await this.decode(stored) : await decodeLegacy(source); + if (stored && typeof stored === "string" && key === CREDENTIAL_KEYS.javdbToken) value = await decodeLegacy(value); + sourceValue = source ? await decodeLegacy(source) : ""; + } catch { + issues.push({ key, code: "LEGACY_CREDENTIAL_UNREADABLE" }); + continue; + } + try { if (!stored || typeof stored === "string") await this.set(key, value); } + catch { issues.push({key, code:"CREDENTIAL_MIGRATION_WRITE_FAILED"}); continue; } + // Different origins may have different legacy values; preserve conflicts. + if (source && value === sourceValue && await this.get(key) === value) { + this.cleanupTargets.set(key, value); + if (key === CREDENTIAL_KEYS.webDavPassword) settingValues.webDavPassword = source; + } + } + const cleanup = await this.validateLegacyCleanup({ settingKeys: Object.keys(settingValues), settingValues, pageKeys }); + if (!options.deferCleanup) { + if (cleanup.settingKeys.length && settings?.update) await settings.update((/** @type {Record} */ draft) => { + for (const key of cleanup.settingKeys) if (draft[key] === settingValues[key]) delete draft[key]; + }); + await this.cleanupLegacyPageStorage(cleanup.pageKeys, settings); + } + return { migrated: Boolean(token || password || legacySecret), cleanup, issues }; + } + /** Revalidate destinations immediately before the deferred settings cleanup. @param {any} cleanup */ + async validateLegacyCleanup(cleanup) { + /** @type {string[]} */ + const settingKeys = []; + if (!globalThis.navigator?.locks?.request) return { ...cleanup, settingKeys }; + for (const key of cleanup.settingKeys || []) { + if (key === "webDavPassword" && this.cleanupTargets.has(CREDENTIAL_KEYS.webDavPassword) && await this.get(CREDENTIAL_KEYS.webDavPassword) === this.cleanupTargets.get(CREDENTIAL_KEYS.webDavPassword)) settingKeys.push(key); + } + return { ...cleanup, settingKeys }; + } + /** 保留不可原子比较删除的页面旧源及其密钥,仅释放本次迁移的内存凭证。 */ + async cleanupLegacyPageStorage(/** @type {string[]} */ keys = [], /** @type {any} */ settings = null) { + this.cleanupValues.clear(); + this.cleanupTargets.clear(); + } +} diff --git a/src/services/diagnostics-service.js b/src/services/diagnostics-service.js new file mode 100644 index 00000000..e8ff754d --- /dev/null +++ b/src/services/diagnostics-service.js @@ -0,0 +1,99 @@ +// @ts-check + +const SENSITIVE_KEY = /^(?:authorization|cookie|set-cookie|password|token|secret|credential)$/i; + +/** @param {unknown} value @param {string} [key] @returns {unknown} */ +function sanitize(value, key = "") { + if (SENSITIVE_KEY.test(key)) return "[redacted]"; + if (Array.isArray(value)) return value.map((item) => sanitize(item)); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, sanitize(child, childKey)])); + if (typeof value !== "string") return value; + return value.replace(/(https?:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, "$1[redacted]@"); +} + +export class DiagnosticsService { + /** @param {{legacyHttp?: any}} [options] */ + constructor(options = {}) { + this.startedAt = performance.now(); + this.activeFeatures = new Set(); + this.activeContributions = new Set(); + this.startupTimings = new Map(); + this.scopes = new Map(); + this.requestStats = { consumers: 0, underlying: 0 }; + this.cacheStats = { hits: 0, misses: 0 }; + this.providerHealth = new Map(); + /** @type {Array>} */ + this.errors = []; + this.browserMetadata = null; + /** @type {string[]} */ + this.legacyPlugins = []; + /** @type {Array<{name: string, disableable: boolean}>} */ + this.legacyPluginDescriptors = []; + this.legacyStartup = null; + /** @type {Array>} */ + this.legacyTimings = []; + this.legacyHttp = options.legacyHttp ?? null; + this.networkController = null; + } + + /** @param {string} id @param {number} durationMs */ + recordStartup(id, durationMs) { this.startupTimings.set(id, durationMs); } + /** @param {string} id @param {boolean} active */ + setFeature(id, active) { active ? this.activeFeatures.add(id) : this.activeFeatures.delete(id); } + /** @param {string} id @param {boolean} active */ + setContribution(id, active) { active ? this.activeContributions.add(id) : this.activeContributions.delete(id); } + /** @param {{id: string}} snapshot */ + updateScope(snapshot) { this.scopes.set(snapshot.id, Object.freeze({ ...snapshot })); } + /** @param {number} consumers @param {number} underlying */ + updateRequests(consumers, underlying) { this.requestStats = { consumers, underlying }; } + /** @param {boolean} hit */ + recordCache(hit) { hit ? this.cacheStats.hits += 1 : this.cacheStats.misses += 1; } + /** @param {string} id @param {Record} health */ + updateProvider(id, health) { this.providerHealth.set(id, Object.freeze({ ...health })); } + /** @param {unknown} error */ + recordError(error) { + const value = error && typeof error === "object" && "toJSON" in error && typeof error.toJSON === "function" + ? error.toJSON() + : error instanceof Error ? { message: error.message } : error && typeof error === "object" ? { ...error } : { message: String(error) }; + this.errors.push({ ...(/** @type {Record} */ (sanitize(value))), timestamp: new Date().toISOString() }); + if (this.errors.length > 100) this.errors.shift(); + } + /** @param {Record} metadata */ + setBrowserMetadata(metadata) { this.browserMetadata = Object.freeze({ ...metadata }); } + /** @param {Array<{name: string, disableable: boolean}>} descriptors @param {Record} startup @param {Array>} timings */ + setLegacyRuntime(descriptors, startup, timings) { + this.legacyPluginDescriptors = descriptors.map((item) => Object.freeze({ ...item })); + this.legacyPlugins = descriptors.map((item) => item.name); + this.legacyStartup = Object.freeze({ ...startup }); + this.legacyTimings = timings.map((item) => Object.freeze({ ...item })); + } + clearErrors() { this.errors = []; } + /** @param {any} controller */ + setNetworkController(controller) { this.networkController = controller; } + getNetworkDiagnostics() { + const controller = this.networkController ?? this.legacyHttp; + return Object.freeze({ + circuitBreakers: controller?.getCircuitBreakerStatus?.() ?? {}, + domainStats: controller?.getDomainStats?.() ?? {}, + }); + } + /** @param {string} domain */ + resetCircuitBreaker(domain) { (this.networkController ?? this.legacyHttp)?.resetCircuitBreaker?.(domain); } + resetAllCircuitBreakers() { (this.networkController ?? this.legacyHttp)?.resetAllCircuitBreakers?.(); } + clearDomainStats() { (this.networkController ?? this.legacyHttp)?.clearDomainStats?.(); } + + exportSnapshot() { + const scopes = [...this.scopes.values()]; + return Object.freeze({ + activeFeatures: [...this.activeFeatures], activeContributions: [...this.activeContributions], + startupTimings: Object.fromEntries(this.startupTimings), activeScopes: scopes, + globalListeners: scopes.filter((scope) => scope.id === "app:root").reduce((sum, scope) => sum + Number(scope.listeners ?? 0), 0), + observers: scopes.reduce((sum, scope) => sum + Number(scope.observers ?? 0), 0), + requestConsumers: this.requestStats.consumers, underlyingRequests: this.requestStats.underlying, + cacheHits: this.cacheStats.hits, cacheMisses: this.cacheStats.misses, + providerHealth: Object.fromEntries(this.providerHealth), errors: this.errors.map((error) => ({ ...error })), + legacyPlugins: [...this.legacyPlugins], legacyPluginDescriptors: this.legacyPluginDescriptors.map((item) => ({ ...item })), legacyStartup: this.legacyStartup, legacyTimings: this.legacyTimings.map((item) => ({ ...item })), + browser: this.browserMetadata, uptimeMs: performance.now() - this.startedAt, + }); + } +} diff --git a/src/services/dialog-service.js b/src/services/dialog-service.js new file mode 100644 index 00000000..a88bc99a --- /dev/null +++ b/src/services/dialog-service.js @@ -0,0 +1,16 @@ +// @ts-check + +import { assertPort, PORT_METHODS } from "../contracts/ports.js"; + +export class DialogService { + /** @param {any} dialogPort */ + constructor(dialogPort) { this.port = assertPort(dialogPort, "DialogPort", PORT_METHODS.dialog); } + /** @param {Record} options */ + open(options) { return this.port.open(options); } + /** @param {number} id */ + close(id) { return this.port.close(id); } + /** @param {string} message @param {Record} options @param {(id: number) => void} yes */ + confirm(message, options, yes) { return this.port.confirm(message, options, yes); } + /** @param {string} message @param {Record} [options] */ + alert(message, options) { return this.port.alert(message, options); } +} diff --git a/src/services/external-url-policy.js b/src/services/external-url-policy.js new file mode 100644 index 00000000..78b9ee18 --- /dev/null +++ b/src/services/external-url-policy.js @@ -0,0 +1,56 @@ +// @ts-check + +import { JhsError } from "../core/jhs-error.js"; + +const PRIVATE_IPV4 = [ + /^10\./, /^127\./, /^169\.254\./, /^192\.168\./, + /^172\.(?:1[6-9]|2\d|3[01])\./, /^0\./, +]; + +/** @param {string} hostname */ +function isPrivateLiteral(hostname) { + const value = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (value === "localhost" || value.endsWith(".localhost") || value === "::1") return true; + if (value.includes(":")) return value.startsWith("fc") || value.startsWith("fd") || value.startsWith("fe8") || value.startsWith("fe9") || value.startsWith("fea") || value.startsWith("feb"); + return PRIVATE_IPV4.some((pattern) => pattern.test(value)); +} + +export class ExternalUrlPolicy { + /** @param {{localOrigins?: string[]}} [options] */ + constructor(options = {}) { + this.localOrigins = new Set(); + this.replaceLocalOrigins(options.localOrigins ?? []); + } + + /** @param {string} origin */ + authorizeLocalOrigin(origin) { this.localOrigins.add(new URL(origin).origin); } + + /** Atomically replaces the exact local origins authorized by the user. @param {readonly string[]} origins */ + replaceLocalOrigins(origins) { + const next = new Set(origins.map((origin) => new URL(origin).origin)); + this.localOrigins = next; + } + + /** @param {string | URL} input @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} policy */ + assertAllowed(input, policy) { + let url; + try { url = input instanceof URL ? input : new URL(input); } + catch (cause) { throw new JhsError("INVALID_URL", "外部地址无效", { source: "ExternalUrlPolicy", cause }); } + if (!new Set(["http:", "https:"]).has(url.protocol)) throw new JhsError("INVALID_URL", "仅允许 HTTP/HTTPS 地址", { source: "ExternalUrlPolicy" }); + if (policy.expectedOrigin && url.origin !== new URL(policy.expectedOrigin).origin) throw new JhsError("INVALID_URL", "地址离开已声明的精确 origin", { source: "ExternalUrlPolicy" }); + if (policy.trustClass === "builtin-public") { + if (url.protocol !== "https:" || !(policy.hosts ?? []).some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`))) { + throw new JhsError("INVALID_URL", "地址不在 Integration manifest 允许范围", { source: "ExternalUrlPolicy" }); + } + } else if (policy.trustClass === "custom-public") { + if (url.protocol !== "https:" || isPrivateLiteral(url.hostname)) throw new JhsError("INVALID_URL", "自定义公开来源必须使用公网 HTTPS", { source: "ExternalUrlPolicy" }); + } else if (policy.trustClass === "user-local") { + const expectedOrigin = policy.expectedOrigin ? new URL(policy.expectedOrigin).origin : url.origin; + if (!this.localOrigins.has(expectedOrigin) || url.origin !== expectedOrigin) throw new JhsError("INVALID_URL", "本地来源未获得精确 origin 授权", { source: "ExternalUrlPolicy" }); + } else throw new JhsError("INVALID_URL", "未知 URL trust class", { source: "ExternalUrlPolicy" }); + return url; + } + + /** @param {string | URL} finalUrl @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} policy */ + assertFinalUrl(finalUrl, policy) { return this.assertAllowed(finalUrl, policy); } +} diff --git a/src/services/http-service.js b/src/services/http-service.js new file mode 100644 index 00000000..038dc71c --- /dev/null +++ b/src/services/http-service.js @@ -0,0 +1,270 @@ +// @ts-check + +import { JhsError } from "../core/jhs-error.js"; + +/** @param {unknown} value @returns {string} */ +function stableSerialize(value) { + if (value == null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`; + const object = /** @type {Record} */ (value); + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(object[key])}`).join(",")}}`; +} + +/** @param {string} value */ +async function sha256(value) { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((item) => item.toString(16).padStart(2, "0")).join(""); +} + +/** @param {string | URL} input */ +export function canonicalizeUrl(input) { + const url = new URL(input); + const entries = [...url.searchParams.entries()].sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)); + url.search = ""; + for (const [key, value] of entries) url.searchParams.append(key, value); + url.hash = ""; + return url.href; +} + +/** @param {unknown} body @param {number} [status] */ +export function isCloudflareChallenge(body, status = 0) { + if (typeof body !== "string" || !body) return false; + const text = body.toLowerCase(), hasTitle = /]*>\s*just a moment(?:\.\.\.)?\s*<\/title>/i.test(body); + const hasForm = /id=["']challenge-form["']/i.test(body), hasChallenge = text.includes("cf-chl-") || text.includes("cf_chl_opt"); + const hasPlatform = text.includes("/cdn-cgi/challenge-platform/") || text.includes("challenge-platform"); + return hasTitle || hasForm && (hasChallenge || hasPlatform) || [403, 429, 503].includes(status) && hasChallenge && hasPlatform; +} + +/** @param {number} delayMs @param {AbortSignal | undefined} signal */ +function waitForRetry(delayMs, signal) { + if (delayMs <= 0) return Promise.resolve(); + return new Promise((/** @type {(value?: void) => void} */ resolve, reject) => { + const finish = () => { signal?.removeEventListener("abort", onAbort), resolve(); }; + const timer = setTimeout(finish, delayMs), onAbort = () => { + clearTimeout(timer), reject(new JhsError("ABORTED", "请求已取消", { source: "HttpService" })); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + Promise.resolve().then(() => signal?.aborted && onAbort()); + }); +} + +/** @param {Record} options */ +export async function createRequestKey(options) { + const method = String(options.method ?? "GET").toUpperCase(); + const headers = Object.fromEntries(Object.entries(options.headers ?? {}).map(([key, value]) => [key.toLowerCase(), String(value)])); + const varyHeaders = [...new Set((options.varyHeaders ?? []).map((/** @type {unknown} */ header) => String(header).toLowerCase()))].sort(); + const varyValues = Object.fromEntries(varyHeaders.map((header) => [header, headers[header] ?? ""])); + const key = { + providerId: String(options.providerId), cacheNamespace: String(options.cacheNamespace ?? options.providerId ?? "default"), method, canonicalUrl: canonicalizeUrl(options.url), + bodyHash: await sha256(stableSerialize(options.body ?? null)), responseType: String(options.responseType ?? "text"), + cacheScope: String(options.cacheScope ?? "none"), varyHeadersHash: await sha256(stableSerialize(varyValues)), + sessionScopeId: options.cacheScope === "session" ? String(options.sessionScopeId ?? "") : "", + }; + if (key.cacheScope === "session" && !key.sessionScopeId) throw new TypeError("sessionScopeId is required for session requests"); + return Object.freeze(key); +} + +export class HttpService { + /** @param {{request: (options: any) => Promise}} port @param {import("./external-url-policy.js").ExternalUrlPolicy} urlPolicy @param {{diagnostics?: import("./diagnostics-service.js").DiagnosticsService, cache?: import("./cache-service.js").CacheService, settings?: {snapshot: () => Readonly>}}} [options] */ + constructor(port, urlPolicy, options = {}) { + this.port = port; + this.urlPolicy = urlPolicy; + this.diagnostics = options.diagnostics ?? null; + this.cache = options.cache ?? null; + this.settings = options.settings ?? null; + /** @type {Map, controller: AbortController, consumers: number}>} */ + this.inflight = new Map(); + /** @type {Map} */ + this.circuitBreakers = new Map(); + /** @type {Map} */ + this.domainStats = new Map(); + } + + /** @param {Record} options @param {import("../core/lifecycle-scope.js").LifecycleScope} [scope] */ + async request(options, scope) { + scope?.assertActive(); + const method = String(options.method ?? "GET").toUpperCase(); + if (this.settings) { + const settings = this.settings.snapshot(); + options = { ...options, + timeout: options.timeout ?? Math.max(1000, Math.min(120000, Number(settings.httpTimeout) || 5000)), + retryCount: options.retryCount ?? (["GET", "HEAD"].includes(method) ? Math.max(1, Math.min(10, Math.trunc(Number(settings.httpRetryCount) || 3))) - 1 : 0), + circuitThreshold: options.circuitThreshold ?? Number(settings.circuitBreakerThreshold ?? 3), + circuitCooldownMs: options.circuitCooldownMs ?? Number(settings.circuitBreakerCooldown ?? 60000), + }; + } + const cacheScope = options.cacheScope ?? "none"; + if (method !== "GET" && cacheScope !== "none") throw new TypeError("Mutation requests cannot use generic cache/dedupe"); + const urlPolicy = /** @type {{trustClass: string, hosts?: string[], expectedOrigin?: string}} */ (options.urlPolicy); + const initialUrl = this.urlPolicy.assertAllowed(options.url, urlPolicy); + if (method !== "GET" || cacheScope === "none") return this.executeUnderlying({ ...options, method, url: initialUrl.href, signal: scope?.signal }, urlPolicy); + const cacheNamespace = String(options.cacheNamespace ?? options.providerId ?? "default"); + const requestKey = await createRequestKey({ ...options, method, url: initialUrl.href, cacheScope, cacheNamespace }); + scope?.assertActive(); + const serializedKey = stableSerialize(requestKey); + const cachePolicy = { scope: cacheScope, sessionScopeId: options.sessionScopeId }; + if (method === "GET" && cacheScope !== "none" && this.cache) { + const cached = await this.cache.get(cacheNamespace, serializedKey, cachePolicy); + scope?.assertActive(); + if (cached.hit) return cached.value; + } + const cacheGeneration = cacheScope === "public" && this.cache ? this.cache.generation(cacheNamespace) : undefined; + const inflightKey = JSON.stringify([serializedKey, cacheGeneration ?? null]); + let entry = this.inflight.get(inflightKey); + if (!entry) { + const controller = new AbortController(); + entry = { controller, consumers: 0, promise: Promise.resolve() }; + entry.promise = this.executeUnderlying({ ...options, method, url: initialUrl.href, signal: controller.signal }, urlPolicy) + .then(async (response) => { + if (this.cache && (cacheGeneration == null || cacheGeneration === this.cache.generation(cacheNamespace))) { + await this.cache.set(cacheNamespace, serializedKey, response, { ...cachePolicy, ttlMs: options.ttlMs ?? 0, negative: options.negative === true, generation: cacheGeneration }); + } + return response; + }) + .finally(() => { + if (this.inflight.get(inflightKey) === entry) this.inflight.delete(inflightKey); + this.updateDiagnostics(); + }); + this.inflight.set(inflightKey, entry); + } + return this.consume(entry, scope); + } + + /** @param {Record} options @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} urlPolicy */ + async executeUnderlying(options, urlPolicy) { + const domain = new URL(options.url).hostname, retryCount = Math.max(0, Math.min(9, Math.trunc(Number(options.retryCount ?? 0) || 0))), acceptableStatuses = new Set((options.acceptableStatuses ?? []).map((/** @type {unknown} */ status) => Number(status)).filter(Number.isFinite)); + const state = this.ensureCircuit(domain, options); + let ownsProbe = false; + let finalError = null; + for (let attempt = 0; attempt <= retryCount; attempt += 1) { + if (options.signal?.aborted) { finalError = new JhsError("ABORTED", "请求已取消", { source: options.providerId }); break; } + const blocked = ownsProbe ? null : this.checkCircuit(domain); + if (blocked) { + finalError = new JhsError("CIRCUIT_OPEN", `站点 ${domain} 已熔断,${blocked.remaining}秒后重试`, { source: options.providerId, details: { domain, remainingSeconds: blocked.remaining } }); + break; + } + if (state.state === "half-open") state.probing = true, ownsProbe = true; + try { + const response = await this.port.request(options); + this.urlPolicy.assertFinalUrl(response.finalUrl || options.url, urlPolicy); + if (isCloudflareChallenge(response.responseText ?? response.data, response.status)) { + throw new JhsError("CF_BLOCKED", `Cloudflare challenge blocked: ${domain}`, { + source: options.providerId, details: { domain, status: Number(response.status) || 0, contentLength: String(response.responseText ?? "").length }, + }); + } + if (acceptableStatuses.has(Number(response.status))) { + this.recordSuccess(domain); + return response; + } + if (response.status >= 400) { + const code = [401, 403].includes(response.status) ? "AUTH_REQUIRED" : response.status === 404 ? "NOT_FOUND" : response.status === 429 ? "RATE_LIMITED" : "NETWORK_ERROR"; + throw new JhsError(code, `HTTP ${response.status}`, { source: options.providerId, retryable: response.status === 429 || response.status >= 500, details: { status: response.status, domain } }); + } + this.recordSuccess(domain); + return response; + } catch (error) { + const normalized = JhsError.from(error, options.providerId); + this.recordAttempt(domain, true); + finalError = normalized; + if (!normalized.retryable || attempt >= retryCount || normalized.code === "CF_BLOCKED") break; + try { await waitForRetry(Number(options.retryDelayMs ?? 250) * (attempt + 1), options.signal); } + catch (abortError) { finalError = JhsError.from(abortError, options.providerId); break; } + } + } + if (finalError && ["NETWORK_ERROR", "TIMEOUT", "CF_BLOCKED"].includes(finalError.code)) this.recordBreakerFailure(domain, options); + if (ownsProbe) state.probing = false; + this.diagnostics?.recordError(finalError); + throw finalError; + } + + /** @param {string} domain @param {Record} options */ + ensureCircuit(domain, options = {}) { + let state = this.circuitBreakers.get(domain); + if (!state) { + state = { state: "closed", failCount: 0, openTime: 0, cooldownMs: Math.max(1000, Number(options.circuitCooldownMs ?? 60_000) || 60_000), threshold: Math.max(1, Number(options.circuitThreshold ?? 3) || 3), probing: false }; + this.circuitBreakers.set(domain, state); + } else { + if (options.circuitCooldownMs != null) state.cooldownMs = Math.max(1000, Number(options.circuitCooldownMs) || 60_000); + if (options.circuitThreshold != null) state.threshold = Math.max(1, Number(options.circuitThreshold) || 3); + } + return state; + } + /** @param {string} domain */ + checkCircuit(domain) { + const state = this.circuitBreakers.get(domain); + if (!state) return null; + if (state.state === "open") { + const elapsed = Date.now() - state.openTime; + if (elapsed < state.cooldownMs) return { state: "open", remaining: Math.ceil((state.cooldownMs - elapsed) / 1000) }; + state.state = "half-open", state.failCount = 0, state.probing = false; + } + return state.state === "half-open" && state.probing ? { state: "half-open", remaining: 0 } : null; + } + /** @param {string} domain */ + recordSuccess(domain) { + const state = this.circuitBreakers.get(domain); + if (state) state.state = "closed", state.failCount = 0, state.probing = false; + this.recordAttempt(domain, false); + } + /** @param {string} domain @param {boolean} error */ + recordAttempt(domain, error) { + const stats = this.domainStats.get(domain) ?? { count: 0, errors: 0, lastUsed: 0 }; + stats.count += 1, stats.errors += error ? 1 : 0, stats.lastUsed = Date.now(), this.domainStats.set(domain, stats); + } + /** @param {string} domain @param {Record} options */ + recordBreakerFailure(domain, options = {}) { + const state = this.ensureCircuit(domain, options); + state.failCount += 1; + if (state.state === "half-open" || state.failCount >= state.threshold) state.state = "open", state.openTime = Date.now(), state.probing = false; + } + /** @param {string} domain @param {Record} options */ + recordFailure(domain, options = {}) { + this.recordAttempt(domain, true); + this.recordBreakerFailure(domain, options); + } + getCircuitBreakerStatus() { return Object.fromEntries([...this.circuitBreakers].map(([domain, state]) => [domain, { ...state }])); } + getDomainStats() { return Object.fromEntries([...this.domainStats].map(([domain, stats]) => [domain, { ...stats }])); } + /** @param {string} domain */ + resetCircuitBreaker(domain) { this.circuitBreakers.delete(domain); } + resetAllCircuitBreakers() { this.circuitBreakers.clear(); } + clearDomainStats() { this.domainStats.clear(); } + + /** @param {{promise: Promise, controller: AbortController, consumers: number}} entry @param {import("../core/lifecycle-scope.js").LifecycleScope} [scope] */ + consume(entry, scope) { + entry.consumers += 1; + let released = false; + const consumer = { release: () => { + if (released) return; + released = true; + entry.consumers -= 1; + if (entry.consumers === 0 && !this.isSettled(entry)) entry.controller.abort(); + this.updateDiagnostics(); + } }; + let removeFromScope; + try { removeFromScope = scope?.ownRequestConsumer(consumer); } + catch (error) { consumer.release(); void entry.promise.catch(() => {}); throw error; } + this.updateDiagnostics(); + let removeAbortListener = () => {}; + const result = scope ? Promise.race([ + entry.promise, + new Promise((_, reject) => { + const onAbort = () => reject(new JhsError("ABORTED", "LifecycleScope 已释放", { source: scope.id })); + scope.signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => scope.signal.removeEventListener("abort", onAbort); + }), + ]) : entry.promise; + return result.finally(() => { + removeAbortListener(); + removeFromScope?.(); + consumer.release(); + }); + } + + /** @param {{promise: Promise}} entry */ + isSettled(entry) { return ![...this.inflight.values()].includes(/** @type {any} */ (entry)); } + updateDiagnostics() { + const consumers = [...this.inflight.values()].reduce((sum, entry) => sum + entry.consumers, 0); + this.diagnostics?.updateRequests(consumers, this.inflight.size); + } +} diff --git a/src/services/image-search-service.js b/src/services/image-search-service.js new file mode 100644 index 00000000..2cf21876 --- /dev/null +++ b/src/services/image-search-service.js @@ -0,0 +1,25 @@ +// @ts-check + +import { JhsError } from "../core/jhs-error.js"; + +export class ImageSearchService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { this.integrations = integrations; } + + /** @param {string} source @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async resolve(source, options = {}) { + const manifest = this.integrations.list("image.search-targets")[0]; + if (!manifest) throw new JhsError("UNSUPPORTED", "以图识图 Integration 不可用", { source: "ImageSearchService" }); + const adapter = this.integrations.getAdapter(manifest.id); + let imageUrl = String(source || ""); + if (imageUrl.startsWith("data:")) imageUrl = await adapter.upload(imageUrl, options); + else { + let parsed; + try { parsed = new URL(imageUrl); } + catch (cause) { throw new JhsError("INVALID_URL", "图片 URL 无效", { source: "ImageSearchService", cause }); } + if (!["http:", "https:"].includes(parsed.protocol)) throw new JhsError("INVALID_URL", "图片 URL 必须使用 HTTP/HTTPS", { source: "ImageSearchService" }); + imageUrl = parsed.href; + } + return Object.freeze({ imageUrl, targets: adapter.createTargets(imageUrl) }); + } +} diff --git a/src/services/magnet-service.js b/src/services/magnet-service.js new file mode 100644 index 00000000..a2ad5919 --- /dev/null +++ b/src/services/magnet-service.js @@ -0,0 +1,52 @@ +// @ts-check + +import { assessMagnetQuality } from "../core/magnet-quality.js"; + +export class MagnetService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } + /** @param {Record} movieRef @param {Record} [context] */ + async search(movieRef, context = {}) { + const results = []; + for (const provider of await this.providers.getAvailable("magnet", context)) { + try { + const items = await provider.search(movieRef, context); + this.providers.updateHealth(provider.id, { ok: true }); + if (Array.isArray(items)) results.push(...items); + } catch (error) { this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } + } + return results; + } + /** @param {Record} movieRef @param {Record} [context] */ + async listNative(movieRef, context = {}) { + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : "javdb"; + const manifest = (this.integrations?.list("movie.magnets") ?? []).find((item) => item.id === providerId); + if (!manifest) return []; + const adapter = this.integrations?.getAdapter(manifest.id); + return typeof adapter?.listMagnets === "function" ? adapter.listMagnets(movieRef, context) : []; + } + getBuiltInSources() { + return (this.integrations?.list("magnet.search") ?? []).flatMap((manifest) => { + const adapter = this.integrations?.getAdapter(manifest.id); + return typeof adapter?.getSources === "function" ? adapter.getSources() : []; + }); + } + /** @param {string} sourceId @param {string} keyword @param {Record} [context] */ + async searchSource(sourceId, keyword, context = {}) { + for (const manifest of this.integrations?.list("magnet.search") ?? []) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (adapter?.getSources?.().some((/** @type {{id: string}} */ source) => source.id === sourceId)) return adapter.search(sourceId, keyword, context); + } + return []; + } + /** @param {string} sourceId @param {string} keyword @param {Record} [context] */ + getSourceTargetUrl(sourceId, keyword, context = {}) { + for (const manifest of this.integrations?.list("magnet.search") ?? []) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (adapter?.getSources?.().some((/** @type {{id: string}} */ source) => source.id === sourceId)) return adapter.targetUrl(sourceId, keyword, context); + } + return null; + } + /** @param {Parameters[0]} magnet */ + assess(magnet) { return assessMagnetQuality(magnet); } +} diff --git a/src/plugins/external-search/magnet-source-registry.js b/src/services/magnet-source-registry.js similarity index 54% rename from src/plugins/external-search/magnet-source-registry.js rename to src/services/magnet-source-registry.js index abce0d39..01e2e7be 100644 --- a/src/plugins/external-search/magnet-source-registry.js +++ b/src/services/magnet-source-registry.js @@ -1,16 +1,23 @@ -const MAGNET_SOURCE_IDS = Object.freeze(["native-javdb", "native-javbus", "u9a9", "u3c3", "sukebei", "btsow"]); +// @ts-check -function normalizeMagnetResult(result, source) { +export const MAGNET_SOURCE_IDS = Object.freeze(["native-javdb", "native-javbus", "u9a9", "u3c3", "sukebei", "btsow"]); + +/** @typedef {Record} MagnetRecord */ +/** @typedef {MagnetRecord & {id: string, name: string, search: (...args: any[]) => unknown, targetUrl: (...args: any[]) => unknown, enabled?: boolean, priority?: number}} MagnetProvider */ + +/** @param {MagnetRecord} result @param {string} source */ +export function normalizeMagnetResult(result, source) { if (!result || !String(result.magnet || "").startsWith("magnet:")) return null; return { title: String(result.title || ""), magnet: result.magnet, size: result.size || "", date: result.date || "", seeders: Number(result.seeders) || 0, leechers: Number(result.leechers) || 0, source, files: Array.isArray(result.files) ? result.files : [] }; } -function extractInfoHash(magnet) { +function extractInfoHash(/** @type {string} */ magnet) { const hash = new URL(magnet).searchParams.get("xt")?.match(/^urn:btih:([a-z2-7]{32}|[a-f\d]{40})$/i)?.[1]; return hash ? hash.toUpperCase() : null; } -function deduplicateMagnetResults(results) { +export function deduplicateMagnetResults(/** @type {MagnetRecord[]} */ results) { + /** @type {Map} */ const unique = new Map(); results.forEach((result => { const key = extractInfoHash(result.magnet) || `${result.source}:${result.magnet}`; @@ -20,24 +27,24 @@ function deduplicateMagnetResults(results) { return [...unique.values()]; } -class MagnetSourceRegistry { - constructor(sources = []) { this.sources = new Map(); sources.forEach((source => this.register(source))); } - register(source) { +export class MagnetSourceRegistry { + constructor(/** @type {MagnetProvider[]} */ sources = []) { /** @type {Map} */ this.sources = new Map(); sources.forEach((source => this.register(source))); } + register(/** @type {MagnetProvider} */ source) { if (!source?.id || !source.name || "function" !== typeof source.search || "function" !== typeof source.targetUrl) throw new TypeError("Invalid magnet provider"); this.sources.set(source.id, { enabled: true, priority: 100, ...source }); return this; } - get(id) { return this.sources.get(id) || null; } + get(/** @type {string} */ id) { return this.sources.get(id) || null; } getEnabledSources() { return [...this.sources.values()].filter((source => source.enabled)).sort(((a, b) => a.priority - b.priority)); } } -function validateHttpsBaseUrl(value) { +export function validateHttpsBaseUrl(/** @type {string} */ value) { const url = new URL(value); if ("https:" !== url.protocol) throw new TypeError("Source URL must use https"); return url.origin; } -function validateCustomMagnetSource(config) { +export function validateCustomMagnetSource(/** @type {MagnetRecord} */ config) { const allowed = ["id", "name", "enabled", "priority", "searchUrlTemplate", "targetUrlTemplate", "parserType", "rowSelector", "titleSelector", "magnetSelector", "sizeSelector", "dateSelector", "seedersSelector", "leechersSelector", "resultsPath", "titlePath", "hashPath", "magnetPath", "sizePath", "datePath", "seedersPath"]; if (Object.keys(config).some((key => !allowed.includes(key)))) throw new TypeError("Unsupported custom source field"); if (!["torrent-table", "magnet-links", "json"].includes(config.parserType)) throw new TypeError("Unsupported parser type"); @@ -49,20 +56,25 @@ function validateCustomMagnetSource(config) { return { ...config, name: config.name.trim(), targetUrlTemplate: config.targetUrlTemplate || config.searchUrlTemplate }; } -function applyMagnetRules(result, tagRules = [], titleFilters = [], fileFilters = []) { +export function applyMagnetRules(/** @type {MagnetRecord} */ result, /** @type {MagnetRecord[]} */ tagRules = [], /** @type {MagnetRecord[]} */ titleFilters = [], /** @type {MagnetRecord[]} */ fileFilters = []) { const text = `${result.title || ""} ${(result.files || []).join(" ")}`; - const matches = (rule, value) => "regex" === rule.type ? new RegExp(rule.pattern, "i").test(value) : value.toLowerCase().includes(rule.pattern.toLowerCase()); + const matches = (/** @type {MagnetRecord} */ rule, /** @type {string} */ value) => "regex" === rule.type ? new RegExp(rule.pattern, "i").test(value) : value.toLowerCase().includes(rule.pattern.toLowerCase()); const tags = tagRules.filter((rule => rule.enabled && matches(rule, text))); - let hidden = false, penalty = 0; const filteredReasons = []; - [...titleFilters.map((rule => ({ ...rule, value: result.title || "" }))), ...fileFilters.map((rule => ({ ...rule, value: (result.files || []).join(" ") })))].filter((rule => rule.enabled)).forEach((rule => { + let hidden = false, penalty = 0; + /** @type {string[]} */ + const filteredReasons = []; + /** @type {MagnetRecord[]} */ + const filterRules = [...titleFilters.map((rule => ({ ...rule, value: result.title || "" }))), ...fileFilters.map((rule => ({ ...rule, value: (result.files || []).join(" ") })))]; + filterRules.filter((rule => rule.enabled)).forEach((rule => { if (!matches(rule, rule.value)) return; filteredReasons.push(rule.id || rule.pattern); "hide" === rule.action ? hidden = true : penalty += Number(rule.penalty) || 0; })); return { ...result, tags: tags.map((rule => rule.name)), customTagWeight: tags.reduce(((sum, rule) => sum + (Number(rule.weight) || 0)), 0), hidden, filterPenalty: penalty, filteredReasons }; } -function parseNativeMagnets(root, source) { +export function parseNativeMagnets(/** @type {Document | Element | any} */ root, /** @type {string} */ source) { + /** @type {Map} */ const results = new Map(); - $(root).find('a[href^="magnet:"],[data-clipboard-text^="magnet:"]').each(((index, element) => { + $(root).find('a[href^="magnet:"],[data-clipboard-text^="magnet:"]').each(((/** @type {number} */ index, /** @type {Element} */ element) => { const node = $(element), magnet = node.attr("href") || node.attr("data-clipboard-text"); if (!magnet) return; const container = node.closest(".item, .magnet-name, tr, .panel-block"), title = container.find(".name, .magnet-name, .title").first().text().trim() || node.text().trim() || "本站磁力"; @@ -72,10 +84,10 @@ function parseNativeMagnets(root, source) { return [...results.values()]; } -function readJsonPath(value, path) { return String(path || "").split(".").filter(Boolean).reduce(((current, key) => current?.[key]), value); } -function parseCustomMagnetResponse(config, payload, sourceId) { +function readJsonPath(/** @type {any} */ value, /** @type {unknown} */ path) { return String(path || "").split(".").filter(Boolean).reduce(((current, key) => current?.[key]), value); } +export function parseCustomMagnetResponse(/** @type {MagnetRecord} */ config, /** @type {any} */ payload, /** @type {string} */ sourceId) { config = validateCustomMagnetSource(config); - if ("json" === config.parserType) return (readJsonPath(payload, config.resultsPath) || []).map((item => normalizeMagnetResult({ title: readJsonPath(item, config.titlePath), magnet: config.magnetPath ? readJsonPath(item, config.magnetPath) : `magnet:?xt=urn:btih:${readJsonPath(item, config.hashPath)}`, size: readJsonPath(item, config.sizePath), date: readJsonPath(item, config.datePath), seeders: readJsonPath(item, config.seedersPath) }, `custom:${sourceId}`))).filter(Boolean); + if ("json" === config.parserType) return (readJsonPath(payload, config.resultsPath) || []).map(((/** @type {any} */ item) => normalizeMagnetResult({ title: readJsonPath(item, config.titlePath), magnet: config.magnetPath ? readJsonPath(item, config.magnetPath) : `magnet:?xt=urn:btih:${readJsonPath(item, config.hashPath)}`, size: readJsonPath(item, config.sizePath), date: readJsonPath(item, config.datePath), seeders: readJsonPath(item, config.seedersPath) }, `custom:${sourceId}`))).filter(Boolean); const root = utils.htmlTo$dom(payload), rows = "magnet-links" === config.parserType ? root.find('a[href^="magnet:"]') : root.find(config.rowSelector); - return rows.map(((index, element) => { const row = $(element), magnetNode = "magnet-links" === config.parserType ? row : row.find(config.magnetSelector).first(); return normalizeMagnetResult({ title: "magnet-links" === config.parserType ? row.text().trim() : row.find(config.titleSelector).first().text().trim(), magnet: magnetNode.attr("href") || magnetNode.attr("data-magnet"), size: row.find(config.sizeSelector).text().trim(), date: row.find(config.dateSelector).text().trim(), seeders: row.find(config.seedersSelector).text().trim(), leechers: row.find(config.leechersSelector).text().trim() }, `custom:${sourceId}`); })).get().filter(Boolean); + return rows.map(((/** @type {number} */ index, /** @type {Element} */ element) => { const row = $(element), magnetNode = "magnet-links" === config.parserType ? row : row.find(config.magnetSelector).first(); return normalizeMagnetResult({ title: "magnet-links" === config.parserType ? row.text().trim() : row.find(config.titleSelector).first().text().trim(), magnet: magnetNode.attr("href") || magnetNode.attr("data-magnet"), size: row.find(config.sizeSelector).text().trim(), date: row.find(config.dateSelector).text().trim(), seeders: row.find(config.seedersSelector).text().trim(), leechers: row.find(config.leechersSelector).text().trim() }, `custom:${sourceId}`); })).get().filter(Boolean); } diff --git a/src/services/movie-identity-service.js b/src/services/movie-identity-service.js new file mode 100644 index 00000000..74373735 --- /dev/null +++ b/src/services/movie-identity-service.js @@ -0,0 +1,134 @@ +// @ts-check + +import { normalizeMovieCarNum } from "../core/movie-identity.js"; + +export class MovieIdentityService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(integrations = null) { this.integrations = integrations; } + /** @param {unknown} value */ + normalize(value) { return normalizeMovieCarNum(value); } + /** @param {...unknown} candidates */ + firstValid(...candidates) { + for (const candidate of candidates) { + const carNum = this.normalize(candidate); + if (carNum) return carNum; + } + return null; + } + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async resolve(movieRef, options = {}) { + const carNum = this.normalize(movieRef.carNum); + if (!carNum) return null; + const candidates = this.integrations?.list("movie.search") ?? []; + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : null; + const ordered = providerId + ? candidates.filter((manifest) => manifest.id === providerId) + : [...candidates].sort((left, right) => Number(right.id === "javdb") - Number(left.id === "javdb")); + for (const manifest of ordered) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.resolveMovie !== "function") continue; + const resolved = await adapter.resolveMovie({ ...movieRef, carNum }, options); + if (resolved) return resolved; + } + return Object.freeze({ ...movieRef, carNum }); + } + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async detail(movieRef, options = {}) { + const candidates = this.integrations?.list("movie.detail") ?? []; + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : movieRef.movieId ? "javdb" : null; + const ordered = providerId ? candidates.filter((manifest) => manifest.id === providerId) : candidates; + for (const manifest of ordered) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.getDetail === "function") return adapter.getDetail(movieRef, options); + } + return null; + } + /** @param {{period?: string, filter?: string, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async rankings(options = {}) { + for (const manifest of this.integrations?.list("movie.ranking") ?? []) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.listRankings === "function") return adapter.listRankings(options); + } + return []; + } + /** @param {string} providerId @param {Record} query @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async catalog(providerId, query = {}, options = {}) { + const manifest = (this.integrations?.list("movie.catalog") ?? []).find((item) => item.id === providerId); + if (!manifest) throw new TypeError(`Movie catalog provider is unavailable: ${providerId}`); + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.listCatalog !== "function") throw new TypeError(`Movie catalog operation is unavailable: ${providerId}`); + return adapter.listCatalog(query, options); + } + /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async people(providerId, movieRef, options = {}) { + const manifest = (this.integrations?.list("movie.credits") ?? []).find((item) => item.id === providerId); + if (!manifest) return Object.freeze({ actors: Object.freeze([]), seller: null }); + const adapter = this.integrations?.getAdapter(manifest.id); + return typeof adapter?.getPeople === "function" ? adapter.getPeople(movieRef, options) : Object.freeze({ actors: Object.freeze([]), seller: null }); + } + /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async images(providerId, movieRef, options = {}) { + const manifest = (this.integrations?.list("movie.images") ?? []).find((item) => item.id === providerId); + if (!manifest) return []; + const adapter = this.integrations?.getAdapter(manifest.id); + return typeof adapter?.getImages === "function" ? adapter.getImages(movieRef, options) : []; + } + /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async preview(providerId, movieRef, options = {}) { + const manifest = (this.integrations?.list("movie.preview") ?? []).find((item) => item.id === providerId); + if (!manifest) throw new TypeError(`Movie preview provider is unavailable: ${providerId}`); + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.getPreviewForMovie !== "function") throw new TypeError(`Movie preview operation is unavailable: ${providerId}`); + return adapter.getPreviewForMovie(movieRef, options); + } + /** @param {Record} movieRef @param {string[]} providerIds */ + sourceUrls(movieRef, providerIds) { + return Object.freeze(providerIds.map((providerId) => { + try { + const adapter = this.integrations?.getAdapter(providerId); + const url = typeof adapter?.detailUrl === "function" ? adapter.detailUrl(movieRef) : null; + return url ? Object.freeze({ providerId, url }) : null; + } catch { return null; } + }).filter(Boolean)); + } + /** @param {string} providerId @param {Record} movieRef */ + searchUrl(providerId, movieRef) { + try { + const adapter = this.integrations?.getAdapter(providerId); + return typeof adapter?.searchUrl === "function" ? adapter.searchUrl(movieRef) : null; + } catch { return null; } + } + /** @param {string} providerId */ + providerOrigin(providerId) { + try { + const adapter = this.integrations?.getAdapter(providerId); + return typeof adapter?.origin === "function" ? adapter.origin() : null; + } catch { return null; } + } + /** @param {string} providerId @param {string} url */ + matchesProviderUrl(providerId, url) { + try { + const adapter = this.integrations?.getAdapter(providerId); + return typeof adapter?.matchesUrl === "function" && adapter.matchesUrl(url); + } catch { return false; } + } + /** @param {Record} [settings] */ + externalSites(settings = {}) { + const manifest = (this.integrations?.list("movie.external-sites") ?? [])[0], adapter = manifest && this.integrations?.getAdapter(manifest.id); + return typeof adapter?.getSites === "function" ? adapter.getSites(settings) : []; + } + /** @param {string} siteId @param {Record} [settings] */ + externalSiteOrigin(siteId, settings = {}) { + return this.externalSites(settings).find((/** @type {{id?: string, baseUrl?: string}} */ site) => site.id === siteId)?.baseUrl || ""; + } + /** @param {string} siteId @param {string} carNum @param {{settings?: Record, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async searchExternalSite(siteId, carNum, options = {}) { + const manifest = (this.integrations?.list("movie.external-sites") ?? [])[0], adapter = manifest && this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.searchSite !== "function") return Object.freeze({ searchUrl: "", matches: Object.freeze([]) }); + return adapter.searchSite(siteId, carNum, options); + } + externalNavigationLinks() { + const manifest = (this.integrations?.list("navigation.external") ?? [])[0], adapter = manifest && this.integrations?.getAdapter(manifest.id); + return typeof adapter?.getNavigationLinks === "function" ? adapter.getNavigationLinks() : []; + } +} diff --git a/src/services/navigation-service.js b/src/services/navigation-service.js new file mode 100644 index 00000000..a4e999b5 --- /dev/null +++ b/src/services/navigation-service.js @@ -0,0 +1,14 @@ +// @ts-check + +import { assertPort, PORT_METHODS } from "../contracts/ports.js"; + +export class NavigationService { + /** @param {any} navigationPort */ + constructor(navigationPort) { this.port = assertPort(navigationPort, "NavigationPort", PORT_METHODS.navigation); } + /** @param {string | URL} url @param {{newTab?: boolean}} [options] */ + open(url, options) { return this.port.open(String(url), options); } + /** @param {string | URL} url */ + assign(url) { return this.port.assign(String(url)); } + /** @param {string | URL} url */ + replace(url) { return this.port.replace(String(url)); } +} diff --git a/src/services/offline-service.js b/src/services/offline-service.js new file mode 100644 index 00000000..0932fd42 --- /dev/null +++ b/src/services/offline-service.js @@ -0,0 +1,57 @@ +// @ts-check + +export class OfflineService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(providers, integrations) { this.providers = providers; this.integrations = integrations; } + /** @param {string} integrationId @param {unknown} resource @param {Record} [context] */ + submitWithIntegration(integrationId, resource, context = {}) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.submit !== "function") throw new TypeError(`Integration ${integrationId} does not support offline submission`); + return adapter.submit(resource, context); + } + /** @param {string} integrationId @param {Record} [context] */ + checkAccount(integrationId, context = {}) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.checkAccount !== "function") throw new TypeError(`Integration ${integrationId} does not support account checks`); + return adapter.checkAccount(context); + } + /** @param {string} integrationId @param {string} keyword @param {Record} [context] */ + searchFiles(integrationId, keyword, context = {}) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.searchFiles !== "function") throw new TypeError(`Integration ${integrationId} does not support file search`); + return adapter.searchFiles(keyword, context); + } + /** @param {string} integrationId @param {string} fileId @param {string} newName @param {Record} [context] */ + renameFile(integrationId, fileId, newName, context = {}) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.renameFile !== "function") throw new TypeError(`Integration ${integrationId} does not support file rename`); + return adapter.renameFile(fileId, newName, context); + } + /** @param {string} integrationId @param {Record} match */ + getPlayUrl(integrationId, match) { + const adapter = this.integrations.getAdapter(integrationId); + return typeof adapter.getPlayUrl === "function" ? adapter.getPlayUrl(match) : null; + } + /** @param {string} integrationId */ + getIntegrationHomeUrl(integrationId) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.homeUrl !== "string") throw new TypeError(`Integration ${integrationId} does not declare a home URL`); + return adapter.homeUrl; + } + /** @param {Record} resource @param {Record} [context] */ + async submit(resource, context = {}) { + const providers = await this.providers.getAvailable("offline", context); + const failures = []; + for (const provider of providers) { + try { + const result = await provider.submit(resource, context); + this.providers.updateHealth(provider.id, { ok: true }); + return result; + } catch (error) { + failures.push(error); + this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + throw new AggregateError(failures, providers.length ? "所有离线 Provider 均失败" : "没有可用的离线 Provider"); + } +} diff --git a/src/services/preview-service.js b/src/services/preview-service.js new file mode 100644 index 00000000..e5ebd33a --- /dev/null +++ b/src/services/preview-service.js @@ -0,0 +1,154 @@ +// @ts-check + +import { ProviderError } from "../core/cache-policy.js"; +import { L, normalizeCarNum } from "../core/constants.js"; + +/** + * 预览视频唯一策略源:JavDB / JavBus / 列表卡片共用同一 DMM 解析与开关语义。 + * + * - enablePreviewVideo: 预览视频总开关(OFF 时任何入口、播放器、DMM 请求都不存在) + * - enableLoadPreviewVideo: DMM 高画质增强子开关(OFF 时仍允许宿主原生预览,但不发起 DMM 请求) + */ + +/** @param {string[]} e @param {string | undefined} t @returns {string | null} */ +export const Z = (e, t) => { + if (!e || 0 === e.length) return null; + const n = new Set(e); + if (t && n.has(t)) return t; + const a = L.map((e => e.quality)).reverse(); + for (const i of a) if (n.has(i)) return i; + return e[0] || null; +}, ee = "jhs_dmm_video"; + +const DMM_CACHE_TTL = 30 * 864e5, DMM_CACHE_LIMIT = 500; + +/** @param {string | null} raw 坏 JSON 返回空对象而不是让整个预览链路瘫痪 */ +function parseJsonMap(raw) { + if (!raw) return {}; + try { + const value = JSON.parse(raw); + return value && "object" == typeof value && !Array.isArray(value) ? value : {}; + } catch { return {}; } +} + +/** @param {Record} cache 裁掉过期与超量条目 */ +function pruneDmmCache(cache) { + const now = Date.now(); + for (const key of Object.keys(cache)) { + const entry = cache[key]; + // 无时间戳的历史条目视为仍然有效,避免升级后缓存整体失效 + (!entry || (entry.time && now - entry.time > DMM_CACHE_TTL)) && delete cache[key]; + } + const keys = Object.keys(cache); + if (keys.length > DMM_CACHE_LIMIT) { + keys.sort(((a, b) => (cache[a].time || 0) - (cache[b].time || 0))); + for (const key of keys.slice(0, keys.length - DMM_CACHE_LIMIT)) delete cache[key]; + } + return cache; +} + +/** @param {Record} settings */ +export function isPreviewEnabled(settings) { + return settings?.enablePreviewVideo !== "no"; +} + +/** @param {Record} settings */ +export function isDmmEnabled(settings) { + return settings?.enableLoadPreviewVideo !== "no"; +} + +/** 预览总开关:任一预览入口可见的前提。 @param {Record} settings */ +export function canUsePreview(settings) { + return isPreviewEnabled(settings); +} + +/** 宿主原生预览:总开关 ON 即可(不依赖 DMM)。 @param {Record} settings */ +export function canUseNativePreview(settings) { + return isPreviewEnabled(settings); +} + +/** DMM 高画质能力:总开关与 DMM 子开关都开。 @param {Record} settings */ +export function canUseDmmPreview(settings) { + return isPreviewEnabled(settings) && isDmmEnabled(settings); +} + +/** 列表卡片 Preview 按钮:当前实现只有 DMM source,因此等价于 canUseDmmPreview。 @param {Record} settings */ +export function canUseCardPreview(settings) { + return canUseDmmPreview(settings); +} + +class DmmPreviewParser { + /** @param {string | null} e @param {any} storage @param {any} movie @param {any} scope */ + constructor(e, storage, movie, scope) { + /** @type {string} */ + this.carNum = e || "", this.storage = storage, this.movie = movie, this.scope = scope, this.lastError = null; + } + _checkCache() { + const cached = pruneDmmCache(parseJsonMap(this.storage.getLocal(ee))); + return cached[this.carNum] ? (clog.debug("缓存中存在预览视频信息", cached[this.carNum]), cached[this.carNum]) : null; + } + /** @param {Record} e */ + _updateCache(e) { + const cached = pruneDmmCache(parseJsonMap(this.storage.getLocal(ee))); + cached[this.carNum] = { ...e, time: Date.now() }, clog.debug("成功解析出预览视频并已缓存:", e), this.storage.setLocal(ee, JSON.stringify(cached)); + } + async _fetchRemote() { + const result = await this.movie.preview("dmm", { carNum: this.carNum }, { scope: this.scope }); + const button = $("#fanzaBtn"); + if (!result.sources) { + clog.warn("所有关键词尝试均未找到匹配的Content ID, 解析Dmm视频失败"); + button.attr("href", result.searchUrl).attr("title", "未查询到, 点击前往搜索页").css("backgroundColor", "var(--jhs-status-filter)"); + return null; + } + button.attr("href", result.pageUrl).css("backgroundColor", "var(--jhs-status-down)"); + if (result.matchType === "multiple") button.append('多结果'); + const cacheKey = "jhs_other_site_dmm", cache = pruneDmmCache(parseJsonMap(this.storage.getLocal(cacheKey))); + cache[this.carNum] = { type: result.matchType, url: result.pageUrl, time: Date.now() }; + this.storage.setLocal(cacheKey, JSON.stringify(cache)); + return result.sources; + } + async fetchVideo() { + const carNum = normalizeCarNum(this.carNum); + if (!carNum) return clog.warn("跳过 DMM 解析:番号不可用"), null; + this.carNum = carNum; + const e = this._checkCache(); + if (e) return e; + try { + const e = this.carNum.toLowerCase(); + if (e.startsWith("heyzo") || /^(n\d+|\d+(-\d+)*)$/.test(e) || /^n\d+$/.test(e)) return clog.debug("无码番号类型,取消 DMM 解析"), null; + if (this.carNum.includes("VR-")) return clog.debug("VR 类型,取消 DMM 解析"), null; + const sources = await this._fetchRemote(); + if (!sources) return null; + return this._updateCache(sources), sources; + } catch (n) { + const error = /** @type {PreviewFailure} */ (n); + this.lastError = n instanceof ProviderError ? n : new ProviderError("dmm", error?.code || "PARSE_ERROR", error?.message || String(n), { + cause: n, + retryable: error?.retryable === true + }), clog.error("DMM API 搜索失败:", this.lastError); + const e = $("#fanzaBtn"); + return e.attr("href", this.movie.searchUrl("dmm", { carNum: this.carNum })), + e.attr("title", "未查询到, 点击前往搜索页"), e.css("backgroundColor", "var(--jhs-status-filter)"), null; + } + } +} + +/** @typedef {{ code?: string, message?: string, retryable?: boolean }} PreviewFailure */ +/** @typedef {{ sources: Record | null, error: PreviewFailure | null }} PreviewResult */ + +/** 获取 DMM 预览源及可供界面判断的失败原因。 */ +/** @param {string | null} carNum @param {any} storage @param {any} movie @param {any} scope @returns {Promise} */ +export async function fetchDmmPreview(carNum, storage, movie, scope) { + const parser = new DmmPreviewParser(carNum, storage, movie, scope), sources = await parser.fetchVideo(); + return { + sources, + error: parser.lastError + }; +} + +/** DMM 子开关门禁:关闭时直接返回无源结果,不发起任何 DMM 请求。 */ +/** @param {string | null} carNum @param {any} storage @param {any} movie @param {any} scope @param {Record} settings @returns {Promise} */ +export async function fetchDmmPreviewIfEnabled(carNum, storage, movie, scope, settings) { + if (!isDmmEnabled(settings)) return { sources: null, error: null }; + return fetchDmmPreview(carNum, storage, movie, scope); +} diff --git a/src/services/profile-service.js b/src/services/profile-service.js new file mode 100644 index 00000000..593403db --- /dev/null +++ b/src/services/profile-service.js @@ -0,0 +1,44 @@ +// @ts-check + +export class ProfileService extends EventTarget { + /** @param {{windowRuntime?: Window, scope: import("../core/lifecycle-scope.js").LifecycleScope, settings?: import("./settings-service.js").SettingsService}} options */ + constructor(options) { + super(); + this.window = options.windowRuntime ?? window; + this.scope = options.scope; + this.settings = options.settings ?? null; + this.coarseQuery = this.window.matchMedia("(any-pointer: coarse)"); + this.profile = this.calculate(); + this.started = false; + } + + calculate() { + const width = this.window.innerWidth; + const mobileMode = /** @type {Readonly>} */ (this.settings?.snapshot() ?? {}).mobileMode; + if (mobileMode === "on") return "compact"; + if (mobileMode === "off") return width >= 1200 ? "wide" : "regular"; + const shortSide = Math.min(this.window.innerWidth, this.window.innerHeight); + if (width <= 767 || this.coarseQuery.matches && shortSide <= 480) return "compact"; + if (width >= 1200) return "wide"; + return "regular"; + } + + start() { + if (this.started) return; + this.started = true; + const update = () => { + const next = this.calculate(); + if (next === this.profile) return; + const previous = this.profile; + this.profile = next; + this.dispatchEvent(new CustomEvent("profile.changed", { detail: Object.freeze({ previous, profile: next }) })); + }; + this.scope.listen(this.window, "resize", update, { passive: true }); + this.scope.listen(this.window, "orientationchange", update, { passive: true }); + this.scope.listen(this.coarseQuery, "change", update); + if (this.settings) this.scope.listen(this.settings, "settings.changed", update); + update(); + } + + current() { return this.profile; } +} diff --git a/src/services/related-service.js b/src/services/related-service.js new file mode 100644 index 00000000..9fc0d4f8 --- /dev/null +++ b/src/services/related-service.js @@ -0,0 +1,14 @@ +// @ts-check + +export class RelatedService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { this.integrations = integrations; } + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, page?: number, limit?: number}} [options] */ + async list(movieRef, options = {}) { + for (const manifest of this.integrations.list("movie.related")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.listRelated === "function") return adapter.listRelated(movieRef, options); + } + return []; + } +} diff --git a/src/services/resource-settings-service.js b/src/services/resource-settings-service.js new file mode 100644 index 00000000..8a847bef --- /dev/null +++ b/src/services/resource-settings-service.js @@ -0,0 +1,133 @@ +// @ts-check + +import { parseBooleanSetting as parseBooleanSettingValue } from "../core/feature-helpers.js"; +import { MAGNET_SOURCE_IDS, validateCustomMagnetSource, validateHttpsBaseUrl } from "./magnet-source-registry.js"; + +export const BUILT_IN_NATIVE_MAGNET_SOURCES = Object.freeze([ + { id: "native-javdb", name: "JavDB 本站", type: "本站资源", domain: "javdb.com", priority: 10, enabled: true }, + { id: "native-javbus", name: "JavBus 本站", type: "本站资源", domain: "javbus.com", priority: 11, enabled: true } +]); +export const BUILT_IN_MAGNET_SOURCES = BUILT_IN_NATIVE_MAGNET_SOURCES; + +/** @typedef {Record} ResourceRecord */ + +export function validateRule(/** @type {ResourceRecord} */ rule) { + if (!rule.name?.trim() || !rule.pattern?.trim()) throw new TypeError("规则名称和匹配内容不能为空"); + if ("regex" === rule.type) try { new RegExp(rule.pattern); } catch { throw new TypeError("正则表达式无效"); } + return { ...rule, name: rule.name.trim(), pattern: rule.pattern.trim() }; +} + +export function buildCustomMagnetSource(/** @type {ResourceRecord} */ form, /** @type {ResourceRecord | null} */ existing = null) { + const parserType = form.parserType || "magnet-links"; + const config = { id: existing?.id || `source-${Date.now()}`, name: String(form.name || "").trim(), enabled: Boolean(form.enabled), priority: Number(form.priority) || 100, searchUrlTemplate: String(form.searchUrlTemplate || "").trim(), targetUrlTemplate: String(form.targetUrlTemplate || form.searchUrlTemplate || "").trim(), parserType }; + if (!config.name) throw new TypeError("来源名称不能为空"); + if ("torrent-table" === parserType) Object.assign(config, { rowSelector: form.rowSelector, titleSelector: form.titleSelector, magnetSelector: form.magnetSelector, sizeSelector: form.sizeSelector, dateSelector: form.dateSelector, seedersSelector: form.seedersSelector, leechersSelector: form.leechersSelector }); + if ("json" === parserType) Object.assign(config, { resultsPath: form.resultsPath, titlePath: form.titlePath, magnetPath: form.magnetPath, hashPath: form.hashPath, sizePath: form.sizePath, datePath: form.datePath, seedersPath: form.seedersPath }); + return validateCustomMagnetSource(config); +} + +/** @param {unknown} value @param {boolean} [fallback] */ +export function parseBooleanSetting(value, fallback = false) { return parseBooleanSettingValue(value, fallback); } + +/** Parse finite numeric setting with optional bounds. */ +/** @param {unknown} value @param {number} fallback @param {{min?: number, max?: number}} [bounds] */ +export function parseFiniteNumberSetting(value, fallback, bounds = {}) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + const rounded = Math.round(parsed); + if (bounds.min != null && rounded < bounds.min) return bounds.min; + if (bounds.max != null && rounded > bounds.max) return bounds.max; + return rounded; +} + +/** Normalize one canonical cloud setting before it reaches storage. */ +/** @param {string} key @param {unknown} value */ +export function normalizeCloudSetting(key, value) { + if (["enable123Offline", "enable115Offline", "enable115Match", "enable115LoginRedirect"].includes(key)) return parseBooleanSetting(value, false); + if (key === "offlineProviderMode") return ["ask", "123", "115"].includes(String(value)) ? String(value) : "ask"; + if (key === "oneOneFiveConcurrency") return parseFiniteNumberSetting(value, 4, { min: 1, max: 10 }); + if (key === "oneOneFiveCacheMinutes") return parseFiniteNumberSetting(value, 60, { min: 1, max: 1440 }); + throw new TypeError(`未知云盘设置: ${key}`); +} + +export class ResourceSettingsService { + constructor(/** @type {any} */ storage = storageManager) { this.storage = storage; } + async getArray(/** @type {string} */ key) { const value = await this.storage.getSetting(key, "[]"); if (Array.isArray(value)) return value; try { const parsed = JSON.parse(value || "[]"); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } + async saveArray(/** @type {string} */ key, /** @type {any[]} */ value) { if (!Array.isArray(value)) throw new TypeError("配置必须是数组"); await this.storage.saveSettingItem(key, JSON.stringify(value)); return value; } + async updateArray(/** @type {string} */ key, /** @type {(list: any[]) => any[]} */ updater) { + if (typeof this.storage.updateSetting === "function") { + /** @type {any[] | undefined} */ + let result; + await this.storage.updateSetting((/** @type {Record} */ draft) => { + const raw = draft[key]; + let list; + if (Array.isArray(raw)) list = raw; + else { try { const parsed = JSON.parse(raw || "[]"); list = Array.isArray(parsed) ? parsed : []; } catch { list = []; } } + result = updater(list); + if (!Array.isArray(result)) throw new TypeError("配置必须是数组"); + draft[key] = JSON.stringify(result); + }); + return result; + } + const current = await this.getArray(key); + const next = updater(current); + await this.saveArray(key, next); + return next; + } + getMagnetSources() { return this.getArray("customMagnetSources"); } + saveMagnetSources(/** @type {ResourceRecord[]} */ value) { value.forEach(validateCustomMagnetSource); return this.saveArray("customMagnetSources", value); } + getMagnetTagRules() { return this.getArray("magnetTagRules"); } + saveMagnetTagRules(/** @type {ResourceRecord[]} */ value) { value.forEach(validateRule); return this.saveArray("magnetTagRules", value); } + getMagnetFilterRules() { return this.getArray("magnetFilterRules"); } + saveMagnetFilterRules(/** @type {ResourceRecord[]} */ value) { value.forEach(validateRule); return this.saveArray("magnetFilterRules", value); } + async getBuiltInSources() { return await this.getArray("magnetBuiltInSources"); } + saveBuiltInSources(/** @type {ResourceRecord[]} */ value) { value.forEach((source => { if (!MAGNET_SOURCE_IDS.includes(source.id)) throw new TypeError("未知的内置磁力源"); if (source.baseUrl) validateHttpsBaseUrl(source.baseUrl); if (source.priority != null && (!Number.isFinite(Number(source.priority)) || Number(source.priority) < 1)) throw new TypeError("来源优先级无效"); })); return this.saveArray("magnetBuiltInSources", value); } + async getScreenshotSettings() { return { mode: await this.storage.getSetting("screenshotMode", "auto"), providers: await this.getArray("screenshotProviders") }; } + async saveScreenshotMode(/** @type {string} */ mode) { await this.storage.saveSettingItem("screenshotMode", mode); return mode; } + async saveScreenshotProviders(/** @type {ResourceRecord[]} */ providers) { return this.saveArray("screenshotProviders", providers); } + async saveScreenshotSettings(/** @type {ResourceRecord} */ value) { + if (typeof this.storage.patch === "function") return this.storage.patch({ screenshotMode: value.mode, screenshotProviders: JSON.stringify(value.providers) }); + await this.saveScreenshotMode(value.mode); + await this.saveScreenshotProviders(value.providers); + return value; + } + async getCloudSettings() { + return { + enable123Offline: parseBooleanSetting(await this.storage.getSetting("enable123Offline", true), true), + enable115Offline: parseBooleanSetting(await this.storage.getSetting("enable115Offline", false), false), + enable115Match: parseBooleanSetting(await this.storage.getSetting("enable115Match", false), false), + enable115LoginRedirect: parseBooleanSetting(await this.storage.getSetting("enable115LoginRedirect", false), false), + providerMode: normalizeCloudSetting("offlineProviderMode", await this.storage.getSetting("offlineProviderMode", "ask")), + concurrency: parseFiniteNumberSetting(await this.storage.getSetting("oneOneFiveConcurrency", 4), 4, { min: 1, max: 10 }), + cacheMinutes: parseFiniteNumberSetting(await this.storage.getSetting("oneOneFiveCacheMinutes", 60), 60, { min: 1, max: 1440 }) + }; + } + async saveCloudSetting(/** @type {string} */ key, /** @type {unknown} */ value) { + /** @type {Record} */ + const keyMap = { enable123Offline: "enable123Offline", enable115Offline: "enable115Offline", enable115Match: "enable115Match", enable115LoginRedirect: "enable115LoginRedirect", offlineProviderMode: "offlineProviderMode", oneOneFiveConcurrency: "oneOneFiveConcurrency", oneOneFiveCacheMinutes: "oneOneFiveCacheMinutes" }; + const storageKey = keyMap[key]; + if (!storageKey) throw new TypeError(`未知云盘设置: ${key}`); + const normalized = normalizeCloudSetting(key, value); + await this.storage.saveSettingItem(storageKey, normalized); + return normalized; + } + async saveCloudSettings(/** @type {ResourceRecord} */ value) { + const normalized = { enable123Offline: normalizeCloudSetting("enable123Offline", value.enable123Offline), enable115Offline: normalizeCloudSetting("enable115Offline", value.enable115Offline), enable115Match: normalizeCloudSetting("enable115Match", value.enable115Match), enable115LoginRedirect: normalizeCloudSetting("enable115LoginRedirect", value.enable115LoginRedirect), offlineProviderMode: normalizeCloudSetting("offlineProviderMode", value.providerMode || "ask"), oneOneFiveConcurrency: normalizeCloudSetting("oneOneFiveConcurrency", value.concurrency), oneOneFiveCacheMinutes: normalizeCloudSetting("oneOneFiveCacheMinutes", value.cacheMinutes) }; + if (typeof this.storage.patch === "function") return this.storage.patch(normalized); + for (const [key, item] of Object.entries(normalized)) await this.storage.saveSettingItem(key, item); + return normalized; + } + async exportConfig() { return { customMagnetSources: await this.getMagnetSources(), magnetTagRules: await this.getMagnetTagRules(), magnetFilterRules: await this.getMagnetFilterRules(), magnetBuiltInSources: await this.getBuiltInSources(), screenshot: await this.getScreenshotSettings() }; } + async importConfig(/** @type {string} */ text) { + /** @type {ResourceRecord} */ let value; try { value = JSON.parse(text); } catch (error) { throw new TypeError(`配置格式错误:${error instanceof Error ? error.message : String(error)}`); } + if (!value || "object" !== typeof value || Array.isArray(value)) throw new TypeError("配置格式错误:根节点必须是对象"); + /** @type {Array<() => Promise>} */ const operations = []; + if (Object.hasOwn(value, "customMagnetSources")) { if (!Array.isArray(value.customMagnetSources)) throw new TypeError("自定义磁力源必须是数组"); value.customMagnetSources.forEach(validateCustomMagnetSource); operations.push(() => this.saveMagnetSources(value.customMagnetSources)); } + if (Object.hasOwn(value, "magnetTagRules")) { if (!Array.isArray(value.magnetTagRules)) throw new TypeError("标签规则必须是数组"); value.magnetTagRules.forEach(validateRule); operations.push(() => this.saveMagnetTagRules(value.magnetTagRules)); } + if (Object.hasOwn(value, "magnetFilterRules")) { if (!Array.isArray(value.magnetFilterRules)) throw new TypeError("过滤规则必须是数组"); value.magnetFilterRules.forEach(validateRule); operations.push(() => this.saveMagnetFilterRules(value.magnetFilterRules)); } + if (Object.hasOwn(value, "magnetBuiltInSources")) { if (!Array.isArray(value.magnetBuiltInSources)) throw new TypeError("内置磁力源配置必须是数组"); operations.push(() => this.saveBuiltInSources(value.magnetBuiltInSources)); } + if (Object.hasOwn(value, "screenshot")) { if (!value.screenshot || !["auto", "manual"].includes(value.screenshot.mode) || !Array.isArray(value.screenshot.providers)) throw new TypeError("截图配置无效"); operations.push(() => this.saveScreenshotSettings(value.screenshot)); } + for (const operation of operations) await operation(); + return value; + } +} diff --git a/src/services/review-service.js b/src/services/review-service.js new file mode 100644 index 00000000..b7bf47c7 --- /dev/null +++ b/src/services/review-service.js @@ -0,0 +1,14 @@ +// @ts-check + +export class ReviewService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { this.integrations = integrations; } + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, page?: number, limit?: number}} [options] */ + async list(movieRef, options = {}) { + for (const manifest of this.integrations.list("movie.reviews")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.listReviews === "function") return adapter.listReviews(movieRef, options); + } + return []; + } +} diff --git a/src/services/screenshot-service.js b/src/services/screenshot-service.js new file mode 100644 index 00000000..121e9b02 --- /dev/null +++ b/src/services/screenshot-service.js @@ -0,0 +1,107 @@ +// @ts-check + +import { BUILT_IN_SCREENSHOT_SOURCES } from "./screenshot-sources.js"; + +/** 未显式指定 provider 时,长缩略图只允许使用的默认来源。 */ +const DEFAULT_SCREENSHOT_PROVIDER = "javstore"; +/** 长缩略图链路只允许这些来源进入;JavBus cover / FC2 gallery 均不属于长缩略图。 */ +const SCREENSHOT_PROVIDER_IDS = new Set([DEFAULT_SCREENSHOT_PROVIDER]); + +export class ScreenshotService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } + + /** 详情/FC2 自动加载开关;列表手动按钮可显式绕过此门禁。 @param {Record} settings */ + isEnabled(settings) { return settings?.enableLoadScreenShot !== "no"; } + + /** 兼容旧存储:screenshotProviders 可能以 JSON 字符串或数组保存。 @param {unknown} value */ + parseScreenshotProviders(value) { + if (Array.isArray(value)) return value; + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { return []; } + } + return []; + } + + /** 返回截图来源设置(mode + 用户覆盖)。 @param {Record} settings */ + getScreenshotSettings(settings) { + return { mode: settings?.screenshotMode === "manual" ? "manual" : "auto", providers: this.parseScreenshotProviders(settings?.screenshotProviders) }; + } + + /** 合并内置目录与用户配置,过滤已禁用来源(implemented=false 仅表示未实现,enabled 由设置 UI 锁定为 false)。 @param {Record} settings */ + getEnabledProviders(settings) { + const configured = this.parseScreenshotProviders(settings?.screenshotProviders); + return BUILT_IN_SCREENSHOT_SOURCES + .map((source) => ({ ...source, ...(configured.find((item) => item?.id === source.id) || {}) })) + .filter((provider) => provider.enabled !== false) + .sort((left, right) => Number(left.priority ?? 100) - Number(right.priority ?? 100)); + } + + /** + * 解析影片长缩略图。 + * 指定 providerId 时只使用该来源,无结果返回 null,不 fallback 到其他 movie.images; + * 未指定时先按 Provider 优先级,再回退到默认长缩略图来源(当前仅 javstore)。 + * 传入 settings 时所有来源(ProviderRegistry / Integration fallback / manual provider) + * 都必须位于 getEnabledProviders(settings) 白名单内;全部禁用时直接返回 null。 + * @param {Record} movieRef + * @param {{ providerId?: string, scope?: unknown, settings?: Record, allowWhenDisabled?: boolean }} [context] + */ + async resolve(movieRef, context = {}) { + // 总开关门禁:任何调用路径在设置关闭时都不得发请求。 + if (context.settings && !context.allowWhenDisabled && !this.isEnabled(context.settings)) return null; + const enabledProviders = context.settings ? this.getEnabledProviders(context.settings) : null; + const enabledIds = enabledProviders ? new Set(enabledProviders.map((provider) => provider.id)) : null; + if (enabledProviders && !enabledProviders.length) return null; + if (context.providerId) { + if (enabledIds && !enabledIds.has(context.providerId)) return null; + const provider = this.providers.get?.(context.providerId); + if (provider) { + if (provider.enabled === false || !provider.capabilities?.includes("screenshot")) return null; + return this.resolveFromProvider(provider, movieRef, context); + } + return this.resolveIntegration(context.providerId, movieRef, context); + } + for (const provider of await this.providers.getAvailable("screenshot", context)) { + if (enabledIds && !enabledIds.has(provider.id)) continue; + const result = await this.resolveFromProvider(provider, movieRef, context); + if (result) return result; + } + if (enabledIds && !enabledIds.has(DEFAULT_SCREENSHOT_PROVIDER)) return null; + return this.resolveIntegration(DEFAULT_SCREENSHOT_PROVIDER, movieRef, context); + } + /** @param {Record} provider @param {Record} movieRef @param {Record} context */ + async resolveFromProvider(provider, movieRef, context) { + try { + const result = await provider.resolve(movieRef, context); + this.providers.updateHealth(provider.id, { ok: true }); + if (result) return result; + } catch (error) { this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } + return null; + } + /** @param {string} providerId @param {Record} movieRef @param {Record} context */ + async resolveIntegration(providerId, movieRef, context) { + if (!SCREENSHOT_PROVIDER_IDS.has(providerId)) return null; + if (context.settings && !context.allowWhenDisabled && !this.isEnabled(context.settings)) return null; + const manifest = this.integrations?.list("movie.images")?.find((item) => item.id === providerId); + if (!manifest) return null; + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.getImages !== "function") return null; + const result = await adapter.getImages(movieRef, context); + return Array.isArray(result) && result.length ? result : null; + } + /** @param {Record} movieRef @param {Record | null} [settings] */ + getSearchUrl(movieRef, settings = null) { + if (!SCREENSHOT_PROVIDER_IDS.has(DEFAULT_SCREENSHOT_PROVIDER)) return null; + if (settings && !this.isEnabled(settings)) return null; + if (settings && !this.getEnabledProviders(settings).some((provider) => provider.id === DEFAULT_SCREENSHOT_PROVIDER)) return null; + for (const manifest of this.integrations?.list("movie.images") ?? []) { + if (manifest.id !== DEFAULT_SCREENSHOT_PROVIDER) continue; + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.getSearchUrl === "function") return adapter.getSearchUrl(movieRef); + } + return null; + } +} diff --git a/src/services/screenshot-sources.js b/src/services/screenshot-sources.js new file mode 100644 index 00000000..93e8846f --- /dev/null +++ b/src/services/screenshot-sources.js @@ -0,0 +1,8 @@ +// @ts-check + +/** 内置截图来源目录:唯一来源定义,ScreenshotService 与资源设置 UI 共用。 */ +export const BUILT_IN_SCREENSHOT_SOURCES = Object.freeze([ + Object.freeze({ id: "javstore", name: "JavStore", domain: "javstore.net", priority: 10, enabled: true }), + Object.freeze({ id: "projectjav", name: "ProjectJav", domain: "projectjav.com", priority: 20, enabled: false, implemented: false }), + Object.freeze({ id: "18av", name: "18AV", domain: "18av.mm-cg.com", priority: 30, enabled: false, implemented: false }), +]); diff --git a/src/services/settings-service.js b/src/services/settings-service.js new file mode 100644 index 00000000..c15b5156 --- /dev/null +++ b/src/services/settings-service.js @@ -0,0 +1,134 @@ +// @ts-check + +export class SettingsService extends EventTarget { + /** @param {{get: (key: string) => Promise, set: (key: string, value: unknown) => Promise}} storage @param {{validators?: Record boolean>, normalizers?: Record unknown>, afterPersist?: (snapshot: Readonly>, changedNames: readonly string[]) => Promise | void}} [options] */ + constructor(storage, options = {}) { + super(); + this.storage = storage; + this.validators = options.validators ?? {}; + this.normalizers = options.normalizers ?? {}; + this.afterPersist = options.afterPersist ?? null; + /** @type {Readonly>} */ this.snapshotValue = Object.freeze({}); + this.writeChain = Promise.resolve(); + } + + /** @param {string} key */ + async load(key = "setting") { + return this._enqueue(() => this._withSettingLock(() => this._readSnapshot(key))); + } + + /** Refreshes the in-memory snapshot after a transitional legacy or remote write; emits settings.changed only when values actually changed. */ + async refresh(key = "setting") { + return this._enqueue(() => this._withSettingLock(async () => { + const previous = this.snapshotValue; + const next = await this._readSnapshot(key); + const keys = new Set([ ...Object.keys(previous), ...Object.keys(next) ]); + const changedNames = [ ...keys ].filter((name) => previous[name] !== next[name]); + if (changedNames.length) { + this.dispatchEvent(new CustomEvent("settings.changed", { detail: Object.freeze({ name: changedNames.length === 1 ? changedNames[0] : null, value: undefined, names: Object.freeze([ ...changedNames ]), snapshot: next }) })); + } + return next; + })); + } + + snapshot() { return this.snapshotValue; } + + /** Returns the current write queue; resolves when all queued writes have settled. */ + waitForIdle() { return this.writeChain; } + + /** @param {string} name @param {unknown} value @param {string} [storageKey] */ + async set(name, value, storageKey = "setting") { + return this.update((draft) => { draft[name] = value; }, storageKey); + } + + /** @param {Record} values @param {string} [storageKey] */ + async patch(values, storageKey = "setting") { + if (!values || typeof values !== "object" || Array.isArray(values)) throw new TypeError("Settings patch must be an object"); + return this.update((draft) => { Object.assign(draft, values); }, storageKey); + } + + /** @param {Record} values @param {string} [storageKey] */ + async replace(values, storageKey = "setting") { + if (!values || typeof values !== "object" || Array.isArray(values)) throw new TypeError("Settings replacement must be an object"); + return this.update((draft) => { + for (const key of Object.keys(draft)) delete draft[key]; + Object.assign(draft, values); + }, storageKey); + } + + /** @param {string|readonly string[]} names @param {string} [storageKey] */ + async unset(names, storageKey = "setting") { + const list = Array.isArray(names) ? names : [ names ]; + if (!list.every((name) => typeof name === "string" && name)) throw new TypeError("unset names must be non-empty strings"); + return this.update((draft) => { for (const name of list) delete draft[name]; }, storageKey); + } + + /** + * Single write primitive for all settings mutations. Every writer enters the + * shared write chain and re-reads the freshest persisted value inside the + * jhs_setting_lock before applying a synchronous mutator. + * + * @param {(draft: Record) => unknown} mutator + * @param {string} [storageKey] + */ + async update(mutator, storageKey = "setting") { + if (typeof mutator !== "function") throw new TypeError("Settings update mutator must be a function"); + return this._enqueue(() => this._withSettingLock(async () => { + const stored = await this.storage.get(storageKey); + const base = /** @type {Record} */ (stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {}); + const draft = { ...base }; + const maybePromise = mutator(draft); + if (maybePromise && typeof maybePromise === "object" && typeof /** @type {PromiseLike} */ (maybePromise).then === "function") throw new TypeError("Settings update mutator must be synchronous"); + for (const [name, normalizer] of Object.entries(this.normalizers)) { + if (Object.prototype.hasOwnProperty.call(draft, name) && typeof normalizer === "function") draft[name] = normalizer(draft[name]); + } + const next = Object.freeze(draft); + const changedNames = [ ...new Set([ ...Object.keys(base), ...Object.keys(next) ]) ].filter((name) => base[name] !== next[name]); + for (const name of changedNames) { + const validator = this.validators[name]; + if (validator && Object.prototype.hasOwnProperty.call(next, name) && !validator(next[name])) throw new TypeError(`Invalid setting: ${name}`); + } + const changed = Object.freeze([ ...changedNames ]); + if (!changed.length) { + this.snapshotValue = next; + return next; + } + await this.storage.set(storageKey, next); + this.snapshotValue = next; + if (changed.length) { + const name = changed.length === 1 ? changed[0] : null; + this.dispatchEvent(new CustomEvent("settings.changed", { detail: Object.freeze({ name, value: name ? next[name] : undefined, names: changed, snapshot: next }) })); + } + try { + await this.afterPersist?.(next, changed); + } catch (error) { + // Storage and local snapshot are already committed. Post-commit + // side effects (legacy cache invalidation / BroadcastChannel) must + // never turn a successful persistence into a reported failure. + if (typeof /** @type {any} */ (globalThis).clog?.error === "function") /** @type {any} */ (globalThis).clog.error("[settings] afterPersist failed (ignored)", error); + } + return next; + })); + } + + /** @param {string} storageKey */ + async _readSnapshot(storageKey) { + const stored = await this.storage.get(storageKey); + this.snapshotValue = Object.freeze(stored && typeof stored === "object" && !Array.isArray(stored) ? { ...stored } : {}); + return this.snapshotValue; + } + + /** Keeps reads and writes ordered while allowing the queue to recover after failures. @template T @param {() => Promise} fn */ + _enqueue(fn) { + const operation = this.writeChain.then(fn); + this.writeChain = operation.then(() => undefined, () => undefined); + return operation; + } + + /** Runs a settings operation inside the shared lock so it serializes with legacy writers and other tabs. @template T @param {() => Promise} fn */ + async _withSettingLock(fn) { + const locks = globalThis.navigator?.locks; + if (!locks?.request) return fn(); + return locks.request("jhs_setting_lock", () => fn()); + } +} diff --git a/src/services/storage-service.js b/src/services/storage-service.js new file mode 100644 index 00000000..4166ca01 --- /dev/null +++ b/src/services/storage-service.js @@ -0,0 +1,27 @@ +// @ts-check + +import { assertPort, PORT_METHODS } from "../contracts/ports.js"; + +export class StorageService { + /** @param {any} storagePort */ + constructor(storagePort) { this.port = assertPort(storagePort, "StoragePort", PORT_METHODS.storage); } + /** @param {string} key */ + get(key) { return this.port.get(key); } + /** @param {string} key @param {unknown} value */ + set(key, value) { return this.port.set(key, value); } + /** @param {string} key */ + remove(key) { return this.port.remove(key); } + keys() { return this.port.keys(); } + /** @param {string} key */ + getLocal(key) { return this.port.getLocal(key); } + /** @param {string} key @param {string} value */ + setLocal(key, value) { return this.port.setLocal(key, value); } + /** @param {string} key */ + removeLocal(key) { return this.port.removeLocal(key); } + /** @param {string} key @param {unknown} [fallback] */ + getValue(key, fallback) { return this.port.getValue(key, fallback); } + /** @param {string} key @param {unknown} value */ + setValue(key, value) { return this.port.setValue(key, value); } + /** @param {string} key */ + removeValue(key) { return this.port.removeValue(key); } +} diff --git a/src/services/style-registry.js b/src/services/style-registry.js new file mode 100644 index 00000000..bb2848da --- /dev/null +++ b/src/services/style-registry.js @@ -0,0 +1,12 @@ +// @ts-check + +import { assertPort, PORT_METHODS } from "../contracts/ports.js"; + +export class StyleRegistry { + /** @param {any} stylePort */ + constructor(stylePort) { this.port = assertPort(stylePort, "StylePort", PORT_METHODS.style); } + /** @param {string} id @param {string} css */ + register(id, css) { return this.port.register(id, css); } + /** @param {string} id */ + remove(id) { return this.port.remove(id); } +} diff --git a/src/services/subtitle-service.js b/src/services/subtitle-service.js new file mode 100644 index 00000000..97c598d8 --- /dev/null +++ b/src/services/subtitle-service.js @@ -0,0 +1,23 @@ +// @ts-check + +export class SubtitleService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { this.integrations = integrations; } + + /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async search(providerId, movieRef, options = {}) { + const manifest = this.integrations.list("subtitle.search").find((item) => item.id === providerId); + if (!manifest) return []; + const adapter = this.integrations.getAdapter(manifest.id); + return typeof adapter?.search === "function" ? adapter.search(movieRef, options) : []; + } + + /** @param {string} providerId @param {Record} subtitle @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async download(providerId, subtitle, options = {}) { + const manifest = this.integrations.list("subtitle.download").find((item) => item.id === providerId); + if (!manifest) throw new TypeError(`Subtitle provider is unavailable: ${providerId}`); + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter?.download !== "function") throw new TypeError(`Subtitle download is unavailable: ${providerId}`); + return adapter.download(subtitle, options); + } +} diff --git a/src/services/translation-service.js b/src/services/translation-service.js new file mode 100644 index 00000000..b0b7345c --- /dev/null +++ b/src/services/translation-service.js @@ -0,0 +1,91 @@ +// @ts-check + +const CACHE_TTL = 2_592_000_000, CACHE_LIMIT = 1_000, CACHE_KEY = "translation_cache_v2", V1_PREFIX = "translation:v1:"; + +export class TranslationService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations @param {import("./storage-service.js").StorageService | null} [storage] */ + constructor(integrations, storage = null) { + this.integrations = integrations, this.storage = storage, this.memoryCache = new Map, this.inflight = new Map, + this.legacyCache = null, this.hydrationPromise = null, this.cacheGeneration = 0, this.persistTimer = null; + } + /** @param {string} text @param {string} source @param {string} target */ + cacheKey(text, source, target) { return `${V1_PREFIX}${source}:${target}:${text}`; } + readLegacyCache() { + if (null !== this.legacyCache) return this.legacyCache; + try { const value = JSON.parse(this.storage?.getLocal?.("jhs_translate") || "{}"); this.legacyCache = value && "object" === typeof value && !Array.isArray(value) ? value : {}; } + catch { this.legacyCache = {}; } + return this.legacyCache; + } + hydrate() { + if (this.hydrationPromise) return this.hydrationPromise; + const generation = this.cacheGeneration; + return this.hydrationPromise = (async () => { + if (!this.storage) return; + const storage = this.storage, migrated = /** @type {string[]} */ ([]); + let changed = !1; + try { + const cache = /** @type {{entries?: Record} | null} */ (await storage.get(CACHE_KEY)); + for (const [key, entry] of Object.entries(cache?.entries || {})) { + if (generation === this.cacheGeneration && "string" === typeof entry?.value && Number(entry.expiresAt) > Date.now()) this.memoryCache.set(key, { value: entry.value, expiresAt: Number(entry.expiresAt), lastUsed: Number(entry.lastUsed) || Date.now() }); + else changed = !0; + } + const keys = /** @type {string[]} */ (await storage.keys()); + for (const key of keys.filter((value => value.startsWith(V1_PREFIX)))) { + const entry = /** @type {any} */ (await storage.get(key)); + generation === this.cacheGeneration && "string" === typeof entry?.value && Number(entry.expiresAt) > Date.now() && this.memoryCache.set(key, { value: entry.value, expiresAt: Number(entry.expiresAt), lastUsed: Number(entry.lastUsed) || Date.now() }); + migrated.push(key), changed = !0; + } + if (generation === this.cacheGeneration && (this.prune() || changed)) void this.persistNow(generation).then((() => Promise.all(migrated.map((key => storage.remove(key)))))).catch((() => {})); + } catch {} + })(); + } + prune() { + const now = Date.now(); let changed = !1; + for (const [key, entry] of this.memoryCache) entry.expiresAt <= now && (this.memoryCache.delete(key), changed = !0); + const overflow = this.memoryCache.size - CACHE_LIMIT; + if (overflow > 0) for (const [key] of [...this.memoryCache].sort(((a, b) => a[1].lastUsed - b[1].lastUsed)).slice(0, overflow)) this.memoryCache.delete(key), changed = !0; + return changed; + } + schedulePersist() { + if (!this.storage || null !== this.persistTimer) return; + const generation = this.cacheGeneration; + this.persistTimer = setTimeout((() => { this.persistTimer = null, void this.persistNow(generation).catch((() => {})); }), 300); + } + /** @param {number} [generation] */ + async persistNow(generation = this.cacheGeneration) { + if (!this.storage || generation !== this.cacheGeneration) return; + this.prune(), await this.storage.set(CACHE_KEY, { version: 2, entries: Object.fromEntries(this.memoryCache) }); + } + /** @param {string} key @param {string} value @param {number} [generation] */ + remember(key, value, generation = this.cacheGeneration) { + if (generation === this.cacheGeneration) { const now = Date.now(); this.memoryCache.set(key, { value, expiresAt: now + CACHE_TTL, lastUsed: now }), this.prune(), this.schedulePersist(); } + return value; + } + async clearCache() { + this.cacheGeneration += 1, null !== this.persistTimer && clearTimeout(this.persistTimer), this.persistTimer = null, + this.memoryCache.clear(), this.legacyCache = {}, this.storage?.removeLocal?.("jhs_translate"); + if (this.storage) { const storage = this.storage; try { const keys = /** @type {string[]} */ (await storage.keys()); await storage.remove(CACHE_KEY), await Promise.all(keys.filter((key => key.startsWith(V1_PREFIX))).map((key => storage.remove(key)))); } catch {} } + this.hydrationPromise = Promise.resolve(); + } + async inspectCache() { return await this.hydrate(), this.prune(), { version: 2, size: this.memoryCache.size, entries: Object.fromEntries(this.memoryCache) }; } + /** @param {string} text @param {{sourceLanguage?: string, targetLanguage?: string, cacheAlias?: string, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async translate(text, options = {}) { + const sourceLanguage = options.sourceLanguage ?? "ja", targetLanguage = options.targetLanguage ?? "zh-CN", key = this.cacheKey(text, sourceLanguage, targetLanguage), generation = this.cacheGeneration; + let cached = this.memoryCache.get(key); + if (cached?.expiresAt > Date.now()) return cached.lastUsed = Date.now(), this.schedulePersist(), cached.value; + cached && this.memoryCache.delete(key); + const legacy = options.cacheAlias && this.readLegacyCache()[options.cacheAlias]; + if ("ja" === sourceLanguage && "zh-CN" === targetLanguage && "string" === typeof legacy && legacy) return this.remember(key, legacy, generation); + await this.hydrate(), cached = this.memoryCache.get(key); + if (cached?.expiresAt > Date.now()) return cached.lastUsed = Date.now(), this.schedulePersist(), cached.value; + const pending = this.inflight.get(key); + if (pending) return pending; + const request = (async () => { + const manifest = this.integrations.list("text.translate")[0], adapter = manifest && this.integrations.getAdapter(manifest.id); + if (typeof adapter?.translate !== "function") throw new TypeError(manifest ? `Translation operation is unavailable: ${manifest.id}` : "Translation provider is unavailable"); + const translated = await adapter.translate(text, { ...options, sourceLanguage, targetLanguage }); + return "string" === typeof translated ? this.remember(key, translated, generation) : translated; + })().finally((() => this.inflight.delete(key))); + return this.inflight.set(key, request), request; + } +} diff --git a/src/services/webdav-service.js b/src/services/webdav-service.js new file mode 100644 index 00000000..694acb7d --- /dev/null +++ b/src/services/webdav-service.js @@ -0,0 +1,82 @@ +// @ts-check + +export class WebDavClient { + /** @param {import("./http-service.js").HttpService} http @param {string} davUrl @param {string} username @param {string} password */ + constructor(http, davUrl, username, password) { + this.http = http; + this.baseUrl = new URL(davUrl.endsWith("/") ? davUrl : `${davUrl}/`); + this.username = username; + this.password = password; + this.folderName = null; + } + authHeaders() { + // TextEncoder 编码后再 btoa,兼容非 Latin1(中文)用户名/密码 + const bytes = new TextEncoder().encode(`${this.username}:${this.password}`); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return { Authorization: `Basic ${btoa(binary)}`, Depth: "1" }; + } + /** @param {string} method @param {string} path @param {Record} [headers] @param {unknown} [body] @param {Record} [requestOptions] */ + async request(method, path, headers = {}, body, requestOptions = {}) { + const url = new URL(path.replace(/^\/+/, ""), this.baseUrl); + const response = await this.http.request({ + providerId: "webdav", method, url: url.href, headers: { ...this.authHeaders(), ...headers }, body, + responseType: "text", cacheScope: "none", ...requestOptions, + urlPolicy: { trustClass: "user-local", expectedOrigin: this.baseUrl.origin }, + }); + return response.data ?? response.responseText ?? ""; + } + /** @param {string} folder */ + async ensureFolder(folder) { + // Existing WebDAV collections report 405|409 and are valid outcomes. + await this.request("MKCOL", folder, {}, undefined, { acceptableStatuses: [405, 409] }); + } + /** @param {string} folder @param {string} name @param {string} content */ + async backup(folder, name, content) { await this.ensureFolder(folder); await this.request("PUT", `${folder}/${name}`, { "Content-Type": "text/plain" }, content); } + /** @param {string} folder */ + async getFileList(folder) { + const xml = String(await this.request("PROPFIND", folder, { "Content-Type": "application/xml" }, '')); + const responses = new DOMParser().parseFromString(xml, "text/xml").getElementsByTagNameNS("DAV:", "response"), files = []; + for (let index = 1; index < responses.length; index++) { + const item = responses[index], node = (/** @type {string} */ name) => item.getElementsByTagNameNS("DAV:", name)[0]?.textContent || ""; + const href = node("href"), name = node("displayname") || decodeURIComponent(href.replace(/\/$/, "").split("/").pop() || ""), size = node("getcontentlength"); + if (size !== "0") files.push({ fileId: name, name, size: Number(size), createTime: node("creationdate") || node("getlastmodified") }); + } + return files.reverse(); + } + /** @param {string} name */ + async deleteFile(name) { await this.request("DELETE", `${this.folderName}/${encodeURI(name)}`, { "Cache-Control": "no-cache" }); } + /** @param {string} folder */ + async getBackupList(folder) { this.folderName = folder; await this.ensureFolder(folder); return this.getFileList(folder); } + /** @param {string} name */ + async getFileContent(name) { return String(await this.request("GET", `${this.folderName}/${name}`, { Accept: "application/octet-stream" })); } +} + +export class WebDavService { + /** @param {import("./http-service.js").HttpService} http @param {import("./credential-service.js").CredentialService} [credential] @param {import("./settings-service.js").SettingsService} [settings] */ + constructor(http, credential = undefined, settings = undefined) { this.http = http; this.credential = credential; this.settings = settings; } + /** @param {{url: string, username: string, password: string}} config */ + createClient(config) { return new WebDavClient(this.http, config.url, config.username, config.password); } + /** Return the installation-local profile without exposing the encrypted password. */ + async getProfile() { + const settings = this.settings?.snapshot?.() || {}; + const password = this.credential ? await this.credential.get("jhs_webdav_password") : ""; + return { url: String(settings.webDavUrl || ""), username: String(settings.webDavUsername || ""), password }; + } + /** Atomically coordinate the GM credential write with the settings update. */ + async saveProfile(/** @type {Record} */ patch = {}) { + if (!this.settings || !this.credential) throw new Error("WebDAV 配置服务未初始化"); + const previous = /** @type {{password: string}} */ (await this.getProfile()); + const passwordChanged = Object.prototype.hasOwnProperty.call(patch, "password"); + if (passwordChanged) await /** @type {any} */ (this.credential).set("jhs_webdav_password", String(patch.password || "")); + try { + await this.settings.update((draft) => { + for (const key of ["url", "username"]) if (Object.prototype.hasOwnProperty.call(patch, key)) draft[key === "url" ? "webDavUrl" : "webDavUsername"] = String(patch[key] || ""); + }); + } catch (error) { + if (passwordChanged) await /** @type {any} */ (this.credential).set("jhs_webdav_password", previous.password); + throw error; + } + return this.getProfile(); + } +} diff --git a/src/ui/detail/detail-resource-adapter.js b/src/ui/detail/detail-resource-adapter.js new file mode 100644 index 00000000..99986b44 --- /dev/null +++ b/src/ui/detail/detail-resource-adapter.js @@ -0,0 +1,20 @@ +// @ts-check + +/** @typedef {any} JQueryHandle Legacy jQuery runtime handle. */ + +/** 返回当前详情页的宿主资源边界;调用者不得重挂载这些节点。 */ +/** @param {any} hostAdapter */ +export function getDetailResourceAdapter(hostAdapter) { + if (!window.isDetailPage || typeof hostAdapter?.getDetailResourceBoundary !== "function") return null; + const boundary = hostAdapter.getDetailResourceBoundary(); + if (!boundary) return null; + return { + ...boundary, hostRoot: $(boundary.hostRoot), controller: $(boundary.controller), observeRoot: $(boundary.observeRoot), resourceRoot: $(boundary.resourceRoot), resourceRegion: $(boundary.resourceRegion), + sortSelect: $(boundary.sortSelect), getActionTarget(/** @type {Element} */ row) { + const target = $(boundary.getActionTarget(row)); + if (!target.length || !boundary.actionTargetRequiresWrapper?.(row)) return target; + let actions = target.children(".jhs-offline-actions").first(); + return actions.length || (actions = $('').appendTo(target)), actions; + }, + }; +} diff --git a/src/ui/detail/fc2-detail-workspace.js b/src/ui/detail/fc2-detail-workspace.js new file mode 100644 index 00000000..84f676ae --- /dev/null +++ b/src/ui/detail/fc2-detail-workspace.js @@ -0,0 +1,60 @@ +// @ts-check + +import { normalizeCarNum } from "../../core/constants.js"; + +/** @typedef {any} JQueryHandle Legacy jQuery runtime handle. */ + +/** Keep the workspace context self-contained for legacy VM-loaded fixtures. */ +/** @param {unknown} value @param {string} [surface] */ +function normalizeWorkspaceMovieContext(value, surface = "other") { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const raw = /** @type {Record} */ (value), carNum = normalizeCarNum(raw.carNum); + if (!carNum) return null; + return Object.freeze({ ...raw, carNum, movieId: typeof raw.movieId === "string" && raw.movieId.trim() ? raw.movieId.trim() : raw.movieId ?? null, surface: raw.surface || surface }); +} + +/** 创建 FC2 自有详情壳,所有异步模块只写入固定插槽。 */ +/** @param {{ carNum?: string, movieId?: string|null, url?: string, source?: string, mode?: string }} [options] */ +export function createFc2DetailShell({ carNum = "", source = "fc2", mode = "dialog" } = {}) { + const workspace = $('
    ').attr({ + "data-jhs-fc2-source": source, + "data-jhs-fc2-mode": mode, + "data-jhs-car-num": normalizeCarNum(carNum) || "" + }); + const definitions = [ [ "summary", "影片概览" ], [ "gallery", "预览与剧照" ], [ "resources", "资源" ], [ "reviews", "评论" ], [ "related", "相关清单" ] ]; + definitions.forEach((([ name, title ]) => { + const section = $('
    ').attr("data-jhs-section", name); + const header = $('
    '), heading = $("

    ").text(title), actions = $('
    ').attr("data-jhs-section-actions", name); + section.append(header.append(heading, actions), $('
    ').attr("data-jhs-slot", name)), workspace.append(section); + })); + return workspace; +} + +/** 创建只属于单个 FC2 详情实例的生命周期和插槽上下文。 */ +/** @param {JQueryHandle | Element} root @param {Record} [options] */ +export function createFc2DetailContext(root, options = {}) { + const workspace = $(root).is(".jhs-fc2-workspace") ? $(root) : $(root).find(".jhs-fc2-workspace").first(); + let destroyed = !1; + const namespace = `.jhsFc2Detail${Date.now()}${Math.random().toString(36).slice(2)}`, observers = new Set(); + const surface = options.mode === "dialog" ? "fc2-dialog" : "native-detail"; + const movieContext = normalizeWorkspaceMovieContext({ ...options, carNum: options.carNum || workspace.attr("data-jhs-car-num"), detailUrl: options.url, surface }, surface); + const context = { + ...options, + movieContext, + root: workspace, + workspace, + namespace, + observers, + getSlot: (/** @type {string} */ name) => workspace.find(`[data-jhs-slot="${name}"]`).first(), + getSection: (/** @type {string} */ name) => workspace.find(`[data-jhs-section="${name}"]`).first(), + isAlive: () => !destroyed && workspace[0]?.isConnected !== !1, + addObserver(/** @type {{ disconnect?: () => void }} */ observer) { observer && observers.add(observer); return observer; }, + destroy() { + if (destroyed) return; + destroyed = !0, workspace.off(namespace).find("*").off(namespace), observers.forEach((observer => observer.disconnect?.())), observers.clear(), workspace.removeData("jhsFc2Context"); + } + }; + workspace.data("jhsFc2Context", context).attr("data-jhs-surface", surface); + movieContext && workspace.data("jhsMovieContext", movieContext); + return context; +} diff --git a/src/ui/detail/fc2-workspace-view.js b/src/ui/detail/fc2-workspace-view.js new file mode 100644 index 00000000..cae35ef9 --- /dev/null +++ b/src/ui/detail/fc2-workspace-view.js @@ -0,0 +1,46 @@ +// @ts-check + +/** @param {unknown} value */ +function normalizeUrl(value) { + try { + const url = new URL(String(value || "")); + return ["http:", "https:"].includes(url.protocol) ? url.href : null; + } catch { return null; } +} + +import { createPanelState } from "./primitives.js"; + +/** @param {any} target @param {string} message @param {null|(() => void)} [retry] */ +export function renderFc2State(target, message, retry = null) { + const jq = /** @type {any} */ (globalThis).$, host = jq(target).empty(); + const state = createPanelState(message, { retry, tone: retry ? "error" : "neutral", extraClass: "jhs-fc2-state" }); + host.append(state); +} + +/** @param {any} context @param {unknown[]} images @param {unknown} [coverUrl] */ +export function renderFc2Gallery(context, images, coverUrl = null) { + const jq = /** @type {any} */ (globalThis).$; + const urls = [...new Set((images || []).map(normalizeUrl).filter(Boolean))]; + const grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); + context.galleryUrls = new Set(urls); + const normalizedCover = normalizeUrl(coverUrl); + if (normalizedCover) preview.append(jq("").attr({ src: normalizedCover, alt: `${context.carNum} 概览`, loading: "eager" })); + if (!urls.length) return renderFc2State(grid, "暂无剧照"); + urls.forEach((url, index) => grid.append(jq('').attr("aria-label", `查看剧照 ${index + 1}`).append(jq("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" })))); + // 去重保险:若额外长缩略图复用了 Gallery 图片,隐藏 screenshot 区域。 + const screenshot = context.root.find('[data-jhs-role="screenshot"]'); + screenshot.find("img").each((/** @type {number} */ _, /** @type {HTMLImageElement} */ img) => { + const src = img.getAttribute("src"); + if (src && context.galleryUrls.has(src)) screenshot.empty(); + }); +} + +/** @param {any} context @param {any} movieService */ +export function createFc2SourceLinks(context, movieService) { + const jq = /** @type {any} */ (globalThis).$; + const links = jq(''); + const providerLinks = movieService.sourceUrls({ carNum: context.carNum }, ["fc2ppvdb", "fc2content"]); + const values = [["123av" === context.source ? "123AV 原页面" : "JavDB 原页面", normalizeUrl(context.url)], ...providerLinks.map((/** @type {any} */ item) => [item.providerId === "fc2ppvdb" ? "FC2PPVDB" : "FC2 市场", item.url])]; + values.forEach(([label, href]) => href && links.append(jq("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({ href, target: "_blank", rel: "noopener noreferrer" }).text(label))); + return links; +} diff --git a/src/ui/detail/hosted-detail-surface.js b/src/ui/detail/hosted-detail-surface.js new file mode 100644 index 00000000..749cb41f --- /dev/null +++ b/src/ui/detail/hosted-detail-surface.js @@ -0,0 +1,63 @@ +// @ts-check + +export class HostedDetailSurface { + /** @param {{locateDetailRoot: () => Element | null, locateDetailSlots: () => Record}} hostAdapter */ + constructor(hostAdapter) { + this.hostAdapter = hostAdapter; + this.root = null; + /** @type {Readonly>} */ + this.slots = Object.freeze({}); + this.mounted = new Map(); + this.created = new Set(); + } + mount() { + this.root = this.hostAdapter.locateDetailRoot(); + if (this.root) this.ensureOwnedSlots(); + this.slots = Object.freeze({ ...this.hostAdapter.locateDetailSlots() }); + return this; + } + ensureOwnedSlots() { + if (!this.root) return; + const documentRuntime = this.root.ownerDocument; + let summary = this.root.querySelector(':scope > [data-jhs-slot="summary-actions"]'); + if (!summary) { + summary = documentRuntime.createElement("div"); + summary.className = "jhs-detail-owned-slot jhs-detail-owned-slot--summary-actions"; + summary.setAttribute("data-jhs-slot", "summary-actions"); + this.root.append(summary); + this.created.add(summary); + } + let group = this.root.querySelector(':scope > [data-jhs-slot-group="post-resource"]'); + if (!group) { + group = documentRuntime.createElement("div"); + group.className = "jhs-detail-post-resource"; + group.setAttribute("data-jhs-slot-group", "post-resource"); + this.root.append(group); + this.created.add(group); + } + for (const name of ["reviews", "related"]) { + if (group.querySelector(`[data-jhs-slot="${name}"]`)) continue; + const slot = documentRuntime.createElement("section"); + slot.className = `jhs-detail-owned-slot jhs-detail-owned-slot--${name}`; + slot.setAttribute("data-jhs-slot", name); + group.append(slot); + this.created.add(slot); + } + } + /** @param {string} slot @param {Element} element */ + mountPanel(slot, element) { + const target = this.slots[slot]; + if (!target) return false; + target.append(element); + this.mounted.set(slot, element); + return true; + } + dispose() { + for (const element of this.mounted.values()) element.remove(); + for (const element of this.created) element.remove(); + this.mounted.clear(); + this.created.clear(); + this.root = null; + this.slots = Object.freeze({}); + } +} diff --git a/src/ui/detail/owned-detail-surface.js b/src/ui/detail/owned-detail-surface.js new file mode 100644 index 00000000..067f1128 --- /dev/null +++ b/src/ui/detail/owned-detail-surface.js @@ -0,0 +1,23 @@ +// @ts-check + +export class OwnedDetailSurface { + /** @param {Document} [documentRuntime] */ + constructor(documentRuntime = document) { this.document = documentRuntime; this.root = null; this.slots = new Map(); } + /** @param {Element} container */ + mount(container) { + const root = this.document.createElement("section"); + root.className = "jhs-ui jhs-owned-detail"; + for (const name of ["summary", "gallery", "resources", "reviews", "related"]) { + const slot = this.document.createElement("section"); + slot.dataset.jhsSection = name; + root.append(slot); + this.slots.set(name, slot); + } + container.replaceChildren(root); + this.root = root; + return this; + } + /** @param {string} name */ + slot(name) { return this.slots.get(name) ?? null; } + dispose() { this.root?.remove(); this.root = null; this.slots.clear(); } +} diff --git a/src/ui/detail/panel-styles.js b/src/ui/detail/panel-styles.js new file mode 100644 index 00000000..89d3886f --- /dev/null +++ b/src/ui/detail/panel-styles.js @@ -0,0 +1,45 @@ +// @ts-check + +/** Shared styles for detail panels that may be mounted by more than one feature. */ +export function buildDetailPanelCss() { + return ` + .jhs-review-panel { min-width:0; } + .jhs-panel-header { display:flex; min-height:var(--jhs-control-height); align-items:center; justify-content:space-between; gap:var(--jhs-space-3); margin-bottom:var(--jhs-space-3); } + .jhs-panel-header h3 { margin:0; color:var(--jhs-text); font-size:var(--jhs-font-size-lg); font-weight:600; line-height:1.5; } + .jhs-panel-toggle { flex:none; } + .jhs-review-list { display:grid; } + .jhs-review-item { min-width:0; padding:var(--jhs-space-4) 0; border-bottom:1px solid color-mix(in srgb,var(--jhs-border) 55%,transparent); } + .jhs-review-item:last-child { border-bottom:0; } + .jhs-review-meta { display:flex; flex-wrap:wrap; align-items:center; gap:var(--jhs-space-1) var(--jhs-space-3); color:var(--jhs-text-muted); font-size:14px; } + .jhs-review-author { color:var(--jhs-text); font-size:15px; font-weight:600; } + .jhs-review-floor { margin-left:auto; color:var(--jhs-text-faint); } + .jhs-review-content { margin:var(--jhs-space-3) 0 0; color:var(--jhs-text); font-size:16px; line-height:1.7; overflow-wrap:anywhere; white-space:pre-wrap; } + .jhs-review-link { display:inline-flex; align-items:center; gap:var(--jhs-space-1); margin:0 var(--jhs-space-1); padding:2px var(--jhs-space-2); border:0; border-radius:var(--jhs-radius-pill); background:var(--jhs-accent-tint); color:var(--jhs-accent); font:inherit; font-size:var(--jhs-font-size-sm); line-height:1.5; text-decoration:none; vertical-align:baseline; cursor:pointer; } + .jhs-review-link-copy { color:var(--jhs-text-muted); } + .jhs-review-link-wrap { display:flex; align-items:center; justify-content:space-between; gap:var(--jhs-space-2); width:100%; margin:var(--jhs-space-1) 0; } + .jhs-review-inline-controls { display:inline-flex; align-items:center; gap:var(--jhs-space-1); margin:0 var(--jhs-space-1); } + .jhs-review-link-main { display:inline-flex; align-items:center; flex-wrap:wrap; gap:var(--jhs-space-1); } + .jhs-review-link-actions { display:inline-flex; align-items:center; gap:var(--jhs-space-1); margin-left:auto; flex-shrink:0; } + .jhs-review-offline-btn { background:var(--jhs-accent) !important; color:var(--jhs-accent-text-on) !important; } + .jhs-panel-state { padding:var(--jhs-space-4) 0; color:var(--jhs-text-muted); text-align:center; } + .jhs-panel-footer { display:flex; justify-content:center; padding-top:var(--jhs-space-3); } + .jhs-panel-end { color:var(--jhs-text-faint); font-size:var(--jhs-font-size-sm); } + @media (max-width:767px) { .jhs-review-floor { width:100%; margin-left:0; } } + .jhs-related-panel { min-width:0; } + .jhs-related-list { display:grid; } + .jhs-related-item { display:grid; gap:var(--jhs-space-2); padding:var(--jhs-space-3) 0; border-bottom:1px solid color-mix(in srgb,var(--jhs-border) 55%,transparent); } + .jhs-related-item:last-child { border-bottom:0; } + .jhs-related-heading { display:flex; min-width:0; align-items:baseline; gap:var(--jhs-space-2); } + .jhs-related-index { flex:none; color:var(--jhs-text-faint); font-size:14px; } + .jhs-related-title { min-width:0; overflow:hidden; color:var(--jhs-accent); font-size:16px; font-weight:600; text-overflow:ellipsis; text-decoration:none; white-space:nowrap; } + .jhs-related-meta { display:flex; flex-wrap:wrap; gap:var(--jhs-space-2) var(--jhs-space-4); color:var(--jhs-text-muted); font-size:14px; } + .jhs-related-time { color:var(--jhs-text-faint); font-size:14px; white-space:nowrap; } + .jhs-detail-owned-slot :is(.jhs-review-item,.jhs-related-item) { padding:var(--jhs-space-3); border-bottom-color:var(--jhs-border); } + .jhs-detail-owned-slot .jhs-panel-header { padding-bottom:var(--jhs-space-3); margin-bottom:0; border-bottom:1px solid var(--jhs-border); } + .jhs-detail-owned-slot .jhs-panel-header:has(.jhs-panel-toggle[aria-expanded="false"]) { padding-bottom:0; border-bottom:0; } + .jhs-detail-owned-slot .jhs-panel-footer { padding:var(--jhs-space-3); } + .jhs-detail-owned-slot :is(.jhs-review-meta,.jhs-related-meta,.jhs-related-time) { font-size:var(--jhs-font-size-sm); } + .jhs-detail-owned-slot .jhs-related-title { white-space:normal; overflow-wrap:anywhere; } + .jhs-detail-owned-slot .jhs-related-time { white-space:normal; overflow-wrap:anywhere; } + `; +} diff --git a/src/ui/detail/primitives.js b/src/ui/detail/primitives.js new file mode 100644 index 00000000..e2362907 --- /dev/null +++ b/src/ui/detail/primitives.js @@ -0,0 +1,42 @@ +// @ts-check + +/** + * 详情面板统一组件基元:FC2 与 hosted 详情共用同一套 loading/empty/error/retry 与 section 结构。 + * 数据 adapter 可以不同,视觉与交互组件不允许各写一套。 + */ + +/** @param {string} message @param {{ retry?: (() => void) | null, tone?: "neutral" | "error", extraClass?: string }} [options] */ +export function createPanelState(message, { retry = null, tone = "neutral", extraClass = "" } = {}) { + const jq = /** @type {any} */ (globalThis).$; + const state = jq('
    ').text(message); + if (extraClass) state.addClass(extraClass); + if ("error" === tone) state.addClass("is-error"); + if (retry) state.append(" ", jq('').on("click", retry)); + return state; +} + +/** @param {string} [message] */ +export function createPanelLoading(message = "正在加载…") { + return createPanelState(message); +} + +/** @param {string} [message] */ +export function createPanelEmpty(message = "暂无内容") { + return createPanelState(message); +} + +/** @param {string} message @param {(() => void) | null} [retry] */ +export function createPanelError(message, retry = null) { + return createPanelState(message, { retry, tone: "error" }); +} + +/** 统一的详情分区:header(标题 + 动作区)+ content 插槽。 */ +/** @param {string} name @param {string} title @param {{ className?: string }} [options] */ +export function createDetailSection(name, title, { className = "jhs-detail-section" } = {}) { + const jq = /** @type {any} */ (globalThis).$; + const section = jq(`
    `); + const header = jq('
    '); + header.append(jq("

    ").text(title), jq(`
    `)); + section.append(header, jq(`
    `)); + return section; +} diff --git a/src/ui/detail/related-panel.js b/src/ui/detail/related-panel.js new file mode 100644 index 00000000..ce1d9968 --- /dev/null +++ b/src/ui/detail/related-panel.js @@ -0,0 +1,131 @@ +// @ts-check + +import { LifecycleScope } from "../../core/lifecycle-scope.js"; +import { createLatestSettingWriter } from "../settings/setting-binding-controller.js"; + +export class RelatedPanel { + /** @param {{related: any, settings: any, scope: () => Promise}} dependencies */ + constructor(dependencies) { this.related = dependencies.related; this.settings = dependencies.settings; this.scope = dependencies.scope; } + + /** @param {any} target @param {string} movieId @param {{ownedSection?: any, isActive?: () => boolean, ownCleanup?: (cleanup: () => void) => unknown}} [options] */ + async show(target, movieId, options = {}) { + const jq = /** @type {any} */ (globalThis).$, isActive = options.isActive ?? (() => true); + if (!movieId) throw new TypeError("未传入movieId"); + if (!isActive() || !target?.length) return jq(); + const existing = target.children('[data-jhs-panel="related"]').filter((/** @type {number} */ _index, /** @type {Element} */ element) => jq(element).attr("data-jhs-movie-id") === String(movieId)).first(); + if (existing.length) return existing; + const panel = jq('').attr("data-jhs-movie-id", String(movieId)); + const header = jq('

    相关清单

    '); + const toggle = jq(''); + const parentScope = await this.scope(), panelScope = new LifecycleScope(`related:${movieId}`); + if (!isActive() || parentScope?.disposed) return jq(); + const releaseParent = parentScope?.addCleanup?.(() => panelScope.dispose()); + options.ownCleanup?.(() => { panelScope.dispose(); releaseParent?.(); }); + const state = { movieId, panel, panelScope, floorIndex: 1, loaded: false, loading: false, page: 1, enabled: false, generation: 0, isActive: () => !panelScope.disposed && isActive(), requestScope: /** @type {LifecycleScope | null} */ (null) }; + header.append(toggle); + if (options.ownedSection) options.ownedSection.find('[data-jhs-section-actions="related"]').first().append(toggle); + else panel.append(header); + panel.append('', ''); + target.append(panel); + const enabled = (this.settings.snapshot().enableLoadRelated ?? "no") === "yes"; + const applyExpanded = (/** @type {unknown} */ value) => { + if (!state.isActive()) return; + const next = value === "yes"; + if (state.enabled !== next) { + state.enabled = next; state.generation++; + if (!next) { state.requestScope?.dispose(); state.loading = false; } + } + this.updateToggle(toggle, next); + panel.find(".jhs-related-container, .jhs-related-footer").toggle(next); + if (!state.loading) panel.find(".jhs-related-load-more").prop("disabled", false).text("加载更多清单"); + if (next && !state.loaded && !state.loading) return this.fetch(state); + }; + if (this.settings.addEventListener) panelScope.listen(this.settings, "settings.changed", (/** @type {any} */ event) => { + if (event.detail?.names?.includes("enableLoadRelated")) void applyExpanded(this.settings.snapshot().enableLoadRelated ?? "no"); + }); + const writeExpanded = createLatestSettingWriter({ settings: this.settings, key: "enableLoadRelated", fallback: "no", apply: value => { void applyExpanded(value); }, onError: error => { + /** @type {any} */ (globalThis).clog?.error("相关清单展开设置保存失败,已恢复", error); + /** @type {any} */ (globalThis).show?.error?.("相关清单展开设置保存失败,已恢复原设置"); + } }); + toggle.on("click", (/** @type {any} */ event) => { + event.preventDefault(); event.stopPropagation(); + void writeExpanded(state.enabled ? "no" : "yes"); + }); + panelScope.addCleanup(() => { state.requestScope?.dispose(); toggle.off("click"); panel.find("button").off("click"); }); + await applyExpanded(enabled ? "yes" : "no"); + return panel; + } + + /** @param {any} toggle @param {boolean} expanded */ + updateToggle(toggle, expanded) { + toggle.attr("aria-expanded", String(expanded)); + toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"); + toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); + } + + /** @param {any} state */ + async fetch(state) { + if (state.loading || !state.enabled || !state.isActive()) return; + state.loading = true; + const container = state.panel.find(".jhs-related-container"), footer = state.panel.find(".jhs-related-footer"); + container.empty().append(/** @type {any} */ (globalThis).$("
    ").addClass("jhs-panel-state").text("获取清单中...")); + footer.empty(); + const generation = state.generation, scope = new LifecycleScope(`related:${state.movieId}:page:1`), release = state.panelScope.addCleanup(() => scope.dispose()); + state.requestScope = scope; + try { + const related = await this.related.list({ movieId: state.movieId }, { page: 1, limit: 20, scope }); + if (!state.isActive() || scope.signal.aborted || generation !== state.generation || !state.enabled) return; + state.loading = false; state.loaded = true; container.empty(); + if (!related.length) return void container.append(/** @type {any} */ (globalThis).$("
    ").addClass("jhs-panel-state").text("无清单")); + this.display(state, related, container); + if (related.length === 20) this.bindLoadMore(state, container, footer); + else footer.append(/** @type {any} */ (globalThis).$("
    ").addClass("jhs-panel-end").text("已加载全部清单")); + } catch (error) { + if (generation === state.generation) state.loading = false; + if (!state.isActive() || scope.signal.aborted || generation !== state.generation || !state.enabled) return; + /** @type {any} */ (globalThis).clog?.error("获取清单失败:", error); + this.renderRetry(container, () => void this.fetch(state)); + } finally { release(); if (state.requestScope === scope) state.requestScope = null; } + } + + /** @param {any} container @param {() => void} retry */ + renderRetry(container, retry) { + const jq = /** @type {any} */ (globalThis).$; + container.empty().append(jq('
    ').append(document.createTextNode("获取清单失败 "), jq('').on("click", retry))); + } + + /** @param {any} state @param {any} container @param {any} footer */ + bindLoadMore(state, container, footer) { + const jq = /** @type {any} */ (globalThis).$, button = jq(''), end = jq('').hide(); + footer.empty().append(button, end); + button.on("click", async () => { + if (!state.enabled || !state.isActive() || state.loading) return; + const nextPage = state.page + 1, generation = state.generation, scope = new LifecycleScope(`related:${state.movieId}:page:${nextPage}`), release = state.panelScope.addCleanup(() => scope.dispose()); + state.loading = true; state.requestScope = scope; + button.text("加载中...").prop("disabled", true); + try { + const related = await this.related.list({ movieId: state.movieId }, { page: nextPage, limit: 20, scope }); + if (!state.isActive() || scope.signal.aborted || generation !== state.generation || !state.enabled) return; + state.page = nextPage; this.display(state, related, container); + if (related.length < 20) button.remove(), end.show(); else button.text("加载更多清单").prop("disabled", false); + } catch (error) { + if (!state.isActive() || scope.signal.aborted || generation !== state.generation || !state.enabled) return; + /** @type {any} */ (globalThis).clog?.error("加载更多清单失败:", error); + button.text("加载失败,请重试").prop("disabled", false); + } finally { release(); if (generation === state.generation) state.loading = false; if (state.requestScope === scope) state.requestScope = null; } + }); + } + + /** @param {any} state @param {any[]} related @param {any} container */ + display(state, related, container) { + const jq = /** @type {any} */ (globalThis).$; + related.forEach((item) => { + const title = jq("").addClass("jhs-related-title").attr({ href: `/lists/${encodeURIComponent(item.id)}`, target: "_blank", rel: "noopener noreferrer" }).text(item.name || "未命名清单"); + const heading = jq('').append(jq("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title); + const meta = jq(''); + const formatted = item.createdAt ? /** @type {any} */ (globalThis).utils.formatDate(item.createdAt) : "未知"; + meta.append(jq("").text(`视频:${Number(item.movieCount) || 0}`), jq("").text(`收藏:${Number(item.collectionCount) || 0}`), jq("").text(`查看:${Number(item.viewCount) || 0}`), jq('').text(`创建时间:${formatted}`)); + container.append(jq('').append(heading, meta)); + }); + } +} diff --git a/src/ui/detail/review-panel.js b/src/ui/detail/review-panel.js new file mode 100644 index 00000000..5e8a7382 --- /dev/null +++ b/src/ui/detail/review-panel.js @@ -0,0 +1,163 @@ +// @ts-check + +import { escapeHtml } from "../../core/constants.js"; +import { createLatestSettingWriter } from "../settings/setting-binding-controller.js"; +import { readReviewKeywords } from "../../core/review-keywords.js"; +import { LifecycleScope } from "../../core/lifecycle-scope.js"; + +export class ReviewPanel { + /** @param {{review: any, settings: any, storage: any, scope: () => Promise}} dependencies */ + constructor(dependencies) { this.review = dependencies.review; this.settings = dependencies.settings; this.storage = dependencies.storage; this.scope = dependencies.scope; } + + /** @param {string} movieId @param {any} target @param {{ownedSection?: any, isActive?: () => boolean, ownCleanup?: (cleanup: () => void) => unknown}} [options] */ + async show(movieId, target, options = {}) { + const jq = /** @type {any} */ (globalThis).$, isActive = options.isActive ?? (() => true); + if (!isActive() || !target?.length) return jq(); + const existing = target.children('[data-jhs-panel="reviews"]').filter((/** @type {number} */ _index, /** @type {Element} */ element) => jq(element).attr("data-jhs-movie-id") === String(movieId)).first(); + if (existing.length) return existing; + const panel = jq('
    ').attr("data-jhs-movie-id", String(movieId)); + const header = jq('

    评论

    '); + const toggle = jq(''); + const parentScope = await this.scope(), panelScope = new LifecycleScope(`reviews:${movieId}`); + if (!isActive() || parentScope?.disposed) return jq(); + const releaseParent = parentScope?.addCleanup?.(() => panelScope.dispose()); + options.ownCleanup?.(() => { panelScope.dispose(); releaseParent?.(); }); + const state = { movieId, panel, panelScope, floorIndex: 1, loaded: false, loading: false, page: 1, enabled: false, generation: 0, isActive: () => !panelScope.disposed && isActive(), requestScope: /** @type {LifecycleScope | null} */ (null) }; + header.append(toggle); + if (options.ownedSection) options.ownedSection.find('[data-jhs-section-actions="reviews"]').first().append(toggle); else panel.append(header); + panel.append('
    ', ''); + target.append(panel); this.bindFilter(panel); + const enabled = (this.settings.snapshot().enableLoadReview ?? "yes") === "yes"; + const applyExpanded = (/** @type {unknown} */ value) => { + if (!state.isActive()) return; + const next = value === "yes"; + if (state.enabled !== next) { + state.enabled = next; state.generation++; + if (!next) { state.requestScope?.dispose(); state.loading = false; } + } + this.updateToggle(toggle, next); + panel.find(".jhs-review-container, .jhs-review-footer").toggle(next); + panel.find(".jhs-review-load-more").prop("disabled", false).text("加载更多评论"); + if (next && !state.loaded && !state.loading) return this.fetch(state); + }; + if (this.settings.addEventListener) panelScope.listen(this.settings, "settings.changed", (/** @type {any} */ event) => { + if (event.detail?.names?.includes("enableLoadReview")) void applyExpanded(this.settings.snapshot().enableLoadReview ?? "yes"); + }); + const writeExpanded = createLatestSettingWriter({ settings: this.settings, key: "enableLoadReview", fallback: "yes", apply: (value) => { void applyExpanded(value); }, onError: (error) => { + /** @type {any} */ (globalThis).clog?.error("评论面板展开设置保存失败,已恢复", error); + /** @type {any} */ (globalThis).show?.error?.("评论面板展开设置保存失败,已恢复原设置"); + } }); + toggle.on("click", (/** @type {any} */ event) => { + event.preventDefault(); event.stopPropagation(); + const expanded = toggle.find(".toggle-text").text() === "展开"; + const desired = expanded ? "yes" : "no"; + void writeExpanded(desired); + }); + panelScope.addCleanup(() => { state.requestScope?.dispose(); toggle.off("click"); panel.off("contextmenu.jhsReviewFilter"); }); + await applyExpanded(enabled ? "yes" : "no"); + return panel; + } + + /** @param {any} toggle @param {boolean} expanded */ + updateToggle(toggle, expanded) { toggle.attr("aria-expanded", String(expanded)); toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"); toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); } + + /** @param {any} state */ + async fetch(state) { + if (state.loading || !state.enabled || !state.isActive()) return; + const jq = /** @type {any} */ (globalThis).$, container = state.panel.find(".jhs-review-container"), footer = state.panel.find(".jhs-review-footer"); + state.loading = true; container.empty().append(jq('
    ').text("获取评论中...")); footer.empty(); + const pageSize = Number(this.settings.snapshot().reviewCount) || 20, generation = state.generation; + const scope = new LifecycleScope(`reviews:${state.movieId}:page:1`), release = state.panelScope.addCleanup(() => scope.dispose()); + state.requestScope = scope; + try { + const reviews = await this.review.list({ movieId: state.movieId }, { page: 1, limit: pageSize, scope }); + if (!state.isActive() || scope.signal.aborted || generation !== state.generation || !state.enabled) return; + const keywords = await this.getKeywords(); + if (!state.isActive() || scope.signal.aborted || generation !== state.generation || !state.enabled) return; + state.loading = false; state.loaded = true; container.empty(); + if (!reviews.length) return void container.append(jq('
    ').text("无评论")); + await this.display(state, reviews, container, keywords); + if (reviews.length === pageSize) this.bindLoadMore(state, pageSize, keywords, container, footer); else footer.append(jq('
    ').text("已加载全部评论")); + } catch (error) { + if (generation === state.generation) state.loading = false; + if (!state.isActive() || scope?.signal?.aborted) return; + /** @type {any} */ (globalThis).clog?.error("获取评论失败:", error); + this.renderRetry(container, "获取评论失败", () => void this.fetch(state)); + } finally { release(); if (state.requestScope === scope) state.requestScope = null; } + } + + async getKeywords() { return readReviewKeywords(this.storage); } + /** @param {string} text */ + async saveKeyword(text) { await readReviewKeywords(this.storage, text); } + + /** @param {any} container @param {string} message @param {() => void} retry */ + renderRetry(container, message, retry) { + const jq = /** @type {any} */ (globalThis).$; + container.empty().append(jq('
    ').append(document.createTextNode(`${message} `), jq('').on("click", retry))); + } + + /** @param {any} state @param {number} pageSize @param {string[]} keywords @param {any} container @param {any} footer */ + bindLoadMore(state, pageSize, keywords, container, footer) { + const jq = /** @type {any} */ (globalThis).$, button = jq(''), end = jq('
    已加载全部评论
    ').hide(); + footer.empty().append(button, end); + button.on("click", async () => { + if (!state.enabled || !state.isActive() || state.loading) return; + const nextPage = state.page + 1, generation = state.generation, scope = new LifecycleScope(`reviews:${state.movieId}:page:${nextPage}`), release = state.panelScope.addCleanup(() => scope.dispose()); + state.requestScope = scope; state.loading = true; button.text("加载中...").prop("disabled", true); + try { + const reviews = await this.review.list({ movieId: state.movieId }, { page: nextPage, limit: pageSize, scope }); + if (!state.isActive() || scope.signal.aborted || generation !== state.generation || !state.enabled) return; + state.page = nextPage; await this.display(state, reviews, container, keywords); + if (reviews.length < pageSize) button.remove(), end.show(); else button.text("加载更多评论").prop("disabled", false); + } catch (error) { + if (!state.isActive() || scope?.signal?.aborted) return; + /** @type {any} */ (globalThis).clog?.error("加载更多评论失败:", error); button.text("加载失败,请重试").prop("disabled", false); + } finally { release(); if (generation === state.generation) state.loading = false; if (state.requestScope === scope) state.requestScope = null; } + }); + } + + /** @param {any} state @param {any[]} reviews @param {any} container @param {string[]} keywords */ + async display(state, reviews, container, keywords) { + const jq = /** @type {any} */ (globalThis).$; + const filter = keywords.length ? new RegExp(keywords.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")) : null; + for (const review of reviews) { + const content = String(review.content || ""); if (filter?.test(content)) continue; + const item = jq('
    '), meta = jq('
    '), body = jq('
    '); + meta.append(jq("").addClass("jhs-review-author").text(review.author || "匿名用户")); + const stars = jq(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); + for (let index = 0; index < score; index++) stars.append(''); + const formatted = /** @type {any} */ (globalThis).utils.formatDate(review.createdAt); + meta.append(stars, jq("").text(formatted), jq("").text(`点赞:${Number(review.likes) || 0}`), jq("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`)); + this.appendContent(body, content); item.append(meta, body); container.append(item); + } + } + + /** @param {any} container @param {string} content */ + appendContent(container, content) { + const pattern = /ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+/g; + let cursor = 0, match; + while ((match = pattern.exec(content))) { if (match.index > cursor) container.append(document.createTextNode(content.slice(cursor, match.index))); this.appendLink(container, match[0]); cursor = match.index + match[0].length; } + if (cursor < content.length) container.append(document.createTextNode(content.slice(cursor))); + } + + /** @param {any} container @param {string} value */ + appendLink(container, value) { + const jq = /** @type {any} */ (globalThis).$, isEd2k = value.startsWith("ed2k://"), isMagnet = value.startsWith("magnet:"), label = isEd2k ? "ED2K 链接" : isMagnet ? "Magnet 链接" : "打开链接", isResource = isEd2k || isMagnet; + const wrapper = jq(isResource ? '' : ''), main = jq(''); + const open = isEd2k ? jq('').text(label).on("click", () => /** @type {any} */ (globalThis).utils.copyToClipboard(label, value)) : jq("").addClass("jhs-review-link").attr({ href: value, target: "_blank", rel: "noopener noreferrer" }).text(label); + const copy = jq('').on("click", () => /** @type {any} */ (globalThis).utils.copyToClipboard(label, value)); + main.append(open, copy); wrapper.append(main); + if (isResource) wrapper.append(jq('').append(jq('').attr("data-resource", value))); + container.append(wrapper); + } + + /** @param {any} panel */ + bindFilter(panel) { + panel.off("contextmenu.jhsReviewFilter", ".review-content").on("contextmenu.jhsReviewFilter", ".review-content", async (/** @type {any} */ event) => { + if ((this.settings.snapshot().enableTitleSelectFilter ?? "yes") !== "yes") return; + const text = String(window.getSelection()?.toString() || ""); if (!text) return; + event.preventDefault(); + await /** @type {any} */ (globalThis).utils.q(event, `是否将 '${escapeHtml(text)}' 加入评论区关键词?`, async () => { await this.saveKeyword(text); /** @type {any} */ (globalThis).show.ok("操作成功, 刷新页面后生效"); }); + }); + } +} diff --git a/src/ui/detail/screenshot-panel.js b/src/ui/detail/screenshot-panel.js new file mode 100644 index 00000000..fbe4a05d --- /dev/null +++ b/src/ui/detail/screenshot-panel.js @@ -0,0 +1,77 @@ +// @ts-check + +import { createPanelError, createPanelLoading } from "./primitives.js"; + +/** @param {unknown} value */ +function normalizeImageUrl(value) { + try { + const url = new URL(String(value || "")); + return url.protocol === "https:" ? url.href : null; + } catch { return null; } +} + +/** @param {any} host @param {string} url @param {string} alt */ +function renderImage(host, url, alt) { + const jq = /** @type {any} */ (globalThis).$; + const normalized = normalizeImageUrl(url); + if (!normalized) return null; + const image = jq("").attr({ src: normalized, alt, loading: "lazy" }).addClass("jhs-fc2-gallery__image"); + const button = jq('').attr("aria-label", `查看${alt}大图`).append(image); + host.empty().append(button).off("click.jhsScreenshot").on("click.jhsScreenshot", ".jhs-fc2-screenshot-thumbnail", (/** @type {any} */ event) => { + event.preventDefault(); event.stopPropagation(); + /** @type {any} */ (globalThis).showImageViewer(image[0]); + }); + return normalized; +} + +/** + * 唯一截图视图:设置解析、provider 过滤、manual/auto 策略全部来自 ScreenshotService。 + * @param {{target: any, carNum: string, screenshot: import("../../services/screenshot-service.js").ScreenshotService, settings: Record, scope?: import("../../core/lifecycle-scope.js").LifecycleScope, isActive?: () => boolean, providerId?: string, isDuplicate?: (url: string) => boolean}} options + */ +export async function renderScreenshotPanel(options) { + const jq = /** @type {any} */ (globalThis).$; + const host = jq(options.target), isActive = options.isActive ?? (() => true), providerId = options.providerId ?? "javstore"; + if (!host.length || !options.screenshot.isEnabled(options.settings)) return host.empty(), null; + const { mode } = options.screenshot.getScreenshotSettings(options.settings); + const enabledProviders = options.screenshot.getEnabledProviders(options.settings); + if (mode !== "manual" && !enabledProviders.some((provider) => provider.id === providerId)) return host.empty(), null; + if (!enabledProviders.length) return host.empty().append(jq('
    没有可用截图来源
    ')), host; + const load = async (resultHost = host, selectedProviderId = providerId) => { + resultHost.empty().append(createPanelLoading("正在加载缩略图…")); + try { + const images = await options.screenshot.resolve({ carNum: options.carNum }, { providerId: selectedProviderId, scope: options.scope, settings: options.settings }); + if (!isActive()) return null; + const image = Array.isArray(images) ? images[0] : images; + if (!image?.url) return resultHost.empty(), null; + if (options.isDuplicate?.(image.url)) return resultHost.empty(), null; + return renderImage(resultHost, image.url, "缩略图"); + } catch (error) { + if (!isActive()) return null; + resultHost.empty().append(createPanelError("缩略图加载失败 ", () => void load(resultHost, selectedProviderId))); + /** @type {any} */ (globalThis).clog?.error("缩略图加载失败", error); + return null; + } + }; + if (mode !== "manual") return load(); + const tabs = jq('
    '); + const resultId = `jhs-screenshot-result-${String(options.carNum).replace(/[^a-z0-9_-]/gi, "-")}`; + const result = jq('
    ').attr("id", resultId).text("请选择截图来源"); + enabledProviders.forEach((provider, index) => { + const button = jq('').attr({ "aria-controls": resultId, tabindex: index === 0 ? "0" : "-1" }).text(provider.name); + button.on("click", async () => { + tabs.find("[role='tab']").attr({ "aria-selected": "false", tabindex: "-1" }); + button.attr({ "aria-selected": "true", tabindex: "0" }); + await load(result, provider.id); + }); + tabs.append(button); + }); + tabs.on("keydown", "[role='tab']", ((/** @type {KeyboardEvent} */ event) => { + if (![ "ArrowLeft", "ArrowRight", "Home", "End" ].includes(event.key)) return; + event.preventDefault(); + const items = tabs.find("[role='tab']"), index = items.index(event.currentTarget); + const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : event.key === "ArrowRight" ? (index + 1) % items.length : (index - 1 + items.length) % items.length; + items.eq(next).trigger("focus").trigger("click"); + })); + host.empty().append(tabs, result); + return host; +} diff --git a/src/ui/detail/state-actions.js b/src/ui/detail/state-actions.js new file mode 100644 index 00000000..22d72315 --- /dev/null +++ b/src/ui/detail/state-actions.js @@ -0,0 +1,25 @@ +// @ts-check +import { m, v, y, k } from "../../core/constants.js"; + +const actions = Object.freeze({ + blocked: { id: "filterBtn", tone: "filter", label: m }, + favorite: { id: "favoriteBtn", tone: "fav", label: v }, + downloaded: { id: "hasDownBtn", tone: "down", label: y }, + watched: { id: "hasWatchBtn", tone: "watch", label: k }, +}); + +/** 只创建控件;影片状态与点击行为由 DetailStateController 管理。 */ +/** @param {{ actions?: (keyof typeof actions)[], ids?: Record }} [options] */ +export function createStateActions({ actions: subset = ["blocked", "favorite", "downloaded", "watched"], ids = {} } = {}) { + const jq = /** @type {any} */ (globalThis).$; + const group = jq('
    '); + group.attr("data-jhs-state-count", String(subset.length)); + for (const key of subset) { + const action = actions[key]; + if (!action) continue; + const button = jq(''); + button.attr("id", ids[key] || action.id).addClass(`jhs-btn--${action.tone}`).find("span").text(action.label); + group.children().append(button); + } + return group; +} diff --git a/src/ui/settings/setting-binding-controller.js b/src/ui/settings/setting-binding-controller.js new file mode 100644 index 00000000..dbf6886a --- /dev/null +++ b/src/ui/settings/setting-binding-controller.js @@ -0,0 +1,194 @@ +// @ts-check + +/** + * Shared Settings UI binding controller. + * + * All live setting surfaces (quick + full) are bound to one hub per + * SettingsService. A change on one surface is immediately reflected on every + * other mounted surface, then persisted through SettingsService. Write failures + * roll back every surface to the last committed value, guarded by a per-key + * revision token so a stale failed promise cannot overwrite a newer intent. + */ + +const hubs = new WeakMap(); + +/** @param {any} settings */ +export function getSettingBindingHub(settings) { + let hub = hubs.get(settings); + if (!hub) { + hub = new SettingBindingHub(settings); + hubs.set(settings, hub); + } + return hub; +} + + +/** + * Bind a non-descriptor static control (checkbox/select/range) through the same + * shared BindingHub used by dynamic setting rows. This gives static live + * controls optimistic sync, cross-surface propagation, rollback and pending + * intent retention. + * + * @param {{ + * root: any, + * selector: string, + * key: string, + * getValue: () => unknown, + * setValue: (value: unknown) => void, + * fallback?: unknown, + * label?: string, + * settings: any, + * onChange?: ((key: string, value: unknown) => void) | null + * }} options + */ +export function bindSettingControl({ root, selector, key, getValue, setValue, fallback = undefined, label = key, settings, onChange = null }) { + const hub = getSettingBindingHub(settings); + const element = root.find(selector); + if (!element.length) return null; + /** @type {any} */ + const binding = { + setValue(/** @type {string} */ name, /** @type {unknown} */ value) { + if (name === key) setValue(value); + }, + dispose() { + element.off(".jhsSettingBinding"); + hub._dropBinding(binding); + }, + }; + hub.bindings.add(binding); + element.off(".jhsSettingBinding").on("change.jhsSettingBinding", () => { + const value = getValue(); + onChange?.(key, value); + hub.userChanged(key, value, fallback, label); + }); + binding.setValue(key, hub.desiredValue(key, settings.snapshot()[key] ?? fallback)); + return { + sync(/** @type {Record} */ snapshot) { + if (Object.prototype.hasOwnProperty.call(snapshot, key)) setValue(snapshot[key]); + }, + flush: (/** @type {any} */ options) => hub.flush(options), + dispose: () => binding.dispose(), + }; +} + +/** + * Creates an optimistic direct-action writer whose rollback is owned only by + * the latest intent for this control. + * + * @param {{settings: any, key: string, fallback?: unknown, apply: (value: unknown) => void, onError?: (error: unknown) => void}} options + */ +export function createLatestSettingWriter({ settings, key, fallback = undefined, apply, onError = () => {} }) { + let revision = 0; + return async (/** @type {unknown} */ value) => { + const token = ++revision; + apply(value); + try { + await settings.set(key, value); + } catch (error) { + if (token === revision) { + apply(settings.snapshot()[key] ?? fallback); + onError(error); + } + } + }; +} + +class SettingBindingHub { + /** @param {any} settings */ + constructor(settings) { + this.settings = settings; + /** @type {Set} */ + this.bindings = new Set(); + /** @type {Map, error?: unknown }>} */ + this.pending = new Map(); + this.revision = 0; + this._onSettingsChanged = this._onSettingsChanged.bind(this); + this.settings.addEventListener("settings.changed", this._onSettingsChanged); + } + + /** @param {any} event */ + _onSettingsChanged(event) { + const detail = event.detail || {}; + const snapshot = detail.snapshot ?? this.settings.snapshot(); + const names = Array.isArray(detail.names) ? detail.names : Object.keys(snapshot); + for (const name of names) { + const pending = this.pending.get(name); + // A newer local intent must never be covered by an intermediate + // committed state from an older write (e.g. OFF -> ON -> OFF). + this.syncAll(name, pending ? pending.value : snapshot[name]); + } + } + + /** @param {string} key @param {unknown} value */ + syncAll(key, value) { + for (const binding of this.bindings) binding.setValue(key, value); + } + + /** @param {string} key @param {unknown} fallback */ + desiredValue(key, fallback) { + const pending = this.pending.get(key); + return pending ? pending.value : fallback; + } + + /** @param {string} key @param {unknown} value @param {unknown} fallback @param {string} [label] */ + userChanged(key, value, fallback, label) { + // Optimistically update every mounted surface. + this.syncAll(key, value); + const token = ++this.revision; + /** @type {{ token: number, value: unknown, promise: Promise, error?: unknown }} */ + const item = { token, value, promise: Promise.resolve() }; + const promise = this.settings.set(key, value).then( + () => { + if (this.pending.get(key) === item) { + this.pending.delete(key); + this._maybeDispose(); + } + }, + (/** @type {unknown} */ error) => { + if (this.pending.get(key) === item) { + this.pending.delete(key); + item.error = error; + const committed = this.settings.snapshot()[key] ?? fallback; + this.syncAll(key, committed); + this._maybeDispose(); + if (typeof /** @type {any} */ (globalThis).show?.error === "function") { + /** @type {any} */ (globalThis).show.error(`${label || key}保存失败,已恢复原设置`); + } else if (typeof /** @type {any} */ (globalThis).clog?.error === "function") { + /** @type {any} */ (globalThis).clog.error(`${label || key}保存失败,已恢复原设置`, error); + } + } + } + ); + item.promise = promise; + this.pending.set(key, item); + return promise; + } + + async flush({ throwOnFailure = false } = {}) { + const items = [ ...this.pending.values() ]; + await Promise.all(items.map((item) => item.promise)); + await this.settings.waitForIdle(); + if (throwOnFailure) { + const failures = items.filter((item) => item.error).map((item) => item.error); + if (failures.length > 0) { + const error = /** @type {any} */ (new Error("部分实时设置保存失败")); + error.cause = failures[0]; + error.liveSettingFailures = failures; + throw error; + } + } + } + + /** @param {any} binding */ + _dropBinding(binding) { + this.bindings.delete(binding); + this._maybeDispose(); + } + + _maybeDispose() { + if (this.bindings.size === 0 && this.pending.size === 0) { + this.settings.removeEventListener("settings.changed", this._onSettingsChanged); + hubs.delete(this.settings); + } + } +} diff --git a/src/ui/settings/setting-control-renderer.js b/src/ui/settings/setting-control-renderer.js new file mode 100644 index 00000000..af8aa465 --- /dev/null +++ b/src/ui/settings/setting-control-renderer.js @@ -0,0 +1,213 @@ +// @ts-check + +import { parseBooleanSetting } from "../../core/feature-helpers.js"; +import { JhsSelect } from "../../core/ui-primitives.js"; +import { getSettingBindingHub } from "./setting-binding-controller.js"; + +/** + * Shared setting UI renderer. Both the full settings dialog and the quick + * settings panel render the same SettingDescriptor through this module and + * write through the same SettingsService key — no second semantic definition. + */ + +/** @param {string} key @param {unknown} value */ +function normalizeBooleanValue(key, value) { + return parseBooleanSetting(value, false); +} + +/** @typedef {{ value?: unknown, onChange?: ((value: unknown) => void) | null }} ControlOptions */ + +/** Build the raw control element for a descriptor. @param {Record} descriptor @param {ControlOptions} [options] */ +export function renderSettingControl(descriptor, options = {}) { + const jq = /** @type {any} */ (globalThis).$; + const value = options.value ?? descriptor.defaultValue; + const onChange = options.onChange ?? null; + /** @type {any} */ let control; + if (descriptor.type === "boolean") { + control = jq('').prop("checked", normalizeBooleanValue(descriptor.key, value)); + control.on("change", () => { onChange?.(control.is(":checked") ? "yes" : "no"); }); + return { root: control, getValue: () => control.is(":checked") ? "yes" : "no", setValue: /** @param {unknown} next */ (next) => control.prop("checked", normalizeBooleanValue(descriptor.key, next)) }; + } + if (descriptor.type === "select") { + const optionsList = Array.isArray(descriptor.options) ? descriptor.options : []; + control = jq(''); + optionsList.forEach((option) => { + const item = option && typeof option === "object" ? option : { value: option, label: String(option) }; + control.append(jq('').attr("value", item.value).text(item.label ?? String(item.value))); + }); + control.val(String(value ?? "")); + control.on("change", () => onChange?.(control.val())); + return { root: control, getValue: () => control.val(), setValue: /** @param {unknown} next */ (next) => control.val(String(next ?? "")) }; + } + if (descriptor.type === "number") { + control = jq('').val(value == null ? "" : String(value)); + control.on("change", () => onChange?.(Number(control.val()) || 0)); + return { root: control, getValue: () => Number(control.val()) || 0, setValue: /** @param {unknown} next */ (next) => control.val(next == null ? "" : String(next)) }; + } + control = jq('').val(value == null ? "" : String(value)); + control.on("change", () => onChange?.(control.val())); + return { root: control, getValue: () => control.val(), setValue: /** @param {unknown} next */ (next) => control.val(next == null ? "" : String(next)) }; +} + +/** Build a canonical jhs-setting-row for a descriptor. @param {Record} descriptor @param {ControlOptions & { value?: unknown }} [options] */ +export function renderSettingRow(descriptor, options = {}) { + const jq = /** @type {any} */ (globalThis).$; + const rendered = renderSettingControl(descriptor, { value: options.value, onChange: options.onChange }); + const copy = jq('') + .append(jq('').text(descriptor.label)) + .append(jq('').text(descriptor.description || "")); + const control = jq('').append(rendered.root); + const row = jq('') + .attr("data-jhs-setting", descriptor.key) + .attr("data-jhs-setting-effect", descriptor.effect || "live") + .toggleClass("jhs-setting-row--indent", descriptor.indent === true) + .append(copy, control); + return { row, root: rendered.root, getValue: rendered.getValue, setValue: rendered.setValue }; +} + +/** Render the quick settings list from registry descriptors. @param {import("../../app/settings-registry.js").SettingsRegistry} registry @param {{ disabledContributions?: Set }} [options] */ +export function buildQuickSettingsHtml(registry, options = {}) { + const jq = /** @type {any} */ (globalThis).$; + const list = jq('
    '); + for (const descriptor of registry.list({ surfaces: [ "quick" ], disabledContributions: options.disabledContributions })) { + const { row } = renderSettingRow(descriptor); + list.append(row); + } + return list; +} + +/** + * Bind a set of rendered setting rows to SettingsService (live effect). + * + * Returns a disposable binding handle. It keeps the original DOM controls and + * subscribes to the shared per-SettingsService binding hub so every mounted + * quick/full surface stays in sync without rebuilding controls. + * + * @param {any} root + * @param {Array>} descriptors + * @param {{ settings: any, onChanged?: ((key: string, value: unknown) => void) | null }} options + */ +export function bindSettingRows(root, descriptors, { settings, onChanged = null }) { + const hub = getSettingBindingHub(settings); + const binding = new RowBinding(root, descriptors, { settings, hub, onChanged }); + return { + sync: (/** @type {Record} */ snapshot) => binding.sync(snapshot), + flush: (/** @type {any} */ options) => hub.flush(options), + dispose: () => binding.dispose(), + setters: binding.setters, + }; +} + +/** + * Sync rendered rows from a snapshot without firing change events. This is a + * compatibility helper only; it never rebuilds controls. + * + * @param {any} root + * @param {Array>} descriptors + * @param {Record} snapshot + */ +export function syncSettingRows(root, descriptors, snapshot) { + descriptors.forEach((descriptor) => { + const row = root.find(`[data-jhs-setting="${descriptor.key}"]`).first(); + if (!row.length) return; + const control = row.find(".jhs-setting-row__control").children().first(); + const value = Object.prototype.hasOwnProperty.call(snapshot, descriptor.key) ? snapshot[descriptor.key] : descriptor.defaultValue; + if (control.is('input[type="checkbox"]')) control.prop("checked", normalizeBooleanValue(descriptor.key, value)); + else if (control.is("select")) control.val(String(value ?? "")); + else control.val(value == null ? "" : String(value)); + }); +} + +class RowBinding { + /** + * @param {any} root + * @param {Array>} descriptors + * @param {{ settings: any, hub: any, onChanged?: ((key: string, value: unknown) => void) | null }} options + */ + constructor(root, descriptors, { settings, hub, onChanged = null }) { + this.root = root; + this.settings = settings; + this.hub = hub; + this.onChanged = onChanged; + /** @type {Record void>} */ + this.setters = {}; + /** @type {Map void }>} */ + this.controls = new Map(); + hub.bindings.add(this); + for (const descriptor of descriptors) { + this._bindDescriptor(descriptor); + } + } + + /** @param {Record} descriptor */ + _bindDescriptor(descriptor) { + const key = descriptor.key; + const row = this.root.find(`[data-jhs-setting="${key}"]`).first(); + if (!row.length) return; + const slot = row.find(".jhs-setting-row__control"); + let element = slot.children().first(); + if (!element.length) { + const rendered = renderSettingControl(descriptor); + slot.append(rendered.root); + element = rendered.root; + } + const type = descriptor.type || (element.is("select") ? "select" : element.is('input[type="checkbox"]') ? "boolean" : element.is('input[type="number"]') ? "number" : "text"); + const setValue = makeSetValue(type, element); + const getValue = makeGetValue(type, element); + const fallback = descriptor.defaultValue; + element.off(".jhsSettingBinding").on("change.jhsSettingBinding", () => { + const rawValue = getValue(); + const value = typeof descriptor.normalize === "function" ? descriptor.normalize(rawValue) : rawValue; + setValue(value); + this.onChanged?.(key, value); + if ((descriptor.effect || "live") === "live") { + this.hub.userChanged(key, value, fallback, descriptor.label || key); + } + }); + this.controls.set(key, { element, setValue }); + this.setters[key] = setValue; + this.setValue(key, this.hub.desiredValue(key, this.settings.snapshot()[key] ?? fallback)); + } + + /** @param {string} key @param {unknown} value */ + setValue(key, value) { + const control = this.controls.get(key); + if (control) control.setValue(value); + } + + /** @param {Record} snapshot */ + sync(snapshot) { + for (const key of this.controls.keys()) { + const value = Object.prototype.hasOwnProperty.call(snapshot, key) ? snapshot[key] : this.settings.snapshot()[key]; + this.setValue(key, value); + } + } + + dispose() { + for (const control of this.controls.values()) { + control.element.off(".jhsSettingBinding"); + } + this.hub._dropBinding(this); + } +} + +/** @param {string} type @param {any} element @returns {(value: unknown) => void} */ +function makeSetValue(type, element) { + if (type === "boolean") return (next) => element.prop("checked", normalizeBooleanValue("", next)); + if (type === "select") return (next) => { element.val(String(next ?? "")), JhsSelect.refresh(element); }; + if (type === "number") return (next) => element.val(next == null ? "" : String(next)); + return (next) => element.val(next == null ? "" : String(next)); +} + +/** @param {string} type @param {any} element @returns {() => unknown} */ +function makeGetValue(type, element) { + if (type === "boolean") return () => element.is(":checked") ? "yes" : "no"; + if (type === "select") return () => element.val(); + if (type === "number") return () => { + const raw = element.val(); + if (raw == null || String(raw).trim() === "") return Number.NaN; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : Number.NaN; + }; + return () => element.val(); +} diff --git a/src/ui/table/create-jhs-table.js b/src/ui/table/create-jhs-table.js new file mode 100644 index 00000000..33757b3d --- /dev/null +++ b/src/ui/table/create-jhs-table.js @@ -0,0 +1,29 @@ +// @ts-check + +const JHS_TABLE_LOCALE = "zh-cn"; +const JHS_TABLE_LANGS = Object.freeze({ + "zh-cn": Object.freeze({ + pagination: Object.freeze({ + first: "首页", first_title: "首页", last: "尾页", last_title: "尾页", + prev: "上一页", prev_title: "上一页", next: "下一页", next_title: "下一页", + all: "全部", page_size: "每页行数", + }), + }), +}); + +/** @param {any} TabulatorRuntime @param {string | Element} target @param {Record} options */ +export function createJhsTable(TabulatorRuntime, target, options) { + if (typeof TabulatorRuntime !== "function") throw new TypeError("Tabulator runtime is required"); + const element = typeof target === "string" ? globalThis.document?.querySelector(target) : target; + element?.classList?.add("jhs-table"); + const pageSizes = (options.paginationSizeSelector ?? [20, 50, 100, 1000]).filter((/** @type {any} */ value) => Number.isInteger(value) && value > 0); + return new TabulatorRuntime(target, { + layout: "fitColumns", responsiveLayout: "collapse", pagination: true, paginationMode: "local", + // 默认 height:100% 启用虚拟滚动并让表格适配弹窗可视高度,避免 .layui-layer 的 overflow:hidden 裁掉尾行与分页条; + // 容器高度不确定时(auto 父级)百分比按 CSS 规则退化为 auto,行为与旧版一致 + height: "100%", + paginationSize: pageSizes[0] ?? 20, ...options, paginationSizeSelector: pageSizes, + locale: options.locale ?? JHS_TABLE_LOCALE, + langs: options.langs ?? JHS_TABLE_LANGS, + }); +} diff --git a/src/ui/translation/title-translation.js b/src/ui/translation/title-translation.js new file mode 100644 index 00000000..41c0c122 --- /dev/null +++ b/src/ui/translation/title-translation.js @@ -0,0 +1,33 @@ +// @ts-check + +/** + * @param {{root?: any, carNum?: string, translation: import("../../services/translation-service.js").TranslationService, scope?: import("../../core/lifecycle-scope.js").LifecycleScope, isActive?: () => boolean}} options + */ +export async function renderTranslatedTitle(options) { + // Double gate: never create DOM or start a network request when the feature + // is already disabled at call time. + if (options.isActive && options.isActive() === false) return; + const jq = /** @type {any} */ (globalThis).$; + const root = options.root ? jq(options.root) : jq(document); + let title = root.find(".origin-title").first(); + if (!title.length) title = root.find(".current-title").first(); + if (!title.length) title = root.find("h3").first(); + if (!title.length) return; + const sourceText = title.text().trim(); + if (!sourceText) throw new TypeError("获取标题失败, 无法进行翻译"); + let translatedNode = title.nextAll(".translated-title").first(); + if (!translatedNode.length) translatedNode = jq('
    ').insertAfter(title); + translatedNode.removeClass("is-error").text("翻译中..."); + try { + const translated = await options.translation.translate(sourceText, { cacheAlias: options.carNum, scope: options.scope }); + if (options.isActive && !options.isActive()) return; + if (!title[0]?.isConnected || !translatedNode[0]?.isConnected) return; + translatedNode.text(translated); + } catch (error) { + if (options.isActive && !options.isActive()) return; + if (!title[0]?.isConnected || !translatedNode[0]?.isConnected) return; + const message = error instanceof Error ? error.message : String(error); + /** @type {any} */ (globalThis).clog?.error("翻译失败:", error); + translatedNode.addClass("is-error").text(`翻译失败: ${message}`); + } +} diff --git a/tests/123av-adapter.test.js b/tests/123av-adapter.test.js index 6df254fd..549ef915 100644 --- a/tests/123av-adapter.test.js +++ b/tests/123av-adapter.test.js @@ -1,18 +1,23 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const readSource = (path) => readFileSync(join(import.meta.dirname, "..", path), "utf8"); +const readSource = (path) => readTestFile(join(import.meta.dirname, "..", path), "utf8"); const fc2Source = readSource("src/plugins/external-search/fc2-by-123av.js"); +const integrationSource = readSource("src/integrations/av123/manifest.js"); const otherSiteSource = readSource("src/plugins/external-search/other-site.js"); const httpSource = readSource("src/core/http.js"); describe("123AV Chinese adapter contract", () => { it("uses the confirmed Chinese list and encoded search routes", () => { - expect(fc2Source).toContain("/cn/makers/fc2?page=${sourcePage}"); - expect(fc2Source).toContain("/cn/search?keyword=${encodeURIComponent(this.keyword)}"); - expect(otherSiteSource).toContain('`${await this.getAv123Url()}/cn`'); - expect(otherSiteSource).toContain("encodeURIComponent(t)"); + expect(integrationSource).toContain("/cn/makers/fc2?page=${sourcePage}"); + expect(integrationSource).toContain("/cn/search?keyword=${encodeURIComponent(keyword)}&page=${sourcePage}"); + expect(integrationSource).toContain("keyword ? [page]"); + expect(fc2Source).not.toContain('this.keyword && $(".page-box").hide()'); + expect(fc2Source).not.toContain("123av.com"); + expect(otherSiteSource).toContain('providerId: "av123"'); + expect(otherSiteSource).toContain('getRuntimeService("movie").searchUrl'); }); it("removes obsolete 123AV selectors, routes and sorting controls", () => { @@ -22,15 +27,18 @@ describe("123AV Chinese adapter contract", () => { expect(otherSiteSource).not.toContain('getAv123Url() + "/ja"'); }); - it("classifies Cloudflare failures by property and preserves request context", () => { - expect(otherSiteSource).toContain("a?._cfBlocked"); + it("classifies Cloudflare failures by normalized code and preserves request context", () => { + expect(otherSiteSource).toContain('const code = /** @type {{ code?: string }} */ (a)?.code'); + expect(otherSiteSource).toContain('"CF_BLOCKED" === code'); + expect(otherSiteSource).not.toContain("a?._cfBlocked"); expect(otherSiteSource).not.toContain('includes("Just a moment")'); expect(httpSource).toContain("Cloudflare challenge blocked: ${t}"); expect(httpSource).toContain("n.requestUrl = t"); expect(httpSource).toContain("n.finalUrl = e.finalUrl"); expect(httpSource).toContain("n.cfDiagnostics ="); - expect(fc2Source).toContain('cookiePartitionTopLevelSite: "https://123av.com"'); - expect(otherSiteSource).toContain('requestOptions: { cookiePartitionTopLevelSite: "https://123av.com" }'); + expect(integrationSource).toContain("cookiePartition: { topLevelSite: origin }"); + expect(integrationSource).toContain('trustClass: "custom-public"'); + expect(otherSiteSource).not.toContain("cookiePartitionTopLevelSite"); }); it("does not classify ordinary 404 markup as a Cloudflare challenge", () => { diff --git a/tests/activity-retention.test.js b/tests/activity-retention.test.js index 479c1545..ae9ae42f 100644 --- a/tests/activity-retention.test.js +++ b/tests/activity-retention.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,7 +7,7 @@ import { describe, expect, it } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); function loadRetention() { - const source = readFileSync(join(repoRoot, "src/core/state-service.js"), "utf8"), end = source.indexOf("class StateService"); + const source = readTestFile(join(repoRoot, "src/core/state-service.js"), "utf8"), end = source.indexOf("class StateService"); const context = vm.createContext({ Date, JSON, Object, Array, Math }); vm.runInContext(`${source.slice(0, end)}; globalThis.prune = pruneActivityLog;`, context); return context.prune; diff --git a/tests/actress-decoration.test.js b/tests/actress-decoration.test.js index a511770b..3bfb8a79 100644 --- a/tests/actress-decoration.test.js +++ b/tests/actress-decoration.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,7 +6,7 @@ import jquery from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it } from "vitest"; -const source = readFileSync(join(import.meta.dirname, "../src/plugins/status/compat-enhancements.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/plugins/status/compat-enhancements.js"), "utf8"); async function render(html, url, favorites = [], blacklist = []) { const dom = new JSDOM(html, { url }), $ = jquery(dom.window); const context = vm.createContext({ window: dom.window, document: dom.window.document, URL, decodeURIComponent, $, BasePlugin: class { getPageInfo() { return {}; } }, storageManager: { getFavoriteActressList: async () => favorites, getBlacklist: async () => blacklist }, isDetailPage: false }); diff --git a/tests/actress-info-service.test.js b/tests/actress-info-service.test.js new file mode 100644 index 00000000..6bf069ee --- /dev/null +++ b/tests/actress-info-service.test.js @@ -0,0 +1,41 @@ +import { expect, it, vi } from "vitest"; +import { ActressInfoService } from "../src/services/actress-info-service.js"; +import { CacheService } from "../src/services/cache-service.js"; + +it("normalizes aliases and caches actress information", async () => { + const lookup = vi.fn(async name => ({ name })); + const adapter = { lookup, profileUrl: name => `profile:${name}` }; + const integrations = { list: () => [{ id: "wikipedia" }], getAdapter: () => adapter }; + const service = new ActressInfoService(integrations, new CacheService()); + await expect(service.lookup("三上悠亞")).resolves.toEqual({ name: "三上悠亜" }); + await expect(service.lookup("三上悠亞")).resolves.toEqual({ name: "三上悠亜" }); + expect(lookup).toHaveBeenCalledOnce(); + expect(service.profileUrl("A")).toBe("profile:A"); +}); + +it("caches a normal miss as null and lets errors propagate", async () => { + const lookup = vi.fn(async () => null); + const adapter = { lookup, profileUrl: name => `profile:${name}` }; + const integrations = { list: () => [{ id: "wikipedia" }], getAdapter: () => adapter }; + const service = new ActressInfoService(integrations, new CacheService()); + await expect(service.lookup("Missing")).resolves.toBeNull(); + await expect(service.lookup("Missing")).resolves.toBeNull(); + expect(lookup).toHaveBeenCalledOnce(); + + const failing = vi.fn(async () => { throw new Error("network"); }); + const failingService = new ActressInfoService({ list: () => [{ id: "wikipedia" }], getAdapter: () => ({ lookup: failing, profileUrl: () => "" }) }, new CacheService()); + await expect(failingService.lookup("Error")).rejects.toThrow("network"); +}); + +it("routes avatar source lookup and placeholders through declared Integrations", async () => { + const searchAvatars = vi.fn(async () => ["avatar:a"]), adapters = { + gfriends: { getSources: () => [{ id: "jsdelivr" }], searchAvatars }, + javdb: { actorPlaceholderUrl: () => "avatar:placeholder" }, + }; + const integrations = { list: capability => capability === "person.avatar-search" ? [{ id: "gfriends" }] : [], getAdapter: id => adapters[id] }; + const service = new ActressInfoService(integrations, new CacheService()); + expect(service.getAvatarSources()).toEqual([{ id: "jsdelivr" }]); + await expect(service.searchAvatars(["Alice"], "jsdelivr", { scope: "scope" })).resolves.toEqual(["avatar:a"]); + expect(searchAvatars).toHaveBeenCalledWith(["Alice"], { sourceId: "jsdelivr", scope: "scope" }); + expect(service.placeholderUrl("javdb")).toBe("avatar:placeholder"); +}); diff --git a/tests/architecture-runtime.test.js b/tests/architecture-runtime.test.js new file mode 100644 index 00000000..b42f7fd3 --- /dev/null +++ b/tests/architecture-runtime.test.js @@ -0,0 +1,297 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import jquery from "jquery"; +import { CommandRegistry } from "../src/app/command-registry.js"; +import { DependencyContainer } from "../src/app/dependency-container.js"; +import { FeatureRuntime, migrateDisabledPlugins } from "../src/app/feature-runtime.js"; +import { ProviderRegistry } from "../src/app/provider-registry.js"; +import { defineFeature, defineIntegration } from "../src/contracts/manifests.js"; +import { PORT, REGISTRY, SERVICE } from "../src/contracts/tokens.js"; +import { SettingsRegistry } from "../src/app/settings-registry.js"; +import { featureManifests } from "../src/features/catalog.js"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; +import { openSettingsUi, registerSettingsUiOwner } from "../src/core/settings-ui-owner.js"; +import { BasePlugin, PluginManager } from "../src/core/plugin-manager.js"; +import { DiagnosticsService } from "../src/services/diagnostics-service.js"; +import { createIntegrationRequestFacade } from "../src/app/integration-registry.js"; + +describe("v6.5 architecture runtime contracts", () => { + it("injects only declared tokens and rejects duplicate or missing dependencies", () => { + const diagnostics = new DiagnosticsService(); + const container = new DependencyContainer(diagnostics); + const movie = { id: "movie" }; + container.register(SERVICE.movie, movie); + expect(container.resolveDeclared([SERVICE.movie])[SERVICE.movie]).toBe(movie); + expect(() => container.resolveDeclared([SERVICE.movie])[SERVICE.review]).toThrow(/Undeclared dependency access/); + expect(diagnostics.exportSnapshot().errors.at(-1)).toMatchObject({ code: "UNDECLARED_DEPENDENCY", source: "DependencyContainer" }); + expect(() => container.register(SERVICE.movie, {})).toThrow(/Duplicate/); + expect(() => container.resolveDeclared([SERVICE.review])).toThrow(/Missing/); + expect(() => container.resolveDeclared([SERVICE.movie, SERVICE.movie])).toThrow(/Duplicate declared dependency/); + }); + + it("limits transitional legacy plugins to explicitly declared dependencies", () => { + class DependencyPlugin extends BasePlugin { getName() { return "DependencyPlugin"; } } + class ConsumerPlugin extends BasePlugin { + getName() { return "ConsumerPlugin"; } + dependency(name) { return this.getDependency(name); } + optionalDependency(name) { return this.getOptionalDependency(name); } + } + const diagnostics = new DiagnosticsService(); + const manager = new PluginManager({ diagnostics }); + manager.setDependencyDeclarations({ ConsumerPlugin: ["DependencyPlugin", "MissingPlugin"] }); + manager.register(DependencyPlugin); + manager.register(ConsumerPlugin); + const consumer = manager.getBean("ConsumerPlugin"); + expect(consumer.dependency("DependencyPlugin")).toBe(manager.getBean("DependencyPlugin")); + expect(consumer.optionalDependency("MissingPlugin")).toBeUndefined(); + expect(() => consumer.dependency("MissingPlugin")).toThrow("Missing dependency: ConsumerPlugin -> MissingPlugin"); + expect(diagnostics.exportSnapshot().errors.at(-1)).toMatchObject({ plugin: "ConsumerPlugin", phase: "dependency" }); + expect(() => consumer.dependency("UndeclaredPlugin")).toThrow(/未声明依赖/); + }); + + it("registers legacy contributions from manifests without changing site order", async () => { + expect(featureManifests).toHaveLength(11); + window.matchMedia ??= () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); + const $ = jquery; + vi.stubGlobal("$", $); + vi.stubGlobal("jQuery", $); + vi.stubGlobal("localforage", { INDEXEDDB: "indexeddb", createInstance: () => ({}) }); + Object.assign(globalThis, { utils: {}, storageManager: {} }); + const { legacyContributionManifests, registerSitePlugins } = await import("../src/plugins/registry.js"); + expect(new Set(legacyContributionManifests.map((item) => item.id)).size).toBe(legacyContributionManifests.length); + expect(new Set(legacyContributionManifests.map((item) => item.legacyPluginId)).size).toBe(legacyContributionManifests.length); + expect(legacyContributionManifests.find((item) => item.id === "discovery.top250")?.legacyPluginId).toBe("TOP250Plugin"); + const createRuntime = (site, disabled = []) => { + const diagnostics = new DiagnosticsService(); + const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.credential, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.imageSearch, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.subtitle, {}).register(SERVICE.account, {}).register(SERVICE.settings, {}).register(SERVICE.profile, { current: () => "regular" }).register(SERVICE.storage, {}).register(SERVICE.cache, {}).register(SERVICE.http, {}).register(SERVICE.offline, {}).register(SERVICE.state, {}).register(REGISTRY.settings, new SettingsRegistry()); + const runtime = new FeatureRuntime({ container, commands: new CommandRegistry(), diagnostics, disabled, site, route: "list" }); + featureManifests.forEach((manifest) => runtime.register(manifest)); + return runtime; + }; + const javdb = new PluginManager(); + registerSitePlugins(javdb, createRuntime("javdb"), "javdb"); + expect(javdb.getPluginNames()).toEqual([ + "OneTwoThreeOfflinePlugin", "ListPagePlugin", "AutoPagePlugin", "Fc2Plugin", "Fc2NavigationPlugin", "FoldCategoryPlugin", "ListPageButtonPlugin", "HistoryPlugin", + "SettingPlugin", "NavBarPlugin", "HitShowPlugin", "TOP250Plugin", "SearchByImagePlugin", "CoverButtonPlugin", + "FilterTitleKeywordPlugin", "ActressInfoPlugin", "TranslatePlugin", "WantAndWatchedVideosPlugin", "BlacklistPlugin", + "FavoriteActressesPlugin", "NewVideoPlugin", "TaskPlugin", "StatsPlugin", "MobileBottomBarPlugin", "OneOneFiveMatchPlugin", "UnifiedOfflinePlugin", "CompatibilityEnhancementsPlugin", + ]); + const javbus = new PluginManager(); + registerSitePlugins(javbus, createRuntime("javbus", ["ReviewPlugin"]), "javbus"); + expect(javbus.getPluginNames()).not.toContain("ReviewPlugin"); + expect(javbus.getPluginNames()).not.toContain("DetailWorkspacePlugin"); + + const javdbWithCoverDisabled = new PluginManager(); + registerSitePlugins(javdbWithCoverDisabled, createRuntime("javdb", ["CoverButtonPlugin"]), "javdb"); + expect(javdbWithCoverDisabled.getPluginNames()).not.toContain("CoverButtonPlugin"); + expect(javdbWithCoverDisabled.getPluginNames()).toContain("ListPagePlugin"); + expect(javdbWithCoverDisabled.getPluginDescriptors()).toContainEqual({ name: "CoverButtonPlugin", disableable: true }); + + const javdbWithExternalSitesDisabled = new PluginManager(); + registerSitePlugins(javdbWithExternalSitesDisabled, createRuntime("javdb", ["OtherSitePlugin"]), "javdb"); + expect(javdbWithExternalSitesDisabled.getPluginNames()).not.toContain("OtherSitePlugin"); + expect(javdbWithExternalSitesDisabled.getPluginDescriptors()).toContainEqual({ name: "OtherSitePlugin", disableable: true }); + + const javbusWithImagesDisabled = new PluginManager(); + registerSitePlugins(javbusWithImagesDisabled, createRuntime("javbus", ["BusImgPlugin"]), "javbus"); + expect(javbusWithImagesDisabled.getPluginNames()).not.toContain("BusImgPlugin"); + expect(javbusWithImagesDisabled.getPluginNames()).not.toContain("BusPreviewVideoPlugin"); + + for (const [site, disabledPlugin, survivingPlugin] of [ + ["javdb", "Fc2Plugin", "ListPagePlugin"], + ["javdb", "AutoPagePlugin", "ListPagePlugin"], + ["javdb", "CoverButtonPlugin", "ListPagePlugin"], + ["javdb", "ListPageButtonPlugin", "ListPagePlugin"], + ["javdb", "HighlightMagnetPlugin", "ListPagePlugin"], + ["javdb", "MagnetHubPlugin", "ListPagePlugin"], + ]) { + const manager = new PluginManager(); + registerSitePlugins(manager, createRuntime(site, [disabledPlugin]), site); + expect(manager.getPluginNames(), `${survivingPlugin} must survive disabled ${disabledPlugin}`).not.toContain(disabledPlugin); + expect(manager.getPluginNames(), `${survivingPlugin} must survive disabled ${disabledPlugin}`).toContain(survivingPlugin); + } + + const legacyDiagnostics = new DiagnosticsService(); + const javdbWithStaleSystemDisables = new PluginManager({ diagnostics: legacyDiagnostics }); + registerSitePlugins(javdbWithStaleSystemDisables, createRuntime("javdb", ["settings.core", "stats.dashboard", "responsive-shell.bottom-bar"]), "javdb"); + expect(javdbWithStaleSystemDisables.getPluginNames()).toEqual(expect.arrayContaining(["SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin"])); + expect(javdbWithStaleSystemDisables.getPluginDescriptors().filter((plugin) => plugin.disableable === false).map((plugin) => plugin.name)).toEqual([ + "SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin", + ]); + expect(legacyDiagnostics.exportSnapshot().legacyPluginDescriptors.filter((plugin) => plugin.disableable === false).map((plugin) => plugin.name)).toEqual([ + "SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin", + ]); + }, 15_000); + + it("declares every literal runtime service used by a legacy contribution", async () => { + const { legacyContributionManifests } = await import("../src/plugins/registry.js"); + const runtimeTokens = new Map([ + ["host", PORT.host], ["diagnostics", SERVICE.diagnostics], ["review", SERVICE.review], + ["related", SERVICE.related], ["movie", SERVICE.movie], ["magnet", SERVICE.magnet], + ["settings", SERVICE.settings], ["cache", SERVICE.cache], ["http", SERVICE.http], + ["profile", SERVICE.profile], ["actressInfo", SERVICE.actressInfo], ["imageSearch", SERVICE.imageSearch], + ["screenshot", SERVICE.screenshot], ["translation", SERVICE.translation], ["subtitle", SERVICE.subtitle], + ["account", SERVICE.account], ["webdav", SERVICE.webdav], ["credential", SERVICE.credential], ["storage", SERVICE.storage], + ["state", SERVICE.state], ["offline", SERVICE.offline], ["dialog", SERVICE.dialog], + ["settingsRegistry", REGISTRY.settings], + ]); + for (const contribution of legacyContributionManifests) { + const source = contribution.plugin.toString(); + const usedNames = [...source.matchAll(/getRuntimeService\(["']([^"']+)["']\)/g)].map((match) => match[1]).filter((name) => name !== "scope"); + for (const name of new Set(usedNames)) { + expect(runtimeTokens.has(name), `${contribution.id} uses unknown runtime service ${name}`).toBe(true); + expect(contribution.requires, `${contribution.id} must declare runtime service ${name}`).toContain(runtimeTokens.get(name)); + } + } + }); + + it("keeps independently disableable legacy contributions out of the hard dependency graph", async () => { + const { legacyContributionManifests } = await import("../src/plugins/registry.js"); + for (const contribution of legacyContributionManifests) { + expect(contribution.plugin.toString(), `${contribution.id} must use optional plugin dependencies`).not.toContain("this.getDependency("); + } + }); + + it("exports measured and redacted diagnostics", () => { + const diagnostics = new DiagnosticsService(); + diagnostics.updateScope({ id: "app:root", listeners: 3, observers: 1 }); + diagnostics.recordError({ message: "failed https://user:pass@example.com/x", authorization: "Bearer secret" }); + const snapshot = diagnostics.exportSnapshot(); + expect(snapshot.globalListeners).toBe(3); + expect(snapshot.observers).toBe(1); + expect(snapshot.errors[0]).toMatchObject({ authorization: "[redacted]" }); + expect(snapshot.errors[0].message).not.toContain("user:pass"); + }); + + it("lazily activates a command owner and preserves contribution-level disabling", async () => { + const commands = new CommandRegistry(); + const diagnostics = new DiagnosticsService(); + const runtime = new FeatureRuntime({ + container: new DependencyContainer().register(SERVICE.movie, { open: vi.fn() }), + commands, diagnostics, disabled: ["ReviewPlugin"], site: "javdb", route: "detail", + }); + const activate = vi.fn((deps, api) => ({ + commands: { "detail.open": () => deps[SERVICE.movie].open() }, + enabled: api.enabledContributions, + })); + runtime.register(defineFeature({ + id: "detail", kind: "feature", disableable: true, sites: ["javdb"], routes: ["detail"], + startup: "on-command", requires: [SERVICE.movie], contributes: ["detail.reviews", "detail.related"], + providesCommands: ["detail.open"], activate, + })); + await commands.execute("detail.open"); + expect(activate).toHaveBeenCalledOnce(); + expect(diagnostics.exportSnapshot().activeContributions).toEqual(["detail.related"]); + expect(migrateDisabledPlugins(["ReviewPlugin", "UnknownPlugin"])).toEqual(["detail.reviews", "UnknownPlugin"]); + expect(migrateDisabledPlugins(["CoverButtonPlugin", "DetailPageButtonPlugin", "BusImgPlugin", "BusPreviewVideoPlugin"])).toEqual([ + "detail.cover-state-actions", "detail.page-state-actions", "detail.javbus-images", "detail.javbus-preview", + ]); + expect(migrateDisabledPlugins(["detail.native", "detail.state-actions", "detail.gallery"])).toEqual([ + "detail.javdb-native", "detail.javbus-native", "detail.cover-state-actions", "detail.page-state-actions", + "detail.javdb-preview", "detail.javbus-images", "detail.javbus-preview", + ]); + expect(migrateDisabledPlugins(["SubTitleCatPlugin", "detail.subtitle"])).toEqual(["external-bridge.subtitle"]); + + expect(() => runtime.register(defineFeature({ + id: "duplicate-owner", kind: "feature", disableable: true, sites: ["javdb"], routes: ["detail"], + startup: "on-demand", requires: [], contributes: ["detail.related"], providesCommands: [], activate: () => ({}), + }))).toThrow(/Duplicate contribution ownership/); + }); + + it("gives a legacy contribution a route-independent scope without activating its owner feature", async () => { + const diagnostics = new DiagnosticsService(), runtime = new FeatureRuntime({ + container: new DependencyContainer(), commands: new CommandRegistry(), diagnostics, + site: "javdb", route: "list", + }); + runtime.register(defineFeature({ + id: "detail", kind: "feature", disableable: true, sites: ["javdb"], routes: ["detail"], + startup: "on-demand", requires: [], contributes: ["detail.fc2"], providesCommands: [], activate: () => ({}), + })); + await expect(runtime.getScope("detail")).rejects.toThrow(/ineligible/); + const scope = await runtime.getContributionScope("detail", "detail.fc2", "Fc2Plugin"); + expect(scope.id).toBe("contribution:detail.fc2"); + expect(runtime.getActiveFeatureIds()).not.toContain("detail"); + }); + + it("disposes contribution scopes when the owning feature is disposed", async () => { + const diagnostics = new DiagnosticsService(), runtime = new FeatureRuntime({ + container: new DependencyContainer(), commands: new CommandRegistry(), diagnostics, + site: "javdb", route: "detail", + }); + runtime.register(defineFeature({ + id: "detail", kind: "feature", disableable: true, sites: ["javdb"], routes: ["detail"], + startup: "eager", requires: [], contributes: ["detail.fc2", "detail.related"], providesCommands: [], activate: () => ({}), + })); + const scope = await runtime.getContributionScope("detail", "detail.fc2", "Fc2Plugin"); + const activation = await runtime.activate("detail"); + activation.dispose(); + expect(scope.disposed).toBe(true); + // contribution scopes are recreated lazily after disposal (activate -> dispose -> activate cycle) + const scope2 = await runtime.getContributionScope("detail", "detail.fc2", "Fc2Plugin"); + expect(scope2.disposed).toBe(false); + expect(scope2).not.toBe(scope); + }); + + it("opens Settings through its registered owner without a DOM trigger", async () => { + const owner = vi.fn(async () => "opened"), cleanup = registerSettingsUiOwner(owner); + await expect(openSettingsUi("filter-panel")).resolves.toBe("opened"); + expect(owner).toHaveBeenCalledWith("filter-panel"); + cleanup(); + expect(() => openSettingsUi()).toThrow(/not ready/); + }); + + it("disposes all scope-owned resources and blocks stale generations", () => { + const target = new EventTarget(); + const listener = vi.fn(); + const observer = { disconnect: vi.fn() }; + const consumer = { release: vi.fn() }; + const scope = new LifecycleScope("test"); + scope.listen(target, "change", listener); + scope.ownObserver(observer); + scope.ownRequestConsumer(consumer); + const generation = scope.nextGeneration(); + expect(scope.canCommit(generation)).toBe(true); + scope.dispose(); + target.dispatchEvent(new Event("change")); + expect(listener).not.toHaveBeenCalled(); + expect(observer.disconnect).toHaveBeenCalledOnce(); + expect(consumer.release).toHaveBeenCalledOnce(); + expect(scope.snapshot()).toMatchObject({ listeners: 0, observers: 0, requestConsumers: 0, disposed: true }); + expect(scope.canCommit(generation)).toBe(false); + }); + + it("validates integration manifests and keeps ProviderRegistry focused", async () => { + expect(() => defineIntegration({ id: "bad", trustClass: "builtin-public", hosts: [], capabilities: [], requires: [], cachePolicy: "none", quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(); + expect(() => defineIntegration({ id: "bad-host", trustClass: "builtin-public", hosts: ["HTTPS://EXAMPLE.COM"], capabilities: ["movie.detail"], requires: [], cachePolicy: "none", quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(/host is invalid/); + expect(() => defineIntegration({ id: "bad-cache", trustClass: "builtin-public", hosts: ["example.com"], capabilities: ["movie.detail", "movie.images"], requires: [], cachePolicy: { "movie.detail": "none" }, quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(/cachePolicy mismatch/); + expect(() => defineIntegration({ id: "bad-deps", trustClass: "builtin-public", hosts: ["example.com"], capabilities: ["movie.detail"], requires: [SERVICE.http, SERVICE.http], cachePolicy: "none", quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(/duplicate tokens/); + expect(() => defineIntegration({ id: "bad-host-adapter", trustClass: "builtin-public", hosts: ["example.com"], capabilities: ["movie.detail"], requires: [], cachePolicy: "none", quality: "bronze", createClient() {}, createAdapter() {}, createHostAdapter: undefined })).toThrow(/createHostAdapter/); + const registry = new ProviderRegistry(); + registry.register({ id: "slow", capabilities: ["magnet"], priority: 1 }); + registry.register({ id: "fast", capabilities: ["magnet"], priority: 2, isAvailable: async () => true }); + expect((await registry.getAvailable("magnet", {})).map((provider) => provider.id)).toEqual(["fast", "slow"]); + registry.updateHealth("fast", { ok: true }); + expect(registry.getHealth("fast")).toMatchObject({ ok: true }); + }); + + it("derives transport cache policy from the requested manifest capability", async () => { + const request = vi.fn(async (options, scope) => ({ options, scope })); + const facade = createIntegrationRequestFacade({ request }, { + id: "fixture", capabilities: ["movie.detail", "movie.state"], + cachePolicy: { "movie.detail": "external-detail-v1", "movie.state": "none" }, + }); + const detail = await facade.request({ capability: "movie.detail", providerId: "fixture", method: "GET", url: "https://example.test/detail", cacheScope: "session", ttlMs: 1 }, "scope"); + const state = await facade.request("movie.state", { providerId: "fixture", method: "GET", url: "https://example.test/state", cacheScope: "public", ttlMs: 1 }, "scope"); + expect(detail.options).toMatchObject({ cacheScope: "public", ttlMs: 604800000, cacheNamespace: "external-detail-v1" }); + expect(state.options).toMatchObject({ cacheScope: "none", ttlMs: 0 }); + expect(detail.options).not.toHaveProperty("capability"); + expect(request).toHaveBeenNthCalledWith(1, expect.any(Object), "scope"); + + const sourceFacade = createIntegrationRequestFacade({ request }, { + id: "sources", capabilities: ["magnet.search"], cachePolicy: { "magnet.search": "source-configured" }, + }); + const post = await sourceFacade.request("magnet.search", { method: "POST", providerId: "btsow", url: "https://example.test/search", body: "{}" }, "scope"); + expect(post.options).toMatchObject({ cacheScope: "none", ttlMs: 0 }); + }); +}); diff --git a/tests/audit-remediation.test.js b/tests/audit-remediation.test.js index c024fd5b..33511c2a 100644 --- a/tests/audit-remediation.test.js +++ b/tests/audit-remediation.test.js @@ -1,12 +1,14 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; function loadClass(file, className, extras = {}) { - const source = readFileSync(join(process.cwd(), file), "utf8"), start = source.indexOf(`class ${className}`); + const source = readTestFile(join(process.cwd(), file), "utf8"), start = source.indexOf(`class ${className}`); const context = vm.createContext({ URL, encodeURIComponent, BasePlugin: class {}, i: (target, key, value) => (target[key] = value), l: false, r: false, o: "https://javdb.com/", ...extras }); vm.runInContext(`${source.slice(start)};globalThis.Exported=${className}`, context); return { Class: context.Exported, context }; @@ -25,22 +27,70 @@ describe("6.2.0 audit remediation", () => { expect(plugin.loader).toBeUndefined(); }); + it("owns AutoPage global listeners and startup timer in its live scope", async () => { + const dom = new JSDOM('
    ', { url: "https://javdb.com/" }); + const add = vi.spyOn(dom.window, "addEventListener"), remove = vi.spyOn(dom.window, "removeEventListener"); + const { Class } = loadClass("src/plugins/status/auto-page.js", "AutoPagePlugin", { + window: dom.window, document: dom.window.document, requestAnimationFrame: callback => callback(), setTimeout: vi.fn(() => 1), + storageManager: { getSetting: vi.fn().mockResolvedValue("yes") }, _: "yes", C: "no", clog: { error: vi.fn() }, + LifecycleScope, + }); + const plugin = new Class(); + plugin.shouldDisablePaging = vi.fn().mockResolvedValue(false); + plugin.getSelector = () => ({ boxSelector: "#list", nextPageSelector: ".next" }); + plugin.getRuntimeService = name => "settings" === name ? { snapshot: () => ({ autoPage: "yes" }) } : "scope" === name ? async () => ({ addCleanup: () => {} }) : {}; + plugin.checkLoad = vi.fn(); + await plugin.start(); + const live = plugin.liveScope; + expect(live.snapshot().listeners).toBe(1); + expect(add).toHaveBeenCalledWith("scroll", expect.any(Function), undefined); + plugin.stop(); + expect(live.snapshot()).toMatchObject({ listeners: 0, disposed: true }); + expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function), undefined); + }); + it("binds OtherSite settings idempotently and recovers malformed storage", () => { const dom = new JSDOM('
    '), $ = jqueryFactory(dom.window), warn = vi.fn(); const storage = new Map([["jhs_enabled_sites", "broken-json"]]); + const storageService = { getLocal: key => storage.get(key) ?? null, setLocal: (key, value) => storage.set(key, value) }; const { Class } = loadClass("src/plugins/external-search/other-site.js", "OtherSitePlugin", { - window: dom.window, document: dom.window.document, $, localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }, clog: { warn }, normalizeCarNum: value => value + window: dom.window, document: dom.window.document, $, clog: { warn }, normalizeCarNum: value => value }); const plugin = new Class(); + plugin.getRuntimeService = name => "storage" === name ? storageService : {}; expect(Array.from(plugin.getEnabledSites())).toEqual(Array.from(plugin.siteConfigs, site => site.id)); expect(warn).toHaveBeenCalledOnce(); + plugin.saveEnabledSites(["javDbBtn"]); + expect(storage.get("jhs_enabled_sites")).toBe('["javDbBtn"]'); plugin.setupEventListeners(); plugin.setupEventListeners(); $("#settingSiteBtn").trigger("click"); expect($("#settingsArea").hasClass("jhs-is-hidden")).toBe(false); }); + it("loads external-site definitions through MovieIdentityService", async () => { + const { Class } = loadClass("src/plugins/external-search/other-site.js", "OtherSitePlugin", { normalizeCarNum: value => value }); + const plugin = new Class(); + plugin.getSettingCache = vi.fn(async () => ({ javBusUrl: "configured" })); + const externalSites = vi.fn(() => [{ id: "javBusBtn", baseUrl: "normalized" }]); + plugin.getRuntimeService = () => ({ externalSites }); + await expect(plugin.getSiteConfigs()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "javBusBtn", baseUrl: "normalized" })])); + expect(externalSites).toHaveBeenCalledWith({ javBusUrl: "configured" }); + }); + + it("builds the DMM external link without shadowing its site config", async () => { + const dom = new JSDOM(''), $ = jqueryFactory(dom.window); + const { Class } = loadClass("src/plugins/external-search/other-site.js", "OtherSitePlugin", { + window: dom.window, document: dom.window.document, $, normalizeCarNum: value => value + }); + const plugin = new Class(), searchUrl = vi.fn(() => "https://www.dmm.co.jp/search/ABC-1"); + plugin.getRuntimeService = name => "movie" === name ? { searchUrl } : { getLocal: () => null }; + await expect(plugin.handleSite("ABC-1", { id: "fanzaBtn", providerId: "dmm", noHandle: true }, { root: $(dom.window.document), configs: [], isActive: () => true })).resolves.toBeUndefined(); + expect(searchUrl).toHaveBeenCalledWith("dmm", { carNum: "ABC-1" }); + expect($("[data-jhs-site-id='fanzaBtn']").attr("href")).toBe("https://www.dmm.co.jp/search/ABC-1"); + }); + it("keeps all JHS UI layout decisions on mobileMode", () => { - const setting = readFileSync(join(process.cwd(), "src/plugins/backup/setting.js"), "utf8"), search = readFileSync(join(process.cwd(), "src/plugins/avatar/search-by-image.js"), "utf8"), mobile = readFileSync(join(process.cwd(), "src/plugins/status/mobile-bottom-bar.js"), "utf8"); + const setting = readTestFile(join(process.cwd(), "src/plugins/backup/setting.js"), "utf8"), search = readTestFile(join(process.cwd(), "src/plugins/avatar/search-by-image.js"), "utf8"), mobile = readTestFile(join(process.cwd(), "src/plugins/status/mobile-bottom-bar.js"), "utf8"); expect(setting).not.toContain("utils.isMobile()"); expect(search).not.toContain("utils.isMobile()"); expect(search).toContain("utils.isMobileMode()"); diff --git a/tests/backup-credential-policy.test.js b/tests/backup-credential-policy.test.js new file mode 100644 index 00000000..ed0f4afd --- /dev/null +++ b/tests/backup-credential-policy.test.js @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { mergePortableSettings, selectPortableSettings } from "../src/core/migration.js"; + +describe("portable backup credential policy", () => { + it("excludes installation-local trust and WebDAV credentials without mutating settings", () => { + const settings = { themeMode: "dark", webDavUrl: "https://dav.example", webDavPassword: "AES:source", trustedLocalOrigins: ["http://127.0.0.1:5244"] }; + expect(selectPortableSettings(settings)).toEqual({ themeMode: "dark" }); + expect(settings).toHaveProperty("webDavPassword", "AES:source"); + }); + + it("keeps the destination installation's local fields during restore", () => { + const imported = { themeMode: "dark", webDavUrl: "https://dav-a.example", webDavPassword: "AES:source", trustedLocalOrigins: ["http://source.local"] }; + expect(mergePortableSettings({ webDavUrl: "https://dav-a.example", webDavPassword: "AES:destination", trustedLocalOrigins: ["http://destination.local"] }, imported)).toEqual({ + themeMode: "dark", webDavUrl: "https://dav-a.example", webDavPassword: "AES:destination", trustedLocalOrigins: ["http://destination.local"], + }); + }); + + it("does not introduce a credential when the destination has none", () => { + expect(mergePortableSettings({}, { webDavPassword: "AES:source", themeMode: "light" })).toEqual({ themeMode: "light" }); + }); +}); diff --git a/tests/backup-cross-install.test.js b/tests/backup-cross-install.test.js new file mode 100644 index 00000000..a0c60e99 --- /dev/null +++ b/tests/backup-cross-install.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { StorageManager } from "../src/core/storage.js"; + +function createStorage(initialSetting) { + const data = new Map([["data_version", 2], ["setting", { ...initialSetting }]]); + const storage = Object.create(StorageManager.prototype); + storage.car_list_key = "car_list"; + storage.favorite_actresses_key = "favorite_actresses"; + storage.setting_key = "setting"; + storage.forage = { + getItem: vi.fn(async (key) => data.get(key)), + setItem: vi.fn(async (key, value) => data.set(key, value)), + removeItem: vi.fn(async (key) => data.delete(key)), + }; + storage.getDataVersion = vi.fn(async () => data.get("data_version") || 0); + storage.setDataVersion = vi.fn(async (value) => data.set("data_version", value)); + storage.getSetting = vi.fn(async () => data.get("setting") || {}); + storage._setItemAndInvalidate = vi.fn(async (key, value) => data.set(key, value)); + storage._invalidateCache = vi.fn(); + storage._withCrossTabLock = vi.fn(async (_name, callback) => callback()); + storage.createSnapshot = vi.fn(async () => null); + storage.data = data; + return storage; +} + +describe("portable backup cross-install restore", () => { + it("exports A data and restores it on B while retaining B WebDAV credentials", async () => { + vi.stubGlobal("window", { stateService: null }); + const source = createStorage({ webDavUrl: "https://dav-a.example", webDavUsername: "alice", webDavPassword: "AES:source" }); + const destination = createStorage({ webDavUrl: "https://dav-b.example", webDavUsername: "bob", webDavPassword: "AES:destination" }); + const backup = await source.exportData(); + + expect(backup.setting).toEqual({}); + await destination.importData(backup); + expect(destination.data.get("setting")).toEqual({ webDavUrl: "https://dav-b.example", webDavUsername: "bob", webDavPassword: "AES:destination" }); + }); + + it("preserves a destination credential when importing a legacy backup that contains one", async () => { + vi.stubGlobal("window", { stateService: null }); + const destination = createStorage({ webDavPassword: "AES:destination", webDavUrl: "https://dav-b.example" }); + await destination.importData({ data_version: 2, setting: { webDavPassword: "AES:source", webDavUrl: "https://dav-a.example" } }); + expect(destination.data.get("setting")).toEqual({ webDavPassword: "AES:destination", webDavUrl: "https://dav-b.example" }); + }); +}); diff --git a/tests/backup-loading.test.js b/tests/backup-loading.test.js new file mode 100644 index 00000000..96bde271 --- /dev/null +++ b/tests/backup-loading.test.js @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/core/credential-crypto.js", () => ({ + decryptCredential: vi.fn(async () => "secret"), + decryptPortableBackup: vi.fn(), + encryptPortableBackup: vi.fn(async value => `encrypted:${value}`), +})); + +import { backupDataByWebDav } from "../src/plugins/backup/setting-backup.js"; + +describe("backup loading lifecycle", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("shows loading before exporting and keeps it until upload completes", async () => { + const close = vi.fn(), loading = vi.fn(() => ({ close })), exportData = vi.fn(async () => ({ records: [1] })), backup = vi.fn(async () => {}); + vi.stubGlobal("loading", loading); + vi.stubGlobal("storageManager", { + getSetting: vi.fn(async () => ({ webDavUrl: "https://dav.example", webDavUsername: "user", webDavPassword: "encrypted" })), + exportData, + }); + vi.stubGlobal("utils", { getNowStr: vi.fn(() => "2026_08_28") }); + vi.stubGlobal("show", { ok: vi.fn(), error: vi.fn() }); + vi.stubGlobal("clog", { error: vi.fn() }); + await backupDataByWebDav("JHS", { createClient: vi.fn(() => ({ backup })) }); + expect(loading.mock.invocationCallOrder[0]).toBeLessThan(exportData.mock.invocationCallOrder[0]); + expect(backup.mock.invocationCallOrder[0]).toBeLessThan(close.mock.invocationCallOrder[0]); + }); +}); diff --git a/tests/batch-contract.test.js b/tests/batch-contract.test.js new file mode 100644 index 00000000..1bea3822 --- /dev/null +++ b/tests/batch-contract.test.js @@ -0,0 +1,61 @@ +import { readTestFile } from "./helpers/read-test-file.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const listPage = readTestFile(join(process.cwd(), "src/plugins/status/list-page.js"), "utf8"); +const listButtons = readTestFile(join(process.cwd(), "src/plugins/status/list-page-button.js"), "utf8"); +const blacklist = readTestFile(join(process.cwd(), "src/plugins/blacklist/blacklist.js"), "utf8"); +const scanner = readTestFile(join(process.cwd(), "src/features/list/batch-scanner.js"), "utf8"); + +describe("batch action contract (筛选后批量语义)", () => { + it("scans every page from the first page through the shared scanner with the frozen filter snapshot", () => { + expect(listPage).toContain("scanAllPages({"); + expect(listPage).toContain("startDom: root ? $(root) : $(document)"); + expect(listPage).toContain("currentUrl: isOwnedRankingPage ? null : (root ? null : window.location.href)"); + expect(listPage).toContain('firstPageUrl: isOwnedRankingPage ? null : (root ? null : (this.getRuntimeService("host")?.resolveFirstPageUrl?.(window.location.href) ?? window.location.href))'); + expect(blacklist).toContain('firstPageUrl: root ? null : (this.getRuntimeService("host")?.resolveFirstPageUrl?.(window.location.href) ?? window.location.href)'); + expect(listPage).toContain("itemSelector: this.getSelector().requestDomItemSelector"); + expect(listPage).toContain('evaluateListItem({ carNum: item.carNum, title: item.title || "" }, context, { filter: normalized })'); + }); + + it("confirms with filter-aware copy for all and other filters", () => { + expect(listPage).toContain("将处理当前搜索全部分页的所有作品,包括屏蔽项。"); + expect(listPage).toContain("将处理当前搜索全部分页中符合「"); + expect(blacklist).toContain("将处理当前搜索全部分页的所有作品(包括屏蔽项)并加入黑名单。"); + }); + + it("writes in chunks through StateService.batch patch instead of per-item transactions", () => { + expect(listPage).toMatch(/index \+= 75[\s\S]{0,220}state"\)\.patch\(chunk\.map\(\(item\) => item\.carNum\)/); + expect(blacklist).toMatch(/index \+= 75[\s\S]{0,220}state"\)\.patch\(chunk\.map\(\(item\) => item\.carNum\)/); + expect(listPage).not.toMatch(/for \(const element of items\)[\s\S]{0,200}state\.patch\(carNum/); + }); + + it("keeps the button entry points on the scope-based batch API with per-filter confirmation", () => { + expect(listButtons).toContain("batchSaveAllVideos?.(scope, h)"); + expect(listButtons).toContain("batchSaveAllVideos?.(scope, g)"); + expect(listButtons).toContain("buildBatchScope()"); + expect(listButtons).toContain('{ kind: "search", displayName: "当前搜索条件", recordName: "" }'); + expect(listButtons).not.toContain("一键收藏所有可见作品?"); + expect(listButtons).toContain("批量屏蔽"); + expect(listButtons).toContain("批量收藏"); + expect(listButtons).toContain("批量标记已下载"); + expect(listButtons).not.toContain('utils.q(n, "一键屏蔽视频列表?"'); + expect(listPage).toContain("async batchSaveAllVideos(scope, flag"); + expect(listPage).toContain('正在写入,无法取消'); + expect(listPage).toContain('#jhs-batch-cancel").prop("disabled", true'); + expect(scanner).toContain("matchesCurrentFilter === true"); + expect(scanner).toContain("isSamePageUrl(firstPageUrl, currentUrl)"); + }); + + it("runs every batch action through the shared Single Flight coordinator", () => { + expect(listPage).toContain("tryBeginBatchRun()"); + expect(listPage).toContain("isActiveBatchRun(run)"); + expect(listPage).toContain("endBatchRun(run)"); + expect(listPage).toContain("已有批量任务正在执行"); + expect(listPage).toContain("setBatchButtonsDisabled(true)"); + expect(listPage).toContain("setBatchButtonsDisabled(false)"); + expect(blacklist).toContain("tryBeginBatchRun()"); + expect(blacklist).toContain("已有批量任务正在执行"); + expect(blacklist).toContain("if (isActiveBatchRun(run)) endBatchRun(run)"); + }); +}); diff --git a/tests/batch-coordinator.test.js b/tests/batch-coordinator.test.js new file mode 100644 index 00000000..49b230d4 --- /dev/null +++ b/tests/batch-coordinator.test.js @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { endBatchRun, isActiveBatchRun, isBatchRunActive, isBatchRunCancelled, requestCancelBatchRun, tryBeginBatchRun } from "../src/features/list/batch-coordinator.js"; + +describe("batch Single Flight coordinator", () => { + it("accepts exactly one run at a time", () => { + const first = tryBeginBatchRun(); + expect(first).not.toBeNull(); + expect(isBatchRunActive()).toBe(true); + expect(tryBeginBatchRun()).toBeNull(); + expect(tryBeginBatchRun()).toBeNull(); + endBatchRun(first); + expect(isBatchRunActive()).toBe(false); + }); + + it("allows a new run after the previous one ends", () => { + const first = tryBeginBatchRun(); + endBatchRun(first); + const second = tryBeginBatchRun(); + expect(second).not.toBeNull(); + expect(isActiveBatchRun(second)).toBe(true); + endBatchRun(second); + }); + + it("a stale run can never clear a newer run", () => { + const first = tryBeginBatchRun(); + endBatchRun(first); + const second = tryBeginBatchRun(); + // 旧任务的 finally 再次结束自己的 run:不得影响新 run。 + endBatchRun(first); + expect(isActiveBatchRun(second)).toBe(true); + expect(isBatchRunActive()).toBe(true); + endBatchRun(second); + expect(isBatchRunActive()).toBe(false); + }); +}); + +describe("batch cancel single-flight", () => { + it("requestCancel marks the run but does not release the active slot", () => { + const run = tryBeginBatchRun(); + expect(run).not.toBeNull(); + requestCancelBatchRun(run); + expect(isBatchRunCancelled(run)).toBe(true); + expect(isBatchRunActive()).toBe(true); + expect(tryBeginBatchRun()).toBeNull(); + // Only the real finally may release the slot. + endBatchRun(run); + expect(isBatchRunActive()).toBe(false); + }); + + it("a stale cancel request cannot mark a newer run", () => { + const first = tryBeginBatchRun(); + endBatchRun(first); + const second = tryBeginBatchRun(); + requestCancelBatchRun(first); + expect(isBatchRunCancelled(second)).toBe(false); + expect(isActiveBatchRun(second)).toBe(true); + endBatchRun(second); + }); +}); diff --git a/tests/batch-scanner.test.js b/tests/batch-scanner.test.js new file mode 100644 index 00000000..0c179a4c --- /dev/null +++ b/tests/batch-scanner.test.js @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import jquery from "jquery"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +beforeAll(() => { globalThis.$ = jquery; }); +import { scanAllPages } from "../src/features/list/batch-scanner.js"; +import { createListEvaluationContext, evaluateListItem } from "../src/features/list/list-evaluator.js"; + +function pageDom(body) { + const dom = new DOMParser().parseFromString(`${body}`, "text/html"); + return jquery(dom); +} + +function makeContext(cars) { + const carMap = new Map(Object.entries(cars).map(([carNum, stateFlags]) => [carNum, { stateFlags }])); + return createListEvaluationContext({ carMap, titleKeywords: [ "禁" ] }); +} + +describe("cross-page batch scanner", () => { + it("collects only records matching the current filter across all pages", async () => { + const page1 = pageDom('
    '); + const page2Html = '
    '; + const page3Html = '
    '; + const fetchHtml = vi.fn(async (url) => url === "/p2" ? page2Html : page3Html); + const context = makeContext({ "A-002": { favorite: true }, "A-004": { favorite: true } }); + const records = await scanAllPages({ + startDom: page1, + itemSelector: ".item", + nextPageSelector: ".next", + fetchHtml, + parseItem: (item) => ({ carNum: item.attr("data-id"), title: "" }), + evaluate: (item) => evaluateListItem({ carNum: item.carNum, title: item.title }, context, { filter: "favorite" }), + pageDelayMs: 0, + }); + expect(fetchHtml).toHaveBeenCalledTimes(2); + expect(records.map((item) => item.carNum)).toEqual([ "A-002", "A-004" ]); + }); + + it("treats all as the true full set including hard-hidden records", async () => { + const page1 = pageDom('
    '); + const context = makeContext({ "B-001": { blocked: true } }); + const records = await scanAllPages({ + startDom: page1, itemSelector: ".item", nextPageSelector: ".next", + fetchHtml: vi.fn(), parseItem: (item) => ({ carNum: item.attr("data-id"), title: "禁播合集" }), + evaluate: (item) => evaluateListItem({ carNum: item.carNum, title: item.title }, context, { filter: "all" }), + pageDelayMs: 0, + }); + expect(records.map((item) => item.carNum)).toEqual([ "B-001", "B-002" ]); + }); + + it("deduplicates repeated car numbers across pages", async () => { + const page1 = pageDom('
    '); + const context = makeContext({ "C-001": {} }); + const records = await scanAllPages({ + startDom: page1, itemSelector: ".item", nextPageSelector: ".next", + fetchHtml: async () => '
    ', parseItem: (item) => ({ carNum: item.attr("data-id"), title: "" }), + evaluate: (item) => evaluateListItem({ carNum: item.carNum, title: item.title }, context, { filter: "waitCheck" }), + pageDelayMs: 0, + }); + expect(records).toHaveLength(1); + }); + + it("stops on cancellation and reports progress", async () => { + const page1 = pageDom('
    '); + let cancelled = false; + const context = makeContext({ "D-001": {}, "D-002": {} }); + const progress = vi.fn(); + const records = await scanAllPages({ + startDom: page1, itemSelector: ".item", nextPageSelector: ".next", + fetchHtml: async () => '
    ', parseItem: (item) => ({ carNum: item.attr("data-id"), title: "" }), + evaluate: (item) => evaluateListItem({ carNum: item.carNum, title: item.title }, context, { filter: "waitCheck" }), + isCancelled: () => cancelled, + onProgress: progress, + pageDelayMs: 0, + }); + expect(records).toHaveLength(2); + cancelled = true; + const cancelledRecords = await scanAllPages({ + startDom: page1, itemSelector: ".item", nextPageSelector: ".next", + fetchHtml: vi.fn(), parseItem: (item) => ({ carNum: item.attr("data-id"), title: "" }), + evaluate: () => ({ matchesCurrentFilter: true }), + isCancelled: () => cancelled, + pageDelayMs: 0, + }); + expect(cancelledRecords).toHaveLength(0); + expect(progress).toHaveBeenCalled(); + }); + + it("starts from the first page even when the user is on a later page", async () => { + const currentPage = pageDom('
    '); + const firstPageHtml = '
    '; + const page2Html = '
    '; + const page3Html = '
    '; + const page4Html = '
    '; + const page5Html = '
    '; + const fetchHtml = vi.fn(async (url) => ({ "/page/1": firstPageHtml, "/p2": page2Html, "/p3": page3Html, "/p4": page4Html, "/p5": page5Html }[url])); + const context = makeContext({}); + const records = await scanAllPages({ + startDom: currentPage, + currentUrl: "/page/3", + firstPageUrl: "/page/1", + itemSelector: ".item", + nextPageSelector: ".next", + fetchHtml, + parseItem: (item) => ({ carNum: item.attr("data-id"), title: "" }), + evaluate: (item) => evaluateListItem({ carNum: item.carNum, title: item.title }, context, { filter: "waitCheck" }), + pageDelayMs: 0, + }); + expect(fetchHtml.mock.calls.map((call) => call[0])).toEqual([ "/page/1", "/p2", "/p3", "/p4", "/p5" ]); + expect(records.map((item) => item.carNum)).toEqual([ "P1-01", "P2-01", "P3-01", "P4-01", "P5-01" ]); + }); + + it("reuses the current DOM when already on the first page", async () => { + const page1 = pageDom('
    '); + const fetchHtml = vi.fn(async () => '
    '); + const context = makeContext({}); + const records = await scanAllPages({ + startDom: page1, + currentUrl: "/page/1", + firstPageUrl: "/page/1", + itemSelector: ".item", + nextPageSelector: ".next", + fetchHtml, + parseItem: (item) => ({ carNum: item.attr("data-id"), title: "" }), + evaluate: (item) => evaluateListItem({ carNum: item.carNum, title: item.title }, context, { filter: "waitCheck" }), + pageDelayMs: 0, + }); + expect(fetchHtml).toHaveBeenCalledTimes(1); + expect(records.map((item) => item.carNum)).toEqual([ "A-001", "A-002" ]); + }); +}); diff --git a/tests/batch-scope.test.js b/tests/batch-scope.test.js new file mode 100644 index 00000000..02408aac --- /dev/null +++ b/tests/batch-scope.test.js @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { isSamePageUrl } from "../src/features/list/batch-scope.js"; +import { JavBusHostAdapter } from "../src/platform/hosts/javbus-host-adapter.js"; +import { JavDbHostAdapter } from "../src/platform/hosts/javdb-host-adapter.js"; + +// resolveFirstPageUrl 不触碰 DOM;node 环境显式传 null,避免默认参数引用 document/window。 +const javdb = new JavDbHostAdapter(/** @type {any} */ (null), /** @type {any} */ (null)); +const javbus = new JavBusHostAdapter(/** @type {any} */ (null), /** @type {any} */ (null)); + +describe("resolveFirstPageUrl (JavDbHostAdapter)", () => { + it("drops the page query param and keeps other search conditions", () => { + expect(javdb.resolveFirstPageUrl("https://javdb.com/actors/abc?page=3&v=1")) + .toBe("https://javdb.com/actors/abc?v=1"); + expect(javdb.resolveFirstPageUrl("https://javdb.com/actors/abc?q=test&page=2")) + .toBe("https://javdb.com/actors/abc?q=test"); + expect(javdb.resolveFirstPageUrl("https://javdb.com/actors/abc?page=1")) + .toBe("https://javdb.com/actors/abc"); + expect(javdb.resolveFirstPageUrl("https://javdb.com/search?q=abc&page=5&f=all")) + .toBe("https://javdb.com/search?q=abc&f=all"); + }); +}); + +describe("resolveFirstPageUrl (JavBusHostAdapter)", () => { + it("strips /page/N", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/page/3")) + .toBe("https://www.javbus.com/"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/page/3?x=1")) + .toBe("https://www.javbus.com/?x=1"); + }); + + it("strips /star//N and other paged list prefixes", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/star/abc/3")) + .toBe("https://www.javbus.com/star/abc"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/genre/xyz/2")) + .toBe("https://www.javbus.com/genre/xyz"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/maker/m/4")) + .toBe("https://www.javbus.com/maker/m"); + }); + + it("strips search/director/studio/label paged routes", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/search/ABC/3")) + .toBe("https://www.javbus.com/search/ABC"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/director/x/3")) + .toBe("https://www.javbus.com/director/x"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/studio/x/3")) + .toBe("https://www.javbus.com/studio/x"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/label/x/3")) + .toBe("https://www.javbus.com/label/x"); + }); + + it("handles site/language prefixed paths such as /en/...", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/en/search/ABC/3")) + .toBe("https://www.javbus.com/en/search/ABC"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/en/page/3")) + .toBe("https://www.javbus.com/en"); + }); + + it("leaves unpaged and unknown URLs untouched", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/star/abc")) + .toBe("https://www.javbus.com/star/abc"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/abc-123")) + .toBe("https://www.javbus.com/abc-123"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/search/ABC")) + .toBe("https://www.javbus.com/search/ABC"); + expect(javbus.resolveFirstPageUrl("not-a-url")) + .toBe("not-a-url"); + }); +}); + +describe("isSamePageUrl", () => { + it("ignores trailing slashes and hashes", () => { + expect(isSamePageUrl("https://javdb.com/actors/abc", "https://javdb.com/actors/abc/")).toBe(true); + expect(isSamePageUrl("https://javdb.com/actors/abc#top", "https://javdb.com/actors/abc")).toBe(true); + }); + + it("treats different queries as different pages", () => { + expect(isSamePageUrl("https://javdb.com/actors/abc?page=3", "https://javdb.com/actors/abc")).toBe(false); + expect(isSamePageUrl("https://javdb.com/actors/abc?v=1", "https://javdb.com/actors/abc?v=2")).toBe(false); + }); +}); diff --git a/tests/blacklist-filter-ui.test.js b/tests/blacklist-filter-ui.test.js index 0cfea266..4e079d80 100644 --- a/tests/blacklist-filter-ui.test.js +++ b/tests/blacklist-filter-ui.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,7 +6,8 @@ import jqueryFactory from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it, vi } from "vitest"; -const source = readFileSync(join(process.cwd(), "src/plugins/blacklist/blacklist.js"), "utf8"); +const source = readTestFile(join(process.cwd(), "src/plugins/blacklist/blacklist.js"), "utf8"); +const tableSource = readTestFile(join(process.cwd(), "src/ui/table/create-jhs-table.js"), "utf8"); function createPlugin() { const dom = new JSDOM(``, { url: "https://javdb.com/" }), $ = jqueryFactory(dom.window); @@ -18,6 +20,7 @@ function createPlugin() { const JhsSelect = { setValue: vi.fn((target, value) => $(target).val(value)) }; let tabulatorOptions; function Tabulator(selector, options) { tabulatorOptions = options; } + const createJhsTable = (Runtime, selector, options) => new Runtime(selector, options); const context = vm.createContext({ console, Object, Array, Map, Set, Promise, Date, URL, $, BasePlugin, storageManager, JhsSelect, i: (target, key, value) => target[key] = value, @@ -25,7 +28,7 @@ function createPlugin() { B: "actor", P: "actress", D: "censored", A: "uncensored", T: "javdb", I: "javbus", l: false, r: true, window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, normalizeHttpUrl: value => { try { const url = new URL(String(value), dom.window.location.href); return ["http:", "https:"].includes(url.protocol) ? url.href : null; } catch { return null; } }, parseNumberSetting: (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback, - renderStateView: vi.fn(), utils: {}, show: {}, clog: {}, navigator: {}, jhsEventBus: {}, stateService: {}, gmHttp: {}, Tabulator + renderStateView: vi.fn(), utils: {}, show: {}, clog: {}, navigator: {}, jhsEventBus: {}, stateService: {}, gmHttp: {}, Tabulator, createJhsTable }); vm.runInContext(`${source};globalThis.TestPlugin=BlacklistPlugin`, context); const plugin = new context.TestPlugin; @@ -36,12 +39,14 @@ function createPlugin() { describe("blacklist combined filters", () => { it("combines search, role, status and URL category without clearing sibling filters", async () => { const { plugin, $ } = createPlugin(); - $("#searchValue").val("别名"), $("#dataType").val("actor"), $("#statusType").val("stop"), $("#urlType").val("hasT"); + // the dialog owns a scoped root (set by openBlacklistDialog); filters live under it + plugin.blacklistRoot = $("body"); + plugin.blacklistRoot.find("#searchValue").val("别名"), plugin.blacklistRoot.find("#dataType").val("actor"), plugin.blacklistRoot.find("#statusType").val("stop"), plugin.blacklistRoot.find("#urlType").val("hasT"); const result = await plugin.getTableData(); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ starId: "a-1", count: 1, isUnCheck: true }); - expect($("#statusType").val()).toBe("stop"); - expect($("#urlType").val()).toBe("hasT"); + expect(plugin.blacklistRoot.find("#statusType").val()).toBe("stop"); + expect(plugin.blacklistRoot.find("#urlType").val()).toBe("hasT"); }); it("keeps one table instance across empty and non-empty reloads", async () => { @@ -63,9 +68,10 @@ describe("blacklist reset and pagination contracts", () => { expect(reset).toContain('search.val("")'); }); - it("exposes a real all-page selector and localized label", () => { - expect(source).toContain("paginationSizeSelector: [ 20, 50, 100, 1e3, !0 ]"); - expect(source).toContain('all: "全部"'); + it("does not expose Tabulator's blank all-page option", () => { + expect(source).toContain("paginationSizeSelector: [ 20, 50, 100, 1e3 ]"); + expect(source).not.toContain("paginationSizeSelector: [ 20, 50, 100, 1e3, !0 ]"); + expect(tableSource).toContain('all: "全部"'); expect(source).not.toContain("99999"); }); diff --git a/tests/blacklist-parser-boundary.test.js b/tests/blacklist-parser-boundary.test.js index de2487de..0d28dfe6 100644 --- a/tests/blacklist-parser-boundary.test.js +++ b/tests/blacklist-parser-boundary.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -10,27 +11,29 @@ const repoRoot = join(import.meta.dirname, ".."); function loadBlacklist(html, save = vi.fn(async () => {}), pageUrl = "https://javdb.com/actors/a") { const dom = new JSDOM(html, { url: pageUrl }), $ = jqueryFactory(dom.window); const gmHttp = { get: vi.fn(async () => '
    ') }; - const listPage = { findCarNumAndHref: element => ({ carNum: element.attr("data-car"), url: element.attr("data-url"), publishTime: element.attr("data-date") }) }; class BasePlugin { getSelector(site) { return "javbus" === site ? { boxSelector: ".masonry", itemSelector: ".masonry .item", requestDomItemSelector: "#waterfall .item", nextPageSelector: "#next" } : { boxSelector: ".movie-list", itemSelector: ".movie-list .item", requestDomItemSelector: ".movie-list .item", nextPageSelector: ".pagination-next" }; } - getBean(name) { return "ListPagePlugin" === name ? listPage : null; } + getBean() { return null; } + getRuntimeService(name) { return "scope" === name ? () => ({}) : {}; } } const context = vm.createContext({ console, URL, Date, window: dom.window, document: dom.window.document, $, BasePlugin, storageManager: { batchSaveBlacklistCarList: save }, - T: "javdb", I: "javbus", d: "filter", r: true, l: false, o: "", _: "yes", gmHttp, clog: { error: vi.fn(), log: vi.fn() }, show: { info: vi.fn(), ok: vi.fn() }, utils: { htmlTo$dom: source => $(new JSDOM(source, { url: pageUrl }).window.document) }, i: (target, key, value) => target[key] = value + T: "javdb", I: "javbus", d: "filter", r: true, l: false, o: "", _: "yes", requestHostPage: (_http, url) => gmHttp.get(String(url)), clog: { error: vi.fn(), log: vi.fn() }, show: { info: vi.fn(), ok: vi.fn() }, utils: { htmlTo$dom: source => $(new JSDOM(source, { url: pageUrl }).window.document) }, i: (target, key, value) => target[key] = value, + readListItem: element => ({ carNum: element.attr("data-car"), url: element.attr("data-url"), publishTime: element.attr("data-date") }) }); - const source = [ "src/core/feature-helpers.js", "src/parsers/third-party-parsers.js", "src/plugins/blacklist/blacklist.js" ].map(file => readFileSync(join(repoRoot, file), "utf8")).join("\n"); + const source = [ "src/core/feature-helpers.js", "src/integrations/host-list/parser.js", "src/plugins/blacklist/blacklist.js" ].map(file => readTestFile(join(repoRoot, file), "utf8")).join("\n"); vm.runInContext(`${source};globalThis.Plugin=BlacklistPlugin`, context); return { plugin: new context.Plugin, $page: $(dom.window.document), save, gmHttp }; } describe("blacklist parser boundaries", () => { it("uses the full-batch label for initial and refreshed tooltips", () => { - const source = readFileSync(join(repoRoot, "src/plugins/blacklist/blacklist.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/plugins/blacklist/blacklist.js"), "utf8"); expect(source).not.toContain("上次检测时间"); expect(source.match(/上次整批检测/g)).toHaveLength(2); + expect(source).not.toMatch(/\b(?:gmHttp|localStorage)\s*\./); }); it("rejects challenge, missing containers and empty pages with pagination", async () => { @@ -71,6 +74,7 @@ describe("blacklist parser boundaries", () => { .mockResolvedValueOnce({ nextPageLink: "https://www.javbus.com/star/a/2" }) .mockResolvedValueOnce({ nextPageLink: null }); await loaded.plugin.filterActorVideo("A", "a", loaded.$page, "javbus"); + expect(loaded.gmHttp.get).toHaveBeenCalledWith("https://www.javbus.com/star/a/2"); expect(loaded.plugin.parseAndSaveFilterInfo).toHaveBeenNthCalledWith(1, loaded.$page, "A", "a", "javbus"); expect(loaded.plugin.parseAndSaveFilterInfo).toHaveBeenNthCalledWith(2, expect.anything(), "A", "a", "javbus"); }); diff --git a/tests/bootstrap-compatibility.test.js b/tests/bootstrap-compatibility.test.js new file mode 100644 index 00000000..49fda58d --- /dev/null +++ b/tests/bootstrap-compatibility.test.js @@ -0,0 +1,128 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { JSDOM } from "jsdom"; +import { describe, expect, it, vi } from "vitest"; +import { attachCompatibilityFacade } from "../src/app/compatibility-facade.js"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; + +const bootstrap = readFileSync(join(import.meta.dirname, "../src/app/bootstrap.js"), "utf8"); +const logger = readFileSync(join(import.meta.dirname, "../src/core/logger.js"), "utf8"); +const appContext = readFileSync(join(import.meta.dirname, "../src/app/create-app-context.js"), "utf8"); + +describe("bootstrap compatibility P0", () => { + it("compatibility facade rejects when clog is missing", () => { + expect(() => attachCompatibilityFacade({ + pluginManager: {}, utils: {}, gmHttp: {}, storageManager: {}, stateService: {}, jhsEventBus: {}, + show: () => {}, loading: () => {}, + }, {})).toThrow(/Compatibility facade is missing clog/); + }); + + it("copies logger runtime values into the target instead of reading globalThis", () => { + const clog = { log() {} }; + const show = { ok() {}, error() {}, info() {} }; + const loading = () => ({ close() {} }); + const target = {}; + attachCompatibilityFacade({ pluginManager: {}, utils: {}, gmHttp: {}, storageManager: {}, stateService: {}, jhsEventBus: {}, clog, show, loading }, target); + expect(target.clog).toBe(clog); + expect(target.show).toBe(show); + expect(target.loading).toBe(loading); + }); + + it("bootstrap passes logger.clog/show/loading, not globalThis.*", () => { + expect(bootstrap).toContain("clog: logger.clog"); + expect(bootstrap).toContain("show: logger.show"); + expect(bootstrap).toContain("loading: logger.loading"); + expect(bootstrap).not.toContain("clog: globalThis.clog"); + expect(bootstrap).not.toContain("show: globalThis.show"); + expect(bootstrap).not.toContain("loading: globalThis.loading"); + }); + + it("attaches compatibility before FeatureRuntime.start", () => { + const attachIndex = bootstrap.indexOf("attachCompatibilityFacade({"); + const startIndex = bootstrap.indexOf("await context.registries.features.start();"); + expect(attachIndex).toBeGreaterThan(-1); + expect(startIndex).toBeGreaterThan(attachIndex); + }); + + it("logger runtime is frozen and exposes clog/show/loading before mirroring to window", () => { + expect(logger).toContain("loggerRuntime = Object.freeze({ loading: loggerLoading, show: loggerShow, clog: loggerClog })"); + expect(logger).toContain("window.clog = loggerRuntime.clog"); + expect(logger).not.toContain("await storageManager.getSetting"); + expect(logger).not.toContain("async function()"); + }); + + it("refreshes settings on pageshow/BFCache restore", () => { + expect(appContext).toContain('rootScope.listen(window, "pageshow"'); + expect(appContext).toContain("runtime.legacyStorage?.invalidateSettingCache?.()"); + expect(appContext).toContain("await settings.refresh()"); + }); + + it("exposes clog synchronously before compatibility attach", async () => { + vi.resetModules(); + const dom = new JSDOM("", { url: "https://javdb.com/" }); + const scope = new LifecycleScope("test-logger-runtime"); + const previous = { + window: globalThis.window, + document: globalThis.document, + localStorage: globalThis.localStorage, + unsafeWindow: globalThis.unsafeWindow, + }; + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.localStorage = dom.window.localStorage; + globalThis.unsafeWindow = dom.window; + try { + const { initializeLoggerRuntime } = await import("../src/core/logger.js?test=" + Date.now()); + const runtime = initializeLoggerRuntime(scope, { clogMsgCount: 2000 }); + expect(runtime.clog).toBeTruthy(); + expect(typeof runtime.clog.log).toBe("function"); + expect(runtime.show?.error).toBeTypeOf("function"); + expect(typeof runtime.loading).toBe("function"); + const handle = runtime.loading(), overlay = dom.window.document.querySelector(".loading-container"); + expect(overlay?.getAttribute("role")).toBe("status"); + expect(overlay?.getAttribute("aria-live")).toBe("polite"); + expect(overlay?.getAttribute("aria-busy")).toBe("true"); + expect(overlay?.getAttribute("aria-label")).toBe("处理中"); + handle.close(); + expect(dom.window.document.querySelector(".loading-container")).toBeNull(); + } finally { + globalThis.window = previous.window; + globalThis.document = previous.document; + globalThis.localStorage = previous.localStorage; + globalThis.unsafeWindow = previous.unsafeWindow; + scope.dispose(); + } + }); + + it("does not depend on async storage hydration to create clog", async () => { + vi.resetModules(); + const dom = new JSDOM("", { url: "https://javdb.com/" }); + const scope = new LifecycleScope("test-logger-no-storage"); + const previous = { + window: globalThis.window, + document: globalThis.document, + localStorage: globalThis.localStorage, + unsafeWindow: globalThis.unsafeWindow, + storageManager: globalThis.storageManager, + }; + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.localStorage = dom.window.localStorage; + globalThis.unsafeWindow = dom.window; + const getSetting = vi.fn(() => new Promise(() => {})); + globalThis.storageManager = { getSetting }; + try { + const { initializeLoggerRuntime } = await import("../src/core/logger.js?no-storage=" + Date.now()); + const runtime = initializeLoggerRuntime(scope, { clogMsgCount: 2000 }); + expect(runtime.clog).toBeTruthy(); + expect(getSetting).not.toHaveBeenCalled(); + } finally { + globalThis.window = previous.window; + globalThis.document = previous.document; + globalThis.localStorage = previous.localStorage; + globalThis.unsafeWindow = previous.unsafeWindow; + globalThis.storageManager = previous.storageManager; + scope.dispose(); + } + }); +}); diff --git a/tests/browser/fixtures/javbus-detail.html b/tests/browser/fixtures/javbus-detail.html new file mode 100644 index 00000000..5aa6f4ec --- /dev/null +++ b/tests/browser/fixtures/javbus-detail.html @@ -0,0 +1,12 @@ + + +ABC-123 Fixture + +
    +

    ABC-123 Browser Fixture

    +

    識別碼: ABC-123

    + + +
    + + \ No newline at end of file diff --git a/tests/browser/fixtures/javbus-list.html b/tests/browser/fixtures/javbus-list.html new file mode 100644 index 00000000..e2b7f4f2 --- /dev/null +++ b/tests/browser/fixtures/javbus-list.html @@ -0,0 +1,19 @@ + + +JavBus list fixture + +
    + +
    + + diff --git a/tests/browser/fixtures/javdb-detail-interactions.html b/tests/browser/fixtures/javdb-detail-interactions.html new file mode 100644 index 00000000..618e08d9 --- /dev/null +++ b/tests/browser/fixtures/javdb-detail-interactions.html @@ -0,0 +1,18 @@ + + +JavDB fixture + +
    +
    ABC-123
    +
    ABC-123

    ABC-123 Browser Fixture

    + +
    +
    Fixture reviews
    +
    + + diff --git a/tests/browser/fixtures/javdb-detail-panels.html b/tests/browser/fixtures/javdb-detail-panels.html new file mode 100644 index 00000000..8afe4d1e --- /dev/null +++ b/tests/browser/fixtures/javdb-detail-panels.html @@ -0,0 +1,14 @@ + +Detail panels fixture + +
    +
    ABC-123
    +
    diff --git a/tests/browser/fixtures/javdb-detail.html b/tests/browser/fixtures/javdb-detail.html new file mode 100644 index 00000000..abc29e8f --- /dev/null +++ b/tests/browser/fixtures/javdb-detail.html @@ -0,0 +1,12 @@ + + +JavDB fixture + +
    +
    ABC-123
    +

    ABC-123 Browser Fixture

    +
    Fixture magnets
    +
    Fixture reviews
    +
    + + diff --git a/tests/browser/fixtures/javdb-fc2-list.html b/tests/browser/fixtures/javdb-fc2-list.html new file mode 100644 index 00000000..a2b3037c --- /dev/null +++ b/tests/browser/fixtures/javdb-fc2-list.html @@ -0,0 +1,33 @@ + + +JavDB FC2 list fixture + + +
    +
    + +
    +
    + + diff --git a/tests/browser/fixtures/javdb-hit-show.html b/tests/browser/fixtures/javdb-hit-show.html new file mode 100644 index 00000000..4750f575 --- /dev/null +++ b/tests/browser/fixtures/javdb-hit-show.html @@ -0,0 +1,16 @@ + + +JavDB hot show fixture + + +
    +
    +
    +

    高级搜索

    +
    宿主筛选表单
    +
    暂无搜索结果
    +
    +
    +
    + + diff --git a/tests/browser/fixtures/javdb-list.html b/tests/browser/fixtures/javdb-list.html new file mode 100644 index 00000000..48eb3c0e --- /dev/null +++ b/tests/browser/fixtures/javdb-list.html @@ -0,0 +1,26 @@ + + +JavDB list fixture + + + +
    +
    + + +
    +
    + + \ No newline at end of file diff --git a/tests/browser/fixtures/javdb-magnet-host.css b/tests/browser/fixtures/javdb-magnet-host.css new file mode 100644 index 00000000..a24626d4 --- /dev/null +++ b/tests/browser/fixtures/javdb-magnet-host.css @@ -0,0 +1,17 @@ +/* Relevant rules read from https://javdb.com/packs/css/app-ab5ba15c.css on 2026-09-08. + Used with the historical columns DOM; current authenticated detail DOM was unavailable. */ +.column { display:block; flex:1 1 0px; padding:.75rem; } +.columns { margin-left:-.75rem; margin-right:-.75rem; margin-top:-.75rem; } +@media print, screen and (min-width:769px) { .column.is-four-fifths { flex:0 0 auto; width:80%; } } +@media screen and (min-width:1024px) { .columns.is-desktop { display:flex; } } +.button { appearance:none; align-items:center; border:1px solid transparent; border-radius:0; box-shadow:none; display:inline-flex; font-size:1rem; height:2.5em; justify-content:flex-start; line-height:1.5; padding:calc(.5em - 1px) calc(.75em - 1px); position:relative; vertical-align:top; } +.button { background-color:#fff; border-color:#dbdbdb; border-width:1px; color:#363636; cursor:pointer; justify-content:center; padding:calc(.5em - 1px) 1em; text-align:center; white-space:nowrap; } +.button.is-small { font-size:.75rem; } +.buttons { align-items:center; display:flex; flex-wrap:wrap; justify-content:flex-start; } +.buttons .button { margin-bottom:.5rem; } +.buttons .button:not(:last-child):not(.is-fullwidth) { margin-right:.5rem; } +.video-panel .magnet-links .item { padding:.6rem .75rem; } +.video-panel .magnet-links .item .magnet-name { line-height:1.2rem; font-size:.9rem; text-align:left; } +.video-panel .magnet-links .item .magnet-name .name { font-weight:700; word-break:break-all; } +.video-panel .magnet-links .item .buttons { justify-content:flex-start; margin-top:.5rem; margin-bottom:0; } +.video-panel .magnet-links .item .buttons .button { margin-bottom:0; } diff --git a/tests/browser/fixtures/javdb-ranking-native-results.html b/tests/browser/fixtures/javdb-ranking-native-results.html new file mode 100644 index 00000000..7ba68fbb --- /dev/null +++ b/tests/browser/fixtures/javdb-ranking-native-results.html @@ -0,0 +1,29 @@ + + + + + JavDB ranking with native search results + + + + +
    +
    +

    高级搜索

    +
    宿主筛选表单
    + + +
    +
    +
    Host footer
    + + diff --git a/tests/browser/fixtures/layer-runtime/NOTICE.md b/tests/browser/fixtures/layer-runtime/NOTICE.md new file mode 100644 index 00000000..d735e56a --- /dev/null +++ b/tests/browser/fixtures/layer-runtime/NOTICE.md @@ -0,0 +1,9 @@ +# Layer 测试副本 + +仅供隔离浏览器回归使用,不加入发布脚本或新增运行时依赖。 + +- npm 包:`layui-layer@1.0.9`,与 `src/main.js` 的固定 `@require` 版本一致。 +- 来源:https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/dist/layer.min.js +- 包元数据:https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/package.json +- npm 包声明许可证:`ISC`;发布包未包含独立 LICENSE 文件。 +- 保留分发文件原始内容及头部说明;本地副本避免回归测试依赖外网或升级到不同渲染实现。 diff --git a/tests/browser/fixtures/layer-runtime/layer-1.0.9.min.js b/tests/browser/fixtures/layer-runtime/layer-1.0.9.min.js new file mode 100644 index 00000000..818eb62f --- /dev/null +++ b/tests/browser/fixtures/layer-runtime/layer-1.0.9.min.js @@ -0,0 +1,8 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/layui-layer@1.0.9/dist/layer.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(e){function t(i){if(a[i])return a[i].exports;var n=a[i]={i:i,l:!1,exports:{}};return e[i].call(n.exports,n,n.exports,t),n.l=!0,n.exports}var a={};return t.m=e,t.c=a,t.i=function(e){return e},t.d=function(e,a,i){t.o(e,a)||Object.defineProperty(e,a,{configurable:!1,enumerable:!0,get:i})},t.n=function(e){var a=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(a,"a",a),a},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=9)}([function(e,t){e.exports="data:image/gif;base64,R0lGODlhJQAlAJECAL3L2AYrTv///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQFCgACACwAAAAAJQAlAAACi5SPqcvtDyGYIFpF690i8xUw3qJBwUlSadmcLqYmGQu6KDIeM13beGzYWWy3DlB4IYaMk+Dso2RWkFCfLPcRvFbZxFLUDTt21BW56TyjRep1e20+i+eYMR145W2eefj+6VFmgTQi+ECVY8iGxcg35phGo/iDFwlTyXWphwlm1imGRdcnuqhHeop6UAAAIfkEBQoAAgAsEAACAAQACwAAAgWMj6nLXAAh+QQFCgACACwVAAUACgALAAACFZQvgRi92dyJcVJlLobUdi8x4bIhBQAh+QQFCgACACwXABEADAADAAACBYyPqcsFACH5BAUKAAIALBUAFQAKAAsAAAITlGKZwWoMHYxqtmplxlNT7ixGAQAh+QQFCgACACwQABgABAALAAACBYyPqctcACH5BAUKAAIALAUAFQAKAAsAAAIVlC+BGL3Z3IlxUmUuhtR2LzHhsiEFACH5BAUKAAIALAEAEQAMAAMAAAIFjI+pywUAIfkEBQoAAgAsBQAFAAoACwAAAhOUYJnAagwdjGq2amXGU1PuLEYBACH5BAUKAAIALBAAAgAEAAsAAAIFhI+py1wAIfkEBQoAAgAsFQAFAAoACwAAAhWUL4AIvdnciXFSZS6G1HYvMeGyIQUAIfkEBQoAAgAsFwARAAwAAwAAAgWEj6nLBQAh+QQFCgACACwVABUACgALAAACE5RgmcBqDB2MarZqZcZTU+4sRgEAIfkEBQoAAgAsEAAYAAQACwAAAgWEj6nLXAAh+QQFCgACACwFABUACgALAAACFZQvgAi92dyJcVJlLobUdi8x4bIhBQAh+QQFCgACACwBABEADAADAAACBYSPqcsFADs="},function(e,t,a){var i=a(2);"string"==typeof i&&(i=[[e.i,i,""]]);a(4)(i,{});i.locals&&(e.exports=i.locals)},function(e,t,a){t=e.exports=a(3)(),t.push([e.i,"html #layuicss-skinlayercss{display:none;position:absolute;width:1989px}.layui-layer,.layui-layer-shade{position:fixed;_position:absolute;pointer-events:auto}.layui-layer-shade{top:0;left:0;width:100%;height:100%}.layui-layer{-webkit-overflow-scrolling:touch;top:150px;left:0;margin:0;padding:0;background-color:#fff;-webkit-background-clip:content;box-shadow:1px 1px 50px rgba(0,0,0,.3)}.layui-layer-close{position:absolute}.layui-layer-content{position:relative}.layui-layer-border{border:1px solid #b2b2b2;border:1px solid rgba(0,0,0,.1);box-shadow:1px 1px 5px rgba(0,0,0,.2)}.layui-layer-load{background:url("+a(0)+") #eee 50% no-repeat}.layui-layer-ico{background:url("+a(6)+") no-repeat}.layui-layer-btn a,.layui-layer-dialog .layui-layer-ico,.layui-layer-setwin a{display:inline-block;*display:inline;*zoom:1;vertical-align:top}.layui-layer-move{display:none;position:fixed;*position:absolute;left:0;top:0;width:100%;height:100%;cursor:move;opacity:0;filter:alpha(opacity=0);background-color:#fff;z-index:2147483647}.layui-layer-resize{position:absolute;width:15px;height:15px;right:0;bottom:0;cursor:se-resize}.layui-layer{border-radius:2px;-webkit-animation-fill-mode:both;animation-fill-mode:both;-webkit-animation-duration:.3s;animation-duration:.3s}@-webkit-keyframes bounceIn{0%{opacity:0;-webkit-transform:scale(.5);transform:scale(.5)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes bounceIn{0%{opacity:0;-webkit-transform:scale(.5);-ms-transform:scale(.5);transform:scale(.5)}to{opacity:1;-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}}.layer-anim{-webkit-animation-name:bounceIn;animation-name:bounceIn}@-webkit-keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale(.1) translateY(-2000px);transform:scale(.1) translateY(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateY(60px);transform:scale(.475) translateY(60px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}@keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale(.1) translateY(-2000px);-ms-transform:scale(.1) translateY(-2000px);transform:scale(.1) translateY(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateY(60px);-ms-transform:scale(.475) translateY(60px);transform:scale(.475) translateY(60px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}.layer-anim-01{-webkit-animation-name:zoomInDown;animation-name:zoomInDown}@-webkit-keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translateY(2000px);transform:translateY(2000px)}to{opacity:1;-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translateY(2000px);-ms-transform:translateY(2000px);transform:translateY(2000px)}to{opacity:1;-webkit-transform:translateY(0);-ms-transform:translateY(0);transform:translateY(0)}}.layer-anim-02{-webkit-animation-name:fadeInUpBig;animation-name:fadeInUpBig}@-webkit-keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale(.1) translateX(-2000px);transform:scale(.1) translateX(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateX(48px);transform:scale(.475) translateX(48px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}@keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale(.1) translateX(-2000px);-ms-transform:scale(.1) translateX(-2000px);transform:scale(.1) translateX(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateX(48px);-ms-transform:scale(.475) translateX(48px);transform:scale(.475) translateX(48px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}.layer-anim-03{-webkit-animation-name:zoomInLeft;animation-name:zoomInLeft}@-webkit-keyframes rollIn{0%{opacity:0;-webkit-transform:translateX(-100%) rotate(-120deg);transform:translateX(-100%) rotate(-120deg)}to{opacity:1;-webkit-transform:translateX(0) rotate(0deg);transform:translateX(0) rotate(0deg)}}@keyframes rollIn{0%{opacity:0;-webkit-transform:translateX(-100%) rotate(-120deg);-ms-transform:translateX(-100%) rotate(-120deg);transform:translateX(-100%) rotate(-120deg)}to{opacity:1;-webkit-transform:translateX(0) rotate(0deg);-ms-transform:translateX(0) rotate(0deg);transform:translateX(0) rotate(0deg)}}.layer-anim-04{-webkit-animation-name:rollIn;animation-name:rollIn}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.layer-anim-05{-webkit-animation-name:fadeIn;animation-name:fadeIn}@-webkit-keyframes shake{0%,to{-webkit-transform:translateX(0);transform:translateX(0)}10%,30%,50%,70%,90%{-webkit-transform:translateX(-10px);transform:translateX(-10px)}20%,40%,60%,80%{-webkit-transform:translateX(10px);transform:translateX(10px)}}@keyframes shake{0%,to{-webkit-transform:translateX(0);-ms-transform:translateX(0);transform:translateX(0)}10%,30%,50%,70%,90%{-webkit-transform:translateX(-10px);-ms-transform:translateX(-10px);transform:translateX(-10px)}20%,40%,60%,80%{-webkit-transform:translateX(10px);-ms-transform:translateX(10px);transform:translateX(10px)}}.layer-anim-06{-webkit-animation-name:shake;animation-name:shake}@-webkit-keyframes fadeIn{0%{opacity:0}to{opacity:1}}@-webkit-keyframes bounceOut{to{opacity:0;-webkit-transform:scale(.7);transform:scale(.7)}30%{-webkit-transform:scale(1.05);transform:scale(1.05)}0%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes bounceOut{to{opacity:0;-webkit-transform:scale(.7);-ms-transform:scale(.7);transform:scale(.7)}30%{-webkit-transform:scale(1.05);-ms-transform:scale(1.05);transform:scale(1.05)}0%{-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}}.layer-anim-close{-webkit-animation-name:bounceOut;animation-name:bounceOut;-webkit-animation-duration:.2s;animation-duration:.2s}.layui-layer-title{padding:0 80px 0 20px;height:42px;line-height:42px;border-bottom:1px solid #eee;font-size:14px;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background-color:#f8f8f8;border-radius:2px 2px 0 0}.layui-layer-setwin{position:absolute;right:15px;*right:0;top:15px;font-size:0;line-height:normal}.layui-layer-setwin a{position:relative;width:16px;height:16px;margin-left:10px;font-size:12px;_overflow:hidden}.layui-layer-setwin .layui-layer-min cite{position:absolute;width:14px;height:2px;left:0;top:50%;margin-top:-1px;background-color:#2e2d3c;cursor:pointer;_overflow:hidden}.layui-layer-setwin .layui-layer-min:hover cite{background-color:#2d93ca}.layui-layer-setwin .layui-layer-max{background-position:-32px -40px}.layui-layer-setwin .layui-layer-max:hover{background-position:-16px -40px}.layui-layer-setwin .layui-layer-maxmin{background-position:-65px -40px}.layui-layer-setwin .layui-layer-maxmin:hover{background-position:-49px -40px}.layui-layer-setwin .layui-layer-close1{background-position:0 -40px;cursor:pointer}.layui-layer-setwin .layui-layer-close1:hover{opacity:.7}.layui-layer-setwin .layui-layer-close2{position:absolute;right:-28px;top:-28px;width:30px;height:30px;margin-left:0;background-position:-149px -31px;*right:-18px;_display:none}.layui-layer-setwin .layui-layer-close2:hover{background-position:-180px -31px}.layui-layer-btn{text-align:right;padding:0 10px 12px;pointer-events:auto;user-select:none;-webkit-user-select:none}.layui-layer-btn a{height:28px;line-height:28px;margin:0 6px;padding:0 15px;border:1px solid #dedede;background-color:#f1f1f1;color:#333;border-radius:2px;font-weight:400;cursor:pointer;text-decoration:none}.layui-layer-btn a:hover{opacity:.9;text-decoration:none}.layui-layer-btn a:active{opacity:.8}.layui-layer-btn .layui-layer-btn0{border-color:#4898d5;background-color:#2e8ded;color:#fff}.layui-layer-btn-l{text-align:left}.layui-layer-btn-c{text-align:center}.layui-layer-dialog{min-width:260px}.layui-layer-dialog .layui-layer-content{position:relative;padding:20px;line-height:24px;word-break:break-all;overflow:hidden;font-size:14px;overflow-x:hidden;overflow-y:auto}.layui-layer-dialog .layui-layer-content .layui-layer-ico{position:absolute;top:16px;left:15px;width:30px;height:30px}.layui-layer-ico1{background-position:-30px 0}.layui-layer-ico2{background-position:-60px 0}.layui-layer-ico3{background-position:-90px 0}.layui-layer-ico4{background-position:-120px 0}.layui-layer-ico5{background-position:-150px 0}.layui-layer-ico6{background-position:-180px 0}.layui-layer-rim{border:6px solid #8d8d8d;border:6px solid rgba(0,0,0,.3);border-radius:5px;box-shadow:none}.layui-layer-msg{min-width:180px;border:1px solid #d3d4d3;box-shadow:none}.layui-layer-hui{min-width:100px;background-color:#000;filter:alpha(opacity=60);background-color:rgba(0,0,0,.6);color:#fff;border:none}.layui-layer-hui .layui-layer-content{padding:12px 25px;text-align:center}.layui-layer-dialog .layui-layer-padding{padding:20px 20px 20px 55px;text-align:left}.layui-layer-page .layui-layer-content{position:relative;overflow:auto}.layui-layer-iframe .layui-layer-btn,.layui-layer-page .layui-layer-btn{padding-top:10px}.layui-layer-nobg{background:none}.layui-layer-iframe iframe{display:block;width:100%}.layui-layer-loading{border-radius:100%;background:none;box-shadow:none;border:none}.layui-layer-loading .layui-layer-content{width:60px;height:24px;background:url("+a(7)+") no-repeat}.layui-layer-loading .layui-layer-loading1{width:37px;height:37px;background:url("+a(0)+") no-repeat}.layui-layer-ico16,.layui-layer-loading .layui-layer-loading2{width:32px;height:32px;background:url("+a(8)+") no-repeat}.layui-layer-tips{background:none;box-shadow:none;border:none}.layui-layer-tips .layui-layer-content{position:relative;line-height:22px;min-width:12px;padding:5px 10px;font-size:12px;_float:left;border-radius:2px;box-shadow:1px 1px 3px rgba(0,0,0,.2);background-color:#000;color:#fff}.layui-layer-tips .layui-layer-close{right:-2px;top:-1px}.layui-layer-tips i.layui-layer-TipsG{position:absolute;width:0;height:0;border:8px dashed transparent;*overflow:hidden}.layui-layer-tips i.layui-layer-TipsB,.layui-layer-tips i.layui-layer-TipsT{left:5px;border-right-style:solid;border-right-color:#000}.layui-layer-tips i.layui-layer-TipsT{bottom:-8px}.layui-layer-tips i.layui-layer-TipsB{top:-8px}.layui-layer-tips i.layui-layer-TipsL,.layui-layer-tips i.layui-layer-TipsR{top:1px;border-bottom-style:solid;border-bottom-color:#000}.layui-layer-tips i.layui-layer-TipsR{left:-8px}.layui-layer-tips i.layui-layer-TipsL{right:-8px}.layui-layer-lan[type=dialog]{min-width:280px}.layui-layer-lan .layui-layer-title{background:#4476a7;color:#fff;border:none}.layui-layer-lan .layui-layer-btn{padding:10px;text-align:right;border-top:1px solid #e9e7e7}.layui-layer-lan .layui-layer-btn a{background:#bbb5b5;border:none}.layui-layer-lan .layui-layer-btn .layui-layer-btn1{background:#c9c5c5}.layui-layer-molv .layui-layer-title{background:#009f95;color:#fff;border:none}.layui-layer-molv .layui-layer-btn a{background:#009f95}.layui-layer-molv .layui-layer-btn .layui-layer-btn1{background:#92b8b1}.layui-layer-iconext{background:url("+a(5)+") no-repeat}.layui-layer-prompt .layui-layer-input{display:block;width:220px;height:30px;margin:0 auto;line-height:30px;padding:0 5px;border:1px solid #ccc;box-shadow:inset 1px 1px 5px rgba(0,0,0,.1);color:#333}.layui-layer-prompt textarea.layui-layer-input{width:300px;height:100px;line-height:20px}.layui-layer-prompt .layui-layer-content{padding:20px}.layui-layer-prompt .layui-layer-btn{padding-top:0}.layui-layer-tab{box-shadow:1px 1px 50px rgba(0,0,0,.4)}.layui-layer-tab .layui-layer-title{padding-left:0;border-bottom:1px solid #ccc;background-color:#eee;overflow:visible}.layui-layer-tab .layui-layer-title span{position:relative;float:left;min-width:80px;max-width:260px;padding:0 20px;text-align:center;cursor:default;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.layui-layer-tab .layui-layer-title span.layui-layer-tabnow{height:43px;border-left:1px solid #ccc;border-right:1px solid #ccc;background-color:#fff;z-index:10}.layui-layer-tab .layui-layer-title span:first-child{border-left:none}.layui-layer-tabmain{line-height:24px;clear:both}.layui-layer-tabmain .layui-layer-tabli{display:none}.layui-layer-tabmain .layui-layer-tabli.xubox_tab_layer{display:block}.xubox_tabclose{position:absolute;right:10px;top:5px;cursor:pointer}.layui-layer-photos{-webkit-animation-duration:1s;animation-duration:1s}.layui-layer-photos .layui-layer-content{overflow:hidden;text-align:center}.layui-layer-photos .layui-layer-phimg img{position:relative;width:100%;display:inline-block;*display:inline;*zoom:1;vertical-align:top}.layui-layer-imgbar,.layui-layer-imguide{display:none}.layui-layer-imgnext,.layui-layer-imgprev{position:absolute;top:50%;width:27px;_width:44px;height:44px;margin-top:-22px;outline:none;blr:expression(this.onFocus=this.blur())}.layui-layer-imgprev{left:10px;background-position:-5px -5px}.layui-layer-imgprev:hover{background-position:-33px -5px}.layui-layer-imgnext{right:10px;background-position:-5px -50px}.layui-layer-imgnext:hover{background-position:-33px -50px}.layui-layer-imgbar{position:absolute;left:0;bottom:0;width:100%;height:32px;line-height:32px;background-color:rgba(0,0,0,.8);background-color:#000\\9;filter:Alpha(opacity=80);color:#fff;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;font-size:0}.layui-layer-imgtit *{display:inline-block;*display:inline;*zoom:1;vertical-align:top;font-size:12px}.layui-layer-imgtit a{max-width:65%;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;color:#fff}.layui-layer-imgtit a:hover{color:#fff;text-decoration:underline}.layui-layer-imgtit em{padding-left:10px;font-style:normal}@media screen and (max-width:1100px){.layui-layer-iframe{overflow-y:auto;-webkit-overflow-scrolling:touch}}",""])},function(e,t){e.exports=function(){var e=[];return e.toString=function(){for(var e=[],t=0;t=0&&g.splice(t,1)}function r(e){var t=document.createElement("style");return t.type="text/css",n(e,t),t}function l(e){var t=document.createElement("link");return t.rel="stylesheet",n(e,t),t}function s(e,t){var a,i,n;if(t.singleton){var s=h++;a=m||(m=r(t)),i=A.bind(null,a,s,!1),n=A.bind(null,a,s,!0)}else e.sourceMap&&"function"==typeof URL&&"function"==typeof URL.createObjectURL&&"function"==typeof URL.revokeObjectURL&&"function"==typeof Blob&&"function"==typeof btoa?(a=l(t),i=p.bind(null,a),n=function(){o(a),a.href&&URL.revokeObjectURL(a.href)}):(a=r(t),i=c.bind(null,a),n=function(){o(a)});return i(e),function(t){if(t){if(t.css===e.css&&t.media===e.media&&t.sourceMap===e.sourceMap)return;i(e=t)}else n()}}function A(e,t,a,i){var n=a?"":i.css;if(e.styleSheet)e.styleSheet.cssText=x(t,n);else{var o=document.createTextNode(n),r=e.childNodes;r[t]&&e.removeChild(r[t]),r.length?e.insertBefore(o,r[t]):e.appendChild(o)}}function c(e,t){var a=t.css,i=t.media;if(i&&e.setAttribute("media",i),e.styleSheet)e.styleSheet.cssText=a;else{for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(document.createTextNode(a))}}function p(e,t){var a=t.css,i=t.sourceMap;i&&(a+="\n/*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(i))))+" */");var n=new Blob([a],{type:"text/css"}),o=e.href;e.href=URL.createObjectURL(n),o&&URL.revokeObjectURL(o)}var u={},f=function(e){var t;return function(){return"undefined"==typeof t&&(t=e.apply(this,arguments)),t}},y=f(function(){return/msie [6-9]\b/.test(window.navigator.userAgent.toLowerCase())}),d=f(function(){return document.head||document.getElementsByTagName("head")[0]}),m=null,h=0,g=[];e.exports=function(e,t){if("undefined"!=typeof DEBUG&&DEBUG&&"object"!=typeof document)throw new Error("The style-loader cannot be used in a non-browser environment");t=t||{},"undefined"==typeof t.singleton&&(t.singleton=y()),"undefined"==typeof t.insertAt&&(t.insertAt="bottom");var n=i(e);return a(n,t),function(e){for(var o=[],r=0;r80?window.console&&console.error("layer.css: Invalid"):void(1989===parseInt(i("#"+s).css("width"))?t():setTimeout(e,100))}()}},ready:function(e){var t="skinlayercss",a="1110";return o?layui.addcss("modules/layer/default/layer.css?v="+l.v+a,e,t):l.link("skin/default/layer.css?v="+l.v+a,e,t),this},alert:function(e,t,a){var n="function"==typeof t;return n&&(a=t),l.open(i.extend({content:e,yes:a},n?{}:t))},confirm:function(e,t,a,n){var o="function"==typeof t;return o&&(n=a,a=t),l.open(i.extend({content:e,btn:r.btn,yes:a,btn2:n},o?{}:t))},msg:function(e,t,a){var n="function"==typeof t,o=r.config.skin,s=(o?o+" "+o+"-msg":"")||"layui-layer-msg",c=A.anim.length-1;return n&&(a=t),l.open(i.extend({content:e,time:3e3,shade:!1,skin:s,title:!1,closeBtn:!1,btn:!1,resize:!1,end:a},n&&!r.config.skin?{skin:s+" layui-layer-hui",anim:c}:function(){return t=t||{},(t.icon===-1||void 0===t.icon&&!r.config.skin)&&(t.skin=s+" "+(t.skin||"layui-layer-hui")),t}()))},load:function(e,t){return l.open(i.extend({type:3,icon:e||0,resize:!1,shade:.01},t))},tips:function(e,t,a){return l.open(i.extend({type:4,content:[e,t],closeBtn:!1,time:3e3,shade:!1,resize:!1,fixed:!1,maxWidth:210},a))}},s=function(e){var t=this;t.index=++l.index,t.config=i.extend({},t.config,r.config,e),document.body?t.creat():setTimeout(function(){t.creat()},50)};s.pt=s.prototype;var A=["layui-layer",".layui-layer-title",".layui-layer-main",".layui-layer-dialog","layui-layer-iframe","layui-layer-content","layui-layer-btn","layui-layer-close"];A.anim=["layer-anim","layer-anim-01","layer-anim-02","layer-anim-03","layer-anim-04","layer-anim-05","layer-anim-06"],s.pt.config={type:0,shade:.3,fixed:!0,move:A[1],title:"信息",offset:"auto",area:"auto",closeBtn:1,time:0,zIndex:19891014,maxWidth:360,anim:0,icon:-1,moveType:1,resize:!0,scrollbar:!0,tips:2},s.pt.vessel=function(e,t){var a=this,n=a.index,o=a.config,l=o.zIndex+n,s="object"==typeof o.title,c=o.maxmin&&(1===o.type||2===o.type),p=o.title?'
    '+(s?o.title[0]:o.title)+"
    ":"";return o.zIndex=l,t([o.shade?'
    ':"",'
    '+(e&&2!=o.type?"":p)+'
    '+(0==o.type&&o.icon!==-1?'':"")+(1==o.type&&e?"":o.content||"")+'
    '+function(){var e=c?'':"";return o.closeBtn&&(e+=''),e}()+""+(o.btn?function(){var e="";"string"==typeof o.btn&&(o.btn=[o.btn]);for(var t=0,a=o.btn.length;t'+o.btn[t]+"";return'
    '+e+"
    "}():"")+(o.resize?'':"")+"
    "],p,i('
    ')),a},s.pt.creat=function(){var e=this,t=e.config,a=e.index,o=t.content,s="object"==typeof o,c=i("body");if(!i("#"+t.id)[0]){switch("string"==typeof t.area&&(t.area="auto"===t.area?["",""]:[t.area,""]),t.shift&&(t.anim=t.shift),6==l.ie&&(t.fixed=!1),t.type){case 0:t.btn="btn"in t?t.btn:r.btn[0],l.closeAll("dialog");break;case 2:var o=t.content=s?t.content:[t.content||"http://layer.layui.com","auto"];t.content='';break;case 3:delete t.title,delete t.closeBtn,t.icon===-1&&0===t.icon,l.closeAll("loading");break;case 4:s||(t.content=[t.content,"body"]),t.follow=t.content[1],t.content=t.content[0]+'',delete t.title,t.tips="object"==typeof t.tips?t.tips:[t.tips,!0],t.tipsMore||l.closeAll("tips")}e.vessel(s,function(n,l,p){c.append(n[0]),s?function(){2==t.type||4==t.type?function(){i("body").append(n[1])}():function(){o.parents("."+A[0])[0]||(o.data("display",o.css("display")).show().addClass("layui-layer-wrap").wrap(n[1]),i("#"+A[0]+a).find("."+A[5]).before(l))}()}():c.append(n[1]),i(".layui-layer-move")[0]||c.append(r.moveElem=p),e.layero=i("#"+A[0]+a),t.scrollbar||A.html.css("overflow","hidden").attr("layer-full",a)}).auto(a),2==t.type&&6==l.ie&&e.layero.find("iframe").attr("src",o[0]),4==t.type?e.tips():e.offset(),t.fixed&&n.on("resize",function(){e.offset(),(/^\d+%$/.test(t.area[0])||/^\d+%$/.test(t.area[1]))&&e.auto(a),4==t.type&&e.tips()}),t.time<=0||setTimeout(function(){l.close(e.index)},t.time),e.move().callback(),A.anim[t.anim]&&e.layero.addClass(A.anim[t.anim]).data("anim",!0)}},s.pt.auto=function(e){function t(e){e=r.find(e),e.height(s[1]-c-p-2*(0|parseFloat(e.css("padding"))))}var a=this,o=a.config,r=i("#"+A[0]+e);""===o.area[0]&&o.maxWidth>0&&(l.ie&&l.ie<8&&o.btn&&r.width(r.innerWidth()),r.outerWidth()>o.maxWidth&&r.width(o.maxWidth));var s=[r.innerWidth(),r.innerHeight()],c=r.find(A[1]).outerHeight()||0,p=r.find("."+A[6]).outerHeight()||0;switch(o.type){case 2:t("iframe");break;default:""===o.area[1]?o.fixed&&s[1]>=n.height()&&(s[1]=n.height(),t("."+A[5])):t("."+A[5])}return a},s.pt.offset=function(){var e=this,t=e.config,a=e.layero,i=[a.outerWidth(),a.outerHeight()],o="object"==typeof t.offset;e.offsetTop=(n.height()-i[1])/2,e.offsetLeft=(n.width()-i[0])/2,o?(e.offsetTop=t.offset[0],e.offsetLeft=t.offset[1]||e.offsetLeft):"auto"!==t.offset&&("t"===t.offset?e.offsetTop=0:"r"===t.offset?e.offsetLeft=n.width()-i[0]:"b"===t.offset?e.offsetTop=n.height()-i[1]:"l"===t.offset?e.offsetLeft=0:"lt"===t.offset?(e.offsetTop=0,e.offsetLeft=0):"lb"===t.offset?(e.offsetTop=n.height()-i[1],e.offsetLeft=0):"rt"===t.offset?(e.offsetTop=0,e.offsetLeft=n.width()-i[0]):"rb"===t.offset?(e.offsetTop=n.height()-i[1],e.offsetLeft=n.width()-i[0]):e.offsetTop=t.offset),t.fixed||(e.offsetTop=/%$/.test(e.offsetTop)?n.height()*parseFloat(e.offsetTop)/100:parseFloat(e.offsetTop),e.offsetLeft=/%$/.test(e.offsetLeft)?n.width()*parseFloat(e.offsetLeft)/100:parseFloat(e.offsetLeft),e.offsetTop+=n.scrollTop(),e.offsetLeft+=n.scrollLeft()),a.attr("minLeft")&&(e.offsetTop=n.height()-(a.find(A[1]).outerHeight()||0),e.offsetLeft=a.css("left")),a.css({top:e.offsetTop,left:e.offsetLeft})},s.pt.tips=function(){var e=this,t=e.config,a=e.layero,o=[a.outerWidth(),a.outerHeight()],r=i(t.follow);r[0]||(r=i("body"));var l={width:r.outerWidth(),height:r.outerHeight(),top:r.offset().top,left:r.offset().left},s=a.find(".layui-layer-TipsG"),c=t.tips[0];t.tips[1]||s.remove(),l.autoLeft=function(){l.left+o[0]-n.width()>0?(l.tipLeft=l.left+l.width-o[0],s.css({right:12,left:"auto"})):l.tipLeft=l.left},l.where=[function(){l.autoLeft(),l.tipTop=l.top-o[1]-10,s.removeClass("layui-layer-TipsB").addClass("layui-layer-TipsT").css("border-right-color",t.tips[1])},function(){l.tipLeft=l.left+l.width+10,l.tipTop=l.top,s.removeClass("layui-layer-TipsL").addClass("layui-layer-TipsR").css("border-bottom-color",t.tips[1])},function(){l.autoLeft(),l.tipTop=l.top+l.height+10,s.removeClass("layui-layer-TipsT").addClass("layui-layer-TipsB").css("border-right-color",t.tips[1])},function(){l.tipLeft=l.left-o[0]-10,l.tipTop=l.top,s.removeClass("layui-layer-TipsR").addClass("layui-layer-TipsL").css("border-bottom-color",t.tips[1])}],l.where[c-1](),1===c?l.top-(n.scrollTop()+o[1]+16)<0&&l.where[2]():2===c?n.width()-(l.left+l.width+o[0]+16)>0||l.where[3]():3===c?l.top-n.scrollTop()+l.height+o[1]+16-n.height()>0&&l.where[0]():4===c&&o[0]+16-l.left>0&&l.where[1](),a.find("."+A[5]).css({"background-color":t.tips[1],"padding-right":t.closeBtn?"30px":""}),a.css({left:l.tipLeft-(t.fixed?n.scrollLeft():0),top:l.tipTop-(t.fixed?n.scrollTop():0)})},s.pt.move=function(){var e=this,t=e.config,a=i(document),o=e.layero,s=o.find(t.move),A=o.find(".layui-layer-resize"),c={};return t.move&&s.css("cursor","move"),s.on("mousedown",function(e){e.preventDefault(),t.move&&(c.moveStart=!0,c.offset=[e.clientX-parseFloat(o.css("left")),e.clientY-parseFloat(o.css("top"))],r.moveElem.css("cursor","move").show())}),A.on("mousedown",function(e){e.preventDefault(),c.resizeStart=!0,c.offset=[e.clientX,e.clientY],c.area=[o.outerWidth(),o.outerHeight()],r.moveElem.css("cursor","se-resize").show()}),a.on("mousemove",function(a){if(c.moveStart){var i=a.clientX-c.offset[0],r=a.clientY-c.offset[1],s="fixed"===o.css("position");if(a.preventDefault(),c.stX=s?0:n.scrollLeft(),c.stY=s?0:n.scrollTop(),!t.moveOut){var A=n.width()-o.outerWidth()+c.stX,p=n.height()-o.outerHeight()+c.stY;iA&&(i=A),rp&&(r=p)}o.css({left:i,top:r})}if(t.resize&&c.resizeStart){var i=a.clientX-c.offset[0],r=a.clientY-c.offset[1];a.preventDefault(),l.style(e.index,{width:c.area[0]+i,height:c.area[1]+r}),c.isResize=!0}}).on("mouseup",function(e){c.moveStart&&(delete c.moveStart,r.moveElem.hide(),t.moveEnd&&t.moveEnd()),c.resizeStart&&(delete c.resizeStart,r.moveElem.hide())}),e},s.pt.callback=function(){function e(){var e=n.cancel&&n.cancel(t.index,a);e===!1||l.close(t.index)}var t=this,a=t.layero,n=t.config;t.openLayer(),n.success&&(2==n.type?a.find("iframe").on("load",function(){n.success(a,t.index)}):n.success(a,t.index)),6==l.ie&&t.IE6(a),a.find("."+A[6]).children("a").on("click",function(){var e=i(this).index();if(0===e)n.yes?n.yes(t.index,a):n.btn1?n.btn1(t.index,a):l.close(t.index);else{var o=n["btn"+(e+1)]&&n["btn"+(e+1)](t.index,a);o===!1||l.close(t.index)}}),a.find("."+A[7]).on("click",e),n.shadeClose&&i("#layui-layer-shade"+t.index).on("click",function(){l.close(t.index)}),a.find(".layui-layer-min").on("click",function(){var e=n.min&&n.min(a);e===!1||l.min(t.index,n)}),a.find(".layui-layer-max").on("click",function(){i(this).hasClass("layui-layer-maxmin")?(l.restore(t.index),n.restore&&n.restore(a)):(l.full(t.index,n),setTimeout(function(){n.full&&n.full(a)},100))}),n.end&&(r.end[t.index]=n.end)},r.reselect=function(){i.each(i("select"),function(e,t){var a=i(this);a.parents("."+A[0])[0]||1==a.attr("layer")&&i("."+A[0]).length<1&&a.removeAttr("layer").show(),a=null})},s.pt.IE6=function(e){i("select").each(function(e,t){var a=i(this);a.parents("."+A[0])[0]||"none"===a.css("display")||a.attr({layer:"1"}).hide(),a=null})},s.pt.openLayer=function(){var e=this;l.zIndex=e.config.zIndex,l.setTop=function(e){var t=function(){l.zIndex++,e.css("z-index",l.zIndex+1)};return l.zIndex=parseInt(e[0].style.zIndex),e.on("mousedown",t),l.zIndex}},r.record=function(e){var t=[e.width(),e.height(),e.position().top,e.position().left+parseFloat(e.css("margin-left"))];e.find(".layui-layer-max").addClass("layui-layer-maxmin"),e.attr({area:t})},r.rescollbar=function(e){A.html.attr("layer-full")==e&&(A.html[0].style.removeProperty?A.html[0].style.removeProperty("overflow"):A.html[0].style.removeAttribute("overflow"),A.html.removeAttr("layer-full"))},window.layer=l,l.getChildFrame=function(e,t){return t=t||i("."+A[4]).attr("times"),i("#"+A[0]+t).find("iframe").contents().find(e)},l.getFrameIndex=function(e){return i("#"+e).parents("."+A[4]).attr("times")},l.iframeAuto=function(e){if(e){var t=l.getChildFrame("html",e).outerHeight(),a=i("#"+A[0]+e),n=a.find(A[1]).outerHeight()||0,o=a.find("."+A[6]).outerHeight()||0;a.css({height:t+n+o}),a.find("iframe").css({height:t})}},l.iframeSrc=function(e,t){i("#"+A[0]+e).find("iframe").attr("src",t)},l.style=function(e,t,a){var n=i("#"+A[0]+e),o=n.find(".layui-layer-content"),l=n.attr("type"),s=n.find(A[1]).outerHeight()||0,c=n.find("."+A[6]).outerHeight()||0;n.attr("minLeft");l!==r.type[3]&&l!==r.type[4]&&(a||(parseFloat(t.width)<=260&&(t.width=260),parseFloat(t.height)-s-c<=64&&(t.height=64+s+c)),n.css(t),c=n.find("."+A[6]).outerHeight(),l===r.type[2]?n.find("iframe").css({height:parseFloat(t.height)-s-c}):o.css({height:parseFloat(t.height)-s-c-parseFloat(o.css("padding-top"))-parseFloat(o.css("padding-bottom"))}))},l.min=function(e,t){var a=i("#"+A[0]+e),o=a.find(A[1]).outerHeight()||0,s=a.attr("minLeft")||181*r.minIndex+"px",c=a.css("position");r.record(a),r.minLeft[0]&&(s=r.minLeft[0],r.minLeft.shift()),a.attr("position",c),l.style(e,{width:180,height:o,left:s,top:n.height()-o,position:"fixed",overflow:"hidden"},!0),a.find(".layui-layer-min").hide(),"page"===a.attr("type")&&a.find(A[4]).hide(),r.rescollbar(e),a.attr("minLeft")||r.minIndex++,a.attr("minLeft",s)},l.restore=function(e){var t=i("#"+A[0]+e),a=t.attr("area").split(",");t.attr("type");l.style(e,{width:parseFloat(a[0]),height:parseFloat(a[1]),top:parseFloat(a[2]),left:parseFloat(a[3]),position:t.attr("position"),overflow:"visible"},!0),t.find(".layui-layer-max").removeClass("layui-layer-maxmin"),t.find(".layui-layer-min").show(),"page"===t.attr("type")&&t.find(A[4]).show(),r.rescollbar(e)},l.full=function(e){var t,a=i("#"+A[0]+e);r.record(a),A.html.attr("layer-full")||A.html.css("overflow","hidden").attr("layer-full",e),clearTimeout(t),t=setTimeout(function(){var t="fixed"===a.css("position");l.style(e,{top:t?0:n.scrollTop(),left:t?0:n.scrollLeft(),width:n.width(),height:n.height()},!0),a.find(".layui-layer-min").hide()},100)},l.title=function(e,t){var a=i("#"+A[0]+(t||l.index)).find(A[1]);a.html(e)},l.close=function(e){var t=i("#"+A[0]+e),a=t.attr("type"),n="layer-anim-close";if(t[0]){var o="layui-layer-wrap",s=function(){if(a===r.type[1]&&"object"===t.attr("conType")){t.children(":not(."+A[5]+")").remove();for(var n=t.find("."+o),l=0;l<2;l++)n.unwrap();n.css("display",n.data("display")).removeClass(o)}else{if(a===r.type[2])try{var s=i("#"+A[4]+e)[0];s.contentWindow.document.write(""),s.contentWindow.close(),t.find("."+A[5])[0].removeChild(s)}catch(e){}t[0].innerHTML="",t.remove()}"function"==typeof r.end[e]&&r.end[e](),delete r.end[e]};t.data("anim")&&t.addClass(n),i("#layui-layer-moves, #layui-layer-shade"+e).remove(),6==l.ie&&r.reselect(),r.rescollbar(e),t.attr("minLeft")&&(r.minIndex--,r.minLeft.push(t.attr("minLeft"))),setTimeout(function(){s()},l.ie&&l.ie<10||!t.data("anim")?0:200)}},l.closeAll=function(e){i.each(i("."+A[0]),function(){var t=i(this),a=e?t.attr("type")===e:1;a&&l.close(t.attr("times")),a=null})};var c=l.cache||{},p=function(e){return c.skin?" "+c.skin+" "+c.skin+"-"+e:""};l.prompt=function(e,t){var a="";if(e=e||{},"function"==typeof e&&(t=e),e.area){var o=e.area;a='style="width: '+o[0]+"; height: "+o[1]+';"',delete e.area}var r,s=2==e.formType?'":function(){return''}();return l.open(i.extend({type:1,btn:["确定","取消"],content:s,skin:"layui-layer-prompt"+p("prompt"),maxWidth:n.width(),success:function(e){r=e.find(".layui-layer-input"),r.focus()},resize:!1,yes:function(a){var i=r.val();""===i?r.focus():i.length>(e.maxlength||500)?l.tips("最多输入"+(e.maxlength||500)+"个字数",r,{tips:1}):t&&t(i,a,r)}},e))},l.tab=function(e){e=e||{};var t=e.tab||{};return l.open(i.extend({type:1,skin:"layui-layer-tab"+p("tab"),resize:!1,title:function(){var e=t.length,a=1,i="";if(e>0)for(i=''+t[0].title+"";a"+t[a].title+"";return i}(),content:'
      '+function(){var e=t.length,a=1,i="";if(e>0)for(i='
    • '+(t[0].content||"no content")+"
    • ";a'+(t[a].content||"no content")+"";return i}()+"
    ",success:function(t){var a=t.find(".layui-layer-title").children(),n=t.find(".layui-layer-tabmain").children();a.on("mousedown",function(t){t.stopPropagation?t.stopPropagation():t.cancelBubble=!0;var a=i(this),o=a.index();a.addClass("layui-layer-tabnow").siblings().removeClass("layui-layer-tabnow"),n.eq(o).show().siblings().hide(),"function"==typeof e.change&&e.change(o)})}},e))},l.photos=function(e,t,a){function n(e,t,a){var i=new Image;return i.src=e,i.complete?t(i):(i.onload=function(){i.onload=null,t(i)},void(i.onerror=function(e){i.onerror=null,a(e)}))}var o={};if(e=e||{},e.photos){var r=e.photos.constructor===Object,s=r?e.photos:{},A=s.data||[],c=s.start||0;if(o.imgIndex=(0|c)+1,e.img=e.img||"img",r){if(0===A.length)return l.msg("没有图片")}else{var u=i(e.photos),f=function(){A=[],u.find(e.img).each(function(e){var t=i(this);t.attr("layer-index",e),A.push({alt:t.attr("alt"),pid:t.attr("layer-pid"),src:t.attr("layer-src")||t.attr("src"),thumb:t.attr("src")})})};if(f(),0===A.length)return;if(t||u.on("click",e.img,function(){var t=i(this),a=t.attr("layer-index");l.photos(i.extend(e,{photos:{start:a,data:A,tab:e.tab},full:e.full}),!0),f()}),!t)return}o.imgprev=function(e){o.imgIndex--,o.imgIndex<1&&(o.imgIndex=A.length),o.tabimg(e)},o.imgnext=function(e,t){o.imgIndex++,o.imgIndex>A.length&&(o.imgIndex=1,t)||o.tabimg(e)},o.keyup=function(e){if(!o.end){var t=e.keyCode;e.preventDefault(),37===t?o.imgprev(!0):39===t?o.imgnext(!0):27===t&&l.close(o.index)}},o.tabimg=function(t){A.length<=1||(s.start=o.imgIndex-1,l.close(o.index),l.photos(e,!0,t))},o.event=function(){o.bigimg.hover(function(){o.imgsee.show()},function(){o.imgsee.hide()}),o.bigimg.find(".layui-layer-imgprev").on("click",function(e){e.preventDefault(),o.imgprev()}),o.bigimg.find(".layui-layer-imgnext").on("click",function(e){e.preventDefault(),o.imgnext()}),i(document).on("keyup",o.keyup)},o.loadi=l.load(1,{shade:!("shade"in e)&&.9,scrollbar:!1}),n(A[c].src,function(t){l.close(o.loadi),o.index=l.open(i.extend({type:1,area:function(){var a=[t.width,t.height],n=[i(window).width()-100,i(window).height()-100];if(!e.full&&(a[0]>n[0]||a[1]>n[1])){var o=[a[0]/n[0],a[1]/n[1]];o[0]>o[1]?(a[0]=a[0]/o[0],a[1]=a[1]/o[0]):o[0]'+(A[c].alt||
    '+(A.length>1?'':"")+'
    '+(A[c].alt||"")+""+o.imgIndex+"/"+A.length+"
    ",success:function(t,a){o.bigimg=t.find(".layui-layer-phimg"),o.imgsee=t.find(".layui-layer-imguide,.layui-layer-imgbar"),o.event(t),e.tab&&e.tab(A[c],t)},end:function(){o.end=!0,i(document).off("keyup",o.keyup)}},e))},function(){l.close(o.loadi),l.msg("当前图片地址异常
    是否继续查看下一张?",{time:3e4,btn:["下一张","不看了"],yes:function(){A.length>1&&o.imgnext(!0,!0)}})})}},r.run=function(e){i=e,n=i(window),A.html=i("html"),l.open=function(e){var t=new s(e);return t.index}},r.run(jQuery),e.exports=l}]); \ No newline at end of file diff --git a/tests/browser/fixtures/viewer-runtime/LICENSE b/tests/browser/fixtures/viewer-runtime/LICENSE new file mode 100644 index 00000000..4ca99acc --- /dev/null +++ b/tests/browser/fixtures/viewer-runtime/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright 2015-present Chen Fengyuan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tests/browser/fixtures/viewer-runtime/NOTICE.md b/tests/browser/fixtures/viewer-runtime/NOTICE.md new file mode 100644 index 00000000..a3408f0f --- /dev/null +++ b/tests/browser/fixtures/viewer-runtime/NOTICE.md @@ -0,0 +1,11 @@ +# Viewer.js 测试副本 + +仅供隔离浏览器回归使用,不加入发布脚本或新增运行时依赖。 + +- npm 包:`viewerjs@1.11.1`,与发布脚本固定版本一致,MIT 许可证。 +- JS 来源:https://cdn.jsdelivr.net/npm/viewerjs@1.11.1/dist/viewer.min.js +- CSS 来源:https://cdn.jsdelivr.net/npm/viewerjs@1.11.1/dist/viewer.min.css +- JS SHA-256:`bc0e260bddb12844ad0344e781d633fe3088afaf5b9cef079305d3ac72c9fd61` +- CSS SHA-256:`f73a2bae7fa48b50f604632ea865bd76f5c2338d3fc96387c96aaa9b61bd7efc` + +保留分发文件原始内容及版权头,固定副本避免测试依赖外网或空 Viewer 桩。 diff --git a/tests/browser/fixtures/viewer-runtime/viewer-1.11.1.min.css b/tests/browser/fixtures/viewer-runtime/viewer-1.11.1.min.css new file mode 100644 index 00000000..c48f95ba --- /dev/null +++ b/tests/browser/fixtures/viewer-runtime/viewer-1.11.1.min.css @@ -0,0 +1,9 @@ +/*! + * Viewer.js v1.11.1 + * https://fengyuanchen.github.io/viewerjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2022-11-06T05:18:17.414Z + */.viewer-close:before,.viewer-flip-horizontal:before,.viewer-flip-vertical:before,.viewer-fullscreen-exit:before,.viewer-fullscreen:before,.viewer-next:before,.viewer-one-to-one:before,.viewer-play:before,.viewer-prev:before,.viewer-reset:before,.viewer-rotate-left:before,.viewer-rotate-right:before,.viewer-zoom-in:before,.viewer-zoom-out:before{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARgAAAAUCAYAAABWOyJDAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNui8sowAAAQPSURBVHic7Zs/iFxVFMa/0U2UaJGksUgnIVhYxVhpjDbZCBmLdAYECxsRFBTUamcXUiSNncgKQbSxsxH8gzAP3FU2jY0kKKJNiiiIghFlccnP4p3nPCdv3p9778vsLOcHB2bfveeb7955c3jvvNkBIMdxnD64a94GHMfZu3iBcRynN7zAOI7TG15gHCeeNUkr8zaxG2lbYDYsdgMbktBsP03jdQwljSXdtBhLOmtjowC9Mg9L+knSlcD8TNKpSA9lBpK2JF2VdDSR5n5J64m0qli399hNFMUlpshQii5jbXTbHGviB0nLNeNDSd9VO4A2UdB2fp+x0eCnaXxWXGA2X0au/3HgN9P4LFCjIANOJdrLr0zzZ+BEpNYDwKbpnQMeAw4m8HjQtM6Z9qa917zPQwFr3M5KgA6J5rTJCdFZJj9/lyvGhsDvwFNVuV2MhhjrK6b9bFiE+j1r87eBl4HDwCF7/U/k+ofAX5b/EXBv5JoLMuILzf3Ap6Z3EzgdqHMCuF7hcQf4HDgeoHnccncqdK/TvSDWffFXI/exICY/xZyqc6XLWF1UFZna4gJ7q8BsRvgd2/xXpo6P+D9dfT7PpECtA3cnWPM0GXGFZh/wgWltA+cDNC7X+AP4GzjZQe+k5dRxuYPeiuXU7e1qwLpDz7dFjXKRaSwuMLvAlG8zZlG+YmiK1HoFqT7wP2z+4Q45TfEGcMt01xLoNZEBTwRqD4BLpnMLeC1A41UmVxsXgXeBayV/Wx20rpTyrpnWRft7p6O/FdqzGrDukPNtkaMoMo3FBdBSQMOnYBCReyf05s126fU9ytfX98+mY54Kxnp7S9K3kj6U9KYdG0h6UdLbkh7poFXMfUnSOyVvL0h6VtIXHbS6nOP+s/Zm9mvyXW1uuC9ohZ72E9uDmXWLJOB1GxsH+DxPftsB8B6wlGDN02TAkxG6+4D3TWsbeC5CS8CDFce+AW500LhhOW2020TRjK3b21HEmgti9m0RonxbdMZeVzV+/4tF3cBpP7E9mKHNL5q8h5g0eYsCMQz0epq8gQrwMXAgcs0FGXGFRcB9wCemF9PkbYqM/Bas7fxLwNeJPdTdpo4itQti8lPMqTpXuozVRVXPpbHI3KkNTB1NfkL81j2mvhDp91HgV9MKuRIqrykj3WPq4rHyL+axj8/qGPmTqi6F9YDlHOvJU6oYcTsh/TYSzWmTE6JT19CtLTJt32D6CmHe0eQn1O8z5AXgT4sx4Vcu0/EQecMydB8z0hUWkTd2t4CrwNEePqMBcAR4mrBbwyXLPWJa8zrXmmLEhNBmfpkuY2102xxrih+pb+ieAb6vGhuA97UcJ5KR8gZ77K+99xxeYBzH6Q3/Z0fHcXrDC4zjOL3hBcZxnN74F+zlvXFWXF9PAAAAAElFTkSuQmCC");background-repeat:no-repeat;background-size:280px;color:transparent;display:block;font-size:0;height:20px;line-height:0;width:20px}.viewer-zoom-in:before{background-position:0 0;content:"Zoom In"}.viewer-zoom-out:before{background-position:-20px 0;content:"Zoom Out"}.viewer-one-to-one:before{background-position:-40px 0;content:"One to One"}.viewer-reset:before{background-position:-60px 0;content:"Reset"}.viewer-prev:before{background-position:-80px 0;content:"Previous"}.viewer-play:before{background-position:-100px 0;content:"Play"}.viewer-next:before{background-position:-120px 0;content:"Next"}.viewer-rotate-left:before{background-position:-140px 0;content:"Rotate Left"}.viewer-rotate-right:before{background-position:-160px 0;content:"Rotate Right"}.viewer-flip-horizontal:before{background-position:-180px 0;content:"Flip Horizontal"}.viewer-flip-vertical:before{background-position:-200px 0;content:"Flip Vertical"}.viewer-fullscreen:before{background-position:-220px 0;content:"Enter Full Screen"}.viewer-fullscreen-exit:before{background-position:-240px 0;content:"Exit Full Screen"}.viewer-close:before{background-position:-260px 0;content:"Close"}.viewer-container{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;bottom:0;direction:ltr;font-size:0;left:0;line-height:0;overflow:hidden;position:absolute;right:0;top:0;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.viewer-container ::-moz-selection,.viewer-container::-moz-selection{background-color:transparent}.viewer-container ::selection,.viewer-container::selection{background-color:transparent}.viewer-container:focus{outline:0}.viewer-container img{display:block;height:auto;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.viewer-canvas{bottom:0;left:0;overflow:hidden;position:absolute;right:0;top:0}.viewer-canvas>img{height:auto;margin:15px auto;max-width:90%!important;width:auto}.viewer-footer{bottom:0;left:0;overflow:hidden;position:absolute;right:0;text-align:center}.viewer-navbar{background-color:rgba(0,0,0,.5);overflow:hidden}.viewer-list{box-sizing:content-box;height:50px;margin:0;overflow:hidden;padding:1px 0}.viewer-list>li{color:transparent;cursor:pointer;float:left;font-size:0;height:50px;line-height:0;opacity:.5;overflow:hidden;transition:opacity .15s;width:30px}.viewer-list>li:focus,.viewer-list>li:hover{opacity:.75}.viewer-list>li:focus{outline:0}.viewer-list>li+li{margin-left:1px}.viewer-list>.viewer-loading{position:relative}.viewer-list>.viewer-loading:after{border-width:2px;height:20px;margin-left:-10px;margin-top:-10px;width:20px}.viewer-list>.viewer-active,.viewer-list>.viewer-active:focus,.viewer-list>.viewer-active:hover{opacity:1}.viewer-player{background-color:#000;bottom:0;cursor:none;display:none;right:0;z-index:1}.viewer-player,.viewer-player>img{left:0;position:absolute;top:0}.viewer-toolbar>ul{display:inline-block;margin:0 auto 5px;overflow:hidden;padding:6px 3px}.viewer-toolbar>ul>li{background-color:rgba(0,0,0,.5);border-radius:50%;cursor:pointer;float:left;height:24px;overflow:hidden;transition:background-color .15s;width:24px}.viewer-toolbar>ul>li:focus,.viewer-toolbar>ul>li:hover{background-color:rgba(0,0,0,.8)}.viewer-toolbar>ul>li:focus{box-shadow:0 0 3px #fff;outline:0;position:relative;z-index:1}.viewer-toolbar>ul>li:before{margin:2px}.viewer-toolbar>ul>li+li{margin-left:1px}.viewer-toolbar>ul>.viewer-small{height:18px;margin-bottom:3px;margin-top:3px;width:18px}.viewer-toolbar>ul>.viewer-small:before{margin:-1px}.viewer-toolbar>ul>.viewer-large{height:30px;margin-bottom:-3px;margin-top:-3px;width:30px}.viewer-toolbar>ul>.viewer-large:before{margin:5px}.viewer-tooltip{background-color:rgba(0,0,0,.8);border-radius:10px;color:#fff;display:none;font-size:12px;height:20px;left:50%;line-height:20px;margin-left:-25px;margin-top:-10px;position:absolute;text-align:center;top:50%;width:50px}.viewer-title{color:#ccc;display:inline-block;font-size:12px;line-height:1.2;margin:5px 5%;max-width:90%;min-height:14px;opacity:.8;overflow:hidden;text-overflow:ellipsis;transition:opacity .15s;white-space:nowrap}.viewer-title:hover{opacity:1}.viewer-button{-webkit-app-region:no-drag;background-color:rgba(0,0,0,.5);border-radius:50%;cursor:pointer;height:80px;overflow:hidden;position:absolute;right:-40px;top:-40px;transition:background-color .15s;width:80px}.viewer-button:focus,.viewer-button:hover{background-color:rgba(0,0,0,.8)}.viewer-button:focus{box-shadow:0 0 3px #fff;outline:0}.viewer-button:before{bottom:15px;left:15px;position:absolute}.viewer-fixed{position:fixed}.viewer-open{overflow:hidden}.viewer-show{display:block}.viewer-hide{display:none}.viewer-backdrop{background-color:rgba(0,0,0,.5)}.viewer-invisible{visibility:hidden}.viewer-move{cursor:move;cursor:grab}.viewer-fade{opacity:0}.viewer-in{opacity:1}.viewer-transition{transition:all .3s}@keyframes viewer-spinner{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.viewer-loading:after{animation:viewer-spinner 1s linear infinite;border:4px solid hsla(0,0%,100%,.1);border-left-color:hsla(0,0%,100%,.5);border-radius:50%;content:"";display:inline-block;height:40px;left:50%;margin-left:-20px;margin-top:-20px;position:absolute;top:50%;width:40px;z-index:1}@media (max-width:767px){.viewer-hide-xs-down{display:none}}@media (max-width:991px){.viewer-hide-sm-down{display:none}}@media (max-width:1199px){.viewer-hide-md-down{display:none}} \ No newline at end of file diff --git a/tests/browser/fixtures/viewer-runtime/viewer-1.11.1.min.js b/tests/browser/fixtures/viewer-runtime/viewer-1.11.1.min.js new file mode 100644 index 00000000..5e7bb0af --- /dev/null +++ b/tests/browser/fixtures/viewer-runtime/viewer-1.11.1.min.js @@ -0,0 +1,10 @@ +/*! + * Viewer.js v1.11.1 + * https://fengyuanchen.github.io/viewerjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2022-11-06T05:18:19.939Z + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Viewer=e()}(this,function(){"use strict";function s(e,t){var i,n=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,i)),n}function q(n){for(var t=1;t=this.length||this.viewed&&t===this.index)){if(!this.isShown)return this.index=t,this.show();this.viewing&&this.viewing.abort();var e,n,o,s=this.element,a=this.options,r=this.title,l=this.canvas,h=this.items[t],c=h.querySelector("img"),u=Y(c,"originalUrl"),d=c.getAttribute("alt"),m=document.createElement("img");S(a.inheritedAttributes,function(t){var e=c.getAttribute(t);null!==e&&m.setAttribute(t,e)}),m.src=u,m.alt=d,A(a.view)&&X(s,"view",a.view,{once:!0}),!1===P(s,"view",{originalImage:this.images[t],index:t,image:m})||!this.isShown||this.hiding||this.played||((u=this.items[this.index])&&(F(u,f),u.removeAttribute("aria-selected")),R(h,f),h.setAttribute("aria-selected",!0),a.focus&&h.focus(),this.image=m,this.viewed=!1,this.index=t,this.imageData={},R(m,p),a.loading&&R(l,b),l.innerHTML="",l.appendChild(m),this.renderList(),r.innerHTML="",X(s,z,e=function(){var t=i.imageData,e=Array.isArray(a.title)?a.title[1]:a.title;r.innerHTML=T(e=A(e)?e.call(i,m,t):"".concat(d," (").concat(t.naturalWidth," × ").concat(t.naturalHeight,")"))?e.replace(/&(?!amp;|quot;|#39;|lt;|gt;)/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">"):e},{once:!0}),this.viewing={abort:function(){M(s,z,e),m.complete?i.imageRendering?i.imageRendering.abort():i.imageInitializing&&i.imageInitializing.abort():(m.src="",M(m,y,n),i.timeout&&clearTimeout(i.timeout))}},m.complete?this.load():(X(m,y,n=function(){M(m,x,o),i.load()},{once:!0}),X(m,x,o=function(){M(m,y,n),i.timeout&&(clearTimeout(i.timeout),i.timeout=!1),F(m,p),a.loading&&F(i.canvas,b)},{once:!0}),this.timeout&&clearTimeout(this.timeout),this.timeout=setTimeout(function(){F(m,p),i.timeout=!1},1e3)))}return this},prev:function(){var t=this.index-1;return t<0&&(t=0Math.abs(r)&&(this.pointers={},1
    ',d=(i=d.querySelector(".".concat(g,"-container"))).querySelector(".".concat(g,"-title")),n=i.querySelector(".".concat(g,"-toolbar")),m=i.querySelector(".".concat(g,"-navbar")),o=i.querySelector(".".concat(g,"-button")),a=i.querySelector(".".concat(g,"-canvas")),this.parent=e,this.viewer=i,this.title=d,this.toolbar=n,this.navbar=m,this.button=o,this.canvas=a,this.footer=i.querySelector(".".concat(g,"-footer")),this.tooltipBox=i.querySelector(".".concat(g,"-tooltip")),this.player=i.querySelector(".".concat(g,"-player")),this.list=i.querySelector(".".concat(g,"-list")),i.id="".concat(g).concat(this.id),d.id="".concat(g,"Title").concat(this.id),R(d,s.title?Et(Array.isArray(s.title)?s.title[0]:s.title):v),R(m,s.navbar?Et(s.navbar):v),N(o,v,!s.button),s.keyboard&&o.setAttribute("tabindex",0),s.backdrop&&(R(i,"".concat(g,"-backdrop")),s.inline||"static"===s.backdrop||bt(a,ct,"hide")),T(s.className)&&s.className&&s.className.split(ut).forEach(function(t){R(i,t)}),s.toolbar?(r=document.createElement("ul"),l=I(s.toolbar),h=dt.slice(0,3),c=dt.slice(7,9),u=dt.slice(9),l||R(n,Et(s.toolbar)),S(l?s.toolbar:dt,function(t,e){var i,n=l&&I(t),e=l?pt(e):t,o=n&&!D(t.show)?t.show:t;!o||!s.zoomable&&-1!==h.indexOf(e)||!s.rotatable&&-1!==c.indexOf(e)||!s.scalable&&-1!==u.indexOf(e)||(i=n&&!D(t.size)?t.size:t,n=n&&!D(t.click)?t.click:t,t=document.createElement("li"),s.keyboard&&t.setAttribute("tabindex",0),t.setAttribute("role","button"),R(t,"".concat(g,"-").concat(e)),A(n)||bt(t,ct,e),E(o)&&R(t,Et(o)),-1!==["small","large"].indexOf(i)?R(t,"".concat(g,"-").concat(i)):"play"===e&&R(t,"".concat(g,"-large")),A(n)&&X(t,w,n),r.appendChild(t))}),n.appendChild(r)):R(n,v),s.rotatable||(R(d=n.querySelectorAll('li[class*="rotate"]'),p),S(d,function(t){n.appendChild(t)})),s.inline?(R(o,V),C(i,{zIndex:s.zIndexInline}),"static"===window.getComputedStyle(e).position&&C(e,{position:"relative"}),e.insertBefore(i,t.nextSibling)):(R(o,j),R(i,B),R(i,H),R(i,v),C(i,{zIndex:s.zIndex}),(m=(m=T(m=s.container)?t.ownerDocument.querySelector(m):m)||this.body).appendChild(i)),s.inline&&(this.render(),this.bind(),this.isShown=!0),this.ready=!0,A(s.ready)&&X(t,rt,s.ready,{once:!0}),!1===P(t,rt)?this.ready=!1:this.ready&&s.inline&&this.view(this.index))}}])&&r(t.prototype,e),i&&r(t,i),Object.defineProperty(t,"prototype",{writable:!1}),o}();return O(e.prototype,i,t,At,St,Ot),e}); \ No newline at end of file diff --git a/tests/browser/harness/runtime.js b/tests/browser/harness/runtime.js new file mode 100644 index 00000000..460bf45a --- /dev/null +++ b/tests/browser/harness/runtime.js @@ -0,0 +1,194 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { performance } from "node:perf_hooks"; + +const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(browserRoot, "..", ".."); + +export async function fulfillHostFixtures(context) { + const javdb = await readFile(join(browserRoot, "fixtures", "javdb-detail.html"), "utf8"); + const javbus = await readFile(join(browserRoot, "fixtures", "javbus-detail.html"), "utf8"); + const javdbList = await readFile(join(browserRoot, "fixtures", "javdb-list.html"), "utf8"); + const javdbFc2List = await readFile(join(browserRoot, "fixtures", "javdb-fc2-list.html"), "utf8"); + const javdbHitShow = await readFile(join(browserRoot, "fixtures", "javdb-hit-show.html"), "utf8"); + const javbusList = await readFile(join(browserRoot, "fixtures", "javbus-list.html"), "utf8"); + await context.route("**/*", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.isNavigationRequest()) { + if (url.hostname === "javdb.com") return route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: url.pathname.startsWith("/v/") ? javdb : url.pathname === "/advanced_search" && url.searchParams.has("handlePlayback") ? javdbHitShow : url.pathname === "/advanced_search" ? javdbFc2List : javdbList }); + if (url.hostname === "www.javbus.com") return route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: url.pathname === "/" ? javbusList : javbus }); + } + if (url.hostname === "c0.jdbstatic.com" && url.pathname === "/thumbs/top-fixture.jpg") return route.fulfill({ status: 200, contentType: "image/svg+xml", body: '' }); + return route.abort("blockedbyclient"); + }); +} + +export async function injectUserscriptRuntime(page, options = {}) { + const startupErrors = []; + const hostPage = typeof page.context === "function" ? page : page.page(); + const browserVersion = hostPage.context().browser()?.version() || "unknown"; + const phaseStartedAt = performance.now(), bootstrapPhases = {}; + const markPhase = (name) => { bootstrapPhases[name] = performance.now() - phaseStartedAt; }; + hostPage.on("pageerror", (error) => startupErrors.push(error.stack || error.message)); + hostPage.on("console", (message) => { + if (message.type() === "error") startupErrors.push(message.text()); + }); + await page.addScriptTag({ path: join(browserRoot, "node_modules", "jquery", "dist", "jquery.min.js") }); + markPhase("jquery"); + await page.addScriptTag({ path: join(browserRoot, "node_modules", "localforage", "dist", "localforage.min.js") }); + markPhase("localforage"); + await page.addScriptTag({ path: join(browserRoot, "node_modules", "tabulator-tables", "dist", "js", "tabulator.min.js") }); + markPhase("tabulator"); + await page.addStyleTag({ path: join(browserRoot, "node_modules", "tabulator-tables", "dist", "css", "tabulator.min.css") }); + markPhase("vendor-style"); + await page.evaluate(async ({ disabledPlugins, settingOverrides }) => { + const forage = window.localforage.createInstance({ driver: window.localforage.INDEXEDDB, name: "JAV-JHS", version: 1, storeName: "appData" }); + await forage.setItem("setting", { + translateTitle: "no", + httpRetryCount: 1, + circuitBreakerThreshold: 1, + ...(disabledPlugins?.length ? { disabledPlugins: JSON.stringify(disabledPlugins) } : {}), + ...settingOverrides, + }); + }, { disabledPlugins: options.disabledPlugins || [], settingOverrides: options.settingOverrides || {} }); + await page.evaluate(({ version, nativeTranslation, rankingMovies, topMovies }) => { + window.__jhsBrowserTestMetadata = { fixture: true, version }; + window.__jhsBrowserDiagnostics = { requests: [], nativeTranslationRequests: 0, startedAt: performance.now() }; + window.unsafeWindow = window; + window.GM_getValue = (_key, fallback) => fallback; + window.GM_setValue = () => undefined; + window.GM_openInTab = () => ({ close() {} }); + window.GM_xmlhttpRequest = (options) => { + window.__jhsBrowserDiagnostics.requests.push({ method: options.method || "GET", url: String(options.url || "") }); + let aborted = false; + queueMicrotask(() => { + if (aborted) return; + if (topMovies && String(options.url || "").includes("/api/v1/movies/top")) { + const payload = { success: 1, data: { movies: topMovies } }; + options.onload?.({ status: 200, response: payload, responseText: JSON.stringify(payload), responseHeaders: "content-type: application/json" }); + } else if (rankingMovies && String(options.url || "").includes("/api/v1/rankings/playback")) { + // rankingMovies 可为数组(所有周期同数据)或按 period 键控的对象(验证周期切换管线) + const periodMatch = /[?&]period=(\w+)/.exec(String(options.url || "")); + const movies = Array.isArray(rankingMovies) ? rankingMovies : rankingMovies[periodMatch?.[1] || "daily"] ?? rankingMovies.daily ?? []; + const payload = { success: 1, data: { movies } }; + options.onload?.({ status: 200, response: payload, responseText: JSON.stringify(payload), responseHeaders: "content-type: application/json" }); + } else options.onerror?.({ error: "Browser fixture blocked external request" }); + }); + return { abort() { aborted = true; options.onabort?.(); } }; + }; + if (nativeTranslation) { + const originalFetch = window.fetch.bind(window); + window.fetch = (input, init) => { + if (String(input).startsWith("https://translate-pa.googleapis.com/v1/translate")) { + window.__jhsBrowserDiagnostics.nativeTranslationRequests += 1; + return Promise.resolve(new Response(JSON.stringify({ translation: nativeTranslation }), { + status: 200, headers: { "content-type": "application/json" }, + })); + } + return originalFetch(input, init); + }; + } + window.md5 = (value) => `fixture-${String(value).length}`; + window.Toastify = () => ({ showToast() {} }); + window.Viewer = class { show() {} hide() {} destroy() {} update() {} }; + window.requestIdleCallback ||= (callback) => setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 0); + let layerId = 0; + const mountedLayers = new Map(); + window.layer = { + open(options = {}) { + const id = ++layerId; + const host = document.createElement("div"); + host.className = "layui-layer"; + host.id = `layui-layer${id}`; + host.dataset.layerId = String(id); + host.style.position = "fixed"; + host.style.top = "50%"; + host.style.left = "50%"; + host.style.transform = "translate(-50%, -50%)"; + const area = Array.isArray(options.area) ? options.area : [options.area, null]; + if (typeof area[0] === "string" && area[0] !== "auto") host.style.width = area[0]; + if (typeof area[1] === "string" && area[1] !== "auto") host.style.height = area[1]; + if (options.zIndex != null) host.style.zIndex = String(options.zIndex); + const contentHost = document.createElement("div"); + contentHost.className = "layui-layer-content"; + contentHost.style.height = "100%"; + contentHost.style.overflow = "auto"; + contentHost.style.boxSizing = "border-box"; + const content = options.content?.jquery ? options.content[0] : options.content; + if (Number(options.type) === 2) { + const iframe = content instanceof HTMLIFrameElement ? content : document.createElement("iframe"); + iframe.id = `layui-layer-iframe${id}`; + iframe.name = iframe.id; + if (typeof content === "string") iframe.src = content; + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "0"; + contentHost.append(iframe); + } else if (content instanceof Node) contentHost.append(content); + else if (typeof content === "string") contentHost.innerHTML = content; + host.append(contentHost); + document.body.append(host); + mountedLayers.set(id, { host, options }); + options.success?.(window.jQuery(host), id); + return id; + }, + close(id) { + const mounted = mountedLayers.get(id); + mounted?.options?.end?.(); + mounted?.host?.remove(); + mountedLayers.delete(id); + }, + closeAll() { [...mountedLayers.keys()].forEach((id) => this.close(id)); }, + style(id, styles) { + const host = mountedLayers.get(id)?.host; + if (!host) return; + Object.assign(host.style, styles); + if (styles.left != null || styles.top != null) host.style.transform = "none"; + }, + confirm(message, options = {}, yes, cancel) { + const content = document.createElement("div"); + content.className = "layui-layer-dialog-content"; + content.textContent = String(message); + const id = this.open({ ...options, type: 1, content }); + const mounted = mountedLayers.get(id); + const buttons = document.createElement("div"); + buttons.className = "layui-layer-btn"; + const labels = Array.isArray(options.btn) ? options.btn : ["确定", "取消"]; + labels.forEach((label, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `layui-layer-btn${index}`; + button.textContent = String(label); + button.addEventListener("click", () => index === 0 ? yes?.(id) : (cancel?.(id), this.close(id))); + buttons.append(button); + }); + mounted?.host?.append(buttons); + return id; + }, + alert(message, options = {}) { return this.open({ ...options, content: String(message) }); }, + msg() {} + }; + }, { version: browserVersion, nativeTranslation: options.nativeTranslation || "", rankingMovies: options.rankingMovies || null, topMovies: options.topMovies || null }); + markPhase("fixture-setup"); + await page.addScriptTag({ path: join(repoRoot, "JHS.user.js") }); + markPhase("userscript-eval"); + try { + await page.waitForFunction(() => Boolean(window.unsafeWindow?.pluginManager?.getStartupReport), null, { timeout: 15_000 }); + markPhase("startup-ready"); + await page.evaluate((phases) => { window.__jhsBrowserDiagnostics.bootstrapPhases = { ...(window.__jhsBrowserDiagnostics.bootstrapPhases || {}), harness: phases }; }, bootstrapPhases); + } catch (error) { + throw new Error(`JHS fixture bootstrap failed: ${startupErrors.join("\n") || error.message}`); + } +} + +export async function assertNoHorizontalOverflow(page) { + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth + })); + if (dimensions.scrollWidth > dimensions.clientWidth + 1) { + throw new Error(`Horizontal overflow: ${dimensions.scrollWidth}px > ${dimensions.clientWidth}px`); + } +} diff --git a/tests/browser/package-lock.json b/tests/browser/package-lock.json new file mode 100644 index 00000000..f8ff47aa --- /dev/null +++ b/tests/browser/package-lock.json @@ -0,0 +1,123 @@ +{ + "name": "jhs-browser-smoke", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jhs-browser-smoke", + "devDependencies": { + "@playwright/test": "1.62.1", + "jquery": "3.7.1", + "localforage": "1.10.0", + "tabulator-tables": "6.3.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tabulator-tables": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/tabulator-tables/-/tabulator-tables-6.3.1.tgz", + "integrity": "sha512-qFW7kfadtcaISQIibKAIy0f3eeIXUVi8242Vly1iJfMD79kfEGzfczNuPBN/80hDxHzQJXYbmJ8VipI40hQtfA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/browser/package.json b/tests/browser/package.json new file mode 100644 index 00000000..29cafa7d --- /dev/null +++ b/tests/browser/package.json @@ -0,0 +1,21 @@ +{ + "name": "jhs-browser-smoke", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "pretest": "node scripts/check-local-browser.mjs", + "test": "npm run test:startup && npm run test:functional", + "test:startup": "playwright test specs/startup-performance.spec.js --project=desktop-wide", + "test:functional": "playwright test --grep-invert \"fixture startup remains within the reviewed median\"", + "test:visual": "node scripts/visual-gate.mjs" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "jquery": "3.7.1", + "localforage": "1.10.0", + "tabulator-tables": "6.3.1" + } +} diff --git a/tests/browser/playwright.config.js b/tests/browser/playwright.config.js new file mode 100644 index 00000000..5b2fe218 --- /dev/null +++ b/tests/browser/playwright.config.js @@ -0,0 +1,33 @@ +import { defineConfig } from "@playwright/test"; + +const visualRegression = process.env.JHS_VISUAL_REGRESSION === "1"; +const channel = visualRegression || process.env.CI ? undefined : process.env.JHS_BROWSER_CHANNEL || "msedge"; +const viewports = [ + ["desktop-wide", 1440, 900, false], + ["desktop-regular", 1280, 720, false], + ["tablet", 820, 1180, true], + ["mobile", 390, 844, true], + ["mobile-small", 360, 640, true], + ["mobile-landscape", 844, 390, true] +]; + +export default defineConfig({ + testDir: "./specs", + outputDir: "../../output/playwright/test-results", + snapshotDir: "./screenshots/baseline", + snapshotPathTemplate: "{snapshotDir}/{arg}-{projectName}-{platform}{ext}", + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: [["line"], ["html", { outputFolder: "../../output/playwright/report", open: "never" }]], + use: { + channel, + headless: true, + serviceWorkers: "block", + trace: "retain-on-failure", + screenshot: "only-on-failure" + }, + projects: viewports.map(([name, width, height, touch]) => ({ + name, + use: { viewport: { width, height }, hasTouch: touch, isMobile: touch } + })) +}); diff --git a/tests/browser/screenshots/baseline/FC2-Detail-dark-desktop-wide-linux.png b/tests/browser/screenshots/baseline/FC2-Detail-dark-desktop-wide-linux.png new file mode 100644 index 00000000..db3e7892 Binary files /dev/null and b/tests/browser/screenshots/baseline/FC2-Detail-dark-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/FC2-Detail-dark-desktop-wide-win32.png b/tests/browser/screenshots/baseline/FC2-Detail-dark-desktop-wide-win32.png new file mode 100644 index 00000000..d435b7cb Binary files /dev/null and b/tests/browser/screenshots/baseline/FC2-Detail-dark-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/FC2-Detail-dark-mobile-linux.png b/tests/browser/screenshots/baseline/FC2-Detail-dark-mobile-linux.png new file mode 100644 index 00000000..3c590ef4 Binary files /dev/null and b/tests/browser/screenshots/baseline/FC2-Detail-dark-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/FC2-Detail-dark-mobile-win32.png b/tests/browser/screenshots/baseline/FC2-Detail-dark-mobile-win32.png new file mode 100644 index 00000000..2eb394ee Binary files /dev/null and b/tests/browser/screenshots/baseline/FC2-Detail-dark-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/FC2-Detail-light-desktop-wide-linux.png b/tests/browser/screenshots/baseline/FC2-Detail-light-desktop-wide-linux.png new file mode 100644 index 00000000..2157b262 Binary files /dev/null and b/tests/browser/screenshots/baseline/FC2-Detail-light-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/FC2-Detail-light-desktop-wide-win32.png b/tests/browser/screenshots/baseline/FC2-Detail-light-desktop-wide-win32.png new file mode 100644 index 00000000..3e1f3d07 Binary files /dev/null and b/tests/browser/screenshots/baseline/FC2-Detail-light-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/FC2-Detail-light-mobile-linux.png b/tests/browser/screenshots/baseline/FC2-Detail-light-mobile-linux.png new file mode 100644 index 00000000..9d81e9a5 Binary files /dev/null and b/tests/browser/screenshots/baseline/FC2-Detail-light-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/FC2-Detail-light-mobile-win32.png b/tests/browser/screenshots/baseline/FC2-Detail-light-mobile-win32.png new file mode 100644 index 00000000..4d972f01 Binary files /dev/null and b/tests/browser/screenshots/baseline/FC2-Detail-light-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide-linux.png new file mode 100644 index 00000000..80cb6ec2 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide-win32.png new file mode 100644 index 00000000..ba69a950 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-dark-mobile-linux.png b/tests/browser/screenshots/baseline/JavBus-Detail-dark-mobile-linux.png new file mode 100644 index 00000000..d06fbc5f Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-Detail-dark-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-dark-mobile-win32.png b/tests/browser/screenshots/baseline/JavBus-Detail-dark-mobile-win32.png new file mode 100644 index 00000000..baa2c8ca Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-Detail-dark-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide-linux.png new file mode 100644 index 00000000..17add1e8 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide-win32.png new file mode 100644 index 00000000..4cd52319 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-light-mobile-linux.png b/tests/browser/screenshots/baseline/JavBus-Detail-light-mobile-linux.png new file mode 100644 index 00000000..27fa8fdc Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-Detail-light-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-light-mobile-win32.png b/tests/browser/screenshots/baseline/JavBus-Detail-light-mobile-win32.png new file mode 100644 index 00000000..7903b952 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-Detail-light-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-List-dark-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavBus-List-dark-desktop-wide-linux.png new file mode 100644 index 00000000..3d0f4a56 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-List-dark-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-List-dark-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavBus-List-dark-desktop-wide-win32.png new file mode 100644 index 00000000..c3a12949 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-List-dark-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-List-dark-mobile-linux.png b/tests/browser/screenshots/baseline/JavBus-List-dark-mobile-linux.png new file mode 100644 index 00000000..2fce4fef Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-List-dark-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-List-dark-mobile-win32.png b/tests/browser/screenshots/baseline/JavBus-List-dark-mobile-win32.png new file mode 100644 index 00000000..e5edae12 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-List-dark-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-List-light-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavBus-List-light-desktop-wide-linux.png new file mode 100644 index 00000000..73c8b284 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-List-light-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-List-light-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavBus-List-light-desktop-wide-win32.png new file mode 100644 index 00000000..1c45fe47 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-List-light-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-List-light-mobile-linux.png b/tests/browser/screenshots/baseline/JavBus-List-light-mobile-linux.png new file mode 100644 index 00000000..ade11ae1 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-List-light-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavBus-List-light-mobile-win32.png b/tests/browser/screenshots/baseline/JavBus-List-light-mobile-win32.png new file mode 100644 index 00000000..ade11ae1 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavBus-List-light-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-Detail-dark-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavDB-Detail-dark-desktop-wide-linux.png new file mode 100644 index 00000000..6a5d0d63 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-Detail-dark-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-Detail-dark-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavDB-Detail-dark-desktop-wide-win32.png new file mode 100644 index 00000000..1250601c Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-Detail-dark-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-Detail-dark-mobile-linux.png b/tests/browser/screenshots/baseline/JavDB-Detail-dark-mobile-linux.png new file mode 100644 index 00000000..57eef343 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-Detail-dark-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-Detail-dark-mobile-win32.png b/tests/browser/screenshots/baseline/JavDB-Detail-dark-mobile-win32.png new file mode 100644 index 00000000..c3ac38cf Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-Detail-dark-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-Detail-light-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavDB-Detail-light-desktop-wide-linux.png new file mode 100644 index 00000000..e2beb109 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-Detail-light-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-Detail-light-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavDB-Detail-light-desktop-wide-win32.png new file mode 100644 index 00000000..ff5fa719 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-Detail-light-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-Detail-light-mobile-linux.png b/tests/browser/screenshots/baseline/JavDB-Detail-light-mobile-linux.png new file mode 100644 index 00000000..f723a417 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-Detail-light-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-Detail-light-mobile-win32.png b/tests/browser/screenshots/baseline/JavDB-Detail-light-mobile-win32.png new file mode 100644 index 00000000..1f6b9f66 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-Detail-light-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-desktop-wide-linux.png new file mode 100644 index 00000000..76aba569 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-desktop-wide-win32.png new file mode 100644 index 00000000..c635d233 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-mobile-linux.png b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-mobile-linux.png new file mode 100644 index 00000000..e69cd0f5 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-mobile-win32.png b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-mobile-win32.png new file mode 100644 index 00000000..08f1acdc Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-dark-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-desktop-wide-linux.png new file mode 100644 index 00000000..2dbb70f6 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-desktop-wide-win32.png new file mode 100644 index 00000000..4c47b19f Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-mobile-linux.png b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-mobile-linux.png new file mode 100644 index 00000000..f95ed9eb Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-mobile-win32.png b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-mobile-win32.png new file mode 100644 index 00000000..8719b042 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-FC2-Dialog-light-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-dark-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavDB-List-dark-desktop-wide-linux.png new file mode 100644 index 00000000..0f2942f9 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-dark-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-dark-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavDB-List-dark-desktop-wide-win32.png new file mode 100644 index 00000000..18793b9d Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-dark-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-dark-mobile-linux.png b/tests/browser/screenshots/baseline/JavDB-List-dark-mobile-linux.png new file mode 100644 index 00000000..5a0c97f0 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-dark-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-dark-mobile-win32.png b/tests/browser/screenshots/baseline/JavDB-List-dark-mobile-win32.png new file mode 100644 index 00000000..5a0c97f0 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-dark-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-light-desktop-wide-linux.png b/tests/browser/screenshots/baseline/JavDB-List-light-desktop-wide-linux.png new file mode 100644 index 00000000..05705622 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-light-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-light-desktop-wide-win32.png b/tests/browser/screenshots/baseline/JavDB-List-light-desktop-wide-win32.png new file mode 100644 index 00000000..fd2327fe Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-light-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-light-mobile-linux.png b/tests/browser/screenshots/baseline/JavDB-List-light-mobile-linux.png new file mode 100644 index 00000000..4e490430 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-light-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/JavDB-List-light-mobile-win32.png b/tests/browser/screenshots/baseline/JavDB-List-light-mobile-win32.png new file mode 100644 index 00000000..4e490430 Binary files /dev/null and b/tests/browser/screenshots/baseline/JavDB-List-light-mobile-win32.png differ diff --git a/tests/browser/screenshots/baseline/README.md b/tests/browser/screenshots/baseline/README.md new file mode 100644 index 00000000..07921069 --- /dev/null +++ b/tests/browser/screenshots/baseline/README.md @@ -0,0 +1,26 @@ +# Visual regression baselines + +视觉回归是真实 PNG diff:toHaveScreenshot() 会与提交的基线逐像素比较 +(maxDiffPixelRatio: 0.02),不再只是“能截出一张图”。 + +基线固定使用两个确定性 viewport project:`desktop-wide`(1440px)与 +`mobile`(390px)。snapshotPathTemplate 带 `win32` / `linux` 平台后缀, +避免 Windows 与 Linux 字体、栅格化和系统布局差异互相污染。 + +生成/更新当前平台基线(视觉模式始终使用 package-lock 锁定的 Chromium): + + cd tests/browser + npm ci + npx playwright install chromium --with-deps + JHS_VISUAL_REGRESSION=1 npx playwright test specs/visual-regression.spec.js --update-snapshots + +Windows 与 Ubuntu 24.04 的基线必须分别生成并提交;Linux 基线应在与 CI +相同的 Ubuntu 24.04 环境生成。禁止拿一个平台生成的 PNG 覆盖另一平台。 + +门禁命令(根目录): + + npm run check:visual + +普通 check:browser 默认跳过视觉回归;只有 `npm run check:visual` +(内部显式设置 JHS_VISUAL_REGRESSION=1)才与已提交基线做真实 diff。 +基线 PNG 生成后作为普通文件提交。 diff --git a/tests/browser/screenshots/baseline/settings-cloud-desktop-wide-linux.png b/tests/browser/screenshots/baseline/settings-cloud-desktop-wide-linux.png new file mode 100644 index 00000000..51f0ef2f Binary files /dev/null and b/tests/browser/screenshots/baseline/settings-cloud-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/settings-cloud-desktop-wide-win32.png b/tests/browser/screenshots/baseline/settings-cloud-desktop-wide-win32.png new file mode 100644 index 00000000..b57c4542 Binary files /dev/null and b/tests/browser/screenshots/baseline/settings-cloud-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/settings-dialog-desktop-wide-linux.png b/tests/browser/screenshots/baseline/settings-dialog-desktop-wide-linux.png new file mode 100644 index 00000000..8663a4fd Binary files /dev/null and b/tests/browser/screenshots/baseline/settings-dialog-desktop-wide-linux.png differ diff --git a/tests/browser/screenshots/baseline/settings-dialog-desktop-wide-win32.png b/tests/browser/screenshots/baseline/settings-dialog-desktop-wide-win32.png new file mode 100644 index 00000000..815556be Binary files /dev/null and b/tests/browser/screenshots/baseline/settings-dialog-desktop-wide-win32.png differ diff --git a/tests/browser/screenshots/baseline/settings-dialog-mobile-linux.png b/tests/browser/screenshots/baseline/settings-dialog-mobile-linux.png new file mode 100644 index 00000000..b6450be4 Binary files /dev/null and b/tests/browser/screenshots/baseline/settings-dialog-mobile-linux.png differ diff --git a/tests/browser/screenshots/baseline/settings-dialog-mobile-win32.png b/tests/browser/screenshots/baseline/settings-dialog-mobile-win32.png new file mode 100644 index 00000000..f8da0454 Binary files /dev/null and b/tests/browser/screenshots/baseline/settings-dialog-mobile-win32.png differ diff --git a/tests/browser/scripts/check-local-browser.mjs b/tests/browser/scripts/check-local-browser.mjs new file mode 100644 index 00000000..3fd5ea2c --- /dev/null +++ b/tests/browser/scripts/check-local-browser.mjs @@ -0,0 +1,23 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +if (process.env.CI) process.exit(0); + +const channel = process.env.JHS_BROWSER_CHANNEL || "msedge"; +if (!new Set(["msedge", "chrome"]).has(channel)) { + throw new Error(`Unsupported JHS_BROWSER_CHANNEL=${channel}; use msedge or chrome`); +} + +const roots = [process.env["PROGRAMFILES"], process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean); +const relativePaths = channel === "msedge" + ? ["Microsoft/Edge/Application/msedge.exe"] + : ["Google/Chrome/Application/chrome.exe"]; +const candidates = roots.flatMap((root) => relativePaths.map((relative) => path.join(root, relative))); +let found = null; +for (const candidate of candidates) { + try { await access(candidate); found = candidate; break; } catch { /* try the next installed location */ } +} +if (!found) { + throw new Error(`${channel} is not installed in a standard Windows location. JHS browser tests never download a browser; install it or set JHS_BROWSER_CHANNEL to an installed channel.`); +} +console.log(`Using installed ${channel}: ${found}`); diff --git a/tests/browser/scripts/visual-gate.mjs b/tests/browser/scripts/visual-gate.mjs new file mode 100644 index 00000000..7e1718e2 --- /dev/null +++ b/tests/browser/scripts/visual-gate.mjs @@ -0,0 +1,19 @@ +// Visual Regression Release Gate:显式开启 JHS_VISUAL_REGRESSION 后运行 Playwright, +// 与已提交的 PNG baseline 做真实 diff(toHaveScreenshot)。 +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "@playwright/test"; + +process.env.JHS_VISUAL_REGRESSION = "1"; +const browserRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const npx = process.platform === "win32" ? "npx.cmd" : "npx"; +const executablePath = chromium.executablePath(); +if (!existsSync(executablePath)) { + console.error(`Playwright bundled Chromium is missing: ${executablePath}`); + console.error("Install it with: cd tests/browser && npx playwright install chromium"); + process.exit(1); +} +const result = spawnSync(npx, [ "playwright", "test", "specs/visual-regression.spec.js", "--project=desktop-wide", "--project=mobile" ], { cwd: browserRoot, stdio: "inherit", shell: process.platform === "win32" }); +process.exit(result.status ?? 1); diff --git a/tests/browser/specs/cross-surface-workflows.spec.js b/tests/browser/specs/cross-surface-workflows.spec.js new file mode 100644 index 00000000..f856a400 --- /dev/null +++ b/tests/browser/specs/cross-surface-workflows.spec.js @@ -0,0 +1,204 @@ +import { expect, test } from "@playwright/test"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +async function openFc2DialogFixture(context, page, settingOverrides = {}) { + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?type=3", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { settingOverrides }); + await page.evaluate(() => { + const fc2 = window.unsafeWindow.pluginManager.getBean("Fc2Plugin"); + fc2.resolveMovieIdForRecord = async () => "fixture-movie-id"; + fc2.resolveFc2Source = async () => "fc2"; + }); + const primary = page.locator('.movie-list .item a[data-jhs-fc2-primary="true"]'); + await expect(primary).toBeVisible(); + await primary.click(); + const dialog = page.locator('.layui-layer').filter({ has: page.locator('.jhs-fc2-dialog-host') }).first(); + await expect(dialog.locator(".jhs-fc2-workspace")).toBeVisible(); + return dialog; +} + +test("FC2 dialog offline marking keeps movie identity across surfaces", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers the cross-surface offline workflow"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?handlePlayback=1&period=daily", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { + settingOverrides: { enableLoadReview: "no", enableLoadOtherSite: "no", enableLoadScreenShot: "no" }, + rankingMovies: [ + { id: "hot-fixture-fc2", number: "FC2-PPV-4959150", origin_title: "Hot FC2", release_date: "2026-08-28", cover_url: "https://c0.jdbstatic.com/covers/fc2.jpg", has_cnsub: false, magnets_count: 1, new_magnets: false }, + { id: "hot-fixture-underlying", number: "ABC-001", origin_title: "Underlying fixture", release_date: "2026-08-27", cover_url: "https://c0.jdbstatic.com/covers/fixture.jpg", has_cnsub: false, magnets_count: 0, new_magnets: false }, + ], + }); + await page.evaluate(async () => { + await window.stateService.patch("ABC-001", { downloaded: false }, { type: "fixture-seed", record: { carNum: "ABC-001", url: "/v/abc-001", names: "" } }); + const fc2 = window.unsafeWindow.pluginManager.getBean("Fc2Plugin"); + fc2.resolveMovieIdForRecord = async () => null; + fc2.resolveFc2Source = async () => "fc2"; + const card = document.querySelector(".jhs-hitshow-list .item"); + const secondary = document.createElement("div"); + secondary.className = "item"; + secondary.innerHTML = '
    ABC-001
    '; + card?.parentElement?.append(secondary); + }); + const fc2Card = page.locator('.jhs-hitshow-list .item').filter({ hasText: "FC2-PPV-4959150" }); + await expect(fc2Card.locator('a[data-jhs-fc2-primary="true"]')).toBeVisible(); + await fc2Card.locator('a[data-jhs-fc2-primary="true"]').click(); + const dialog = page.locator('.layui-layer').filter({ has: page.locator('.jhs-fc2-dialog-host') }).first(); + await expect(dialog.locator(".jhs-fc2-workspace")).toBeVisible(); + await dialog.locator('[data-jhs-slot="resources"]').evaluate((root) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "jhs-offline-btn"; + button.dataset.resource = "magnet:?xt=urn:btih:fc2-fixture"; + button.textContent = "离线"; + root.append(button); + }); + await page.evaluate(() => { + const offline = window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"); + offline.registry = { + getCandidates: async () => [{ provider: { id: "fixture", name: "Fixture", isEnabled: async () => true, submit: async (_resource, info) => { window.__offlineContext = info; } }, availability: { authState: "ready" } }], + updateAvailability() {}, + }; + window.utils.q = (_event, _message, confirm) => confirm(); + }); + await dialog.locator(".jhs-offline-btn").click(); + await expect.poll(() => page.evaluate(() => window.__offlineContext?.carNum)).toBe("FC2-PPV-4959150"); + await expect.poll(() => page.evaluate(async () => (await window.stateService.getState("FC2-PPV-4959150"))?.stateFlags?.downloaded)).toBe(true); + await expect.poll(() => page.evaluate(async () => Boolean((await window.stateService.getState("ABC-001"))?.stateFlags?.downloaded))).toBe(false); + await expect(dialog).toHaveCount(0); +}); + +test("NewVideo hover preview follows its owning dialog and is destroyed on close", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers overlay ownership"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => { + const plugin = window.unsafeWindow.pluginManager.getBean("NewVideoPlugin"); + window.localStorage.setItem("jhs_newVideoViewMode", "list"); + plugin.reloadNewVideoWorkspaceData = async function() { + this.nvFlatListCache = [{ carNum: "ABC-001", title: "Preview fixture", actressName: "Fixture Actress", publishTime: "2026-09-01", coverUrl: "data:image/gif;base64,R0lGODlhAQABAAAAACw=", flags: {}, decisionState: "pending" }]; + this.nvJavDbUrl = "https://javdb.com"; + this.nvRenderPage(this.nvRenderGeneration); + }; + void plugin.openDialog(); + }); + const cover = page.locator(".newVideoToolBox .nv-cover-img"); + // The fixture uses a tiny data URI; dispatch the real delegated boundary + // event directly so image decoding/viewport visibility cannot mask preview + // ownership and cleanup behavior. + await cover.evaluate((image) => { + image.classList.remove("jhs-is-hidden"); + image.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, cancelable: true, clientX: 80, clientY: 120 })); + }); + await expect.poll(() => page.evaluate(() => document.querySelector(".image-hover-preview")?.classList.contains("active") === true)).toBe(true); + const ordering = await page.evaluate(() => { + const preview = document.querySelector(".image-hover-preview"); + const dialog = [...document.querySelectorAll(".layui-layer")].find((element) => element.querySelector(".newVideoToolBox")); + const viewer = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue("--jhs-z-viewer"), 10); + const loading = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue("--jhs-z-loading"), 10); + return { preview: Number.parseInt(preview?.style.zIndex || "0", 10), dialog: Number.parseInt(dialog?.style.zIndex || "0", 10), viewer, loading }; + }); + expect(ordering.preview).toBeGreaterThan(ordering.dialog); + expect(ordering.preview).toBeLessThan(ordering.viewer); + expect(ordering.preview).toBeLessThan(ordering.loading); + await page.evaluate(() => window.layer.closeAll()); + await expect(page.locator(".image-hover-preview")).toHaveCount(0); +}); + +test("FC2 dialog favorite action targets the FC2 context", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers FC2 state actions"); + const dialog = await openFc2DialogFixture(context, page, { enableLoadReview: "no", enableLoadOtherSite: "no", enableLoadScreenShot: "no" }); + await page.evaluate(() => window.stateService.patch("ABC-001", { favorite: false }, { type: "fixture-seed", record: { carNum: "ABC-001", url: "/v/abc-001", names: "" } })); + await dialog.locator("#favoriteBtn").click(); + await expect.poll(() => page.evaluate(async () => (await window.stateService.getState("FC2-PPV-4959150"))?.stateFlags?.favorite)).toBe(true); + await expect.poll(() => page.evaluate(async () => (await window.stateService.getState("ABC-001"))?.stateFlags?.favorite)).toBe(false); + await expect(dialog).toHaveCount(0); +}); + +test("FC2 dialog watched action targets the FC2 context", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers FC2 state actions"); + const dialog = await openFc2DialogFixture(context, page, { enableLoadReview: "no", enableLoadOtherSite: "no", enableLoadScreenShot: "no" }); + await page.evaluate(() => window.stateService.patch("ABC-001", { watched: false }, { type: "fixture-seed", record: { carNum: "ABC-001", url: "/v/abc-001", names: "" } })); + await dialog.locator("#hasWatchBtn").click(); + await expect.poll(() => page.evaluate(async () => (await window.stateService.getState("FC2-PPV-4959150"))?.stateFlags?.watched)).toBe(true); + await expect.poll(() => page.evaluate(async () => (await window.stateService.getState("ABC-001"))?.stateFlags?.watched)).toBe(false); + await expect(dialog).toHaveCount(0); +}); + +test("NewVideo dialog close removes its owned preview instance", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers NewVideo cleanup"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => { + const plugin = window.unsafeWindow.pluginManager.getBean("NewVideoPlugin"); + plugin.reloadNewVideoWorkspaceData = async function() { + this.nvFlatListCache = [{ carNum: "ABC-001", title: "Cleanup fixture", actressName: "Fixture Actress", publishTime: "2026-09-01", coverUrl: "data:image/gif;base64,R0lGODlhAQABAAAAACw=", flags: {}, decisionState: "pending" }]; + this.nvJavDbUrl = "https://javdb.com"; + this.nvRenderPage(this.nvRenderGeneration); + }; + void plugin.openDialog(); + }); + await expect(page.locator(".newVideoToolBox .nv-cover-img")).toHaveCount(1); + await page.evaluate(() => window.layer.closeAll()); + await expect(page.locator(".newVideoToolBox")).toHaveCount(0); + await expect(page.locator(".image-hover-preview")).toHaveCount(0); + await expect.poll(() => page.evaluate(() => window.imageHoverPreviewObj?.destroyed ?? true)).toBe(true); +}); + +test("cloud settings dependency status remains a custom header beside catalog rows", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers cloud dependency state"); + const dialog = await (async () => { + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const root = page.locator(".layui-layer"); + await expect(root.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + await root.locator('.side-menu-item[data-panel="cloud-services-panel"]').click(); + return root; + })(); + await expect(dialog.locator("#one-one-five-state")).toBeVisible(); + await expect(dialog.locator("#check-one-one-five-login")).toBeVisible(); + await expect(dialog.locator("#cloud-settings-catalog .jhs-setting-row")).toHaveCount(7); + await expect(dialog.locator("#cloud-services-panel")).toContainText("115 状态"); +}); + +test("cloud settings save and reopen retains the canonical provider value", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers settings persistence"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + const openCloud = async () => { + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const root = page.locator(".layui-layer"); + await expect(root.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + await root.locator('.side-menu-item[data-panel="cloud-services-panel"]').click(); + await expect(root.locator("#cloud-settings-catalog")).toBeVisible(); + return root; + }; + let dialog = await openCloud(); + await dialog.locator("#offlineProviderMode").selectOption("115", { force: true }); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").snapshot().offlineProviderMode)).toBe("115"); + await page.evaluate(() => window.layer.closeAll()); + dialog = await openCloud(); + await expect(dialog.locator("#offlineProviderMode")).toHaveValue("115"); +}); + +test("cloud settings reject invalid numeric input by persisting the normalized fallback", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers settings normalization"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const dialog = page.locator(".layui-layer"); + await expect(dialog.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + await dialog.locator('.side-menu-item[data-panel="cloud-services-panel"]').click(); + await dialog.locator("#oneOneFiveCacheMinutes").evaluate((input) => { + input.value = ""; + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").snapshot().oneOneFiveCacheMinutes)).toBe(60); + await expect(dialog.locator("#oneOneFiveCacheMinutes")).toHaveValue("60"); +}); diff --git a/tests/browser/specs/detail-audit-regressions.spec.js b/tests/browser/specs/detail-audit-regressions.spec.js new file mode 100644 index 00000000..7ba24845 --- /dev/null +++ b/tests/browser/specs/detail-audit-regressions.spec.js @@ -0,0 +1,283 @@ +import { test, expect } from "@playwright/test"; +test.beforeEach(({}, testInfo) => { test.skip(!["desktop-wide", "mobile"].includes(testInfo.project.name), "interaction owners"); }); + +import { readFile } from "node:fs/promises"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +const injectRuntime = async (page, options) => { + if (!process.env.JHS_AUDIT_BASELINE) return injectUserscriptRuntime(page, options); + const original = page.addScriptTag.bind(page); + page.addScriptTag = options => original(options.path?.endsWith("JHS.user.js") ? { path: process.env.JHS_AUDIT_BASELINE } : options); + try { return await injectUserscriptRuntime(page, options); } finally { page.addScriptTag = original; } +}; + +async function boot(page, context, { hiddenVideo = false, iframe = false } = {}) { + await fulfillHostFixtures(context); + const base = await readFile(new URL("../fixtures/javdb-detail-interactions.html", import.meta.url), "utf8"); + await context.route("https://javdb.com/v/**", route => route.fulfill({ contentType: "text/html", body: base })); + await page.goto(iframe ? "https://javdb.com/" : "https://javdb.com/v/test-id"); + await injectRuntime(page, { settingOverrides: { enableLoadPreviewVideo: "no", enableLoadReview: "no", enableMagnetsFilter: "no", needClosePage: "no" } }); + await page.waitForFunction(() => Boolean(window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"])); + if (!iframe) return page; + const navigation = page.waitForEvent("framenavigated", { predicate: frame => frame !== page.mainFrame() && frame.url().includes("/v/") }); + await page.locator(".movie-list .video-title").click(); + const frame = await navigation; + await injectRuntime(frame, { settingOverrides: { enableLoadPreviewVideo: "no", enableLoadReview: "no", enableMagnetsFilter: "no", needClosePage: "no" } }); + await frame.waitForFunction(() => Boolean(window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"])); + return frame; +} + +async function nativePlayer(frame) { + await frame.evaluate(() => { + document.querySelector("#preview-video")?.remove(); + const video = document.createElement("video"); video.id = "preview-video"; video.currentTime = 7; video.style.display = "none"; + document.body.append(video); + HTMLMediaElement.prototype.play = function () { this.dataset.auditPlaying = "true"; return Promise.resolve(); }; + HTMLMediaElement.prototype.pause = function () { this.dataset.auditPlaying = "false"; }; + HTMLMediaElement.prototype.load = function () {}; + Object.defineProperty(HTMLMediaElement.prototype, "readyState", { configurable:true, get() { return 4; } }); + Object.defineProperty(HTMLMediaElement.prototype, "paused", { configurable:true, get() { return this.dataset.auditPlaying !== "true"; } }); + const plugin = window.unsafeWindow.pluginManager.getBean("PreviewVideoPlugin"); + window.auditPlugin = plugin; + plugin.getDmmPreview = async () => ({ sources: { "720": "https://example.invalid/test.mp4" }, error: null }); + let trigger = document.querySelector(".preview-video-container"); + if (!trigger) { trigger = document.createElement("button"); trigger.type = "button"; trigger.className = "preview-video-container"; trigger.textContent = "预览"; document.body.append(trigger); } + // Fixture host adapter models Fancybox mounting; the test does not claim real media decoding. + trigger.addEventListener("click", event => { + event.preventDefault(); + const host = document.createElement("div"); host.className = "fancybox-content"; + video.style.display = ""; host.append(video); document.body.append(host); void video.play(); + }); + plugin._previewMounted = false; plugin.mountPreview(); + }); + await frame.locator(".preview-video-container").first().click(); + await expect(frame.locator("#video-bottom-toolbar")).toBeVisible(); +} + +test("open detail iframe shrinks with its viewport and stays usable", async ({ page, context }) => { + await page.setViewportSize({width:1600,height:1000}); + const frame = await boot(page, context, {iframe:true}); + await page.setViewportSize({width:390,height:844}); + await expect.poll(async()=>frame.evaluate(()=>innerWidth)).toBeLessThanOrEqual(390); + expect(await frame.evaluate(()=>innerWidth)).toBeGreaterThan(320); + await expect(frame.locator("#favoriteBtn")).toBeVisible(); + await page.setViewportSize({width:1248,height:900}); + await expect.poll(async()=>frame.evaluate(()=>innerWidth)).toBeGreaterThan(1000); +}); + +test("unopened native preview must not show a second detail toolbar in the list iframe", async ({ page, context }) => { + const frame = await boot(page, context, { hiddenVideo: true, iframe: true }); + await expect(frame.locator("#favoriteBtn")).toBeVisible(); + + console.log("unopened-preview", await frame.evaluate(() => ({ width: innerWidth, toolbarParent: document.querySelector("#video-bottom-toolbar")?.parentElement.tagName, toolbarText: document.querySelector("#video-bottom-toolbar")?.textContent, nativeDisplay: getComputedStyle(document.querySelector("#preview-video")).display }))); + await expect(frame.locator("#video-bottom-toolbar")).not.toBeVisible(); +}); + +test("native preview fast-forward must seek the currently playing native video", async ({ page, context }) => { + await boot(page, context); await nativePlayer(page); + await page.evaluate(() => window.auditPlugin.handleVideo()); + await page.locator("#speed-btn").click(); + expect(await page.locator("#preview-video").evaluate(video => video.currentTime)).toBe(17); +}); + +test("DMM fallback entry must have an existing playback target when no native video exists", async ({ page, context }) => { + await boot(page, context); await nativePlayer(page); + await page.evaluate(async () => { + document.querySelector("#preview-video").remove(); document.querySelectorAll(".preview-video-container").forEach(node => node.remove()); + document.body.insertAdjacentHTML("beforeend", '
    '); + await window.auditPlugin.getRuntimeService("settings").set("enableLoadPreviewVideo", "yes"); + await window.auditPlugin.initDmm(window.auditPlugin.lifecycleScope); + }); + await expect(page.locator("[data-jhs-dmm-trigger]").first()).toBeVisible(); + await page.locator("[data-jhs-dmm-trigger]").first().click(); + await expect(page.locator("[data-jhs-preview-host] #jhs-preview-video")).toBeVisible(); + await expect(page.locator("[data-jhs-preview-host] #speed-btn")).toBeVisible(); +}); + +test("disabling preview while media.play is pending must not resurrect toolbar or hide native video", async ({ page, context }) => { + await boot(page, context); await nativePlayer(page); + await page.evaluate(async () => { + HTMLMediaElement.prototype.play = function () { return this.id === "jhs-preview-video" ? new Promise(resolve => { window.auditReleasePlayback = resolve; }) : Promise.resolve(); }; + await window.auditPlugin.getRuntimeService("settings").set("enableLoadPreviewVideo", "yes"); + }); + await expect.poll(() => page.evaluate(() => typeof window.auditReleasePlayback)).toBe("function"); + await page.evaluate(async () => { await window.auditPlugin.getRuntimeService("settings").set("enablePreviewVideo", "no"); window.auditReleasePlayback(); }); + expect(await page.locator("#preview-video").evaluate(video => video.classList.contains("jhs-native-preview-hidden"))).toBe(false); + await expect(page.locator("#video-bottom-toolbar")).toHaveCount(0); +}); + +test("DMM OFF must resume the native player previously paused by DMM", async ({ page, context }) => { + await boot(page, context); await nativePlayer(page); + await page.evaluate(async () => { await window.auditPlugin.getRuntimeService("settings").set("enableLoadPreviewVideo", "yes"); await window.auditPlugin.handleVideo(); }); + await expect(page.locator("#jhs-preview-video")).toHaveCount(1); + await page.evaluate(() => window.auditPlugin.getRuntimeService("settings").set("enableLoadPreviewVideo", "no")); + await expect(page.locator("#jhs-preview-video")).toHaveCount(0); + expect(await page.locator("#preview-video").getAttribute("data-audit-playing")).toBe("true"); +}); + +test("unloadable DMM cannot hide a paused native preview", async ({page,context}) => { + await boot(page,context); await nativePlayer(page); + await page.evaluate(async()=>{ + document.querySelector("#preview-video").pause(); + Object.defineProperty(HTMLMediaElement.prototype,"readyState",{configurable:true,get(){return this.id === "jhs-preview-video" ? 0 : 4;}}); + HTMLMediaElement.prototype.load=function(){ if(this.id === "jhs-preview-video") setTimeout(()=>this.dispatchEvent(new Event("error")),0); }; + await window.auditPlugin.getRuntimeService("settings").set("enableLoadPreviewVideo","yes"); + }); + await expect(page.locator("#jhs-preview-video")).toHaveCount(0); + await expect(page.locator("#preview-video")).toBeVisible(); + expect(await page.locator("#preview-video").evaluate(v=>v.paused)).toBe(true); + await expect(page.locator("#speed-btn")).toBeVisible(); +}); + +test("DMM ON must enhance an already open native preview without reopening it", async ({ page, context }) => { + await boot(page, context); await nativePlayer(page); + await page.evaluate(() => window.auditPlugin.handleVideo()); + await page.evaluate(() => window.auditPlugin.getRuntimeService("settings").set("enableLoadPreviewVideo", "yes")); + await expect(page.locator("#jhs-preview-video")).toHaveCount(1); +}); + +test("preview favorite control must reflect the saved favorite state", async ({ page, context }) => { + await boot(page, context); await nativePlayer(page); + await page.evaluate(async () => { + await window.auditPlugin.handleVideo(); + await window.stateService.patch("ABC-123", { favorite: true }, { type: "audit", record: { carNum: "ABC-123" } }); + await window.unsafeWindow.pluginManager.getBean("DetailPageButtonPlugin").showStatus("ABC-123"); + }); + await expect(page.locator("#favoriteBtn")).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("#video-favoriteBtn")).toHaveAttribute("aria-pressed", "true"); +}); + +test("two rapid offline clicks during availability checks must submit only once", async ({ page, context }) => { + await boot(page, context); + await page.evaluate(() => { + document.body.insertAdjacentHTML("beforeend", ''); + window.jQuery("#audit-offline").data("jhsMovieContext", { carNum: "ABC-123", surface: "native-detail" }); + window.auditOffline = window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"); + window.auditPendingAvailability = []; window.auditSubmits = 0; + window.auditCandidate = { provider: { id: "123", name: "123", isEnabled: async () => true, submit: async () => { window.auditSubmits++; } }, availability: { authState: "ready" } }; + window.auditOffline.registry = { getCandidates: () => new Promise(resolve => window.auditPendingAvailability.push(resolve)), updateAvailability() {} }; + }); + await page.locator("#audit-offline").click(); await page.locator("#audit-offline").dispatchEvent("click"); + await page.evaluate(() => window.auditPendingAvailability.forEach(resolve => resolve([window.auditCandidate]))); + console.log("offline-doubleclick", await page.evaluate(() => ({availability: window.auditPendingAvailability.length, submissions: window.auditSubmits, button:document.querySelector("#audit-offline").textContent}))); + await expect.poll(() => page.locator("#audit-offline").textContent()).toBe("已提交"); + expect(await page.evaluate(() => window.auditSubmits)).toBe(1); +}); + +test("offline remote success followed by history failure must not be recorded as remote failure", async ({ page, context }) => { + await boot(page, context); + const result = await page.evaluate(async () => { + const offline = window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"), button = window.jQuery('').appendTo(document.body); + let calls = 0; const history = []; + offline.registry = { getCandidates: async () => [{ provider: { id: "123", name: "123", isEnabled: async () => true, submit: async () => { calls++; } }, availability: { authState: "ready" } }], updateAvailability() {} }; + offline.getRuntimeService("state").appendOfflineHistory = async row => { history.push(row.status); if (history.length === 1) throw new Error("local history write failed"); }; + await offline.submitResource({ currentTarget: button[0] }, "magnet:?xt=urn:btih:audit", button, { carNum: "ABC-123" }); + return { calls, history, buttonText: button.text() }; + }); + console.log("offline-history-failure", result); + expect(result.calls).toBe(1); + expect(result.history).not.toContain("failed"); +}); + +test("mark downloaded with automatic close disabled must not report a close failure", async ({ page, context }) => { + await boot(page, context); + const result = await page.evaluate(async () => { + const offline = window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"), messages = []; + window.show.error = message => messages.push(String(message)); + await offline.markDownloadedAndClose({ carNum: "ABC-123" }, { root: document, layerIndex: null }); + return { messages, downloaded: (await offline.getRuntimeService("state").getState("ABC-123")).stateFlags.downloaded }; + }); + expect(result.downloaded).toBe(true); + expect(result.messages).toEqual([]); +}); + +test("list iframe preview closes, reopens, and persists favorite without a second detail toolbar", async ({ page, context }) => { + const frame = await boot(page, context, { iframe: true }); + await nativePlayer(frame); + await frame.locator("#video-favoriteBtn").click(); + await expect(frame.locator("#video-favoriteBtn")).toHaveAttribute("aria-pressed", "true"); + await expect(frame.locator("#favoriteBtn")).toHaveAttribute("aria-pressed", "true"); + await frame.evaluate(() => { + const host = document.querySelector(".fancybox-content"), video = host.querySelector("video"); + video.style.display = "none"; document.body.append(video); host.remove(); + }); + await expect(frame.locator("#video-bottom-toolbar")).toHaveCount(0); + await frame.locator(".preview-video-container").click(); + await expect(frame.locator("#video-bottom-toolbar")).toHaveCount(1); + await expect(frame.locator("#video-favoriteBtn")).toHaveAttribute("aria-pressed", "true"); + await frame.locator("#video-favoriteBtn").click(); + await expect(frame.locator("#favoriteBtn")).toHaveAttribute("aria-pressed", "false"); + expect(await frame.evaluate(() => document.querySelector("#preview-video").getAttribute("src"))).toBeNull(); +}); + +test("saved state reaches an already open preview in another tab", async ({page,context}) => { + await boot(page,context); await nativePlayer(page); + const other = await context.newPage(); await boot(other,context); await nativePlayer(other); + await page.locator("#video-favoriteBtn").click(); + await expect(other.locator("#video-favoriteBtn")).toHaveAttribute("aria-pressed","true"); + await expect(other.locator("#favoriteBtn")).toHaveAttribute("aria-pressed","true"); + await other.close(); +}); + +test("native magnet layout uses container width and keeps host operations after sorting", async ({ page, context }, testInfo) => { + const frame = await boot(page, context, { iframe: true }); + await frame.addStyleTag({ path: new URL("../fixtures/javdb-magnet-host.css", import.meta.url).pathname.replace(/^\/(\w:)/, "$1") }); + await frame.evaluate(() => { + const root = document.querySelector("#magnets-content"), row = root.firstElementChild; + row.querySelector(".name").textContent = "ABC-123 " + "VeryLongResourceName".repeat(12); + root.append(row.cloneNode(true)); + window.fixtureCopies = 0; + root.querySelectorAll(".copy-to-clipboard").forEach(button => button.addEventListener("click", () => window.fixtureCopies++)); + document.querySelector('select[data-action*="magnet-sort"]').addEventListener("change", () => root.prepend(root.lastElementChild)); + }); + await expect(frame.locator("[data-jhs-magnet-row]")).toHaveCount(2); + for (const width of [360,390,768,992,1248,1408]) { + await page.setViewportSize({ width: width + 32, height: 1000 }); + await frame.evaluate(width => { + const root = document.querySelector("#magnets-content"); root.style.width = `${width}px`; root.style.maxWidth = "none"; + }, width); + const geometry = await frame.locator("[data-jhs-magnet-row]").first().evaluate(row => { + const box = node => { const r = node.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom }; }; + return { display:getComputedStyle(row).display, style:row.getAttribute("style"), grid:getComputedStyle(row).gridTemplateAreas, row:box(row), info:box(row.querySelector('[data-jhs-magnet-part="info"]')), date:box(row.querySelector('[data-jhs-magnet-part="date"]')), actions:box(row.querySelector('[data-jhs-magnet-part="actions"]')), buttons:[...row.querySelector('[data-jhs-magnet-part="actions"]').children].map(box) }; + }); + expect(geometry.buttons).toHaveLength(3); + const appearances = await frame.locator('[data-jhs-magnet-part="actions"]').first().evaluate(root => [...root.children].map(button => { const s=getComputedStyle(button); return [s.backgroundColor,s.color,s.borderRadius,s.fontSize]; })); + expect(appearances[0]).toEqual(appearances[1]); + expect(appearances[1]).toEqual(appearances[2]); + expect(Math.max(...geometry.buttons.map(x=>x.y))-Math.min(...geometry.buttons.map(x=>x.y))).toBeLessThanOrEqual(1); + expect(Math.max(...geometry.buttons.map(x=>x.height))-Math.min(...geometry.buttons.map(x=>x.height))).toBeLessThanOrEqual(1); + expect(geometry.buttons.at(-1).right).toBeLessThanOrEqual(geometry.row.right + 1); + if (width >= 768) { + expect(geometry.info.right).toBeLessThanOrEqual(geometry.date.x); + expect(geometry.date.right).toBeLessThanOrEqual(geometry.actions.x); + } else { + expect(geometry.date.y).toBeGreaterThanOrEqual(geometry.info.bottom); + expect(geometry.actions.y).toBeGreaterThanOrEqual(geometry.date.bottom); + expect(geometry.buttons[0].height).toBeGreaterThanOrEqual(44); + } + } + await frame.locator('select[data-action*="magnet-sort"]').selectOption("1", {force:true}); + await frame.locator(".copy-to-clipboard").first().click(); + expect(await frame.evaluate(() => window.fixtureCopies)).toBe(1); + await expect(frame.locator(".jhs-offline-native")).toHaveCount(2); + await expect(frame.locator('[data-jhs-magnet-part="actions"] a').first()).toHaveAttribute("href", "magnet:?xt=urn:btih:fixture"); + await frame.evaluate(() => { + document.querySelector("#magnets-content .name").textContent = "ABC-123 4k"; + }); + await frame.locator("#enable-magnets-filter").click(); + await expect(frame.locator("[data-jhs-magnet-row]:visible")).toHaveCount(1); + await frame.locator("#enable-magnets-filter").click(); + await expect(frame.locator("[data-jhs-magnet-row]:visible")).toHaveCount(2); + for (const zoom of [1,1.25,1.5]) { + await frame.evaluate(zoom => { document.body.style.zoom = String(zoom); document.querySelector("#magnets-content").style.width = `${1360/zoom}px`; }, zoom); + const layout = await frame.locator("[data-jhs-magnet-row]").first().evaluate(row => ({display:getComputedStyle(row).display,overflow:row.scrollWidth>row.clientWidth+1, tops:[...row.querySelector('[data-jhs-magnet-part="actions"]').children].map(button=>button.getBoundingClientRect().top)})); + expect(layout.display).toBe("grid"); expect(layout.overflow).toBe(false); + expect(Math.max(...layout.tops)-Math.min(...layout.tops)).toBeLessThanOrEqual(1); + } + await frame.evaluate(() => {document.body.style.zoom="1";}); + for (const theme of ["light", "dark"]) { + await page.emulateMedia({colorScheme:theme}); + await frame.evaluate(theme => document.documentElement.setAttribute("data-jhs-theme", theme), theme); + await page.screenshot({path:testInfo.outputPath(`magnet-${theme}.png`),fullPage:true}); + } +}); diff --git a/tests/browser/specs/detail-panel-consistency.spec.js b/tests/browser/specs/detail-panel-consistency.spec.js new file mode 100644 index 00000000..8efc67bc --- /dev/null +++ b/tests/browser/specs/detail-panel-consistency.spec.js @@ -0,0 +1,63 @@ +import { test, expect } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +test("list iframe panels share inset, surface and typography without losing controls", async ({page,context}, info) => { + test.skip(info.project.name !== "desktop-wide", "explicit viewport matrix owner"); + await fulfillHostFixtures(context); + const fixture = await readFile(new URL("../fixtures/javdb-detail-panels.html", import.meta.url), "utf8"); + await context.route("https://javdb.com/v/**", route => route.fulfill({contentType:"text/html",body:fixture})); + await page.goto("https://javdb.com/"); + const options = {settingOverrides:{enableLoadReview:"no",enableLoadRelated:"no",enableMagnetsFilter:"no",enableLoadPreviewVideo:"no"}}; + await injectUserscriptRuntime(page, options); + await page.waitForFunction(()=>window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + const navigation = page.waitForEvent("framenavigated", {predicate:frame=>frame !== page.mainFrame() && frame.url().includes("/v/")}); + await page.locator(".movie-list .video-title").click(); + const frame = await navigation; + await injectUserscriptRuntime(frame, options); + await frame.waitForFunction(()=>window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + await frame.evaluate(()=>{ + const manager=window.unsafeWindow.pluginManager; + manager.getBean("ReviewPlugin").getRuntimeService("review").list=async()=>[{author:"Fixture reviewer",content:"评论正文与长链接 "+"long-name-".repeat(30),createdAt:"2026-09-09",likes:3}]; + manager.getBean("RelatedPlugin").getRuntimeService("related").list=async()=>[{id:"fixture",name:"相关清单长标题".repeat(12),movieCount:123,collectionCount:45,viewCount:678,createdAt:"2026-09-09"}]; + window.originalMagnetNodes=[...document.querySelectorAll("#magnets-content .item")]; + }); + await frame.locator(".jhs-review-toggle").click(); + await frame.locator(".jhs-related-toggle").click(); + await expect(frame.locator(".jhs-review-item")).toHaveCount(1); + await expect(frame.locator(".jhs-related-item")).toHaveCount(1); + for(const width of [360,390,768,992,1248,1408]) for(const theme of ["light","dark"]) { + await page.setViewportSize({width,height:900}); + await frame.evaluate(theme=>window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("themeMode",theme),theme); + const geometry=await frame.evaluate(()=>{ + const selectors=[".message.video-panel","[data-jhs-slot=reviews]","[data-jhs-slot=related]"]; + return selectors.map(selector=>{ + const node=document.querySelector(selector),s=getComputedStyle(node),r=node.getBoundingClientRect(); + const content=node.querySelector("#magnets-content,.jhs-panel-header"),c=content.getBoundingClientRect(); + return {left:r.left,right:r.right,inset:c.left-r.left,radius:s.borderRadius,bg:s.backgroundColor,overflow:node.scrollWidth-node.clientWidth}; + }); + }); + for(const panel of geometry) { + expect(panel.inset, `${width}/${theme}: panels must not touch edges`).toBeGreaterThanOrEqual(16); + expect(panel.radius).toBe("12px"); + expect(panel.bg).toBe(geometry[0].bg); + expect(Math.abs(panel.left-geometry[0].left)).toBeLessThanOrEqual(1); + expect(Math.abs(panel.right-geometry[0].right)).toBeLessThanOrEqual(1); + expect(panel.overflow).toBeLessThanOrEqual(1); + } + const titles=await frame.locator(".jhs-panel-header h3").evaluateAll(nodes=>nodes.map(n=>({size:getComputedStyle(n).fontSize,weight:getComputedStyle(n).fontWeight}))); + for(const title of titles) {expect(title.size).toBe("16px");expect(title.weight).toBe("600");} + } + await page.setViewportSize({width:1248,height:900}); + await frame.locator(".jhs-review-toggle").click(); + await expect(frame.locator(".jhs-review-container")).toBeHidden(); + await frame.locator(".jhs-review-toggle").click(); + await expect(frame.locator(".jhs-review-item")).toBeVisible(); + await frame.locator(".jhs-related-toggle").click(); + await expect(frame.locator(".jhs-related-container")).toBeHidden(); + await frame.locator(".jhs-related-toggle").click(); + await expect(frame.locator(".jhs-related-title")).toHaveAttribute("href","/lists/fixture"); + expect(await frame.evaluate(()=>window.originalMagnetNodes.every(n=>n.isConnected && n.querySelector(".copy-to-clipboard")))).toBe(true); + await frame.locator(".jhs-detail-post-resource").screenshot({path:fileURLToPath(new URL("../../../output/playwright/detail-panels-after.png", import.meta.url))}); +}); diff --git a/tests/browser/specs/escape-ownership.spec.js b/tests/browser/specs/escape-ownership.spec.js new file mode 100644 index 00000000..558fceaa --- /dev/null +++ b/tests/browser/specs/escape-ownership.spec.js @@ -0,0 +1,26 @@ +import {test,expect} from "@playwright/test"; +import {fileURLToPath} from "node:url"; +import {fulfillHostFixtures,injectUserscriptRuntime} from "../harness/runtime.js"; +test.beforeEach(({},info)=>test.skip(info.project.name!=="desktop-wide","one owner for real Layer keyboard regression")); +test("settings dropdown consumes first Escape and restores focus before parent closes",async({page,context})=>{ + await fulfillHostFixtures(context);await page.goto("https://javdb.com/"); + const add=page.addScriptTag.bind(page); + page.addScriptTag=async options=>{ + if(options.path?.endsWith("JHS.user.js"))await add({path:fileURLToPath(new URL("../fixtures/layer-runtime/layer-1.0.9.min.js",import.meta.url))}); + return add(options); + }; + await injectUserscriptRuntime(page); + await page.waitForFunction(()=>window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + await page.evaluate(()=>window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + await expect(page.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready","true"); + await page.locator('[data-panel="base-panel"]').click(); + const trigger=page.locator("#base-panel .jhs-select-trigger").first(); + await trigger.click(); + await page.locator("#base-panel .jhs-select-menu.is-open .jhs-select-option").first().press("Escape"); + await page.waitForTimeout(600); + await expect(page.locator(".layui-layer")).toHaveCount(1); + await expect(trigger).toHaveAttribute("aria-expanded","false"); + await expect(trigger).toBeFocused(); + await trigger.press("Escape"); + await expect(page.locator(".layui-layer")).toHaveCount(0); +}); diff --git a/tests/browser/specs/fc2-viewer-lifecycle.spec.js b/tests/browser/specs/fc2-viewer-lifecycle.spec.js new file mode 100644 index 00000000..9d1725ed --- /dev/null +++ b/tests/browser/specs/fc2-viewer-lifecycle.spec.js @@ -0,0 +1,173 @@ +import { test, expect } from "@playwright/test"; +import { fileURLToPath } from "node:url"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +test.beforeEach(({}, info) => test.skip(!["desktop-wide", "mobile", "mobile-small", "mobile-landscape"].includes(info.project.name), "viewer ownership has desktop and touch coverage")); + +async function openFixture(page, context) { + await fulfillHostFixtures(context); + await context.route("https://images.example.test/**", route => { + const sheet = route.request().url().endsWith("sheet.svg"); + return route.fulfill({ contentType: "image/svg+xml", body: `` }); + }); + await page.goto("https://javdb.com/advanced_search?type=3"); + const add = page.addScriptTag.bind(page); + page.addScriptTag = async options => { + if (options.path?.endsWith("JHS.user.js")) { + await add({ path: fileURLToPath(new URL("../fixtures/layer-runtime/layer-1.0.9.min.js", import.meta.url)) }); + await add({ path: fileURLToPath(new URL("../fixtures/viewer-runtime/viewer-1.11.1.min.js", import.meta.url)) }); + } + return add(options); + }; + await injectUserscriptRuntime(page, { settingOverrides: { enableLoadReview: "no", enableLoadRelated: "no", enableLoadScreenShot: "yes", enableLoadOtherSite: "no" } }); + await page.addStyleTag({ path: fileURLToPath(new URL("../fixtures/viewer-runtime/viewer-1.11.1.min.css", import.meta.url)) }); + await page.waitForFunction(() => window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + await page.evaluate(() => { + const fc2 = window.unsafeWindow.pluginManager.getBean("Fc2Plugin"), get = fc2.getRuntimeService.bind(fc2); + fc2.resolveMovieIdForRecord = async () => "fixture-id"; + fc2.resolveFc2Source = async () => "fc2"; + fc2.fetchAndRenderNativeMagnets = async () => {}; + fc2.configureJavDbWantButton = async () => {}; + fc2.getRuntimeService = name => { + if (name === "movie") return new Proxy(get(name), { get(target, key) { return key === "detail" ? async () => ({title:"Gallery fixture", carNum:"FC2-4959150", imageUrls:["https://images.example.test/1.svg", "https://images.example.test/2.svg"]}) : target[key]; } }); + if (name === "screenshot") return {isEnabled:()=>true, getScreenshotSettings:()=>({mode:"auto"}), getEnabledProviders:()=>[{id:"javstore"}], resolve:async()=>[{url:"https://images.example.test/sheet.svg"}]}; + return get(name); + }; + }); + await openDetail(page); +} +async function openDetail(page) { + await page.locator('.movie-list a[data-jhs-fc2-primary="true"]').click(); + await expect(page.getByRole("button", {name:"查看剧照 2", exact:true})).toBeVisible(); + await expect(page.getByRole("button", {name:"查看缩略图大图", exact:true})).toBeVisible(); + await page.waitForFunction(() => document.querySelector(".layui-layer").getAnimations().every(animation => animation.playState !== "running")); +} + +async function expectImageFits(page) { + await expect.poll(() => page.locator(".viewer-canvas img").evaluate(image => { + const rect = image.getBoundingClientRect(), viewer = image.closest(".viewer-container"), bounds = viewer.getBoundingClientRect(), footer = viewer.querySelector(".viewer-footer").getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && rect.left >= bounds.left && rect.right <= bounds.right + 1 && rect.top >= bounds.top && rect.bottom <= footer.top + 1; + })).toBe(true); +} + +for (const reducedMotion of ["no-preference", "reduce"]) { + test(`FC2 zoom controls and reset work with motion ${reducedMotion}`, async ({page,context}, testInfo) => { + await page.emulateMedia({reducedMotion}); + await openFixture(page,context); + await page.getByRole("button",{name:"查看剧照 2",exact:true}).click(); + await expectImageFits(page); + const image = page.locator(".viewer-canvas img"), viewer = page.locator(".viewer-container"); + const width = () => image.evaluate(node => node.getBoundingClientRect().width); + const initialWidth = await width(); + const activate = control => testInfo.project.use.hasTouch ? control.tap() : control.click(); + await activate(viewer.getByRole("button",{name:"放大图片",exact:true})); + await expect.poll(width).toBeGreaterThan(initialWidth * 1.05); + await activate(viewer.getByRole("button",{name:"缩小图片",exact:true})); + await expect.poll(width).toBeCloseTo(initialWidth, 0); + await activate(viewer.getByRole("button",{name:"放大图片",exact:true})); + await expect.poll(width).toBeGreaterThan(initialWidth * 1.05); + await activate(viewer.getByRole("button",{name:"适应窗口",exact:true})); + await expect.poll(width).toBeCloseTo(initialWidth, 0); + await expect(viewer.locator(".jhs-image-viewer-count")).toHaveText("2 / 3"); + await expectImageFits(page); + await activate(viewer.getByRole("button",{name:"下一张",exact:true})); + await expect(viewer.locator(".jhs-image-viewer-count")).toHaveText("3 / 3"); + await expectImageFits(page); + const sheetWidth = await width(); + await activate(viewer.getByRole("button",{name:"放大图片",exact:true})); + await expect.poll(width).toBeGreaterThan(sheetWidth * 1.05); + }); +} + +test("FC2 images fit inside their detail dialog and expose touch sized side navigation", async ({page,context}, testInfo) => { + await openFixture(page,context); + await page.getByRole("button",{name:"查看剧照 2",exact:true}).click(); + const viewer = page.locator(".viewer-container"); + const activate = control => testInfo.project.use.hasTouch ? control.tap() : control.click(); + await expect(viewer).toBeVisible(); + await expect(viewer.locator("xpath=ancestor::*[contains(@class,'layui-layer-content')]")).toHaveCount(1); + await expect(page.locator(".jhs-image-viewer-host")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(viewer).toHaveCSS("background-color", "rgba(0, 0, 0, 0.5)"); + await expectImageFits(page); + for (const name of ["上一张", "下一张", "关闭图片"]) { + const button = viewer.getByRole("button",{name,exact:true}); + await expect(button).toBeVisible(); + const size = await button.boundingBox(); + expect(size.width).toBeGreaterThanOrEqual(44); + expect(size.height).toBeGreaterThanOrEqual(44); + } + if (testInfo.project.name === "mobile") await page.screenshot({path:testInfo.outputPath("fc2-preview-fit.png")}); + await activate(viewer.getByRole("button",{name:"下一张",exact:true})); + await expect(page.locator(".viewer-canvas img")).toHaveAttribute("src","https://images.example.test/sheet.svg"); + await expectImageFits(page); + await activate(viewer.getByRole("button",{name:"上一张",exact:true})); + await expect(page.locator(".viewer-canvas img")).toHaveAttribute("src","https://images.example.test/2.svg"); + await activate(viewer.getByRole("button",{name:"关闭图片",exact:true})); + await expect(viewer).toHaveCount(0); + await expect(page.getByRole("button",{name:"查看剧照 2",exact:true})).toBeFocused(); + await expect(page.locator(".jhs-fc2-workspace")).not.toHaveAttribute("inert"); +}); + +test("FC2 wheel changes image and screenshot shares the gallery across resize", async ({page,context}) => { + await openFixture(page,context); + await page.getByRole("button",{name:"查看缩略图大图",exact:true}).click(); + await expectImageFits(page); + const canvas = page.locator(".viewer-canvas"); + await canvas.hover(); + await page.mouse.wheel(0,-120); + await expect(page.locator(".viewer-canvas img")).toHaveAttribute("src","https://images.example.test/2.svg"); + await expectImageFits(page); + await page.setViewportSize({width:360,height:640}); + await expectImageFits(page); + const bounds = await page.locator(".viewer-container").boundingBox(); + expect(bounds.width).toBeLessThanOrEqual(360); + expect(bounds.height).toBeLessThanOrEqual(640); + await page.keyboard.press("Escape"); + await expect(page.locator(".viewer-container,.jhs-image-viewer-host")).toHaveCount(0); +}); + +test("FC2 viewer stays above a repeatedly opened real Layer and Escape closes in order", async ({page,context}) => { + await openFixture(page,context); + for (let i=0;i<6;i++) { await page.locator(".layui-layer-close").click(); await expect(page.locator(".layui-layer")).toHaveCount(0); await openDetail(page); } + await page.getByRole("button",{name:"查看剧照 2",exact:true}).click(); + await expect(page.locator(".viewer-container.viewer-in")).toHaveCount(1); + const order = await page.evaluate(() => ({viewer:+getComputedStyle(document.querySelector(".viewer-container")).zIndex, layer:+getComputedStyle(document.querySelector(".layui-layer")).zIndex, top:document.elementFromPoint(innerWidth/2,innerHeight/2)?.closest(".viewer-container") !== null})); + expect(order.viewer).toBeGreaterThan(order.layer); + expect(order.top).toBe(true); + await expect(page.locator(".viewer-canvas img")).toHaveAttribute("src","https://images.example.test/2.svg"); + await page.keyboard.press("ArrowLeft"); + await expect(page.locator(".viewer-canvas img")).toHaveAttribute("src","https://images.example.test/1.svg"); + await page.keyboard.press("Escape"); + await expect(page.locator(".viewer-container")).toHaveCount(0); + await expect(page.locator(".layui-layer")).toHaveCount(1); + await page.keyboard.press("Escape"); + await expect(page.locator(".layui-layer")).toHaveCount(0); +}); + +test("FC2 screenshot and gallery entries replace one another without stale viewers", async ({page,context}) => { + await openFixture(page,context); + await page.getByRole("button",{name:"查看缩略图大图",exact:true}).click(); + await expect(page.locator(".viewer-container.viewer-in")).toHaveCount(1); + // Exercise a queued second entry even when its source is covered by the active overlay. + await page.getByRole("button",{name:"查看剧照 2",exact:true}).dispatchEvent("click"); + await expect(page.locator(".viewer-container")).toHaveCount(1); + await expect(page.locator(".viewer-canvas img")).toHaveAttribute("src","https://images.example.test/2.svg"); + await page.getByRole("button", {name:"关闭图片",exact:true}).click(); + await expect(page.locator(".viewer-container")).toHaveCount(0); + await page.getByRole("button",{name:"查看缩略图大图",exact:true}).click(); + await expect(page.locator(".viewer-container")).toHaveCount(1); + await page.keyboard.press("Escape"); + await expect(page.locator(".layui-layer")).toHaveCount(1); +}); + +test("FC2 closing its owner during image display removes the viewer and permits reopening", async ({page,context}) => { + await openFixture(page,context); + await page.getByRole("button",{name:"查看剧照 1",exact:true}).click(); + await expect(page.locator(".viewer-container.viewer-in")).toHaveCount(1); + await page.locator(".layui-layer-close").dispatchEvent("click"); + await expect(page.locator(".layui-layer")).toHaveCount(0); + await expect(page.locator(".viewer-container")).toHaveCount(0); + await openDetail(page); + await page.getByRole("button",{name:"查看剧照 1",exact:true}).click(); + await expect(page.locator(".viewer-container.viewer-in")).toHaveCount(1); +}); diff --git a/tests/browser/specs/history-detail-layer.spec.js b/tests/browser/specs/history-detail-layer.spec.js new file mode 100644 index 00000000..33ccc748 --- /dev/null +++ b/tests/browser/specs/history-detail-layer.spec.js @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; +import { fileURLToPath } from "node:url"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +test("history detail and its shade stay above history across close and reopen", async ({ context, page }) => { + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/"); + const addScript = page.addScriptTag.bind(page); + page.addScriptTag = async options => { + if (options.path?.endsWith("JHS.user.js")) { + await addScript({ path: fileURLToPath(new URL("../fixtures/layer-runtime/layer-1.0.9.min.js", import.meta.url)) }); + } + return addScript(options); + }; + await injectUserscriptRuntime(page, { settingOverrides: { enableLoadReview: "no" } }); + await page.waitForFunction(() => window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + await page.evaluate(() => { + const history = window.unsafeWindow.pluginManager.getBean("HistoryPlugin"); + history.historyRepository.list = async () => [{ + carNum: "ABC-123", url: "https://javdb.com/v/test-id", names: "Fixture", + stateFlags: { downloaded: true }, createdAt: "2026-09-18", updatedAt: "2026-09-18", + }]; + }); + + for (let cycle = 0; cycle < 3; cycle++) { + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("HistoryPlugin").openHistory()); + const history = page.locator(".layui-layer-page").filter({ has: page.locator(".jhs-history-dialog") }); + await expect(history.locator(".history-detailBtn")).toBeVisible(); + const historyStack = await page.evaluate(() => [...window.utils.layerIndexStack]); + for (const closeMethod of ["button", "escape"]) { + await history.locator(".history-detailBtn").click(); + const detail = page.locator(".layui-layer-iframe"); + await expect(detail.locator("iframe")).toBeVisible(); + await expect(detail).toHaveClass(/jhs-dialog/); + const ordering = await detail.evaluate(node => { + const owner = document.querySelector(".jhs-history-dialog").closest(".layui-layer"); + const shade = document.getElementById(node.id.replace("layui-layer", "layui-layer-shade")); + const rect = node.getBoundingClientRect(); + return { + owner: Number(getComputedStyle(owner).zIndex), + shade: Number(getComputedStyle(shade).zIndex), + detail: Number(getComputedStyle(node).zIndex), + onTop: node.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)), + paintOrder: document.elementsFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) + .map(element => element === node ? "detail" : element === shade ? "shade" : element === owner ? "history" : null).filter(Boolean), + }; + }); + // Layer gives the later shade the parent's z-index; DOM paint order must still cover the parent. + expect(ordering.paintOrder).toEqual(["detail", "shade", "history"]); + expect(ordering.detail).toBeGreaterThan(ordering.owner); + expect(ordering.detail).toBeGreaterThan(ordering.shade); + expect(ordering.onTop).toBe(true); + if (closeMethod === "button") await detail.getByRole("button", { name: "关闭弹窗" }).click(); + else await page.frameLocator(".layui-layer-iframe iframe").locator("body").press("Escape"); + await expect(detail).toHaveCount(0); + await expect(history).toBeVisible(); + expect(await page.evaluate(() => window.utils.layerIndexStack)).toEqual(historyStack); + } + await history.getByRole("button", { name: "关闭弹窗" }).click(); + await expect(page.locator(".layui-layer, .layui-layer-shade")).toHaveCount(0); + expect(await page.evaluate(() => window.utils.layerIndexStack)).toEqual([]); + } +}); diff --git a/tests/browser/specs/host-routing.spec.js b/tests/browser/specs/host-routing.spec.js new file mode 100644 index 00000000..bbf7471e --- /dev/null +++ b/tests/browser/specs/host-routing.spec.js @@ -0,0 +1,464 @@ +import { expect, test } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import budget from "../../../performance-budget.json" with { type: "json" }; +import { assertNoHorizontalOverflow, fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +for (const [label, url, expectedPlugin] of [ + ["JavDB", "https://javdb.com/v/test-id", "DetailPagePlugin"], + ["JavBus", "https://www.javbus.com/ABC-123", "BusDetailPagePlugin"] +]) { + test(`${label} uses the real host origin with local fixtures`, async ({ context, page }) => { + await fulfillHostFixtures(context); + await page.goto(url, { waitUntil: "domcontentloaded" }); + // 本用例只验证宿主路由和零请求启动预算;评论默认开启的行为由专门设置/单元回归覆盖。 + await injectUserscriptRuntime(page, { settingOverrides: { enableLoadReview: "no" } }); + await expect.poll(() => page.evaluate((name) => window.unsafeWindow.pluginManager.getPluginNames().includes(name), expectedPlugin)).toBe(true); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--jhs-accent").trim()), "Bootstrap must inject the core theme tokens").not.toBe(""); + await expect(page.locator("body")).toBeVisible(); + await assertNoHorizontalOverflow(page); + await page.waitForTimeout(250); + const initialRequests = await page.evaluate(() => window.__jhsBrowserDiagnostics.requests); + expect(initialRequests.length, `deterministic fixture startup request budget: ${JSON.stringify(initialRequests)}`).toBeLessThanOrEqual(budget.browserFixture.maximumInitialRequests[label]); + }); +} + +for (const [label, url] of [ + ["JavDB", "https://javdb.com/"], + ["JavBus", "https://www.javbus.com/"] +]) { + test(`${label} list route uses its HostAdapter and list runtime`, async ({ context, page }, testInfo) => { + await fulfillHostFixtures(context); + await page.goto(url, { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect.poll(() => page.evaluate(() => window.isListPage)).toBe(true); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getPluginNames().includes("ListPagePlugin"))).toBe(true); + await expect(page.locator(label === "JavDB" ? ".movie-list .item" : ".masonry .movie-box")).toHaveCount(1); + if (testInfo.project.name.startsWith("mobile")) { + await expect(page.locator("#jhs-fab")).toBeVisible(); + await expect(page.locator("#jhs-fab-menu .jhs-mobile-filter-menu")).toHaveCount(1); + } else await expect(page.locator("#jhs-quick-filter")).toBeVisible(); + await assertNoHorizontalOverflow(page); + }); +} + +test("Top250 intercepts the native premium route without a favorite-tab label", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers the Top250 entry regression"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await page.locator(".main-tabs").evaluate((root) => { + root.innerHTML = 'Top250'; + }); + await injectUserscriptRuntime(page); + await page.locator("#fixture-top250").click(); + await expect(page.locator("#username")).toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/"); +}); + +test("Top250 keeps filters above the list and restores API proxy cover URLs", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers Top250 layout and covers"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?handleTop=1&handleType=all&type_value=", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { topMovies: [{ + id: "top-fixture", number: "TOP-001", origin_title: "Top Fixture", release_date: "2026-08-30", + cover_url: "https://jdforrepam.com/rhe951l4q/covers/top-fixture.jpg", + thumb_url: "https://jdforrepam.com/rhe951l4q/thumbs/top-fixture.jpg", + has_cnsub: false, magnets_count: 1, new_magnets: false, + }] }); + const filters = page.locator(".jhs-top250-filters"), list = page.locator(".jhs-top250-list"), image = list.locator("#top-fixture img"); + await expect(filters).toBeVisible(); + await expect(list).toBeVisible(); + expect(await filters.evaluate((node, listNode) => Boolean(node.compareDocumentPosition(listNode) & Node.DOCUMENT_POSITION_FOLLOWING), await list.elementHandle())).toBe(true); + await expect(image).toHaveAttribute("src", "https://c0.jdbstatic.com/thumbs/top-fixture.jpg"); + await expect(image).toHaveAttribute("data-full", "https://c0.jdbstatic.com/covers/top-fixture.jpg"); + await expect.poll(() => image.evaluate((element) => element.complete && element.naturalWidth > 0)).toBe(true); +}); + +for (const [label, query, listClass, movieId] of [ + ["HotShow", "handlePlayback=1&period=daily", "jhs-hitshow-list", "hot-owned"], + ["Top250", "handleTop=1&handleType=all&type_value=", "jhs-top250-list", "top-owned"], +]) { + test(`${label} replaces native search results before rendering its owned ranking`, async ({ context, page }) => { + await fulfillHostFixtures(context); + const html = await readFile(new URL("../fixtures/javdb-ranking-native-results.html", import.meta.url), "utf8"); + await context.route("https://javdb.com/advanced_search?**", route => route.fulfill({ status: 200, contentType: "text/html", body: html })); + await page.goto(`https://javdb.com/advanced_search?${query}`, { waitUntil: "domcontentloaded" }); + await expect(page.locator("#native-search-result")).toHaveCount(1); + const movies = [{ id: movieId, number: "OWNED-001", origin_title: "Owned ranking fixture", release_date: "2026-09-18", cover_url: "https://c0.jdbstatic.com/covers/owned.jpg", has_cnsub: false, magnets_count: 1, new_magnets: false }]; + await injectUserscriptRuntime(page, { rankingMovies: movies, topMovies: movies }); + const card = page.locator(`.${listClass} #${movieId}`); + await expect(card).toBeVisible(); + await expect(page.locator(".movie-list")).toHaveCount(1); + await expect(page.locator("#native-search-result, [data-native-pagination]")).toHaveCount(0); + await expect(page.locator("#native-footer")).toHaveCount(1); + await expect(page.locator("#jhs-quick-filter")).toHaveCount(1); + expect(await card.evaluate(node => node.getBoundingClientRect().top)).toBeLessThan(page.viewportSize().height); + await expect(page.locator("nav.pagination")).toHaveCount(label === "Top250" ? 1 : 0); + // Re-enter the same rendering path to cover error/login retries with an existing owned list. + await page.evaluate(async name => { + const plugin = window.unsafeWindow.pluginManager.getBean(name === "HotShow" ? "HitShowPlugin" : "TOP250Plugin"); + await (name === "HotShow" ? plugin.handlePlayback() : plugin.handleTop()); + }, label); + await expect(page.locator(".movie-list")).toHaveCount(1); + await expect(page.locator("#jhs-quick-filter")).toHaveCount(1); + await expect(card).toBeVisible(); + await assertNoHorizontalOverflow(page); + }); +} + +test("legacy disabled plugin migrates to one contribution only", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers storage migration"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { disabledPlugins: ["ReviewPlugin"] }); + const pluginNames = await page.evaluate(() => window.unsafeWindow.pluginManager.getPluginNames()); + expect(pluginNames).not.toContain("ReviewPlugin"); + expect(pluginNames).toContain("RelatedPlugin"); + expect(pluginNames).toContain("DetailWorkspacePlugin"); +}); + +for (const path of ["/advanced_search?type=3", "/advanced_search?type=100", "/want_watch_videos", "/watched_videos"]) { + test(`JavDB DOM list route remains active on ${path}`, async ({ context, page }) => { + await fulfillHostFixtures(context); + await page.goto(`https://javdb.com${path}`, { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect.poll(() => page.evaluate(() => window.isListPage)).toBe(true); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getPluginNames().includes("ListPagePlugin"))).toBe(true); + }); +} + +test("HotShow creates an owned list when advanced search has no native list root", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers HotShow host fallback"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?handlePlayback=1&period=daily", { waitUntil: "domcontentloaded" }); + expect(await page.locator(".movie-list").count()).toBe(0); + await injectUserscriptRuntime(page, { rankingMovies: [{ + id: "hot-fixture", number: "ABC-123", origin_title: "Hot Fixture", release_date: "2026-08-29", + cover_url: "https://c0.jdbstatic.com/covers/fixture.jpg", has_cnsub: true, magnets_count: 1, new_magnets: false, + }, { + id: "hot-fixture-fc2", number: "FC2-PPV-1234567", origin_title: "Hot FC2", release_date: "2026-08-28", + cover_url: "https://c0.jdbstatic.com/covers/fc2.jpg", has_cnsub: false, magnets_count: 0, new_magnets: false, + }] }); + await expect.poll(() => page.evaluate(() => window.isListPage)).toBe(true); + await expect(page.locator(".jhs-hitshow-list #hot-fixture")).toBeVisible(); + await expect(page.locator(".jhs-hitshow-title")).toContainText("热播"); + await expect(page.locator(".empty-message")).toHaveCount(0); + const periodToolbar = page.locator("#jhs-hitshow-period"); + await expect(periodToolbar).toBeVisible(); + await expect(periodToolbar.locator(".jhs-segmented__item")).toHaveCount(3); + // 排序与鉴定操作控件挂进热播标题容器(之后由命令栏收拢),不注入页面 h2 + await expect(page.locator("#sort-toggle-btn")).toHaveCount(1); + await expect(page.locator("#waitCheckBtn")).toHaveCount(1); + const image = page.locator(".jhs-hitshow-list #hot-fixture img"); + await expect(image).toHaveAttribute("src", /\/thumbs\//); + await expect(image).toHaveAttribute("data-full", /\/covers\//); + const quickFilter = page.locator("#jhs-quick-filter"); + await expect(quickFilter).toBeVisible(); + await quickFilter.locator('[data-jhs-filter="all"]').click(); + await expect(quickFilter.locator('[data-jhs-filter="all"]')).toHaveAttribute("aria-selected", "true"); + await expect(page.locator(".jhs-hitshow-list #hot-fixture")).toBeVisible(); + // 回归场景:在热播页把卡片标记为已下载后,卡片标记必须实时刷新,且各筛选档位显隐正确 + const card = page.locator(".jhs-hitshow-list #hot-fixture"); + await page.evaluate(() => window.unsafeWindow.stateService.patch("ABC-123", { downloaded: true }, { + type: "list-card-state", record: { carNum: "ABC-123", url: "/v/hot-fixture", names: "", publishTime: "2026-08-29", fc2Source: "fc2" }, + })); + await expect.poll(() => card.getAttribute("data-jhs-flags"), { timeout: 10_000 }).toContain('"downloaded":true'); + await quickFilter.locator('[data-jhs-filter="waitCheck"]').click(); + await expect(card).toBeHidden(); + await quickFilter.locator('[data-jhs-filter="hasDown"]').click(); + await expect(card).toBeVisible(); + await quickFilter.locator('[data-jhs-filter="all"]').click(); + await expect(card).toBeVisible(); + // 回归场景:自渲染榜单上的 FC2 卡片必须被延迟挂载保护,点击打开 FC2 对话框而不是被详情导航吞掉 + const fc2Card = page.locator(".jhs-hitshow-list #hot-fixture-fc2"); + await expect.poll(() => fc2Card.getAttribute("data-jhs-fc2-protected"), { timeout: 10_000 }).toBe("true"); + await page.waitForTimeout(120); + await expect(fc2Card.locator("a").first()).toHaveAttribute("href", "/v/hot-fixture-fc2"); + await page.evaluate(() => { + const fc2 = window.unsafeWindow.pluginManager.getBean("Fc2Plugin"); + fc2.resolveMovieIdForRecord = async () => null; + fc2.resolveFc2Source = async () => "fc2"; + fc2.openFc2Dialog = (...args) => { window.__jhsFc2Navigation = { mode: "dialog", args }; }; + fc2.openFc2Page = (...args) => { window.__jhsFc2Navigation = { mode: "page", args }; }; + }); + await fc2Card.locator(".video-title").click(); + await expect.poll(() => page.evaluate(() => window.__jhsFc2Navigation?.mode)).toBe("dialog"); +}); + +test("HotShow hover preview stays below the FC2 detail dialog", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers hover/dialog stacking"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?handlePlayback=1&period=daily", { waitUntil: "domcontentloaded" }); + await page.addStyleTag({ content: ".movie-list{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:20px}.item .cover{position:relative;width:100%;padding-top:60%}.item .cover img{position:absolute;inset:0;width:100%;height:100%}" }); + await injectUserscriptRuntime(page, { + settingOverrides: { hoverBigImg: "yes" }, + rankingMovies: [{ + id: "hot-fixture-fc2", number: "FC2-PPV-1234567", origin_title: "Hot FC2", release_date: "2026-08-28", + cover_url: "https://c0.jdbstatic.com/covers/fc2.jpg", has_cnsub: false, magnets_count: 0, new_magnets: false, + }], + }); + await expect(page.locator(".jhs-hitshow-list #hot-fixture-fc2")).toBeVisible(); + await expect.poll(() => page.evaluate(() => Boolean(window.imageHoverPreviewObj))).toBe(true); + await page.evaluate(() => { + const fc2 = window.unsafeWindow.pluginManager.getBean("Fc2Plugin"); + fc2.resolveMovieIdForRecord = async () => null; + fc2.resolveFc2Source = async () => "fc2"; + }); + const fc2Card = page.locator(".jhs-hitshow-list #hot-fixture-fc2"); + await expect.poll(() => fc2Card.getAttribute("data-jhs-fc2-protected"), { timeout: 10_000 }).toBe("true"); + await page.waitForTimeout(120); + await fc2Card.locator(".cover img").hover(); + await expect.poll(() => page.evaluate(() => document.querySelector(".image-hover-preview")?.classList.contains("active") === true)).toBe(true); + await page.evaluate(() => document.querySelector(".jhs-hitshow-list #hot-fixture-fc2 .cover img")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))); + await expect.poll(() => page.evaluate(() => Boolean([...document.querySelectorAll(".layui-layer")].find((el) => el.querySelector(".jhs-fc2-dialog-host"))))).toBe(true); + // 回归门禁:FC2 详情对话框的层级必须压过悬停预览(hoverPreview 档低于 modal/layer) + const ordering = await page.evaluate(() => { + const preview = document.querySelector(".image-hover-preview"); + const dialog = [...document.querySelectorAll(".layui-layer")].find((el) => el.querySelector(".jhs-fc2-dialog-host")); + if (!preview || !dialog) return null; + return { + previewZ: Number.parseInt(preview.style.zIndex, 10), + dialogZ: Number.parseInt(dialog.style.zIndex, 10), + token: Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue("--jhs-z-hover-preview"), 10), + }; + }); + expect(ordering).not.toBeNull(); + expect(ordering.previewZ).toBe(ordering.token); + expect(ordering.dialogZ).toBeGreaterThan(ordering.previewZ); +}); + +test("HotShow period tabs fetch and render period-specific rankings", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers period switching"); + const periodMovies = { + daily: [{ id: "hot-daily", number: "DAILY-001", origin_title: "Daily movie", release_date: "2026-08-29", cover_url: "https://c0.jdbstatic.com/covers/daily.jpg", has_cnsub: false, magnets_count: 1, new_magnets: false }], + weekly: [{ id: "hot-weekly", number: "WEEKLY-001", origin_title: "Weekly movie", release_date: "2026-08-28", cover_url: "https://c0.jdbstatic.com/covers/weekly.jpg", has_cnsub: false, magnets_count: 1, new_magnets: false }], + monthly: [{ id: "hot-monthly", number: "MONTHLY-001", origin_title: "Monthly movie", release_date: "2026-08-27", cover_url: "https://c0.jdbstatic.com/covers/monthly.jpg", has_cnsub: false, magnets_count: 1, new_magnets: false }], + }; + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?handlePlayback=1&period=daily", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { rankingMovies: periodMovies }); + // 回归门禁:周期参数必须贯穿 URL → 请求 query → 渲染,路由重构不得把它拍平成单一榜单 + await expect(page.locator(".jhs-hitshow-list #hot-daily")).toBeVisible(); + await expect(page.locator("#jhs-hitshow-period .active")).toHaveAttribute("href", /period=daily/); + await expect.poll(() => page.evaluate(() => window.__jhsBrowserDiagnostics.requests.some((r) => r.url.includes("period=daily")))).toBe(true); + + for (const period of [ "weekly", "monthly" ]) { + await page.locator(`#jhs-hitshow-period a[href*="period=${period}"]`).click(); + await page.waitForURL(new RegExp(`period=${period}`), { timeout: 15_000 }); + await injectUserscriptRuntime(page, { rankingMovies: periodMovies }); + await expect(page.locator(`.jhs-hitshow-list #hot-${period}`)).toBeVisible(); + await expect(page.locator(".jhs-hitshow-list #hot-daily")).toHaveCount(0); + await expect(page.locator("#jhs-hitshow-period .active")).toHaveAttribute("href", new RegExp(`period=${period}`)); + await expect.poll(() => page.evaluate((marker) => window.__jhsBrowserDiagnostics.requests.some((r) => r.url.includes(marker)), `period=${period}`)).toBe(true); + } +}); + +test("HotShow sort starts at default and stays page-local", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers owned ranking sort"); + const rankingMovies = [ + { id: "sort-a", number: "AAA-001", origin_title: "Older movie", release_date: "2026-08-01", cover_url: "https://c0.jdbstatic.com/covers/a.jpg", has_cnsub: false, magnets_count: 1, new_magnets: false }, + { id: "sort-b", number: "BBB-002", origin_title: "Newer movie", release_date: "2026-08-29", cover_url: "https://c0.jdbstatic.com/covers/b.jpg", has_cnsub: false, magnets_count: 1, new_magnets: false }, + ]; + const cardOrder = () => page.locator(".jhs-hitshow-list .item .video-title strong").allTextContents(); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?handlePlayback=1&period=daily", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { settingOverrides: { sortMethod: "rateCount" }, rankingMovies }); + // 回归门禁:全局排序是“评价人数”,热播也必须以“默认”排序启动(榜单原始顺序) + await expect(page.locator("#jhs-sort-current")).toHaveText("默认"); + await expect.poll(cardOrder).toEqual([ "AAA-001", "BBB-002" ]); + // 页内选择“时间”:立即生效,但不写全局设置 + await page.locator("#sort-toggle-btn").click(); + await page.locator('[data-sort-method="date"]').click(); + await expect(page.locator("#jhs-sort-current")).toHaveText("时间"); + await expect.poll(cardOrder).toEqual([ "BBB-002", "AAA-001" ]); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").snapshot().sortMethod)).toBe("rateCount"); + // 页内切回默认:恢复榜单原始顺序 + await page.locator("#sort-toggle-btn").click(); + await page.locator('[data-sort-method="default"]').click(); + await expect(page.locator("#jhs-sort-current")).toHaveText("默认"); + await expect.poll(cardOrder).toEqual([ "AAA-001", "BBB-002" ]); +}); + +test("FC2 cards keep dialog navigation and use owned-page anchor fallback", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers navigation semantics"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?type=3", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect.poll(() => page.locator(".movie-list .item").getAttribute("data-jhs-fc2-protected")).toBe("true"); + const primary = page.locator('.movie-list .item a[data-jhs-fc2-primary="true"]'); + const href = await primary.getAttribute("href"); + expect(href).toBe("/v/vip-fc2-placeholder"); + expect(await primary.getAttribute("data-jhs-fc2-primary")).toBe("true"); + await expect(page.locator(".movie-list .item .tags a").first()).toHaveAttribute("href", "/actors/fixture-actor"); + await page.evaluate(() => { + const fc2 = window.unsafeWindow.pluginManager.getBean("Fc2Plugin"); + fc2.resolveMovieIdForRecord = async () => null; + fc2.resolveFc2Source = async () => "fc2"; + fc2.openFc2Dialog = (...args) => { window.__jhsFc2Navigation = { mode: "dialog", args }; }; + fc2.openFc2Page = (...args) => { window.__jhsFc2Navigation = { mode: "page", args }; }; + }); + await page.evaluate(() => document.querySelector(".movie-list .item img").dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))); + await expect.poll(() => page.evaluate(() => window.__jhsFc2Navigation?.mode)).toBe("dialog"); + await page.evaluate(() => { window.__jhsFc2Navigation = null; }); + await page.evaluate(() => document.querySelector(".movie-list .item img").dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, ctrlKey: true }))); + await expect.poll(() => page.evaluate(() => window.__jhsFc2Navigation?.mode)).toBe("page"); + await page.evaluate(() => { window.__jhsFc2Navigation = null; document.querySelector(".movie-list .item img").dispatchEvent(new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 1 })); }); + await expect.poll(() => page.evaluate(() => window.__jhsFc2Navigation?.mode)).toBe("page"); + await page.evaluate(() => { window.__jhsFc2Navigation = null; document.querySelector(".movie-list .item a[data-jhs-fc2-primary]").dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); }); + await expect.poll(() => page.evaluate(() => window.__jhsFc2Navigation?.mode)).toBe("dialog"); + const ownedUrl = new URL("/users/collection_codes", page.url()); + ownedUrl.searchParams.set("movieId", "fixture-id"); + ownedUrl.searchParams.set("carNum", "FC2-PPV-4959150"); + ownedUrl.searchParams.set("url", href); + ownedUrl.searchParams.set("source", "fc2"); + await page.goto(ownedUrl.href, { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + expect(new URL(page.url()).pathname).toBe("/users/collection_codes"); + await expect(page.locator(".jhs-fc2-workspace[data-jhs-fc2-mode='page']")).toBeVisible(); +}); + +test("Settings opens when optional CoverButton and Blacklist contributions are disabled", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers optional settings dependencies"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { disabledPlugins: ["CoverButtonPlugin", "BlacklistPlugin"] }); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + await expect(page.locator(".layui-layer #saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + await page.locator('.layui-layer .side-menu-item[data-panel="base-panel"]').click(); + await page.locator(".layui-layer #reviewCount").selectOption("30", { force: true }); + await page.locator(".layui-layer #saveBtn").click(); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").snapshot().reviewCount)).toBe("30"); + await expect(page.locator(".layui-layer #saveBtn")).not.toHaveAttribute("aria-busy", "true"); +}); + +test("Settings remains interactive and catalogs a disabled external-sites contribution", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers disabled optional settings dependencies"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { disabledPlugins: ["OtherSitePlugin", "BusImgPlugin", "UnknownLegacyPlugin"] }); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + await expect(page.locator(".layui-layer #saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + await page.locator('.layui-layer .side-menu-item[data-panel="base-panel"]').click(); + await expect(page.locator(".layui-layer #base-panel")).toBeVisible(); + await page.locator('.layui-layer .side-menu-item[data-panel="plugin-mgmt-panel"]').click(); + const externalSitesToggle = page.locator('.layui-layer .pm-toggle[data-plugin="OtherSitePlugin"]'); + await expect(externalSitesToggle).toBeVisible(); + await expect(externalSitesToggle).not.toBeChecked(); + await expect(page.locator(".layui-layer #pm-disabled")).toHaveText("1"); + const enabled = Number(await page.locator(".layui-layer #pm-enabled").textContent()); + const total = Number(await page.locator(".layui-layer #pm-total").textContent()); + expect(enabled).toBe(total - 1); +}); + +test("Settings blocks saving until failed hydration is retried successfully", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers the hydration failure gate"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => { + const movie = window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("movie"); + window.__jhsOriginalExternalSiteOrigin = movie.externalSiteOrigin.bind(movie); + movie.externalSiteOrigin = () => { throw new Error("fixture hydration failure"); }; + }); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const save = page.locator(".layui-layer #saveBtn"); + await expect(save).toBeDisabled(); + await expect(save).toHaveAttribute("data-jhs-settings-ready", "false"); + await expect(page.locator(".layui-layer #settings-hydration-status")).toContainText("表单加载失败"); + await page.evaluate(() => { + const movie = window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("movie"); + movie.externalSiteOrigin = window.__jhsOriginalExternalSiteOrigin; + }); + await page.getByRole("button", { name: "重试加载" }).click(); + await expect(save).toHaveAttribute("data-jhs-settings-ready", "true"); + await expect(save).toBeEnabled(); +}); + +test("list runtime survives disabled optional list contributions", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers disabled list combinations"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + const disabledPlugins = ["Fc2Plugin", "AutoPagePlugin", "CoverButtonPlugin", "ListPageButtonPlugin"]; + await injectUserscriptRuntime(page, { disabledPlugins }); + const pluginNames = await page.evaluate(() => window.unsafeWindow.pluginManager.getPluginNames()); + expect(pluginNames).toContain("ListPagePlugin"); + disabledPlugins.forEach((name) => expect(pluginNames).not.toContain(name)); + await expect(page.locator(".movie-list .item")).toBeVisible(); +}); + +test("detail state controls survive disabled optional magnet contributions", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers disabled detail combinations"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + const disabledPlugins = ["HighlightMagnetPlugin", "MagnetHubPlugin"]; + await injectUserscriptRuntime(page, { disabledPlugins }); + const pluginNames = await page.evaluate(() => window.unsafeWindow.pluginManager.getPluginNames()); + expect(pluginNames).toContain("DetailPageButtonPlugin"); + disabledPlugins.forEach((name) => expect(pluginNames).not.toContain(name)); + await expect(page.locator(".jhs-detail-btn-row")).toBeVisible(); +}); + +test("title translation uses native fetch instead of the GM transport", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers translation transport"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { settingOverrides: { translateTitle: "yes" }, nativeTranslation: "即时译文" }); + await expect(page.locator(".translated-title")).toHaveText("即时译文"); + const diagnostics = await page.evaluate(() => window.__jhsBrowserDiagnostics); + expect(diagnostics.nativeTranslationRequests).toBe(1); + expect(diagnostics.requests.some((request) => request.url.includes("translate-pa.googleapis.com"))).toBe(false); +}); + +test("captured detail ownership survives detached controls, iframe isolation, and legacy boolean settings", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers detail close ownership"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + const result = await page.evaluate(async () => { + await window.settingsService.set("needClosePage", true); + const layerIndex = window.layer.open({ type: 1, content: '' }); + const button = document.querySelector("#offline-close-fixture"); + const capturedLayerIndex = window.utils.getOwningLayerIndex({ root: button }); + button.remove(); + const closed = await window.utils.closePage({ root: button, layerIndex: capturedLayerIndex }); + return { layerIndex, capturedLayerIndex, closed, remaining: document.querySelectorAll(".layui-layer").length }; + }); + expect(result).toEqual({ layerIndex: 1, capturedLayerIndex: 1, closed: true, remaining: 0 }); + + const iframeNavigation = page.waitForEvent("framenavigated", { + predicate: (frame) => frame.url().includes("jhs-close-frame=1"), + }); + await page.evaluate(() => { + window.utils.openPage("https://javdb.com/v/test-id?jhs-close-frame=1", "ABC-1"); + }); + const detailFrame = await iframeNavigation; + const iframeLayerIndex = await page.locator('.layui-layer:has(iframe[src*="jhs-close-frame=1"])').evaluate((element) => Number(element.dataset.layerId)); + await injectUserscriptRuntime(detailFrame); + await detailFrame.evaluate(() => window.settingsService.set("needClosePage", true)); + await detailFrame.evaluate(async () => { + const plugin = window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"); + const button = document.createElement("button"); + button.className = "jhs-offline-btn"; + button.textContent = "离线"; + document.body.append(button); + plugin.registry = { + getCandidates: async () => [{ provider: { id: "123", name: "123 云盘", isEnabled: async () => true, submit: async () => ({ ok: true }) }, availability: { authState: "ready" } }], + updateAvailability() {}, + }; + await plugin.submitResource({ currentTarget: button, clientX: 120, clientY: 120 }, "magnet:?xt=urn:btih:fixture", window.jQuery(button), { carNum: "ABC-1" }); + }); + await detailFrame.locator(".layui-layer-btn0").click(); + await expect(page.locator(`#layui-layer${iframeLayerIndex}`)).toHaveCount(0); +}); + +test("FC2 core workspace survives disabled optional detail contributions", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers FC2 optional contribution isolation"); + await fulfillHostFixtures(context); + const disabledPlugins = ["OtherSitePlugin", "ScreenShotPlugin", "FilterTitleKeywordPlugin", "MagnetHubPlugin"]; + await page.goto("https://javdb.com/users/collection_codes?movieId=fixture-id&carNum=FC2-123&url=https%3A%2F%2Ffc2ppvdb.com%2Farticles%2F123&source=fc2", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { disabledPlugins }); + await expect(page.locator(".jhs-fc2-workspace[data-jhs-fc2-mode='page']")).toBeVisible(); + const pluginNames = await page.evaluate(() => window.unsafeWindow.pluginManager.getPluginNames()); + disabledPlugins.forEach((name) => expect(pluginNames).not.toContain(name)); + await expect(page.locator('[data-jhs-role="other-sites"]')).toHaveCount(0); + await expect(page.locator('[data-jhs-role="magnet-hub"]')).toHaveCount(0); +}); diff --git a/tests/browser/specs/mobile-surface.spec.js b/tests/browser/specs/mobile-surface.spec.js new file mode 100644 index 00000000..8413fc31 --- /dev/null +++ b/tests/browser/specs/mobile-surface.spec.js @@ -0,0 +1,76 @@ +import { expect, test } from "@playwright/test"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +test.beforeEach(async ({ context }) => { + await fulfillHostFixtures(context); +}); + +test("compact list exposes only the FAB surface", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "mobile", "one compact viewport covers mobile surfaces"); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect(page.locator("#jhs-fab")).toBeVisible(); + await expect(page.locator("#jhs-fab-safe-area")).toHaveCount(1); + await expect(page.locator("#jhs-page-commandbar, #setting-btn")).toHaveCount(0); +}); + +test("compact detail can scroll the last toggle clear of the FAB", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "mobile", "one compact viewport covers FAB clearance"); + await page.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + const toggle = page.locator(".jhs-related-toggle"); + await expect(toggle).toBeVisible(); + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect.poll(async () => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + const overlaps = await page.evaluate(() => { + const fab = document.querySelector("#jhs-fab")?.getBoundingClientRect(); + const control = document.querySelector(".jhs-related-toggle")?.getBoundingClientRect(); + if (!fab || !control) return true; + return !(control.right <= fab.left || control.left >= fab.right || control.bottom <= fab.top || control.top >= fab.bottom); + }); + expect(overlaps).toBe(false); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +}); + +test("full settings layer stays above the compact FAB", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "mobile", "one compact viewport covers dialog stacking"); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const layer = page.locator(".layui-layer"); + const save = layer.locator("#saveBtn"); + await expect(save).toHaveAttribute("data-jhs-settings-ready", "true"); + const fab = page.locator("#jhs-fab"); + await expect(fab).toBeVisible(); + await expect(layer).toBeVisible(); + const stacking = { + fab: await fab.evaluate((element) => Number(getComputedStyle(element).zIndex)), + layer: await layer.evaluate((element) => Number(getComputedStyle(element).zIndex)), + }; + expect(stacking.layer).toBeGreaterThan(stacking.fab); + await save.click({ trial: true }); +}); + +test("backup loading feedback stays above the full settings layer", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic viewport covers loading stacking"); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const layer = page.locator(".layui-layer"); + await expect(layer.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + await page.evaluate(() => { window.__jhsLoadingFixture = window.loading(); }); + const loading = page.locator(".loading-container"); + await expect(loading).toBeVisible(); + await expect(loading).toHaveAttribute("role", "status"); + await expect(loading).toHaveAttribute("aria-live", "polite"); + await expect(loading).toHaveAttribute("aria-busy", "true"); + await expect(loading).toHaveAttribute("aria-label", "处理中"); + const stacking = { + layer: await layer.evaluate((element) => Number(getComputedStyle(element).zIndex)), + loading: await loading.evaluate((element) => Number(getComputedStyle(element).zIndex)), + }; + expect(stacking.loading).toBeGreaterThan(stacking.layer); + await page.evaluate(() => window.__jhsLoadingFixture.close()); + await expect(loading).toHaveCount(0); +}); diff --git a/tests/browser/specs/new-video-hover-layer.spec.js b/tests/browser/specs/new-video-hover-layer.spec.js new file mode 100644 index 00000000..c6b3e103 --- /dev/null +++ b/tests/browser/specs/new-video-hover-layer.spec.js @@ -0,0 +1,69 @@ +import { test, expect } from "@playwright/test"; +import { fileURLToPath } from "node:url"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +test.beforeEach(({}, info) => test.skip(info.project.name !== "desktop-wide", "real hover needs a mouse viewport")); +async function boot(page, context) { + await fulfillHostFixtures(context); + await context.route("https://images.example.test/**", route => route.fulfill({contentType:"image/svg+xml",body:''})); + await page.goto("https://javdb.com/"); + const add=page.addScriptTag.bind(page); + page.addScriptTag=async options=>{ + if(options.path?.endsWith("JHS.user.js")) { + await add({path:fileURLToPath(new URL("../fixtures/layer-runtime/layer-1.0.9.min.js",import.meta.url))}); + await add({path:fileURLToPath(new URL("../fixtures/viewer-runtime/viewer-1.11.1.min.js",import.meta.url))}); + } + return add(options); + }; + await injectUserscriptRuntime(page,{settingOverrides:{enableLoadReview:"no"}}); + await page.addStyleTag({path:fileURLToPath(new URL("../fixtures/viewer-runtime/viewer-1.11.1.min.css",import.meta.url))}); + await page.waitForFunction(()=>window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + await page.evaluate(()=>{ + const plugin=window.unsafeWindow.pluginManager.getBean("NewVideoPlugin"); + window.localStorage.setItem("jhs_newVideoViewMode","list"); + plugin.reloadNewVideoWorkspaceData=async function(){ + this.nvFlatListCache=[{carNum:"ABC-001",title:"Hover fixture",actressName:"Fixture",coverUrl:"https://images.example.test/cover.svg",flags:{},decisionState:"pending"}]; + this.nvJavDbUrl="https://javdb.com"; + this.nvRenderPage(this.nvRenderGeneration); + }; + }); + await page.locator("#newVideoBtn").click(); + for(let i=0;i<4;i++) {await page.locator(".layui-layer-close").click(); await expect(page.locator(".layui-layer")).toHaveCount(0);await page.locator("#newVideoBtn").click();} + await expect(page.locator(".nv-cover-img")).toBeVisible(); +} +async function hover(page) { + await page.locator(".nv-cover-img").hover(); + await expect(page.locator(".image-hover-preview.active img")).toBeVisible(); +} + +test("new work hover covers its high-index owner without clipping and cleans up on close",async({page,context})=>{ + await boot(page,context);await hover(page); + const order=await page.evaluate(()=>{ + const preview=document.querySelector(".image-hover-preview.active"),owner=document.querySelector(".newVideoToolBox").closest(".layui-layer"),r=preview.getBoundingClientRect(); + return {preview:+getComputedStyle(preview).zIndex,owner:+getComputedStyle(owner).zIndex,stack:document.elementsFromPoint(r.x+r.width/2,r.y+r.height/2).map(e=>e.className),right:r.right,bottom:r.bottom,width:innerWidth,height:innerHeight}; + }); + expect(order.preview).toBeGreaterThan(order.owner); + expect(order.right).toBeLessThanOrEqual(order.width); + expect(order.bottom).toBeLessThanOrEqual(order.height); + await page.locator(".layui-layer-close").click(); + await expect(page.locator(".image-hover-preview")).toHaveCount(0); +}); + +test("new work hover yields to nested settings without waiting for mouseout",async({page,context})=>{ + await boot(page,context);await hover(page); + await page.locator("#toSetting").dispatchEvent("click"); + await expect(page.locator(".layui-layer")).toHaveCount(2); + await expect(page.locator(".image-hover-preview.active")).toHaveCount(0); +}); + +for(const overlay of ["viewer","loading"])test(`new work hover yields to ${overlay} and can be opened again`,async({page,context})=>{ + await boot(page,context);await hover(page); + await page.evaluate(overlay=>{ + if(overlay==="viewer")window.showImageViewer(document.querySelector(".nv-cover-img")); + else window.fixtureLoading=window.loading(); + },overlay); + await expect(page.locator(".image-hover-preview.active")).toHaveCount(0); + if(overlay==="viewer") {await page.keyboard.press("Escape");await expect(page.locator(".viewer-container")).toHaveCount(0);} + else await page.evaluate(()=>window.fixtureLoading.close()); + await page.mouse.move(10,10);await hover(page); +}); diff --git a/tests/browser/specs/new-video-layout.spec.js b/tests/browser/specs/new-video-layout.spec.js new file mode 100644 index 00000000..3725b968 --- /dev/null +++ b/tests/browser/specs/new-video-layout.spec.js @@ -0,0 +1,42 @@ +import {test,expect} from "@playwright/test"; +import {fulfillHostFixtures,injectUserscriptRuntime} from "../harness/runtime.js"; + +test("new video pagination remains reachable without scrolling through sixty cards",async({page,context},info)=>{ + test.skip(info.project.name!=="desktop-wide","explicit viewport matrix owner"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/"); + await injectUserscriptRuntime(page,{settingOverrides:{enableLoadReview:"no"}}); + await page.waitForFunction(()=>window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + await page.evaluate(()=>{ + const plugin=window.unsafeWindow.pluginManager.getBean("NewVideoPlugin"); + plugin.getNewVideoFlatList=async()=>Array.from({length:75},(_,i)=>({carNum:`ABC-${i+100}`,actressName:"Fixture",publishTime:"2026-09-09",title:"Fixture title"})); + plugin.hydrateVisibleCovers=async()=>{}; + }); + await page.locator("#newVideoBtn").click(); + await page.locator("#nvViewList").click(); + await expect(page.locator("#nv-grid .nv-card")).toHaveCount(60); + for(const width of [1248,390,360]) { + await page.setViewportSize({width,height:844}); + await expect(page.locator("#nv-pagination-bar")).toBeInViewport({ratio:1}); + await expect.poll(async()=>(await page.locator("#new-video-list-container").boundingBox()).height).toBeGreaterThanOrEqual(150); + } + await page.locator('#nv-pagination-bar').getByRole("button",{name:"下一页",exact:true}).click(); + await expect(page.locator("#nv-grid .nv-card")).toHaveCount(15); + await page.setViewportSize({width:360,height:640}); + await expect.poll(async()=>(await page.locator("#new-video-list-container").boundingBox()).height).toBeGreaterThanOrEqual(150); + await page.locator("#nv-pagination-bar").scrollIntoViewIfNeeded(); + await expect(page.locator("#nv-pagination-bar")).toBeInViewport({ratio:1}); + await page.locator("#nvViewActress").click(); + await expect(page.locator("#nv-pagination-bar")).not.toBeVisible(); + await page.locator("#nvViewList").click(); + await expect(page.locator("#nv-pagination-bar")).toHaveCount(1); + await page.evaluate(()=>{ + const plugin=window.unsafeWindow.pluginManager.getBean("NewVideoPlugin"); + plugin.getRuntimeService("state").getNewVideoDecisions=()=>new Promise(resolve=>window.finishNvReload=()=>resolve({})); + }); + await page.locator("#reLoad").click(); + await expect(page.locator("#nv-pagination-bar")).toHaveCount(0); + await page.waitForFunction(()=>typeof window.finishNvReload==="function"); + await page.evaluate(()=>window.finishNvReload()); + await expect(page.locator("#nv-pagination-bar")).toHaveCount(1); +}); diff --git a/tests/browser/specs/offline-native-context.spec.js b/tests/browser/specs/offline-native-context.spec.js new file mode 100644 index 00000000..2b776296 --- /dev/null +++ b/tests/browser/specs/offline-native-context.spec.js @@ -0,0 +1,59 @@ +import { test, expect } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +test.beforeEach(({},info)=>test.skip(!["desktop-wide","mobile"].includes(info.project.name),"desktop and touch owners")); + +for(const url of ["https://javdb.com/","https://www.javbus.com/"]) { + test(`offline list context remains attached to the clicked card: ${url}`,async({page,context})=>{ + await fulfillHostFixtures(context); + await page.goto(url); + await injectUserscriptRuntime(page,{settingOverrides:{enableLoadReview:"no"}}); + await page.waitForFunction(()=>window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + const result=await page.evaluate(()=>{ + const card=document.querySelector(".movie-list .item, .masonry > .item, .masonry > .movie-box"); + const button=document.createElement("button"); card.append(button); + const info=window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin").getVideoInfo(window.jQuery(button)); + return {carNum:info.carNum,surface:info.surface}; + }); + expect(result).toMatchObject({carNum:"ABC-123",surface:"list-item"}); + }); +} + +for (const providerId of ["123","115"]) for (const surface of ["javdb-detail","javdb-iframe","javbus-detail"]) { + test(`${providerId} native magnet click resolves owning movie on ${surface}`,async({page,context})=>{ + await fulfillHostFixtures(context); + const html=await readFile(new URL("../fixtures/javdb-detail-interactions.html",import.meta.url),"utf8"); + await context.route("https://javdb.com/v/**",route=>route.fulfill({contentType:"text/html",body:html})); + const busHtml=await readFile(new URL("../fixtures/javbus-detail.html",import.meta.url),"utf8"); + const busTable=busHtml.replace('', '
    fixture magnet1GB2026-09-09
    '); + await context.route("https://www.javbus.com/ABC-123",route=>route.fulfill({contentType:"text/html",body:busTable})); + const settings={enableLoadReview:"no",enableLoadPreviewVideo:"no",needClosePage:"no"}; + await page.goto(surface==="javbus-detail"?"https://www.javbus.com/ABC-123":surface==="javdb-iframe"?"https://javdb.com/":"https://javdb.com/v/test-id"); + await injectUserscriptRuntime(page,{settingOverrides:settings}); + await page.waitForFunction(()=>window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + let target=page; + if(surface==="javdb-iframe") { + const navigation=page.waitForEvent("framenavigated",{predicate:frame=>frame!==page.mainFrame()&&frame.url().includes("/v/")}); + await page.locator(".movie-list .video-title").click(); + target=await navigation; + await injectUserscriptRuntime(target,{settingOverrides:settings}); + await target.waitForFunction(()=>window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); + } + await target.evaluate(id=>{ + const plugin=window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"); + window.offlineContextSubmits=[]; + window.offlineContextHistory=[]; + plugin.registry={getCandidates:async()=>[{provider:{id,name:id,isEnabled:async()=>true,submit:async(resource,info)=>window.offlineContextSubmits.push({resource,info})},availability:{authState:"ready"}}],updateAvailability(){}}; + plugin.getRuntimeService("state").appendOfflineHistory=async record=>window.offlineContextHistory.push(record); + },providerId); + const button=target.locator(".jhs-offline-native").first(); + await expect(button).toBeVisible(); + await button.click(); + await expect.poll(()=>target.evaluate(()=>window.offlineContextSubmits.length)).toBe(1); + const result=await target.evaluate(()=>({submits:window.offlineContextSubmits,history:window.offlineContextHistory})); + expect(result.submits[0].info.carNum).toBe("ABC-123"); + expect(result.submits[0].resource).toMatch(/^magnet:/); + expect(result.history).toEqual([expect.objectContaining({providerId,carNum:"ABC-123",status:"submitted"})]); + }); +} diff --git a/tests/browser/specs/release-five-regressions.spec.js b/tests/browser/specs/release-five-regressions.spec.js new file mode 100644 index 00000000..c22e01f4 --- /dev/null +++ b/tests/browser/specs/release-five-regressions.spec.js @@ -0,0 +1,165 @@ +import { test, expect } from "@playwright/test"; +import { fileURLToPath } from "node:url"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +test.beforeEach(({}, info) => test.skip(info.project.name !== "desktop-wide", "cross-tab and fault probes have one deterministic owner")); +async function boot(page, settings = {}, url = "https://javdb.com/v/test-id?jhsCarNum=ABC-123") { + await page.goto(url); + await injectUserscriptRuntime(page, {settingOverrides:{enableLoadReview:"no",enableLoadRelated:"no",enableLoadPreviewVideo:"no",...settings}}); + await page.waitForFunction(() => window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); +} +test("review keyword confirmation treats external text as text with the real Layer runtime", async ({page,context}) => { + await fulfillHostFixtures(context); await boot(page); + await page.addScriptTag({path:fileURLToPath(new URL("../fixtures/layer-runtime/layer-1.0.9.min.js", import.meta.url))}); + const text = ' \' & keyword'; + await page.evaluate(text => { window.unsafeWindow.pluginManager.getBean("ReviewPlugin").getRuntimeService("review").list = async () => [{author:"Audit",content:text,createdAt:"2026-09-09"}]; }, text); + await page.locator(".jhs-review-toggle").click(); + const body=page.locator(".jhs-review-content");await expect(body).toHaveText(text); + await body.evaluate(node => {const range=document.createRange();range.selectNodeContents(node);getSelection().removeAllRanges();getSelection().addRange(range);}); + await body.click({button:"right"}); + const dialog=page.locator(".layui-layer-dialog");await expect(dialog).toBeVisible(); + await expect(dialog.locator("img,b")).toHaveCount(0); + expect(await page.evaluate(()=>document.documentElement.dataset.auditExecuted)).toBeUndefined(); + await expect(dialog).toContainText(text); + await dialog.locator(".layui-layer-btn1").click(); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(async () => (await window.unsafeWindow.pluginManager.getBean("ReviewPlugin").getRuntimeService("storage").get("filter_keyword_review")) ?? [])).not.toContain(text); + await body.click({button:"right"});await page.locator(".layui-layer-dialog .layui-layer-btn0").click(); + await expect.poll(()=>page.evaluate(()=>window.unsafeWindow.pluginManager.getBean("ReviewPlugin").getRuntimeService("storage").get("filter_keyword_review"))).toContain(text); +}); +test("cloud OFF controls persist booleans and exclude both providers", async ({page,context}) => { + await fulfillHostFixtures(context);await boot(page,{enable123Offline:true,enable115Offline:true}); + await page.evaluate(()=>window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + await expect(page.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready","true"); + await page.locator('.side-menu-item[data-panel="cloud-services-panel"]').click(); + await page.locator("#enable123Offline").uncheck();await page.locator("#enable115Offline").uncheck(); + const result=await page.evaluate(async()=>{ + const plugin=window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"),settings=plugin.getRuntimeService("settings");await settings.waitForIdle(); + plugin.registry.providers.get("123").getAvailability=async()=>({available:true,authState:"ready"}); + return {values:[settings.snapshot().enable123Offline,settings.snapshot().enable115Offline],ids:(await plugin.registry.getCandidates("magnet:?xt=urn:btih:fixture",{force:true})).map(c=>c.provider.id)}; + }); + expect(result).toEqual({values:[false,false],ids:[]}); + await page.keyboard.press("Escape"); + await expect(page.locator("#enable123Offline")).toHaveCount(0); + await page.evaluate(()=>window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + await expect(page.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready","true"); + await page.locator('.side-menu-item[data-panel="cloud-services-panel"]').click(); + await expect(page.locator("#enable123Offline")).not.toBeChecked(); + await expect(page.locator("#enable115Offline")).not.toBeChecked(); +}); +test("115 OFF legacy value never matches a detail with a valid movie identity", async ({page,context}) => { + await fulfillHostFixtures(context);await boot(page,{enable115Match:"no"}); + expect(await page.evaluate(()=>window.__jhsBrowserDiagnostics.requests.filter(r=>new URL(r.url).hostname.endsWith("115.com")))).toEqual([]); + await expect(page.locator(".jhs-115-match")).toHaveCount(0); +}); +test("115 matching retry stays on the list", async ({page,context}) => { + await fulfillHostFixtures(context);await boot(page,{enable115Match:true},"https://javdb.com/"); + await page.locator(".jhs-115-list-match").click(); + await expect(page.locator(".jhs-115-list-match")).toBeVisible(); + expect(page.url()).toBe("https://javdb.com/");await expect(page.locator("iframe")).toHaveCount(0); +}); +test("related expansion synchronizes across tabs like reviews", async ({page,context}) => { + await fulfillHostFixtures(context);await boot(page);const other=await context.newPage();await boot(other); + for (const current of [page,other]) await current.evaluate(()=>{for(const [plugin,service] of [["RelatedPlugin","related"],["ReviewPlugin","review"]]) window.unsafeWindow.pluginManager.getBean(plugin).getRuntimeService(service).list=async()=>[];}); + await page.locator(".jhs-related-toggle").click();await page.locator(".jhs-review-toggle").click(); + await expect(other.locator(".jhs-review-toggle")).toHaveAttribute("aria-expanded","true"); + await expect(other.locator(".jhs-related-toggle")).toHaveAttribute("aria-expanded","true"); + await other.locator(".jhs-related-toggle").click();await expect(page.locator(".jhs-related-container")).toBeHidden(); +}); + +test("115 matching cancels late detail results and restarts once", async ({page,context}) => { + await fulfillHostFixtures(context);await boot(page,{enable115Match:false}); + await page.evaluate(()=>{ + window.matchRequests=[]; + const plugin=window.unsafeWindow.pluginManager.getBean("OneOneFiveMatchPlugin"); + plugin.getRuntimeService("offline").searchFiles=(_id,_query,{scope})=>new Promise(resolve=>window.matchRequests.push({scope,resolve})); + window.matchSettings=plugin.getRuntimeService("settings"); + return window.matchSettings.set("enable115Match",true); + }); + await expect.poll(()=>page.evaluate(()=>window.matchRequests.length)).toBe(1); + await page.evaluate(()=>window.matchSettings.set("enable115Match",false)); + expect(await page.evaluate(()=>window.matchRequests[0].scope.signal.aborted)).toBe(true); + await page.evaluate(()=>window.matchRequests[0].resolve([{name:"late result"}])); + await expect(page.locator(".jhs-115-match")).toHaveCount(0); + await page.evaluate(()=>window.matchSettings.set("enable115Match",true)); + await expect.poll(()=>page.evaluate(()=>window.matchRequests.length)).toBe(2); + await page.evaluate(()=>window.matchRequests[1].resolve([])); + await expect(page.locator(".jhs-115-match")).toHaveText("115匹配:未匹配 重试"); +}); + +for(const count of [0,1,2]) test(`115 ${count} matches isolate their card action`, async ({page,context})=>{ + await fulfillHostFixtures(context);await boot(page,{enable115Match:false},"https://javdb.com/"); + await page.evaluate(async count=>{ + const plugin=window.unsafeWindow.pluginManager.getBean("OneOneFiveMatchPlugin"),offline=plugin.getRuntimeService("offline"); + window.matchCalls=0;window.playLinks=[]; + window.open=url=>window.playLinks.push(url); + offline.searchFiles=async()=>{window.matchCalls++;return Array.from({length:count},(_,id)=>({name:`result ${id}`,fileId:String(id)}));}; + offline.getPlayUrl=(_id,match)=>`https://115.com/?fixture=${match.fileId}`; + await plugin.getRuntimeService("settings").set("enable115Match",true); + },count); + await page.locator(".jhs-115-list-match").click(); + if(count===0) await expect.poll(()=>page.evaluate(()=>window.matchCalls)).toBe(2); + if(count===1) expect(await page.evaluate(()=>window.playLinks)).toEqual(["https://115.com/?fixture=0"]); + if(count===2) await expect(page.locator('.layui-layer a[href^="https://115.com/"]')).toHaveCount(2); + expect(page.url()).toBe("https://javdb.com/");await expect(page.locator("iframe")).toHaveCount(0); +}); + +test("related collapse invalidates pending load and pagination can retry", async ({page,context})=>{ + await fulfillHostFixtures(context);await boot(page); + await page.evaluate(()=>{ + window.relatedRequests=[]; + window.unsafeWindow.pluginManager.getBean("RelatedPlugin").getRuntimeService("related").list=(_movie,{page,scope})=>new Promise((resolve,reject)=>window.relatedRequests.push({page,scope,resolve,reject})); + }); + await page.locator(".jhs-related-toggle").click(); + await expect.poll(()=>page.evaluate(()=>window.relatedRequests.length)).toBe(1); + await page.locator(".jhs-related-toggle").click(); + expect(await page.evaluate(()=>window.relatedRequests[0].scope.signal.aborted)).toBe(true); + await page.evaluate(()=>window.relatedRequests[0].resolve([{id:"late",name:"late"}])); + await page.locator(".jhs-related-toggle").click(); + await expect.poll(()=>page.evaluate(()=>window.relatedRequests.length)).toBe(2); + await page.evaluate(()=>window.relatedRequests[1].resolve(Array.from({length:20},(_,id)=>({id:String(id),name:`list ${id}`})))); + await expect(page.locator(".jhs-related-item")).toHaveCount(20); + await page.locator(".jhs-related-load-more").click(); + await page.evaluate(()=>window.relatedRequests[2].reject(new Error("fixture failure"))); + await expect(page.locator(".jhs-related-load-more")).toHaveText("加载失败,请重试"); + await page.locator(".jhs-related-load-more").click(); + await page.evaluate(()=>window.relatedRequests[3].resolve([{id:"last",name:"last"}])); + await expect(page.locator(".jhs-related-item")).toHaveCount(21); + expect(await page.evaluate(()=>window.relatedRequests.map(r=>r.page))).toEqual([1,1,2,2]); +}); + +test("provider disabled while choosing cannot submit", async ({page,context})=>{ + await fulfillHostFixtures(context);await boot(page,{enable115Offline:true}); + const result=await page.evaluate(async()=>{ + const plugin=window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"),provider=plugin.registry.providers.get("115");let submissions=0; + provider.submit=async()=>{submissions++;}; + plugin.chooseCandidate=async(_event,candidates)=>{await plugin.getRuntimeService("settings").set("enable115Offline",false);return candidates.find(c=>c.provider.id==="115");}; + const button=$("").appendTo("body"); + await plugin.submitResource({},"magnet:?xt=urn:btih:fixture",button,{carNum:"ABC-123"}); + return {submissions,busy:button.hasClass("loading"),text:button.text()}; + }); + expect(result).toEqual({submissions:0,busy:false,text:"离线"}); +}); + +test("123 disabled during token decryption never dispatches a cloud request", async ({page,context})=>{ + await fulfillHostFixtures(context);await boot(page,{enable123Offline:true}); + await page.evaluate(()=>{ + const plugin=window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin"); + const bridge=window.unsafeWindow.pluginManager.getBean("OneTwoThreeOfflinePlugin"); + window.tokenBoundaryCalls=0; + bridge.getStoredToken=()=>new Promise(resolve=>window.releaseBoundaryToken=resolve); + plugin.getRuntimeService("offline").submitWithIntegration=async()=>{window.tokenBoundaryCalls++;}; + const provider=plugin.registry.providers.get("123"); + plugin.registry.getCandidates=async()=>[{provider,availability:{authState:"ready"}}]; + const button=$("").appendTo("body"); + window.tokenBoundaryTask=plugin.submitResource({},"magnet:?xt=fixture",button,{carNum:"ABC-123"},{preferredProviderId:"123"}); + }); + await expect.poll(()=>page.evaluate(()=>typeof window.releaseBoundaryToken)).toBe("function"); + await page.evaluate(async()=>{ + await window.unsafeWindow.pluginManager.getBean("UnifiedOfflinePlugin").getRuntimeService("settings").set("enable123Offline",false); + window.releaseBoundaryToken("mock-token");await window.tokenBoundaryTask; + }); + expect(await page.evaluate(()=>window.tokenBoundaryCalls)).toBe(0); + await expect(page.locator("#token-boundary-button")).toHaveText("离线"); + await expect(page.locator("#token-boundary-button")).not.toHaveAttribute("aria-busy","true"); +}); diff --git a/tests/browser/specs/reviewed-regressions.spec.js b/tests/browser/specs/reviewed-regressions.spec.js new file mode 100644 index 00000000..78dc4be6 --- /dev/null +++ b/tests/browser/specs/reviewed-regressions.spec.js @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +for (const url of ["https://javdb.com/v/test-id", "https://www.javbus.com/ABC-123"]) { + test(`review preference mounts from OFF and blocks hidden pagination: ${url}`, async ({ page, context }, testInfo) => { + test.skip(!["desktop-wide", "mobile"].includes(testInfo.project.name), "desktop and mobile own this interaction contract"); + await fulfillHostFixtures(context); + await page.goto(url); + await injectUserscriptRuntime(page, { settingOverrides: { enableLoadReview: "no", reviewCount: 1 } }); + await expect.poll(() => page.evaluate(() => Boolean(window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]))).toBe(true); + await page.evaluate(async () => { + const plugin = window.unsafeWindow.pluginManager.getBean("ReviewPlugin"); + window.reviewRegressionRequests = 0; + plugin.getRuntimeService("movie").resolve = async () => ({ movieId: "test-id" }); + plugin.getRuntimeService("review").list = async () => { window.reviewRegressionRequests++; return [{ author: "fixture", content: "review body", createdAt: "2026-09-08" }]; }; + await plugin.getRuntimeService("settings").set("enableLoadReview", "yes"); + }); + await expect(page.locator(".jhs-review-item")).toHaveCount(1); + await expect(page.locator(".jhs-review-toggle")).toHaveAttribute("aria-expanded", "true"); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("ReviewPlugin").getRuntimeService("settings").set("enableLoadReview", "no")); + await expect(page.locator(".jhs-review-toggle")).toHaveAttribute("aria-expanded", "false"); + await page.locator(".jhs-review-load-more").evaluate(button => button.click()); + expect(await page.evaluate(() => window.reviewRegressionRequests)).toBe(1); + }); +} diff --git a/tests/browser/specs/setting-effects.spec.js b/tests/browser/specs/setting-effects.spec.js new file mode 100644 index 00000000..f6529c98 --- /dev/null +++ b/tests/browser/specs/setting-effects.spec.js @@ -0,0 +1,272 @@ +import { expect, test } from "@playwright/test"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +test("feature disabled leaves no dead list button", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers capability rendering"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { disabledPlugins: ["NewVideoPlugin", "BlacklistPlugin"] }); + await expect.poll(() => page.evaluate(() => window.isListPage)).toBe(true); + await expect(page.locator("#newVideoBtn")).toHaveCount(0); + await expect(page.locator("#blacklistBtn")).toHaveCount(0); + await expect(page.locator("#waitCheckBtn")).toHaveCount(1); +}); + +test("screenshot master switch OFF removes detail screenshot UI", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers screenshot gating"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enableLoadScreenShot", "no")); + await page.waitForTimeout(300); + await expect(page.locator(".screen-container")).toHaveCount(0); + await expect(page.locator(".jhs-screenshot-providers")).toHaveCount(0); +}); + +test("review defaults ON and explicit OFF closes the panel", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers review default and explicit opt-out"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + const reviewToggle = page.locator(".jhs-review-toggle").first(); + await expect(reviewToggle).toHaveAttribute("aria-expanded", "true"); + await page.close(); + const closedPage = await context.newPage(); + await closedPage.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(closedPage, { settingOverrides: { enableLoadReview: "no" } }); + await expect(closedPage.locator(".jhs-review-toggle").first()).toHaveAttribute("aria-expanded", "false"); + await closedPage.close(); +}); + +test("preview master switch OFF removes card preview buttons", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers preview gating"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enablePreviewVideo", "no")); + await page.waitForTimeout(300); + await expect(page.locator(".videoSvg")).toBeHidden(); // 实现为 toggle(false) 隐藏,语义:列表工具按钮隐藏而非 unmount +}); + +test("all quick filter is the true full set including blocked items", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers frozen filter semantics"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getPluginNames().includes("ListPagePlugin"))).toBe(true); + await page.evaluate(async () => { + const listPage = window.unsafeWindow.pluginManager.getBean("ListPagePlugin"); + const item = document.querySelector(".movie-list .item"); + const { carNum, url } = listPage.findCarNumAndHref(window.$ ? window.$(item) : null); + await window.stateService.patch(carNum, { blocked: true }, { type: "browser-setting-effect", record: { carNum, url, names: "", publishTime: "" } }); + }); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("ListPagePlugin").setQuickFilter("all")); + await expect.poll(() => page.evaluate(() => getComputedStyle(document.querySelector(".movie-list .item")).display)).not.toBe("none"); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("ListPagePlugin").setQuickFilter("favorite")); + await expect.poll(() => page.evaluate(() => getComputedStyle(document.querySelector(".movie-list .item")).display)).toBe("none"); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("ListPagePlugin").setQuickFilter("blockedItems")); + await expect.poll(() => page.evaluate(() => getComputedStyle(document.querySelector(".movie-list .item")).display)).not.toBe("none"); +}); + +test("quick-filter and DOM generations reject stale filter commits", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project owns the list generation stress check"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(async () => { + const listPage = window.unsafeWindow.pluginManager.getBean("ListPagePlugin"); + const originalGetFilterContext = listPage.getFilterContext.bind(listPage); + for (let index = 0; index < 50; index += 1) { + const context = listPage.filterContext || await originalGetFilterContext(); + let release; + listPage.filterContext = null; + listPage.getFilterContext = () => new Promise((resolve) => { release = resolve; }); + const pending = listPage.doFilter(listPage.captureListRevision()); + index % 2 && listPage.advanceListGeneration(); + listPage.setQuickFilter(index % 2 ? "favorite" : "blockedItems"); + release(context); + listPage.filterContext = context; + await pending; + } + listPage.getFilterContext = originalGetFilterContext; + listPage.setQuickFilter("all"); + await listPage.requestListRefresh({ reason: "stress-final", full: true }); + return window.__jhsBrowserDiagnostics.listPhases || []; + }); + const phases = await page.evaluate(() => window.__jhsBrowserDiagnostics.listPhases || []); + expect(phases.some(({ phase }) => phase === "doFilter-start")).toBe(true); + expect(phases.some(({ phase }) => phase === "doFilter-end")).toBe(true); + expect(phases.some(({ phase }) => phase === "setQuickFilter")).toBe(true); + expect(phases.every(({ generation, filterRevision, activeQuickFilter, itemCount }) => Number.isInteger(generation) && Number.isInteger(filterRevision) && typeof activeQuickFilter === "string" && Number.isInteger(itemCount))).toBe(true); + await expect.poll(() => page.evaluate(() => getComputedStyle(document.querySelector(".movie-list .item")).display)).not.toBe("none"); +}); + + +test("mobileMode force on/off swaps FAB and the desktop commandbar/setting surfaces", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers mobileMode live layout"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect(page.locator("#jhs-fab")).toHaveCount(0); + await expect(page.locator("#jhs-page-commandbar")).toHaveCount(1); + await expect(page.locator("#setting-btn")).toHaveCount(1); + const toolbarSelectors = ["#waitCheckBtn", "#newVideoBtn", "#jhs-quick-filter", ".jhs-sort-control", "#favoriteAllVideo", "#hasDownAllVideo"]; + for (const selector of toolbarSelectors) await expect(page.locator(selector)).toHaveCount(1); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("mobileMode", "on")); + await expect(page.locator("#jhs-fab")).toBeVisible(); + await expect(page.locator("#jhs-fab-safe-area")).toHaveCount(1); + await expect(page.locator("html")).toHaveClass(/jhs-fab-mounted/); + await expect(page.locator("#jhs-page-commandbar")).toHaveCount(0); + await expect(page.locator("#setting-btn")).toHaveCount(0); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("mobileMode", "off")); + await expect(page.locator("#jhs-fab")).toHaveCount(0); + await expect(page.locator("#jhs-fab-safe-area")).toHaveCount(0); + await expect(page.locator("html")).not.toHaveClass(/jhs-fab-mounted/); + await expect(page.locator("#jhs-page-commandbar")).toHaveCount(1); + await expect(page.locator("#setting-btn")).toHaveCount(1); + for (const selector of toolbarSelectors) await expect(page.locator(selector)).toHaveCount(1); + // 真实 click 验证 handler 在切换后仍然有效。 + await page.evaluate(() => { + window.__waitCheckClicks = 0; + const plugin = window.unsafeWindow.pluginManager.getBean("ListPageButtonPlugin"); + plugin.openWaitCheck = async () => { window.__waitCheckClicks++; }; + }); + await page.locator("#waitCheckBtn").click(); + await expect.poll(() => page.evaluate(() => window.__waitCheckClicks)).toBe(1); +}); + +test("search list pages get batch favorite/download buttons without an actress name", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers search batch entries"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/search?q=test", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect.poll(() => page.evaluate(() => window.isListPage)).toBe(true); + await expect(page.locator("#favoriteAllVideo")).toHaveCount(1); + await expect(page.locator("#hasDownAllVideo")).toHaveCount(1); + // 搜索页不渲染演员专用的批量屏蔽入口。 + await expect(page.locator("#addBlacklistBtn")).toHaveCount(0); +}); + +test("Preview master ON + DMM OFF leaves no DMM-only card button", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers preview capability gating"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + const setSetting = (key, value) => page.evaluate(({ key, value }) => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set(key, value), { key, value }); + // fixture 卡片带隐藏 .tags 宿主,卡片工具可被创建;DMM OFF 时按钮隐藏,ON 时恢复。 + await expect(page.locator(".videoSvg")).toHaveCount(1); + await setSetting("enableLoadPreviewVideo", "no"); + await page.waitForTimeout(300); + await expect(page.locator(".videoSvg")).toBeHidden(); + await setSetting("enableLoadPreviewVideo", "yes"); + await page.waitForTimeout(300); + await expect(page.locator(".videoSvg")).toHaveCount(1); + const display = await page.locator(".videoSvg").first().evaluate((element) => getComputedStyle(element).display); + expect(display).not.toBe("none"); +}); + +test("JavBus JHS preview entry disappears when the DMM sub switch is OFF", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers JavBus preview capability"); + await fulfillHostFixtures(context); + await page.goto("https://www.javbus.com/ABC-123", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect(page.locator(".preview-video-container")).toHaveCount(1); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enableLoadPreviewVideo", "no")); + await page.waitForTimeout(300); + await expect(page.locator(".preview-video-container")).toHaveCount(0); +}); + +test("FC2 OtherSite slot survives OFF→ON toggles", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers FC2 stable slots"); + await fulfillHostFixtures(context); + const url = "https://javdb.com/users/collection_codes?movieId=fixture-id&carNum=FC2-123&url=https%3A%2F%2Ffc2ppvdb.com%2Farticles%2F123&source=fc2"; + await page.goto(url, { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + const slot = page.locator('[data-jhs-role="other-sites"]'); + const group = page.locator('.jhs-fc2-resource-group:has([data-jhs-role="other-sites"])'); + const setOtherSite = (value) => page.evaluate((v) => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enableLoadOtherSite", v), value); + await expect(slot).toHaveCount(1); + await expect(group).toBeVisible(); + await setOtherSite("no"); + await expect(slot).toHaveCount(1); + await expect(group).toBeHidden(); + await setOtherSite("yes"); + await expect(slot).toHaveCount(1); + await expect(group).toBeVisible(); + await setOtherSite("no"); + await expect(slot).toHaveCount(1); + await setOtherSite("yes"); + await expect(slot).toHaveCount(1); + await expect(group).toBeVisible(); +}); + +test("batch actions are single-flight while a batch is running", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers batch single flight"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => { + const listPage = window.unsafeWindow.pluginManager.getBean("ListPagePlugin"); + const original = listPage.getRuntimeService.bind(listPage); + window.__httpCalls = 0; + window.__releaseBatch = null; + listPage.getRuntimeService = (name) => name === "http" ? { + request: () => { window.__httpCalls++; return new Promise((resolve) => { window.__releaseBatch = resolve; }); }, + } : original(name); + }); + const batchToggle = ".jhs-commandbar__batch .jhs-commandbar__menu-toggle"; + await page.locator(batchToggle).click(); + await page.locator("#favoriteAllVideo").click(); + await page.evaluate(() => document.querySelector(".layui-layer-btn0").click()); + await expect(page.locator("#jhs-batch-progress")).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__httpCalls)).toBe(1); + // 批量任务期间 loading 遮罩会拦截指针事件:用原生 click 验证 Single Flight 拒绝第二个任务。 + await page.evaluate(() => document.querySelector(".jhs-commandbar__batch .jhs-commandbar__menu-toggle").click()); + await page.evaluate(() => document.querySelector("#hasDownAllVideo").click()); + await page.waitForTimeout(300); + expect(await page.evaluate(() => window.__httpCalls)).toBe(1); + // 释放第一个任务,进度浮层消失、按钮恢复。 + await page.evaluate(() => window.__releaseBatch?.({ data: "" })); + await expect(page.locator("#jhs-batch-progress")).toHaveCount(0, { timeout: 5000 }); + await expect(page.locator("#favoriteAllVideo")).not.toHaveAttribute("aria-disabled", "true"); +}); + +test("FC2 detail screenshot slot follows the master switch live", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers FC2 screenshot lifecycle"); + await fulfillHostFixtures(context); + const url = "https://javdb.com/users/collection_codes?movieId=fixture-id&carNum=FC2-123&url=https%3A%2F%2Ffc2ppvdb.com%2Farticles%2F123&source=fc2"; + await page.goto(url, { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect(page.locator(".jhs-fc2-workspace")).toBeVisible(); + await expect(page.locator('[data-jhs-role="screenshot"]')).toHaveCount(1); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enableLoadScreenShot", "no")); + await expect(page.locator('[data-jhs-role="screenshot"]')).toBeEmpty(); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enableLoadScreenShot", "yes")); + await expect(page.locator('[data-jhs-role="screenshot"]')).not.toBeEmpty(); +}); + +test("cloud settings use the canonical catalog and persist normalized numeric values", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers the cloud settings catalog"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + const openCloud = async () => { + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + await expect(page.locator(".layui-layer #saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + await page.locator('.layui-layer .side-menu-item[data-panel="cloud-services-panel"]').click(); + await expect(page.locator(".layui-layer #cloud-settings-catalog")).toBeVisible(); + }; + await openCloud(); + await expect(page.locator(".layui-layer #cloud-settings-catalog .jhs-setting-row")).toHaveCount(7); + await page.locator(".layui-layer #oneOneFiveConcurrency").fill("99"); + await page.locator(".layui-layer #oneOneFiveConcurrency").press("Tab"); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").snapshot().oneOneFiveConcurrency)).toBe(10); + await page.locator(".layui-layer #oneOneFiveCacheMinutes").fill("4.8"); + await page.locator(".layui-layer #oneOneFiveCacheMinutes").press("Tab"); + await expect.poll(() => page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").snapshot().oneOneFiveCacheMinutes)).toBe(5); + await page.evaluate(() => window.layer.closeAll()); + await expect(page.locator(".layui-layer")).toHaveCount(0); + await openCloud(); + await expect(page.locator(".layui-layer #oneOneFiveConcurrency")).toHaveValue("10"); + await expect(page.locator(".layui-layer #oneOneFiveCacheMinutes")).toHaveValue("5"); +}); diff --git a/tests/browser/specs/startup-performance.spec.js b/tests/browser/specs/startup-performance.spec.js new file mode 100644 index 00000000..9b979f0e --- /dev/null +++ b/tests/browser/specs/startup-performance.spec.js @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import budget from "../../../performance-budget.json" with { type: "json" }; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +const SAMPLE_COUNT = Math.max(1, Number.parseInt(process.env.JHS_STARTUP_SAMPLES || "30", 10) || 30); +const BOOTSTRAP_PHASES = ["legacy-runtime", "pre-settings", "context", "settings-load", "settings-migration", "logger", "theme-ui", "registry", "feature-runtime", "data-prepare", "plugin-css", "plugin-runtime", "first-ready", "total"]; + +function median(values) { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)]; +} + +function percentile(values, ratio) { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; +} + +for (const [label, url] of [ + ["JavDB", "https://javdb.com/v/test-id"], + ["JavBus", "https://www.javbus.com/ABC-123"] +]) { + test(`${label} fixture startup remains within the reviewed median`, async ({ browser, context }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic desktop project owns startup timing"); + const cold = process.env.JHS_STARTUP_MODE !== "warm"; + if (!cold) await fulfillHostFixtures(context); + const samples = [], phaseSamples = []; + for (let index = 0; index < SAMPLE_COUNT; index += 1) { + const sampleContext = cold ? await browser.newContext({ viewport: testInfo.project.use.viewport, hasTouch: testInfo.project.use.hasTouch, isMobile: testInfo.project.use.isMobile, serviceWorkers: "block" }) : context; + if (cold) await fulfillHostFixtures(sampleContext); + const page = await sampleContext.newPage(); + await page.goto(url, { waitUntil: "domcontentloaded" }); + if (process.env.JHS_STARTUP_BUNDLE) { + const addScriptTag = page.addScriptTag.bind(page); + page.addScriptTag = options => addScriptTag(options.path?.endsWith("JHS.user.js") ? { path: process.env.JHS_STARTUP_BUNDLE } : options); + } + const startedAt = performance.now(); + // 保持启动基准只测核心引导;评论默认开启的请求不纳入启动预算。 + await injectUserscriptRuntime(page, { settingOverrides: { enableLoadReview: "no" } }); + samples.push(performance.now() - startedAt); + await expect.poll(() => page.evaluate(() => Boolean(window.__jhsBrowserDiagnostics?.bootstrapPhases?.["first-ready"]))).toBe(true); + phaseSamples.push(await page.evaluate(() => window.__jhsBrowserDiagnostics.bootstrapPhases)); + for (const phase of BOOTSTRAP_PHASES) expect(typeof phaseSamples.at(-1)?.[phase], `${label} bootstrap phase ${phase} must be recorded`).toBe("number"); + await page.close(); + if (cold) await sampleContext.close(); + } + const actualMedian = median(samples); + const baselineGroup = process.env.CI + ? budget.browserFixture.ciStartupMedianMilliseconds + : budget.browserFixture.startupMedianMilliseconds; + const baseline = baselineGroup[label]; + const maximum = baseline * (1 + budget.browserFixture.startupRegressionRatio); + const actualP95 = percentile(samples, 0.95); + const environment = process.env.CI ? "GitHub Ubuntu Chromium" : "Windows Edge"; + const report = `${label} fixture startup P50 ${actualMedian.toFixed(1)}ms P95 ${actualP95.toFixed(1)}ms; ${environment} baseline ${baseline}ms; samples ${samples.map((value) => value.toFixed(1)).join(", ")}`; + const harnessPhases = Object.keys(phaseSamples[0]?.harness || {}).map((phase) => `${phase}=${median(phaseSamples.map((sample) => sample.harness?.[phase] || 0)).toFixed(1)}ms`).join(", "); + const bootstrapPhases = Object.keys(phaseSamples[0] || {}).filter((phase) => phase !== "harness").map((phase) => `${phase}=${median(phaseSamples.map((sample) => sample[phase] || 0)).toFixed(1)}ms`).join(", "); + testInfo.annotations.push({ type: "startup-median", description: report }); + console.log(`${report}; harness phases ${harnessPhases}; bootstrap phases ${bootstrapPhases}`); + expect(baseline, `${label} startup baseline must be positive`).toBeGreaterThan(0); + expect(actualMedian, `${report}; reviewed maximum ${maximum.toFixed(1)}ms`).toBeLessThanOrEqual(maximum); + }); +} diff --git a/tests/browser/specs/ui-contract.spec.js b/tests/browser/specs/ui-contract.spec.js new file mode 100644 index 00000000..ae1475d7 --- /dev/null +++ b/tests/browser/specs/ui-contract.spec.js @@ -0,0 +1,74 @@ +import { test, expect } from "@playwright/test"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; +test.beforeEach(({}, info) => test.skip(info.project.name !== "desktop-wide", "one owner for explicit viewport matrix")); +async function boot(page, context, url) { + await fulfillHostFixtures(context); + await page.goto(url); + if (process.env.JHS_UI_BASELINE) { + const original = page.addScriptTag.bind(page); + page.addScriptTag = options => original(options.path?.endsWith("JHS.user.js") ? { path: process.env.JHS_UI_BASELINE } : options); + } + await injectUserscriptRuntime(page, { settingOverrides: { enableLoadReview:"no", enableLoadPreviewVideo:"no", needClosePage:"no" } }); + await page.waitForFunction(() => window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"]); +} +test("detail state actions obey shared geometry across widths and themes", async ({ page, context }) => { + await boot(page, context, "https://javdb.com/v/test-id"); + for (const width of [360,390,768,992,1248,1408]) for (const theme of ["light","dark"]) { + await page.setViewportSize({width,height:900}); + await page.evaluate(theme => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("themeMode",theme), theme); + const values = await page.locator("#filterBtn,#favoriteBtn,#hasDownBtn,#hasWatchBtn").evaluateAll(buttons => buttons.map(button => { + const style=getComputedStyle(button), rect=button.getBoundingClientRect(); + return {height:rect.height,width:rect.width,radius:style.borderRadius,top:rect.top,left:rect.left,pressed:button.getAttribute("aria-pressed")}; + })); + expect(values).toHaveLength(4); + for(const item of values) { expect(item.height).toBe(width<768?44:36); expect(item.width).toBeGreaterThanOrEqual(80); expect(item.radius).toBe("8px"); expect(["true","false"]).toContain(item.pressed); } + const groupWidth=await page.locator(".jhs-state-actions").first().evaluate(node=>node.getBoundingClientRect().width); + if(groupWidth<480) { expect(values[0].top).toBe(values[1].top); expect(values[2].top).toBeGreaterThan(values[0].top); } + expect(await page.locator("#enable-magnets-filter").getAttribute("class")).not.toContain("jhs-btn--watch"); + } +}); +test("settings dialog clamps after resize and preserves keyboard close", async ({page,context}) => { + await boot(page,context,"https://javdb.com/"); + await page.evaluate(()=>window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const dialog=page.locator(".layui-layer"); + await expect(dialog.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready","true"); + await page.setViewportSize({width:390,height:640}); + await expect.poll(async()=>(await dialog.boundingBox()).width).toBeLessThanOrEqual(374); + await expect.poll(async()=>(await dialog.boundingBox()).height).toBeLessThanOrEqual(624); + await expect(dialog.locator("#saveBtn")).toBeInViewport(); + await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); +}); +test("every settings category keeps visible controls inside its panel", async ({page,context}) => { + await boot(page,context,"https://javdb.com/"); + await page.evaluate(()=>window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const dialog=page.locator(".layui-layer"); + await expect(dialog.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready","true"); + const panels=await dialog.locator(".side-menu-item[data-panel]").evaluateAll(nodes=>nodes.map(node=>node.getAttribute("data-panel"))); + for(const width of [390,1248]) { + await page.setViewportSize({width,height:900}); + for(const panel of panels) { + const tab=dialog.locator(`.side-menu-item[data-panel="${panel}"]`); + if(!await tab.isVisible()) continue; + await tab.click(); + const visible=dialog.locator(`#${panel}`); + await expect(visible).toBeVisible(); + const size=await visible.evaluate(root=>({width:root.clientWidth,scroll:root.scrollWidth})); + expect(size.scroll, `${panel} at ${width}`).toBeLessThanOrEqual(size.width+1); + } + } +}); +test("history pagination stays inside the dialog after narrowing", async ({page,context}) => { + await boot(page,context,"https://javdb.com/"); + await page.locator("#historyBtn").click(); + const dialog=page.locator(".jhs-dialog"); + const footer=dialog.locator(".tabulator-footer"); + await expect(footer).toBeVisible(); + for(const width of [1408,390,768]) { + await page.setViewportSize({width,height:900}); + await expect.poll(async()=> { + const bounds=await dialog.boundingBox(), pagination=await footer.boundingBox(); + return pagination.y+pagination.height <= bounds.y+bounds.height-1; + }).toBe(true); + await expect(footer.locator('[data-page="next"]')).toBeInViewport(); + } +}); diff --git a/tests/browser/specs/visual-regression.spec.js b/tests/browser/specs/visual-regression.spec.js new file mode 100644 index 00000000..04a21e24 --- /dev/null +++ b/tests/browser/specs/visual-regression.spec.js @@ -0,0 +1,145 @@ +import { expect, test } from "@playwright/test"; +import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +/** + * 视觉回归(第一阶段核心页面)。 + * 首次运行生成基线:JHS_VISUAL_REGRESSION=1 npx playwright test --update-snapshots + * 普通 CI 默认跳过(由 JHS_VISUAL_REGRESSION=1 显式开启)。 + * 断言为真实 PNG diff(toHaveScreenshot),不再只验证“能截出一张图”。 + */ +const ENABLED = process.env.JHS_VISUAL_REGRESSION === "1"; +const VISUAL_PROJECTS = new Set(["desktop-wide", "mobile"]); + +const pages = [ + ["JavDB List", "https://javdb.com/", ".movie-list"], + ["JavDB Detail", "https://javdb.com/v/test-id", ".video-meta-panel"], + ["JavBus List", "https://www.javbus.com/", ".masonry"], + ["JavBus Detail", "https://www.javbus.com/ABC-123", ".row.movie"], + ["FC2 Detail", "https://javdb.com/users/collection_codes?movieId=fixture-id&carNum=FC2-123&url=https%3A%2F%2Ffc2ppvdb.com%2Farticles%2F123&source=fc2", ".jhs-fc2-workspace"], +]; + +for (const [label, url, selector] of pages) { + for (const theme of ["light", "dark"]) { + test(`${label} ${theme} baseline`, async ({ context, page }, testInfo) => { + test.skip(!ENABLED || !VISUAL_PROJECTS.has(testInfo.project.name), "visual regression is opt-in via JHS_VISUAL_REGRESSION=1 and pinned to desktop-wide/mobile"); + await fulfillHostFixtures(context); + await page.goto(url, { waitUntil: "domcontentloaded" }); + // 固定历史页面视觉基线时关闭评论网络加载;默认开启与显式关闭行为由 Review 回归覆盖。 + await injectUserscriptRuntime(page, { settingOverrides: { enableLoadReview: "no" } }); + await page.waitForFunction(() => Boolean(window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"])); + await page.evaluate((mode) => { + const settings = window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings"); + return settings.set("themeMode", mode); + }, theme); + await expect(page.locator("html")).toHaveAttribute("data-jhs-theme", theme); + await page.waitForTimeout(300); + await expect(page.locator(selector).first()).toBeVisible(); + await expect(page).toHaveScreenshot(`${label.replace(/\s+/g, "-")}-${theme}.png`, { fullPage: false, maxDiffPixelRatio: 0.02 }); + }); + } +} + +test("Settings dialog visual baseline", async ({ context, page }, testInfo) => { + test.skip(!ENABLED || !VISUAL_PROJECTS.has(testInfo.project.name), "visual regression is opt-in via JHS_VISUAL_REGRESSION=1 and pinned to desktop-wide/mobile"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + await expect(page.locator(".layui-layer #saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + // 只锁定设置弹窗本身,避免页面背景(FAB/日志浮层/列表状态)造成非确定性 diff。 + await expect(page.locator(".layui-layer")).toHaveScreenshot("settings-dialog.png", { maxDiffPixelRatio: 0.02 }); +}); + +test("Settings cloud panel visual baseline", async ({ context, page }, testInfo) => { + test.skip(!ENABLED || testInfo.project.name !== "desktop-wide", "cloud settings visual baseline is pinned to desktop-wide and opt-in"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); + const dialog = page.locator(".layui-layer"); + await expect(dialog.locator("#saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); + await dialog.locator('.side-menu-item[data-panel="cloud-services-panel"]').click(); + const panel = dialog.locator("#cloud-services-panel"); + await expect(panel).toBeVisible(); + await expect(panel.locator("#cloud-settings-catalog .jhs-setting-row")).toHaveCount(7); + await expect(panel).toHaveScreenshot("settings-cloud.png", { maxDiffPixelRatio: 0.02 }); +}); + +for (const theme of ["light", "dark"]) { + test(`JavDB List → FC2 Dialog ${theme}`, async ({ context, page }, testInfo) => { + test.skip(!ENABLED || !VISUAL_PROJECTS.has(testInfo.project.name), "visual regression is opt-in via JHS_VISUAL_REGRESSION=1 and pinned to desktop-wide/mobile"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/advanced_search?type=3", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { + settingOverrides: { + enableLoadReview: "yes", + enableLoadRelated: "yes", + enableLoadScreenShot: "no", + enableLoadOtherSite: "no", + }, + }); + await page.waitForFunction(() => Boolean(window.__jhsBrowserDiagnostics.bootstrapPhases["first-ready"])); + await page.evaluate((mode) => { + const manager = window.unsafeWindow.pluginManager; + const fc2 = manager.getBean("Fc2Plugin"); + const originalGetRuntimeService = fc2.getRuntimeService.bind(fc2); + fc2.resolveMovieIdForRecord = async () => "fixture-movie-id"; + fc2.resolveFc2Source = async () => "fc2"; + fc2.fetchAndRenderNativeDetail = async (context) => fc2.renderSummary(context, { + title: "FC2 Fixture Movie", + originalTitle: "FC2 Fixture Movie Original", + carNum: "FC2-PPV-4959150", + releaseDate: "2026-08-25", + score: 4.8, + duration: 118, + coverUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='450'%3E%3Crect width='300' height='450' fill='%23344a5f'/%3E%3Ccircle cx='150' cy='190' r='88' fill='%238ca7bf'/%3E%3C/svg%3E", + actors: [{ id: "fixture-actor", name: "Fixture Actor", gender: 0 }, { id: "fixture-actor-2", name: "Second Actor", gender: 1 }], + }); + fc2.fetchAndRenderNativeMagnets = async (context) => { + const host = context.root.find('[data-jhs-role="native-magnets"]'); + host.empty().append(window.jQuery("
    ").addClass("jhs-fc2-state").text("暂无站内磁力")); + }; + fc2.loadFc2Screenshot = () => {}; + fc2.getRuntimeService = (name) => { + if (name === "review") return { list: async () => [ + { author: "Alice", score: 5, createdAt: "2026-08-20", likes: 12, content: "清晰、稳定,值得收藏。" }, + { author: "Bob", score: 4, createdAt: "2026-08-18", likes: 7, content: "画面和字幕都很完整。" }, + ] }; + if (name === "related") return { list: async () => [ + { id: "list-1", name: "Related One", movieCount: 10, collectionCount: 4, viewCount: 20, createdAt: "2026-08-18" }, + { id: "list-2", name: "Related Two", movieCount: 6, collectionCount: 3, viewCount: 11, createdAt: "2026-08-17" }, + ] }; + return originalGetRuntimeService(name); + }; + const settings = manager.getBean("SettingPlugin").getRuntimeService("settings"); + return settings.set("themeMode", mode); + }, theme); + await expect(page.locator("html")).toHaveAttribute("data-jhs-theme", theme); + const primary = page.locator('.movie-list .item a[data-jhs-fc2-primary="true"]'); + await expect(primary).toBeVisible(); + await expect(primary).toHaveAttribute("data-jhs-fc2-primary", "true"); + await page.evaluate(() => document.querySelector('.movie-list .item a[data-jhs-fc2-primary="true"]')?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))); + const dialog = page.locator('.layui-layer').filter({ has: page.locator('.jhs-fc2-dialog-host') }).first(); + await expect(dialog.locator(".jhs-fc2-workspace")).toBeVisible(); + await expect(dialog.locator(".jhs-review-item")).toHaveCount(2); + await expect(dialog.locator(".jhs-related-item")).toHaveCount(2); + const geometry = await page.evaluate(() => { + const item = document.querySelector('.movie-list .item'); + const anchor = item?.querySelector('a[data-jhs-fc2-primary="true"]'); + const cover = item?.querySelector('.cover'); + const image = item?.querySelector('.cover img'); + const workspace = document.querySelector('.jhs-fc2-workspace'); + return { + anchor: anchor?.getBoundingClientRect().width || 0, + cover: cover?.getBoundingClientRect().width || 0, + image: image?.getBoundingClientRect().width || 0, + workspaceClient: workspace?.clientWidth || 0, + workspaceScroll: workspace?.scrollWidth || 0, + }; + }); + expect(Math.abs(geometry.anchor - geometry.cover)).toBeLessThanOrEqual(1); + expect(Math.abs(geometry.cover - geometry.image)).toBeLessThanOrEqual(1); + expect(geometry.workspaceScroll).toBeLessThanOrEqual(geometry.workspaceClient + 1); + await expect(dialog).toHaveScreenshot(`JavDB-List-FC2-Dialog-${theme}.png`, { maxDiffPixelRatio: 0.02 }); + }); +} diff --git a/tests/cache-service.test.js b/tests/cache-service.test.js new file mode 100644 index 00000000..d3f72fc4 --- /dev/null +++ b/tests/cache-service.test.js @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CacheService } from "../src/services/cache-service.js"; + +function backend() { + const data = new Map(); + return { + data, + get: vi.fn(async key => data.get(key)), + set: vi.fn(async (key, value) => data.set(key, value)), + remove: vi.fn(async key => data.delete(key)), + keys: vi.fn(async () => [ ...data.keys() ]), + }; +} + +afterEach(() => vi.useRealTimers()); + +describe("CacheService L1/L2 cache", () => { + it("reuses public entries across instances through the storage layer", async () => { + const storage = backend(), first = new CacheService({ storage }), second = new CacheService({ storage }); + await first.set("provider:key", { status: 200 }, { scope: "public", ttlMs: 60_000 }); + expect((await second.get("provider:key", { scope: "public" })).value).toEqual({ status: 200 }); + expect(second.publicCache.size).toBe(1); + }); + + it("expires public entries and removes their persisted representation", async () => { + vi.useFakeTimers(); + const storage = backend(), cache = new CacheService({ storage }); + await cache.set("expiring", "value", { scope: "public", ttlMs: 1_000 }); + vi.advanceTimersByTime(1_001); + await expect(cache.get("expiring", { scope: "public" })).resolves.toMatchObject({ hit: false }); + expect([ ...storage.data.keys() ]).toHaveLength(0); + }); + + it("never persists session entries", async () => { + const storage = backend(), first = new CacheService({ storage }), second = new CacheService({ storage }); + await first.set("auth", "secret", { scope: "session", sessionScopeId: "tab-1", ttlMs: 60_000 }); + await expect(second.get("auth", { scope: "session", sessionScopeId: "tab-1" })).resolves.toMatchObject({ hit: false }); + expect(storage.set).not.toHaveBeenCalled(); + }); + + it("clearPublic forces the next request to miss both L1 and L2", async () => { + const storage = backend(), first = new CacheService({ storage }), second = new CacheService({ storage }); + await first.set("network", "old", { scope: "public", ttlMs: 60_000 }); + await first.clearPublic(); + expect((await second.get("network", { scope: "public" })).hit).toBe(false); + expect(first.publicCache.size).toBe(0); + }); + + it("does not let an in-flight read repopulate after clearPublic", async () => { + let resolveRead; + const persisted = { version: 1, value: "stale", expiresAt: Date.now() + 60_000, negative: false, lastUsed: Date.now() }; + const storage = backend(); + storage.get.mockImplementationOnce(() => new Promise(resolve => { resolveRead = () => resolve(persisted); })); + const cache = new CacheService({ storage }); + const pending = cache.get("race", { scope: "public" }); + await vi.waitFor(() => expect(storage.get).toHaveBeenCalledOnce()); + await cache.clearPublic(); + resolveRead(); + await expect(pending).resolves.toMatchObject({ hit: false }); + expect(cache.publicCache.size).toBe(0); + }); + + it("defers persistent pruning and performs one bounded scan per maintenance batch", async () => { + const storage = backend(), cache = new CacheService({ storage }); + for (let index = 0; index < 128; index += 1) await cache.set(`key-${index}`, index, { scope: "public", ttlMs: 60_000 }); + await cache.waitForPrune(); + expect(storage.keys.mock.calls.length).toBeLessThanOrEqual(2); + expect(storage.get.mock.calls.length).toBeLessThanOrEqual(256); + }); + + it("does not persist a response with an obsolete public generation", async () => { + const storage = backend(), cache = new CacheService({ storage }), generation = cache.publicGeneration; + await cache.clearPublic(); + await cache.set("race", "stale", { scope: "public", ttlMs: 60_000, generation }); + expect(storage.set).not.toHaveBeenCalled(); + expect(cache.publicCache.size).toBe(0); + }); + + it("isolates namespaces and clears only the selected persisted namespace", async () => { + const storage = backend(), cache = new CacheService({ storage }); + await cache.set("actress-info", "same-key", "actress", { scope: "public", ttlMs: 60_000 }); + await cache.set("screenshot", "same-key", "shot", { scope: "public", ttlMs: 60_000 }); + expect((await cache.get("actress-info", "same-key", { scope: "public" })).value).toBe("actress"); + expect((await cache.get("screenshot", "same-key", { scope: "public" })).value).toBe("shot"); + await cache.clearNamespace("actress-info"); + expect((await cache.get("actress-info", "same-key", { scope: "public" })).hit).toBe(false); + expect((await cache.get("screenshot", "same-key", { scope: "public" })).value).toBe("shot"); + expect([...storage.data.keys()].some((key) => key.includes(":actress-info:") || key.includes(":screenshot:"))).toBe(true); + }); +}); diff --git a/tests/capability-availability.test.js b/tests/capability-availability.test.js new file mode 100644 index 00000000..be3e248f --- /dev/null +++ b/tests/capability-availability.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { readTestFile } from "./helpers/read-test-file.js"; +import { join } from "node:path"; +import { CommandRegistry } from "../src/app/command-registry.js"; +import { DependencyContainer } from "../src/app/dependency-container.js"; +import { FeatureRuntime } from "../src/app/feature-runtime.js"; +import { DiagnosticsService } from "../src/services/diagnostics-service.js"; +import { defineFeature } from "../src/contracts/manifests.js"; + +const listButtons = readTestFile(join(process.cwd(), "src/plugins/status/list-page-button.js"), "utf8"); +const newVideo = readTestFile(join(process.cwd(), "src/plugins/new-video/new-video.js"), "utf8"); +const coverButtons = readTestFile(join(process.cwd(), "src/plugins/image-viewer/cover-button.js"), "utf8"); +const registrySource = readTestFile(join(process.cwd(), "src/app/command-registry.js"), "utf8"); +const runtimeSource = readTestFile(join(process.cwd(), "src/app/feature-runtime.js"), "utf8"); + +describe("capability availability (无死按钮)", () => { + it("CommandRegistry tracks owner availability", () => { + const commands = new CommandRegistry(); + const diagnostics = new DiagnosticsService(); + const runtime = new FeatureRuntime({ container: new DependencyContainer(), commands, diagnostics, disabled: [], site: "javdb", route: "list" }); + runtime.register(defineFeature({ id: "sample", kind: "feature", disableable: true, sites: ["javdb"], routes: ["list"], startup: "on-command", requires: [], contributes: [], providesCommands: [ "sample.open" ], activate: () => ({ commands: { "sample.open": () => "ok" } }) })); + expect(commands.isAvailable("sample.open")).toBe(true); + const disabledRuntime = new FeatureRuntime({ container: new DependencyContainer(), commands: new CommandRegistry(), diagnostics, disabled: [ "disabled-feature" ], site: "javdb", route: "list" }); + disabledRuntime.register(defineFeature({ id: "disabled-feature", kind: "feature", disableable: true, sites: ["javdb"], routes: ["list"], startup: "on-command", requires: [], contributes: [], providesCommands: [ "disabled.open" ], activate: () => ({ commands: { "disabled.open": () => "no" } }) })); + expect(disabledRuntime.commands.isAvailable("disabled.open")).toBe(false); + expect(commands.isAvailable("unknown.open")).toBe(false); + }); + + it("legacy list toolbar renders buttons only when the capability exists", () => { + expect(listButtons).toContain('${hasNewVideo ? ``; - const { $, context } = createContext(fixture, { javdb: false }), row = $("#magnet-table tr").eq(1), cells = row.children("td").length, offline = new context.UnifiedOfflinePlugin; + const { $, context, host } = createContext(fixture, { javdb: false }), row = $("#magnet-table tr").eq(1), cells = row.children("td").length, offline = new context.UnifiedOfflinePlugin; + offline.runtimeServices = { host }; offline.injectNativeButtons(), offline.injectNativeButtons(); expect(row.children("td")).toHaveLength(cells); expect(row.children("td").first().children(".jhs-offline-actions")).toHaveLength(1); diff --git a/tests/detail-panel-lifecycle.test.js b/tests/detail-panel-lifecycle.test.js index eb9e2aac..b7500902 100644 --- a/tests/detail-panel-lifecycle.test.js +++ b/tests/detail-panel-lifecycle.test.js @@ -1,26 +1,48 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; +import { createLatestSettingWriter } from "../src/ui/settings/setting-binding-controller.js"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; +import { readReviewKeywords } from "../src/core/review-keywords.js"; -function loadPanels() { +function loadPanels({ settings = { enableLoadReview: "yes", enableLoadRelated: "yes", enableTitleSelectFilter: "yes" } } = {}) { const dom = new JSDOM('
    ', { url: "https://javdb.com/v/a" }), $ = jqueryFactory(dom.window); - const reviewFetch = vi.fn(async movieId => [{ username: movieId, score: 1, created_at: "2026-08-22", likes_count: 0, content: "ok" }]); - const relatedFetch = vi.fn(async movieId => [{ relatedId: movieId, name: movieId, movieCount: 1, collectionCount: 0, viewCount: 0, createTime: "today" }]); + const reviewFetch = vi.fn(async movieRef => [{ author: movieRef.movieId, score: 1, createdAt: "2026-08-22", likes: 0, content: "ok" }]); + const relatedFetch = vi.fn(async movieRef => [{ id: movieRef.movieId, name: movieRef.movieId, movieCount: 1, collectionCount: 0, viewCount: 0, createdAt: "today" }]); + const reviewKeywords = []; + const runtimeServices = { review: { list: reviewFetch }, related: { list: relatedFetch }, settings: { snapshot: () => settings, set: vi.fn(async (name, value) => settings[name] = value) }, storage: { get: vi.fn(async () => reviewKeywords), set: vi.fn(async (_key, value) => { reviewKeywords.splice(0, reviewKeywords.length, ...value); }) }, scope: async () => null }; const context = vm.createContext({ - document: dom.window.document, window: dom.window, $, BasePlugin: class { getBean() { return null; } }, _: "yes", C: "no", r: false, l: false, - storageManager: { getSetting: vi.fn(async key => "reviewCount" === key ? 20 : "yes"), getReviewFilterKeywordList: vi.fn(async () => []), saveReviewFilterKeyword: vi.fn(), saveSettingItem: vi.fn() }, - R: reviewFetch, K: relatedFetch, utils: { formatDate: value => value, q: (event, message, callback) => callback() }, show: { error: vi.fn(), ok: vi.fn() }, clog: { error: vi.fn(), warn: vi.fn() }, escapeHtml: String, - i: (target, key, value) => (target[key] = value) + document: dom.window.document, window: dom.window, $, BasePlugin: class { getBean() { return null; } getRuntimeService(name) { return runtimeServices[name]; } }, _: "yes", C: "no", r: false, l: false, + storageManager: { getSetting: vi.fn(async (key, fallback) => null == key ? settings : "reviewCount" === key ? 20 : settings[key] ?? fallback), getReviewFilterKeywordList: vi.fn(async () => []), saveReviewFilterKeyword: vi.fn(), saveSettingItem: vi.fn() }, + utils: { formatDate: value => value, q: (event, message, callback) => callback() }, show: { error: vi.fn(), ok: vi.fn() }, clog: { error: vi.fn(), warn: vi.fn() }, escapeHtml: String, + i: (target, key, value) => (target[key] = value), createLatestSettingWriter, LifecycleScope, readReviewKeywords }); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8")};globalThis.Review=ReviewPlugin`, context); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8")};globalThis.Related=RelatedPlugin`, context); - return { $, Review: context.Review, Related: context.Related, reviewFetch, relatedFetch, storageManager: context.storageManager, window: dom.window }; + vm.runInContext(readTestFile(join(process.cwd(), "src/ui/detail/related-panel.js"), "utf8"), context); + vm.runInContext(readTestFile(join(process.cwd(), "src/ui/detail/review-panel.js"), "utf8"), context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8")};globalThis.Review=ReviewPlugin`, context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8")};globalThis.Related=RelatedPlugin`, context); + return { $, Review: context.Review, Related: context.Related, reviewFetch, relatedFetch, storage: runtimeServices.storage, window: dom.window }; } describe("detail panel instance lifecycle", () => { + it("loads reviews by default when the preference was never saved", async () => { + const { $, Review, reviewFetch } = loadPanels({ settings: {} }), plugin = new Review; + const panel = await plugin.showReview("A", $("#review-a")); + expect(reviewFetch).toHaveBeenCalledOnce(); + expect(panel.find(".jhs-review-toggle").attr("aria-expanded")).toBe("true"); + }); + + it("keeps reviews closed when explicitly disabled", async () => { + const { $, Review, reviewFetch } = loadPanels({ settings: { enableLoadReview: "no" } }), plugin = new Review; + const panel = await plugin.showReview("A", $("#review-a")); + expect(reviewFetch).not.toHaveBeenCalled(); + expect(panel.find(".jhs-review-toggle").attr("aria-expanded")).toBe("false"); + }); + it("keeps review floor, loading state, and panel ownership per target", async () => { const { $, Review, reviewFetch } = loadPanels(), plugin = new Review; const panelA = await plugin.showReview("A", $("#review-a")), panelB = await plugin.showReview("B", $("#review-b")); @@ -43,17 +65,17 @@ describe("detail panel instance lifecycle", () => { }); it("binds the review context-menu handler exactly once across multiple panels", async () => { - const { $, Review, storageManager, window } = loadPanels(), plugin = new Review; + const { $, Review, storage, window } = loadPanels(), plugin = new Review; await plugin.showReview("A", $("#review-a")), await plugin.showReview("B", $("#review-b")); window.getSelection = () => ({ toString: () => "keyword" }); $("#review-a .review-content").trigger("contextmenu"); - await vi.waitFor((() => expect(storageManager.saveReviewFilterKeyword).toHaveBeenCalledOnce())); + await vi.waitFor((() => expect(storage.set).toHaveBeenCalledOnce())); }); it("adopts owned workspace headers and does not prefetch the second review page", async () => { const { $, Review, reviewFetch } = loadPanels(), plugin = new Review; const section = $('
    ').appendTo("body"); - reviewFetch.mockResolvedValueOnce(Array.from({ length: 20 }, ((_, index) => ({ username: String(index), score: 1, created_at: "2026", likes_count: 0, content: "ok" })))); + reviewFetch.mockResolvedValueOnce(Array.from({ length: 20 }, ((_, index) => ({ author: String(index), score: 1, createdAt: "2026", likes: 0, content: "ok" })))); const panel = await plugin.showReview("owned", section.find("#owned-reviews"), { ownedSection: section, isActive: () => true }); expect(panel.children(".jhs-panel-header")).toHaveLength(0), expect(section.find('> header [data-jhs-section-actions="reviews"] .jhs-review-toggle')).toHaveLength(1); expect(reviewFetch).toHaveBeenCalledTimes(1), expect(panel.find(".jhs-review-load-more")).toHaveLength(1); diff --git a/tests/detail-state-controller.test.js b/tests/detail-state-controller.test.js index a7c8d146..d61729ff 100644 --- a/tests/detail-state-controller.test.js +++ b/tests/detail-state-controller.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -17,12 +18,12 @@ function loadController() { document: dom.window.document, $, m: "屏蔽", u: "取消屏蔽", v: "收藏", b: "取消收藏", y: "标记下载", k: "标记观看", normalizeCarNum: value => String(value).trim().toUpperCase(), normalizeStateFlags: flags => ({ blocked: false, favorite: false, downloaded: false, watched: false, ...flags }), - storageManager: { getCar: async carNum => records.get(carNum) }, stateService: { toggle }, + storageManager: { getCar: async carNum => records.get(carNum) }, utils: { q: confirm, closePage }, show: { error: vi.fn() }, clog: { error: vi.fn() } }); - const source = readFileSync(join(repoRoot, "src/core/detail-state-controller.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/core/detail-state-controller.js"), "utf8"); vm.runInContext(`${source};globalThis.Controller=DetailStateController`, context); - return { controller: new context.Controller(), dom, $, records, toggle, confirm, closePage }; + return { controller: new context.Controller({ toggle }), dom, $, records, toggle, confirm, closePage }; } describe("DetailStateController", () => { @@ -52,16 +53,48 @@ describe("DetailStateController", () => { }); describe("Utils.closePage", () => { + it("attempts to close a userscript-opened detail tab even without window.opener", async () => { + const dom = new JSDOM('
    ', { url: "https://javdb.example/v/a" }), $ = jqueryFactory(dom.window), closeWindow = vi.fn(); + dom.window.close = closeWindow; + const context = vm.createContext({ + console, URL, window: dom.window, document: dom.window.document, $, layer: { close: vi.fn(), open: vi.fn() }, + storageManager: { getSetting: vi.fn().mockResolvedValue("yes") }, GM_openInTab: vi.fn(), normalizeCarNum: value => value, + i: (target, key, value) => (target[key] = value) + }); + vm.runInContext(`${readTestFile(join(repoRoot, "src/core/utils.js"), "utf8")};globalThis.TestUtils=Utils`, context); + await expect(new context.TestUtils().closePage({ root: dom.window.document.querySelector("#detail") })).resolves.toBe(true); + expect(closeWindow).toHaveBeenCalledOnce(); + }); + + it("prefers the owning iframe detail layer over a nested resource layer", async () => { + const parentDom = new JSDOM('
    ', { url: "https://javdb.example/" }); + const frame = parentDom.window.document.querySelector("#detail-frame"), childWindow = frame.contentWindow; + childWindow.document.body.innerHTML = '
    '; + const $ = jqueryFactory(childWindow), parentClosePage = vi.fn().mockResolvedValue(true), childClose = vi.fn(); + parentDom.window.utils = { closePage: parentClosePage }; + const context = vm.createContext({ + console, URL, window: childWindow, document: childWindow.document, unsafeWindow: childWindow, $, layer: { close: childClose, open: vi.fn() }, + storageManager: { getSetting: vi.fn().mockResolvedValue("yes") }, GM_openInTab: vi.fn(), normalizeCarNum: value => value, + i: (target, key, value) => (target[key] = value) + }); + vm.runInContext(`${readTestFile(join(repoRoot, "src/core/utils.js"), "utf8")};globalThis.TestUtils=Utils`, context); + await new context.TestUtils().closePage({ root: childWindow.document.querySelector("#offline") }); + expect(parentClosePage).toHaveBeenCalledWith({ layerIndex: 7 }); + expect(childClose).not.toHaveBeenCalled(); + }); + it("closes the layer owning root instead of the last layer", async () => { const dom = new JSDOM('
    ', { url: "https://javdb.example/v/a" }); const $ = jqueryFactory(dom.window), close = vi.fn(), context = vm.createContext({ console, URL, window: dom.window, document: dom.window.document, $, layer: { close, open: vi.fn() }, - storageManager: { getSetting: vi.fn().mockResolvedValue("yes") }, GM_openInTab: vi.fn(), normalizeCarNum: value => value, + storageManager: { getSetting: vi.fn().mockResolvedValue(true) }, GM_openInTab: vi.fn(), normalizeCarNum: value => value, i: (target, key, value) => (target[key] = value) }); dom.window.layer = context.layer; - vm.runInContext(`${readFileSync(join(repoRoot, "src/core/utils.js"), "utf8")};globalThis.TestUtils=Utils`, context); - await new context.TestUtils().closePage({ root: dom.window.document.querySelector("#detail") }); + vm.runInContext(`${readTestFile(join(repoRoot, "src/core/utils.js"), "utf8")};globalThis.TestUtils=Utils`, context); + const utils = new context.TestUtils(), detail = dom.window.document.querySelector("#detail"), layerIndex = utils.getOwningLayerIndex({ root: detail }); + detail.remove(); + await utils.closePage({ root: detail, layerIndex }); expect(close).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledWith(7); }); diff --git a/tests/dialog-scoping.test.js b/tests/dialog-scoping.test.js new file mode 100644 index 00000000..8bdd55bf --- /dev/null +++ b/tests/dialog-scoping.test.js @@ -0,0 +1,40 @@ +import { readTestFile } from "./helpers/read-test-file.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const blacklist = readTestFile(join(process.cwd(), "src/plugins/blacklist/blacklist.js"), "utf8"); +const settingBackup = readTestFile(join(process.cwd(), "src/plugins/backup/setting-backup.js"), "utf8"); +const history = readTestFile(join(process.cwd(), "src/plugins/status/history.js"), "utf8"); + +function methodBody(source, start, end) { + return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start))); +} + +describe("layered dialog DOM ownership", () => { + it("scopes blacklist dialog queries to the layer root", () => { + expect(blacklist).toContain("this.blacklistRoot = dialog;"); + expect(blacklist).toContain("this.blacklistRoot = null"); + expect(blacklist).toContain('this.blacklistRoot.find("#dataType")'); + const getData = methodBody(blacklist, "async getTableData", "async loadTableData"); + expect(getData).toContain('root.find("#searchValue")'); + expect(getData).toContain('root.find("#dataType")'); + const renderStatus = methodBody(blacklist, "renderTaskStatus()", "async reloadTable"); + expect(renderStatus).toContain('(this.blacklistRoot || $()).find("#blacklist-task-status")'); + // the table is created on a scoped element, never the global "#table-container" selector + expect(blacklist).not.toContain('Tabulator, "#table-container"'); + }); + + it("scopes the WebDAV file-list table to its layer element", () => { + const open = methodBody(settingBackup, "function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialog) {", "async function exportSettingData"); + expect(open).toContain('$(a).find(".jhs-table-dialog__content")'); + expect(open).not.toContain('Tabulator, "#table-container"'); + }); + + it("scopes History dialog CSS under the dialog root class", () => { + const css = methodBody(history, "async initCss", "handleResize()"); + expect(css).toContain(".jhs-history-dialog #filterBox"); + expect(css).toContain(".jhs-history-dialog #allSelectBox"); + expect(css).toContain(".jhs-history-dialog #table-container"); + expect(history).toContain('class="jhs-history-layout jhs-history-dialog"'); + }); +}); diff --git a/tests/dialog-service.test.js b/tests/dialog-service.test.js new file mode 100644 index 00000000..689bbc28 --- /dev/null +++ b/tests/dialog-service.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { LayerDialogAdapter } from "../src/platform/userscript/layer-dialog-adapter.js"; +import { DialogService } from "../src/services/dialog-service.js"; +import { JHS_Z_INDEX } from "../src/core/theme.js"; + +describe("dialog service", () => { + it("keeps the image viewer above ordinary Layer dialogs", () => { + expect(JHS_Z_INDEX.viewer).toBeGreaterThan(JHS_Z_INDEX.layer); + expect(JHS_Z_INDEX.loading).toBeGreaterThan(JHS_Z_INDEX.viewer); + expect(JHS_Z_INDEX.debug).toBeGreaterThan(JHS_Z_INDEX.loading); + }); + + it("keeps the hover preview below modal dialogs but above host chrome", () => { + expect(JHS_Z_INDEX.hoverPreview).toBeGreaterThan(JHS_Z_INDEX.hostTopbar); + expect(JHS_Z_INDEX.modal).toBeGreaterThan(JHS_Z_INDEX.hoverPreview); + }); + + it("forwards open, close, confirm and alert through the DialogPort", () => { + const layer = { + open: vi.fn(() => 1), close: vi.fn(), confirm: vi.fn(() => 2), alert: vi.fn(() => 3), + }; + const service = new DialogService(new LayerDialogAdapter(layer)), yes = vi.fn(); + expect(service.open({ title: "dialog" })).toBe(1); + service.close(1); + expect(service.confirm("confirm", { icon: 3 }, yes)).toBe(2); + expect(service.alert("done")).toBe(3); + expect(layer.close).toHaveBeenCalledWith(1); + expect(layer.open).toHaveBeenCalledWith({ title: "dialog", zIndex: JHS_Z_INDEX.layer }); + expect(layer.confirm).toHaveBeenCalledWith("confirm", { icon: 3, zIndex: JHS_Z_INDEX.layer }, yes); + expect(layer.alert).toHaveBeenCalledWith("done", { zIndex: JHS_Z_INDEX.layer }); + }); + + it("preserves an explicit caller z-index", () => { + const layer = { open: vi.fn(() => 1), close: vi.fn(), confirm: vi.fn(), alert: vi.fn() }; + const service = new DialogService(new LayerDialogAdapter(layer)); + service.open({ zIndex: 42 }); + expect(layer.open).toHaveBeenCalledWith({ zIndex: 42 }); + }); +}); diff --git a/tests/dialog-shell.test.js b/tests/dialog-shell.test.js new file mode 100644 index 00000000..0683aab9 --- /dev/null +++ b/tests/dialog-shell.test.js @@ -0,0 +1,27 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { JSDOM } from "jsdom"; +import { prepareDialogOptions } from "../src/core/dialog-shell.js"; +let dom; +afterEach(() => { dom?.window.close(); vi.unstubAllGlobals(); }); +it("consumes UI metadata, preserves callbacks and releases resize listeners", () => { + dom = new JSDOM('
    '); + vi.stubGlobal("window", dom.window); + const remove = vi.spyOn(dom.window, "removeEventListener"), success = vi.fn(() => "mounted"), end = vi.fn(() => "closed"); + const options = prepareDialogOptions({ ui: { size: "lg", body: "table" }, success, end, shadeClose: false }, { getDialogArea: () => ["800px", "600px"] }); + expect(options.ui).toBeUndefined(); + expect(Object.getOwnPropertySymbols(options)).toEqual([]); + expect(options.shadeClose).toBe(false); + expect(options.area).toEqual(["800px", "600px"]); + expect(prepareDialogOptions(options)).toBe(options); + const copied = { ...options }; + expect(prepareDialogOptions(copied)).toBe(copied); + const root = dom.window.document.querySelector("#dialog"); + expect(options.success(root, 7)).toBe("mounted"); + expect(root.dataset.jhsDialogBody).toBe("table"); + Object.defineProperty(dom.window, "innerWidth", { value:390, configurable:true }); + dom.window.dispatchEvent(new dom.window.Event("resize")); + expect(root.style.maxWidth).toBe("374px"); + expect(options.end("reason")).toBe("closed"); + expect(end).toHaveBeenCalledExactlyOnceWith("reason"); + expect(remove.mock.calls.some(([event]) => event === "resize")).toBe(true); +}); diff --git a/tests/dmm-preview-storage.test.js b/tests/dmm-preview-storage.test.js new file mode 100644 index 00000000..2dd064ed --- /dev/null +++ b/tests/dmm-preview-storage.test.js @@ -0,0 +1,19 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchDmmPreview } from "../src/services/preview-service.js"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("DMM preview persistence boundary", () => { + it("reads the legacy cache key through the declared StorageService", async () => { + const sources = { mhb_w: "https://example.test/preview.mp4" }, storage = { + getLocal: vi.fn((key) => key === "jhs_dmm_video" ? JSON.stringify({ "ABC-123": sources }) : null), + setLocal: vi.fn(), + }; + vi.stubGlobal("clog", { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }); + await expect(fetchDmmPreview("ABC-123", storage)).resolves.toEqual({ sources, error: null }); + expect(storage.getLocal).toHaveBeenCalledWith("jhs_dmm_video"); + expect(storage.setLocal).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/escape-ownership.test.js b/tests/escape-ownership.test.js new file mode 100644 index 00000000..400bf1ab --- /dev/null +++ b/tests/escape-ownership.test.js @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import {afterEach,it,expect,vi} from "vitest"; +import jquery from "jquery"; +import {Utils} from "../src/core/utils.js"; +afterEach(()=>{jquery(document).off(".globalLayerEsc");document.body.replaceChildren();Utils.instance=null;vi.unstubAllGlobals();}); +function mount() { + vi.stubGlobal("$",jquery);vi.stubGlobal("clog",{warn:vi.fn()}); + document.body.innerHTML='
    '; + const utils=new Utils(); + vi.stubGlobal("layer",{close:id=>{document.getElementById(`layui-layer${id}`)?.remove();utils.releaseEscClose(id);}}); + utils.setupEscClose(1); + return {utils,frame:document.querySelector("iframe").contentDocument}; +} +it("respects an Escape event already consumed by a child",()=>{ + const {utils}=mount(),event=jquery.Event("keydown",{key:"Escape"});event.preventDefault(); + utils._handleGlobalEscKey(event); + expect(document.querySelector("#layui-layer1")).not.toBeNull(); +}); +it.each(["fancybox-is-open","fancybox-is-closing"])("keeps the parent while iframe preview is %s",state=>{ + const {frame}=mount(); + frame.body.innerHTML=`
    `; + const overlay=frame.querySelector("div"); + overlay.getClientRects=()=>[{width:100,height:100}]; + frame.querySelector("button").dispatchEvent(new frame.defaultView.KeyboardEvent("keydown",{key:"Escape",bubbles:true,cancelable:true})); + expect(document.querySelector("#layui-layer1")).not.toBeNull(); + overlay.remove(); + frame.body.dispatchEvent(new frame.defaultView.KeyboardEvent("keydown",{key:"Escape",bubbles:true,cancelable:true})); + expect(document.querySelector("#layui-layer1")).toBeNull(); +}); +it("a hidden preview container does not swallow parent Escape",()=>{ + const {frame}=mount();frame.body.innerHTML=''; + frame.body.dispatchEvent(new frame.defaultView.KeyboardEvent("keydown",{key:"Escape",bubbles:true,cancelable:true})); + expect(document.querySelector("#layui-layer1")).toBeNull(); +}); +it("a preview owned by a lower layer does not block closing the upper layer",()=>{ + const {utils}=mount(); + const overlay=document.createElement("div");overlay.className="fancybox-container fancybox-is-open"; + overlay.getClientRects=()=>[{width:100,height:100}];document.querySelector("#layui-layer1").append(overlay); + const upper=document.createElement("div");upper.className="layui-layer";upper.id="layui-layer2";document.body.append(upper);utils.setupEscClose(2); + upper.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true,cancelable:true})); + expect(document.querySelector("#layui-layer2")).toBeNull(); + expect(document.querySelector("#layui-layer1")).not.toBeNull(); +}); diff --git a/tests/event-bus-v2.test.js b/tests/event-bus-v2.test.js index b166b3f4..eb5b789b 100644 --- a/tests/event-bus-v2.test.js +++ b/tests/event-bus-v2.test.js @@ -1,9 +1,10 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it, vi } from "vitest"; -const source = readFileSync(join(import.meta.dirname, "../src/core/event-bus.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/core/event-bus.js"), "utf8"); class FakeBroadcastChannel { static channels = []; @@ -13,7 +14,7 @@ class FakeBroadcastChannel { } function loadBus() { - const context = vm.createContext({ BroadcastChannel: FakeBroadcastChannel, crypto: { randomUUID: vi.fn().mockReturnValueOnce("tab-a").mockReturnValueOnce("tab-b").mockReturnValue("event-1") }, Date, Math, Map, Set, window: {}, unsafeWindow: {} }), end = source.indexOf("const jhsEventBus"); + const context = vm.createContext({ BroadcastChannel: FakeBroadcastChannel, crypto: { randomUUID: vi.fn().mockReturnValueOnce("tab-a").mockReturnValueOnce("tab-b").mockReturnValue("event-1") }, Date, Math, Map, Set, window: {}, unsafeWindow: {} }), end = source.indexOf("let jhsEventBus"); vm.runInContext(`${source.slice(0, end)}; globalThis.Bus = JhsEventBus;`, context); return context.Bus; } @@ -44,4 +45,30 @@ describe("precise event bus", () => { await bus._receive(event), await bus._receive(event), await bus._receive({ ...event, eventId: "self", originId: bus.originId }); expect(handler).toHaveBeenCalledTimes(1); }); + + it("isolates a throwing local listener and still broadcasts to other tabs", async () => { + FakeBroadcastChannel.channels = []; + const Bus = loadBus(), first = new Bus("iso-local"), second = new Bus("iso-local"); + const bad = vi.fn(async () => { throw new Error("boom"); }), good = vi.fn(), remote = vi.fn(), errors = []; + first.errorReporter = (error) => errors.push(error); + first.on("settings-changed", bad), first.on("settings-changed", good), second.on("settings-changed", remote); + await first.emit("settings-changed", { source: "legacy" }); + await Promise.resolve(); + expect(bad).toHaveBeenCalledTimes(1); + expect(good).toHaveBeenCalledTimes(1); + expect(remote).toHaveBeenCalledTimes(1); + expect(errors).toHaveLength(1); + }); + + it("isolates throwing remote listeners from each other", async () => { + FakeBroadcastChannel.channels = []; + const Bus = loadBus(), first = new Bus("iso-remote"), second = new Bus("iso-remote"); + const bad = vi.fn(async () => { throw new Error("boom"); }), good = vi.fn(); + second.errorReporter = () => {}; + second.on("car-state-changed", bad), second.on("car-state-changed", good); + await first.emit("car-state-changed", { carNums: [] }); + await Promise.resolve(); + expect(bad).toHaveBeenCalledTimes(1); + expect(good).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/external-rendering.test.js b/tests/external-rendering.test.js index 6661422b..4d35950e 100644 --- a/tests/external-rendering.test.js +++ b/tests/external-rendering.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,9 +7,10 @@ import jqueryFactory from "jquery"; import { describe, expect, it } from "vitest"; function loadHitShow() { - const dom = new JSDOM('
    '), $ = jqueryFactory(dom.window), source = readFileSync(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); - const escapeHtml = value => $("").text(String(value ?? "")).html(), normalizeHttpUrl = value => { try { const url = new URL(value, "https://javdb.com/"); return ["http:", "https:"].includes(url.protocol) ? url.href : null; } catch { return null; } }; - const context = vm.createContext({ BasePlugin: class {}, i: (target, key, value) => (target[key] = value), $, document: dom.window.document, escapeHtml, normalizeHttpUrl, loading: () => ({ close() {} }), window: dom.window }); + const dom = new JSDOM('
    ', { url: "https://javdb.com/" }), $ = jqueryFactory(dom.window), source = readTestFile(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); + const escapeHtml = value => $("").text(String(value ?? "")).html(), normalizeHttpUrl = value => { if (value == null || value === "") return null; try { const url = new URL(value, "https://javdb.com/"); return ["http:", "https:"].includes(url.protocol) ? url.href : null; } catch { return null; } }; + const normalizeJavdbMediaUrl = value => { const url = normalizeHttpUrl(value); return url?.replace(/^https:\/\/[^/]+\/rhe951l4q(?=\/)/i, "https://c0.jdbstatic.com") ?? null; }; + const context = vm.createContext({ BasePlugin: class {}, i: (target, key, value) => (target[key] = value), $, document: dom.window.document, escapeHtml, normalizeJavdbMediaUrl, loading: () => ({ close() {} }), window: dom.window }); vm.runInContext(`${source};globalThis.HitShowPlugin=HitShowPlugin`, context); return { plugin: new context.HitShowPlugin(), dom }; } diff --git a/tests/fc2-by-123av.test.js b/tests/fc2-by-123av.test.js new file mode 100644 index 00000000..4dc87bdd --- /dev/null +++ b/tests/fc2-by-123av.test.js @@ -0,0 +1,94 @@ +import { readTestFile } from "./helpers/read-test-file.js"; +import { join } from "node:path"; +import vm from "node:vm"; +import { JSDOM } from "jsdom"; +import jqueryFactory from "jquery"; +import { describe, expect, it, vi } from "vitest"; + +function loadFc2123Av({ search = "", page = 1 } = {}) { + const dom = new JSDOM('

    123Av

    ', { url: `https://javdb.com/advanced_search?type=100&keyword=${encodeURIComponent(search)}&page=${page}` }); + const $ = jqueryFactory(dom.window), catalog = vi.fn(async () => ({ items: [], maxPage: 1 })), loadingClose = vi.fn(), setHrefParam = vi.fn(); + const host = { + locateListRoot: () => dom.window.document.querySelector(".container"), + getListContainer: () => dom.window.document.querySelector(".container"), + createOwnedListRoot(classes = []) { const root = dom.window.document.createElement("div"); root.classList.add("movie-list", ...classes); return root; }, + }; + const context = vm.createContext({ + BasePlugin: class { + getBean() { return undefined; } + getRuntimeService(name) { return { host, scope: async () => ({}), movie: { catalog }, settings: { snapshot: () => ({ translateTitle: "_" }) }, translation: {} }[name]; } + }, + _: "_", o: "", escapeHtml: value => $("").text(String(value ?? "")).html(), + renderTranslatedTitle: async () => {}, createFc2SourceLinks: () => "", renderFc2Gallery: () => {}, renderFc2State: () => {}, + $, document: dom.window.document, window: dom.window, URLSearchParams, + loading: () => ({ close: loadingClose }), show: { info: vi.fn(), error: vi.fn(), ok: vi.fn() }, + clog: { error: vi.fn(), log: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + utils: { setHrefParam, smoothScrollToTop: vi.fn(async () => {}) }, + }); + const source = readTestFile(join(process.cwd(), "src/plugins/external-search/fc2-by-123av.js"), "utf8"); + vm.runInContext(`${source};globalThis.Fc2By123AvPlugin=Fc2By123AvPlugin`, context); + return { plugin: new context.Fc2By123AvPlugin(), $, catalog, setHrefParam, loadingClose, show: context.show, dom }; +} + +describe("Fc2By123AvPlugin page-state handling", () => { + it("resets page and maxPage when searching with a new keyword", async () => { + const { plugin, dom, catalog, setHrefParam } = loadFc2123Av({ page: 8 }); + plugin.hookPage(); + dom.window.document.querySelector("#search-123av-keyword").value = "fc2-123"; + dom.window.document.querySelector("#search-123av-btn").click(); + expect(plugin.currentPage).toBe(1); + expect(plugin.maxPage).toBe(null); + expect(setHrefParam).toHaveBeenCalledWith("page", 1); + expect(setHrefParam).toHaveBeenCalledWith("keyword", "fc2-123"); + await vi.waitFor(() => expect(catalog).toHaveBeenCalledWith("av123", { page: 1, keyword: "fc2-123" }, expect.anything())); + expect(plugin.currentPage).toBe(1); + expect(plugin.maxPage).toBe(1); + }); + + it("clears keyword, page and maxPage when resetting the search", async () => { + const { plugin, dom, catalog, setHrefParam } = loadFc2123Av({ search: "old", page: 3 }); + plugin.hookPage(); + dom.window.document.querySelector("#search-123av-keyword").value = "old"; + plugin.currentPage = 4, plugin.maxPage = 20; + dom.window.document.querySelector("#clear-123av-btn").click(); + expect(plugin.keyword).toBe(""); + expect(plugin.currentPage).toBe(1); + expect(plugin.maxPage).toBe(null); + expect(setHrefParam).toHaveBeenCalledWith("keyword", ""); + await vi.waitFor(() => expect(catalog).toHaveBeenCalledWith("av123", { page: 1, keyword: "" }, expect.anything())); + }); + + it("normalizes maxPage and clamps currentPage into bounds", async () => { + const { plugin, catalog } = loadFc2123Av({ page: 99 }); + catalog.mockResolvedValue({ items: [], maxPage: 5 }); + await plugin.handleQuery(); + expect(plugin.maxPage).toBe(5); + expect(plugin.currentPage).toBe(5); + }); + + it("treats a missing maxPage as 1", async () => { + const { plugin, catalog } = loadFc2123Av({ page: 3 }); + catalog.mockResolvedValue({ items: [], maxPage: null }); + await plugin.handleQuery(); + expect(plugin.maxPage).toBe(1); + expect(plugin.currentPage).toBe(1); + }); + + it("drops stale results from an earlier overlapping request", async () => { + const { plugin, catalog } = loadFc2123Av(); + plugin.hookPage(); + let resolveFirst, resolveSecond; + const first = new Promise(resolve => { resolveFirst = resolve; }), second = new Promise(resolve => { resolveSecond = resolve; }); + catalog.mockImplementationOnce(() => first).mockImplementationOnce(() => second); + const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + const p1 = plugin.handleQuery(); + await flush(); + const p2 = plugin.handleQuery(); + await flush(); + resolveFirst({ items: [{ url: "/m/OLD", title: "old" }], maxPage: 10 }); + resolveSecond({ items: [{ url: "/m/NEW", title: "new" }], maxPage: 10 }); + await Promise.all([p1, p2]); + expect(plugin.$listRoot.html()).toContain("NEW"); + expect(plugin.$listRoot.html()).not.toContain("OLD"); + }); +}); diff --git a/tests/fc2-gallery-dedup.test.js b/tests/fc2-gallery-dedup.test.js new file mode 100644 index 00000000..e4fa6e7d --- /dev/null +++ b/tests/fc2-gallery-dedup.test.js @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import jquery from "jquery"; +import { beforeEach, describe, expect, it } from "vitest"; +import { renderFc2Gallery } from "../src/ui/detail/fc2-workspace-view.js"; + +beforeEach(() => { + globalThis.$ = jquery; + document.body.innerHTML = ""; +}); + +function makeContext() { + const root = jquery('
    ').append( + '
    ' + ).appendTo(document.body); + return { root, carNum: "FC2-123", galleryUrls: undefined }; +} + +describe("FC2 gallery dedup insurance", () => { + it("records gallery urls on the context", () => { + const context = makeContext(); + renderFc2Gallery(context, [ "https://fc2content.net/a/1.jpg", "https://fc2content.net/a/2.jpg" ], "https://covers.example/cover.jpg"); + expect([ ...context.galleryUrls ]).toEqual([ "https://fc2content.net/a/1.jpg", "https://fc2content.net/a/2.jpg" ]); + expect(context.root.find('[data-jhs-role="main-preview"] img').attr("src")).toBe("https://covers.example/cover.jpg"); + }); + + it("keeps the overview cover separate from the first gallery image", () => { + const context = makeContext(); + renderFc2Gallery(context, [ "https://fc2content.net/a/1.jpg", "https://fc2content.net/a/2.jpg" ]); + expect(context.root.find('[data-jhs-role="main-preview"] img').length).toBe(0); + expect(context.root.find('[data-jhs-role="gallery-grid"] img').first().attr("src")).toBe("https://fc2content.net/a/1.jpg"); + }); + + it("records an empty gallery so a later duplicate check cannot match", () => { + const context = makeContext(); + renderFc2Gallery(context, []); + expect(context.galleryUrls.size).toBe(0); + expect(context.root.find('[data-jhs-role="gallery-grid"]').text()).toBe("暂无剧照"); + }); + + it("hides an extra screenshot whose url duplicates a gallery image", () => { + const context = makeContext(); + const shot = context.root.find('[data-jhs-role="screenshot"]'); + shot.append(jquery('')); + renderFc2Gallery(context, [ "https://fc2content.net/a/1.jpg", "https://fc2content.net/a/2.jpg" ]); + expect(shot.children().length).toBe(0); + }); + + it("keeps an extra screenshot that is not in the gallery", () => { + const context = makeContext(); + const shot = context.root.find('[data-jhs-role="screenshot"]'); + shot.append(jquery('')); + renderFc2Gallery(context, [ "https://fc2content.net/a/1.jpg" ]); + expect(shot.children().length).toBe(1); + }); +}); diff --git a/tests/fc2-navigation-dynamic.test.js b/tests/fc2-navigation-dynamic.test.js new file mode 100644 index 00000000..2e598b8c --- /dev/null +++ b/tests/fc2-navigation-dynamic.test.js @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import jquery from "jquery"; +import { initializeEventBus } from "../src/core/event-bus.js"; +import { Fc2NavigationPlugin } from "../src/plugins/status/fc2-navigation.js"; + +class FakeBroadcastChannel { + static channels = []; + constructor(name) { this.name = name; this.handlers = []; FakeBroadcastChannel.channels.push(this); } + addEventListener(type, handler) { "message" === type && this.handlers.push(handler); } + postMessage(data) { FakeBroadcastChannel.channels.filter((channel) => channel.name === this.name).forEach((channel) => channel.handlers.forEach((handler) => handler({ data }))); } +} + +function fc2Card(carNum, href) { + const item = document.createElement("div"); + item.className = "item"; + const anchor = document.createElement("a"); + anchor.href = href; + const title = document.createElement("div"); + title.className = "video-title"; + const strong = document.createElement("strong"); + strong.textContent = carNum; + title.appendChild(strong); + const cover = document.createElement("div"); + cover.className = "cover"; + const image = document.createElement("img"); + image.src = "/cover.jpg"; + cover.appendChild(image); + anchor.appendChild(cover); + anchor.appendChild(title); + item.appendChild(anchor); + const secondary = document.createElement("a"); + secondary.href = "/actors/actor-1"; + secondary.textContent = "Actor"; + secondary.addEventListener("click", event => event.preventDefault()); + item.appendChild(secondary); + return item; +} + +function makeScope() { + let observerCallback = null; + const cleanups = []; + return { + addCleanup: vi.fn((fn) => cleanups.push(fn)), + observe: vi.fn((_target, callback, _options) => { observerCallback = callback; }), + fireAddedNodes() { observerCallback?.([{ addedNodes: [document.createElement("div")] }]); }, + dispose() { cleanups.forEach((fn) => fn()); cleanups.length = 0; }, + }; +} + +function makeFc2Mock() { + return { + resolveFc2Source: vi.fn(async () => ""), + createFc2PageUrl: vi.fn((_movieId, carNum, _href, _opts) => `https://owned.example/detail/${carNum}`), + resolveMovieIdForRecord: vi.fn(async () => "mid"), + openFc2Dialog: vi.fn(), + openFc2Page: vi.fn(), + }; +} + +describe("FC2 dynamic navigation protection", () => { + afterEach(() => { vi.unstubAllGlobals(); document.body.innerHTML = ""; }); + + it("protects cards appended after initial render via list-items-added and the observer fallback", async () => { + document.body.innerHTML = '
    '; + const list = document.querySelector(".movie-list"); + const first = fc2Card("FC2-123", "/v/abc"); + list.appendChild(first); + const $ = jquery; + vi.stubGlobal("$", $), vi.stubGlobal("jQuery", $); + globalThis.BroadcastChannel = FakeBroadcastChannel; + FakeBroadcastChannel.channels = []; + window.isListPage = true; + initializeEventBus(); + const fc2 = makeFc2Mock(), scope = makeScope(), host = { locateListRoot: () => list }; + const plugin = new Fc2NavigationPlugin(); + plugin.getOptionalDependency = (name) => (name === "Fc2Plugin" ? fc2 : undefined); + plugin.getRuntimeService = (name) => (name === "host" ? host : name === "scope" ? () => scope : undefined); + + await plugin.handle(); + expect(first.querySelector("a").href).toBe("http://localhost:3000/v/abc"); + expect(first.querySelector("a").dataset.jhsFc2Primary).toBe("true"); + expect(first.querySelectorAll("a")[1].href).toBe("http://localhost:3000/actors/actor-1"); + expect(scope.observe).toHaveBeenCalledWith(list, expect.any(Function), { childList: true, subtree: false }); + + // AutoPage appends a new page of cards; list-items-added triggers protection. + const second = fc2Card("FC2-456", "/v/xyz"); + list.appendChild(second); + const bus = (await import("../src/core/event-bus.js")).jhsEventBus; + bus.emit("list-items-added", { items: [second] }, { broadcast: false }); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(second.querySelector("a").href).toBe("http://localhost:3000/v/xyz"); + expect(second.querySelector("a").closest(".item").getAttribute("data-jhs-fc2-protected")).toBe("true"); + + // Fallback observer path (list.core disabled) also protects new cards. + const third = fc2Card("FC2-789", "/v/zzz"); + list.appendChild(third); + scope.fireAddedNodes(); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(third.querySelector("a").href).toBe("http://localhost:3000/v/zzz"); + + scope.dispose(); + }); + + it("handles the whole primary anchor while leaving secondary links native", async () => { + document.body.innerHTML = '
    '; + const list = document.querySelector(".movie-list"), card = fc2Card("FC2-999", "/v/primary"); + list.appendChild(card); + const $ = jquery; + vi.stubGlobal("$", $), vi.stubGlobal("jQuery", $), vi.stubGlobal("utils", { openPage: vi.fn() }); + globalThis.BroadcastChannel = FakeBroadcastChannel; + FakeBroadcastChannel.channels = []; + window.isListPage = true; + initializeEventBus(); + const fc2 = makeFc2Mock(), scope = makeScope(), host = { locateListRoot: () => list }; + const plugin = new Fc2NavigationPlugin(); + plugin.getOptionalDependency = (name) => (name === "Fc2Plugin" ? fc2 : undefined); + plugin.getRuntimeService = (name) => (name === "host" ? host : name === "scope" ? () => scope : undefined); + + await plugin.handle(); + const primary = card.querySelector("a[data-jhs-fc2-primary]"); + primary.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0 })); + await vi.waitFor(() => expect(fc2.openFc2Dialog).toHaveBeenCalledOnce()); + primary.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, ctrlKey: true })); + await vi.waitFor(() => expect(fc2.openFc2Page).toHaveBeenCalledOnce()); + primary.dispatchEvent(new MouseEvent("auxclick", { bubbles: true, button: 1 })); + await vi.waitFor(() => expect(fc2.openFc2Page).toHaveBeenCalledTimes(2)); + card.querySelectorAll("a")[1].dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0 })); + expect(fc2.openFc2Dialog).toHaveBeenCalledOnce(); + expect(card.querySelectorAll("a")[1].href).toBe("http://localhost:3000/actors/actor-1"); + scope.dispose(); + }); +}); diff --git a/tests/fc2-owned-workspace.test.js b/tests/fc2-owned-workspace.test.js index d49c39d4..847f5363 100644 --- a/tests/fc2-owned-workspace.test.js +++ b/tests/fc2-owned-workspace.test.js @@ -1,48 +1,50 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; +import javdbManifest, { createJavDbAdapter } from "../src/integrations/javdb/manifest.js"; +import { createIntegrationRequestFacade } from "../src/app/integration-registry.js"; +import { HttpService } from "../src/services/http-service.js"; +import { ExternalUrlPolicy } from "../src/services/external-url-policy.js"; +import { CacheService } from "../src/services/cache-service.js"; +import { assessMagnetQuality } from "../src/core/magnet-quality.js"; +import { Fc2Plugin } from "../src/plugins/external-search/fc2.js"; const repoRoot = join(import.meta.dirname, ".."); -const fc2Source = readFileSync(join(repoRoot, "src/plugins/external-search/fc2.js"), "utf8"); -const fc2By123AvSource = readFileSync(join(repoRoot, "src/plugins/external-search/fc2-by-123av.js"), "utf8"); -const screenshotSource = readFileSync(join(repoRoot, "src/plugins/image-viewer/screenshot.js"), "utf8"); -const listPageSource = readFileSync(join(repoRoot, "src/plugins/status/list-page.js"), "utf8"); -const historySource = readFileSync(join(repoRoot, "src/plugins/status/history.js"), "utf8"); -const stateServiceSource = readFileSync(join(repoRoot, "src/core/state-service.js"), "utf8"); -const titleFilterSource = readFileSync(join(repoRoot, "src/plugins/blacklist/filter-title-keyword.js"), "utf8"); -const highlightMagnetSource = readFileSync(join(repoRoot, "src/plugins/status/highlight-magnet.js"), "utf8"); -const primitivesSource = readFileSync(join(repoRoot, "src/core/ui-primitives.js"), "utf8"); -const magnetHubSource = readFileSync(join(repoRoot, "src/plugins/external-search/magnet-hub.js"), "utf8"); -const loggerSource = readFileSync(join(repoRoot, "src/core/logger.js"), "utf8"); -const top250Source = readFileSync(join(repoRoot, "src/plugins/external-search/top250.js"), "utf8"); +const fc2Source = readTestFile(join(repoRoot, "src/plugins/external-search/fc2.js"), "utf8"); +const fc2ViewSource = readTestFile(join(repoRoot, "src/ui/detail/fc2-workspace-view.js"), "utf8"); +const fc2NavigationSource = readTestFile(join(repoRoot, "src/plugins/status/fc2-navigation.js"), "utf8"); +const fc2By123AvSource = readTestFile(join(repoRoot, "src/plugins/external-search/fc2-by-123av.js"), "utf8"); +const screenshotSource = readTestFile(join(repoRoot, "src/plugins/image-viewer/screenshot.js"), "utf8"); +const listPageSource = readTestFile(join(repoRoot, "src/plugins/status/list-page.js"), "utf8"); +const historySource = readTestFile(join(repoRoot, "src/plugins/status/history.js"), "utf8"); +const stateServiceSource = readTestFile(join(repoRoot, "src/core/state-service.js"), "utf8"); +const titleFilterSource = readTestFile(join(repoRoot, "src/plugins/blacklist/filter-title-keyword.js"), "utf8"); +const highlightMagnetSource = readTestFile(join(repoRoot, "src/plugins/status/highlight-magnet.js"), "utf8"); +const primitivesSource = readTestFile(join(repoRoot, "src/core/ui-primitives.js"), "utf8"); +const loggerSource = readTestFile(join(repoRoot, "src/core/logger.js"), "utf8"); +const top250Source = readTestFile(join(repoRoot, "src/plugins/external-search/top250.js"), "utf8"); function loadWorkspace() { const dom = new JSDOM('
    ', { url: "https://javdb.com/users/collection_codes" }), $ = jqueryFactory(dom.window); const context = vm.createContext({ window: dom.window, document: dom.window.document, Node: dom.window.Node, MutationObserver: dom.window.MutationObserver, $, BasePlugin: class {}, r: true, l: false, normalizeCarNum: value => String(value || "").trim().toUpperCase() || null, jhsEventBus: { emit: vi.fn() }, utils: {}, JhsSelect: {} }); - const source = readFileSync(join(repoRoot, "src/plugins/status/detail-workspace.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/ui/detail/fc2-detail-workspace.js"), "utf8"); vm.runInContext(`${source};globalThis.createShell=createFc2DetailShell;globalThis.createContext=createFc2DetailContext`, context); return { $, context }; } function loadResolver(responseFactory) { - const cacheCalls = [], get = vi.fn(responseFactory), storageManager = { - async cachedRequest(key, ttl, loader) { - const loaded = await loader(); - cacheCalls.push({ key, ttl, loaded }); - return loaded?.data ?? loaded; - } - }, local = new Map(), context = vm.createContext({ - storageManager, gmHttp: { get }, localStorage: { getItem: key => local.get(key) || null, setItem: (key, value) => local.set(key, value) }, md5: String, - normalizeCarNum: value => String(value || "").trim().toUpperCase().replace("FC2-PPV-", "FC2-") || null, - utils: { formatDate: String }, show: { error: vi.fn() } - }); - const source = readFileSync(join(repoRoot, "src/core/javdb-api.js"), "utf8"); - vm.runInContext(`${source};globalThis.resolveId=resolveJavDbMovieId`, context); - return { resolveId: context.resolveId, get, cacheCalls }; + const requests = [], get = vi.fn(responseFactory), port = { request: async options => { + requests.push(options); + return { status: 200, data: await get(options), finalUrl: options.url }; + } }; + const http = new HttpService(port, new ExternalUrlPolicy(), { cache: new CacheService() }); + const adapter = createJavDbAdapter(createIntegrationRequestFacade(http, javdbManifest), () => "signature"); + return { resolveId: carNum => adapter.resolveMovie({ carNum }).then(value => value?.movieId || null), get, requests }; } function loadWantApi({ encryptedToken = "encrypted", response = { success: 1 } } = {}) { @@ -50,24 +52,35 @@ function loadWantApi({ encryptedToken = "encrypted", response = { success: 1 } } storageManager: { deleteCachedRequest }, gmHttp: { gmRequest }, localStorage: { getItem: key => local.get(key) || null, setItem: (key, value) => local.set(key, value), removeItem: key => local.delete(key) }, decryptData: vi.fn(async value => `token:${value}`), md5: String, normalizeCarNum: String, utils: { formatDate: String }, show: { error: vi.fn() } }); - const source = readFileSync(join(repoRoot, "src/core/javdb-api.js"), "utf8"); - vm.runInContext(`${source};globalThis.markWant=markJavDbWantWatch`, context); - return { markWant: context.markWant, gmRequest, deleteCachedRequest }; + const source = readTestFile(join(repoRoot, "src/core/javdb-api.js"), "utf8"); + vm.runInContext(`${source};globalThis.markWant=markJavDbWantWatch;globalThis.getWantState=getJavDbWantWatchState`, context); + return { markWant: context.markWant, getWantState: context.getWantState, gmRequest, deleteCachedRequest }; } function loadImageViewer() { const dom = new JSDOM(''), $ = jqueryFactory(dom.window), instances = []; class ViewerMock { - constructor(host, options) { this.host = host, this.options = options, this.viewerData = { width: 1000, height: 800 }, this.imageData = { width: 400, height: 200 }, this.zoomTo = vi.fn(), this.moveTo = vi.fn(), this.prev = vi.fn(), this.next = vi.fn(), instances.push(this); } + constructor(host, options) { this.host = host, this.options = options, this.viewerData = { width: 1000, height: 800 }, this.imageData = { width: 400, height: 200 }, this.zoomTo = vi.fn(), this.moveTo = vi.fn(), this.resize = vi.fn(), this.prev = vi.fn(), this.next = vi.fn(), this.destroy = vi.fn(), instances.push(this); } show() {} destroy() {} } - const marker = loggerSource.indexOf("}(), function() {", loggerSource.indexOf("unsafeWindow.show")), start = loggerSource.indexOf("function() {", marker), end = loggerSource.indexOf("}(), window.ImageHoverPreview", start), viewerIife = loggerSource.slice(start, end + 1), context = vm.createContext({ window: dom.window, document: dom.window.document, $, Viewer: ViewerMock, JHS_Z_INDEX: { viewer: 100 }, setTimeout: vi.fn() }); - vm.runInContext(`(${viewerIife})();`, context); - return { dom, instances }; + const marker = loggerSource.indexOf("}(), function() {", loggerSource.indexOf("unsafeWindow.show")), start = loggerSource.indexOf("function() {", marker), end = loggerSource.indexOf("}(), window.ImageHoverPreview", start), viewerIife = loggerSource.slice(start, end + 1), context = vm.createContext({ window: dom.window, document: dom.window.document, $, Viewer: ViewerMock, JHS_Z_INDEX: { viewer: 100 }, AbortController: dom.window.AbortController, MutationObserver: dom.window.MutationObserver, setTimeout: vi.fn() }); + const scopeSource = readFileSync(join(repoRoot, "src/core/lifecycle-scope.js"), "utf8").replace("export class", "class"); + vm.runInContext(`${scopeSource}; const scope = new LifecycleScope("viewer-test"); globalThis.testScope=scope; (${viewerIife})();`, context); + return { dom, instances, scope: context.testScope }; } describe("FC2 owned detail workspace", () => { + it("never builds a private FC2 URL without a resolved movie id", async () => { + const openPage = vi.fn(); + vi.stubGlobal("utils", { openPage }); + const plugin = new Fc2Plugin(); + expect(() => plugin.createFc2PageUrl(null, "FC2-123", "/v/abc")).toThrow("movieId"); + await plugin.openFc2Page(null, "FC2-123", "/v/abc", { newTab: true }); + expect(openPage).toHaveBeenCalledWith("/v/abc", "FC2-123", true, { newTab: true }); + vi.unstubAllGlobals(); + }); + it("passes Layer an HTML string instead of a raw DOM node", () => { expect(fc2Source).toContain('content: \'
    \''); expect(fc2Source).not.toContain("content: host[0]"); @@ -84,7 +97,7 @@ describe("FC2 owned detail workspace", () => { it("lets sections keep their content height and opens gallery thumbnails in the viewer", () => { expect(fc2Source).toMatch(/\.jhs-fc2-workspace \{[^}]*grid-auto-rows:max-content;[^}]*align-content:start;/); expect(fc2Source).toMatch(/\.jhs-fc2-gallery-grid \{[^}]*minmax\(112px,144px\)/); - expect(fc2Source).toContain('class=\\"jhs-btn jhs-fc2-gallery-item\\"'); + expect(fc2ViewSource).toContain('class="jhs-btn jhs-fc2-gallery-item"'); expect(fc2Source).toContain('showImageViewer(image, "", { galleryRoot: gallery[0] })'); expect(fc2Source).not.toContain('"data-fancybox"'); expect(loggerSource).toContain("initialViewIndex"); @@ -97,12 +110,65 @@ describe("FC2 owned detail workspace", () => { }); it("opens the selected gallery image with navigation and centers it in both axes", () => { - const { dom, instances } = loadImageViewer(), gallery = dom.window.document.querySelector("#gallery"), selected = gallery.querySelectorAll("img")[1]; + const { dom, instances, scope } = loadImageViewer(), gallery = dom.window.document.querySelector("#gallery"), selected = gallery.querySelectorAll("img")[1]; dom.window.showImageViewer(selected, "", { galleryRoot: gallery }); const viewer = instances[0]; expect(viewer.host).toBe(gallery), expect(viewer.options.initialViewIndex).toBe(1), expect(viewer.options.toolbar.prev).toBe(1), expect(viewer.options.toolbar.next).toBe(1); viewer.options.viewed(); expect(viewer.moveTo).toHaveBeenCalledWith(300, 300); + scope.dispose(); dom.window.close(); + }); + + it("replaces an opening viewer and ignores its late callbacks", () => { + const {dom,instances,scope}=loadImageViewer(), images=dom.window.document.querySelectorAll("img"); + dom.window.showImageViewer(images[0]); + dom.window.showImageViewer(images[1]); + expect(instances[0].destroy).toHaveBeenCalledOnce(); + instances[1].options.shown(); + instances[0].options.shown(); instances[0].options.viewed(); instances[0].options.hidden(); + expect(instances[0].zoomTo).not.toHaveBeenCalled(); + expect(instances[1].destroy).not.toHaveBeenCalled(); + expect(dom.window.document.body.style.overflow).toBe("hidden"); + scope.dispose(); + expect(instances[1].destroy).toHaveBeenCalledOnce(); + expect(dom.window.document.body.style.overflow).toBe(""); + dom.window.close(); + }); + + it("contains FC2 previews in their owner and shares screenshots without eager full image loading", () => { + const { dom, instances, scope } = loadImageViewer(), document = dom.window.document; + document.body.innerHTML = '
    '; + const selected = document.querySelector("#sheet"), workspace = document.querySelector(".jhs-fc2-workspace"); + dom.window.showImageViewer(selected); + const viewer = instances[0]; + expect(viewer.host.closest(".layui-layer-content")).not.toBeNull(); + expect(viewer.options.inline).toBe(true); + expect(viewer.options.initialViewIndex).toBe(2); + expect(viewer.options.url).toBe("data-jhs-viewer-source"); + expect([...viewer.host.querySelectorAll("img")].map(image => image.getAttribute(viewer.options.url))).toEqual(["https://example.test/a.jpg", "https://example.test/b.jpg", "https://example.test/sheet.jpg"]); + expect([...viewer.host.querySelectorAll("img")].every(image => image.src.startsWith("data:image/"))).toBe(true); + expect(workspace.inert).toBe(true); + viewer.options.viewed(); + expect(viewer.zoomTo).not.toHaveBeenCalled(); + expect(viewer.resize).toHaveBeenCalledOnce(); + scope.dispose(); + expect(document.querySelector(".jhs-image-viewer-host")).toBeNull(); + expect(document.querySelector(".jhs-image-viewer-owner")).toBeNull(); + expect(workspace.inert).toBeFalsy(); + dom.window.close(); + }); + + it("releases per-viewer subscriptions on repeated close and preserves unrelated scroll styles", () => { + const {dom,instances,scope}=loadImageViewer(); + dom.window.document.body.style.overflow="clip"; + for(let i=0;i<5;i++) { + dom.window.showImageViewer("https://example.test/image.png"); + instances[i].options.shown(); instances[i].options.hidden(); + expect(scope.cleanups.size).toBe(0); + expect(dom.window.document.querySelectorAll(".temporary-container")).toHaveLength(0); + expect(dom.window.document.body.style.overflow).toBe("clip"); + } + scope.dispose(); dom.window.close(); }); it("renders screenshot-provider results as the smallest thumbnail until opened", () => { @@ -122,33 +188,45 @@ describe("FC2 owned detail workspace", () => { it("propagates an explicit FC2 source without guessing from URL text", () => { expect(fc2Source).not.toContain('url.includes("123av")'); expect(fc2By123AvSource).toContain('data-jhs-fc2-source="123av"'); - expect(listPageSource).toContain("{ source: fc2Source }"); + expect(fc2NavigationSource).toContain("fc2Source || (await fc2.resolveFc2Source"); + expect(fc2NavigationSource).toContain("resolveMovieIdForRecord(carNum, aHref)"); expect(historySource).toContain("resolveFc2Source(t)"); expect(stateServiceSource).toContain('"fc2Source"'); - expect(fc2Source).toContain("&source=${encodeURIComponent(source)}"); + expect(fc2Source).toContain('target.searchParams.set("source", source)'); + }); + + it("keeps the native FC2 entry free of a hard list.core dependency so disabling ListPagePlugin stays safe", () => { + expect(fc2Source).not.toContain('getBean("ListPagePlugin")'); + expect(fc2Source).toContain('o.includes("collection_codes?movieId")'); + expect(fc2Source).toContain("openFc2Dialog("); + expect(fc2NavigationSource).toContain("protectFc2Navigation(root, fc2)"); + expect(fc2NavigationSource).toContain('this.getBean("Fc2Plugin")'); + expect(listPageSource).not.toContain("protectFc2Navigation(root)"); + expect(listPageSource).not.toContain('getBean("Fc2Plugin")'); }); it("restores source links, magnet metadata and scoped quality filtering", () => { - expect(fc2Source).toContain("FC2PPVDB"); - expect(fc2Source).toContain("FC2 市场"); - expect(fc2Source).toContain("item.hd && tags.append"); - expect(fc2Source).toContain("item.cnsub && tags.append"); - expect(fc2Source).toContain("item.created_at"); + expect(fc2ViewSource).toContain("FC2PPVDB"); + expect(fc2ViewSource).toContain("FC2 市场"); + expect(fc2Source).toContain("item.hasHdTag && tags.append"); + expect(fc2Source).toContain("item.hasSubtitleTag && tags.append"); + expect(fc2Source).toContain("item.createdAt"); expect(fc2Source).toContain('data-jhs-action="filter-native-magnets"'); - expect(highlightMagnetSource).toContain("assessMagnet({"); + expect(fc2Source).toContain("magnetService.assess({"); }); it("assesses explicit HD and subtitle tags even when the title has no marker", () => { - const context = vm.createContext({ BasePlugin: class {}, clog: { debug: vi.fn() } }); - vm.runInContext(`${magnetHubSource.slice(0, magnetHubSource.indexOf("class MagnetHubPlugin"))}\n${highlightMagnetSource};globalThis.Highlighter=HighlightMagnetPlugin`, context); - const highlighter = Object.create(context.Highlighter.prototype), assessed = highlighter.assessMagnet({ title: "FC2-123", hasHdTag: true, hasSubtitleTag: true, seeders: 0 }); + const assessed = assessMagnetQuality({ title: "FC2-123", hasHdTag: true, hasSubtitleTag: true, seeders: 0 }); expect(assessed.highQuality).toBe(true), expect(assessed.subtitle).toBe(true), expect(assessed.score.resolution).toBe(20), expect(assessed.score.subtitle).toBe(20); }); it("shares the 123AV movie resolver and keeps summary retry local", () => { - expect(fc2By123AvSource).toContain("fc2Plugin.mountPanels(context, movieIdPromise)"); - expect(fc2By123AvSource).toContain("fc2Plugin.configureJavDbWantButton(context, movieIdPromise)"); - expect(fc2By123AvSource).toContain("movieIdPromise.then"); + expect(fc2Source).toContain("this.mountPanels(context, movieIdPromise)"); + expect(fc2Source).toContain("this.configureJavDbWantButton(context, movieIdPromise)"); + expect(fc2Source).toContain("movieIdPromise.then"); + expect(fc2By123AvSource).not.toContain("mountPanels(context"); + expect(fc2By123AvSource).not.toContain("configureJavDbWantButton(context"); + expect(fc2By123AvSource).not.toContain('getBean("Fc2Plugin")'); expect(fc2By123AvSource).toContain("loadSummary(context, url)"); expect(fc2By123AvSource).not.toContain("() => void this.loadDetail(context, url)"); }); @@ -158,6 +236,9 @@ describe("FC2 owned detail workspace", () => { expect(fc2Source).toContain("markJavDbWantWatch(movieId)"); expect(fc2Source).toContain('this.getBean("TOP250Plugin")'); expect(top250Source).toContain('"function" === typeof onSuccess ? await onSuccess()'); + expect(top250Source).not.toContain("hasStoredEncryptedCredential"); + expect(top250Source).not.toContain("removeStoredEncryptedCredential"); + expect(top250Source).not.toContain("storeEncryptedCredential"); }); it("supports exact layer closing, reusable MagnetHub and hardened mobile layout", () => { @@ -172,14 +253,20 @@ describe("FC2 owned detail workspace", () => { it("keeps asynchronous error variables inside their catch callbacks", () => { expect(fc2Source).not.toMatch(/catch\(\(error => [^{\n]*\), clog\.error/); expect(fc2By123AvSource).not.toMatch(/catch\(\(error => [^{\n]*\), clog\.error/); - expect(fc2Source).toContain('catch((error => {\n context.isAlive() && sitesGroup.remove()'); - expect(fc2By123AvSource).toContain('catch((error => {\n context.isAlive() && fc2Plugin.setState'); + expect(fc2Source).toMatch(/catch\(\(\/\*\* @type \{unknown\} \*\/ error\) => \{\n\s+if \(!context\.isAlive\(\) \|\| generation !== context\.otherSiteGeneration\) return;\n\s+sitesGroup\.show\(\);/); + expect(fc2Source).toContain("renderFc2State(context.root.find('[data-jhs-role=\"other-sites\"]'), \"外部站点加载失败\")"); + expect(fc2Source).toContain('catch((/** @type {unknown} */ error) => {'); }); - it("initializes screenshot providers once per owned render and removes empty spacing", () => { - expect(screenshotSource).toContain("getScreenshotFromInitializedProviders(carNum)"); + it("initializes screenshot through the single ScreenshotService-owned view and keeps stable slots", () => { + expect(screenshotSource).toContain("renderScreenshotPanel"); + expect(screenshotSource).toContain('service.isEnabled(this.getSettingsSnapshot())'); + expect(fc2Source).toContain('screenshotService.isEnabled(settings.snapshot())'); expect(fc2Source).toContain(".jhs-fc2-screenshot:empty"); - expect(fc2Source).toContain("if (context.isAlive() && !box) sitesGroup.remove()"); + expect(fc2Source).toContain("box ? sitesGroup.show() : sitesGroup.hide()"); + expect(fc2Source).toContain("sitesGroup.hide();"); + expect(fc2Source).not.toContain("if (!result && !screenshot.children().length) screenshot.remove()"); + expect(fc2Source).not.toContain('settings.snapshot().enableLoadScreenShot !== "no" && screenshot.remove()'); }); it("creates fixed slots in display order and keeps two contexts isolated", () => { @@ -204,18 +291,25 @@ describe("JavDB exact movie resolver", () => { const first = loaded.resolveId("FC2-123"), second = loaded.resolveId("fc2-123"); release({ data: { movies: [ { id: "wrong", number: "FC2-1234" }, { id: "right", number: "FC2-123" } ] } }); await expect(Promise.all([ first, second ])).resolves.toEqual([ "right", "right" ]), expect(loaded.get).toHaveBeenCalledOnce(); - expect(loaded.cacheCalls[0].loaded.__jhsCacheTtl).toBe(7 * 864e5); + expect(loaded.requests).toHaveLength(1); + expect(loaded.requests[0]).toMatchObject({ providerId: "javdb", cacheScope: "public", ttlMs: 7 * 864e5 }); }); it("uses a short negative cache value but does not convert network errors into misses", async () => { const miss = loadResolver(async () => ({ data: { movies: [ { id: "near", number: "FC2-999" } ] } })); - await expect(miss.resolveId("FC2-123")).resolves.toBeNull(), expect(miss.cacheCalls[0].loaded).toEqual({ __jhsCacheTtl: 6 * 36e5, data: { miss: true } }); + await expect(miss.resolveId("FC2-123")).resolves.toBeNull(), expect(miss.get).toHaveBeenCalledOnce(); const failed = loadResolver(async () => { throw new Error("network"); }); - await expect(failed.resolveId("FC2-123")).rejects.toThrow("network"), expect(failed.cacheCalls).toHaveLength(0); + await expect(failed.resolveId("FC2-123")).rejects.toThrow("network"), expect(failed.get).toHaveBeenCalledOnce(); }); }); describe("JavDB native want action", () => { + it("reads an existing authenticated want-watch state before enabling the action", async () => { + const api = loadWantApi({ response: { data: { movies: [ { id: "movie-123" } ] } } }); + await expect(api.getWantState("movie-123")).resolves.toBe(true); + expect(api.gmRequest).toHaveBeenCalledWith("GET", expect.stringContaining("/v2/users/review_movies?status=want_watch"), null, {}, expect.objectContaining({ authorization: "Bearer token:encrypted" })); + }); + it("submits want_watch to the JavDB account and invalidates cached details", async () => { const api = loadWantApi(); await expect(api.markWant("movie-123")).resolves.toEqual({ success: 1 }); diff --git a/tests/feature-helpers.test.js b/tests/feature-helpers.test.js index 4794d963..9d023b72 100644 --- a/tests/feature-helpers.test.js +++ b/tests/feature-helpers.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,8 +6,8 @@ import { describe, expect, it } from "vitest"; const warnings = [], notices = []; const context = vm.createContext({ URL, window: { location: { href: "https://javdb.com/" } }, encodeURIComponent, normalizeCarNum: value => typeof value === "string" && value.trim() && !["null", "undefined"].includes(value.trim().toLowerCase()) ? value.trim() : null, escapeHtml: value => value, clog: { warn: (...args) => warnings.push(args), debug: () => {} }, show: { error: message => notices.push(message) } }); -const source = readFileSync(join(import.meta.dirname, "../src/core/feature-helpers.js"), "utf8"); -vm.runInContext(`${source};globalThis.api={mapLimit,parseNumberSetting,parseTaskTimestamp,shouldSkipStopped,selectLatestPublishTime,normalizeDmmCid,normalizeHttpUrl,normalizeBtihHash,resolveHighResCover,parseCarNumberText,buildFallbackCarUrl,linkCommentImageReferences,safePlay}`, context); +const source = readTestFile(join(import.meta.dirname, "../src/core/feature-helpers.js"), "utf8"); +vm.runInContext(`${source};globalThis.api={mapLimit,parseNumberSetting,parseTaskTimestamp,shouldSkipStopped,selectLatestPublishTime,normalizeDmmCid,normalizeHttpUrl,normalizeJavdbMediaUrl,normalizeBtihHash,resolveHighResCover,parseCarNumberText,buildFallbackCarUrl,linkCommentImageReferences,safePlay}`, context); const api = context.api; describe("feature helpers", () => { @@ -33,6 +34,8 @@ describe("feature helpers", () => { it("accepts only HTTP(S) URLs and supported BTIH hashes", () => { expect(api.normalizeHttpUrl("/cover.jpg")).toBe("https://javdb.com/cover.jpg"); expect(api.normalizeHttpUrl("javascript:alert(1)")).toBeNull(); + expect(api.normalizeJavdbMediaUrl("https://jdforrepam.com/rhe951l4q/thumbs/a.jpg")).toBe("https://c0.jdbstatic.com/thumbs/a.jpg"); + expect(api.normalizeJavdbMediaUrl("https://c0.jdbstatic.com/covers/a.jpg")).toBe("https://c0.jdbstatic.com/covers/a.jpg"); expect(api.normalizeBtihHash("a".repeat(40))).toBe("A".repeat(40)); expect(api.normalizeBtihHash("b".repeat(32))).toBe("B".repeat(32)); expect(api.normalizeBtihHash(" 女优2', + url: 'javascript:alert(1)', + remark: '