]> git.basschouten.com Git - openhab-addons.git/blob
c4487ffe2f53df3b0f8d5c9bc7216d6c755c257d
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.squeezebox.internal.handler;
14
15 import static org.openhab.binding.squeezebox.internal.SqueezeBoxBindingConstants.*;
16
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;
34 import java.util.Map;
35 import java.util.Set;
36 import java.util.concurrent.Future;
37 import java.util.concurrent.ScheduledFuture;
38 import java.util.concurrent.TimeUnit;
39 import java.util.stream.Collectors;
40
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;
63
64 import com.google.gson.Gson;
65 import com.google.gson.GsonBuilder;
66 import com.google.gson.JsonSyntaxException;
67
68 /**
69  * Handles connection and event handling to a SqueezeBox Server.
70  *
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
83  */
84 public class SqueezeBoxServerHandler extends BaseBridgeHandler {
85     private final Logger logger = LoggerFactory.getLogger(SqueezeBoxServerHandler.class);
86
87     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(SQUEEZEBOXSERVER_THING_TYPE);
88
89     // time in seconds to try to reconnect
90     private static final int RECONNECT_TIME = 60;
91
92     // the value by which the volume is changed by each INCREASE or
93     // DECREASE-Event
94     private static final int VOLUME_CHANGE_SIZE = 5;
95     private static final String NEW_LINE = System.getProperty("line.separator");
96
97     private static final String CHANNEL_CONFIG_QUOTE_LIST = "quoteList";
98
99     private static final String JSONRPC_STATUS_REQUEST = "{\"id\":1,\"method\":\"slim.request\",\"params\":[\"@@MAC@@\",[\"status\",\"-\",\"tags:yagJlNKjcB\"]]}";
100
101     private List<SqueezeBoxPlayerEventListener> squeezeBoxPlayerListeners = Collections
102             .synchronizedList(new ArrayList<>());
103
104     private Map<String, SqueezeBoxPlayer> players = Collections.synchronizedMap(new HashMap<>());
105
106     // client socket and listener thread
107     private Socket clientSocket;
108     private SqueezeServerListener listener;
109     private Future<?> reconnectFuture;
110
111     private String host;
112
113     private int cliport;
114
115     private int webport;
116
117     private String userId;
118
119     private String password;
120
121     private final Gson gson = new GsonBuilder().registerTypeAdapter(ButtonDTO.class, new ButtonDTODeserializer())
122             .create();
123     private String jsonRpcUrl;
124     private String basicAuthorization;
125
126     public SqueezeBoxServerHandler(Bridge bridge) {
127         super(bridge);
128     }
129
130     @Override
131     public void initialize() {
132         logger.debug("initializing server handler for thing {}", getThing().getUID());
133         scheduler.submit(this::connect);
134     }
135
136     @Override
137     public void dispose() {
138         logger.debug("disposing server handler for thing {}", getThing().getUID());
139         cancelReconnect();
140         disconnect();
141     }
142
143     @Override
144     public void handleCommand(ChannelUID channelUID, Command command) {
145     }
146
147     /**
148      * Checks if we have a connection to the Server
149      *
150      * @return
151      */
152     public synchronized boolean isConnected() {
153         if (clientSocket == null) {
154             return false;
155         }
156
157         // NOTE: isConnected() returns true once a connection is made and will
158         // always return true even after the socket is closed
159         // http://stackoverflow.com/questions/10163358/
160         return clientSocket.isConnected() && !clientSocket.isClosed();
161     }
162
163     public void mute(String mac) {
164         sendCommand(mac + " mixer muting 1");
165     }
166
167     public void unMute(String mac) {
168         sendCommand(mac + " mixer muting 0");
169     }
170
171     public void powerOn(String mac) {
172         sendCommand(mac + " power 1");
173     }
174
175     public void powerOff(String mac) {
176         sendCommand(mac + " power 0");
177     }
178
179     public void syncPlayer(String mac, String player2mac) {
180         sendCommand(mac + " sync " + player2mac);
181     }
182
183     public void unSyncPlayer(String mac) {
184         sendCommand(mac + " sync -");
185     }
186
187     public void play(String mac) {
188         sendCommand(mac + " play");
189     }
190
191     public void playUrl(String mac, String url) {
192         sendCommand(mac + " playlist play " + url);
193     }
194
195     public void pause(String mac) {
196         sendCommand(mac + " pause 1");
197     }
198
199     public void unPause(String mac) {
200         sendCommand(mac + " pause 0");
201     }
202
203     public void stop(String mac) {
204         sendCommand(mac + " stop");
205     }
206
207     public void prev(String mac) {
208         sendCommand(mac + " playlist index -1");
209     }
210
211     public void next(String mac) {
212         sendCommand(mac + " playlist index +1");
213     }
214
215     public void clearPlaylist(String mac) {
216         sendCommand(mac + " playlist clear");
217     }
218
219     public void deletePlaylistItem(String mac, int playlistIndex) {
220         sendCommand(mac + " playlist delete " + playlistIndex);
221     }
222
223     public void playPlaylistItem(String mac, int playlistIndex) {
224         sendCommand(mac + " playlist index " + playlistIndex);
225     }
226
227     public void addPlaylistItem(String mac, String url) {
228         addPlaylistItem(mac, url, null);
229     }
230
231     public void addPlaylistItem(String mac, String url, String title) {
232         StringBuilder playlistCommand = new StringBuilder();
233         playlistCommand.append(mac).append(" playlist add ").append(url);
234         if (title != null) {
235             playlistCommand.append(" ").append(title);
236         }
237         sendCommand(playlistCommand.toString());
238     }
239
240     public void setPlayingTime(String mac, int time) {
241         sendCommand(mac + " time " + time);
242     }
243
244     public void setRepeatMode(String mac, int repeatMode) {
245         sendCommand(mac + " playlist repeat " + repeatMode);
246     }
247
248     public void setShuffleMode(String mac, int shuffleMode) {
249         sendCommand(mac + " playlist shuffle " + shuffleMode);
250     }
251
252     public void volumeUp(String mac, int currentVolume) {
253         setVolume(mac, currentVolume + VOLUME_CHANGE_SIZE);
254     }
255
256     public void volumeDown(String mac, int currentVolume) {
257         setVolume(mac, currentVolume - VOLUME_CHANGE_SIZE);
258     }
259
260     public void setVolume(String mac, int volume) {
261         int newVolume = volume;
262         newVolume = Math.min(100, newVolume);
263         newVolume = Math.max(0, newVolume);
264         sendCommand(mac + " mixer volume " + newVolume);
265     }
266
267     public void showString(String mac, String line) {
268         showString(mac, line, 5);
269     }
270
271     public void showString(String mac, String line, int duration) {
272         sendCommand(mac + " show line1:" + line + " duration:" + duration);
273     }
274
275     public void showStringHuge(String mac, String line) {
276         showStringHuge(mac, line, 5);
277     }
278
279     public void showStringHuge(String mac, String line, int duration) {
280         sendCommand(mac + " show line1:" + line + " font:huge duration:" + duration);
281     }
282
283     public void showStrings(String mac, String line1, String line2) {
284         showStrings(mac, line1, line2, 5);
285     }
286
287     public void showStrings(String mac, String line1, String line2, int duration) {
288         sendCommand(mac + " show line1:" + line1 + " line2:" + line2 + " duration:" + duration);
289     }
290
291     public void playFavorite(String mac, String favorite) {
292         sendCommand(mac + " favorites playlist play item_id:" + favorite);
293     }
294
295     public void rate(String mac, String rateCommand) {
296         if (rateCommand != null) {
297             sendCommand(mac + " " + rateCommand);
298         }
299     }
300
301     public void sleep(String mac, Duration sleepDuration) {
302         sendCommand(mac + " sleep " + sleepDuration.toSeconds());
303     }
304
305     /**
306      * Send a generic command to a given player
307      *
308      * @param mac
309      * @param command
310      */
311     public void playerCommand(String mac, String command) {
312         sendCommand(mac + " " + command);
313     }
314
315     /**
316      * Ask for player list
317      */
318     public void requestPlayers() {
319         sendCommand("players 0");
320     }
321
322     /**
323      * Ask for favorites list
324      */
325     public void requestFavorites() {
326         sendCommand("favorites items 0 100");
327     }
328
329     /**
330      * Login to server
331      */
332     public void login() {
333         if (userId.isEmpty()) {
334             return;
335         }
336         // Create basic auth string for jsonrpc interface
337         basicAuthorization = new String(
338                 Base64.getEncoder().encode((userId + ":" + password).getBytes(StandardCharsets.UTF_8)));
339         logger.debug("Logging into Squeeze Server using userId={}", userId);
340         sendCommand("login " + userId + " " + password);
341     }
342
343     /**
344      * Send a command to the Squeeze Server.
345      */
346     private synchronized void sendCommand(String command) {
347         if (getThing().getStatus() != ThingStatus.ONLINE) {
348             return;
349         }
350
351         if (!isConnected()) {
352             logger.debug("no connection to squeeze server when trying to send command, returning...");
353             return;
354         }
355
356         logger.debug("Sending command: {}", sanitizeCommand(command));
357         try {
358             BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()));
359             writer.write(command + NEW_LINE);
360             writer.flush();
361         } catch (IOException e) {
362             logger.error("Error while sending command to Squeeze Server ({}) ", sanitizeCommand(command), e);
363         }
364     }
365
366     /*
367      * Remove password from login command to prevent it from being logged
368      */
369     String sanitizeCommand(String command) {
370         String sanitizedCommand = command;
371         if (command.startsWith("login")) {
372             sanitizedCommand = command.replace(password, "**********");
373         }
374         return sanitizedCommand;
375     }
376
377     /**
378      * Connects to a SqueezeBox Server
379      */
380     private void connect() {
381         logger.trace("attempting to get a connection to the server");
382         disconnect();
383         SqueezeBoxServerConfig config = getConfigAs(SqueezeBoxServerConfig.class);
384         this.host = config.ipAddress;
385         this.cliport = config.cliport;
386         this.webport = config.webport;
387         this.userId = config.userId;
388         this.password = config.password;
389
390         if (host.isEmpty()) {
391             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "host is not set");
392             return;
393         }
394         // Create URL for jsonrpc interface
395         jsonRpcUrl = String.format("http://%s:%d/jsonrpc.js", host, webport);
396
397         try {
398             clientSocket = new Socket(host, cliport);
399         } catch (IOException e) {
400             logger.debug("unable to open socket to server: {}", e.getMessage());
401             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
402             scheduleReconnect();
403             return;
404         }
405
406         try {
407             listener = new SqueezeServerListener();
408             listener.start();
409             logger.debug("listener connection started to server {}:{}", host, cliport);
410         } catch (IllegalThreadStateException e) {
411             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
412         }
413         // Mark the server ONLINE. bridgeStatusChanged will cause the players to come ONLINE
414         updateStatus(ThingStatus.ONLINE);
415     }
416
417     /**
418      * Disconnects from a SqueezeBox Server
419      */
420     private void disconnect() {
421         try {
422             if (listener != null) {
423                 listener.terminate();
424             }
425             if (clientSocket != null) {
426                 clientSocket.close();
427             }
428         } catch (Exception e) {
429             logger.trace("Error attempting to disconnect from Squeeze Server", e);
430             return;
431         } finally {
432             clientSocket = null;
433             listener = null;
434         }
435         players.clear();
436         logger.trace("Squeeze Server connection stopped.");
437     }
438
439     private class SqueezeServerListener extends Thread {
440         private boolean terminate = false;
441
442         public SqueezeServerListener() {
443             super("Squeeze Server Listener");
444         }
445
446         public void terminate() {
447             logger.debug("setting squeeze server listener terminate flag");
448             this.terminate = true;
449         }
450
451         @Override
452         public void run() {
453             BufferedReader reader = null;
454             boolean endOfStream = false;
455             ScheduledFuture<?> requestFavoritesJob = null;
456
457             try {
458                 reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
459                 login();
460                 updateStatus(ThingStatus.ONLINE);
461                 requestPlayers();
462                 requestFavoritesJob = scheduleRequestFavorites();
463                 sendCommand("listen 1");
464
465                 String message = null;
466                 while (!terminate && (message = reader.readLine()) != null) {
467                     // Message is very long and frequent; only show when running at trace level logging
468                     logger.trace("Message received: {}", message);
469
470                     // Fix for some third-party apps that are sending "subscribe playlist"
471                     if (message.startsWith("listen 1") || message.startsWith("subscribe playlist")) {
472                         continue;
473                     }
474
475                     if (message.startsWith("players 0")) {
476                         handlePlayersList(message);
477                     } else if (message.startsWith("favorites")) {
478                         handleFavorites(message);
479                     } else {
480                         handlePlayerUpdate(message);
481                     }
482                 }
483                 if (message == null) {
484                     endOfStream = true;
485                 }
486             } catch (IOException e) {
487                 if (!terminate) {
488                     logger.warn("failed to read line from squeeze server socket: {}", e.getMessage());
489                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
490                     scheduleReconnect();
491                 }
492             } finally {
493                 if (reader != null) {
494                     try {
495                         reader.close();
496                     } catch (IOException e) {
497                         // ignore
498                     }
499                     reader = null;
500                 }
501             }
502
503             // check for end of stream from readLine
504             if (endOfStream && !terminate) {
505                 logger.info("end of stream received from socket during readLine");
506                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
507                         "end of stream on socket read");
508                 scheduleReconnect();
509             }
510             if (requestFavoritesJob != null && !requestFavoritesJob.isDone()) {
511                 requestFavoritesJob.cancel(true);
512                 logger.debug("Canceled request favorites job");
513             }
514             logger.debug("Squeeze Server listener exiting.");
515         }
516
517         private String decode(String raw) {
518             return URLDecoder.decode(raw, StandardCharsets.UTF_8);
519         }
520
521         private String encode(String raw) {
522             return URLEncoder.encode(raw, StandardCharsets.UTF_8);
523         }
524
525         @NonNullByDefault
526         private class KeyValue {
527             final String key;
528             final String value;
529
530             public KeyValue(String key, String value) {
531                 this.key = key;
532                 this.value = value;
533             }
534         };
535
536         private List<KeyValue> decodeKeyValueResponse(String[] response) {
537             final List<KeyValue> keysAndValues = new ArrayList<>();
538             if (response != null) {
539                 for (String line : response) {
540                     final String decoded = decode(line);
541                     int colonPos = decoded.indexOf(":");
542                     if (colonPos < 0) {
543                         continue;
544                     }
545                     keysAndValues.add(new KeyValue(decoded.substring(0, colonPos), decoded.substring(colonPos + 1)));
546                 }
547             }
548             return keysAndValues;
549         }
550
551         private void handlePlayersList(String message) {
552             final Set<String> connectedPlayers = new HashSet<>();
553
554             // Split out players
555             String[] playersList = message.split("playerindex\\S*\\s");
556             for (String playerParams : playersList) {
557                 // For each player, split out parameters and decode parameter
558                 final Map<String, String> keysAndValues = decodeKeyValueResponse(playerParams.split("\\s")).stream()
559                         .collect(Collectors.toMap(kv -> kv.key, kv -> kv.value));
560                 final String macAddress = keysAndValues.get("playerid");
561
562                 // if none found then ignore this set of params
563                 if (macAddress == null) {
564                     continue;
565                 }
566
567                 final SqueezeBoxPlayer player = new SqueezeBoxPlayer(macAddress, keysAndValues.get("name"),
568                         keysAndValues.get("ip"), keysAndValues.get("model"), keysAndValues.get("uuid"));
569                 if ("1".equals(keysAndValues.get("connected"))) {
570                     connectedPlayers.add(macAddress);
571                 }
572
573                 // Save player if we haven't seen it yet
574                 if (!players.containsKey(macAddress)) {
575                     players.put(macAddress, player);
576                     updatePlayer(listener -> listener.playerAdded(player));
577                     // tell the server we want to subscribe to player updates
578                     sendCommand(player.macAddress + " status - 1 subscribe:10 tags:yagJlNKjcA");
579                 }
580             }
581             for (final SqueezeBoxPlayer player : players.values()) {
582                 final boolean connected = connectedPlayers.contains(player.macAddress);
583                 updatePlayer(listener -> listener.connectedStateChangeEvent(player.macAddress, connected));
584             }
585         }
586
587         private void handlePlayerUpdate(String message) {
588             String[] messageParts = message.split("\\s");
589             if (messageParts.length < 2) {
590                 logger.warn("Invalid message - expecting at least 2 parts. Ignoring.");
591                 return;
592             }
593
594             final String mac = decode(messageParts[0]);
595
596             // get the message type
597             String messageType = messageParts[1];
598             switch (messageType) {
599                 case "client":
600                     handleClientMessage(mac, messageParts);
601                     break;
602                 case "status":
603                     handleStatusMessage(mac, messageParts);
604                     break;
605                 case "playlist":
606                     handlePlaylistMessage(mac, messageParts);
607                     break;
608                 case "prefset":
609                     handlePrefsetMessage(mac, messageParts);
610                     break;
611                 case "mixer":
612                     handleMixerMessage(mac, messageParts);
613                     break;
614                 case "ir":
615                     final String ircode = messageParts[2];
616                     updatePlayer(listener -> listener.irCodeChangeEvent(mac, ircode));
617                     break;
618                 default:
619                     logger.trace("Unhandled player update message type '{}'.", messageType);
620             }
621         }
622
623         private void handleMixerMessage(String mac, String[] messageParts) {
624             if (messageParts.length < 4) {
625                 return;
626             }
627             String action = messageParts[2];
628
629             switch (action) {
630                 case "volume":
631                     String volumeStringValue = decode(messageParts[3]);
632                     updatePlayer(listener -> {
633                         try {
634                             int volume = Math.round(Float.parseFloat(volumeStringValue));
635
636                             // Check if we received a relative volume change, or an absolute
637                             // volume value.
638                             if (volumeStringValue.contains("+") || (volumeStringValue.contains("-"))) {
639                                 listener.relativeVolumeChangeEvent(mac, volume);
640                             } else {
641                                 listener.absoluteVolumeChangeEvent(mac, volume);
642                             }
643                         } catch (NumberFormatException e) {
644                             logger.warn("Unable to parse volume [{}] received from mixer message.", volumeStringValue,
645                                     e);
646                         }
647                     });
648                     break;
649                 default:
650                     logger.trace("Unhandled mixer message type '{}'", Arrays.toString(messageParts));
651
652             }
653         }
654
655         private void handleClientMessage(final String mac, String[] messageParts) {
656             if (messageParts.length < 3) {
657                 return;
658             }
659
660             String action = messageParts[2];
661             final boolean connected;
662
663             if ("new".equals(action) || "reconnect".equals(action)) {
664                 connected = true;
665             } else if ("disconnect".equals(action) || "forget".equals(action)) {
666                 connected = false;
667             } else {
668                 logger.trace("Unhandled client message type '{}'", Arrays.toString(messageParts));
669                 return;
670             }
671
672             updatePlayer(listener -> listener.connectedStateChangeEvent(mac, connected));
673         }
674
675         private void handleStatusMessage(final String mac, String[] messageParts) {
676             String remoteTitle = "", artist = "", album = "", genre = "", year = "", albumArtist = "", trackArtist = "",
677                     band = "", composer = "", conductor = "";
678             boolean coverart = false;
679             String coverid = null;
680             String artworkUrl = null;
681
682             for (KeyValue entry : decodeKeyValueResponse(messageParts)) {
683                 try {
684                     // Parameter Power
685                     if ("power".equals(entry.key)) {
686                         final boolean power = "1".equals(entry.value);
687                         updatePlayer(listener -> listener.powerChangeEvent(mac, power));
688                     }
689                     // Parameter Volume
690                     else if ("mixer volume".equals(entry.key)) {
691                         final int volume = (int) Double.parseDouble(entry.value);
692                         updatePlayer(listener -> listener.absoluteVolumeChangeEvent(mac, volume));
693                     }
694                     // Parameter Mode
695                     else if ("mode".equals(entry.key)) {
696                         updatePlayer(listener -> listener.modeChangeEvent(mac, entry.value));
697                     }
698                     // Parameter Playing Time
699                     else if ("time".equals(entry.key) && !"N/A".equals(entry.value)) {
700                         final int time = (int) Double.parseDouble(entry.value);
701                         updatePlayer(listener -> listener.currentPlayingTimeEvent(mac, time));
702                     }
703                     // Parameter duration
704                     else if ("duration".equals(entry.key)) {
705                         final int duration = (int) Double.parseDouble(entry.value);
706                         updatePlayer(listener -> listener.durationEvent(mac, duration));
707                     }
708                     // Parameter Playing Playlist Index
709                     else if ("playlist_cur_index".equals(entry.key)) {
710                         final int index = (int) Double.parseDouble(entry.value);
711                         updatePlayer(listener -> listener.currentPlaylistIndexEvent(mac, index));
712                     }
713                     // Parameter Playlist Number Tracks
714                     else if ("playlist_tracks".equals(entry.key)) {
715                         final int track = (int) Double.parseDouble(entry.value);
716                         updatePlayer(listener -> listener.numberPlaylistTracksEvent(mac, track));
717                     }
718                     // Parameter Playlist Repeat Mode
719                     else if ("playlist repeat".equals(entry.key)) {
720                         final int repeat = (int) Double.parseDouble(entry.value);
721                         updatePlayer(listener -> listener.currentPlaylistRepeatEvent(mac, repeat));
722                     }
723                     // Parameter Playlist Shuffle Mode
724                     else if ("playlist shuffle".equals(entry.key)) {
725                         final int shuffle = (int) Double.parseDouble(entry.value);
726                         updatePlayer(listener -> listener.currentPlaylistShuffleEvent(mac, shuffle));
727                     }
728                     // Parameter Title
729                     else if ("title".equals(entry.key)) {
730                         updatePlayer(listener -> listener.titleChangeEvent(mac, entry.value));
731                     }
732                     // Parameter Remote Title (radio)
733                     else if ("remote_title".equals(entry.key)) {
734                         remoteTitle = entry.value;
735                     }
736                     // Parameter Artist
737                     else if ("artist".equals(entry.key)) {
738                         artist = entry.value;
739                     }
740                     // Parameter Album
741                     else if ("album".equals(entry.key)) {
742                         album = entry.value;
743                     }
744                     // Parameter Genre
745                     else if ("genre".equals(entry.key)) {
746                         genre = entry.value;
747                     }
748                     // Parameter Album Artist
749                     else if ("albumartist".equals(entry.key)) {
750                         albumArtist = entry.value;
751                     }
752                     // Parameter Track Artist
753                     else if ("trackartist".equals(entry.key)) {
754                         trackArtist = entry.value;
755                     }
756                     // Parameter Band
757                     else if ("band".equals(entry.key)) {
758                         band = entry.value;
759                     }
760                     // Parameter Composer
761                     else if ("composer".equals(entry.key)) {
762                         composer = entry.value;
763                     }
764                     // Parameter Conductor
765                     else if ("conductor".equals(entry.key)) {
766                         conductor = entry.value;
767                     }
768                     // Parameter Year
769                     else if ("year".equals(entry.key)) {
770                         year = entry.value;
771                     }
772                     // Parameter artwork_url contains url to cover art
773                     else if ("artwork_url".equals(entry.key)) {
774                         artworkUrl = entry.value;
775                     }
776                     // When coverart is "1" coverid will contain a unique coverart id
777                     else if ("coverart".equals(entry.key)) {
778                         coverart = "1".equals(entry.value);
779                     }
780                     // Id for covert art (only valid when coverart is "1")
781                     else if ("coverid".equals(entry.key)) {
782                         coverid = entry.value;
783                     } else {
784                         // Added to be able to see additional status message types
785                         logger.trace("Unhandled status message type '{}' (value '{}')", entry.key, entry.value);
786                     }
787                 } catch (NumberFormatException e) {
788                     // Skip this key/value
789                     logger.debug("Cannot parse number in status message: key '{}', value '{}'", entry.key, entry.value);
790                 }
791             }
792
793             final String finalUrl = constructCoverArtUrl(mac, coverart, coverid, artworkUrl);
794             final String finalRemoteTitle = remoteTitle;
795             final String finalArtist = artist;
796             final String finalAlbum = album;
797             final String finalGenre = genre;
798             final String finalYear = year;
799             final String finalAlbumArtist = albumArtist;
800             final String finalTrackArtist = trackArtist;
801             final String finalBand = band;
802             final String finalComposer = composer;
803             final String finalConductor = conductor;
804
805             updatePlayer(listener -> {
806                 listener.coverArtChangeEvent(mac, finalUrl);
807                 listener.remoteTitleChangeEvent(mac, finalRemoteTitle);
808                 listener.artistChangeEvent(mac, finalArtist);
809                 listener.albumChangeEvent(mac, finalAlbum);
810                 listener.genreChangeEvent(mac, finalGenre);
811                 listener.yearChangeEvent(mac, finalYear);
812                 listener.albumArtistChangeEvent(mac, finalAlbumArtist);
813                 listener.trackArtistChangeEvent(mac, finalTrackArtist);
814                 listener.bandChangeEvent(mac, finalBand);
815                 listener.composerChangeEvent(mac, finalComposer);
816                 listener.conductorChangeEvent(mac, finalConductor);
817             });
818         }
819
820         private String constructCoverArtUrl(String mac, boolean coverart, String coverid, String artwork_url) {
821             String hostAndPort;
822             if (!userId.isEmpty()) {
823                 hostAndPort = "http://" + encode(userId) + ":" + encode(password) + "@" + host + ":" + webport;
824             } else {
825                 hostAndPort = "http://" + host + ":" + webport;
826             }
827
828             // Default to using the convenience artwork URL (should be rare)
829             String url = hostAndPort + "/music/current/cover.jpg?player=" + encode(mac);
830
831             // If additional artwork info provided, use that instead
832             if (coverart) {
833                 if (coverid != null) {
834                     // Typically is used to access cover art of local music files
835                     url = hostAndPort + "/music/" + coverid + "/cover.jpg";
836                 }
837             } else if (artwork_url != null) {
838                 if (artwork_url.startsWith("http")) {
839                     // Typically indicates that cover art is not local to LMS
840                     url = artwork_url;
841                 } else if (artwork_url.startsWith("/")) {
842                     // Typically used for default coverart for plugins (e.g. Pandora, etc.)
843                     url = hostAndPort + artwork_url;
844                 } else {
845                     // Another variation of default coverart for plugins (e.g. Pandora, etc.)
846                     url = hostAndPort + "/" + artwork_url;
847                 }
848             }
849             return url;
850         }
851
852         private void handlePlaylistMessage(final String mac, String[] messageParts) {
853             if (messageParts.length < 3) {
854                 return;
855             }
856             String action = messageParts[2];
857             String mode;
858             if ("newsong".equals(action)) {
859                 mode = "play";
860                 // Execute in separate thread to avoid delaying listener
861                 scheduler.execute(() -> updateCustomButtons(mac));
862                 // Set the track duration to 0
863                 updatePlayer(listener -> listener.durationEvent(mac, 0));
864             } else if ("pause".equals(action)) {
865                 if (messageParts.length < 4) {
866                     return;
867                 }
868                 mode = "0".equals(messageParts[3]) ? "play" : "pause";
869             } else if ("stop".equals(action)) {
870                 mode = "stop";
871             } else if ("play".equals(action) && "playlist".equals(messageParts[1])) {
872                 if (messageParts.length >= 4) {
873                     handleSourceChangeMessage(mac, messageParts[3]);
874                 }
875                 return;
876             } else {
877                 // Added so that actions (such as delete, index, jump, open) are not treated as "play"
878                 logger.trace("Unhandled playlist message type '{}'", Arrays.toString(messageParts));
879                 return;
880             }
881             final String value = mode;
882             updatePlayer(listener -> listener.modeChangeEvent(mac, value));
883         }
884
885         private void handleSourceChangeMessage(String mac, String rawSource) {
886             String source = URLDecoder.decode(rawSource, StandardCharsets.UTF_8);
887             updatePlayer(listener -> listener.sourceChangeEvent(mac, source));
888         }
889
890         private void handlePrefsetMessage(final String mac, String[] messageParts) {
891             if (messageParts.length < 5) {
892                 return;
893             }
894             // server prefsets
895             if ("server".equals(messageParts[2])) {
896                 String function = messageParts[3];
897                 String value = messageParts[4];
898                 if ("power".equals(function)) {
899                     final boolean power = "1".equals(value);
900                     updatePlayer(listener -> listener.powerChangeEvent(mac, power));
901                 } else if ("volume".equals(function)) {
902                     final int volume = (int) Double.parseDouble(value);
903                     updatePlayer(listener -> listener.absoluteVolumeChangeEvent(mac, volume));
904                 }
905             }
906         }
907
908         private void handleFavorites(String message) {
909             String[] messageParts = message.split("\\s");
910             if (messageParts.length == 2 && "changed".equals(messageParts[1])) {
911                 // LMS informing us that favorites have changed; request an update to the favorites list
912                 requestFavorites();
913                 return;
914             }
915             if (messageParts.length < 7) {
916                 logger.trace("No favorites in message.");
917                 return;
918             }
919
920             List<Favorite> favorites = new ArrayList<>();
921             Favorite f = null;
922             boolean isTypePlaylist = false;
923             for (KeyValue entry : decodeKeyValueResponse(messageParts)) {
924                 // Favorite ID (in form xxxxxxxxx.n)
925                 if ("id".equals(entry.key)) {
926                     f = new Favorite(entry.value);
927                     favorites.add(f);
928                     isTypePlaylist = false;
929                 }
930                 // Favorite name
931                 else if ("name".equals(entry.key)) {
932                     f.name = entry.value;
933                 } else if ("type".equals(entry.key) && "playlist".equals(entry.value)) {
934                     isTypePlaylist = true;
935                 }
936                 // When "1", favorite is a submenu with additional favorites
937                 else if ("hasitems".equals(entry.key)) {
938                     boolean hasitems = "1".equals(entry.value);
939                     if (f != null) {
940                         // Except for some favorites (e.g. Spotify) use hasitems:1 and type:playlist
941                         if (hasitems && !isTypePlaylist) {
942                             // Skip subfolders
943                             favorites.remove(f);
944                             f = null;
945                         }
946                     }
947                 }
948             }
949             updatePlayer(listener -> listener.updateFavoritesListEvent(favorites));
950             updateChannelFavoritesList(favorites);
951         }
952
953         private void updateChannelFavoritesList(List<Favorite> favorites) {
954             final Channel channel = getThing().getChannel(CHANNEL_FAVORITES_LIST);
955             if (channel == null) {
956                 logger.debug("Channel {} doesn't exist. Delete & add thing to get channel.", CHANNEL_FAVORITES_LIST);
957                 return;
958             }
959
960             // Get channel config parameter indicating whether name should be wrapped with double quotes
961             Boolean includeQuotes = Boolean.FALSE;
962             if (channel.getConfiguration().containsKey(CHANNEL_CONFIG_QUOTE_LIST)) {
963                 includeQuotes = (Boolean) channel.getConfiguration().get(CHANNEL_CONFIG_QUOTE_LIST);
964             }
965
966             String quote = includeQuotes.booleanValue() ? "\"" : "";
967             StringBuilder sb = new StringBuilder();
968             for (Favorite favorite : favorites) {
969                 sb.append(favorite.shortId).append("=").append(quote).append(favorite.name.replace(",", ""))
970                         .append(quote).append(",");
971             }
972
973             if (sb.length() == 0) {
974                 updateState(CHANNEL_FAVORITES_LIST, UnDefType.NULL);
975             } else {
976                 // Drop the last comma
977                 sb.setLength(sb.length() - 1);
978                 String favoritesList = sb.toString();
979                 logger.trace("Updating favorites channel for {} to state {}", getThing().getUID(), favoritesList);
980                 updateState(CHANNEL_FAVORITES_LIST, new StringType(favoritesList));
981             }
982         }
983
984         private ScheduledFuture<?> scheduleRequestFavorites() {
985             // Delay the execution to give the player thing handlers a chance to initialize
986             return scheduler.schedule(SqueezeBoxServerHandler.this::requestFavorites, 3L, TimeUnit.SECONDS);
987         }
988
989         private void updateCustomButtons(final String mac) {
990             String response = executePost(jsonRpcUrl, JSONRPC_STATUS_REQUEST.replace("@@MAC@@", mac));
991             if (response != null) {
992                 logger.trace("Status response: {}", response);
993                 String likeCommand = null;
994                 String unlikeCommand = null;
995                 try {
996                     StatusResponseDTO status = gson.fromJson(response, StatusResponseDTO.class);
997                     if (status != null && status.result != null && status.result.remoteMeta != null
998                             && status.result.remoteMeta.buttons != null) {
999                         ButtonsDTO buttons = status.result.remoteMeta.buttons;
1000                         if (buttons.repeat != null && buttons.repeat.isCustom()) {
1001                             likeCommand = buttons.repeat.command;
1002                         }
1003                         if (buttons.shuffle != null && buttons.shuffle.isCustom()) {
1004                             unlikeCommand = buttons.shuffle.command;
1005                         }
1006                     }
1007                 } catch (JsonSyntaxException e) {
1008                     logger.debug("JsonSyntaxException parsing status response: {}", response, e);
1009                 }
1010                 final String like = likeCommand;
1011                 final String unlike = unlikeCommand;
1012                 updatePlayer(listener -> listener.buttonsChangeEvent(mac, like, unlike));
1013             }
1014         }
1015
1016         private String executePost(String url, String content) {
1017             // @formatter:off
1018             HttpRequestBuilder builder = HttpRequestBuilder.postTo(url)
1019                 .withTimeout(Duration.ofSeconds(5))
1020                 .withContent(content)
1021                 .withHeader("charset", "utf-8")
1022                 .withHeader("Content-Type", "application/json");
1023             // @formatter:on
1024             if (basicAuthorization != null) {
1025                 builder = builder.withHeader("Authorization", "Basic " + basicAuthorization);
1026             }
1027             try {
1028                 return builder.getContentAsString();
1029             } catch (IOException e) {
1030                 logger.debug("Bridge: IOException on jsonrpc call: {}", e.getMessage(), e);
1031                 return null;
1032             }
1033         }
1034     }
1035
1036     /**
1037      * Interface to allow us to pass function call-backs to SqueezeBox Player
1038      * Event Listeners
1039      *
1040      * @author Dan Cunningham
1041      *
1042      */
1043     interface PlayerUpdateEvent {
1044         void updateListener(SqueezeBoxPlayerEventListener listener);
1045     }
1046
1047     /**
1048      * Update Listeners and child Squeeze Player Things
1049      *
1050      * @param event
1051      */
1052     private void updatePlayer(PlayerUpdateEvent event) {
1053         // update listeners like disco services
1054         synchronized (squeezeBoxPlayerListeners) {
1055             for (SqueezeBoxPlayerEventListener listener : squeezeBoxPlayerListeners) {
1056                 event.updateListener(listener);
1057             }
1058         }
1059         // update our children
1060         Bridge bridge = getThing();
1061
1062         List<Thing> things = bridge.getThings();
1063         for (Thing thing : things) {
1064             ThingHandler handler = thing.getHandler();
1065             if (handler instanceof SqueezeBoxPlayerEventListener playerEventListener
1066                     && !squeezeBoxPlayerListeners.contains(handler)) {
1067                 event.updateListener(playerEventListener);
1068             }
1069         }
1070     }
1071
1072     /**
1073      * Adds a listener for player events
1074      *
1075      * @param squeezeBoxPlayerListener
1076      * @return
1077      */
1078     public boolean registerSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1079         logger.trace("Registering player listener");
1080         return squeezeBoxPlayerListeners.add(squeezeBoxPlayerListener);
1081     }
1082
1083     /**
1084      * Removes a listener from player events
1085      *
1086      * @param squeezeBoxPlayerListener
1087      * @return
1088      */
1089     public boolean unregisterSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1090         logger.trace("Unregistering player listener");
1091         return squeezeBoxPlayerListeners.remove(squeezeBoxPlayerListener);
1092     }
1093
1094     /**
1095      * Removed a player from our known list of players, will populate again if
1096      * player is seen
1097      *
1098      * @param mac
1099      */
1100     public void removePlayerCache(String mac) {
1101         players.remove(mac);
1102     }
1103
1104     /**
1105      * Schedule the server to try and reconnect
1106      */
1107     private void scheduleReconnect() {
1108         logger.debug("scheduling squeeze server reconnect in {} seconds", RECONNECT_TIME);
1109         cancelReconnect();
1110         reconnectFuture = scheduler.schedule(this::connect, RECONNECT_TIME, TimeUnit.SECONDS);
1111     }
1112
1113     /**
1114      * Clears our reconnect job if exists
1115      */
1116     private void cancelReconnect() {
1117         if (reconnectFuture != null) {
1118             reconnectFuture.cancel(true);
1119         }
1120     }
1121 }