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/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 配置
+
+
+
+
{/* 操作按钮 */}
+ {/* 音乐视听入口 */}
+ {musicEnabled && (
+
+
+
+
+
+ )}
+
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index 15272f6..4d6d657 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -56,6 +56,10 @@ export interface AdminConfig {
OIDCClientSecret?: string; // OIDC Client Secret
OIDCButtonText?: string; // OIDC登录按钮文字
OIDCMinTrustLevel?: number; // 最低信任等级(仅LinuxDo网站有效,为0时不判断)
+ // TuneHub音乐配置
+ TuneHubEnabled?: boolean; // 启用音乐功能
+ TuneHubBaseUrl?: string; // TuneHub API地址
+ TuneHubApiKey?: string; // TuneHub API Key
};
UserConfig: {
Users: {
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/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,
+ };
+}
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;
// 检查用户是否存在(无需密码)