From dffeb8b945d52b3483ba0c5a30d2e53b270be509 Mon Sep 17 00:00:00 2001 From: Fabien Crespel Date: Sun, 21 Jan 2024 22:29:25 +0100 Subject: [PATCH] Support playlist export to KaraFun Remote V2 (#144) --- pom.xml | 4 + .../impl/KarafunRemoteExportServiceImpl.java | 184 ++++------------ .../KarafunRemoteV1ExportServiceImpl.java | 208 ++++++++++++++++++ .../KarafunRemoteV2ExportServiceImpl.java | 135 ++++++++++++ 4 files changed, 387 insertions(+), 144 deletions(-) create mode 100644 src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteV1ExportServiceImpl.java create mode 100644 src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteV2ExportServiceImpl.java diff --git a/pom.xml b/pom.xml index 8f5d720..8e178e5 100644 --- a/pom.xml +++ b/pom.xml @@ -123,6 +123,10 @@ org.springframework.session spring-session-jdbc + + org.springframework + spring-websocket + diff --git a/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteExportServiceImpl.java b/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteExportServiceImpl.java index 45f40f4..3d47190 100644 --- a/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteExportServiceImpl.java +++ b/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteExportServiceImpl.java @@ -1,171 +1,67 @@ package me.crespel.karaplan.service.impl; -import java.net.URISyntaxException; -import java.util.List; -import java.util.stream.Collectors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; -import org.json.JSONException; -import org.json.JSONObject; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; -import io.socket.client.Ack; -import io.socket.client.IO; -import io.socket.client.Socket; -import io.socket.emitter.Emitter; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; import me.crespel.karaplan.config.KarafunConfig.KarafunRemoteProperties; import me.crespel.karaplan.domain.Playlist; +import me.crespel.karaplan.model.exception.BusinessException; import me.crespel.karaplan.model.exception.TechnicalException; import me.crespel.karaplan.service.ExportService; -@Slf4j @Service("karafunRemoteExport") public class KarafunRemoteExportServiceImpl implements ExportService { - public static final String EVENT_AUTHENTICATE = "authenticate"; - public static final String EVENT_PERMISSIONS = "permissions"; - public static final String EVENT_PREFERENCES = "preferences"; - public static final String EVENT_STATUS = "status"; - public static final String EVENT_QUEUE = "queue"; - public static final String EVENT_QUEUE_ADD = "queueAdd"; + @Autowired + @Qualifier("karafunRemoteV1Export") + protected ExportService karafunRemoteV1ExportService; + + @Autowired + @Qualifier("karafunRemoteV2Export") + protected ExportService karafunRemoteV2ExportService; + + @Autowired + private KarafunRemoteProperties properties; @Autowired - protected KarafunRemoteProperties properties; + private RestTemplate restTemplate; + + protected final Pattern remoteTargetPattern = Pattern.compile("[0-9]+"); + protected final Pattern remoteDisconnectedPattern = Pattern.compile("reactivate the remote control feature"); + protected final Pattern remoteV2UrlPattern = Pattern.compile("\"kcs_url\":\"([^\"]+)\""); @Override public void exportPlaylist(Playlist playlist, String target) { + if (!remoteTargetPattern.matcher(target).matches()) { + throw new BusinessException("Invalid KaraFun Remote target, must be numeric"); + } if (playlist.getSongs() != null && !playlist.getSongs().isEmpty()) { - List songIds = playlist.getSongs().stream().map(it -> it.getSong().getCatalogId()).collect(Collectors.toList()); + // Retrieve remote page content + String remotePage; + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(properties.getEndpoint()).path(target); try { - Socket socket = buildSocket(target); - socket.on(Socket.EVENT_CONNECT, new ConnectEventListener(socket, target)) - .on(Socket.EVENT_CONNECT_ERROR, new LoggingListener(Socket.EVENT_CONNECT_ERROR)) - .on(Socket.EVENT_CONNECT_TIMEOUT, new LoggingListener(Socket.EVENT_CONNECT_ERROR)) - .on(Socket.EVENT_ERROR, new ErrorEventListener(socket)) - .on(Socket.EVENT_MESSAGE, new LoggingListener(Socket.EVENT_CONNECT_ERROR)) - .on(Socket.EVENT_DISCONNECT, new LoggingListener(Socket.EVENT_CONNECT_ERROR)) - .on(EVENT_PERMISSIONS, new LoggingListener(EVENT_PERMISSIONS)) - .on(EVENT_PREFERENCES, new LoggingListener(EVENT_PREFERENCES)) - .on(EVENT_STATUS, new LoggingListener(EVENT_STATUS)) - .on(EVENT_QUEUE, new QueueEventListener(EVENT_QUEUE, socket, songIds, 0)); - log.debug("Connecting to Karafun Remote {}", target); - socket.connect(); - } catch (Exception e) { - throw new TechnicalException(e); + remotePage = restTemplate.getForObject(builder.toUriString(), String.class); + if (remoteDisconnectedPattern.matcher(remotePage).find()) { + throw new BusinessException("Remote #" + target + " is not reachable, please check KaraFun application"); + } + } catch (RestClientException e) { + throw new TechnicalException("Failed to retrieve KaraFun Remote page, please try again"); } - } - } - - protected Socket buildSocket(String remoteId) throws URISyntaxException { - IO.Options opts = new IO.Options(); - opts.forceNew = true; - opts.reconnection = false; - opts.query = "remote=kf" + remoteId; - return IO.socket(properties.getEndpoint(), opts); - } - - protected JSONObject buildAuthenticateEvent(String remoteId) { - try { - JSONObject obj = new JSONObject(); - obj.put("login", "KaraPlan"); - obj.put("channel", remoteId); - obj.put("role", "participant"); - obj.put("app", "karafun"); - obj.put("socket_id", JSONObject.NULL); - return obj; - } catch (JSONException e) { - throw new RuntimeException(e); - } - } - - protected JSONObject buildQueueAddEvent(Long songId) { - try { - JSONObject obj = new JSONObject(); - obj.put("songId", songId); - obj.put("pos", 99999); - obj.put("singer", ""); - return obj; - } catch (JSONException e) { - throw new RuntimeException(e); - } - } - - protected void logSendEvent(String eventName, Object... args) { - log.debug("Sending event {}: {}", eventName, args); - } - - protected void logReceivedEvent(String eventName, Object... args) { - log.debug("Received event {}: {}", eventName, args); - } - - protected void logReceivedAck(String eventName, Object... args) { - log.debug("Received ack for {}: {}", eventName, args); - } - - @AllArgsConstructor - public class LoggingListener implements Emitter.Listener { - private final String eventName; - - @Override - public void call(Object... args) { - logReceivedEvent(eventName, args); - } - } - - @AllArgsConstructor - public class LoggingAck implements Ack { - private final String eventName; - - @Override - public void call(Object... args) { - logReceivedAck(eventName, args); - } - } - - @AllArgsConstructor - public class ConnectEventListener implements Emitter.Listener { - private final Socket socket; - private final String remoteId; - - @Override - public void call(Object... args) { - logReceivedEvent(Socket.EVENT_CONNECT, args); - JSONObject eventData = buildAuthenticateEvent(remoteId); - logSendEvent(EVENT_AUTHENTICATE, eventData); - socket.emit(EVENT_AUTHENTICATE, eventData, new LoggingAck(EVENT_AUTHENTICATE)); - } - } - - @AllArgsConstructor - public class ErrorEventListener implements Emitter.Listener { - private final Socket socket; - - @Override - public void call(Object... args) { - logReceivedEvent(Socket.EVENT_ERROR, args); - socket.disconnect(); - } - } - - @AllArgsConstructor - public class QueueEventListener implements Emitter.Listener { - private final String eventName; - private final Socket socket; - private final List songIds; - private int index = 0; - @Override - public void call(Object... args) { - logReceivedEvent(eventName, args); - if (index < songIds.size()) { - JSONObject eventData = buildQueueAddEvent(songIds.get(index++)); - logSendEvent(EVENT_QUEUE_ADD, eventData); - socket.emit(EVENT_QUEUE_ADD, eventData); + // Determine remote version + Matcher remoteV2UrlMatcher = remoteV2UrlPattern.matcher(remotePage); + if (remoteV2UrlMatcher.find()) { + String remoteV2Url = remoteV2UrlMatcher.group(1).replace("\\/", "/"); + karafunRemoteV2ExportService.exportPlaylist(playlist, remoteV2Url); } else { - log.debug("Disconnecting from Karafun session"); - socket.disconnect(); + karafunRemoteV1ExportService.exportPlaylist(playlist, target); } } } diff --git a/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteV1ExportServiceImpl.java b/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteV1ExportServiceImpl.java new file mode 100644 index 0000000..b0b1fd8 --- /dev/null +++ b/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteV1ExportServiceImpl.java @@ -0,0 +1,208 @@ +package me.crespel.karaplan.service.impl; + +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.json.JSONException; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import io.socket.client.Ack; +import io.socket.client.IO; +import io.socket.client.Socket; +import io.socket.emitter.Emitter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.crespel.karaplan.config.KarafunConfig.KarafunRemoteProperties; +import me.crespel.karaplan.domain.Playlist; +import me.crespel.karaplan.model.exception.BusinessException; +import me.crespel.karaplan.model.exception.TechnicalException; +import me.crespel.karaplan.service.ExportService; + +@Slf4j +@Service("karafunRemoteV1Export") +public class KarafunRemoteV1ExportServiceImpl implements ExportService { + + public static final String EVENT_AUTHENTICATE = "authenticate"; + public static final String EVENT_PERMISSIONS = "permissions"; + public static final String EVENT_PREFERENCES = "preferences"; + public static final String EVENT_STATUS = "status"; + public static final String EVENT_QUEUE = "queue"; + public static final String EVENT_QUEUE_ADD = "queueAdd"; + + @Autowired + protected KarafunRemoteProperties properties; + + protected final Pattern remoteTargetPattern = Pattern.compile("[0-9]+"); + + @Override + public void exportPlaylist(Playlist playlist, String target) { + if (!remoteTargetPattern.matcher(target).matches()) { + throw new BusinessException("Invalid KaraFun Remote V1 target, must be numeric"); + } + if (playlist.getSongs() != null && !playlist.getSongs().isEmpty()) { + CompletableFuture completable = new CompletableFuture<>(); + List songIds = playlist.getSongs().stream().map(it -> it.getSong().getCatalogId()).collect(Collectors.toList()); + Socket socket = buildSocket(target); + try { + socket.on(Socket.EVENT_CONNECT, new ConnectEventListener(socket, target)) + .on(Socket.EVENT_CONNECT_ERROR, new LoggingListener(Socket.EVENT_CONNECT_ERROR)) + .on(Socket.EVENT_CONNECT_TIMEOUT, new LoggingListener(Socket.EVENT_CONNECT_TIMEOUT)) + .on(Socket.EVENT_ERROR, new ErrorEventListener(completable)) + .on(Socket.EVENT_MESSAGE, new LoggingListener(Socket.EVENT_MESSAGE)) + .on(Socket.EVENT_DISCONNECT, new LoggingListener(Socket.EVENT_DISCONNECT)) + .on(EVENT_PERMISSIONS, new PermissionsEventListener(completable)) + .on(EVENT_PREFERENCES, new LoggingListener(EVENT_PREFERENCES)) + .on(EVENT_STATUS, new LoggingListener(EVENT_STATUS)) + .on(EVENT_QUEUE, new QueueEventListener(socket, songIds, completable)); + log.debug("Connecting to Karafun Remote {}", target); + socket.connect(); + completable.get(30, TimeUnit.SECONDS); + } catch (Throwable e) { + if (e instanceof ExecutionException) { + e = e.getCause(); + } + throw new TechnicalException("Failed to export playlist to KaraFun Remote V1: " + e.getMessage(), e); + } finally { + socket.disconnect(); + } + } + } + + protected Socket buildSocket(String remoteId) { + try { + IO.Options opts = new IO.Options(); + opts.forceNew = true; + opts.reconnection = false; + opts.query = "remote=kf" + remoteId; + return IO.socket(properties.getEndpoint(), opts); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + + protected JSONObject buildAuthenticateEvent(String remoteId) { + try { + JSONObject obj = new JSONObject(); + obj.put("login", "KaraPlan"); + obj.put("channel", remoteId); + obj.put("role", "participant"); + obj.put("app", "karafun"); + obj.put("socket_id", JSONObject.NULL); + return obj; + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + protected JSONObject buildQueueAddEvent(Long songId) { + try { + JSONObject obj = new JSONObject(); + obj.put("songId", songId); + obj.put("pos", 99999); + obj.put("singer", ""); + return obj; + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + protected void logSendEvent(String eventName, Object... args) { + log.debug("Sending event {}: {}", eventName, args); + } + + protected void logReceivedEvent(String eventName, Object... args) { + log.debug("Received event {}: {}", eventName, args); + } + + protected void logReceivedAck(String eventName, Object... args) { + log.debug("Received ack for {}: {}", eventName, args); + } + + @RequiredArgsConstructor + public class LoggingListener implements Emitter.Listener { + private final String eventName; + + @Override + public void call(Object... args) { + logReceivedEvent(eventName, args); + } + } + + @RequiredArgsConstructor + public class LoggingAck implements Ack { + private final String eventName; + + @Override + public void call(Object... args) { + logReceivedAck(eventName, args); + } + } + + @RequiredArgsConstructor + public class ConnectEventListener implements Emitter.Listener { + private final Socket socket; + private final String remoteId; + + @Override + public void call(Object... args) { + logReceivedEvent(Socket.EVENT_CONNECT, args); + JSONObject eventData = buildAuthenticateEvent(remoteId); + logSendEvent(EVENT_AUTHENTICATE, eventData); + socket.emit(EVENT_AUTHENTICATE, eventData, new LoggingAck(EVENT_AUTHENTICATE)); + } + } + + @RequiredArgsConstructor + public class ErrorEventListener implements Emitter.Listener { + private final CompletableFuture completable; + + @Override + public void call(Object... args) { + logReceivedEvent(Socket.EVENT_ERROR, args); + completable.completeExceptionally(new RuntimeException("Socket.IO error: " + Arrays.toString(args))); + } + } + + @RequiredArgsConstructor + public class PermissionsEventListener implements Emitter.Listener { + private final CompletableFuture completable; + + @Override + public void call(Object... args) { + logReceivedEvent(EVENT_PERMISSIONS, args); + if (args != null && args.length > 0 && !args[0].toString().contains("addToQueue")) { + completable.completeExceptionally(new BusinessException("Missing 'Add to queue' permission on KaraFun remote, please enable it")); + } + } + } + + @RequiredArgsConstructor + public class QueueEventListener implements Emitter.Listener { + private final Socket socket; + private final List songIds; + private final CompletableFuture completable; + private int index = 0; + + @Override + public void call(Object... args) { + logReceivedEvent(EVENT_QUEUE, args); + if (index < songIds.size()) { + JSONObject eventData = buildQueueAddEvent(songIds.get(index++)); + logSendEvent(EVENT_QUEUE_ADD, eventData); + socket.emit(EVENT_QUEUE_ADD, eventData); + } else { + log.debug("Finished adding songs to queue"); + completable.complete(null); + } + } + } + +} diff --git a/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteV2ExportServiceImpl.java b/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteV2ExportServiceImpl.java new file mode 100644 index 0000000..39e7fd8 --- /dev/null +++ b/src/main/java/me/crespel/karaplan/service/impl/KarafunRemoteV2ExportServiceImpl.java @@ -0,0 +1,135 @@ +package me.crespel.karaplan.service.impl; + +import java.net.URI; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.WebSocketConnectionManager; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; +import me.crespel.karaplan.domain.Playlist; +import me.crespel.karaplan.model.exception.BusinessException; +import me.crespel.karaplan.model.exception.TechnicalException; +import me.crespel.karaplan.service.ExportService; + +@Slf4j +@Service("karafunRemoteV2Export") +public class KarafunRemoteV2ExportServiceImpl implements ExportService { + + protected final Pattern remoteTargetPattern = Pattern.compile("wss://.*"); + + @Override + public void exportPlaylist(Playlist playlist, String target) { + if (!remoteTargetPattern.matcher(target).matches()) { + throw new BusinessException("Invalid KaraFun Remote V2 target, must be a WebSocket URL"); + } + if (playlist.getSongs() != null && !playlist.getSongs().isEmpty()) { + CompletableFuture completable = new CompletableFuture<>(); + List songIds = playlist.getSongs().stream().map(it -> it.getSong().getCatalogId()).collect(Collectors.toList()); + WebSocketConnectionManager wsConn = new WebSocketConnectionManager(new StandardWebSocketClient(), new KarafunWebSocketHandler(songIds, completable), URI.create(target)); + try { + wsConn.setSubProtocols(Arrays.asList("kcpj+emuping")); + wsConn.start(); + completable.get(30, TimeUnit.SECONDS); + } catch (Throwable e) { + if (e instanceof ExecutionException) { + e = e.getCause(); + } + throw new TechnicalException("Failed to export playlist to KaraFun Remote V2: " + e.getMessage(), e); + } finally { + wsConn.stop(); + } + } + } + + @RequiredArgsConstructor + public static class KarafunWebSocketHandler extends TextWebSocketHandler { + private final ObjectMapper mapper = new ObjectMapper(); + private final List songIds; + private final CompletableFuture completable; + private int index = 0; + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + log.error("WebSocket transport error: " + exception.getMessage(), exception); + completable.completeExceptionally(exception); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + KarafunWebSocketMessage msg = mapper.readValue(message.getPayload(), KarafunWebSocketMessage.class); + log.debug("Received message: " + msg); + KarafunWebSocketMessage resp = handleKarafunMessage(msg); + if (resp != null) { + log.debug("Sending message: " + resp); + session.sendMessage(new TextMessage(mapper.writeValueAsString(resp))); + } + } + + @SuppressWarnings("unchecked") + protected KarafunWebSocketMessage handleKarafunMessage(KarafunWebSocketMessage message) { + switch (message.getType()) { + case "core.PingRequest": + return new KarafunWebSocketMessage().setId(message.getId()).setType("core.PingResponse"); + case "remote.PermissionsUpdateEvent": + Object perms = message.getPayload().get("permissions"); + if (perms instanceof Map) { + Object addToQueue = ((Map) perms).get("addToQueue"); + if (!Boolean.TRUE.equals(addToQueue)) { + completable.completeExceptionally(new BusinessException("Missing 'Add to queue' permission on KaraFun remote, please enable it")); + } else { + // Add first song + return buildAddToQueueMessage(index, songIds.get(index++)); + } + } + break; + case "remote.AddToQueueResponse": + if (index < songIds.size()) { + // Add next song + return buildAddToQueueMessage(index, songIds.get(index++)); + } else { + log.debug("Finished adding songs to queue"); + completable.complete(null); + } + break; + } + return null; + } + + protected KarafunWebSocketMessage buildAddToQueueMessage(int index, long songId) { + KarafunWebSocketMessage message = new KarafunWebSocketMessage().setId(index + 1).setType("remote.AddToQueueRequest"); + Map song = new HashMap<>(); + song.put("type", 1); + song.put("id", songId); + message.getPayload().put("identifier", song); + message.getPayload().put("position", 99999); + return message; + } + } + + @Data + @Accessors(chain = true) + public static class KarafunWebSocketMessage { + private Integer id; + private String type; + private Map payload = new HashMap<>(); + } + +}