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 java.time.Duration;
16 import java.time.temporal.ChronoUnit;
17 import java.util.Collection;
18 import java.util.Collections;
19 import java.util.List;
20 import java.util.Objects;
21 import java.util.UUID;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.jellyfin.sdk.Jellyfin;
28 import org.jellyfin.sdk.JellyfinOptions;
29 import org.jellyfin.sdk.api.client.ApiClient;
30 import org.jellyfin.sdk.api.client.exception.ApiClientException;
31 import org.jellyfin.sdk.api.client.exception.InvalidStatusException;
32 import org.jellyfin.sdk.api.client.exception.MissingUserIdException;
33 import org.jellyfin.sdk.api.operations.ItemsApi;
34 import org.jellyfin.sdk.api.operations.SessionApi;
35 import org.jellyfin.sdk.api.operations.SystemApi;
36 import org.jellyfin.sdk.api.operations.TvShowsApi;
37 import org.jellyfin.sdk.api.operations.UserApi;
38 import org.jellyfin.sdk.model.ClientInfo;
39 import org.jellyfin.sdk.model.api.AuthenticateUserByName;
40 import org.jellyfin.sdk.model.api.AuthenticationResult;
41 import org.jellyfin.sdk.model.api.BaseItemDto;
42 import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult;
43 import org.jellyfin.sdk.model.api.BaseItemKind;
44 import org.jellyfin.sdk.model.api.ItemFields;
45 import org.jellyfin.sdk.model.api.MessageCommand;
46 import org.jellyfin.sdk.model.api.PlayCommand;
47 import org.jellyfin.sdk.model.api.PlaystateCommand;
48 import org.jellyfin.sdk.model.api.SessionInfo;
49 import org.jellyfin.sdk.model.api.SystemInfo;
50 import org.openhab.binding.jellyfin.internal.JellyfinServerConfiguration;
51 import org.openhab.binding.jellyfin.internal.discovery.JellyfinClientDiscoveryService;
52 import org.openhab.binding.jellyfin.internal.util.EmptySyncResponse;
53 import org.openhab.binding.jellyfin.internal.util.SyncCallback;
54 import org.openhab.binding.jellyfin.internal.util.SyncResponse;
55 import org.openhab.core.OpenHAB;
56 import org.openhab.core.cache.ExpiringCache;
57 import org.openhab.core.thing.Bridge;
58 import org.openhab.core.thing.ChannelUID;
59 import org.openhab.core.thing.Thing;
60 import org.openhab.core.thing.ThingStatus;
61 import org.openhab.core.thing.ThingStatusDetail;
62 import org.openhab.core.thing.binding.BaseBridgeHandler;
63 import org.openhab.core.thing.binding.ThingHandlerService;
64 import org.openhab.core.types.Command;
65 import org.slf4j.Logger;
66 import org.slf4j.LoggerFactory;
69 * The {@link JellyfinServerHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Miguel Álvarez - Initial contribution
75 public class JellyfinServerHandler extends BaseBridgeHandler {
76 private final Logger logger = LoggerFactory.getLogger(JellyfinServerHandler.class);
77 private final ApiClient jellyApiClient;
78 private final ExpiringCache<List<SessionInfo>> sessionsCache = new ExpiringCache<>(
79 Duration.of(1, ChronoUnit.SECONDS), this::tryGetSessions);
80 private JellyfinServerConfiguration config = new JellyfinServerConfiguration();
81 private @Nullable ScheduledFuture<?> checkInterval;
83 public JellyfinServerHandler(Bridge bridge) {
85 var options = new JellyfinOptions.Builder();
86 options.setClientInfo(new ClientInfo("openHAB", OpenHAB.getVersion()));
87 options.setDeviceInfo(new org.jellyfin.sdk.model.DeviceInfo(getThing().getUID().getId(), "openHAB"));
88 jellyApiClient = new Jellyfin(options.build()).createApi();
92 public void initialize() {
93 config = getConfigAs(JellyfinServerConfiguration.class);
94 jellyApiClient.setBaseUrl(getServerUrl());
95 if (config.token.isBlank() || config.userId.isBlank()) {
96 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
97 "Navigate to <your local openhab url>/jellyfin/" + this.getThing().getUID().getId() + " to login.");
100 jellyApiClient.setAccessToken(config.token);
101 jellyApiClient.setUserId(UUID.fromString(config.userId));
102 updateStatus(ThingStatus.UNKNOWN);
107 public void handleCommand(ChannelUID channelUID, Command command) {
111 public void dispose() {
117 public Collection<Class<? extends ThingHandlerService>> getServices() {
118 return Collections.singleton(JellyfinClientDiscoveryService.class);
121 public String getServerUrl() {
122 return (config.ssl ? "https" : "http") + "://" + config.hostname + ":" + config.port;
125 public boolean isOnline() {
126 var asyncResponse = new SyncResponse<String>();
127 new SystemApi(jellyApiClient).getPingSystem(asyncResponse);
129 return asyncResponse.awaitResponse().getStatus() == 200;
130 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
131 logger.warn("Response error: {}", e.getMessage());
136 public boolean isAuthenticated() {
137 if (config.token.isBlank() || config.userId.isBlank()) {
140 var asyncResponse = new SyncResponse<SystemInfo>();
141 new SystemApi(jellyApiClient).getSystemInfo(asyncResponse);
143 var systemInfo = asyncResponse.awaitContent();
144 var properties = editProperties();
145 var productName = systemInfo.getProductName();
146 if (productName != null) {
147 properties.put(Thing.PROPERTY_VENDOR, productName);
149 var version = systemInfo.getVersion();
150 if (version != null) {
151 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, version);
153 updateProperties(properties);
155 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
160 public JellyfinCredentials login(String user, String password)
161 throws SyncCallback.SyncCallbackError, ApiClientException {
162 var asyncCall = new SyncResponse<AuthenticationResult>();
163 new UserApi(jellyApiClient).authenticateUserByName(new AuthenticateUserByName(user, password, null), asyncCall);
164 var authResult = asyncCall.awaitContent();
165 var token = Objects.requireNonNull(authResult.getAccessToken());
166 var userId = Objects.requireNonNull(authResult.getUser()).getId().toString();
167 return new JellyfinCredentials(token, userId);
170 public void updateCredentials(JellyfinCredentials credentials) {
171 var currentConfig = getConfig();
172 currentConfig.put("token", credentials.getAccessToken());
173 currentConfig.put("userId", credentials.getUserId());
174 updateConfiguration(currentConfig);
178 private void updateStatusUnauthenticated() {
179 sessionsCache.invalidateValue();
180 updateClients(List.of());
181 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
182 "Authentication failed. Navigate to <your local openhab url>/jellyfin/"
183 + this.getThing().getUID().getId() + " to login again.");
186 private void checkClientStates() {
187 var sessions = sessionsCache.getValue();
188 if (sessions != null) {
189 logger.debug("Got {} sessions", sessions.size());
190 updateClients(sessions);
192 sessionsCache.invalidateValue();
196 private @Nullable List<SessionInfo> tryGetSessions() {
198 if (jellyApiClient.getAccessToken() == null) {
201 var clientActiveWithInSeconds = config.clientActiveWithInSeconds != 0 ? config.clientActiveWithInSeconds
203 return getControllableSessions(clientActiveWithInSeconds);
204 } catch (SyncCallback.SyncCallbackError syncCallbackError) {
205 logger.warn("Unexpected error while running channel calling server: {}", syncCallbackError.getMessage());
206 } catch (ApiClientException e) {
207 handleApiException(e);
212 public void handleApiException(ApiClientException e) {
213 logger.warn("Api error: {}", e.getMessage());
214 var cause = e.getCause();
215 boolean unauthenticated = false;
216 if (cause instanceof InvalidStatusException) {
217 var status = ((InvalidStatusException) cause).getStatus();
219 unauthenticated = true;
221 logger.warn("Api error has invalid status: {}", status);
223 if (cause instanceof MissingUserIdException) {
224 unauthenticated = true;
226 if (unauthenticated) {
227 updateStatusUnauthenticated();
231 public void updateClientState(JellyfinClientHandler handler) {
232 var sessions = sessionsCache.getValue();
233 if (sessions != null) {
234 updateClientState(handler, sessions);
236 sessionsCache.invalidateValue();
240 public List<SessionInfo> getControllableSessions() throws SyncCallback.SyncCallbackError, ApiClientException {
241 return getControllableSessions(null);
244 public List<SessionInfo> getControllableSessions(@Nullable Integer activeWithInSeconds)
245 throws SyncCallback.SyncCallbackError, ApiClientException {
246 var asyncContinuation = new SyncResponse<List<SessionInfo>>();
247 new SessionApi(jellyApiClient).getSessions(this.jellyApiClient.getUserId(), null, activeWithInSeconds,
249 return asyncContinuation.awaitContent();
252 public void sendPlayStateCommand(String sessionId, PlaystateCommand command, @Nullable Long seekPositionTicks)
253 throws SyncCallback.SyncCallbackError, ApiClientException {
254 var awaiter = new EmptySyncResponse();
255 new SessionApi(jellyApiClient).sendPlaystateCommand(sessionId, command, seekPositionTicks,
256 Objects.requireNonNull(jellyApiClient.getUserId()).toString(), awaiter);
257 awaiter.awaitResponse();
260 public void sendDeviceMessage(String sessionId, String header, String text, long ms)
261 throws SyncCallback.SyncCallbackError, ApiClientException {
262 var awaiter = new EmptySyncResponse();
263 new SessionApi(jellyApiClient).sendMessageCommand(sessionId, new MessageCommand(header, text, ms), awaiter);
264 awaiter.awaitResponse();
267 public void playItem(String sessionId, PlayCommand playCommand, String itemId, @Nullable Long startPositionTicks)
268 throws SyncCallback.SyncCallbackError, ApiClientException {
269 var awaiter = new EmptySyncResponse();
270 new SessionApi(jellyApiClient).play(sessionId, playCommand, List.of(UUID.fromString(itemId)),
271 startPositionTicks, null, null, null, null, awaiter);
272 awaiter.awaitResponse();
275 public void browseToItem(String sessionId, BaseItemKind itemType, String itemId, String itemName)
276 throws SyncCallback.SyncCallbackError, ApiClientException {
277 var awaiter = new EmptySyncResponse();
278 new SessionApi(jellyApiClient).displayContent(sessionId, itemType, itemId, itemName, awaiter);
279 awaiter.awaitResponse();
282 public @Nullable BaseItemDto getSeriesNextUpItem(UUID seriesId)
283 throws SyncCallback.SyncCallbackError, ApiClientException {
284 return getSeriesNextUpItems(seriesId, 1).stream().findFirst().orElse(null);
287 public List<BaseItemDto> getSeriesNextUpItems(UUID seriesId, int limit)
288 throws SyncCallback.SyncCallbackError, ApiClientException {
289 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
290 new TvShowsApi(jellyApiClient).getNextUp(jellyApiClient.getUserId(), null, limit, null, seriesId.toString(),
291 null, null, null, null, null, null, null, null, null, asyncContinuation);
292 var result = asyncContinuation.awaitContent();
293 return Objects.requireNonNull(result.getItems());
296 public @Nullable BaseItemDto getSeriesResumeItem(UUID seriesId)
297 throws SyncCallback.SyncCallbackError, ApiClientException {
298 return getSeriesResumeItems(seriesId, 1).stream().findFirst().orElse(null);
301 public List<BaseItemDto> getSeriesResumeItems(UUID seriesId, int limit)
302 throws SyncCallback.SyncCallbackError, ApiClientException {
303 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
304 new ItemsApi(jellyApiClient).getResumeItems(Objects.requireNonNull(jellyApiClient.getUserId()), null, limit,
305 null, seriesId, null, null, true, null, null, null, List.of(BaseItemKind.EPISODE), null, null, null,
307 var result = asyncContinuation.awaitContent();
308 return Objects.requireNonNull(result.getItems());
311 public @Nullable BaseItemDto getSeriesEpisodeItem(UUID seriesId, @Nullable Integer season,
312 @Nullable Integer episode) throws SyncCallback.SyncCallbackError, ApiClientException {
313 return getSeriesEpisodeItems(seriesId, season, episode, 1).stream().findFirst().orElse(null);
316 public List<BaseItemDto> getSeriesEpisodeItems(UUID seriesId, @Nullable Integer season, @Nullable Integer episode,
317 int limit) throws SyncCallback.SyncCallbackError, ApiClientException {
318 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
319 new TvShowsApi(jellyApiClient).getEpisodes(seriesId, jellyApiClient.getUserId(), null, season, null, null, null,
320 null, episode != null ? episode - 1 : null, limit, null, null, null, null, null, asyncContinuation);
321 var result = asyncContinuation.awaitContent();
322 return Objects.requireNonNull(result.getItems());
325 public @Nullable BaseItemDto getItem(UUID id, @Nullable List<ItemFields> fields)
326 throws SyncCallback.SyncCallbackError, ApiClientException {
327 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
328 new ItemsApi(jellyApiClient).getItems(jellyApiClient.getUserId(), null, null, null, null, null, null, null,
329 null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
330 null, null, null, null, null, null, null, null, 1, true, null, null, null, fields, null, null, null,
331 null, null, null, null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null,
332 null, null, null, null, null, List.of(id), null, null, null, null, null, null, null, null, null, null,
333 null, null, null, null, null, null, null, false, false, asyncContinuation);
334 var response = asyncContinuation.awaitContent();
335 return Objects.requireNonNull(response.getItems()).stream().findFirst().orElse(null);
338 public @Nullable BaseItemDto searchItem(@Nullable String searchTerm, @Nullable BaseItemKind itemType,
339 @Nullable List<ItemFields> fields) throws SyncCallback.SyncCallbackError, ApiClientException {
340 return searchItems(searchTerm, itemType, fields, 1).stream().findFirst().orElse(null);
343 public List<BaseItemDto> searchItems(@Nullable String searchTerm, @Nullable BaseItemKind itemType,
344 @Nullable List<ItemFields> fields, int limit) throws SyncCallback.SyncCallbackError, ApiClientException {
345 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
346 var itemTypes = itemType != null ? List.of(itemType) : null;
347 new ItemsApi(jellyApiClient).getItems(jellyApiClient.getUserId(), null, null, null, null, null, null, null,
348 null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
349 null, null, null, null, null, null, null, null, limit, true, searchTerm, null, null, fields, null,
350 itemTypes, null, null, null, null, null, null, null, null, null, null, null, 1, null, null, null, null,
351 null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
352 null, null, null, null, null, null, null, null, null, false, false, asyncContinuation);
353 var response = asyncContinuation.awaitContent();
354 return Objects.requireNonNull(response.getItems());
357 private void startChecker() {
359 checkInterval = scheduler.scheduleWithFixedDelay(() -> {
361 updateStatus(ThingStatus.OFFLINE);
363 } else if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
364 if (!isAuthenticated()) {
365 updateStatusUnauthenticated();
368 updateStatus(ThingStatus.ONLINE);
371 }, 0, config.refreshSeconds, TimeUnit.SECONDS);
374 private void stopChecker() {
375 var checkInterval = this.checkInterval;
376 if (checkInterval != null) {
377 checkInterval.cancel(true);
378 this.checkInterval = null;
382 private void updateClients(List<SessionInfo> sessions) {
383 var things = getThing().getThings();
384 things.forEach((childThing) -> {
385 var handler = childThing.getHandler();
386 if (handler == null) {
389 if (handler instanceof JellyfinClientHandler) {
390 updateClientState((JellyfinClientHandler) handler, sessions);
392 logger.warn("Found unknown thing-handler instance: {}", handler);
397 private void updateClientState(JellyfinClientHandler handler, List<SessionInfo> sessions) {
399 SessionInfo clientSession = sessions.stream()
400 .filter(session -> Objects.equals(session.getDeviceId(), handler.getThing().getUID().getId()))
401 .sorted((a, b) -> b.getLastActivityDate().compareTo(a.getLastActivityDate())).findFirst().orElse(null);
402 handler.updateStateFromSession(clientSession);
405 public static class JellyfinCredentials {
406 private final String accessToken;
407 private final String userId;
409 public JellyfinCredentials(String accessToken, String userId) {
410 this.accessToken = accessToken;
411 this.userId = userId;
414 public String getUserId() {
418 public String getAccessToken() {