2 * Copyright (c) 2010-2023 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.List;
19 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;
68 import io.ktor.http.URLBuilder;
69 import io.ktor.http.URLProtocol;
72 * The {@link JellyfinServerHandler} is responsible for handling commands, which are
73 * sent to one of the channels.
75 * @author Miguel Álvarez - Initial contribution
78 public class JellyfinServerHandler extends BaseBridgeHandler {
79 private final Logger logger = LoggerFactory.getLogger(JellyfinServerHandler.class);
80 private final ApiClient jellyApiClient;
81 private final ExpiringCache<List<SessionInfo>> sessionsCache = new ExpiringCache<>(
82 Duration.of(1, ChronoUnit.SECONDS), this::tryGetSessions);
83 private JellyfinServerConfiguration config = new JellyfinServerConfiguration();
84 private @Nullable ScheduledFuture<?> checkInterval;
86 public JellyfinServerHandler(Bridge bridge) {
88 var options = new JellyfinOptions.Builder();
89 options.setClientInfo(new ClientInfo("openHAB", OpenHAB.getVersion()));
90 options.setDeviceInfo(new org.jellyfin.sdk.model.DeviceInfo(getThing().getUID().getId(), "openHAB"));
91 jellyApiClient = new Jellyfin(options.build()).createApi();
95 public void initialize() {
96 config = getConfigAs(JellyfinServerConfiguration.class);
97 jellyApiClient.setBaseUrl(getServerUrl());
98 if (config.token.isBlank() || config.userId.isBlank()) {
99 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
100 "Navigate to <your local openhab url>/jellyfin/" + this.getThing().getUID().getId() + " to login.");
103 jellyApiClient.setAccessToken(config.token);
104 jellyApiClient.setUserId(UUID.fromString(config.userId));
105 updateStatus(ThingStatus.UNKNOWN);
110 public void handleCommand(ChannelUID channelUID, Command command) {
114 public void dispose() {
120 public Collection<Class<? extends ThingHandlerService>> getServices() {
121 return Set.of(JellyfinClientDiscoveryService.class);
124 public String getServerUrl() {
125 var builder = new URLBuilder();
126 builder.setHost(config.hostname);
128 builder.setProtocol(URLProtocol.Companion.getHTTPS());
130 builder.setProtocol(URLProtocol.Companion.getHTTP());
132 builder.setPort(config.port);
133 builder.setEncodedPath(config.path);
134 return builder.buildString();
137 public boolean isOnline() {
138 var asyncResponse = new SyncResponse<String>();
139 new SystemApi(jellyApiClient).getPingSystem(asyncResponse);
141 return asyncResponse.awaitResponse().getStatus() == 200;
142 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
143 logger.warn("Response error: {}", e.getMessage());
148 public boolean isAuthenticated() {
149 if (config.token.isBlank() || config.userId.isBlank()) {
152 var asyncResponse = new SyncResponse<SystemInfo>();
153 new SystemApi(jellyApiClient).getSystemInfo(asyncResponse);
155 var systemInfo = asyncResponse.awaitContent();
156 var properties = editProperties();
157 var productName = systemInfo.getProductName();
158 if (productName != null) {
159 properties.put(Thing.PROPERTY_VENDOR, productName);
161 var version = systemInfo.getVersion();
162 if (version != null) {
163 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, version);
165 updateProperties(properties);
167 } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
172 public JellyfinCredentials login(String user, String password)
173 throws SyncCallback.SyncCallbackError, ApiClientException {
174 var asyncCall = new SyncResponse<AuthenticationResult>();
175 new UserApi(jellyApiClient).authenticateUserByName(new AuthenticateUserByName(user, password, null), asyncCall);
176 var authResult = asyncCall.awaitContent();
177 var token = Objects.requireNonNull(authResult.getAccessToken());
178 var userId = Objects.requireNonNull(authResult.getUser()).getId().toString();
179 return new JellyfinCredentials(token, userId);
182 public void updateCredentials(JellyfinCredentials credentials) {
183 var currentConfig = getConfig();
184 currentConfig.put("token", credentials.getAccessToken());
185 currentConfig.put("userId", credentials.getUserId());
186 updateConfiguration(currentConfig);
190 private void updateStatusUnauthenticated() {
191 sessionsCache.invalidateValue();
192 updateClients(List.of());
193 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
194 "Authentication failed. Navigate to <your local openhab url>/jellyfin/"
195 + this.getThing().getUID().getId() + " to login again.");
198 private void checkClientStates() {
199 var sessions = sessionsCache.getValue();
200 if (sessions != null) {
201 logger.debug("Got {} sessions", sessions.size());
202 updateClients(sessions);
204 sessionsCache.invalidateValue();
208 private @Nullable List<SessionInfo> tryGetSessions() {
210 if (jellyApiClient.getAccessToken() == null) {
213 var clientActiveWithInSeconds = config.clientActiveWithInSeconds != 0 ? config.clientActiveWithInSeconds
215 return getControllableSessions(clientActiveWithInSeconds);
216 } catch (SyncCallback.SyncCallbackError syncCallbackError) {
217 logger.warn("Unexpected error while running channel calling server: {}", syncCallbackError.getMessage());
218 } catch (ApiClientException e) {
219 handleApiException(e);
224 public void handleApiException(ApiClientException e) {
225 logger.warn("Api error: {}", e.getMessage());
226 var cause = e.getCause();
227 boolean unauthenticated = false;
228 if (cause instanceof InvalidStatusException invalidStatusException) {
229 var status = invalidStatusException.getStatus();
231 unauthenticated = true;
233 logger.warn("Api error has invalid status: {}", status);
235 if (cause instanceof MissingUserIdException) {
236 unauthenticated = true;
238 if (unauthenticated) {
239 updateStatusUnauthenticated();
243 public void updateClientState(JellyfinClientHandler handler) {
244 var sessions = sessionsCache.getValue();
245 if (sessions != null) {
246 updateClientState(handler, sessions);
248 sessionsCache.invalidateValue();
252 public List<SessionInfo> getControllableSessions() throws SyncCallback.SyncCallbackError, ApiClientException {
253 return getControllableSessions(null);
256 public List<SessionInfo> getControllableSessions(@Nullable Integer activeWithInSeconds)
257 throws SyncCallback.SyncCallbackError, ApiClientException {
258 var asyncContinuation = new SyncResponse<List<SessionInfo>>();
259 new SessionApi(jellyApiClient).getSessions(this.jellyApiClient.getUserId(), null, activeWithInSeconds,
261 return asyncContinuation.awaitContent();
264 public void sendPlayStateCommand(String sessionId, PlaystateCommand command, @Nullable Long seekPositionTicks)
265 throws SyncCallback.SyncCallbackError, ApiClientException {
266 var awaiter = new EmptySyncResponse();
267 new SessionApi(jellyApiClient).sendPlaystateCommand(sessionId, command, seekPositionTicks,
268 Objects.requireNonNull(jellyApiClient.getUserId()).toString(), awaiter);
269 awaiter.awaitResponse();
272 public void sendDeviceMessage(String sessionId, String header, String text, long ms)
273 throws SyncCallback.SyncCallbackError, ApiClientException {
274 var awaiter = new EmptySyncResponse();
275 new SessionApi(jellyApiClient).sendMessageCommand(sessionId, new MessageCommand(header, text, ms), awaiter);
276 awaiter.awaitResponse();
279 public void playItem(String sessionId, PlayCommand playCommand, String itemId, @Nullable Long startPositionTicks)
280 throws SyncCallback.SyncCallbackError, ApiClientException {
281 var awaiter = new EmptySyncResponse();
282 new SessionApi(jellyApiClient).play(sessionId, playCommand, List.of(UUID.fromString(itemId)),
283 startPositionTicks, null, null, null, null, awaiter);
284 awaiter.awaitResponse();
287 public void browseToItem(String sessionId, BaseItemKind itemType, String itemId, String itemName)
288 throws SyncCallback.SyncCallbackError, ApiClientException {
289 var awaiter = new EmptySyncResponse();
290 new SessionApi(jellyApiClient).displayContent(sessionId, itemType, itemId, itemName, awaiter);
291 awaiter.awaitResponse();
294 public @Nullable BaseItemDto getSeriesNextUpItem(UUID seriesId)
295 throws SyncCallback.SyncCallbackError, ApiClientException {
296 return getSeriesNextUpItems(seriesId, 1).stream().findFirst().orElse(null);
299 public List<BaseItemDto> getSeriesNextUpItems(UUID seriesId, int limit)
300 throws SyncCallback.SyncCallbackError, ApiClientException {
301 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
302 new TvShowsApi(jellyApiClient).getNextUp(jellyApiClient.getUserId(), null, limit, null, seriesId.toString(),
303 null, null, null, null, null, null, null, null, null, asyncContinuation);
304 var result = asyncContinuation.awaitContent();
305 return Objects.requireNonNull(result.getItems());
308 public @Nullable BaseItemDto getSeriesResumeItem(UUID seriesId)
309 throws SyncCallback.SyncCallbackError, ApiClientException {
310 return getSeriesResumeItems(seriesId, 1).stream().findFirst().orElse(null);
313 public List<BaseItemDto> getSeriesResumeItems(UUID seriesId, int limit)
314 throws SyncCallback.SyncCallbackError, ApiClientException {
315 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
316 new ItemsApi(jellyApiClient).getResumeItems(Objects.requireNonNull(jellyApiClient.getUserId()), null, limit,
317 null, seriesId, null, null, true, null, null, null, List.of(BaseItemKind.EPISODE), null, null, null,
319 var result = asyncContinuation.awaitContent();
320 return Objects.requireNonNull(result.getItems());
323 public @Nullable BaseItemDto getSeriesEpisodeItem(UUID seriesId, @Nullable Integer season,
324 @Nullable Integer episode) throws SyncCallback.SyncCallbackError, ApiClientException {
325 return getSeriesEpisodeItems(seriesId, season, episode, 1).stream().findFirst().orElse(null);
328 public List<BaseItemDto> getSeriesEpisodeItems(UUID seriesId, @Nullable Integer season, @Nullable Integer episode,
329 int limit) throws SyncCallback.SyncCallbackError, ApiClientException {
330 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
331 new TvShowsApi(jellyApiClient).getEpisodes(seriesId, jellyApiClient.getUserId(), null, season, null, null, null,
332 null, episode != null ? episode - 1 : null, limit, null, null, null, null, null, asyncContinuation);
333 var result = asyncContinuation.awaitContent();
334 return Objects.requireNonNull(result.getItems());
337 public @Nullable BaseItemDto getItem(UUID id, @Nullable List<ItemFields> fields)
338 throws SyncCallback.SyncCallbackError, ApiClientException {
339 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
340 new ItemsApi(jellyApiClient).getItems(jellyApiClient.getUserId(), null, null, null, null, null, null, null,
341 null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
342 null, null, null, null, null, null, null, null, 1, true, null, null, null, fields, null, null, null,
343 null, null, null, null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null,
344 null, null, null, null, null, List.of(id), null, null, null, null, null, null, null, null, null, null,
345 null, null, null, null, null, null, null, false, false, asyncContinuation);
346 var response = asyncContinuation.awaitContent();
347 return Objects.requireNonNull(response.getItems()).stream().findFirst().orElse(null);
350 public @Nullable BaseItemDto searchItem(@Nullable String searchTerm, @Nullable BaseItemKind itemType,
351 @Nullable List<ItemFields> fields) throws SyncCallback.SyncCallbackError, ApiClientException {
352 return searchItems(searchTerm, itemType, fields, 1).stream().findFirst().orElse(null);
355 public List<BaseItemDto> searchItems(@Nullable String searchTerm, @Nullable BaseItemKind itemType,
356 @Nullable List<ItemFields> fields, int limit) throws SyncCallback.SyncCallbackError, ApiClientException {
357 var asyncContinuation = new SyncResponse<BaseItemDtoQueryResult>();
358 var itemTypes = itemType != null ? List.of(itemType) : null;
359 new ItemsApi(jellyApiClient).getItems(jellyApiClient.getUserId(), null, null, null, null, null, null, null,
360 null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
361 null, null, null, null, null, null, null, null, limit, true, searchTerm, null, null, fields, null,
362 itemTypes, null, null, null, null, null, null, null, null, null, null, null, 1, null, null, null, null,
363 null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
364 null, null, null, null, null, null, null, null, null, false, false, asyncContinuation);
365 var response = asyncContinuation.awaitContent();
366 return Objects.requireNonNull(response.getItems());
369 private void startChecker() {
371 checkInterval = scheduler.scheduleWithFixedDelay(() -> {
373 updateStatus(ThingStatus.OFFLINE);
375 } else if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
376 if (!isAuthenticated()) {
377 updateStatusUnauthenticated();
380 updateStatus(ThingStatus.ONLINE);
383 }, 0, config.refreshSeconds, TimeUnit.SECONDS);
386 private void stopChecker() {
387 var checkInterval = this.checkInterval;
388 if (checkInterval != null) {
389 checkInterval.cancel(true);
390 this.checkInterval = null;
394 private void updateClients(List<SessionInfo> sessions) {
395 var things = getThing().getThings();
396 things.forEach((childThing) -> {
397 var handler = childThing.getHandler();
398 if (handler == null) {
401 if (handler instanceof JellyfinClientHandler clientHandler) {
402 updateClientState(clientHandler, sessions);
404 logger.warn("Found unknown thing-handler instance: {}", handler);
409 private void updateClientState(JellyfinClientHandler handler, List<SessionInfo> sessions) {
411 SessionInfo clientSession = sessions.stream()
412 .filter(session -> Objects.equals(session.getDeviceId(), handler.getThing().getUID().getId()))
413 .sorted((a, b) -> b.getLastActivityDate().compareTo(a.getLastActivityDate())).findFirst().orElse(null);
414 handler.updateStateFromSession(clientSession);
417 public static class JellyfinCredentials {
418 private final String accessToken;
419 private final String userId;
421 public JellyfinCredentials(String accessToken, String userId) {
422 this.accessToken = accessToken;
423 this.userId = userId;
426 public String getUserId() {
430 public String getAccessToken() {