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