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.ItemFields;
44 import org.jellyfin.sdk.model.api.MessageCommand;
45 import org.jellyfin.sdk.model.api.PlayCommand;
46 import org.jellyfin.sdk.model.api.PlaystateCommand;
47 import org.jellyfin.sdk.model.api.SessionInfo;
48 import org.jellyfin.sdk.model.api.SystemInfo;
49 import org.openhab.binding.jellyfin.internal.JellyfinServerConfiguration;
50 import org.openhab.binding.jellyfin.internal.discovery.JellyfinClientDiscoveryService;
51 import org.openhab.binding.jellyfin.internal.util.EmptySyncResponse;
52 import org.openhab.binding.jellyfin.internal.util.SyncCallback;
53 import org.openhab.binding.jellyfin.internal.util.SyncResponse;
54 import org.openhab.core.OpenHAB;
55 import org.openhab.core.cache.ExpiringCache;
56 import org.openhab.core.thing.Bridge;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.binding.BaseBridgeHandler;
62 import org.openhab.core.thing.binding.ThingHandlerService;
63 import org.openhab.core.types.Command;
64 import org.slf4j.Logger;
65 import org.slf4j.LoggerFactory;
68 * The {@link JellyfinServerHandler} is responsible for handling commands, which are
69 * sent to one of the channels.
71 * @author Miguel Álvarez - Initial contribution
74 public class JellyfinServerHandler extends BaseBridgeHandler {
75 private final Logger logger = LoggerFactory.getLogger(JellyfinServerHandler.class);
76 private final ApiClient jellyApiClient;
77 private final ExpiringCache<List<SessionInfo>> sessionsCache = new ExpiringCache<>(
78 Duration.of(1, ChronoUnit.SECONDS), this::tryGetSessions);
79 private JellyfinServerConfiguration config = new JellyfinServerConfiguration();
80 private @Nullable ScheduledFuture<?> checkInterval;
82 public JellyfinServerHandler(Bridge bridge) {
84 var options = new JellyfinOptions.Builder();
85 options.setClientInfo(new ClientInfo("openHAB", OpenHAB.getVersion()));
86 options.setDeviceInfo(new org.jellyfin.sdk.model.DeviceInfo(getThing().getUID().getId(), "openHAB"));
87 jellyApiClient = new Jellyfin(options.build()).createApi();
91 public void initialize() {
92 config = getConfigAs(JellyfinServerConfiguration.class);
93 jellyApiClient.setBaseUrl(getServerUrl());
94 if (config.token.isBlank() || config.userId.isBlank()) {
95 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
96 "Navigate to <your local openhab url>/jellyfin/" + this.getThing().getUID().getId() + " to login.");
99 jellyApiClient.setAccessToken(config.token);
100 jellyApiClient.setUserId(UUID.fromString(config.userId));
101 updateStatus(ThingStatus.UNKNOWN);
106 public void handleCommand(ChannelUID channelUID, Command command) {
110 public void dispose() {
116 public Collection<Class<? extends ThingHandlerService>> getServices() {
117 return Collections.singleton(JellyfinClientDiscoveryService.class);
120 public String getServerUrl() {
121 return (config.ssl ? "https" : "http") + "://" + config.hostname + ":" + config.port;
124 public boolean isOnline() {
125 var asyncResponse = new SyncResponse<String>();
126 new SystemApi(jellyApiClient).getPingSystem(asyncResponse);
128 return asyncResponse.awaitResponse().getStatus() == 200;
129 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
130 logger.warn("Response error: {}", e.getMessage());
135 public boolean isAuthenticated() {
136 if (config.token.isBlank() || config.userId.isBlank()) {
139 var asyncResponse = new SyncResponse<SystemInfo>();
140 new SystemApi(jellyApiClient).getSystemInfo(asyncResponse);
142 var systemInfo = asyncResponse.awaitContent();
143 var properties = editProperties();
144 var productName = systemInfo.getProductName();
145 if (productName != null) {
146 properties.put(Thing.PROPERTY_VENDOR, productName);
148 var version = systemInfo.getVersion();
149 if (version != null) {
150 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, version);
152 updateProperties(properties);
154 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
159 public JellyfinCredentials login(String user, String password)
160 throws SyncCallback.SyncCallbackError, ApiClientException {
161 var asyncCall = new SyncResponse<AuthenticationResult>();
162 new UserApi(jellyApiClient).authenticateUserByName(new AuthenticateUserByName(user, password, null), asyncCall);
163 var authResult = asyncCall.awaitContent();
164 var token = Objects.requireNonNull(authResult.getAccessToken());
165 var userId = Objects.requireNonNull(authResult.getUser()).getId().toString();
166 return new JellyfinCredentials(token, userId);
169 public void updateCredentials(JellyfinCredentials credentials) {
170 var currentConfig = getConfig();
171 currentConfig.put("token", credentials.getAccessToken());
172 currentConfig.put("userId", credentials.getUserId());
173 updateConfiguration(currentConfig);
177 private void updateStatusUnauthenticated() {
178 sessionsCache.invalidateValue();
179 updateClients(List.of());
180 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
181 "Authentication failed. Navigate to <your local openhab url>/jellyfin/"
182 + this.getThing().getUID().getId() + " to login again.");
185 private void checkClientStates() {
186 var sessions = sessionsCache.getValue();
187 if (sessions != null) {
188 logger.debug("Got {} sessions", sessions.size());
189 updateClients(sessions);
191 sessionsCache.invalidateValue();
195 private @Nullable List<SessionInfo> tryGetSessions() {
197 if (jellyApiClient.getAccessToken() == null) {
200 var clientActiveWithInSeconds = config.clientActiveWithInSeconds != 0 ? config.clientActiveWithInSeconds
202 return getControllableSessions(clientActiveWithInSeconds);
203 } catch (SyncCallback.SyncCallbackError syncCallbackError) {
204 logger.warn("Unexpected error while running channel calling server: {}", syncCallbackError.getMessage());
205 } catch (ApiClientException e) {
206 handleApiException(e);
211 public void handleApiException(ApiClientException e) {
212 logger.warn("Api error: {}", e.getMessage());
213 var cause = e.getCause();
214 boolean unauthenticated = false;
215 if (cause instanceof InvalidStatusException) {
216 var status = ((InvalidStatusException) cause).getStatus();
218 unauthenticated = true;
220 logger.warn("Api error has invalid status: {}", status);
222 if (cause instanceof MissingUserIdException) {
223 unauthenticated = true;
225 if (unauthenticated) {
226 updateStatusUnauthenticated();
230 public void updateClientState(JellyfinClientHandler handler) {
231 var sessions = sessionsCache.getValue();
232 if (sessions != null) {
233 updateClientState(handler, sessions);
235 sessionsCache.invalidateValue();
239 public List<SessionInfo> getControllableSessions() throws SyncCallback.SyncCallbackError, ApiClientException {
240 return getControllableSessions(null);
243 public List<SessionInfo> getControllableSessions(@Nullable Integer activeWithInSeconds)
244 throws SyncCallback.SyncCallbackError, ApiClientException {
245 var asyncContinuation = new SyncResponse<List<SessionInfo>>();
246 new SessionApi(jellyApiClient).getSessions(this.jellyApiClient.getUserId(), null, activeWithInSeconds,
248 return asyncContinuation.awaitContent();
251 public void sendPlayStateCommand(String sessionId, PlaystateCommand command, @Nullable Long seekPositionTicks)
252 throws SyncCallback.SyncCallbackError, ApiClientException {
253 var awaiter = new EmptySyncResponse();
254 new SessionApi(jellyApiClient).sendPlaystateCommand(sessionId, command, seekPositionTicks,
255 Objects.requireNonNull(jellyApiClient.getUserId()).toString(), awaiter);
256 awaiter.awaitResponse();
259 public void sendDeviceMessage(String sessionId, String header, String text, long ms)
260 throws SyncCallback.SyncCallbackError, ApiClientException {
261 var awaiter = new EmptySyncResponse();
262 new SessionApi(jellyApiClient).sendMessageCommand(sessionId, new MessageCommand(header, text, ms), awaiter);
263 awaiter.awaitResponse();
266 public void playItem(String sessionId, PlayCommand playCommand, String itemId, @Nullable Long startPositionTicks)
267 throws SyncCallback.SyncCallbackError, ApiClientException {
268 var awaiter = new EmptySyncResponse();
269 new SessionApi(jellyApiClient).play(sessionId, playCommand, List.of(UUID.fromString(itemId)),
270 startPositionTicks, null, null, null, null, awaiter);
271 awaiter.awaitResponse();
274 public void browseToItem(String sessionId, String itemType, String itemId, String itemName)
275 throws SyncCallback.SyncCallbackError, ApiClientException {
276 var awaiter = new EmptySyncResponse();
277 new SessionApi(jellyApiClient).displayContent(sessionId, itemType, itemId, itemName, awaiter);
278 awaiter.awaitResponse();
281 public @Nullable BaseItemDto getSeriesNextUpItem(UUID seriesId)
282 throws SyncCallback.SyncCallbackError, ApiClientException {
283 return getSeriesNextUpItems(seriesId, 1).stream().findFirst().orElse(null);
286 public List<BaseItemDto> getSeriesNextUpItems(UUID seriesId, int limit)
287 throws SyncCallback.SyncCallbackError, ApiClientException {
288 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
289 new TvShowsApi(jellyApiClient).getNextUp(jellyApiClient.getUserId(), null, limit, null, seriesId.toString(),
290 null, null, null, null, null, null, null, asyncContinuation);
291 var result = asyncContinuation.awaitContent();
292 return Objects.requireNonNull(result.getItems());
295 public @Nullable BaseItemDto getSeriesResumeItem(UUID seriesId)
296 throws SyncCallback.SyncCallbackError, ApiClientException {
297 return getSeriesResumeItems(seriesId, 1).stream().findFirst().orElse(null);
300 public List<BaseItemDto> getSeriesResumeItems(UUID seriesId, int limit)
301 throws SyncCallback.SyncCallbackError, ApiClientException {
302 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
303 new ItemsApi(jellyApiClient).getResumeItems(Objects.requireNonNull(jellyApiClient.getUserId()), null, limit,
304 null, seriesId, null, null, true, null, null, null, List.of("Episode"), null, null, asyncContinuation);
305 var result = asyncContinuation.awaitContent();
306 return Objects.requireNonNull(result.getItems());
309 public @Nullable BaseItemDto getSeriesEpisodeItem(UUID seriesId, @Nullable Integer season,
310 @Nullable Integer episode) throws SyncCallback.SyncCallbackError, ApiClientException {
311 return getSeriesEpisodeItems(seriesId, season, episode, 1).stream().findFirst().orElse(null);
314 public List<BaseItemDto> getSeriesEpisodeItems(UUID seriesId, @Nullable Integer season, @Nullable Integer episode,
315 int limit) throws SyncCallback.SyncCallbackError, ApiClientException {
316 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
317 new TvShowsApi(jellyApiClient).getEpisodes(seriesId, jellyApiClient.getUserId(), null, season, null, null, null,
318 null, episode != null ? episode - 1 : null, limit, null, null, null, null, null, asyncContinuation);
319 var result = asyncContinuation.awaitContent();
320 return Objects.requireNonNull(result.getItems());
323 public @Nullable BaseItemDto searchItem(@Nullable String searchTerm, @Nullable String itemType,
324 @Nullable List<ItemFields> fields) throws SyncCallback.SyncCallbackError, ApiClientException {
325 return searchItems(searchTerm, itemType, fields, 1).stream().findFirst().orElse(null);
328 public List<BaseItemDto> searchItems(@Nullable String searchTerm, @Nullable String itemType,
329 @Nullable List<ItemFields> fields, int limit) throws SyncCallback.SyncCallbackError, ApiClientException {
330 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
331 var itemTypes = itemType != null ? List.of(itemType) : null;
332 new ItemsApi(jellyApiClient).getItems(jellyApiClient.getUserId(), null, null, null, null, null, null, null,
333 null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
334 null, null, null, limit, true, searchTerm, null, null, fields, null, itemTypes, null, null, null, null,
335 null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null, null, null, null,
336 null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
337 null, null, null, false, false, asyncContinuation);
338 var response = asyncContinuation.awaitContent();
339 return Objects.requireNonNull(response.getItems());
342 private void startChecker() {
344 checkInterval = scheduler.scheduleWithFixedDelay(() -> {
346 updateStatus(ThingStatus.OFFLINE);
348 } else if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
349 if (!isAuthenticated()) {
350 updateStatusUnauthenticated();
353 updateStatus(ThingStatus.ONLINE);
356 }, 0, config.refreshSeconds, TimeUnit.SECONDS);
359 private void stopChecker() {
360 var checkInterval = this.checkInterval;
361 if (checkInterval != null) {
362 checkInterval.cancel(true);
363 this.checkInterval = null;
367 private void updateClients(List<SessionInfo> sessions) {
368 var things = getThing().getThings();
369 things.forEach((childThing) -> {
370 var handler = childThing.getHandler();
371 if (handler == null) {
374 if (handler instanceof JellyfinClientHandler) {
375 updateClientState((JellyfinClientHandler) handler, sessions);
377 logger.warn("Found unknown thing-handler instance: {}", handler);
382 private void updateClientState(JellyfinClientHandler handler, List<SessionInfo> sessions) {
384 SessionInfo clientSession = sessions.stream()
385 .filter(session -> Objects.equals(session.getDeviceId(), handler.getThing().getUID().getId()))
386 .sorted((a, b) -> b.getLastActivityDate().compareTo(a.getLastActivityDate())).findFirst().orElse(null);
387 handler.updateStateFromSession(clientSession);
390 public static class JellyfinCredentials {
391 private final String accessToken;
392 private final String userId;
394 public JellyfinCredentials(String accessToken, String userId) {
395 this.accessToken = accessToken;
396 this.userId = userId;
399 public String getUserId() {
403 public String getAccessToken() {