2 * Copyright (c) 2010-2023 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_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";
88 private final Logger logger = LoggerFactory.getLogger(KodiConnection.class);
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));
98 private final Gson gson = new Gson();
100 private String hostname;
102 private URI imageUri;
103 private KodiClientSocket socket;
105 private int volume = 0;
106 private KodiState currentState = KodiState.STOP;
107 private KodiPlaylistState currentPlaylistState = KodiPlaylistState.CLEAR;
109 private final KodiEventListener listener;
110 private final WebSocketClient webSocketClient;
111 private final String callbackUrl;
113 public KodiConnection(KodiEventListener listener, WebSocketClient webSocketClient, String callbackUrl) {
114 this.listener = listener;
115 this.webSocketClient = webSocketClient;
116 this.callbackUrl = callbackUrl;
120 public synchronized void onConnectionClosed() {
121 listener.updateConnectionState(false);
125 public synchronized void onConnectionOpened() {
126 listener.updateConnectionState(true);
129 public synchronized void connect(String hostname, int port, ScheduledExecutorService scheduler, URI imageUri) {
130 this.hostname = hostname;
131 this.imageUri = imageUri;
134 wsUri = new URI("ws", null, hostname, port, "/jsonrpc", null, null);
135 socket = new KodiClientSocket(this, wsUri, scheduler, webSocketClient);
137 } catch (URISyntaxException e) {
138 logger.warn("exception during constructing URI host={}, port={}", hostname, port, e);
142 private int getActivePlayer() {
143 JsonElement response = socket.callMethod("Player.GetActivePlayers");
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();
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) {
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();
184 private synchronized JsonArray getPlaylistsInternal() {
185 String method = "Playlist.GetPlaylists";
186 String hash = hostname + '#' + method;
187 JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
188 return socket.callMethod(method);
191 if (response instanceof JsonArray) {
192 return response.getAsJsonArray();
198 private synchronized JsonObject getPlaylistItemsInternal(int playlistID) {
199 JsonObject params = new JsonObject();
200 params.addProperty("playlistid", playlistID);
201 JsonElement response = socket.callMethod("Playlist.GetItems", params);
203 if (response instanceof JsonObject) {
204 return response.getAsJsonObject();
210 public synchronized void playerPlayPause() {
211 int activePlayer = getActivePlayer();
213 JsonObject params = new JsonObject();
214 params.addProperty("playerid", activePlayer);
215 socket.callMethod("Player.PlayPause", params);
218 public synchronized void playerStop() {
219 int activePlayer = getActivePlayer();
221 JsonObject params = new JsonObject();
222 params.addProperty("playerid", activePlayer);
223 socket.callMethod("Player.Stop", params);
226 public synchronized void playerNext() {
227 goToInternal("next");
229 updatePlayerStatus();
232 public synchronized void playerPrevious() {
233 goToInternal("previous");
235 updatePlayerStatus();
238 private void goToInternal(String to) {
239 int activePlayer = getActivePlayer();
241 JsonObject params = new JsonObject();
242 params.addProperty("playerid", activePlayer);
243 params.addProperty("to", to);
244 socket.callMethod("Player.GoTo", params);
247 public synchronized void playerRewind() {
248 setSpeedInternal(calcNextSpeed(-1));
250 updatePlayerStatus();
253 public synchronized void playerFastForward() {
254 setSpeedInternal(calcNextSpeed(1));
256 updatePlayerStatus();
259 private int calcNextSpeed(int modifier) {
260 int activePlayer = getActivePlayer();
261 if (activePlayer >= 0) {
262 int position = SPEEDS.indexOf(getSpeed(activePlayer));
263 if (position == -1) {
265 } else if (position == 0 || position == (SPEEDS.size() - 1)) {
266 return SPEEDS.get(position);
268 return SPEEDS.get(position + modifier);
275 private void setSpeedInternal(int speed) {
276 int activePlayer = getActivePlayer();
278 JsonObject params = new JsonObject();
279 params.addProperty("playerid", activePlayer);
280 params.addProperty("speed", speed);
281 socket.callMethod("Player.SetSpeed", params);
284 public synchronized void playlistAdd(int playlistID, String uri) {
285 currentPlaylistState = KodiPlaylistState.ADD;
287 JsonObject item = new JsonObject();
288 item.addProperty("file", uri);
290 JsonObject params = new JsonObject();
291 params.addProperty("playlistid", playlistID);
292 params.add("item", item);
293 socket.callMethod("Playlist.Add", params);
296 public synchronized void playlistClear(int playlistID) {
297 currentPlaylistState = KodiPlaylistState.CLEAR;
299 JsonObject params = new JsonObject();
300 params.addProperty("playlistid", playlistID);
301 socket.callMethod("Playlist.Clear", params);
304 public synchronized void playlistInsert(int playlistID, String uri, int position) {
305 currentPlaylistState = KodiPlaylistState.INSERT;
307 JsonObject item = new JsonObject();
308 item.addProperty("file", uri);
310 JsonObject params = new JsonObject();
311 params.addProperty("playlistid", playlistID);
312 params.addProperty("position", position);
313 params.add("item", item);
314 socket.callMethod("Playlist.Insert", params);
317 public synchronized void playlistPlay(int playlistID, int position) {
318 JsonObject item = new JsonObject();
319 item.addProperty("playlistid", playlistID);
320 item.addProperty("position", position);
322 playInternal(item, null);
325 public synchronized void playlistRemove(int playlistID, int position) {
326 currentPlaylistState = KodiPlaylistState.REMOVE;
328 JsonObject params = new JsonObject();
329 params.addProperty("playlistid", playlistID);
330 params.addProperty("position", position);
331 socket.callMethod("Playlist.Remove", params);
335 * Retrieves a list of favorites from the Kodi instance. The result is cached.
337 * @return a list of {@link KodiFavorite}
339 public synchronized List<KodiFavorite> getFavorites() {
340 String method = "Favourites.GetFavourites";
341 String hash = hostname + '#' + method;
342 JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
343 final String[] properties = { "path", "window", "windowparameter" };
345 JsonObject params = new JsonObject();
346 params.add("properties", getJsonArray(properties));
347 return socket.callMethod(method, params);
350 List<KodiFavorite> favorites = new ArrayList<>();
351 if (response instanceof JsonObject) {
352 JsonObject result = response.getAsJsonObject();
353 if (result.has("favourites")) {
354 JsonElement favourites = result.get("favourites");
355 if (favourites instanceof JsonArray) {
356 for (JsonElement element : favourites.getAsJsonArray()) {
357 JsonObject object = (JsonObject) element;
358 KodiFavorite favorite = new KodiFavorite(object.get("title").getAsString());
359 favorite.setFavoriteType(object.get("type").getAsString());
360 if (object.has("path")) {
361 favorite.setPath(object.get("path").getAsString());
363 if (object.has("window")) {
364 favorite.setWindow(object.get("window").getAsString());
365 favorite.setWindowParameter(object.get("windowparameter").getAsString());
367 favorites.add(favorite);
376 * Returns the favorite with the given title or null.
378 * @param favoriteTitle the title of the favorite
379 * @return the ({@link KodiFavorite}) with the given title
381 public @Nullable KodiFavorite getFavorite(final String favoriteTitle) {
382 for (KodiFavorite favorite : getFavorites()) {
383 String title = favorite.getTitle();
384 if (favoriteTitle.equalsIgnoreCase(title)) {
392 * Activates the given window.
394 * @param window the window
396 public synchronized void activateWindow(final String window) {
397 activateWindow(window, null);
401 * Activates the given window.
403 * @param window the window
404 * @param windowParameter list of parameters of the window
406 public synchronized void activateWindow(final String window, @Nullable final String[] windowParameter) {
407 JsonObject params = new JsonObject();
408 params.addProperty("window", window);
409 if (windowParameter != null) {
410 params.add("parameters", getJsonArray(windowParameter));
412 socket.callMethod("GUI.ActivateWindow", params);
415 public synchronized void increaseVolume() {
416 setVolumeInternal(this.volume + VOLUMESTEP);
419 public synchronized void decreaseVolume() {
420 setVolumeInternal(this.volume - VOLUMESTEP);
423 public synchronized void setVolume(int volume) {
424 setVolumeInternal(volume);
427 private void setVolumeInternal(int volume) {
428 JsonObject params = new JsonObject();
429 params.addProperty(PROPERTY_VOLUME, volume);
430 socket.callMethod("Application.SetVolume", params);
433 public int getVolume() {
437 public synchronized void setMute(boolean mute) {
438 JsonObject params = new JsonObject();
439 params.addProperty("mute", mute);
440 socket.callMethod("Application.SetMute", params);
443 public synchronized void setAudioStream(int stream) {
444 JsonObject params = new JsonObject();
445 params.addProperty("stream", stream);
446 int activePlayer = getActivePlayer();
447 params.addProperty("playerid", activePlayer);
448 socket.callMethod("Player.SetAudioStream", params);
451 public synchronized void setVideoStream(int stream) {
452 JsonObject params = new JsonObject();
453 params.addProperty("stream", stream);
454 int activePlayer = getActivePlayer();
455 params.addProperty("playerid", activePlayer);
456 socket.callMethod("Player.SetVideoStream", params);
459 public synchronized void setSubtitle(int subtitle) {
460 JsonObject params = new JsonObject();
461 params.addProperty("subtitle", subtitle);
462 int activePlayer = getActivePlayer();
463 params.addProperty("playerid", activePlayer);
464 socket.callMethod("Player.SetSubtitle", params);
467 public synchronized void setSubtitleEnabled(boolean subtitleenabled) {
468 JsonObject params = new JsonObject();
469 params.addProperty("subtitle", subtitleenabled ? "on" : "off");
470 int activePlayer = getActivePlayer();
471 params.addProperty("playerid", activePlayer);
472 socket.callMethod("Player.SetSubtitle", params);
475 private int getSpeed(int activePlayer) {
476 final String[] properties = { "speed" };
478 JsonObject params = new JsonObject();
479 params.addProperty("playerid", activePlayer);
480 params.add("properties", getJsonArray(properties));
481 JsonElement response = socket.callMethod("Player.GetProperties", params);
483 if (response instanceof JsonObject) {
484 JsonObject result = response.getAsJsonObject();
485 if (result.has("speed")) {
486 return result.get("speed").getAsInt();
492 public synchronized void updatePlayerStatus() {
493 if (socket.isConnected()) {
494 int activePlayer = getActivePlayer();
495 if (activePlayer >= 0) {
496 int speed = getSpeed(activePlayer);
498 updateState(KodiState.STOP);
499 } else if (speed == 1) {
500 updateState(KodiState.PLAY);
501 } else if (speed < 0) {
502 updateState(KodiState.REWIND);
504 updateState(KodiState.FASTFORWARD);
506 requestPlayerUpdate(activePlayer);
508 updateState(KodiState.STOP);
513 private void requestPlayerUpdate(int activePlayer) {
514 requestPlayerPropertiesUpdate(activePlayer);
515 requestPlayerItemUpdate(activePlayer);
518 private void requestPlayerItemUpdate(int activePlayer) {
519 final String[] properties = { PROPERTY_UNIQUEID, "title", "originaltitle", "album", "artist", "track",
520 "director", PROPERTY_THUMBNAIL, PROPERTY_FANART, "file", "showtitle", "season", "episode", "channel",
521 "channeltype", "genre", "mpaa", "rating", "votes", "userrating" };
523 JsonObject params = new JsonObject();
524 params.addProperty("playerid", activePlayer);
525 params.add("properties", getJsonArray(properties));
526 JsonElement response = socket.callMethod("Player.GetItem", params);
528 if (response instanceof JsonObject) {
529 JsonObject result = response.getAsJsonObject();
530 if (result.has("item")) {
531 JsonObject item = result.get("item").getAsJsonObject();
534 if (item.has("id")) {
535 mediaid = item.get("id").getAsInt();
539 if (item.has("rating")) {
540 rating = item.get("rating").getAsDouble();
543 double userrating = -1;
544 if (item.has("userrating")) {
545 userrating = item.get("userrating").getAsDouble();
549 if (item.has("mpaa")) {
550 mpaa = item.get("mpaa").getAsString();
553 String mediafile = "";
554 if (item.has("file")) {
555 mediafile = item.get("file").getAsString();
558 String uniqueIDDouban = "";
559 String uniqueIDImdb = "";
560 String uniqueIDTmdb = "";
561 String uniqueIDImdbtvshow = "";
562 String uniqueIDTmdbtvshow = "";
563 String uniqueIDTmdbepisode = "";
565 if (item.has(PROPERTY_UNIQUEID)) {
567 KodiUniqueID uniqueID = gson.fromJson(item.get(PROPERTY_UNIQUEID), KodiUniqueID.class);
568 if (uniqueID != null) {
569 uniqueIDImdb = uniqueID.getImdb();
570 uniqueIDDouban = uniqueID.getDouban();
571 uniqueIDTmdb = uniqueID.getTmdb();
572 uniqueIDImdbtvshow = uniqueID.getImdbtvshow();
573 uniqueIDTmdbtvshow = uniqueID.getTmdbtvshow();
574 uniqueIDTmdbepisode = uniqueID.getTmdbepisode();
576 } catch (JsonSyntaxException e) {
581 String originaltitle = "";
582 if (item.has("originaltitle")) {
583 originaltitle = item.get("originaltitle").getAsString();
587 if (item.has("title")) {
588 title = item.get("title").getAsString();
590 if (title.isEmpty()) {
591 title = item.get("label").getAsString();
594 String showTitle = "";
595 if (item.has("showtitle")) {
596 showTitle = item.get("showtitle").getAsString();
600 if (item.has("season")) {
601 season = item.get("season").getAsInt();
605 if (item.has("episode")) {
606 episode = item.get("episode").getAsInt();
610 if (item.has("album")) {
611 album = item.get("album").getAsString();
614 String mediaType = item.get("type").getAsString();
615 if ("channel".equals(mediaType) && item.has("channeltype")) {
616 String channelType = item.get("channeltype").getAsString();
617 if ("radio".equals(channelType)) {
622 List<String> artistList = null;
623 if ("movie".equals(mediaType) && item.has("director")) {
624 artistList = convertFromArrayToList(item.get("director").getAsJsonArray());
626 if (item.has("artist")) {
627 artistList = convertFromArrayToList(item.get("artist").getAsJsonArray());
631 List<String> genreList = null;
632 if (item.has("genre")) {
633 JsonElement genre = item.get("genre");
634 if (genre instanceof JsonArray) {
635 genreList = convertFromArrayToList(genre.getAsJsonArray());
640 if (item.has("channel")) {
641 channel = item.get("channel").getAsString();
644 RawType thumbnail = null;
645 if (item.has(PROPERTY_THUMBNAIL)) {
646 thumbnail = getImageForElement(item.get(PROPERTY_THUMBNAIL));
649 RawType fanart = null;
650 if (item.has(PROPERTY_FANART)) {
651 fanart = getImageForElement(item.get(PROPERTY_FANART));
654 listener.updateMediaID(mediaid);
655 listener.updateAlbum(album);
656 listener.updateTitle(title);
657 listener.updateOriginalTitle(originaltitle);
658 listener.updateShowTitle(showTitle);
659 listener.updateArtistList(artistList);
660 listener.updateMediaType(mediaType);
661 listener.updateGenreList(genreList);
662 listener.updatePVRChannel(channel);
663 listener.updateThumbnail(thumbnail);
664 listener.updateFanart(fanart);
665 listener.updateSeason(season);
666 listener.updateEpisode(episode);
667 listener.updateMediaFile(mediafile);
668 listener.updateMpaa(mpaa);
669 listener.updateRating(rating);
670 listener.updateUserRating(userrating);
671 listener.updateUniqueIDDouban(uniqueIDDouban);
672 listener.updateUniqueIDImdb(uniqueIDImdb);
673 listener.updateUniqueIDTmdb(uniqueIDTmdb);
674 listener.updateUniqueIDImdbtvshow(uniqueIDImdbtvshow);
675 listener.updateUniqueIDTmdbtvshow(uniqueIDTmdbtvshow);
676 listener.updateUniqueIDTmdbepisode(uniqueIDTmdbepisode);
681 private void requestPlayerPropertiesUpdate(int activePlayer) {
682 final String[] properties = { PROPERTY_SUBTITLEENABLED, PROPERTY_CURRENTSUBTITLE, PROPERTY_CURRENTAUDIOSTREAM,
683 PROPERTY_CURRENTVIDEOSTREAM, PROPERTY_PERCENTAGE, PROPERTY_TIME, PROPERTY_TOTALTIME,
684 PROPERTY_AUDIOSTREAMS, PROPERTY_SUBTITLES };
686 JsonObject params = new JsonObject();
687 params.addProperty("playerid", activePlayer);
688 params.add("properties", getJsonArray(properties));
689 JsonElement response = socket.callMethod("Player.GetProperties", params);
691 if (response instanceof JsonObject) {
692 JsonObject result = response.getAsJsonObject();
694 if (result.has(PROPERTY_AUDIOSTREAMS)) {
696 JsonElement audioGroup = result.get(PROPERTY_AUDIOSTREAMS);
697 if (audioGroup instanceof JsonArray) {
698 List<KodiAudioStream> audioStreamList = new ArrayList<>();
699 for (JsonElement element : audioGroup.getAsJsonArray()) {
700 KodiAudioStream audioStream = gson.fromJson(element, KodiAudioStream.class);
701 audioStreamList.add(audioStream);
703 listener.updateAudioStreamOptions(audioStreamList);
705 } catch (JsonSyntaxException e) {
710 if (result.has(PROPERTY_SUBTITLES)) {
712 JsonElement subtitleGroup = result.get(PROPERTY_SUBTITLES);
713 if (subtitleGroup instanceof JsonArray) {
714 List<KodiSubtitle> subtitleList = new ArrayList<>();
715 for (JsonElement element : subtitleGroup.getAsJsonArray()) {
716 KodiSubtitle subtitle = gson.fromJson(element, KodiSubtitle.class);
717 subtitleList.add(subtitle);
719 listener.updateSubtitleOptions(subtitleList);
721 } catch (JsonSyntaxException e) {
726 boolean subtitleEnabled = false;
727 if (result.has(PROPERTY_SUBTITLEENABLED)) {
728 subtitleEnabled = result.get(PROPERTY_SUBTITLEENABLED).getAsBoolean();
731 int subtitleIndex = -1;
732 String subtitleLanguage = null;
733 String subtitleName = null;
734 if (result.has(PROPERTY_CURRENTSUBTITLE)) {
736 KodiSubtitle subtitleStream = gson.fromJson(result.get(PROPERTY_CURRENTSUBTITLE),
738 if (subtitleStream != null) {
739 subtitleIndex = subtitleStream.getIndex();
740 subtitleLanguage = subtitleStream.getLanguage();
741 subtitleName = subtitleStream.getName();
743 } catch (JsonSyntaxException e) {
748 String audioCodec = null;
750 int audioChannels = 0;
751 String audioLanguage = null;
752 String audioName = null;
753 if (result.has(PROPERTY_CURRENTAUDIOSTREAM)) {
755 KodiAudioStream audioStream = gson.fromJson(result.get(PROPERTY_CURRENTAUDIOSTREAM),
756 KodiAudioStream.class);
757 if (audioStream != null) {
758 audioCodec = audioStream.getCodec();
759 audioIndex = audioStream.getIndex();
760 audioChannels = audioStream.getChannels();
761 audioLanguage = audioStream.getLanguage();
762 audioName = audioStream.getName();
764 } catch (JsonSyntaxException e) {
769 String videoCodec = null;
773 if (result.has(PROPERTY_CURRENTVIDEOSTREAM)) {
775 KodiVideoStream videoStream = gson.fromJson(result.get(PROPERTY_CURRENTVIDEOSTREAM),
776 KodiVideoStream.class);
777 if (videoStream != null) {
778 videoCodec = videoStream.getCodec();
779 videoWidth = videoStream.getWidth();
780 videoHeight = videoStream.getHeight();
781 videoIndex = videoStream.getIndex();
783 } catch (JsonSyntaxException e) {
788 double percentage = -1;
789 if (result.has(PROPERTY_PERCENTAGE)) {
790 percentage = result.get(PROPERTY_PERCENTAGE).getAsDouble();
793 long currentTime = -1;
794 if (result.has(PROPERTY_TIME)) {
796 KodiDuration time = gson.fromJson(result.get(PROPERTY_TIME), KodiDuration.class);
797 currentTime = time.toSeconds();
798 } catch (JsonSyntaxException e) {
804 if (result.has(PROPERTY_TOTALTIME)) {
806 KodiDuration totalTime = gson.fromJson(result.get(PROPERTY_TOTALTIME), KodiDuration.class);
807 duration = totalTime.toSeconds();
808 } catch (JsonSyntaxException e) {
813 listener.updateAudioCodec(audioCodec);
814 listener.updateAudioIndex(audioIndex);
815 listener.updateAudioName(audioName);
816 listener.updateAudioLanguage(audioLanguage);
817 listener.updateAudioChannels(audioChannels);
818 listener.updateVideoCodec(videoCodec);
819 listener.updateVideoIndex(videoIndex);
820 listener.updateVideoHeight(videoHeight);
821 listener.updateVideoWidth(videoWidth);
822 listener.updateSubtitleEnabled(subtitleEnabled);
823 listener.updateSubtitleIndex(subtitleIndex);
824 listener.updateSubtitleName(subtitleName);
825 listener.updateSubtitleLanguage(subtitleLanguage);
826 listener.updateCurrentTimePercentage(percentage);
827 listener.updateCurrentTime(currentTime);
828 listener.updateDuration(duration);
832 private JsonArray getJsonArray(String[] values) {
833 JsonArray result = new JsonArray();
834 for (String param : values) {
835 result.add(new JsonPrimitive(param));
840 private List<String> convertFromArrayToList(JsonArray data) {
841 List<String> list = new ArrayList<>();
842 for (JsonElement element : data) {
843 list.add(element.getAsString());
848 private @Nullable RawType getImageForElement(JsonElement element) {
849 String text = element.getAsString();
850 if (!text.isEmpty()) {
851 String url = stripImageUrl(text);
853 return downloadImageFromCache(url);
859 private @Nullable String stripImageUrl(String url) {
860 // we have to strip ending "/" here because Kodi returns a not valid path and filename
861 // "fanart":"image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f263365-31.jpg/"
862 // "thumbnail":"image://http%3a%2f%2fthetvdb.com%2fbanners%2fepisodes%2f263365%2f5640869.jpg/"
863 String encodedURL = URLEncoder.encode(stripEnd(url, '/'), StandardCharsets.UTF_8);
864 return imageUri.resolve(encodedURL).toString();
867 private String stripEnd(final String str, final char suffix) {
868 int end = str.length();
872 while (end > 0 && str.charAt(end - 1) == suffix) {
875 return str.substring(0, end);
878 private @Nullable RawType downloadImage(String url) {
879 logger.debug("Trying to download the content of URL '{}'", url);
880 RawType downloadedImage = HttpUtil.downloadImage(url);
881 if (downloadedImage == null) {
882 logger.debug("Failed to download the content of URL '{}'", url);
884 return downloadedImage;
887 private @Nullable RawType downloadImageFromCache(String url) {
888 if (IMAGE_CACHE.containsKey(url)) {
890 byte[] bytes = IMAGE_CACHE.get(url);
891 String contentType = HttpUtil.guessContentTypeFromData(bytes);
892 return new RawType(bytes,
893 contentType == null || contentType.isEmpty() ? RawType.DEFAULT_MIME_TYPE : contentType);
894 } catch (IOException e) {
895 logger.trace("Failed to download the content of URL '{}'", url, e);
898 RawType image = downloadImage(url);
900 IMAGE_CACHE.put(url, image.getBytes());
907 public KodiState getState() {
911 public KodiPlaylistState getPlaylistState() {
912 return currentPlaylistState;
915 private void updateState(KodiState state) {
916 // sometimes get a Pause immediately after a Stop - so just ignore
917 if (currentState.equals(KodiState.STOP) && state.equals(KodiState.PAUSE)) {
920 listener.updatePlayerState(state);
921 // if this is a Stop then clear everything else
922 if (state == KodiState.STOP) {
923 listener.updateAlbum("");
924 listener.updateTitle("");
925 listener.updateShowTitle("");
926 listener.updateArtistList(null);
927 listener.updateMediaType("");
928 listener.updateGenreList(null);
929 listener.updatePVRChannel("");
930 listener.updateThumbnail(null);
931 listener.updateFanart(null);
932 listener.updateCurrentTimePercentage(-1);
933 listener.updateCurrentTime(-1);
934 listener.updateDuration(-1);
935 listener.updateMediaID(-1);
936 listener.updateOriginalTitle("");
937 listener.updateSeason(-1);
938 listener.updateEpisode(-1);
939 listener.updateMediaFile("");
940 listener.updateMpaa("");
941 listener.updateRating(-1);
942 listener.updateUserRating(-1);
943 listener.updateUniqueIDDouban("");
944 listener.updateUniqueIDImdb("");
945 listener.updateUniqueIDTmdb("");
946 listener.updateUniqueIDImdbtvshow("");
947 listener.updateUniqueIDTmdbtvshow("");
948 listener.updateUniqueIDTmdbepisode("");
949 listener.updateAudioStreamOptions(new ArrayList<>());
950 listener.updateSubtitleOptions(new ArrayList<>());
951 listener.updateAudioCodec(null);
952 listener.updateVideoCodec(null);
953 listener.updateAudioIndex(-1);
954 listener.updateAudioName(null);
955 listener.updateAudioLanguage(null);
956 listener.updateAudioChannels(-1);
957 listener.updateVideoIndex(-1);
958 listener.updateVideoHeight(-1);
959 listener.updateVideoWidth(-1);
960 listener.updateSubtitleIndex(-1);
961 listener.updateSubtitleName(null);
962 listener.updateSubtitleLanguage(null);
964 // keep track of our current state
965 currentState = state;
969 public void handleEvent(JsonObject json) {
970 JsonElement methodElement = json.get("method");
972 if (methodElement != null) {
973 String method = methodElement.getAsString();
974 JsonObject params = json.get("params").getAsJsonObject();
975 if (method.startsWith("Player.On")) {
976 processPlayerStateChanged(method, params);
977 } else if (method.startsWith("Application.On")) {
978 processApplicationStateChanged(method, params);
979 } else if (method.startsWith("System.On")) {
980 processSystemStateChanged(method, params);
981 } else if (method.startsWith("GUI.OnScreensaver")) {
982 processScreenSaverStateChanged(method, params);
983 } else if (method.startsWith("Input.OnInput")) {
984 processInputRequestedStateChanged(method, params);
985 } else if (method.startsWith("Playlist.On")) {
986 processPlaylistStateChanged(method, params);
988 logger.debug("Received unknown method: {}", method);
993 private void processPlayerStateChanged(String method, JsonObject json) {
994 if ("Player.OnPlay".equals(method) || "Player.OnAVStart".equals(method)) {
995 // get the player id and make a new request for the media details
997 JsonObject data = json.get("data").getAsJsonObject();
998 JsonObject player = data.get("player").getAsJsonObject();
999 Integer playerId = player.get("playerid").getAsInt();
1001 updateState(KodiState.PLAY);
1003 requestPlayerUpdate(playerId);
1004 } else if ("Player.OnPause".equals(method)) {
1005 updateState(KodiState.PAUSE);
1006 } else if ("Player.OnResume".equals(method)) {
1007 updateState(KodiState.PLAY);
1008 } else if ("Player.OnStop".equals(method)) {
1009 // get the end parameter and send an End state if true
1010 JsonObject data = json.get("data").getAsJsonObject();
1011 Boolean end = data.get("end").getAsBoolean();
1013 updateState(KodiState.END);
1015 updateState(KodiState.STOP);
1016 } else if ("Player.OnPropertyChanged".equals(method)) {
1017 logger.debug("Player.OnPropertyChanged");
1018 } else if ("Player.OnSpeedChanged".equals(method)) {
1019 JsonObject data = json.get("data").getAsJsonObject();
1020 JsonObject player = data.get("player").getAsJsonObject();
1021 int speed = player.get("speed").getAsInt();
1023 updateState(KodiState.PAUSE);
1024 } else if (speed == 1) {
1025 updateState(KodiState.PLAY);
1026 } else if (speed < 0) {
1027 updateState(KodiState.REWIND);
1028 } else if (speed > 1) {
1029 updateState(KodiState.FASTFORWARD);
1032 logger.debug("Unknown event from Kodi {}: {}", method, json);
1034 listener.updateConnectionState(true);
1037 private void processApplicationStateChanged(String method, JsonObject json) {
1038 if ("Application.OnVolumeChanged".equals(method)) {
1039 // get the player id and make a new request for the media details
1040 JsonObject data = json.get("data").getAsJsonObject();
1041 if (data.has(PROPERTY_VOLUME)) {
1042 volume = data.get(PROPERTY_VOLUME).getAsInt();
1043 listener.updateVolume(volume);
1045 if (data.has(PROPERTY_MUTED)) {
1046 boolean muted = data.get(PROPERTY_MUTED).getAsBoolean();
1047 listener.updateMuted(muted);
1050 logger.debug("Unknown event from Kodi {}: {}", method, json);
1052 listener.updateConnectionState(true);
1055 private void processSystemStateChanged(String method, JsonObject json) {
1056 if ("System.OnQuit".equals(method) || "System.OnRestart".equals(method) || "System.OnSleep".equals(method)) {
1057 listener.updateConnectionState(false);
1058 } else if ("System.OnWake".equals(method)) {
1059 listener.updateConnectionState(true);
1061 logger.debug("Unknown event from Kodi {}: {}", method, json);
1065 private void processScreenSaverStateChanged(String method, JsonObject json) {
1066 if ("GUI.OnScreensaverDeactivated".equals(method)) {
1067 listener.updateScreenSaverState(false);
1068 } else if ("GUI.OnScreensaverActivated".equals(method)) {
1069 listener.updateScreenSaverState(true);
1071 logger.debug("Unknown event from Kodi {}: {}", method, json);
1073 listener.updateConnectionState(true);
1076 private void processInputRequestedStateChanged(String method, JsonObject json) {
1077 if ("Input.OnInputFinished".equals(method)) {
1078 listener.updateInputRequestedState(false);
1079 } else if ("Input.OnInputRequested".equals(method)) {
1080 listener.updateInputRequestedState(true);
1082 logger.debug("Unknown event from Kodi {}: {}", method, json);
1084 listener.updateConnectionState(true);
1087 private void processPlaylistStateChanged(String method, JsonObject json) {
1088 if ("Playlist.OnAdd".equals(method)) {
1089 currentPlaylistState = KodiPlaylistState.ADDED;
1091 listener.updatePlaylistState(KodiPlaylistState.ADDED);
1092 } else if ("Playlist.OnRemove".equals(method)) {
1093 currentPlaylistState = KodiPlaylistState.REMOVED;
1095 listener.updatePlaylistState(KodiPlaylistState.REMOVED);
1097 logger.debug("Unknown event from Kodi {}: {}", method, json);
1099 listener.updateConnectionState(true);
1102 public synchronized void close() {
1103 if (socket != null && socket.isConnected()) {
1108 public void updateScreenSaverState() {
1109 if (socket.isConnected()) {
1110 String[] props = { PROPERTY_SCREENSAVER };
1112 JsonObject params = new JsonObject();
1113 params.add("booleans", getJsonArray(props));
1114 JsonElement response = socket.callMethod("XBMC.GetInfoBooleans", params);
1116 if (response instanceof JsonObject) {
1117 JsonObject data = response.getAsJsonObject();
1118 if (data.has(PROPERTY_SCREENSAVER)) {
1119 listener.updateScreenSaverState(data.get(PROPERTY_SCREENSAVER).getAsBoolean());
1123 listener.updateScreenSaverState(false);
1127 public void updateVolume() {
1128 if (socket.isConnected()) {
1129 String[] props = { PROPERTY_VOLUME, PROPERTY_MUTED };
1131 JsonObject params = new JsonObject();
1132 params.add("properties", getJsonArray(props));
1133 JsonElement response = socket.callMethod("Application.GetProperties", params);
1135 if (response instanceof JsonObject) {
1136 JsonObject data = response.getAsJsonObject();
1137 if (data.has(PROPERTY_VOLUME)) {
1138 volume = data.get(PROPERTY_VOLUME).getAsInt();
1139 listener.updateVolume(volume);
1141 if (data.has(PROPERTY_MUTED)) {
1142 boolean muted = data.get(PROPERTY_MUTED).getAsBoolean();
1143 listener.updateMuted(muted);
1147 listener.updateVolume(100);
1148 listener.updateMuted(false);
1152 public void updateCurrentProfile() {
1153 if (socket.isConnected()) {
1154 JsonElement response = socket.callMethod("Profiles.GetCurrentProfile");
1157 final KodiProfile profile = gson.fromJson(response, KodiProfile.class);
1158 if (profile != null) {
1159 listener.updateCurrentProfile(profile.getLabel());
1161 } catch (JsonSyntaxException e) {
1162 logger.debug("Json syntax exception occurred: {}", e.getMessage(), e);
1167 public synchronized void playURI(String uri) {
1168 String fileUri = uri;
1169 JsonObject item = new JsonObject();
1170 JsonObject options = null;
1172 if (uri.contains(TIMESTAMP_FRAGMENT)) {
1173 fileUri = uri.substring(0, uri.indexOf(TIMESTAMP_FRAGMENT));
1174 String timestamp = uri.substring(uri.indexOf(TIMESTAMP_FRAGMENT) + TIMESTAMP_FRAGMENT.length());
1176 int s = Integer.parseInt(timestamp);
1177 options = new JsonObject();
1178 options.add("resume", timeValueFromSeconds(s));
1179 } catch (NumberFormatException e) {
1180 logger.warn("Illegal parameter for timestamp - it must be an integer: {}", timestamp);
1183 item.addProperty("file", fileUri);
1184 playInternal(item, options);
1187 public synchronized List<KodiPVRChannelGroup> getPVRChannelGroups(final String pvrChannelType) {
1188 String method = "PVR.GetChannelGroups";
1189 String hash = hostname + '#' + method + "#channeltype=" + pvrChannelType;
1190 JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
1191 JsonObject params = new JsonObject();
1192 params.addProperty("channeltype", pvrChannelType);
1193 return socket.callMethod(method, params);
1196 List<KodiPVRChannelGroup> pvrChannelGroups = new ArrayList<>();
1197 if (response instanceof JsonObject) {
1198 JsonObject result = response.getAsJsonObject();
1199 if (result.has("channelgroups")) {
1200 JsonElement channelgroups = result.get("channelgroups");
1201 if (channelgroups instanceof JsonArray) {
1202 for (JsonElement element : channelgroups.getAsJsonArray()) {
1203 JsonObject object = (JsonObject) element;
1204 KodiPVRChannelGroup pvrChannelGroup = new KodiPVRChannelGroup();
1205 pvrChannelGroup.setId(object.get("channelgroupid").getAsInt());
1206 pvrChannelGroup.setLabel(object.get("label").getAsString());
1207 pvrChannelGroup.setChannelType(pvrChannelType);
1208 pvrChannelGroups.add(pvrChannelGroup);
1213 return pvrChannelGroups;
1216 public int getPVRChannelGroupId(final String channelType, final String pvrChannelGroupName) {
1217 List<KodiPVRChannelGroup> pvrChannelGroups = getPVRChannelGroups(channelType);
1218 for (KodiPVRChannelGroup pvrChannelGroup : pvrChannelGroups) {
1219 String label = pvrChannelGroup.getLabel();
1220 if (pvrChannelGroupName.equalsIgnoreCase(label)) {
1221 return pvrChannelGroup.getId();
1224 // if we don't find a matching PVR channel group return the first (which is the default: "All channels")
1225 return pvrChannelGroups.isEmpty() ? 0 : pvrChannelGroups.get(0).getId();
1228 public synchronized List<KodiPVRChannel> getPVRChannels(final int pvrChannelGroupId) {
1229 String method = "PVR.GetChannels";
1230 String hash = hostname + '#' + method + "#channelgroupid=" + pvrChannelGroupId;
1231 JsonElement response = REQUEST_CACHE.putIfAbsentAndGet(hash, () -> {
1232 JsonObject params = new JsonObject();
1233 params.addProperty("channelgroupid", pvrChannelGroupId);
1234 return socket.callMethod(method, params);
1237 List<KodiPVRChannel> pvrChannels = new ArrayList<>();
1238 if (response instanceof JsonObject) {
1239 JsonObject result = response.getAsJsonObject();
1240 if (result.has("channels")) {
1241 JsonElement channels = result.get("channels");
1242 if (channels instanceof JsonArray) {
1243 for (JsonElement element : channels.getAsJsonArray()) {
1244 JsonObject object = (JsonObject) element;
1245 KodiPVRChannel pvrChannel = new KodiPVRChannel();
1246 pvrChannel.setId(object.get("channelid").getAsInt());
1247 pvrChannel.setLabel(object.get("label").getAsString());
1248 pvrChannel.setChannelGroupId(pvrChannelGroupId);
1249 pvrChannels.add(pvrChannel);
1257 public int getPVRChannelId(final int pvrChannelGroupId, final String pvrChannelName) {
1258 for (KodiPVRChannel pvrChannel : getPVRChannels(pvrChannelGroupId)) {
1259 String label = pvrChannel.getLabel();
1260 if (pvrChannelName.equalsIgnoreCase(label)) {
1261 return pvrChannel.getId();
1267 public synchronized void playPVRChannel(final int pvrChannelId) {
1268 JsonObject item = new JsonObject();
1269 item.addProperty("channelid", pvrChannelId);
1271 playInternal(item, null);
1274 private void playInternal(JsonObject item, JsonObject options) {
1275 JsonObject params = new JsonObject();
1276 params.add("item", item);
1277 if (options != null) {
1278 params.add("options", options);
1280 socket.callMethod("Player.Open", params);
1283 public synchronized void showNotification(String title, BigDecimal displayTime, String icon, String message) {
1284 JsonObject params = new JsonObject();
1285 params.addProperty("message", message);
1286 if (title != null) {
1287 params.addProperty("title", title);
1289 if (displayTime != null) {
1290 params.addProperty("displaytime", displayTime.longValue());
1293 params.addProperty("image", callbackUrl + "/icon/" + icon.toLowerCase() + ".png");
1295 socket.callMethod("GUI.ShowNotification", params);
1298 public boolean checkConnection() {
1299 if (!socket.isConnected()) {
1300 logger.debug("checkConnection: try to connect to Kodi {}", wsUri);
1303 return socket.isConnected();
1304 } catch (IOException e) {
1305 logger.debug("exception during connect to {}", wsUri, e);
1310 // Ping Kodi with the get version command. This prevents the idle timeout on the web socket.
1311 return !getVersion().isEmpty();
1315 public String getConnectionName() {
1316 return wsUri.toString();
1319 public String getVersion() {
1320 if (socket.isConnected()) {
1321 String[] props = { PROPERTY_VERSION };
1323 JsonObject params = new JsonObject();
1324 params.add("properties", getJsonArray(props));
1325 JsonElement response = socket.callMethod("Application.GetProperties", params);
1327 if (response instanceof JsonObject) {
1328 JsonObject result = response.getAsJsonObject();
1329 if (result.has(PROPERTY_VERSION)) {
1330 JsonObject version = result.get(PROPERTY_VERSION).getAsJsonObject();
1331 int major = version.get("major").getAsInt();
1332 int minor = version.get("minor").getAsInt();
1333 String revision = version.get("revision").getAsString();
1334 return String.format("%d.%d (%s)", major, minor, revision);
1341 public void input(String key) {
1342 socket.callMethod("Input." + key);
1345 public void inputText(String text) {
1346 JsonObject params = new JsonObject();
1347 params.addProperty("text", text);
1348 socket.callMethod("Input.SendText", params);
1351 public void inputAction(String action) {
1352 JsonObject params = new JsonObject();
1353 params.addProperty("action", action);
1354 socket.callMethod("Input.ExecuteAction", params);
1357 public void inputButtonEvent(String buttonEvent) {
1358 logger.debug("inputButtonEvent {}.", buttonEvent);
1360 String button = buttonEvent;
1361 String keymap = "KB";
1362 Integer holdtime = null;
1364 if (buttonEvent.contains(";")) {
1365 String[] params = buttonEvent.split(";");
1366 switch (params.length) {
1375 holdtime = Integer.parseInt(params[2]);
1376 } catch (NumberFormatException nfe) {
1383 this.inputButtonEvent(button, keymap, holdtime);
1386 private void inputButtonEvent(String button, String keymap, Integer holdtime) {
1387 JsonObject params = new JsonObject();
1388 params.addProperty("button", button);
1389 params.addProperty("keymap", keymap);
1390 if (holdtime != null) {
1391 params.addProperty("holdtime", holdtime.intValue());
1393 JsonElement result = socket.callMethod("Input.ButtonEvent", params);
1394 logger.debug("inputButtonEvent result {}.", result);
1397 public void getSystemProperties() {
1398 KodiSystemProperties systemProperties = null;
1399 if (socket.isConnected()) {
1400 String[] props = { PROPERTY_CANHIBERNATE, PROPERTY_CANREBOOT, PROPERTY_CANSHUTDOWN, PROPERTY_CANSUSPEND };
1402 JsonObject params = new JsonObject();
1403 params.add("properties", getJsonArray(props));
1404 JsonElement response = socket.callMethod("System.GetProperties", params);
1407 systemProperties = gson.fromJson(response, KodiSystemProperties.class);
1408 } catch (JsonSyntaxException e) {
1412 listener.updateSystemProperties(systemProperties);
1415 public void sendApplicationQuit() {
1416 String method = "Application.Quit";
1417 socket.callMethod(method);
1420 public void sendSystemCommand(String command) {
1421 String method = "System." + command;
1422 socket.callMethod(method);
1425 public void profile(String profile) {
1426 JsonObject params = new JsonObject();
1427 params.addProperty("profile", profile);
1428 socket.callMethod("Profiles.LoadProfile", params);
1431 public KodiProfile[] getProfiles() {
1432 KodiProfile[] profiles = new KodiProfile[0];
1433 if (socket.isConnected()) {
1434 JsonElement response = socket.callMethod("Profiles.GetProfiles");
1437 JsonObject profilesJson = response.getAsJsonObject();
1438 profiles = gson.fromJson(profilesJson.get("profiles"), KodiProfile[].class);
1439 } catch (JsonSyntaxException e) {
1440 logger.debug("Json syntax exception occurred: {}", e.getMessage(), e);
1446 public void setTime(int time) {
1448 JsonObject params = new JsonObject();
1449 params.addProperty("playerid", 1);
1450 JsonObject value = new JsonObject();
1451 JsonObject timeValue = timeValueFromSeconds(seconds);
1453 value.add("time", timeValue);
1454 params.add("value", value);
1455 socket.callMethod("Player.Seek", params);
1458 private JsonObject timeValueFromSeconds(int seconds) {
1459 JsonObject timeValue = new JsonObject();
1463 int hours = s / 3600;
1464 timeValue.addProperty("hours", hours);
1468 int minutes = s / 60;
1469 timeValue.addProperty("minutes", minutes);
1472 timeValue.addProperty("seconds", s);