rename com.imyeyu.server to com.imyeyu.api

This commit is contained in:
Timi
2025-07-22 15:26:14 +08:00
parent e816b885b2
commit 323e038e86
340 changed files with 1174 additions and 1175 deletions
@@ -0,0 +1,28 @@
package com.imyeyu.api.modules.music.bean;
import io.netty.channel.ChannelHandlerContext;
import lombok.Data;
import com.imyeyu.utils.Time;
/**
* 频道绑定
*
* @author 夜雨
* @version 2023-02-07 14:43
*/
@Data
public class ChannelBinding {
/** 与受控端连接频道 */
private ChannelHandlerContext playerChannel;
/** 与控制端连接频道 */
private ChannelHandlerContext controllerChannel;
private long lastActiviedAt = -1;
/** @return true 为超过 1 分钟未发生通信 */
public boolean isTimeout() {
return Time.M < Time.now() - lastActiviedAt;
}
}
@@ -0,0 +1,46 @@
package com.imyeyu.api.modules.music.bean.pkg;
import lombok.Data;
/**
* 基本数据包
*
* @author 夜雨
* @version 2023-02-08 11:14
*/
@Data
abstract class BasePackage {
/**
* 播放模式
*
* @author 夜雨
* @version 2023-02-08 14:12
*/
public enum Type {
/** 单曲循环 */
REPEAT,
/** 队列循环 */
REPEAT_PLAY_LIST,
/** 队后单循 */
REPEAT_PLAY_LIST_END,
/** 列表循环 */
REPEAT_FILE_LIST
}
/** 频道 */
private String channelID;
/** 播放模式 */
private Type type;
/** 音量 */
private double volume;
/** 包生成时间 */
private Long createdAt;
}
@@ -0,0 +1,47 @@
package com.imyeyu.api.modules.music.bean.pkg;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* 控制端数据包,由控制器发出至受控端
*
* @author 夜雨
* @version 2023-02-08 11:15
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class ControllerPackage extends BasePackage {
/**
* @author 夜雨
* @version 2023-02-07 11:37
*/
public enum Action {
/** 上一首 */
PREVIOUS,
/** 切换播放状态 */
TOGGLE,
/** 下一首 */
NEXT,
/** 随机列表 */
RANDOM,
/** 音量调整 */
VOLUME,
/** 播放模式调整 */
TYPE
}
/** 控制动作 */
private Action action;
}
@@ -0,0 +1,21 @@
package com.imyeyu.api.modules.music.bean.pkg;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 受控端数据包,推送播放器状态到控制器
*
* @author 夜雨
* @version 2023-02-08 11:15
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PlayerPackage extends BasePackage {
/** 当前播放 */
private String nowPlaying;
/** 下一首播放 */
private String nextPlay;
}
@@ -0,0 +1,100 @@
package com.imyeyu.api.modules.music.core;
import com.google.gson.Gson;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import lombok.extern.slf4j.Slf4j;
import com.imyeyu.utils.Time;
import com.imyeyu.java.TimiJava;
import com.imyeyu.api.modules.music.bean.ChannelBinding;
import com.imyeyu.api.modules.music.bean.pkg.ControllerPackage;
import com.imyeyu.api.modules.music.bean.pkg.PlayerPackage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 中转站
*
* @author 夜雨
* @version 2021-11-03 22:56
*/
@Slf4j
@Component
public class Middleware implements SchedulingConfigurer {
/** 缓存清理间隔 */
private static final String CACHE_CLEAR_CORN = "0 0/20 * * * ?";
@Autowired
private Gson gson;
/** 缓存频道,Map&lt;频道 ID, 关联频道&gt; */
private final Map<String, ChannelBinding> channels = new HashMap<>();
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
// 定时清理过期频道
Trigger trigger = tc -> new CronTrigger(CACHE_CLEAR_CORN).nextExecution(tc);
registrar.addTriggerTask(() -> channels.keySet().removeIf(channelID -> channels.get(channelID).isTimeout()), trigger);
}
/**
* 推送受控端数据
*
* @param ctx 受控端频道(关联控制端)
* @param pkg 数据包
*/
public void pushPlayerData(ChannelHandlerContext ctx, PlayerPackage pkg) {
if (TimiJava.isEmpty(pkg.getChannelID())) {
return;
}
ChannelBinding channelBinding = channels.get(pkg.getChannelID());
if (channelBinding == null) {
channelBinding = new ChannelBinding();
channels.put(pkg.getChannelID(), channelBinding);
log.info("TimiMusic created channel: " + pkg.getChannelID());
}
channelBinding.setPlayerChannel(ctx);
channelBinding.setLastActiviedAt(Time.now());
if (channelBinding.getControllerChannel() != null) {
channelBinding.getControllerChannel().writeAndFlush(new TextWebSocketFrame(gson.toJson(pkg)));
}
}
/**
* 推送控制端数据
*
* @param ctx 控制端频道(关联受控端)
* @param pkg 数据包
*/
public void pushControllerData(ChannelHandlerContext ctx, ControllerPackage pkg) {
if (TimiJava.isEmpty(pkg.getChannelID())) {
return;
}
ChannelBinding channelBinding = channels.get(pkg.getChannelID());
if (channelBinding == null) {
channelBinding = new ChannelBinding();
channels.put(pkg.getChannelID(), channelBinding);
log.info("TimiMusic created channel: " + pkg.getChannelID());
}
channelBinding.setControllerChannel(ctx);
channelBinding.setLastActiviedAt(Time.now());
if (pkg.getAction() != null && channelBinding.getPlayerChannel() != null) {
byte[] bytes = gson.toJson(pkg).getBytes(StandardCharsets.UTF_8);
ByteBuf buffer = Unpooled.buffer();
buffer.writeInt(bytes.length);
buffer.writeBytes(bytes);
channelBinding.getPlayerChannel().writeAndFlush(buffer);
}
}
}
@@ -0,0 +1,52 @@
package com.imyeyu.api.modules.music.handler;
import com.google.gson.Gson;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus;
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
import lombok.extern.slf4j.Slf4j;
import com.imyeyu.api.modules.music.bean.pkg.ControllerPackage;
import com.imyeyu.api.modules.music.core.Middleware;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 控制端消息处理
*
* @author 夜雨
* @version 2021-11-03 15:34
*/
@Slf4j
@ChannelHandler.Sharable
@Component
public class ControllerMessageHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
@Autowired
private Gson gson;
@Autowired
private Middleware middleware;
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame msg) {
if (msg instanceof TextWebSocketFrame textWebSocketFrame) {
middleware.pushControllerData(ctx, gson.fromJson(textWebSocketFrame.text(), ControllerPackage.class));
} else {
ctx.channel().writeAndFlush(WebSocketCloseStatus.INVALID_MESSAGE_TYPE).addListener(ChannelFutureListener.CLOSE);
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("TimiMusic controller disconnected: " + ctx.channel().remoteAddress());
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("TimiMusic controller connected: " + ctx.channel().remoteAddress());
}
}
@@ -0,0 +1,44 @@
package com.imyeyu.api.modules.music.handler;
import com.google.gson.Gson;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.extern.slf4j.Slf4j;
import com.imyeyu.api.modules.music.bean.pkg.PlayerPackage;
import com.imyeyu.api.modules.music.core.Middleware;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 受控端消息处理
*
* @author 夜雨
* @version 2021-11-03 17:50
*/
@Slf4j
@ChannelHandler.Sharable
@Component
public class PlayerMessageHandler extends SimpleChannelInboundHandler<String> {
@Autowired
private Gson gson;
@Autowired
private Middleware middleware;
@Override
protected synchronized void channelRead0(ChannelHandlerContext ctx, String result) {
middleware.pushPlayerData(ctx, gson.fromJson(result, PlayerPackage.class));
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("TimiMusic player disconnected: " + ctx.channel().remoteAddress());
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("TimiMusic player connected: " + ctx.channel().remoteAddress());
}
}
@@ -0,0 +1,137 @@
package com.imyeyu.api.modules.music.runner;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketServerCompressionHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.imyeyu.api.config.CORSConfig;
import com.imyeyu.api.modules.common.bean.SettingKey;
import com.imyeyu.api.modules.common.service.SettingService;
import com.imyeyu.api.modules.music.handler.ControllerMessageHandler;
import org.springframework.beans.BeansException;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
/**
* 控制端 Netty 服务
*
* @author 夜雨
* @version 2021-11-03 15:29
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ControllerBootstrapRunner implements ApplicationRunner, ApplicationListener<ContextClosedEvent>, ApplicationContextAware {
private final CORSConfig corsConfig;
private final SettingService settingService;
private ApplicationContext applicationContext;
private Channel channel;
private EventLoopGroup boss, worker;
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
public void run(ApplicationArguments args) {
final DefaultFullHttpResponse NFP = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND);
final DefaultFullHttpResponse BRP = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST);
boss = new NioEventLoopGroup();
worker = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, worker);
bootstrap.channel(NioServerSocketChannel.class);
bootstrap.localAddress(new InetSocketAddress(settingService.getAsInt(SettingKey.MUSIC_CONTROLLER_PORT)));
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new ChunkedWriteHandler());
pipeline.addLast(new HttpObjectAggregator(65536));
pipeline.addLast(new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object obj) throws Exception {
if (obj instanceof FullHttpRequest req) {
if (!req.uri().equals(settingService.getAsString(SettingKey.MUSIC_CONTROLLER_URI))) {
// 访问的路径不是 Web Socket 的端点地址,响应 404
ctx.channel().writeAndFlush(NFP).addListener(ChannelFutureListener.CLOSE);
return;
}
// 域名限制
String reqOrigin = req.headers().get(HttpHeaderNames.ORIGIN);
String[] allowOrigin = corsConfig.getAllowOrigin();
for (int i = 0; i < allowOrigin.length; i++) {
if (allowOrigin[i].equals(reqOrigin)) {
super.channelRead(ctx, obj);
return;
}
}
ctx.channel().writeAndFlush(BRP).addListener(ChannelFutureListener.CLOSE);
return;
}
super.channelRead(ctx, obj);
}
});
pipeline.addLast(new WebSocketServerCompressionHandler());
pipeline.addLast(new WebSocketServerProtocolHandler(
settingService.getAsString(SettingKey.MUSIC_CONTROLLER_URI),
null,
true,
settingService.getAsInt(SettingKey.MUSIC_MAX_FRAME_LENGTH)
)
);
// 从 IOC 获取 Handler
pipeline.addLast(applicationContext.getBean(ControllerMessageHandler.class));
}
});
channel = bootstrap.bind().sync().channel();
log.info("TimiMusicRC controller service startup with " + settingService.getAsString(SettingKey.MUSIC_CONTROLLER_IP));
} catch (Exception e) {
log.error("TimiMusicRC controller service startup error", e);
}
}
public void onApplicationEvent(@NonNull ContextClosedEvent event) {
if (channel != null) {
channel.closeFuture();
boss.shutdownGracefully();
worker.shutdownGracefully();
}
log.info("TimiMusicRC controller service shutdown finished");
}
}
@@ -0,0 +1,94 @@
package com.imyeyu.api.modules.music.runner;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.imyeyu.api.modules.common.bean.SettingKey;
import com.imyeyu.api.modules.common.service.SettingService;
import com.imyeyu.api.modules.music.handler.PlayerMessageHandler;
import org.springframework.beans.BeansException;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
/**
* 受控端 Netty 服务
*
* @author 夜雨
* @version 2021-11-03 19:18
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PlayerBootstrapRunner implements ApplicationRunner, ApplicationListener<ContextClosedEvent>, ApplicationContextAware {
private final SettingService settingService;
private ApplicationContext applicationContext;
private Channel channel;
private EventLoopGroup boss, worker;
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
public void run(ApplicationArguments args) {
ServerBootstrap bootstrap = new ServerBootstrap();
boss = new NioEventLoopGroup();
worker = new NioEventLoopGroup();
try {
bootstrap.group(boss, worker);
bootstrap.channel(NioServerSocketChannel.class);
bootstrap.localAddress(new InetSocketAddress(settingService.getAsInt(SettingKey.MUSIC_PLAYER_PORT)));
bootstrap.childHandler(new ChannelInitializer<>() {
@Override
protected void initChannel(Channel ch) {
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(
settingService.getAsInt(SettingKey.MUSIC_MAX_FRAME_LENGTH),
0,
4,
0,
4));
ch.pipeline().addLast(new StringDecoder(StandardCharsets.UTF_8));
ch.pipeline().addLast(applicationContext.getBean(PlayerMessageHandler.class));
}
});
bootstrap.option(ChannelOption.SO_BACKLOG, 1024);
bootstrap.childOption(ChannelOption.TCP_NODELAY, true);
bootstrap.childOption(ChannelOption.SO_KEEPALIVE, true);
channel = bootstrap.bind().sync().channel();
log.info("TimiMusicRC player service startup with " + settingService.getAsString(SettingKey.MUSIC_PLAYER_IP));
} catch (Exception e) {
log.error("TimiMusicRC player service startup error", e);
}
}
public void onApplicationEvent(@NonNull ContextClosedEvent event) {
if (channel != null) {
channel.closeFuture();
boss.shutdownGracefully();
worker.shutdownGracefully();
}
log.info("TimiMusicRC player service shutdown finished");
}
}