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