]> git.basschouten.com Git - openhab-addons.git/blob
a8e8c1d92657b470292df95d2f3c5eb03ca446ea
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.squeezebox.internal.handler;
14
15 import static org.openhab.binding.squeezebox.internal.SqueezeBoxBindingConstants.*;
16
17 import java.io.BufferedReader;
18 import java.io.BufferedWriter;
19 import java.io.IOException;
20 import java.io.InputStreamReader;
21 import java.io.OutputStreamWriter;
22 import java.io.UnsupportedEncodingException;
23 import java.net.Socket;
24 import java.net.URLDecoder;
25 import java.net.URLEncoder;
26 import java.nio.charset.StandardCharsets;
27 import java.time.Duration;
28 import java.util.ArrayList;
29 import java.util.Arrays;
30 import java.util.Base64;
31 import java.util.Collections;
32 import java.util.HashMap;
33 import java.util.List;
34 import java.util.Map;
35 import java.util.Set;
36 import java.util.concurrent.Future;
37 import java.util.concurrent.ScheduledFuture;
38 import java.util.concurrent.TimeUnit;
39
40 import org.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     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 (StringUtils.isEmpty(userId)) {
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 (StringUtils.isEmpty(this.host)) {
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             // Split out players
540             String[] playersList = message.split("playerindex\\S*\\s");
541             for (String playerParams : playersList) {
542
543                 // For each player, split out parameters and decode parameter
544                 String[] parameterList = playerParams.split("\\s");
545                 for (int i = 0; i < parameterList.length; i++) {
546                     parameterList[i] = decode(parameterList[i]);
547                 }
548
549                 // parse out the MAC address first
550                 String macAddress = null;
551                 for (String parameter : parameterList) {
552                     if (parameter.contains("playerid")) {
553                         macAddress = parameter.substring(parameter.indexOf(":") + 1);
554                         break;
555                     }
556                 }
557
558                 // if none found then ignore this set of params
559                 if (macAddress == null) {
560                     continue;
561                 }
562
563                 final SqueezeBoxPlayer player = new SqueezeBoxPlayer();
564                 player.setMacAddress(macAddress);
565                 // populate the player state
566                 for (String parameter : parameterList) {
567                     if (parameter.startsWith("ip:")) {
568                         player.setIpAddr(parameter.substring(parameter.indexOf(":") + 1));
569                     } else if (parameter.startsWith("uuid:")) {
570                         player.setUuid(parameter.substring(parameter.indexOf(":") + 1));
571                     } else if (parameter.startsWith("name:")) {
572                         player.setName(parameter.substring(parameter.indexOf(":") + 1));
573                     } else if (parameter.startsWith("model:")) {
574                         player.setModel(parameter.substring(parameter.indexOf(":") + 1));
575                     }
576                 }
577
578                 // Save player if we haven't seen it yet
579                 if (!players.containsKey(macAddress)) {
580                     players.put(macAddress, player);
581                     updatePlayer(new PlayerUpdateEvent() {
582                         @Override
583                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
584                             listener.playerAdded(player);
585                         }
586                     });
587                     // tell the server we want to subscribe to player updates
588                     sendCommand(player.getMacAddress() + " status - 1 subscribe:10 tags:yagJlNKjc");
589                 }
590             }
591         }
592
593         private void handlePlayerUpdate(String message) {
594             String[] messageParts = message.split("\\s");
595             if (messageParts.length < 2) {
596                 logger.warn("Invalid message - expecting at least 2 parts. Ignoring.");
597                 return;
598             }
599
600             final String mac = decode(messageParts[0]);
601
602             // get the message type
603             String messageType = messageParts[1];
604             switch (messageType) {
605                 case "status":
606                     handleStatusMessage(mac, messageParts);
607                     break;
608                 case "playlist":
609                     handlePlaylistMessage(mac, messageParts);
610                     break;
611                 case "prefset":
612                     handlePrefsetMessage(mac, messageParts);
613                     break;
614                 case "mixer":
615                     handleMixerMessage(mac, messageParts);
616                     break;
617                 case "ir":
618                     final String ircode = messageParts[2];
619                     updatePlayer(new PlayerUpdateEvent() {
620                         @Override
621                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
622                             listener.irCodeChangeEvent(mac, ircode);
623                         }
624                     });
625                     break;
626                 default:
627                     logger.trace("Unhandled player update message type '{}'.", messageType);
628             }
629         }
630
631         private void handleMixerMessage(String mac, String[] messageParts) {
632             if (messageParts.length < 4) {
633                 return;
634             }
635             String action = messageParts[2];
636
637             switch (action) {
638                 case "volume":
639                     String volumeStringValue = decode(messageParts[3]);
640                     updatePlayer(new PlayerUpdateEvent() {
641                         @Override
642                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
643                             try {
644                                 int volume = Integer.parseInt(volumeStringValue);
645
646                                 // Check if we received a relative volume change, or an absolute
647                                 // volume value.
648                                 if (volumeStringValue.contains("+") || (volumeStringValue.contains("-"))) {
649                                     listener.relativeVolumeChangeEvent(mac, volume);
650                                 } else {
651                                     listener.absoluteVolumeChangeEvent(mac, volume);
652                                 }
653                             } catch (NumberFormatException e) {
654                                 logger.warn("Unable to parse volume [{}] received from mixer message.",
655                                         volumeStringValue, e);
656                             }
657                         }
658                     });
659                     break;
660                 default:
661                     logger.trace("Unhandled mixer message type '{}'", Arrays.toString(messageParts));
662
663             }
664         }
665
666         private void handleStatusMessage(final String mac, String[] messageParts) {
667             String remoteTitle = "", artist = "", album = "", genre = "", year = "";
668             boolean coverart = false;
669             String coverid = null;
670             String artworkUrl = null;
671
672             for (String messagePart : messageParts) {
673                 // Parameter Power
674                 if (messagePart.startsWith("power%3A")) {
675                     final boolean power = "1".matches(messagePart.substring("power%3A".length()));
676                     updatePlayer(new PlayerUpdateEvent() {
677                         @Override
678                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
679                             listener.powerChangeEvent(mac, power);
680                         }
681                     });
682                 }
683                 // Parameter Volume
684                 else if (messagePart.startsWith("mixer%20volume%3A")) {
685                     String value = messagePart.substring("mixer%20volume%3A".length());
686                     final int volume = (int) Double.parseDouble(value);
687                     updatePlayer(new PlayerUpdateEvent() {
688                         @Override
689                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
690                             listener.absoluteVolumeChangeEvent(mac, volume);
691                         }
692                     });
693                 }
694                 // Parameter Mode
695                 else if (messagePart.startsWith("mode%3A")) {
696                     final String mode = messagePart.substring("mode%3A".length());
697                     updatePlayer(new PlayerUpdateEvent() {
698                         @Override
699                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
700                             listener.modeChangeEvent(mac, mode);
701                         }
702                     });
703                 }
704                 // Parameter Playing Time
705                 else if (messagePart.startsWith("time%3A")) {
706                     String value = messagePart.substring("time%3A".length());
707                     final int time = (int) Double.parseDouble(value);
708                     updatePlayer(new PlayerUpdateEvent() {
709                         @Override
710                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
711                             listener.currentPlayingTimeEvent(mac, time);
712                         }
713                     });
714                 }
715                 // Parameter duration
716                 else if (messagePart.startsWith("duration%3A")) {
717                     String value = messagePart.substring("duration%3A".length());
718                     final int duration = (int) Double.parseDouble(value);
719                     updatePlayer(new PlayerUpdateEvent() {
720                         @Override
721                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
722                             listener.durationEvent(mac, duration);
723                         }
724                     });
725                 }
726                 // Parameter Playing Playlist Index
727                 else if (messagePart.startsWith("playlist_cur_index%3A")) {
728                     String value = messagePart.substring("playlist_cur_index%3A".length());
729                     final int index = (int) Double.parseDouble(value);
730                     updatePlayer(new PlayerUpdateEvent() {
731                         @Override
732                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
733                             listener.currentPlaylistIndexEvent(mac, index);
734                         }
735                     });
736                 }
737                 // Parameter Playlist Number Tracks
738                 else if (messagePart.startsWith("playlist_tracks%3A")) {
739                     String value = messagePart.substring("playlist_tracks%3A".length());
740                     final int track = (int) Double.parseDouble(value);
741                     updatePlayer(new PlayerUpdateEvent() {
742                         @Override
743                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
744                             listener.numberPlaylistTracksEvent(mac, track);
745                         }
746                     });
747                 }
748                 // Parameter Playlist Repeat Mode
749                 else if (messagePart.startsWith("playlist%20repeat%3A")) {
750                     String value = messagePart.substring("playlist%20repeat%3A".length());
751                     final int repeat = (int) Double.parseDouble(value);
752                     updatePlayer(new PlayerUpdateEvent() {
753                         @Override
754                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
755                             listener.currentPlaylistRepeatEvent(mac, repeat);
756                         }
757                     });
758                 }
759                 // Parameter Playlist Shuffle Mode
760                 else if (messagePart.startsWith("playlist%20shuffle%3A")) {
761                     String value = messagePart.substring("playlist%20shuffle%3A".length());
762                     final int shuffle = (int) Double.parseDouble(value);
763                     updatePlayer(new PlayerUpdateEvent() {
764                         @Override
765                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
766                             listener.currentPlaylistShuffleEvent(mac, shuffle);
767                         }
768                     });
769                 }
770                 // Parameter Title
771                 else if (messagePart.startsWith("title%3A")) {
772                     final String value = messagePart.substring("title%3A".length());
773                     updatePlayer(new PlayerUpdateEvent() {
774                         @Override
775                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
776                             listener.titleChangeEvent(mac, decode(value));
777                         }
778                     });
779                 }
780                 // Parameter Remote Title (radio)
781                 else if (messagePart.startsWith("remote_title%3A")) {
782                     remoteTitle = messagePart.substring("remote_title%3A".length());
783                 }
784                 // Parameter Artist
785                 else if (messagePart.startsWith("artist%3A")) {
786                     artist = messagePart.substring("artist%3A".length());
787                 }
788                 // Parameter Album
789                 else if (messagePart.startsWith("album%3A")) {
790                     album = messagePart.substring("album%3A".length());
791                 }
792                 // Parameter Genre
793                 else if (messagePart.startsWith("genre%3A")) {
794                     genre = messagePart.substring("genre%3A".length());
795                 }
796                 // Parameter Year
797                 else if (messagePart.startsWith("year%3A")) {
798                     year = messagePart.substring("year%3A".length());
799                 }
800                 // Parameter artwork_url contains url to cover art
801                 else if (messagePart.startsWith("artwork_url%3A")) {
802                     artworkUrl = messagePart.substring("artwork_url%3A".length());
803                 }
804                 // When coverart is "1" coverid will contain a unique coverart id
805                 else if (messagePart.startsWith("coverart%3A")) {
806                     coverart = "1".matches(messagePart.substring("coverart%3A".length()));
807                 }
808                 // Id for covert art (only valid when coverart is "1")
809                 else if (messagePart.startsWith("coverid%3A")) {
810                     coverid = messagePart.substring("coverid%3A".length());
811                 } else {
812                     // Added to be able to see additional status message types
813                     logger.trace("Unhandled status message type '{}'", messagePart);
814                 }
815             }
816
817             final String finalUrl = constructCoverArtUrl(mac, coverart, coverid, artworkUrl);
818             final String finalRemoteTitle = remoteTitle;
819             final String finalArtist = artist;
820             final String finalAlbum = album;
821             final String finalGenre = genre;
822             final String finalYear = year;
823
824             updatePlayer(new PlayerUpdateEvent() {
825                 @Override
826                 public void updateListener(SqueezeBoxPlayerEventListener listener) {
827                     listener.coverArtChangeEvent(mac, finalUrl);
828                     listener.remoteTitleChangeEvent(mac, decode(finalRemoteTitle));
829                     listener.artistChangeEvent(mac, decode(finalArtist));
830                     listener.albumChangeEvent(mac, decode(finalAlbum));
831                     listener.genreChangeEvent(mac, decode(finalGenre));
832                     listener.yearChangeEvent(mac, decode(finalYear));
833                 }
834             });
835         }
836
837         private String constructCoverArtUrl(String mac, boolean coverart, String coverid, String artwork_url) {
838             String hostAndPort;
839             if (StringUtils.isNotEmpty(userId)) {
840                 hostAndPort = "http://" + encode(userId) + ":" + encode(password) + "@" + host + ":" + webport;
841             } else {
842                 hostAndPort = "http://" + host + ":" + webport;
843             }
844
845             // Default to using the convenience artwork URL (should be rare)
846             String url = hostAndPort + "/music/current/cover.jpg?player=" + encode(mac);
847
848             // If additional artwork info provided, use that instead
849             if (coverart) {
850                 if (coverid != null) {
851                     // Typically is used to access cover art of local music files
852                     url = hostAndPort + "/music/" + coverid + "/cover.jpg";
853                 }
854             } else if (artwork_url != null) {
855                 if (artwork_url.startsWith("http")) {
856                     // Typically indicates that cover art is not local to LMS
857                     url = decode(artwork_url);
858                 } else if (artwork_url.startsWith("%2F")) {
859                     // Typically used for default coverart for plugins (e.g. Pandora, etc.)
860                     url = hostAndPort + decode(artwork_url);
861                 } else {
862                     // Another variation of default coverart for plugins (e.g. Pandora, etc.)
863                     url = hostAndPort + "/" + decode(artwork_url);
864                 }
865             }
866             return url;
867         }
868
869         private void handlePlaylistMessage(final String mac, String[] messageParts) {
870             if (messageParts.length < 3) {
871                 return;
872             }
873             String action = messageParts[2];
874             String mode;
875             if (action.equals("newsong")) {
876                 mode = "play";
877                 // Execute in separate thread to avoid delaying listener
878                 scheduler.execute(() -> updateCustomButtons(mac));
879                 // Set the track duration to 0
880                 updatePlayer(new PlayerUpdateEvent() {
881                     @Override
882                     public void updateListener(SqueezeBoxPlayerEventListener listener) {
883                         listener.durationEvent(mac, 0);
884                     }
885                 });
886             } else if (action.equals("pause")) {
887                 if (messageParts.length < 4) {
888                     return;
889                 }
890                 mode = messageParts[3].equals("0") ? "play" : "pause";
891             } else if (action.equals("stop")) {
892                 mode = "stop";
893             } else if ("play".equals(action) && "playlist".equals(messageParts[1])) {
894                 if (messageParts.length >= 4) {
895                     handleSourceChangeMessage(mac, messageParts[3]);
896                 }
897                 return;
898             } else {
899                 // Added so that actions (such as delete, index, jump, open) are not treated as "play"
900                 logger.trace("Unhandled playlist message type '{}'", Arrays.toString(messageParts));
901                 return;
902             }
903             final String value = mode;
904             updatePlayer(new PlayerUpdateEvent() {
905                 @Override
906                 public void updateListener(SqueezeBoxPlayerEventListener listener) {
907                     listener.modeChangeEvent(mac, value);
908                 }
909             });
910         }
911
912         private void handleSourceChangeMessage(String mac, String rawSource) {
913             String source = URLDecoder.decode(rawSource);
914             updatePlayer(new PlayerUpdateEvent() {
915                 @Override
916                 public void updateListener(SqueezeBoxPlayerEventListener listener) {
917                     listener.sourceChangeEvent(mac, source);
918                 }
919             });
920         }
921
922         private void handlePrefsetMessage(final String mac, String[] messageParts) {
923             if (messageParts.length < 5) {
924                 return;
925             }
926             // server prefsets
927             if (messageParts[2].equals("server")) {
928                 String function = messageParts[3];
929                 String value = messageParts[4];
930                 if (function.equals("power")) {
931                     final boolean power = value.equals("1");
932                     updatePlayer(new PlayerUpdateEvent() {
933                         @Override
934                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
935                             listener.powerChangeEvent(mac, power);
936                         }
937                     });
938                 } else if (function.equals("volume")) {
939                     final int volume = (int) Double.parseDouble(value);
940                     updatePlayer(new PlayerUpdateEvent() {
941                         @Override
942                         public void updateListener(SqueezeBoxPlayerEventListener listener) {
943                             listener.absoluteVolumeChangeEvent(mac, volume);
944                         }
945                     });
946                 }
947             }
948         }
949
950         private void handleFavorites(String message) {
951             String[] messageParts = message.split("\\s");
952             if (messageParts.length == 2 && "changed".equals(messageParts[1])) {
953                 // LMS informing us that favorites have changed; request an update to the favorites list
954                 requestFavorites();
955                 return;
956             }
957             if (messageParts.length < 7) {
958                 logger.trace("No favorites in message.");
959                 return;
960             }
961
962             List<Favorite> favorites = new ArrayList<>();
963             Favorite f = null;
964             for (String part : messageParts) {
965                 // Favorite ID (in form xxxxxxxxx.n)
966                 if (part.startsWith("id%3A")) {
967                     String id = part.substring("id%3A".length());
968                     f = new Favorite(id);
969                     favorites.add(f);
970                 }
971                 // Favorite name
972                 else if (part.startsWith("name%3A")) {
973                     String name = decode(part.substring("name%3A".length()));
974                     if (f != null) {
975                         f.name = name;
976                     }
977                 }
978                 // When "1", favorite is a submenu with additional favorites
979                 else if (part.startsWith("hasitems%3A")) {
980                     boolean hasitems = "1".matches(part.substring("hasitems%3A".length()));
981                     if (f != null) {
982                         if (hasitems) {
983                             // Skip subfolders
984                             favorites.remove(f);
985                             f = null;
986                         }
987                     }
988                 }
989             }
990             updatePlayersFavoritesList(favorites);
991             updateChannelFavoritesList(favorites);
992         }
993
994         private void updatePlayersFavoritesList(List<Favorite> favorites) {
995             updatePlayer(new PlayerUpdateEvent() {
996                 @Override
997                 public void updateListener(SqueezeBoxPlayerEventListener listener) {
998                     listener.updateFavoritesListEvent(favorites);
999                 }
1000             });
1001         }
1002
1003         private void updateChannelFavoritesList(List<Favorite> favorites) {
1004             final Channel channel = getThing().getChannel(CHANNEL_FAVORITES_LIST);
1005             if (channel == null) {
1006                 logger.debug("Channel {} doesn't exist. Delete & add thing to get channel.", CHANNEL_FAVORITES_LIST);
1007                 return;
1008             }
1009
1010             // Get channel config parameter indicating whether name should be wrapped with double quotes
1011             Boolean includeQuotes = Boolean.FALSE;
1012             if (channel.getConfiguration().containsKey(CHANNEL_CONFIG_QUOTE_LIST)) {
1013                 includeQuotes = (Boolean) channel.getConfiguration().get(CHANNEL_CONFIG_QUOTE_LIST);
1014             }
1015
1016             String quote = includeQuotes.booleanValue() ? "\"" : "";
1017             StringBuilder sb = new StringBuilder();
1018             for (Favorite favorite : favorites) {
1019                 sb.append(favorite.shortId).append("=").append(quote).append(favorite.name.replaceAll(",", ""))
1020                         .append(quote).append(",");
1021             }
1022
1023             if (sb.length() == 0) {
1024                 updateState(CHANNEL_FAVORITES_LIST, UnDefType.NULL);
1025             } else {
1026                 // Drop the last comma
1027                 sb.setLength(sb.length() - 1);
1028                 String favoritesList = sb.toString();
1029                 logger.trace("Updating favorites channel for {} to state {}", getThing().getUID(), favoritesList);
1030                 updateState(CHANNEL_FAVORITES_LIST, new StringType(favoritesList));
1031             }
1032         }
1033
1034         private ScheduledFuture<?> scheduleRequestFavorites() {
1035             // Delay the execution to give the player thing handlers a chance to initialize
1036             return scheduler.schedule(SqueezeBoxServerHandler.this::requestFavorites, 3L, TimeUnit.SECONDS);
1037         }
1038
1039         private void updateCustomButtons(final String mac) {
1040             String response = executePost(jsonRpcUrl, JSONRPC_STATUS_REQUEST.replace("@@MAC@@", mac));
1041             if (response != null) {
1042                 logger.trace("Status response: {}", response);
1043                 String likeCommand = null;
1044                 String unlikeCommand = null;
1045                 try {
1046                     StatusResponseDTO status = gson.fromJson(response, StatusResponseDTO.class);
1047                     if (status != null && status.result != null && status.result.remoteMeta != null
1048                             && status.result.remoteMeta.buttons != null) {
1049                         ButtonsDTO buttons = status.result.remoteMeta.buttons;
1050                         if (buttons.repeat != null && buttons.repeat.isCustom()) {
1051                             likeCommand = buttons.repeat.command;
1052                         }
1053                         if (buttons.shuffle != null && buttons.shuffle.isCustom()) {
1054                             unlikeCommand = buttons.shuffle.command;
1055                         }
1056                     }
1057                 } catch (JsonSyntaxException e) {
1058                     logger.debug("JsonSyntaxException parsing status response: {}", response, e);
1059                 }
1060                 final String like = likeCommand;
1061                 final String unlike = unlikeCommand;
1062                 updatePlayer(new PlayerUpdateEvent() {
1063                     @Override
1064                     public void updateListener(SqueezeBoxPlayerEventListener listener) {
1065                         listener.buttonsChangeEvent(mac, like, unlike);
1066                     }
1067                 });
1068             }
1069         }
1070
1071         private String executePost(String url, String content) {
1072             // @formatter:off
1073             HttpRequestBuilder builder = HttpRequestBuilder.postTo(url)
1074                 .withTimeout(Duration.ofSeconds(5))
1075                 .withContent(content)
1076                 .withHeader("charset", "utf-8")
1077                 .withHeader("Content-Type", "application/json");
1078             // @formatter:on
1079             if (basicAuthorization != null) {
1080                 builder = builder.withHeader("Authorization", "Basic " + basicAuthorization);
1081             }
1082             try {
1083                 return builder.getContentAsString();
1084             } catch (IOException e) {
1085                 logger.debug("Bridge: IOException on jsonrpc call: {}", e.getMessage(), e);
1086                 return null;
1087             }
1088         }
1089     }
1090
1091     /**
1092      * Interface to allow us to pass function call-backs to SqueezeBox Player
1093      * Event Listeners
1094      *
1095      * @author Dan Cunningham
1096      *
1097      */
1098     interface PlayerUpdateEvent {
1099         void updateListener(SqueezeBoxPlayerEventListener listener);
1100     }
1101
1102     /**
1103      * Update Listeners and child Squeeze Player Things
1104      *
1105      * @param event
1106      */
1107     private void updatePlayer(PlayerUpdateEvent event) {
1108         // update listeners like disco services
1109         synchronized (squeezeBoxPlayerListeners) {
1110             for (SqueezeBoxPlayerEventListener listener : squeezeBoxPlayerListeners) {
1111                 event.updateListener(listener);
1112             }
1113         }
1114         // update our children
1115         Bridge bridge = getThing();
1116
1117         List<Thing> things = bridge.getThings();
1118         for (Thing thing : things) {
1119             ThingHandler handler = thing.getHandler();
1120             if (handler instanceof SqueezeBoxPlayerEventListener && !squeezeBoxPlayerListeners.contains(handler)) {
1121                 event.updateListener((SqueezeBoxPlayerEventListener) handler);
1122             }
1123         }
1124     }
1125
1126     /**
1127      * Adds a listener for player events
1128      *
1129      * @param squeezeBoxPlayerListener
1130      * @return
1131      */
1132     public boolean registerSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1133         logger.trace("Registering player listener");
1134         return squeezeBoxPlayerListeners.add(squeezeBoxPlayerListener);
1135     }
1136
1137     /**
1138      * Removes a listener from player events
1139      *
1140      * @param squeezeBoxPlayerListener
1141      * @return
1142      */
1143     public boolean unregisterSqueezeBoxPlayerListener(SqueezeBoxPlayerEventListener squeezeBoxPlayerListener) {
1144         logger.trace("Unregistering player listener");
1145         return squeezeBoxPlayerListeners.remove(squeezeBoxPlayerListener);
1146     }
1147
1148     /**
1149      * Removed a player from our known list of players, will populate again if
1150      * player is seen
1151      *
1152      * @param mac
1153      */
1154     public void removePlayerCache(String mac) {
1155         players.remove(mac);
1156     }
1157
1158     /**
1159      * Schedule the server to try and reconnect
1160      */
1161     private void scheduleReconnect() {
1162         logger.debug("scheduling squeeze server reconnect in {} seconds", RECONNECT_TIME);
1163         cancelReconnect();
1164         reconnectFuture = scheduler.schedule(this::connect, RECONNECT_TIME, TimeUnit.SECONDS);
1165     }
1166
1167     /**
1168      * Clears our reconnect job if exists
1169      */
1170     private void cancelReconnect() {
1171         if (reconnectFuture != null) {
1172             reconnectFuture.cancel(true);
1173         }
1174     }
1175 }