2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.squeezebox.internal.handler;
15 import static org.openhab.binding.squeezebox.internal.SqueezeBoxBindingConstants.*;
17 import java.io.BufferedReader;
18 import java.io.BufferedWriter;
19 import java.io.IOException;
20 import java.io.InputStreamReader;
21 import java.io.OutputStreamWriter;
22 import java.io.UnsupportedEncodingException;
23 import java.net.Socket;
24 import java.net.URLDecoder;
25 import java.net.URLEncoder;
26 import java.nio.charset.StandardCharsets;
27 import java.time.Duration;
28 import java.util.ArrayList;
29 import java.util.Arrays;
30 import java.util.Base64;
31 import java.util.Collections;
32 import java.util.HashMap;
33 import java.util.HashSet;
34 import java.util.List;
37 import java.util.concurrent.Future;
38 import java.util.concurrent.ScheduledFuture;
39 import java.util.concurrent.TimeUnit;
41 import org.openhab.binding.squeezebox.internal.config.SqueezeBoxServerConfig;
42 import org.openhab.binding.squeezebox.internal.dto.ButtonDTO;
43 import org.openhab.binding.squeezebox.internal.dto.ButtonDTODeserializer;
44 import org.openhab.binding.squeezebox.internal.dto.ButtonsDTO;
45 import org.openhab.binding.squeezebox.internal.dto.StatusResponseDTO;
46 import org.openhab.binding.squeezebox.internal.model.Favorite;
47 import org.openhab.core.io.net.http.HttpRequestBuilder;
48 import org.openhab.core.library.types.StringType;
49 import org.openhab.core.thing.Bridge;
50 import org.openhab.core.thing.Channel;
51 import org.openhab.core.thing.ChannelUID;
52 import org.openhab.core.thing.Thing;
53 import org.openhab.core.thing.ThingStatus;
54 import org.openhab.core.thing.ThingStatusDetail;
55 import org.openhab.core.thing.ThingTypeUID;
56 import org.openhab.core.thing.binding.BaseBridgeHandler;
57 import org.openhab.core.thing.binding.ThingHandler;
58 import org.openhab.core.types.Command;
59 import org.openhab.core.types.UnDefType;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
63 import com.google.gson.Gson;
64 import com.google.gson.GsonBuilder;
65 import com.google.gson.JsonSyntaxException;
68 * Handles connection and event handling to a SqueezeBox Server.
70 * @author Markus Wolters - Initial contribution
71 * @author Ben Jones - ?
72 * @author Dan Cunningham - OH2 port
73 * @author Daniel Walters - Fix player discovery when player name contains spaces
74 * @author Mark Hilbush - Improve reconnect logic. Improve player status updates.
75 * @author Mark Hilbush - Implement AudioSink and notifications
76 * @author Mark Hilbush - Added duration channel
77 * @author Mark Hilbush - Added login/password authentication for LMS
78 * @author Philippe Siem - Improve refresh of cover art url,remote title, artist, album, genre, year.
79 * @author Patrik Gfeller - Support for mixer volume message added
80 * @author Mark Hilbush - Get favorites from LMS; update channel and send to players
81 * @author Mark Hilbush - Add like/unlike functionality
83 public class SqueezeBoxServerHandler extends BaseBridgeHandler {
84 private final Logger logger = LoggerFactory.getLogger(SqueezeBoxServerHandler.class);
86 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
87 .singleton(SQUEEZEBOXSERVER_THING_TYPE);
89 // time in seconds to try to reconnect
90 private static final int RECONNECT_TIME = 60;
93 private static final String UTF8_NAME = StandardCharsets.UTF_8.name();
95 // the value by which the volume is changed by each INCREASE or
97 private static final int VOLUME_CHANGE_SIZE = 5;
98 private static final String NEW_LINE = System.getProperty("line.separator");
100 private static final String CHANNEL_CONFIG_QUOTE_LIST = "quoteList";
102 private static final String JSONRPC_STATUS_REQUEST = "{\"id\":1,\"method\":\"slim.request\",\"params\":[\"@@MAC@@\",[\"status\",\"-\",\"tags:yagJlNKjcB\"]]}";
104 private List<SqueezeBoxPlayerEventListener> squeezeBoxPlayerListeners = Collections
105 .synchronizedList(new ArrayList<>());
107 private Map<String, SqueezeBoxPlayer> players = Collections.synchronizedMap(new HashMap<>());
109 // client socket and listener thread
110 private Socket clientSocket;
111 private SqueezeServerListener listener;
112 private Future<?> reconnectFuture;
120 private String userId;
122 private String password;
124 private final Gson gson = new GsonBuilder().registerTypeAdapter(ButtonDTO.class, new ButtonDTODeserializer())
126 private String jsonRpcUrl;
127 private String basicAuthorization;
129 public SqueezeBoxServerHandler(Bridge bridge) {
134 public void initialize() {
135 logger.debug("initializing server handler for thing {}", getThing().getUID());
136 scheduler.submit(this::connect);
140 public void dispose() {
141 logger.debug("disposing server handler for thing {}", getThing().getUID());
147 public void handleCommand(ChannelUID channelUID, Command command) {
151 * Checks if we have a connection to the Server
155 public synchronized boolean isConnected() {
156 if (clientSocket == null) {
160 // NOTE: isConnected() returns true once a connection is made and will
161 // always return true even after the socket is closed
162 // http://stackoverflow.com/questions/10163358/
163 return clientSocket.isConnected() && !clientSocket.isClosed();
166 public void mute(String mac) {
167 sendCommand(mac + " mixer muting 1");
170 public void unMute(String mac) {
171 sendCommand(mac + " mixer muting 0");
174 public void powerOn(String mac) {
175 sendCommand(mac + " power 1");
178 public void powerOff(String mac) {
179 sendCommand(mac + " power 0");
182 public void syncPlayer(String mac, String player2mac) {
183 sendCommand(mac + " sync " + player2mac);
186 public void unSyncPlayer(String mac) {
187 sendCommand(mac + " sync -");
190 public void play(String mac) {
191 sendCommand(mac + " play");
194 public void playUrl(String mac, String url) {
195 sendCommand(mac + " playlist play " + url);
198 public void pause(String mac) {
199 sendCommand(mac + " pause 1");
202 public void unPause(String mac) {
203 sendCommand(mac + " pause 0");
206 public void stop(String mac) {
207 sendCommand(mac + " stop");
210 public void prev(String mac) {
211 sendCommand(mac + " playlist index -1");
214 public void next(String mac) {
215 sendCommand(mac + " playlist index +1");
218 public void clearPlaylist(String mac) {
219 sendCommand(mac + " playlist clear");
222 public void deletePlaylistItem(String mac, int playlistIndex) {
223 sendCommand(mac + " playlist delete " + playlistIndex);
226 public void playPlaylistItem(String mac, int playlistIndex) {
227 sendCommand(mac + " playlist index " + playlistIndex);
230 public void addPlaylistItem(String mac, String url) {
231 addPlaylistItem(mac, url, null);
234 public void addPlaylistItem(String mac, String url, String title) {
235 StringBuilder playlistCommand = new StringBuilder();
236 playlistCommand.append(mac).append(" playlist add ").append(url);
238 playlistCommand.append(" ").append(title);
240 sendCommand(playlistCommand.toString());
243 public void setPlayingTime(String mac, int time) {
244 sendCommand(mac + " time " + time);
247 public void setRepeatMode(String mac, int repeatMode) {
248 sendCommand(mac + " playlist repeat " + repeatMode);
251 public void setShuffleMode(String mac, int shuffleMode) {
252 sendCommand(mac + " playlist shuffle " + shuffleMode);
255 public void volumeUp(String mac, int currentVolume) {
256 setVolume(mac, currentVolume + VOLUME_CHANGE_SIZE);
259 public void volumeDown(String mac, int currentVolume) {
260 setVolume(mac, currentVolume - VOLUME_CHANGE_SIZE);
263 public void setVolume(String mac, int volume) {
264 int newVolume = volume;
265 newVolume = Math.min(100, newVolume);
266 newVolume = Math.max(0, newVolume);
267 sendCommand(mac + " mixer volume " + String.valueOf(newVolume));
270 public void showString(String mac, String line) {
271 showString(mac, line, 5);
274 public void showString(String mac, String line, int duration) {
275 sendCommand(mac + " show line1:" + line + " duration:" + String.valueOf(duration));
278 public void showStringHuge(String mac, String line) {
279 showStringHuge(mac, line, 5);
282 public void showStringHuge(String mac, String line, int duration) {
283 sendCommand(mac + " show line1:" + line + " font:huge duration:" + String.valueOf(duration));
286 public void showStrings(String mac, String line1, String line2) {
287 showStrings(mac, line1, line2, 5);
290 public void showStrings(String mac, String line1, String line2, int duration) {
291 sendCommand(mac + " show line1:" + line1 + " line2:" + line2 + " duration:" + String.valueOf(duration));
294 public void playFavorite(String mac, String favorite) {
295 sendCommand(mac + " favorites playlist play item_id:" + favorite);
298 public void rate(String mac, String rateCommand) {
299 if (rateCommand != null) {
300 sendCommand(mac + " " + rateCommand);
304 public void sleep(String mac, Duration sleepDuration) {
305 sendCommand(mac + " sleep " + String.valueOf(sleepDuration.toSeconds()));
309 * Send a generic command to a given player
314 public void playerCommand(String mac, String command) {
315 sendCommand(mac + " " + command);
319 * Ask for player list
321 public void requestPlayers() {
322 sendCommand("players 0");
326 * Ask for favorites list
328 public void requestFavorites() {
329 sendCommand("favorites items 0 100");
335 public void login() {
336 if (userId.isEmpty()) {
339 // Create basic auth string for jsonrpc interface
340 basicAuthorization = new String(
341 Base64.getEncoder().encode((userId + ":" + password).getBytes(StandardCharsets.UTF_8)));
342 logger.debug("Logging into Squeeze Server using userId={}", userId);
343 sendCommand("login " + userId + " " + password);
347 * Send a command to the Squeeze Server.
349 private synchronized void sendCommand(String command) {
350 if (getThing().getStatus() != ThingStatus.ONLINE) {
354 if (!isConnected()) {
355 logger.debug("no connection to squeeze server when trying to send command, returning...");
359 logger.debug("Sending command: {}", sanitizeCommand(command));
361 BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()));
362 writer.write(command + NEW_LINE);
364 } catch (IOException e) {
365 logger.error("Error while sending command to Squeeze Server ({}) ", sanitizeCommand(command), e);
370 * Remove password from login command to prevent it from being logged
372 String sanitizeCommand(String command) {
373 String sanitizedCommand = command;
374 if (command.startsWith("login")) {
375 sanitizedCommand = command.replace(password, "**********");
377 return sanitizedCommand;
381 * Connects to a SqueezeBox Server
383 private void connect() {
384 logger.trace("attempting to get a connection to the server");
386 SqueezeBoxServerConfig config = getConfigAs(SqueezeBoxServerConfig.class);
387 this.host = config.ipAddress;
388 this.cliport = config.cliport;
389 this.webport = config.webport;
390 this.userId = config.userId;
391 this.password = config.password;
393 if (host.isEmpty()) {
394 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "host is not set");
397 // Create URL for jsonrpc interface
398 jsonRpcUrl = String.format("http://%s:%d/jsonrpc.js", host, webport);
401 clientSocket = new Socket(host, cliport);
402 } catch (IOException e) {
403 logger.debug("unable to open socket to server: {}", e.getMessage());
404 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
410 listener = new SqueezeServerListener();
412 logger.debug("listener connection started to server {}:{}", host, cliport);
413 } catch (IllegalThreadStateException e) {
414 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
416 // Mark the server ONLINE. bridgeStatusChanged will cause the players to come ONLINE
417 updateStatus(ThingStatus.ONLINE);
421 * Disconnects from a SqueezeBox Server
423 private void disconnect() {
425 if (listener != null) {
426 listener.terminate();
428 if (clientSocket != null) {
429 clientSocket.close();
431 } catch (Exception e) {
432 logger.trace("Error attempting to disconnect from Squeeze Server", e);
439 logger.trace("Squeeze Server connection stopped.");
442 private class SqueezeServerListener extends Thread {
443 private boolean terminate = false;
445 public SqueezeServerListener() {
446 super("Squeeze Server Listener");
449 public void terminate() {
450 logger.debug("setting squeeze server listener terminate flag");
451 this.terminate = true;
456 BufferedReader reader = null;
457 boolean endOfStream = false;
458 ScheduledFuture<?> requestFavoritesJob = null;
461 reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
463 updateStatus(ThingStatus.ONLINE);
465 requestFavoritesJob = scheduleRequestFavorites();
466 sendCommand("listen 1");
468 String message = null;
469 while (!terminate && (message = reader.readLine()) != null) {
470 // Message is very long and frequent; only show when running at trace level logging
471 logger.trace("Message received: {}", message);
473 // Fix for some third-party apps that are sending "subscribe playlist"
474 if (message.startsWith("listen 1") || message.startsWith("subscribe playlist")) {
478 if (message.startsWith("players 0")) {
479 handlePlayersList(message);
480 } else if (message.startsWith("favorites")) {
481 handleFavorites(message);
483 handlePlayerUpdate(message);
486 if (message == null) {
489 } catch (IOException e) {
491 logger.warn("failed to read line from squeeze server socket: {}", e.getMessage());
492 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
496 if (reader != null) {
499 } catch (IOException e) {
506 // check for end of stream from readLine
507 if (endOfStream && !terminate) {
508 logger.info("end of stream received from socket during readLine");
509 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
510 "end of stream on socket read");
513 if (requestFavoritesJob != null && !requestFavoritesJob.isDone()) {
514 requestFavoritesJob.cancel(true);
515 logger.debug("Canceled request favorites job");
517 logger.debug("Squeeze Server listener exiting.");
520 private String decode(String raw) {
522 return URLDecoder.decode(raw, UTF8_NAME);
523 } catch (UnsupportedEncodingException e) {
524 logger.debug("Failed to decode '{}' ", raw, e);
529 private String encode(String raw) {
531 return URLEncoder.encode(raw, UTF8_NAME);
532 } catch (UnsupportedEncodingException e) {
533 logger.debug("Failed to encode '{}' ", raw, e);
538 private void handlePlayersList(String message) {
539 final Set<String> connectedPlayers = new HashSet<>();
542 String[] playersList = message.split("playerindex\\S*\\s");
543 for (String playerParams : playersList) {
545 // For each player, split out parameters and decode parameter
546 String[] parameterList = playerParams.split("\\s");
547 for (int i = 0; i < parameterList.length; i++) {
548 parameterList[i] = decode(parameterList[i]);
551 // parse out the MAC address first
552 String macAddress = null;
553 for (String parameter : parameterList) {
554 if (parameter.contains("playerid")) {
555 macAddress = parameter.substring(parameter.indexOf(":") + 1);
560 // if none found then ignore this set of params
561 if (macAddress == null) {
565 final SqueezeBoxPlayer player = new SqueezeBoxPlayer();
566 player.setMacAddress(macAddress);
567 // populate the player state
568 for (String parameter : parameterList) {
569 if (parameter.startsWith("ip:")) {
570 player.setIpAddr(parameter.substring(parameter.indexOf(":") + 1));
571 } else if (parameter.startsWith("uuid:")) {
572 player.setUuid(parameter.substring(parameter.indexOf(":") + 1));
573 } else if (parameter.startsWith("name:")) {
574 player.setName(parameter.substring(parameter.indexOf(":") + 1));
575 } else if (parameter.startsWith("model:")) {
576 player.setModel(parameter.substring(parameter.indexOf(":") + 1));
577 } else if (parameter.startsWith("connected:")) {
578 if ("1".equals(parameter.substring(parameter.indexOf(":") + 1))) {
579 connectedPlayers.add(macAddress);
584 // Save player if we haven't seen it yet
585 if (!players.containsKey(macAddress)) {
586 players.put(macAddress, player);
587 updatePlayer(new PlayerUpdateEvent() {
589 public void updateListener(SqueezeBoxPlayerEventListener listener) {
590 listener.playerAdded(player);
593 // tell the server we want to subscribe to player updates
594 sendCommand(player.getMacAddress() + " status - 1 subscribe:10 tags:yagJlNKjc");
597 for (final SqueezeBoxPlayer player : players.values()) {
598 final String mac = player.getMacAddress();
599 final boolean connected = connectedPlayers.contains(mac);
600 updatePlayer(listener -> listener.connectedStateChangeEvent(mac, connected));
604 private void handlePlayerUpdate(String message) {
605 String[] messageParts = message.split("\\s");
606 if (messageParts.length < 2) {
607 logger.warn("Invalid message - expecting at least 2 parts. Ignoring.");
611 final String mac = decode(messageParts[0]);
613 // get the message type
614 String messageType = messageParts[1];
615 switch (messageType) {
617 handleClientMessage(mac, messageParts);
620 handleStatusMessage(mac, messageParts);
623 handlePlaylistMessage(mac, messageParts);
626 handlePrefsetMessage(mac, messageParts);
629 handleMixerMessage(mac, messageParts);
632 final String ircode = messageParts[2];
633 updatePlayer(new PlayerUpdateEvent() {
635 public void updateListener(SqueezeBoxPlayerEventListener listener) {
636 listener.irCodeChangeEvent(mac, ircode);
641 logger.trace("Unhandled player update message type '{}'.", messageType);
645 private void handleMixerMessage(String mac, String[] messageParts) {
646 if (messageParts.length < 4) {
649 String action = messageParts[2];
653 String volumeStringValue = decode(messageParts[3]);
654 updatePlayer(new PlayerUpdateEvent() {
656 public void updateListener(SqueezeBoxPlayerEventListener listener) {
658 int volume = Integer.parseInt(volumeStringValue);
660 // Check if we received a relative volume change, or an absolute
662 if (volumeStringValue.contains("+") || (volumeStringValue.contains("-"))) {
663 listener.relativeVolumeChangeEvent(mac, volume);
665 listener.absoluteVolumeChangeEvent(mac, volume);
667 } catch (NumberFormatException e) {
668 logger.warn("Unable to parse volume [{}] received from mixer message.",
669 volumeStringValue, e);
675 logger.trace("Unhandled mixer message type '{}'", Arrays.toString(messageParts));
680 private void handleClientMessage(final String mac, String[] messageParts) {
681 if (messageParts.length < 3) {
685 String action = messageParts[2];
686 final boolean connected;
688 if ("new".equals(action) || "reconnect".equals(action)) {
690 } else if ("disconnect".equals(action) || "forget".equals(action)) {
693 logger.trace("Unhandled client message type '{}'", Arrays.toString(messageParts));
697 updatePlayer(listener -> listener.connectedStateChangeEvent(mac, connected));
700 private void handleStatusMessage(final String mac, String[] messageParts) {
701 String remoteTitle = "", artist = "", album = "", genre = "", year = "";
702 boolean coverart = false;
703 String coverid = null;
704 String artworkUrl = null;
706 for (String messagePart : messageParts) {
708 if (messagePart.startsWith("power%3A")) {
709 final boolean power = "1".matches(messagePart.substring("power%3A".length()));
710 updatePlayer(new PlayerUpdateEvent() {
712 public void updateListener(SqueezeBoxPlayerEventListener listener) {
713 listener.powerChangeEvent(mac, power);
718 else if (messagePart.startsWith("mixer%20volume%3A")) {
719 String value = messagePart.substring("mixer%20volume%3A".length());
720 final int volume = (int) Double.parseDouble(value);
721 updatePlayer(new PlayerUpdateEvent() {
723 public void updateListener(SqueezeBoxPlayerEventListener listener) {
724 listener.absoluteVolumeChangeEvent(mac, volume);
729 else if (messagePart.startsWith("mode%3A")) {
730 final String mode = messagePart.substring("mode%3A".length());
731 updatePlayer(new PlayerUpdateEvent() {
733 public void updateListener(SqueezeBoxPlayerEventListener listener) {
734 listener.modeChangeEvent(mac, mode);
738 // Parameter Playing Time
739 else if (messagePart.startsWith("time%3A")) {
740 String value = messagePart.substring("time%3A".length());
741 final int time = (int) Double.parseDouble(value);
742 updatePlayer(new PlayerUpdateEvent() {
744 public void updateListener(SqueezeBoxPlayerEventListener listener) {
745 listener.currentPlayingTimeEvent(mac, time);
749 // Parameter duration
750 else if (messagePart.startsWith("duration%3A")) {
751 String value = messagePart.substring("duration%3A".length());
752 final int duration = (int) Double.parseDouble(value);
753 updatePlayer(new PlayerUpdateEvent() {
755 public void updateListener(SqueezeBoxPlayerEventListener listener) {
756 listener.durationEvent(mac, duration);
760 // Parameter Playing Playlist Index
761 else if (messagePart.startsWith("playlist_cur_index%3A")) {
762 String value = messagePart.substring("playlist_cur_index%3A".length());
763 final int index = (int) Double.parseDouble(value);
764 updatePlayer(new PlayerUpdateEvent() {
766 public void updateListener(SqueezeBoxPlayerEventListener listener) {
767 listener.currentPlaylistIndexEvent(mac, index);
771 // Parameter Playlist Number Tracks
772 else if (messagePart.startsWith("playlist_tracks%3A")) {
773 String value = messagePart.substring("playlist_tracks%3A".length());
774 final int track = (int) Double.parseDouble(value);
775 updatePlayer(new PlayerUpdateEvent() {
777 public void updateListener(SqueezeBoxPlayerEventListener listener) {
778 listener.numberPlaylistTracksEvent(mac, track);
782 // Parameter Playlist Repeat Mode
783 else if (messagePart.startsWith("playlist%20repeat%3A")) {
784 String value = messagePart.substring("playlist%20repeat%3A".length());
785 final int repeat = (int) Double.parseDouble(value);
786 updatePlayer(new PlayerUpdateEvent() {
788 public void updateListener(SqueezeBoxPlayerEventListener listener) {
789 listener.currentPlaylistRepeatEvent(mac, repeat);
793 // Parameter Playlist Shuffle Mode
794 else if (messagePart.startsWith("playlist%20shuffle%3A")) {
795 String value = messagePart.substring("playlist%20shuffle%3A".length());
796 final int shuffle = (int) Double.parseDouble(value);
797 updatePlayer(new PlayerUpdateEvent() {
799 public void updateListener(SqueezeBoxPlayerEventListener listener) {
800 listener.currentPlaylistShuffleEvent(mac, shuffle);
805 else if (messagePart.startsWith("title%3A")) {
806 final String value = messagePart.substring("title%3A".length());
807 updatePlayer(new PlayerUpdateEvent() {
809 public void updateListener(SqueezeBoxPlayerEventListener listener) {
810 listener.titleChangeEvent(mac, decode(value));
814 // Parameter Remote Title (radio)
815 else if (messagePart.startsWith("remote_title%3A")) {
816 remoteTitle = messagePart.substring("remote_title%3A".length());
819 else if (messagePart.startsWith("artist%3A")) {
820 artist = messagePart.substring("artist%3A".length());
823 else if (messagePart.startsWith("album%3A")) {
824 album = messagePart.substring("album%3A".length());
827 else if (messagePart.startsWith("genre%3A")) {
828 genre = messagePart.substring("genre%3A".length());
831 else if (messagePart.startsWith("year%3A")) {
832 year = messagePart.substring("year%3A".length());
834 // Parameter artwork_url contains url to cover art
835 else if (messagePart.startsWith("artwork_url%3A")) {
836 artworkUrl = messagePart.substring("artwork_url%3A".length());
838 // When coverart is "1" coverid will contain a unique coverart id
839 else if (messagePart.startsWith("coverart%3A")) {
840 coverart = "1".matches(messagePart.substring("coverart%3A".length()));
842 // Id for covert art (only valid when coverart is "1")
843 else if (messagePart.startsWith("coverid%3A")) {
844 coverid = messagePart.substring("coverid%3A".length());
846 // Added to be able to see additional status message types
847 logger.trace("Unhandled status message type '{}'", messagePart);
851 final String finalUrl = constructCoverArtUrl(mac, coverart, coverid, artworkUrl);
852 final String finalRemoteTitle = remoteTitle;
853 final String finalArtist = artist;
854 final String finalAlbum = album;
855 final String finalGenre = genre;
856 final String finalYear = year;
858 updatePlayer(new PlayerUpdateEvent() {
860 public void updateListener(SqueezeBoxPlayerEventListener listener) {
861 listener.coverArtChangeEvent(mac, finalUrl);
862 listener.remoteTitleChangeEvent(mac, decode(finalRemoteTitle));
863 listener.artistChangeEvent(mac, decode(finalArtist));
864 listener.albumChangeEvent(mac, decode(finalAlbum));
865 listener.genreChangeEvent(mac, decode(finalGenre));
866 listener.yearChangeEvent(mac, decode(finalYear));
871 private String constructCoverArtUrl(String mac, boolean coverart, String coverid, String artwork_url) {
873 if (!userId.isEmpty()) {
874 hostAndPort = "http://" + encode(userId) + ":" + encode(password) + "@" + host + ":" + webport;
876 hostAndPort = "http://" + host + ":" + webport;
879 // Default to using the convenience artwork URL (should be rare)
880 String url = hostAndPort + "/music/current/cover.jpg?player=" + encode(mac);
882 // If additional artwork info provided, use that instead
884 if (coverid != null) {
885 // Typically is used to access cover art of local music files
886 url = hostAndPort + "/music/" + coverid + "/cover.jpg";
888 } else if (artwork_url != null) {
889 if (artwork_url.startsWith("http")) {
890 // Typically indicates that cover art is not local to LMS
891 url = decode(artwork_url);
892 } else if (artwork_url.startsWith("%2F")) {
893 // Typically used for default coverart for plugins (e.g. Pandora, etc.)
894 url = hostAndPort + decode(artwork_url);
896 // Another variation of default coverart for plugins (e.g. Pandora, etc.)
897 url = hostAndPort + "/" + decode(artwork_url);
903 private void handlePlaylistMessage(final String mac, String[] messageParts) {
904 if (messageParts.length < 3) {
907 String action = messageParts[2];
909 if (action.equals("newsong")) {
911 // Execute in separate thread to avoid delaying listener
912 scheduler.execute(() -> updateCustomButtons(mac));
913 // Set the track duration to 0
914 updatePlayer(new PlayerUpdateEvent() {
916 public void updateListener(SqueezeBoxPlayerEventListener listener) {
917 listener.durationEvent(mac, 0);
920 } else if (action.equals("pause")) {
921 if (messageParts.length < 4) {
924 mode = messageParts[3].equals("0") ? "play" : "pause";
925 } else if (action.equals("stop")) {
927 } else if ("play".equals(action) && "playlist".equals(messageParts[1])) {
928 if (messageParts.length >= 4) {
929 handleSourceChangeMessage(mac, messageParts[3]);
933 // Added so that actions (such as delete, index, jump, open) are not treated as "play"
934 logger.trace("Unhandled playlist message type '{}'", Arrays.toString(messageParts));
937 final String value = mode;
938 updatePlayer(new PlayerUpdateEvent() {
940 public void updateListener(SqueezeBoxPlayerEventListener listener) {
941 listener.modeChangeEvent(mac, value);
946 private void handleSourceChangeMessage(String mac, String rawSource) {
947 String source = URLDecoder.decode(rawSource);
948 updatePlayer(new PlayerUpdateEvent() {
950 public void updateListener(SqueezeBoxPlayerEventListener listener) {
951 listener.sourceChangeEvent(mac, source);
956 private void handlePrefsetMessage(final String mac, String[] messageParts) {
957 if (messageParts.length < 5) {
961 if (messageParts[2].equals("server")) {
962 String function = messageParts[3];
963 String value = messageParts[4];
964 if (function.equals("power")) {
965 final boolean power = value.equals("1");
966 updatePlayer(new PlayerUpdateEvent() {
968 public void updateListener(SqueezeBoxPlayerEventListener listener) {
969 listener.powerChangeEvent(mac, power);
972 } else if (function.equals("volume")) {
973 final int volume = (int) Double.parseDouble(value);
974 updatePlayer(new PlayerUpdateEvent() {
976 public void updateListener(SqueezeBoxPlayerEventListener listener) {
977 listener.absoluteVolumeChangeEvent(mac, volume);
984 private void handleFavorites(String message) {
985 String[] messageParts = message.split("\\s");
986 if (messageParts.length == 2 && "changed".equals(messageParts[1])) {
987 // LMS informing us that favorites have changed; request an update to the favorites list
991 if (messageParts.length < 7) {
992 logger.trace("No favorites in message.");
996 List<Favorite> favorites = new ArrayList<>();
998 boolean isTypePlaylist = false;
999 for (String part : messageParts) {
1000 // Favorite ID (in form xxxxxxxxx.n)
1001 if (part.startsWith("id%3A")) {
1002 String id = part.substring("id%3A".length());
1003 f = new Favorite(id);
1005 isTypePlaylist = false;
1008 else if (part.startsWith("name%3A")) {
1009 String name = decode(part.substring("name%3A".length()));
1013 } else if (part.equals("type%3Aplaylist")) {
1014 isTypePlaylist = true;
1016 // When "1", favorite is a submenu with additional favorites
1017 else if (part.startsWith("hasitems%3A")) {
1018 boolean hasitems = "1".matches(part.substring("hasitems%3A".length()));
1020 // Except for some favorites (e.g. Spotify) use hasitems:1 and type:playlist
1021 if (hasitems && isTypePlaylist == false) {
1023 favorites.remove(f);
1029 updatePlayersFavoritesList(favorites);
1030 updateChannelFavoritesList(favorites);
1033 private void updatePlayersFavoritesList(List<Favorite> favorites) {
1034 updatePlayer(new PlayerUpdateEvent() {
1036 public void updateListener(SqueezeBoxPlayerEventListener listener) {
1037 listener.updateFavoritesListEvent(favorites);
1042 private void updateChannelFavoritesList(List<Favorite> favorites) {
1043 final Channel channel = getThing().getChannel(CHANNEL_FAVORITES_LIST);
1044 if (channel == null) {
1045 logger.debug("Channel {} doesn't exist. Delete & add thing to get channel.", CHANNEL_FAVORITES_LIST);
1049 // Get channel config parameter indicating whether name should be wrapped with double quotes
1050 Boolean includeQuotes = Boolean.FALSE;
1051 if (channel.getConfiguration().containsKey(CHANNEL_CONFIG_QUOTE_LIST)) {
1052 includeQuotes = (Boolean) channel.getConfiguration().get(CHANNEL_CONFIG_QUOTE_LIST);
1055 String quote = includeQuotes.booleanValue() ? "\"" : "";
1056 StringBuilder sb = new StringBuilder();
1057 for (Favorite favorite : favorites) {
1058 sb.append(favorite.shortId).append("=").append(quote).append(favorite.name.replaceAll(",", ""))
1059 .append(quote).append(",");
1062 if (sb.length() == 0) {
1063 updateState(CHANNEL_FAVORITES_LIST, UnDefType.NULL);
1065 // Drop the last comma
1066 sb.setLength(sb.length() - 1);
1067 String favoritesList = sb.toString();
1068 logger.trace("Updating favorites channel for {} to state {}", getThing().getUID(), favoritesList);
1069 updateState(CHANNEL_FAVORITES_LIST, new StringType(favoritesList));
1073 private ScheduledFuture<?> scheduleRequestFavorites() {
1074 // Delay the execution to give the player thing handlers a chance to initialize
1075 return scheduler.schedule(SqueezeBoxServerHandler.this::requestFavorites, 3L, TimeUnit.SECONDS);
1078 private void updateCustomButtons(final String mac) {
1079 String response = executePost(jsonRpcUrl, JSONRPC_STATUS_REQUEST.replace("@@MAC@@", mac));
1080 if (response != null) {
1081 logger.trace("Status response: {}", response);
1082 String likeCommand = null;
1083 String unlikeCommand = null;
1085 StatusResponseDTO status = gson.fromJson(response, StatusResponseDTO.class);
1086 if (status != null && status.result != null && status.result.remoteMeta != null
1087 && status.result.remoteMeta.buttons != null) {
1088 ButtonsDTO buttons = status.result.remoteMeta.buttons;
1089 if (buttons.repeat != null && buttons.repeat.isCustom()) {
1090 likeCommand = buttons.repeat.command;
1092 if (buttons.shuffle != null && buttons.shuffle.isCustom()) {
1093 unlikeCommand = buttons.shuffle.command;
1096 } catch (JsonSyntaxException e) {
1097 logger.debug("JsonSyntaxException parsing status response: {}", response, e);
1099 final String like = likeCommand;
1100 final String unlike = unlikeCommand;
1101 updatePlayer(new PlayerUpdateEvent() {
1103 public void updateListener(SqueezeBoxPlayerEventListener listener) {
1104 listener.buttonsChangeEvent(mac, like, unlike);
1110 private String executePost(String url, String content) {
1112 HttpRequestBuilder builder = HttpRequestBuilder.postTo(url)
1113 .withTimeout(Duration.ofSeconds(5))
1114 .withContent(content)
1115 .withHeader("charset", "utf-8")
1116 .withHeader("Content-Type", "application/json");
1118 if (basicAuthorization != null) {
1119 builder = builder.withHeader("Authorization", "Basic " + basicAuthorization);
1122 return builder.getContentAsString();
1123 } catch (IOException e) {
1124 logger.debug("Bridge: IOException on jsonrpc call: {}", e.getMessage(), e);
1131 * Interface to allow us to pass function call-backs to SqueezeBox Player
1134 * @author Dan Cunningham
1137 interface PlayerUpdateEvent {
1138 void updateListener(SqueezeBoxPlayerEventListener listener);
1142 * Update Listeners and child Squeeze Player Things
1146 private void updatePlayer(PlayerUpdateEvent event) {
1147 // update listeners like disco services
1148 synchronized (squeezeBoxPlayerListeners) {
1149 for (SqueezeBoxPlayerEventListener listener : squeezeBoxPlayerListeners) {
1150 event.updateListener(listener);
1153 // update our children
1154 Bridge bridge = getThing();
1156 List<Thing> things = bridge.getThings();
1157 for (Thing thing : things) {
1158 ThingHandler handler = thing.getHandler();
1159 if (handler instanceof SqueezeBoxPlayerEventListener && !squeezeBoxPlayerListeners.contains(handler)) {
1160 event.updateListener((SqueezeBoxPlayerEventListener) handler);
1166 * Adds a listener for player events
1168 * @param squeezeBoxPlayerListener
1171 public boolean registerSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1172 logger.trace("Registering player listener");
1173 return squeezeBoxPlayerListeners.add(squeezeBoxPlayerListener);
1177 * Removes a listener from player events
1179 * @param squeezeBoxPlayerListener
1182 public boolean unregisterSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1183 logger.trace("Unregistering player listener");
1184 return squeezeBoxPlayerListeners.remove(squeezeBoxPlayerListener);
1188 * Removed a player from our known list of players, will populate again if
1193 public void removePlayerCache(String mac) {
1194 players.remove(mac);
1198 * Schedule the server to try and reconnect
1200 private void scheduleReconnect() {
1201 logger.debug("scheduling squeeze server reconnect in {} seconds", RECONNECT_TIME);
1203 reconnectFuture = scheduler.schedule(this::connect, RECONNECT_TIME, TimeUnit.SECONDS);
1207 * Clears our reconnect job if exists
1209 private void cancelReconnect() {
1210 if (reconnectFuture != null) {
1211 reconnectFuture.cancel(true);