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; // 检查用户是否存在(无需密码)