From c82d7b38418f754c78814c7b9a5d1cfe0bb7aa9a Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 13 Apr 2026 23:17:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9F=BA=E4=BA=8Elxserver?= =?UTF-8?q?=E7=9A=84=E9=9F=B3=E4=B9=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/002_add_music_play_records.sql | 72 +- migrations/005_music_v2.sql | 58 ++ migrations/postgres/002_add_music.sql | 71 +- migrations/postgres/005_music_v2.sql | 58 ++ src/app/admin/page.tsx | 186 ++++ src/app/api/admin/music/route.ts | 48 +- .../music/v2/discovery/board-songs/route.ts | 38 + .../api/music/v2/discovery/boards/route.ts | 58 ++ .../music/v2/discovery/hot-search/route.ts | 30 + src/app/api/music/v2/history/route.ts | 80 ++ src/app/api/music/v2/lyric/route.ts | 23 + src/app/api/music/v2/play/route.ts | 165 ++++ .../music/v2/playlists/[playlistId]/route.ts | 46 + .../v2/playlists/[playlistId]/songs/route.ts | 75 ++ src/app/api/music/v2/playlists/route.ts | 41 + src/app/api/music/v2/search/route.ts | 33 + src/app/api/music/v2/stream/route.ts | 93 ++ src/app/layout.tsx | 6 +- src/app/music/page.tsx | 792 ++++++++++-------- src/app/page.tsx | 34 +- src/components/AddToPlaylistModal.tsx | 19 +- src/components/LyricsPiPWindow.tsx | 23 +- src/lib/admin.types.ts | 24 +- src/lib/config.ts | 12 +- src/lib/d1.db.ts | 306 +++++++ src/lib/db.ts | 98 +++ src/lib/music-v2-api.ts | 33 + src/lib/music-v2.ts | 308 +++++++ src/lib/postgres.db.ts | 296 +++++++ src/lib/redis-base.db.ts | 160 ++++ 30 files changed, 2740 insertions(+), 546 deletions(-) create mode 100644 migrations/005_music_v2.sql create mode 100644 migrations/postgres/005_music_v2.sql create mode 100644 src/app/api/music/v2/discovery/board-songs/route.ts create mode 100644 src/app/api/music/v2/discovery/boards/route.ts create mode 100644 src/app/api/music/v2/discovery/hot-search/route.ts create mode 100644 src/app/api/music/v2/history/route.ts create mode 100644 src/app/api/music/v2/lyric/route.ts create mode 100644 src/app/api/music/v2/play/route.ts create mode 100644 src/app/api/music/v2/playlists/[playlistId]/route.ts create mode 100644 src/app/api/music/v2/playlists/[playlistId]/songs/route.ts create mode 100644 src/app/api/music/v2/playlists/route.ts create mode 100644 src/app/api/music/v2/search/route.ts create mode 100644 src/app/api/music/v2/stream/route.ts create mode 100644 src/lib/music-v2-api.ts create mode 100644 src/lib/music-v2.ts diff --git a/migrations/002_add_music_play_records.sql b/migrations/002_add_music_play_records.sql index 1647afa..3e8b5b8 100644 --- a/migrations/002_add_music_play_records.sql +++ b/migrations/002_add_music_play_records.sql @@ -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_* 表替代。 diff --git a/migrations/005_music_v2.sql b/migrations/005_music_v2.sql new file mode 100644 index 0000000..1f9d1e3 --- /dev/null +++ b/migrations/005_music_v2.sql @@ -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); diff --git a/migrations/postgres/002_add_music.sql b/migrations/postgres/002_add_music.sql index 9a16083..e37508b 100644 --- a/migrations/postgres/002_add_music.sql +++ b/migrations/postgres/002_add_music.sql @@ -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_* 表替代。 diff --git a/migrations/postgres/005_music_v2.sql b/migrations/postgres/005_music_v2.sql new file mode 100644 index 0000000..c36d887 --- /dev/null +++ b/migrations/postgres/005_music_v2.sql @@ -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); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 263c6ff..bfe981b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -11524,6 +11524,166 @@ const AIConfigComponent = ({ ); }; +// 音乐配置组件 +const MusicConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + 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 ( +
+
+
+ + + + + 使用说明 + +
+
+

• 音乐功能基于 lxserver 提供搜索、热搜、榜单、歌词与播放解析能力

+

• 建议填写服务端 Base URL 与持久 Token,由 MoonTV 服务端代为访问 lxserver

+

• 项目地址:https://github.com/XCQ0607/lxserver

+
+
+ +
+
+

+ 启用音乐功能 +

+

+ 关闭后不显示音乐入口,前端音乐页与接口将不可用 +

+
+ +
+ +
+
+ + 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' + /> +

+ 例如: http://127.0.0.1:9527 或 https://music.example.com +

+
+ +
+ + 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' + /> +

+ 推荐填写 lxserver 持久 Token;留空则按匿名访问处理 +

+
+
+ +
+ +
+ + +
+ ); +}; + // 直播源配置组件 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() { + + + + + + } + isExpanded={expandedTabs.musicConfig} + onToggle={() => toggleTab('musicConfig')} + > + + + {/* 电视直播源配置标签 */} (`/api/music/leaderboard/list?source=${source}&bangid=${encodeURIComponent(boardId)}&page=${page}`, 'none'); + const list = unwrapLxArray(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); + } +} diff --git a/src/app/api/music/v2/discovery/boards/route.ts b/src/app/api/music/v2/discovery/boards/route.ts new file mode 100644 index 0000000..d8f8231 --- /dev/null +++ b/src/app/api/music/v2/discovery/boards/route.ts @@ -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( + `/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); + } +} diff --git a/src/app/api/music/v2/discovery/hot-search/route.ts b/src/app/api/music/v2/discovery/hot-search/route.ts new file mode 100644 index 0000000..6c6ae47 --- /dev/null +++ b/src/app/api/music/v2/discovery/hot-search/route.ts @@ -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>(`/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); + } +} diff --git a/src/app/api/music/v2/history/route.ts b/src/app/api/music/v2/history/route.ts new file mode 100644 index 0000000..9170f62 --- /dev/null +++ b/src/app/api/music/v2/history/route.ts @@ -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); + } +} diff --git a/src/app/api/music/v2/lyric/route.ts b/src/app/api/music/v2/lyric/route.ts new file mode 100644 index 0000000..c779a67 --- /dev/null +++ b/src/app/api/music/v2/lyric/route.ts @@ -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); + } +} diff --git a/src/app/api/music/v2/play/route.ts b/src/app/api/music/v2/play/route.ts new file mode 100644 index 0000000..a9b765e --- /dev/null +++ b/src/app/api/music/v2/play/route.ts @@ -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; + lyric: { + lyric?: string; + tlyric?: string; + }; + meta: { + attempts: any[]; + }; +}; + +const globalMusicPlayMetaCache = globalThis as typeof globalThis & { + __musicV2PlayMetaCache?: Map; +}; + +const playMetaCache = globalMusicPlayMetaCache.__musicV2PlayMetaCache ?? new Map(); +globalMusicPlayMetaCache.__musicV2PlayMetaCache = playMetaCache; + +function buildStableStreamUrl(song: ReturnType, 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, 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); + } +} diff --git a/src/app/api/music/v2/playlists/[playlistId]/route.ts b/src/app/api/music/v2/playlists/[playlistId]/route.ts new file mode 100644 index 0000000..48e433b --- /dev/null +++ b/src/app/api/music/v2/playlists/[playlistId]/route.ts @@ -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); + } +} diff --git a/src/app/api/music/v2/playlists/[playlistId]/songs/route.ts b/src/app/api/music/v2/playlists/[playlistId]/songs/route.ts new file mode 100644 index 0000000..03ad7e5 --- /dev/null +++ b/src/app/api/music/v2/playlists/[playlistId]/songs/route.ts @@ -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); + } +} diff --git a/src/app/api/music/v2/playlists/route.ts b/src/app/api/music/v2/playlists/route.ts new file mode 100644 index 0000000..d592088 --- /dev/null +++ b/src/app/api/music/v2/playlists/route.ts @@ -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); + } +} diff --git a/src/app/api/music/v2/search/route.ts b/src/app/api/music/v2/search/route.ts new file mode 100644 index 0000000..a84e78b --- /dev/null +++ b/src/app/api/music/v2/search/route.ts @@ -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(`/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); + } +} diff --git a/src/app/api/music/v2/stream/route.ts b/src/app/api/music/v2/stream/route.ts new file mode 100644 index 0000000..bc54414 --- /dev/null +++ b/src/app/api/music/v2/stream/route.ts @@ -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 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 54378f6..2e45803 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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', }; diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 1c62400..5c88332 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -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('wy'); const [playlists, setPlaylists] = useState([]); const [songs, setSongs] = useState([]); 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(null); // 要添加到歌单的歌曲 @@ -134,18 +139,86 @@ export default function MusicPage() { const restoredTimeRef = useRef(0); const songStartTimeRef = useRef(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(); + 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 (
<> + {resolvingCount > 0 && ( +
+
+
+
+
+
解析中
+
{resolvingCount}
+
+
+
+ )} {/* Header */}
@@ -1297,38 +1333,48 @@ export default function MusicPage() {
音乐
-
+
- -
+
+ {musicSources.map((source) => ( + + ))} +
{(currentView === 'songs' || currentView === 'myPlaylists') && ( @@ -1385,34 +1431,41 @@ export default function MusicPage() { {getSourceLabel()}
-
- {playlists.map((playlist) => ( -
loadPlaylist(playlist.id, playlist.name)} - className="cursor-pointer group" - > -
- {playlist.pic && ( - {playlist.name} - )} -
- - - + {playlists.length > 0 ? ( +
+ {playlists.map((playlist, index) => ( +
-

{playlist.name}

- {playlist.updateFrequency && ( -

{playlist.updateFrequency}

- )} + + ))} +
+ ) : ( +
+
当前音源暂无排行榜
+
+ 你可以切换其它音源,或使用上方搜索继续找歌。
- ))} -
+
+ )}
)} @@ -1868,7 +1921,18 @@ export default function MusicPage() { : 'text-zinc-600 text-sm' }`} > - {line.text} +
{line.text}
+ {line.translation && ( +
+ {line.translation} +
+ )}
))}
@@ -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() {
)} + {showSourceMenu && ( +
+ + ); + })} +
+ + + )} + {/* Add to Playlist Modal */} { + 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() { - {/* 音乐视听入口(暂时隐藏,后续可能恢复) */} - {/** - * {musicEnabled && ( - * - * - * - * )} - */} + {musicEnabled && ( + + + + )} {/* 源站寻片入口 */} {sourceSearchEnabled && ( diff --git a/src/components/AddToPlaylistModal.tsx b/src/components/AddToPlaylistModal.tsx index d7d9e98..df62662 100644 --- a/src/components/AddToPlaylistModal.tsx +++ b/src/components/AddToPlaylistModal.tsx @@ -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, }, }), }); diff --git a/src/components/LyricsPiPWindow.tsx b/src/components/LyricsPiPWindow.tsx index 0735de5..d74f148 100644 --- a/src/components/LyricsPiPWindow.tsx +++ b/src/components/LyricsPiPWindow.tsx @@ -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} +
{line.text}
+ {line.translation && ( +
+ {line.translation} +
+ )} )) ) : ( diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 9c20072..2a81f3a 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -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; // 是否启用追番功能 diff --git a/src/lib/config.ts b/src/lib/config.ts index 042c89c..f19c646 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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: '', }; } diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts index aa47bce..c489a32 100644 --- a/src/lib/d1.db.ts +++ b/src/lib/d1.db.ts @@ -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 { + 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 { + 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 { + for (const record of records) { + await this.upsertMusicV2History(userName, record); + } + } + + async deleteMusicV2History(userName: string, songId: string): Promise { + await this.db + .prepare('DELETE FROM music_v2_history WHERE username = ? AND song_id = ?') + .bind(userName, songId) + .run(); + } + + async clearMusicV2History(userName: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.db + .prepare('DELETE FROM music_v2_playlists WHERE id = ?') + .bind(playlistId) + .run(); + } + + async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise { + 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 { + 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 { + 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 { + 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 { diff --git a/src/lib/db.ts b/src/lib/db.ts index 340bcc3..57f21a1 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 { + if (typeof (this.storage as any).listMusicV2History === 'function') { + return (this.storage as any).listMusicV2History(userName); + } + return []; + } + + async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise { + if (typeof (this.storage as any).upsertMusicV2History === 'function') { + await (this.storage as any).upsertMusicV2History(userName, record); + } + } + + async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise { + if (typeof (this.storage as any).batchUpsertMusicV2History === 'function') { + await (this.storage as any).batchUpsertMusicV2History(userName, records); + } + } + + async deleteMusicV2History(userName: string, songId: string): Promise { + if (typeof (this.storage as any).deleteMusicV2History === 'function') { + await (this.storage as any).deleteMusicV2History(userName, songId); + } + } + + async clearMusicV2History(userName: string): Promise { + 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 { + if (typeof (this.storage as any).createMusicV2Playlist === 'function') { + await (this.storage as any).createMusicV2Playlist(userName, playlist); + } + } + + async getMusicV2Playlist(playlistId: string): Promise { + if (typeof (this.storage as any).getMusicV2Playlist === 'function') { + return (this.storage as any).getMusicV2Playlist(playlistId); + } + return null; + } + + async listMusicV2Playlists(userName: string): Promise { + 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 { + if (typeof (this.storage as any).updateMusicV2Playlist === 'function') { + await (this.storage as any).updateMusicV2Playlist(playlistId, updates); + } + } + + async deleteMusicV2Playlist(playlistId: string): Promise { + if (typeof (this.storage as any).deleteMusicV2Playlist === 'function') { + await (this.storage as any).deleteMusicV2Playlist(playlistId); + } + } + + async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise { + if (typeof (this.storage as any).addMusicV2PlaylistItem === 'function') { + await (this.storage as any).addMusicV2PlaylistItem(playlistId, item); + } + } + + async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise { + if (typeof (this.storage as any).removeMusicV2PlaylistItem === 'function') { + await (this.storage as any).removeMusicV2PlaylistItem(playlistId, songId); + } + } + + async listMusicV2PlaylistItems(playlistId: string): Promise { + if (typeof (this.storage as any).listMusicV2PlaylistItems === 'function') { + return (this.storage as any).listMusicV2PlaylistItems(playlistId); + } + return []; + } + + async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise { + if (typeof (this.storage as any).hasMusicV2PlaylistItem === 'function') { + return (this.storage as any).hasMusicV2PlaylistItem(playlistId, songId); + } + return false; + } + // 音乐歌单相关方法 async createMusicPlaylist( userName: string, diff --git a/src/lib/music-v2-api.ts b/src/lib/music-v2-api.ts new file mode 100644 index 0000000..be08fd3 --- /dev/null +++ b/src/lib/music-v2-api.ts @@ -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 { + 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 } + ); +} diff --git a/src/lib/music-v2.ts b/src/lib/music-v2.ts new file mode 100644 index 0000000..3341bc2 --- /dev/null +++ b/src/lib/music-v2.ts @@ -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 { + 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 & { + 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(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(path: string, authMode: LxFetchAuthMode = 'auto'): Promise { + 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; +} + +export async function lxPostJson(path: string, body: any, authMode: LxFetchAuthMode = 'auto'): Promise { + 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; +} + +export function extractSongmid(song: Pick) { + 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(`/api/music/lyric?${query.toString()}`, 'none'); + return normalizeLyricPayload(payload); + } catch { + const payload = await lxPostJson('/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); + } +} diff --git a/src/lib/postgres.db.ts b/src/lib/postgres.db.ts index e138944..310c273 100644 --- a/src/lib/postgres.db.ts +++ b/src/lib/postgres.db.ts @@ -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 { + 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 { + 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 { + for (const record of records) { + await this.upsertMusicV2History(userName, record); + } + } + + async deleteMusicV2History(userName: string, songId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.db + .prepare('DELETE FROM music_v2_playlists WHERE id = $1') + .bind(playlistId) + .run(); + } + + async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise { + 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 { + 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 { + 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 { + 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 { diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index 4ddf952..fea6a7c 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -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 { + 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 { + await this.withRetry(() => + this.adapter.hSet(this.musicV2HistoryKey(userName), record.songId, JSON.stringify(record)) + ); + } + + async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise { + if (!records.length) return; + const payload: Record = {}; + 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 { + await this.withRetry(() => this.adapter.hDel(this.musicV2HistoryKey(userName), songId)); + } + + async clearMusicV2History(userName: string): Promise { + 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 { + 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 { + 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 { + 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 { + const payload: Record = { + 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 { + 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 { + 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 { + 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 { + 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 { + const exists = await this.withRetry(() => this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId)); + return exists !== null; + } + // ---------- 用户注册 / 登录(旧版本,保持兼容) ---------- private userPwdKey(user: string) { return `u:${user}:pwd`;