2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.jellyfin.internal.handler;
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;
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;
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;
64 * The {@link JellyfinClientHandler} is responsible for handling commands, which are
65 * sent to one of the channels.
67 * @author Miguel Álvarez - Initial contribution
70 public class JellyfinClientHandler extends BaseThingHandler {
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;
81 public JellyfinClientHandler(Thing thing) {
86 public void initialize() {
87 updateStatus(ThingStatus.UNKNOWN);
88 scheduler.execute(() -> refreshState());
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());
97 lastPlayingState = false;
99 updateStatus(ThingStatus.OFFLINE);
104 public void handleCommand(ChannelUID channelUID, Command command) {
106 switch (channelUID.getId()) {
107 case SEND_NOTIFICATION_CHANNEL:
108 if (command instanceof RefreshType) {
111 sendDeviceMessage(command);
113 case MEDIA_CONTROL_CHANNEL:
114 if (command instanceof RefreshType) {
118 handleMediaControlCommand(channelUID, command);
120 case PLAY_BY_TERMS_CHANNEL:
121 if (command instanceof RefreshType) {
124 runItemSearch(command.toFullString(), PlayCommand.PLAY_NOW);
126 case PLAY_NEXT_BY_TERMS_CHANNEL:
127 if (command instanceof RefreshType) {
130 runItemSearch(command.toFullString(), PlayCommand.PLAY_NEXT);
132 case PLAY_LAST_BY_TERMS_CHANNEL:
133 if (command instanceof RefreshType) {
136 runItemSearch(command.toFullString(), PlayCommand.PLAY_LAST);
138 case BROWSE_ITEM_BY_TERMS_CHANNEL:
139 if (command instanceof RefreshType) {
142 runItemSearch(command.toFullString(), null);
144 case PLAYING_ITEM_SECOND_CHANNEL:
145 if (command instanceof RefreshType) {
149 if (command.toFullString().equals(UnDefType.NULL.toFullString())) {
152 seekToSecond(Long.parseLong(command.toFullString()));
154 case PLAYING_ITEM_PERCENTAGE_CHANNEL:
155 if (command instanceof RefreshType) {
159 if (command.toFullString().equals(UnDefType.NULL.toFullString())) {
162 seekToPercentage(Integer.parseInt(command.toFullString()));
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) {
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);
187 public void dispose() {
189 cancelDelayedCommand();
192 private void cancelDelayedCommand() {
193 var delayedCommand = this.delayedCommand;
194 if (delayedCommand != null) {
195 delayedCommand.cancel(true);
199 private void refreshState() {
200 getServerHandler().updateClientState(this);
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);
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));
219 cleanChannel(PLAYING_ITEM_PERCENTAGE_CHANNEL);
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));
227 cleanChannel(PLAYING_ITEM_SECOND_CHANNEL);
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));
236 cleanChannel(PLAYING_ITEM_TOTAL_SECOND_CHANNEL);
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()));
244 cleanChannel(PLAYING_ITEM_NAME_CHANNEL);
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()));
252 cleanChannel(PLAYING_ITEM_SERIES_NAME_CHANNEL);
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()));
260 cleanChannel(PLAYING_ITEM_SEASON_NAME_CHANNEL);
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())));
268 cleanChannel(PLAYING_ITEM_SEASON_CHANNEL);
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())));
276 cleanChannel(PLAYING_ITEM_EPISODE_CHANNEL);
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()))));
284 cleanChannel(PLAYING_ITEM_GENRES_CHANNEL);
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()));
292 cleanChannel(PLAYING_ITEM_TYPE_CHANNEL);
297 private void runItemSearch(String terms, @Nullable PlayCommand playCommand)
298 throws SyncCallback.SyncCallbackError, ApiClientException {
299 if (terms.isBlank() || UnDefType.NULL.toFullString().equals(terms)) {
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);
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);
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());
331 if (seriesItem != null) {
332 logger.debug("Found series: '{}'", seriesItem.getName());
334 if (episodeItem != null) {
335 logger.debug("Found episode: '{}'", episodeItem.getName());
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);
358 logger.warn("Unable to found episode for series");
361 logger.debug("Browse series '{}'", seriesItem.getName());
362 browseItem(seriesItem);
364 } else if (episodeItem != null) {
365 runItem(episodeItem, playCommand);
367 logger.warn("Nothing to display for: {}", terms);
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);
381 logger.warn("Series {} episode {}x{} not found", seriesItem.getName(), season, episode);
384 logger.warn("Series not found");
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) {
396 playItem(item, playCommand);
400 private void playItem(BaseItemDto item, PlayCommand playCommand)
401 throws SyncCallback.SyncCallbackError, ApiClientException {
402 playItem(item, playCommand, null);
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(() -> {
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());
415 }, 3, TimeUnit.SECONDS);
417 playItemInternal(item, playCommand, startPositionTicks);
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);
426 private void browseItem(BaseItemDto item) throws SyncCallback.SyncCallbackError, ApiClientException {
427 if (stopCurrentPlayback()) {
428 cancelDelayedCommand();
429 delayedCommand = scheduler.schedule(() -> {
431 browseItemInternal(item);
432 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
433 logger.warn("Unexpected error while running channel {}: {}", BROWSE_ITEM_BY_TERMS_CHANNEL,
436 }, 3, TimeUnit.SECONDS);
438 browseItemInternal(item);
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()));
447 private boolean stopCurrentPlayback() throws SyncCallback.SyncCallbackError, ApiClientException {
448 if (lastPlayingState) {
449 sendPlayStateCommand(PlaystateCommand.STOP);
455 private void sendPlayStateCommand(PlaystateCommand command)
456 throws SyncCallback.SyncCallbackError, ApiClientException {
457 sendPlayStateCommand(command, null);
460 private void sendPlayStateCommand(PlaystateCommand command, @Nullable Long seekPositionTick)
461 throws SyncCallback.SyncCallbackError, ApiClientException {
462 getServerHandler().sendPlayStateCommand(lastSessionId, command, seekPositionTick);
465 private void sendDeviceMessage(Command command) throws SyncCallback.SyncCallbackError, ApiClientException {
466 getServerHandler().sendDeviceMessage(lastSessionId, "Jellyfin OpenHAB", command.toFullString(), 15000);
469 private void handleMediaControlCommand(ChannelUID channelUID, Command command)
470 throws SyncCallback.SyncCallbackError, ApiClientException {
471 if (command instanceof RefreshType) {
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);
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);
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);
494 logger.warn("Unknown media control command: {}", command);
498 private void seekToPercentage(int percentage) throws SyncCallback.SyncCallbackError, ApiClientException {
499 if (lastRunTimeTicks == 0L) {
500 logger.warn("Can't seek missing RunTimeTicks info");
503 var seekPositionTick = Math.round(((float) lastRunTimeTicks) * ((float) percentage / 100.0));
504 logger.debug("Seek to {}%: {} of {}", percentage, seekPositionTick, lastRunTimeTicks);
505 seekToTick(seekPositionTick);
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);
514 private void seekToTick(long seekPositionTick) throws SyncCallback.SyncCallbackError, ApiClientException {
515 sendPlayStateCommand(PlaystateCommand.SEEK, seekPositionTick);
516 scheduler.schedule(this::refreshState, 3, TimeUnit.SECONDS);
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);
526 private void cleanChannel(String channelId) {
527 updateState(new ChannelUID(this.thing.getUID(), channelId), UnDefType.NULL);
530 private JellyfinServerHandler getServerHandler() {
531 var bridge = Objects.requireNonNull(getBridge());
532 return (JellyfinServerHandler) Objects.requireNonNull(bridge.getHandler());