]> git.basschouten.com Git - openhab-addons.git/blob
50172b34d93c597223a1771ed47ef342b7b44174
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.jellyfin.internal.handler;
14
15 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.BROWSE_ITEM_BY_ID_CHANNEL;
16 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.BROWSE_ITEM_BY_TERMS_CHANNEL;
17 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.MEDIA_CONTROL_CHANNEL;
18 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_EPISODE_CHANNEL;
19 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_GENRES_CHANNEL;
20 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_ID_CHANNEL;
21 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_NAME_CHANNEL;
22 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_PERCENTAGE_CHANNEL;
23 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_SEASON_CHANNEL;
24 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_SEASON_NAME_CHANNEL;
25 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_SECOND_CHANNEL;
26 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_SERIES_NAME_CHANNEL;
27 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_TOTAL_SECOND_CHANNEL;
28 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAYING_ITEM_TYPE_CHANNEL;
29 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_BY_ID_CHANNEL;
30 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_BY_TERMS_CHANNEL;
31 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_LAST_BY_ID_CHANNEL;
32 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_LAST_BY_TERMS_CHANNEL;
33 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_NEXT_BY_ID_CHANNEL;
34 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.PLAY_NEXT_BY_TERMS_CHANNEL;
35 import static org.openhab.binding.jellyfin.internal.JellyfinBindingConstants.SEND_NOTIFICATION_CHANNEL;
36
37 import java.math.BigInteger;
38 import java.util.List;
39 import java.util.Objects;
40 import java.util.UUID;
41 import java.util.concurrent.ScheduledFuture;
42 import java.util.concurrent.TimeUnit;
43 import java.util.regex.Pattern;
44
45 import org.eclipse.jdt.annotation.NonNullByDefault;
46 import org.eclipse.jdt.annotation.Nullable;
47 import org.jellyfin.sdk.api.client.exception.ApiClientException;
48 import org.jellyfin.sdk.model.api.BaseItemDto;
49 import org.jellyfin.sdk.model.api.BaseItemKind;
50 import org.jellyfin.sdk.model.api.PlayCommand;
51 import org.jellyfin.sdk.model.api.PlayerStateInfo;
52 import org.jellyfin.sdk.model.api.PlaystateCommand;
53 import org.jellyfin.sdk.model.api.SessionInfo;
54 import org.openhab.binding.jellyfin.internal.util.SyncCallback;
55 import org.openhab.core.library.types.DecimalType;
56 import org.openhab.core.library.types.NextPreviousType;
57 import org.openhab.core.library.types.PercentType;
58 import org.openhab.core.library.types.PlayPauseType;
59 import org.openhab.core.library.types.RewindFastforwardType;
60 import org.openhab.core.library.types.StringType;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.binding.BaseThingHandler;
65 import org.openhab.core.types.Command;
66 import org.openhab.core.types.RefreshType;
67 import org.openhab.core.types.UnDefType;
68 import org.slf4j.Logger;
69 import org.slf4j.LoggerFactory;
70
71 /**
72  * The {@link JellyfinClientHandler} is responsible for handling commands, which are
73  * sent to one of the channels.
74  *
75  * @author Miguel Álvarez - Initial contribution
76  */
77 @NonNullByDefault
78 public class JellyfinClientHandler extends BaseThingHandler {
79
80     private final Logger logger = LoggerFactory.getLogger(JellyfinClientHandler.class);
81     private final Pattern typeSearchPattern = Pattern.compile("<type:(?<type>movie|series|episode)>\\s?(?<terms>.*)");
82     private final Pattern seriesSearchPattern = Pattern
83             .compile("(<type:series>)?<season:(?<season>[0-9]*)><episode:(?<episode>[0-9]*)>\\s?(?<terms>.*)");
84     private @Nullable ScheduledFuture<?> delayedCommand;
85     private String lastSessionId = "";
86     private boolean lastPlayingState = false;
87     private long lastRunTimeTicks = 0L;
88
89     public JellyfinClientHandler(Thing thing) {
90         super(thing);
91     }
92
93     @Override
94     public void initialize() {
95         updateStatus(ThingStatus.UNKNOWN);
96         scheduler.execute(() -> refreshState());
97     }
98
99     public synchronized void updateStateFromSession(@Nullable SessionInfo session) {
100         if (session != null) {
101             lastSessionId = Objects.requireNonNull(session.getId());
102             updateStatus(ThingStatus.ONLINE);
103             updateChannelStates(session.getNowPlayingItem(), session.getPlayState());
104         } else {
105             lastPlayingState = false;
106             cleanChannels();
107             updateStatus(ThingStatus.OFFLINE);
108         }
109     }
110
111     @Override
112     public void handleCommand(ChannelUID channelUID, Command command) {
113         try {
114             switch (channelUID.getId()) {
115                 case SEND_NOTIFICATION_CHANNEL:
116                     if (command instanceof RefreshType) {
117                         return;
118                     }
119                     sendDeviceMessage(command);
120                     break;
121                 case MEDIA_CONTROL_CHANNEL:
122                     if (command instanceof RefreshType) {
123                         refreshState();
124                         return;
125                     }
126                     handleMediaControlCommand(channelUID, command);
127                     break;
128                 case PLAY_BY_TERMS_CHANNEL:
129                     if (command instanceof RefreshType) {
130                         return;
131                     }
132                     runItemSearch(command.toFullString(), PlayCommand.PLAY_NOW);
133                     break;
134                 case PLAY_NEXT_BY_TERMS_CHANNEL:
135                     if (command instanceof RefreshType) {
136                         return;
137                     }
138                     runItemSearch(command.toFullString(), PlayCommand.PLAY_NEXT);
139                     break;
140                 case PLAY_LAST_BY_TERMS_CHANNEL:
141                     if (command instanceof RefreshType) {
142                         return;
143                     }
144                     runItemSearch(command.toFullString(), PlayCommand.PLAY_LAST);
145                     break;
146                 case BROWSE_ITEM_BY_TERMS_CHANNEL:
147                     if (command instanceof RefreshType) {
148                         return;
149                     }
150                     runItemSearch(command.toFullString(), null);
151                     break;
152                 case PLAY_BY_ID_CHANNEL:
153                     if (command instanceof RefreshType) {
154                         return;
155                     }
156                     UUID itemUUID;
157                     try {
158                         itemUUID = parseItemUUID(command);
159                     } catch (NumberFormatException e) {
160                         logger.warn("Thing {}: Unable to parse item UUID in command {}.", thing.getUID(), command);
161                         return;
162                     }
163                     runItemById(itemUUID, PlayCommand.PLAY_NOW);
164                     break;
165                 case PLAY_NEXT_BY_ID_CHANNEL:
166                     if (command instanceof RefreshType) {
167                         return;
168                     }
169                     try {
170                         itemUUID = parseItemUUID(command);
171                     } catch (NumberFormatException e) {
172                         logger.warn("Thing {}: Unable to parse item UUID in command {}.", thing.getUID(), command);
173                         return;
174                     }
175                     runItemById(itemUUID, PlayCommand.PLAY_NEXT);
176                     break;
177                 case PLAY_LAST_BY_ID_CHANNEL:
178                     if (command instanceof RefreshType) {
179                         return;
180                     }
181                     try {
182                         itemUUID = parseItemUUID(command);
183                     } catch (NumberFormatException e) {
184                         logger.warn("Thing {}: Unable to parse item UUID in command {}.", thing.getUID(), command);
185                         return;
186                     }
187                     runItemById(itemUUID, PlayCommand.PLAY_LAST);
188                     break;
189                 case BROWSE_ITEM_BY_ID_CHANNEL:
190                     if (command instanceof RefreshType) {
191                         return;
192                     }
193                     try {
194                         itemUUID = parseItemUUID(command);
195                     } catch (NumberFormatException e) {
196                         logger.warn("Thing {}: Unable to parse item UUID in command {}.", thing.getUID(), command);
197                         return;
198                     }
199                     runItemById(itemUUID, null);
200                     break;
201                 case PLAYING_ITEM_SECOND_CHANNEL:
202                     if (command instanceof RefreshType) {
203                         refreshState();
204                         return;
205                     }
206                     if (command.toFullString().equals(UnDefType.NULL.toFullString())) {
207                         return;
208                     }
209                     seekToSecond(Long.parseLong(command.toFullString()));
210                     break;
211                 case PLAYING_ITEM_PERCENTAGE_CHANNEL:
212                     if (command instanceof RefreshType) {
213                         refreshState();
214                         return;
215                     }
216                     if (command.toFullString().equals(UnDefType.NULL.toFullString())) {
217                         return;
218                     }
219                     seekToPercentage(Integer.parseInt(command.toFullString()));
220                     break;
221                 case PLAYING_ITEM_ID_CHANNEL:
222                 case PLAYING_ITEM_NAME_CHANNEL:
223                 case PLAYING_ITEM_GENRES_CHANNEL:
224                 case PLAYING_ITEM_SEASON_CHANNEL:
225                 case PLAYING_ITEM_EPISODE_CHANNEL:
226                 case PLAYING_ITEM_SERIES_NAME_CHANNEL:
227                 case PLAYING_ITEM_SEASON_NAME_CHANNEL:
228                 case PLAYING_ITEM_TYPE_CHANNEL:
229                 case PLAYING_ITEM_TOTAL_SECOND_CHANNEL:
230                     if (command instanceof RefreshType) {
231                         refreshState();
232                         return;
233                     }
234                     break;
235             }
236         } catch (SyncCallback.SyncCallbackError syncCallbackError) {
237             logger.warn("Unexpected error while running channel {}: {}", channelUID.getId(),
238                     syncCallbackError.getMessage());
239         } catch (ApiClientException e) {
240             getServerHandler().handleApiException(e);
241         }
242     }
243
244     private UUID parseItemUUID(Command command) throws NumberFormatException {
245         var itemId = command.toFullString().replace("-", "");
246         return new UUID(new BigInteger(itemId.substring(0, 16), 16).longValue(),
247                 new BigInteger(itemId.substring(16), 16).longValue());
248     }
249
250     @Override
251     public void dispose() {
252         super.dispose();
253         cancelDelayedCommand();
254     }
255
256     private void cancelDelayedCommand() {
257         var delayedCommand = this.delayedCommand;
258         if (delayedCommand != null) {
259             delayedCommand.cancel(true);
260         }
261     }
262
263     private void refreshState() {
264         getServerHandler().updateClientState(this);
265     }
266
267     private void updateChannelStates(@Nullable BaseItemDto playingItem, @Nullable PlayerStateInfo playState) {
268         lastPlayingState = playingItem != null;
269         lastRunTimeTicks = playingItem != null ? Objects.requireNonNull(playingItem.getRunTimeTicks()) : 0L;
270         var positionTicks = playState != null ? playState.getPositionTicks() : null;
271         var runTimeTicks = playingItem != null ? playingItem.getRunTimeTicks() : null;
272         if (isLinked(MEDIA_CONTROL_CHANNEL)) {
273             updateState(new ChannelUID(this.thing.getUID(), MEDIA_CONTROL_CHANNEL),
274                     playingItem != null && playState != null && !playState.isPaused() ? PlayPauseType.PLAY
275                             : PlayPauseType.PAUSE);
276         }
277         if (isLinked(PLAYING_ITEM_PERCENTAGE_CHANNEL)) {
278             if (positionTicks != null && runTimeTicks != null) {
279                 int percentage = (int) Math.round((positionTicks * 100.0) / runTimeTicks);
280                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_PERCENTAGE_CHANNEL),
281                         new PercentType(percentage));
282             } else {
283                 cleanChannel(PLAYING_ITEM_PERCENTAGE_CHANNEL);
284             }
285         }
286         if (isLinked(PLAYING_ITEM_SECOND_CHANNEL)) {
287             if (positionTicks != null) {
288                 var second = Math.round((float) positionTicks / 10000000.0);
289                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SECOND_CHANNEL), new DecimalType(second));
290             } else {
291                 cleanChannel(PLAYING_ITEM_SECOND_CHANNEL);
292             }
293         }
294         if (isLinked(PLAYING_ITEM_TOTAL_SECOND_CHANNEL)) {
295             if (runTimeTicks != null) {
296                 var seconds = Math.round((float) runTimeTicks / 10000000.0);
297                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_TOTAL_SECOND_CHANNEL),
298                         new DecimalType(seconds));
299             } else {
300                 cleanChannel(PLAYING_ITEM_TOTAL_SECOND_CHANNEL);
301             }
302         }
303         if (isLinked(PLAYING_ITEM_ID_CHANNEL)) {
304             if (playingItem != null) {
305                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_ID_CHANNEL),
306                         new StringType(playingItem.getId().toString()));
307             } else {
308                 cleanChannel(PLAYING_ITEM_ID_CHANNEL);
309             }
310         }
311         if (isLinked(PLAYING_ITEM_NAME_CHANNEL)) {
312             if (playingItem != null) {
313                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_NAME_CHANNEL),
314                         new StringType(playingItem.getName()));
315             } else {
316                 cleanChannel(PLAYING_ITEM_NAME_CHANNEL);
317             }
318         }
319         if (isLinked(PLAYING_ITEM_SERIES_NAME_CHANNEL)) {
320             if (playingItem != null) {
321                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SERIES_NAME_CHANNEL),
322                         new StringType(playingItem.getSeriesName()));
323             } else {
324                 cleanChannel(PLAYING_ITEM_SERIES_NAME_CHANNEL);
325             }
326         }
327         if (isLinked(PLAYING_ITEM_SEASON_NAME_CHANNEL)) {
328             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
329                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SEASON_NAME_CHANNEL),
330                         new StringType(playingItem.getSeasonName()));
331             } else {
332                 cleanChannel(PLAYING_ITEM_SEASON_NAME_CHANNEL);
333             }
334         }
335         if (isLinked(PLAYING_ITEM_SEASON_CHANNEL)) {
336             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
337                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SEASON_CHANNEL),
338                         new DecimalType(Objects.requireNonNull(playingItem.getParentIndexNumber())));
339             } else {
340                 cleanChannel(PLAYING_ITEM_SEASON_CHANNEL);
341             }
342         }
343         if (isLinked(PLAYING_ITEM_EPISODE_CHANNEL)) {
344             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
345                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_EPISODE_CHANNEL),
346                         new DecimalType(Objects.requireNonNull(playingItem.getIndexNumber())));
347             } else {
348                 cleanChannel(PLAYING_ITEM_EPISODE_CHANNEL);
349             }
350         }
351         if (isLinked(PLAYING_ITEM_GENRES_CHANNEL)) {
352             if (playingItem != null) {
353                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_GENRES_CHANNEL),
354                         new StringType(String.join(",", Objects.requireNonNull(playingItem.getGenres()))));
355             } else {
356                 cleanChannel(PLAYING_ITEM_GENRES_CHANNEL);
357             }
358         }
359         if (isLinked(PLAYING_ITEM_TYPE_CHANNEL)) {
360             if (playingItem != null) {
361                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_TYPE_CHANNEL),
362                         new StringType(playingItem.getType().toString()));
363             } else {
364                 cleanChannel(PLAYING_ITEM_TYPE_CHANNEL);
365             }
366         }
367     }
368
369     private void runItemSearch(String terms, @Nullable PlayCommand playCommand)
370             throws SyncCallback.SyncCallbackError, ApiClientException {
371         if (terms.isBlank() || UnDefType.NULL.toFullString().equals(terms)) {
372             return;
373         }
374         // detect series search with season and episode info
375         var seriesEpisodeMatcher = seriesSearchPattern.matcher(terms);
376         if (seriesEpisodeMatcher.matches()) {
377             var season = Integer.parseInt(seriesEpisodeMatcher.group("season"));
378             var episode = Integer.parseInt(seriesEpisodeMatcher.group("episode"));
379             var cleanTerms = seriesEpisodeMatcher.group("terms");
380             runSeriesEpisode(cleanTerms, season, episode, playCommand);
381             return;
382         }
383         // detect search with type info or consider all types are enabled
384         var typeMatcher = typeSearchPattern.matcher(terms);
385         boolean searchByTypeEnabled = typeMatcher.matches();
386         var type = searchByTypeEnabled ? typeMatcher.group("type") : "";
387         boolean movieSearchEnabled = !searchByTypeEnabled || "movie".equals(type);
388         boolean seriesSearchEnabled = !searchByTypeEnabled || "series".equals(type);
389         boolean episodeSearchEnabled = !searchByTypeEnabled || "episode".equals(type);
390         var searchTerms = searchByTypeEnabled ? typeMatcher.group("terms") : terms;
391         runItemSearchByType(searchTerms, playCommand, movieSearchEnabled, seriesSearchEnabled, episodeSearchEnabled);
392     }
393
394     private void runItemSearchByType(String terms, @Nullable PlayCommand playCommand, boolean movieSearchEnabled,
395             boolean seriesSearchEnabled, boolean episodeSearchEnabled)
396             throws SyncCallback.SyncCallbackError, ApiClientException {
397         var seriesItem = seriesSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.SERIES, null) : null;
398         var movieItem = movieSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.MOVIE, null) : null;
399         var episodeItem = episodeSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.EPISODE, null)
400                 : null;
401         if (movieItem != null) {
402             logger.debug("Found movie: '{}'", movieItem.getName());
403         }
404         if (seriesItem != null) {
405             logger.debug("Found series: '{}'", seriesItem.getName());
406         }
407         if (episodeItem != null) {
408             logger.debug("Found episode: '{}'", episodeItem.getName());
409         }
410         if (movieItem != null) {
411             runItem(movieItem, playCommand);
412         } else if (seriesItem != null) {
413             runSeriesItem(seriesItem, playCommand);
414         } else if (episodeItem != null) {
415             runItem(episodeItem, playCommand);
416         } else {
417             logger.warn("Nothing to display for: {}", terms);
418         }
419     }
420
421     private void runSeriesItem(BaseItemDto seriesItem, @Nullable PlayCommand playCommand)
422             throws SyncCallback.SyncCallbackError, ApiClientException {
423         if (playCommand != null) {
424             var resumeEpisodeItem = getServerHandler().getSeriesResumeItem(seriesItem.getId());
425             var nextUpEpisodeItem = getServerHandler().getSeriesNextUpItem(seriesItem.getId());
426             var firstEpisodeItem = getServerHandler().getSeriesEpisodeItem(seriesItem.getId(), 1, 1);
427             if (resumeEpisodeItem != null) {
428                 logger.debug("Resuming series '{}' episode '{}'", seriesItem.getName(), resumeEpisodeItem.getName());
429                 playItem(resumeEpisodeItem, playCommand,
430                         Objects.requireNonNull(resumeEpisodeItem.getUserData()).getPlaybackPositionTicks());
431             } else if (nextUpEpisodeItem != null) {
432                 logger.debug("Playing next series '{}' episode '{}'", seriesItem.getName(),
433                         nextUpEpisodeItem.getName());
434                 playItem(nextUpEpisodeItem, playCommand);
435             } else if (firstEpisodeItem != null) {
436                 logger.debug("Playing series '{}' first episode '{}'", seriesItem.getName(),
437                         firstEpisodeItem.getName());
438                 playItem(firstEpisodeItem, playCommand);
439             } else {
440                 logger.warn("Unable to found episode for series");
441             }
442         } else {
443             logger.debug("Browse series '{}'", seriesItem.getName());
444             browseItem(seriesItem);
445         }
446     }
447
448     private void runSeriesEpisode(String terms, int season, int episode, @Nullable PlayCommand playCommand)
449             throws SyncCallback.SyncCallbackError, ApiClientException {
450         logger.debug("{} series episode mode", playCommand != null ? "Play" : "Browse");
451         var seriesItem = getServerHandler().searchItem(terms, BaseItemKind.SERIES, null);
452         if (seriesItem != null) {
453             logger.debug("Searching series {} episode {}x{}", seriesItem.getName(), season, episode);
454             var episodeItem = getServerHandler().getSeriesEpisodeItem(seriesItem.getId(), season, episode);
455             if (episodeItem != null) {
456                 runItem(episodeItem, playCommand);
457             } else {
458                 logger.warn("Series {} episode {}x{} not found", seriesItem.getName(), season, episode);
459             }
460         } else {
461             logger.warn("Series not found");
462         }
463     }
464
465     private void runItem(BaseItemDto item, @Nullable PlayCommand playCommand)
466             throws SyncCallback.SyncCallbackError, ApiClientException {
467         var itemType = Objects.requireNonNull(item.getType());
468         logger.debug("{} {} '{}'", playCommand == null ? "Browsing" : "Playing", itemType.toString().toLowerCase(),
469                 BaseItemKind.EPISODE.equals(itemType) ? item.getSeriesName() + ": " + item.getName() : item.getName());
470         if (playCommand == null) {
471             browseItem(item);
472         } else {
473             playItem(item, playCommand);
474         }
475     }
476
477     private void playItem(BaseItemDto item, PlayCommand playCommand)
478             throws SyncCallback.SyncCallbackError, ApiClientException {
479         playItem(item, playCommand, null);
480     }
481
482     private void playItem(BaseItemDto item, PlayCommand playCommand, @Nullable Long startPositionTicks)
483             throws SyncCallback.SyncCallbackError, ApiClientException {
484         if (playCommand.equals(PlayCommand.PLAY_NOW) && stopCurrentPlayback()) {
485             cancelDelayedCommand();
486             delayedCommand = scheduler.schedule(() -> {
487                 try {
488                     playItemInternal(item, playCommand, startPositionTicks);
489                 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
490                     logger.warn("Unexpected error while running channel {}: {}", PLAY_BY_TERMS_CHANNEL, e.getMessage());
491                 }
492             }, 3, TimeUnit.SECONDS);
493         } else {
494             playItemInternal(item, playCommand, startPositionTicks);
495         }
496     }
497
498     private void playItemInternal(BaseItemDto item, PlayCommand playCommand, @Nullable Long startPositionTicks)
499             throws SyncCallback.SyncCallbackError, ApiClientException {
500         getServerHandler().playItem(lastSessionId, playCommand, item.getId().toString(), startPositionTicks);
501     }
502
503     private void runItemById(UUID itemId, @Nullable PlayCommand playCommand)
504             throws SyncCallback.SyncCallbackError, ApiClientException {
505         var item = getServerHandler().getItem(itemId, null);
506         if (item == null) {
507             logger.warn("Unable to find item with id: {}", itemId);
508             return;
509         }
510         if (BaseItemKind.SERIES.equals(item.getType())) {
511             runSeriesItem(item, playCommand);
512         } else {
513             runItem(item, playCommand);
514         }
515     }
516
517     private void browseItem(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
518         if (stopCurrentPlayback()) {
519             cancelDelayedCommand();
520             delayedCommand = scheduler.schedule(() -> {
521                 try {
522                     browseItemInternal(item);
523                 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
524                     logger.warn("Unexpected error while running channel {}: {}", BROWSE_ITEM_BY_TERMS_CHANNEL,
525                             e.getMessage());
526                 }
527             }, 3, TimeUnit.SECONDS);
528         } else {
529             browseItemInternal(item);
530         }
531     }
532
533     private void browseItemInternal(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
534         getServerHandler().browseToItem(lastSessionId, Objects.requireNonNull(item.getType()), item.getId().toString(),
535                 Objects.requireNonNull(item.getName()));
536     }
537
538     private boolean stopCurrentPlayback() throws SyncCallback.SyncCallbackError, ApiClientException {
539         if (lastPlayingState) {
540             sendPlayStateCommand(PlaystateCommand.STOP);
541             return true;
542         }
543         return false;
544     }
545
546     private void sendPlayStateCommand(PlaystateCommand command)
547             throws SyncCallback.SyncCallbackError, ApiClientException {
548         sendPlayStateCommand(command, null);
549     }
550
551     private void sendPlayStateCommand(PlaystateCommand command, @Nullable Long seekPositionTick)
552             throws SyncCallback.SyncCallbackError, ApiClientException {
553         getServerHandler().sendPlayStateCommand(lastSessionId, command, seekPositionTick);
554     }
555
556     private void sendDeviceMessage(Command command) throws SyncCallback.SyncCallbackError, ApiClientException {
557         getServerHandler().sendDeviceMessage(lastSessionId, "Jellyfin OpenHAB", command.toFullString(), 15000);
558     }
559
560     private void handleMediaControlCommand(ChannelUID channelUID, Command command)
561             throws SyncCallback.SyncCallbackError, ApiClientException {
562         if (command instanceof RefreshType) {
563             refreshState();
564         } else if (command instanceof PlayPauseType) {
565             if (command == PlayPauseType.PLAY) {
566                 sendPlayStateCommand(PlaystateCommand.UNPAUSE);
567                 updateState(channelUID, PlayPauseType.PLAY);
568             } else if (command == PlayPauseType.PAUSE) {
569                 sendPlayStateCommand(PlaystateCommand.PAUSE);
570                 updateState(channelUID, PlayPauseType.PAUSE);
571             }
572         } else if (command instanceof NextPreviousType) {
573             if (command == NextPreviousType.NEXT) {
574                 sendPlayStateCommand(PlaystateCommand.NEXT_TRACK);
575             } else if (command == NextPreviousType.PREVIOUS) {
576                 sendPlayStateCommand(PlaystateCommand.PREVIOUS_TRACK);
577             }
578         } else if (command instanceof RewindFastforwardType) {
579             if (command == RewindFastforwardType.FASTFORWARD) {
580                 sendPlayStateCommand(PlaystateCommand.FAST_FORWARD);
581             } else if (command == RewindFastforwardType.REWIND) {
582                 sendPlayStateCommand(PlaystateCommand.REWIND);
583             }
584         } else {
585             logger.warn("Unknown media control command: {}", command);
586         }
587     }
588
589     private void seekToPercentage(int percentage) throws SyncCallback.SyncCallbackError, ApiClientException {
590         if (lastRunTimeTicks == 0L) {
591             logger.warn("Can't seek missing RunTimeTicks info");
592             return;
593         }
594         var seekPositionTick = Math.round(((float) lastRunTimeTicks) * ((float) percentage / 100.0));
595         logger.debug("Seek to {}%: {} of {}", percentage, seekPositionTick, lastRunTimeTicks);
596         seekToTick(seekPositionTick);
597     }
598
599     private void seekToSecond(long second) throws SyncCallback.SyncCallbackError, ApiClientException {
600         long seekPositionTick = second * 10000000L;
601         logger.debug("Seek to second {}: {} of {}", second, seekPositionTick, lastRunTimeTicks);
602         seekToTick(seekPositionTick);
603     }
604
605     private void seekToTick(long seekPositionTick) throws SyncCallback.SyncCallbackError, ApiClientException {
606         sendPlayStateCommand(PlaystateCommand.SEEK, seekPositionTick);
607         scheduler.schedule(this::refreshState, 3, TimeUnit.SECONDS);
608     }
609
610     private void cleanChannels() {
611         List.of(MEDIA_CONTROL_CHANNEL, PLAYING_ITEM_PERCENTAGE_CHANNEL, PLAYING_ITEM_ID_CHANNEL,
612                 PLAYING_ITEM_NAME_CHANNEL, PLAYING_ITEM_SERIES_NAME_CHANNEL, PLAYING_ITEM_SEASON_NAME_CHANNEL,
613                 PLAYING_ITEM_SEASON_CHANNEL, PLAYING_ITEM_EPISODE_CHANNEL, PLAYING_ITEM_GENRES_CHANNEL,
614                 PLAYING_ITEM_TYPE_CHANNEL, PLAYING_ITEM_SECOND_CHANNEL, PLAYING_ITEM_TOTAL_SECOND_CHANNEL)
615                 .forEach(this::cleanChannel);
616     }
617
618     private void cleanChannel(String channelId) {
619         updateState(new ChannelUID(this.thing.getUID(), channelId), UnDefType.NULL);
620     }
621
622     private JellyfinServerHandler getServerHandler() {
623         var bridge = Objects.requireNonNull(getBridge());
624         return (JellyfinServerHandler) Objects.requireNonNull(bridge.getHandler());
625     }
626 }