]> git.basschouten.com Git - openhab-addons.git/blob
21b876d0f3989449b5fac6a1ce57e06d4f32c276
[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         UUID itemUUID = new UUID(new BigInteger(itemId.substring(0, 16), 16).longValue(),
247                 new BigInteger(itemId.substring(16), 16).longValue());
248         return itemUUID;
249     }
250
251     @Override
252     public void dispose() {
253         super.dispose();
254         cancelDelayedCommand();
255     }
256
257     private void cancelDelayedCommand() {
258         var delayedCommand = this.delayedCommand;
259         if (delayedCommand != null) {
260             delayedCommand.cancel(true);
261         }
262     }
263
264     private void refreshState() {
265         getServerHandler().updateClientState(this);
266     }
267
268     private void updateChannelStates(@Nullable BaseItemDto playingItem, @Nullable PlayerStateInfo playState) {
269         lastPlayingState = playingItem != null;
270         lastRunTimeTicks = playingItem != null ? Objects.requireNonNull(playingItem.getRunTimeTicks()) : 0L;
271         var positionTicks = playState != null ? playState.getPositionTicks() : null;
272         var runTimeTicks = playingItem != null ? playingItem.getRunTimeTicks() : null;
273         if (isLinked(MEDIA_CONTROL_CHANNEL)) {
274             updateState(new ChannelUID(this.thing.getUID(), MEDIA_CONTROL_CHANNEL),
275                     playingItem != null && playState != null && !playState.isPaused() ? PlayPauseType.PLAY
276                             : PlayPauseType.PAUSE);
277         }
278         if (isLinked(PLAYING_ITEM_PERCENTAGE_CHANNEL)) {
279             if (positionTicks != null && runTimeTicks != null) {
280                 int percentage = (int) Math.round((positionTicks * 100.0) / runTimeTicks);
281                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_PERCENTAGE_CHANNEL),
282                         new PercentType(percentage));
283             } else {
284                 cleanChannel(PLAYING_ITEM_PERCENTAGE_CHANNEL);
285             }
286         }
287         if (isLinked(PLAYING_ITEM_SECOND_CHANNEL)) {
288             if (positionTicks != null) {
289                 var second = Math.round((float) positionTicks / 10000000.0);
290                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SECOND_CHANNEL), new DecimalType(second));
291             } else {
292                 cleanChannel(PLAYING_ITEM_SECOND_CHANNEL);
293             }
294         }
295         if (isLinked(PLAYING_ITEM_TOTAL_SECOND_CHANNEL)) {
296             if (runTimeTicks != null) {
297                 var seconds = Math.round((float) runTimeTicks / 10000000.0);
298                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_TOTAL_SECOND_CHANNEL),
299                         new DecimalType(seconds));
300             } else {
301                 cleanChannel(PLAYING_ITEM_TOTAL_SECOND_CHANNEL);
302             }
303         }
304         if (isLinked(PLAYING_ITEM_ID_CHANNEL)) {
305             if (playingItem != null) {
306                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_ID_CHANNEL),
307                         new StringType(playingItem.getId().toString()));
308             } else {
309                 cleanChannel(PLAYING_ITEM_ID_CHANNEL);
310             }
311         }
312         if (isLinked(PLAYING_ITEM_NAME_CHANNEL)) {
313             if (playingItem != null) {
314                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_NAME_CHANNEL),
315                         new StringType(playingItem.getName()));
316             } else {
317                 cleanChannel(PLAYING_ITEM_NAME_CHANNEL);
318             }
319         }
320         if (isLinked(PLAYING_ITEM_SERIES_NAME_CHANNEL)) {
321             if (playingItem != null) {
322                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SERIES_NAME_CHANNEL),
323                         new StringType(playingItem.getSeriesName()));
324             } else {
325                 cleanChannel(PLAYING_ITEM_SERIES_NAME_CHANNEL);
326             }
327         }
328         if (isLinked(PLAYING_ITEM_SEASON_NAME_CHANNEL)) {
329             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
330                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SEASON_NAME_CHANNEL),
331                         new StringType(playingItem.getSeasonName()));
332             } else {
333                 cleanChannel(PLAYING_ITEM_SEASON_NAME_CHANNEL);
334             }
335         }
336         if (isLinked(PLAYING_ITEM_SEASON_CHANNEL)) {
337             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
338                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SEASON_CHANNEL),
339                         new DecimalType(Objects.requireNonNull(playingItem.getParentIndexNumber())));
340             } else {
341                 cleanChannel(PLAYING_ITEM_SEASON_CHANNEL);
342             }
343         }
344         if (isLinked(PLAYING_ITEM_EPISODE_CHANNEL)) {
345             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
346                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_EPISODE_CHANNEL),
347                         new DecimalType(Objects.requireNonNull(playingItem.getIndexNumber())));
348             } else {
349                 cleanChannel(PLAYING_ITEM_EPISODE_CHANNEL);
350             }
351         }
352         if (isLinked(PLAYING_ITEM_GENRES_CHANNEL)) {
353             if (playingItem != null) {
354                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_GENRES_CHANNEL),
355                         new StringType(String.join(",", Objects.requireNonNull(playingItem.getGenres()))));
356             } else {
357                 cleanChannel(PLAYING_ITEM_GENRES_CHANNEL);
358             }
359         }
360         if (isLinked(PLAYING_ITEM_TYPE_CHANNEL)) {
361             if (playingItem != null) {
362                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_TYPE_CHANNEL),
363                         new StringType(playingItem.getType().toString()));
364             } else {
365                 cleanChannel(PLAYING_ITEM_TYPE_CHANNEL);
366             }
367         }
368     }
369
370     private void runItemSearch(String terms, @Nullable PlayCommand playCommand)
371             throws SyncCallback.SyncCallbackError, ApiClientException {
372         if (terms.isBlank() || UnDefType.NULL.toFullString().equals(terms)) {
373             return;
374         }
375         // detect series search with season and episode info
376         var seriesEpisodeMatcher = seriesSearchPattern.matcher(terms);
377         if (seriesEpisodeMatcher.matches()) {
378             var season = Integer.parseInt(seriesEpisodeMatcher.group("season"));
379             var episode = Integer.parseInt(seriesEpisodeMatcher.group("episode"));
380             var cleanTerms = seriesEpisodeMatcher.group("terms");
381             runSeriesEpisode(cleanTerms, season, episode, playCommand);
382             return;
383         }
384         // detect search with type info or consider all types are enabled
385         var typeMatcher = typeSearchPattern.matcher(terms);
386         boolean searchByTypeEnabled = typeMatcher.matches();
387         var type = searchByTypeEnabled ? typeMatcher.group("type") : "";
388         boolean movieSearchEnabled = !searchByTypeEnabled || type.equals("movie");
389         boolean seriesSearchEnabled = !searchByTypeEnabled || type.equals("series");
390         boolean episodeSearchEnabled = !searchByTypeEnabled || type.equals("episode");
391         var searchTerms = searchByTypeEnabled ? typeMatcher.group("terms") : terms;
392         runItemSearchByType(searchTerms, playCommand, movieSearchEnabled, seriesSearchEnabled, episodeSearchEnabled);
393     }
394
395     private void runItemSearchByType(String terms, @Nullable PlayCommand playCommand, boolean movieSearchEnabled,
396             boolean seriesSearchEnabled, boolean episodeSearchEnabled)
397             throws SyncCallback.SyncCallbackError, ApiClientException {
398         var seriesItem = seriesSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.SERIES, null) : null;
399         var movieItem = movieSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.MOVIE, null) : null;
400         var episodeItem = episodeSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.EPISODE, null)
401                 : null;
402         if (movieItem != null) {
403             logger.debug("Found movie: '{}'", movieItem.getName());
404         }
405         if (seriesItem != null) {
406             logger.debug("Found series: '{}'", seriesItem.getName());
407         }
408         if (episodeItem != null) {
409             logger.debug("Found episode: '{}'", episodeItem.getName());
410         }
411         if (movieItem != null) {
412             runItem(movieItem, playCommand);
413         } else if (seriesItem != null) {
414             runSeriesItem(seriesItem, playCommand);
415         } else if (episodeItem != null) {
416             runItem(episodeItem, playCommand);
417         } else {
418             logger.warn("Nothing to display for: {}", terms);
419         }
420     }
421
422     private void runSeriesItem(BaseItemDto seriesItem, @Nullable PlayCommand playCommand)
423             throws SyncCallback.SyncCallbackError, ApiClientException {
424         if (playCommand != null) {
425             var resumeEpisodeItem = getServerHandler().getSeriesResumeItem(seriesItem.getId());
426             var nextUpEpisodeItem = getServerHandler().getSeriesNextUpItem(seriesItem.getId());
427             var firstEpisodeItem = getServerHandler().getSeriesEpisodeItem(seriesItem.getId(), 1, 1);
428             if (resumeEpisodeItem != null) {
429                 logger.debug("Resuming series '{}' episode '{}'", seriesItem.getName(), resumeEpisodeItem.getName());
430                 playItem(resumeEpisodeItem, playCommand,
431                         Objects.requireNonNull(resumeEpisodeItem.getUserData()).getPlaybackPositionTicks());
432             } else if (nextUpEpisodeItem != null) {
433                 logger.debug("Playing next series '{}' episode '{}'", seriesItem.getName(),
434                         nextUpEpisodeItem.getName());
435                 playItem(nextUpEpisodeItem, playCommand);
436             } else if (firstEpisodeItem != null) {
437                 logger.debug("Playing series '{}' first episode '{}'", seriesItem.getName(),
438                         firstEpisodeItem.getName());
439                 playItem(firstEpisodeItem, playCommand);
440             } else {
441                 logger.warn("Unable to found episode for series");
442             }
443         } else {
444             logger.debug("Browse series '{}'", seriesItem.getName());
445             browseItem(seriesItem);
446         }
447     }
448
449     private void runSeriesEpisode(String terms, int season, int episode, @Nullable PlayCommand playCommand)
450             throws SyncCallback.SyncCallbackError, ApiClientException {
451         logger.debug("{} series episode mode", playCommand != null ? "Play" : "Browse");
452         var seriesItem = getServerHandler().searchItem(terms, BaseItemKind.SERIES, null);
453         if (seriesItem != null) {
454             logger.debug("Searching series {} episode {}x{}", seriesItem.getName(), season, episode);
455             var episodeItem = getServerHandler().getSeriesEpisodeItem(seriesItem.getId(), season, episode);
456             if (episodeItem != null) {
457                 runItem(episodeItem, playCommand);
458             } else {
459                 logger.warn("Series {} episode {}x{} not found", seriesItem.getName(), season, episode);
460             }
461         } else {
462             logger.warn("Series not found");
463         }
464     }
465
466     private void runItem(BaseItemDto item, @Nullable PlayCommand playCommand)
467             throws SyncCallback.SyncCallbackError, ApiClientException {
468         var itemType = Objects.requireNonNull(item.getType());
469         logger.debug("{} {} '{}'", playCommand == null ? "Browsing" : "Playing", itemType.toString().toLowerCase(),
470                 BaseItemKind.EPISODE.equals(itemType) ? item.getSeriesName() + ": " + item.getName() : item.getName());
471         if (playCommand == null) {
472             browseItem(item);
473         } else {
474             playItem(item, playCommand);
475         }
476     }
477
478     private void playItem(BaseItemDto item, PlayCommand playCommand)
479             throws SyncCallback.SyncCallbackError, ApiClientException {
480         playItem(item, playCommand, null);
481     }
482
483     private void playItem(BaseItemDto item, PlayCommand playCommand, @Nullable Long startPositionTicks)
484             throws SyncCallback.SyncCallbackError, ApiClientException {
485         if (playCommand.equals(PlayCommand.PLAY_NOW) && stopCurrentPlayback()) {
486             cancelDelayedCommand();
487             delayedCommand = scheduler.schedule(() -> {
488                 try {
489                     playItemInternal(item, playCommand, startPositionTicks);
490                 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
491                     logger.warn("Unexpected error while running channel {}: {}", PLAY_BY_TERMS_CHANNEL, e.getMessage());
492                 }
493             }, 3, TimeUnit.SECONDS);
494         } else {
495             playItemInternal(item, playCommand, startPositionTicks);
496         }
497     }
498
499     private void playItemInternal(BaseItemDto item, PlayCommand playCommand, @Nullable Long startPositionTicks)
500             throws SyncCallback.SyncCallbackError, ApiClientException {
501         getServerHandler().playItem(lastSessionId, playCommand, item.getId().toString(), startPositionTicks);
502     }
503
504     private void runItemById(UUID itemId, @Nullable PlayCommand playCommand)
505             throws SyncCallback.SyncCallbackError, ApiClientException {
506         var item = getServerHandler().getItem(itemId, null);
507         if (item == null) {
508             logger.warn("Unable to find item with id: {}", itemId);
509             return;
510         }
511         if (BaseItemKind.SERIES.equals(item.getType())) {
512             runSeriesItem(item, playCommand);
513         } else {
514             runItem(item, playCommand);
515         }
516     }
517
518     private void browseItem(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
519         if (stopCurrentPlayback()) {
520             cancelDelayedCommand();
521             delayedCommand = scheduler.schedule(() -> {
522                 try {
523                     browseItemInternal(item);
524                 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
525                     logger.warn("Unexpected error while running channel {}: {}", BROWSE_ITEM_BY_TERMS_CHANNEL,
526                             e.getMessage());
527                 }
528             }, 3, TimeUnit.SECONDS);
529         } else {
530             browseItemInternal(item);
531         }
532     }
533
534     private void browseItemInternal(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
535         getServerHandler().browseToItem(lastSessionId, Objects.requireNonNull(item.getType()), item.getId().toString(),
536                 Objects.requireNonNull(item.getName()));
537     }
538
539     private boolean stopCurrentPlayback() throws SyncCallback.SyncCallbackError, ApiClientException {
540         if (lastPlayingState) {
541             sendPlayStateCommand(PlaystateCommand.STOP);
542             return true;
543         }
544         return false;
545     }
546
547     private void sendPlayStateCommand(PlaystateCommand command)
548             throws SyncCallback.SyncCallbackError, ApiClientException {
549         sendPlayStateCommand(command, null);
550     }
551
552     private void sendPlayStateCommand(PlaystateCommand command, @Nullable Long seekPositionTick)
553             throws SyncCallback.SyncCallbackError, ApiClientException {
554         getServerHandler().sendPlayStateCommand(lastSessionId, command, seekPositionTick);
555     }
556
557     private void sendDeviceMessage(Command command) throws SyncCallback.SyncCallbackError, ApiClientException {
558         getServerHandler().sendDeviceMessage(lastSessionId, "Jellyfin OpenHAB", command.toFullString(), 15000);
559     }
560
561     private void handleMediaControlCommand(ChannelUID channelUID, Command command)
562             throws SyncCallback.SyncCallbackError, ApiClientException {
563         if (command instanceof RefreshType) {
564             refreshState();
565         } else if (command instanceof PlayPauseType) {
566             if (command == PlayPauseType.PLAY) {
567                 sendPlayStateCommand(PlaystateCommand.UNPAUSE);
568                 updateState(channelUID, PlayPauseType.PLAY);
569             } else if (command == PlayPauseType.PAUSE) {
570                 sendPlayStateCommand(PlaystateCommand.PAUSE);
571                 updateState(channelUID, PlayPauseType.PAUSE);
572             }
573         } else if (command instanceof NextPreviousType) {
574             if (command == NextPreviousType.NEXT) {
575                 sendPlayStateCommand(PlaystateCommand.NEXT_TRACK);
576             } else if (command == NextPreviousType.PREVIOUS) {
577                 sendPlayStateCommand(PlaystateCommand.PREVIOUS_TRACK);
578             }
579         } else if (command instanceof RewindFastforwardType) {
580             if (command == RewindFastforwardType.FASTFORWARD) {
581                 sendPlayStateCommand(PlaystateCommand.FAST_FORWARD);
582             } else if (command == RewindFastforwardType.REWIND) {
583                 sendPlayStateCommand(PlaystateCommand.REWIND);
584             }
585         } else {
586             logger.warn("Unknown media control command: {}", command);
587         }
588     }
589
590     private void seekToPercentage(int percentage) throws SyncCallback.SyncCallbackError, ApiClientException {
591         if (lastRunTimeTicks == 0L) {
592             logger.warn("Can't seek missing RunTimeTicks info");
593             return;
594         }
595         var seekPositionTick = Math.round(((float) lastRunTimeTicks) * ((float) percentage / 100.0));
596         logger.debug("Seek to {}%: {} of {}", percentage, seekPositionTick, lastRunTimeTicks);
597         seekToTick(seekPositionTick);
598     }
599
600     private void seekToSecond(long second) throws SyncCallback.SyncCallbackError, ApiClientException {
601         long seekPositionTick = second * 10000000L;
602         logger.debug("Seek to second {}: {} of {}", second, seekPositionTick, lastRunTimeTicks);
603         seekToTick(seekPositionTick);
604     }
605
606     private void seekToTick(long seekPositionTick) throws SyncCallback.SyncCallbackError, ApiClientException {
607         sendPlayStateCommand(PlaystateCommand.SEEK, seekPositionTick);
608         scheduler.schedule(this::refreshState, 3, TimeUnit.SECONDS);
609     }
610
611     private void cleanChannels() {
612         List.of(MEDIA_CONTROL_CHANNEL, PLAYING_ITEM_PERCENTAGE_CHANNEL, PLAYING_ITEM_ID_CHANNEL,
613                 PLAYING_ITEM_NAME_CHANNEL, PLAYING_ITEM_SERIES_NAME_CHANNEL, PLAYING_ITEM_SEASON_NAME_CHANNEL,
614                 PLAYING_ITEM_SEASON_CHANNEL, PLAYING_ITEM_EPISODE_CHANNEL, PLAYING_ITEM_GENRES_CHANNEL,
615                 PLAYING_ITEM_TYPE_CHANNEL, PLAYING_ITEM_SECOND_CHANNEL, PLAYING_ITEM_TOTAL_SECOND_CHANNEL)
616                 .forEach(this::cleanChannel);
617     }
618
619     private void cleanChannel(String channelId) {
620         updateState(new ChannelUID(this.thing.getUID(), channelId), UnDefType.NULL);
621     }
622
623     private JellyfinServerHandler getServerHandler() {
624         var bridge = Objects.requireNonNull(getBridge());
625         return (JellyfinServerHandler) Objects.requireNonNull(bridge.getHandler());
626     }
627 }