diff --git a/plugins/chksz.splayer-source.js b/plugins/chksz.splayer-source.js index 18c0482..e1015f5 100644 --- a/plugins/chksz.splayer-source.js +++ b/plugins/chksz.splayer-source.js @@ -1,66 +1,228 @@ /** * @name ChKSz 音源 * @id chksz.splayer-source - * @version 0.1.0 - * @description 使用 ChKSz API 解析网易云、QQ 音乐和酷狗播放地址 + * @version 0.8.0 + * @description 为 SPlayer-Next 解析网易云 / QQ 音乐 / 酷狗音源:超清母带、Hi-Res、无损,无版权歌曲自动跨平台兜底 * @author HSJ-BanFan * @homepage https://github.com/HSJ-BanFan/splayer-chksz-plugin * @type source * @grant network - * @apiLevel 1 + * @apiLevel 2 * @updateUrl https://raw.githubusercontent.com/HSJ-BanFan/splayer-chksz-plugin/main/dist/chksz.splayer-source.js - * @changelog 首次公开发布 + * @changelog 按来源读取平台 ID;上游故障时支持跨平台搜索与解析 */ const API_BASE_URL = "https://api.chksz.com"; const API_KEY_SETTING = "apiKey"; const REQUEST_TIMEOUT = 20_000; -const SOURCE_CONFIG = { +const CROSS_PLATFORM_SETTING = "crossPlatformFallback"; +const DURATION_TOLERANCE_SECONDS = 20; +const SEARCH_RESULT_LIMIT = 10; +const CROSS_PLATFORM_MAX_CANDIDATES = 3; +const CROSS_PLATFORM_REQUEST_BUDGET = 8; +const CROSS_PLATFORM_TIME_BUDGET = 10_000; +const CROSS_PLATFORM_LIMIT_ERROR = "CHKSZ_CROSS_PLATFORM_LIMIT"; +const CROSS_PLATFORM_UNAVAILABLE_ERROR = "CHKSZ_CROSS_PLATFORM_UNAVAILABLE"; +const CHANNEL_COOLDOWN_ERROR = "CHKSZ_CHANNEL_COOLDOWN"; +const RATE_LIMIT_ERROR = "CHKSZ_RATE_LIMITED"; +const RESOLUTION_TIME_BUDGET = 18_000; +const RESOLUTION_TIMEOUT_ERROR = "CHKSZ_RESOLUTION_TIMEOUT"; +const REQUEST_TIMEOUT_ERROR = "CHKSZ_REQUEST_TIMEOUT"; +const NETWORK_ERROR = "CHKSZ_NETWORK_ERROR"; +const CACHE_LIMIT = 128; +const CACHE_TTL = 5 * 60_000; +const UNAVAILABLE_TTL = 60_000; +const URL_EXPIRY_MARGIN = 30_000; +// ChKSz 限流为 20 RPM;命中 429 后按 Retry-After 进入冷却,避免把额度继续打空。 +const RATE_LIMIT_COOLDOWN = 60_000; +const MAX_RATE_LIMIT_COOLDOWN = 15 * 60_000; +// 502/503/504 是 ChKSz 上游故障(实测酷狗会持续返回并触发服务端熔断),冷却期内不再探测该平台。 +const CHANNEL_COOLDOWN = 2 * 60_000; +// 通道冷却优先按服务端 Retry-After,但不超过这个上限。 +const MAX_CHANNEL_COOLDOWN = 15 * 60_000; +// 同一平台的 ChKSz 搜索对多个不同关键词连续 404,说明是搜索通道故障而不是"没这首歌"。 +const SEARCH_OUTAGE_THRESHOLD = 3; +// 酷狗对过长的 msg 返回 400;关键词统一截断。 +const SEARCH_KEYWORD_MAX_LENGTH = 60; +// ChKSz 的 QQ 音乐搜索通道曾对任何关键词都返回 404(2026-09-11 起持续数日),而按 mid 解析正常。 +// 此时改用 QQ 音乐公开搜索拿 mid,再交给 ChKSz 解析;公开搜索不消耗 ChKSz 额度。 +const DIRECT_SEARCH_SETTING = "directSearchFallback"; +const DIRECT_SEARCH_ERROR = "CHKSZ_DIRECT_SEARCH_FAILED"; +const QQ_PUBLIC_SEARCH_URL = "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"; +const DIRECT_SEARCH_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +// 整条音质阶梯都不可用说明是版权问题而非瞬时抖动,短期内同曲直接跳跨平台,省下阶梯请求。 +const TRACK_UNAVAILABLE_TTL = CACHE_TTL; +// 交付体检:返回地址前只取前几个字节确认真的能拉流(CDN 请求,不消耗 ChKSz 额度)。 +const DELIVERY_UNUSABLE_ERROR = "CHKSZ_DELIVERY_UNUSABLE"; +const PROBE_TIMEOUT = 4_000; +const PROBE_RANGE = "bytes=0-3"; +// 只有这些错误才说明"地址打不开";宿主不支持体检选项等其它错误按无法判定处理,不降级。 +const UNREACHABLE_PROBE_CODES = new Set([ + REQUEST_TIMEOUT_ERROR, + "PLUGIN_REQUEST_TIMEOUT", + "PLUGIN_NETWORK_ERROR", + "REQUEST_TIMEOUT", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ENOTFOUND", + "ECONNREFUSED", + "ECONNRESET", + "EAI_AGAIN", +]); + +const QUALITY_NAMES = ["lq", "sq", "hq", "lossless", "hi-res"]; +// 母带/音效档:实测网易云这些档位交付 192kHz/24-bit FLAC,单曲 46–150MB(一首 153 秒 +// 的歌 103MB),部分播放器无法起播。"可播放优先"把它们排到阶梯最后。 +const MASTER_TIER_LEVELS = new Set(["jymaster", "jyeffect", "sky"]); +// ChKSz 原生档位 → SPlayer 逻辑音质。多对一是不可避免的:hq/sq 共用一个原生档位, +// hi-res 对应 jymaster/hires/sky/jyeffect 四个不同的事实,所以只用于"实际交付了什么"。 +const NATIVE_QUALITY_LEVELS = { + standard: "lq", + "128k": "lq", + exhigh: "hq", + "320k": "hq", + lossless: "lossless", + flac: "lossless", + hires: "hi-res", + master: "hi-res", + jymaster: "hi-res", + sky: "hi-res", + jyeffect: "hi-res", +}; +// Logical downgrade ladder shared by every provider: hi-res → lossless → hq → sq → lq. +const QUALITY_FALLBACKS = Object.fromEntries( + QUALITY_NAMES.map((quality, index) => [ + quality, + QUALITY_NAMES.slice(0, index + 1).reverse(), + ]), +); + +const SOURCE_POLICIES = { wy: { name: "ChKSz 网易云", - endpoint: "/api/163_music", - idParameter: "id", - qualityParameter: "level", - qualityValues: { - "hi-res": "hires", - lossless: "lossless", - hq: "exhigh", - sq: "exhigh", - lq: "standard", + identity: { idParameter: "id", idFields: ["id", "songId", "songmid"] }, + playback: { + endpoint: "/api/163_music", + qualityParameter: "level", + qualityValues: { + "hi-res": "jymaster", + lossless: "lossless", + hq: "exhigh", + sq: "exhigh", + lq: "standard", + }, + qualityAlternatives: { "hi-res": ["hires"] }, + qualityFallbacks: QUALITY_FALLBACKS, + }, + search: { + endpoint: "/api/163_search", + keywordParameter: "keyword", + params: { limit: SEARCH_RESULT_LIMIT }, + candidateIdField: "id", + }, + // NetEase lacks the rights to many catalogues; look the same song up elsewhere. + crossPlatform: { sources: ["tx", "kg"] }, + actions: { + musicLyric: { endpoint: "/api/163_lyric", params: {} }, + musicPic: { + endpoint: "/api/163_music", + params: { level: "standard", type: "json" }, + }, }, }, tx: { name: "ChKSz QQ 音乐", - endpoint: "/api/qq_music", - idParameter: "mid", - qualityParameter: "size", - qualityValues: { - "hi-res": "hires", - lossless: "flac", - hq: "320k", - sq: "320k", - lq: "128k", + identity: { idParameter: "mid", idFields: ["songmid", "mid", "id", "songId"] }, + playback: { + endpoint: "/api/qq_music", + qualityParameter: "size", + qualityValues: { + "hi-res": "master", + lossless: "flac", + hq: "320k", + sq: "320k", + lq: "128k", + }, + qualityAlternatives: { "hi-res": ["hires"] }, + qualityFallbacks: QUALITY_FALLBACKS, + }, + search: { + endpoint: "/api/qq_music", + keywordParameter: "msg", + params: { num: SEARCH_RESULT_LIMIT }, + candidateIdField: "mid", + }, + crossPlatform: { sources: ["wy", "kg"] }, + // ChKSz 搜索不可用时的备用搜索:QQ 音乐公开接口,结果映射成与 ChKSz 搜索相同的候选字段。 + directSearch: { + name: "QQ 音乐公开搜索", + endpoint: QQ_PUBLIC_SEARCH_URL, + params: (keyword) => ({ + format: "json", + p: 1, + n: SEARCH_RESULT_LIMIT, + w: keyword, + cr: 1, + g_tk: 5381, + t: 0, + }), + headers: { Referer: "https://y.qq.com/", "User-Agent": DIRECT_SEARCH_USER_AGENT }, + candidates: (body) => { + const list = body?.data?.song?.list; + if (!Array.isArray(list)) return []; + return list + .map((item) => ({ + name: textOrEmpty(item?.songname), + singer: (Array.isArray(item?.singer) ? item.singer : []) + .map((artist) => textOrEmpty(artist?.name)) + .filter(Boolean) + .join("/"), + album: textOrEmpty(item?.albumname), + interval: Number(item?.interval) || undefined, + mid: textOrEmpty(item?.songmid), + })) + .filter((item) => item.mid && item.name); + }, + }, + actions: { + musicLyric: { request: "trackDetails" }, + musicPic: { request: "trackDetails" }, }, }, kg: { name: "ChKSz 酷狗", - endpoint: "/api/kugou_music", - idParameter: "id", - qualityParameter: "size", - qualityValues: { - "hi-res": "hires", - lossless: "flac", - hq: "320k", - sq: "320k", - lq: "128k", + identity: { idParameter: "id", idFields: ["hash", "id", "songId"] }, + playback: { + endpoint: "/api/kugou_music", + qualityParameter: "size", + qualityValues: { + "hi-res": "master", + lossless: "flac", + hq: "320k", + sq: "320k", + lq: "128k", + }, + qualityAlternatives: { "hi-res": ["hires"] }, + qualityFallbacks: QUALITY_FALLBACKS, + }, + search: { + endpoint: "/api/kugou_music", + keywordParameter: "msg", + params: {}, + candidateIdField: "id", + }, + crossPlatform: { sources: ["tx", "wy"] }, + actions: { + musicLyric: { request: "trackDetails" }, + musicPic: { request: "trackDetails" }, }, }, }; -const QUALITY_NAMES = ["lq", "sq", "hq", "lossless", "hi-res"]; - -const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +const isRecord = (value) => + value !== null && typeof value === "object" && !Array.isArray(value); const pluginError = (code, message) => { const error = new Error(message); @@ -68,180 +230,1338 @@ const pluginError = (code, message) => { return error; }; -const getApiKey = () => { - const value = splayer.getSetting(API_KEY_SETTING); - const apiKey = typeof value === "string" ? value.trim() : ""; - - if (!apiKey) { - throw pluginError( - "CHKSZ_CONFIG_MISSING", - "未配置 ChKSz API Key:请打开 设置 → 插件管理 → ChKSz 音源 → 配置。", - ); - } - - // RFC 3986 unreserved characters; this prevents accidentally sending whitespace or a URL. - if (!/^chksz_[A-Za-z0-9._~-]+$/.test(apiKey)) { - throw pluginError( - "CHKSZ_CONFIG_INVALID", - "ChKSz API Key 格式无效:Key 应以 chksz_ 开头,并只包含 URL 安全字符。", - ); - } - - return apiKey; -}; - -const getMusicId = (musicInfo) => { +const getMusicId = (source, musicInfo) => { if (!isRecord(musicInfo)) { throw pluginError("CHKSZ_TRACK_INVALID", "SPlayer 未提供有效的歌曲信息。"); } - for (const key of ["songmid", "id", "songId"]) { + const fields = getSourcePolicy(source).identity.idFields ?? []; + for (const key of fields) { const value = musicInfo[key]; if (typeof value === "string" && value.trim()) return value.trim(); - if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (typeof value === "number" && Number.isFinite(value)) + return String(value); } throw pluginError("CHKSZ_TRACK_INVALID", "歌曲缺少平台 ID,无法请求 ChKSz。"); }; -const buildApiUrl = (endpoint, params) => { - const url = new URL(`${API_BASE_URL}${endpoint}`); - for (const [key, value] of Object.entries({ ...params, apikey: getApiKey() })) { - if (value !== undefined && value !== null && value !== "") { - url.searchParams.set(key, String(value)); - } - } - return url.toString(); +const textOrEmpty = (value) => (typeof value === "string" ? value.trim() : ""); + +const ARTIST_SEPARATOR = /[/、,,&]/; +const TITLE_BRACKET_PATTERN = /[((\[【][^))\]】]{0,40}[))\]】]/g; +const TITLE_TRAILING_VARIANT_PATTERN = + /[\s\-–—_]+(?:live|remaster(?:ed)?|version|ver\.?|instrumental|inst\.?|off\s*vocal|伴奏|现场|演唱会).*$/i; + +/** + * 去掉标题里的版本标记(括号段、"- Live" 这类后缀)。 + * 用于跨平台搜索关键词,以及"原曲自带版本标记、候选是干净标题"时的宽松匹配。 + */ +const stripTitleVariant = (value) => { + const original = textOrEmpty(value); + const stripped = original + .replace(TITLE_BRACKET_PATTERN, " ") + .replace(TITLE_TRAILING_VARIANT_PATTERN, "") + .replace(/\s+/g, " ") + .trim(); + // 整个标题都是版本标记时(例如 "(Live)")退回原值,避免把所有候选都视为同一首。 + return stripped || original; }; -const getBodyMessage = (body) => { - if (!isRecord(body)) return ""; - for (const key of ["msg", "message", "error"]) { - const value = body[key]; - if (typeof value === "string" && value.trim()) return value.trim(); - if (isRecord(value) && typeof value.message === "string" && value.message.trim()) { - return value.message.trim(); - } +/** Metadata SPlayer attaches to musicInfo; only used for cross-platform matching. */ +const getTrackDescriptor = (musicInfo) => ({ + name: textOrEmpty(musicInfo?.name), + singer: textOrEmpty(musicInfo?.singer), + interval: textOrEmpty(musicInfo?.interval), +}); + +const getSourcePolicy = (source) => { + const policy = SOURCE_POLICIES[source]; + if (!policy) { + throw pluginError( + "CHKSZ_SOURCE_UNSUPPORTED", + `不支持的 SPlayer 音源:${String(source)}。`, + ); } - return ""; + return policy; }; -const getHeader = (headers, name) => { - if (!isRecord(headers)) return ""; - const wanted = name.toLowerCase(); - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === wanted && typeof value === "string") return value; - } - return ""; +const buildTrackParams = (source, id, quality, nativeQualityOverride) => { + const policy = getSourcePolicy(source); + const requestedQuality = QUALITY_NAMES.includes(quality) ? quality : "hq"; + return { + policy, + requestedQuality, + params: { + [policy.identity.idParameter]: id, + [policy.playback.qualityParameter]: + nativeQualityOverride ?? policy.playback.qualityValues[requestedQuality], + type: "json", + }, + }; }; -const throwHttpError = (response) => { - const status = Number(response?.status) || 0; - const bodyMessage = getBodyMessage(response?.body); - const parts = [`ChKSz 请求失败(HTTP ${status})`]; - if (bodyMessage) parts.push(bodyMessage); +const createResolutionCore = () => { + let session; + const enabledSetting = (key, fallback) => { + const value = splayer.getSetting(key); + return value === undefined || value === null || value === "" ? fallback : value === true; + }; + + // A new account/configuration owns new maps. In-flight old work cannot fill them. + const getSession = () => { + const apiKey = getApiKey(); + const config = { + smartCache: enabledSetting("smartCache", true), + economyMode: enabledSetting("economyMode", false), + metadataFallback: enabledSetting("metadataFallback", true), + crossPlatformFallback: enabledSetting(CROSS_PLATFORM_SETTING, true), + directSearchFallback: enabledSetting(DIRECT_SEARCH_SETTING, true), + playableFirst: enabledSetting("playableFirst", false), + verifyDelivery: enabledSetting("verifyDelivery", true), + }; + const signature = JSON.stringify([apiKey, config]); + if (session?.signature !== signature) { + session = { + apiKey, config, signature, + responses: new Map(), metadata: new Map(), actions: new Map(), + pending: new Map(), actionPending: new Map(), + channelCooldowns: new Map(), unavailableTracks: new Map(), probes: new Map(), + searchFailures: new Map(), searchCooldowns: new Map(), + rateLimitUntil: 0, + }; + } + return session; + }; + + const readCache = (map, key) => { + const entry = map.get(key); + if (!entry) return undefined; + if (entry.until <= Date.now()) { + map.delete(key); + return undefined; + } + return entry.value; + }; + + const writeCache = (map, key, value, until) => { + if (until <= Date.now()) return; + map.delete(key); + while (map.size >= CACHE_LIMIT) map.delete(map.keys().next().value); + map.set(key, { value, until }); + }; + + const sharePending = async (state, map, key, operation) => { + if (!state.config.smartCache) return operation(); + const existing = map.get(key); + if (existing) return existing; + if (map.size >= CACHE_LIMIT) return operation(); + const pending = Promise.resolve().then(operation); + map.set(key, pending); + try { + return await pending; + } finally { + if (map.get(key) === pending) map.delete(key); + } + }; + + const remainingSeconds = (until) => + Math.max(0, Math.ceil((Number(until) - Date.now()) / 1000)); + + const assertRateLimit = (state) => { + const remaining = remainingSeconds(state.rateLimitUntil); + if (remaining > 0) { + throw pluginError( + RATE_LIMIT_ERROR, + `ChKSz 请求受限:请在 ${remaining} 秒后再试。`, + ); + } + }; + + const noteRateLimit = (state, error) => { + if (Number(error?.status) !== 429) return; + const retryAfter = Number(error?.retryAfterSeconds); + const seconds = + Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter + : RATE_LIMIT_COOLDOWN / 1000; + const until = Date.now() + Math.min(seconds * 1000, MAX_RATE_LIMIT_COOLDOWN); + state.rateLimitUntil = Math.max(state.rateLimitUntil, until); + }; + + // 502/503/504 由 ChKSz 的上游选曲通道返回;此时继续探测只会白打请求。 + const isUpstreamUnavailableError = (error) => + [502, 503, 504].includes(Number(error?.status)); + + const coolingDown = (map, source) => { + const until = map.get(source); + if (!until) return 0; + const remaining = remainingSeconds(until); + if (remaining <= 0) { + map.delete(source); + return 0; + } + return remaining; + }; + + const coolingDownChannel = (state, source) => coolingDown(state.channelCooldowns, source); + + const canDirectSearch = (state, source) => + Boolean(getSourcePolicy(source).directSearch) && Boolean(state?.config.directSearchFallback); + + // 平台级冷却:上游 502/503/504;或者 ChKSz 搜索通道故障且没有备用搜索可用。 + const coolingDownPlatform = (state, source) => + coolingDownChannel(state, source) || + (canDirectSearch(state, source) ? 0 : coolingDown(state.searchCooldowns, source)); + + const cooldownMillis = (error, fallback) => { + const retryAfter = Number(error?.retryAfterSeconds); + return Number.isFinite(retryAfter) && retryAfter > 0 + ? Math.min(retryAfter * 1000, MAX_CHANNEL_COOLDOWN) + : fallback; + }; + + const noteChannelFailure = (state, source, error) => { + if (!isUpstreamUnavailableError(error)) return; + state.channelCooldowns.set(source, Date.now() + cooldownMillis(error, CHANNEL_COOLDOWN)); + }; + + // 搜索接口的 404 表示"没搜到";对多个不同关键词都 404 才判为通道故障。 + const isSearchNotFoundError = (error) => + error?.code === "CHKSZ_HTTP_404" && !isUnavailableQualityError(error); + + const noteSearchOutcome = (state, source, keyword, error) => { + if (!error) { + state.searchFailures.delete(source); + return; + } + if (!isSearchNotFoundError(error)) return; + const keywords = state.searchFailures.get(source) ?? new Set(); + keywords.add(keyword); + state.searchFailures.set(source, keywords); + if (keywords.size >= SEARCH_OUTAGE_THRESHOLD) { + state.searchFailures.delete(source); + state.searchCooldowns.set(source, Date.now() + CHANNEL_COOLDOWN); + } + }; + + const trackUnavailableKey = (source, id) => `${source}:${String(id)}`; + + const readUnavailableTrack = (state, source, id) => { + const key = trackUnavailableKey(source, id); + const entry = state.unavailableTracks.get(key); + if (!entry) return undefined; + if (entry.until <= Date.now()) { + state.unavailableTracks.delete(key); + return undefined; + } + return entry.error; + }; + + const rememberUnavailableTrack = (state, source, id, error) => { + if (!state.config.smartCache) return; + const key = trackUnavailableKey(source, id); + state.unavailableTracks.delete(key); + while (state.unavailableTracks.size >= CACHE_LIMIT) { + state.unavailableTracks.delete(state.unavailableTracks.keys().next().value); + } + state.unavailableTracks.set(key, { + error, + until: Date.now() + TRACK_UNAVAILABLE_TTL, + }); + }; + + const getApiKey = () => { + const value = splayer.getSetting(API_KEY_SETTING); + const apiKey = typeof value === "string" ? value.trim() : ""; + + if (!apiKey) { + throw pluginError( + "CHKSZ_CONFIG_MISSING", + "未配置 ChKSz API Key:请打开 设置 → 插件管理 → ChKSz 音源 → 配置。", + ); + } + + // RFC 3986 unreserved characters; this prevents accidentally sending whitespace or a URL. + if (!/^chksz_[A-Za-z0-9._~-]+$/.test(apiKey)) { + throw pluginError( + "CHKSZ_CONFIG_INVALID", + "ChKSz API Key 格式无效:Key 应以 chksz_ 开头,并只包含 URL 安全字符。", + ); + } + + return apiKey; + }; + + const buildApiUrl = (endpoint, params, apiKey) => { + const url = new URL(`${API_BASE_URL}${endpoint}`); + for (const [key, value] of Object.entries({ + ...params, + apikey: apiKey, + })) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + return url.toString(); + }; + + const getBodyMessage = (body) => { + if (!isRecord(body)) return ""; + for (const key of ["msg", "message", "error"]) { + const value = body[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if ( + isRecord(value) && + typeof value.message === "string" && + value.message.trim() + ) { + return value.message.trim(); + } + } + return ""; + }; + + const redactSensitiveData = (value) => + String(value ?? "") + .replace(/([?&]apikey=)[^&\s]+/gi, "$1[REDACTED]") + .replace(/chksz_[A-Za-z0-9._~-]+/gi, "chksz_[REDACTED]"); + + const getHeader = (headers, name) => { + if (!isRecord(headers)) return ""; + const wanted = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === wanted && typeof value === "string") + return value; + } + return ""; + }; + + const throwHttpError = (response) => { + const status = Number(response?.status) || 0; + const bodyMessage = redactSensitiveData(getBodyMessage(response?.body)); + const parts = [`ChKSz 请求失败(HTTP ${status})`]; + if (bodyMessage) parts.push(bodyMessage); - if (status === 429) { const retryAfter = getHeader(response?.headers, "retry-after"); - if (retryAfter) parts.push(`请在 ${retryAfter} 秒后再试`); - } + if (retryAfter && status === 429) { + parts.push(`请在 ${redactSensitiveData(retryAfter)} 秒后再试`); + } + // 额度类错误把响应头里的剩余额度写进文案,用户一眼知道是免费还是付费额度用完。 + if (status === 402 || status === 429) { + const free = getHeader(response?.headers, "x-quota-free-remaining"); + const paid = getHeader(response?.headers, "x-quota-paid-remaining"); + if (free !== "" || paid !== "") { + parts.push( + `免费额度剩余 ${redactSensitiveData(free || "?")},付费额度剩余 ${redactSensitiveData(paid || "?")}`, + ); + } + } - const code = status > 0 ? `CHKSZ_HTTP_${status}` : "CHKSZ_HTTP_ERROR"; - throw pluginError(code, parts.join(":")); -}; + const code = status > 0 ? `CHKSZ_HTTP_${status}` : "CHKSZ_HTTP_ERROR"; + const error = pluginError(code, parts.join(":")); + // 结构化状态供限流/通道冷却判定,避免从文案反推。Retry-After 对 429 和 5xx 都记录。 + error.status = status; + const retryAfterSeconds = Number(retryAfter); + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { + error.retryAfterSeconds = retryAfterSeconds; + } + throw error; + }; -const requestJson = async (endpoint, params) => { - const response = await splayer.request(buildApiUrl(endpoint, params), { - method: "GET", - responseType: "json", - timeout: REQUEST_TIMEOUT, - }); + const throwApiError = (body) => { + const apiCode = Number(body?.code); + if (!Number.isInteger(apiCode) || apiCode === 200) return; - if (!response || Number(response.status) < 200 || Number(response.status) >= 300) { - throwHttpError(response); - } + const bodyMessage = redactSensitiveData(getBodyMessage(body)); + const isHttpStatus = apiCode >= 400 && apiCode <= 599; + const prefix = isHttpStatus + ? `ChKSz 请求失败(HTTP ${apiCode})` + : `ChKSz 返回错误码 ${apiCode}`; + const code = isHttpStatus ? `CHKSZ_HTTP_${apiCode}` : `CHKSZ_API_${apiCode}`; + const error = pluginError(code, bodyMessage ? `${prefix}:${bodyMessage}` : prefix); + error.status = apiCode; + throw error; + }; - if (!isRecord(response.body)) { - throw pluginError("CHKSZ_INVALID_RESPONSE", "ChKSz 返回的不是有效 JSON 对象。"); - } + const isUnavailableQualityError = (error) => + error?.code === "CHKSZ_HTTP_404" && + /music url not found\s*,\s*song may be unavailable at this quality level/i.test( + error.message || "", + ); - return response.body; -}; + // Key, quota, ban and rate-limit failures affect every request; stop probing after them. + const isAccountError = (error) => + /^CHKSZ_(CONFIG_|RATE_LIMITED$|HTTP_(401|402|403|429)$)/.test(String(error?.code)); -const getConfiguredSource = (source) => { - const config = SOURCE_CONFIG[source]; - if (!config) { - throw pluginError("CHKSZ_SOURCE_UNSUPPORTED", `不支持的 SPlayer 音源:${String(source)}。`); - } - return config; -}; + const isCrossPlatformLimitError = (error) => + error?.code === CROSS_PLATFORM_LIMIT_ERROR; -const buildTrackParams = (source, id, quality) => { - const config = getConfiguredSource(source); - const requestedQuality = QUALITY_NAMES.includes(quality) ? quality : "hq"; - return { - config, - requestedQuality, - params: { - [config.idParameter]: id, - [config.qualityParameter]: config.qualityValues[requestedQuality], - type: "json", - }, + const isResolutionTimeoutError = (error) => + error?.code === RESOLUTION_TIMEOUT_ERROR; + + const isRequestTimeoutError = (error) => + error?.code === REQUEST_TIMEOUT_ERROR; + + const isHostRequestTimeoutError = (error) => + ["PLUGIN_REQUEST_TIMEOUT", "REQUEST_TIMEOUT", "ETIMEDOUT", "ESOCKETTIMEDOUT"].includes( + String(error?.code), + ) || error?.name === "TimeoutError"; + + const isHostCancellationError = (error) => + ["PLUGIN_CANCELLED", "PLUGIN_ABORTED", "ABORT_ERR", "ERR_ABORTED"].includes( + String(error?.code), + ) || error?.name === "AbortError"; + + const isOperationalError = (error) => + error?.code === NETWORK_ERROR || isRequestTimeoutError(error); + + const isProviderError = (error) => + /^CHKSZ_(HTTP(?:_\d{3}|_ERROR)|API_|INVALID_RESPONSE)/.test( + String(error?.code), + ); + + const requestWithTimeout = async ( + url, + options, + timeoutCode = REQUEST_TIMEOUT_ERROR, + ) => { + const timeout = Number(options?.timeout) > 0 ? Number(options.timeout) : REQUEST_TIMEOUT; + let timer; + try { + return await Promise.race([ + Promise.resolve().then(() => splayer.request(url, options)), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + pluginError( + timeoutCode, + timeoutCode === RESOLUTION_TIMEOUT_ERROR + ? "SPlayer 播放地址解析已达到时间上限。" + : timeoutCode === CROSS_PLATFORM_LIMIT_ERROR + ? "ChKSz 跨平台兜底已达到请求或时间上限。" + : `ChKSz 请求超过 ${timeout} 毫秒。`, + ), + ); + }, timeout); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } }; -}; -const extractUrl = (body) => { - const candidates = [ - body?.url, - body?.data?.url, - body?.result?.url, - typeof body?.data === "string" ? body.data : undefined, - typeof body?.result === "string" ? body.result : undefined, - ]; + const performRequestJson = async (endpoint, params, { budget, deadline, state }) => { + let timeout = REQUEST_TIMEOUT; + let timeoutCode = REQUEST_TIMEOUT_ERROR; + const remainingResolutionTime = Number.isFinite(deadline) + ? deadline - Date.now() + : Infinity; + if (remainingResolutionTime <= 0) { + throw pluginError( + RESOLUTION_TIMEOUT_ERROR, + "SPlayer 播放地址解析已达到时间上限。", + ); + } - const url = candidates.find((value) => typeof value === "string" && value.trim()); - if (!url) return ""; + if (budget) { + const remainingTime = budget.deadline - Date.now(); + if (budget.remaining <= 0 || remainingTime <= 0) { + throw pluginError( + CROSS_PLATFORM_LIMIT_ERROR, + "ChKSz 跨平台兜底已达到请求或时间上限。", + ); + } + budget.remaining -= 1; + timeout = Math.max(1, Math.min(REQUEST_TIMEOUT, remainingTime)); + timeoutCode = CROSS_PLATFORM_LIMIT_ERROR; + } + if (Number.isFinite(remainingResolutionTime)) { + if (remainingResolutionTime < timeout) { + timeoutCode = RESOLUTION_TIMEOUT_ERROR; + } + timeout = Math.max(1, Math.min(timeout, remainingResolutionTime)); + } - const trimmed = url.trim(); - return /^https?:\/\//i.test(trimmed) ? trimmed : ""; -}; + const requestUrl = buildApiUrl(endpoint, params, state.apiKey); + const requestOptions = { + method: "GET", + responseType: "json", + timeout, + }; + let response; + try { + response = await requestWithTimeout(requestUrl, requestOptions, timeoutCode); + } catch (error) { + if (isHostCancellationError(error)) { + const code = + typeof error?.code === "string" && error.code + ? error.code + : "PLUGIN_CANCELLED"; + throw pluginError( + code, + redactSensitiveData(error?.message ?? "SPlayer 已取消请求。"), + ); + } + if ( + isRequestTimeoutError(error) || + isResolutionTimeoutError(error) || + isCrossPlatformLimitError(error) + ) { + throw error; + } + if (isHostRequestTimeoutError(error)) { + const hostTimeoutCode = + timeoutCode === RESOLUTION_TIMEOUT_ERROR && + Number.isFinite(deadline) && + Date.now() >= deadline + ? RESOLUTION_TIMEOUT_ERROR + : timeoutCode === CROSS_PLATFORM_LIMIT_ERROR && + budget && + Date.now() >= budget.deadline + ? CROSS_PLATFORM_LIMIT_ERROR + : REQUEST_TIMEOUT_ERROR; + throw pluginError( + hostTimeoutCode, + hostTimeoutCode === RESOLUTION_TIMEOUT_ERROR + ? "SPlayer 播放地址解析已达到时间上限。" + : hostTimeoutCode === CROSS_PLATFORM_LIMIT_ERROR + ? "ChKSz 跨平台兜底已达到请求或时间上限。" + : `ChKSz 请求超过 ${timeout} 毫秒。`, + ); + } + throw pluginError( + NETWORK_ERROR, + `ChKSz 网络请求失败:${redactSensitiveData(error?.message ?? error)}`, + ); + } -const normaliseExpiry = (value) => { - const number = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(number) || number <= 0) return undefined; - const expiry = number < 1_000_000_000_000 ? number * 1000 : number; - return expiry > Date.now() ? expiry : undefined; -}; + if ( + !response || + Number(response.status) < 200 || + Number(response.status) >= 300 + ) { + throwHttpError(response); + } -const extractExpiry = (body) => { - for (const container of [body, body?.data, body?.result]) { - if (!isRecord(container)) continue; - for (const key of ["expire", "expiresAt", "expires_at", "urlExpire"]) { - const expiry = normaliseExpiry(container[key]); - if (expiry) return expiry; + if (!isRecord(response.body)) { + throw pluginError( + "CHKSZ_INVALID_RESPONSE", + "ChKSz 返回的不是有效 JSON 对象。", + ); } - } - return undefined; -}; -const resolveUrl = async ({ source, quality, musicInfo }) => { - const id = getMusicId(musicInfo); - const { config, requestedQuality, params } = buildTrackParams(source, id, quality); - const body = await requestJson(config.endpoint, params); - const url = extractUrl(body); + throwApiError(response.body); + return response.body; + }; - if (!url) { - const message = getBodyMessage(body); - throw pluginError( - "CHKSZ_NO_URL", - message ? `ChKSz 未返回播放地址:${message}` : "ChKSz 未返回播放地址。", + const requestKey = (endpoint, params) => JSON.stringify([ + endpoint, Object.entries(params).sort(([left], [right]) => left.localeCompare(right)), + ]); + + const assertCurrentSession = (state) => { + if (state !== getSession()) { + throw pluginError("CHKSZ_CONFIG_CHANGED", "ChKSz 配置已变化,请重新播放。"); + } + }; + + const isTierUnavailable = (error) => + isUnavailableQualityError(error) || error?.code === DELIVERY_UNUSABLE_ERROR; + + /** + * 返回地址前的交付体检:只取前几个字节确认地址真的能拉流。 + * SPlayer 在播放失败时只会换音源、不会降音质,所以"这个地址能不能播"必须由插件自己兜。 + * @returns 不可用原因;可以放行时返回空串 + */ + const probeDelivery = async (url, state, deadline) => { + const cached = readCache(state.probes, url); + if (cached !== undefined) return cached; + const remaining = Number.isFinite(deadline) ? deadline - Date.now() : Infinity; + // 时间预算已经用尽时不再体检,交给调用方按原结果返回。 + if (remaining <= 0) return ""; + + let response; + try { + response = await requestWithTimeout(url, { + method: "GET", + headers: { Range: PROBE_RANGE }, + responseType: "arraybuffer", + timeout: Math.max(1, Math.min(PROBE_TIMEOUT, remaining)), + }); + } catch (error) { + if (!UNREACHABLE_PROBE_CODES.has(String(error?.code))) { + // 老版本宿主可能不支持自定义头或 arraybuffer;无法判定时按可用处理,绝不因此降级。 + splayer.log.debug( + `ChKSz 交付体检无法判定,按可用处理:${redactSensitiveData(error?.message ?? error)}`, + ); + return ""; + } + const reason = `地址打不开(${redactSensitiveData(error?.message ?? error)})`; + writeCache(state.probes, url, reason, Date.now() + CACHE_TTL); + return reason; + } + + const status = Number(response?.status) || 0; + if (status < 200 || status >= 300) { + const reason = `地址返回 HTTP ${status}`; + writeCache(state.probes, url, reason, Date.now() + CACHE_TTL); + return reason; + } + + writeCache(state.probes, url, "", Date.now() + CACHE_TTL); + return ""; + }; + + const hasActionMetadata = (body, action) => { + const fields = action === "musicLyric" + ? ["lyric", "lrc", "awlyric", "yrc", "qrc", "krc"] + : ["cover", "coverUrl", "pic", "picUrl", "albumCover"]; + const text = extractTextField(body, fields); + return Boolean(text) && (action === "musicLyric" || /^https?:\/\//i.test(text)); + }; + + const requestJson = async (endpoint, params, context = {}) => { + const state = context.state ?? getSession(); + assertCurrentSession(state); + // Cache hits do not spend a request budget, but must respect the total deadline. + if (Number.isFinite(context.deadline) && Date.now() >= context.deadline) { + throw pluginError(RESOLUTION_TIMEOUT_ERROR, "SPlayer 播放地址解析已达到时间上限。"); + } + if (context.budget && Date.now() >= context.budget.deadline) { + throw pluginError(CROSS_PLATFORM_LIMIT_ERROR, "ChKSz 跨平台兜底已达到请求或时间上限。"); + } + const key = requestKey(endpoint, params); + const cached = state.config.smartCache && readCache(state.responses, key); + if (cached && (cached.error || !context.action || hasActionMetadata(cached.body, context.action))) { + splayer.log.debug(`ChKSz 复用缓存:${endpoint}`); + if (cached.error) throw cached.error; + return cached.body; + } + let body; + try { + // 缓存命中不算请求,因此限流闸门放在缓存之后、真正发请求之前。 + assertRateLimit(state); + body = await performRequestJson(endpoint, params, { ...context, state }); + } catch (error) { + noteRateLimit(state, error); + if (state.config.smartCache && isUnavailableQualityError(error)) { + writeCache(state.responses, key, { error }, Date.now() + UNAVAILABLE_TTL); + } + throw error; + } + assertCurrentSession(state); + if (state.config.smartCache) { + if (params.msg || params.keyword) { + const hasSearchShape = [body, body?.data, body?.result].some( + (value) => Array.isArray(value) || Array.isArray(value?.list), + ); + if (hasSearchShape) { + const ttl = extractCandidates(body).length ? CACHE_TTL : UNAVAILABLE_TTL; + writeCache(state.responses, key, { body }, Date.now() + ttl); + } + } else { + const expiry = extractExpiry(body); + if (extractUrl(body) && expiry) { + writeCache(state.responses, key, { body }, Math.min(expiry - URL_EXPIRY_MARGIN, Date.now() + CACHE_TTL)); + } + for (const [source, policy] of Object.entries(SOURCE_POLICIES)) { + if (policy.playback.endpoint === endpoint && params[policy.identity.idParameter] != null) { + writeCache(state.metadata, JSON.stringify([source, String(params[policy.identity.idParameter])]), body, Date.now() + CACHE_TTL); + } + } + } + } + return body; + }; + + const extractUrl = (body) => { + const candidates = [ + body?.url, + body?.data?.url, + body?.result?.url, + typeof body?.data === "string" ? body.data : undefined, + typeof body?.result === "string" ? body.result : undefined, + ]; + + const url = candidates.find( + (value) => typeof value === "string" && value.trim(), ); - } + if (!url) return ""; + + const trimmed = url.trim(); + return /^https?:\/\//i.test(trimmed) ? trimmed : ""; + }; + + const normaliseExpiry = (value) => { + const number = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(number) || number <= 0) return undefined; + const expiry = number < 1_000_000_000_000 ? number * 1000 : number; + return expiry > Date.now() ? expiry : undefined; + }; + + const extractExpiry = (body) => { + for (const container of [body, body?.data, body?.result]) { + if (!isRecord(container)) continue; + for (const key of ["expire", "expiresAt", "expires_at", "urlExpire"]) { + const expiry = normaliseExpiry(container[key]); + if (expiry) return expiry; + } + } + return undefined; + }; + + /** + * 服务端会用响应体里的 level/bitrate/format 说明实际交付的档位。 + * 实测请求 hires 时服务端会静默返回 lossless,因此不能拿请求档位当结果。 + */ + const readServedQuality = (body) => { + for (const container of [body, body?.data, body?.result]) { + if (!isRecord(container)) continue; + for (const key of ["level", "bitrate", "format"]) { + const logical = NATIVE_QUALITY_LEVELS[textOrEmpty(container[key]).toLowerCase()]; + if (logical) return logical; + } + } + return ""; + }; + + const normalisePlaybackResponse = (body) => { + const url = extractUrl(body); + if (!url) { + const message = redactSensitiveData(getBodyMessage(body)); + throw pluginError( + "CHKSZ_NO_URL", + message ? `ChKSz 未返回播放地址:${message}` : "ChKSz 未返回播放地址。", + ); + } + + const result = { url }; + const expire = extractExpiry(body); + if (expire) result.expire = expire; + return result; + }; + + const selectQualityCandidates = (policy, requestedQuality, config = {}) => { + const { economyMode = false, playableFirst = true } = config; + const logicalQualities = economyMode ? [requestedQuality, "lq"] : policy.playback.qualityFallbacks?.[ + requestedQuality + ] ?? [requestedQuality]; + const attemptedNativeQualities = new Set(); + const { qualityValues } = policy.playback; + + const candidates = logicalQualities + .flatMap((candidateQuality) => + [ + qualityValues[candidateQuality], + ...(economyMode ? [] : policy.playback.qualityAlternatives?.[candidateQuality] ?? []), + ].map((nativeQuality) => ({ candidateQuality, nativeQuality })), + ) + .filter(({ nativeQuality }) => { + if (attemptedNativeQualities.has(nativeQuality)) return false; + attemptedNativeQualities.add(nativeQuality); + return true; + }); + + // 省配额模式本身就是用户显式选的取舍,不改它的顺序。 + if (!playableFirst || economyMode) return candidates; + + return [ + ...candidates.filter(({ nativeQuality }) => !MASTER_TIER_LEVELS.has(nativeQuality)), + ...candidates.filter(({ nativeQuality }) => MASTER_TIER_LEVELS.has(nativeQuality)), + ]; + }; + + const resolveOnPlatform = async (source, quality, id, requestContext = {}) => { + const { policy, requestedQuality } = buildTrackParams(source, id, quality); + const state = requestContext.state; + const remembered = state ? readUnavailableTrack(state, source, id) : undefined; + if (remembered) throw remembered; + + const economyMode = state?.config.economyMode; + const qualityCandidates = selectQualityCandidates(policy, requestedQuality, state?.config); + let body; + let resolvedQuality = requestedQuality; + + for (const [index, { candidateQuality, nativeQuality }] of qualityCandidates.entries()) { + const { params } = buildTrackParams(source, id, candidateQuality, nativeQuality); + + try { + body = await requestJson(policy.playback.endpoint, params, requestContext); + if (state?.config.verifyDelivery) { + const candidateUrl = extractUrl(body); + if (candidateUrl) { + const unusable = await probeDelivery(candidateUrl, state, requestContext.deadline); + if (unusable) throw pluginError(DELIVERY_UNUSABLE_ERROR, unusable); + } + } + resolvedQuality = candidateQuality; + break; + } catch (error) { + const hasFallback = index < qualityCandidates.length - 1; + // 完整阶梯全部不可用说明是版权问题,而非某一档抖动;省配额模式的阶梯不完整,不能据此下结论。 + if (!hasFallback && state && !economyMode && isUnavailableQualityError(error)) { + rememberUnavailableTrack(state, source, id, error); + } + if (!hasFallback || !isTierUnavailable(error)) throw error; + } + } + + const result = normalisePlaybackResponse(body); + // 上报服务端实际交付的档位;响应体没有档位字段时退回请求的逻辑档位。 + result.quality = readServedQuality(body) || resolvedQuality; + return { result, body }; + }; + + const normaliseText = (value) => + String(value ?? "") + .toLowerCase() + .replace(/[\s\p{P}\p{S}]+/gu, ""); + + const splitArtists = (value) => + String(value ?? "").split(ARTIST_SEPARATOR).map(normaliseText).filter(Boolean); + + const exactlyEqual = (a, b) => Boolean(a) && Boolean(b) && a === b; + + const parseDurationSeconds = (value) => { + if (typeof value === "number") { + if (!Number.isFinite(value) || value <= 0) return 0; + // Values this large can only be milliseconds. + return value >= 36_000 ? value / 1000 : value; + } + const text = textOrEmpty(value); + const clock = /^(\d{1,3}):(\d{2})$/.exec(text); + if (clock) return Number(clock[1]) * 60 + Number(clock[2]); + return parseDurationSeconds(Number(text)); + }; + + /** 只有"原曲自带版本标记、候选是干净标题"时才允许宽松匹配。 */ + const isVariantFallback = (wantedTitle, candidateTitle) => { + const wanted = textOrEmpty(wantedTitle); + const candidate = textOrEmpty(candidateTitle); + const wantedBase = stripTitleVariant(wanted); + const candidateBase = stripTitleVariant(candidate); + // 原曲本身没有版本标记,说明它就是规范版本,不接受别的版本顶替。 + if (normaliseText(wantedBase) === normaliseText(wanted)) return false; + // 候选自己也带版本标记时必须走精确匹配,否则会拿另一个变体冒充。 + if (normaliseText(candidateBase) !== normaliseText(candidate)) return false; + return exactlyEqual(normaliseText(wantedBase), normaliseText(candidateBase)); + }; + + /** 0 = different song, 2 = exact normalised title, 1 = relaxed title match. */ + const matchScore = ( + track, + candidate, + { requireDuration = false, requireDurationForRelaxed = false } = {}, + ) => { + if (!isRecord(candidate)) return 0; + const wantedName = normaliseText(track.name); + const candidateName = normaliseText(candidate.name); + const exact = exactlyEqual(wantedName, candidateName); + const relaxed = !exact && isVariantFallback(track.name, candidate.name); + if (!exact && !relaxed) return 0; + + const wantedArtists = splitArtists(track.singer); + const candidateArtists = splitArtists(candidate.singer); + if ( + wantedArtists.length && + (!candidateArtists.length || + !wantedArtists.some((artist) => + candidateArtists.some((other) => exactlyEqual(artist, other)), + )) + ) { + return 0; + } + + const wantedSeconds = parseDurationSeconds(track.interval); + const candidateSeconds = parseDurationSeconds( + candidate.interval ?? candidate.duration, + ); + if (requireDuration && wantedSeconds && !candidateSeconds) return 0; + if (requireDurationForRelaxed && relaxed && wantedSeconds && !candidateSeconds) return 0; + if ( + wantedSeconds && + candidateSeconds && + Math.abs(wantedSeconds - candidateSeconds) > DURATION_TOLERANCE_SECONDS + ) { + return 0; + } + + return exact ? 2 : 1; + }; + + const extractCandidates = (body) => { + for (const container of [body, body?.data, body?.result]) { + if (Array.isArray(container)) return container; + if (Array.isArray(container?.list)) return container.list; + } + return []; + }; + + const extractTrackDetails = (body) => { + for (const container of [body, body?.data, body?.result]) { + if (!isRecord(container)) continue; + const details = {}; + for (const key of ["name", "singer", "artist", "artists", "interval", "duration"]) { + if (container[key] !== undefined && container[key] !== null && container[key] !== "") { + details[key] = container[key]; + } + } + if (!details.singer) { + const singer = textOrEmpty(details.artist || details.artists); + if (singer) details.singer = singer; + } + if (Object.keys(details).length > 0) return details; + } + return {}; + }; + + const normaliseCandidate = (candidate) => { + if (!isRecord(candidate) || textOrEmpty(candidate.singer)) return candidate; + const singer = textOrEmpty(candidate.artist || candidate.artists); + return singer ? { ...candidate, singer } : candidate; + }; + + /** + * 搜索关键词:去掉版本标记的标题 + 第一位歌手。把 "(Live)"、"My jealousy (Original ver.)" 这类后缀 + * 直接丢给搜索接口会显著降低命中率;酷狗对过长的 msg 返回 400,因此统一截断。 + */ + const buildSearchKeyword = (track) => { + const primaryArtist = track.singer.split(ARTIST_SEPARATOR)[0].trim(); + const title = stripTitleVariant(track.name).slice(0, SEARCH_KEYWORD_MAX_LENGTH).trim(); + const keyword = [title, primaryArtist].filter(Boolean).join(" "); + return keyword.length > SEARCH_KEYWORD_MAX_LENGTH ? title : keyword; + }; + + // 公开搜索接口偶尔用 JSONP 包裹;宿主按 json 解析失败时会原样给字符串。 + const parseLooseJson = (body) => { + if (isRecord(body)) return body; + if (typeof body !== "string") return undefined; + const text = body.trim().replace(/^[\w$.]+\s*\(/, "").replace(/\)\s*;?\s*$/, ""); + try { + const parsed = JSON.parse(text); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } + }; + + /** 备用搜索:不经过 ChKSz,不扣额度,但仍受整体解析与跨平台时间预算约束。 */ + const performDirectSearch = async (source, keyword, { budget, deadline, state } = {}) => { + const { directSearch } = getSourcePolicy(source); + const cacheKey = requestKey(directSearch.endpoint, { w: keyword }); + if (state?.config.smartCache) { + const cached = readCache(state.responses, cacheKey); + if (cached?.candidates) return cached.candidates; + } + + let timeout = REQUEST_TIMEOUT; + let timeoutCode = REQUEST_TIMEOUT_ERROR; + if (budget) { + const remaining = budget.deadline - Date.now(); + if (remaining <= 0) { + throw pluginError(CROSS_PLATFORM_LIMIT_ERROR, "ChKSz 跨平台兜底已达到请求或时间上限。"); + } + timeout = Math.min(timeout, remaining); + timeoutCode = CROSS_PLATFORM_LIMIT_ERROR; + } + if (Number.isFinite(deadline)) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw pluginError(RESOLUTION_TIMEOUT_ERROR, "SPlayer 播放地址解析已达到时间上限。"); + } + if (remaining < timeout) { + timeout = remaining; + timeoutCode = RESOLUTION_TIMEOUT_ERROR; + } + } + + const url = new URL(directSearch.endpoint); + for (const [key, value] of Object.entries(directSearch.params(keyword))) { + url.searchParams.set(key, String(value)); + } + let response; + try { + response = await requestWithTimeout( + url.toString(), + { + method: "GET", + responseType: "json", + headers: { ...directSearch.headers }, + timeout: Math.max(1, timeout), + }, + timeoutCode, + ); + } catch (error) { + if ( + isHostCancellationError(error) || + isResolutionTimeoutError(error) || + isCrossPlatformLimitError(error) + ) { + throw error; + } + throw pluginError( + DIRECT_SEARCH_ERROR, + `${directSearch.name}请求失败:${redactSensitiveData(error?.message ?? error)}`, + ); + } + if (state) assertCurrentSession(state); + + const status = Number(response?.status) || 0; + if (status < 200 || status >= 300) { + throw pluginError(DIRECT_SEARCH_ERROR, `${directSearch.name}返回 HTTP ${status}。`); + } + const body = parseLooseJson(response?.body); + if (!body) { + throw pluginError(DIRECT_SEARCH_ERROR, `${directSearch.name}返回的不是有效 JSON。`); + } + const candidates = directSearch.candidates(body); + if (state?.config.smartCache) { + writeCache( + state.responses, + cacheKey, + { candidates }, + Date.now() + (candidates.length ? CACHE_TTL : UNAVAILABLE_TTL), + ); + } + return candidates; + }; + + /** + * 先走 ChKSz 搜索;它 404/5xx/网络失败或没有结果时,有备用搜索的平台改用备用搜索。 + * 账号类错误、取消和超时原样抛出;备用搜索也失败时抛回 ChKSz 的原始错误,保持通道诊断语义。 + */ + const searchCandidates = async (source, keyword, requestContext = {}) => { + const policy = getSourcePolicy(source); + const { search, directSearch } = policy; + const state = requestContext.state; + const useDirect = canDirectSearch(state, source); + const searchCooldown = state ? coolingDown(state.searchCooldowns, source) : 0; + let providerError; + + if (useDirect && searchCooldown > 0) { + splayer.log.warn( + `${policy.name}搜索通道故障冷却中(${searchCooldown} 秒),直接改用${directSearch.name}。`, + ); + } else { + try { + const body = await requestJson( + search.endpoint, + { ...search.params, [search.keywordParameter]: keyword }, + requestContext, + ); + if (state) noteSearchOutcome(state, source, keyword); + const candidates = extractCandidates(body); + if (candidates.length || !useDirect) return candidates; + } catch (error) { + if ( + !useDirect || + isHostCancellationError(error) || + isResolutionTimeoutError(error) || + isCrossPlatformLimitError(error) || + isAccountError(error) || + !(isProviderError(error) || isOperationalError(error)) + ) { + throw error; + } + if (state) noteSearchOutcome(state, source, keyword, error); + providerError = error; + } + } + + try { + const candidates = await performDirectSearch(source, keyword, requestContext); + splayer.log.info( + `${policy.name}搜索${providerError ? `失败(${providerError.message})` : "没有结果"},${directSearch.name}返回 ${candidates.length} 个候选。`, + ); + return candidates; + } catch (error) { + if ( + isHostCancellationError(error) || + isResolutionTimeoutError(error) || + isCrossPlatformLimitError(error) + ) { + throw error; + } + splayer.log.warn(`${error?.message ?? error}`); + if (providerError) throw providerError; + return []; + } + }; + + const findMatchingTracks = async ( + source, + track, + requestContext = {}, + matchOptions = {}, + ) => { + const { search } = getSourcePolicy(source); + const keyword = buildSearchKeyword(track); + const candidates = await searchCandidates(source, keyword, requestContext); + + const matches = []; + for (const rawCandidate of candidates) { + const candidate = normaliseCandidate(rawCandidate); + const score = matchScore(track, candidate, matchOptions); + if (score <= 0 || !isRecord(candidate)) continue; + const rawId = candidate[search.candidateIdField]; + const id = typeof rawId === "number" ? String(rawId) : textOrEmpty(rawId); + if (id) matches.push({ candidate, id, score }); + } + return matches + .sort((left, right) => right.score - left.score) + .slice(0, requestContext.state?.config.economyMode ? 1 : CROSS_PLATFORM_MAX_CANDIDATES) + .map(({ candidate, id }) => ({ candidate, id })); + }; + + const resolveAcrossPlatforms = async (policy, quality, track, deadline, state, primaryError) => { + const budget = { + remaining: state.config.economyMode ? 4 : CROSS_PLATFORM_REQUEST_BUDGET, + deadline: Math.min(deadline, Date.now() + CROSS_PLATFORM_TIME_BUDGET), + }; + const requestContext = { budget, deadline, state }; + const attempts = []; + let firstRecoverableError; + let completedSearch = false; + + const rememberRecoverableError = (error) => { + if ( + !firstRecoverableError && + (isOperationalError(error) || isProviderError(error)) + ) { + firstRecoverableError = error; + } + }; + + for (const targetSource of policy.crossPlatform.sources) { + const targetPolicy = getSourcePolicy(targetSource); + const cooldownSeconds = coolingDownPlatform(state, targetSource); + if (cooldownSeconds > 0) { + attempts.push({ name: targetPolicy.name, outcome: "cooldown", cooldownSeconds }); + splayer.log.warn( + `${targetPolicy.name} 上游故障冷却中,${cooldownSeconds} 秒内跳过《${track.name}》的跨平台匹配。`, + ); + continue; + } + try { + const trackHasDuration = parseDurationSeconds(track.interval) > 0; + const matchOptions = { + // ChKSz's NetEase search contract does not promise duration. Keep exact + // title matches usable, but never let an unknown-duration clean title + // replace a versioned original through the relaxed-match path. + requireDurationForRelaxed: targetSource === "wy" && trackHasDuration, + requireDuration: targetSource !== "wy" && trackHasDuration, + }; + const searchMatchOptions = { + requireDurationForRelaxed: matchOptions.requireDurationForRelaxed, + }; + const matches = await findMatchingTracks( + targetSource, + track, + requestContext, + searchMatchOptions, + ); + // 搜索本身跑通了才算"这首歌在该平台不存在";搜索失败只能说明平台不可用。 + completedSearch = true; + attempts.push({ + name: targetPolicy.name, + outcome: matches.length ? "candidates" : "no-match", + }); + for (const { candidate, id } of matches) { + try { + const resolution = await resolveOnPlatform( + targetSource, + quality, + id, + requestContext, + ); + const details = extractTrackDetails(resolution.body); + const resolvedCandidate = { + ...candidate, + ...details, + }; + if ( + targetSource === "tx" && + matchOptions.requireDuration && + parseDurationSeconds(details.interval ?? details.duration) <= 0 + ) { + splayer.log.warn( + `${targetPolicy.name} 候选(${id})详情缺少有效时长,跳过《${track.name}》。`, + ); + continue; + } + if (matchScore(track, resolvedCandidate, matchOptions) <= 0) { + splayer.log.warn( + `${targetPolicy.name} 候选(${id})未通过《${track.name}》的时长校验。`, + ); + continue; + } + + splayer.log.info( + `《${track.name}》在${policy.name}不可用,已改用 ${targetPolicy.name} 播放(${id})。`, + ); + return resolution.result; + } catch (error) { + if (isHostCancellationError(error)) throw error; + if (isResolutionTimeoutError(error)) throw error; + if (isCrossPlatformLimitError(error)) { + if (firstRecoverableError) throw firstRecoverableError; + throw error; + } + if (isAccountError(error)) throw error; + rememberRecoverableError(error); + noteChannelFailure(state, targetSource, error); + if (coolingDownChannel(state, targetSource) > 0) break; + splayer.log.warn( + `${targetPolicy.name} 匹配《${track.name}》失败:${error?.message ?? error}`, + ); + } + } + } catch (error) { + if (isHostCancellationError(error)) throw error; + if (isResolutionTimeoutError(error)) throw error; + if (isCrossPlatformLimitError(error)) { + if (firstRecoverableError) throw firstRecoverableError; + throw error; + } + if (isAccountError(error)) throw error; + rememberRecoverableError(error); + noteChannelFailure(state, targetSource, error); + attempts.push({ name: targetPolicy.name, outcome: "error", error }); + splayer.log.warn( + `${targetPolicy.name} 匹配《${track.name}》失败:${error?.message ?? error}`, + ); + } + } + + // 一个平台都没能跑完搜索:这是 ChKSz 上游故障,不能报成"没有这首歌"。 + // 但本机网络/超时类错误本身已足够精确,保持原有的 NETWORK_ERROR 语义。 + if (!completedSearch && attempts.length > 0 && !isOperationalError(firstRecoverableError)) { + const detail = attempts + .map(({ name, outcome, error, cooldownSeconds }) => + outcome === "cooldown" + ? `${name}(${cooldownSeconds} 秒冷却中,未发起请求)` + : `${name}(${error?.message ?? "未完成搜索"})`, + ) + .join(";"); + throw pluginError( + CROSS_PLATFORM_UNAVAILABLE_ERROR, + `${policy.name}无法提供《${track.name}》的播放地址(${primaryError?.message ?? "没有可用音质"}),且跨平台搜索未能完成:${detail}。以上是 ChKSz 服务端的返回状态,不代表歌曲不存在,请稍后重试或更换音源。`, + ); + } + + if (firstRecoverableError) throw firstRecoverableError; + + if (budget.remaining <= 0 || budget.deadline - Date.now() <= 0) { + throw pluginError( + CROSS_PLATFORM_LIMIT_ERROR, + "ChKSz 跨平台兜底已达到请求或时间上限。", + ); + } + return null; + }; + + const resolveUncached = async ({ source, quality, id, track }, state) => { + const { policy } = buildTrackParams(source, id, quality); + const descriptor = getTrackDescriptor(track); + const deadline = Date.now() + RESOLUTION_TIME_BUDGET; + const canCrossSearch = + Boolean(policy.crossPlatform) && + Boolean(descriptor.name) && + state.config.crossPlatformFallback; + + const primaryCooldownSeconds = coolingDownChannel(state, source); + if (primaryCooldownSeconds > 0) { + const cooldownError = pluginError( + CHANNEL_COOLDOWN_ERROR, + `${policy.name}上游通道冷却中(还剩 ${primaryCooldownSeconds} 秒),跳过本次主渠道请求。`, + ); + if (!canCrossSearch) throw cooldownError; + + const fallback = await resolveAcrossPlatforms( + policy, + quality, + descriptor, + deadline, + state, + cooldownError, + ); + if (fallback) return fallback; + throw pluginError( + CROSS_PLATFORM_UNAVAILABLE_ERROR, + `${cooldownError.message}且在其他平台中未匹配到《${descriptor.name}》。请稍后重试或更换音源。`, + ); + } + + try { + const resolution = await resolveOnPlatform(source, quality, id, { deadline, state }); + // 成功也留一行:宿主只在插件抛错时写日志,没有这行就分不清"插件没被调用"和"插件已成功返回"。 + splayer.log.info( + `《${descriptor.name || id}》已由${policy.name}解析,交付音质 ${resolution.result.quality}。`, + ); + return resolution.result; + } catch (error) { + noteChannelFailure(state, source, error); + const canCrossSearchAfterFailure = + canCrossSearch && + (isUnavailableQualityError(error) || isUpstreamUnavailableError(error)); + if (!canCrossSearchAfterFailure) throw error; - const result = { url, quality: requestedQuality }; - const expire = extractExpiry(body); - if (expire) result.expire = expire; - return result; + const fallback = await resolveAcrossPlatforms(policy, quality, descriptor, deadline, state, error); + if (fallback) return fallback; + + const platformNames = policy.crossPlatform.sources + .map((target) => getSourcePolicy(target).name) + .join("、"); + if (isUpstreamUnavailableError(error)) { + throw pluginError( + CROSS_PLATFORM_UNAVAILABLE_ERROR, + `${policy.name}无法提供《${descriptor.name}》的播放地址(${error.message}),且在 ${platformNames} 中未匹配到同一首歌。以上是 ChKSz 服务端的返回状态,不代表歌曲不存在,请稍后重试或更换音源。`, + ); + } + throw pluginError( + "CHKSZ_TRACK_UNAVAILABLE", + `${policy.name}无法提供《${descriptor.name}》的播放地址(${error.message}),且在 ${platformNames} 中未匹配到同一首歌。`, + ); + } + }; + + const resolve = async (input) => { + const state = getSession(); + const { requestedQuality } = buildTrackParams(input.source, input.id, input.quality); + const key = JSON.stringify([input.source, String(input.id), requestedQuality, getTrackDescriptor(input.track)]); + const result = await sharePending(state, state.pending, key, () => resolveUncached(input, state)); + assertCurrentSession(state); + return { ...result }; + }; + + const requestAction = async (source, action, id) => { + const state = getSession(); + const { endpoint, params } = buildActionRequest(source, action, id); + const key = requestKey(endpoint, params); + if (state.config.smartCache) { + const metadata = readCache(state.metadata, JSON.stringify([source, String(id)])); + if (hasActionMetadata(metadata, action)) return metadata; + const cached = readCache(state.actions, key); + if (hasActionMetadata(cached, action)) return cached; + } + if (!state.config.metadataFallback) return {}; + return sharePending(state, state.actionPending, key, async () => { + const body = await requestJson(endpoint, params, { state, action }); + if (state.config.smartCache) writeCache(state.actions, key, body, Date.now() + CACHE_TTL); + return body; + }); + }; + + return { requestAction, resolve }; +}; + +const resolutionImplementation = createResolutionCore(); +const { requestAction } = resolutionImplementation; +const resolutionCore = { resolve: resolutionImplementation.resolve }; + +const resolveUrl = async ({ source, quality, musicInfo }) => { + const id = getMusicId(source, musicInfo); + return resolutionCore.resolve({ source, quality, id, track: musicInfo }); }; const textFromValue = (value) => { @@ -264,17 +1584,28 @@ const extractTextField = (body, names) => { return ""; }; -const requestTrackDetails = async (source, id) => { - const { config, params } = buildTrackParams(source, id, "lq"); - return requestJson(config.endpoint, params); +const buildActionRequest = (source, action, id) => { + const policy = getSourcePolicy(source); + const actionPolicy = policy.actions[action]; + const params = { + [policy.identity.idParameter]: id, + ...actionPolicy.params, + }; + + if (actionPolicy.request === "trackDetails") { + params[policy.playback.qualityParameter] = policy.playback.qualityValues.lq; + params.type = "json"; + } + + return { + endpoint: actionPolicy.endpoint ?? policy.playback.endpoint, + params, + }; }; const getLyric = async ({ source, musicInfo }) => { - const id = getMusicId(musicInfo); - const body = - source === "wy" - ? await requestJson("/api/163_lyric", { id }) - : await requestTrackDetails(source, id); + const id = getMusicId(source, musicInfo); + const body = await requestAction(source, "musicLyric", id); return { lyric: extractTextField(body, ["lyric", "lrc"]), @@ -285,22 +1616,36 @@ const getLyric = async ({ source, musicInfo }) => { }; const getCover = async ({ source, musicInfo }) => { - const id = getMusicId(musicInfo); - const body = - source === "wy" - ? await requestJson("/api/163_music", { id, level: "standard", type: "json" }) - : await requestTrackDetails(source, id); + const id = getMusicId(source, musicInfo); + const body = await requestAction(source, "musicPic", id); - const cover = extractTextField(body, ["cover", "coverUrl", "pic", "picUrl", "albumCover"]); + const cover = extractTextField(body, [ + "cover", + "coverUrl", + "pic", + "picUrl", + "albumCover", + ]); return { url: /^https?:\/\//i.test(cover) ? cover : "" }; }; -splayer.register({ +const createRuntimeAdapter = (runtime) => ({ + register(metadata) { + runtime.register(metadata); + }, + bind({ musicUrl, musicLyric, musicPic }) { + runtime.on("musicUrl", musicUrl); + runtime.on("musicLyric", musicLyric); + runtime.on("musicPic", musicPic); + }, +}); +const runtimeAdapter = createRuntimeAdapter(splayer); +runtimeAdapter.register({ sources: Object.fromEntries( - Object.entries(SOURCE_CONFIG).map(([source, config]) => [ + Object.entries(SOURCE_POLICIES).map(([source, policy]) => [ source, { - name: config.name, + name: policy.name, actions: ["musicUrl", "musicLyric", "musicPic"], qualities: QUALITY_NAMES, }, @@ -315,9 +1660,63 @@ splayer.register({ default: "", placeholder: "chksz_...", }, + { + key: CROSS_PLATFORM_SETTING, + type: "switch", + label: "主渠道不可用时跨平台兜底", + description: + "当前来源无版权或上游故障时,按歌名、歌手和时长在其他平台匹配同一首歌,再使用目标平台自己的 ID 解析。每次匹配会额外消耗 ChKSz 额度。", + default: true, + }, + { + key: DIRECT_SEARCH_SETTING, + type: "switch", + label: "ChKSz 搜索不可用时改用 QQ 音乐公开搜索", + description: + "ChKSz 的 QQ 音乐搜索返回 404、5xx 或没有结果时,改用 QQ 音乐公开搜索接口找同一首歌的 mid,再交给 ChKSz 按 mid 解析。公开搜索不消耗 ChKSz 额度。", + default: true, + }, + { + key: "smartCache", + type: "switch", + label: "智能请求复用", + description: "合并相同播放请求;缓存明确有效的地址与搜索结果,短暂跳过不可用音质。仅保存在内存,切换配置会清空。", + default: true, + }, + { + key: "playableFirst", + type: "switch", + label: "可播放优先(母带档留到最后)", + description: + "默认母带优先,拿最好的音质。网络或设备吃不下 46–150MB 的母带文件时打开:改为先取 hires、无损等通用档位,把网易云的母带/音效档排到最后。", + default: false, + }, + { + key: "verifyDelivery", + type: "switch", + label: "交付体检(返回前验证地址能拉流)", + description: + "拿到播放地址后先取前几个字节确认可访问,地址被拒或打不开时自动改用下一档。这是 CDN 请求,不消耗 ChKSz 额度。", + default: true, + }, + { + key: "economyMode", + type: "switch", + label: "省配额模式(减少音质与候选探测)", + description: "只尝试目标音质和标准音质;跨平台每个平台只探测一个候选、最多四次请求。可能错过中间音质或可播版本。", + default: false, + }, + { + key: "metadataFallback", + type: "switch", + label: "允许为歌词和封面额外请求", + description: "关闭后仅返回已有缓存中的歌词、封面,不为这些动作单独调用 ChKSz。", + default: true, + }, ], }); - -splayer.on("musicUrl", resolveUrl); -splayer.on("musicLyric", getLyric); -splayer.on("musicPic", getCover); +runtimeAdapter.bind({ + musicUrl: resolveUrl, + musicLyric: getLyric, + musicPic: getCover, +});