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