]> git.basschouten.com Git - openhab-addons.git/blob
989a8987e46eeb22c18a7590de21659675b2bb5e
[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 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;
24
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;
67
68 import io.ktor.http.URLBuilder;
69 import io.ktor.http.URLProtocol;
70
71 /**
72  * The {@link JellyfinServerHandler} 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 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;
85
86     public JellyfinServerHandler(Bridge bridge) {
87         super(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();
92     }
93
94     @Override
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.");
101             return;
102         }
103         jellyApiClient.setAccessToken(config.token);
104         jellyApiClient.setUserId(UUID.fromString(config.userId));
105         updateStatus(ThingStatus.UNKNOWN);
106         startChecker();
107     }
108
109     @Override
110     public void handleCommand(ChannelUID channelUID, Command command) {
111     }
112
113     @Override
114     public void dispose() {
115         super.dispose();
116         stopChecker();
117     }
118
119     @Override
120     public Collection<Class<? extends ThingHandlerService>> getServices() {
121         return Collections.singleton(JellyfinClientDiscoveryService.class);
122     }
123
124     public String getServerUrl() {
125         var builder = new URLBuilder();
126         builder.setHost(config.hostname);
127         if (config.ssl) {
128             builder.setProtocol(URLProtocol.Companion.getHTTPS());
129         } else {
130             builder.setProtocol(URLProtocol.Companion.getHTTP());
131         }
132         builder.setPort(config.port);
133         builder.setEncodedPath(config.path);
134         return builder.buildString();
135     }
136
137     public boolean isOnline() {
138         var asyncResponse = new SyncResponse<String>();
139         new SystemApi(jellyApiClient).getPingSystem(asyncResponse);
140         try {
141             return asyncResponse.awaitResponse().getStatus() == 200;
142         } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
143             logger.warn("Response error: {}", e.getMessage());
144             return false;
145         }
146     }
147
148     public boolean isAuthenticated() {
149         if (config.token.isBlank() || config.userId.isBlank()) {
150             return false;
151         }
152         var asyncResponse = new SyncResponse<SystemInfo>();
153         new SystemApi(jellyApiClient).getSystemInfo(asyncResponse);
154         try {
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);
160             }
161             var version = systemInfo.getVersion();
162             if (version != null) {
163                 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, version);
164             }
165             updateProperties(properties);
166             return true;
167         } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
168             return false;
169         }
170     }
171
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);
180     }
181
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);
187         initialize();
188     }
189
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.");
196     }
197
198     private void checkClientStates() {
199         var sessions = sessionsCache.getValue();
200         if (sessions != null) {
201             logger.debug("Got {} sessions", sessions.size());
202             updateClients(sessions);
203         } else {
204             sessionsCache.invalidateValue();
205         }
206     }
207
208     private @Nullable List<SessionInfo> tryGetSessions() {
209         try {
210             if (jellyApiClient.getAccessToken() == null) {
211                 return null;
212             }
213             var clientActiveWithInSeconds = config.clientActiveWithInSeconds != 0 ? config.clientActiveWithInSeconds
214                     : null;
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);
220         }
221         return null;
222     }
223
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) {
229             var status = ((InvalidStatusException) cause).getStatus();
230             if (status == 401) {
231                 unauthenticated = true;
232             }
233             logger.warn("Api error has invalid status: {}", status);
234         }
235         if (cause instanceof MissingUserIdException) {
236             unauthenticated = true;
237         }
238         if (unauthenticated) {
239             updateStatusUnauthenticated();
240         }
241     }
242
243     public void updateClientState(JellyfinClientHandler handler) {
244         var sessions = sessionsCache.getValue();
245         if (sessions != null) {
246             updateClientState(handler, sessions);
247         } else {
248             sessionsCache.invalidateValue();
249         }
250     }
251
252     public List<SessionInfo> getControllableSessions() throws SyncCallback.SyncCallbackError, ApiClientException {
253         return getControllableSessions(null);
254     }
255
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,
260                 asyncContinuation);
261         return asyncContinuation.awaitContent();
262     }
263
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();
270     }
271
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();
277     }
278
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();
285     }
286
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();
292     }
293
294     public @Nullable BaseItemDto getSeriesNextUpItem(UUID seriesId)
295             throws SyncCallback.SyncCallbackError, ApiClientException {
296         return getSeriesNextUpItems(seriesId, 1).stream().findFirst().orElse(null);
297     }
298
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());
306     }
307
308     public @Nullable BaseItemDto getSeriesResumeItem(UUID seriesId)
309             throws SyncCallback.SyncCallbackError, ApiClientException {
310         return getSeriesResumeItems(seriesId, 1).stream().findFirst().orElse(null);
311     }
312
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,
318                 asyncContinuation);
319         var result = asyncContinuation.awaitContent();
320         return Objects.requireNonNull(result.getItems());
321     }
322
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);
326     }
327
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());
335     }
336
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);
348     }
349
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);
353     }
354
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());
367     }
368
369     private void startChecker() {
370         stopChecker();
371         checkInterval = scheduler.scheduleWithFixedDelay(() -> {
372             if (!isOnline()) {
373                 updateStatus(ThingStatus.OFFLINE);
374                 return;
375             } else if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
376                 if (!isAuthenticated()) {
377                     updateStatusUnauthenticated();
378                     return;
379                 }
380                 updateStatus(ThingStatus.ONLINE);
381             }
382             checkClientStates();
383         }, 0, config.refreshSeconds, TimeUnit.SECONDS);
384     }
385
386     private void stopChecker() {
387         var checkInterval = this.checkInterval;
388         if (checkInterval != null) {
389             checkInterval.cancel(true);
390             this.checkInterval = null;
391         }
392     }
393
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) {
399                 return;
400             }
401             if (handler instanceof JellyfinClientHandler) {
402                 updateClientState((JellyfinClientHandler) handler, sessions);
403             } else {
404                 logger.warn("Found unknown thing-handler instance: {}", handler);
405             }
406         });
407     }
408
409     private void updateClientState(JellyfinClientHandler handler, List<SessionInfo> sessions) {
410         @Nullable
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);
415     }
416
417     public static class JellyfinCredentials {
418         private final String accessToken;
419         private final String userId;
420
421         public JellyfinCredentials(String accessToken, String userId) {
422             this.accessToken = accessToken;
423             this.userId = userId;
424         }
425
426         public String getUserId() {
427             return userId;
428         }
429
430         public String getAccessToken() {
431             return accessToken;
432         }
433     }
434 }