新增基于lxserver的音乐功能
This commit is contained in:
+13
-11
@@ -261,17 +261,19 @@ export interface AdminConfig {
|
||||
};
|
||||
};
|
||||
MusicConfig?: {
|
||||
// TuneHub音乐配置
|
||||
TuneHubEnabled?: boolean; // 启用音乐功能
|
||||
TuneHubBaseUrl?: string; // TuneHub API地址
|
||||
TuneHubApiKey?: string; // TuneHub API Key
|
||||
// OpenList缓存配置
|
||||
OpenListCacheEnabled?: boolean; // 启用OpenList缓存
|
||||
OpenListCacheURL?: string; // OpenList服务器地址
|
||||
OpenListCacheUsername?: string; // OpenList用户名
|
||||
OpenListCachePassword?: string; // OpenList密码
|
||||
OpenListCachePath?: string; // OpenList缓存目录路径
|
||||
OpenListCacheProxyEnabled?: boolean; // 启用缓存代理返回(默认开启)
|
||||
Enabled?: boolean; // 启用音乐功能
|
||||
BaseUrl?: string; // lxserver 地址
|
||||
Token?: string; // lxserver x-user-token
|
||||
// 兼容旧代码的遗留字段(待删除)
|
||||
TuneHubEnabled?: boolean;
|
||||
TuneHubBaseUrl?: string;
|
||||
TuneHubApiKey?: string;
|
||||
OpenListCacheEnabled?: boolean;
|
||||
OpenListCacheURL?: string;
|
||||
OpenListCacheUsername?: string;
|
||||
OpenListCachePassword?: string;
|
||||
OpenListCachePath?: string;
|
||||
OpenListCacheProxyEnabled?: boolean;
|
||||
};
|
||||
AnimeSubscriptionConfig?: {
|
||||
Enabled: boolean; // 是否启用追番功能
|
||||
|
||||
+3
-9
@@ -643,15 +643,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
// 确保音乐配置存在
|
||||
if (!adminConfig.MusicConfig) {
|
||||
adminConfig.MusicConfig = {
|
||||
TuneHubEnabled: false,
|
||||
TuneHubBaseUrl: 'https://tunehub.sayqz.com/api',
|
||||
TuneHubApiKey: '',
|
||||
OpenListCacheEnabled: false,
|
||||
OpenListCacheURL: '',
|
||||
OpenListCacheUsername: '',
|
||||
OpenListCachePassword: '',
|
||||
OpenListCachePath: '/music-cache',
|
||||
OpenListCacheProxyEnabled: true,
|
||||
Enabled: false,
|
||||
BaseUrl: '',
|
||||
Token: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './types';
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { DatabaseAdapter } from './d1-adapter';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { userInfoCache } from './user-cache';
|
||||
|
||||
/**
|
||||
@@ -715,6 +716,311 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Music V2 历史记录相关 ====================
|
||||
|
||||
async listMusicV2History(userName: string): Promise<MusicV2HistoryRecord[]> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_v2_history WHERE username = ? ORDER BY last_played_at DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
if (!results.results) return [];
|
||||
|
||||
return results.results.map((row: any) => ({
|
||||
songId: row.song_id,
|
||||
source: row.source,
|
||||
songmid: row.songmid || undefined,
|
||||
name: row.name,
|
||||
artist: row.artist,
|
||||
album: row.album || undefined,
|
||||
cover: row.cover || undefined,
|
||||
durationText: row.duration_text || undefined,
|
||||
durationSec: row.duration_sec ?? undefined,
|
||||
playProgressSec: row.play_progress_sec ?? 0,
|
||||
lastPlayedAt: row.last_played_at,
|
||||
playCount: row.play_count ?? 0,
|
||||
lastQuality: row.last_quality || undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('D1Storage.listMusicV2History error:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_v2_history (
|
||||
username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec,
|
||||
play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, song_id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
songmid = excluded.songmid,
|
||||
name = excluded.name,
|
||||
artist = excluded.artist,
|
||||
album = excluded.album,
|
||||
cover = excluded.cover,
|
||||
duration_text = excluded.duration_text,
|
||||
duration_sec = excluded.duration_sec,
|
||||
play_progress_sec = excluded.play_progress_sec,
|
||||
last_played_at = excluded.last_played_at,
|
||||
play_count = excluded.play_count,
|
||||
last_quality = excluded.last_quality,
|
||||
updated_at = excluded.updated_at
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
record.songId,
|
||||
record.source,
|
||||
record.songmid || null,
|
||||
record.name,
|
||||
record.artist,
|
||||
record.album || null,
|
||||
record.cover || null,
|
||||
record.durationText || null,
|
||||
record.durationSec ?? null,
|
||||
record.playProgressSec,
|
||||
record.lastPlayedAt,
|
||||
record.playCount,
|
||||
record.lastQuality || null,
|
||||
record.createdAt,
|
||||
record.updatedAt
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.upsertMusicV2History error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
|
||||
for (const record of records) {
|
||||
await this.upsertMusicV2History(userName, record);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_v2_history WHERE username = ? AND song_id = ?')
|
||||
.bind(userName, songId)
|
||||
.run();
|
||||
}
|
||||
|
||||
async clearMusicV2History(userName: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_v2_history WHERE username = ?')
|
||||
.bind(userName)
|
||||
.run();
|
||||
}
|
||||
|
||||
// ==================== Music V2 歌单相关 ====================
|
||||
|
||||
async createMusicV2Playlist(userName: string, playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.bind(playlist.id, userName, playlist.name, playlist.description || null, playlist.cover || null, 0, now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
|
||||
const row: any = await this.db
|
||||
.prepare('SELECT * FROM music_v2_playlists WHERE id = ?')
|
||||
.bind(playlistId)
|
||||
.first();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
description: row.description || undefined,
|
||||
cover: row.cover || undefined,
|
||||
song_count: row.song_count ?? 0,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_v2_playlists WHERE username = ? ORDER BY updated_at DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
if (!results.results) return [];
|
||||
|
||||
return results.results.map((row: any) => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
description: row.description || undefined,
|
||||
cover: row.cover || undefined,
|
||||
song_count: row.song_count ?? 0,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateMusicV2Playlist(playlistId: string, updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
song_count?: number;
|
||||
}): Promise<void> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
fields.push('name = ?');
|
||||
values.push(updates.name);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
fields.push('description = ?');
|
||||
values.push(updates.description || null);
|
||||
}
|
||||
if (updates.cover !== undefined) {
|
||||
fields.push('cover = ?');
|
||||
values.push(updates.cover || null);
|
||||
}
|
||||
if (updates.song_count !== undefined) {
|
||||
fields.push('song_count = ?');
|
||||
values.push(updates.song_count);
|
||||
}
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(playlistId);
|
||||
|
||||
await this.db
|
||||
.prepare(`UPDATE music_v2_playlists SET ${fields.join(', ')} WHERE id = ?`)
|
||||
.bind(...values)
|
||||
.run();
|
||||
}
|
||||
|
||||
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_v2_playlists WHERE id = ?')
|
||||
.bind(playlistId)
|
||||
.run();
|
||||
}
|
||||
|
||||
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
|
||||
const playlist = await this.getMusicV2Playlist(playlistId);
|
||||
if (!playlist) {
|
||||
throw new Error('歌单不存在');
|
||||
}
|
||||
|
||||
const maxOrder: any = await this.db
|
||||
.prepare('SELECT MAX(sort_order) as max_order FROM music_v2_playlist_items WHERE playlist_id = ?')
|
||||
.bind(playlistId)
|
||||
.first();
|
||||
const nextOrder = Math.max(item.sortOrder || 0, (maxOrder?.max_order as number || 0) + 1);
|
||||
const now = Date.now();
|
||||
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_v2_playlist_items (
|
||||
playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(playlist_id, song_id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
songmid = excluded.songmid,
|
||||
name = excluded.name,
|
||||
artist = excluded.artist,
|
||||
album = excluded.album,
|
||||
cover = excluded.cover,
|
||||
duration_text = excluded.duration_text,
|
||||
duration_sec = excluded.duration_sec,
|
||||
updated_at = excluded.updated_at
|
||||
`)
|
||||
.bind(
|
||||
playlistId,
|
||||
playlist.username,
|
||||
item.songId,
|
||||
item.source,
|
||||
item.songmid || null,
|
||||
item.name,
|
||||
item.artist,
|
||||
item.album || null,
|
||||
item.cover || null,
|
||||
item.durationText || null,
|
||||
item.durationSec ?? null,
|
||||
nextOrder,
|
||||
item.addedAt || now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
|
||||
const items = await this.listMusicV2PlaylistItems(playlistId);
|
||||
await this.updateMusicV2Playlist(playlistId, {
|
||||
song_count: items.length,
|
||||
cover: items[0]?.cover,
|
||||
});
|
||||
}
|
||||
|
||||
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ?')
|
||||
.bind(playlistId, songId)
|
||||
.run();
|
||||
|
||||
const items = await this.listMusicV2PlaylistItems(playlistId);
|
||||
await this.updateMusicV2Playlist(playlistId, {
|
||||
song_count: items.length,
|
||||
cover: items[0]?.cover || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_v2_playlist_items WHERE playlist_id = ? ORDER BY sort_order ASC, added_at ASC')
|
||||
.bind(playlistId)
|
||||
.all();
|
||||
|
||||
if (!results.results) return [];
|
||||
|
||||
return results.results.map((row: any) => ({
|
||||
playlistId: row.playlist_id,
|
||||
songId: row.song_id,
|
||||
source: row.source,
|
||||
songmid: row.songmid || undefined,
|
||||
name: row.name,
|
||||
artist: row.artist,
|
||||
album: row.album || undefined,
|
||||
cover: row.cover || undefined,
|
||||
durationText: row.duration_text || undefined,
|
||||
durationSec: row.duration_sec ?? undefined,
|
||||
sortOrder: row.sort_order,
|
||||
addedAt: row.added_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
|
||||
const row = await this.db
|
||||
.prepare('SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ? LIMIT 1')
|
||||
.bind(playlistId, songId)
|
||||
.first();
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private rowToPlayRecord(row: any): PlayRecord {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MusicPlayRecord } from './db.client';
|
||||
import { KvrocksStorage } from './kvrocks.db';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { RedisStorage } from './redis.db';
|
||||
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { UpstashRedisStorage } from './upstash.db';
|
||||
@@ -267,6 +268,103 @@ export class DbManager {
|
||||
await this.storage.clearAllMusicPlayRecords(userName);
|
||||
}
|
||||
|
||||
// Music V2 历史记录相关
|
||||
async listMusicV2History(userName: string): Promise<MusicV2HistoryRecord[]> {
|
||||
if (typeof (this.storage as any).listMusicV2History === 'function') {
|
||||
return (this.storage as any).listMusicV2History(userName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
|
||||
if (typeof (this.storage as any).upsertMusicV2History === 'function') {
|
||||
await (this.storage as any).upsertMusicV2History(userName, record);
|
||||
}
|
||||
}
|
||||
|
||||
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
|
||||
if (typeof (this.storage as any).batchUpsertMusicV2History === 'function') {
|
||||
await (this.storage as any).batchUpsertMusicV2History(userName, records);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteMusicV2History === 'function') {
|
||||
await (this.storage as any).deleteMusicV2History(userName, songId);
|
||||
}
|
||||
}
|
||||
|
||||
async clearMusicV2History(userName: string): Promise<void> {
|
||||
if (typeof (this.storage as any).clearMusicV2History === 'function') {
|
||||
await (this.storage as any).clearMusicV2History(userName);
|
||||
}
|
||||
}
|
||||
|
||||
// Music V2 歌单相关
|
||||
async createMusicV2Playlist(
|
||||
userName: string,
|
||||
playlist: { id: string; name: string; description?: string; cover?: string; }
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).createMusicV2Playlist === 'function') {
|
||||
await (this.storage as any).createMusicV2Playlist(userName, playlist);
|
||||
}
|
||||
}
|
||||
|
||||
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
|
||||
if (typeof (this.storage as any).getMusicV2Playlist === 'function') {
|
||||
return (this.storage as any).getMusicV2Playlist(playlistId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
|
||||
if (typeof (this.storage as any).listMusicV2Playlists === 'function') {
|
||||
return (this.storage as any).listMusicV2Playlists(userName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async updateMusicV2Playlist(
|
||||
playlistId: string,
|
||||
updates: { name?: string; description?: string; cover?: string; song_count?: number; }
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).updateMusicV2Playlist === 'function') {
|
||||
await (this.storage as any).updateMusicV2Playlist(playlistId, updates);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteMusicV2Playlist === 'function') {
|
||||
await (this.storage as any).deleteMusicV2Playlist(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
|
||||
if (typeof (this.storage as any).addMusicV2PlaylistItem === 'function') {
|
||||
await (this.storage as any).addMusicV2PlaylistItem(playlistId, item);
|
||||
}
|
||||
}
|
||||
|
||||
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).removeMusicV2PlaylistItem === 'function') {
|
||||
await (this.storage as any).removeMusicV2PlaylistItem(playlistId, songId);
|
||||
}
|
||||
}
|
||||
|
||||
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
|
||||
if (typeof (this.storage as any).listMusicV2PlaylistItems === 'function') {
|
||||
return (this.storage as any).listMusicV2PlaylistItems(playlistId);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
|
||||
if (typeof (this.storage as any).hasMusicV2PlaylistItem === 'function') {
|
||||
return (this.storage as any).hasMusicV2PlaylistItem(playlistId, songId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 音乐歌单相关方法
|
||||
async createMusicPlaylist(
|
||||
userName: string,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function getMusicV2Username(request: NextRequest): Promise<string | null> {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) return null;
|
||||
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
const userInfo = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfo || userInfo.banned) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return authInfo.username;
|
||||
}
|
||||
|
||||
export function unauthorized() {
|
||||
return NextResponse.json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }, { status: 401 });
|
||||
}
|
||||
|
||||
export function badRequest(message: string, code = 'BAD_REQUEST') {
|
||||
return NextResponse.json({ success: false, error: { code, message } }, { status: 400 });
|
||||
}
|
||||
|
||||
export function internalError(message: string, details?: string) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { code: 'INTERNAL_ERROR', message, details } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
|
||||
export type MusicQuality = '128k' | '192k' | '320k' | 'flac' | 'flac24bit';
|
||||
|
||||
export function normalizeMusicSource(source?: string): MusicSource | '' {
|
||||
switch ((source || '').trim()) {
|
||||
case 'wy':
|
||||
case 'tx':
|
||||
case 'kw':
|
||||
case 'kg':
|
||||
case 'mg':
|
||||
return source as MusicSource;
|
||||
case 'netease':
|
||||
return 'wy';
|
||||
case 'qq':
|
||||
return 'tx';
|
||||
case 'kuwo':
|
||||
return 'kw';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMusicQuality(quality?: string): Exclude<MusicQuality, 'flac24bit'> {
|
||||
switch (quality) {
|
||||
case '128k':
|
||||
case '192k':
|
||||
case '320k':
|
||||
case 'flac':
|
||||
return quality;
|
||||
case 'flac24bit':
|
||||
return 'flac';
|
||||
default:
|
||||
return '320k';
|
||||
}
|
||||
}
|
||||
|
||||
export interface MusicV2Song {
|
||||
songId: string;
|
||||
source: MusicSource;
|
||||
songmid?: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
cover?: string;
|
||||
durationText?: string;
|
||||
durationSec?: number;
|
||||
hash?: string;
|
||||
copyrightId?: string;
|
||||
albumId?: string;
|
||||
lrcUrl?: string;
|
||||
mrcUrl?: string;
|
||||
trcUrl?: string;
|
||||
}
|
||||
|
||||
export interface MusicV2HistoryRecord extends MusicV2Song {
|
||||
playProgressSec: number;
|
||||
lastPlayedAt: number;
|
||||
playCount: number;
|
||||
lastQuality?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface MusicV2PlaylistRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
song_count: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface MusicV2PlaylistItem extends MusicV2Song {
|
||||
playlistId: string;
|
||||
sortOrder: number;
|
||||
addedAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface LxServerSong {
|
||||
id: string;
|
||||
name: string;
|
||||
singer: string;
|
||||
source: string;
|
||||
interval?: string;
|
||||
albumName?: string;
|
||||
img?: string;
|
||||
songmid?: string;
|
||||
}
|
||||
|
||||
export function isMusicSource(source: string | null | undefined): source is MusicSource {
|
||||
return !!source && ['wy', 'tx', 'kw', 'kg', 'mg'].includes(source);
|
||||
}
|
||||
|
||||
export function parseDurationTextToSec(durationText?: string): number | undefined {
|
||||
if (!durationText) return undefined;
|
||||
const parts = durationText.split(':').map(part => Number(part));
|
||||
if (parts.length !== 2 || parts.some(num => Number.isNaN(num))) {
|
||||
return undefined;
|
||||
}
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
export function normalizeSong(input: Partial<MusicV2Song> & {
|
||||
songId?: string;
|
||||
id?: string;
|
||||
source?: string;
|
||||
name?: string;
|
||||
artist?: string;
|
||||
singer?: string;
|
||||
songmid?: string;
|
||||
album?: string;
|
||||
albumName?: string;
|
||||
cover?: string;
|
||||
pic?: string;
|
||||
img?: string;
|
||||
durationText?: string;
|
||||
interval?: string;
|
||||
durationSec?: number;
|
||||
hash?: string;
|
||||
copyrightId?: string;
|
||||
albumId?: string;
|
||||
lrcUrl?: string;
|
||||
mrcUrl?: string;
|
||||
trcUrl?: string;
|
||||
}): MusicV2Song {
|
||||
const source = normalizeMusicSource(input.source) as MusicSource;
|
||||
const rawSongId = (input.songId || input.id || '').trim();
|
||||
const derivedSongmid = String(input.songmid || '').trim();
|
||||
const songId = rawSongId || (source && derivedSongmid ? `${source}_${derivedSongmid}` : '');
|
||||
const durationText = input.durationText || input.interval;
|
||||
const durationSec = input.durationSec ?? parseDurationTextToSec(durationText);
|
||||
|
||||
return {
|
||||
songId,
|
||||
source,
|
||||
songmid: derivedSongmid || songId.split('_').slice(1).join('_') || undefined,
|
||||
name: (input.name || '').trim(),
|
||||
artist: (input.artist || input.singer || '').trim(),
|
||||
album: input.album || input.albumName || undefined,
|
||||
cover: input.cover || input.pic || input.img || undefined,
|
||||
durationText: durationText || undefined,
|
||||
durationSec,
|
||||
hash: input.hash || undefined,
|
||||
copyrightId: input.copyrightId || undefined,
|
||||
albumId: input.albumId || undefined,
|
||||
lrcUrl: input.lrcUrl || undefined,
|
||||
mrcUrl: input.mrcUrl || undefined,
|
||||
trcUrl: input.trcUrl || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLxSong(song: LxServerSong): MusicV2Song {
|
||||
return normalizeSong({
|
||||
songId: song.id,
|
||||
source: song.source as MusicSource,
|
||||
songmid: song.songmid,
|
||||
name: song.name,
|
||||
artist: song.singer,
|
||||
album: song.albumName,
|
||||
cover: song.img,
|
||||
durationText: song.interval,
|
||||
});
|
||||
}
|
||||
|
||||
export function unwrapLxArray<T>(payload: any): T[] {
|
||||
if (Array.isArray(payload)) return payload as T[];
|
||||
if (Array.isArray(payload?.list)) return payload.list as T[];
|
||||
if (Array.isArray(payload?.data)) return payload.data as T[];
|
||||
if (Array.isArray(payload?.data?.list)) return payload.data.list as T[];
|
||||
if (Array.isArray(payload?.data?.data)) return payload.data.data as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getMusicV2Config() {
|
||||
const config = await getConfig();
|
||||
const musicConfig = config?.MusicConfig;
|
||||
|
||||
const enabled = musicConfig?.Enabled ?? false;
|
||||
const baseUrl = (musicConfig?.BaseUrl || process.env.MUSIC_V2_BASE_URL || '').replace(/\/$/, '');
|
||||
const token = musicConfig?.Token || process.env.MUSIC_V2_TOKEN || '';
|
||||
|
||||
return { enabled, baseUrl, token };
|
||||
}
|
||||
|
||||
type LxFetchAuthMode = 'auto' | 'required' | 'none';
|
||||
|
||||
async function lxFetch(path: string, init: RequestInit = {}, authMode: LxFetchAuthMode = 'auto') {
|
||||
const { enabled, baseUrl, token } = await getMusicV2Config();
|
||||
|
||||
if (!enabled) {
|
||||
throw new Error('音乐功能未开启');
|
||||
}
|
||||
if (!baseUrl) {
|
||||
throw new Error('未配置音乐服务地址');
|
||||
}
|
||||
|
||||
const headers = new Headers(init.headers || {});
|
||||
if (!headers.has('Content-Type') && init.body) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
headers.set('Accept', 'application/json');
|
||||
if (authMode !== 'none' && token) {
|
||||
headers.set('x-user-token', token);
|
||||
} else if (authMode === 'required' && !token) {
|
||||
throw new Error('未配置音乐服务访问 Token');
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function lxGetJson<T>(path: string, authMode: LxFetchAuthMode = 'auto'): Promise<T> {
|
||||
const response = await lxFetch(path, {}, authMode);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `请求失败(${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function lxPostJson<T>(path: string, body: any, authMode: LxFetchAuthMode = 'auto'): Promise<T> {
|
||||
const response = await lxFetch(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}, authMode);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `请求失败(${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function extractSongmid(song: Pick<MusicV2Song, 'songId' | 'songmid'>) {
|
||||
return song.songmid || song.songId.split('_').slice(1).join('_');
|
||||
}
|
||||
|
||||
function normalizeLyricPayload(payload: any) {
|
||||
return {
|
||||
lyric: typeof payload?.lyric === 'string'
|
||||
? payload.lyric
|
||||
: typeof payload?.lrc === 'string'
|
||||
? payload.lrc
|
||||
: '',
|
||||
tlyric: typeof payload?.tlyric === 'string'
|
||||
? payload.tlyric
|
||||
: typeof payload?.trc === 'string'
|
||||
? payload.trc
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLxLyric(song: MusicV2Song) {
|
||||
const songmid = extractSongmid(song);
|
||||
const query = new URLSearchParams({
|
||||
source: song.source,
|
||||
songmid,
|
||||
});
|
||||
|
||||
if (song.songId) query.set('id', song.songId);
|
||||
if (song.name) query.set('name', song.name);
|
||||
if (song.artist) query.set('singer', song.artist);
|
||||
if (song.hash) query.set('hash', song.hash);
|
||||
if (song.durationText) query.set('interval', song.durationText);
|
||||
if (song.copyrightId) query.set('copyrightId', song.copyrightId);
|
||||
if (song.albumId) query.set('albumId', song.albumId);
|
||||
if (song.lrcUrl) query.set('lrcUrl', song.lrcUrl);
|
||||
if (song.mrcUrl) query.set('mrcUrl', song.mrcUrl);
|
||||
if (song.trcUrl) query.set('trcUrl', song.trcUrl);
|
||||
|
||||
try {
|
||||
const payload = await lxGetJson<any>(`/api/music/lyric?${query.toString()}`, 'none');
|
||||
return normalizeLyricPayload(payload);
|
||||
} catch {
|
||||
const payload = await lxPostJson<any>('/api/music/lyric', {
|
||||
songInfo: {
|
||||
source: song.source,
|
||||
id: song.songId,
|
||||
songId: songmid,
|
||||
songmid,
|
||||
name: song.name,
|
||||
singer: song.artist,
|
||||
artist: song.artist,
|
||||
hash: song.hash,
|
||||
interval: song.durationText,
|
||||
copyrightId: song.copyrightId,
|
||||
albumId: song.albumId,
|
||||
lrcUrl: song.lrcUrl,
|
||||
mrcUrl: song.mrcUrl,
|
||||
trcUrl: song.trcUrl,
|
||||
},
|
||||
}, 'none');
|
||||
|
||||
return normalizeLyricPayload(payload);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './types';
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { DatabaseAdapter } from './d1-adapter';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
|
||||
/**
|
||||
* Vercel Postgres 存储实现
|
||||
@@ -1373,6 +1374,301 @@ export class PostgresStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Music V2 历史记录相关 ====================
|
||||
|
||||
async listMusicV2History(userName: string): Promise<MusicV2HistoryRecord[]> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_v2_history WHERE username = $1 ORDER BY last_played_at DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
if (!results.results) return [];
|
||||
|
||||
return results.results.map((row: any) => ({
|
||||
songId: row.song_id,
|
||||
source: row.source,
|
||||
songmid: row.songmid || undefined,
|
||||
name: row.name,
|
||||
artist: row.artist,
|
||||
album: row.album || undefined,
|
||||
cover: row.cover || undefined,
|
||||
durationText: row.duration_text || undefined,
|
||||
durationSec: row.duration_sec ?? undefined,
|
||||
playProgressSec: row.play_progress_sec ?? 0,
|
||||
lastPlayedAt: row.last_played_at,
|
||||
playCount: row.play_count ?? 0,
|
||||
lastQuality: row.last_quality || undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.listMusicV2History error:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_v2_history (
|
||||
username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec,
|
||||
play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
ON CONFLICT(username, song_id) DO UPDATE SET
|
||||
source = EXCLUDED.source,
|
||||
songmid = EXCLUDED.songmid,
|
||||
name = EXCLUDED.name,
|
||||
artist = EXCLUDED.artist,
|
||||
album = EXCLUDED.album,
|
||||
cover = EXCLUDED.cover,
|
||||
duration_text = EXCLUDED.duration_text,
|
||||
duration_sec = EXCLUDED.duration_sec,
|
||||
play_progress_sec = EXCLUDED.play_progress_sec,
|
||||
last_played_at = EXCLUDED.last_played_at,
|
||||
play_count = EXCLUDED.play_count,
|
||||
last_quality = EXCLUDED.last_quality,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
record.songId,
|
||||
record.source,
|
||||
record.songmid || null,
|
||||
record.name,
|
||||
record.artist,
|
||||
record.album || null,
|
||||
record.cover || null,
|
||||
record.durationText || null,
|
||||
record.durationSec ?? null,
|
||||
record.playProgressSec,
|
||||
record.lastPlayedAt,
|
||||
record.playCount,
|
||||
record.lastQuality || null,
|
||||
record.createdAt,
|
||||
record.updatedAt
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.upsertMusicV2History error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
|
||||
for (const record of records) {
|
||||
await this.upsertMusicV2History(userName, record);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_v2_history WHERE username = $1 AND song_id = $2')
|
||||
.bind(userName, songId)
|
||||
.run();
|
||||
}
|
||||
|
||||
async clearMusicV2History(userName: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_v2_history WHERE username = $1')
|
||||
.bind(userName)
|
||||
.run();
|
||||
}
|
||||
|
||||
// ==================== Music V2 歌单相关 ====================
|
||||
|
||||
async createMusicV2Playlist(userName: string, playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`)
|
||||
.bind(playlist.id, userName, playlist.name, playlist.description || null, playlist.cover || null, 0, now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
|
||||
const row: any = await this.db
|
||||
.prepare('SELECT * FROM music_v2_playlists WHERE id = $1')
|
||||
.bind(playlistId)
|
||||
.first();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
description: row.description || undefined,
|
||||
cover: row.cover || undefined,
|
||||
song_count: row.song_count ?? 0,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_v2_playlists WHERE username = $1 ORDER BY updated_at DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
if (!results.results) return [];
|
||||
return results.results.map((row: any) => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
description: row.description || undefined,
|
||||
cover: row.cover || undefined,
|
||||
song_count: row.song_count ?? 0,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateMusicV2Playlist(playlistId: string, updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
song_count?: number;
|
||||
}): Promise<void> {
|
||||
const clauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let index = 1;
|
||||
if (updates.name !== undefined) {
|
||||
clauses.push(`name = $${index++}`);
|
||||
values.push(updates.name);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
clauses.push(`description = $${index++}`);
|
||||
values.push(updates.description || null);
|
||||
}
|
||||
if (updates.cover !== undefined) {
|
||||
clauses.push(`cover = $${index++}`);
|
||||
values.push(updates.cover || null);
|
||||
}
|
||||
if (updates.song_count !== undefined) {
|
||||
clauses.push(`song_count = $${index++}`);
|
||||
values.push(updates.song_count);
|
||||
}
|
||||
clauses.push(`updated_at = $${index++}`);
|
||||
values.push(Date.now());
|
||||
values.push(playlistId);
|
||||
await this.db
|
||||
.prepare(`UPDATE music_v2_playlists SET ${clauses.join(', ')} WHERE id = $${index}`)
|
||||
.bind(...values)
|
||||
.run();
|
||||
}
|
||||
|
||||
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_v2_playlists WHERE id = $1')
|
||||
.bind(playlistId)
|
||||
.run();
|
||||
}
|
||||
|
||||
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
|
||||
const playlist = await this.getMusicV2Playlist(playlistId);
|
||||
if (!playlist) {
|
||||
throw new Error('歌单不存在');
|
||||
}
|
||||
const maxSort: any = await this.db
|
||||
.prepare('SELECT MAX(sort_order) as max_sort FROM music_v2_playlist_items WHERE playlist_id = $1')
|
||||
.bind(playlistId)
|
||||
.first();
|
||||
const nextOrder = Math.max(item.sortOrder || 0, (maxSort?.max_sort as number || 0) + 1);
|
||||
const now = Date.now();
|
||||
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_v2_playlist_items (
|
||||
playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT(playlist_id, song_id) DO UPDATE SET
|
||||
source = EXCLUDED.source,
|
||||
songmid = EXCLUDED.songmid,
|
||||
name = EXCLUDED.name,
|
||||
artist = EXCLUDED.artist,
|
||||
album = EXCLUDED.album,
|
||||
cover = EXCLUDED.cover,
|
||||
duration_text = EXCLUDED.duration_text,
|
||||
duration_sec = EXCLUDED.duration_sec,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`)
|
||||
.bind(
|
||||
playlistId,
|
||||
playlist.username,
|
||||
item.songId,
|
||||
item.source,
|
||||
item.songmid || null,
|
||||
item.name,
|
||||
item.artist,
|
||||
item.album || null,
|
||||
item.cover || null,
|
||||
item.durationText || null,
|
||||
item.durationSec ?? null,
|
||||
nextOrder,
|
||||
item.addedAt || now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
|
||||
const items = await this.listMusicV2PlaylistItems(playlistId);
|
||||
await this.updateMusicV2Playlist(playlistId, {
|
||||
song_count: items.length,
|
||||
cover: items[0]?.cover || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2')
|
||||
.bind(playlistId, songId)
|
||||
.run();
|
||||
const items = await this.listMusicV2PlaylistItems(playlistId);
|
||||
await this.updateMusicV2Playlist(playlistId, {
|
||||
song_count: items.length,
|
||||
cover: items[0]?.cover || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_v2_playlist_items WHERE playlist_id = $1 ORDER BY sort_order ASC, added_at ASC')
|
||||
.bind(playlistId)
|
||||
.all();
|
||||
if (!results.results) return [];
|
||||
return results.results.map((row: any) => ({
|
||||
playlistId: row.playlist_id,
|
||||
songId: row.song_id,
|
||||
source: row.source,
|
||||
songmid: row.songmid || undefined,
|
||||
name: row.name,
|
||||
artist: row.artist,
|
||||
album: row.album || undefined,
|
||||
cover: row.cover || undefined,
|
||||
durationText: row.duration_text || undefined,
|
||||
durationSec: row.duration_sec ?? undefined,
|
||||
sortOrder: row.sort_order,
|
||||
addedAt: row.added_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
|
||||
const row = await this.db
|
||||
.prepare('SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2 LIMIT 1')
|
||||
.bind(playlistId, songId)
|
||||
.first();
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
// ==================== 搜索历史 ====================
|
||||
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2';
|
||||
import { RedisAdapter } from './redis-adapter';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { userInfoCache } from './user-cache';
|
||||
@@ -791,6 +792,165 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
return exists !== null;
|
||||
}
|
||||
|
||||
// ---------- Music V2 历史记录 ----------
|
||||
private musicV2HistoryKey(userName: string) {
|
||||
return `u:${userName}:music:v2:history`;
|
||||
}
|
||||
|
||||
async listMusicV2History(userName: string): Promise<MusicV2HistoryRecord[]> {
|
||||
const rows = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.musicV2HistoryKey(userName))
|
||||
);
|
||||
|
||||
return Object.values(rows || {})
|
||||
.filter(Boolean)
|
||||
.map(value => JSON.parse(value as string) as MusicV2HistoryRecord)
|
||||
.sort((a, b) => b.lastPlayedAt - a.lastPlayedAt);
|
||||
}
|
||||
|
||||
async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(this.musicV2HistoryKey(userName), record.songId, JSON.stringify(record))
|
||||
);
|
||||
}
|
||||
|
||||
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
|
||||
if (!records.length) return;
|
||||
const payload: Record<string, string> = {};
|
||||
for (const record of records) {
|
||||
payload[record.songId] = JSON.stringify(record);
|
||||
}
|
||||
await this.withRetry(() => this.adapter.hSet(this.musicV2HistoryKey(userName), payload));
|
||||
}
|
||||
|
||||
async deleteMusicV2History(userName: string, songId: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.musicV2HistoryKey(userName), songId));
|
||||
}
|
||||
|
||||
async clearMusicV2History(userName: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.del(this.musicV2HistoryKey(userName)));
|
||||
}
|
||||
|
||||
// ---------- Music V2 歌单 ----------
|
||||
private musicV2PlaylistsKey(userName: string) {
|
||||
return `u:${userName}:music:v2:playlists`;
|
||||
}
|
||||
|
||||
private musicV2PlaylistKey(playlistId: string) {
|
||||
return `music:v2:playlist:${playlistId}`;
|
||||
}
|
||||
|
||||
private musicV2PlaylistItemsKey(playlistId: string) {
|
||||
return `music:v2:playlist:${playlistId}:items`;
|
||||
}
|
||||
|
||||
async createMusicV2Playlist(userName: string, playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
const payload = {
|
||||
id: playlist.id,
|
||||
username: userName,
|
||||
name: playlist.name,
|
||||
description: playlist.description || '',
|
||||
cover: playlist.cover || '',
|
||||
song_count: '0',
|
||||
created_at: now.toString(),
|
||||
updated_at: now.toString(),
|
||||
};
|
||||
|
||||
await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlist.id), payload));
|
||||
await this.withRetry(() =>
|
||||
this.adapter.zAdd(this.musicV2PlaylistsKey(userName), { score: now, value: playlist.id })
|
||||
);
|
||||
}
|
||||
|
||||
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
|
||||
const data = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistKey(playlistId)));
|
||||
if (!data || Object.keys(data).length === 0) return null;
|
||||
return {
|
||||
id: data.id,
|
||||
username: data.username,
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
cover: data.cover || undefined,
|
||||
song_count: parseInt(data.song_count || '0', 10) || 0,
|
||||
created_at: parseInt(data.created_at, 10),
|
||||
updated_at: parseInt(data.updated_at, 10),
|
||||
};
|
||||
}
|
||||
|
||||
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
|
||||
const playlistIds = await this.withRetry(() => this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1));
|
||||
const playlists: MusicV2PlaylistRecord[] = [];
|
||||
for (const playlistId of playlistIds || []) {
|
||||
const playlist = await this.getMusicV2Playlist(ensureString(playlistId));
|
||||
if (playlist) playlists.push(playlist);
|
||||
}
|
||||
return playlists.sort((a, b) => b.updated_at - a.updated_at);
|
||||
}
|
||||
|
||||
async updateMusicV2Playlist(playlistId: string, updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
song_count?: number;
|
||||
}): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
updated_at: Date.now().toString(),
|
||||
};
|
||||
if (updates.name !== undefined) payload.name = updates.name;
|
||||
if (updates.description !== undefined) payload.description = updates.description || '';
|
||||
if (updates.cover !== undefined) payload.cover = updates.cover || '';
|
||||
if (updates.song_count !== undefined) payload.song_count = String(updates.song_count);
|
||||
await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlistId), payload));
|
||||
}
|
||||
|
||||
async deleteMusicV2Playlist(playlistId: string): Promise<void> {
|
||||
const playlist = await this.getMusicV2Playlist(playlistId);
|
||||
if (!playlist) return;
|
||||
await this.withRetry(() => this.adapter.zRem(this.musicV2PlaylistsKey(playlist.username), playlistId));
|
||||
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistKey(playlistId)));
|
||||
await this.withRetry(() => this.adapter.del(this.musicV2PlaylistItemsKey(playlistId)));
|
||||
}
|
||||
|
||||
async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(this.musicV2PlaylistItemsKey(playlistId), item.songId, JSON.stringify(item))
|
||||
);
|
||||
const items = await this.listMusicV2PlaylistItems(playlistId);
|
||||
const playlist = await this.getMusicV2Playlist(playlistId);
|
||||
await this.updateMusicV2Playlist(playlistId, {
|
||||
song_count: items.length,
|
||||
cover: playlist?.cover || item.cover,
|
||||
});
|
||||
}
|
||||
|
||||
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId));
|
||||
const items = await this.listMusicV2PlaylistItems(playlistId);
|
||||
await this.updateMusicV2Playlist(playlistId, {
|
||||
song_count: items.length,
|
||||
cover: items[0]?.cover || '',
|
||||
});
|
||||
}
|
||||
|
||||
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
|
||||
const rows = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId)));
|
||||
return Object.values(rows || {})
|
||||
.filter(Boolean)
|
||||
.map(value => JSON.parse(value as string) as MusicV2PlaylistItem)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.addedAt - b.addedAt);
|
||||
}
|
||||
|
||||
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
|
||||
const exists = await this.withRetry(() => this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId));
|
||||
return exists !== null;
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
|
||||
private userPwdKey(user: string) {
|
||||
return `u:${user}:pwd`;
|
||||
|
||||
Reference in New Issue
Block a user