新增基于lxserver的音乐功能

This commit is contained in:
mtvpls
2026-04-13 23:17:57 +08:00
parent 7469cc1537
commit c82d7b3841
30 changed files with 2740 additions and 546 deletions
+10 -62
View File
@@ -1,66 +1,14 @@
-- ============================================
-- MoonTV Plus - 音乐模块数据表
-- 版本: 1.2.0
-- MoonTV Plus - 历史音乐 V1 迁移已废弃
-- 保留文件编号仅用于兼容旧的迁移顺序
-- 创建时间: 2026-02-01
-- 更新时间: 2026-02-02
-- 更新时间: 2026-04-13
-- ============================================
-- 音乐播放记录表
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);
-- ============================================
-- 音乐歌单表
-- ============================================
-- 音乐歌单表
CREATE TABLE IF NOT EXISTS music_playlists (
id TEXT NOT NULL, -- 歌单ID (UUID)
username TEXT NOT NULL, -- 用户名
name TEXT NOT NULL, -- 歌单名称
description TEXT, -- 歌单描述(可选)
cover TEXT, -- 歌单封面(可选,使用第一首歌的封面)
created_at INTEGER NOT NULL, -- 创建时间戳
updated_at INTEGER NOT NULL, -- 更新时间戳
PRIMARY KEY (id),
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
-- 音乐歌单歌曲关联表
CREATE TABLE IF NOT EXISTS music_playlist_songs (
playlist_id TEXT NOT NULL, -- 歌单ID
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(可选)
duration REAL NOT NULL DEFAULT 0, -- 总时长(秒)
added_at INTEGER NOT NULL, -- 添加时间戳
sort_order INTEGER NOT NULL DEFAULT 0, -- 排序顺序
PRIMARY KEY (playlist_id, platform, song_id),
FOREIGN KEY (playlist_id) REFERENCES music_playlists(id) ON DELETE CASCADE
);
-- 创建索引以提高查询性能
CREATE INDEX IF NOT EXISTS idx_music_playlists_username ON music_playlists(username, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_playlist ON music_playlist_songs(playlist_id, sort_order ASC);
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_added_at ON music_playlist_songs(playlist_id, added_at DESC);
-- Music V1 已下线。
-- 新安装实例不再创建以下旧表:
-- - music_play_records
-- - music_playlists
-- - music_playlist_songs
--
-- 现已由 005_music_v2.sql 中的 music_v2_* 表替代。
+58
View File
@@ -0,0 +1,58 @@
-- Music V2 schema
CREATE TABLE IF NOT EXISTS music_v2_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
song_id TEXT NOT NULL,
source TEXT NOT NULL,
songmid TEXT,
name TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
cover TEXT,
duration_text TEXT,
duration_sec REAL,
play_progress_sec REAL NOT NULL DEFAULT 0,
last_played_at INTEGER NOT NULL,
play_count INTEGER NOT NULL DEFAULT 0,
last_quality TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(username, song_id),
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_music_v2_history_username ON music_v2_history(username, last_played_at DESC);
CREATE TABLE IF NOT EXISTS music_v2_playlists (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
cover TEXT,
song_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_music_v2_playlists_username ON music_v2_playlists(username, updated_at DESC);
CREATE TABLE IF NOT EXISTS music_v2_playlist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
username TEXT NOT NULL,
song_id TEXT NOT NULL,
source TEXT NOT NULL,
songmid TEXT,
name TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
cover TEXT,
duration_text TEXT,
duration_sec REAL,
sort_order INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(playlist_id, song_id),
FOREIGN KEY (playlist_id) REFERENCES music_v2_playlists(id) ON DELETE CASCADE,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_music_v2_playlist_items_playlist ON music_v2_playlist_items(playlist_id, sort_order ASC);
+10 -61
View File
@@ -1,65 +1,14 @@
-- ============================================
-- MoonTV Plus - 音乐模块数据表 (PostgreSQL)
-- 版本: 1.2.0
-- MoonTV Plus - 历史音乐 V1 迁移已废弃 (PostgreSQL)
-- 保留文件编号仅用于兼容旧的迁移顺序
-- 创建时间: 2026-02-08
-- 更新时间: 2026-04-13
-- ============================================
-- 音乐播放记录表
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 BIGINT 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);
-- ============================================
-- 音乐歌单表
-- ============================================
-- 音乐歌单表
CREATE TABLE IF NOT EXISTS music_playlists (
id TEXT NOT NULL, -- 歌单ID (UUID)
username TEXT NOT NULL, -- 用户名
name TEXT NOT NULL, -- 歌单名称
description TEXT, -- 歌单描述(可选)
cover TEXT, -- 歌单封面(可选,使用第一首歌的封面)
created_at BIGINT NOT NULL, -- 创建时间戳
updated_at BIGINT NOT NULL, -- 更新时间戳
PRIMARY KEY (id),
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
-- 音乐歌单歌曲关联表
CREATE TABLE IF NOT EXISTS music_playlist_songs (
playlist_id TEXT NOT NULL, -- 歌单ID
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(可选)
duration REAL NOT NULL DEFAULT 0, -- 总时长(秒)
added_at BIGINT NOT NULL, -- 添加时间戳
sort_order INTEGER NOT NULL DEFAULT 0, -- 排序顺序
PRIMARY KEY (playlist_id, platform, song_id),
FOREIGN KEY (playlist_id) REFERENCES music_playlists(id) ON DELETE CASCADE
);
-- 创建索引以提高查询性能
CREATE INDEX IF NOT EXISTS idx_music_playlists_username ON music_playlists(username, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_playlist ON music_playlist_songs(playlist_id, sort_order ASC);
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_added_at ON music_playlist_songs(playlist_id, added_at DESC);
-- Music V1 已下线。
-- 新安装实例不再创建以下旧表:
-- - music_play_records
-- - music_playlists
-- - music_playlist_songs
--
-- 现已由 005_music_v2.sql 中的 music_v2_* 表替代。
+58
View File
@@ -0,0 +1,58 @@
-- Music V2 schema
CREATE TABLE IF NOT EXISTS music_v2_history (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
song_id TEXT NOT NULL,
source TEXT NOT NULL,
songmid TEXT,
name TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
cover TEXT,
duration_text TEXT,
duration_sec DOUBLE PRECISION,
play_progress_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
last_played_at BIGINT NOT NULL,
play_count INTEGER NOT NULL DEFAULT 0,
last_quality TEXT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE(username, song_id),
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_music_v2_history_username ON music_v2_history(username, last_played_at DESC);
CREATE TABLE IF NOT EXISTS music_v2_playlists (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
cover TEXT,
song_count INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_music_v2_playlists_username ON music_v2_playlists(username, updated_at DESC);
CREATE TABLE IF NOT EXISTS music_v2_playlist_items (
id BIGSERIAL PRIMARY KEY,
playlist_id TEXT NOT NULL,
username TEXT NOT NULL,
song_id TEXT NOT NULL,
source TEXT NOT NULL,
songmid TEXT,
name TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
cover TEXT,
duration_text TEXT,
duration_sec DOUBLE PRECISION,
sort_order INTEGER NOT NULL DEFAULT 0,
added_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE(playlist_id, song_id),
FOREIGN KEY (playlist_id) REFERENCES music_v2_playlists(id) ON DELETE CASCADE,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_music_v2_playlist_items_playlist ON music_v2_playlist_items(playlist_id, sort_order ASC);
+186
View File
@@ -11524,6 +11524,166 @@ const AIConfigComponent = ({
);
};
// 音乐配置组件
const MusicConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [enabled, setEnabled] = useState(false);
const [baseUrl, setBaseUrl] = useState('');
const [token, setToken] = useState('');
useEffect(() => {
if (config?.MusicConfig) {
setEnabled(config.MusicConfig.Enabled || false);
setBaseUrl(config.MusicConfig.BaseUrl || '');
setToken(config.MusicConfig.Token || '');
}
}, [config]);
const handleSave = async () => {
await withLoading('saveMusicConfig', async () => {
try {
const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, '');
if (enabled && !normalizedBaseUrl) {
throw new Error('启用音乐功能时必须填写 lxserver 地址');
}
const response = await fetch('/api/admin/music', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Enabled: enabled,
BaseUrl: normalizedBaseUrl,
Token: token.trim(),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '保存失败');
}
showSuccess('音乐配置保存成功', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
throw error;
}
});
};
return (
<div className='space-y-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center gap-2 mb-2'>
<svg
className='w-5 h-5 text-blue-600 dark:text-blue-400'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'
/>
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
使
</span>
</div>
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
<p> lxserver </p>
<p> Base URL Token MoonTV 访 lxserver</p>
<p> <a href='https://github.com/XCQ0607/lxserver' target='_blank' rel='noreferrer' className='underline hover:text-blue-500'>https://github.com/XCQ0607/lxserver</a></p>
</div>
</div>
<div className='flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='relative inline-flex items-center cursor-pointer'>
<input
type='checkbox'
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className='sr-only peer'
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-green-300 dark:peer-focus:ring-green-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:start-[4px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all dark:border-gray-600 peer-checked:bg-green-600"></div>
</label>
</div>
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
lxserver Base URL
</label>
<input
type='text'
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder='http://127.0.0.1:9527'
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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
http://127.0.0.1:9527 或 https://music.example.com
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
x-user-token
</label>
<input
type='password'
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder='lx_tk_xxx'
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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
lxserver Token访
</p>
</div>
</div>
<div className='flex justify-end'>
<button
onClick={handleSave}
disabled={isLoading('saveMusicConfig')}
className={isLoading('saveMusicConfig') ? buttonStyles.disabled : buttonStyles.success}
>
{isLoading('saveMusicConfig') ? '保存中...' : '保存音乐配置'}
</button>
</div>
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
// 直播源配置组件
const LiveSourceConfig = ({
config,
@@ -12602,6 +12762,7 @@ function AdminPageClient() {
userConfig: false,
videoSource: false,
sourceScriptLab: false,
musicConfig: false,
mediaLibrary: false,
openListConfig: false,
netDiskConfig: false,
@@ -12973,6 +13134,31 @@ function AdminPageClient() {
<VideoSourceScriptLab />
</CollapsibleTab>
<CollapsibleTab
title='音乐配置'
icon={
<svg
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='text-gray-600 dark:text-gray-400'
>
<path d='M9 18V5l12-2v13' />
<circle cx='6' cy='18' r='3' />
<circle cx='18' cy='16' r='3' />
</svg>
}
isExpanded={expandedTabs.musicConfig}
onToggle={() => toggleTab('musicConfig')}
>
<MusicConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 电视直播源配置标签 */}
<CollapsibleTab
title='电视直播源配置'
+12 -36
View File
@@ -29,38 +29,20 @@ export async function POST(request: NextRequest) {
const username = authInfo.username;
const {
TuneHubEnabled,
TuneHubBaseUrl,
TuneHubApiKey,
OpenListCacheEnabled,
OpenListCacheURL,
OpenListCacheUsername,
OpenListCachePassword,
OpenListCachePath,
OpenListCacheProxyEnabled,
Enabled,
BaseUrl,
Token,
} = body as {
TuneHubEnabled?: boolean;
TuneHubBaseUrl?: string;
TuneHubApiKey?: string;
OpenListCacheEnabled?: boolean;
OpenListCacheURL?: string;
OpenListCacheUsername?: string;
OpenListCachePassword?: string;
OpenListCachePath?: string;
OpenListCacheProxyEnabled?: boolean;
Enabled?: boolean;
BaseUrl?: string;
Token?: string;
};
// 参数校验
if (
(TuneHubEnabled !== undefined && typeof TuneHubEnabled !== 'boolean') ||
(TuneHubBaseUrl !== undefined && typeof TuneHubBaseUrl !== 'string') ||
(TuneHubApiKey !== undefined && typeof TuneHubApiKey !== 'string') ||
(OpenListCacheEnabled !== undefined && typeof OpenListCacheEnabled !== 'boolean') ||
(OpenListCacheURL !== undefined && typeof OpenListCacheURL !== 'string') ||
(OpenListCacheUsername !== undefined && typeof OpenListCacheUsername !== 'string') ||
(OpenListCachePassword !== undefined && typeof OpenListCachePassword !== 'string') ||
(OpenListCachePath !== undefined && typeof OpenListCachePath !== 'string') ||
(OpenListCacheProxyEnabled !== undefined && typeof OpenListCacheProxyEnabled !== 'boolean')
(Enabled !== undefined && typeof Enabled !== 'boolean') ||
(BaseUrl !== undefined && typeof BaseUrl !== 'string') ||
(Token !== undefined && typeof Token !== 'string')
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -77,15 +59,9 @@ export async function POST(request: NextRequest) {
// 更新缓存中的音乐配置
adminConfig.MusicConfig = {
TuneHubEnabled,
TuneHubBaseUrl,
TuneHubApiKey,
OpenListCacheEnabled,
OpenListCacheURL,
OpenListCacheUsername,
OpenListCachePassword,
OpenListCachePath,
OpenListCacheProxyEnabled,
Enabled,
BaseUrl,
Token,
};
// 写入数据库
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { isMusicSource, lxGetJson, normalizeLxSong, unwrapLxArray } from '@/lib/music-v2';
import { badRequest, internalError } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const source = searchParams.get('source') || 'kw';
const boardId = searchParams.get('boardId') || searchParams.get('bangid') || '';
const page = Number(searchParams.get('page') || '1');
if (!isMusicSource(source)) return badRequest('不支持的音源');
if (!boardId) return badRequest('缺少榜单 ID');
const payload = await lxGetJson<any>(`/api/music/leaderboard/list?source=${source}&bangid=${encodeURIComponent(boardId)}&page=${page}`, 'none');
const list = unwrapLxArray<any>(payload);
const total =
payload?.total ??
payload?.data?.total ??
payload?.data?.data?.total ??
list.length;
return NextResponse.json({
success: true,
data: {
board: { id: boardId },
list: list.map(normalizeLxSong),
total,
page,
},
});
} catch (error) {
return internalError('获取榜单歌曲失败', (error as Error).message);
}
}
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { isMusicSource, lxGetJson, unwrapLxArray } from '@/lib/music-v2';
import { badRequest, internalError } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const source = searchParams.get('source') || 'kw';
if (!isMusicSource(source)) return badRequest('不支持的音源');
const fallbackSources = [source, 'kg', 'kw', 'tx', 'wy', 'mg'].filter(
(item, index, arr) => arr.indexOf(item) === index
);
let actualSource = source as typeof source;
let list: Array<{ id?: string; bangid?: string; name: string; img?: string }> = [];
const errors: string[] = [];
for (const candidate of fallbackSources) {
try {
const candidatePayload = await lxGetJson<any>(
`/api/music/leaderboard/boards?source=${candidate}`,
'none'
);
const candidateList = unwrapLxArray<{ id?: string; bangid?: string; name: string; img?: string }>(candidatePayload);
if (Array.isArray(candidateList) && candidateList.length > 0) {
actualSource = candidate as typeof source;
list = candidateList;
break;
}
} catch (error) {
const message = (error as Error).message;
errors.push(`${candidate}: ${message}`);
console.error(`[music-v2] 获取榜单源失败: ${candidate}`, error);
}
}
return NextResponse.json({
success: true,
data: {
list: list.map(item => ({
id: item.bangid || item.id || '',
name: item.name,
cover: item.img,
source: actualSource,
})),
source: actualSource,
errors,
},
});
} catch (error) {
console.error('[music-v2] 获取榜单失败:', error);
return internalError('获取榜单失败', (error as Error).message);
}
}
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { isMusicSource, lxGetJson } from '@/lib/music-v2';
import { badRequest, internalError } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const source = searchParams.get('source') || 'mg';
if (!isMusicSource(source)) return badRequest('不支持的音源');
const list = await lxGetJson<Array<{ name: string; singer?: string; source: string }>>(`/api/music/hotSearch?source=${source}`, 'none');
return NextResponse.json({
success: true,
data: {
list: list.map(item => ({
keyword: item.name,
name: item.name,
artist: item.singer || '',
source: item.source,
})),
},
});
} catch (error) {
return internalError('获取热搜失败', (error as Error).message);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { MusicV2HistoryRecord, normalizeSong } from '@/lib/music-v2';
import { badRequest, getMusicV2Username, internalError, unauthorized } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
function toHistoryRecord(input: any, previous?: MusicV2HistoryRecord): MusicV2HistoryRecord {
const song = normalizeSong(input.song || input);
const now = Date.now();
return {
...song,
playProgressSec: Number(input.playProgressSec ?? input.play_progress_sec ?? previous?.playProgressSec ?? 0),
lastPlayedAt: Number(input.lastPlayedAt ?? input.last_played_at ?? now),
playCount: Number(input.playCount ?? input.play_count ?? ((previous?.playCount || 0) + 1)),
lastQuality: input.lastQuality || input.last_quality || previous?.lastQuality,
createdAt: Number(input.createdAt ?? input.created_at ?? previous?.createdAt ?? now),
updatedAt: now,
};
}
export async function GET(request: NextRequest) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const records = await db.listMusicV2History(username);
return NextResponse.json({ success: true, data: { records } });
} catch (error) {
return internalError('获取播放历史失败', (error as Error).message);
}
}
export async function POST(request: NextRequest) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const body = await request.json();
const existingRecords = await db.listMusicV2History(username);
const existingMap = new Map(existingRecords.map(record => [record.songId, record]));
if (Array.isArray(body.records)) {
const records = body.records
.map((item: any) => toHistoryRecord(item, existingMap.get(item.song?.songId || item.songId)))
.filter((item: MusicV2HistoryRecord) => item.songId && item.source && item.name && item.artist);
await db.batchUpsertMusicV2History(username, records);
return NextResponse.json({ success: true, data: { count: records.length } });
}
const record = toHistoryRecord(body.record || body, existingMap.get(body.song?.songId || body.songId));
if (!record.songId || !record.source || !record.name || !record.artist) {
return badRequest('历史记录数据不完整');
}
await db.upsertMusicV2History(username, record);
return NextResponse.json({ success: true, data: { record } });
} catch (error) {
return internalError('保存播放历史失败', (error as Error).message);
}
}
export async function DELETE(request: NextRequest) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const songId = searchParams.get('songId');
if (songId) {
await db.deleteMusicV2History(username, songId);
} else {
await db.clearMusicV2History(username);
}
return NextResponse.json({ success: true });
} catch (error) {
return internalError('删除播放历史失败', (error as Error).message);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchLxLyric, normalizeSong } from '@/lib/music-v2';
import { badRequest, internalError } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const song = normalizeSong(body?.song || body?.songInfo || {});
if (!song.source || !song.songId) {
return badRequest(`歌曲信息不完整: songId=${song.songId || ''}, source=${song.source || ''}`);
}
const data = await fetchLxLyric(song);
return NextResponse.json({ success: true, data: { lyric: data.lyric || '', tlyric: data.tlyric || '' } });
} catch (error) {
console.error('[music-v2] lyric route error:', error);
return internalError('获取歌词失败', (error as Error).message);
}
}
+165
View File
@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from 'next/server';
import { extractSongmid, fetchLxLyric, MusicQuality, normalizeMusicQuality, normalizeSong, lxPostJson } from '@/lib/music-v2';
import { badRequest, internalError } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
const PLAY_META_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
type PlayMetaPayload = {
song: ReturnType<typeof normalizeSong>;
lyric: {
lyric?: string;
tlyric?: string;
};
meta: {
attempts: any[];
};
};
const globalMusicPlayMetaCache = globalThis as typeof globalThis & {
__musicV2PlayMetaCache?: Map<string, { expiresAt: number; payload: PlayMetaPayload }>;
};
const playMetaCache = globalMusicPlayMetaCache.__musicV2PlayMetaCache ?? new Map<string, { expiresAt: number; payload: PlayMetaPayload }>();
globalMusicPlayMetaCache.__musicV2PlayMetaCache = playMetaCache;
function buildStableStreamUrl(song: ReturnType<typeof normalizeSong>, quality: string) {
const params = new URLSearchParams({
songId: song.songId,
source: song.source,
quality,
songmid: extractSongmid(song),
name: song.name,
artist: song.artist,
});
if (song.durationText) params.set('durationText', song.durationText);
if (song.hash) params.set('hash', song.hash);
if (song.copyrightId) params.set('copyrightId', song.copyrightId);
if (song.albumId) params.set('albumId', song.albumId);
if (song.lrcUrl) params.set('lrcUrl', song.lrcUrl);
if (song.mrcUrl) params.set('mrcUrl', song.mrcUrl);
if (song.trcUrl) params.set('trcUrl', song.trcUrl);
return `/api/music/v2/stream?${params.toString()}`;
}
function getPlayMetaCacheKey(song: ReturnType<typeof normalizeSong>, quality: string) {
return `${song.source}:${song.songId}:${quality}`;
}
function getCachedPlayMeta(cacheKey: string) {
const cached = playMetaCache.get(cacheKey);
if (!cached) return null;
if (cached.expiresAt <= Date.now()) {
playMetaCache.delete(cacheKey);
return null;
}
return cached.payload;
}
function setCachedPlayMeta(cacheKey: string, payload: PlayMetaPayload) {
playMetaCache.set(cacheKey, {
expiresAt: Date.now() + PLAY_META_CACHE_TTL_MS,
payload,
});
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const requestedQuality = ((body?.quality || '320k') as MusicQuality);
const quality = normalizeMusicQuality(requestedQuality);
const includeUrl = body?.includeUrl !== false;
const song = normalizeSong(body?.song || {});
if (!song.songId || !song.source || !song.name || !song.artist) {
return badRequest(`歌曲信息不完整: songId=${song.songId || ''}, source=${song.source || ''}, name=${song.name || ''}, artist=${song.artist || ''}`);
}
const cacheKey = getPlayMetaCacheKey(song, quality);
let cachedMeta = getCachedPlayMeta(cacheKey);
if (!cachedMeta) {
let lyric: { lyric?: string; tlyric?: string } = { lyric: '', tlyric: '' };
try {
lyric = await fetchLxLyric(song);
} catch {
// ignore lyric failure
}
cachedMeta = {
song,
lyric,
meta: {
attempts: [],
},
};
setCachedPlayMeta(cacheKey, cachedMeta);
}
let play: {
url: string;
directUrl: string;
quality: string;
requestedQuality: MusicQuality;
} | undefined;
let attempts = cachedMeta.meta.attempts || [];
if (includeUrl) {
const urlResult = await lxPostJson<{ url?: string; type?: string; attempts?: any[]; error?: string }>(
'/api/music/url',
{
songInfo: {
id: song.songId,
name: song.name,
singer: song.artist,
source: song.source,
songmid: song.songmid || song.songId.split('_').slice(1).join('_'),
},
quality,
},
'auto'
);
if (!urlResult?.url) {
return NextResponse.json({
success: false,
error: {
code: 'MUSIC_PLAY_FAILED',
message: urlResult?.error || '获取播放地址失败',
},
}, { status: 502 });
}
attempts = urlResult.attempts || attempts;
play = {
url: buildStableStreamUrl(song, quality),
directUrl: urlResult.url,
quality: urlResult.type || quality,
requestedQuality,
};
}
return NextResponse.json({
success: true,
data: {
song: cachedMeta.song,
...(play ? { play } : {}),
lyric: {
lyric: cachedMeta.lyric.lyric || '',
tlyric: cachedMeta.lyric.tlyric || '',
},
meta: {
attempts,
includeUrl,
},
},
});
} catch (error) {
console.error('[music-v2] play route error:', error);
return internalError('获取播放信息失败', (error as Error).message);
}
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { badRequest, getMusicV2Username, internalError, unauthorized } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ playlistId: string }> }) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const { playlistId } = await params;
const playlist = await db.getMusicV2Playlist(playlistId);
if (!playlist) return NextResponse.json({ success: false, error: { code: 'NOT_FOUND', message: '歌单不存在' } }, { status: 404 });
if (playlist.username !== username) return NextResponse.json({ success: false, error: { code: 'FORBIDDEN', message: '无权限操作此歌单' } }, { status: 403 });
const body = await request.json();
await db.updateMusicV2Playlist(playlistId, {
name: body?.name,
description: body?.description,
cover: body?.cover,
});
const updated = await db.getMusicV2Playlist(playlistId);
return NextResponse.json({ success: true, data: { playlist: updated } });
} catch (error) {
return internalError('更新歌单失败', (error as Error).message);
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ playlistId: string }> }) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const { playlistId } = await params;
const playlist = await db.getMusicV2Playlist(playlistId);
if (!playlist) return NextResponse.json({ success: false, error: { code: 'NOT_FOUND', message: '歌单不存在' } }, { status: 404 });
if (playlist.username !== username) return NextResponse.json({ success: false, error: { code: 'FORBIDDEN', message: '无权限操作此歌单' } }, { status: 403 });
await db.deleteMusicV2Playlist(playlistId);
return NextResponse.json({ success: true });
} catch (error) {
return internalError('删除歌单失败', (error as Error).message);
}
}
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { MusicV2PlaylistItem, normalizeSong } from '@/lib/music-v2';
import { badRequest, getMusicV2Username, internalError, unauthorized } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function GET(request: NextRequest, { params }: { params: Promise<{ playlistId: string }> }) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const { playlistId } = await params;
const playlist = await db.getMusicV2Playlist(playlistId);
if (!playlist) return NextResponse.json({ success: false, error: { code: 'NOT_FOUND', message: '歌单不存在' } }, { status: 404 });
if (playlist.username !== username) return NextResponse.json({ success: false, error: { code: 'FORBIDDEN', message: '无权限访问此歌单' } }, { status: 403 });
const songs = await db.listMusicV2PlaylistItems(playlistId);
return NextResponse.json({ success: true, data: { songs } });
} catch (error) {
return internalError('获取歌单歌曲失败', (error as Error).message);
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ playlistId: string }> }) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const { playlistId } = await params;
const playlist = await db.getMusicV2Playlist(playlistId);
if (!playlist) return NextResponse.json({ success: false, error: { code: 'NOT_FOUND', message: '歌单不存在' } }, { status: 404 });
if (playlist.username !== username) return NextResponse.json({ success: false, error: { code: 'FORBIDDEN', message: '无权限操作此歌单' } }, { status: 403 });
const body = await request.json();
const song = normalizeSong(body?.song || {});
if (!song.songId || !song.source || !song.name || !song.artist) return badRequest('歌曲信息不完整');
const exists = await db.hasMusicV2PlaylistItem(playlistId, song.songId);
if (exists) return badRequest('歌曲已在歌单中', 'DUPLICATE_SONG');
const item: MusicV2PlaylistItem = {
...song,
playlistId,
sortOrder: Number(body?.sortOrder || 0),
addedAt: Date.now(),
updatedAt: Date.now(),
};
await db.addMusicV2PlaylistItem(playlistId, item);
return NextResponse.json({ success: true });
} catch (error) {
return internalError('添加歌曲失败', (error as Error).message);
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ playlistId: string }> }) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const { playlistId } = await params;
const playlist = await db.getMusicV2Playlist(playlistId);
if (!playlist) return NextResponse.json({ success: false, error: { code: 'NOT_FOUND', message: '歌单不存在' } }, { status: 404 });
if (playlist.username !== username) return NextResponse.json({ success: false, error: { code: 'FORBIDDEN', message: '无权限操作此歌单' } }, { status: 403 });
const { searchParams } = new URL(request.url);
const songId = searchParams.get('songId');
if (!songId) return badRequest('缺少 songId');
await db.removeMusicV2PlaylistItem(playlistId, songId);
return NextResponse.json({ success: true });
} catch (error) {
return internalError('删除歌曲失败', (error as Error).message);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { randomUUID } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { badRequest, getMusicV2Username, internalError, unauthorized } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const playlists = await db.listMusicV2Playlists(username);
return NextResponse.json({ success: true, data: { playlists } });
} catch (error) {
return internalError('获取歌单失败', (error as Error).message);
}
}
export async function POST(request: NextRequest) {
const username = await getMusicV2Username(request);
if (!username) return unauthorized();
try {
const body = await request.json();
const name = body?.name?.trim();
if (!name) return badRequest('歌单名称不能为空');
const playlistId = randomUUID();
await db.createMusicV2Playlist(username, {
id: playlistId,
name,
description: body?.description?.trim(),
});
const playlist = await db.getMusicV2Playlist(playlistId);
return NextResponse.json({ success: true, data: { playlist } });
} catch (error) {
return internalError('创建歌单失败', (error as Error).message);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { isMusicSource, lxGetJson, LxServerSong, normalizeLxSong } from '@/lib/music-v2';
import { badRequest, internalError } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim() || '';
const source = searchParams.get('source') || 'kw';
const page = Number(searchParams.get('page') || '1');
const limit = Number(searchParams.get('limit') || '20');
if (!q) return badRequest('缺少搜索关键词');
if (!isMusicSource(source)) return badRequest('不支持的音源');
const list = await lxGetJson<LxServerSong[]>(`/api/music/search?name=${encodeURIComponent(q)}&source=${source}&page=${page}&limit=${limit}`, 'none');
return NextResponse.json({
success: true,
data: {
list: list.map(normalizeLxSong),
page,
limit,
hasMore: Array.isArray(list) && list.length >= limit,
},
});
} catch (error) {
return internalError('搜索歌曲失败', (error as Error).message);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { extractSongmid, isMusicSource, lxPostJson, normalizeMusicQuality, normalizeSong } from '@/lib/music-v2';
import { badRequest } from '@/lib/music-v2-api';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const source = searchParams.get('source') || '';
const songId = searchParams.get('songId') || '';
const quality = normalizeMusicQuality(searchParams.get('quality') || '320k');
if (!isMusicSource(source)) return badRequest('不支持的音源');
if (!songId) return badRequest('缺少歌曲ID');
const song = normalizeSong({
songId,
source,
songmid: searchParams.get('songmid') || undefined,
name: searchParams.get('name') || '',
artist: searchParams.get('artist') || '',
durationText: searchParams.get('durationText') || undefined,
hash: searchParams.get('hash') || undefined,
copyrightId: searchParams.get('copyrightId') || undefined,
albumId: searchParams.get('albumId') || undefined,
lrcUrl: searchParams.get('lrcUrl') || undefined,
mrcUrl: searchParams.get('mrcUrl') || undefined,
trcUrl: searchParams.get('trcUrl') || undefined,
});
const urlResult = await lxPostJson<{ url?: string; error?: string }>(
'/api/music/url',
{
songInfo: {
id: song.songId,
name: song.name,
singer: song.artist,
source: song.source,
songmid: extractSongmid(song),
hash: song.hash,
interval: song.durationText,
copyrightId: song.copyrightId,
albumId: song.albumId,
lrcUrl: song.lrcUrl,
mrcUrl: song.mrcUrl,
trcUrl: song.trcUrl,
},
quality,
},
'auto'
);
const upstreamUrl = urlResult?.url;
if (!upstreamUrl) {
return NextResponse.json({ success: false, error: { code: 'STREAM_FAILED', message: urlResult?.error || '获取音频流失败' } }, { status: 502 });
}
const headers = new Headers();
headers.set('User-Agent', 'Mozilla/5.0');
const range = request.headers.get('range');
if (range) headers.set('Range', range);
const upstream = await fetch(upstreamUrl, {
headers,
signal: AbortSignal.timeout(30000),
});
if (!upstream.ok && upstream.status !== 206) {
return NextResponse.json({ success: false, error: { code: 'STREAM_FAILED', message: '获取音频流失败' } }, { status: upstream.status });
}
const responseHeaders = new Headers();
responseHeaders.set('Content-Type', upstream.headers.get('content-type') || 'audio/mpeg');
responseHeaders.set('Cache-Control', 'public, max-age=31536000, immutable');
responseHeaders.set('Accept-Ranges', upstream.headers.get('accept-ranges') || 'bytes');
responseHeaders.set('Access-Control-Allow-Origin', '*');
const copyHeaders = ['content-length', 'content-range', 'etag', 'last-modified'];
for (const header of copyHeaders) {
const value = upstream.headers.get(header);
if (value) responseHeaders.set(header, value);
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
} catch (error) {
return NextResponse.json({ success: false, error: { code: 'STREAM_FAILED', message: (error as Error).message } }, { status: 400 });
}
}
+3 -3
View File
@@ -147,8 +147,8 @@ export default async function RootLayout({
webLiveEnabled = config.WebLiveEnabled ?? false;
// 自定义去广告代码版本号
customAdFilterVersion = config.SiteConfig?.CustomAdFilterVersion || 0;
// TuneHub音乐功能配置
tuneHubEnabled = config.MusicConfig?.TuneHubEnabled || false;
// 音乐功能配置
tuneHubEnabled = config.MusicConfig?.Enabled || false;
// 高级推荐功能配置:存在已启用视频源脚本时显示
advancedRecommendationEnabled =
(await listEnabledSourceScripts()).length > 0;
@@ -222,7 +222,7 @@ export default async function RootLayout({
WEB_LIVE_ENABLED: webLiveEnabled,
ADVANCED_RECOMMENDATION_ENABLED: advancedRecommendationEnabled,
CUSTOM_AD_FILTER_VERSION: customAdFilterVersion,
TUNEHUB_ENABLED: tuneHubEnabled,
MUSIC_ENABLED: tuneHubEnabled,
FESTIVE_EFFECT_ENABLED:
process.env.FESTIVE_EFFECT_ENABLED === 'true',
};
+455 -337
View File
@@ -4,28 +4,26 @@
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
getAllMusicPlayRecords,
saveMusicPlayRecord,
MusicPlayRecord,
deleteMusicPlayRecord,
clearAllMusicPlayRecords,
} from '@/lib/db.client';
import AddToPlaylistModal from '@/components/AddToPlaylistModal';
import Toast, { ToastProps } from '@/components/Toast';
import LyricsPiPWindow from '@/components/LyricsPiPWindow';
type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
interface Song {
id: string;
name: string;
artist: string;
album?: string;
pic?: string;
platform: 'netease' | 'qq' | 'kuwo'; // 添加平台信息
platform: MusicSource;
duration?: number;
durationText?: string;
songmid?: string;
}
interface PlayRecord {
platform: 'netease' | 'qq' | 'kuwo';
platform: MusicSource;
id: string;
playTime: number; // 播放时间(秒)
duration: number; // 总时长(秒)
@@ -35,25 +33,30 @@ interface PlayRecord {
interface LyricLine {
time: number;
text: string;
translation?: string;
}
interface Playlist {
id: string;
name: string;
pic: string;
pic?: string;
source?: MusicSource;
updateFrequency?: string;
}
interface DbRecord {
platform: 'netease' | 'qq' | 'kuwo';
source: MusicSource;
songId: string;
id: string;
play_time: number;
duration: number;
save_time: number;
playProgressSec: number;
durationSec: number;
lastPlayedAt: number;
name: string;
artist: string;
album?: string;
pic?: string;
cover?: string;
durationText?: string;
songmid?: string;
}
// 扩展 Window 接口以支持 Document PiP API
@@ -68,7 +71,7 @@ declare global {
export default function MusicPage() {
const router = useRouter();
const [currentSource, setCurrentSource] = useState<'netease' | 'qq' | 'kuwo'>('netease');
const [currentSource, setCurrentSource] = useState<MusicSource>('wy');
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [songs, setSongs] = useState<Song[]>([]);
const [currentView, setCurrentView] = useState<'playlists' | 'songs' | 'myPlaylists'>('playlists');
@@ -93,8 +96,10 @@ export default function MusicPage() {
const [showPlaylist, setShowPlaylist] = useState(false);
const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引
const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单
const [showSourceMenu, setShowSourceMenu] = useState(false); // 移动端音源菜单
const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态
const [pendingSongToPlay, setPendingSongToPlay] = useState<{ platform: string; id: string } | null>(null); // 待播放的歌曲信息
const [resolvingCount, setResolvingCount] = useState(0); // 当前解析中的歌曲数量
const [showAddToPlaylistModal, setShowAddToPlaylistModal] = useState(false); // 添加到歌单弹窗
const [songToAddToPlaylist, setSongToAddToPlaylist] = useState<Song | null>(null); // 要添加到歌单的歌曲
@@ -134,18 +139,86 @@ export default function MusicPage() {
const restoredTimeRef = useRef<number>(0);
const songStartTimeRef = useRef<number>(0); // 歌曲开始播放的时间戳
// 工具函数:处理图片 URL(在 HTTPS 环境下代理 HTTP 图片)
const processImageUrl = (url: string | undefined, platform: string): string | undefined => {
if (!url) return url;
const mapSong = (song: any): Song => ({
id: song.songId || song.id,
name: song.name,
artist: song.artist,
album: song.album,
pic: song.cover || song.pic,
platform: normalizeSource(song.source || song.platform),
duration: song.durationSec || song.duration,
durationText: song.durationText || song.interval,
songmid: song.songmid,
});
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
// 只对酷我音乐的 HTTP 图片在 HTTPS 环境下进行代理
if (platform === 'kuwo' && isHttps && url.startsWith('http://')) {
return `/api/music/proxy?url=${encodeURIComponent(url)}`;
const normalizeSource = (source: string | undefined): MusicSource => {
switch (source) {
case 'netease': return 'wy';
case 'qq': return 'tx';
case 'kuwo': return 'kw';
case 'wy':
case 'tx':
case 'kw':
case 'kg':
case 'mg':
return source;
default:
return 'wy';
}
};
return url;
const musicSources: Array<{ key: MusicSource; label: string }> = [
{ key: 'wy', label: '网易云' },
{ key: 'tx', label: 'QQ' },
{ key: 'kw', label: '酷我' },
{ key: 'kg', label: '酷狗' },
{ key: 'mg', label: '咪咕' },
];
const buildStreamUrl = (song: Song, source: MusicSource, songQuality: '128k' | '320k' | 'flac' | 'flac24bit') => {
const params = new URLSearchParams({
songId: song.id,
source,
quality: songQuality,
songmid: song.songmid || song.id.split('_').slice(1).join('_'),
name: song.name,
artist: song.artist,
});
if (song.durationText) params.set('durationText', song.durationText);
return `/api/music/v2/stream?${params.toString()}`;
};
const beginResolving = () => {
setResolvingCount((prev) => prev + 1);
};
const endResolving = () => {
setResolvingCount((prev) => Math.max(0, prev - 1));
};
const saveHistoryRecord = async (record: PlayRecord, song: Song, playTime: number, totalDuration: number) => {
await fetch('/api/music/v2/history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
song: {
songId: record.id,
source: record.platform,
songmid: song.songmid,
name: song.name,
artist: song.artist,
album: song.album,
cover: song.pic,
durationSec: totalDuration || song.duration || 0,
durationText: song.durationText,
},
playProgressSec: playTime,
lastPlayedAt: Date.now(),
lastQuality: quality,
}),
});
};
// 保存播放状态到 localStorage
@@ -182,41 +255,29 @@ export default function MusicPage() {
useEffect(() => {
const initializePlayState = async () => {
try {
// 1. 直接从 API 同步加载播放记录(阻塞等待,不使用缓存)
const response = await fetch('/api/music/playrecords');
const dbRecords = await response.json();
const response = await fetch('/api/music/v2/history');
const history = await response.json();
const dbRecords = (history.data?.records || []) as DbRecord[];
// 将数据库记录转换为前端格式
const records: PlayRecord[] = [];
const songs: Song[] = [];
const sortedRecords: PlayRecord[] = dbRecords.map((record) => ({
platform: record.source,
id: record.songId,
playTime: record.playProgressSec,
duration: record.durationSec || 0,
timestamp: record.lastPlayedAt,
}));
Object.entries(dbRecords).forEach(([key, record]) => {
const dbRecord = record as DbRecord;
records.push({
platform: dbRecord.platform,
id: dbRecord.id,
playTime: dbRecord.play_time,
duration: dbRecord.duration,
timestamp: dbRecord.save_time,
});
songs.push({
id: dbRecord.id,
name: dbRecord.name,
artist: dbRecord.artist,
album: dbRecord.album,
pic: dbRecord.pic,
platform: dbRecord.platform,
});
});
// 按 save_time 倒序排序
const sortedIndices = records
.map((record, index) => ({ record, index }))
.sort((a, b) => b.record.timestamp - a.record.timestamp);
const sortedRecords = sortedIndices.map(item => records[item.index]);
const sortedSongs = sortedIndices.map(item => songs[item.index]);
const sortedSongs: Song[] = dbRecords.map((record) => ({
id: record.songId,
name: record.name,
artist: record.artist,
album: record.album,
pic: record.cover,
platform: record.source,
duration: record.durationSec,
durationText: record.durationText,
songmid: record.songmid,
}));
// 2. 更新播放列表
if (sortedRecords.length > 0) {
@@ -231,7 +292,7 @@ export default function MusicPage() {
// 恢复配置状态(不包括歌曲)
setSongs(playState.songs || []);
setCurrentPlaylistTitle(playState.currentPlaylistTitle || '');
setCurrentSource(playState.currentSource || 'netease');
setCurrentSource(normalizeSource(playState.currentSource));
setCurrentView(playState.currentView || 'playlists');
setQuality(playState.quality || '320k');
setPlayMode(playState.playMode || 'loop');
@@ -251,57 +312,53 @@ export default function MusicPage() {
const dbPlayTime = latestDbRecord.playTime || 0;
songStartTimeRef.current = Date.now();
// 5. 同步解析歌曲获取播放链接(阻塞等待)
const platform = latestDbSong.platform || 'netease';
// 5. 先直接使用稳定 stream 地址恢复播放
const platform = latestDbSong.platform || 'kw';
const streamUrl = buildStreamUrl(latestDbSong, platform, playState.quality || '320k');
setCurrentSongUrl(streamUrl);
console.log('开始解析歌曲:', platform, latestDbSong.id);
if (audioRef.current) {
audioRef.current.src = streamUrl;
const parseResponse = await fetch('/api/music', {
const restoreTime = () => {
if (audioRef.current && dbPlayTime > 0) {
audioRef.current.currentTime = dbPlayTime;
}
};
audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true });
audioRef.current.load();
}
fetch('/api/music/v2/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'parse',
platform: platform,
ids: latestDbSong.id,
includeUrl: false,
song: {
songId: latestDbSong.id,
source: platform,
songmid: latestDbSong.songmid,
name: latestDbSong.name,
artist: latestDbSong.artist,
album: latestDbSong.album,
cover: latestDbSong.pic,
durationSec: latestDbSong.duration,
durationText: latestDbSong.durationText,
},
quality: playState.quality || '320k',
}),
});
let data = await parseResponse.json();
// 执行前端 transform(如果有)
data = executeTransform(data);
if (data.code === 0 && data.data?.data && data.data.data.length > 0) {
const songData = data.data.data[0];
if (songData.url && songData.success) {
let playUrl = songData.url;
if (platform === 'kuwo') {
playUrl = `/api/music/proxy?url=${encodeURIComponent(songData.url)}`;
}
setCurrentSongUrl(songData.url);
if (songData.lyrics) {
const parsedLyrics = parseLyric(songData.lyrics);
})
.then(res => res.json())
.then((data) => {
if (data.success && data.data?.lyric?.lyric) {
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
setLyrics(parsedLyrics);
}
// 6. 等待所有数据准备好后,再设置音频源和进度
if (audioRef.current) {
audioRef.current.src = playUrl;
const restoreTime = () => {
if (audioRef.current && dbPlayTime > 0) {
audioRef.current.currentTime = dbPlayTime;
}
};
audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true });
audioRef.current.load();
}
}
}
})
.catch((error) => {
console.error('加载歌词失败:', error);
});
}
} catch (error) {
console.error('加载播放记录失败:', error);
@@ -366,35 +423,24 @@ export default function MusicPage() {
}
}, [volume]);
// 执行前端 transform(用于 Cloudflare 环境)
const executeTransform = (data: any) => {
if (data && typeof data === 'object' && data.__transform) {
try {
// eslint-disable-next-line no-eval
const transformFn = eval(`(${data.__transform})`);
delete data.__transform; // 删除 transform 字段
return transformFn(data);
} catch (err) {
console.error('[Frontend] Transform 函数执行失败:', err);
delete data.__transform;
return data;
}
}
return data;
};
// 加载排行榜列表
const loadPlaylists = async (source: string) => {
setLoading(true);
try {
const response = await fetch(
`/api/music?action=toplists&platform=${source}`
);
let data = await response.json();
// 执行前端 transform(如果有)
data = executeTransform(data);
// 确保返回的是数组
setPlaylists(Array.isArray(data) ? data : []);
const boardsResponse = await fetch(`/api/music/v2/discovery/boards?source=${source}`);
const boardsData = await boardsResponse.json();
if (boardsResponse.ok && boardsData.success) {
setPlaylists((boardsData.data?.list || []).map((item: any) => ({
id: item.id,
name: item.name,
source: normalizeSource(item.source || boardsData.data?.source || source),
updateFrequency: item.updateFrequency || item.description || '',
})));
} else {
console.error('加载排行榜失败:', boardsData);
setPlaylists([]);
}
} catch (error) {
console.error('加载排行榜失败:', error);
setPlaylists([]);
@@ -404,17 +450,15 @@ export default function MusicPage() {
};
// 加载歌单详情
const loadPlaylist = async (playlistId: string, playlistName: string) => {
const loadPlaylist = async (playlistId: string, playlistName: string, playlistSource?: MusicSource) => {
setLoading(true);
try {
const source = playlistSource || currentSource;
const response = await fetch(
`/api/music?action=toplist&platform=${currentSource}&id=${playlistId}`
`/api/music/v2/discovery/board-songs?source=${source}&boardId=${playlistId}`
);
let data = await response.json();
// 执行前端 transform(如果有)
data = executeTransform(data);
// 确保返回的是数组
setSongs(Array.isArray(data) ? data : []);
const data = await response.json();
setSongs((data.data?.list || []).map(mapSong));
setCurrentPlaylistTitle(playlistName);
setCurrentView('songs');
} catch (error) {
@@ -432,13 +476,10 @@ export default function MusicPage() {
setLoading(true);
try {
const response = await fetch(
`/api/music?action=search&platform=${currentSource}&keyword=${encodeURIComponent(searchKeyword)}&page=1&pageSize=20`
`/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(searchKeyword)}&page=1&limit=20`
);
let data = await response.json();
// 执行前端 transform(如果有)
data = executeTransform(data);
// 确保返回的是数组
setSongs(Array.isArray(data) ? data : []);
const data = await response.json();
setSongs((data.data?.list || []).map(mapSong));
setCurrentPlaylistTitle(`搜索: ${searchKeyword}`);
setCurrentView('songs');
} catch (error) {
@@ -460,10 +501,10 @@ export default function MusicPage() {
const loadUserPlaylists = async () => {
try {
setLoadingUserPlaylists(true);
const response = await fetch('/api/music/playlists');
const response = await fetch('/api/music/v2/playlists');
if (response.ok) {
const data = await response.json();
setUserPlaylists(data.playlists || []);
setUserPlaylists(data.data?.playlists || []);
}
} catch (error) {
console.error('加载歌单失败:', error);
@@ -476,10 +517,16 @@ export default function MusicPage() {
const loadUserPlaylistSongs = async (playlistId: string) => {
try {
setLoadingUserPlaylistSongs(true);
const response = await fetch(`/api/music/playlists/songs?playlistId=${playlistId}`);
const response = await fetch(`/api/music/v2/playlists/${playlistId}/songs`);
if (response.ok) {
const data = await response.json();
setUserPlaylistSongs(data.songs || []);
setUserPlaylistSongs((data.data?.songs || []).map((song: any) => ({
...song,
id: song.songId,
platform: song.source,
pic: song.cover,
duration: song.durationSec,
})));
}
} catch (error) {
console.error('加载歌单歌曲失败:', error);
@@ -508,7 +555,7 @@ export default function MusicPage() {
setLoadingPlayAll(true);
try {
// 1. 清空所有播放历史
await clearAllMusicPlayRecords();
await fetch('/api/music/v2/history', { method: 'DELETE' });
// 2. 清空本地状态
setPlayRecords([]);
@@ -517,22 +564,25 @@ export default function MusicPage() {
// 3. 批量添加歌单中的所有歌曲到播放历史
const baseTime = Date.now();
const recordsToAdd = userPlaylistSongs.map((song, i) => ({
key: `${song.platform}+${song.id}`,
record: {
platform: song.platform,
id: song.id,
song: {
songId: song.id,
source: song.platform,
songmid: song.songmid,
name: song.name,
artist: song.artist,
album: song.album,
pic: song.pic,
play_time: 0,
duration: song.duration || 0,
save_time: baseTime + i, // 使用递增的时间戳
cover: song.pic,
durationSec: song.duration || 0,
durationText: song.durationText,
},
playProgressSec: 0,
lastPlayedAt: baseTime + i,
playCount: 1,
lastQuality: quality,
}));
// 一次性批量添加所有歌曲
const response = await fetch('/api/music/playrecords', {
const response = await fetch('/api/music/v2/history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -560,6 +610,9 @@ export default function MusicPage() {
album: song.album,
pic: song.pic,
platform: song.platform,
duration: song.duration,
durationText: song.durationText,
songmid: song.songmid,
}));
setPlayRecords(newRecords);
@@ -607,9 +660,7 @@ export default function MusicPage() {
// 然后执行删除
setDeletingPlaylistId(playlistId);
try {
const response = await fetch(`/api/music/playlists?playlistId=${playlistId}`, {
method: 'DELETE',
});
const response = await fetch(`/api/music/v2/playlists/${playlistId}`, { method: 'DELETE' });
if (response.ok) {
setToast({
@@ -664,7 +715,7 @@ export default function MusicPage() {
onConfirm: async () => {
try {
const response = await fetch(
`/api/music/playlists/songs?playlistId=${selectedUserPlaylist.id}&platform=${song.platform}&songId=${song.id}`,
`/api/music/v2/playlists/${selectedUserPlaylist.id}/songs?songId=${encodeURIComponent(song.id)}`,
{ method: 'DELETE' }
);
@@ -713,6 +764,7 @@ export default function MusicPage() {
// 播放歌曲
const playSong = async (song: Song, index: number) => {
beginResolving();
try {
// 使用歌曲自己的平台信息,如果没有则使用当前选择的平台
const platform = song.platform || currentSource;
@@ -763,103 +815,109 @@ export default function MusicPage() {
}
});
// 调用解析接口获取播放链接
const response = await fetch('/api/music', {
const streamUrl = buildStreamUrl(song, platform, quality);
setCurrentSongUrl(streamUrl);
if (audioRef.current) {
audioRef.current.src = streamUrl;
audioRef.current.load();
audioRef.current.play().catch(err => {
console.error('播放失败:', err);
});
setIsPlaying(true);
}
fetch('/api/music/v2/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'parse',
platform: platform, // 使用歌曲的平台
ids: song.id,
quality: quality,
includeUrl: false,
song: {
songId: song.id,
source: platform,
songmid: song.songmid,
name: song.name,
artist: song.artist,
album: song.album,
cover: song.pic,
durationSec: song.duration,
durationText: song.durationText,
},
quality,
}),
});
})
.then(res => res.json())
.then((data) => {
if (data.success) {
if (data.data.song?.cover) {
setCurrentSong({
...song,
pic: data.data.song.cover,
platform,
});
}
let data = await response.json();
// 执行前端 transform(如果有)
data = executeTransform(data);
// TuneHub 返回格式: { code: 0, data: { data: [...] } }
if (data.code === 0 && data.data?.data && data.data.data.length > 0) {
const songData = data.data.data[0];
if (songData.url && songData.success) {
// 处理封面图片(在 HTTPS 环境下代理 HTTP 图片)
const coverUrl = processImageUrl(songData.cover, platform);
// 更新歌曲信息,包括封面
if (coverUrl) {
setCurrentSong({
...song,
pic: coverUrl,
platform,
});
if (data.data.lyric?.lyric) {
const parsedLyrics = parseLyric(data.data.lyric.lyric, data.data.lyric.tlyric);
setLyrics(parsedLyrics);
}
} else {
console.error('播放信息获取失败:', data);
}
// 解析歌词
if (songData.lyrics) {
const parsedLyrics = parseLyric(songData.lyrics);
setLyrics(parsedLyrics);
}
// 保存原始 URL 用于下载
setCurrentSongUrl(songData.url);
// 对于酷我音乐,使用代理
let playUrl = songData.url;
if (platform === 'kuwo') {
playUrl = `/api/music/proxy?url=${encodeURIComponent(songData.url)}`;
}
if (audioRef.current) {
audioRef.current.src = playUrl;
audioRef.current.load();
audioRef.current.play().catch(err => {
console.error('播放失败:', err);
});
setIsPlaying(true);
}
} else {
console.error('无法获取播放链接,songData:', songData);
}
} else {
console.error('解析失败,完整响应:', data);
}
})
.catch((error) => {
console.error('加载歌词失败:', error);
});
} catch (error) {
console.error('播放失败:', error);
} finally {
endResolving();
}
};
// 解析歌词文本
const parseLyric = (lyricText: string): LyricLine[] => {
if (!lyricText) return [];
const lines = lyricText.split('\n');
const lyricLines: LyricLine[] = [];
const parseLyric = (lyricText: string, tlyricText?: string): LyricLine[] => {
if (!lyricText && !tlyricText) return [];
// 匹配 [mm:ss.xx] 或 [mm:ss] 格式
const timeRegex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g;
const parseLyricText = (text: string) => {
const parsed = new Map<number, string>();
const lines = text.split('\n');
lines.forEach(line => {
const matches = Array.from(line.matchAll(timeRegex));
if (matches.length > 0) {
// 提取歌词文本(去掉所有时间标签)
const text = line.replace(timeRegex, '').trim();
if (text) {
// 一行可能有多个时间标签
matches.forEach(match => {
const minutes = parseInt(match[1]);
const seconds = parseInt(match[2]);
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0;
const time = minutes * 60 + seconds + milliseconds / 1000;
lyricLines.push({ time, text });
});
lines.forEach(line => {
const matches = Array.from(line.matchAll(timeRegex));
if (matches.length > 0) {
const content = line.replace(timeRegex, '').trim();
if (content) {
matches.forEach(match => {
const minutes = parseInt(match[1]);
const seconds = parseInt(match[2]);
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0;
const time = minutes * 60 + seconds + milliseconds / 1000;
parsed.set(time, content);
});
}
}
}
});
});
// 按时间排序
return lyricLines.sort((a, b) => a.time - b.time);
return parsed;
};
const mainMap = parseLyricText(lyricText || '');
const transMap = parseLyricText(tlyricText || '');
const times = Array.from(new Set([
...Array.from(mainMap.keys()),
...Array.from(transMap.keys()),
])).sort((a, b) => a - b);
return times
.map(time => ({
time,
text: mainMap.get(time) || '',
translation: transMap.get(time) || undefined,
}))
.filter(line => line.text || line.translation);
};
// 切换播放/暂停
@@ -879,19 +937,7 @@ export default function MusicPage() {
// 保存到数据库
if (currentSong && playlistIndex >= 0 && playRecords[playlistIndex]) {
const record = playRecords[playlistIndex];
const dbRecord: MusicPlayRecord = {
platform: record.platform,
id: record.id,
name: currentSong.name,
artist: currentSong.artist,
album: currentSong.album,
pic: currentSong.pic,
play_time: audioRef.current.currentTime,
duration: audioRef.current.duration || 0,
save_time: Date.now(),
};
saveMusicPlayRecord(record.platform, record.id, dbRecord).catch(err => {
saveHistoryRecord(record, currentSong, audioRef.current.currentTime, audioRef.current.duration || 0).catch(err => {
console.error('暂停时保存播放记录失败:', err);
});
}
@@ -974,7 +1020,7 @@ export default function MusicPage() {
};
// 切换平台
const switchSource = (source: 'netease' | 'qq' | 'kuwo') => {
const switchSource = (source: MusicSource) => {
setCurrentSource(source);
setCurrentView('playlists');
setSongs([]);
@@ -1024,19 +1070,7 @@ export default function MusicPage() {
// 保存到数据库
const record = updated[playlistIndex];
const dbRecord: MusicPlayRecord = {
platform: record.platform,
id: record.id,
name: currentSong.name,
artist: currentSong.artist,
album: currentSong.album,
pic: currentSong.pic,
play_time: audio.currentTime,
duration: audio.duration || 0,
save_time: Date.now(),
};
saveMusicPlayRecord(record.platform, record.id, dbRecord).catch(err => {
saveHistoryRecord(record, currentSong, audio.currentTime, audio.duration || 0).catch(err => {
console.error('保存播放记录到数据库失败:', err);
});
}
@@ -1076,19 +1110,7 @@ export default function MusicPage() {
// 保存到数据库(包含时长信息)
const record = updated[playlistIndex];
const dbRecord: MusicPlayRecord = {
platform: record.platform,
id: record.id,
name: currentSong.name,
artist: currentSong.artist,
album: currentSong.album,
pic: currentSong.pic,
play_time: record.playTime,
duration: audio.duration,
save_time: Date.now(),
};
saveMusicPlayRecord(record.platform, record.id, dbRecord).catch(err => {
saveHistoryRecord(record, currentSong, record.playTime, audio.duration).catch(err => {
console.error('保存播放记录到数据库失败:', err);
});
}
@@ -1260,9 +1282,11 @@ export default function MusicPage() {
const getSourceLabel = () => {
switch (currentSource) {
case 'netease': return '网易云';
case 'qq': return 'QQ音乐';
case 'kuwo': return '酷我';
case 'wy': return '网易云';
case 'tx': return 'QQ音乐';
case 'kw': return '酷我';
case 'kg': return '酷狗';
case 'mg': return '咪咕';
}
};
@@ -1276,6 +1300,18 @@ export default function MusicPage() {
return (
<div className="min-h-screen bg-zinc-950 text-white">
<>
{resolvingCount > 0 && (
<div className="fixed top-4 right-4 z-[80] pointer-events-none">
<div className="relative w-16 h-16 md:w-20 md:h-20">
<div className="absolute inset-0 rounded-full border-4 border-white/10" />
<div className="absolute inset-0 rounded-full border-4 border-transparent border-t-green-500 border-r-emerald-400 animate-spin shadow-[0_0_20px_rgba(34,197,94,0.35)]" />
<div className="absolute inset-1 rounded-full bg-zinc-950/90 backdrop-blur-md border border-white/10 flex flex-col items-center justify-center">
<div className="text-[10px] md:text-xs text-zinc-400 leading-none mb-1"></div>
<div className="text-lg md:text-xl font-bold text-white leading-none">{resolvingCount}</div>
</div>
</div>
</div>
)}
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-40 bg-zinc-950/95 backdrop-blur-md border-b border-white/10 px-4 md:px-6">
<div className="w-full mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-4 py-3">
@@ -1297,38 +1333,48 @@ export default function MusicPage() {
</div>
<span className="font-bold text-lg text-white"></span>
</div>
<div className="flex bg-white/5 rounded-lg p-1 gap-1 border border-white/5">
<div className="md:hidden relative">
<button
onClick={() => switchSource('netease')}
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
currentSource === 'netease'
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
: 'text-zinc-400 border border-transparent'
}`}
onClick={() => setShowSourceMenu(true)}
className="relative h-10 min-w-[132px] rounded-full border border-white/10 bg-gradient-to-r from-white/8 to-white/4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] px-3"
>
NET
</button>
<button
onClick={() => switchSource('qq')}
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
currentSource === 'qq'
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
: 'text-zinc-400 border border-transparent'
}`}
>
QQ
</button>
<button
onClick={() => switchSource('kuwo')}
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
currentSource === 'kuwo'
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
: 'text-zinc-400 border border-transparent'
}`}
>
<div className="absolute inset-0 flex items-center justify-between px-3">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center shrink-0">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
</div>
<div className="min-w-0">
<div className="text-[9px] uppercase tracking-[0.18em] text-zinc-500 leading-none"></div>
<div className="text-sm font-medium text-white leading-tight truncate">
{musicSources.find((source) => source.key === currentSource)?.label || '酷我'}
</div>
</div>
</div>
<div className="w-7 h-7 rounded-full bg-white/6 border border-white/8 flex items-center justify-center text-zinc-300 shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</button>
</div>
<div className="hidden md:flex flex-wrap bg-white/5 rounded-lg p-1 gap-1 border border-white/5">
{musicSources.map((source) => (
<button
key={source.key}
onClick={() => switchSource(source.key)}
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
currentSource === source.key
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
: 'text-zinc-400 border border-transparent'
}`}
>
{source.label}
</button>
))}
</div>
</div>
<div className="flex items-center w-full md:flex-1 md:max-w-md md:ml-auto h-10 md:h-9 gap-2">
{(currentView === 'songs' || currentView === 'myPlaylists') && (
@@ -1385,34 +1431,41 @@ export default function MusicPage() {
{getSourceLabel()}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{playlists.map((playlist) => (
<div
key={playlist.id}
onClick={() => loadPlaylist(playlist.id, playlist.name)}
className="cursor-pointer group"
>
<div className="relative aspect-square rounded-lg overflow-hidden mb-2 bg-white/5">
{playlist.pic && (
<img
src={playlist.pic}
alt={playlist.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
/>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg className="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
{playlists.length > 0 ? (
<div className="space-y-2">
{playlists.map((playlist, index) => (
<button
key={playlist.id}
onClick={() => loadPlaylist(playlist.id, playlist.name, playlist.source)}
className="w-full text-left rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 transition-colors px-4 py-3"
>
<div className="flex items-center gap-4">
<div className="w-8 text-sm text-zinc-500 font-mono shrink-0">
{String(index + 1).padStart(2, '0')}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white/90 truncate">{playlist.name}</div>
{playlist.updateFrequency ? (
<div className="text-xs text-zinc-500 mt-1 truncate">{playlist.updateFrequency}</div>
) : null}
</div>
<div className="text-zinc-500 shrink-0">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
<h3 className="text-sm font-medium text-white/80 truncate">{playlist.name}</h3>
{playlist.updateFrequency && (
<p className="text-xs text-zinc-500 mt-1">{playlist.updateFrequency}</p>
)}
</button>
))}
</div>
) : (
<div className="rounded-xl border border-white/10 bg-white/5 p-6 text-center text-zinc-400">
<div className="text-base font-medium text-white/80 mb-2"></div>
<div className="text-sm text-zinc-500">
使
</div>
))}
</div>
</div>
)}
</div>
)}
@@ -1868,7 +1921,18 @@ export default function MusicPage() {
: 'text-zinc-600 text-sm'
}`}
>
{line.text}
<div>{line.text}</div>
{line.translation && (
<div
className={`mt-1 ${
index === currentLyricIndex
? 'text-zinc-300 text-sm md:text-base font-normal'
: 'text-zinc-500 text-xs md:text-sm font-normal'
}`}
>
{line.translation}
</div>
)}
</div>
))}
</div>
@@ -2084,7 +2148,7 @@ export default function MusicPage() {
onClick={async () => {
if (confirm('确定要清空全部播放记录吗?')) {
try {
await clearAllMusicPlayRecords();
await fetch('/api/music/v2/history', { method: 'DELETE' });
setPlaylist([]);
setPlayRecords([]);
setPlaylistIndex(-1);
@@ -2168,8 +2232,7 @@ export default function MusicPage() {
onClick={async (e) => {
e.stopPropagation();
try {
const platform = song.platform || 'netease';
await deleteMusicPlayRecord(platform, song.id);
await fetch(`/api/music/v2/history?songId=${encodeURIComponent(song.id)}`, { method: 'DELETE' });
// 更新本地状态
const newPlaylist = playlist.filter((_, i) => i !== index);
@@ -2342,6 +2405,61 @@ export default function MusicPage() {
</div>
)}
{showSourceMenu && (
<div className="md:hidden fixed inset-0 z-[90]">
<button
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowSourceMenu(false)}
aria-label="关闭音源菜单"
/>
<div className="absolute inset-x-0 bottom-0 rounded-t-3xl border-t border-white/10 bg-zinc-950/98 px-4 pb-6 pt-4 shadow-2xl">
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-white/15" />
<div className="mb-3 px-1 text-sm font-medium text-white"></div>
<div className="space-y-2">
{musicSources.map((source) => {
const active = currentSource === source.key;
return (
<button
key={source.key}
onClick={() => {
setShowSourceMenu(false);
if (!active) switchSource(source.key);
}}
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition-all ${
active
? 'border-green-500/50 bg-green-500/12 text-white'
: 'border-white/8 bg-white/5 text-zinc-200'
}`}
>
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 items-center justify-center rounded-full ${active ? 'bg-green-500/20 text-green-400' : 'bg-white/8 text-zinc-400'}`}>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
</div>
<div className="text-base font-medium">{source.label}</div>
</div>
{active ? (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
</svg>
</div>
) : (
<div className="text-zinc-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
)}
</button>
);
})}
</div>
</div>
</div>
)}
{/* Add to Playlist Modal */}
<AddToPlaylistModal
song={songToAddToPlaylist}
+20 -14
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?.MUSIC_ENABLED;
setMusicEnabled(enabled);
}
}, []);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
@@ -604,19 +613,16 @@ 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>
* )}
*/}
{musicEnabled && (
<Link href='/music'>
<button
className='p-1.5 rounded-lg text-green-500 hover:text-green-600 transition-colors'
title='音乐视听'
>
<Music size={18} />
</button>
</Link>
)}
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
+9 -10
View File
@@ -9,7 +9,7 @@ interface Song {
artist: string;
album?: string;
pic?: string;
platform: 'netease' | 'qq' | 'kuwo';
platform: 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
duration?: number;
}
@@ -56,10 +56,10 @@ export default function AddToPlaylistModal({
const loadPlaylists = async () => {
try {
setLoading(true);
const response = await fetch('/api/music/playlists');
const response = await fetch('/api/music/v2/playlists');
if (response.ok) {
const data = await response.json();
setPlaylists(data.playlists || []);
setPlaylists(data.data?.playlists || []);
}
} catch (error) {
console.error('加载歌单失败:', error);
@@ -76,7 +76,7 @@ export default function AddToPlaylistModal({
try {
setCreating(true);
const response = await fetch('/api/music/playlists', {
const response = await fetch('/api/music/v2/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -107,19 +107,18 @@ export default function AddToPlaylistModal({
try {
setAddingToPlaylistId(playlistId);
const response = await fetch('/api/music/playlists/songs', {
const response = await fetch(`/api/music/v2/playlists/${playlistId}/songs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlistId,
song: {
platform: song.platform,
id: song.id,
source: song.platform,
songId: song.id,
name: song.name,
artist: song.artist,
album: song.album,
pic: song.pic,
duration: song.duration || 0,
cover: song.pic,
durationSec: song.duration || 0,
},
}),
});
+20 -3
View File
@@ -10,12 +10,13 @@ interface Song {
artist: string;
album?: string;
pic?: string;
platform: 'netease' | 'qq' | 'kuwo';
platform: 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
}
interface LyricLine {
time: number;
text: string;
translation?: string;
}
interface LyricsPiPWindowProps {
@@ -187,7 +188,11 @@ const PiPLyricsContent = ({
}}
>
{lyrics.length > 0 && currentLyricIndex >= 0
? lyrics[currentLyricIndex]?.text || '♪'
? (
lyrics[currentLyricIndex]?.translation
? `${lyrics[currentLyricIndex]?.text || '♪'}\n${lyrics[currentLyricIndex]?.translation}`
: lyrics[currentLyricIndex]?.text || '♪'
)
: currentSong
? '暂无歌词'
: '请播放歌曲'}
@@ -220,7 +225,19 @@ const PiPLyricsContent = ({
fontWeight: index === currentLyricIndex ? 'bold' : 'normal',
}}
>
{line.text}
<div>{line.text}</div>
{line.translation && (
<div
style={{
marginTop: '4px',
fontSize: index === currentLyricIndex ? '13px' : '12px',
opacity: index === currentLyricIndex ? 0.85 : 0.55,
fontWeight: 'normal',
}}
>
{line.translation}
</div>
)}
</div>
))
) : (
+13 -11
View File
@@ -261,17 +261,19 @@ export interface AdminConfig {
};
};
MusicConfig?: {
// TuneHub音乐配置
TuneHubEnabled?: boolean; // 启用音乐功能
TuneHubBaseUrl?: string; // TuneHub API地址
TuneHubApiKey?: string; // TuneHub API Key
// OpenList缓存配置
OpenListCacheEnabled?: boolean; // 启用OpenList缓存
OpenListCacheURL?: string; // OpenList服务器地址
OpenListCacheUsername?: string; // OpenList用户名
OpenListCachePassword?: string; // OpenList密码
OpenListCachePath?: string; // OpenList缓存目录路径
OpenListCacheProxyEnabled?: boolean; // 启用缓存代理返回(默认开启)
Enabled?: boolean; // 启用音乐功能
BaseUrl?: string; // lxserver 地址
Token?: string; // lxserver x-user-token
// 兼容旧代码的遗留字段(待删除)
TuneHubEnabled?: boolean;
TuneHubBaseUrl?: string;
TuneHubApiKey?: string;
OpenListCacheEnabled?: boolean;
OpenListCacheURL?: string;
OpenListCacheUsername?: string;
OpenListCachePassword?: string;
OpenListCachePath?: string;
OpenListCacheProxyEnabled?: boolean;
};
AnimeSubscriptionConfig?: {
Enabled: boolean; // 是否启用追番功能
+3 -9
View File
@@ -643,15 +643,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
// 确保音乐配置存在
if (!adminConfig.MusicConfig) {
adminConfig.MusicConfig = {
TuneHubEnabled: false,
TuneHubBaseUrl: 'https://tunehub.sayqz.com/api',
TuneHubApiKey: '',
OpenListCacheEnabled: false,
OpenListCacheURL: '',
OpenListCacheUsername: '',
OpenListCachePassword: '',
OpenListCachePath: '/music-cache',
OpenListCacheProxyEnabled: true,
Enabled: false,
BaseUrl: '',
Token: '',
};
}
+306
View File
@@ -17,6 +17,7 @@ import {
} from './types';
import { AdminConfig } from './admin.types';
import { DatabaseAdapter } from './d1-adapter';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
import { userInfoCache } from './user-cache';
/**
@@ -715,6 +716,311 @@ export class D1Storage implements IStorage {
}
}
// ==================== Music V2 历史记录相关 ====================
async listMusicV2History(userName: string): Promise<MusicV2HistoryRecord[]> {
try {
const results = await this.db
.prepare('SELECT * FROM music_v2_history WHERE username = ? ORDER BY last_played_at DESC')
.bind(userName)
.all();
if (!results.results) return [];
return results.results.map((row: any) => ({
songId: row.song_id,
source: row.source,
songmid: row.songmid || undefined,
name: row.name,
artist: row.artist,
album: row.album || undefined,
cover: row.cover || undefined,
durationText: row.duration_text || undefined,
durationSec: row.duration_sec ?? undefined,
playProgressSec: row.play_progress_sec ?? 0,
lastPlayedAt: row.last_played_at,
playCount: row.play_count ?? 0,
lastQuality: row.last_quality || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
} catch (err) {
console.error('D1Storage.listMusicV2History error:', err);
return [];
}
}
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
try {
await this.db
.prepare(`
INSERT INTO music_v2_history (
username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec,
play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(username, song_id) DO UPDATE SET
source = excluded.source,
songmid = excluded.songmid,
name = excluded.name,
artist = excluded.artist,
album = excluded.album,
cover = excluded.cover,
duration_text = excluded.duration_text,
duration_sec = excluded.duration_sec,
play_progress_sec = excluded.play_progress_sec,
last_played_at = excluded.last_played_at,
play_count = excluded.play_count,
last_quality = excluded.last_quality,
updated_at = excluded.updated_at
`)
.bind(
userName,
record.songId,
record.source,
record.songmid || null,
record.name,
record.artist,
record.album || null,
record.cover || null,
record.durationText || null,
record.durationSec ?? null,
record.playProgressSec,
record.lastPlayedAt,
record.playCount,
record.lastQuality || null,
record.createdAt,
record.updatedAt
)
.run();
} catch (err) {
console.error('D1Storage.upsertMusicV2History error:', err);
throw err;
}
}
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
for (const record of records) {
await this.upsertMusicV2History(userName, record);
}
}
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
await this.db
.prepare('DELETE FROM music_v2_history WHERE username = ? AND song_id = ?')
.bind(userName, songId)
.run();
}
async clearMusicV2History(userName: string): Promise<void> {
await this.db
.prepare('DELETE FROM music_v2_history WHERE username = ?')
.bind(userName)
.run();
}
// ==================== Music V2 歌单相关 ====================
async createMusicV2Playlist(userName: string, playlist: {
id: string;
name: string;
description?: string;
cover?: string;
}): Promise<void> {
const now = Date.now();
await this.db
.prepare(`
INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
.bind(playlist.id, userName, playlist.name, playlist.description || null, playlist.cover || null, 0, now, now)
.run();
}
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
const row: any = await this.db
.prepare('SELECT * FROM music_v2_playlists WHERE id = ?')
.bind(playlistId)
.first();
if (!row) return null;
return {
id: row.id,
username: row.username,
name: row.name,
description: row.description || undefined,
cover: row.cover || undefined,
song_count: row.song_count ?? 0,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
const results = await this.db
.prepare('SELECT * FROM music_v2_playlists WHERE username = ? ORDER BY updated_at DESC')
.bind(userName)
.all();
if (!results.results) return [];
return results.results.map((row: any) => ({
id: row.id,
username: row.username,
name: row.name,
description: row.description || undefined,
cover: row.cover || undefined,
song_count: row.song_count ?? 0,
created_at: row.created_at,
updated_at: row.updated_at,
}));
}
async updateMusicV2Playlist(playlistId: string, updates: {
name?: string;
description?: string;
cover?: string;
song_count?: number;
}): Promise<void> {
const fields: string[] = [];
const values: any[] = [];
if (updates.name !== undefined) {
fields.push('name = ?');
values.push(updates.name);
}
if (updates.description !== undefined) {
fields.push('description = ?');
values.push(updates.description || null);
}
if (updates.cover !== undefined) {
fields.push('cover = ?');
values.push(updates.cover || null);
}
if (updates.song_count !== undefined) {
fields.push('song_count = ?');
values.push(updates.song_count);
}
fields.push('updated_at = ?');
values.push(Date.now());
values.push(playlistId);
await this.db
.prepare(`UPDATE music_v2_playlists SET ${fields.join(', ')} WHERE id = ?`)
.bind(...values)
.run();
}
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
await this.db
.prepare('DELETE FROM music_v2_playlists WHERE id = ?')
.bind(playlistId)
.run();
}
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
const playlist = await this.getMusicV2Playlist(playlistId);
if (!playlist) {
throw new Error('歌单不存在');
}
const maxOrder: any = await this.db
.prepare('SELECT MAX(sort_order) as max_order FROM music_v2_playlist_items WHERE playlist_id = ?')
.bind(playlistId)
.first();
const nextOrder = Math.max(item.sortOrder || 0, (maxOrder?.max_order as number || 0) + 1);
const now = Date.now();
await this.db
.prepare(`
INSERT INTO music_v2_playlist_items (
playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(playlist_id, song_id) DO UPDATE SET
source = excluded.source,
songmid = excluded.songmid,
name = excluded.name,
artist = excluded.artist,
album = excluded.album,
cover = excluded.cover,
duration_text = excluded.duration_text,
duration_sec = excluded.duration_sec,
updated_at = excluded.updated_at
`)
.bind(
playlistId,
playlist.username,
item.songId,
item.source,
item.songmid || null,
item.name,
item.artist,
item.album || null,
item.cover || null,
item.durationText || null,
item.durationSec ?? null,
nextOrder,
item.addedAt || now,
now
)
.run();
const items = await this.listMusicV2PlaylistItems(playlistId);
await this.updateMusicV2Playlist(playlistId, {
song_count: items.length,
cover: items[0]?.cover,
});
}
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
await this.db
.prepare('DELETE FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ?')
.bind(playlistId, songId)
.run();
const items = await this.listMusicV2PlaylistItems(playlistId);
await this.updateMusicV2Playlist(playlistId, {
song_count: items.length,
cover: items[0]?.cover || undefined,
});
}
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
const results = await this.db
.prepare('SELECT * FROM music_v2_playlist_items WHERE playlist_id = ? ORDER BY sort_order ASC, added_at ASC')
.bind(playlistId)
.all();
if (!results.results) return [];
return results.results.map((row: any) => ({
playlistId: row.playlist_id,
songId: row.song_id,
source: row.source,
songmid: row.songmid || undefined,
name: row.name,
artist: row.artist,
album: row.album || undefined,
cover: row.cover || undefined,
durationText: row.duration_text || undefined,
durationSec: row.duration_sec ?? undefined,
sortOrder: row.sort_order,
addedAt: row.added_at,
updatedAt: row.updated_at,
}));
}
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
const row = await this.db
.prepare('SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ? LIMIT 1')
.bind(playlistId, songId)
.first();
return row !== null;
}
// ==================== 辅助方法 ====================
private rowToPlayRecord(row: any): PlayRecord {
+98
View File
@@ -3,6 +3,7 @@
import { AdminConfig } from './admin.types';
import { MusicPlayRecord } from './db.client';
import { KvrocksStorage } from './kvrocks.db';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
import { RedisStorage } from './redis.db';
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
import { UpstashRedisStorage } from './upstash.db';
@@ -267,6 +268,103 @@ export class DbManager {
await this.storage.clearAllMusicPlayRecords(userName);
}
// Music V2 历史记录相关
async listMusicV2History(userName: string): Promise<MusicV2HistoryRecord[]> {
if (typeof (this.storage as any).listMusicV2History === 'function') {
return (this.storage as any).listMusicV2History(userName);
}
return [];
}
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
if (typeof (this.storage as any).upsertMusicV2History === 'function') {
await (this.storage as any).upsertMusicV2History(userName, record);
}
}
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
if (typeof (this.storage as any).batchUpsertMusicV2History === 'function') {
await (this.storage as any).batchUpsertMusicV2History(userName, records);
}
}
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
if (typeof (this.storage as any).deleteMusicV2History === 'function') {
await (this.storage as any).deleteMusicV2History(userName, songId);
}
}
async clearMusicV2History(userName: string): Promise<void> {
if (typeof (this.storage as any).clearMusicV2History === 'function') {
await (this.storage as any).clearMusicV2History(userName);
}
}
// Music V2 歌单相关
async createMusicV2Playlist(
userName: string,
playlist: { id: string; name: string; description?: string; cover?: string; }
): Promise<void> {
if (typeof (this.storage as any).createMusicV2Playlist === 'function') {
await (this.storage as any).createMusicV2Playlist(userName, playlist);
}
}
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
if (typeof (this.storage as any).getMusicV2Playlist === 'function') {
return (this.storage as any).getMusicV2Playlist(playlistId);
}
return null;
}
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
if (typeof (this.storage as any).listMusicV2Playlists === 'function') {
return (this.storage as any).listMusicV2Playlists(userName);
}
return [];
}
async updateMusicV2Playlist(
playlistId: string,
updates: { name?: string; description?: string; cover?: string; song_count?: number; }
): Promise<void> {
if (typeof (this.storage as any).updateMusicV2Playlist === 'function') {
await (this.storage as any).updateMusicV2Playlist(playlistId, updates);
}
}
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
if (typeof (this.storage as any).deleteMusicV2Playlist === 'function') {
await (this.storage as any).deleteMusicV2Playlist(playlistId);
}
}
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
if (typeof (this.storage as any).addMusicV2PlaylistItem === 'function') {
await (this.storage as any).addMusicV2PlaylistItem(playlistId, item);
}
}
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
if (typeof (this.storage as any).removeMusicV2PlaylistItem === 'function') {
await (this.storage as any).removeMusicV2PlaylistItem(playlistId, songId);
}
}
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
if (typeof (this.storage as any).listMusicV2PlaylistItems === 'function') {
return (this.storage as any).listMusicV2PlaylistItems(playlistId);
}
return [];
}
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
if (typeof (this.storage as any).hasMusicV2PlaylistItem === 'function') {
return (this.storage as any).hasMusicV2PlaylistItem(playlistId, songId);
}
return false;
}
// 音乐歌单相关方法
async createMusicPlaylist(
userName: string,
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
export async function getMusicV2Username(request: NextRequest): Promise<string | null> {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) return null;
if (authInfo.username !== process.env.USERNAME) {
const userInfo = await db.getUserInfoV2(authInfo.username);
if (!userInfo || userInfo.banned) {
return null;
}
}
return authInfo.username;
}
export function unauthorized() {
return NextResponse.json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }, { status: 401 });
}
export function badRequest(message: string, code = 'BAD_REQUEST') {
return NextResponse.json({ success: false, error: { code, message } }, { status: 400 });
}
export function internalError(message: string, details?: string) {
return NextResponse.json(
{ success: false, error: { code: 'INTERNAL_ERROR', message, details } },
{ status: 500 }
);
}
+308
View File
@@ -0,0 +1,308 @@
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
export type MusicQuality = '128k' | '192k' | '320k' | 'flac' | 'flac24bit';
export function normalizeMusicSource(source?: string): MusicSource | '' {
switch ((source || '').trim()) {
case 'wy':
case 'tx':
case 'kw':
case 'kg':
case 'mg':
return source as MusicSource;
case 'netease':
return 'wy';
case 'qq':
return 'tx';
case 'kuwo':
return 'kw';
default:
return '';
}
}
export function normalizeMusicQuality(quality?: string): Exclude<MusicQuality, 'flac24bit'> {
switch (quality) {
case '128k':
case '192k':
case '320k':
case 'flac':
return quality;
case 'flac24bit':
return 'flac';
default:
return '320k';
}
}
export interface MusicV2Song {
songId: string;
source: MusicSource;
songmid?: string;
name: string;
artist: string;
album?: string;
cover?: string;
durationText?: string;
durationSec?: number;
hash?: string;
copyrightId?: string;
albumId?: string;
lrcUrl?: string;
mrcUrl?: string;
trcUrl?: string;
}
export interface MusicV2HistoryRecord extends MusicV2Song {
playProgressSec: number;
lastPlayedAt: number;
playCount: number;
lastQuality?: string;
createdAt: number;
updatedAt: number;
}
export interface MusicV2PlaylistRecord {
id: string;
username: string;
name: string;
description?: string;
cover?: string;
song_count: number;
created_at: number;
updated_at: number;
}
export interface MusicV2PlaylistItem extends MusicV2Song {
playlistId: string;
sortOrder: number;
addedAt: number;
updatedAt: number;
}
export interface LxServerSong {
id: string;
name: string;
singer: string;
source: string;
interval?: string;
albumName?: string;
img?: string;
songmid?: string;
}
export function isMusicSource(source: string | null | undefined): source is MusicSource {
return !!source && ['wy', 'tx', 'kw', 'kg', 'mg'].includes(source);
}
export function parseDurationTextToSec(durationText?: string): number | undefined {
if (!durationText) return undefined;
const parts = durationText.split(':').map(part => Number(part));
if (parts.length !== 2 || parts.some(num => Number.isNaN(num))) {
return undefined;
}
return parts[0] * 60 + parts[1];
}
export function normalizeSong(input: Partial<MusicV2Song> & {
songId?: string;
id?: string;
source?: string;
name?: string;
artist?: string;
singer?: string;
songmid?: string;
album?: string;
albumName?: string;
cover?: string;
pic?: string;
img?: string;
durationText?: string;
interval?: string;
durationSec?: number;
hash?: string;
copyrightId?: string;
albumId?: string;
lrcUrl?: string;
mrcUrl?: string;
trcUrl?: string;
}): MusicV2Song {
const source = normalizeMusicSource(input.source) as MusicSource;
const rawSongId = (input.songId || input.id || '').trim();
const derivedSongmid = String(input.songmid || '').trim();
const songId = rawSongId || (source && derivedSongmid ? `${source}_${derivedSongmid}` : '');
const durationText = input.durationText || input.interval;
const durationSec = input.durationSec ?? parseDurationTextToSec(durationText);
return {
songId,
source,
songmid: derivedSongmid || songId.split('_').slice(1).join('_') || undefined,
name: (input.name || '').trim(),
artist: (input.artist || input.singer || '').trim(),
album: input.album || input.albumName || undefined,
cover: input.cover || input.pic || input.img || undefined,
durationText: durationText || undefined,
durationSec,
hash: input.hash || undefined,
copyrightId: input.copyrightId || undefined,
albumId: input.albumId || undefined,
lrcUrl: input.lrcUrl || undefined,
mrcUrl: input.mrcUrl || undefined,
trcUrl: input.trcUrl || undefined,
};
}
export function normalizeLxSong(song: LxServerSong): MusicV2Song {
return normalizeSong({
songId: song.id,
source: song.source as MusicSource,
songmid: song.songmid,
name: song.name,
artist: song.singer,
album: song.albumName,
cover: song.img,
durationText: song.interval,
});
}
export function unwrapLxArray<T>(payload: any): T[] {
if (Array.isArray(payload)) return payload as T[];
if (Array.isArray(payload?.list)) return payload.list as T[];
if (Array.isArray(payload?.data)) return payload.data as T[];
if (Array.isArray(payload?.data?.list)) return payload.data.list as T[];
if (Array.isArray(payload?.data?.data)) return payload.data.data as T[];
return [];
}
export async function getMusicV2Config() {
const config = await getConfig();
const musicConfig = config?.MusicConfig;
const enabled = musicConfig?.Enabled ?? false;
const baseUrl = (musicConfig?.BaseUrl || process.env.MUSIC_V2_BASE_URL || '').replace(/\/$/, '');
const token = musicConfig?.Token || process.env.MUSIC_V2_TOKEN || '';
return { enabled, baseUrl, token };
}
type LxFetchAuthMode = 'auto' | 'required' | 'none';
async function lxFetch(path: string, init: RequestInit = {}, authMode: LxFetchAuthMode = 'auto') {
const { enabled, baseUrl, token } = await getMusicV2Config();
if (!enabled) {
throw new Error('音乐功能未开启');
}
if (!baseUrl) {
throw new Error('未配置音乐服务地址');
}
const headers = new Headers(init.headers || {});
if (!headers.has('Content-Type') && init.body) {
headers.set('Content-Type', 'application/json');
}
headers.set('Accept', 'application/json');
if (authMode !== 'none' && token) {
headers.set('x-user-token', token);
} else if (authMode === 'required' && !token) {
throw new Error('未配置音乐服务访问 Token');
}
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers,
signal: AbortSignal.timeout(15000),
cache: 'no-store',
});
return response;
}
export async function lxGetJson<T>(path: string, authMode: LxFetchAuthMode = 'auto'): Promise<T> {
const response = await lxFetch(path, {}, authMode);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `请求失败(${response.status})`);
}
return response.json() as Promise<T>;
}
export async function lxPostJson<T>(path: string, body: any, authMode: LxFetchAuthMode = 'auto'): Promise<T> {
const response = await lxFetch(path, {
method: 'POST',
body: JSON.stringify(body),
}, authMode);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `请求失败(${response.status})`);
}
return response.json() as Promise<T>;
}
export function extractSongmid(song: Pick<MusicV2Song, 'songId' | 'songmid'>) {
return song.songmid || song.songId.split('_').slice(1).join('_');
}
function normalizeLyricPayload(payload: any) {
return {
lyric: typeof payload?.lyric === 'string'
? payload.lyric
: typeof payload?.lrc === 'string'
? payload.lrc
: '',
tlyric: typeof payload?.tlyric === 'string'
? payload.tlyric
: typeof payload?.trc === 'string'
? payload.trc
: '',
};
}
export async function fetchLxLyric(song: MusicV2Song) {
const songmid = extractSongmid(song);
const query = new URLSearchParams({
source: song.source,
songmid,
});
if (song.songId) query.set('id', song.songId);
if (song.name) query.set('name', song.name);
if (song.artist) query.set('singer', song.artist);
if (song.hash) query.set('hash', song.hash);
if (song.durationText) query.set('interval', song.durationText);
if (song.copyrightId) query.set('copyrightId', song.copyrightId);
if (song.albumId) query.set('albumId', song.albumId);
if (song.lrcUrl) query.set('lrcUrl', song.lrcUrl);
if (song.mrcUrl) query.set('mrcUrl', song.mrcUrl);
if (song.trcUrl) query.set('trcUrl', song.trcUrl);
try {
const payload = await lxGetJson<any>(`/api/music/lyric?${query.toString()}`, 'none');
return normalizeLyricPayload(payload);
} catch {
const payload = await lxPostJson<any>('/api/music/lyric', {
songInfo: {
source: song.source,
id: song.songId,
songId: songmid,
songmid,
name: song.name,
singer: song.artist,
artist: song.artist,
hash: song.hash,
interval: song.durationText,
copyrightId: song.copyrightId,
albumId: song.albumId,
lrcUrl: song.lrcUrl,
mrcUrl: song.mrcUrl,
trcUrl: song.trcUrl,
},
}, 'none');
return normalizeLyricPayload(payload);
}
}
+296
View File
@@ -19,6 +19,7 @@ import {
} from './types';
import { AdminConfig } from './admin.types';
import { DatabaseAdapter } from './d1-adapter';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
/**
* Vercel Postgres 存储实现
@@ -1373,6 +1374,301 @@ export class PostgresStorage implements IStorage {
}
}
// ==================== Music V2 历史记录相关 ====================
async listMusicV2History(userName: string): Promise<MusicV2HistoryRecord[]> {
try {
const results = await this.db
.prepare('SELECT * FROM music_v2_history WHERE username = $1 ORDER BY last_played_at DESC')
.bind(userName)
.all();
if (!results.results) return [];
return results.results.map((row: any) => ({
songId: row.song_id,
source: row.source,
songmid: row.songmid || undefined,
name: row.name,
artist: row.artist,
album: row.album || undefined,
cover: row.cover || undefined,
durationText: row.duration_text || undefined,
durationSec: row.duration_sec ?? undefined,
playProgressSec: row.play_progress_sec ?? 0,
lastPlayedAt: row.last_played_at,
playCount: row.play_count ?? 0,
lastQuality: row.last_quality || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
} catch (err) {
console.error('PostgresStorage.listMusicV2History error:', err);
return [];
}
}
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
try {
await this.db
.prepare(`
INSERT INTO music_v2_history (
username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec,
play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT(username, song_id) DO UPDATE SET
source = EXCLUDED.source,
songmid = EXCLUDED.songmid,
name = EXCLUDED.name,
artist = EXCLUDED.artist,
album = EXCLUDED.album,
cover = EXCLUDED.cover,
duration_text = EXCLUDED.duration_text,
duration_sec = EXCLUDED.duration_sec,
play_progress_sec = EXCLUDED.play_progress_sec,
last_played_at = EXCLUDED.last_played_at,
play_count = EXCLUDED.play_count,
last_quality = EXCLUDED.last_quality,
updated_at = EXCLUDED.updated_at
`)
.bind(
userName,
record.songId,
record.source,
record.songmid || null,
record.name,
record.artist,
record.album || null,
record.cover || null,
record.durationText || null,
record.durationSec ?? null,
record.playProgressSec,
record.lastPlayedAt,
record.playCount,
record.lastQuality || null,
record.createdAt,
record.updatedAt
)
.run();
} catch (err) {
console.error('PostgresStorage.upsertMusicV2History error:', err);
throw err;
}
}
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
for (const record of records) {
await this.upsertMusicV2History(userName, record);
}
}
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
await this.db
.prepare('DELETE FROM music_v2_history WHERE username = $1 AND song_id = $2')
.bind(userName, songId)
.run();
}
async clearMusicV2History(userName: string): Promise<void> {
await this.db
.prepare('DELETE FROM music_v2_history WHERE username = $1')
.bind(userName)
.run();
}
// ==================== Music V2 歌单相关 ====================
async createMusicV2Playlist(userName: string, playlist: {
id: string;
name: string;
description?: string;
cover?: string;
}): Promise<void> {
const now = Date.now();
await this.db
.prepare(`
INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`)
.bind(playlist.id, userName, playlist.name, playlist.description || null, playlist.cover || null, 0, now, now)
.run();
}
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
const row: any = await this.db
.prepare('SELECT * FROM music_v2_playlists WHERE id = $1')
.bind(playlistId)
.first();
if (!row) return null;
return {
id: row.id,
username: row.username,
name: row.name,
description: row.description || undefined,
cover: row.cover || undefined,
song_count: row.song_count ?? 0,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
const results = await this.db
.prepare('SELECT * FROM music_v2_playlists WHERE username = $1 ORDER BY updated_at DESC')
.bind(userName)
.all();
if (!results.results) return [];
return results.results.map((row: any) => ({
id: row.id,
username: row.username,
name: row.name,
description: row.description || undefined,
cover: row.cover || undefined,
song_count: row.song_count ?? 0,
created_at: row.created_at,
updated_at: row.updated_at,
}));
}
async updateMusicV2Playlist(playlistId: string, updates: {
name?: string;
description?: string;
cover?: string;
song_count?: number;
}): Promise<void> {
const clauses: string[] = [];
const values: any[] = [];
let index = 1;
if (updates.name !== undefined) {
clauses.push(`name = $${index++}`);
values.push(updates.name);
}
if (updates.description !== undefined) {
clauses.push(`description = $${index++}`);
values.push(updates.description || null);
}
if (updates.cover !== undefined) {
clauses.push(`cover = $${index++}`);
values.push(updates.cover || null);
}
if (updates.song_count !== undefined) {
clauses.push(`song_count = $${index++}`);
values.push(updates.song_count);
}
clauses.push(`updated_at = $${index++}`);
values.push(Date.now());
values.push(playlistId);
await this.db
.prepare(`UPDATE music_v2_playlists SET ${clauses.join(', ')} WHERE id = $${index}`)
.bind(...values)
.run();
}
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
await this.db
.prepare('DELETE FROM music_v2_playlists WHERE id = $1')
.bind(playlistId)
.run();
}
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
const playlist = await this.getMusicV2Playlist(playlistId);
if (!playlist) {
throw new Error('歌单不存在');
}
const maxSort: any = await this.db
.prepare('SELECT MAX(sort_order) as max_sort FROM music_v2_playlist_items WHERE playlist_id = $1')
.bind(playlistId)
.first();
const nextOrder = Math.max(item.sortOrder || 0, (maxSort?.max_sort as number || 0) + 1);
const now = Date.now();
await this.db
.prepare(`
INSERT INTO music_v2_playlist_items (
playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT(playlist_id, song_id) DO UPDATE SET
source = EXCLUDED.source,
songmid = EXCLUDED.songmid,
name = EXCLUDED.name,
artist = EXCLUDED.artist,
album = EXCLUDED.album,
cover = EXCLUDED.cover,
duration_text = EXCLUDED.duration_text,
duration_sec = EXCLUDED.duration_sec,
updated_at = EXCLUDED.updated_at
`)
.bind(
playlistId,
playlist.username,
item.songId,
item.source,
item.songmid || null,
item.name,
item.artist,
item.album || null,
item.cover || null,
item.durationText || null,
item.durationSec ?? null,
nextOrder,
item.addedAt || now,
now
)
.run();
const items = await this.listMusicV2PlaylistItems(playlistId);
await this.updateMusicV2Playlist(playlistId, {
song_count: items.length,
cover: items[0]?.cover || undefined,
});
}
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
await this.db
.prepare('DELETE FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2')
.bind(playlistId, songId)
.run();
const items = await this.listMusicV2PlaylistItems(playlistId);
await this.updateMusicV2Playlist(playlistId, {
song_count: items.length,
cover: items[0]?.cover || undefined,
});
}
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
const results = await this.db
.prepare('SELECT * FROM music_v2_playlist_items WHERE playlist_id = $1 ORDER BY sort_order ASC, added_at ASC')
.bind(playlistId)
.all();
if (!results.results) return [];
return results.results.map((row: any) => ({
playlistId: row.playlist_id,
songId: row.song_id,
source: row.source,
songmid: row.songmid || undefined,
name: row.name,
artist: row.artist,
album: row.album || undefined,
cover: row.cover || undefined,
durationText: row.duration_text || undefined,
durationSec: row.duration_sec ?? undefined,
sortOrder: row.sort_order,
addedAt: row.added_at,
updatedAt: row.updated_at,
}));
}
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
const row = await this.db
.prepare('SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2 LIMIT 1')
.bind(playlistId, songId)
.first();
return row !== null;
}
// ==================== 搜索历史 ====================
async getSearchHistory(userName: string): Promise<string[]> {
+160
View File
@@ -3,6 +3,7 @@
import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
import { RedisAdapter } from './redis-adapter';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
import { userInfoCache } from './user-cache';
@@ -791,6 +792,165 @@ export abstract class BaseRedisStorage implements IStorage {
return exists !== null;
}
// ---------- Music V2 历史记录 ----------
private musicV2HistoryKey(userName: string) {
return `u:${userName}:music:v2:history`;
}
async listMusicV2History(userName: string): Promise<MusicV2HistoryRecord[]> {
const rows = await this.withRetry(() =>
this.adapter.hGetAll(this.musicV2HistoryKey(userName))
);
return Object.values(rows || {})
.filter(Boolean)
.map(value => JSON.parse(value as string) as MusicV2HistoryRecord)
.sort((a, b) => b.lastPlayedAt - a.lastPlayedAt);
}
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(this.musicV2HistoryKey(userName), record.songId, JSON.stringify(record))
);
}
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
if (!records.length) return;
const payload: Record<string, string> = {};
for (const record of records) {
payload[record.songId] = JSON.stringify(record);
}
await this.withRetry(() => this.adapter.hSet(this.musicV2HistoryKey(userName), payload));
}
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.musicV2HistoryKey(userName), songId));
}
async clearMusicV2History(userName: string): Promise<void> {
await this.withRetry(() => this.adapter.del(this.musicV2HistoryKey(userName)));
}
// ---------- Music V2 歌单 ----------
private musicV2PlaylistsKey(userName: string) {
return `u:${userName}:music:v2:playlists`;
}
private musicV2PlaylistKey(playlistId: string) {
return `music:v2:playlist:${playlistId}`;
}
private musicV2PlaylistItemsKey(playlistId: string) {
return `music:v2:playlist:${playlistId}:items`;
}
async createMusicV2Playlist(userName: string, playlist: {
id: string;
name: string;
description?: string;
cover?: string;
}): Promise<void> {
const now = Date.now();
const payload = {
id: playlist.id,
username: userName,
name: playlist.name,
description: playlist.description || '',
cover: playlist.cover || '',
song_count: '0',
created_at: now.toString(),
updated_at: now.toString(),
};
await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlist.id), payload));
await this.withRetry(() =>
this.adapter.zAdd(this.musicV2PlaylistsKey(userName), { score: now, value: playlist.id })
);
}
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
const data = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistKey(playlistId)));
if (!data || Object.keys(data).length === 0) return null;
return {
id: data.id,
username: data.username,
name: data.name,
description: data.description || undefined,
cover: data.cover || undefined,
song_count: parseInt(data.song_count || '0', 10) || 0,
created_at: parseInt(data.created_at, 10),
updated_at: parseInt(data.updated_at, 10),
};
}
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
const playlistIds = await this.withRetry(() => this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1));
const playlists: MusicV2PlaylistRecord[] = [];
for (const playlistId of playlistIds || []) {
const playlist = await this.getMusicV2Playlist(ensureString(playlistId));
if (playlist) playlists.push(playlist);
}
return playlists.sort((a, b) => b.updated_at - a.updated_at);
}
async updateMusicV2Playlist(playlistId: string, updates: {
name?: string;
description?: string;
cover?: string;
song_count?: number;
}): Promise<void> {
const payload: Record<string, string> = {
updated_at: Date.now().toString(),
};
if (updates.name !== undefined) payload.name = updates.name;
if (updates.description !== undefined) payload.description = updates.description || '';
if (updates.cover !== undefined) payload.cover = updates.cover || '';
if (updates.song_count !== undefined) payload.song_count = String(updates.song_count);
await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlistId), payload));
}
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
const playlist = await this.getMusicV2Playlist(playlistId);
if (!playlist) return;
await this.withRetry(() => this.adapter.zRem(this.musicV2PlaylistsKey(playlist.username), playlistId));
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistKey(playlistId)));
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistItemsKey(playlistId)));
}
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
await this.withRetry(() =>
this.adapter.hSet(this.musicV2PlaylistItemsKey(playlistId), item.songId, JSON.stringify(item))
);
const items = await this.listMusicV2PlaylistItems(playlistId);
const playlist = await this.getMusicV2Playlist(playlistId);
await this.updateMusicV2Playlist(playlistId, {
song_count: items.length,
cover: playlist?.cover || item.cover,
});
}
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
await this.withRetry(() => this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId));
const items = await this.listMusicV2PlaylistItems(playlistId);
await this.updateMusicV2Playlist(playlistId, {
song_count: items.length,
cover: items[0]?.cover || '',
});
}
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
const rows = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId)));
return Object.values(rows || {})
.filter(Boolean)
.map(value => JSON.parse(value as string) as MusicV2PlaylistItem)
.sort((a, b) => a.sortOrder - b.sortOrder || a.addedAt - b.addedAt);
}
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
const exists = await this.withRetry(() => this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId));
return exists !== null;
}
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
private userPwdKey(user: string) {
return `u:${user}:pwd`;