歌单
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
-- ============================================
|
||||
-- MoonTV Plus - 音乐播放记录表
|
||||
-- 版本: 1.1.0
|
||||
-- MoonTV Plus - 音乐模块数据表
|
||||
-- 版本: 1.2.0
|
||||
-- 创建时间: 2026-02-01
|
||||
-- 更新时间: 2026-02-02
|
||||
-- ============================================
|
||||
|
||||
-- 音乐播放记录表
|
||||
@@ -25,3 +26,41 @@ CREATE TABLE IF NOT EXISTS music_play_records (
|
||||
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);
|
||||
|
||||
-- ============================================
|
||||
-- 音乐歌单表
|
||||
-- ============================================
|
||||
|
||||
-- 音乐歌单表
|
||||
CREATE TABLE IF NOT EXISTS music_playlists (
|
||||
id TEXT NOT NULL, -- 歌单ID (UUID)
|
||||
username TEXT NOT NULL, -- 用户名
|
||||
name TEXT NOT NULL, -- 歌单名称
|
||||
description TEXT, -- 歌单描述(可选)
|
||||
cover TEXT, -- 歌单封面(可选,使用第一首歌的封面)
|
||||
created_at INTEGER NOT NULL, -- 创建时间戳
|
||||
updated_at INTEGER NOT NULL, -- 更新时间戳
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 音乐歌单歌曲关联表
|
||||
CREATE TABLE IF NOT EXISTS music_playlist_songs (
|
||||
playlist_id TEXT NOT NULL, -- 歌单ID
|
||||
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(可选)
|
||||
duration REAL NOT NULL DEFAULT 0, -- 总时长(秒)
|
||||
added_at INTEGER NOT NULL, -- 添加时间戳
|
||||
sort_order INTEGER NOT NULL DEFAULT 0, -- 排序顺序
|
||||
PRIMARY KEY (playlist_id, platform, song_id),
|
||||
FOREIGN KEY (playlist_id) REFERENCES music_playlists(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 创建索引以提高查询性能
|
||||
CREATE INDEX IF NOT EXISTS idx_music_playlists_username ON music_playlists(username, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_playlist ON music_playlist_songs(playlist_id, sort_order ASC);
|
||||
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_added_at ON music_playlist_songs(playlist_id, added_at DESC);
|
||||
|
||||
200
src/app/api/music/playlists/route.ts
Normal file
200
src/app/api/music/playlists/route.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// GET - 获取用户的所有歌单
|
||||
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 playlists = await db.getUserMusicPlaylists(authInfo.username);
|
||||
|
||||
return NextResponse.json({ playlists });
|
||||
} catch (error) {
|
||||
console.error('GET /api/music/playlists error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - 创建新歌单
|
||||
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 { name, description } = body;
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '歌单名称不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const playlistId = randomUUID();
|
||||
|
||||
await db.createMusicPlaylist(authInfo.username, {
|
||||
id: playlistId,
|
||||
name: name.trim(),
|
||||
description: description?.trim(),
|
||||
});
|
||||
|
||||
const playlist = await db.getMusicPlaylist(playlistId);
|
||||
|
||||
return NextResponse.json({ playlist });
|
||||
} catch (error) {
|
||||
console.error('POST /api/music/playlists error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - 更新歌单信息
|
||||
export async function PUT(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 { playlistId, name, description, cover } = body;
|
||||
|
||||
if (!playlistId) {
|
||||
return NextResponse.json(
|
||||
{ error: '歌单ID不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查歌单是否存在且属于当前用户
|
||||
const playlist = await db.getMusicPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||
}
|
||||
if (playlist.username !== authInfo.username) {
|
||||
return NextResponse.json({ error: '无权限操作此歌单' }, { status: 403 });
|
||||
}
|
||||
|
||||
const updates: any = {};
|
||||
if (name !== undefined) updates.name = name.trim();
|
||||
if (description !== undefined) updates.description = description?.trim();
|
||||
if (cover !== undefined) updates.cover = cover;
|
||||
|
||||
await db.updateMusicPlaylist(playlistId, updates);
|
||||
|
||||
const updatedPlaylist = await db.getMusicPlaylist(playlistId);
|
||||
|
||||
return NextResponse.json({ playlist: updatedPlaylist });
|
||||
} catch (error) {
|
||||
console.error('PUT /api/music/playlists error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - 删除歌单
|
||||
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 playlistId = searchParams.get('playlistId');
|
||||
|
||||
if (!playlistId) {
|
||||
return NextResponse.json(
|
||||
{ error: '歌单ID不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查歌单是否存在且属于当前用户
|
||||
const playlist = await db.getMusicPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||
}
|
||||
if (playlist.username !== authInfo.username) {
|
||||
return NextResponse.json({ error: '无权限操作此歌单' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.deleteMusicPlaylist(playlistId);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('DELETE /api/music/playlists error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
187
src/app/api/music/playlists/songs/route.ts
Normal file
187
src/app/api/music/playlists/songs/route.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// GET - 获取歌单中的所有歌曲
|
||||
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 { searchParams } = new URL(request.url);
|
||||
const playlistId = searchParams.get('playlistId');
|
||||
|
||||
if (!playlistId) {
|
||||
return NextResponse.json(
|
||||
{ error: '歌单ID不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查歌单是否存在且属于当前用户
|
||||
const playlist = await db.getMusicPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||
}
|
||||
if (playlist.username !== authInfo.username) {
|
||||
return NextResponse.json({ error: '无权限访问此歌单' }, { status: 403 });
|
||||
}
|
||||
|
||||
const songs = await db.getPlaylistSongs(playlistId);
|
||||
|
||||
return NextResponse.json({ songs });
|
||||
} catch (error) {
|
||||
console.error('GET /api/music/playlists/songs error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - 添加歌曲到歌单
|
||||
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 { playlistId, song } = body;
|
||||
|
||||
if (!playlistId) {
|
||||
return NextResponse.json(
|
||||
{ error: '歌单ID不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!song || !song.platform || !song.id || !song.name || !song.artist) {
|
||||
return NextResponse.json(
|
||||
{ error: '歌曲信息不完整' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查歌单是否存在且属于当前用户
|
||||
const playlist = await db.getMusicPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||
}
|
||||
if (playlist.username !== authInfo.username) {
|
||||
return NextResponse.json({ error: '无权限操作此歌单' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 检查歌曲是否已在歌单中
|
||||
const exists = await db.isSongInPlaylist(playlistId, song.platform, song.id);
|
||||
if (exists) {
|
||||
return NextResponse.json(
|
||||
{ error: '歌曲已在歌单中' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.addSongToPlaylist(playlistId, {
|
||||
platform: song.platform,
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
pic: song.pic,
|
||||
duration: song.duration || 0,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('POST /api/music/playlists/songs error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - 从歌单中移除歌曲
|
||||
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 playlistId = searchParams.get('playlistId');
|
||||
const platform = searchParams.get('platform');
|
||||
const songId = searchParams.get('songId');
|
||||
|
||||
if (!playlistId || !platform || !songId) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数不完整' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查歌单是否存在且属于当前用户
|
||||
const playlist = await db.getMusicPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||
}
|
||||
if (playlist.username !== authInfo.username) {
|
||||
return NextResponse.json({ error: '无权限操作此歌单' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.removeSongFromPlaylist(playlistId, platform, songId);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('DELETE /api/music/playlists/songs error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,44 +81,95 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
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 (Array.isArray(body.records)) {
|
||||
// 批量添加
|
||||
const records: Array<{ platform: string; id: string; record: MusicPlayRecord }> = [];
|
||||
|
||||
for (const item of body.records) {
|
||||
const { key, record } = item;
|
||||
if (!key || !record) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing key or record in batch item' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证音乐播放记录数据
|
||||
if (!record.platform || !record.id || !record.name || !record.artist) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid record data in batch item' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从key中解析platform和id
|
||||
const [platform, id] = key.split('+');
|
||||
if (!platform || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format in batch item' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
records.push({ platform, id, record });
|
||||
|
||||
// 缓存歌曲信息到服务器内存
|
||||
setCachedSong(platform, id, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
artist: record.artist,
|
||||
album: record.album,
|
||||
pic: record.pic,
|
||||
});
|
||||
}
|
||||
|
||||
// 批量保存到数据库
|
||||
await db.batchSaveMusicPlayRecords(authInfo.username, records);
|
||||
|
||||
return NextResponse.json({ success: true, count: records.length }, { status: 200 });
|
||||
} else {
|
||||
// 单个添加(保持向后兼容)
|
||||
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);
|
||||
|
||||
// 缓存歌曲信息到服务器内存
|
||||
setCachedSong(platform, id, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
artist: record.artist,
|
||||
album: record.album,
|
||||
pic: record.pic,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
}
|
||||
|
||||
// 验证音乐播放记录数据
|
||||
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);
|
||||
|
||||
// 缓存歌曲信息到服务器内存
|
||||
setCachedSong(platform, id, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
artist: record.artist,
|
||||
album: record.album,
|
||||
pic: record.pic,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('保存音乐播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
getAllMusicPlayRecords,
|
||||
saveMusicPlayRecord,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
deleteMusicPlayRecord,
|
||||
clearAllMusicPlayRecords,
|
||||
} from '@/lib/db.client';
|
||||
import AddToPlaylistModal from '@/components/AddToPlaylistModal';
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
|
||||
interface Song {
|
||||
id: string;
|
||||
@@ -17,7 +20,7 @@ interface Song {
|
||||
artist: string;
|
||||
album?: string;
|
||||
pic?: string;
|
||||
platform?: 'netease' | 'qq' | 'kuwo'; // 添加平台信息
|
||||
platform: 'netease' | 'qq' | 'kuwo'; // 添加平台信息
|
||||
}
|
||||
|
||||
interface PlayRecord {
|
||||
@@ -57,7 +60,7 @@ export default function MusicPage() {
|
||||
const [currentSource, setCurrentSource] = useState<'netease' | 'qq' | 'kuwo'>('netease');
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [currentView, setCurrentView] = useState<'playlists' | 'songs'>('playlists');
|
||||
const [currentView, setCurrentView] = useState<'playlists' | 'songs' | 'myPlaylists'>('playlists');
|
||||
const [currentPlaylistTitle, setCurrentPlaylistTitle] = useState('');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [currentSong, setCurrentSong] = useState<Song | null>(null);
|
||||
@@ -81,6 +84,33 @@ export default function MusicPage() {
|
||||
const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态
|
||||
const [pendingSongToPlay, setPendingSongToPlay] = useState<{ platform: string; id: string } | null>(null); // 待播放的歌曲信息
|
||||
const [showAddToPlaylistModal, setShowAddToPlaylistModal] = useState(false); // 添加到歌单弹窗
|
||||
const [songToAddToPlaylist, setSongToAddToPlaylist] = useState<Song | null>(null); // 要添加到歌单的歌曲
|
||||
|
||||
// 我的歌单相关状态
|
||||
const [userPlaylists, setUserPlaylists] = useState<any[]>([]);
|
||||
const [selectedUserPlaylist, setSelectedUserPlaylist] = useState<any | null>(null);
|
||||
const [userPlaylistSongs, setUserPlaylistSongs] = useState<any[]>([]);
|
||||
const [loadingUserPlaylists, setLoadingUserPlaylists] = useState(false);
|
||||
const [loadingUserPlaylistSongs, setLoadingUserPlaylistSongs] = useState(false);
|
||||
const [loadingPlayAll, setLoadingPlayAll] = useState(false); // 播放全部加载状态
|
||||
const [deletingPlaylistId, setDeletingPlaylistId] = useState<string | null>(null); // 正在删除的歌单ID
|
||||
|
||||
// Toast 和 Confirm Modal 状态
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const lyricsContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -358,8 +388,272 @@ export default function MusicPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 打开添加到歌单弹窗
|
||||
const handleAddToPlaylist = (song: Song, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发播放
|
||||
setSongToAddToPlaylist(song);
|
||||
setShowAddToPlaylistModal(true);
|
||||
};
|
||||
|
||||
// 加载用户歌单列表
|
||||
const loadUserPlaylists = async () => {
|
||||
try {
|
||||
setLoadingUserPlaylists(true);
|
||||
const response = await fetch('/api/music/playlists');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlaylists(data.playlists || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载歌单失败:', error);
|
||||
} finally {
|
||||
setLoadingUserPlaylists(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载歌单中的歌曲
|
||||
const loadUserPlaylistSongs = async (playlistId: string) => {
|
||||
try {
|
||||
setLoadingUserPlaylistSongs(true);
|
||||
const response = await fetch(`/api/music/playlists/songs?playlistId=${playlistId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlaylistSongs(data.songs || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载歌单歌曲失败:', error);
|
||||
} finally {
|
||||
setLoadingUserPlaylistSongs(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择歌单
|
||||
const handleSelectUserPlaylist = (playlist: any) => {
|
||||
setSelectedUserPlaylist(playlist);
|
||||
loadUserPlaylistSongs(playlist.id);
|
||||
};
|
||||
|
||||
// 播放全部歌单歌曲
|
||||
const handlePlayAllPlaylist = async () => {
|
||||
if (!selectedUserPlaylist || userPlaylistSongs.length === 0) {
|
||||
setToast({
|
||||
message: '歌单为空',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingPlayAll(true);
|
||||
try {
|
||||
// 1. 清空所有播放历史
|
||||
await clearAllMusicPlayRecords();
|
||||
|
||||
// 2. 清空本地状态
|
||||
setPlayRecords([]);
|
||||
setPlaylist([]);
|
||||
|
||||
// 3. 批量添加歌单中的所有歌曲到播放历史
|
||||
const baseTime = Date.now();
|
||||
const recordsToAdd = userPlaylistSongs.map((song, i) => ({
|
||||
key: `${song.platform}+${song.id}`,
|
||||
record: {
|
||||
platform: song.platform,
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
pic: song.pic,
|
||||
play_time: 0,
|
||||
duration: song.duration || 0,
|
||||
save_time: baseTime + i, // 使用递增的时间戳
|
||||
},
|
||||
}));
|
||||
|
||||
// 一次性批量添加所有歌曲
|
||||
const response = await fetch('/api/music/playrecords', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
records: recordsToAdd,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('批量添加歌曲失败');
|
||||
}
|
||||
|
||||
// 4. 立即更新本地状态
|
||||
const newRecords: PlayRecord[] = userPlaylistSongs.map((song, i) => ({
|
||||
platform: song.platform,
|
||||
id: song.id,
|
||||
playTime: 0,
|
||||
duration: song.duration || 0,
|
||||
timestamp: baseTime + i,
|
||||
}));
|
||||
|
||||
const newPlaylist: Song[] = userPlaylistSongs.map((song) => ({
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
pic: song.pic,
|
||||
platform: song.platform,
|
||||
}));
|
||||
|
||||
setPlayRecords(newRecords);
|
||||
setPlaylist(newPlaylist);
|
||||
|
||||
// 5. 直接播放第一首歌
|
||||
if (userPlaylistSongs.length > 0) {
|
||||
console.log('播放全部: 准备播放第一首歌', userPlaylistSongs[0]);
|
||||
setPlaylistIndex(0);
|
||||
await playSong(userPlaylistSongs[0], 0);
|
||||
}
|
||||
|
||||
setToast({
|
||||
message: `已将 ${userPlaylistSongs.length} 首歌曲添加到播放列表`,
|
||||
type: 'success',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('播放全部失败:', error);
|
||||
setToast({
|
||||
message: '播放全部失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} finally {
|
||||
setLoadingPlayAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除歌单
|
||||
const handleDeleteUserPlaylist = async (playlistId: string) => {
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: '确认删除',
|
||||
message: '确定要删除这个歌单吗?',
|
||||
onConfirm: async () => {
|
||||
// 先关闭确认框
|
||||
setConfirmModal({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
|
||||
// 然后执行删除
|
||||
setDeletingPlaylistId(playlistId);
|
||||
try {
|
||||
const response = await fetch(`/api/music/playlists?playlistId=${playlistId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setToast({
|
||||
message: '删除成功',
|
||||
type: 'success',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
if (selectedUserPlaylist?.id === playlistId) {
|
||||
setSelectedUserPlaylist(null);
|
||||
setUserPlaylistSongs([]);
|
||||
}
|
||||
loadUserPlaylists();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setToast({
|
||||
message: data.error || '删除失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除歌单失败:', error);
|
||||
setToast({
|
||||
message: '删除歌单失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} finally {
|
||||
setDeletingPlaylistId(null);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
setConfirmModal({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 从歌单中移除歌曲
|
||||
const handleRemoveSongFromUserPlaylist = async (song: any) => {
|
||||
if (!selectedUserPlaylist) return;
|
||||
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: '确认移除',
|
||||
message: `确定要从歌单中移除 "${song.name}" 吗?`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/music/playlists/songs?playlistId=${selectedUserPlaylist.id}&platform=${song.platform}&songId=${song.id}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setToast({
|
||||
message: '移除成功',
|
||||
type: 'success',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
loadUserPlaylistSongs(selectedUserPlaylist.id);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setToast({
|
||||
message: data.error || '移除失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('移除歌曲失败:', error);
|
||||
setToast({
|
||||
message: '移除歌曲失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}
|
||||
setConfirmModal({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
setConfirmModal({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 播放歌曲
|
||||
const playSong = async (song: Song, index: number) => {
|
||||
console.log('playSong 被调用:', song, 'index:', index);
|
||||
try {
|
||||
// 使用歌曲自己的平台信息,如果没有则使用当前选择的平台
|
||||
const platform = song.platform || currentSource;
|
||||
@@ -373,6 +667,8 @@ export default function MusicPage() {
|
||||
setShowPlayer(true);
|
||||
setLyrics([]); // 清空旧歌词
|
||||
|
||||
console.log('已设置 showPlayer=true, currentSong=', song);
|
||||
|
||||
// 添加到播放记录和播放列表
|
||||
const record: PlayRecord = {
|
||||
platform: platform,
|
||||
@@ -596,6 +892,10 @@ export default function MusicPage() {
|
||||
if (currentView === 'songs') {
|
||||
setCurrentView('playlists');
|
||||
setSongs([]);
|
||||
} else if (currentView === 'myPlaylists') {
|
||||
setCurrentView('playlists');
|
||||
setSelectedUserPlaylist(null);
|
||||
setUserPlaylistSongs([]);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
@@ -774,6 +1074,13 @@ export default function MusicPage() {
|
||||
loadPlaylists(currentSource);
|
||||
}, [currentSource]);
|
||||
|
||||
// 当切换到我的歌单视图时加载歌单列表
|
||||
useEffect(() => {
|
||||
if (currentView === 'myPlaylists') {
|
||||
loadUserPlaylists();
|
||||
}
|
||||
}, [currentView]);
|
||||
|
||||
// 歌词自动滚动
|
||||
useEffect(() => {
|
||||
if (lyricsContainerRef.current && currentLyricIndex >= 0) {
|
||||
@@ -840,6 +1147,7 @@ export default function MusicPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white">
|
||||
<>
|
||||
{/* Header */}
|
||||
<header className="fixed top-0 left-0 right-0 z-40 bg-zinc-950/95 backdrop-blur-md border-b border-white/10 px-4 md:px-6">
|
||||
<div className="w-full mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-4 py-3">
|
||||
@@ -895,7 +1203,7 @@ export default function MusicPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center w-full md:flex-1 md:max-w-md md:ml-auto h-10 md:h-9 gap-2">
|
||||
{currentView === 'songs' && (
|
||||
{(currentView === 'songs' || currentView === 'myPlaylists') && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="w-10 h-full rounded-lg bg-white/10 hover:bg-white/20 flex items-center justify-center text-white border border-white/10"
|
||||
@@ -920,6 +1228,15 @@ export default function MusicPage() {
|
||||
placeholder="搜索歌曲或艺术家..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentView('myPlaylists')}
|
||||
className="w-10 h-full rounded-lg bg-white/10 hover:bg-white/20 flex items-center justify-center text-white border border-white/10 shrink-0"
|
||||
title="我的歌单"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -986,29 +1303,230 @@ export default function MusicPage() {
|
||||
{songs.map((song, index) => (
|
||||
<div
|
||||
key={`${song.id}-${index}`}
|
||||
onClick={() => playSong(song, index)}
|
||||
className={`grid grid-cols-[40px_1fr_auto] md:grid-cols-[50px_2fr_1fr_auto] gap-2 px-3 py-3 rounded-lg cursor-pointer transition-all ${
|
||||
className={`grid grid-cols-[40px_1fr_auto_auto] md:grid-cols-[50px_2fr_1fr_auto_auto] gap-2 px-3 py-3 rounded-lg cursor-pointer transition-all ${
|
||||
currentSongIndex === index
|
||||
? 'bg-white/12 border-l-2 border-green-500'
|
||||
: 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="text-center text-zinc-500 text-sm">{index + 1}</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="text-center text-zinc-500 text-sm col-span-1"
|
||||
onClick={() => playSong(song, index)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div
|
||||
className="min-w-0 col-span-1"
|
||||
onClick={() => playSong(song, index)}
|
||||
>
|
||||
<div className="text-sm font-medium text-white truncate">{song.name}</div>
|
||||
<div className="text-xs text-zinc-500 truncate md:hidden">{song.artist}</div>
|
||||
</div>
|
||||
<div className="hidden md:block text-sm text-zinc-400 truncate">{song.artist}</div>
|
||||
<div className="text-xs text-zinc-600">{getSourceLabel()}</div>
|
||||
<div
|
||||
className="hidden md:block text-sm text-zinc-400 truncate col-span-1"
|
||||
onClick={() => playSong(song, index)}
|
||||
>
|
||||
{song.artist}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs text-zinc-600 col-span-1"
|
||||
onClick={() => playSong(song, index)}
|
||||
>
|
||||
{getSourceLabel()}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleAddToPlaylist(song, e)}
|
||||
className="text-zinc-500 hover:text-red-500 transition-colors p-1 col-span-1"
|
||||
title="添加到歌单"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Playlists View */}
|
||||
{currentView === 'myPlaylists' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Playlists List */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10">
|
||||
<h2 className="text-lg font-bold mb-4">歌单列表</h2>
|
||||
{loadingUserPlaylists ? (
|
||||
<div className="text-center py-8 text-zinc-400">加载中...</div>
|
||||
) : userPlaylists.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-400">
|
||||
还没有歌单
|
||||
<br />
|
||||
<button
|
||||
onClick={() => setCurrentView('playlists')}
|
||||
className="mt-4 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
||||
>
|
||||
去添加歌曲
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{userPlaylists.map((playlist) => (
|
||||
<div
|
||||
key={playlist.id}
|
||||
className={`p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedUserPlaylist?.id === playlist.id
|
||||
? 'bg-green-600/20 border border-green-500'
|
||||
: 'bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => handleSelectUserPlaylist(playlist)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{playlist.cover ? (
|
||||
<img
|
||||
src={playlist.cover}
|
||||
alt={playlist.name}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded bg-zinc-700 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{playlist.name}</div>
|
||||
{playlist.description && (
|
||||
<div className="text-xs text-zinc-500 truncate">{playlist.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist Songs */}
|
||||
<div className="md:col-span-2">
|
||||
{selectedUserPlaylist ? (
|
||||
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">{selectedUserPlaylist.name}</h2>
|
||||
{selectedUserPlaylist.description && (
|
||||
<p className="text-sm text-zinc-400 mt-1">{selectedUserPlaylist.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePlayAllPlaylist}
|
||||
disabled={userPlaylistSongs.length === 0 || loadingPlayAll}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-zinc-700 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{loadingPlayAll ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
加载中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
播放全部
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUserPlaylist(selectedUserPlaylist.id)}
|
||||
disabled={deletingPlaylistId !== null}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-zinc-700 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{deletingPlaylistId === selectedUserPlaylist.id ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
删除中...
|
||||
</>
|
||||
) : (
|
||||
'删除歌单'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingUserPlaylistSongs ? (
|
||||
<div className="text-center py-8 text-zinc-400">加载中...</div>
|
||||
) : userPlaylistSongs.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-400">歌单为空</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{userPlaylistSongs.map((song, index) => (
|
||||
<div
|
||||
key={`${song.platform}+${song.id}`}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div className="text-zinc-500 text-sm w-8 text-center">{index + 1}</div>
|
||||
{song.pic && (
|
||||
<img
|
||||
src={song.pic}
|
||||
alt={song.name}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{song.name}</div>
|
||||
<div className="text-sm text-zinc-400 truncate">{song.artist}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => playSong(song, index)}
|
||||
className="text-zinc-500 hover:text-green-500 transition-colors p-2"
|
||||
title="播放"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveSongFromUserPlaylist(song)}
|
||||
className="text-zinc-500 hover:text-red-500 transition-colors p-2"
|
||||
title="移除"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-zinc-800/50 rounded-xl p-4 border border-white/10 h-full flex items-center justify-center">
|
||||
<div className="text-center text-zinc-400">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
<p>选择一个歌单查看详情</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Player */}
|
||||
{console.log('渲染检查: showPlayer=', showPlayer, 'currentSong=', currentSong)}
|
||||
{showPlayer && currentSong && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 w-[95%] max-w-3xl z-50">
|
||||
<div className="bg-zinc-900/95 backdrop-blur-md rounded-xl p-4 border border-white/10 shadow-2xl">
|
||||
@@ -1095,6 +1613,21 @@ export default function MusicPage() {
|
||||
className="w-16 h-1 bg-white/10 rounded-full appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (currentSong) {
|
||||
setSongToAddToPlaylist(currentSong);
|
||||
setShowAddToPlaylistModal(true);
|
||||
}
|
||||
}}
|
||||
className="text-zinc-500 hover:text-red-500 transition-colors"
|
||||
title="添加到歌单"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadSong}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
@@ -1350,6 +1883,22 @@ export default function MusicPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 添加到歌单按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (currentSong) {
|
||||
setSongToAddToPlaylist(currentSong);
|
||||
setShowAddToPlaylistModal(true);
|
||||
}
|
||||
}}
|
||||
className="text-zinc-500 hover:text-red-500 transition-colors"
|
||||
title="添加到歌单"
|
||||
>
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
@@ -1651,6 +2200,109 @@ export default function MusicPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to Playlist Modal */}
|
||||
<AddToPlaylistModal
|
||||
song={songToAddToPlaylist}
|
||||
isOpen={showAddToPlaylistModal}
|
||||
onClose={() => {
|
||||
setShowAddToPlaylistModal(false);
|
||||
setSongToAddToPlaylist(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setToast({
|
||||
message: '已添加到歌单',
|
||||
type: 'success',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}}
|
||||
onError={(message) => {
|
||||
setToast({
|
||||
message,
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && <Toast {...toast} />}
|
||||
|
||||
{/* Confirm Modal */}
|
||||
{confirmModal.isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
style={{ zIndex: 99999 }}
|
||||
onClick={confirmModal.onCancel}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-900 rounded-xl max-w-md w-full border border-white/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{confirmModal.title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={confirmModal.onCancel}
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{confirmModal.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={confirmModal.onCancel}
|
||||
disabled={deletingPlaylistId !== null}
|
||||
className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 disabled:bg-zinc-800 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmModal.onConfirm}
|
||||
disabled={deletingPlaylistId !== null}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-zinc-700 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{deletingPlaylistId !== null ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
删除中...
|
||||
</>
|
||||
) : (
|
||||
'确定'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
280
src/components/AddToPlaylistModal.tsx
Normal file
280
src/components/AddToPlaylistModal.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Song {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
pic?: string;
|
||||
platform: 'netease' | 'qq' | 'kuwo';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface MusicPlaylist {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
song: Song | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export default function AddToPlaylistModal({
|
||||
song,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: AddToPlaylistModalProps) {
|
||||
const [playlists, setPlaylists] = useState<MusicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newPlaylistName, setNewPlaylistName] = useState('');
|
||||
const [newPlaylistDescription, setNewPlaylistDescription] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [addingToPlaylistId, setAddingToPlaylistId] = useState<string | null>(null); // 正在添加的歌单ID
|
||||
|
||||
// 加载用户的歌单列表
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadPlaylists();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/music/playlists');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPlaylists(data.playlists || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载歌单失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePlaylist = async () => {
|
||||
if (!newPlaylistName.trim()) {
|
||||
onError?.('请输入歌单名称');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCreating(true);
|
||||
const response = await fetch('/api/music/playlists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newPlaylistName.trim(),
|
||||
description: newPlaylistDescription.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewPlaylistName('');
|
||||
setNewPlaylistDescription('');
|
||||
setShowCreateForm(false);
|
||||
await loadPlaylists();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
onError?.(data.error || '创建歌单失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建歌单失败:', error);
|
||||
onError?.('创建歌单失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToPlaylist = async (playlistId: string) => {
|
||||
if (!song) return;
|
||||
|
||||
try {
|
||||
setAddingToPlaylistId(playlistId);
|
||||
const response = await fetch('/api/music/playlists/songs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playlistId,
|
||||
song: {
|
||||
platform: song.platform,
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
pic: song.pic,
|
||||
duration: song.duration || 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
onError?.(data.error || '添加失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加到歌单失败:', error);
|
||||
onError?.('添加到歌单失败');
|
||||
} finally {
|
||||
setAddingToPlaylistId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[110] flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-900 rounded-xl max-w-md w-full max-h-[80vh] overflow-hidden border border-white/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-white">添加到歌单</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{song && (
|
||||
<div className="mt-2 text-sm text-zinc-400">
|
||||
{song.name} - {song.artist}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 overflow-y-auto max-h-[calc(80vh-120px)]">
|
||||
{/* Create New Playlist Button */}
|
||||
{!showCreateForm && (
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="w-full mb-4 px-4 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
创建新歌单
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<div className="mb-4 p-4 bg-white/5 rounded-lg border border-white/10">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="歌单名称"
|
||||
value={newPlaylistName}
|
||||
onChange={(e) => setNewPlaylistName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-zinc-800 text-white rounded-lg border border-white/10 focus:border-green-500 focus:outline-none mb-2"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="歌单描述(可选)"
|
||||
value={newPlaylistDescription}
|
||||
onChange={(e) => setNewPlaylistDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-zinc-800 text-white rounded-lg border border-white/10 focus:border-green-500 focus:outline-none resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleCreatePlaylist}
|
||||
disabled={creating}
|
||||
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-zinc-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{creating ? '创建中...' : '确定'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setNewPlaylistName('');
|
||||
setNewPlaylistDescription('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-zinc-700 hover:bg-zinc-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playlists List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-zinc-400">加载中...</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-400">
|
||||
还没有歌单,创建一个吧
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{playlists.map((playlist) => (
|
||||
<button
|
||||
key={playlist.id}
|
||||
onClick={() => handleAddToPlaylist(playlist.id)}
|
||||
disabled={addingToPlaylistId !== null}
|
||||
className="w-full px-4 py-3 bg-white/5 hover:bg-white/10 disabled:bg-white/5 disabled:cursor-not-allowed rounded-lg transition-colors text-left flex items-center gap-3"
|
||||
>
|
||||
{playlist.cover ? (
|
||||
<img
|
||||
src={playlist.cover}
|
||||
alt={playlist.name}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded bg-zinc-800 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-white font-medium truncate">{playlist.name}</div>
|
||||
{playlist.description && (
|
||||
<div className="text-xs text-zinc-500 truncate">{playlist.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{addingToPlaylistId === playlist.id ? (
|
||||
<svg className="w-5 h-5 text-green-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
src/lib/d1.db.ts
301
src/lib/d1.db.ts
@@ -358,6 +358,52 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise<void> {
|
||||
if (records.length === 0) return;
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
// 使用批量插入,D1 支持 batch 操作
|
||||
const statements = records.map(({ key, record }) =>
|
||||
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
|
||||
platform = excluded.platform,
|
||||
song_id = excluded.song_id,
|
||||
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
|
||||
)
|
||||
);
|
||||
|
||||
if (this.db.batch) {
|
||||
await this.db.batch(statements);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('D1Storage.batchSetMusicPlayRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
@@ -412,6 +458,261 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 音乐歌单相关 ====================
|
||||
|
||||
async createMusicPlaylist(userName: string, playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_playlists (id, username, name, description, cover, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.bind(
|
||||
playlist.id,
|
||||
userName,
|
||||
playlist.name,
|
||||
playlist.description || null,
|
||||
playlist.cover || null,
|
||||
now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.createMusicPlaylist error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getMusicPlaylist(playlistId: string): Promise<any | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM music_playlists WHERE id = ?')
|
||||
.bind(playlistId)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
username: result.username,
|
||||
name: result.name,
|
||||
description: result.description || undefined,
|
||||
cover: result.cover || undefined,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getMusicPlaylist error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserMusicPlaylists(userName: string): Promise<any[]> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_playlists WHERE username = ? ORDER BY created_at DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
if (!results.results) return [];
|
||||
|
||||
return results.results.map((row) => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
description: row.description || undefined,
|
||||
cover: row.cover || undefined,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getUserMusicPlaylists error:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async updateMusicPlaylist(playlistId: string, updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
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 (fields.length === 0) return;
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(playlistId);
|
||||
|
||||
await this.db
|
||||
.prepare(`UPDATE music_playlists SET ${fields.join(', ')} WHERE id = ?`)
|
||||
.bind(...values)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.updateMusicPlaylist error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMusicPlaylist(playlistId: string): Promise<void> {
|
||||
try {
|
||||
// 由于设置了 ON DELETE CASCADE,删除歌单会自动删除关联的歌曲
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_playlists WHERE id = ?')
|
||||
.bind(playlistId)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.deleteMusicPlaylist error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async addSongToPlaylist(playlistId: string, song: {
|
||||
platform: string;
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
pic?: string;
|
||||
duration: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// 获取当前最大的 sort_order
|
||||
const maxOrderResult = await this.db
|
||||
.prepare('SELECT MAX(sort_order) as max_order FROM music_playlist_songs WHERE playlist_id = ?')
|
||||
.bind(playlistId)
|
||||
.first();
|
||||
|
||||
const nextOrder = (maxOrderResult?.max_order as number || 0) + 1;
|
||||
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_playlist_songs (
|
||||
playlist_id, platform, song_id, name, artist, album, pic, duration, added_at, sort_order
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(playlist_id, platform, song_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
artist = excluded.artist,
|
||||
album = excluded.album,
|
||||
pic = excluded.pic,
|
||||
duration = excluded.duration
|
||||
`)
|
||||
.bind(
|
||||
playlistId,
|
||||
song.platform,
|
||||
song.id,
|
||||
song.name,
|
||||
song.artist,
|
||||
song.album || null,
|
||||
song.pic || null,
|
||||
song.duration,
|
||||
now,
|
||||
nextOrder
|
||||
)
|
||||
.run();
|
||||
|
||||
// 更新歌单的 updated_at 和封面(如果是第一首歌)
|
||||
const songCount = await this.db
|
||||
.prepare('SELECT COUNT(*) as count FROM music_playlist_songs WHERE playlist_id = ?')
|
||||
.bind(playlistId)
|
||||
.first();
|
||||
|
||||
if ((songCount?.count as number) === 1 && song.pic) {
|
||||
await this.updateMusicPlaylist(playlistId, { cover: song.pic });
|
||||
} else {
|
||||
await this.db
|
||||
.prepare('UPDATE music_playlists SET updated_at = ? WHERE id = ?')
|
||||
.bind(Date.now(), playlistId)
|
||||
.run();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('D1Storage.addSongToPlaylist error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ?')
|
||||
.bind(playlistId, platform, songId)
|
||||
.run();
|
||||
|
||||
// 更新歌单的 updated_at
|
||||
await this.db
|
||||
.prepare('UPDATE music_playlists SET updated_at = ? WHERE id = ?')
|
||||
.bind(Date.now(), playlistId)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.removeSongFromPlaylist error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<any[]> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_playlist_songs WHERE playlist_id = ? ORDER BY sort_order ASC')
|
||||
.bind(playlistId)
|
||||
.all();
|
||||
|
||||
if (!results.results) return [];
|
||||
|
||||
return results.results.map((row) => ({
|
||||
platform: row.platform,
|
||||
id: row.song_id,
|
||||
name: row.name,
|
||||
artist: row.artist,
|
||||
album: row.album || undefined,
|
||||
pic: row.pic || undefined,
|
||||
duration: row.duration,
|
||||
added_at: row.added_at,
|
||||
sort_order: row.sort_order,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getPlaylistSongs error:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async isSongInPlaylist(playlistId: string, platform: string, songId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT 1 FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ? LIMIT 1')
|
||||
.bind(playlistId, platform, songId)
|
||||
.first();
|
||||
|
||||
return result !== null;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.isSongInPlaylist error:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private rowToPlayRecord(row: any): PlayRecord {
|
||||
|
||||
103
src/lib/db.ts
103
src/lib/db.ts
@@ -211,6 +211,17 @@ export class DbManager {
|
||||
await this.storage.setMusicPlayRecord(userName, key, record);
|
||||
}
|
||||
|
||||
async batchSaveMusicPlayRecords(
|
||||
userName: string,
|
||||
records: Array<{ platform: string; id: string; record: MusicPlayRecord }>
|
||||
): Promise<void> {
|
||||
const batchRecords = records.map(({ platform, id, record }) => ({
|
||||
key: generateStorageKey(platform, id),
|
||||
record,
|
||||
}));
|
||||
await this.storage.batchSetMusicPlayRecords(userName, batchRecords);
|
||||
}
|
||||
|
||||
async getAllMusicPlayRecords(userName: string): Promise<{
|
||||
[key: string]: MusicPlayRecord;
|
||||
}> {
|
||||
@@ -230,6 +241,98 @@ export class DbManager {
|
||||
await this.storage.clearAllMusicPlayRecords(userName);
|
||||
}
|
||||
|
||||
// 音乐歌单相关方法
|
||||
async createMusicPlaylist(
|
||||
userName: string,
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).createMusicPlaylist === 'function') {
|
||||
await (this.storage as any).createMusicPlaylist(userName, playlist);
|
||||
}
|
||||
}
|
||||
|
||||
async getMusicPlaylist(playlistId: string): Promise<any | null> {
|
||||
if (typeof (this.storage as any).getMusicPlaylist === 'function') {
|
||||
return (this.storage as any).getMusicPlaylist(playlistId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getUserMusicPlaylists(userName: string): Promise<any[]> {
|
||||
if (typeof (this.storage as any).getUserMusicPlaylists === 'function') {
|
||||
return (this.storage as any).getUserMusicPlaylists(userName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async updateMusicPlaylist(
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).updateMusicPlaylist === 'function') {
|
||||
await (this.storage as any).updateMusicPlaylist(playlistId, updates);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMusicPlaylist(playlistId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteMusicPlaylist === 'function') {
|
||||
await (this.storage as any).deleteMusicPlaylist(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
async addSongToPlaylist(
|
||||
playlistId: string,
|
||||
song: {
|
||||
platform: string;
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
pic?: string;
|
||||
duration: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).addSongToPlaylist === 'function') {
|
||||
await (this.storage as any).addSongToPlaylist(playlistId, song);
|
||||
}
|
||||
}
|
||||
|
||||
async removeSongFromPlaylist(
|
||||
playlistId: string,
|
||||
platform: string,
|
||||
songId: string
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).removeSongFromPlaylist === 'function') {
|
||||
await (this.storage as any).removeSongFromPlaylist(playlistId, platform, songId);
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<any[]> {
|
||||
if (typeof (this.storage as any).getPlaylistSongs === 'function') {
|
||||
return (this.storage as any).getPlaylistSongs(playlistId);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async isSongInPlaylist(
|
||||
playlistId: string,
|
||||
platform: string,
|
||||
songId: string
|
||||
): Promise<boolean> {
|
||||
if (typeof (this.storage as any).isSongInPlaylist === 'function') {
|
||||
return (this.storage as any).isSongInPlaylist(playlistId, platform, songId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
return this.storage.verifyUser(userName, password);
|
||||
|
||||
@@ -537,6 +537,21 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
);
|
||||
}
|
||||
|
||||
async batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise<void> {
|
||||
if (records.length === 0) return;
|
||||
|
||||
const hashKey = this.musicPlayRecordHashKey(userName);
|
||||
const data: Record<string, string> = {};
|
||||
|
||||
for (const { key, record } of records) {
|
||||
data[key] = JSON.stringify(record);
|
||||
}
|
||||
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(hashKey, data)
|
||||
);
|
||||
}
|
||||
|
||||
async getAllMusicPlayRecords(userName: string): Promise<Record<string, any>> {
|
||||
const hashData = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.musicPlayRecordHashKey(userName))
|
||||
@@ -563,6 +578,219 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 音乐歌单相关 ----------
|
||||
private musicPlaylistsKey(userName: string) {
|
||||
return `u:${userName}:music_playlists`;
|
||||
}
|
||||
|
||||
private musicPlaylistKey(playlistId: string) {
|
||||
return `music_playlist:${playlistId}`;
|
||||
}
|
||||
|
||||
private musicPlaylistSongsKey(playlistId: string) {
|
||||
return `music_playlist:${playlistId}:songs`;
|
||||
}
|
||||
|
||||
async createMusicPlaylist(userName: string, playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
const playlistData = {
|
||||
id: playlist.id,
|
||||
username: userName,
|
||||
name: playlist.name,
|
||||
description: playlist.description || '',
|
||||
cover: playlist.cover || '',
|
||||
created_at: now.toString(),
|
||||
updated_at: now.toString(),
|
||||
};
|
||||
|
||||
// 存储歌单信息
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(this.musicPlaylistKey(playlist.id), playlistData)
|
||||
);
|
||||
|
||||
// 添加到用户的歌单列表(使用 sorted set,按创建时间排序)
|
||||
await this.withRetry(() =>
|
||||
this.adapter.zAdd(this.musicPlaylistsKey(userName), {
|
||||
score: now,
|
||||
value: playlist.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async getMusicPlaylist(playlistId: string): Promise<any | null> {
|
||||
const data = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.musicPlaylistKey(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,
|
||||
created_at: parseInt(data.created_at, 10),
|
||||
updated_at: parseInt(data.updated_at, 10),
|
||||
};
|
||||
}
|
||||
|
||||
async getUserMusicPlaylists(userName: string): Promise<any[]> {
|
||||
// 获取用户的所有歌单ID(按创建时间倒序)
|
||||
const playlistIds = await this.withRetry(() =>
|
||||
this.adapter.zRange(this.musicPlaylistsKey(userName), 0, -1)
|
||||
);
|
||||
|
||||
if (!playlistIds || playlistIds.length === 0) return [];
|
||||
|
||||
// 获取每个歌单的详细信息
|
||||
const playlists = [];
|
||||
for (const id of playlistIds) {
|
||||
const playlist = await this.getMusicPlaylist(ensureString(id));
|
||||
if (playlist) {
|
||||
playlists.push(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
// 按创建时间倒序排序
|
||||
return playlists.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
async updateMusicPlaylist(playlistId: string, updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
const updateData: Record<string, string> = {
|
||||
updated_at: Date.now().toString(),
|
||||
};
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
updateData.name = updates.name;
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
updateData.description = updates.description || '';
|
||||
}
|
||||
if (updates.cover !== undefined) {
|
||||
updateData.cover = updates.cover || '';
|
||||
}
|
||||
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(this.musicPlaylistKey(playlistId), updateData)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteMusicPlaylist(playlistId: string): Promise<void> {
|
||||
// 获取歌单信息以获取用户名
|
||||
const playlist = await this.getMusicPlaylist(playlistId);
|
||||
if (!playlist) return;
|
||||
|
||||
// 从用户的歌单列表中移除
|
||||
await this.withRetry(() =>
|
||||
this.adapter.zRem(this.musicPlaylistsKey(playlist.username), playlistId)
|
||||
);
|
||||
|
||||
// 删除歌单信息
|
||||
await this.withRetry(() =>
|
||||
this.adapter.del(this.musicPlaylistKey(playlistId))
|
||||
);
|
||||
|
||||
// 删除歌单的歌曲列表
|
||||
await this.withRetry(() =>
|
||||
this.adapter.del(this.musicPlaylistSongsKey(playlistId))
|
||||
);
|
||||
}
|
||||
|
||||
async addSongToPlaylist(playlistId: string, song: {
|
||||
platform: string;
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
pic?: string;
|
||||
duration: number;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
const songKey = `${song.platform}+${song.id}`;
|
||||
|
||||
const songData = {
|
||||
platform: song.platform,
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album || '',
|
||||
pic: song.pic || '',
|
||||
duration: song.duration.toString(),
|
||||
added_at: now.toString(),
|
||||
};
|
||||
|
||||
// 添加歌曲到歌单(使用 hash 存储歌曲信息)
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(this.musicPlaylistSongsKey(playlistId), songKey, JSON.stringify(songData))
|
||||
);
|
||||
|
||||
// 更新歌单的 updated_at
|
||||
await this.updateMusicPlaylist(playlistId, {});
|
||||
|
||||
// 如果是第一首歌且有封面,更新歌单封面
|
||||
const songs = await this.getPlaylistSongs(playlistId);
|
||||
if (songs.length === 1 && song.pic) {
|
||||
await this.updateMusicPlaylist(playlistId, { cover: song.pic });
|
||||
}
|
||||
}
|
||||
|
||||
async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise<void> {
|
||||
const songKey = `${platform}+${songId}`;
|
||||
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hDel(this.musicPlaylistSongsKey(playlistId), songKey)
|
||||
);
|
||||
|
||||
// 更新歌单的 updated_at
|
||||
await this.updateMusicPlaylist(playlistId, {});
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<any[]> {
|
||||
const songsData = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.musicPlaylistSongsKey(playlistId))
|
||||
);
|
||||
|
||||
if (!songsData || Object.keys(songsData).length === 0) return [];
|
||||
|
||||
const songs = [];
|
||||
for (const [, value] of Object.entries(songsData)) {
|
||||
if (value) {
|
||||
const song = JSON.parse(value);
|
||||
songs.push({
|
||||
platform: song.platform,
|
||||
id: song.id,
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album || undefined,
|
||||
pic: song.pic || undefined,
|
||||
duration: parseFloat(song.duration),
|
||||
added_at: parseInt(song.added_at, 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按添加时间排序
|
||||
return songs.sort((a, b) => a.added_at - b.added_at);
|
||||
}
|
||||
|
||||
async isSongInPlaylist(playlistId: string, platform: string, songId: string): Promise<boolean> {
|
||||
const songKey = `${platform}+${songId}`;
|
||||
const exists = await this.withRetry(() =>
|
||||
this.adapter.hGet(this.musicPlaylistSongsKey(playlistId), songKey)
|
||||
);
|
||||
return exists !== null;
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
|
||||
private userPwdKey(user: string) {
|
||||
return `u:${user}:pwd`;
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface IStorage {
|
||||
// 音乐播放记录相关
|
||||
getMusicPlayRecord(userName: string, key: string): Promise<any | null>;
|
||||
setMusicPlayRecord(userName: string, key: string, record: any): Promise<void>;
|
||||
batchSetMusicPlayRecords(userName: string, records: { 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>;
|
||||
|
||||
Reference in New Issue
Block a user