This commit is contained in:
mtvpls
2026-02-01 22:23:39 +08:00
16 changed files with 3183 additions and 3 deletions

View File

@@ -0,0 +1,27 @@
-- ============================================
-- MoonTV Plus - 音乐播放记录表
-- 版本: 1.1.0
-- 创建时间: 2026-02-01
-- ============================================
-- 音乐播放记录表
CREATE TABLE IF NOT EXISTS music_play_records (
username TEXT NOT NULL,
key TEXT NOT NULL, -- format: "platform+id" (e.g., "netease+12345")
platform TEXT NOT NULL CHECK(platform IN ('netease', 'qq', 'kuwo')), -- 音乐平台
song_id TEXT NOT NULL, -- 歌曲ID
name TEXT NOT NULL, -- 歌曲名
artist TEXT NOT NULL, -- 艺术家
album TEXT, -- 专辑(可选)
pic TEXT, -- 封面图URL可选
play_time REAL NOT NULL DEFAULT 0, -- 播放进度(秒)
duration REAL NOT NULL DEFAULT 0, -- 总时长(秒)
save_time INTEGER NOT NULL, -- 保存时间戳
PRIMARY KEY (username, key),
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
-- 创建索引以提高查询性能
CREATE INDEX IF NOT EXISTS idx_music_play_records_username ON music_play_records(username);
CREATE INDEX IF NOT EXISTS idx_music_play_records_save_time ON music_play_records(username, save_time DESC);
CREATE INDEX IF NOT EXISTS idx_music_play_records_platform ON music_play_records(username, platform);

View File

@@ -353,6 +353,9 @@ interface SiteConfig {
OIDCClientId?: string;
OIDCClientSecret?: string;
OIDCButtonText?: string;
TuneHubEnabled?: boolean;
TuneHubBaseUrl?: string;
TuneHubApiKey?: string;
}
// 视频源数据类型
@@ -6790,6 +6793,9 @@ const SiteConfigComponent = ({
OIDCClientId: '',
OIDCClientSecret: '',
OIDCButtonText: '',
TuneHubEnabled: false,
TuneHubBaseUrl: 'https://tunehub.sayqz.com/api',
TuneHubApiKey: '',
});
// 豆瓣数据源相关状态
@@ -7666,6 +7672,93 @@ const SiteConfigComponent = ({
</div>
</div>
{/* TuneHub 音乐配置 */}
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
TuneHub
</h3>
{/* 开启 TuneHub */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
TuneHubEnabled: !prev.TuneHubEnabled,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.TuneHubEnabled
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
siteSettings.TuneHubEnabled
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
QQ音乐
</p>
</div>
{/* TuneHub Base URL */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
TuneHub API
</label>
<input
type='text'
placeholder='https://tunehub.sayqz.com/api'
value={siteSettings.TuneHubBaseUrl}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
TuneHubBaseUrl: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
TuneHub API https://tunehub.sayqz.com/api。也可以通过环境变量 TUNEHUB_BASE_URL 配置
</p>
</div>
{/* TuneHub API Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
TuneHub API Key
</label>
<input
type='password'
placeholder='th_your_api_key_here'
value={siteSettings.TuneHubApiKey}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
TuneHubApiKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
API Key Key TUNEHUB_API_KEY
</p>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button

View File

@@ -69,6 +69,9 @@ export async function POST(request: NextRequest) {
OIDCClientSecret,
OIDCButtonText,
OIDCMinTrustLevel,
TuneHubEnabled,
TuneHubBaseUrl,
TuneHubApiKey,
} = body as {
SiteName: string;
Announcement: string;
@@ -110,6 +113,9 @@ export async function POST(request: NextRequest) {
OIDCClientSecret?: string;
OIDCButtonText?: string;
OIDCMinTrustLevel?: number;
TuneHubEnabled?: boolean;
TuneHubBaseUrl?: string;
TuneHubApiKey?: string;
};
// 参数校验
@@ -150,7 +156,10 @@ export async function POST(request: NextRequest) {
(OIDCClientId !== undefined && typeof OIDCClientId !== 'string') ||
(OIDCClientSecret !== undefined && typeof OIDCClientSecret !== 'string') ||
(OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string') ||
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number')
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number') ||
(TuneHubEnabled !== undefined && typeof TuneHubEnabled !== 'boolean') ||
(TuneHubBaseUrl !== undefined && typeof TuneHubBaseUrl !== 'string') ||
(TuneHubApiKey !== undefined && typeof TuneHubApiKey !== 'string')
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -207,6 +216,9 @@ export async function POST(request: NextRequest) {
OIDCClientSecret,
OIDCButtonText,
OIDCMinTrustLevel,
TuneHubEnabled,
TuneHubBaseUrl,
TuneHubApiKey,
};
// 写入数据库

View File

@@ -0,0 +1,176 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
import { MusicPlayRecord } from '@/lib/db.client';
import { getCachedSongs, setCachedSong } from '@/lib/music-song-cache';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 检查用户状态
if (authInfo.username !== process.env.USERNAME) {
// 非站长,检查用户存在或被封禁
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
if (!userInfoV2) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (userInfoV2.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const records = await db.getAllMusicPlayRecords(authInfo.username);
// 从缓存中获取歌曲信息并填充到记录中
const keys = Object.keys(records).map(key => {
const [platform, id] = key.split('+');
return { platform, id };
});
const cachedSongs = getCachedSongs(keys);
// 将缓存的歌曲信息合并到记录中
const enrichedRecords: Record<string, MusicPlayRecord> = {};
for (const [key, record] of Object.entries(records)) {
const cachedSong = cachedSongs.get(key);
enrichedRecords[key] = {
...record,
name: cachedSong?.name || record.name,
artist: cachedSong?.artist || record.artist,
album: cachedSong?.album || record.album,
pic: cachedSong?.pic || record.pic,
};
}
return NextResponse.json(enrichedRecords, { status: 200 });
} catch (err) {
console.error('获取音乐播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
// 非站长,检查用户存在或被封禁
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
if (!userInfoV2) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (userInfoV2.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const { key, record }: { key: string; record: MusicPlayRecord } = body;
if (!key || !record) {
return NextResponse.json(
{ error: 'Missing key or record' },
{ status: 400 }
);
}
// 验证音乐播放记录数据
if (!record.platform || !record.id || !record.name || !record.artist) {
return NextResponse.json(
{ error: 'Invalid record data' },
{ status: 400 }
);
}
// 从key中解析platform和id
const [platform, id] = key.split('+');
if (!platform || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
await db.saveMusicPlayRecord(authInfo.username, platform, id, record);
// 缓存歌曲信息到服务器内存
setCachedSong(platform, id, {
id: record.id,
name: record.name,
artist: record.artist,
album: record.album,
pic: record.pic,
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('保存音乐播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
// 非站长,检查用户存在或被封禁
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
if (!userInfoV2) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (userInfoV2.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (key) {
// 删除单条记录
const [platform, id] = key.split('+');
if (!platform || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
await db.deleteMusicPlayRecord(authInfo.username, platform, id);
} else {
// 清空所有记录
await db.clearAllMusicPlayRecords(authInfo.username);
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('删除音乐播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,121 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
// 代理音频流
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
if (!url) {
return NextResponse.json(
{ error: '缺少 url 参数' },
{ status: 400 }
);
}
// 安全检查:只允许代理音乐平台的音频和图片 CDN
const allowedDomains = [
'sycdn.kuwo.cn',
'kwcdn.kuwo.cn',
'img1.kwcdn.kuwo.cn',
'img2.kwcdn.kuwo.cn',
'img3.kwcdn.kuwo.cn',
'img4.kwcdn.kuwo.cn',
'music.163.com',
'y.qq.com',
'ws.stream.qqmusic.qq.com',
'isure.stream.qqmusic.qq.com',
'dl.stream.qqmusic.qq.com',
];
let urlObj: URL;
try {
urlObj = new URL(url);
} catch {
return NextResponse.json(
{ error: '无效的 URL' },
{ status: 400 }
);
}
const isAllowed = allowedDomains.some(domain =>
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
);
if (!isAllowed) {
console.warn(`拒绝代理音频请求: ${urlObj.hostname}`);
return NextResponse.json(
{ error: '不允许的目标域名' },
{ status: 403 }
);
}
// 检查是否有 Range 请求头
const range = request.headers.get('range');
// 构建上游请求头
const upstreamHeaders: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'http://www.kuwo.cn/',
};
// 如果有 Range 请求,转发给上游
if (range) {
upstreamHeaders['Range'] = range;
}
// 发起请求获取音频流
const response = await fetch(url, {
headers: upstreamHeaders,
});
if (!response.ok && response.status !== 206) {
return NextResponse.json(
{ error: '获取音频失败' },
{ status: response.status }
);
}
// 获取响应头
const contentType = response.headers.get('content-type') || 'audio/mpeg';
const contentLength = response.headers.get('content-length');
const contentRange = response.headers.get('content-range');
const acceptRanges = response.headers.get('accept-ranges');
// 创建响应头
const headers: Record<string, string> = {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
'Accept-Ranges': acceptRanges || 'bytes',
};
if (contentLength) {
headers['Content-Length'] = contentLength;
}
// 如果上游返回了 Content-Range转发给客户端
if (contentRange) {
headers['Content-Range'] = contentRange;
}
// 返回音频流保持原始状态码200 或 206
return new NextResponse(response.body, {
status: response.status,
headers,
});
} catch (error) {
console.error('代理音频失败:', error);
return NextResponse.json(
{
error: '代理请求失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

444
src/app/api/music/route.ts Normal file
View File

@@ -0,0 +1,444 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
// 服务器端内存缓存
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小时缓存
};
// 获取 TuneHub 配置
async function getTuneHubConfig() {
const config = await getConfig();
const siteConfig = config?.SiteConfig;
const enabled = siteConfig?.TuneHubEnabled ?? false;
const baseUrl =
siteConfig?.TuneHubBaseUrl ||
process.env.TUNEHUB_BASE_URL ||
'https://tunehub.sayqz.com/api';
const apiKey = siteConfig?.TuneHubApiKey || process.env.TUNEHUB_API_KEY || '';
return { enabled, baseUrl, apiKey };
}
// 通用请求处理函数
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 {
// 创建一个函数来执行表达式,传入所有变量作为参数
// 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) {
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 }
);
}
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: quality || '320k',
}),
});
const data = await response.json();
console.log('TuneHub 解析响应:', data);
// 如果 TuneHub 返回错误,包装成统一格式
if (!response.ok || data.code !== 0) {
return NextResponse.json({
code: data.code || -1,
message: data.message || data.error || '解析失败',
error: data.error || data.message || '解析失败',
});
}
return NextResponse.json(data);
} 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 }
);
}
}

View File

@@ -86,6 +86,7 @@ export default async function RootLayout({
let enableMovieRequest = true;
let webLiveEnabled = false;
let customAdFilterVersion = 0;
let tuneHubEnabled = false;
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
@@ -134,6 +135,8 @@ export default async function RootLayout({
webLiveEnabled = config.WebLiveEnabled ?? false;
// 自定义去广告代码版本号
customAdFilterVersion = config.SiteConfig?.CustomAdFilterVersion || 0;
// TuneHub音乐功能配置
tuneHubEnabled = config.SiteConfig?.TuneHubEnabled || false;
// 检查是否启用了 OpenList 功能
openListEnabled = !!(
config.OpenListConfig?.Enabled &&
@@ -191,6 +194,7 @@ export default async function RootLayout({
ENABLE_MOVIE_REQUEST: enableMovieRequest,
WEB_LIVE_ENABLED: webLiveEnabled,
CUSTOM_AD_FILTER_VERSION: customAdFilterVersion,
TUNEHUB_ENABLED: tuneHubEnabled,
FESTIVE_EFFECT_ENABLED:
process.env.FESTIVE_EFFECT_ENABLED === 'true',
};

1635
src/app/music/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
'use client';
import { Bot, ChevronRight, Link as LinkIcon, ListVideo } from 'lucide-react';
import { Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
@@ -66,6 +66,7 @@ function HomeClient() {
const [aiEnabled, setAiEnabled] = useState(false);
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好我是MoonTVPlus的AI影视助手。想看什么电影或剧集需要推荐吗');
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
const [musicEnabled, setMusicEnabled] = useState(false);
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
const [directPlayUrl, setDirectPlayUrl] = useState('');
@@ -148,6 +149,14 @@ function HomeClient() {
}
}, []);
// 检查音乐功能是否启用
useEffect(() => {
if (typeof window !== 'undefined') {
const enabled = (window as any).RUNTIME_CONFIG?.TUNEHUB_ENABLED === true;
setMusicEnabled(enabled);
}
}, []);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
@@ -593,6 +602,18 @@ function HomeClient() {
<LinkIcon size={18} />
</button>
{/* 音乐视听入口 */}
{musicEnabled && (
<Link href='/music'>
<button
className='p-2 rounded-lg text-green-500 hover:text-green-600 transition-colors'
title='音乐视听'
>
<Music size={20} />
</button>
</Link>
)}
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
<Link href='/source-search'>

View File

@@ -56,6 +56,10 @@ export interface AdminConfig {
OIDCClientSecret?: string; // OIDC Client Secret
OIDCButtonText?: string; // OIDC登录按钮文字
OIDCMinTrustLevel?: number; // 最低信任等级仅LinuxDo网站有效为0时不判断
// TuneHub音乐配置
TuneHubEnabled?: boolean; // 启用音乐功能
TuneHubBaseUrl?: string; // TuneHub API地址
TuneHubApiKey?: string; // TuneHub API Key
};
UserConfig: {
Users: {

View File

@@ -288,6 +288,123 @@ export class D1Storage implements IStorage {
}
}
// ==================== 音乐播放记录相关 ====================
async getMusicPlayRecord(userName: string, key: string): Promise<any | null> {
try {
const result = await this.db
.prepare('SELECT * FROM music_play_records WHERE username = ? AND key = ?')
.bind(userName, key)
.first();
if (!result) return null;
return {
platform: result.platform,
id: result.song_id,
name: result.name,
artist: result.artist,
album: result.album || undefined,
pic: result.pic || undefined,
play_time: result.play_time,
duration: result.duration,
save_time: result.save_time,
};
} catch (err) {
console.error('D1Storage.getMusicPlayRecord error:', err);
return null;
}
}
async setMusicPlayRecord(userName: string, key: string, record: any): Promise<void> {
try {
await this.db
.prepare(`
INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, key) DO UPDATE SET
name = excluded.name,
artist = excluded.artist,
album = excluded.album,
pic = excluded.pic,
play_time = excluded.play_time,
duration = excluded.duration,
save_time = excluded.save_time
`)
.bind(
userName,
key,
record.platform,
record.id,
record.name,
record.artist,
record.album || null,
record.pic || null,
record.play_time,
record.duration,
record.save_time
)
.run();
} catch (err) {
console.error('D1Storage.setMusicPlayRecord error:', err);
throw err;
}
}
async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> {
try {
const results = await this.db
.prepare('SELECT * FROM music_play_records WHERE username = ? ORDER BY save_time DESC')
.bind(userName)
.all();
const records: { [key: string]: any } = {};
if (results.results) {
for (const row of results.results) {
records[row.key as string] = {
platform: row.platform,
id: row.song_id,
name: row.name,
artist: row.artist,
album: row.album || undefined,
pic: row.pic || undefined,
play_time: row.play_time,
duration: row.duration,
save_time: row.save_time,
};
}
}
return records;
} catch (err) {
console.error('D1Storage.getAllMusicPlayRecords error:', err);
throw err;
}
}
async deleteMusicPlayRecord(userName: string, key: string): Promise<void> {
try {
await this.db
.prepare('DELETE FROM music_play_records WHERE username = ? AND key = ?')
.bind(userName, key)
.run();
} catch (err) {
console.error('D1Storage.deleteMusicPlayRecord error:', err);
throw err;
}
}
async clearAllMusicPlayRecords(userName: string): Promise<void> {
try {
await this.db
.prepare('DELETE FROM music_play_records WHERE username = ?')
.bind(userName)
.run();
} catch (err) {
console.error('D1Storage.clearAllMusicPlayRecords error:', err);
throw err;
}
}
// ==================== 辅助方法 ====================
private rowToPlayRecord(row: any): PlayRecord {

View File

@@ -57,6 +57,19 @@ export interface Favorite {
vod_remarks?: string; // 视频备注信息
}
// ---- 音乐播放记录类型 ----
export interface MusicPlayRecord {
platform: 'netease' | 'qq' | 'kuwo'; // 音乐平台
id: string; // 歌曲ID
name: string; // 歌曲名
artist: string; // 艺术家
album?: string; // 专辑
pic?: string; // 封面图
play_time: number; // 播放进度(秒)
duration: number; // 总时长(秒)
save_time: number; // 记录保存时间(时间戳)
}
// ---- 缓存数据结构 ----
interface CacheData<T> {
data: T;
@@ -70,12 +83,14 @@ interface UserCacheStore {
searchHistory?: CacheData<string[]>;
skipConfigs?: CacheData<Record<string, SkipConfig>>;
danmakuFilterConfig?: CacheData<DanmakuFilterConfig>;
musicPlayRecords?: CacheData<Record<string, MusicPlayRecord>>; // 音乐播放记录
}
// ---- 常量 ----
const PLAY_RECORDS_KEY = 'moontv_play_records';
const FAVORITES_KEY = 'moontv_favorites';
const SEARCH_HISTORY_KEY = 'moontv_search_history';
const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records';
// 缓存相关常量
const CACHE_PREFIX = 'moontv_cache_';
@@ -398,6 +413,32 @@ class HybridCacheManager {
this.saveUserCache(username, userCache);
}
/**
* 音乐播放记录缓存方法
*/
getCachedMusicPlayRecords(): Record<string, MusicPlayRecord> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.musicPlayRecords;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
cacheMusicPlayRecords(data: Record<string, MusicPlayRecord>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.musicPlayRecords = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 清除指定用户的所有缓存
*/
@@ -1888,6 +1929,236 @@ export async function saveDanmakuFilterConfig(
}
}
// ---------------- 音乐播放记录相关 API ----------------
/**
* 获取全部音乐播放记录。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/
export async function getAllMusicPlayRecords(): Promise<Record<string, MusicPlayRecord>> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return {};
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedMusicPlayRecords();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, MusicPlayRecord>>(`/api/music/playrecords`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheMusicPlayRecords(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('musicPlayRecordsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步音乐播放记录失败:', err);
triggerGlobalError('后台同步音乐播放记录失败');
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, MusicPlayRecord>>(
`/api/music/playrecords`
);
cacheManager.cacheMusicPlayRecords(freshData);
return freshData;
} catch (err) {
console.error('获取音乐播放记录失败:', err);
triggerGlobalError('获取音乐播放记录失败');
return {};
}
}
}
// localstorage 模式
try {
const raw = localStorage.getItem(MUSIC_PLAY_RECORDS_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, MusicPlayRecord>;
} catch (err) {
console.error('读取音乐播放记录失败:', err);
triggerGlobalError('读取音乐播放记录失败');
return {};
}
}
/**
* 保存音乐播放记录。
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
*/
export async function saveMusicPlayRecord(
platform: string,
id: string,
record: MusicPlayRecord
): Promise<void> {
const key = generateStorageKey(platform, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedMusicPlayRecords() || {};
cachedRecords[key] = record;
cacheManager.cacheMusicPlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('musicPlayRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/music/playrecords', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, record }),
});
} catch (err) {
console.error('保存音乐播放记录失败:', err);
triggerGlobalError('保存音乐播放记录失败');
throw err;
}
return;
}
// localstorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端保存音乐播放记录到 localStorage');
return;
}
try {
const allRecords = await getAllMusicPlayRecords();
allRecords[key] = record;
localStorage.setItem(MUSIC_PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('musicPlayRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) {
console.error('保存音乐播放记录失败:', err);
triggerGlobalError('保存音乐播放记录失败');
throw err;
}
}
/**
* 删除音乐播放记录。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function deleteMusicPlayRecord(
platform: string,
id: string
): Promise<void> {
const key = generateStorageKey(platform, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedMusicPlayRecords() || {};
delete cachedRecords[key];
cacheManager.cacheMusicPlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('musicPlayRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/music/playrecords?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
console.error('删除音乐播放记录失败:', err);
triggerGlobalError('删除音乐播放记录失败');
throw err;
}
return;
}
// localstorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端删除音乐播放记录到 localStorage');
return;
}
try {
const allRecords = await getAllMusicPlayRecords();
delete allRecords[key];
localStorage.setItem(MUSIC_PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('musicPlayRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) {
console.error('删除音乐播放记录失败:', err);
triggerGlobalError('删除音乐播放记录失败');
throw err;
}
}
/**
* 清空全部音乐播放记录
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function clearAllMusicPlayRecords(): Promise<void> {
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cacheMusicPlayRecords({});
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('musicPlayRecordsUpdated', {
detail: {},
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/music/playrecords`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
console.error('清空音乐播放记录失败:', err);
triggerGlobalError('清空音乐播放记录失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
localStorage.removeItem(MUSIC_PLAY_RECORDS_KEY);
window.dispatchEvent(
new CustomEvent('musicPlayRecordsUpdated', {
detail: {},
})
);
}
// ---------------- 集数过滤配置相关 API ----------------
/**

View File

@@ -1,6 +1,7 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { AdminConfig } from './admin.types';
import { MusicPlayRecord } from './db.client';
import { KvrocksStorage } from './kvrocks.db';
import { RedisStorage } from './redis.db';
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
@@ -199,7 +200,37 @@ export class DbManager {
return favorite !== null;
}
// 音乐播放记录相关方法
async saveMusicPlayRecord(
userName: string,
platform: string,
id: string,
record: MusicPlayRecord
): Promise<void> {
const key = generateStorageKey(platform, id);
await this.storage.setMusicPlayRecord(userName, key, record);
}
async getAllMusicPlayRecords(userName: string): Promise<{
[key: string]: MusicPlayRecord;
}> {
return this.storage.getAllMusicPlayRecords(userName);
}
async deleteMusicPlayRecord(
userName: string,
platform: string,
id: string
): Promise<void> {
const key = generateStorageKey(platform, id);
await this.storage.deleteMusicPlayRecord(userName, key);
}
async clearAllMusicPlayRecords(userName: string): Promise<void> {
await this.storage.clearAllMusicPlayRecords(userName);
}
async verifyUser(userName: string, password: string): Promise<boolean> {
return this.storage.verifyUser(userName, password);
}

169
src/lib/music-song-cache.ts Normal file
View File

@@ -0,0 +1,169 @@
// 音乐歌曲信息缓存模块 - 基于 platform+id 的全局缓存
// 歌曲信息接口
export interface SongInfo {
id: string;
name: string;
artist: string;
album?: string;
pic?: string;
}
// 缓存条目接口
export interface SongCacheEntry {
expiresAt: number;
data: SongInfo;
}
// 缓存配置
const SONG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24小时
const CACHE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1小时清理一次
const MAX_CACHE_SIZE = 5000; // 最大缓存条目数量
const SONG_CACHE: Map<string, SongCacheEntry> = new Map();
// 惰性清理时间戳
let lastCleanupTime = 0;
/**
* 生成歌曲缓存键platform+id
*/
function makeSongCacheKey(platform: string, id: string): string {
return `${platform}+${id}`;
}
/**
* 获取缓存的歌曲信息
*/
export function getCachedSong(platform: string, id: string): SongInfo | null {
const key = makeSongCacheKey(platform, id);
const entry = SONG_CACHE.get(key);
if (!entry) return null;
// 检查是否过期
if (entry.expiresAt <= Date.now()) {
SONG_CACHE.delete(key);
return null;
}
return entry.data;
}
/**
* 设置缓存的歌曲信息
*/
export function setCachedSong(platform: string, id: string, songInfo: SongInfo): void {
// 惰性清理:每次写入时检查是否需要清理
const now = Date.now();
if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) {
performCacheCleanup();
}
const key = makeSongCacheKey(platform, id);
SONG_CACHE.set(key, {
expiresAt: now + SONG_CACHE_TTL_MS,
data: songInfo,
});
}
/**
* 批量获取缓存的歌曲信息
*/
export function getCachedSongs(keys: Array<{ platform: string; id: string }>): Map<string, SongInfo> {
const result = new Map<string, SongInfo>();
const now = Date.now();
for (const { platform, id } of keys) {
const key = makeSongCacheKey(platform, id);
const entry = SONG_CACHE.get(key);
if (entry && entry.expiresAt > now) {
result.set(key, entry.data);
}
}
return result;
}
/**
* 批量设置缓存的歌曲信息
*/
export function setCachedSongs(songs: Array<{ platform: string; id: string; songInfo: SongInfo }>): void {
const now = Date.now();
// 惰性清理
if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) {
performCacheCleanup();
}
for (const { platform, id, songInfo } of songs) {
const key = makeSongCacheKey(platform, id);
SONG_CACHE.set(key, {
expiresAt: now + SONG_CACHE_TTL_MS,
data: songInfo,
});
}
}
/**
* 智能清理过期的缓存条目
*/
function performCacheCleanup(): { expired: number; total: number; sizeLimited: number } {
const now = Date.now();
const keysToDelete: string[] = [];
let sizeLimitedDeleted = 0;
// 1. 清理过期条目
SONG_CACHE.forEach((entry, key) => {
if (entry.expiresAt <= now) {
keysToDelete.push(key);
}
});
const expiredCount = keysToDelete.length;
keysToDelete.forEach(key => SONG_CACHE.delete(key));
// 2. 如果缓存大小超限清理最老的条目LRU策略
if (SONG_CACHE.size > MAX_CACHE_SIZE) {
const entries = Array.from(SONG_CACHE.entries());
// 按照过期时间排序,最早过期的在前面
entries.sort((a, b) => a[1].expiresAt - b[1].expiresAt);
const toRemove = SONG_CACHE.size - MAX_CACHE_SIZE;
for (let i = 0; i < toRemove; i++) {
SONG_CACHE.delete(entries[i][0]);
sizeLimitedDeleted++;
}
}
lastCleanupTime = now;
return {
expired: expiredCount,
total: SONG_CACHE.size,
sizeLimited: sizeLimitedDeleted
};
}
/**
* 清除所有歌曲缓存
*/
export function clearSongCache(): { cleared: number } {
const size = SONG_CACHE.size;
SONG_CACHE.clear();
return { cleared: size };
}
/**
* 获取缓存统计信息
*/
export function getSongCacheStats(): {
size: number;
maxSize: number;
ttlMs: number;
} {
return {
size: SONG_CACHE.size,
maxSize: MAX_CACHE_SIZE,
ttlMs: SONG_CACHE_TTL_MS,
};
}

View File

@@ -515,6 +515,54 @@ export abstract class BaseRedisStorage implements IStorage {
console.log(`用户 ${userName} 的收藏迁移完成`);
}
// ---------- 音乐播放记录相关 ----------
private musicPlayRecordHashKey(userName: string) {
return `u:${userName}:music_play_records`;
}
async getMusicPlayRecord(userName: string, key: string): Promise<any | null> {
const value = await this.withRetry(() =>
this.adapter.hGet(this.musicPlayRecordHashKey(userName), key)
);
return value ? JSON.parse(value) : null;
}
async setMusicPlayRecord(userName: string, key: string, record: any): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(
this.musicPlayRecordHashKey(userName),
key,
JSON.stringify(record)
)
);
}
async getAllMusicPlayRecords(userName: string): Promise<Record<string, any>> {
const hashData = await this.withRetry(() =>
this.adapter.hGetAll(this.musicPlayRecordHashKey(userName))
);
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(hashData)) {
if (value) {
result[key] = JSON.parse(value);
}
}
return result;
}
async deleteMusicPlayRecord(userName: string, key: string): Promise<void> {
await this.withRetry(() =>
this.adapter.hDel(this.musicPlayRecordHashKey(userName), key)
);
}
async clearAllMusicPlayRecords(userName: string): Promise<void> {
await this.withRetry(() =>
this.adapter.del(this.musicPlayRecordHashKey(userName))
);
}
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
private userPwdKey(user: string) {
return `u:${user}:pwd`;

View File

@@ -52,6 +52,13 @@ export interface IStorage {
// 迁移收藏
migrateFavorites(userName: string): Promise<void>;
// 音乐播放记录相关
getMusicPlayRecord(userName: string, key: string): Promise<any | null>;
setMusicPlayRecord(userName: string, key: string, record: any): Promise<void>;
getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }>;
deleteMusicPlayRecord(userName: string, key: string): Promise<void>;
clearAllMusicPlayRecords(userName: string): Promise<void>;
// 用户相关
verifyUser(userName: string, password: string): Promise<boolean>;
// 检查用户是否存在(无需密码)