修复芒果tv弹幕没有彩色弹幕的问题并优化upstash redis请求 (#60)

* 修复芒果tv弹幕没有彩色弹幕的问题

* 优化upstash redis请求,增加hash缓存,减少调用量
This commit is contained in:
huangxd- authored and GitHub committed 2025-10-19 08:36:01 +08:00
1 parent cc3ffe6b0b
commit 5df03312d0
1 file changed
+216 -61
+216 -61
View File
@@ -28,6 +28,13 @@ const requestHistory = new Map();
let redisValid = false;
// 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime
let lastSelectMap = new Map();
// 存储上一次各变量哈希值
let lastHashes = {
animes: null,
episodeIds: null,
episodeNum: null,
lastSelectMap: null
};
// 搜索结果缓存,存储格式:{ keyword: { results, timestamp } }
const searchCache = new Map();
// 弹幕缓存,存储格式:{ videoUrl: { comments, timestamp } }
@@ -526,7 +533,7 @@ function addAnime(anime) {
removeEarliestAnime();
}
console.log("animes: ", animes);
log("info", "animes: ", animes);
return true;
}
@@ -839,6 +846,7 @@ async function getPageTitle(url) {
// 使用 GET 发送简单命令(如 PING 检查连接)
async function pingRedis() {
const url = `${redisUrl}/ping`;
log("info", `[redis] 开始发送 PING 请求:`, url);
try {
const response = await fetch(url, {
method: 'GET',
@@ -860,6 +868,7 @@ async function pingRedis() {
// 使用 GET 发送 GET 命令(读取键值)
async function getRedisKey(key) {
const url = `${redisUrl}/get/${key}`;
log("info", `[redis] 开始发送 GET 请求:`, url);
try {
const response = await fetch(url, {
method: 'GET',
@@ -878,9 +887,19 @@ async function getRedisKey(key) {
}
}
// 使用 POST 发送 SET 命令(写入键值,值在请求体中)
// 使用 POST 发送 SET 命令,仅在值变化时更新
async function setRedisKey(key, value) {
const serializedValue = JSON.stringify(value);
const currentHash = simpleHash(serializedValue);
// 检查值是否变化
if (lastHashes[key] === currentHash) {
log("info", `[redis] 键 ${key} 无变化,跳过 SET 请求`);
return { result: "OK" }; // 模拟成功响应
}
const url = `${redisUrl}/set/${key}`;
log("info", `[redis] 开始发送 SET 请求:`, url);
try {
const response = await fetch(url, {
method: 'POST',
@@ -888,22 +907,35 @@ async function setRedisKey(key, value) {
'Authorization': `Bearer ${redisToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(value) // 值作为 body 发送,支持 JSON 或字符串
body: serializedValue
});
return await response.json(); // 预期: ["OK"]
const result = await response.json();
lastHashes[key] = currentHash; // 更新哈希值
log("info", `[redis] 键 ${key} 更新成功`);
return result; // 预期: ["OK"]
} catch (error) {
log("error", `[redis] 请求失败:`, error.message);
log("error", `[redis] SET 请求失败:`, error.message);
log("error", '- 错误类型:', error.name);
if (error.cause) {
log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET'
log("error", '- 码:', error.cause.code);
log("error", '- 原因:', error.cause.message);
}
}
}
// 复杂命令,如 SETEX(设置键值并过期,使用查询参数)
// 使用 POST 发送 SETEX 命令,仅在值变化时更新
async function setRedisKeyWithExpiry(key, value, expirySeconds) {
const serializedValue = JSON.stringify(value);
const currentHash = simpleHash(serializedValue);
// 检查值是否变化
if (lastHashes[key] === currentHash) {
log("info", `[redis] 键 ${key} 无变化,跳过 SETEX 请求`);
return { result: "OK" }; // 模拟成功响应
}
const url = `${redisUrl}/set/${key}?EX=${expirySeconds}`;
log("info", `[redis] 开始发送 SETEX 请求:`, url);
try {
const response = await fetch(url, {
method: 'POST',
@@ -911,49 +943,102 @@ async function setRedisKeyWithExpiry(key, value, expirySeconds) {
'Authorization': `Bearer ${redisToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(value)
body: serializedValue
});
return await response.json();
const result = await response.json();
lastHashes[key] = currentHash; // 更新哈希值
log("info", `[redis] 键 ${key} 更新成功(带过期时间 ${expirySeconds}s)`);
return result;
} catch (error) {
log("error", `[redis] 请求失败:`, error.message);
log("error", `[redis] SETEX 请求失败:`, error.message);
log("error", '- 错误类型:', error.name);
if (error.cause) {
log("error", '- 码:', error.cause.code); // e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET'
log("error", '- 码:', error.cause.code);
log("error", '- 原因:', error.cause.message);
}
}
}
// 从redis获取变量数据
async function getCaches() {
if (animes.length === 0) {
log("info", 'getCaches start.');
const [kv_animes, kv_episodeIds, kv_episodeNum, kv_logBuffer, kv_lastSelectMap] = await Promise.all([
getRedisKey('animes'),
getRedisKey('episodeIds'),
getRedisKey('episodeNum'),
getRedisKey('logBuffer'),
getRedisKey('lastSelectMap'),
]);
animes = kv_animes.result ? JSON.parse(kv_animes.result) : animes;
episodeIds = kv_episodeIds.result ? JSON.parse(kv_episodeIds.result) : episodeIds;
episodeNum = kv_episodeNum.result ? JSON.parse(kv_episodeNum.result) : episodeNum;
logBuffer = kv_logBuffer.result ? JSON.parse(kv_logBuffer.result) : logBuffer;
lastSelectMap = kv_lastSelectMap.result ? JSON.parse(kv_lastSelectMap.result) : lastSelectMap;
// 通用的 pipeline 请求函数
async function runPipeline(commands) {
const url = `${redisUrl}/pipeline`;
log("info", `[redis] 开始发送 PIPELINE 请求:`, url);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${redisToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(commands) // commands 是一个数组,包含多个 Redis 命令
});
const result = await response.json();
return result; // 返回结果数组,按命令顺序
} catch (error) {
log("error", `[redis] Pipeline 请求失败:`, error.message);
log("error", '- 错误类型:', error.name);
if (error.cause) {
log("error", '- 码:', error.cause.code);
log("error", '- 原因:', error.cause.message);
}
}
}
// 存储更新后的变量到redis
async function updateCaches() {
log("info", 'updateCaches start.');
await Promise.all([
setRedisKey('animes', animes),
setRedisKey('episodeIds', episodeIds),
setRedisKey('episodeNum', episodeNum),
setRedisKey('logBuffer', logBuffer),
setRedisKey('lastSelectMap', lastSelectMap)
]);
// 优化后的 getRedisCaches,单次请求获取所有键
async function getRedisCaches() {
if (animes.length === 0) {
log("info", 'getCaches start.');
const keys = ['animes', 'episodeIds', 'episodeNum', 'lastSelectMap'];
const commands = keys.map(key => ['GET', key]); // 构造 pipeline 命令
const results = await runPipeline(commands);
// 解析结果,按顺序赋值
animes = results[0].result ? JSON.parse(results[0].result) : animes;
episodeIds = results[1].result ? JSON.parse(results[1].result) : episodeIds;
episodeNum = results[2].result ? JSON.parse(results[2].result) : episodeNum;
lastSelectMap = results[3].result ? JSON.parse(results[3].result) : lastSelectMap;
// 更新哈希值
lastHashes.animes = simpleHash(JSON.stringify(animes));
lastHashes.episodeIds = simpleHash(JSON.stringify(episodeIds));
lastHashes.episodeNum = simpleHash(JSON.stringify(episodeNum));
lastHashes.lastSelectMap = simpleHash(JSON.stringify(lastSelectMap));
}
}
// 优化后的 updateRedisCaches,仅更新有变化的变量
async function updateRedisCaches() {
log("info", 'updateCaches start.');
const commands = [];
const updates = [];
// 检查每个变量的哈希值
const variables = [
{ key: 'animes', value: animes },
{ key: 'episodeIds', value: episodeIds },
{ key: 'episodeNum', value: episodeNum },
{ key: 'lastSelectMap', value: lastSelectMap }
];
for (const { key, value } of variables) {
const currentHash = simpleHash(JSON.stringify(value));
if (currentHash !== lastHashes[key]) {
commands.push(['SET', key, JSON.stringify(value)]);
updates.push({ key, hash: currentHash });
}
}
// 如果有需要更新的键,执行 pipeline
if (commands.length > 0) {
log("info", `Updating ${commands.length} changed keys: ${updates.map(u => u.key).join(', ')}`);
await runPipeline(commands);
// 更新哈希值
updates.forEach(({ key, hash }) => {
lastHashes[key] = hash;
});
} else {
log("info", 'No changes detected, skipping Redis update.');
}
}
// =====================
@@ -1793,6 +1878,30 @@ function encryptStr(str) {
return '*'.repeat(str.length);
}
// RGB 转整数的函数
function rgbToInt(color) {
// 检查 RGB 值是否有效
if (
typeof color.r !== 'number' || color.r < 0 || color.r > 255 ||
typeof color.g !== 'number' || color.g < 0 || color.g > 255 ||
typeof color.b !== 'number' || color.b < 0 || color.b > 255
) {
return -1;
}
return color.r * 256 * 256 + color.g * 256 + color.b;
}
// 简单的字符串哈希函数
function simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash >>> 0; // 确保为无符号 32 位整数
}
return hash.toString(16); // 转换为十六进制
}
// =====================
// 腾讯视频搜索和分集
// =====================
@@ -2423,7 +2532,7 @@ async function fetchMangoTV(inputUrl) {
// 弹幕和视频信息 API 基础地址
const api_video_info = "https://pcweb.api.mgtv.com/video/info";
const api_danmaku = "https://galaxy.bz.mgtv.com/rdbarrage";
const api_ctl_barrage = "https://galaxy.bz.mgtv.com/getctlbarrage";
// 解析 URL 获取 cid 和 vid
// 手动解析 URL(没有 URL 对象的情况下)
@@ -2454,7 +2563,7 @@ async function fetchMangoTV(inputUrl) {
},
});
} catch (error) {
log("info", "请求视频信息失败:", error);
log("error", "请求视频信息失败:", error);
return [];
}
@@ -2464,23 +2573,64 @@ async function fetchMangoTV(inputUrl) {
log("info", "标题:", title);
// 计算弹幕分段请求
const step = 60 * 1000; // 每60秒一个分段
const end_time = time_to_second(time) * 1000; // 将视频时长转换为毫秒
const promises = [];
for (let i = 0; i < end_time; i += step) {
const danmakuUrl = `${api_danmaku}?vid=${vid}&cid=${cid}&time=${i}`;
promises.push(
httpGet(danmakuUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
})
);
try {
const ctlBarrageUrl = `${api_ctl_barrage}?version=8.1.39&abroad=0&uuid=&os=10.15.7&platform=0&mac=&vid=${vid}&pid=&cid=${cid}&ticket=`;
const res = await httpGet(ctlBarrageUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
});
const ctlBarrage = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
// 每1分钟一个分段
for (let i = 0; i < Math.ceil(time_to_second(time) / 60); i += 1) {
const danmakuUrl = `https://${ctlBarrage.data?.cdn_list.split(',')[0]}/${ctlBarrage.data?.cdn_version}/${i}.json`;
promises.push(
httpGet(danmakuUrl, {
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
})
);
}
} catch (error) {
log("error", "请求弹幕分片失败:", error);
return [];
}
log("info", "弹幕分段数量:", promises.length);
// 默认颜色值
const DEFAULT_COLOR_INT = -1;
// 处理 v2_color 对象的转换逻辑
function transformV2Color(v2_color) {
// 如果 v2_color 不存在,返回默认值
if (!v2_color) {
return DEFAULT_COLOR_INT;
}
// 计算左右颜色的整数值
const leftColor = rgbToInt(v2_color.color_left);
const rightColor = rgbToInt(v2_color.color_right);
// 如果左右颜色均为 -1,返回默认值
if (leftColor === -1 && rightColor === -1) {
return DEFAULT_COLOR_INT;
}
// 如果左颜色无效,返回右颜色
if (leftColor === -1) {
return rightColor;
}
// 如果右颜色无效,返回左颜色
if (rightColor === -1) {
return leftColor;
}
// 返回左右颜色的平均值
return Math.floor((leftColor + rightColor) / 2);
}
// 解析弹幕数据
let contents = [];
try {
@@ -2502,10 +2652,15 @@ async function fetchMangoTV(inputUrl) {
uid: 0, //发送人的 id
content: "",
};
if (item.type === 1) {
content.ct = 5;
} else if (item.type === 2) {
content.ct = 4;
if (item?.v2_color) {
content.color = transformV2Color(item?.v2_color);
}
if (item?.v2_position) {
if (item?.v2_position === 1) {
content.ct = 5;
} else if (item?.v2_position === 2) {
content.ct = 4;
}
}
content.timepoint = item.time / 1000;
content.content = item.content;
@@ -4556,7 +4711,7 @@ async function searchAnime(url) {
// 如果有新的anime获取到,则更新redis
if (redisValid && curAnimes.length !== 0) {
await updateCaches();
await updateRedisCaches();
}
return jsonResponse({
@@ -4667,7 +4822,7 @@ async function searchAnime(url) {
// 如果有新的anime获取到,则更新redis
if (redisValid && curAnimes.length !== 0) {
await updateCaches();
await updateRedisCaches();
}
// 缓存搜索结果
@@ -5262,8 +5417,8 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
otherServer = resolveOtherServer(env);
envs["otherServer"] = otherServer;
vodServers = resolveVodServers(env);
vodReturnMode = resolveVodReturnMode(env);
envs["vodServers"] = vodServers.map(s => `${s.name}@${s.url}`).join(',');
vodReturnMode = resolveVodReturnMode(env);
envs["vodReturnMode"] = vodReturnMode;
bilibliCookie = resolveBilibiliCookie(env);
envs["bilibliCookie"] = encryptStr(bilibliCookie);
@@ -5310,7 +5465,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
log("info", `client ip: ${clientIp}`);
if (redisValid && path !== "/favicon.ico" && path !== "/robots.txt") {
await getCaches();
await getRedisCaches();
}
function handleHomepage() {
@@ -5321,7 +5476,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
envs: envs,
repository: "https://github.com/huangxd-/danmu_api.git",
description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩巴弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口,并提供日志记录,支持vercel/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。",
notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok。https://t.me/ddjdd_bot"
notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok,TG MSG ROBOT: [https://t.me/ddjdd_bot]; 推荐加互助群咨询,TG GROUP: [https://t.me/logvar_danmu_group]; 关注频道获取最新更新内容,TG CHANNEL: [https://t.me/logvar_danmu_channel]。"
});
}