音乐播放记录多设备同步。
This commit is contained in:
@@ -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);
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -434,7 +434,7 @@ export default function MusicPage() {
|
|||||||
const timeRegex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g;
|
const timeRegex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g;
|
||||||
|
|
||||||
lines.forEach(line => {
|
lines.forEach(line => {
|
||||||
const matches = [...line.matchAll(timeRegex)];
|
const matches = Array.from(line.matchAll(timeRegex));
|
||||||
if (matches.length > 0) {
|
if (matches.length > 0) {
|
||||||
// 提取歌词文本(去掉所有时间标签)
|
// 提取歌词文本(去掉所有时间标签)
|
||||||
const text = line.replace(timeRegex, '').trim();
|
const text = line.replace(timeRegex, '').trim();
|
||||||
|
|||||||
@@ -288,6 +288,123 @@ export class D1Storage implements IStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 音乐播放记录相关 ====================
|
||||||
|
|
||||||
|
async getMusicPlayRecord(userName: string, key: string): Promise<any | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 {
|
private rowToPlayRecord(row: any): PlayRecord {
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ export interface Favorite {
|
|||||||
vod_remarks?: string; // 视频备注信息
|
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<T> {
|
interface CacheData<T> {
|
||||||
data: T;
|
data: T;
|
||||||
@@ -70,12 +83,14 @@ interface UserCacheStore {
|
|||||||
searchHistory?: CacheData<string[]>;
|
searchHistory?: CacheData<string[]>;
|
||||||
skipConfigs?: CacheData<Record<string, SkipConfig>>;
|
skipConfigs?: CacheData<Record<string, SkipConfig>>;
|
||||||
danmakuFilterConfig?: CacheData<DanmakuFilterConfig>;
|
danmakuFilterConfig?: CacheData<DanmakuFilterConfig>;
|
||||||
|
musicPlayRecords?: CacheData<Record<string, MusicPlayRecord>>; // 音乐播放记录
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 常量 ----
|
// ---- 常量 ----
|
||||||
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||||
const FAVORITES_KEY = 'moontv_favorites';
|
const FAVORITES_KEY = 'moontv_favorites';
|
||||||
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||||
|
const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records';
|
||||||
|
|
||||||
// 缓存相关常量
|
// 缓存相关常量
|
||||||
const CACHE_PREFIX = 'moontv_cache_';
|
const CACHE_PREFIX = 'moontv_cache_';
|
||||||
@@ -398,6 +413,32 @@ class HybridCacheManager {
|
|||||||
this.saveUserCache(username, userCache);
|
this.saveUserCache(username, userCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音乐播放记录缓存方法
|
||||||
|
*/
|
||||||
|
getCachedMusicPlayRecords(): Record<string, MusicPlayRecord> | 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<string, MusicPlayRecord>): 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<Record<string, MusicPlayRecord>> {
|
||||||
|
// 服务器端渲染阶段直接返回空
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 优先从缓存获取数据
|
||||||
|
const cachedData = cacheManager.getCachedMusicPlayRecords();
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
// 返回缓存数据,同时后台异步更新
|
||||||
|
fetchFromApi<Record<string, MusicPlayRecord>>(`/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<Record<string, MusicPlayRecord>>(
|
||||||
|
`/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<string, MusicPlayRecord>;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('读取音乐播放记录失败:', err);
|
||||||
|
triggerGlobalError('读取音乐播放记录失败');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存音乐播放记录。
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function saveMusicPlayRecord(
|
||||||
|
platform: string,
|
||||||
|
id: string,
|
||||||
|
record: MusicPlayRecord
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 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 ----------------
|
// ---------------- 集数过滤配置相关 API ----------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+32
-1
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
import { AdminConfig } from './admin.types';
|
import { AdminConfig } from './admin.types';
|
||||||
|
import { MusicPlayRecord } from './db.client';
|
||||||
import { KvrocksStorage } from './kvrocks.db';
|
import { KvrocksStorage } from './kvrocks.db';
|
||||||
import { RedisStorage } from './redis.db';
|
import { RedisStorage } from './redis.db';
|
||||||
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||||
@@ -199,7 +200,37 @@ export class DbManager {
|
|||||||
return favorite !== null;
|
return favorite !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 音乐播放记录相关方法
|
||||||
|
async saveMusicPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
platform: string,
|
||||||
|
id: string,
|
||||||
|
record: MusicPlayRecord
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
const key = generateStorageKey(platform, id);
|
||||||
|
await this.storage.deleteMusicPlayRecord(userName, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAllMusicPlayRecords(userName: string): Promise<void> {
|
||||||
|
await this.storage.clearAllMusicPlayRecords(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||||
return this.storage.verifyUser(userName, password);
|
return this.storage.verifyUser(userName, password);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,6 +515,54 @@ export abstract class BaseRedisStorage implements IStorage {
|
|||||||
console.log(`用户 ${userName} 的收藏迁移完成`);
|
console.log(`用户 ${userName} 的收藏迁移完成`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 音乐播放记录相关 ----------
|
||||||
|
private musicPlayRecordHashKey(userName: string) {
|
||||||
|
return `u:${userName}:music_play_records`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMusicPlayRecord(userName: string, key: string): Promise<any | null> {
|
||||||
|
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<void> {
|
||||||
|
await this.withRetry(() =>
|
||||||
|
this.adapter.hSet(
|
||||||
|
this.musicPlayRecordHashKey(userName),
|
||||||
|
key,
|
||||||
|
JSON.stringify(record)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllMusicPlayRecords(userName: string): Promise<Record<string, any>> {
|
||||||
|
const hashData = await this.withRetry(() =>
|
||||||
|
this.adapter.hGetAll(this.musicPlayRecordHashKey(userName))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(hashData)) {
|
||||||
|
if (value) {
|
||||||
|
result[key] = JSON.parse(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMusicPlayRecord(userName: string, key: string): Promise<void> {
|
||||||
|
await this.withRetry(() =>
|
||||||
|
this.adapter.hDel(this.musicPlayRecordHashKey(userName), key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAllMusicPlayRecords(userName: string): Promise<void> {
|
||||||
|
await this.withRetry(() =>
|
||||||
|
this.adapter.del(this.musicPlayRecordHashKey(userName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
|
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
|
||||||
private userPwdKey(user: string) {
|
private userPwdKey(user: string) {
|
||||||
return `u:${user}:pwd`;
|
return `u:${user}:pwd`;
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ export interface IStorage {
|
|||||||
// 迁移收藏
|
// 迁移收藏
|
||||||
migrateFavorites(userName: string): Promise<void>;
|
migrateFavorites(userName: string): Promise<void>;
|
||||||
|
|
||||||
|
// 音乐播放记录相关
|
||||||
|
getMusicPlayRecord(userName: string, key: string): Promise<any | null>;
|
||||||
|
setMusicPlayRecord(userName: string, key: string, record: any): Promise<void>;
|
||||||
|
getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }>;
|
||||||
|
deleteMusicPlayRecord(userName: string, key: string): Promise<void>;
|
||||||
|
clearAllMusicPlayRecords(userName: string): Promise<void>;
|
||||||
|
|
||||||
// 用户相关
|
// 用户相关
|
||||||
verifyUser(userName: string, password: string): Promise<boolean>;
|
verifyUser(userName: string, password: string): Promise<boolean>;
|
||||||
// 检查用户是否存在(无需密码)
|
// 检查用户是否存在(无需密码)
|
||||||
|
|||||||
Reference in New Issue
Block a user