]> git.basschouten.com Git - openhab-addons.git/blob
e92930512d7ce6a037de5e68607e5eb29f75e7f2
[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.kodi.internal.protocol;
14
15 import java.io.IOException;
16 import java.math.BigDecimal;
17 import java.net.URI;
18 import java.net.URISyntaxException;
19 import java.net.URLEncoder;
20 import java.nio.charset.StandardCharsets;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.List;
24 import java.util.concurrent.ScheduledExecutorService;
25 import java.util.concurrent.TimeUnit;
26
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.websocket.client.WebSocketClient;
29 import org.openhab.binding.kodi.internal.KodiEventListener;
30 import org.openhab.binding.kodi.internal.KodiEventListener.KodiPlaylistState;
31 import org.openhab.binding.kodi.internal.KodiEventListener.KodiState;
32 import org.openhab.binding.kodi.internal.model.KodiAudioStream;
33 import org.openhab.binding.kodi.internal.model.KodiDuration;
34 import org.openhab.binding.kodi.internal.model.KodiFavorite;
35 import org.openhab.binding.kodi.internal.model.KodiPVRChannel;
36 import org.openhab.binding.kodi.internal.model.KodiPVRChannelGroup;
37 import org.openhab.binding.kodi.internal.model.KodiProfile;
38 import org.openhab.binding.kodi.internal.model.KodiSubtitle;
39 import org.openhab.binding.kodi.internal.model.KodiSystemProperties;
40 import org.openhab.binding.kodi.internal.model.KodiUniqueID;
41 import org.openhab.binding.kodi.internal.model.KodiVideoStream;
42 import org.openhab.core.cache.ByteArrayFileCache;
43 import org.openhab.core.cache.ExpiringCacheMap;
44 import org.openhab.core.io.net.http.HttpUtil;
45 import org.openhab.core.library.types.RawType;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48
49 import com.google.gson.Gson;
50 import com.google.gson.JsonArray;
51 import com.google.gson.JsonElement;
52 import com.google.gson.JsonObject;
53 import com.google.gson.JsonPrimitive;
54 import com.google.gson.JsonSyntaxException;
55
56 /**
57  * KodiConnection provides an API for accessing a Kodi device.
58  *
59  * @author Paul Frank - Initial contribution
60  * @author Christoph Weitkamp - Added channels for opening PVR TV or Radio streams
61  * @author Andreas Reinhardt & Christoph Weitkamp - Added channels for thumbnail and fanart
62  * @author Christoph Weitkamp - Improvements for playing audio notifications
63  */
64 public class KodiConnection implements KodiClientSocketEventListener {
65
66     private static final String TIMESTAMP_FRAGMENT = "#timestamp=";
67     private static final String PROPERTY_FANART = "fanart";
68     private static final String PROPERTY_THUMBNAIL = "thumbnail";
69     private static final String PROPERTY_VERSION = "version";
70     private static final String PROPERTY_SCREENSAVER = "System.ScreensaverActive";
71     private static final String PROPERTY_VOLUME = "volume";
72     private static final String PROPERTY_MUTED = "muted";
73     private static final String PROPERTY_TOTALTIME = "totaltime";
74     private static final String PROPERTY_TIME = "time";
75     private static final String PROPERTY_PERCENTAGE = "percentage";
76     private static final String PROPERTY_SUBTITLEENABLED = "subtitleenabled";
77     private static final String PROPERTY_CURRENTSUBTITLE = "currentsubtitle";
78     private static final String PROPERTY_CURRENTVIDEOSTREAM = "currentvideostream";
79     private static final String PROPERTY_CURRENTAUDIOSTREAM = "currentaudiostream";
80     private static final String PROPERTY_SUBTITLES = "subtitles";
81     private static final String PROPERTY_AUDIOSTREAMS = "audiostreams";
82     private static final String PROPERTY_CANHIBERNATE = "canhibernate";
83     private static final String PROPERTY_CANREBOOT = "canreboot";
84     private static final String PROPERTY_CANSHUTDOWN = "canshutdown";
85     private static final String PROPERTY_CANSUSPEND = "cansuspend";
86     private static final String PROPERTY_UNIQUEID = "uniqueid";
87
88     private final Logger logger = LoggerFactory.getLogger(KodiConnection.class);
89
90     private static final int VOLUMESTEP = 10;
91     // 0 = STOP or -1 = PLAY BACKWARDS are valid as well, but we don't want use them for FAST FORWARD or REWIND speeds
92     private static final List<Integer> SPEEDS = Arrays
93             .asList(new Integer[] { -32, -16, -8, -4, -2, 1, 2, 4, 8, 16, 32 });
94     private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.kodi");
95     private static final ExpiringCacheMap<String, JsonElement> REQUEST_CACHE = new ExpiringCacheMap<>(
96             TimeUnit.MINUTES.toMillis(5));
97
98     private final Gson gson = new Gson();
99
100     private String hostname;
101     private URI wsUri;
102     private URI imageUri;
103     private KodiClientSocket socket;
104
105     private int volume = 0;
106     private KodiState currentState = KodiState.STOP;
107     private KodiPlaylistState currentPlaylistState = KodiPlaylistState.CLEAR;
108
109     private final KodiEventListener listener;
110     private final WebSocketClient webSocketClient;
111     private final String callbackUrl;
112
113     public KodiConnection(KodiEventListener listener, WebSocketClient webSocketClient, String callbackUrl) {
114         this.listener = listener;
115         this.webSocketClient = webSocketClient;
116         this.callbackUrl = callbackUrl;
117     }
118
119     @Override
120     public synchronized void onConnectionClosed() {
121         listener.updateConnectionState(false);
122     }
123
124     @Override
125     public synchronized void onConnectionOpened() {
126         listener.updateConnectionState(true);
127     }
128
129     public synchronized void connect(String hostname, int port, ScheduledExecutorService scheduler, URI imageUri) {
130         this.hostname = hostname;
131         this.imageUri = imageUri;
132         try {
133             close();
134             wsUri = new URI("ws", null, hostname, port, "/jsonrpc", null, null);
135             socket = new KodiClientSocket(this, wsUri, scheduler, webSocketClient);
136             checkConnection();
137         } catch (URISyntaxException e) {
138             logger.warn("exception during constructing URI host={}, port={}", hostname, port, e);
139         }
140     }
141
142     private int getActivePlayer() {
143         JsonElement response = socket.callMethod("Player.GetActivePlayers");
144
145         if (response instanceof JsonArray) {
146             JsonArray result = response.getAsJsonArray();
147             if (result.size() > 0) {
148                 JsonObject player0 = result.get(0).getAsJsonObject();
149                 if (player0.has("playerid")) {
150                     return player0.get("playerid").getAsInt();
151                 }
152             }
153         }
154         return -1;
155     }
156
157     public int getActivePlaylist() {
158         for (JsonElement element : getPlaylistsInternal()) {
159             JsonObject playlist = (JsonObject) element;
160             if (playlist.has("playlistid")) {
161                 int playlistID = playlist.get("playlistid").getAsInt();
162                 JsonObject playlistItems = getPlaylistItemsInternal(playlistID);
163                 if (playlistItems.has("limits") && playlistItems.get("limits") instanceof JsonObject) {
164                     JsonObject limits = playlistItems.get("limits").getAsJsonObject();
165                     if (limits.has("total") && limits.get("total").getAsInt() > 0) {
166                         return playlistID;
167                     }
168                 }
169             }
170         }
171         return -1;
172     }
173
174     public int getPlaylistID(String type) {
175         for (JsonElement element : getPlaylistsInternal()) {
176             JsonObject playlist = (JsonObject) element;
177             if (playlist.has("playlistid") && playlist.has("type") && type.equals(playlist.get("type").getAsString())) {
178                 return playlist.get("playlistid").getAsInt();
179             }
180         }
181         return -1;
182     }
183
184     private synchronized JsonArray getPlaylistsInternal() {
185         String method = "Playlist.GetPlaylists";
186         String hash = hostname + '#' + method;
187         JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> socket.callMethod(method));
188
189         if (response instanceof JsonArray) {
190             return response.getAsJsonArray();
191         } else {
192             return null;
193         }
194     }
195
196     private synchronized JsonObject getPlaylistItemsInternal(int playlistID) {
197         JsonObject params = new JsonObject();
198         params.addProperty("playlistid", playlistID);
199         JsonElement response = socket.callMethod("Playlist.GetItems", params);
200
201         if (response instanceof JsonObject) {
202             return response.getAsJsonObject();
203         } else {
204             return null;
205         }
206     }
207
208     public synchronized void playerPlayPause() {
209         int activePlayer = getActivePlayer();
210
211         JsonObject params = new JsonObject();
212         params.addProperty("playerid", activePlayer);
213         socket.callMethod("Player.PlayPause", params);
214     }
215
216     public synchronized void playerStop() {
217         int activePlayer = getActivePlayer();
218
219         JsonObject params = new JsonObject();
220         params.addProperty("playerid", activePlayer);
221         socket.callMethod("Player.Stop", params);
222     }
223
224     public synchronized void playerNext() {
225         goToInternal("next");
226
227         updatePlayerStatus();
228     }
229
230     public synchronized void playerPrevious() {
231         goToInternal("previous");
232
233         updatePlayerStatus();
234     }
235
236     private void goToInternal(String to) {
237         int activePlayer = getActivePlayer();
238
239         JsonObject params = new JsonObject();
240         params.addProperty("playerid", activePlayer);
241         params.addProperty("to", to);
242         socket.callMethod("Player.GoTo", params);
243     }
244
245     public synchronized void playerRewind() {
246         setSpeedInternal(calcNextSpeed(-1));
247
248         updatePlayerStatus();
249     }
250
251     public synchronized void playerFastForward() {
252         setSpeedInternal(calcNextSpeed(1));
253
254         updatePlayerStatus();
255     }
256
257     private int calcNextSpeed(int modifier) {
258         int activePlayer = getActivePlayer();
259         if (activePlayer >= 0) {
260             int position = SPEEDS.indexOf(getSpeed(activePlayer));
261             if (position == -1) {
262                 return 0;
263             } else if (position == 0 || position == (SPEEDS.size() - 1)) {
264                 return SPEEDS.get(position);
265             } else {
266                 return SPEEDS.get(position + modifier);
267             }
268         } else {
269             return 0;
270         }
271     }
272
273     private void setSpeedInternal(int speed) {
274         int activePlayer = getActivePlayer();
275
276         JsonObject params = new JsonObject();
277         params.addProperty("playerid", activePlayer);
278         params.addProperty("speed", speed);
279         socket.callMethod("Player.SetSpeed", params);
280     }
281
282     public synchronized void playlistAdd(int playlistID, String uri) {
283         currentPlaylistState = KodiPlaylistState.ADD;
284
285         JsonObject item = new JsonObject();
286         item.addProperty("file", uri);
287
288         JsonObject params = new JsonObject();
289         params.addProperty("playlistid", playlistID);
290         params.add("item", item);
291         socket.callMethod("Playlist.Add", params);
292     }
293
294     public synchronized void playlistClear(int playlistID) {
295         currentPlaylistState = KodiPlaylistState.CLEAR;
296
297         JsonObject params = new JsonObject();
298         params.addProperty("playlistid", playlistID);
299         socket.callMethod("Playlist.Clear", params);
300     }
301
302     public synchronized void playlistInsert(int playlistID, String uri, int position) {
303         currentPlaylistState = KodiPlaylistState.INSERT;
304
305         JsonObject item = new JsonObject();
306         item.addProperty("file", uri);
307
308         JsonObject params = new JsonObject();
309         params.addProperty("playlistid", playlistID);
310         params.addProperty("position", position);
311         params.add("item", item);
312         socket.callMethod("Playlist.Insert", params);
313     }
314
315     public synchronized void playlistPlay(int playlistID, int position) {
316         JsonObject item = new JsonObject();
317         item.addProperty("playlistid", playlistID);
318         item.addProperty("position", position);
319
320         playInternal(item, null);
321     }
322
323     public synchronized void playlistRemove(int playlistID, int position) {
324         currentPlaylistState = KodiPlaylistState.REMOVE;
325
326         JsonObject params = new JsonObject();
327         params.addProperty("playlistid", playlistID);
328         params.addProperty("position", position);
329         socket.callMethod("Playlist.Remove", params);
330     }
331
332     /**
333      * Retrieves a list of favorites from the Kodi instance. The result is cached.
334      *
335      * @return a list of {@link KodiFavorite}
336      */
337     public synchronized List<KodiFavorite> getFavorites() {
338         String method = "Favourites.GetFavourites";
339         String hash = hostname + '#' + method;
340         JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
341             final String[] properties = { "path", "window", "windowparameter" };
342
343             JsonObject params = new JsonObject();
344             params.add("properties", getJsonArray(properties));
345             return socket.callMethod(method, params);
346         });
347
348         List<KodiFavorite> favorites = new ArrayList<>();
349         if (response instanceof JsonObject) {
350             JsonObject result = response.getAsJsonObject();
351             if (result.has("favourites")) {
352                 JsonElement favourites = result.get("favourites");
353                 if (favourites instanceof JsonArray) {
354                     for (JsonElement element : favourites.getAsJsonArray()) {
355                         JsonObject object = (JsonObject) element;
356                         KodiFavorite favorite = new KodiFavorite(object.get("title").getAsString());
357                         favorite.setFavoriteType(object.get("type").getAsString());
358                         if (object.has("path")) {
359                             favorite.setPath(object.get("path").getAsString());
360                         }
361                         if (object.has("window")) {
362                             favorite.setWindow(object.get("window").getAsString());
363                             favorite.setWindowParameter(object.get("windowparameter").getAsString());
364                         }
365                         favorites.add(favorite);
366                     }
367                 }
368             }
369         }
370         return favorites;
371     }
372
373     /**
374      * Returns the favorite with the given title or null.
375      *
376      * @param favoriteTitle the title of the favorite
377      * @return the ({@link KodiFavorite}) with the given title
378      */
379     public @Nullable KodiFavorite getFavorite(final String favoriteTitle) {
380         for (KodiFavorite favorite : getFavorites()) {
381             String title = favorite.getTitle();
382             if (favoriteTitle.equalsIgnoreCase(title)) {
383                 return favorite;
384             }
385         }
386         return null;
387     }
388
389     /**
390      * Activates the given window.
391      *
392      * @param window the window
393      */
394     public synchronized void activateWindow(final String window) {
395         activateWindow(window, null);
396     }
397
398     /**
399      * Activates the given window.
400      *
401      * @param window the window
402      * @param windowParameter list of parameters of the window
403      */
404     public synchronized void activateWindow(final String window, @Nullable final String[] windowParameter) {
405         JsonObject params = new JsonObject();
406         params.addProperty("window", window);
407         if (windowParameter != null) {
408             params.add("parameters", getJsonArray(windowParameter));
409         }
410         socket.callMethod("GUI.ActivateWindow", params);
411     }
412
413     public synchronized void increaseVolume() {
414         setVolumeInternal(this.volume + VOLUMESTEP);
415     }
416
417     public synchronized void decreaseVolume() {
418         setVolumeInternal(this.volume - VOLUMESTEP);
419     }
420
421     public synchronized void setVolume(int volume) {
422         setVolumeInternal(volume);
423     }
424
425     private void setVolumeInternal(int volume) {
426         JsonObject params = new JsonObject();
427         params.addProperty(PROPERTY_VOLUME, volume);
428         socket.callMethod("Application.SetVolume", params);
429     }
430
431     public int getVolume() {
432         return volume;
433     }
434
435     public synchronized void setMute(boolean mute) {
436         JsonObject params = new JsonObject();
437         params.addProperty("mute", mute);
438         socket.callMethod("Application.SetMute", params);
439     }
440
441     public synchronized void setAudioStream(int stream) {
442         JsonObject params = new JsonObject();
443         params.addProperty("stream", stream);
444         int activePlayer = getActivePlayer();
445         params.addProperty("playerid", activePlayer);
446         socket.callMethod("Player.SetAudioStream", params);
447     }
448
449     public synchronized void setVideoStream(int stream) {
450         JsonObject params = new JsonObject();
451         params.addProperty("stream", stream);
452         int activePlayer = getActivePlayer();
453         params.addProperty("playerid", activePlayer);
454         socket.callMethod("Player.SetVideoStream", params);
455     }
456
457     public synchronized void setSubtitle(int subtitle) {
458         JsonObject params = new JsonObject();
459         params.addProperty("subtitle", subtitle);
460         int activePlayer = getActivePlayer();
461         params.addProperty("playerid", activePlayer);
462         socket.callMethod("Player.SetSubtitle", params);
463     }
464
465     public synchronized void setSubtitleEnabled(boolean subtitleenabled) {
466         JsonObject params = new JsonObject();
467         params.addProperty("subtitle", subtitleenabled ? "on" : "off");
468         int activePlayer = getActivePlayer();
469         params.addProperty("playerid", activePlayer);
470         socket.callMethod("Player.SetSubtitle", params);
471     }
472
473     private int getSpeed(int activePlayer) {
474         final String[] properties = { "speed" };
475
476         JsonObject params = new JsonObject();
477         params.addProperty("playerid", activePlayer);
478         params.add("properties", getJsonArray(properties));
479         JsonElement response = socket.callMethod("Player.GetProperties", params);
480
481         if (response instanceof JsonObject) {
482             JsonObject result = response.getAsJsonObject();
483             if (result.has("speed")) {
484                 return result.get("speed").getAsInt();
485             }
486         }
487         return 0;
488     }
489
490     public synchronized void updatePlayerStatus() {
491         if (socket.isConnected()) {
492             int activePlayer = getActivePlayer();
493             if (activePlayer >= 0) {
494                 int speed = getSpeed(activePlayer);
495                 if (speed == 0) {
496                     updateState(KodiState.STOP);
497                 } else if (speed == 1) {
498                     updateState(KodiState.PLAY);
499                 } else if (speed < 0) {
500                     updateState(KodiState.REWIND);
501                 } else {
502                     updateState(KodiState.FASTFORWARD);
503                 }
504                 requestPlayerUpdate(activePlayer);
505             } else {
506                 updateState(KodiState.STOP);
507             }
508         }
509     }
510
511     private void requestPlayerUpdate(int activePlayer) {
512         requestPlayerPropertiesUpdate(activePlayer);
513         requestPlayerItemUpdate(activePlayer);
514     }
515
516     private void requestPlayerItemUpdate(int activePlayer) {
517         final String[] properties = { PROPERTY_UNIQUEID, "title", "originaltitle", "album", "artist", "track",
518                 "director", PROPERTY_THUMBNAIL, PROPERTY_FANART, "file", "showtitle", "season", "episode", "channel",
519                 "channeltype", "genre", "mpaa", "rating", "votes", "userrating" };
520
521         JsonObject params = new JsonObject();
522         params.addProperty("playerid", activePlayer);
523         params.add("properties", getJsonArray(properties));
524         JsonElement response = socket.callMethod("Player.GetItem", params);
525
526         if (response instanceof JsonObject) {
527             JsonObject result = response.getAsJsonObject();
528             if (result.has("item")) {
529                 JsonObject item = result.get("item").getAsJsonObject();
530
531                 int mediaid = -1;
532                 if (item.has("id")) {
533                     mediaid = item.get("id").getAsInt();
534                 }
535
536                 double rating = -1;
537                 if (item.has("rating")) {
538                     rating = item.get("rating").getAsDouble();
539                 }
540
541                 double userrating = -1;
542                 if (item.has("userrating")) {
543                     userrating = item.get("userrating").getAsDouble();
544                 }
545
546                 String mpaa = "";
547                 if (item.has("mpaa")) {
548                     mpaa = item.get("mpaa").getAsString();
549                 }
550
551                 String mediafile = "";
552                 if (item.has("file")) {
553                     mediafile = item.get("file").getAsString();
554                 }
555
556                 String uniqueIDDouban = "";
557                 String uniqueIDImdb = "";
558                 String uniqueIDTmdb = "";
559                 String uniqueIDImdbtvshow = "";
560                 String uniqueIDTmdbtvshow = "";
561                 String uniqueIDTmdbepisode = "";
562
563                 if (item.has(PROPERTY_UNIQUEID)) {
564                     try {
565                         KodiUniqueID uniqueID = gson.fromJson(item.get(PROPERTY_UNIQUEID), KodiUniqueID.class);
566                         if (uniqueID != null) {
567                             uniqueIDImdb = uniqueID.getImdb();
568                             uniqueIDDouban = uniqueID.getDouban();
569                             uniqueIDTmdb = uniqueID.getTmdb();
570                             uniqueIDImdbtvshow = uniqueID.getImdbtvshow();
571                             uniqueIDTmdbtvshow = uniqueID.getTmdbtvshow();
572                             uniqueIDTmdbepisode = uniqueID.getTmdbepisode();
573                         }
574                     } catch (JsonSyntaxException e) {
575                         // do nothing
576                     }
577                 }
578
579                 String originaltitle = "";
580                 if (item.has("originaltitle")) {
581                     originaltitle = item.get("originaltitle").getAsString();
582                 }
583
584                 String title = "";
585                 if (item.has("title")) {
586                     title = item.get("title").getAsString();
587                 }
588                 if (title.isEmpty()) {
589                     title = item.get("label").getAsString();
590                 }
591
592                 String showTitle = "";
593                 if (item.has("showtitle")) {
594                     showTitle = item.get("showtitle").getAsString();
595                 }
596
597                 int season = -1;
598                 if (item.has("season")) {
599                     season = item.get("season").getAsInt();
600                 }
601
602                 int episode = -1;
603                 if (item.has("episode")) {
604                     episode = item.get("episode").getAsInt();
605                 }
606
607                 String album = "";
608                 if (item.has("album")) {
609                     album = item.get("album").getAsString();
610                 }
611
612                 String mediaType = item.get("type").getAsString();
613                 if ("channel".equals(mediaType) && item.has("channeltype")) {
614                     String channelType = item.get("channeltype").getAsString();
615                     if ("radio".equals(channelType)) {
616                         mediaType = "radio";
617                     }
618                 }
619
620                 List<String> artistList = null;
621                 if ("movie".equals(mediaType) && item.has("director")) {
622                     artistList = convertFromArrayToList(item.get("director").getAsJsonArray());
623                 } else {
624                     if (item.has("artist")) {
625                         artistList = convertFromArrayToList(item.get("artist").getAsJsonArray());
626                     }
627                 }
628
629                 List<String> genreList = null;
630                 if (item.has("genre")) {
631                     JsonElement genre = item.get("genre");
632                     if (genre instanceof JsonArray) {
633                         genreList = convertFromArrayToList(genre.getAsJsonArray());
634                     }
635                 }
636
637                 String channel = "";
638                 if (item.has("channel")) {
639                     channel = item.get("channel").getAsString();
640                 }
641
642                 RawType thumbnail = null;
643                 if (item.has(PROPERTY_THUMBNAIL)) {
644                     thumbnail = getImageForElement(item.get(PROPERTY_THUMBNAIL));
645                 }
646
647                 RawType fanart = null;
648                 if (item.has(PROPERTY_FANART)) {
649                     fanart = getImageForElement(item.get(PROPERTY_FANART));
650                 }
651
652                 listener.updateMediaID(mediaid);
653                 listener.updateAlbum(album);
654                 listener.updateTitle(title);
655                 listener.updateOriginalTitle(originaltitle);
656                 listener.updateShowTitle(showTitle);
657                 listener.updateArtistList(artistList);
658                 listener.updateMediaType(mediaType);
659                 listener.updateGenreList(genreList);
660                 listener.updatePVRChannel(channel);
661                 listener.updateThumbnail(thumbnail);
662                 listener.updateFanart(fanart);
663                 listener.updateSeason(season);
664                 listener.updateEpisode(episode);
665                 listener.updateMediaFile(mediafile);
666                 listener.updateMpaa(mpaa);
667                 listener.updateRating(rating);
668                 listener.updateUserRating(userrating);
669                 listener.updateUniqueIDDouban(uniqueIDDouban);
670                 listener.updateUniqueIDImdb(uniqueIDImdb);
671                 listener.updateUniqueIDTmdb(uniqueIDTmdb);
672                 listener.updateUniqueIDImdbtvshow(uniqueIDImdbtvshow);
673                 listener.updateUniqueIDTmdbtvshow(uniqueIDTmdbtvshow);
674                 listener.updateUniqueIDTmdbepisode(uniqueIDTmdbepisode);
675             }
676         }
677     }
678
679     private void requestPlayerPropertiesUpdate(int activePlayer) {
680         final String[] properties = { PROPERTY_SUBTITLEENABLED, PROPERTY_CURRENTSUBTITLE, PROPERTY_CURRENTAUDIOSTREAM,
681                 PROPERTY_CURRENTVIDEOSTREAM, PROPERTY_PERCENTAGE, PROPERTY_TIME, PROPERTY_TOTALTIME,
682                 PROPERTY_AUDIOSTREAMS, PROPERTY_SUBTITLES };
683
684         JsonObject params = new JsonObject();
685         params.addProperty("playerid", activePlayer);
686         params.add("properties", getJsonArray(properties));
687         JsonElement response = socket.callMethod("Player.GetProperties", params);
688
689         if (response instanceof JsonObject) {
690             JsonObject result = response.getAsJsonObject();
691
692             if (result.has(PROPERTY_AUDIOSTREAMS)) {
693                 try {
694                     JsonElement audioGroup = result.get(PROPERTY_AUDIOSTREAMS);
695                     if (audioGroup instanceof JsonArray) {
696                         List<KodiAudioStream> audioStreamList = new ArrayList<>();
697                         for (JsonElement element : audioGroup.getAsJsonArray()) {
698                             KodiAudioStream audioStream = gson.fromJson(element, KodiAudioStream.class);
699                             audioStreamList.add(audioStream);
700                         }
701                         listener.updateAudioStreamOptions(audioStreamList);
702                     }
703                 } catch (JsonSyntaxException e) {
704                     // do nothing
705                 }
706             }
707
708             if (result.has(PROPERTY_SUBTITLES)) {
709                 try {
710                     JsonElement subtitleGroup = result.get(PROPERTY_SUBTITLES);
711                     if (subtitleGroup instanceof JsonArray) {
712                         List<KodiSubtitle> subtitleList = new ArrayList<>();
713                         for (JsonElement element : subtitleGroup.getAsJsonArray()) {
714                             KodiSubtitle subtitle = gson.fromJson(element, KodiSubtitle.class);
715                             subtitleList.add(subtitle);
716                         }
717                         listener.updateSubtitleOptions(subtitleList);
718                     }
719                 } catch (JsonSyntaxException e) {
720                     // do nothing
721                 }
722             }
723
724             boolean subtitleEnabled = false;
725             if (result.has(PROPERTY_SUBTITLEENABLED)) {
726                 subtitleEnabled = result.get(PROPERTY_SUBTITLEENABLED).getAsBoolean();
727             }
728
729             int subtitleIndex = -1;
730             String subtitleLanguage = null;
731             String subtitleName = null;
732             if (result.has(PROPERTY_CURRENTSUBTITLE)) {
733                 try {
734                     KodiSubtitle subtitleStream = gson.fromJson(result.get(PROPERTY_CURRENTSUBTITLE),
735                             KodiSubtitle.class);
736                     if (subtitleStream != null) {
737                         subtitleIndex = subtitleStream.getIndex();
738                         subtitleLanguage = subtitleStream.getLanguage();
739                         subtitleName = subtitleStream.getName();
740                     }
741                 } catch (JsonSyntaxException e) {
742                     // do nothing
743                 }
744             }
745
746             String audioCodec = null;
747             int audioIndex = -1;
748             int audioChannels = 0;
749             String audioLanguage = null;
750             String audioName = null;
751             if (result.has(PROPERTY_CURRENTAUDIOSTREAM)) {
752                 try {
753                     KodiAudioStream audioStream = gson.fromJson(result.get(PROPERTY_CURRENTAUDIOSTREAM),
754                             KodiAudioStream.class);
755                     if (audioStream != null) {
756                         audioCodec = audioStream.getCodec();
757                         audioIndex = audioStream.getIndex();
758                         audioChannels = audioStream.getChannels();
759                         audioLanguage = audioStream.getLanguage();
760                         audioName = audioStream.getName();
761                     }
762                 } catch (JsonSyntaxException e) {
763                     // do nothing
764                 }
765             }
766
767             String videoCodec = null;
768             int videoWidth = 0;
769             int videoHeight = 0;
770             int videoIndex = -1;
771             if (result.has(PROPERTY_CURRENTVIDEOSTREAM)) {
772                 try {
773                     KodiVideoStream videoStream = gson.fromJson(result.get(PROPERTY_CURRENTVIDEOSTREAM),
774                             KodiVideoStream.class);
775                     if (videoStream != null) {
776                         videoCodec = videoStream.getCodec();
777                         videoWidth = videoStream.getWidth();
778                         videoHeight = videoStream.getHeight();
779                         videoIndex = videoStream.getIndex();
780                     }
781                 } catch (JsonSyntaxException e) {
782                     // do nothing
783                 }
784             }
785
786             double percentage = -1;
787             if (result.has(PROPERTY_PERCENTAGE)) {
788                 percentage = result.get(PROPERTY_PERCENTAGE).getAsDouble();
789             }
790
791             long currentTime = -1;
792             if (result.has(PROPERTY_TIME)) {
793                 try {
794                     KodiDuration time = gson.fromJson(result.get(PROPERTY_TIME), KodiDuration.class);
795                     currentTime = time.toSeconds();
796                 } catch (JsonSyntaxException e) {
797                     // do nothing
798                 }
799             }
800
801             long duration = -1;
802             if (result.has(PROPERTY_TOTALTIME)) {
803                 try {
804                     KodiDuration totalTime = gson.fromJson(result.get(PROPERTY_TOTALTIME), KodiDuration.class);
805                     duration = totalTime.toSeconds();
806                 } catch (JsonSyntaxException e) {
807                     // do nothing
808                 }
809             }
810
811             listener.updateAudioCodec(audioCodec);
812             listener.updateAudioIndex(audioIndex);
813             listener.updateAudioName(audioName);
814             listener.updateAudioLanguage(audioLanguage);
815             listener.updateAudioChannels(audioChannels);
816             listener.updateVideoCodec(videoCodec);
817             listener.updateVideoIndex(videoIndex);
818             listener.updateVideoHeight(videoHeight);
819             listener.updateVideoWidth(videoWidth);
820             listener.updateSubtitleEnabled(subtitleEnabled);
821             listener.updateSubtitleIndex(subtitleIndex);
822             listener.updateSubtitleName(subtitleName);
823             listener.updateSubtitleLanguage(subtitleLanguage);
824             listener.updateCurrentTimePercentage(percentage);
825             listener.updateCurrentTime(currentTime);
826             listener.updateDuration(duration);
827         }
828     }
829
830     private JsonArray getJsonArray(String[] values) {
831         JsonArray result = new JsonArray();
832         for (String param : values) {
833             result.add(new JsonPrimitive(param));
834         }
835         return result;
836     }
837
838     private List<String> convertFromArrayToList(JsonArray data) {
839         List<String> list = new ArrayList<>();
840         for (JsonElement element : data) {
841             list.add(element.getAsString());
842         }
843         return list;
844     }
845
846     private @Nullable RawType getImageForElement(JsonElement element) {
847         String text = element.getAsString();
848         if (!text.isEmpty()) {
849             String url = stripImageUrl(text);
850             if (url != null) {
851                 return downloadImageFromCache(url);
852             }
853         }
854         return null;
855     }
856
857     private @Nullable String stripImageUrl(String url) {
858         // we have to strip ending "/" here because Kodi returns a not valid path and filename
859         // "fanart":"image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f263365-31.jpg/"
860         // "thumbnail":"image://http%3a%2f%2fthetvdb.com%2fbanners%2fepisodes%2f263365%2f5640869.jpg/"
861         String encodedURL = URLEncoder.encode(stripEnd(url, '/'), StandardCharsets.UTF_8);
862         return imageUri.resolve(encodedURL).toString();
863     }
864
865     private String stripEnd(final String str, final char suffix) {
866         int end = str.length();
867         if (end == 0) {
868             return str;
869         }
870         while (end > 0 && str.charAt(end - 1) == suffix) {
871             end--;
872         }
873         return str.substring(0, end);
874     }
875
876     private @Nullable RawType downloadImage(String url) {
877         logger.debug("Trying to download the content of URL '{}'", url);
878         RawType downloadedImage = HttpUtil.downloadImage(url);
879         if (downloadedImage == null) {
880             logger.debug("Failed to download the content of URL '{}'", url);
881         }
882         return downloadedImage;
883     }
884
885     private @Nullable RawType downloadImageFromCache(String url) {
886         if (IMAGE_CACHE.containsKey(url)) {
887             try {
888                 byte[] bytes = IMAGE_CACHE.get(url);
889                 String contentType = HttpUtil.guessContentTypeFromData(bytes);
890                 return new RawType(bytes,
891                         contentType == null || contentType.isEmpty() ? RawType.DEFAULT_MIME_TYPE : contentType);
892             } catch (IOException e) {
893                 logger.trace("Failed to download the content of URL '{}'", url, e);
894             }
895         } else {
896             RawType image = downloadImage(url);
897             if (image != null) {
898                 IMAGE_CACHE.put(url, image.getBytes());
899                 return image;
900             }
901         }
902         return null;
903     }
904
905     public KodiState getState() {
906         return currentState;
907     }
908
909     public KodiPlaylistState getPlaylistState() {
910         return currentPlaylistState;
911     }
912
913     private void updateState(KodiState state) {
914         // sometimes get a Pause immediately after a Stop - so just ignore
915         if (currentState.equals(KodiState.STOP) && state.equals(KodiState.PAUSE)) {
916             return;
917         }
918         listener.updatePlayerState(state);
919         // if this is a Stop then clear everything else
920         if (state == KodiState.STOP) {
921             listener.updateAlbum("");
922             listener.updateTitle("");
923             listener.updateShowTitle("");
924             listener.updateArtistList(null);
925             listener.updateMediaType("");
926             listener.updateGenreList(null);
927             listener.updatePVRChannel("");
928             listener.updateThumbnail(null);
929             listener.updateFanart(null);
930             listener.updateCurrentTimePercentage(-1);
931             listener.updateCurrentTime(-1);
932             listener.updateDuration(-1);
933             listener.updateMediaID(-1);
934             listener.updateOriginalTitle("");
935             listener.updateSeason(-1);
936             listener.updateEpisode(-1);
937             listener.updateMediaFile("");
938             listener.updateMpaa("");
939             listener.updateRating(-1);
940             listener.updateUserRating(-1);
941             listener.updateUniqueIDDouban("");
942             listener.updateUniqueIDImdb("");
943             listener.updateUniqueIDTmdb("");
944             listener.updateUniqueIDImdbtvshow("");
945             listener.updateUniqueIDTmdbtvshow("");
946             listener.updateUniqueIDTmdbepisode("");
947             listener.updateAudioStreamOptions(new ArrayList<>());
948             listener.updateSubtitleOptions(new ArrayList<>());
949             listener.updateAudioCodec(null);
950             listener.updateVideoCodec(null);
951             listener.updateAudioIndex(-1);
952             listener.updateAudioName(null);
953             listener.updateAudioLanguage(null);
954             listener.updateAudioChannels(-1);
955             listener.updateVideoIndex(-1);
956             listener.updateVideoHeight(-1);
957             listener.updateVideoWidth(-1);
958             listener.updateSubtitleIndex(-1);
959             listener.updateSubtitleName(null);
960             listener.updateSubtitleLanguage(null);
961         }
962         // keep track of our current state
963         currentState = state;
964     }
965
966     @Override
967     public void handleEvent(JsonObject json) {
968         JsonElement methodElement = json.get("method");
969
970         if (methodElement != null) {
971             String method = methodElement.getAsString();
972             JsonObject params = json.get("params").getAsJsonObject();
973             if (method.startsWith("Player.On")) {
974                 processPlayerStateChanged(method, params);
975             } else if (method.startsWith("Application.On")) {
976                 processApplicationStateChanged(method, params);
977             } else if (method.startsWith("System.On")) {
978                 processSystemStateChanged(method, params);
979             } else if (method.startsWith("GUI.OnScreensaver")) {
980                 processScreenSaverStateChanged(method, params);
981             } else if (method.startsWith("Input.OnInput")) {
982                 processInputRequestedStateChanged(method, params);
983             } else if (method.startsWith("Playlist.On")) {
984                 processPlaylistStateChanged(method, params);
985             } else {
986                 logger.debug("Received unknown method: {}", method);
987             }
988         }
989     }
990
991     private void processPlayerStateChanged(String method, JsonObject json) {
992         if ("Player.OnPlay".equals(method) || "Player.OnAVStart".equals(method)) {
993             // get the player id and make a new request for the media details
994
995             JsonObject data = json.get("data").getAsJsonObject();
996             JsonObject player = data.get("player").getAsJsonObject();
997             Integer playerId = player.get("playerid").getAsInt();
998
999             updateState(KodiState.PLAY);
1000
1001             requestPlayerUpdate(playerId);
1002         } else if ("Player.OnPause".equals(method)) {
1003             updateState(KodiState.PAUSE);
1004         } else if ("Player.OnResume".equals(method)) {
1005             updateState(KodiState.PLAY);
1006         } else if ("Player.OnStop".equals(method)) {
1007             // get the end parameter and send an End state if true
1008             JsonObject data = json.get("data").getAsJsonObject();
1009             Boolean end = data.get("end").getAsBoolean();
1010             if (end) {
1011                 updateState(KodiState.END);
1012             }
1013             updateState(KodiState.STOP);
1014         } else if ("Player.OnPropertyChanged".equals(method)) {
1015             logger.debug("Player.OnPropertyChanged");
1016         } else if ("Player.OnSpeedChanged".equals(method)) {
1017             JsonObject data = json.get("data").getAsJsonObject();
1018             JsonObject player = data.get("player").getAsJsonObject();
1019             int speed = player.get("speed").getAsInt();
1020             if (speed == 0) {
1021                 updateState(KodiState.PAUSE);
1022             } else if (speed == 1) {
1023                 updateState(KodiState.PLAY);
1024             } else if (speed < 0) {
1025                 updateState(KodiState.REWIND);
1026             } else if (speed > 1) {
1027                 updateState(KodiState.FASTFORWARD);
1028             }
1029         } else {
1030             logger.debug("Unknown event from Kodi {}: {}", method, json);
1031         }
1032         listener.updateConnectionState(true);
1033     }
1034
1035     private void processApplicationStateChanged(String method, JsonObject json) {
1036         if ("Application.OnVolumeChanged".equals(method)) {
1037             // get the player id and make a new request for the media details
1038             JsonObject data = json.get("data").getAsJsonObject();
1039             if (data.has(PROPERTY_VOLUME)) {
1040                 volume = data.get(PROPERTY_VOLUME).getAsInt();
1041                 listener.updateVolume(volume);
1042             }
1043             if (data.has(PROPERTY_MUTED)) {
1044                 boolean muted = data.get(PROPERTY_MUTED).getAsBoolean();
1045                 listener.updateMuted(muted);
1046             }
1047         } else {
1048             logger.debug("Unknown event from Kodi {}: {}", method, json);
1049         }
1050         listener.updateConnectionState(true);
1051     }
1052
1053     private void processSystemStateChanged(String method, JsonObject json) {
1054         if ("System.OnQuit".equals(method) || "System.OnRestart".equals(method) || "System.OnSleep".equals(method)) {
1055             listener.updateConnectionState(false);
1056         } else if ("System.OnWake".equals(method)) {
1057             listener.updateConnectionState(true);
1058         } else {
1059             logger.debug("Unknown event from Kodi {}: {}", method, json);
1060         }
1061     }
1062
1063     private void processScreenSaverStateChanged(String method, JsonObject json) {
1064         if ("GUI.OnScreensaverDeactivated".equals(method)) {
1065             listener.updateScreenSaverState(false);
1066         } else if ("GUI.OnScreensaverActivated".equals(method)) {
1067             listener.updateScreenSaverState(true);
1068         } else {
1069             logger.debug("Unknown event from Kodi {}: {}", method, json);
1070         }
1071         listener.updateConnectionState(true);
1072     }
1073
1074     private void processInputRequestedStateChanged(String method, JsonObject json) {
1075         if ("Input.OnInputFinished".equals(method)) {
1076             listener.updateInputRequestedState(false);
1077         } else if ("Input.OnInputRequested".equals(method)) {
1078             listener.updateInputRequestedState(true);
1079         } else {
1080             logger.debug("Unknown event from Kodi {}: {}", method, json);
1081         }
1082         listener.updateConnectionState(true);
1083     }
1084
1085     private void processPlaylistStateChanged(String method, JsonObject json) {
1086         if ("Playlist.OnAdd".equals(method)) {
1087             currentPlaylistState = KodiPlaylistState.ADDED;
1088
1089             listener.updatePlaylistState(KodiPlaylistState.ADDED);
1090         } else if ("Playlist.OnRemove".equals(method)) {
1091             currentPlaylistState = KodiPlaylistState.REMOVED;
1092
1093             listener.updatePlaylistState(KodiPlaylistState.REMOVED);
1094         } else {
1095             logger.debug("Unknown event from Kodi {}: {}", method, json);
1096         }
1097         listener.updateConnectionState(true);
1098     }
1099
1100     public synchronized void close() {
1101         if (socket != null && socket.isConnected()) {
1102             socket.close();
1103         }
1104     }
1105
1106     public void updateScreenSaverState() {
1107         if (socket.isConnected()) {
1108             String[] props = { PROPERTY_SCREENSAVER };
1109
1110             JsonObject params = new JsonObject();
1111             params.add("booleans", getJsonArray(props));
1112             JsonElement response = socket.callMethod("XBMC.GetInfoBooleans", params);
1113
1114             if (response instanceof JsonObject) {
1115                 JsonObject data = response.getAsJsonObject();
1116                 if (data.has(PROPERTY_SCREENSAVER)) {
1117                     listener.updateScreenSaverState(data.get(PROPERTY_SCREENSAVER).getAsBoolean());
1118                 }
1119             }
1120         } else {
1121             listener.updateScreenSaverState(false);
1122         }
1123     }
1124
1125     public void updateVolume() {
1126         if (socket.isConnected()) {
1127             String[] props = { PROPERTY_VOLUME, PROPERTY_MUTED };
1128
1129             JsonObject params = new JsonObject();
1130             params.add("properties", getJsonArray(props));
1131             JsonElement response = socket.callMethod("Application.GetProperties", params);
1132
1133             if (response instanceof JsonObject) {
1134                 JsonObject data = response.getAsJsonObject();
1135                 if (data.has(PROPERTY_VOLUME)) {
1136                     volume = data.get(PROPERTY_VOLUME).getAsInt();
1137                     listener.updateVolume(volume);
1138                 }
1139                 if (data.has(PROPERTY_MUTED)) {
1140                     boolean muted = data.get(PROPERTY_MUTED).getAsBoolean();
1141                     listener.updateMuted(muted);
1142                 }
1143             }
1144         } else {
1145             listener.updateVolume(100);
1146             listener.updateMuted(false);
1147         }
1148     }
1149
1150     public void updateCurrentProfile() {
1151         if (socket.isConnected()) {
1152             JsonElement response = socket.callMethod("Profiles.GetCurrentProfile");
1153
1154             try {
1155                 final KodiProfile profile = gson.fromJson(response, KodiProfile.class);
1156                 if (profile != null) {
1157                     listener.updateCurrentProfile(profile.getLabel());
1158                 }
1159             } catch (JsonSyntaxException e) {
1160                 logger.debug("Json syntax exception occurred: {}", e.getMessage(), e);
1161             }
1162         }
1163     }
1164
1165     public synchronized void playURI(String uri) {
1166         String fileUri = uri;
1167         JsonObject item = new JsonObject();
1168         JsonObject options = null;
1169
1170         if (uri.contains(TIMESTAMP_FRAGMENT)) {
1171             fileUri = uri.substring(0, uri.indexOf(TIMESTAMP_FRAGMENT));
1172             String timestamp = uri.substring(uri.indexOf(TIMESTAMP_FRAGMENT) + TIMESTAMP_FRAGMENT.length());
1173             try {
1174                 int s = Integer.parseInt(timestamp);
1175                 options = new JsonObject();
1176                 options.add("resume", timeValueFromSeconds(s));
1177             } catch (NumberFormatException e) {
1178                 logger.warn("Illegal parameter for timestamp - it must be an integer: {}", timestamp);
1179             }
1180         }
1181         item.addProperty("file", fileUri);
1182         playInternal(item, options);
1183     }
1184
1185     public synchronized List<KodiPVRChannelGroup> getPVRChannelGroups(final String pvrChannelType) {
1186         String method = "PVR.GetChannelGroups";
1187         String hash = hostname + '#' + method + "#channeltype=" + pvrChannelType;
1188         JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
1189             JsonObject params = new JsonObject();
1190             params.addProperty("channeltype", pvrChannelType);
1191             return socket.callMethod(method, params);
1192         });
1193
1194         List<KodiPVRChannelGroup> pvrChannelGroups = new ArrayList<>();
1195         if (response instanceof JsonObject) {
1196             JsonObject result = response.getAsJsonObject();
1197             if (result.has("channelgroups")) {
1198                 JsonElement channelgroups = result.get("channelgroups");
1199                 if (channelgroups instanceof JsonArray) {
1200                     for (JsonElement element : channelgroups.getAsJsonArray()) {
1201                         JsonObject object = (JsonObject) element;
1202                         KodiPVRChannelGroup pvrChannelGroup = new KodiPVRChannelGroup();
1203                         pvrChannelGroup.setId(object.get("channelgroupid").getAsInt());
1204                         pvrChannelGroup.setLabel(object.get("label").getAsString());
1205                         pvrChannelGroup.setChannelType(pvrChannelType);
1206                         pvrChannelGroups.add(pvrChannelGroup);
1207                     }
1208                 }
1209             }
1210         }
1211         return pvrChannelGroups;
1212     }
1213
1214     public int getPVRChannelGroupId(final String channelType, final String pvrChannelGroupName) {
1215         List<KodiPVRChannelGroup> pvrChannelGroups = getPVRChannelGroups(channelType);
1216         for (KodiPVRChannelGroup pvrChannelGroup : pvrChannelGroups) {
1217             String label = pvrChannelGroup.getLabel();
1218             if (pvrChannelGroupName.equalsIgnoreCase(label)) {
1219                 return pvrChannelGroup.getId();
1220             }
1221         }
1222         // if we don't find a matching PVR channel group return the first (which is the default: "All channels")
1223         return pvrChannelGroups.isEmpty() ? 0 : pvrChannelGroups.get(0).getId();
1224     }
1225
1226     public synchronized List<KodiPVRChannel> getPVRChannels(final int pvrChannelGroupId) {
1227         String method = "PVR.GetChannels";
1228         String hash = hostname + '#' + method + "#channelgroupid=" + pvrChannelGroupId;
1229         JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
1230             JsonObject params = new JsonObject();
1231             params.addProperty("channelgroupid", pvrChannelGroupId);
1232             return socket.callMethod(method, params);
1233         });
1234
1235         List<KodiPVRChannel> pvrChannels = new ArrayList<>();
1236         if (response instanceof JsonObject) {
1237             JsonObject result = response.getAsJsonObject();
1238             if (result.has("channels")) {
1239                 JsonElement channels = result.get("channels");
1240                 if (channels instanceof JsonArray) {
1241                     for (JsonElement element : channels.getAsJsonArray()) {
1242                         JsonObject object = (JsonObject) element;
1243                         KodiPVRChannel pvrChannel = new KodiPVRChannel();
1244                         pvrChannel.setId(object.get("channelid").getAsInt());
1245                         pvrChannel.setLabel(object.get("label").getAsString());
1246                         pvrChannel.setChannelGroupId(pvrChannelGroupId);
1247                         pvrChannels.add(pvrChannel);
1248                     }
1249                 }
1250             }
1251         }
1252         return pvrChannels;
1253     }
1254
1255     public int getPVRChannelId(final int pvrChannelGroupId, final String pvrChannelName) {
1256         for (KodiPVRChannel pvrChannel : getPVRChannels(pvrChannelGroupId)) {
1257             String label = pvrChannel.getLabel();
1258             if (pvrChannelName.equalsIgnoreCase(label)) {
1259                 return pvrChannel.getId();
1260             }
1261         }
1262         return 0;
1263     }
1264
1265     public synchronized void playPVRChannel(final int pvrChannelId) {
1266         JsonObject item = new JsonObject();
1267         item.addProperty("channelid", pvrChannelId);
1268
1269         playInternal(item, null);
1270     }
1271
1272     private void playInternal(JsonObject item, JsonObject options) {
1273         JsonObject params = new JsonObject();
1274         params.add("item", item);
1275         if (options != null) {
1276             params.add("options", options);
1277         }
1278         socket.callMethod("Player.Open", params);
1279     }
1280
1281     public synchronized void showNotification(String title, BigDecimal displayTime, String icon, String message) {
1282         JsonObject params = new JsonObject();
1283         params.addProperty("message", message);
1284         if (title != null) {
1285             params.addProperty("title", title);
1286         }
1287         if (displayTime != null) {
1288             params.addProperty("displaytime", displayTime.longValue());
1289         }
1290         if (icon != null) {
1291             params.addProperty("image", callbackUrl + "/icon/" + icon.toLowerCase() + ".png");
1292         }
1293         socket.callMethod("GUI.ShowNotification", params);
1294     }
1295
1296     public boolean checkConnection() {
1297         if (!socket.isConnected()) {
1298             logger.debug("checkConnection: try to connect to Kodi {}", wsUri);
1299             try {
1300                 socket.open();
1301                 return socket.isConnected();
1302             } catch (IOException e) {
1303                 logger.debug("exception during connect to {}", wsUri, e);
1304                 socket.close();
1305                 return false;
1306             }
1307         } else {
1308             // Ping Kodi with the get version command. This prevents the idle timeout on the web socket.
1309             return !getVersion().isEmpty();
1310         }
1311     }
1312
1313     public String getConnectionName() {
1314         return wsUri.toString();
1315     }
1316
1317     public String getVersion() {
1318         if (socket.isConnected()) {
1319             String[] props = { PROPERTY_VERSION };
1320
1321             JsonObject params = new JsonObject();
1322             params.add("properties", getJsonArray(props));
1323             JsonElement response = socket.callMethod("Application.GetProperties", params);
1324
1325             if (response instanceof JsonObject) {
1326                 JsonObject result = response.getAsJsonObject();
1327                 if (result.has(PROPERTY_VERSION)) {
1328                     JsonObject version = result.get(PROPERTY_VERSION).getAsJsonObject();
1329                     int major = version.get("major").getAsInt();
1330                     int minor = version.get("minor").getAsInt();
1331                     String revision = version.get("revision").getAsString();
1332                     return String.format("%d.%d (%s)", major, minor, revision);
1333                 }
1334             }
1335         }
1336         return "";
1337     }
1338
1339     public void input(String key) {
1340         socket.callMethod("Input." + key);
1341     }
1342
1343     public void inputText(String text) {
1344         JsonObject params = new JsonObject();
1345         params.addProperty("text", text);
1346         socket.callMethod("Input.SendText", params);
1347     }
1348
1349     public void inputAction(String action) {
1350         JsonObject params = new JsonObject();
1351         params.addProperty("action", action);
1352         socket.callMethod("Input.ExecuteAction", params);
1353     }
1354
1355     public void inputButtonEvent(String buttonEvent) {
1356         logger.debug("inputButtonEvent {}.", buttonEvent);
1357
1358         String button = buttonEvent;
1359         String keymap = "KB";
1360         Integer holdtime = null;
1361
1362         if (buttonEvent.contains(";")) {
1363             String[] params = buttonEvent.split(";");
1364             switch (params.length) {
1365                 case 2:
1366                     button = params[0];
1367                     keymap = params[1];
1368                     break;
1369                 case 3:
1370                     button = params[0];
1371                     keymap = params[1];
1372                     try {
1373                         holdtime = Integer.parseInt(params[2]);
1374                     } catch (NumberFormatException nfe) {
1375                         holdtime = null;
1376                     }
1377                     break;
1378             }
1379         }
1380
1381         this.inputButtonEvent(button, keymap, holdtime);
1382     }
1383
1384     private void inputButtonEvent(String button, String keymap, Integer holdtime) {
1385         JsonObject params = new JsonObject();
1386         params.addProperty("button", button);
1387         params.addProperty("keymap", keymap);
1388         if (holdtime != null) {
1389             params.addProperty("holdtime", holdtime.intValue());
1390         }
1391         JsonElement result = socket.callMethod("Input.ButtonEvent", params);
1392         logger.debug("inputButtonEvent result {}.", result);
1393     }
1394
1395     public void getSystemProperties() {
1396         KodiSystemProperties systemProperties = null;
1397         if (socket.isConnected()) {
1398             String[] props = { PROPERTY_CANHIBERNATE, PROPERTY_CANREBOOT, PROPERTY_CANSHUTDOWN, PROPERTY_CANSUSPEND };
1399
1400             JsonObject params = new JsonObject();
1401             params.add("properties", getJsonArray(props));
1402             JsonElement response = socket.callMethod("System.GetProperties", params);
1403
1404             try {
1405                 systemProperties = gson.fromJson(response, KodiSystemProperties.class);
1406             } catch (JsonSyntaxException e) {
1407                 // do nothing
1408             }
1409         }
1410         listener.updateSystemProperties(systemProperties);
1411     }
1412
1413     public void sendApplicationQuit() {
1414         String method = "Application.Quit";
1415         socket.callMethod(method);
1416     }
1417
1418     public void sendSystemCommand(String command) {
1419         String method = "System." + command;
1420         socket.callMethod(method);
1421     }
1422
1423     public void profile(String profile) {
1424         JsonObject params = new JsonObject();
1425         params.addProperty("profile", profile);
1426         socket.callMethod("Profiles.LoadProfile", params);
1427     }
1428
1429     public KodiProfile[] getProfiles() {
1430         KodiProfile[] profiles = new KodiProfile[0];
1431         if (socket.isConnected()) {
1432             JsonElement response = socket.callMethod("Profiles.GetProfiles");
1433
1434             try {
1435                 JsonObject profilesJson = response.getAsJsonObject();
1436                 profiles = gson.fromJson(profilesJson.get("profiles"), KodiProfile[].class);
1437             } catch (JsonSyntaxException e) {
1438                 logger.debug("Json syntax exception occurred: {}", e.getMessage(), e);
1439             }
1440         }
1441         return profiles;
1442     }
1443
1444     public void setTime(int time) {
1445         int seconds = time;
1446         JsonObject params = new JsonObject();
1447         params.addProperty("playerid", 1);
1448         JsonObject value = new JsonObject();
1449         JsonObject timeValue = timeValueFromSeconds(seconds);
1450
1451         value.add("time", timeValue);
1452         params.add("value", value);
1453         socket.callMethod("Player.Seek", params);
1454     }
1455
1456     private JsonObject timeValueFromSeconds(int seconds) {
1457         JsonObject timeValue = new JsonObject();
1458         int s = seconds;
1459
1460         if (s >= 3600) {
1461             int hours = s / 3600;
1462             timeValue.addProperty("hours", hours);
1463             s = s % 3600;
1464         }
1465         if (s >= 60) {
1466             int minutes = s / 60;
1467             timeValue.addProperty("minutes", minutes);
1468             s = seconds % 60;
1469         }
1470         timeValue.addProperty("seconds", s);
1471         return timeValue;
1472     }
1473 }