From 19be34c6fe1617c252d937860df14dc525851264 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 1 Feb 2026 15:36:33 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E6=8E=A5=E5=85=A5tunehub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 93 +++ src/app/api/admin/site/route.ts | 14 +- src/app/api/music/route.ts | 343 ++++++++++ src/app/layout.tsx | 4 + src/app/music/page.tsx | 1069 +++++++++++++++++++++++++++++++ src/app/page.tsx | 23 +- src/lib/admin.types.ts | 4 + 7 files changed, 1548 insertions(+), 2 deletions(-) create mode 100644 src/app/api/music/route.ts create mode 100644 src/app/music/page.tsx diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 54da021..b99ad2a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -353,6 +353,9 @@ interface SiteConfig { OIDCClientId?: string; OIDCClientSecret?: string; OIDCButtonText?: string; + TuneHubEnabled?: boolean; + TuneHubBaseUrl?: string; + TuneHubApiKey?: string; } // 视频源数据类型 @@ -6790,6 +6793,9 @@ const SiteConfigComponent = ({ OIDCClientId: '', OIDCClientSecret: '', OIDCButtonText: '', + TuneHubEnabled: false, + TuneHubBaseUrl: 'https://tunehub.sayqz.com/api', + TuneHubApiKey: '', }); // 豆瓣数据源相关状态 @@ -7666,6 +7672,93 @@ const SiteConfigComponent = ({ + {/* TuneHub 音乐配置 */} +
+

+ TuneHub 音乐配置 +

+ + {/* 开启 TuneHub */} +
+
+ + +
+

+ 开启后将在首页显示音乐视听入口,支持网易云、QQ音乐、酷我音乐 +

+
+ + {/* TuneHub Base URL */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + TuneHubBaseUrl: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ TuneHub API 的基础地址,默认为 https://tunehub.sayqz.com/api。也可以通过环境变量 TUNEHUB_BASE_URL 配置 +

+
+ + {/* TuneHub API Key */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + TuneHubApiKey: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 用于解析歌曲播放链接的 API Key(消耗积分)。搜索、榜单、歌单等功能不需要 Key。也可以通过环境变量 TUNEHUB_API_KEY 配置 +

+
+
+ {/* 操作按钮 */}
+
+ + + +
+ 音乐 +
+
+ + + +
+ +
+ {currentView === 'songs' && ( + + )} +
+
+ + + +
+ setSearchKeyword(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="w-full h-full bg-black/30 border border-white/10 rounded-lg pl-9 pr-4 text-sm text-white focus:outline-none focus:border-green-500 focus:ring-2 focus:ring-green-500/50 font-mono placeholder-zinc-500" + placeholder="搜索歌曲或艺术家..." + /> +
+
+ + + + {/* Main Content */} +
+
+ {loading && ( +
加载中...
+ )} + + {/* Playlists View */} + {currentView === 'playlists' && !loading && ( +
+
+

排行榜

+ + {getSourceLabel()} + +
+
+ {playlists.map((playlist) => ( +
loadPlaylist(playlist.id, playlist.name)} + className="cursor-pointer group" + > +
+ {playlist.pic && ( + {playlist.name} + )} +
+ + + +
+
+

{playlist.name}

+ {playlist.updateFrequency && ( +

{playlist.updateFrequency}

+ )} +
+ ))} +
+
+ )} + + {/* Songs View */} + {currentView === 'songs' && !loading && ( +
+
+

+ {currentPlaylistTitle} +

+ + {songs.length} 首歌曲 + +
+
+ {songs.map((song, index) => ( +
playSong(song, index)} + className={`grid grid-cols-[40px_1fr_auto] md:grid-cols-[50px_2fr_1fr_auto] gap-2 px-3 py-3 rounded-lg cursor-pointer transition-all ${ + currentSongIndex === index + ? 'bg-white/12 border-l-2 border-green-500' + : 'hover:bg-white/5' + }`} + > +
{index + 1}
+
+
{song.name}
+
{song.artist}
+
+
{song.artist}
+
{getSourceLabel()}
+
+ ))} +
+
+ )} +
+
+ + {/* Player */} + {showPlayer && currentSong && ( +
+
+ {/* Progress Bar */} +
+
+ +
+ +
+ {/* Song Info */} +
+
setShowLyrics(true)} + > + {currentSong.pic ? ( + {currentSong.name} { + // 图片加载失败时显示默认图标 + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( + + + + )} +
+
+
{currentSong.name}
+
{currentSong.artist}
+
+
+ + {/* Controls */} +
+ + + +
+ + {/* Right Controls */} +
+
+ +
+ + + +
+
+
+
+ )} + + {/* Audio Element */} +
)} + + {/* Playlist Modal */} + {showPlaylist && ( +
+
+ {/* Header */} +
+

播放列表

+ +
+ + {/* Playlist */} +
+ {playlist.length > 0 ? ( +
+ {playlist.map((song, index) => ( +
{ + setPlaylistIndex(index); + playSong(song, -1); + setShowPlaylist(false); + }} + className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors group ${ + index === playlistIndex + ? 'bg-green-500/20 border border-green-500/50' + : 'bg-white/5 hover:bg-white/10' + }`} + > +
+ {song.pic ? ( + {song.name} + ) : ( +
+ + + +
+ )} +
+
+
+ {song.name} +
+
{song.artist}
+
+ {index === playlistIndex ? ( + + + + ) : ( + + + + )} +
+ ))} +
+ ) : ( +
+ + + +

播放列表为空

+

播放歌曲后会自动添加到列表

+
+ )} +
+
+
+ )} + + {/* Quality Selection Menu */} + {showQualityMenu && ( +
setShowQualityMenu(false)} + > +
e.stopPropagation()} + > + {/* Header */} +
+

选择音质

+
+ + {/* Quality Options */} +
+ + + + + + + +
+ + {/* Cancel Button */} +
+ +
+
+
+ )} ); } From 4cb5bc27c6324ba7fc84aeb19457a36d24f2ef94 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 1 Feb 2026 17:07:50 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E9=9F=B3=E4=B9=90=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=A4=9A=E8=AE=BE=E5=A4=87=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/002_add_music_play_records.sql | 27 +++ src/app/api/music/playrecords/route.ts | 144 ++++++++++++ src/app/music/page.tsx | 2 +- src/lib/d1.db.ts | 117 ++++++++++ src/lib/db.client.ts | 271 ++++++++++++++++++++++ src/lib/db.ts | 33 ++- src/lib/redis-base.db.ts | 48 ++++ src/lib/types.ts | 7 + 8 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 migrations/002_add_music_play_records.sql create mode 100644 src/app/api/music/playrecords/route.ts diff --git a/migrations/002_add_music_play_records.sql b/migrations/002_add_music_play_records.sql new file mode 100644 index 0000000..4771147 --- /dev/null +++ b/migrations/002_add_music_play_records.sql @@ -0,0 +1,27 @@ +-- ============================================ +-- MoonTV Plus - 音乐播放记录表 +-- 版本: 1.1.0 +-- 创建时间: 2026-02-01 +-- ============================================ + +-- 音乐播放记录表 +CREATE TABLE IF NOT EXISTS music_play_records ( + username TEXT NOT NULL, + key TEXT NOT NULL, -- format: "platform+id" (e.g., "netease+12345") + platform TEXT NOT NULL CHECK(platform IN ('netease', 'qq', 'kuwo')), -- 音乐平台 + song_id TEXT NOT NULL, -- 歌曲ID + name TEXT NOT NULL, -- 歌曲名 + artist TEXT NOT NULL, -- 艺术家 + album TEXT, -- 专辑(可选) + pic TEXT, -- 封面图URL(可选) + play_time REAL NOT NULL DEFAULT 0, -- 播放进度(秒) + duration REAL NOT NULL DEFAULT 0, -- 总时长(秒) + save_time INTEGER NOT NULL, -- 保存时间戳 + PRIMARY KEY (username, key), + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); + +-- 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_music_play_records_username ON music_play_records(username); +CREATE INDEX IF NOT EXISTS idx_music_play_records_save_time ON music_play_records(username, save_time DESC); +CREATE INDEX IF NOT EXISTS idx_music_play_records_platform ON music_play_records(username, platform); diff --git a/src/app/api/music/playrecords/route.ts b/src/app/api/music/playrecords/route.ts new file mode 100644 index 0000000..3e7b8a5 --- /dev/null +++ b/src/app/api/music/playrecords/route.ts @@ -0,0 +1,144 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { MusicPlayRecord } from '@/lib/db.client'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + // 从 cookie 获取用户信息 + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // 检查用户状态 + if (authInfo.username !== process.env.USERNAME) { + // 非站长,检查用户存在或被封禁 + const userInfoV2 = await db.getUserInfoV2(authInfo.username); + if (!userInfoV2) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + if (userInfoV2.banned) { + return NextResponse.json({ error: '用户已被封禁' }, { status: 401 }); + } + } + + const records = await db.getAllMusicPlayRecords(authInfo.username); + return NextResponse.json(records, { status: 200 }); + } catch (err) { + console.error('获取音乐播放记录失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + // 从 cookie 获取用户信息 + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (authInfo.username !== process.env.USERNAME) { + // 非站长,检查用户存在或被封禁 + const userInfoV2 = await db.getUserInfoV2(authInfo.username); + if (!userInfoV2) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + if (userInfoV2.banned) { + return NextResponse.json({ error: '用户已被封禁' }, { status: 401 }); + } + } + + const body = await request.json(); + const { key, record }: { key: string; record: MusicPlayRecord } = body; + + if (!key || !record) { + return NextResponse.json( + { error: 'Missing key or record' }, + { status: 400 } + ); + } + + // 验证音乐播放记录数据 + if (!record.platform || !record.id || !record.name || !record.artist) { + return NextResponse.json( + { error: 'Invalid record data' }, + { status: 400 } + ); + } + + // 从key中解析platform和id + const [platform, id] = key.split('+'); + if (!platform || !id) { + return NextResponse.json( + { error: 'Invalid key format' }, + { status: 400 } + ); + } + + await db.saveMusicPlayRecord(authInfo.username, platform, id, record); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error('保存音乐播放记录失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + // 从 cookie 获取用户信息 + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (authInfo.username !== process.env.USERNAME) { + // 非站长,检查用户存在或被封禁 + const userInfoV2 = await db.getUserInfoV2(authInfo.username); + if (!userInfoV2) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + if (userInfoV2.banned) { + return NextResponse.json({ error: '用户已被封禁' }, { status: 401 }); + } + } + + const { searchParams } = new URL(request.url); + const key = searchParams.get('key'); + + if (key) { + // 删除单条记录 + const [platform, id] = key.split('+'); + if (!platform || !id) { + return NextResponse.json( + { error: 'Invalid key format' }, + { status: 400 } + ); + } + await db.deleteMusicPlayRecord(authInfo.username, platform, id); + } else { + // 清空所有记录 + await db.clearAllMusicPlayRecords(authInfo.username); + } + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error('删除音乐播放记录失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 6adc6bb..dfe9833 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -434,7 +434,7 @@ export default function MusicPage() { const timeRegex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g; lines.forEach(line => { - const matches = [...line.matchAll(timeRegex)]; + const matches = Array.from(line.matchAll(timeRegex)); if (matches.length > 0) { // 提取歌词文本(去掉所有时间标签) const text = line.replace(timeRegex, '').trim(); diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts index d9a9b69..17cb2c1 100644 --- a/src/lib/d1.db.ts +++ b/src/lib/d1.db.ts @@ -288,6 +288,123 @@ export class D1Storage implements IStorage { } } + // ==================== 音乐播放记录相关 ==================== + + async getMusicPlayRecord(userName: string, key: string): Promise { + try { + const result = await this.db + .prepare('SELECT * FROM music_play_records WHERE username = ? AND key = ?') + .bind(userName, key) + .first(); + + if (!result) return null; + + return { + platform: result.platform, + id: result.song_id, + name: result.name, + artist: result.artist, + album: result.album || undefined, + pic: result.pic || undefined, + play_time: result.play_time, + duration: result.duration, + save_time: result.save_time, + }; + } catch (err) { + console.error('D1Storage.getMusicPlayRecord error:', err); + return null; + } + } + + async setMusicPlayRecord(userName: string, key: string, record: any): Promise { + try { + await this.db + .prepare(` + INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(username, key) DO UPDATE SET + name = excluded.name, + artist = excluded.artist, + album = excluded.album, + pic = excluded.pic, + play_time = excluded.play_time, + duration = excluded.duration, + save_time = excluded.save_time + `) + .bind( + userName, + key, + record.platform, + record.id, + record.name, + record.artist, + record.album || null, + record.pic || null, + record.play_time, + record.duration, + record.save_time + ) + .run(); + } catch (err) { + console.error('D1Storage.setMusicPlayRecord error:', err); + throw err; + } + } + + async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> { + try { + const results = await this.db + .prepare('SELECT * FROM music_play_records WHERE username = ? ORDER BY save_time DESC') + .bind(userName) + .all(); + + const records: { [key: string]: any } = {}; + if (results.results) { + for (const row of results.results) { + records[row.key as string] = { + platform: row.platform, + id: row.song_id, + name: row.name, + artist: row.artist, + album: row.album || undefined, + pic: row.pic || undefined, + play_time: row.play_time, + duration: row.duration, + save_time: row.save_time, + }; + } + } + return records; + } catch (err) { + console.error('D1Storage.getAllMusicPlayRecords error:', err); + throw err; + } + } + + async deleteMusicPlayRecord(userName: string, key: string): Promise { + try { + await this.db + .prepare('DELETE FROM music_play_records WHERE username = ? AND key = ?') + .bind(userName, key) + .run(); + } catch (err) { + console.error('D1Storage.deleteMusicPlayRecord error:', err); + throw err; + } + } + + async clearAllMusicPlayRecords(userName: string): Promise { + try { + await this.db + .prepare('DELETE FROM music_play_records WHERE username = ?') + .bind(userName) + .run(); + } catch (err) { + console.error('D1Storage.clearAllMusicPlayRecords error:', err); + throw err; + } + } + // ==================== 辅助方法 ==================== private rowToPlayRecord(row: any): PlayRecord { diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 92d0861..beff712 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -57,6 +57,19 @@ export interface Favorite { vod_remarks?: string; // 视频备注信息 } +// ---- 音乐播放记录类型 ---- +export interface MusicPlayRecord { + platform: 'netease' | 'qq' | 'kuwo'; // 音乐平台 + id: string; // 歌曲ID + name: string; // 歌曲名 + artist: string; // 艺术家 + album?: string; // 专辑 + pic?: string; // 封面图 + play_time: number; // 播放进度(秒) + duration: number; // 总时长(秒) + save_time: number; // 记录保存时间(时间戳) +} + // ---- 缓存数据结构 ---- interface CacheData { data: T; @@ -70,12 +83,14 @@ interface UserCacheStore { searchHistory?: CacheData; skipConfigs?: CacheData>; danmakuFilterConfig?: CacheData; + musicPlayRecords?: CacheData>; // 音乐播放记录 } // ---- 常量 ---- const PLAY_RECORDS_KEY = 'moontv_play_records'; const FAVORITES_KEY = 'moontv_favorites'; const SEARCH_HISTORY_KEY = 'moontv_search_history'; +const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records'; // 缓存相关常量 const CACHE_PREFIX = 'moontv_cache_'; @@ -398,6 +413,32 @@ class HybridCacheManager { this.saveUserCache(username, userCache); } + /** + * 音乐播放记录缓存方法 + */ + getCachedMusicPlayRecords(): Record | null { + const username = this.getCurrentUsername(); + if (!username) return null; + + const userCache = this.getUserCache(username); + const cached = userCache.musicPlayRecords; + + if (cached && this.isCacheValid(cached)) { + return cached.data; + } + + return null; + } + + cacheMusicPlayRecords(data: Record): void { + const username = this.getCurrentUsername(); + if (!username) return; + + const userCache = this.getUserCache(username); + userCache.musicPlayRecords = this.createCacheData(data); + this.saveUserCache(username, userCache); + } + /** * 清除指定用户的所有缓存 */ @@ -1888,6 +1929,236 @@ export async function saveDanmakuFilterConfig( } } +// ---------------- 音乐播放记录相关 API ---------------- + +/** + * 获取全部音乐播放记录。 + * 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。 + */ +export async function getAllMusicPlayRecords(): Promise> { + // 服务器端渲染阶段直接返回空 + if (typeof window === 'undefined') { + return {}; + } + + // 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 优先从缓存获取数据 + const cachedData = cacheManager.getCachedMusicPlayRecords(); + + if (cachedData) { + // 返回缓存数据,同时后台异步更新 + fetchFromApi>(`/api/music/playrecords`) + .then((freshData) => { + // 只有数据真正不同时才更新缓存 + if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { + cacheManager.cacheMusicPlayRecords(freshData); + // 触发数据更新事件 + window.dispatchEvent( + new CustomEvent('musicPlayRecordsUpdated', { + detail: freshData, + }) + ); + } + }) + .catch((err) => { + console.warn('后台同步音乐播放记录失败:', err); + triggerGlobalError('后台同步音乐播放记录失败'); + }); + + return cachedData; + } else { + // 缓存为空,直接从 API 获取并缓存 + try { + const freshData = await fetchFromApi>( + `/api/music/playrecords` + ); + cacheManager.cacheMusicPlayRecords(freshData); + return freshData; + } catch (err) { + console.error('获取音乐播放记录失败:', err); + triggerGlobalError('获取音乐播放记录失败'); + return {}; + } + } + } + + // localstorage 模式 + try { + const raw = localStorage.getItem(MUSIC_PLAY_RECORDS_KEY); + if (!raw) return {}; + return JSON.parse(raw) as Record; + } catch (err) { + console.error('读取音乐播放记录失败:', err); + triggerGlobalError('读取音乐播放记录失败'); + return {}; + } +} + +/** + * 保存音乐播放记录。 + * 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。 + */ +export async function saveMusicPlayRecord( + platform: string, + id: string, + record: MusicPlayRecord +): Promise { + const key = generateStorageKey(platform, id); + + // 数据库存储模式:乐观更新策略(包括 redis 和 upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 立即更新缓存 + const cachedRecords = cacheManager.getCachedMusicPlayRecords() || {}; + cachedRecords[key] = record; + cacheManager.cacheMusicPlayRecords(cachedRecords); + + // 触发立即更新事件 + window.dispatchEvent( + new CustomEvent('musicPlayRecordsUpdated', { + detail: cachedRecords, + }) + ); + + // 异步同步到数据库 + try { + await fetchWithAuth('/api/music/playrecords', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key, record }), + }); + } catch (err) { + console.error('保存音乐播放记录失败:', err); + triggerGlobalError('保存音乐播放记录失败'); + throw err; + } + return; + } + + // localstorage 模式 + if (typeof window === 'undefined') { + console.warn('无法在服务端保存音乐播放记录到 localStorage'); + return; + } + + try { + const allRecords = await getAllMusicPlayRecords(); + allRecords[key] = record; + localStorage.setItem(MUSIC_PLAY_RECORDS_KEY, JSON.stringify(allRecords)); + window.dispatchEvent( + new CustomEvent('musicPlayRecordsUpdated', { + detail: allRecords, + }) + ); + } catch (err) { + console.error('保存音乐播放记录失败:', err); + triggerGlobalError('保存音乐播放记录失败'); + throw err; + } +} + +/** + * 删除音乐播放记录。 + * 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。 + */ +export async function deleteMusicPlayRecord( + platform: string, + id: string +): Promise { + const key = generateStorageKey(platform, id); + + // 数据库存储模式:乐观更新策略(包括 redis 和 upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 立即更新缓存 + const cachedRecords = cacheManager.getCachedMusicPlayRecords() || {}; + delete cachedRecords[key]; + cacheManager.cacheMusicPlayRecords(cachedRecords); + + // 触发立即更新事件 + window.dispatchEvent( + new CustomEvent('musicPlayRecordsUpdated', { + detail: cachedRecords, + }) + ); + + // 异步同步到数据库 + try { + await fetchWithAuth(`/api/music/playrecords?key=${encodeURIComponent(key)}`, { + method: 'DELETE', + }); + } catch (err) { + console.error('删除音乐播放记录失败:', err); + triggerGlobalError('删除音乐播放记录失败'); + throw err; + } + return; + } + + // localstorage 模式 + if (typeof window === 'undefined') { + console.warn('无法在服务端删除音乐播放记录到 localStorage'); + return; + } + + try { + const allRecords = await getAllMusicPlayRecords(); + delete allRecords[key]; + localStorage.setItem(MUSIC_PLAY_RECORDS_KEY, JSON.stringify(allRecords)); + window.dispatchEvent( + new CustomEvent('musicPlayRecordsUpdated', { + detail: allRecords, + }) + ); + } catch (err) { + console.error('删除音乐播放记录失败:', err); + triggerGlobalError('删除音乐播放记录失败'); + throw err; + } +} + +/** + * 清空全部音乐播放记录 + * 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。 + */ +export async function clearAllMusicPlayRecords(): Promise { + // 数据库存储模式:乐观更新策略(包括 redis 和 upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 立即更新缓存 + cacheManager.cacheMusicPlayRecords({}); + + // 触发立即更新事件 + window.dispatchEvent( + new CustomEvent('musicPlayRecordsUpdated', { + detail: {}, + }) + ); + + // 异步同步到数据库 + try { + await fetchWithAuth(`/api/music/playrecords`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + } catch (err) { + console.error('清空音乐播放记录失败:', err); + triggerGlobalError('清空音乐播放记录失败'); + throw err; + } + return; + } + + // localStorage 模式 + if (typeof window === 'undefined') return; + localStorage.removeItem(MUSIC_PLAY_RECORDS_KEY); + window.dispatchEvent( + new CustomEvent('musicPlayRecordsUpdated', { + detail: {}, + }) + ); +} + // ---------------- 集数过滤配置相关 API ---------------- /** diff --git a/src/lib/db.ts b/src/lib/db.ts index 9860caf..9a24e93 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ import { AdminConfig } from './admin.types'; +import { MusicPlayRecord } from './db.client'; import { KvrocksStorage } from './kvrocks.db'; import { RedisStorage } from './redis.db'; import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types'; @@ -199,7 +200,37 @@ export class DbManager { return favorite !== null; } - + // 音乐播放记录相关方法 + async saveMusicPlayRecord( + userName: string, + platform: string, + id: string, + record: MusicPlayRecord + ): Promise { + const key = generateStorageKey(platform, id); + await this.storage.setMusicPlayRecord(userName, key, record); + } + + async getAllMusicPlayRecords(userName: string): Promise<{ + [key: string]: MusicPlayRecord; + }> { + return this.storage.getAllMusicPlayRecords(userName); + } + + async deleteMusicPlayRecord( + userName: string, + platform: string, + id: string + ): Promise { + const key = generateStorageKey(platform, id); + await this.storage.deleteMusicPlayRecord(userName, key); + } + + async clearAllMusicPlayRecords(userName: string): Promise { + await this.storage.clearAllMusicPlayRecords(userName); + } + + async verifyUser(userName: string, password: string): Promise { return this.storage.verifyUser(userName, password); } diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index d04d960..9591619 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -515,6 +515,54 @@ export abstract class BaseRedisStorage implements IStorage { console.log(`用户 ${userName} 的收藏迁移完成`); } + // ---------- 音乐播放记录相关 ---------- + private musicPlayRecordHashKey(userName: string) { + return `u:${userName}:music_play_records`; + } + + async getMusicPlayRecord(userName: string, key: string): Promise { + const value = await this.withRetry(() => + this.adapter.hGet(this.musicPlayRecordHashKey(userName), key) + ); + return value ? JSON.parse(value) : null; + } + + async setMusicPlayRecord(userName: string, key: string, record: any): Promise { + await this.withRetry(() => + this.adapter.hSet( + this.musicPlayRecordHashKey(userName), + key, + JSON.stringify(record) + ) + ); + } + + async getAllMusicPlayRecords(userName: string): Promise> { + const hashData = await this.withRetry(() => + this.adapter.hGetAll(this.musicPlayRecordHashKey(userName)) + ); + + const result: Record = {}; + for (const [key, value] of Object.entries(hashData)) { + if (value) { + result[key] = JSON.parse(value); + } + } + return result; + } + + async deleteMusicPlayRecord(userName: string, key: string): Promise { + await this.withRetry(() => + this.adapter.hDel(this.musicPlayRecordHashKey(userName), key) + ); + } + + async clearAllMusicPlayRecords(userName: string): Promise { + await this.withRetry(() => + this.adapter.del(this.musicPlayRecordHashKey(userName)) + ); + } + // ---------- 用户注册 / 登录(旧版本,保持兼容) ---------- private userPwdKey(user: string) { return `u:${user}:pwd`; diff --git a/src/lib/types.ts b/src/lib/types.ts index bc71820..7889fb4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,6 +52,13 @@ export interface IStorage { // 迁移收藏 migrateFavorites(userName: string): Promise; + // 音乐播放记录相关 + getMusicPlayRecord(userName: string, key: string): Promise; + setMusicPlayRecord(userName: string, key: string, record: any): Promise; + getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }>; + deleteMusicPlayRecord(userName: string, key: string): Promise; + clearAllMusicPlayRecords(userName: string): Promise; + // 用户相关 verifyUser(userName: string, password: string): Promise; // 检查用户是否存在(无需密码) From 46220ee927c99d448fb3a9dc9bfb731c3ab3e1ef Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 1 Feb 2026 17:57:49 +0800 Subject: [PATCH 4/8] song cache --- src/app/api/music/playrecords/route.ts | 34 ++++- src/app/music/page.tsx | 85 ++++++++++++- src/lib/music-song-cache.ts | 169 +++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 src/lib/music-song-cache.ts diff --git a/src/app/api/music/playrecords/route.ts b/src/app/api/music/playrecords/route.ts index 3e7b8a5..8ecc0bc 100644 --- a/src/app/api/music/playrecords/route.ts +++ b/src/app/api/music/playrecords/route.ts @@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { db } from '@/lib/db'; import { MusicPlayRecord } from '@/lib/db.client'; +import { getCachedSongs, setCachedSong } from '@/lib/music-song-cache'; export const runtime = 'nodejs'; @@ -29,7 +30,28 @@ export async function GET(request: NextRequest) { } const records = await db.getAllMusicPlayRecords(authInfo.username); - return NextResponse.json(records, { status: 200 }); + + // 从缓存中获取歌曲信息并填充到记录中 + const keys = Object.keys(records).map(key => { + const [platform, id] = key.split('+'); + return { platform, id }; + }); + const cachedSongs = getCachedSongs(keys); + + // 将缓存的歌曲信息合并到记录中 + const enrichedRecords: Record = {}; + for (const [key, record] of Object.entries(records)) { + const cachedSong = cachedSongs.get(key); + enrichedRecords[key] = { + ...record, + name: cachedSong?.name || record.name, + artist: cachedSong?.artist || record.artist, + album: cachedSong?.album || record.album, + pic: cachedSong?.pic || record.pic, + }; + } + + return NextResponse.json(enrichedRecords, { status: 200 }); } catch (err) { console.error('获取音乐播放记录失败', err); return NextResponse.json( @@ -86,6 +108,16 @@ export async function POST(request: NextRequest) { } await db.saveMusicPlayRecord(authInfo.username, platform, id, record); + + // 缓存歌曲信息到服务器内存 + setCachedSong(platform, id, { + id: record.id, + name: record.name, + artist: record.artist, + album: record.album, + pic: record.pic, + }); + return NextResponse.json({ success: true }, { status: 200 }); } catch (err) { console.error('保存音乐播放记录失败', err); diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index dfe9833..d22ae3e 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -3,6 +3,11 @@ import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; +import { + getAllMusicPlayRecords, + saveMusicPlayRecord, + MusicPlayRecord, +} from '@/lib/db.client'; interface Song { id: string; @@ -145,9 +150,49 @@ export default function MusicPage() { } }; - // 页面加载时恢复播放状态 + // 页面加载时恢复播放状态和数据库记录 useEffect(() => { - restorePlayState(); + const initializePlayState = async () => { + // 先恢复 localStorage 中的播放状态 + restorePlayState(); + + // 从数据库加载播放记录 + try { + const dbRecords = await getAllMusicPlayRecords(); + + // 将数据库记录转换为前端格式 + const records: PlayRecord[] = []; + const songs: Song[] = []; + + Object.entries(dbRecords).forEach(([key, record]) => { + records.push({ + platform: record.platform, + id: record.id, + playTime: record.play_time, + duration: record.duration, + timestamp: record.save_time, + }); + + songs.push({ + id: record.id, + name: record.name, + artist: record.artist, + album: record.album, + pic: record.pic, + }); + }); + + // 更新状态 + if (records.length > 0) { + setPlayRecords(records); + setPlaylist(songs); + } + } catch (error) { + console.error('加载播放记录失败:', error); + } + }; + + initializePlayState(); }, []); // 监听播放状态变化,自动保存 @@ -578,6 +623,24 @@ export default function MusicPage() { ...updated[playlistIndex], playTime: audio.currentTime, }; + + // 保存到数据库 + 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 => { + console.error('保存播放记录到数据库失败:', err); + }); } return updated; }); @@ -607,6 +670,24 @@ export default function MusicPage() { ...updated[playlistIndex], duration: audio.duration, }; + + // 保存到数据库(包含时长信息) + 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 => { + console.error('保存播放记录到数据库失败:', err); + }); } return updated; }); diff --git a/src/lib/music-song-cache.ts b/src/lib/music-song-cache.ts new file mode 100644 index 0000000..b303db5 --- /dev/null +++ b/src/lib/music-song-cache.ts @@ -0,0 +1,169 @@ +// 音乐歌曲信息缓存模块 - 基于 platform+id 的全局缓存 + +// 歌曲信息接口 +export interface SongInfo { + id: string; + name: string; + artist: string; + album?: string; + pic?: string; +} + +// 缓存条目接口 +export interface SongCacheEntry { + expiresAt: number; + data: SongInfo; +} + +// 缓存配置 +const SONG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24小时 +const CACHE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1小时清理一次 +const MAX_CACHE_SIZE = 5000; // 最大缓存条目数量 +const SONG_CACHE: Map = new Map(); + +// 惰性清理时间戳 +let lastCleanupTime = 0; + +/** + * 生成歌曲缓存键:platform+id + */ +function makeSongCacheKey(platform: string, id: string): string { + return `${platform}+${id}`; +} + +/** + * 获取缓存的歌曲信息 + */ +export function getCachedSong(platform: string, id: string): SongInfo | null { + const key = makeSongCacheKey(platform, id); + const entry = SONG_CACHE.get(key); + if (!entry) return null; + + // 检查是否过期 + if (entry.expiresAt <= Date.now()) { + SONG_CACHE.delete(key); + return null; + } + + return entry.data; +} + +/** + * 设置缓存的歌曲信息 + */ +export function setCachedSong(platform: string, id: string, songInfo: SongInfo): void { + // 惰性清理:每次写入时检查是否需要清理 + const now = Date.now(); + if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) { + performCacheCleanup(); + } + + const key = makeSongCacheKey(platform, id); + SONG_CACHE.set(key, { + expiresAt: now + SONG_CACHE_TTL_MS, + data: songInfo, + }); +} + +/** + * 批量获取缓存的歌曲信息 + */ +export function getCachedSongs(keys: Array<{ platform: string; id: string }>): Map { + const result = new Map(); + const now = Date.now(); + + for (const { platform, id } of keys) { + const key = makeSongCacheKey(platform, id); + const entry = SONG_CACHE.get(key); + + if (entry && entry.expiresAt > now) { + result.set(key, entry.data); + } + } + + return result; +} + +/** + * 批量设置缓存的歌曲信息 + */ +export function setCachedSongs(songs: Array<{ platform: string; id: string; songInfo: SongInfo }>): void { + const now = Date.now(); + + // 惰性清理 + if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) { + performCacheCleanup(); + } + + for (const { platform, id, songInfo } of songs) { + const key = makeSongCacheKey(platform, id); + SONG_CACHE.set(key, { + expiresAt: now + SONG_CACHE_TTL_MS, + data: songInfo, + }); + } +} + +/** + * 智能清理过期的缓存条目 + */ +function performCacheCleanup(): { expired: number; total: number; sizeLimited: number } { + const now = Date.now(); + const keysToDelete: string[] = []; + let sizeLimitedDeleted = 0; + + // 1. 清理过期条目 + SONG_CACHE.forEach((entry, key) => { + if (entry.expiresAt <= now) { + keysToDelete.push(key); + } + }); + + const expiredCount = keysToDelete.length; + keysToDelete.forEach(key => SONG_CACHE.delete(key)); + + // 2. 如果缓存大小超限,清理最老的条目(LRU策略) + if (SONG_CACHE.size > MAX_CACHE_SIZE) { + const entries = Array.from(SONG_CACHE.entries()); + // 按照过期时间排序,最早过期的在前面 + entries.sort((a, b) => a[1].expiresAt - b[1].expiresAt); + + const toRemove = SONG_CACHE.size - MAX_CACHE_SIZE; + for (let i = 0; i < toRemove; i++) { + SONG_CACHE.delete(entries[i][0]); + sizeLimitedDeleted++; + } + } + + lastCleanupTime = now; + + return { + expired: expiredCount, + total: SONG_CACHE.size, + sizeLimited: sizeLimitedDeleted + }; +} + +/** + * 清除所有歌曲缓存 + */ +export function clearSongCache(): { cleared: number } { + const size = SONG_CACHE.size; + SONG_CACHE.clear(); + return { cleared: size }; +} + +/** + * 获取缓存统计信息 + */ +export function getSongCacheStats(): { + size: number; + maxSize: number; + ttlMs: number; +} { + return { + size: SONG_CACHE.size, + maxSize: MAX_CACHE_SIZE, + ttlMs: SONG_CACHE_TTL_MS, + }; +} From e915a97a70d24f8a337778e55511022837d930ac Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 1 Feb 2026 20:07:41 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=89=8D=E7=AB=AFproxy?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/music/route.ts | 293 +++++++++++++++++++++---------------- src/app/music/page.tsx | 137 ++--------------- 2 files changed, 178 insertions(+), 252 deletions(-) diff --git a/src/app/api/music/route.ts b/src/app/api/music/route.ts index 9e96046..8848470 100644 --- a/src/app/api/music/route.ts +++ b/src/app/api/music/route.ts @@ -50,6 +50,86 @@ async function proxyRequest( } } +// 获取方法配置并执行请求 +async function executeMethod( + baseUrl: string, + platform: string, + func: string, + variables: Record = {} +): Promise { + // 1. 获取方法配置 + const cacheKey = `method-config-${platform}-${func}`; + let config: any; + + const cached = serverCache.methodConfigs.get(cacheKey); + if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) { + config = cached.data.data; + } else { + const response = await proxyRequest(`${baseUrl}/v1/methods/${platform}/${func}`); + const data = await response.json(); + serverCache.methodConfigs.set(cacheKey, { data, timestamp: Date.now() }); + config = data.data; + } + + if (!config) { + throw new Error('无法获取方法配置'); + } + + // 2. 替换模板变量 + let url = config.url; + const params: Record = {}; + + if (config.params) { + for (const [key, value] of Object.entries(config.params)) { + let processedValue = String(value); + // 替换所有模板变量 + for (const [varName, varValue] of Object.entries(variables)) { + processedValue = processedValue.replace(`{{${varName}}}`, varValue); + } + params[key] = processedValue; + } + } + + // 3. 构建完整 URL + if (config.method === 'GET' && Object.keys(params).length > 0) { + const urlObj = new URL(url); + for (const [key, value] of Object.entries(params)) { + urlObj.searchParams.append(key, value); + } + url = urlObj.toString(); + } + + // 4. 发起请求 + const requestOptions: RequestInit = { + method: config.method || 'GET', + headers: config.headers || {}, + }; + + if (config.method === 'POST' && config.body) { + requestOptions.body = JSON.stringify(config.body); + requestOptions.headers = { + ...requestOptions.headers, + 'Content-Type': 'application/json', + }; + } + + const response = await proxyRequest(url, requestOptions); + let data = await response.json(); + + // 5. 执行 transform 函数(如果有) + if (config.transform) { + try { + // eslint-disable-next-line no-eval + const transformFn = eval(`(${config.transform})`); + data = transformFn(data); + } catch (err) { + console.error('Transform 函数执行失败:', err); + } + } + + return data; +} + // GET 请求处理 export async function GET(request: NextRequest) { try { @@ -74,29 +154,8 @@ export async function GET(request: NextRequest) { // 处理不同的 action switch (action) { - case 'methods': { - // 获取所有平台方法 - const cacheKey = 'methods-all'; - const cached = serverCache.methodConfigs.get(cacheKey); - - if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) { - console.log('使用缓存: methods'); - return NextResponse.json(cached.data); - } - - const response = await proxyRequest(`${baseUrl}/v1/methods`); - const data = await response.json(); - - serverCache.methodConfigs.set(cacheKey, { - data, - timestamp: Date.now(), - }); - - return NextResponse.json(data); - } - - case 'platform-methods': { - // 获取指定平台的方法 + case 'toplists': { + // 获取排行榜列表 const platform = searchParams.get('platform'); if (!platform) { return NextResponse.json( @@ -105,95 +164,96 @@ export async function GET(request: NextRequest) { ); } - const cacheKey = `platform-methods-${platform}`; - const cached = serverCache.methodConfigs.get(cacheKey); - - if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) { - console.log(`使用缓存: platform-methods-${platform}`); - return NextResponse.json(cached.data); - } - - const response = await proxyRequest(`${baseUrl}/v1/methods/${platform}`); - const data = await response.json(); - - serverCache.methodConfigs.set(cacheKey, { - data, - timestamp: Date.now(), - }); - - return NextResponse.json(data); - } - - case 'method-config': { - // 获取指定平台指定方法的配置 - const platform = searchParams.get('platform'); - const func = searchParams.get('function'); - if (!platform || !func) { - return NextResponse.json( - { error: '缺少 platform 或 function 参数' }, - { status: 400 } - ); - } - - const cacheKey = `method-config-${platform}-${func}`; - const cached = serverCache.methodConfigs.get(cacheKey); - - if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) { - console.log(`使用缓存: method-config-${platform}-${func}`); - return NextResponse.json(cached.data); - } - - const response = await proxyRequest( - `${baseUrl}/v1/methods/${platform}/${func}` - ); - const data = await response.json(); - - serverCache.methodConfigs.set(cacheKey, { - data, - timestamp: Date.now(), - }); - - return NextResponse.json(data); - } - - case 'proxy': { - // 代理上游平台请求(用于方法下发后的实际请求) - const targetUrl = searchParams.get('url'); - if (!targetUrl) { - return NextResponse.json( - { error: '缺少 url 参数' }, - { status: 400 } - ); - } - - // 使用完整 URL 作为缓存键 - const cacheKey = `proxy-${targetUrl}`; + const cacheKey = `toplists-${platform}`; const cached = serverCache.proxyRequests.get(cacheKey); if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) { - console.log(`使用缓存: proxy request`); return NextResponse.json(cached.data); } - // 获取其他查询参数 - const params = new URLSearchParams(); - searchParams.forEach((value, key) => { - if (key !== 'action' && key !== 'url') { - params.append(key, value); - } - }); - - const fullUrl = params.toString() - ? `${targetUrl}?${params.toString()}` - : targetUrl; - - const response = await proxyRequest(fullUrl); - const data = await response.json(); - - serverCache.proxyRequests.set(cacheKey, { - data, - timestamp: Date.now(), + const data = await executeMethod(baseUrl, platform, 'toplists'); + serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() }); + + return NextResponse.json(data); + } + + case 'toplist': { + // 获取排行榜详情 + const platform = searchParams.get('platform'); + const id = searchParams.get('id'); + + if (!platform || !id) { + return NextResponse.json( + { error: '缺少 platform 或 id 参数' }, + { status: 400 } + ); + } + + const cacheKey = `toplist-${platform}-${id}`; + const cached = serverCache.proxyRequests.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) { + return NextResponse.json(cached.data); + } + + const data = await executeMethod(baseUrl, platform, 'toplist', { id }); + serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() }); + + return NextResponse.json(data); + } + + case 'playlist': { + // 获取歌单详情 + const platform = searchParams.get('platform'); + const id = searchParams.get('id'); + + if (!platform || !id) { + return NextResponse.json( + { error: '缺少 platform 或 id 参数' }, + { status: 400 } + ); + } + + const cacheKey = `playlist-${platform}-${id}`; + const cached = serverCache.proxyRequests.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) { + return NextResponse.json(cached.data); + } + + const data = await executeMethod(baseUrl, platform, 'playlist', { id }); + serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() }); + + return NextResponse.json(data); + } + + case 'search': { + // 搜索歌曲 + const platform = searchParams.get('platform'); + const keyword = searchParams.get('keyword'); + const page = searchParams.get('page') || '0'; + const pageSize = searchParams.get('pageSize') || '20'; + + if (!platform || !keyword) { + return NextResponse.json( + { error: '缺少 platform 或 keyword 参数' }, + { status: 400 } + ); + } + + const cacheKey = `search-${platform}-${keyword}-${page}-${pageSize}`; + const cached = serverCache.proxyRequests.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) { + return NextResponse.json(cached.data); + } + + const data = await executeMethod(baseUrl, platform, 'search', { + keyword, + page, + pageSize, }); + serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() }); return NextResponse.json(data); } @@ -301,29 +361,6 @@ export async function POST(request: NextRequest) { } } - case 'proxy-post': { - // 代理 POST 请求到上游平台 - const { url, data: postData, headers } = body; - if (!url) { - return NextResponse.json( - { error: '缺少 url 参数' }, - { status: 400 } - ); - } - - const response = await proxyRequest(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: JSON.stringify(postData), - }); - - const responseData = await response.json(); - return NextResponse.json(responseData); - } - default: return NextResponse.json( { error: '不支持的 action' }, diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index d22ae3e..2cf397f 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -207,41 +207,10 @@ export default function MusicPage() { setLoading(true); try { const response = await fetch( - `/api/music?action=method-config&platform=${source}&function=toplists` + `/api/music?action=toplists&platform=${source}` ); - const configData = await response.json(); - - if (configData.code === 0 && configData.data) { - const config = configData.data; - const url = new URL(config.url); - - // 添加参数 - if (config.params) { - Object.entries(config.params).forEach(([key, value]) => { - url.searchParams.append(key, String(value)); - }); - } - - // 通过后端代理请求 - const proxyResponse = await fetch( - `/api/music?action=proxy&url=${encodeURIComponent(url.toString())}` - ); - const data = await proxyResponse.json(); - - // 使用 transform 函数处理数据 - if (config.transform) { - try { - const transformFn = eval(`(${config.transform})`); - const transformed = transformFn(data); - setPlaylists(transformed || []); - } catch (err) { - console.error('Transform 函数执行失败:', err); - setPlaylists(data.list || data.data || []); - } - } else { - setPlaylists(data.list || data.data || []); - } - } + const data = await response.json(); + setPlaylists(data || []); } catch (error) { console.error('加载排行榜失败:', error); } finally { @@ -254,47 +223,12 @@ export default function MusicPage() { setLoading(true); try { const response = await fetch( - `/api/music?action=method-config&platform=${currentSource}&function=toplist` + `/api/music?action=toplist&platform=${currentSource}&id=${playlistId}` ); - const configData = await response.json(); - - if (configData.code === 0 && configData.data) { - const config = configData.data; - let url = config.url; - - // 替换模板变量 - if (config.params) { - const params = new URLSearchParams(); - Object.entries(config.params).forEach(([key, value]) => { - const processedValue = String(value).replace('{{id}}', playlistId); - params.append(key, processedValue); - }); - url = `${url}?${params.toString()}`; - } - - // 通过后端代理请求 - const proxyResponse = await fetch( - `/api/music?action=proxy&url=${encodeURIComponent(url)}` - ); - const data = await proxyResponse.json(); - - // 使用 transform 函数处理数据 - if (config.transform) { - try { - const transformFn = eval(`(${config.transform})`); - const transformed = transformFn(data); - setSongs(transformed || []); - } catch (err) { - console.error('Transform 函数执行失败:', err); - setSongs(data.songs || data.data || []); - } - } else { - setSongs(data.songs || data.data || []); - } - - setCurrentPlaylistTitle(playlistName); - setCurrentView('songs'); - } + const data = await response.json(); + setSongs(data || []); + setCurrentPlaylistTitle(playlistName); + setCurrentView('songs'); } catch (error) { console.error('加载歌单失败:', error); } finally { @@ -309,57 +243,12 @@ export default function MusicPage() { setLoading(true); try { const response = await fetch( - `/api/music?action=method-config&platform=${currentSource}&function=search` + `/api/music?action=search&platform=${currentSource}&keyword=${encodeURIComponent(searchKeyword)}&page=1&pageSize=20` ); - const configData = await response.json(); - - if (configData.code === 0 && configData.data) { - const config = configData.data; - let url = config.url; - - // 替换模板变量 - if (config.params) { - const params = new URLSearchParams(); - Object.entries(config.params).forEach(([key, value]) => { - let processedValue = String(value) - .replace('{{keyword}}', searchKeyword) - .replace('{{page}}', '1') - .replace('{{limit}}', '20') - .replace('{{pageSize}}', '20'); - - // 处理复杂表达式 - if (processedValue.includes('{{')) { - processedValue = processedValue.replace(/\{\{.*?\}\}/g, '0'); - } - - params.append(key, processedValue); - }); - url = `${url}?${params.toString()}`; - } - - // 通过后端代理请求 - const proxyResponse = await fetch( - `/api/music?action=proxy&url=${encodeURIComponent(url)}` - ); - const data = await proxyResponse.json(); - - // 使用 transform 函数处理数据 - if (config.transform) { - try { - const transformFn = eval(`(${config.transform})`); - const transformed = transformFn(data); - setSongs(transformed || []); - } catch (err) { - console.error('Transform 函数执行失败:', err); - setSongs(data.songs || data.data || []); - } - } else { - setSongs(data.songs || data.data || []); - } - - setCurrentPlaylistTitle(`搜索: ${searchKeyword}`); - setCurrentView('songs'); - } + const data = await response.json(); + setSongs(data || []); + setCurrentPlaylistTitle(`搜索: ${searchKeyword}`); + setCurrentView('songs'); } catch (error) { console.error('搜索失败:', error); } finally { From e08e953047c892a12e79b2fe9cc268283155c04b Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 1 Feb 2026 21:12:25 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E4=BB=A3=E7=90=86kuwo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/music/proxy/route.ts | 108 +++++++++++++++++++++++++ src/app/api/music/route.ts | 84 ++++++++++++++++--- src/app/music/page.tsx | 134 +++++++++++++++++++++++-------- 3 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 src/app/api/music/proxy/route.ts diff --git a/src/app/api/music/proxy/route.ts b/src/app/api/music/proxy/route.ts new file mode 100644 index 0000000..0235c18 --- /dev/null +++ b/src/app/api/music/proxy/route.ts @@ -0,0 +1,108 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +// 代理音频流 +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json( + { error: '缺少 url 参数' }, + { status: 400 } + ); + } + + // 安全检查:只允许代理音乐平台的音频和图片 CDN + const allowedDomains = [ + 'sycdn.kuwo.cn', + 'kwcdn.kuwo.cn', + 'img1.kwcdn.kuwo.cn', + 'img2.kwcdn.kuwo.cn', + 'img3.kwcdn.kuwo.cn', + 'img4.kwcdn.kuwo.cn', + 'music.163.com', + 'y.qq.com', + 'ws.stream.qqmusic.qq.com', + 'isure.stream.qqmusic.qq.com', + 'dl.stream.qqmusic.qq.com', + ]; + + let urlObj: URL; + try { + urlObj = new URL(url); + } catch { + return NextResponse.json( + { error: '无效的 URL' }, + { status: 400 } + ); + } + + const isAllowed = allowedDomains.some(domain => + urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`) + ); + + if (!isAllowed) { + console.warn(`拒绝代理音频请求: ${urlObj.hostname}`); + return NextResponse.json( + { error: '不允许的目标域名' }, + { status: 403 } + ); + } + + // 发起请求获取音频流 + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'http://www.kuwo.cn/', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: '获取音频失败' }, + { status: response.status } + ); + } + + // 获取响应头 + const contentType = response.headers.get('content-type') || 'audio/mpeg'; + const contentLength = response.headers.get('content-length'); + + // 创建响应头 + const headers: Record = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*', + }; + + if (contentLength) { + headers['Content-Length'] = contentLength; + } + + // 支持 Range 请求(用于音频拖动) + const range = request.headers.get('range'); + if (range) { + headers['Accept-Ranges'] = 'bytes'; + } + + // 返回音频流 + return new NextResponse(response.body, { + status: response.status, + headers, + }); + } catch (error) { + console.error('代理音频失败:', error); + return NextResponse.json( + { + error: '代理请求失败', + details: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/music/route.ts b/src/app/api/music/route.ts index 8848470..18f5035 100644 --- a/src/app/api/music/route.ts +++ b/src/app/api/music/route.ts @@ -79,17 +79,56 @@ async function executeMethod( let url = config.url; const params: Record = {}; + // 先将 variables 中的值转换为可执行的变量 + const evalContext: Record = {}; + for (const [key, value] of Object.entries(variables)) { + // 尝试将字符串转换为数字(如果可能) + const numValue = Number(value); + evalContext[key] = isNaN(numValue) ? value : numValue; + } + + // 递归处理对象中的模板变量 + function processTemplateValue(value: any): any { + if (typeof value === 'string') { + // 处理包含模板变量的表达式 + const expressionRegex = /\{\{(.+?)\}\}/g; + return value.replace(expressionRegex, (match, expression) => { + try { + // 创建一个函数来执行表达式,传入所有变量作为参数 + // eslint-disable-next-line no-new-func + const func = new Function(...Object.keys(evalContext), `return ${expression}`); + const result = func(...Object.values(evalContext)); + return String(result); + } catch (err) { + console.error(`[executeMethod] 执行表达式失败: ${expression}`, err); + return '0'; // 默认值 + } + }); + } else if (Array.isArray(value)) { + return value.map(item => processTemplateValue(item)); + } else if (typeof value === 'object' && value !== null) { + const result: any = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = processTemplateValue(v); + } + return result; + } + return value; + } + + // 处理 URL 参数 if (config.params) { for (const [key, value] of Object.entries(config.params)) { - let processedValue = String(value); - // 替换所有模板变量 - for (const [varName, varValue] of Object.entries(variables)) { - processedValue = processedValue.replace(`{{${varName}}}`, varValue); - } - params[key] = processedValue; + params[key] = processTemplateValue(value); } } + // 处理 POST body + let processedBody = config.body; + if (config.body) { + processedBody = processTemplateValue(config.body); + } + // 3. 构建完整 URL if (config.method === 'GET' && Object.keys(params).length > 0) { const urlObj = new URL(url); @@ -105,8 +144,8 @@ async function executeMethod( headers: config.headers || {}, }; - if (config.method === 'POST' && config.body) { - requestOptions.body = JSON.stringify(config.body); + if (config.method === 'POST' && processedBody) { + requestOptions.body = JSON.stringify(processedBody); requestOptions.headers = { ...requestOptions.headers, 'Content-Type': 'application/json', @@ -123,10 +162,31 @@ async function executeMethod( const transformFn = eval(`(${config.transform})`); data = transformFn(data); } catch (err) { - console.error('Transform 函数执行失败:', err); + console.error('[executeMethod] Transform 函数执行失败:', err); } } + // 6. 处理酷我音乐的图片 URL(转换为代理 URL) + if (platform === 'kuwo') { + const processKuwoImages = (obj: any): any => { + if (typeof obj === 'string' && obj.startsWith('http://') && obj.includes('kwcdn.kuwo.cn')) { + // 将 HTTP 图片 URL 转换为代理 URL + return `/api/music/proxy?url=${encodeURIComponent(obj)}`; + } else if (Array.isArray(obj)) { + return obj.map(item => processKuwoImages(item)); + } else if (typeof obj === 'object' && obj !== null) { + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = processKuwoImages(value); + } + return result; + } + return obj; + }; + + data = processKuwoImages(data); + } + return data; } @@ -231,7 +291,7 @@ export async function GET(request: NextRequest) { // 搜索歌曲 const platform = searchParams.get('platform'); const keyword = searchParams.get('keyword'); - const page = searchParams.get('page') || '0'; + const page = searchParams.get('page') || '1'; const pageSize = searchParams.get('pageSize') || '20'; if (!platform || !keyword) { @@ -248,11 +308,15 @@ export async function GET(request: NextRequest) { return NextResponse.json(cached.data); } + // 注意:不同平台可能使用不同的变量名 + // 统一传递 keyword, page, pageSize, limit (limit = pageSize) const data = await executeMethod(baseUrl, platform, 'search', { keyword, page, pageSize, + limit: pageSize, // 有些平台使用 limit 而不是 pageSize }); + serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() }); return NextResponse.json(data); diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 2cf397f..295cdc5 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -15,6 +15,7 @@ interface Song { artist: string; album?: string; pic?: string; + platform?: 'netease' | 'qq' | 'kuwo'; // 添加平台信息 } interface PlayRecord { @@ -70,6 +71,20 @@ export default function MusicPage() { const lastSaveTimeRef = useRef(0); const restoredTimeRef = useRef(0); + // 工具函数:处理图片 URL(在 HTTPS 环境下代理 HTTP 图片) + const processImageUrl = (url: string | undefined, platform: string): string | undefined => { + if (!url) return url; + + 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)}`; + } + + return url; + }; + // 保存播放状态到 localStorage const savePlayState = () => { if (!currentSong) return; @@ -96,7 +111,7 @@ export default function MusicPage() { }; // 从 localStorage 恢复播放状态 - const restorePlayState = () => { + const restorePlayState = async () => { try { const saved = localStorage.getItem('musicPlayState'); if (!saved) return; @@ -112,7 +127,6 @@ export default function MusicPage() { setQuality(playState.quality || '320k'); setPlayMode(playState.playMode || 'loop'); setVolume(playState.volume || 100); - setCurrentSongUrl(playState.currentSongUrl || ''); setLyrics(playState.lyrics || []); setPlayRecords(playState.playRecords || []); setPlaylist(playState.playlist || []); // 恢复播放列表 @@ -121,29 +135,65 @@ export default function MusicPage() { // 保存需要恢复的时间点 restoredTimeRef.current = playState.currentTime || 0; - if (playState.currentSong && playState.currentSongUrl) { + if (playState.currentSong) { setShowPlayer(true); - // 延迟设置音频源,等待 audio 元素加载 - setTimeout(() => { - if (audioRef.current && playState.currentSongUrl) { - audioRef.current.src = playState.currentSongUrl; - // 监听多个事件以确保进度恢复 - const restoreTime = () => { - if (audioRef.current && restoredTimeRef.current > 0) { - audioRef.current.currentTime = restoredTimeRef.current; - restoredTimeRef.current = 0; + // 获取歌曲的平台信息 + const platform = playState.currentSong.platform || playState.currentSource || 'netease'; + + // 重新解析歌曲获取新的播放链接 + try { + const response = await fetch('/api/music', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'parse', + platform: platform, + ids: playState.currentSong.id, + quality: playState.quality || '320k', + }), + }); + + const data = await response.json(); + + 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)}`; } - }; - // 监听加载完成事件 - audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true }); - audioRef.current.addEventListener('canplay', restoreTime, { once: true }); + setCurrentSongUrl(songData.url); - // 调用 load() 触发音频加载 - audioRef.current.load(); + // 延迟设置音频源,等待 audio 元素加载 + setTimeout(() => { + if (audioRef.current) { + audioRef.current.src = playUrl; + + // 监听多个事件以确保进度恢复 + const restoreTime = () => { + if (audioRef.current && restoredTimeRef.current > 0) { + audioRef.current.currentTime = restoredTimeRef.current; + restoredTimeRef.current = 0; + } + }; + + // 监听加载完成事件 + audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true }); + audioRef.current.addEventListener('canplay', restoreTime, { once: true }); + + // 调用 load() 触发音频加载 + audioRef.current.load(); + } + }, 100); + } } - }, 100); + } catch (error) { + console.error('重新解析歌曲失败:', error); + } } } catch (error) { console.error('恢复播放状态失败:', error); @@ -179,6 +229,7 @@ export default function MusicPage() { artist: record.artist, album: record.album, pic: record.pic, + platform: record.platform, // 添加平台信息 }); }); @@ -210,9 +261,11 @@ export default function MusicPage() { `/api/music?action=toplists&platform=${source}` ); const data = await response.json(); - setPlaylists(data || []); + // 确保返回的是数组 + setPlaylists(Array.isArray(data) ? data : []); } catch (error) { console.error('加载排行榜失败:', error); + setPlaylists([]); } finally { setLoading(false); } @@ -226,11 +279,13 @@ export default function MusicPage() { `/api/music?action=toplist&platform=${currentSource}&id=${playlistId}` ); const data = await response.json(); - setSongs(data || []); + // 确保返回的是数组 + setSongs(Array.isArray(data) ? data : []); setCurrentPlaylistTitle(playlistName); setCurrentView('songs'); } catch (error) { console.error('加载歌单失败:', error); + setSongs([]); } finally { setLoading(false); } @@ -246,11 +301,13 @@ export default function MusicPage() { `/api/music?action=search&platform=${currentSource}&keyword=${encodeURIComponent(searchKeyword)}&page=1&pageSize=20` ); const data = await response.json(); - setSongs(data || []); + // 确保返回的是数组 + setSongs(Array.isArray(data) ? data : []); setCurrentPlaylistTitle(`搜索: ${searchKeyword}`); setCurrentView('songs'); } catch (error) { console.error('搜索失败:', error); + setSongs([]); } finally { setLoading(false); } @@ -259,6 +316,9 @@ export default function MusicPage() { // 播放歌曲 const playSong = async (song: Song, index: number) => { try { + // 使用歌曲自己的平台信息,如果没有则使用当前选择的平台 + const platform = song.platform || currentSource; + // 先设置当前歌曲和显示播放器 setCurrentSong(song); setCurrentSongIndex(index); @@ -267,7 +327,7 @@ export default function MusicPage() { // 添加到播放记录和播放列表 const record: PlayRecord = { - platform: currentSource, + platform: platform, id: song.id, playTime: 0, // 初始播放时间 duration: 0, // 将在音频加载后更新 @@ -294,11 +354,11 @@ export default function MusicPage() { }); setPlaylist(prev => { - const existingIndex = prev.findIndex(s => s.id === song.id); + const existingIndex = prev.findIndex(s => s.id === song.id && s.platform === platform); if (existingIndex >= 0) { return prev; } else { - return [...prev, song]; + return [...prev, { ...song, platform }]; } }); @@ -308,39 +368,49 @@ export default function MusicPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'parse', - platform: currentSource, + platform: platform, // 使用歌曲的平台 ids: song.id, quality: quality, }), }); const data = await response.json(); - console.log('解析返回数据:', 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 (songData.cover) { + if (coverUrl) { setCurrentSong({ ...song, - pic: songData.cover, + pic: coverUrl, + platform, }); } - // 解析歌词 - 字段名是 lyrics + // 解析歌词 if (songData.lyrics) { const parsedLyrics = parseLyric(songData.lyrics); setLyrics(parsedLyrics); } - // 保存歌曲URL用于下载 + // 保存原始 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 = songData.url; + audioRef.current.src = playUrl; + audioRef.current.load(); audioRef.current.play().catch(err => { console.error('播放失败:', err); }); From 9b217b8eaed993526e00f631c697bfbf074e625d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 1 Feb 2026 21:26:10 +0800 Subject: [PATCH 7/8] 206 --- src/app/api/music/proxy/route.ts | 33 ++++++++++++------ src/app/music/page.tsx | 57 ++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/app/api/music/proxy/route.ts b/src/app/api/music/proxy/route.ts index 0235c18..01e8f2f 100644 --- a/src/app/api/music/proxy/route.ts +++ b/src/app/api/music/proxy/route.ts @@ -54,15 +54,26 @@ export async function GET(request: NextRequest) { ); } + // 检查是否有 Range 请求头 + const range = request.headers.get('range'); + + // 构建上游请求头 + const upstreamHeaders: Record = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'http://www.kuwo.cn/', + }; + + // 如果有 Range 请求,转发给上游 + if (range) { + upstreamHeaders['Range'] = range; + } + // 发起请求获取音频流 const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'http://www.kuwo.cn/', - }, + headers: upstreamHeaders, }); - if (!response.ok) { + if (!response.ok && response.status !== 206) { return NextResponse.json( { error: '获取音频失败' }, { status: response.status } @@ -72,25 +83,27 @@ export async function GET(request: NextRequest) { // 获取响应头 const contentType = response.headers.get('content-type') || 'audio/mpeg'; const contentLength = response.headers.get('content-length'); + const contentRange = response.headers.get('content-range'); + const acceptRanges = response.headers.get('accept-ranges'); // 创建响应头 const headers: Record = { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=3600', 'Access-Control-Allow-Origin': '*', + 'Accept-Ranges': acceptRanges || 'bytes', }; if (contentLength) { headers['Content-Length'] = contentLength; } - // 支持 Range 请求(用于音频拖动) - const range = request.headers.get('range'); - if (range) { - headers['Accept-Ranges'] = 'bytes'; + // 如果上游返回了 Content-Range,转发给客户端 + if (contentRange) { + headers['Content-Range'] = contentRange; } - // 返回音频流 + // 返回音频流,保持原始状态码(200 或 206) return new NextResponse(response.body, { status: response.status, headers, diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 295cdc5..2f4db8e 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -65,6 +65,7 @@ export default function MusicPage() { const [showPlaylist, setShowPlaylist] = useState(false); const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引 const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单 + const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态 const audioRef = useRef(null); const lyricsContainerRef = useRef(null); @@ -1047,8 +1048,19 @@ export default function MusicPage() { {/* Lyrics Modal */} {showLyrics && currentSong && ( -
-
+
{ + // 点击背景关闭音量条 + if (e.target === e.currentTarget) { + setShowVolumeSlider(false); + } + }} + > +
setShowVolumeSlider(false)} + > {/* Header */}
{currentSong.pic && ( @@ -1203,6 +1215,47 @@ export default function MusicPage() { )} + {/* 音量控制 */} +
+ + {/* 垂直音量条 - 桌面悬浮/移动端点击 */} +
e.stopPropagation()} + > +
+
+ {volume} +
+
+ +
+
+
+
+
{/* 进度条 */} From 34b62d62975e48aea164350e73a216b9cf234827 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 1 Feb 2026 22:07:28 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/music/page.tsx | 234 ++++++++++++++++++++++++++++++++--------- 1 file changed, 183 insertions(+), 51 deletions(-) diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 2f4db8e..e6fbc16 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -7,6 +7,8 @@ import { getAllMusicPlayRecords, saveMusicPlayRecord, MusicPlayRecord, + deleteMusicPlayRecord, + clearAllMusicPlayRecords, } from '@/lib/db.client'; interface Song { @@ -66,11 +68,13 @@ export default function MusicPage() { const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引 const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单 const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态 + const [pendingSongToPlay, setPendingSongToPlay] = useState<{ platform: string; id: string } | null>(null); // 待播放的歌曲信息 const audioRef = useRef(null); const lyricsContainerRef = useRef(null); const lastSaveTimeRef = useRef(0); const restoredTimeRef = useRef(0); + const songStartTimeRef = useRef(0); // 歌曲开始播放的时间戳 // 工具函数:处理图片 URL(在 HTTPS 环境下代理 HTTP 图片) const processImageUrl = (url: string | undefined, platform: string): string | undefined => { @@ -131,7 +135,10 @@ export default function MusicPage() { setLyrics(playState.lyrics || []); setPlayRecords(playState.playRecords || []); setPlaylist(playState.playlist || []); // 恢复播放列表 - setPlaylistIndex(playState.playlistIndex || -1); + + // 恢复 playlistIndex,如果没有则设置为 -1 + const restoredIndex = playState.playlistIndex ?? -1; + setPlaylistIndex(restoredIndex); // 保存需要恢复的时间点 restoredTimeRef.current = playState.currentTime || 0; @@ -139,6 +146,9 @@ export default function MusicPage() { if (playState.currentSong) { setShowPlayer(true); + // 记录歌曲开始播放的时间(恢复时也需要设置) + songStartTimeRef.current = Date.now(); + // 获取歌曲的平台信息 const platform = playState.currentSong.platform || playState.currentSource || 'netease'; @@ -238,6 +248,21 @@ export default function MusicPage() { if (records.length > 0) { setPlayRecords(records); setPlaylist(songs); + + // 如果当前有正在播放的歌曲,找到它在记录中的索引 + const savedPlayState = localStorage.getItem('musicPlayState'); + if (savedPlayState) { + const playState = JSON.parse(savedPlayState); + if (playState.currentSong) { + const platform = playState.currentSong.platform || playState.currentSource || 'netease'; + const currentIndex = records.findIndex( + r => r.platform === platform && r.id === playState.currentSong.id + ); + if (currentIndex >= 0) { + setPlaylistIndex(currentIndex); + } + } + } } } catch (error) { console.error('加载播放记录失败:', error); @@ -254,6 +279,17 @@ export default function MusicPage() { } }, [currentSong, currentSongIndex, songs, currentPlaylistTitle, currentSource, currentView, quality, playMode, volume, currentSongUrl, lyrics, playRecords, playlistIndex]); + // 监听 playRecords 变化,更新 playlistIndex + useEffect(() => { + if (pendingSongToPlay) { + const index = playRecords.findIndex( + r => r.platform === pendingSongToPlay.platform && r.id === pendingSongToPlay.id + ); + setPlaylistIndex(index); + setPendingSongToPlay(null); + } + }, [playRecords, pendingSongToPlay]); + // 加载排行榜列表 const loadPlaylists = async (source: string) => { setLoading(true); @@ -320,6 +356,9 @@ export default function MusicPage() { // 使用歌曲自己的平台信息,如果没有则使用当前选择的平台 const platform = song.platform || currentSource; + // 记录歌曲开始播放的时间 + songStartTimeRef.current = Date.now(); + // 先设置当前歌曲和显示播放器 setCurrentSong(song); setCurrentSongIndex(index); @@ -335,6 +374,9 @@ export default function MusicPage() { timestamp: Date.now(), }; + // 设置待播放歌曲信息,用于在 playRecords 更新后找到索引 + setPendingSongToPlay({ platform, id: song.id }); + setPlayRecords(prev => { const existingIndex = prev.findIndex(r => r.platform === record.platform && r.id === record.id); if (existingIndex >= 0) { @@ -344,13 +386,10 @@ export default function MusicPage() { ...updated[existingIndex], timestamp: Date.now(), }; - setPlaylistIndex(existingIndex); return updated; } else { // 新记录,添加到列表末尾 - const newRecords = [...prev, record]; - setPlaylistIndex(newRecords.length - 1); - return newRecords; + return [...prev, record]; } }); @@ -466,8 +505,33 @@ export default function MusicPage() { if (isPlaying) { audioRef.current.pause(); setIsPlaying(false); - // 暂停时保存状态 + // 暂停时保存状态到 localStorage 和数据库 savePlayState(); + + // 前5秒不保存(避免加载时的跳转触发保存) + if (Date.now() - songStartTimeRef.current < 5000) { + return; + } + + // 保存到数据库 + 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 => { + console.error('暂停时保存播放记录失败:', err); + }); + } } else { audioRef.current.play().catch(err => { console.error('播放失败:', err); @@ -574,6 +638,11 @@ export default function MusicPage() { if (now - lastSaveTimeRef.current > 20000) { lastSaveTimeRef.current = now; + // 前5秒不保存(避免加载时的跳转触发保存) + if (Date.now() - songStartTimeRef.current < 5000) { + return; + } + // 更新当前播放记录的播放时间 if (currentSong && playlistIndex >= 0) { setPlayRecords(prev => { @@ -621,6 +690,11 @@ export default function MusicPage() { const handleDurationChange = () => { setDuration(audio.duration); + // 前5秒不保存(避免加载时的跳转触发保存) + if (Date.now() - songStartTimeRef.current < 5000) { + return; + } + // 更新当前播放记录的总时长 if (currentSong && playlistIndex >= 0) { setPlayRecords(prev => { @@ -676,7 +750,7 @@ export default function MusicPage() { audio.removeEventListener('durationchange', handleDurationChange); audio.removeEventListener('ended', handleEnded); }; - }, [playMode, songs, currentSongIndex, lyrics]); + }, [playMode, songs, currentSongIndex, lyrics, currentSong, playlistIndex, playRecords]); // 初始加载 useEffect(() => { @@ -1249,7 +1323,6 @@ export default function MusicPage() { value={volume} onChange={handleVolumeChange} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer [writing-mode:bt-lr] [-webkit-appearance:slider-vertical]" - orient="vertical" />
@@ -1290,15 +1363,40 @@ export default function MusicPage() {
{/* Header */}
-

播放列表

- +
+

播放列表

+ ({playlist.length}) +
+
+ {playlist.length > 0 && ( + + )} + +
{/* Playlist */} @@ -1308,49 +1406,83 @@ export default function MusicPage() { {playlist.map((song, index) => (
{ - setPlaylistIndex(index); - playSong(song, -1); - setShowPlaylist(false); - }} - className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors group ${ + className={`flex items-center gap-3 p-3 rounded-lg transition-colors group ${ index === playlistIndex ? 'bg-green-500/20 border border-green-500/50' : 'bg-white/5 hover:bg-white/10' }`} > -
- {song.pic ? ( - {song.name} - ) : ( -
- - - +
{ + setPlaylistIndex(index); + playSong(song, -1); + setShowPlaylist(false); + }} + className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer" + > +
+ {song.pic ? ( + {song.name} + ) : ( +
+ + + +
+ )} +
+
+
+ {song.name}
+
{song.artist}
+
+ {index === playlistIndex ? ( + + + + ) : ( + + + )}
-
-
- {song.name} -
-
{song.artist}
-
- {index === playlistIndex ? ( - - +
))}