submit
This commit is contained in:
@@ -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;
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user