]> git.basschouten.com Git - openhab-addons.git/blob
3d31ccedb86ee1fba8053b8b10f1b70e44a5d20e
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.squeezebox.internal.handler;
14
15 import static org.openhab.binding.squeezebox.internal.SqueezeBoxBindingConstants.*;
16
17 import java.io.BufferedReader;
18 import java.io.BufferedWriter;
19 import java.io.IOException;
20 import java.io.InputStreamReader;
21 import java.io.OutputStreamWriter;
22 import java.io.UnsupportedEncodingException;
23 import java.net.Socket;
24 import java.net.URLDecoder;
25 import java.net.URLEncoder;
26 import java.nio.charset.StandardCharsets;
27 import java.time.Duration;
28 import java.util.ArrayList;
29 import java.util.Arrays;
30 import java.util.Base64;
31 import java.util.Collections;
32 import java.util.HashMap;
33 import java.util.List;
34 import java.util.Map;
35 import java.util.Set;
36 import java.util.concurrent.Future;
37 import java.util.concurrent.ScheduledFuture;
38 import java.util.concurrent.TimeUnit;
39
40 import org.apache.commons.lang.StringUtils;
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     /**
305      * Send a generic command to a given player
306      *
307      * @param playerId
308      * @param command
309      */
310     public void playerCommand(String mac, String command) {
311         sendCommand(mac + " " + command);
312     }
313
314     /**
315      * Ask for player list
316      */
317     public void requestPlayers() {
318         sendCommand("players 0");
319     }
320
321     /**
322      * Ask for favorites list
323      */
324     public void requestFavorites() {
325         sendCommand("favorites items 0 100");
326     }
327
328     /**
329      * Login to server
330      */
331     public void login() {
332         if (StringUtils.isEmpty(userId)) {
333             return;
334         }
335         // Create basic auth string for jsonrpc interface
336         basicAuthorization = new String(
337                 Base64.getEncoder().encode((userId + ":" + password).getBytes(StandardCharsets.UTF_8)));
338         logger.debug("Logging into Squeeze Server using userId={}", userId);
339         sendCommand("login " + userId + " " + password);
340     }
341
342     /**
343      * Send a command to the Squeeze Server.
344      */
345     private synchronized void sendCommand(String command) {
346         if (getThing().getStatus() != ThingStatus.ONLINE) {
347             return;
348         }
349
350         if (!isConnected()) {
351             logger.debug("no connection to squeeze server when trying to send command, returning...");
352             return;
353         }
354
355         logger.debug("Sending command: {}", sanitizeCommand(command));
356         try {
357             BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()));
358             writer.write(command + NEW_LINE);
359             writer.flush();
360         } catch (IOException e) {
361             logger.error("Error while sending command to Squeeze Server ({}) ", sanitizeCommand(command), e);
362         }
363     }
364
365     /*
366      * Remove password from login command to prevent it from being logged
367      */
368     String sanitizeCommand(String command) {
369         String sanitizedCommand = command;
370         if (command.startsWith("login")) {
371             sanitizedCommand = command.replace(password, "**********");
372         }
373         return sanitizedCommand;
374     }
375
376     /**
377      * Connects to a SqueezeBox Server
378      */
379     private void connect() {
380         logger.trace("attempting to get a connection to the server");
381         disconnect();
382         SqueezeBoxServerConfig config = getConfigAs(SqueezeBoxServerConfig.class);
383         this.host = config.ipAddress;
384         this.cliport = config.cliport;
385         this.webport = config.webport;
386         this.userId = config.userId;
387         this.password = config.password;
388
389         if (StringUtils.isEmpty(this.host)) {
390             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "host is not set");
391             return;
392         }
393         // Create URL for jsonrpc interface
394         jsonRpcUrl = String.format("http://%s:%d/jsonrpc.js", host, webport);
395
396         try {
397             clientSocket = new Socket(host, cliport);
398         } catch (IOException e) {
399             logger.debug("unable to open socket to server: {}", e.getMessage());
400             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
401             scheduleReconnect();
402             return;
403         }
404
405         try {
406             listener = new SqueezeServerListener();
407             listener.start();
408             logger.debug("listener connection started to server {}:{}", host, cliport);
409         } catch (IllegalThreadStateException e) {
410             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
411         }
412         // Mark the server ONLINE. bridgeStatusChanged will cause the players to come ONLINE
413         updateStatus(ThingStatus.ONLINE);
414     }
415
416     /**
417      * Disconnects from a SqueezeBox Server
418      */
419     private void disconnect() {
420         try {
421             if (listener != null) {
422                 listener.terminate();
423             }
424             if (clientSocket != null) {
425                 clientSocket.close();
426             }
427         } catch (Exception e) {
428             logger.trace("Error attempting to disconnect from Squeeze Server", e);
429             return;
430         } finally {
431             clientSocket = null;
432             listener = null;
433         }
434         players.clear();
435         logger.trace("Squeeze Server connection stopped.");
436     }
437
438     private class SqueezeServerListener extends Thread {
439         private boolean terminate = false;
440
441         public SqueezeServerListener() {
442             super("Squeeze Server Listener");
443         }
444
445         public void terminate() {
446             logger.debug("setting squeeze server listener terminate flag");
447             this.terminate = true;
448         }
449
450         @Override
451         public void run() {
452             BufferedReader reader = null;
453             boolean endOfStream = false;
454             ScheduledFuture<?> requestFavoritesJob = null;
455
456             try {
457                 reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
458                 login();
459                 updateStatus(ThingStatus.ONLINE);
460                 requestPlayers();
461                 requestFavoritesJob = scheduleRequestFavorites();
462                 sendCommand("listen 1");
463
464                 String message = null;
465                 while (!terminate && (message = reader.readLine()) != null) {
466                     // Message is very long and frequent; only show when running at trace level logging
467                     logger.trace("Message received: {}", message);
468
469                     // Fix for some third-party apps that are sending "subscribe playlist"
470                     if (message.startsWith("listen 1") || message.startsWith("subscribe playlist")) {
471                         continue;
472                     }
473
474                     if (message.startsWith("players 0")) {
475                         handlePlayersList(message);
476                     } else if (message.startsWith("favorites")) {
477                         handleFavorites(message);
478                     } else {
479                         handlePlayerUpdate(message);
480                     }
481                 }
482                 if (message == null) {
483                     endOfStream = true;
484                 }
485             } catch (IOException e) {
486                 if (!terminate) {
487                     logger.warn("failed to read line from squeeze server socket: {}", e.getMessage());
488                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
489                     scheduleReconnect();
490                 }
491             } finally {
492                 if (reader != null) {
493                     try {
494                         reader.close();
495                     } catch (IOException e) {
496                         // ignore
497                     }
498                     reader = null;
499                 }
500             }
501
502             // check for end of stream from readLine
503             if (endOfStream && !terminate) {
504                 logger.info("end of stream received from socket during readLine");
505                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
506                         "end of stream on socket read");
507                 scheduleReconnect();
508             }
509             if (requestFavoritesJob != null && !requestFavoritesJob.isDone()) {
510                 requestFavoritesJob.cancel(true);
511                 logger.debug("Canceled request favorites job");
512             }
513             logger.debug("Squeeze Server listener exiting.");
514         }
515
516         private String decode(String raw) {
517             try {
518                 return URLDecoder.decode(raw, UTF8_NAME);
519             } catch (UnsupportedEncodingException e) {
520                 logger.debug("Failed to decode '{}' ", raw, e);
521                 return null;
522             }
523         }
524
525         private String encode(String raw) {
526             try {
527                 return URLEncoder.encode(raw, UTF8_NAME);
528             } catch (UnsupportedEncodingException e) {
529                 logger.debug("Failed to encode '{}' ", raw, e);
530                 return null;
531             }
532         }
533
534         private void handlePlayersList(String message) {
535             // Split out players
536             String[] playersList = message.split("playerindex\\S*\\s");
537             for (String playerParams : playersList) {
538
539                 // For each player, split out parameters and decode parameter
540                 String[] parameterList = playerParams.split("\\s");
541                 for (int i = 0; i < parameterList.length; i++) {
542                     parameterList[i] = decode(parameterList[i]);
543                 }
544
545                 // parse out the MAC address first
546                 String macAddress = null;
547                 for (String parameter : parameterList) {
548                     if (parameter.contains("playerid")) {
549                         macAddress = parameter.substring(parameter.indexOf(":") + 1);
550                         break;
551                     }
552                 }
553
554                 // if none found then ignore this set of params
555                 if (macAddress == null) {
556                     continue;
557                 }
558
559                 final SqueezeBoxPlayer player = new SqueezeBoxPlayer();
560                 player.setMacAddress(macAddress);
561                 // populate the player state
562                 for (String parameter : parameterList) {
563                     if (parameter.startsWith("ip:")) {
564                         player.setIpAddr(parameter.substring(parameter.indexOf(":") + 1));
565                     } else if (parameter.startsWith("uuid:")) {
566                         player.setUuid(parameter.substring(parameter.indexOf(":") + 1));
567                     } else if (parameter.startsWith("name:")) {
568                         player.setName(parameter.substring(parameter.indexOf(":") + 1));
569                     } else if (parameter.startsWith("model:")) {
570                         player.setModel(parameter.substring(parameter.indexOf(":") + 1));
571                     }
572                 }
573
574                 // Save player if we haven't seen it yet
575                 if (!players.containsKey(macAddress)) {
576                     players.put(macAddress, player);
577                     updatePlayer(new PlayerUpdateEvent() {
578                         @Override
579                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
580                             listener.playerAdded(player);
581                         }
582                     });
583                     // tell the server we want to subscribe to player updates
584                     sendCommand(player.getMacAddress() + " status - 1 subscribe:10 tags:yagJlNKjc");
585                 }
586             }
587         }
588
589         private void handlePlayerUpdate(String message) {
590             String[] messageParts = message.split("\\s");
591             if (messageParts.length < 2) {
592                 logger.warn("Invalid message - expecting at least 2 parts. Ignoring.");
593                 return;
594             }
595
596             final String mac = decode(messageParts[0]);
597
598             // get the message type
599             String messageType = messageParts[1];
600             switch (messageType) {
601                 case "status":
602                     handleStatusMessage(mac, messageParts);
603                     break;
604                 case "playlist":
605                     handlePlaylistMessage(mac, messageParts);
606                     break;
607                 case "prefset":
608                     handlePrefsetMessage(mac, messageParts);
609                     break;
610                 case "mixer":
611                     handleMixerMessage(mac, messageParts);
612                     break;
613                 case "ir":
614                     final String ircode = messageParts[2];
615                     updatePlayer(new PlayerUpdateEvent() {
616                         @Override
617                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
618                             listener.irCodeChangeEvent(mac, ircode);
619                         }
620                     });
621                     break;
622                 default:
623                     logger.trace("Unhandled player update message type '{}'.", messageType);
624             }
625         }
626
627         private void handleMixerMessage(String mac, String[] messageParts) {
628             if (messageParts.length < 4) {
629                 return;
630             }
631             String action = messageParts[2];
632
633             switch (action) {
634                 case "volume":
635                     String volumeStringValue = decode(messageParts[3]);
636                     updatePlayer(new PlayerUpdateEvent() {
637                         @Override
638                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
639                             try {
640                                 int volume = Integer.parseInt(volumeStringValue);
641
642                                 // Check if we received a relative volume change, or an absolute
643                                 // volume value.
644                                 if (volumeStringValue.contains("+") || (volumeStringValue.contains("-"))) {
645                                     listener.relativeVolumeChangeEvent(mac, volume);
646                                 } else {
647                                     listener.absoluteVolumeChangeEvent(mac, volume);
648                                 }
649                             } catch (NumberFormatException e) {
650                                 logger.warn("Unable to parse volume [{}] received from mixer message.",
651                                         volumeStringValue, e);
652                             }
653                         }
654                     });
655                     break;
656                 default:
657                     logger.trace("Unhandled mixer message type '{}'", Arrays.toString(messageParts));
658
659             }
660         }
661
662         private void handleStatusMessage(final String mac, String[] messageParts) {
663             String remoteTitle = "", artist = "", album = "", genre = "", year = "";
664             boolean coverart = false;
665             String coverid = null;
666             String artworkUrl = null;
667
668             for (String messagePart : messageParts) {
669                 // Parameter Power
670                 if (messagePart.startsWith("power%3A")) {
671                     final boolean power = "1".matches(messagePart.substring("power%3A".length()));
672                     updatePlayer(new PlayerUpdateEvent() {
673                         @Override
674                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
675                             listener.powerChangeEvent(mac, power);
676                         }
677                     });
678                 }
679                 // Parameter Volume
680                 else if (messagePart.startsWith("mixer%20volume%3A")) {
681                     String value = messagePart.substring("mixer%20volume%3A".length());
682                     final int volume = (int) Double.parseDouble(value);
683                     updatePlayer(new PlayerUpdateEvent() {
684                         @Override
685                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
686                             listener.absoluteVolumeChangeEvent(mac, volume);
687                         }
688                     });
689                 }
690                 // Parameter Mode
691                 else if (messagePart.startsWith("mode%3A")) {
692                     final String mode = messagePart.substring("mode%3A".length());
693                     updatePlayer(new PlayerUpdateEvent() {
694                         @Override
695                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
696                             listener.modeChangeEvent(mac, mode);
697                         }
698                     });
699                 }
700                 // Parameter Playing Time
701                 else if (messagePart.startsWith("time%3A")) {
702                     String value = messagePart.substring("time%3A".length());
703                     final int time = (int) Double.parseDouble(value);
704                     updatePlayer(new PlayerUpdateEvent() {
705                         @Override
706                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
707                             listener.currentPlayingTimeEvent(mac, time);
708                         }
709                     });
710                 }
711                 // Parameter duration
712                 else if (messagePart.startsWith("duration%3A")) {
713                     String value = messagePart.substring("duration%3A".length());
714                     final int duration = (int) Double.parseDouble(value);
715                     updatePlayer(new PlayerUpdateEvent() {
716                         @Override
717                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
718                             listener.durationEvent(mac, duration);
719                         }
720                     });
721                 }
722                 // Parameter Playing Playlist Index
723                 else if (messagePart.startsWith("playlist_cur_index%3A")) {
724                     String value = messagePart.substring("playlist_cur_index%3A".length());
725                     final int index = (int) Double.parseDouble(value);
726                     updatePlayer(new PlayerUpdateEvent() {
727                         @Override
728                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
729                             listener.currentPlaylistIndexEvent(mac, index);
730                         }
731                     });
732                 }
733                 // Parameter Playlist Number Tracks
734                 else if (messagePart.startsWith("playlist_tracks%3A")) {
735                     String value = messagePart.substring("playlist_tracks%3A".length());
736                     final int track = (int) Double.parseDouble(value);
737                     updatePlayer(new PlayerUpdateEvent() {
738                         @Override
739                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
740                             listener.numberPlaylistTracksEvent(mac, track);
741                         }
742                     });
743                 }
744                 // Parameter Playlist Repeat Mode
745                 else if (messagePart.startsWith("playlist%20repeat%3A")) {
746                     String value = messagePart.substring("playlist%20repeat%3A".length());
747                     final int repeat = (int) Double.parseDouble(value);
748                     updatePlayer(new PlayerUpdateEvent() {
749                         @Override
750                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
751                             listener.currentPlaylistRepeatEvent(mac, repeat);
752                         }
753                     });
754                 }
755                 // Parameter Playlist Shuffle Mode
756                 else if (messagePart.startsWith("playlist%20shuffle%3A")) {
757                     String value = messagePart.substring("playlist%20shuffle%3A".length());
758                     final int shuffle = (int) Double.parseDouble(value);
759                     updatePlayer(new PlayerUpdateEvent() {
760                         @Override
761                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
762                             listener.currentPlaylistShuffleEvent(mac, shuffle);
763                         }
764                     });
765                 }
766                 // Parameter Title
767                 else if (messagePart.startsWith("title%3A")) {
768                     final String value = messagePart.substring("title%3A".length());
769                     updatePlayer(new PlayerUpdateEvent() {
770                         @Override
771                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
772                             listener.titleChangeEvent(mac, decode(value));
773                         }
774                     });
775                 }
776                 // Parameter Remote Title (radio)
777                 else if (messagePart.startsWith("remote_title%3A")) {
778                     remoteTitle = messagePart.substring("remote_title%3A".length());
779                 }
780                 // Parameter Artist
781                 else if (messagePart.startsWith("artist%3A")) {
782                     artist = messagePart.substring("artist%3A".length());
783                 }
784                 // Parameter Album
785                 else if (messagePart.startsWith("album%3A")) {
786                     album = messagePart.substring("album%3A".length());
787                 }
788                 // Parameter Genre
789                 else if (messagePart.startsWith("genre%3A")) {
790                     genre = messagePart.substring("genre%3A".length());
791                 }
792                 // Parameter Year
793                 else if (messagePart.startsWith("year%3A")) {
794                     year = messagePart.substring("year%3A".length());
795                 }
796                 // Parameter artwork_url contains url to cover art
797                 else if (messagePart.startsWith("artwork_url%3A")) {
798                     artworkUrl = messagePart.substring("artwork_url%3A".length());
799                 }
800                 // When coverart is "1" coverid will contain a unique coverart id
801                 else if (messagePart.startsWith("coverart%3A")) {
802                     coverart = "1".matches(messagePart.substring("coverart%3A".length()));
803                 }
804                 // Id for covert art (only valid when coverart is "1")
805                 else if (messagePart.startsWith("coverid%3A")) {
806                     coverid = messagePart.substring("coverid%3A".length());
807                 } else {
808                     // Added to be able to see additional status message types
809                     logger.trace("Unhandled status message type '{}'", messagePart);
810                 }
811             }
812
813             final String finalUrl = constructCoverArtUrl(mac, coverart, coverid, artworkUrl);
814             final String finalRemoteTitle = remoteTitle;
815             final String finalArtist = artist;
816             final String finalAlbum = album;
817             final String finalGenre = genre;
818             final String finalYear = year;
819
820             updatePlayer(new PlayerUpdateEvent() {
821                 @Override
822                 public void updateListener(SqueezeBoxPlayerEventListener listener) {
823                     listener.coverArtChangeEvent(mac, finalUrl);
824                     listener.remoteTitleChangeEvent(mac, decode(finalRemoteTitle));
825                     listener.artistChangeEvent(mac, decode(finalArtist));
826                     listener.albumChangeEvent(mac, decode(finalAlbum));
827                     listener.genreChangeEvent(mac, decode(finalGenre));
828                     listener.yearChangeEvent(mac, decode(finalYear));
829                 }
830             });
831         }
832
833         private String constructCoverArtUrl(String mac, boolean coverart, String coverid, String artwork_url) {
834             String hostAndPort;
835             if (StringUtils.isNotEmpty(userId)) {
836                 hostAndPort = "http://" + encode(userId) + ":" + encode(password) + "@" + host + ":" + webport;
837             } else {
838                 hostAndPort = "http://" + host + ":" + webport;
839             }
840
841             // Default to using the convenience artwork URL (should be rare)
842             String url = hostAndPort + "/music/current/cover.jpg?player=" + encode(mac);
843
844             // If additional artwork info provided, use that instead
845             if (coverart) {
846                 if (coverid != null) {
847                     // Typically is used to access cover art of local music files
848                     url = hostAndPort + "/music/" + coverid + "/cover.jpg";
849                 }
850             } else if (artwork_url != null) {
851                 if (artwork_url.startsWith("http")) {
852                     // Typically indicates that cover art is not local to LMS
853                     url = decode(artwork_url);
854                 } else if (artwork_url.startsWith("%2F")) {
855                     // Typically used for default coverart for plugins (e.g. Pandora, etc.)
856                     url = hostAndPort + decode(artwork_url);
857                 } else {
858                     // Another variation of default coverart for plugins (e.g. Pandora, etc.)
859                     url = hostAndPort + "/" + decode(artwork_url);
860                 }
861             }
862             return url;
863         }
864
865         private void handlePlaylistMessage(final String mac, String[] messageParts) {
866             if (messageParts.length < 3) {
867                 return;
868             }
869             String action = messageParts[2];
870             String mode;
871             if (action.equals("newsong")) {
872                 mode = "play";
873                 // Execute in separate thread to avoid delaying listener
874                 scheduler.execute(() -> updateCustomButtons(mac));
875                 // Set the track duration to 0
876                 updatePlayer(new PlayerUpdateEvent() {
877                     @Override
878                     public void updateListener(SqueezeBoxPlayerEventListener listener) {
879                         listener.durationEvent(mac, 0);
880                     }
881                 });
882             } else if (action.equals("pause")) {
883                 if (messageParts.length < 4) {
884                     return;
885                 }
886                 mode = messageParts[3].equals("0") ? "play" : "pause";
887             } else if (action.equals("stop")) {
888                 mode = "stop";
889             } else if ("play".equals(action) && "playlist".equals(messageParts[1])) {
890                 if (messageParts.length >= 4) {
891                     handleSourceChangeMessage(mac, messageParts[3]);
892                 }
893                 return;
894             } else {
895                 // Added so that actions (such as delete, index, jump, open) are not treated as "play"
896                 logger.trace("Unhandled playlist message type '{}'", Arrays.toString(messageParts));
897                 return;
898             }
899             final String value = mode;
900             updatePlayer(new PlayerUpdateEvent() {
901                 @Override
902                 public void updateListener(SqueezeBoxPlayerEventListener listener) {
903                     listener.modeChangeEvent(mac, value);
904                 }
905             });
906         }
907
908         private void handleSourceChangeMessage(String mac, String rawSource) {
909             String source = URLDecoder.decode(rawSource);
910             updatePlayer(new PlayerUpdateEvent() {
911                 @Override
912                 public void updateListener(SqueezeBoxPlayerEventListener listener) {
913                     listener.sourceChangeEvent(mac, source);
914                 }
915             });
916         }
917
918         private void handlePrefsetMessage(final String mac, String[] messageParts) {
919             if (messageParts.length < 5) {
920                 return;
921             }
922             // server prefsets
923             if (messageParts[2].equals("server")) {
924                 String function = messageParts[3];
925                 String value = messageParts[4];
926                 if (function.equals("power")) {
927                     final boolean power = value.equals("1");
928                     updatePlayer(new PlayerUpdateEvent() {
929                         @Override
930                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
931                             listener.powerChangeEvent(mac, power);
932                         }
933                     });
934                 } else if (function.equals("volume")) {
935                     final int volume = (int) Double.parseDouble(value);
936                     updatePlayer(new PlayerUpdateEvent() {
937                         @Override
938                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
939                             listener.absoluteVolumeChangeEvent(mac, volume);
940                         }
941                     });
942                 }
943             }
944         }
945
946         private void handleFavorites(String message) {
947             String[] messageParts = message.split("\\s");
948             if (messageParts.length == 2 && "changed".equals(messageParts[1])) {
949                 // LMS informing us that favorites have changed; request an update to the favorites list
950                 requestFavorites();
951                 return;
952             }
953             if (messageParts.length < 7) {
954                 logger.trace("No favorites in message.");
955                 return;
956             }
957
958             List<Favorite> favorites = new ArrayList<>();
959             Favorite f = null;
960             for (String part : messageParts) {
961                 // Favorite ID (in form xxxxxxxxx.n)
962                 if (part.startsWith("id%3A")) {
963                     String id = part.substring("id%3A".length());
964                     f = new Favorite(id);
965                     favorites.add(f);
966                 }
967                 // Favorite name
968                 else if (part.startsWith("name%3A")) {
969                     String name = decode(part.substring("name%3A".length()));
970                     if (f != null) {
971                         f.name = name;
972                     }
973                 }
974                 // When "1", favorite is a submenu with additional favorites
975                 else if (part.startsWith("hasitems%3A")) {
976                     boolean hasitems = "1".matches(part.substring("hasitems%3A".length()));
977                     if (f != null) {
978                         if (hasitems) {
979                             // Skip subfolders
980                             favorites.remove(f);
981                             f = null;
982                         }
983                     }
984                 }
985             }
986             updatePlayersFavoritesList(favorites);
987             updateChannelFavoritesList(favorites);
988         }
989
990         private void updatePlayersFavoritesList(List<Favorite> favorites) {
991             updatePlayer(new PlayerUpdateEvent() {
992                 @Override
993                 public void updateListener(SqueezeBoxPlayerEventListener listener) {
994                     listener.updateFavoritesListEvent(favorites);
995                 }
996             });
997         }
998
999         private void updateChannelFavoritesList(List<Favorite> favorites) {
1000             final Channel channel = getThing().getChannel(CHANNEL_FAVORITES_LIST);
1001             if (channel == null) {
1002                 logger.debug("Channel {} doesn't exist. Delete & add thing to get channel.", CHANNEL_FAVORITES_LIST);
1003                 return;
1004             }
1005
1006             // Get channel config parameter indicating whether name should be wrapped with double quotes
1007             Boolean includeQuotes = Boolean.FALSE;
1008             if (channel.getConfiguration().containsKey(CHANNEL_CONFIG_QUOTE_LIST)) {
1009                 includeQuotes = (Boolean) channel.getConfiguration().get(CHANNEL_CONFIG_QUOTE_LIST);
1010             }
1011
1012             String quote = includeQuotes.booleanValue() ? "\"" : "";
1013             StringBuilder sb = new StringBuilder();
1014             for (Favorite favorite : favorites) {
1015                 sb.append(favorite.shortId).append("=").append(quote).append(favorite.name.replaceAll(",", ""))
1016                         .append(quote).append(",");
1017             }
1018
1019             if (sb.length() == 0) {
1020                 updateState(CHANNEL_FAVORITES_LIST, UnDefType.NULL);
1021             } else {
1022                 // Drop the last comma
1023                 sb.setLength(sb.length() - 1);
1024                 String favoritesList = sb.toString();
1025                 logger.trace("Updating favorites channel for {} to state {}", getThing().getUID(), favoritesList);
1026                 updateState(CHANNEL_FAVORITES_LIST, new StringType(favoritesList));
1027             }
1028         }
1029
1030         private ScheduledFuture<?> scheduleRequestFavorites() {
1031             // Delay the execution to give the player thing handlers a chance to initialize
1032             return scheduler.schedule(SqueezeBoxServerHandler.this::requestFavorites, 3L, TimeUnit.SECONDS);
1033         }
1034
1035         private void updateCustomButtons(final String mac) {
1036             String response = executePost(jsonRpcUrl, JSONRPC_STATUS_REQUEST.replace("@@MAC@@", mac));
1037             if (response != null) {
1038                 logger.trace("Status response: {}", response);
1039                 String likeCommand = null;
1040                 String unlikeCommand = null;
1041                 try {
1042                     StatusResponseDTO status = gson.fromJson(response, StatusResponseDTO.class);
1043                     if (status != null && status.result != null && status.result.remoteMeta != null
1044                             && status.result.remoteMeta.buttons != null) {
1045                         ButtonsDTO buttons = status.result.remoteMeta.buttons;
1046                         if (buttons.repeat != null && buttons.repeat.isCustom()) {
1047                             likeCommand = buttons.repeat.command;
1048                         }
1049                         if (buttons.shuffle != null && buttons.shuffle.isCustom()) {
1050                             unlikeCommand = buttons.shuffle.command;
1051                         }
1052                     }
1053                 } catch (JsonSyntaxException e) {
1054                     logger.debug("JsonSyntaxException parsing status response: {}", response, e);
1055                 }
1056                 final String like = likeCommand;
1057                 final String unlike = unlikeCommand;
1058                 updatePlayer(new PlayerUpdateEvent() {
1059                     @Override
1060                     public void updateListener(SqueezeBoxPlayerEventListener listener) {
1061                         listener.buttonsChangeEvent(mac, like, unlike);
1062                     }
1063                 });
1064             }
1065         }
1066
1067         private String executePost(String url, String content) {
1068             // @formatter:off
1069             HttpRequestBuilder builder = HttpRequestBuilder.postTo(url)
1070                 .withTimeout(Duration.ofSeconds(5))
1071                 .withContent(content)
1072                 .withHeader("charset", "utf-8")
1073                 .withHeader("Content-Type", "application/json");
1074             // @formatter:on
1075             if (basicAuthorization != null) {
1076                 builder = builder.withHeader("Authorization", "Basic " + basicAuthorization);
1077             }
1078             try {
1079                 return builder.getContentAsString();
1080             } catch (IOException e) {
1081                 logger.debug("Bridge: IOException on jsonrpc call: {}", e.getMessage(), e);
1082                 return null;
1083             }
1084         }
1085     }
1086
1087     /**
1088      * Interface to allow us to pass function call-backs to SqueezeBox Player
1089      * Event Listeners
1090      *
1091      * @author Dan Cunningham
1092      *
1093      */
1094     interface PlayerUpdateEvent {
1095         void updateListener(SqueezeBoxPlayerEventListener listener);
1096     }
1097
1098     /**
1099      * Update Listeners and child Squeeze Player Things
1100      *
1101      * @param event
1102      */
1103     private void updatePlayer(PlayerUpdateEvent event) {
1104         // update listeners like disco services
1105         synchronized (squeezeBoxPlayerListeners) {
1106             for (SqueezeBoxPlayerEventListener listener : squeezeBoxPlayerListeners) {
1107                 event.updateListener(listener);
1108             }
1109         }
1110         // update our children
1111         Bridge bridge = getThing();
1112
1113         List<Thing> things = bridge.getThings();
1114         for (Thing thing : things) {
1115             ThingHandler handler = thing.getHandler();
1116             if (handler instanceof SqueezeBoxPlayerEventListener && !squeezeBoxPlayerListeners.contains(handler)) {
1117                 event.updateListener((SqueezeBoxPlayerEventListener) handler);
1118             }
1119         }
1120     }
1121
1122     /**
1123      * Adds a listener for player events
1124      *
1125      * @param squeezeBoxPlayerListener
1126      * @return
1127      */
1128     public boolean registerSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1129         logger.trace("Registering player listener");
1130         return squeezeBoxPlayerListeners.add(squeezeBoxPlayerListener);
1131     }
1132
1133     /**
1134      * Removes a listener from player events
1135      *
1136      * @param squeezeBoxPlayerListener
1137      * @return
1138      */
1139     public boolean unregisterSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1140         logger.trace("Unregistering player listener");
1141         return squeezeBoxPlayerListeners.remove(squeezeBoxPlayerListener);
1142     }
1143
1144     /**
1145      * Removed a player from our known list of players, will populate again if
1146      * player is seen
1147      *
1148      * @param mac
1149      */
1150     public void removePlayerCache(String mac) {
1151         players.remove(mac);
1152     }
1153
1154     /**
1155      * Schedule the server to try and reconnect
1156      */
1157     private void scheduleReconnect() {
1158         logger.debug("scheduling squeeze server reconnect in {} seconds", RECONNECT_TIME);
1159         cancelReconnect();
1160         reconnectFuture = scheduler.schedule(this::connect, RECONNECT_TIME, TimeUnit.SECONDS);
1161     }
1162
1163     /**
1164      * Clears our reconnect job if exists
1165      */
1166     private void cancelReconnect() {
1167         if (reconnectFuture != null) {
1168             reconnectFuture.cancel(true);
1169         }
1170     }
1171 }