This commit is contained in:
CNLuminous
2025-11-04 15:15:28 +08:00
commit a6af28e4c5
719 changed files with 570630 additions and 0 deletions
@@ -0,0 +1,9 @@
import { MidwayConfig } from '@midwayjs/core';
export default {
// use for cookie sign key, should change to your own and keep security
keys: '1762222374963_5045',
koa: {
port: null, // 禁用自动端口监听,我们手动管理服务器
},
} as MidwayConfig;
@@ -0,0 +1,7 @@
import { MidwayConfig } from '@midwayjs/core';
export default {
koa: {
port: null,
},
} as MidwayConfig;
+62
View File
@@ -0,0 +1,62 @@
import { Configuration, App } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import * as validate from '@midwayjs/validate';
import * as info from '@midwayjs/info';
import { join } from 'path';
import { createServer } from 'http';
// import { DefaultErrorFilter } from './filter/default.filter';
// import { NotFoundFilter } from './filter/notfound.filter';
import { ReportMiddleware } from './middleware/report.middleware';
import { CorsMiddleware } from './middleware/cors.middleware';
import { WebSocketService } from './service/websocket.service';
@Configuration({
imports: [
koa,
validate,
{
component: info,
enabledEnvironment: ['local'],
},
],
importConfigs: [join(__dirname, './config')],
})
export class MainConfiguration {
@App('koa')
app: koa.Application;
private httpServer: any;
async onReady() {
// add middleware
this.app.useMiddleware([CorsMiddleware, ReportMiddleware]);
// add filter
// this.app.useFilter([NotFoundFilter, DefaultErrorFilter]);
// 处理未捕获的Promise拒绝(防止ECONNRESET等错误导致进程崩溃)
process.on('unhandledRejection', (reason: any) => {
const errCode = reason?.code || reason?.errno;
if (errCode === 'ECONNRESET' || errCode === 'EPIPE' || errCode === -4077 || errCode === 'ECANCELED') {
// 静默处理客户端断开连接的错误
return;
}
console.error('未处理的Promise拒绝:', reason);
});
// 创建HTTP服务器并挂载Koa应用
this.httpServer = createServer(this.app.callback());
// 初始化WebSocket服务
const webSocketService = await this.app.getApplicationContext().getAsync(WebSocketService);
webSocketService.init(this.httpServer);
// 获取端口配置
const port = this.app.getConfig('koa.port') || 7001;
// 监听HTTP服务器
this.httpServer.listen(port, () => {
console.log(`服务器启动在端口 ${port}`);
console.log(`WebSocket服务已启动: ws://localhost:${port}/ws`);
});
}
}
@@ -0,0 +1,344 @@
import { Controller, Get, Post, Body, Param, Inject } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { PlaybackService } from '../service/playback.service';
import { WebSocketService } from '../service/websocket.service';
@Controller('/api/admin')
export class AdminController {
@Inject()
ctx: Context;
@Inject()
playbackService: PlaybackService;
@Inject()
webSocketService: WebSocketService;
/**
* 验证管理员token
*/
private verifyAdminToken(): boolean {
const token = this.ctx.headers.authorization?.replace('Bearer ', '') || this.ctx.query.token;
if (!token) {
this.ctx.status = 401;
this.ctx.body = {
success: false,
message: '未提供认证token',
};
return false;
}
const adminToken = process.env.ADMIN_TOKEN || 'admin123';
if (token !== adminToken) {
this.ctx.status = 403;
this.ctx.body = {
success: false,
message: '无效的认证token',
};
return false;
}
return true;
}
/**
* 管理员登录(获取token)- 不需要token验证
*/
@Post('/login')
async login(@Body() body: { password: string }) {
const adminPassword = process.env.ADMIN_PASSWORD || 'admin';
if (body.password !== adminPassword) {
return {
success: false,
message: '密码错误',
data: null,
};
}
// 返回token(实际生产环境应使用JWT等更安全的方式)
const token = process.env.ADMIN_TOKEN || 'admin123';
return {
success: true,
message: '登录成功',
data: { token },
};
}
/**
* 控制播放/暂停
*/
@Post('/playback/play')
async play() {
if (!this.verifyAdminToken()) {
return;
}
try {
this.playbackService.play();
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
return {
success: true,
message: '播放命令已发送',
data: state,
};
} catch (error: any) {
return {
success: false,
message: error.message || '播放失败',
data: null,
};
}
}
@Post('/playback/pause')
async pause() {
if (!this.verifyAdminToken()) {
return;
}
try {
this.playbackService.pause();
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
return {
success: true,
message: '暂停命令已发送',
data: state,
};
} catch (error: any) {
return {
success: false,
message: error.message || '暂停失败',
data: null,
};
}
}
/**
* 发送公告
*/
@Post('/announcement')
async sendAnnouncement(@Body() body: { title: string; content: string; duration?: number }) {
if (!this.verifyAdminToken()) {
return;
}
if (!body.title || !body.content) {
return {
success: false,
message: '标题和内容不能为空',
data: null,
};
}
try {
this.webSocketService.broadcastAnnouncement({
title: body.title,
content: body.content,
duration: body.duration || 5000, // 默认5秒
});
return {
success: true,
message: '公告已发送',
data: null,
};
} catch (error: any) {
return {
success: false,
message: error.message || '发送公告失败',
data: null,
};
}
}
/**
* 控制特定用户的音量
*/
@Post('/user/:userId/volume')
async setUserVolume(@Body() body: { volume: number; muted?: boolean }, @Param('userId') userId: string) {
if (!this.verifyAdminToken()) {
return;
}
// 确保userId是字符串
if (typeof userId !== 'string') {
return {
success: false,
message: '无效的用户ID',
data: null,
};
}
const volume = body.volume;
const muted = body.muted;
if (volume === undefined || volume < 0 || volume > 1) {
return {
success: false,
message: '音量值必须在0-1之间',
data: null,
};
}
console.log(`管理员请求控制用户 ${userId} 的音量:`, { volume, muted });
try {
const success = this.webSocketService.sendVolumeControlToUser(userId, {
volume,
muted,
});
if (!success) {
return {
success: false,
message: '用户不在线或连接已断开',
data: null,
};
}
return {
success: true,
message: '音量控制命令已发送',
data: { volume, muted },
};
} catch (error: any) {
return {
success: false,
message: error.message || '设置音量失败',
data: null,
};
}
}
/**
* 控制特定用户的静音状态
*/
@Post('/user/:userId/mute')
async setUserMute(@Body() body: { muted: boolean }, @Param('userId') userId: string) {
if (!this.verifyAdminToken()) {
return;
}
// 确保userId是字符串
if (typeof userId !== 'string') {
return {
success: false,
message: '无效的用户ID',
data: null,
};
}
const muted = body.muted;
if (typeof muted !== 'boolean') {
return {
success: false,
message: 'muted必须是布尔值',
data: null,
};
}
console.log(`管理员请求控制用户 ${userId} 的静音状态:`, muted);
try {
// 静音时音量设为0,取消静音时恢复默认音量0.5(或根据实际情况调整)
const volume = muted ? 0 : 0.5;
const success = this.webSocketService.sendVolumeControlToUser(userId, {
volume,
muted,
});
if (!success) {
return {
success: false,
message: '用户不在线或连接已断开',
data: null,
};
}
return {
success: true,
message: muted ? '静音命令已发送' : '取消静音命令已发送',
data: { muted, volume },
};
} catch (error: any) {
return {
success: false,
message: error.message || '设置静音失败',
data: null,
};
}
}
/**
* 获取当前播放状态
*/
@Get('/playback/state')
async getPlaybackState() {
if (!this.verifyAdminToken()) {
return;
}
const state = this.playbackService.getState();
return {
success: true,
message: 'OK',
data: state,
};
}
/**
* 调整播放进度
*/
@Post('/playback/seek')
async seek(@Body() body: { time: number }) {
if (!this.verifyAdminToken()) {
return;
}
if (body.time === undefined || body.time === null) {
return {
success: false,
message: '时间不能为空',
data: null,
};
}
try {
this.playbackService.setCurrentTime(body.time);
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
return {
success: true,
message: 'OK',
data: state,
};
} catch (error: any) {
return {
success: false,
message: error.message || '调整进度失败',
data: null,
};
}
}
/**
* 获取所有在线用户
*/
@Get('/users')
async getAllUsers() {
if (!this.verifyAdminToken()) {
return;
}
const users = this.webSocketService.getAllUsers();
return {
success: true,
message: 'OK',
data: users,
};
}
}
@@ -0,0 +1,18 @@
import { Inject, Controller, Get, Query } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { UserService } from '../service/user.service';
@Controller('/api')
export class APIController {
@Inject()
ctx: Context;
@Inject()
userService: UserService;
@Get('/get_user')
async getUser(@Query('uid') uid) {
const user = await this.userService.getUser({ uid });
return { success: true, message: 'OK', data: user };
}
}
@@ -0,0 +1,9 @@
import { Controller, Get } from '@midwayjs/core';
@Controller('/')
export class HomeController {
@Get('/')
async home(): Promise<string> {
return 'Hello Midwayjs!';
}
}
@@ -0,0 +1,671 @@
import { Controller, Get, Post, Query, Body, Param, Inject } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { MusicService, Song, SongDetail } from '../service/music.service';
import { PlaybackService } from '../service/playback.service';
import { WebSocketService } from '../service/websocket.service';
@Controller('/api/music')
export class MusicController {
@Inject()
ctx: Context;
@Inject()
musicService: MusicService;
@Inject()
playbackService: PlaybackService;
@Inject()
webSocketService: WebSocketService;
constructor() {
// 延迟注入,避免循环依赖
setTimeout(() => {
this.webSocketService.setPlaybackService(this.playbackService);
this.playbackService.setWebSocketService(this.webSocketService);
// 设置MusicController到PlaybackService,用于自动播放下一首
this.playbackService.setMusicController(this);
}, 100);
}
// 注意:管理员控制器也需要注入,所以这里不需要改动
/**
* 搜索歌曲
*/
@Get('/search')
async search(@Query('keywords') keywords: string, @Query('limit') limit: number = 20): Promise<{ success: boolean; message: string; data: Song[] }> {
if (!keywords || keywords.trim() === '') {
return {
success: false,
message: '搜索关键词不能为空',
data: [],
};
}
try {
const songs = await this.musicService.searchSongs(keywords.trim(), limit);
return {
success: true,
message: 'OK',
data: songs,
};
} catch (error: any) {
return {
success: false,
message: error.message || '搜索失败',
data: [],
};
}
}
/**
* 获取歌曲详情
*/
@Get('/song/:id/detail')
async getSongDetail(@Param('id') id: string): Promise<{ success: boolean; message: string; data: SongDetail | null }> {
try {
const detail = await this.musicService.getSongDetail(id);
if (!detail) {
return {
success: false,
message: '歌曲不存在',
data: null,
};
}
return {
success: true,
message: 'OK',
data: detail,
};
} catch (error: any) {
return {
success: false,
message: error.message || '获取歌曲详情失败',
data: null,
};
}
}
/**
* 获取歌曲播放URL(返回后端代理的流URL)
*/
@Get('/song/:id/url')
async getSongUrl(@Param('id') id: string, @Query('level') level: string = 'standard') {
try {
// 返回后端代理的流URL,而不是直接返回网易云的URL
const proxyUrl = `/api/music/stream/${id}?level=${level}`;
return {
success: true,
message: 'OK',
data: { url: proxyUrl },
};
} catch (error: any) {
return {
success: false,
message: error.message || '获取播放URL失败',
data: { url: '' },
};
}
}
/**
* 音频流代理端点 - 从网易云API获取音频流并转发
*/
@Get('/stream/:id')
async streamAudio(@Param('id') id: string, @Query('level') level: string = 'standard') {
try {
// 获取真实的音频URL
let audioUrl: string;
try {
audioUrl = await this.musicService.getSongUrl(id, level);
} catch (error: any) {
console.error(`获取音频URL失败,歌曲ID: ${id}:`, error.message);
this.ctx.status = 500;
this.ctx.body = { error: '获取音频URL失败: ' + error.message };
return;
}
if (!audioUrl || audioUrl.trim() === '') {
console.warn(`音频URL为空,歌曲ID: ${id}`);
this.ctx.status = 404;
this.ctx.body = { error: '音频不存在或已下架' };
return;
}
// 验证URL格式
let audioUrlObj: URL;
try {
audioUrlObj = new URL(audioUrl);
} catch (error) {
console.error(`音频URL格式无效: ${audioUrl}`);
this.ctx.status = 500;
this.ctx.body = { error: '音频URL格式无效' };
return;
}
console.log(`代理音频流: ${id} -> ${audioUrlObj.protocol}//${audioUrlObj.host}${audioUrlObj.pathname.substring(0, 50)}...`);
// 设置响应头,支持流式传输
const http = require('http');
const https = require('https');
const httpModule = audioUrlObj.protocol === 'https:' ? https : http;
// 检查客户端是否已断开连接
if (this.ctx.req.aborted || this.ctx.res.destroyed || this.ctx.res.writableEnded) {
return;
}
// 从网易云API获取音频流
return new Promise<void>((resolve) => {
let isResolved = false;
const cleanup = () => {
if (isResolved) return;
isResolved = true;
};
const safeResolve = () => {
if (!isResolved) {
cleanup();
resolve();
}
};
// 监听客户端断开
this.ctx.req.on('close', () => {
if (upstreamResponse) {
upstreamResponse.destroy();
}
safeResolve();
});
this.ctx.req.on('aborted', () => {
if (upstreamResponse) {
upstreamResponse.destroy();
}
safeResolve();
});
let upstreamResponse: any = null;
// 构建请求选项,支持Range请求
const requestOptions: any = {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
};
// 如果有Range请求,转发给上游服务器
if (this.ctx.request.headers.range) {
requestOptions.headers['Range'] = this.ctx.request.headers.range;
}
// 使用正确的http.get调用方式
const request = httpModule.get(audioUrl, requestOptions, (response: any) => {
upstreamResponse = response;
// 检查客户端是否已断开
if (this.ctx.req.aborted || this.ctx.res.destroyed || this.ctx.res.writableEnded) {
response.destroy();
safeResolve();
return;
}
// 处理错误响应
if (response.statusCode && response.statusCode >= 400) {
console.error(`音频流上游返回错误: ${response.statusCode}, 歌曲ID: ${id}, URL: ${audioUrl.substring(0, 100)}`);
if (!this.ctx.res.headersSent) {
this.ctx.status = response.statusCode;
this.ctx.body = { error: `获取音频流失败: ${response.statusCode}` };
}
safeResolve();
return;
}
console.log(`音频流代理成功: ${id}, 状态码: ${response.statusCode}, Range: ${this.ctx.request.headers.range || '无'}`);
// 复制响应头
this.ctx.set('Content-Type', response.headers['content-type'] || 'audio/mpeg');
if (response.headers['content-length']) {
this.ctx.set('Content-Length', response.headers['content-length']);
}
this.ctx.set('Accept-Ranges', 'bytes');
this.ctx.set('Cache-Control', 'no-cache');
// 支持Range请求(断点续传)
// 转发上游服务器的状态码和Range相关头
this.ctx.status = response.statusCode || 200;
if (response.statusCode === 206) {
// 部分内容响应
if (response.headers['content-range']) {
this.ctx.set('Content-Range', response.headers['content-range']);
}
if (response.headers['content-length']) {
this.ctx.set('Content-Length', response.headers['content-length']);
}
}
// 处理所有可能的错误,避免未处理的异常
const handleError = (err: any, source: string) => {
const errCode = err?.code || err?.errno;
// 忽略客户端断开连接的错误(正常情况)
if (errCode === 'ECONNRESET' || errCode === 'EPIPE' || errCode === -4077 || errCode === 'ECANCELED') {
// 静默处理,不记录日志
if (upstreamResponse) {
upstreamResponse.destroy();
}
safeResolve();
return;
}
// 其他错误才记录
console.warn(`音频流${source}错误:`, err.message || err);
if (upstreamResponse) {
upstreamResponse.destroy();
}
safeResolve();
};
// 流式传输数据
try {
response.pipe(this.ctx.res, { end: true });
} catch (err: any) {
handleError(err, '管道');
return;
}
response.on('end', () => {
safeResolve();
});
response.on('error', (err: any) => {
handleError(err, '上游响应');
});
// 监听管道错误
this.ctx.res.on('error', (err: any) => {
handleError(err, '响应流');
});
// 监听管道完成(可能在某些情况下不会触发end)
this.ctx.res.on('finish', () => {
safeResolve();
});
});
request.on('error', (err: any) => {
const errCode = err?.code || err?.errno;
if (errCode === 'ECONNRESET' || errCode === 'EPIPE' || errCode === -4077) {
safeResolve();
return;
}
console.warn('获取音频流请求失败:', err.message || err);
if (!this.ctx.res.headersSent) {
this.ctx.status = 500;
this.ctx.body = { error: '获取音频流失败' };
}
safeResolve();
});
// 设置超时
request.setTimeout(30000, () => {
request.destroy();
if (!this.ctx.res.headersSent) {
this.ctx.status = 504;
this.ctx.body = { error: '请求超时' };
}
safeResolve();
});
}).catch((err: any) => {
// 确保所有错误都被捕获,不会导致未处理的异常
const errCode = err?.code || err?.errno;
if (errCode === 'ECONNRESET' || errCode === 'EPIPE' || errCode === -4077) {
// 静默处理
return;
}
console.warn('音频流代理异常:', err.message || err);
if (!this.ctx.res.headersSent) {
this.ctx.status = 500;
this.ctx.body = { error: '音频流代理失败' };
}
});
} catch (error: any) {
if (!this.ctx.res.headersSent) {
this.ctx.status = 500;
this.ctx.body = { error: error.message || '音频流代理失败' };
}
}
}
/**
* 获取歌词
*/
@Get('/song/:id/lyric')
async getLyric(@Param('id') id: string): Promise<{ success: boolean; message: string; data: { raw: string; tlyric: string } | null }> {
try {
const lyric = await this.musicService.getLyric(id);
return {
success: true,
message: 'OK',
data: lyric,
};
} catch (error: any) {
return {
success: false,
message: error.message || '获取歌词失败',
data: null,
};
}
}
/**
* 获取播放队列
*/
@Get('/queue')
async getQueue(): Promise<{ success: boolean; message: string; data: Song[] }> {
const queue = this.musicService.getQueue();
return {
success: true,
message: 'OK',
data: queue,
};
}
/**
* 添加到播放队列
*/
@Post('/queue/add')
async addToQueue(@Body() body: { song: any }): Promise<{ success: boolean; message: string; data: { added: boolean; queue: Song[]; playbackState?: any } | null }> {
if (!body.song || !body.song.id) {
return {
success: false,
message: '歌曲信息不完整',
data: null,
};
}
const added = this.musicService.addToQueue(body.song);
// 如果成功添加到队列,且当前没有歌曲在播放,则自动开始播放
if (added) {
const currentState = this.playbackService.getState();
// 检查当前是否有歌曲在播放
if (!currentState.song || !currentState.isPlaying) {
// 从队列中取出第一首歌曲并播放(这样切歌时就不会重复播放同一首)
const firstSong = this.musicService.playNext();
if (firstSong) {
try {
const detail = await this.musicService.getSongDetail(firstSong.id);
if (detail) {
// 设置当前播放歌曲
this.playbackService.setCurrentSong(detail);
// 自动开始播放
this.playbackService.play();
// 广播播放状态
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
return {
success: true,
message: '已添加到队列并开始播放',
data: {
added,
queue: this.musicService.getQueue(),
playbackState: state,
},
};
}
} catch (error: any) {
console.error('自动播放失败:', error);
// 如果自动播放失败,将歌曲重新加回队列(因为playNext已经移除了)
this.musicService.addToQueue(firstSong);
// 如果自动播放失败,仍然返回成功添加
}
}
}
}
return {
success: true,
message: added ? '已添加到队列' : '歌曲已在队列中',
data: {
added,
queue: this.musicService.getQueue(),
},
};
}
/**
* 播放下一首(内部方法,供PlaybackService调用)
*/
async playNextInternal(): Promise<void> {
const song = this.musicService.playNext();
// 如果有下一首歌曲,自动设置并开始播放
if (song) {
try {
const detail = await this.musicService.getSongDetail(song.id);
if (detail) {
// 设置当前播放歌曲
this.playbackService.setCurrentSong(detail);
// 自动开始播放
this.playbackService.play();
// 广播播放状态
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
console.log(`自动播放下一首: ${detail.title}`);
}
} catch (error: any) {
console.error('自动播放下一首失败:', error);
// 如果播放失败,尝试继续播放下一首
if (this.musicService.getQueue().length > 0) {
setTimeout(() => this.playNextInternal(), 1000);
}
}
} else {
// 队列为空时,取消当前播放
this.playbackService.setCurrentSong(null);
// 广播播放状态(清空播放状态)
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
console.log('队列为空,播放结束');
}
}
/**
* 播放下一首
*/
@Post('/queue/play-next')
async playNext(): Promise<{ success: boolean; message: string; data: { song: Song | null; queue: Song[]; playbackState: any } }> {
await this.playNextInternal();
const state = this.playbackService.getState();
return {
success: true,
message: state.song ? 'OK' : '队列为空,已停止播放',
data: {
song: state.song ? {
id: state.song.id,
title: state.song.title,
artist: state.song.artist,
album: state.song.album || '',
durationSec: state.song.durationSec || 0
} : null,
queue: this.musicService.getQueue(),
playbackState: state,
},
};
}
/**
* 清空队列
*/
@Post('/queue/clear')
async clearQueue() {
this.musicService.clearQueue();
return {
success: true,
message: '队列已清空',
data: [],
};
}
/**
* 从队列移除歌曲
*/
@Post('/queue/remove')
async removeFromQueue(@Body() body: { songId: string }): Promise<{ success: boolean; message: string; data: { removed: boolean; queue: Song[] } | null }> {
if (!body.songId) {
return {
success: false,
message: '歌曲ID不能为空',
data: null,
};
}
const removed = this.musicService.removeFromQueue(body.songId);
return {
success: true,
message: removed ? '已从队列移除' : '歌曲不在队列中',
data: {
removed,
queue: this.musicService.getQueue(),
},
};
}
/**
* 设置当前播放歌曲
*/
@Post('/playback/set-song')
async setPlaybackSong(@Body() body: { songId: string }) {
try {
if (!body.songId) {
return {
success: false,
message: '歌曲ID不能为空',
data: null,
};
}
const detail = await this.musicService.getSongDetail(body.songId);
if (!detail) {
return {
success: false,
message: '歌曲不存在',
data: null,
};
}
this.playbackService.setCurrentSong(detail);
// 广播播放状态
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
return {
success: true,
message: 'OK',
data: state,
};
} catch (error: any) {
return {
success: false,
message: error.message || '设置播放歌曲失败',
data: null,
};
}
}
/**
* 播放控制 - 播放
*/
@Post('/playback/play')
async play() {
try {
this.playbackService.play();
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
return {
success: true,
message: 'OK',
data: state,
};
} catch (error: any) {
return {
success: false,
message: error.message || '播放失败',
data: null,
};
}
}
/**
* 播放控制 - 暂停
*/
@Post('/playback/pause')
async pause() {
try {
this.playbackService.pause();
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
return {
success: true,
message: 'OK',
data: state,
};
} catch (error: any) {
return {
success: false,
message: error.message || '暂停失败',
data: null,
};
}
}
/**
* 播放控制 - 设置播放时间
*/
@Post('/playback/seek')
async seek(@Body() body: { time: number }) {
try {
if (body.time === undefined || body.time === null) {
return {
success: false,
message: '时间不能为空',
data: null,
};
}
this.playbackService.setCurrentTime(body.time);
const state = this.playbackService.getState();
this.webSocketService.broadcastPlaybackState(state);
return {
success: true,
message: 'OK',
data: state,
};
} catch (error: any) {
return {
success: false,
message: error.message || '设置播放时间失败',
data: null,
};
}
}
/**
* 获取当前播放状态
*/
@Get('/playback/state')
async getPlaybackState() {
const state = this.playbackService.getState();
return {
success: true,
message: 'OK',
data: state,
};
}
}
@@ -0,0 +1,29 @@
import { Catch } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
@Catch()
export class DefaultErrorFilter {
async catch(err: any, ctx: Context) {
// 忽略客户端断开连接的错误(正常情况)
const errCode = err?.code || err?.errno;
if (errCode === 'ECONNRESET' || errCode === 'EPIPE' || errCode === -4077 || errCode === 'ECANCELED') {
// 如果响应头已发送,直接返回,不设置状态码
if (ctx.res.headersSent || ctx.res.destroyed || ctx.res.writableEnded) {
return;
}
// 否则返回空响应
ctx.status = 204;
return;
}
// 其他的未分类错误
if (!ctx.res.headersSent) {
return {
success: false,
message: err.message || '服务器错误',
};
}
// 如果响应头已发送,无法返回JSON,只能记录日志
console.error('响应头已发送后的错误:', err.message || err);
}
}
@@ -0,0 +1,10 @@
import { Catch, httpError, MidwayHttpError } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
@Catch(httpError.NotFoundError)
export class NotFoundFilter {
async catch(err: MidwayHttpError, ctx: Context) {
// 404 错误会到这里
ctx.redirect('/404.html');
}
}
+6
View File
@@ -0,0 +1,6 @@
/**
* @description User-Service parameters
*/
export interface IUserOptions {
uid: number;
}
@@ -0,0 +1,42 @@
import { Middleware, IMiddleware } from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
@Middleware()
export class AdminMiddleware implements IMiddleware<Context, NextFunction> {
resolve() {
return async (ctx: Context, next: NextFunction) => {
// 从请求头获取token
const token = ctx.headers.authorization?.replace('Bearer ', '') || ctx.query.token;
if (!token) {
ctx.status = 401;
ctx.body = {
success: false,
message: '未提供认证token',
};
return;
}
// 简单的token验证(实际生产环境应使用更安全的方式)
// 这里使用配置中的admin token
const adminToken = process.env.ADMIN_TOKEN || 'admin123';
if (token !== adminToken) {
ctx.status = 403;
ctx.body = {
success: false,
message: '无效的认证token',
};
return;
}
// token验证通过,继续处理请求
await next();
};
}
static getName(): string {
return 'admin';
}
}
@@ -0,0 +1,28 @@
import { Middleware, IMiddleware } from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
@Middleware()
export class CorsMiddleware implements IMiddleware<Context, NextFunction> {
resolve() {
return async (ctx: Context, next: NextFunction) => {
// 设置CORS头
ctx.set('Access-Control-Allow-Origin', '*');
ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
ctx.set('Access-Control-Max-Age', '86400');
// 处理预检请求
if (ctx.method === 'OPTIONS') {
ctx.status = 204;
return;
}
await next();
};
}
static getName(): string {
return 'cors';
}
}
@@ -0,0 +1,27 @@
import { Middleware, IMiddleware } from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
@Middleware()
export class ReportMiddleware implements IMiddleware<Context, NextFunction> {
resolve() {
return async (ctx: Context, next: NextFunction) => {
// 控制器前执行的逻辑
const startTime = Date.now();
// 执行下一个 Web 中间件,最后执行到控制器
// 这里可以拿到下一个中间件或者控制器的返回值
const result = await next();
// 控制器之后执行的逻辑
ctx.logger.info(
`Report in "src/middleware/report.middleware.ts", rt = ${
Date.now() - startTime
}ms`
);
// 返回给上一个中间件的结果
return result;
};
}
static getName(): string {
return 'report';
}
}
@@ -0,0 +1,261 @@
import { Provide, Singleton, Inject } from '@midwayjs/core';
import { WebSocketService } from './websocket.service';
export interface Song {
id: string;
title: string;
artist: string;
album: string;
durationSec: number;
}
export interface SongDetail extends Song {
year?: number;
language?: string;
tags?: string[];
description?: string;
playUrl?: string;
}
@Provide()
@Singleton()
export class MusicService {
@Inject()
webSocketService: WebSocketService;
// 网易云API基础URL
private neteaseApiBase = process.env.NETEASE_API_BASE || 'http://localhost:3000';
// 搜索缓存:key为搜索关键词,value为搜索结果
private searchCache = new Map<string, { songs: Song[]; timestamp: number }>();
// 搜索缓存过期时间(毫秒),默认5分钟
private searchCacheTTL = 5 * 60 * 1000;
// 歌曲详情缓存:key为歌曲ID,value为详情
private detailCache = new Map<string, { detail: SongDetail; timestamp: number }>();
// 详情缓存过期时间(毫秒),默认10分钟
private detailCacheTTL = 10 * 60 * 1000;
// 播放队列
private queue: Song[] = [];
/**
* 调用网易云API
*/
private async requestNeteaseAPI(path: string, params: Record<string, any> = {}): Promise<any> {
const url = new URL(path, this.neteaseApiBase);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') {
url.searchParams.set(k, String(v));
}
});
// 使用Node.js内置的http/https模块
const http = url.protocol === 'https:' ? require('https') : require('http');
return new Promise((resolve, reject) => {
http.get(url.toString(), (res: any) => {
let data = '';
res.on('data', (chunk: string) => {
data += chunk;
});
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json);
} catch (error) {
reject(new Error('解析响应失败'));
}
});
}).on('error', (error: Error) => {
reject(new Error(`HTTP请求失败: ${error.message}`));
});
});
}
/**
* 搜索歌曲
*/
async searchSongs(keywords: string, limit: number = 20): Promise<Song[]> {
const cacheKey = `${keywords}_${limit}`;
const cached = this.searchCache.get(cacheKey);
const now = Date.now();
// 检查缓存是否有效
if (cached && (now - cached.timestamp) < this.searchCacheTTL) {
return cached.songs;
}
// 调用API
const data = await this.requestNeteaseAPI('/search', { keywords, limit });
const songs = (data?.result?.songs || []).map((s: any) => ({
id: String(s.id),
title: s.name,
artist: (s.ar || s.artists || []).map((a: any) => a.name).join(' / '),
album: (s.al || s.album || {}).name || '',
durationSec: Math.floor((s.dt || s.duration || 0) / 1000),
}));
// 更新缓存
this.searchCache.set(cacheKey, { songs, timestamp: now });
return songs;
}
/**
* 获取歌曲详情
*/
async getSongDetail(id: string): Promise<SongDetail | null> {
const cached = this.detailCache.get(id);
const now = Date.now();
// 检查缓存是否有效
if (cached && (now - cached.timestamp) < this.detailCacheTTL) {
return cached.detail;
}
// 调用API
const data = await this.requestNeteaseAPI('/song/detail', { ids: id });
const song = (data?.songs || [])[0];
if (!song) {
return null;
}
const detail: SongDetail = {
id: String(song.id),
title: song.name,
artist: (song.ar || []).map((a: any) => a.name).join(' / '),
album: (song.al || {}).name || '',
durationSec: Math.floor((song.dt || 0) / 1000),
year: undefined,
language: undefined,
tags: [],
description: `${song.name} · ${(song.ar || []).map((a: any) => a.name).join(' / ')} - ${(song.al || {}).name || ''}`,
};
// 更新缓存
this.detailCache.set(id, { detail, timestamp: now });
return detail;
}
/**
* 获取歌曲播放URL
*/
async getSongUrl(id: string, level: string = 'standard'): Promise<string> {
try {
// 使用新的接口 /song/url/match?id=xxx&source=pyncmd
const data = await this.requestNeteaseAPI('/song/url/match', { id, source: 'pyncmd' });
// 根据新接口的响应格式解析URL
// 新接口可能返回不同的数据结构,需要根据实际响应调整
let url = '';
// 尝试多种可能的响应格式
if (data?.url) {
url = data.url;
} else if (data?.data?.url) {
url = data.data.url;
} else if (data?.data && Array.isArray(data.data) && data.data.length > 0) {
url = data.data[0]?.url || '';
} else if (typeof data === 'string') {
// 如果直接返回URL字符串
url = data;
}
if (!url) {
console.warn(`获取音频URL失败,歌曲ID: ${id}, 响应:`, JSON.stringify(data).substring(0, 200));
}
return url;
} catch (error: any) {
console.error(`获取音频URL异常,歌曲ID: ${id}:`, error.message);
throw error;
}
}
/**
* 获取歌词
*/
async getLyric(id: string): Promise<{ raw: string; tlyric: string }> {
const data = await this.requestNeteaseAPI('/lyric', { id });
// Prefer karaoke version if provided; fallback to standard lrc
const raw = data?.lrc?.lyric || data?.klyric?.lyric || '';
const tlyric = data?.tlyric?.lyric || '';
return { raw, tlyric };
}
/**
* 获取播放队列
*/
getQueue(): Song[] {
return [...this.queue];
}
/**
* 添加到播放队列
*/
addToQueue(song: Song): boolean {
// 检查是否已存在
if (this.queue.find(s => s.id === song.id)) {
return false;
}
this.queue.push(song);
// 广播队列更新
this.broadcastQueueUpdate();
return true;
}
/**
* 播放下一首(从队列取出)
*/
playNext(): Song | null {
if (this.queue.length === 0) {
return null;
}
const song = this.queue.shift() || null;
// 广播队列更新
this.broadcastQueueUpdate();
return song;
}
/**
* 清空队列
*/
clearQueue(): void {
this.queue = [];
// 广播队列更新
this.broadcastQueueUpdate();
}
/**
* 从队列移除指定歌曲
*/
removeFromQueue(songId: string): boolean {
const index = this.queue.findIndex(s => s.id === songId);
if (index >= 0) {
this.queue.splice(index, 1);
// 广播队列更新
this.broadcastQueueUpdate();
return true;
}
return false;
}
/**
* 广播队列更新
*/
private broadcastQueueUpdate(): void {
try {
if (this.webSocketService) {
this.webSocketService.broadcastQueue(this.getQueue());
}
} catch (error) {
// WebSocket服务可能未初始化,忽略错误
console.warn('广播队列更新失败:', error);
}
}
}
@@ -0,0 +1,158 @@
import { Provide, Singleton } from '@midwayjs/core';
import { SongDetail } from './music.service';
export interface PlaybackState {
song: SongDetail | null;
currentTime: number; // 当前播放时间(秒)
duration: number; // 总时长(秒)
isPlaying: boolean; // 是否正在播放
paused: boolean; // 是否暂停
lastUpdateTime: number; // 最后更新时间戳
}
@Provide()
@Singleton()
export class PlaybackService {
private state: PlaybackState = {
song: null,
currentTime: 0,
duration: 0,
isPlaying: false,
paused: false,
lastUpdateTime: Date.now(),
};
private updateTimer: NodeJS.Timeout | null = null;
private webSocketService: any; // 延迟注入,避免循环依赖
private musicController: any; // 延迟注入,避免循环依赖
/**
* 设置WebSocket服务(用于广播状态更新)
*/
setWebSocketService(service: any) {
this.webSocketService = service;
}
/**
* 设置MusicController(用于调用播放下一首API)
*/
setMusicController(controller: any) {
this.musicController = controller;
}
/**
* 设置当前播放的歌曲
*/
setCurrentSong(song: SongDetail | null) {
this.state.song = song;
this.state.currentTime = 0;
this.state.duration = song?.durationSec || 0;
this.state.isPlaying = false;
this.state.paused = false;
this.state.lastUpdateTime = Date.now();
this.stopUpdateTimer();
}
/**
* 开始播放
*/
play() {
if (!this.state.song) return;
this.state.isPlaying = true;
this.state.paused = false;
this.state.lastUpdateTime = Date.now();
this.startUpdateTimer();
}
/**
* 暂停播放
*/
pause() {
this.state.isPlaying = false;
this.state.paused = true;
this.state.lastUpdateTime = Date.now();
this.stopUpdateTimer();
}
/**
* 设置播放时间
*/
setCurrentTime(time: number) {
this.state.currentTime = Math.max(0, Math.min(time, this.state.duration));
this.state.lastUpdateTime = Date.now();
}
/**
* 获取当前播放状态
*/
getState(): PlaybackState {
return { ...this.state };
}
/**
* 开始更新计时器(用于自动更新播放进度)
*/
private startUpdateTimer() {
this.stopUpdateTimer();
let lastBroadcastTime = 0;
this.updateTimer = setInterval(() => {
if (this.state.isPlaying && this.state.song) {
const elapsed = (Date.now() - this.state.lastUpdateTime) / 1000;
const newTime = this.state.currentTime + elapsed;
if (newTime >= this.state.duration) {
// 歌曲播放完成
this.state.currentTime = this.state.duration;
this.state.isPlaying = false;
this.state.paused = true;
this.stopUpdateTimer();
// 自动播放下一首
this.playNextSong();
} else {
this.state.currentTime = newTime;
}
this.state.lastUpdateTime = Date.now();
// 每1秒广播一次播放状态
const now = Date.now();
if (this.webSocketService && now - lastBroadcastTime >= 1000) {
this.webSocketService.broadcastPlaybackState(this.getState());
lastBroadcastTime = now;
}
}
}, 100); // 每100ms更新一次
}
/**
* 停止更新计时器
*/
private stopUpdateTimer() {
if (this.updateTimer) {
clearInterval(this.updateTimer);
this.updateTimer = null;
}
}
/**
* 播放下一首歌曲
*/
private async playNextSong() {
try {
// 通过MusicController播放下一首(避免循环依赖)
if (this.musicController) {
// 调用MusicController的playNext方法
await this.musicController.playNextInternal();
}
} catch (error) {
console.error('自动播放下一首失败:', error);
}
}
/**
* 清理资源
*/
destroy() {
this.stopUpdateTimer();
}
}
+163
View File
@@ -0,0 +1,163 @@
import { Provide, Singleton } from '@midwayjs/core';
import * as crypto from 'crypto';
export interface UserInfo {
id: string;
ip: string;
connectedAt: number;
lastActiveAt: number;
}
@Provide()
@Singleton()
export class UserService {
// IP到用户ID的映射(IP -> UserID
private ipToUserId: Map<string, string> = new Map();
// 用户ID到用户信息的映射
private users: Map<string, UserInfo> = new Map();
/**
* 根据客户端标识获取或创建用户ID
* @param clientId 客户端提供的唯一标识(从localStorage获取)
* @param ip 客户端IP地址(用于显示)
*/
getUserIdByClientId(clientId: string, ip: string): string {
// 如果该客户端标识已有用户ID,直接返回
if (this.ipToUserId.has(clientId)) {
const userId = this.ipToUserId.get(clientId)!;
// 更新最后活跃时间
const userInfo = this.users.get(userId);
if (userInfo) {
userInfo.lastActiveAt = Date.now();
// 更新IP(可能变化)
userInfo.ip = ip;
}
return userId;
}
// 生成新的用户ID(基于客户端标识的哈希,确保相同客户端总是得到相同ID)
const hash = crypto.createHash('md5').update(clientId).digest('hex');
const userId = `user_${hash.substring(0, 8)}`;
// 保存映射关系
this.ipToUserId.set(clientId, userId);
// 创建用户信息
const userInfo: UserInfo = {
id: userId,
ip: ip,
connectedAt: Date.now(),
lastActiveAt: Date.now(),
};
this.users.set(userId, userInfo);
return userId;
}
/**
* 根据IP获取或创建用户ID(兼容旧方法)
* @deprecated 使用 getUserIdByClientId 代替
*/
getUserIdByIp(ip: string): string {
// 如果该IP已有用户ID,直接返回
if (this.ipToUserId.has(ip)) {
const userId = this.ipToUserId.get(ip)!;
// 更新最后活跃时间
const userInfo = this.users.get(userId);
if (userInfo) {
userInfo.lastActiveAt = Date.now();
}
return userId;
}
// 生成新的用户ID(基于IP的哈希,确保相同IP总是得到相同ID)
const hash = crypto.createHash('md5').update(ip).digest('hex');
const userId = `user_${hash.substring(0, 8)}`;
// 保存映射关系
this.ipToUserId.set(ip, userId);
// 创建用户信息
const userInfo: UserInfo = {
id: userId,
ip: ip,
connectedAt: Date.now(),
lastActiveAt: Date.now(),
};
this.users.set(userId, userInfo);
return userId;
}
/**
* 获取用户信息
*/
getUserInfo(userId: string): UserInfo | null {
return this.users.get(userId) || null;
}
/**
* 更新用户活跃时间
*/
updateUserActiveTime(userId: string): void {
const userInfo = this.users.get(userId);
if (userInfo) {
userInfo.lastActiveAt = Date.now();
}
}
/**
* 移除用户(断开连接时)
*/
removeUser(userId: string, clientId?: string): void {
const userInfo = this.users.get(userId);
if (userInfo) {
// 如果提供了clientId,优先使用clientId删除映射
if (clientId) {
this.ipToUserId.delete(clientId);
} else {
// 否则尝试通过IP删除(兼容旧逻辑)
this.ipToUserId.delete(userInfo.ip);
}
this.users.delete(userId);
}
}
/**
* 获取所有在线用户
*/
getAllUsers(): UserInfo[] {
return Array.from(this.users.values());
}
/**
* 获取用户数量
*/
getUserCount(): number {
return this.users.size;
}
/**
* 兼容旧接口:根据uid获取用户信息
* @deprecated 使用 getUserInfo 代替
*/
async getUser(options: { uid: string }) {
const userInfo = this.getUserInfo(options.uid);
if (userInfo) {
return {
uid: userInfo.id,
username: `用户_${userInfo.id.substring(5)}`,
ip: userInfo.ip,
connectedAt: userInfo.connectedAt,
lastActiveAt: userInfo.lastActiveAt,
};
}
return {
uid: options.uid,
username: 'unknown',
ip: 'unknown',
connectedAt: 0,
lastActiveAt: 0,
};
}
}
@@ -0,0 +1,369 @@
import { Provide, Singleton, Inject } from '@midwayjs/core';
import { Song } from './music.service';
import { PlaybackState } from './playback.service';
import { UserService } from './user.service';
import * as WebSocket from 'ws';
@Provide()
@Singleton()
export class WebSocketService {
@Inject()
playbackService: any; // 避免循环依赖,使用any
@Inject()
userService: UserService;
private wss: WebSocket.Server | null = null;
private clients: Set<WebSocket> = new Set();
private clientToUserId: Map<WebSocket, string> = new Map(); // WebSocket连接 -> 用户ID映射
private clientToClientId: Map<WebSocket, string> = new Map(); // WebSocket连接 -> 客户端标识映射
private currentQueue: Song[] = [];
/**
* 初始化WebSocket服务器
*/
init(server: any) {
if (this.wss) {
console.warn('WebSocket服务器已经初始化');
return;
}
this.wss = new WebSocket.Server({
server,
path: '/ws'
});
this.wss.on('connection', (ws: WebSocket, req: any) => {
// 获取客户端IP地址(用于显示)
// 优先从代理头获取,然后从socket获取
let ip = 'unknown';
if (req.headers['x-forwarded-for']) {
ip = String(req.headers['x-forwarded-for']).split(',')[0].trim();
} else if (req.headers['x-real-ip']) {
ip = String(req.headers['x-real-ip']);
} else if (req.socket?.remoteAddress) {
ip = req.socket.remoteAddress;
// 处理IPv6格式 (::ffff:127.0.0.1 -> 127.0.0.1)
if (ip.startsWith('::ffff:')) {
ip = ip.substring(7);
}
}
// 获取客户端标识(从URL参数或headers中获取)
const url = new URL(req.url || '/ws', `http://${req.headers.host || 'localhost'}`);
let clientId = url.searchParams.get('clientId') || req.headers['x-client-id'];
// 如果没有提供clientId,使用IP作为fallback(但这样在代理环境下可能不准确)
if (!clientId) {
clientId = ip;
}
// 根据客户端标识获取或创建用户ID(相同客户端标识总是得到相同ID)
const userId = this.userService.getUserIdByClientId(clientId, ip);
// 设置isAlive标志
(ws as any).isAlive = true;
(ws as any).userId = userId;
(ws as any).ip = ip;
(ws as any).clientId = clientId;
this.clients.add(ws);
this.clientToUserId.set(ws, userId);
this.clientToClientId.set(ws, clientId);
console.log(`WebSocket客户端已连接,IP: ${ip}, 客户端ID: ${clientId}, 用户ID: ${userId}, 当前连接数: ${this.clients.size}`);
// 发送当前队列状态
this.broadcastQueue(this.currentQueue);
// 发送当前播放状态(延迟获取,避免循环依赖)
// 会在连接后通过controller注入
this.sendPlaybackState(ws);
// 发送用户ID给客户端
this.sendUserId(ws, userId);
// 广播在线人数更新(包括新连接的客户端)
this.broadcastOnlineCount();
ws.on('close', () => {
const userId = this.clientToUserId.get(ws);
const clientId = this.clientToClientId.get(ws);
this.clients.delete(ws);
this.clientToUserId.delete(ws);
this.clientToClientId.delete(ws);
// 移除用户(传递clientId以便正确清理映射)
if (userId) {
this.userService.removeUser(userId, clientId);
}
console.log(`WebSocket客户端已断开,用户ID: ${userId}, 当前连接数: ${this.clients.size}`);
// 广播在线人数更新
this.broadcastOnlineCount();
});
ws.on('error', (error) => {
const userId = this.clientToUserId.get(ws);
const clientId = this.clientToClientId.get(ws);
console.error('WebSocket错误:', error, '用户ID:', userId);
this.clients.delete(ws);
this.clientToUserId.delete(ws);
this.clientToClientId.delete(ws);
// 移除用户(传递clientId以便正确清理映射)
if (userId) {
this.userService.removeUser(userId, clientId);
}
// 广播在线人数更新
this.broadcastOnlineCount();
});
// 心跳检测
ws.on('pong', () => {
(ws as any).isAlive = true;
// 更新用户活跃时间
const userId = (ws as any).userId;
if (userId) {
this.userService.updateUserActiveTime(userId);
}
});
});
// 定期发送ping保持连接
const pingInterval = setInterval(() => {
this.clients.forEach((ws) => {
if ((ws as any).isAlive === false) {
const userId = this.clientToUserId.get(ws);
const clientId = this.clientToClientId.get(ws);
ws.terminate();
this.clients.delete(ws);
this.clientToUserId.delete(ws);
this.clientToClientId.delete(ws);
// 移除用户(传递clientId以便正确清理映射)
if (userId) {
this.userService.removeUser(userId, clientId);
}
// 广播在线人数更新
this.broadcastOnlineCount();
return;
}
(ws as any).isAlive = false;
ws.ping();
});
}, 30000);
// 清理定时器
if (this.wss) {
this.wss.on('close', () => {
clearInterval(pingInterval);
});
}
}
/**
* 广播队列更新
*/
broadcastQueue(queue: Song[]) {
this.currentQueue = queue;
const message = JSON.stringify({
type: 'queue_update',
data: queue,
timestamp: Date.now(),
});
this.broadcast(message);
}
/**
* 广播播放状态更新
*/
broadcastPlaybackState(state: PlaybackState) {
const message = JSON.stringify({
type: 'playback_state',
data: state,
timestamp: Date.now(),
});
this.broadcast(message);
}
/**
* 广播在线人数更新
*/
broadcastOnlineCount() {
const count = this.clients.size;
const message = JSON.stringify({
type: 'online_count',
data: { count },
timestamp: Date.now(),
});
this.broadcast(message);
}
/**
* 广播公告
*/
broadcastAnnouncement(announcement: { title: string; content: string; duration: number }) {
const message = JSON.stringify({
type: 'announcement',
data: announcement,
timestamp: Date.now(),
});
this.broadcast(message);
}
/**
* 广播音量控制(已废弃,使用sendVolumeControlToUser代替)
* @deprecated
*/
broadcastVolumeControl(control: { volume: number }) {
const message = JSON.stringify({
type: 'volume_control',
data: control,
timestamp: Date.now(),
});
this.broadcast(message);
}
/**
* 向特定用户发送音量控制
*/
sendVolumeControlToUser(userId: string, control: { volume: number; muted?: boolean }) {
// 确保userId是字符串类型
if (typeof userId !== 'string') {
console.error(`无效的用户ID类型: ${typeof userId}, 值: ${userId}`);
return false;
}
console.log(`查找用户 ${userId} 的WebSocket连接,当前连接数: ${this.clients.size}`);
// 找到该用户对应的WebSocket连接
let targetWs: WebSocket | null = null;
this.clientToUserId.forEach((uid, ws) => {
if (uid === userId) {
targetWs = ws;
console.log(`找到用户 ${userId} 的WebSocket连接`);
}
});
if (!targetWs) {
console.warn(`用户 ${userId} 的WebSocket连接不存在`);
console.log('当前所有用户ID:', Array.from(this.clientToUserId.values()));
return false;
}
if (targetWs.readyState !== WebSocket.OPEN) {
console.warn(`用户 ${userId} 的WebSocket连接已关闭,状态: ${targetWs.readyState}`);
return false;
}
const message = JSON.stringify({
type: 'volume_control',
data: {
...control,
targetUserId: userId, // 标识这是针对特定用户的控制
},
timestamp: Date.now(),
});
try {
console.log(`向用户 ${userId} 发送音量控制:`, JSON.parse(message));
targetWs.send(message);
console.log(`音量控制消息已发送给用户 ${userId}`);
return true;
} catch (error) {
console.error(`向用户 ${userId} 发送音量控制失败:`, error);
return false;
}
}
/**
* 设置播放服务(延迟注入)
*/
setPlaybackService(service: any) {
this.playbackService = service;
}
/**
* 发送播放状态给单个客户端
*/
private sendPlaybackState(ws: WebSocket) {
if (this.playbackService) {
try {
const playbackState = this.playbackService.getState();
ws.send(JSON.stringify({
type: 'playback_state',
data: playbackState,
timestamp: Date.now(),
}));
} catch (error) {
console.warn('发送播放状态失败:', error);
}
}
// 同时发送当前在线人数给新连接的客户端
try {
ws.send(JSON.stringify({
type: 'online_count',
data: { count: this.clients.size },
timestamp: Date.now(),
}));
} catch (error) {
console.warn('发送在线人数失败:', error);
}
}
/**
* 发送用户ID给客户端
*/
private sendUserId(ws: WebSocket, userId: string) {
try {
ws.send(JSON.stringify({
type: 'user_id',
data: { userId },
timestamp: Date.now(),
}));
} catch (error) {
console.warn('发送用户ID失败:', error);
}
}
/**
* 获取所有在线用户信息
*/
getAllUsers() {
return this.userService.getAllUsers();
}
/**
* 广播消息给所有客户端
*/
private broadcast(message: string) {
this.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(message);
} catch (error) {
console.error('发送WebSocket消息失败:', error);
}
}
});
}
/**
* 获取当前连接数
*/
getClientCount(): number {
return this.clients.size;
}
/**
* 关闭所有连接
*/
close() {
this.clients.forEach((client) => {
client.close();
});
this.clients.clear();
if (this.wss) {
this.wss.close();
}
}
}