Files
MoonTVPlus/src/app/api/music/route.ts
mtvpls e0606d0b8b 修复OpenList缓存未启用时仍走代理的问题
在replaceAudioUrlsWithOpenList函数中添加OpenListCacheEnabled检查,
确保只有在启用OpenList缓存时才会执行代理逻辑。
2026-02-09 02:16:09 +08:00

733 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { OpenListClient } from '@/lib/openlist.client';
export const runtime = 'nodejs';
// 检测是否为 Cloudflare 环境
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
// 服务器端内存缓存
const serverCache = {
methodConfigs: new Map<string, { data: any; timestamp: number }>(),
proxyRequests: new Map<string, { data: any; timestamp: number }>(),
CACHE_DURATION: 24 * 60 * 60 * 1000, // 24小时缓存
};
// 正在下载的音频任务追踪(防止重复下载)
const downloadingTasks = new Map<string, Promise<void>>();
// 获取 TuneHub 配置
async function getTuneHubConfig() {
const config = await getConfig();
const musicConfig = config?.MusicConfig;
const enabled = musicConfig?.TuneHubEnabled ?? false;
const baseUrl =
musicConfig?.TuneHubBaseUrl ||
process.env.TUNEHUB_BASE_URL ||
'https://tunehub.sayqz.com/api';
const apiKey = musicConfig?.TuneHubApiKey || process.env.TUNEHUB_API_KEY || '';
return { enabled, baseUrl, apiKey, musicConfig };
}
// 获取 OpenList 客户端
async function getOpenListClient(): Promise<OpenListClient | null> {
const config = await getConfig();
const musicConfig = config?.MusicConfig;
if (!musicConfig?.OpenListCacheEnabled) {
return null;
}
const url = musicConfig.OpenListCacheURL;
const username = musicConfig.OpenListCacheUsername;
const password = musicConfig.OpenListCachePassword;
if (!url || !username || !password) {
return null;
}
return new OpenListClient(url, username, password);
}
// 异步下载音频文件并上传到 OpenList
async function cacheAudioToOpenList(
openListClient: OpenListClient,
audioUrl: string,
platform: string,
songId: string,
quality: string,
cachePath: string
): Promise<void> {
const taskKey = `${platform}-${songId}-${quality}`;
// 检查是否已经有任务在下载
const existingTask = downloadingTasks.get(taskKey);
if (existingTask) {
return existingTask;
}
// 创建下载任务
const downloadTask = (async () => {
try {
const audioPath = `${cachePath}/${platform}/audio/${songId}-${quality}.mp3`;
const audioResponse = await fetch(audioUrl);
if (!audioResponse.ok) {
console.error('[Music Cache] 下载音频失败:', audioResponse.status);
return;
}
const audioBuffer = await audioResponse.arrayBuffer();
const audioBlob = Buffer.from(audioBuffer);
const token = await (openListClient as any).getToken();
const uploadResponse = await fetch(`${(openListClient as any).baseURL}/api/fs/put`, {
method: 'PUT',
headers: {
'Authorization': token,
'Content-Type': 'audio/mpeg',
'File-Path': encodeURIComponent(audioPath),
'As-Task': 'false',
},
body: audioBlob,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
console.error('[Music Cache] 上传音频失败:', uploadResponse.status, errorText);
return;
}
} catch (error) {
console.error('[Music Cache] 缓存音频到 OpenList 失败:', error);
} finally {
downloadingTasks.delete(taskKey);
}
})();
downloadingTasks.set(taskKey, downloadTask);
return downloadTask;
}
// 检查并替换音频 URL 为 OpenList URL
async function replaceAudioUrlsWithOpenList(
data: any,
openListClient: OpenListClient | null,
platform: string,
quality: string,
cachePath: string
): Promise<any> {
// 获取配置,检查是否启用 OpenList 缓存
const config = await getConfig();
const cacheEnabled = config?.MusicConfig?.OpenListCacheEnabled ?? false;
const cacheProxyEnabled = config?.MusicConfig?.OpenListCacheProxyEnabled ?? true;
// 如果没有启用 OpenList 缓存,直接返回原数据
if (!cacheEnabled || !openListClient || !data?.data) {
return data;
}
// TuneHub 返回的数据结构是 { code: 0, data: { data: [...], total: 1 } }
// 需要提取内层的 data 数组
const songsData = data.data.data || data.data;
const songs = Array.isArray(songsData) ? songsData : [songsData];
for (const song of songs) {
if (!song?.id || !song?.url) {
continue;
}
const audioPath = `${cachePath}/${platform}/audio/${song.id}-${quality}.mp3`;
// 如果缓存中已经标记为已缓存且使用代理模式直接返回代理URL
if (song.cached === true && cacheProxyEnabled) {
song.url = `/api/music/audio-proxy?platform=${platform}&id=${song.id}&quality=${quality}`;
continue;
}
try {
// 只有在未确认缓存状态时才调用 getFile()
const fileResponse = await openListClient.getFile(audioPath);
if (fileResponse.code === 200 && fileResponse.data?.raw_url) {
// 如果启用缓存代理返回代理URL否则返回直接URL
if (cacheProxyEnabled) {
// 使用代理URL通过我们的服务器代理OpenList的音频
song.url = `/api/music/audio-proxy?platform=${platform}&id=${song.id}&quality=${quality}`;
} else {
// 直接使用OpenList的raw_url
song.url = fileResponse.data.raw_url;
}
song.cached = true;
} else {
song.cached = false;
cacheAudioToOpenList(openListClient, song.url, platform, song.id, quality, cachePath)
.catch(error => {
console.error('[Music Cache] 异步缓存音频失败:', error);
});
}
} catch (error) {
song.cached = false;
cacheAudioToOpenList(openListClient, song.url, platform, song.id, quality, cachePath)
.catch(err => {
console.error('[Music Cache] 异步缓存音频失败:', err);
});
}
}
return data;
}
// 通用请求处理函数
async function proxyRequest(
url: string,
options: RequestInit = {}
): Promise<Response> {
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
return response;
} catch (error) {
console.error('TuneHub API 请求失败:', error);
throw error;
}
}
// 获取方法配置并执行请求
async function executeMethod(
baseUrl: string,
platform: string,
func: string,
variables: Record<string, string> = {}
): Promise<any> {
// 1. 获取方法配置
const cacheKey = `method-config-${platform}-${func}`;
let config: any;
const cached = serverCache.methodConfigs.get(cacheKey);
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
config = cached.data.data;
} else {
const response = await proxyRequest(`${baseUrl}/v1/methods/${platform}/${func}`);
const data = await response.json();
serverCache.methodConfigs.set(cacheKey, { data, timestamp: Date.now() });
config = data.data;
}
if (!config) {
throw new Error('无法获取方法配置');
}
// 2. 替换模板变量
let url = config.url;
const params: Record<string, string> = {};
// 先将 variables 中的值转换为可执行的变量
const evalContext: Record<string, any> = {};
for (const [key, value] of Object.entries(variables)) {
// 尝试将字符串转换为数字(如果可能)
const numValue = Number(value);
evalContext[key] = isNaN(numValue) ? value : numValue;
}
// 递归处理对象中的模板变量
function processTemplateValue(value: any): any {
if (typeof value === 'string') {
// 处理包含模板变量的表达式
const expressionRegex = /\{\{(.+?)\}\}/g;
return value.replace(expressionRegex, (match, expression) => {
try {
// 在 Cloudflare 环境下,使用简单的表达式替换
if (isCloudflare) {
const expr = expression.trim();
// 检查是否是单个变量(没有运算符)
if (evalContext.hasOwnProperty(expr)) {
// 直接返回变量值
return String(evalContext[expr]);
}
// 处理包含运算的表达式(如 page - 1
let result: any = expr;
// 替换变量为其值
for (const [key, val] of Object.entries(evalContext)) {
const regex = new RegExp(`\\b${key}\\b`, 'g');
// 对于数字直接替换,对于字符串需要加引号以便 eval
const replacement = typeof val === 'number' ? String(val) : `"${String(val).replace(/"/g, '\\"')}"`;
result = result.replace(regex, replacement);
}
// 尝试计算表达式
try {
// eslint-disable-next-line no-eval
result = eval(result);
} catch (err) {
console.error(`[executeMethod] Cloudflare 环境执行表达式失败: ${expr}`, err);
// 如果计算失败,尝试直接返回替换后的结果(去掉可能的引号)
result = result.replace(/^["']|["']$/g, '');
}
return String(result);
} else {
// 在 Node.js 环境下,使用 Function 构造器
// eslint-disable-next-line no-new-func
const func = new Function(...Object.keys(evalContext), `return ${expression}`);
const result = func(...Object.values(evalContext));
return String(result);
}
} catch (err) {
console.error(`[executeMethod] 执行表达式失败: ${expression}`, err);
return '0'; // 默认值
}
});
} else if (Array.isArray(value)) {
return value.map(item => processTemplateValue(item));
} else if (typeof value === 'object' && value !== null) {
const result: any = {};
for (const [k, v] of Object.entries(value)) {
result[k] = processTemplateValue(v);
}
return result;
}
return value;
}
// 处理 URL 参数
if (config.params) {
for (const [key, value] of Object.entries(config.params)) {
params[key] = processTemplateValue(value);
}
}
// 处理 POST body
let processedBody = config.body;
if (config.body) {
processedBody = processTemplateValue(config.body);
}
// 3. 构建完整 URL
if (config.method === 'GET' && Object.keys(params).length > 0) {
const urlObj = new URL(url);
for (const [key, value] of Object.entries(params)) {
urlObj.searchParams.append(key, value);
}
url = urlObj.toString();
}
// 4. 发起请求
const requestOptions: RequestInit = {
method: config.method || 'GET',
headers: config.headers || {},
};
if (config.method === 'POST' && processedBody) {
requestOptions.body = JSON.stringify(processedBody);
requestOptions.headers = {
...requestOptions.headers,
'Content-Type': 'application/json',
};
}
const response = await proxyRequest(url, requestOptions);
let data = await response.json();
// 5. 执行 transform 函数(如果有)
if (config.transform) {
// 在 Cloudflare 环境下,将 transform 函数返回给前端执行
if (isCloudflare) {
// 将 transform 函数字符串附加到响应数据中
data.__transform = config.transform;
} else {
// 在 Node.js 环境下,直接执行 transform
try {
// eslint-disable-next-line no-eval
const transformFn = eval(`(${config.transform})`);
data = transformFn(data);
} catch (err) {
console.error('[executeMethod] Transform 函数执行失败:', err);
}
}
}
// 6. 处理酷我音乐的图片 URL转换为代理 URL
if (platform === 'kuwo') {
const processKuwoImages = (obj: any): any => {
if (typeof obj === 'string' && obj.startsWith('http://') && obj.includes('kwcdn.kuwo.cn')) {
// 将 HTTP 图片 URL 转换为代理 URL
return `/api/music/proxy?url=${encodeURIComponent(obj)}`;
} else if (Array.isArray(obj)) {
return obj.map(item => processKuwoImages(item));
} else if (typeof obj === 'object' && obj !== null) {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = processKuwoImages(value);
}
return result;
}
return obj;
};
data = processKuwoImages(data);
}
return data;
}
// GET 请求处理
export async function GET(request: NextRequest) {
try {
const { enabled, baseUrl } = await getTuneHubConfig();
if (!enabled) {
return NextResponse.json(
{ error: '音乐功能未开启' },
{ status: 403 }
);
}
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
if (!action) {
return NextResponse.json(
{ error: '缺少 action 参数' },
{ status: 400 }
);
}
// 处理不同的 action
switch (action) {
case 'toplists': {
// 获取排行榜列表
const platform = searchParams.get('platform');
if (!platform) {
return NextResponse.json(
{ error: '缺少 platform 参数' },
{ status: 400 }
);
}
const cacheKey = `toplists-${platform}`;
const cached = serverCache.proxyRequests.get(cacheKey);
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
return NextResponse.json(cached.data);
}
const data = await executeMethod(baseUrl, platform, 'toplists');
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
return NextResponse.json(data);
}
case 'toplist': {
// 获取排行榜详情
const platform = searchParams.get('platform');
const id = searchParams.get('id');
if (!platform || !id) {
return NextResponse.json(
{ error: '缺少 platform 或 id 参数' },
{ status: 400 }
);
}
const cacheKey = `toplist-${platform}-${id}`;
const cached = serverCache.proxyRequests.get(cacheKey);
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
return NextResponse.json(cached.data);
}
const data = await executeMethod(baseUrl, platform, 'toplist', { id });
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
return NextResponse.json(data);
}
case 'playlist': {
// 获取歌单详情
const platform = searchParams.get('platform');
const id = searchParams.get('id');
if (!platform || !id) {
return NextResponse.json(
{ error: '缺少 platform 或 id 参数' },
{ status: 400 }
);
}
const cacheKey = `playlist-${platform}-${id}`;
const cached = serverCache.proxyRequests.get(cacheKey);
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
return NextResponse.json(cached.data);
}
const data = await executeMethod(baseUrl, platform, 'playlist', { id });
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
return NextResponse.json(data);
}
case 'search': {
// 搜索歌曲
const platform = searchParams.get('platform');
const keyword = searchParams.get('keyword');
const page = searchParams.get('page') || '1';
const pageSize = searchParams.get('pageSize') || '20';
if (!platform || !keyword) {
return NextResponse.json(
{ error: '缺少 platform 或 keyword 参数' },
{ status: 400 }
);
}
const cacheKey = `search-${platform}-${keyword}-${page}-${pageSize}`;
const cached = serverCache.proxyRequests.get(cacheKey);
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
return NextResponse.json(cached.data);
}
// 注意:不同平台可能使用不同的变量名
// 统一传递 keyword, page, pageSize, limit (limit = pageSize)
const data = await executeMethod(baseUrl, platform, 'search', {
keyword,
page,
pageSize,
limit: pageSize, // 有些平台使用 limit 而不是 pageSize
});
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
return NextResponse.json(data);
}
default:
return NextResponse.json(
{ error: '不支持的 action' },
{ status: 400 }
);
}
} catch (error) {
console.error('音乐 API 错误:', error);
return NextResponse.json(
{
error: '请求失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}
// POST 请求处理(用于解析歌曲)
export async function POST(request: NextRequest) {
try {
const { enabled, baseUrl, apiKey } = await getTuneHubConfig();
if (!enabled) {
return NextResponse.json(
{ error: '音乐功能未开启' },
{ status: 403 }
);
}
const body = await request.json();
const { action } = body;
if (!action) {
return NextResponse.json(
{ error: '缺少 action 参数' },
{ status: 400 }
);
}
switch (action) {
case 'parse': {
// 解析歌曲(需要 API Key
if (!apiKey) {
return NextResponse.json(
{
code: -1,
error: '未配置 TuneHub API Key',
message: '未配置 TuneHub API Key'
},
{ status: 403 }
);
}
const { platform, ids, quality } = body;
if (!platform || !ids) {
return NextResponse.json(
{
code: -1,
error: '缺少 platform 或 ids 参数',
message: '缺少 platform 或 ids 参数'
},
{ status: 400 }
);
}
// 添加缓存支持
const qualityKey = quality || '320k';
const idsKey = Array.isArray(ids) ? ids.join(',') : ids;
const cacheKey = `parse-${platform}-${idsKey}-${qualityKey}`;
// 1. 获取 OpenList 配置
const openListClient = await getOpenListClient();
const config = await getConfig();
const cachePath = config?.MusicConfig?.OpenListCachePath || '/music-cache';
// 2. 检查内存缓存
const cached = serverCache.proxyRequests.get(cacheKey);
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
// 如果启用了 OpenList需要检查并替换音频 URL
if (openListClient) {
const updatedData = await replaceAudioUrlsWithOpenList(
cached.data,
openListClient,
platform,
qualityKey,
cachePath
);
// 更新内存缓存
serverCache.proxyRequests.set(cacheKey, { data: updatedData, timestamp: Date.now() });
return NextResponse.json(updatedData);
} else {
// 没有 OpenList 配置,直接返回内存缓存
return NextResponse.json(cached.data);
}
}
// 3. 检查 OpenList JSON 缓存
if (openListClient) {
try {
const openListPath = `${cachePath}/${platform}/${idsKey}-${qualityKey}.json`;
const fileResponse = await openListClient.getFile(openListPath);
if (fileResponse.code === 200 && fileResponse.data?.raw_url) {
// 下载缓存文件
const cacheResponse = await fetch(fileResponse.data.raw_url);
if (cacheResponse.ok) {
const cachedData = await cacheResponse.json();
// 检查并替换音频 URL
const updatedData = await replaceAudioUrlsWithOpenList(
cachedData,
openListClient,
platform,
qualityKey,
cachePath
);
// 更新内存缓存
serverCache.proxyRequests.set(cacheKey, { data: updatedData, timestamp: Date.now() });
return NextResponse.json(updatedData);
}
}
} catch (error) {
// OpenList 缓存未命中,继续调用 TuneHub
}
}
// 4. 调用 TuneHub API 解析
try {
const response = await proxyRequest(`${baseUrl}/v1/parse`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({
platform,
ids,
quality: qualityKey,
}),
});
const data = await response.json();
// 如果 TuneHub 返回错误,包装成统一格式
if (!response.ok || data.code !== 0) {
return NextResponse.json({
code: data.code || -1,
message: data.message || data.error || '解析失败',
error: data.error || data.message || '解析失败',
});
}
// 5. 缓存成功的解析结果到内存
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
// 6. 检查并替换音频 URL 为 OpenList URL如果已缓存
// 同时异步下载未缓存的音频
const finalData = await replaceAudioUrlsWithOpenList(
data,
openListClient,
platform,
qualityKey,
cachePath
);
// 7. 缓存解析结果到 OpenList异步不阻塞响应
if (openListClient) {
const jsonPath = `${cachePath}/${platform}/${idsKey}-${qualityKey}.json`;
openListClient.uploadFile(jsonPath, JSON.stringify(finalData, null, 2))
.catch((error) => {
console.error('[Music Cache] 缓存解析结果到 OpenList 失败:', error);
});
}
return NextResponse.json(finalData);
} catch (error) {
console.error('解析歌曲失败:', error);
return NextResponse.json({
code: -1,
message: '解析请求失败',
error: (error as Error).message,
});
}
}
default:
return NextResponse.json(
{ error: '不支持的 action' },
{ status: 400 }
);
}
} catch (error) {
console.error('音乐 API 错误:', error);
return NextResponse.json(
{
error: '请求失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}