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, () -> socket.callMethod(method));
189 if (response instanceof JsonArray) {
190 return response.getAsJsonArray();
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);
201 if (response instanceof JsonObject) {
202 return response.getAsJsonObject();
208 public synchronized void playerPlayPause() {
209 int activePlayer = getActivePlayer();
211 JsonObject params = new JsonObject();
212 params.addProperty("playerid", activePlayer);
213 socket.callMethod("Player.PlayPause", params);
216 public synchronized void playerStop() {
217 int activePlayer = getActivePlayer();
219 JsonObject params = new JsonObject();
220 params.addProperty("playerid", activePlayer);
221 socket.callMethod("Player.Stop", params);
224 public synchronized void playerNext() {
225 goToInternal("next");
227 updatePlayerStatus();
230 public synchronized void playerPrevious() {
231 goToInternal("previous");
233 updatePlayerStatus();
236 private void goToInternal(String to) {
237 int activePlayer = getActivePlayer();
239 JsonObject params = new JsonObject();
240 params.addProperty("playerid", activePlayer);
241 params.addProperty("to", to);
242 socket.callMethod("Player.GoTo", params);
245 public synchronized void playerRewind() {
246 setSpeedInternal(calcNextSpeed(-1));
248 updatePlayerStatus();
251 public synchronized void playerFastForward() {
252 setSpeedInternal(calcNextSpeed(1));
254 updatePlayerStatus();
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) {
263 } else if (position == 0 || position == (SPEEDS.size() - 1)) {
264 return SPEEDS.get(position);
266 return SPEEDS.get(position + modifier);
273 private void setSpeedInternal(int speed) {
274 int activePlayer = getActivePlayer();
276 JsonObject params = new JsonObject();
277 params.addProperty("playerid", activePlayer);
278 params.addProperty("speed", speed);
279 socket.callMethod("Player.SetSpeed", params);
282 public synchronized void playlistAdd(int playlistID, String uri) {
283 currentPlaylistState = KodiPlaylistState.ADD;
285 JsonObject item = new JsonObject();
286 item.addProperty("file", uri);
288 JsonObject params = new JsonObject();
289 params.addProperty("playlistid", playlistID);
290 params.add("item", item);
291 socket.callMethod("Playlist.Add", params);
294 public synchronized void playlistClear(int playlistID) {
295 currentPlaylistState = KodiPlaylistState.CLEAR;
297 JsonObject params = new JsonObject();
298 params.addProperty("playlistid", playlistID);
299 socket.callMethod("Playlist.Clear", params);
302 public synchronized void playlistInsert(int playlistID, String uri, int position) {
303 currentPlaylistState = KodiPlaylistState.INSERT;
305 JsonObject item = new JsonObject();
306 item.addProperty("file", uri);
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);
315 public synchronized void playlistPlay(int playlistID, int position) {
316 JsonObject item = new JsonObject();
317 item.addProperty("playlistid", playlistID);
318 item.addProperty("position", position);
320 playInternal(item, null);
323 public synchronized void playlistRemove(int playlistID, int position) {
324 currentPlaylistState = KodiPlaylistState.REMOVE;
326 JsonObject params = new JsonObject();
327 params.addProperty("playlistid", playlistID);
328 params.addProperty("position", position);
329 socket.callMethod("Playlist.Remove", params);
333 * Retrieves a list of favorites from the Kodi instance. The result is cached.
335 * @return a list of {@link KodiFavorite}
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" };
343 JsonObject params = new JsonObject();
344 params.add("properties", getJsonArray(properties));
345 return socket.callMethod(method, params);
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());
361 if (object.has("window")) {
362 favorite.setWindow(object.get("window").getAsString());
363 favorite.setWindowParameter(object.get("windowparameter").getAsString());
365 favorites.add(favorite);
374 * Returns the favorite with the given title or null.
376 * @param favoriteTitle the title of the favorite
377 * @return the ({@link KodiFavorite}) with the given title
379 public @Nullable KodiFavorite getFavorite(final String favoriteTitle) {
380 for (KodiFavorite favorite : getFavorites()) {
381 String title = favorite.getTitle();
382 if (favoriteTitle.equalsIgnoreCase(title)) {
390 * Activates the given window.
392 * @param window the window
394 public synchronized void activateWindow(final String window) {
395 activateWindow(window, null);
399 * Activates the given window.
401 * @param window the window
402 * @param windowParameter list of parameters of the window
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));
410 socket.callMethod("GUI.ActivateWindow", params);
413 public synchronized void increaseVolume() {
414 setVolumeInternal(this.volume + VOLUMESTEP);
417 public synchronized void decreaseVolume() {
418 setVolumeInternal(this.volume - VOLUMESTEP);
421 public synchronized void setVolume(int volume) {
422 setVolumeInternal(volume);
425 private void setVolumeInternal(int volume) {
426 JsonObject params = new JsonObject();
427 params.addProperty(PROPERTY_VOLUME, volume);
428 socket.callMethod("Application.SetVolume", params);
431 public int getVolume() {
435 public synchronized void setMute(boolean mute) {
436 JsonObject params = new JsonObject();
437 params.addProperty("mute", mute);
438 socket.callMethod("Application.SetMute", params);
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);
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);
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);
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);
473 private int getSpeed(int activePlayer) {
474 final String[] properties = { "speed" };
476 JsonObject params = new JsonObject();
477 params.addProperty("playerid", activePlayer);
478 params.add("properties", getJsonArray(properties));
479 JsonElement response = socket.callMethod("Player.GetProperties", params);
481 if (response instanceof JsonObject) {
482 JsonObject result = response.getAsJsonObject();
483 if (result.has("speed")) {
484 return result.get("speed").getAsInt();
490 public synchronized void updatePlayerStatus() {
491 if (socket.isConnected()) {
492 int activePlayer = getActivePlayer();
493 if (activePlayer >= 0) {
494 int speed = getSpeed(activePlayer);
496 updateState(KodiState.STOP);
497 } else if (speed == 1) {
498 updateState(KodiState.PLAY);
499 } else if (speed < 0) {
500 updateState(KodiState.REWIND);
502 updateState(KodiState.FASTFORWARD);
504 requestPlayerUpdate(activePlayer);
506 updateState(KodiState.STOP);
511 private void requestPlayerUpdate(int activePlayer) {
512 requestPlayerPropertiesUpdate(activePlayer);
513 requestPlayerItemUpdate(activePlayer);
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" };
521 JsonObject params = new JsonObject();
522 params.addProperty("playerid", activePlayer);
523 params.add("properties", getJsonArray(properties));
524 JsonElement response = socket.callMethod("Player.GetItem", params);
526 if (response instanceof JsonObject) {
527 JsonObject result = response.getAsJsonObject();
528 if (result.has("item")) {
529 JsonObject item = result.get("item").getAsJsonObject();
532 if (item.has("id")) {
533 mediaid = item.get("id").getAsInt();
537 if (item.has("rating")) {
538 rating = item.get("rating").getAsDouble();
541 double userrating = -1;
542 if (item.has("userrating")) {
543 userrating = item.get("userrating").getAsDouble();
547 if (item.has("mpaa")) {
548 mpaa = item.get("mpaa").getAsString();
551 String mediafile = "";
552 if (item.has("file")) {
553 mediafile = item.get("file").getAsString();
556 String uniqueIDDouban = "";
557 String uniqueIDImdb = "";
558 String uniqueIDTmdb = "";
559 String uniqueIDImdbtvshow = "";
560 String uniqueIDTmdbtvshow = "";
561 String uniqueIDTmdbepisode = "";
563 if (item.has(PROPERTY_UNIQUEID)) {
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();
574 } catch (JsonSyntaxException e) {
579 String originaltitle = "";
580 if (item.has("originaltitle")) {
581 originaltitle = item.get("originaltitle").getAsString();
585 if (item.has("title")) {
586 title = item.get("title").getAsString();
588 if (title.isEmpty()) {
589 title = item.get("label").getAsString();
592 String showTitle = "";
593 if (item.has("showtitle")) {
594 showTitle = item.get("showtitle").getAsString();
598 if (item.has("season")) {
599 season = item.get("season").getAsInt();
603 if (item.has("episode")) {
604 episode = item.get("episode").getAsInt();
608 if (item.has("album")) {
609 album = item.get("album").getAsString();
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)) {
620 List<String> artistList = null;
621 if ("movie".equals(mediaType) && item.has("director")) {
622 artistList = convertFromArrayToList(item.get("director").getAsJsonArray());
624 if (item.has("artist")) {
625 artistList = convertFromArrayToList(item.get("artist").getAsJsonArray());
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());
638 if (item.has("channel")) {
639 channel = item.get("channel").getAsString();
642 RawType thumbnail = null;
643 if (item.has(PROPERTY_THUMBNAIL)) {
644 thumbnail = getImageForElement(item.get(PROPERTY_THUMBNAIL));
647 RawType fanart = null;
648 if (item.has(PROPERTY_FANART)) {
649 fanart = getImageForElement(item.get(PROPERTY_FANART));
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);
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 };
684 JsonObject params = new JsonObject();
685 params.addProperty("playerid", activePlayer);
686 params.add("properties", getJsonArray(properties));
687 JsonElement response = socket.callMethod("Player.GetProperties", params);
689 if (response instanceof JsonObject) {
690 JsonObject result = response.getAsJsonObject();
692 if (result.has(PROPERTY_AUDIOSTREAMS)) {
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);
701 listener.updateAudioStreamOptions(audioStreamList);
703 } catch (JsonSyntaxException e) {
708 if (result.has(PROPERTY_SUBTITLES)) {
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);
717 listener.updateSubtitleOptions(subtitleList);
719 } catch (JsonSyntaxException e) {
724 boolean subtitleEnabled = false;
725 if (result.has(PROPERTY_SUBTITLEENABLED)) {
726 subtitleEnabled = result.get(PROPERTY_SUBTITLEENABLED).getAsBoolean();
729 int subtitleIndex = -1;
730 String subtitleLanguage = null;
731 String subtitleName = null;
732 if (result.has(PROPERTY_CURRENTSUBTITLE)) {
734 KodiSubtitle subtitleStream = gson.fromJson(result.get(PROPERTY_CURRENTSUBTITLE),
736 if (subtitleStream != null) {
737 subtitleIndex = subtitleStream.getIndex();
738 subtitleLanguage = subtitleStream.getLanguage();
739 subtitleName = subtitleStream.getName();
741 } catch (JsonSyntaxException e) {
746 String audioCodec = null;
748 int audioChannels = 0;
749 String audioLanguage = null;
750 String audioName = null;
751 if (result.has(PROPERTY_CURRENTAUDIOSTREAM)) {
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();
762 } catch (JsonSyntaxException e) {
767 String videoCodec = null;
771 if (result.has(PROPERTY_CURRENTVIDEOSTREAM)) {
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();
781 } catch (JsonSyntaxException e) {
786 double percentage = -1;
787 if (result.has(PROPERTY_PERCENTAGE)) {
788 percentage = result.get(PROPERTY_PERCENTAGE).getAsDouble();
791 long currentTime = -1;
792 if (result.has(PROPERTY_TIME)) {
794 KodiDuration time = gson.fromJson(result.get(PROPERTY_TIME), KodiDuration.class);
795 currentTime = time.toSeconds();
796 } catch (JsonSyntaxException e) {
802 if (result.has(PROPERTY_TOTALTIME)) {
804 KodiDuration totalTime = gson.fromJson(result.get(PROPERTY_TOTALTIME), KodiDuration.class);
805 duration = totalTime.toSeconds();
806 } catch (JsonSyntaxException e) {
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);
830 private JsonArray getJsonArray(String[] values) {
831 JsonArray result = new JsonArray();
832 for (String param : values) {
833 result.add(new JsonPrimitive(param));
838 private List<String> convertFromArrayToList(JsonArray data) {
839 List<String> list = new ArrayList<>();
840 for (JsonElement element : data) {
841 list.add(element.getAsString());
846 private @Nullable RawType getImageForElement(JsonElement element) {
847 String text = element.getAsString();
848 if (!text.isEmpty()) {
849 String url = stripImageUrl(text);
851 return downloadImageFromCache(url);
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();
865 private String stripEnd(final String str, final char suffix) {
866 int end = str.length();
870 while (end > 0 && str.charAt(end - 1) == suffix) {
873 return str.substring(0, end);
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);
882 return downloadedImage;
885 private @Nullable RawType downloadImageFromCache(String url) {
886 if (IMAGE_CACHE.containsKey(url)) {
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);
896 RawType image = downloadImage(url);
898 IMAGE_CACHE.put(url, image.getBytes());
905 public KodiState getState() {
909 public KodiPlaylistState getPlaylistState() {
910 return currentPlaylistState;
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)) {
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);
962 // keep track of our current state
963 currentState = state;
967 public void handleEvent(JsonObject json) {
968 JsonElement methodElement = json.get("method");
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);
986 logger.debug("Received unknown method: {}", method);
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
995 JsonObject data = json.get("data").getAsJsonObject();
996 JsonObject player = data.get("player").getAsJsonObject();
997 Integer playerId = player.get("playerid").getAsInt();
999 updateState(KodiState.PLAY);
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();
1011 updateState(KodiState.END);
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();
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);
1030 logger.debug("Unknown event from Kodi {}: {}", method, json);
1032 listener.updateConnectionState(true);
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);
1043 if (data.has(PROPERTY_MUTED)) {
1044 boolean muted = data.get(PROPERTY_MUTED).getAsBoolean();
1045 listener.updateMuted(muted);
1048 logger.debug("Unknown event from Kodi {}: {}", method, json);
1050 listener.updateConnectionState(true);
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);
1059 logger.debug("Unknown event from Kodi {}: {}", method, json);
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);
1069 logger.debug("Unknown event from Kodi {}: {}", method, json);
1071 listener.updateConnectionState(true);
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);
1080 logger.debug("Unknown event from Kodi {}: {}", method, json);
1082 listener.updateConnectionState(true);
1085 private void processPlaylistStateChanged(String method, JsonObject json) {
1086 if ("Playlist.OnAdd".equals(method)) {
1087 currentPlaylistState = KodiPlaylistState.ADDED;
1089 listener.updatePlaylistState(KodiPlaylistState.ADDED);
1090 } else if ("Playlist.OnRemove".equals(method)) {
1091 currentPlaylistState = KodiPlaylistState.REMOVED;
1093 listener.updatePlaylistState(KodiPlaylistState.REMOVED);
1095 logger.debug("Unknown event from Kodi {}: {}", method, json);
1097 listener.updateConnectionState(true);
1100 public synchronized void close() {
1101 if (socket != null && socket.isConnected()) {
1106 public void updateScreenSaverState() {
1107 if (socket.isConnected()) {
1108 String[] props = { PROPERTY_SCREENSAVER };
1110 JsonObject params = new JsonObject();
1111 params.add("booleans", getJsonArray(props));
1112 JsonElement response = socket.callMethod("XBMC.GetInfoBooleans", params);
1114 if (response instanceof JsonObject) {
1115 JsonObject data = response.getAsJsonObject();
1116 if (data.has(PROPERTY_SCREENSAVER)) {
1117 listener.updateScreenSaverState(data.get(PROPERTY_SCREENSAVER).getAsBoolean());
1121 listener.updateScreenSaverState(false);
1125 public void updateVolume() {
1126 if (socket.isConnected()) {
1127 String[] props = { PROPERTY_VOLUME, PROPERTY_MUTED };
1129 JsonObject params = new JsonObject();
1130 params.add("properties", getJsonArray(props));
1131 JsonElement response = socket.callMethod("Application.GetProperties", params);
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);
1139 if (data.has(PROPERTY_MUTED)) {
1140 boolean muted = data.get(PROPERTY_MUTED).getAsBoolean();
1141 listener.updateMuted(muted);
1145 listener.updateVolume(100);
1146 listener.updateMuted(false);
1150 public void updateCurrentProfile() {
1151 if (socket.isConnected()) {
1152 JsonElement response = socket.callMethod("Profiles.GetCurrentProfile");
1155 final KodiProfile profile = gson.fromJson(response, KodiProfile.class);
1156 if (profile != null) {
1157 listener.updateCurrentProfile(profile.getLabel());
1159 } catch (JsonSyntaxException e) {
1160 logger.debug("Json syntax exception occurred: {}", e.getMessage(), e);
1165 public synchronized void playURI(String uri) {
1166 String fileUri = uri;
1167 JsonObject item = new JsonObject();
1168 JsonObject options = null;
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());
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);
1181 item.addProperty("file", fileUri);
1182 playInternal(item, options);
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);
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);
1211 return pvrChannelGroups;
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();
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();
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);
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);
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();
1265 public synchronized void playPVRChannel(final int pvrChannelId) {
1266 JsonObject item = new JsonObject();
1267 item.addProperty("channelid", pvrChannelId);
1269 playInternal(item, null);
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);
1278 socket.callMethod("Player.Open", params);
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);
1287 if (displayTime != null) {
1288 params.addProperty("displaytime", displayTime.longValue());
1291 params.addProperty("image", callbackUrl + "/icon/" + icon.toLowerCase() + ".png");
1293 socket.callMethod("GUI.ShowNotification", params);
1296 public boolean checkConnection() {
1297 if (!socket.isConnected()) {
1298 logger.debug("checkConnection: try to connect to Kodi {}", wsUri);
1301 return socket.isConnected();
1302 } catch (IOException e) {
1303 logger.debug("exception during connect to {}", wsUri, e);
1308 // Ping Kodi with the get version command. This prevents the idle timeout on the web socket.
1309 return !getVersion().isEmpty();
1313 public String getConnectionName() {
1314 return wsUri.toString();
1317 public String getVersion() {
1318 if (socket.isConnected()) {
1319 String[] props = { PROPERTY_VERSION };
1321 JsonObject params = new JsonObject();
1322 params.add("properties", getJsonArray(props));
1323 JsonElement response = socket.callMethod("Application.GetProperties", params);
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);
1339 public void input(String key) {
1340 socket.callMethod("Input." + key);
1343 public void inputText(String text) {
1344 JsonObject params = new JsonObject();
1345 params.addProperty("text", text);
1346 socket.callMethod("Input.SendText", params);
1349 public void inputAction(String action) {
1350 JsonObject params = new JsonObject();
1351 params.addProperty("action", action);
1352 socket.callMethod("Input.ExecuteAction", params);
1355 public void inputButtonEvent(String buttonEvent) {
1356 logger.debug("inputButtonEvent {}.", buttonEvent);
1358 String button = buttonEvent;
1359 String keymap = "KB";
1360 Integer holdtime = null;
1362 if (buttonEvent.contains(";")) {
1363 String[] params = buttonEvent.split(";");
1364 switch (params.length) {
1373 holdtime = Integer.parseInt(params[2]);
1374 } catch (NumberFormatException nfe) {
1381 this.inputButtonEvent(button, keymap, holdtime);
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());
1391 JsonElement result = socket.callMethod("Input.ButtonEvent", params);
1392 logger.debug("inputButtonEvent result {}.", result);
1395 public void getSystemProperties() {
1396 KodiSystemProperties systemProperties = null;
1397 if (socket.isConnected()) {
1398 String[] props = { PROPERTY_CANHIBERNATE, PROPERTY_CANREBOOT, PROPERTY_CANSHUTDOWN, PROPERTY_CANSUSPEND };
1400 JsonObject params = new JsonObject();
1401 params.add("properties", getJsonArray(props));
1402 JsonElement response = socket.callMethod("System.GetProperties", params);
1405 systemProperties = gson.fromJson(response, KodiSystemProperties.class);
1406 } catch (JsonSyntaxException e) {
1410 listener.updateSystemProperties(systemProperties);
1413 public void sendApplicationQuit() {
1414 String method = "Application.Quit";
1415 socket.callMethod(method);
1418 public void sendSystemCommand(String command) {
1419 String method = "System." + command;
1420 socket.callMethod(method);
1423 public void profile(String profile) {
1424 JsonObject params = new JsonObject();
1425 params.addProperty("profile", profile);
1426 socket.callMethod("Profiles.LoadProfile", params);
1429 public KodiProfile[] getProfiles() {
1430 KodiProfile[] profiles = new KodiProfile[0];
1431 if (socket.isConnected()) {
1432 JsonElement response = socket.callMethod("Profiles.GetProfiles");
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);
1444 public void setTime(int time) {
1446 JsonObject params = new JsonObject();
1447 params.addProperty("playerid", 1);
1448 JsonObject value = new JsonObject();
1449 JsonObject timeValue = timeValueFromSeconds(seconds);
1451 value.add("time", timeValue);
1452 params.add("value", value);
1453 socket.callMethod("Player.Seek", params);
1456 private JsonObject timeValueFromSeconds(int seconds) {
1457 JsonObject timeValue = new JsonObject();
1461 int hours = s / 3600;
1462 timeValue.addProperty("hours", hours);
1466 int minutes = s / 60;
1467 timeValue.addProperty("minutes", minutes);
1470 timeValue.addProperty("seconds", s);