]> git.basschouten.com Git - openhab-addons.git/blob
538913debe0b856a59ba6297f68053c0bacea009
[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(this::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             if (command instanceof RefreshType) {
115                 refreshState();
116                 return;
117             }
118             switch (channelUID.getId()) {
119                 case SEND_NOTIFICATION_CHANNEL -> sendDeviceMessage(command);
120                 case MEDIA_CONTROL_CHANNEL -> handleMediaControlCommand(channelUID, command);
121                 case PLAY_BY_TERMS_CHANNEL -> runItemSearch(command.toFullString(), PlayCommand.PLAY_NOW);
122                 case PLAY_NEXT_BY_TERMS_CHANNEL -> runItemSearch(command.toFullString(), PlayCommand.PLAY_NEXT);
123                 case PLAY_LAST_BY_TERMS_CHANNEL -> runItemSearch(command.toFullString(), PlayCommand.PLAY_LAST);
124                 case BROWSE_ITEM_BY_TERMS_CHANNEL -> runItemSearch(command.toFullString(), null);
125                 case PLAY_BY_ID_CHANNEL -> runItemById(parseItemUUID(command), PlayCommand.PLAY_NOW);
126                 case PLAY_NEXT_BY_ID_CHANNEL -> runItemById(parseItemUUID(command), PlayCommand.PLAY_NEXT);
127                 case PLAY_LAST_BY_ID_CHANNEL -> runItemById(parseItemUUID(command), PlayCommand.PLAY_LAST);
128                 case BROWSE_ITEM_BY_ID_CHANNEL -> runItemById(parseItemUUID(command), null);
129                 case PLAYING_ITEM_SECOND_CHANNEL -> seekToSecond(command);
130                 case PLAYING_ITEM_PERCENTAGE_CHANNEL -> seekToPercentage(command);
131             }
132         } catch (NumberFormatException numberFormatException) {
133             logger.warn("NumberFormatException error while running channel {}: {}", channelUID.getId(),
134                     numberFormatException.getMessage());
135         } catch (IllegalArgumentException illegalArgumentException) {
136             logger.warn("IllegalArgumentException error while running channel {}: {}", channelUID.getId(),
137                     illegalArgumentException.getMessage());
138         } catch (SyncCallback.SyncCallbackError syncCallbackError) {
139             logger.warn("Unexpected error while running channel {}: {}", channelUID.getId(),
140                     syncCallbackError.getMessage());
141         } catch (ApiClientException e) {
142             getServerHandler().handleApiException(e);
143         }
144     }
145
146     private UUID parseItemUUID(Command command) throws IllegalArgumentException {
147         try {
148             var itemId = command.toFullString().replace("-", "");
149             return new UUID(new BigInteger(itemId.substring(0, 16), 16).longValue(),
150                     new BigInteger(itemId.substring(16), 16).longValue());
151         } catch (NumberFormatException ignored) {
152             throw new IllegalArgumentException("Unable to parse item UUID in command " + command.toFullString() + ".");
153         }
154     }
155
156     @Override
157     public void dispose() {
158         super.dispose();
159         cancelDelayedCommand();
160     }
161
162     private void cancelDelayedCommand() {
163         var delayedCommand = this.delayedCommand;
164         if (delayedCommand != null) {
165             delayedCommand.cancel(true);
166         }
167     }
168
169     private void refreshState() {
170         getServerHandler().updateClientState(this);
171     }
172
173     private void updateChannelStates(@Nullable BaseItemDto playingItem, @Nullable PlayerStateInfo playState) {
174         lastPlayingState = playingItem != null;
175         lastRunTimeTicks = playingItem != null ? Objects.requireNonNull(playingItem.getRunTimeTicks()) : 0L;
176         var positionTicks = playState != null ? playState.getPositionTicks() : null;
177         var runTimeTicks = playingItem != null ? playingItem.getRunTimeTicks() : null;
178         if (isLinked(MEDIA_CONTROL_CHANNEL)) {
179             updateState(new ChannelUID(this.thing.getUID(), MEDIA_CONTROL_CHANNEL),
180                     playingItem != null && playState != null && !playState.isPaused() ? PlayPauseType.PLAY
181                             : PlayPauseType.PAUSE);
182         }
183         if (isLinked(PLAYING_ITEM_PERCENTAGE_CHANNEL)) {
184             if (positionTicks != null && runTimeTicks != null) {
185                 int percentage = (int) Math.round((positionTicks * 100.0) / runTimeTicks);
186                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_PERCENTAGE_CHANNEL),
187                         new PercentType(percentage));
188             } else {
189                 cleanChannel(PLAYING_ITEM_PERCENTAGE_CHANNEL);
190             }
191         }
192         if (isLinked(PLAYING_ITEM_SECOND_CHANNEL)) {
193             if (positionTicks != null) {
194                 var second = Math.round((float) positionTicks / 10000000.0);
195                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SECOND_CHANNEL), new DecimalType(second));
196             } else {
197                 cleanChannel(PLAYING_ITEM_SECOND_CHANNEL);
198             }
199         }
200         if (isLinked(PLAYING_ITEM_TOTAL_SECOND_CHANNEL)) {
201             if (runTimeTicks != null) {
202                 var seconds = Math.round((float) runTimeTicks / 10000000.0);
203                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_TOTAL_SECOND_CHANNEL),
204                         new DecimalType(seconds));
205             } else {
206                 cleanChannel(PLAYING_ITEM_TOTAL_SECOND_CHANNEL);
207             }
208         }
209         if (isLinked(PLAYING_ITEM_ID_CHANNEL)) {
210             if (playingItem != null) {
211                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_ID_CHANNEL),
212                         new StringType(playingItem.getId().toString()));
213             } else {
214                 cleanChannel(PLAYING_ITEM_ID_CHANNEL);
215             }
216         }
217         if (isLinked(PLAYING_ITEM_NAME_CHANNEL)) {
218             if (playingItem != null) {
219                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_NAME_CHANNEL),
220                         new StringType(playingItem.getName()));
221             } else {
222                 cleanChannel(PLAYING_ITEM_NAME_CHANNEL);
223             }
224         }
225         if (isLinked(PLAYING_ITEM_SERIES_NAME_CHANNEL)) {
226             if (playingItem != null) {
227                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SERIES_NAME_CHANNEL),
228                         new StringType(playingItem.getSeriesName()));
229             } else {
230                 cleanChannel(PLAYING_ITEM_SERIES_NAME_CHANNEL);
231             }
232         }
233         if (isLinked(PLAYING_ITEM_SEASON_NAME_CHANNEL)) {
234             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
235                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SEASON_NAME_CHANNEL),
236                         new StringType(playingItem.getSeasonName()));
237             } else {
238                 cleanChannel(PLAYING_ITEM_SEASON_NAME_CHANNEL);
239             }
240         }
241         if (isLinked(PLAYING_ITEM_SEASON_CHANNEL)) {
242             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
243                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_SEASON_CHANNEL),
244                         new DecimalType(Objects.requireNonNull(playingItem.getParentIndexNumber())));
245             } else {
246                 cleanChannel(PLAYING_ITEM_SEASON_CHANNEL);
247             }
248         }
249         if (isLinked(PLAYING_ITEM_EPISODE_CHANNEL)) {
250             if (playingItem != null && BaseItemKind.EPISODE.equals(playingItem.getType())) {
251                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_EPISODE_CHANNEL),
252                         new DecimalType(Objects.requireNonNull(playingItem.getIndexNumber())));
253             } else {
254                 cleanChannel(PLAYING_ITEM_EPISODE_CHANNEL);
255             }
256         }
257         if (isLinked(PLAYING_ITEM_GENRES_CHANNEL)) {
258             if (playingItem != null) {
259                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_GENRES_CHANNEL),
260                         new StringType(String.join(",", Objects.requireNonNull(playingItem.getGenres()))));
261             } else {
262                 cleanChannel(PLAYING_ITEM_GENRES_CHANNEL);
263             }
264         }
265         if (isLinked(PLAYING_ITEM_TYPE_CHANNEL)) {
266             if (playingItem != null) {
267                 updateState(new ChannelUID(this.thing.getUID(), PLAYING_ITEM_TYPE_CHANNEL),
268                         new StringType(playingItem.getType().toString()));
269             } else {
270                 cleanChannel(PLAYING_ITEM_TYPE_CHANNEL);
271             }
272         }
273     }
274
275     private void runItemSearch(String terms, @Nullable PlayCommand playCommand)
276             throws SyncCallback.SyncCallbackError, ApiClientException {
277         if (terms.isBlank() || UnDefType.NULL.toFullString().equals(terms)) {
278             return;
279         }
280         // detect series search with season and episode info
281         var seriesEpisodeMatcher = seriesSearchPattern.matcher(terms);
282         if (seriesEpisodeMatcher.matches()) {
283             var season = Integer.parseInt(seriesEpisodeMatcher.group("season"));
284             var episode = Integer.parseInt(seriesEpisodeMatcher.group("episode"));
285             var cleanTerms = seriesEpisodeMatcher.group("terms");
286             runSeriesEpisode(cleanTerms, season, episode, playCommand);
287             return;
288         }
289         // detect search with type info or consider all types are enabled
290         var typeMatcher = typeSearchPattern.matcher(terms);
291         boolean searchByTypeEnabled = typeMatcher.matches();
292         var type = searchByTypeEnabled ? typeMatcher.group("type") : "";
293         boolean movieSearchEnabled = !searchByTypeEnabled || "movie".equals(type);
294         boolean seriesSearchEnabled = !searchByTypeEnabled || "series".equals(type);
295         boolean episodeSearchEnabled = !searchByTypeEnabled || "episode".equals(type);
296         var searchTerms = searchByTypeEnabled ? typeMatcher.group("terms") : terms;
297         runItemSearchByType(searchTerms, playCommand, movieSearchEnabled, seriesSearchEnabled, episodeSearchEnabled);
298     }
299
300     private void runItemSearchByType(String terms, @Nullable PlayCommand playCommand, boolean movieSearchEnabled,
301             boolean seriesSearchEnabled, boolean episodeSearchEnabled)
302             throws SyncCallback.SyncCallbackError, ApiClientException {
303         var seriesItem = seriesSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.SERIES, null) : null;
304         var movieItem = movieSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.MOVIE, null) : null;
305         var episodeItem = episodeSearchEnabled ? getServerHandler().searchItem(terms, BaseItemKind.EPISODE, null)
306                 : null;
307         if (movieItem != null) {
308             logger.debug("Found movie: '{}'", movieItem.getName());
309         }
310         if (seriesItem != null) {
311             logger.debug("Found series: '{}'", seriesItem.getName());
312         }
313         if (episodeItem != null) {
314             logger.debug("Found episode: '{}'", episodeItem.getName());
315         }
316         if (movieItem != null) {
317             runItem(movieItem, playCommand);
318         } else if (seriesItem != null) {
319             runSeriesItem(seriesItem, playCommand);
320         } else if (episodeItem != null) {
321             runItem(episodeItem, playCommand);
322         } else {
323             logger.warn("Nothing to display for: {}", terms);
324         }
325     }
326
327     private void runSeriesItem(BaseItemDto seriesItem, @Nullable PlayCommand playCommand)
328             throws SyncCallback.SyncCallbackError, ApiClientException {
329         if (playCommand != null) {
330             var resumeEpisodeItem = getServerHandler().getSeriesResumeItem(seriesItem.getId());
331             var nextUpEpisodeItem = getServerHandler().getSeriesNextUpItem(seriesItem.getId());
332             var firstEpisodeItem = getServerHandler().getSeriesEpisodeItem(seriesItem.getId(), 1, 1);
333             if (resumeEpisodeItem != null) {
334                 logger.debug("Resuming series '{}' episode '{}'", seriesItem.getName(), resumeEpisodeItem.getName());
335                 playItem(resumeEpisodeItem, playCommand,
336                         Objects.requireNonNull(resumeEpisodeItem.getUserData()).getPlaybackPositionTicks());
337             } else if (nextUpEpisodeItem != null) {
338                 logger.debug("Playing next series '{}' episode '{}'", seriesItem.getName(),
339                         nextUpEpisodeItem.getName());
340                 playItem(nextUpEpisodeItem, playCommand);
341             } else if (firstEpisodeItem != null) {
342                 logger.debug("Playing series '{}' first episode '{}'", seriesItem.getName(),
343                         firstEpisodeItem.getName());
344                 playItem(firstEpisodeItem, playCommand);
345             } else {
346                 logger.warn("Unable to found episode for series");
347             }
348         } else {
349             logger.debug("Browse series '{}'", seriesItem.getName());
350             browseItem(seriesItem);
351         }
352     }
353
354     private void runSeriesEpisode(String terms, int season, int episode, @Nullable PlayCommand playCommand)
355             throws SyncCallback.SyncCallbackError, ApiClientException {
356         logger.debug("{} series episode mode", playCommand != null ? "Play" : "Browse");
357         var seriesItem = getServerHandler().searchItem(terms, BaseItemKind.SERIES, null);
358         if (seriesItem != null) {
359             logger.debug("Searching series {} episode {}x{}", seriesItem.getName(), season, episode);
360             var episodeItem = getServerHandler().getSeriesEpisodeItem(seriesItem.getId(), season, episode);
361             if (episodeItem != null) {
362                 runItem(episodeItem, playCommand);
363             } else {
364                 logger.warn("Series {} episode {}x{} not found", seriesItem.getName(), season, episode);
365             }
366         } else {
367             logger.warn("Series not found");
368         }
369     }
370
371     private void runItem(BaseItemDto item, @Nullable PlayCommand playCommand)
372             throws SyncCallback.SyncCallbackError, ApiClientException {
373         var itemType = Objects.requireNonNull(item.getType());
374         logger.debug("{} {} '{}'", playCommand == null ? "Browsing" : "Playing", itemType.toString().toLowerCase(),
375                 BaseItemKind.EPISODE.equals(itemType) ? item.getSeriesName() + ": " + item.getName() : item.getName());
376         if (playCommand == null) {
377             browseItem(item);
378         } else {
379             playItem(item, playCommand);
380         }
381     }
382
383     private void playItem(BaseItemDto item, PlayCommand playCommand)
384             throws SyncCallback.SyncCallbackError, ApiClientException {
385         playItem(item, playCommand, null);
386     }
387
388     private void playItem(BaseItemDto item, PlayCommand playCommand, @Nullable Long startPositionTicks)
389             throws SyncCallback.SyncCallbackError, ApiClientException {
390         if (playCommand.equals(PlayCommand.PLAY_NOW) && stopCurrentPlayback()) {
391             cancelDelayedCommand();
392             delayedCommand = scheduler.schedule(() -> {
393                 try {
394                     playItemInternal(item, playCommand, startPositionTicks);
395                 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
396                     logger.warn("Unexpected error while running channel {}: {}", PLAY_BY_TERMS_CHANNEL, e.getMessage());
397                 }
398             }, 3, TimeUnit.SECONDS);
399         } else {
400             playItemInternal(item, playCommand, startPositionTicks);
401         }
402     }
403
404     private void playItemInternal(BaseItemDto item, PlayCommand playCommand, @Nullable Long startPositionTicks)
405             throws SyncCallback.SyncCallbackError, ApiClientException {
406         getServerHandler().playItem(lastSessionId, playCommand, item.getId().toString(), startPositionTicks);
407     }
408
409     private void runItemById(UUID itemId, @Nullable PlayCommand playCommand)
410             throws SyncCallback.SyncCallbackError, ApiClientException {
411         var item = getServerHandler().getItem(itemId, null);
412         if (item == null) {
413             logger.warn("Unable to find item with id: {}", itemId);
414             return;
415         }
416         if (BaseItemKind.SERIES.equals(item.getType())) {
417             runSeriesItem(item, playCommand);
418         } else {
419             runItem(item, playCommand);
420         }
421     }
422
423     private void browseItem(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
424         if (stopCurrentPlayback()) {
425             cancelDelayedCommand();
426             delayedCommand = scheduler.schedule(() -> {
427                 try {
428                     browseItemInternal(item);
429                 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
430                     logger.warn("Unexpected error while running channel {}: {}", BROWSE_ITEM_BY_TERMS_CHANNEL,
431                             e.getMessage());
432                 }
433             }, 3, TimeUnit.SECONDS);
434         } else {
435             browseItemInternal(item);
436         }
437     }
438
439     private void browseItemInternal(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
440         getServerHandler().browseToItem(lastSessionId, Objects.requireNonNull(item.getType()), item.getId().toString(),
441                 Objects.requireNonNull(item.getName()));
442     }
443
444     private boolean stopCurrentPlayback() throws SyncCallback.SyncCallbackError, ApiClientException {
445         if (lastPlayingState) {
446             sendPlayStateCommand(PlaystateCommand.STOP);
447             return true;
448         }
449         return false;
450     }
451
452     private void sendPlayStateCommand(PlaystateCommand command)
453             throws SyncCallback.SyncCallbackError, ApiClientException {
454         sendPlayStateCommand(command, null);
455     }
456
457     private void sendPlayStateCommand(PlaystateCommand command, @Nullable Long seekPositionTick)
458             throws SyncCallback.SyncCallbackError, ApiClientException {
459         getServerHandler().sendPlayStateCommand(lastSessionId, command, seekPositionTick);
460     }
461
462     private void sendDeviceMessage(Command command) throws SyncCallback.SyncCallbackError, ApiClientException {
463         getServerHandler().sendDeviceMessage(lastSessionId, "Jellyfin OpenHAB", command.toFullString(), 15000);
464     }
465
466     private void handleMediaControlCommand(ChannelUID channelUID, Command command)
467             throws SyncCallback.SyncCallbackError, ApiClientException {
468         if (command instanceof RefreshType) {
469             refreshState();
470         } else if (command instanceof PlayPauseType) {
471             if (command == PlayPauseType.PLAY) {
472                 sendPlayStateCommand(PlaystateCommand.UNPAUSE);
473                 updateState(channelUID, PlayPauseType.PLAY);
474             } else if (command == PlayPauseType.PAUSE) {
475                 sendPlayStateCommand(PlaystateCommand.PAUSE);
476                 updateState(channelUID, PlayPauseType.PAUSE);
477             }
478         } else if (command instanceof NextPreviousType) {
479             if (command == NextPreviousType.NEXT) {
480                 sendPlayStateCommand(PlaystateCommand.NEXT_TRACK);
481             } else if (command == NextPreviousType.PREVIOUS) {
482                 sendPlayStateCommand(PlaystateCommand.PREVIOUS_TRACK);
483             }
484         } else if (command instanceof RewindFastforwardType) {
485             if (command == RewindFastforwardType.FASTFORWARD) {
486                 sendPlayStateCommand(PlaystateCommand.FAST_FORWARD);
487             } else if (command == RewindFastforwardType.REWIND) {
488                 sendPlayStateCommand(PlaystateCommand.REWIND);
489             }
490         } else {
491             logger.warn("Unknown media control command: {}", command);
492         }
493     }
494
495     private void seekToPercentage(Command command)
496             throws NumberFormatException, SyncCallback.SyncCallbackError, ApiClientException {
497         if (command.toFullString().equals(UnDefType.NULL.toFullString())) {
498             return;
499         }
500         if (lastRunTimeTicks == 0L) {
501             logger.warn("Can't seek missing RunTimeTicks info");
502             return;
503         }
504         int percentage = Integer.parseInt(command.toFullString());
505         var seekPositionTick = Math.round(((float) lastRunTimeTicks) * ((float) percentage / 100.0));
506         logger.debug("Seek to {}%: {} of {}", percentage, seekPositionTick, lastRunTimeTicks);
507         seekToTick(seekPositionTick);
508     }
509
510     private void seekToSecond(Command command)
511             throws NumberFormatException, SyncCallback.SyncCallbackError, ApiClientException {
512         if (command.toFullString().equals(UnDefType.NULL.toFullString())) {
513             return;
514         }
515         long second = Long.parseLong(command.toFullString());
516         long seekPositionTick = second * 10000000L;
517         logger.debug("Seek to second {}: {} of {}", second, seekPositionTick, lastRunTimeTicks);
518         seekToTick(seekPositionTick);
519     }
520
521     private void seekToTick(long seekPositionTick) throws SyncCallback.SyncCallbackError, ApiClientException {
522         sendPlayStateCommand(PlaystateCommand.SEEK, seekPositionTick);
523         scheduler.schedule(this::refreshState, 3, TimeUnit.SECONDS);
524     }
525
526     private void cleanChannels() {
527         List.of(MEDIA_CONTROL_CHANNEL, PLAYING_ITEM_PERCENTAGE_CHANNEL, PLAYING_ITEM_ID_CHANNEL,
528                 PLAYING_ITEM_NAME_CHANNEL, PLAYING_ITEM_SERIES_NAME_CHANNEL, PLAYING_ITEM_SEASON_NAME_CHANNEL,
529                 PLAYING_ITEM_SEASON_CHANNEL, PLAYING_ITEM_EPISODE_CHANNEL, PLAYING_ITEM_GENRES_CHANNEL,
530                 PLAYING_ITEM_TYPE_CHANNEL, PLAYING_ITEM_SECOND_CHANNEL, PLAYING_ITEM_TOTAL_SECOND_CHANNEL)
531                 .forEach(this::cleanChannel);
532     }
533
534     private void cleanChannel(String channelId) {
535         updateState(new ChannelUID(this.thing.getUID(), channelId), UnDefType.NULL);
536     }
537
538     private JellyfinServerHandler getServerHandler() {
539         var bridge = Objects.requireNonNull(getBridge());
540         return (JellyfinServerHandler) Objects.requireNonNull(bridge.getHandler());
541     }
542 }