2 * Copyright (c) 2010-2023 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.net.Socket;
23 import java.net.URLDecoder;
24 import java.net.URLEncoder;
25 import java.nio.charset.StandardCharsets;
26 import java.time.Duration;
27 import java.util.ArrayList;
28 import java.util.Arrays;
29 import java.util.Base64;
30 import java.util.Collections;
31 import java.util.HashMap;
32 import java.util.HashSet;
33 import java.util.List;
36 import java.util.concurrent.Future;
37 import java.util.concurrent.ScheduledFuture;
38 import java.util.concurrent.TimeUnit;
39 import java.util.stream.Collectors;
41 import org.eclipse.jdt.annotation.NonNullByDefault;
42 import org.openhab.binding.squeezebox.internal.config.SqueezeBoxServerConfig;
43 import org.openhab.binding.squeezebox.internal.dto.ButtonDTO;
44 import org.openhab.binding.squeezebox.internal.dto.ButtonDTODeserializer;
45 import org.openhab.binding.squeezebox.internal.dto.ButtonsDTO;
46 import org.openhab.binding.squeezebox.internal.dto.StatusResponseDTO;
47 import org.openhab.binding.squeezebox.internal.model.Favorite;
48 import org.openhab.core.io.net.http.HttpRequestBuilder;
49 import org.openhab.core.library.types.StringType;
50 import org.openhab.core.thing.Bridge;
51 import org.openhab.core.thing.Channel;
52 import org.openhab.core.thing.ChannelUID;
53 import org.openhab.core.thing.Thing;
54 import org.openhab.core.thing.ThingStatus;
55 import org.openhab.core.thing.ThingStatusDetail;
56 import org.openhab.core.thing.ThingTypeUID;
57 import org.openhab.core.thing.binding.BaseBridgeHandler;
58 import org.openhab.core.thing.binding.ThingHandler;
59 import org.openhab.core.types.Command;
60 import org.openhab.core.types.UnDefType;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
64 import com.google.gson.Gson;
65 import com.google.gson.GsonBuilder;
66 import com.google.gson.JsonSyntaxException;
69 * Handles connection and event handling to a SqueezeBox Server.
71 * @author Markus Wolters - Initial contribution
72 * @author Ben Jones - ?
73 * @author Dan Cunningham - OH2 port
74 * @author Daniel Walters - Fix player discovery when player name contains spaces
75 * @author Mark Hilbush - Improve reconnect logic. Improve player status updates.
76 * @author Mark Hilbush - Implement AudioSink and notifications
77 * @author Mark Hilbush - Added duration channel
78 * @author Mark Hilbush - Added login/password authentication for LMS
79 * @author Philippe Siem - Improve refresh of cover art url,remote title, artist, album, genre, year.
80 * @author Patrik Gfeller - Support for mixer volume message added
81 * @author Mark Hilbush - Get favorites from LMS; update channel and send to players
82 * @author Mark Hilbush - Add like/unlike functionality
84 public class SqueezeBoxServerHandler extends BaseBridgeHandler {
85 private final Logger logger = LoggerFactory.getLogger(SqueezeBoxServerHandler.class);
87 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
88 .singleton(SQUEEZEBOXSERVER_THING_TYPE);
90 // time in seconds to try to reconnect
91 private static final int RECONNECT_TIME = 60;
93 // the value by which the volume is changed by each INCREASE or
95 private static final int VOLUME_CHANGE_SIZE = 5;
96 private static final String NEW_LINE = System.getProperty("line.separator");
98 private static final String CHANNEL_CONFIG_QUOTE_LIST = "quoteList";
100 private static final String JSONRPC_STATUS_REQUEST = "{\"id\":1,\"method\":\"slim.request\",\"params\":[\"@@MAC@@\",[\"status\",\"-\",\"tags:yagJlNKjcB\"]]}";
102 private List<SqueezeBoxPlayerEventListener> squeezeBoxPlayerListeners = Collections
103 .synchronizedList(new ArrayList<>());
105 private Map<String, SqueezeBoxPlayer> players = Collections.synchronizedMap(new HashMap<>());
107 // client socket and listener thread
108 private Socket clientSocket;
109 private SqueezeServerListener listener;
110 private Future<?> reconnectFuture;
118 private String userId;
120 private String password;
122 private final Gson gson = new GsonBuilder().registerTypeAdapter(ButtonDTO.class, new ButtonDTODeserializer())
124 private String jsonRpcUrl;
125 private String basicAuthorization;
127 public SqueezeBoxServerHandler(Bridge bridge) {
132 public void initialize() {
133 logger.debug("initializing server handler for thing {}", getThing().getUID());
134 scheduler.submit(this::connect);
138 public void dispose() {
139 logger.debug("disposing server handler for thing {}", getThing().getUID());
145 public void handleCommand(ChannelUID channelUID, Command command) {
149 * Checks if we have a connection to the Server
153 public synchronized boolean isConnected() {
154 if (clientSocket == null) {
158 // NOTE: isConnected() returns true once a connection is made and will
159 // always return true even after the socket is closed
160 // http://stackoverflow.com/questions/10163358/
161 return clientSocket.isConnected() && !clientSocket.isClosed();
164 public void mute(String mac) {
165 sendCommand(mac + " mixer muting 1");
168 public void unMute(String mac) {
169 sendCommand(mac + " mixer muting 0");
172 public void powerOn(String mac) {
173 sendCommand(mac + " power 1");
176 public void powerOff(String mac) {
177 sendCommand(mac + " power 0");
180 public void syncPlayer(String mac, String player2mac) {
181 sendCommand(mac + " sync " + player2mac);
184 public void unSyncPlayer(String mac) {
185 sendCommand(mac + " sync -");
188 public void play(String mac) {
189 sendCommand(mac + " play");
192 public void playUrl(String mac, String url) {
193 sendCommand(mac + " playlist play " + url);
196 public void pause(String mac) {
197 sendCommand(mac + " pause 1");
200 public void unPause(String mac) {
201 sendCommand(mac + " pause 0");
204 public void stop(String mac) {
205 sendCommand(mac + " stop");
208 public void prev(String mac) {
209 sendCommand(mac + " playlist index -1");
212 public void next(String mac) {
213 sendCommand(mac + " playlist index +1");
216 public void clearPlaylist(String mac) {
217 sendCommand(mac + " playlist clear");
220 public void deletePlaylistItem(String mac, int playlistIndex) {
221 sendCommand(mac + " playlist delete " + playlistIndex);
224 public void playPlaylistItem(String mac, int playlistIndex) {
225 sendCommand(mac + " playlist index " + playlistIndex);
228 public void addPlaylistItem(String mac, String url) {
229 addPlaylistItem(mac, url, null);
232 public void addPlaylistItem(String mac, String url, String title) {
233 StringBuilder playlistCommand = new StringBuilder();
234 playlistCommand.append(mac).append(" playlist add ").append(url);
236 playlistCommand.append(" ").append(title);
238 sendCommand(playlistCommand.toString());
241 public void setPlayingTime(String mac, int time) {
242 sendCommand(mac + " time " + time);
245 public void setRepeatMode(String mac, int repeatMode) {
246 sendCommand(mac + " playlist repeat " + repeatMode);
249 public void setShuffleMode(String mac, int shuffleMode) {
250 sendCommand(mac + " playlist shuffle " + shuffleMode);
253 public void volumeUp(String mac, int currentVolume) {
254 setVolume(mac, currentVolume + VOLUME_CHANGE_SIZE);
257 public void volumeDown(String mac, int currentVolume) {
258 setVolume(mac, currentVolume - VOLUME_CHANGE_SIZE);
261 public void setVolume(String mac, int volume) {
262 int newVolume = volume;
263 newVolume = Math.min(100, newVolume);
264 newVolume = Math.max(0, newVolume);
265 sendCommand(mac + " mixer volume " + String.valueOf(newVolume));
268 public void showString(String mac, String line) {
269 showString(mac, line, 5);
272 public void showString(String mac, String line, int duration) {
273 sendCommand(mac + " show line1:" + line + " duration:" + String.valueOf(duration));
276 public void showStringHuge(String mac, String line) {
277 showStringHuge(mac, line, 5);
280 public void showStringHuge(String mac, String line, int duration) {
281 sendCommand(mac + " show line1:" + line + " font:huge duration:" + String.valueOf(duration));
284 public void showStrings(String mac, String line1, String line2) {
285 showStrings(mac, line1, line2, 5);
288 public void showStrings(String mac, String line1, String line2, int duration) {
289 sendCommand(mac + " show line1:" + line1 + " line2:" + line2 + " duration:" + String.valueOf(duration));
292 public void playFavorite(String mac, String favorite) {
293 sendCommand(mac + " favorites playlist play item_id:" + favorite);
296 public void rate(String mac, String rateCommand) {
297 if (rateCommand != null) {
298 sendCommand(mac + " " + rateCommand);
302 public void sleep(String mac, Duration sleepDuration) {
303 sendCommand(mac + " sleep " + String.valueOf(sleepDuration.toSeconds()));
307 * Send a generic command to a given player
312 public void playerCommand(String mac, String command) {
313 sendCommand(mac + " " + command);
317 * Ask for player list
319 public void requestPlayers() {
320 sendCommand("players 0");
324 * Ask for favorites list
326 public void requestFavorites() {
327 sendCommand("favorites items 0 100");
333 public void login() {
334 if (userId.isEmpty()) {
337 // Create basic auth string for jsonrpc interface
338 basicAuthorization = new String(
339 Base64.getEncoder().encode((userId + ":" + password).getBytes(StandardCharsets.UTF_8)));
340 logger.debug("Logging into Squeeze Server using userId={}", userId);
341 sendCommand("login " + userId + " " + password);
345 * Send a command to the Squeeze Server.
347 private synchronized void sendCommand(String command) {
348 if (getThing().getStatus() != ThingStatus.ONLINE) {
352 if (!isConnected()) {
353 logger.debug("no connection to squeeze server when trying to send command, returning...");
357 logger.debug("Sending command: {}", sanitizeCommand(command));
359 BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()));
360 writer.write(command + NEW_LINE);
362 } catch (IOException e) {
363 logger.error("Error while sending command to Squeeze Server ({}) ", sanitizeCommand(command), e);
368 * Remove password from login command to prevent it from being logged
370 String sanitizeCommand(String command) {
371 String sanitizedCommand = command;
372 if (command.startsWith("login")) {
373 sanitizedCommand = command.replace(password, "**********");
375 return sanitizedCommand;
379 * Connects to a SqueezeBox Server
381 private void connect() {
382 logger.trace("attempting to get a connection to the server");
384 SqueezeBoxServerConfig config = getConfigAs(SqueezeBoxServerConfig.class);
385 this.host = config.ipAddress;
386 this.cliport = config.cliport;
387 this.webport = config.webport;
388 this.userId = config.userId;
389 this.password = config.password;
391 if (host.isEmpty()) {
392 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "host is not set");
395 // Create URL for jsonrpc interface
396 jsonRpcUrl = String.format("http://%s:%d/jsonrpc.js", host, webport);
399 clientSocket = new Socket(host, cliport);
400 } catch (IOException e) {
401 logger.debug("unable to open socket to server: {}", e.getMessage());
402 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
408 listener = new SqueezeServerListener();
410 logger.debug("listener connection started to server {}:{}", host, cliport);
411 } catch (IllegalThreadStateException e) {
412 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
414 // Mark the server ONLINE. bridgeStatusChanged will cause the players to come ONLINE
415 updateStatus(ThingStatus.ONLINE);
419 * Disconnects from a SqueezeBox Server
421 private void disconnect() {
423 if (listener != null) {
424 listener.terminate();
426 if (clientSocket != null) {
427 clientSocket.close();
429 } catch (Exception e) {
430 logger.trace("Error attempting to disconnect from Squeeze Server", e);
437 logger.trace("Squeeze Server connection stopped.");
440 private class SqueezeServerListener extends Thread {
441 private boolean terminate = false;
443 public SqueezeServerListener() {
444 super("Squeeze Server Listener");
447 public void terminate() {
448 logger.debug("setting squeeze server listener terminate flag");
449 this.terminate = true;
454 BufferedReader reader = null;
455 boolean endOfStream = false;
456 ScheduledFuture<?> requestFavoritesJob = null;
459 reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
461 updateStatus(ThingStatus.ONLINE);
463 requestFavoritesJob = scheduleRequestFavorites();
464 sendCommand("listen 1");
466 String message = null;
467 while (!terminate && (message = reader.readLine()) != null) {
468 // Message is very long and frequent; only show when running at trace level logging
469 logger.trace("Message received: {}", message);
471 // Fix for some third-party apps that are sending "subscribe playlist"
472 if (message.startsWith("listen 1") || message.startsWith("subscribe playlist")) {
476 if (message.startsWith("players 0")) {
477 handlePlayersList(message);
478 } else if (message.startsWith("favorites")) {
479 handleFavorites(message);
481 handlePlayerUpdate(message);
484 if (message == null) {
487 } catch (IOException e) {
489 logger.warn("failed to read line from squeeze server socket: {}", e.getMessage());
490 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
494 if (reader != null) {
497 } catch (IOException e) {
504 // check for end of stream from readLine
505 if (endOfStream && !terminate) {
506 logger.info("end of stream received from socket during readLine");
507 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
508 "end of stream on socket read");
511 if (requestFavoritesJob != null && !requestFavoritesJob.isDone()) {
512 requestFavoritesJob.cancel(true);
513 logger.debug("Canceled request favorites job");
515 logger.debug("Squeeze Server listener exiting.");
518 private String decode(String raw) {
519 return URLDecoder.decode(raw, StandardCharsets.UTF_8);
522 private String encode(String raw) {
523 return URLEncoder.encode(raw, StandardCharsets.UTF_8);
527 private class KeyValue {
531 public KeyValue(String key, String value) {
537 private List<KeyValue> decodeKeyValueResponse(String[] response) {
538 final List<KeyValue> keysAndValues = new ArrayList<>();
539 if (response != null) {
540 for (String line : response) {
541 final String decoded = decode(line);
542 int colonPos = decoded.indexOf(":");
546 keysAndValues.add(new KeyValue(decoded.substring(0, colonPos), decoded.substring(colonPos + 1)));
549 return keysAndValues;
552 private void handlePlayersList(String message) {
553 final Set<String> connectedPlayers = new HashSet<>();
556 String[] playersList = message.split("playerindex\\S*\\s");
557 for (String playerParams : playersList) {
558 // For each player, split out parameters and decode parameter
559 final Map<String, String> keysAndValues = decodeKeyValueResponse(playerParams.split("\\s")).stream()
560 .collect(Collectors.toMap(kv -> kv.key, kv -> kv.value));
561 final String macAddress = keysAndValues.get("playerid");
563 // if none found then ignore this set of params
564 if (macAddress == null) {
568 final SqueezeBoxPlayer player = new SqueezeBoxPlayer(macAddress, keysAndValues.get("name"),
569 keysAndValues.get("ip"), keysAndValues.get("model"), keysAndValues.get("uuid"));
570 if ("1".equals(keysAndValues.get("connected"))) {
571 connectedPlayers.add(macAddress);
574 // Save player if we haven't seen it yet
575 if (!players.containsKey(macAddress)) {
576 players.put(macAddress, player);
577 updatePlayer(listener -> listener.playerAdded(player));
578 // tell the server we want to subscribe to player updates
579 sendCommand(player.macAddress + " status - 1 subscribe:10 tags:yagJlNKjcA");
582 for (final SqueezeBoxPlayer player : players.values()) {
583 final boolean connected = connectedPlayers.contains(player.macAddress);
584 updatePlayer(listener -> listener.connectedStateChangeEvent(player.macAddress, connected));
588 private void handlePlayerUpdate(String message) {
589 String[] messageParts = message.split("\\s");
590 if (messageParts.length < 2) {
591 logger.warn("Invalid message - expecting at least 2 parts. Ignoring.");
595 final String mac = decode(messageParts[0]);
597 // get the message type
598 String messageType = messageParts[1];
599 switch (messageType) {
601 handleClientMessage(mac, messageParts);
604 handleStatusMessage(mac, messageParts);
607 handlePlaylistMessage(mac, messageParts);
610 handlePrefsetMessage(mac, messageParts);
613 handleMixerMessage(mac, messageParts);
616 final String ircode = messageParts[2];
617 updatePlayer(listener -> listener.irCodeChangeEvent(mac, ircode));
620 logger.trace("Unhandled player update message type '{}'.", messageType);
624 private void handleMixerMessage(String mac, String[] messageParts) {
625 if (messageParts.length < 4) {
628 String action = messageParts[2];
632 String volumeStringValue = decode(messageParts[3]);
633 updatePlayer(listener -> {
635 int volume = Math.round(Float.parseFloat(volumeStringValue));
637 // Check if we received a relative volume change, or an absolute
639 if (volumeStringValue.contains("+") || (volumeStringValue.contains("-"))) {
640 listener.relativeVolumeChangeEvent(mac, volume);
642 listener.absoluteVolumeChangeEvent(mac, volume);
644 } catch (NumberFormatException e) {
645 logger.warn("Unable to parse volume [{}] received from mixer message.", volumeStringValue,
651 logger.trace("Unhandled mixer message type '{}'", Arrays.toString(messageParts));
656 private void handleClientMessage(final String mac, String[] messageParts) {
657 if (messageParts.length < 3) {
661 String action = messageParts[2];
662 final boolean connected;
664 if ("new".equals(action) || "reconnect".equals(action)) {
666 } else if ("disconnect".equals(action) || "forget".equals(action)) {
669 logger.trace("Unhandled client message type '{}'", Arrays.toString(messageParts));
673 updatePlayer(listener -> listener.connectedStateChangeEvent(mac, connected));
676 private void handleStatusMessage(final String mac, String[] messageParts) {
677 String remoteTitle = "", artist = "", album = "", genre = "", year = "", albumArtist = "", trackArtist = "",
678 band = "", composer = "", conductor = "";
679 boolean coverart = false;
680 String coverid = null;
681 String artworkUrl = null;
683 for (KeyValue entry : decodeKeyValueResponse(messageParts)) {
686 if ("power".equals(entry.key)) {
687 final boolean power = "1".equals(entry.value);
688 updatePlayer(listener -> listener.powerChangeEvent(mac, power));
691 else if ("mixer volume".equals(entry.key)) {
692 final int volume = (int) Double.parseDouble(entry.value);
693 updatePlayer(listener -> listener.absoluteVolumeChangeEvent(mac, volume));
696 else if ("mode".equals(entry.key)) {
697 updatePlayer(listener -> listener.modeChangeEvent(mac, entry.value));
699 // Parameter Playing Time
700 else if ("time".equals(entry.key) && !"N/A".equals(entry.value)) {
701 final int time = (int) Double.parseDouble(entry.value);
702 updatePlayer(listener -> listener.currentPlayingTimeEvent(mac, time));
704 // Parameter duration
705 else if ("duration".equals(entry.key)) {
706 final int duration = (int) Double.parseDouble(entry.value);
707 updatePlayer(listener -> listener.durationEvent(mac, duration));
709 // Parameter Playing Playlist Index
710 else if ("playlist_cur_index".equals(entry.key)) {
711 final int index = (int) Double.parseDouble(entry.value);
712 updatePlayer(listener -> listener.currentPlaylistIndexEvent(mac, index));
714 // Parameter Playlist Number Tracks
715 else if ("playlist_tracks".equals(entry.key)) {
716 final int track = (int) Double.parseDouble(entry.value);
717 updatePlayer(listener -> listener.numberPlaylistTracksEvent(mac, track));
719 // Parameter Playlist Repeat Mode
720 else if ("playlist repeat".equals(entry.key)) {
721 final int repeat = (int) Double.parseDouble(entry.value);
722 updatePlayer(listener -> listener.currentPlaylistRepeatEvent(mac, repeat));
724 // Parameter Playlist Shuffle Mode
725 else if ("playlist shuffle".equals(entry.key)) {
726 final int shuffle = (int) Double.parseDouble(entry.value);
727 updatePlayer(listener -> listener.currentPlaylistShuffleEvent(mac, shuffle));
730 else if ("title".equals(entry.key)) {
731 updatePlayer(listener -> listener.titleChangeEvent(mac, entry.value));
733 // Parameter Remote Title (radio)
734 else if ("remote_title".equals(entry.key)) {
735 remoteTitle = entry.value;
738 else if ("artist".equals(entry.key)) {
739 artist = entry.value;
742 else if ("album".equals(entry.key)) {
746 else if ("genre".equals(entry.key)) {
749 // Parameter Album Artist
750 else if ("albumartist".equals(entry.key)) {
751 albumArtist = entry.value;
753 // Parameter Track Artist
754 else if ("trackartist".equals(entry.key)) {
755 trackArtist = entry.value;
758 else if ("band".equals(entry.key)) {
761 // Parameter Composer
762 else if ("composer".equals(entry.key)) {
763 composer = entry.value;
765 // Parameter Conductor
766 else if ("conductor".equals(entry.key)) {
767 conductor = entry.value;
770 else if ("year".equals(entry.key)) {
773 // Parameter artwork_url contains url to cover art
774 else if ("artwork_url".equals(entry.key)) {
775 artworkUrl = entry.value;
777 // When coverart is "1" coverid will contain a unique coverart id
778 else if ("coverart".equals(entry.key)) {
779 coverart = "1".equals(entry.value);
781 // Id for covert art (only valid when coverart is "1")
782 else if ("coverid".equals(entry.key)) {
783 coverid = entry.value;
785 // Added to be able to see additional status message types
786 logger.trace("Unhandled status message type '{}' (value '{}')", entry.key, entry.value);
788 } catch (NumberFormatException e) {
789 // Skip this key/value
790 logger.debug("Cannot parse number in status message: key '{}', value '{}'", entry.key, entry.value);
794 final String finalUrl = constructCoverArtUrl(mac, coverart, coverid, artworkUrl);
795 final String finalRemoteTitle = remoteTitle;
796 final String finalArtist = artist;
797 final String finalAlbum = album;
798 final String finalGenre = genre;
799 final String finalYear = year;
800 final String finalAlbumArtist = albumArtist;
801 final String finalTrackArtist = trackArtist;
802 final String finalBand = band;
803 final String finalComposer = composer;
804 final String finalConductor = conductor;
806 updatePlayer(listener -> {
807 listener.coverArtChangeEvent(mac, finalUrl);
808 listener.remoteTitleChangeEvent(mac, finalRemoteTitle);
809 listener.artistChangeEvent(mac, finalArtist);
810 listener.albumChangeEvent(mac, finalAlbum);
811 listener.genreChangeEvent(mac, finalGenre);
812 listener.yearChangeEvent(mac, finalYear);
813 listener.albumArtistChangeEvent(mac, finalAlbumArtist);
814 listener.trackArtistChangeEvent(mac, finalTrackArtist);
815 listener.bandChangeEvent(mac, finalBand);
816 listener.composerChangeEvent(mac, finalComposer);
817 listener.conductorChangeEvent(mac, finalConductor);
821 private String constructCoverArtUrl(String mac, boolean coverart, String coverid, String artwork_url) {
823 if (!userId.isEmpty()) {
824 hostAndPort = "http://" + encode(userId) + ":" + encode(password) + "@" + host + ":" + webport;
826 hostAndPort = "http://" + host + ":" + webport;
829 // Default to using the convenience artwork URL (should be rare)
830 String url = hostAndPort + "/music/current/cover.jpg?player=" + encode(mac);
832 // If additional artwork info provided, use that instead
834 if (coverid != null) {
835 // Typically is used to access cover art of local music files
836 url = hostAndPort + "/music/" + coverid + "/cover.jpg";
838 } else if (artwork_url != null) {
839 if (artwork_url.startsWith("http")) {
840 // Typically indicates that cover art is not local to LMS
842 } else if (artwork_url.startsWith("/")) {
843 // Typically used for default coverart for plugins (e.g. Pandora, etc.)
844 url = hostAndPort + artwork_url;
846 // Another variation of default coverart for plugins (e.g. Pandora, etc.)
847 url = hostAndPort + "/" + artwork_url;
853 private void handlePlaylistMessage(final String mac, String[] messageParts) {
854 if (messageParts.length < 3) {
857 String action = messageParts[2];
859 if (action.equals("newsong")) {
861 // Execute in separate thread to avoid delaying listener
862 scheduler.execute(() -> updateCustomButtons(mac));
863 // Set the track duration to 0
864 updatePlayer(listener -> listener.durationEvent(mac, 0));
865 } else if (action.equals("pause")) {
866 if (messageParts.length < 4) {
869 mode = messageParts[3].equals("0") ? "play" : "pause";
870 } else if (action.equals("stop")) {
872 } else if ("play".equals(action) && "playlist".equals(messageParts[1])) {
873 if (messageParts.length >= 4) {
874 handleSourceChangeMessage(mac, messageParts[3]);
878 // Added so that actions (such as delete, index, jump, open) are not treated as "play"
879 logger.trace("Unhandled playlist message type '{}'", Arrays.toString(messageParts));
882 final String value = mode;
883 updatePlayer(listener -> listener.modeChangeEvent(mac, value));
886 private void handleSourceChangeMessage(String mac, String rawSource) {
887 String source = URLDecoder.decode(rawSource);
888 updatePlayer(listener -> listener.sourceChangeEvent(mac, source));
891 private void handlePrefsetMessage(final String mac, String[] messageParts) {
892 if (messageParts.length < 5) {
896 if (messageParts[2].equals("server")) {
897 String function = messageParts[3];
898 String value = messageParts[4];
899 if (function.equals("power")) {
900 final boolean power = value.equals("1");
901 updatePlayer(listener -> listener.powerChangeEvent(mac, power));
902 } else if (function.equals("volume")) {
903 final int volume = (int) Double.parseDouble(value);
904 updatePlayer(listener -> listener.absoluteVolumeChangeEvent(mac, volume));
909 private void handleFavorites(String message) {
910 String[] messageParts = message.split("\\s");
911 if (messageParts.length == 2 && "changed".equals(messageParts[1])) {
912 // LMS informing us that favorites have changed; request an update to the favorites list
916 if (messageParts.length < 7) {
917 logger.trace("No favorites in message.");
921 List<Favorite> favorites = new ArrayList<>();
923 boolean isTypePlaylist = false;
924 for (KeyValue entry : decodeKeyValueResponse(messageParts)) {
925 // Favorite ID (in form xxxxxxxxx.n)
926 if ("id".equals(entry.key)) {
927 f = new Favorite(entry.value);
929 isTypePlaylist = false;
932 else if ("name".equals(entry.key)) {
933 f.name = entry.value;
934 } else if ("type".equals(entry.key) && "playlist".equals(entry.value)) {
935 isTypePlaylist = true;
937 // When "1", favorite is a submenu with additional favorites
938 else if ("hasitems".equals(entry.key)) {
939 boolean hasitems = "1".equals(entry.value);
941 // Except for some favorites (e.g. Spotify) use hasitems:1 and type:playlist
942 if (hasitems && !isTypePlaylist) {
950 updatePlayer(listener -> listener.updateFavoritesListEvent(favorites));
951 updateChannelFavoritesList(favorites);
954 private void updateChannelFavoritesList(List<Favorite> favorites) {
955 final Channel channel = getThing().getChannel(CHANNEL_FAVORITES_LIST);
956 if (channel == null) {
957 logger.debug("Channel {} doesn't exist. Delete & add thing to get channel.", CHANNEL_FAVORITES_LIST);
961 // Get channel config parameter indicating whether name should be wrapped with double quotes
962 Boolean includeQuotes = Boolean.FALSE;
963 if (channel.getConfiguration().containsKey(CHANNEL_CONFIG_QUOTE_LIST)) {
964 includeQuotes = (Boolean) channel.getConfiguration().get(CHANNEL_CONFIG_QUOTE_LIST);
967 String quote = includeQuotes.booleanValue() ? "\"" : "";
968 StringBuilder sb = new StringBuilder();
969 for (Favorite favorite : favorites) {
970 sb.append(favorite.shortId).append("=").append(quote).append(favorite.name.replaceAll(",", ""))
971 .append(quote).append(",");
974 if (sb.length() == 0) {
975 updateState(CHANNEL_FAVORITES_LIST, UnDefType.NULL);
977 // Drop the last comma
978 sb.setLength(sb.length() - 1);
979 String favoritesList = sb.toString();
980 logger.trace("Updating favorites channel for {} to state {}", getThing().getUID(), favoritesList);
981 updateState(CHANNEL_FAVORITES_LIST, new StringType(favoritesList));
985 private ScheduledFuture<?> scheduleRequestFavorites() {
986 // Delay the execution to give the player thing handlers a chance to initialize
987 return scheduler.schedule(SqueezeBoxServerHandler.this::requestFavorites, 3L, TimeUnit.SECONDS);
990 private void updateCustomButtons(final String mac) {
991 String response = executePost(jsonRpcUrl, JSONRPC_STATUS_REQUEST.replace("@@MAC@@", mac));
992 if (response != null) {
993 logger.trace("Status response: {}", response);
994 String likeCommand = null;
995 String unlikeCommand = null;
997 StatusResponseDTO status = gson.fromJson(response, StatusResponseDTO.class);
998 if (status != null && status.result != null && status.result.remoteMeta != null
999 && status.result.remoteMeta.buttons != null) {
1000 ButtonsDTO buttons = status.result.remoteMeta.buttons;
1001 if (buttons.repeat != null && buttons.repeat.isCustom()) {
1002 likeCommand = buttons.repeat.command;
1004 if (buttons.shuffle != null && buttons.shuffle.isCustom()) {
1005 unlikeCommand = buttons.shuffle.command;
1008 } catch (JsonSyntaxException e) {
1009 logger.debug("JsonSyntaxException parsing status response: {}", response, e);
1011 final String like = likeCommand;
1012 final String unlike = unlikeCommand;
1013 updatePlayer(listener -> listener.buttonsChangeEvent(mac, like, unlike));
1017 private String executePost(String url, String content) {
1019 HttpRequestBuilder builder = HttpRequestBuilder.postTo(url)
1020 .withTimeout(Duration.ofSeconds(5))
1021 .withContent(content)
1022 .withHeader("charset", "utf-8")
1023 .withHeader("Content-Type", "application/json");
1025 if (basicAuthorization != null) {
1026 builder = builder.withHeader("Authorization", "Basic " + basicAuthorization);
1029 return builder.getContentAsString();
1030 } catch (IOException e) {
1031 logger.debug("Bridge: IOException on jsonrpc call: {}", e.getMessage(), e);
1038 * Interface to allow us to pass function call-backs to SqueezeBox Player
1041 * @author Dan Cunningham
1044 interface PlayerUpdateEvent {
1045 void updateListener(SqueezeBoxPlayerEventListener listener);
1049 * Update Listeners and child Squeeze Player Things
1053 private void updatePlayer(PlayerUpdateEvent event) {
1054 // update listeners like disco services
1055 synchronized (squeezeBoxPlayerListeners) {
1056 for (SqueezeBoxPlayerEventListener listener : squeezeBoxPlayerListeners) {
1057 event.updateListener(listener);
1060 // update our children
1061 Bridge bridge = getThing();
1063 List<Thing> things = bridge.getThings();
1064 for (Thing thing : things) {
1065 ThingHandler handler = thing.getHandler();
1066 if (handler instanceof SqueezeBoxPlayerEventListener && !squeezeBoxPlayerListeners.contains(handler)) {
1067 event.updateListener((SqueezeBoxPlayerEventListener) handler);
1073 * Adds a listener for player events
1075 * @param squeezeBoxPlayerListener
1078 public boolean registerSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1079 logger.trace("Registering player listener");
1080 return squeezeBoxPlayerListeners.add(squeezeBoxPlayerListener);
1084 * Removes a listener from player events
1086 * @param squeezeBoxPlayerListener
1089 public boolean unregisterSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1090 logger.trace("Unregistering player listener");
1091 return squeezeBoxPlayerListeners.remove(squeezeBoxPlayerListener);
1095 * Removed a player from our known list of players, will populate again if
1100 public void removePlayerCache(String mac) {
1101 players.remove(mac);
1105 * Schedule the server to try and reconnect
1107 private void scheduleReconnect() {
1108 logger.debug("scheduling squeeze server reconnect in {} seconds", RECONNECT_TIME);
1110 reconnectFuture = scheduler.schedule(this::connect, RECONNECT_TIME, TimeUnit.SECONDS);
1114 * Clears our reconnect job if exists
1116 private void cancelReconnect() {
1117 if (reconnectFuture != null) {
1118 reconnectFuture.cancel(true);