2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.kodi.internal.protocol;
15 import java.io.IOException;
16 import java.math.BigDecimal;
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;
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;
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;
57 * KodiConnection provides an API for accessing a Kodi device.
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
64 public class KodiConnection implements KodiClientSocketEventListener {
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_VOLUME = "volume";
71 private static final String PROPERTY_MUTED = "muted";
72 private static final String PROPERTY_TOTALTIME = "totaltime";
73 private static final String PROPERTY_TIME = "time";
74 private static final String PROPERTY_PERCENTAGE = "percentage";
75 private static final String PROPERTY_SUBTITLEENABLED = "subtitleenabled";
76 private static final String PROPERTY_CURRENTSUBTITLE = "currentsubtitle";
77 private static final String PROPERTY_CURRENTVIDEOSTREAM = "currentvideostream";
78 private static final String PROPERTY_CURRENTAUDIOSTREAM = "currentaudiostream";
79 private static final String PROPERTY_SUBTITLES = "subtitles";
80 private static final String PROPERTY_AUDIOSTREAMS = "audiostreams";
81 private static final String PROPERTY_CANHIBERNATE = "canhibernate";
82 private static final String PROPERTY_CANREBOOT = "canreboot";
83 private static final String PROPERTY_CANSHUTDOWN = "canshutdown";
84 private static final String PROPERTY_CANSUSPEND = "cansuspend";
85 private static final String PROPERTY_UNIQUEID = "uniqueid";
87 private final Logger logger = LoggerFactory.getLogger(KodiConnection.class);
89 private static final int VOLUMESTEP = 10;
90 // 0 = STOP or -1 = PLAY BACKWARDS are valid as well, but we don't want use them for FAST FORWARD or REWIND speeds
91 private static final List<Integer> SPEEDS = Arrays
92 .asList(new Integer[] { -32, -16, -8, -4, -2, 1, 2, 4, 8, 16, 32 });
93 private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.kodi");
94 private static final ExpiringCacheMap<String, JsonElement> REQUEST_CACHE = new ExpiringCacheMap<>(
95 TimeUnit.MINUTES.toMillis(5));
97 private final Gson gson = new Gson();
99 private String hostname;
101 private URI imageUri;
102 private KodiClientSocket socket;
104 private int volume = 0;
105 private KodiState currentState = KodiState.STOP;
106 private KodiPlaylistState currentPlaylistState = KodiPlaylistState.CLEAR;
108 private final KodiEventListener listener;
109 private final WebSocketClient webSocketClient;
110 private final String callbackUrl;
112 public KodiConnection(KodiEventListener listener, WebSocketClient webSocketClient, String callbackUrl) {
113 this.listener = listener;
114 this.webSocketClient = webSocketClient;
115 this.callbackUrl = callbackUrl;
119 public synchronized void onConnectionClosed() {
120 listener.updateConnectionState(false);
124 public synchronized void onConnectionOpened() {
125 listener.updateConnectionState(true);
128 public synchronized void connect(String hostname, int port, ScheduledExecutorService scheduler, URI imageUri) {
129 this.hostname = hostname;
130 this.imageUri = imageUri;
133 wsUri = new URI("ws", null, hostname, port, "/jsonrpc", null, null);
134 socket = new KodiClientSocket(this, wsUri, scheduler, webSocketClient);
136 } catch (URISyntaxException e) {
137 logger.warn("exception during constructing URI host={}, port={}", hostname, port, e);
141 private int getActivePlayer() {
142 JsonElement response = socket.callMethod("Player.GetActivePlayers");
144 if (response instanceof JsonArray) {
145 JsonArray result = response.getAsJsonArray();
146 if (result.size() > 0) {
147 JsonObject player0 = result.get(0).getAsJsonObject();
148 if (player0.has("playerid")) {
149 return player0.get("playerid").getAsInt();
156 public int getActivePlaylist() {
157 for (JsonElement element : getPlaylistsInternal()) {
158 JsonObject playlist = (JsonObject) element;
159 if (playlist.has("playlistid")) {
160 int playlistID = playlist.get("playlistid").getAsInt();
161 JsonObject playlistItems = getPlaylistItemsInternal(playlistID);
162 if (playlistItems.has("limits") && playlistItems.get("limits") instanceof JsonObject) {
163 JsonObject limits = playlistItems.get("limits").getAsJsonObject();
164 if (limits.has("total") && limits.get("total").getAsInt() > 0) {
173 public int getPlaylistID(String type) {
174 for (JsonElement element : getPlaylistsInternal()) {
175 JsonObject playlist = (JsonObject) element;
176 if (playlist.has("playlistid") && playlist.has("type") && type.equals(playlist.get("type").getAsString())) {
177 return playlist.get("playlistid").getAsInt();
183 private synchronized JsonArray getPlaylistsInternal() {
184 String method = "Playlist.GetPlaylists";
185 String hash = hostname + '#' + method;
186 JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
187 return socket.callMethod(method);
190 if (response instanceof JsonArray) {
191 return response.getAsJsonArray();
197 private synchronized JsonObject getPlaylistItemsInternal(int playlistID) {
198 JsonObject params = new JsonObject();
199 params.addProperty("playlistid", playlistID);
200 JsonElement response = socket.callMethod("Playlist.GetItems", params);
202 if (response instanceof JsonObject) {
203 return response.getAsJsonObject();
209 public synchronized void playerPlayPause() {
210 int activePlayer = getActivePlayer();
212 JsonObject params = new JsonObject();
213 params.addProperty("playerid", activePlayer);
214 socket.callMethod("Player.PlayPause", params);
217 public synchronized void playerStop() {
218 int activePlayer = getActivePlayer();
220 JsonObject params = new JsonObject();
221 params.addProperty("playerid", activePlayer);
222 socket.callMethod("Player.Stop", params);
225 public synchronized void playerNext() {
226 goToInternal("next");
228 updatePlayerStatus();
231 public synchronized void playerPrevious() {
232 goToInternal("previous");
234 updatePlayerStatus();
237 private void goToInternal(String to) {
238 int activePlayer = getActivePlayer();
240 JsonObject params = new JsonObject();
241 params.addProperty("playerid", activePlayer);
242 params.addProperty("to", to);
243 socket.callMethod("Player.GoTo", params);
246 public synchronized void playerRewind() {
247 setSpeedInternal(calcNextSpeed(-1));
249 updatePlayerStatus();
252 public synchronized void playerFastForward() {
253 setSpeedInternal(calcNextSpeed(1));
255 updatePlayerStatus();
258 private int calcNextSpeed(int modifier) {
259 int activePlayer = getActivePlayer();
260 if (activePlayer >= 0) {
261 int position = SPEEDS.indexOf(getSpeed(activePlayer));
262 if (position == -1) {
264 } else if (position == 0 || position == (SPEEDS.size() - 1)) {
265 return SPEEDS.get(position);
267 return SPEEDS.get(position + modifier);
274 private void setSpeedInternal(int speed) {
275 int activePlayer = getActivePlayer();
277 JsonObject params = new JsonObject();
278 params.addProperty("playerid", activePlayer);
279 params.addProperty("speed", speed);
280 socket.callMethod("Player.SetSpeed", params);
283 public synchronized void playlistAdd(int playlistID, String uri) {
284 currentPlaylistState = KodiPlaylistState.ADD;
286 JsonObject item = new JsonObject();
287 item.addProperty("file", uri);
289 JsonObject params = new JsonObject();
290 params.addProperty("playlistid", playlistID);
291 params.add("item", item);
292 socket.callMethod("Playlist.Add", params);
295 public synchronized void playlistClear(int playlistID) {
296 currentPlaylistState = KodiPlaylistState.CLEAR;
298 JsonObject params = new JsonObject();
299 params.addProperty("playlistid", playlistID);
300 socket.callMethod("Playlist.Clear", params);
303 public synchronized void playlistInsert(int playlistID, String uri, int position) {
304 currentPlaylistState = KodiPlaylistState.INSERT;
306 JsonObject item = new JsonObject();
307 item.addProperty("file", uri);
309 JsonObject params = new JsonObject();
310 params.addProperty("playlistid", playlistID);
311 params.addProperty("position", position);
312 params.add("item", item);
313 socket.callMethod("Playlist.Insert", params);
316 public synchronized void playlistPlay(int playlistID, int position) {
317 JsonObject item = new JsonObject();
318 item.addProperty("playlistid", playlistID);
319 item.addProperty("position", position);
321 playInternal(item, null);
324 public synchronized void playlistRemove(int playlistID, int position) {
325 currentPlaylistState = KodiPlaylistState.REMOVE;
327 JsonObject params = new JsonObject();
328 params.addProperty("playlistid", playlistID);
329 params.addProperty("position", position);
330 socket.callMethod("Playlist.Remove", params);
334 * Retrieves a list of favorites from the Kodi instance. The result is cached.
336 * @return a list of {@link KodiFavorite}
338 public synchronized List<KodiFavorite> getFavorites() {
339 String method = "Favourites.GetFavourites";
340 String hash = hostname + '#' + method;
341 JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
342 final String[] properties = { "path", "window", "windowparameter" };
344 JsonObject params = new JsonObject();
345 params.add("properties", getJsonArray(properties));
346 return socket.callMethod(method, params);
349 List<KodiFavorite> favorites = new ArrayList<>();
350 if (response instanceof JsonObject) {
351 JsonObject result = response.getAsJsonObject();
352 if (result.has("favourites")) {
353 JsonElement favourites = result.get("favourites");
354 if (favourites instanceof JsonArray) {
355 for (JsonElement element : favourites.getAsJsonArray()) {
356 JsonObject object = (JsonObject) element;
357 KodiFavorite favorite = new KodiFavorite(object.get("title").getAsString());
358 favorite.setFavoriteType(object.get("type").getAsString());
359 if (object.has("path")) {
360 favorite.setPath(object.get("path").getAsString());
362 if (object.has("window")) {
363 favorite.setWindow(object.get("window").getAsString());
364 favorite.setWindowParameter(object.get("windowparameter").getAsString());
366 favorites.add(favorite);
375 * Returns the favorite with the given title or null.
377 * @param favoriteTitle the title of the favorite
378 * @return the ({@link KodiFavorite}) with the given title
380 public @Nullable KodiFavorite getFavorite(final String favoriteTitle) {
381 for (KodiFavorite favorite : getFavorites()) {
382 String title = favorite.getTitle();
383 if (favoriteTitle.equalsIgnoreCase(title)) {
391 * Activates the given window.
393 * @param window the window
395 public synchronized void activateWindow(final String window) {
396 activateWindow(window, null);
400 * Activates the given window.
402 * @param window the window
403 * @param windowParameter list of parameters of the window
405 public synchronized void activateWindow(final String window, @Nullable final String[] windowParameter) {
406 JsonObject params = new JsonObject();
407 params.addProperty("window", window);
408 if (windowParameter != null) {
409 params.add("parameters", getJsonArray(windowParameter));
411 socket.callMethod("GUI.ActivateWindow", params);
414 public synchronized void increaseVolume() {
415 setVolumeInternal(this.volume + VOLUMESTEP);
418 public synchronized void decreaseVolume() {
419 setVolumeInternal(this.volume - VOLUMESTEP);
422 public synchronized void setVolume(int volume) {
423 setVolumeInternal(volume);
426 private void setVolumeInternal(int volume) {
427 JsonObject params = new JsonObject();
428 params.addProperty(PROPERTY_VOLUME, volume);
429 socket.callMethod("Application.SetVolume", params);
432 public int getVolume() {
436 public synchronized void setMute(boolean mute) {
437 JsonObject params = new JsonObject();
438 params.addProperty("mute", mute);
439 socket.callMethod("Application.SetMute", params);
442 public synchronized void setAudioStream(int stream) {
443 JsonObject params = new JsonObject();
444 params.addProperty("stream", stream);
445 int activePlayer = getActivePlayer();
446 params.addProperty("playerid", activePlayer);
447 socket.callMethod("Player.SetAudioStream", params);
450 public synchronized void setVideoStream(int stream) {
451 JsonObject params = new JsonObject();
452 params.addProperty("stream", stream);
453 int activePlayer = getActivePlayer();
454 params.addProperty("playerid", activePlayer);
455 socket.callMethod("Player.SetVideoStream", params);
458 public synchronized void setSubtitle(int subtitle) {
459 JsonObject params = new JsonObject();
460 params.addProperty("subtitle", subtitle);
461 int activePlayer = getActivePlayer();
462 params.addProperty("playerid", activePlayer);
463 socket.callMethod("Player.SetSubtitle", params);
466 public synchronized void setSubtitleEnabled(boolean subtitleenabled) {
467 JsonObject params = new JsonObject();
468 params.addProperty("subtitle", subtitleenabled ? "on" : "off");
469 int activePlayer = getActivePlayer();
470 params.addProperty("playerid", activePlayer);
471 socket.callMethod("Player.SetSubtitle", params);
474 private int getSpeed(int activePlayer) {
475 final String[] properties = { "speed" };
477 JsonObject params = new JsonObject();
478 params.addProperty("playerid", activePlayer);
479 params.add("properties", getJsonArray(properties));
480 JsonElement response = socket.callMethod("Player.GetProperties", params);
482 if (response instanceof JsonObject) {
483 JsonObject result = response.getAsJsonObject();
484 if (result.has("speed")) {
485 return result.get("speed").getAsInt();
491 public synchronized void updatePlayerStatus() {
492 if (socket.isConnected()) {
493 int activePlayer = getActivePlayer();
494 if (activePlayer >= 0) {
495 int speed = getSpeed(activePlayer);
497 updateState(KodiState.STOP);
498 } else if (speed == 1) {
499 updateState(KodiState.PLAY);
500 } else if (speed < 0) {
501 updateState(KodiState.REWIND);
503 updateState(KodiState.FASTFORWARD);
505 requestPlayerUpdate(activePlayer);
507 updateState(KodiState.STOP);
512 private void requestPlayerUpdate(int activePlayer) {
513 requestPlayerPropertiesUpdate(activePlayer);
514 requestPlayerItemUpdate(activePlayer);
517 private void requestPlayerItemUpdate(int activePlayer) {
518 final String[] properties = { PROPERTY_UNIQUEID, "title", "originaltitle", "album", "artist", "track",
519 "director", PROPERTY_THUMBNAIL, PROPERTY_FANART, "file", "showtitle", "season", "episode", "channel",
520 "channeltype", "genre", "mpaa", "rating", "votes", "userrating" };
522 JsonObject params = new JsonObject();
523 params.addProperty("playerid", activePlayer);
524 params.add("properties", getJsonArray(properties));
525 JsonElement response = socket.callMethod("Player.GetItem", params);
527 if (response instanceof JsonObject) {
528 JsonObject result = response.getAsJsonObject();
529 if (result.has("item")) {
530 JsonObject item = result.get("item").getAsJsonObject();
533 if (item.has("id")) {
534 mediaid = item.get("id").getAsInt();
538 if (item.has("rating")) {
539 rating = item.get("rating").getAsDouble();
542 double userrating = -1;
543 if (item.has("userrating")) {
544 userrating = item.get("userrating").getAsDouble();
548 if (item.has("mpaa")) {
549 mpaa = item.get("mpaa").getAsString();
552 String mediafile = "";
553 if (item.has("file")) {
554 mediafile = item.get("file").getAsString();
557 String uniqueIDDouban = "";
558 String uniqueIDImdb = "";
559 String uniqueIDTmdb = "";
560 String uniqueIDImdbtvshow = "";
561 String uniqueIDTmdbtvshow = "";
562 String uniqueIDTmdbepisode = "";
564 if (item.has(PROPERTY_UNIQUEID)) {
566 KodiUniqueID uniqueID = gson.fromJson(item.get(PROPERTY_UNIQUEID), KodiUniqueID.class);
567 if (uniqueID != null) {
568 uniqueIDImdb = uniqueID.getImdb();
569 uniqueIDDouban = uniqueID.getDouban();
570 uniqueIDTmdb = uniqueID.getTmdb();
571 uniqueIDImdbtvshow = uniqueID.getImdbtvshow();
572 uniqueIDTmdbtvshow = uniqueID.getTmdbtvshow();
573 uniqueIDTmdbepisode = uniqueID.getTmdbepisode();
575 } catch (JsonSyntaxException e) {
580 String originaltitle = "";
581 if (item.has("originaltitle")) {
582 originaltitle = item.get("originaltitle").getAsString();
586 if (item.has("title")) {
587 title = item.get("title").getAsString();
589 if (title.isEmpty()) {
590 title = item.get("label").getAsString();
593 String showTitle = "";
594 if (item.has("showtitle")) {
595 showTitle = item.get("showtitle").getAsString();
599 if (item.has("season")) {
600 season = item.get("season").getAsInt();
604 if (item.has("episode")) {
605 episode = item.get("episode").getAsInt();
609 if (item.has("album")) {
610 album = item.get("album").getAsString();
613 String mediaType = item.get("type").getAsString();
614 if ("channel".equals(mediaType) && item.has("channeltype")) {
615 String channelType = item.get("channeltype").getAsString();
616 if ("radio".equals(channelType)) {
621 List<String> artistList = null;
622 if ("movie".equals(mediaType) && item.has("director")) {
623 artistList = convertFromArrayToList(item.get("director").getAsJsonArray());
625 if (item.has("artist")) {
626 artistList = convertFromArrayToList(item.get("artist").getAsJsonArray());
630 List<String> genreList = null;
631 if (item.has("genre")) {
632 JsonElement genre = item.get("genre");
633 if (genre instanceof JsonArray) {
634 genreList = convertFromArrayToList(genre.getAsJsonArray());
639 if (item.has("channel")) {
640 channel = item.get("channel").getAsString();
643 RawType thumbnail = null;
644 if (item.has(PROPERTY_THUMBNAIL)) {
645 thumbnail = getImageForElement(item.get(PROPERTY_THUMBNAIL));
648 RawType fanart = null;
649 if (item.has(PROPERTY_FANART)) {
650 fanart = getImageForElement(item.get(PROPERTY_FANART));
653 listener.updateMediaID(mediaid);
654 listener.updateAlbum(album);
655 listener.updateTitle(title);
656 listener.updateOriginalTitle(originaltitle);
657 listener.updateShowTitle(showTitle);
658 listener.updateArtistList(artistList);
659 listener.updateMediaType(mediaType);
660 listener.updateGenreList(genreList);
661 listener.updatePVRChannel(channel);
662 listener.updateThumbnail(thumbnail);
663 listener.updateFanart(fanart);
664 listener.updateSeason(season);
665 listener.updateEpisode(episode);
666 listener.updateMediaFile(mediafile);
667 listener.updateMpaa(mpaa);
668 listener.updateRating(rating);
669 listener.updateUserRating(userrating);
670 listener.updateUniqueIDDouban(uniqueIDDouban);
671 listener.updateUniqueIDImdb(uniqueIDImdb);
672 listener.updateUniqueIDTmdb(uniqueIDTmdb);
673 listener.updateUniqueIDImdbtvshow(uniqueIDImdbtvshow);
674 listener.updateUniqueIDTmdbtvshow(uniqueIDTmdbtvshow);
675 listener.updateUniqueIDTmdbepisode(uniqueIDTmdbepisode);
680 private void requestPlayerPropertiesUpdate(int activePlayer) {
681 final String[] properties = { PROPERTY_SUBTITLEENABLED, PROPERTY_CURRENTSUBTITLE, PROPERTY_CURRENTAUDIOSTREAM,
682 PROPERTY_CURRENTVIDEOSTREAM, PROPERTY_PERCENTAGE, PROPERTY_TIME, PROPERTY_TOTALTIME,
683 PROPERTY_AUDIOSTREAMS, PROPERTY_SUBTITLES };
685 JsonObject params = new JsonObject();
686 params.addProperty("playerid", activePlayer);
687 params.add("properties", getJsonArray(properties));
688 JsonElement response = socket.callMethod("Player.GetProperties", params);
690 if (response instanceof JsonObject) {
691 JsonObject result = response.getAsJsonObject();
693 if (result.has(PROPERTY_AUDIOSTREAMS)) {
695 JsonElement audioGroup = result.get(PROPERTY_AUDIOSTREAMS);
696 if (audioGroup instanceof JsonArray) {
697 List<KodiAudioStream> audioStreamList = new ArrayList<>();
698 for (JsonElement element : audioGroup.getAsJsonArray()) {
699 KodiAudioStream audioStream = gson.fromJson(element, KodiAudioStream.class);
700 audioStreamList.add(audioStream);
702 listener.updateAudioStreamOptions(audioStreamList);
704 } catch (JsonSyntaxException e) {
709 if (result.has(PROPERTY_SUBTITLES)) {
711 JsonElement subtitleGroup = result.get(PROPERTY_SUBTITLES);
712 if (subtitleGroup instanceof JsonArray) {
713 List<KodiSubtitle> subtitleList = new ArrayList<>();
714 for (JsonElement element : subtitleGroup.getAsJsonArray()) {
715 KodiSubtitle subtitle = gson.fromJson(element, KodiSubtitle.class);
716 subtitleList.add(subtitle);
718 listener.updateSubtitleOptions(subtitleList);
720 } catch (JsonSyntaxException e) {
725 boolean subtitleEnabled = false;
726 if (result.has(PROPERTY_SUBTITLEENABLED)) {
727 subtitleEnabled = result.get(PROPERTY_SUBTITLEENABLED).getAsBoolean();
730 int subtitleIndex = -1;
731 String subtitleLanguage = null;
732 String subtitleName = null;
733 if (result.has(PROPERTY_CURRENTSUBTITLE)) {
735 KodiSubtitle subtitleStream = gson.fromJson(result.get(PROPERTY_CURRENTSUBTITLE),
737 if (subtitleStream != null) {
738 subtitleIndex = subtitleStream.getIndex();
739 subtitleLanguage = subtitleStream.getLanguage();
740 subtitleName = subtitleStream.getName();
742 } catch (JsonSyntaxException e) {
747 String audioCodec = null;
749 int audioChannels = 0;
750 String audioLanguage = null;
751 String audioName = null;
752 if (result.has(PROPERTY_CURRENTAUDIOSTREAM)) {
754 KodiAudioStream audioStream = gson.fromJson(result.get(PROPERTY_CURRENTAUDIOSTREAM),
755 KodiAudioStream.class);
756 if (audioStream != null) {
757 audioCodec = audioStream.getCodec();
758 audioIndex = audioStream.getIndex();
759 audioChannels = audioStream.getChannels();
760 audioLanguage = audioStream.getLanguage();
761 audioName = audioStream.getName();
763 } catch (JsonSyntaxException e) {
768 String videoCodec = null;
772 if (result.has(PROPERTY_CURRENTVIDEOSTREAM)) {
774 KodiVideoStream videoStream = gson.fromJson(result.get(PROPERTY_CURRENTVIDEOSTREAM),
775 KodiVideoStream.class);
776 if (videoStream != null) {
777 videoCodec = videoStream.getCodec();
778 videoWidth = videoStream.getWidth();
779 videoHeight = videoStream.getHeight();
780 videoIndex = videoStream.getIndex();
782 } catch (JsonSyntaxException e) {
787 double percentage = -1;
788 if (result.has(PROPERTY_PERCENTAGE)) {
789 percentage = result.get(PROPERTY_PERCENTAGE).getAsDouble();
792 long currentTime = -1;
793 if (result.has(PROPERTY_TIME)) {
795 KodiDuration time = gson.fromJson(result.get(PROPERTY_TIME), KodiDuration.class);
796 currentTime = time.toSeconds();
797 } catch (JsonSyntaxException e) {
803 if (result.has(PROPERTY_TOTALTIME)) {
805 KodiDuration totalTime = gson.fromJson(result.get(PROPERTY_TOTALTIME), KodiDuration.class);
806 duration = totalTime.toSeconds();
807 } catch (JsonSyntaxException e) {
812 listener.updateAudioCodec(audioCodec);
813 listener.updateAudioIndex(audioIndex);
814 listener.updateAudioName(audioName);
815 listener.updateAudioLanguage(audioLanguage);
816 listener.updateAudioChannels(audioChannels);
817 listener.updateVideoCodec(videoCodec);
818 listener.updateVideoIndex(videoIndex);
819 listener.updateVideoHeight(videoHeight);
820 listener.updateVideoWidth(videoWidth);
821 listener.updateSubtitleEnabled(subtitleEnabled);
822 listener.updateSubtitleIndex(subtitleIndex);
823 listener.updateSubtitleName(subtitleName);
824 listener.updateSubtitleLanguage(subtitleLanguage);
825 listener.updateCurrentTimePercentage(percentage);
826 listener.updateCurrentTime(currentTime);
827 listener.updateDuration(duration);
831 private JsonArray getJsonArray(String[] values) {
832 JsonArray result = new JsonArray();
833 for (String param : values) {
834 result.add(new JsonPrimitive(param));
839 private List<String> convertFromArrayToList(JsonArray data) {
840 List<String> list = new ArrayList<>();
841 for (JsonElement element : data) {
842 list.add(element.getAsString());
847 private @Nullable RawType getImageForElement(JsonElement element) {
848 String text = element.getAsString();
849 if (!text.isEmpty()) {
850 String url = stripImageUrl(text);
852 return downloadImageFromCache(url);
858 private @Nullable String stripImageUrl(String url) {
859 // we have to strip ending "/" here because Kodi returns a not valid path and filename
860 // "fanart":"image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f263365-31.jpg/"
861 // "thumbnail":"image://http%3a%2f%2fthetvdb.com%2fbanners%2fepisodes%2f263365%2f5640869.jpg/"
862 String encodedURL = URLEncoder.encode(stripEnd(url, '/'), StandardCharsets.UTF_8);
863 return imageUri.resolve(encodedURL).toString();
866 private String stripEnd(final String str, final char suffix) {
867 int end = str.length();
871 while (end > 0 && str.charAt(end - 1) == suffix) {
874 return str.substring(0, end);
877 private @Nullable RawType downloadImage(String url) {
878 logger.debug("Trying to download the content of URL '{}'", url);
879 RawType downloadedImage = HttpUtil.downloadImage(url);
880 if (downloadedImage == null) {
881 logger.debug("Failed to download the content of URL '{}'", url);
883 return downloadedImage;
886 private @Nullable RawType downloadImageFromCache(String url) {
887 if (IMAGE_CACHE.containsKey(url)) {
889 byte[] bytes = IMAGE_CACHE.get(url);
890 String contentType = HttpUtil.guessContentTypeFromData(bytes);
891 return new RawType(bytes,
892 contentType == null || contentType.isEmpty() ? RawType.DEFAULT_MIME_TYPE : contentType);
893 } catch (IOException e) {
894 logger.trace("Failed to download the content of URL '{}'", url, e);
897 RawType image = downloadImage(url);
899 IMAGE_CACHE.put(url, image.getBytes());
906 public KodiState getState() {
910 public KodiPlaylistState getPlaylistState() {
911 return currentPlaylistState;
914 private void updateState(KodiState state) {
915 // sometimes get a Pause immediately after a Stop - so just ignore
916 if (currentState.equals(KodiState.STOP) && state.equals(KodiState.PAUSE)) {
919 listener.updatePlayerState(state);
920 // if this is a Stop then clear everything else
921 if (state == KodiState.STOP) {
922 listener.updateAlbum("");
923 listener.updateTitle("");
924 listener.updateShowTitle("");
925 listener.updateArtistList(null);
926 listener.updateMediaType("");
927 listener.updateGenreList(null);
928 listener.updatePVRChannel("");
929 listener.updateThumbnail(null);
930 listener.updateFanart(null);
931 listener.updateCurrentTimePercentage(-1);
932 listener.updateCurrentTime(-1);
933 listener.updateDuration(-1);
934 listener.updateMediaID(-1);
935 listener.updateOriginalTitle("");
936 listener.updateSeason(-1);
937 listener.updateEpisode(-1);
938 listener.updateMediaFile("");
939 listener.updateMpaa("");
940 listener.updateRating(-1);
941 listener.updateUserRating(-1);
942 listener.updateUniqueIDDouban("");
943 listener.updateUniqueIDImdb("");
944 listener.updateUniqueIDTmdb("");
945 listener.updateUniqueIDImdbtvshow("");
946 listener.updateUniqueIDTmdbtvshow("");
947 listener.updateUniqueIDTmdbepisode("");
948 listener.updateAudioStreamOptions(new ArrayList<>());
949 listener.updateSubtitleOptions(new ArrayList<>());
950 listener.updateAudioCodec(null);
951 listener.updateVideoCodec(null);
952 listener.updateAudioIndex(-1);
953 listener.updateAudioName(null);
954 listener.updateAudioLanguage(null);
955 listener.updateAudioChannels(-1);
956 listener.updateVideoIndex(-1);
957 listener.updateVideoHeight(-1);
958 listener.updateVideoWidth(-1);
959 listener.updateSubtitleIndex(-1);
960 listener.updateSubtitleName(null);
961 listener.updateSubtitleLanguage(null);
963 // keep track of our current state
964 currentState = state;
968 public void handleEvent(JsonObject json) {
969 JsonElement methodElement = json.get("method");
971 if (methodElement != null) {
972 String method = methodElement.getAsString();
973 JsonObject params = json.get("params").getAsJsonObject();
974 if (method.startsWith("Player.On")) {
975 processPlayerStateChanged(method, params);
976 } else if (method.startsWith("Application.On")) {
977 processApplicationStateChanged(method, params);
978 } else if (method.startsWith("System.On")) {
979 processSystemStateChanged(method, params);
980 } else if (method.startsWith("GUI.OnScreensaver")) {
981 processScreensaverStateChanged(method, params);
982 } else if (method.startsWith("Playlist.On")) {
983 processPlaylistStateChanged(method, params);
985 logger.debug("Received unknown method: {}", method);
990 private void processPlayerStateChanged(String method, JsonObject json) {
991 if ("Player.OnPlay".equals(method)) {
992 // get the player id and make a new request for the media details
994 JsonObject data = json.get("data").getAsJsonObject();
995 JsonObject player = data.get("player").getAsJsonObject();
996 Integer playerId = player.get("playerid").getAsInt();
998 updateState(KodiState.PLAY);
1000 requestPlayerUpdate(playerId);
1001 } else if ("Player.OnPause".equals(method)) {
1002 updateState(KodiState.PAUSE);
1003 } else if ("Player.OnResume".equals(method)) {
1004 updateState(KodiState.PLAY);
1005 } else if ("Player.OnStop".equals(method)) {
1006 // get the end parameter and send an End state if true
1007 JsonObject data = json.get("data").getAsJsonObject();
1008 Boolean end = data.get("end").getAsBoolean();
1010 updateState(KodiState.END);
1012 updateState(KodiState.STOP);
1013 } else if ("Player.OnPropertyChanged".equals(method)) {
1014 logger.debug("Player.OnPropertyChanged");
1015 } else if ("Player.OnSpeedChanged".equals(method)) {
1016 JsonObject data = json.get("data").getAsJsonObject();
1017 JsonObject player = data.get("player").getAsJsonObject();
1018 int speed = player.get("speed").getAsInt();
1020 updateState(KodiState.PAUSE);
1021 } else if (speed == 1) {
1022 updateState(KodiState.PLAY);
1023 } else if (speed < 0) {
1024 updateState(KodiState.REWIND);
1025 } else if (speed > 1) {
1026 updateState(KodiState.FASTFORWARD);
1029 logger.debug("Unknown event from Kodi {}: {}", method, json);
1031 listener.updateConnectionState(true);
1034 private void processApplicationStateChanged(String method, JsonObject json) {
1035 if ("Application.OnVolumeChanged".equals(method)) {
1036 // get the player id and make a new request for the media details
1037 JsonObject data = json.get("data").getAsJsonObject();
1038 if (data.has(PROPERTY_VOLUME)) {
1039 volume = data.get(PROPERTY_VOLUME).getAsInt();
1040 listener.updateVolume(volume);
1042 if (data.has(PROPERTY_MUTED)) {
1043 boolean muted = data.get(PROPERTY_MUTED).getAsBoolean();
1044 listener.updateMuted(muted);
1047 logger.debug("Unknown event from Kodi {}: {}", method, json);
1049 listener.updateConnectionState(true);
1052 private void processSystemStateChanged(String method, JsonObject json) {
1053 if ("System.OnQuit".equals(method) || "System.OnRestart".equals(method) || "System.OnSleep".equals(method)) {
1054 listener.updateConnectionState(false);
1055 } else if ("System.OnWake".equals(method)) {
1056 listener.updateConnectionState(true);
1058 logger.debug("Unknown event from Kodi {}: {}", method, json);
1062 private void processScreensaverStateChanged(String method, JsonObject json) {
1063 if ("GUI.OnScreensaverDeactivated".equals(method)) {
1064 listener.updateScreenSaverState(false);
1065 } else if ("GUI.OnScreensaverActivated".equals(method)) {
1066 listener.updateScreenSaverState(true);
1068 logger.debug("Unknown event from Kodi {}: {}", method, json);
1070 listener.updateConnectionState(true);
1073 private void processPlaylistStateChanged(String method, JsonObject json) {
1074 if ("Playlist.OnAdd".equals(method)) {
1075 currentPlaylistState = KodiPlaylistState.ADDED;
1077 listener.updatePlaylistState(KodiPlaylistState.ADDED);
1078 } else if ("Playlist.OnRemove".equals(method)) {
1079 currentPlaylistState = KodiPlaylistState.REMOVED;
1081 listener.updatePlaylistState(KodiPlaylistState.REMOVED);
1083 logger.debug("Unknown event from Kodi {}: {}", method, json);
1085 listener.updateConnectionState(true);
1088 public synchronized void close() {
1089 if (socket != null && socket.isConnected()) {
1094 public void updateVolume() {
1095 if (socket.isConnected()) {
1096 String[] props = { PROPERTY_VOLUME, PROPERTY_MUTED };
1098 JsonObject params = new JsonObject();
1099 params.add("properties", getJsonArray(props));
1100 JsonElement response = socket.callMethod("Application.GetProperties", params);
1102 if (response instanceof JsonObject) {
1103 JsonObject data = response.getAsJsonObject();
1104 if (data.has(PROPERTY_VOLUME)) {
1105 volume = data.get(PROPERTY_VOLUME).getAsInt();
1106 listener.updateVolume(volume);
1108 if (data.has(PROPERTY_MUTED)) {
1109 boolean muted = data.get(PROPERTY_MUTED).getAsBoolean();
1110 listener.updateMuted(muted);
1114 listener.updateVolume(100);
1115 listener.updateMuted(false);
1119 public void updateCurrentProfile() {
1120 if (socket.isConnected()) {
1121 JsonElement response = socket.callMethod("Profiles.GetCurrentProfile");
1124 final KodiProfile profile = gson.fromJson(response, KodiProfile.class);
1125 if (profile != null) {
1126 listener.updateCurrentProfile(profile.getLabel());
1128 } catch (JsonSyntaxException e) {
1129 logger.debug("Json syntax exception occurred: {}", e.getMessage(), e);
1134 public synchronized void playURI(String uri) {
1135 String fileUri = uri;
1136 JsonObject item = new JsonObject();
1137 JsonObject options = null;
1139 if (uri.contains(TIMESTAMP_FRAGMENT)) {
1140 fileUri = uri.substring(0, uri.indexOf(TIMESTAMP_FRAGMENT));
1141 String timestamp = uri.substring(uri.indexOf(TIMESTAMP_FRAGMENT) + TIMESTAMP_FRAGMENT.length());
1143 int s = Integer.parseInt(timestamp);
1144 options = new JsonObject();
1145 options.add("resume", timeValueFromSeconds(s));
1146 } catch (NumberFormatException e) {
1147 logger.warn("Illegal parameter for timestamp - it must be an integer: {}", timestamp);
1150 item.addProperty("file", fileUri);
1151 playInternal(item, options);
1154 public synchronized List<KodiPVRChannelGroup> getPVRChannelGroups(final String pvrChannelType) {
1155 String method = "PVR.GetChannelGroups";
1156 String hash = hostname + '#' + method + "#channeltype=" + pvrChannelType;
1157 JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
1158 JsonObject params = new JsonObject();
1159 params.addProperty("channeltype", pvrChannelType);
1160 return socket.callMethod(method, params);
1163 List<KodiPVRChannelGroup> pvrChannelGroups = new ArrayList<>();
1164 if (response instanceof JsonObject) {
1165 JsonObject result = response.getAsJsonObject();
1166 if (result.has("channelgroups")) {
1167 JsonElement channelgroups = result.get("channelgroups");
1168 if (channelgroups instanceof JsonArray) {
1169 for (JsonElement element : channelgroups.getAsJsonArray()) {
1170 JsonObject object = (JsonObject) element;
1171 KodiPVRChannelGroup pvrChannelGroup = new KodiPVRChannelGroup();
1172 pvrChannelGroup.setId(object.get("channelgroupid").getAsInt());
1173 pvrChannelGroup.setLabel(object.get("label").getAsString());
1174 pvrChannelGroup.setChannelType(pvrChannelType);
1175 pvrChannelGroups.add(pvrChannelGroup);
1180 return pvrChannelGroups;
1183 public int getPVRChannelGroupId(final String channelType, final String pvrChannelGroupName) {
1184 List<KodiPVRChannelGroup> pvrChannelGroups = getPVRChannelGroups(channelType);
1185 for (KodiPVRChannelGroup pvrChannelGroup : pvrChannelGroups) {
1186 String label = pvrChannelGroup.getLabel();
1187 if (pvrChannelGroupName.equalsIgnoreCase(label)) {
1188 return pvrChannelGroup.getId();
1191 // if we don't find a matching PVR channel group return the first (which is the default: "All channels")
1192 return pvrChannelGroups.isEmpty() ? 0 : pvrChannelGroups.get(0).getId();
1195 public synchronized List<KodiPVRChannel> getPVRChannels(final int pvrChannelGroupId) {
1196 String method = "PVR.GetChannels";
1197 String hash = hostname + '#' + method + "#channelgroupid=" + pvrChannelGroupId;
1198 JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
1199 JsonObject params = new JsonObject();
1200 params.addProperty("channelgroupid", pvrChannelGroupId);
1201 return socket.callMethod(method, params);
1204 List<KodiPVRChannel> pvrChannels = new ArrayList<>();
1205 if (response instanceof JsonObject) {
1206 JsonObject result = response.getAsJsonObject();
1207 if (result.has("channels")) {
1208 JsonElement channels = result.get("channels");
1209 if (channels instanceof JsonArray) {
1210 for (JsonElement element : channels.getAsJsonArray()) {
1211 JsonObject object = (JsonObject) element;
1212 KodiPVRChannel pvrChannel = new KodiPVRChannel();
1213 pvrChannel.setId(object.get("channelid").getAsInt());
1214 pvrChannel.setLabel(object.get("label").getAsString());
1215 pvrChannel.setChannelGroupId(pvrChannelGroupId);
1216 pvrChannels.add(pvrChannel);
1224 public int getPVRChannelId(final int pvrChannelGroupId, final String pvrChannelName) {
1225 for (KodiPVRChannel pvrChannel : getPVRChannels(pvrChannelGroupId)) {
1226 String label = pvrChannel.getLabel();
1227 if (pvrChannelName.equalsIgnoreCase(label)) {
1228 return pvrChannel.getId();
1234 public synchronized void playPVRChannel(final int pvrChannelId) {
1235 JsonObject item = new JsonObject();
1236 item.addProperty("channelid", pvrChannelId);
1238 playInternal(item, null);
1241 private void playInternal(JsonObject item, JsonObject options) {
1242 JsonObject params = new JsonObject();
1243 params.add("item", item);
1244 if (options != null) {
1245 params.add("options", options);
1247 socket.callMethod("Player.Open", params);
1250 public synchronized void showNotification(String title, BigDecimal displayTime, String icon, String message) {
1251 JsonObject params = new JsonObject();
1252 params.addProperty("message", message);
1253 if (title != null) {
1254 params.addProperty("title", title);
1256 if (displayTime != null) {
1257 params.addProperty("displaytime", displayTime.longValue());
1260 params.addProperty("image", callbackUrl + "/icon/" + icon.toLowerCase() + ".png");
1262 socket.callMethod("GUI.ShowNotification", params);
1265 public boolean checkConnection() {
1266 if (!socket.isConnected()) {
1267 logger.debug("checkConnection: try to connect to Kodi {}", wsUri);
1270 return socket.isConnected();
1271 } catch (IOException e) {
1272 logger.debug("exception during connect to {}", wsUri, e);
1277 // Ping Kodi with the get version command. This prevents the idle timeout on the web socket.
1278 return !getVersion().isEmpty();
1282 public String getConnectionName() {
1283 return wsUri.toString();
1286 public String getVersion() {
1287 if (socket.isConnected()) {
1288 String[] props = { PROPERTY_VERSION };
1290 JsonObject params = new JsonObject();
1291 params.add("properties", getJsonArray(props));
1292 JsonElement response = socket.callMethod("Application.GetProperties", params);
1294 if (response instanceof JsonObject) {
1295 JsonObject result = response.getAsJsonObject();
1296 if (result.has(PROPERTY_VERSION)) {
1297 JsonObject version = result.get(PROPERTY_VERSION).getAsJsonObject();
1298 int major = version.get("major").getAsInt();
1299 int minor = version.get("minor").getAsInt();
1300 String revision = version.get("revision").getAsString();
1301 return String.format("%d.%d (%s)", major, minor, revision);
1308 public void input(String key) {
1309 socket.callMethod("Input." + key);
1312 public void inputText(String text) {
1313 JsonObject params = new JsonObject();
1314 params.addProperty("text", text);
1315 socket.callMethod("Input.SendText", params);
1318 public void inputAction(String action) {
1319 JsonObject params = new JsonObject();
1320 params.addProperty("action", action);
1321 socket.callMethod("Input.ExecuteAction", params);
1324 public void inputButtonEvent(String buttonEvent) {
1325 logger.debug("inputButtonEvent {}.", buttonEvent);
1327 String button = buttonEvent;
1328 String keymap = "KB";
1329 Integer holdtime = null;
1331 if (buttonEvent.contains(";")) {
1332 String[] params = buttonEvent.split(";");
1333 switch (params.length) {
1342 holdtime = Integer.parseInt(params[2]);
1343 } catch (NumberFormatException nfe) {
1350 this.inputButtonEvent(button, keymap, holdtime);
1353 private void inputButtonEvent(String button, String keymap, Integer holdtime) {
1354 JsonObject params = new JsonObject();
1355 params.addProperty("button", button);
1356 params.addProperty("keymap", keymap);
1357 if (holdtime != null) {
1358 params.addProperty("holdtime", holdtime.intValue());
1360 JsonElement result = socket.callMethod("Input.ButtonEvent", params);
1361 logger.debug("inputButtonEvent result {}.", result);
1364 public void getSystemProperties() {
1365 KodiSystemProperties systemProperties = null;
1366 if (socket.isConnected()) {
1367 String[] props = { PROPERTY_CANHIBERNATE, PROPERTY_CANREBOOT, PROPERTY_CANSHUTDOWN, PROPERTY_CANSUSPEND };
1369 JsonObject params = new JsonObject();
1370 params.add("properties", getJsonArray(props));
1371 JsonElement response = socket.callMethod("System.GetProperties", params);
1374 systemProperties = gson.fromJson(response, KodiSystemProperties.class);
1375 } catch (JsonSyntaxException e) {
1379 listener.updateSystemProperties(systemProperties);
1382 public void sendApplicationQuit() {
1383 String method = "Application.Quit";
1384 socket.callMethod(method);
1387 public void sendSystemCommand(String command) {
1388 String method = "System." + command;
1389 socket.callMethod(method);
1392 public void profile(String profile) {
1393 JsonObject params = new JsonObject();
1394 params.addProperty("profile", profile);
1395 socket.callMethod("Profiles.LoadProfile", params);
1398 public KodiProfile[] getProfiles() {
1399 KodiProfile[] profiles = new KodiProfile[0];
1400 if (socket.isConnected()) {
1401 JsonElement response = socket.callMethod("Profiles.GetProfiles");
1404 JsonObject profilesJson = response.getAsJsonObject();
1405 profiles = gson.fromJson(profilesJson.get("profiles"), KodiProfile[].class);
1406 } catch (JsonSyntaxException e) {
1407 logger.debug("Json syntax exception occurred: {}", e.getMessage(), e);
1413 public void setTime(int time) {
1415 JsonObject params = new JsonObject();
1416 params.addProperty("playerid", 1);
1417 JsonObject value = new JsonObject();
1418 JsonObject timeValue = timeValueFromSeconds(seconds);
1420 value.add("time", timeValue);
1421 params.add("value", value);
1422 socket.callMethod("Player.Seek", params);
1425 private JsonObject timeValueFromSeconds(int seconds) {
1426 JsonObject timeValue = new JsonObject();
1430 int hours = s / 3600;
1431 timeValue.addProperty("hours", hours);
1435 int minutes = s / 60;
1436 timeValue.addProperty("minutes", minutes);
1439 timeValue.addProperty("seconds", s);