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