]> git.basschouten.com Git - openhab-addons.git/blob
38947a58f4d9e88e5655efba89272c8625f33b73
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.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;
66
67 /**
68  * The {@link JellyfinServerHandler} is responsible for handling commands, which are
69  * sent to one of the channels.
70  *
71  * @author Miguel Álvarez - Initial contribution
72  */
73 @NonNullByDefault
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;
81
82     public JellyfinServerHandler(Bridge bridge) {
83         super(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();
88     }
89
90     @Override
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.");
97             return;
98         }
99         jellyApiClient.setAccessToken(config.token);
100         jellyApiClient.setUserId(UUID.fromString(config.userId));
101         updateStatus(ThingStatus.UNKNOWN);
102         startChecker();
103     }
104
105     @Override
106     public void handleCommand(ChannelUID channelUID, Command command) {
107     }
108
109     @Override
110     public void dispose() {
111         super.dispose();
112         stopChecker();
113     }
114
115     @Override
116     public Collection<Class<? extends ThingHandlerService>> getServices() {
117         return Collections.singleton(JellyfinClientDiscoveryService.class);
118     }
119
120     public String getServerUrl() {
121         return (config.ssl ? "https" : "http") + "://" + config.hostname + ":" + config.port;
122     }
123
124     public boolean isOnline() {
125         var asyncResponse = new SyncResponse<String>();
126         new SystemApi(jellyApiClient).getPingSystem(asyncResponse);
127         try {
128             return asyncResponse.awaitResponse().getStatus() == 200;
129         } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
130             logger.warn("Response error: {}", e.getMessage());
131             return false;
132         }
133     }
134
135     public boolean isAuthenticated() {
136         if (config.token.isBlank() || config.userId.isBlank()) {
137             return false;
138         }
139         var asyncResponse = new SyncResponse<SystemInfo>();
140         new SystemApi(jellyApiClient).getSystemInfo(asyncResponse);
141         try {
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);
147             }
148             var version = systemInfo.getVersion();
149             if (version != null) {
150                 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, version);
151             }
152             updateProperties(properties);
153             return true;
154         } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
155             return false;
156         }
157     }
158
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);
167     }
168
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);
174         initialize();
175     }
176
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.");
183     }
184
185     private void checkClientStates() {
186         var sessions = sessionsCache.getValue();
187         if (sessions != null) {
188             logger.debug("Got {} sessions", sessions.size());
189             updateClients(sessions);
190         } else {
191             sessionsCache.invalidateValue();
192         }
193     }
194
195     private @Nullable List<SessionInfo> tryGetSessions() {
196         try {
197             if (jellyApiClient.getAccessToken() == null) {
198                 return null;
199             }
200             var clientActiveWithInSeconds = config.clientActiveWithInSeconds != 0 ? config.clientActiveWithInSeconds
201                     : null;
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);
207         }
208         return null;
209     }
210
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();
217             if (status == 401) {
218                 unauthenticated = true;
219             }
220             logger.warn("Api error has invalid status: {}", status);
221         }
222         if (cause instanceof MissingUserIdException) {
223             unauthenticated = true;
224         }
225         if (unauthenticated) {
226             updateStatusUnauthenticated();
227         }
228     }
229
230     public void updateClientState(JellyfinClientHandler handler) {
231         var sessions = sessionsCache.getValue();
232         if (sessions != null) {
233             updateClientState(handler, sessions);
234         } else {
235             sessionsCache.invalidateValue();
236         }
237     }
238
239     public List<SessionInfo> getControllableSessions() throws SyncCallback.SyncCallbackError, ApiClientException {
240         return getControllableSessions(null);
241     }
242
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,
247                 asyncContinuation);
248         return asyncContinuation.awaitContent();
249     }
250
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();
257     }
258
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();
264     }
265
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();
272     }
273
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();
279     }
280
281     public @Nullable BaseItemDto getSeriesNextUpItem(UUID seriesId)
282             throws SyncCallback.SyncCallbackError, ApiClientException {
283         return getSeriesNextUpItems(seriesId, 1).stream().findFirst().orElse(null);
284     }
285
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());
293     }
294
295     public @Nullable BaseItemDto getSeriesResumeItem(UUID seriesId)
296             throws SyncCallback.SyncCallbackError, ApiClientException {
297         return getSeriesResumeItems(seriesId, 1).stream().findFirst().orElse(null);
298     }
299
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());
307     }
308
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);
312     }
313
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());
321     }
322
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);
326     }
327
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());
340     }
341
342     private void startChecker() {
343         stopChecker();
344         checkInterval = scheduler.scheduleWithFixedDelay(() -> {
345             if (!isOnline()) {
346                 updateStatus(ThingStatus.OFFLINE);
347                 return;
348             } else if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
349                 if (!isAuthenticated()) {
350                     updateStatusUnauthenticated();
351                     return;
352                 }
353                 updateStatus(ThingStatus.ONLINE);
354             }
355             checkClientStates();
356         }, 0, config.refreshSeconds, TimeUnit.SECONDS);
357     }
358
359     private void stopChecker() {
360         var checkInterval = this.checkInterval;
361         if (checkInterval != null) {
362             checkInterval.cancel(true);
363             this.checkInterval = null;
364         }
365     }
366
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) {
372                 return;
373             }
374             if (handler instanceof JellyfinClientHandler) {
375                 updateClientState((JellyfinClientHandler) handler, sessions);
376             } else {
377                 logger.warn("Found unknown thing-handler instance: {}", handler);
378             }
379         });
380     }
381
382     private void updateClientState(JellyfinClientHandler handler, List<SessionInfo> sessions) {
383         @Nullable
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);
388     }
389
390     public static class JellyfinCredentials {
391         private final String accessToken;
392         private final String userId;
393
394         public JellyfinCredentials(String accessToken, String userId) {
395             this.accessToken = accessToken;
396             this.userId = userId;
397         }
398
399         public String getUserId() {
400             return userId;
401         }
402
403         public String getAccessToken() {
404             return accessToken;
405         }
406     }
407 }