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