]> git.basschouten.com Git - openhab-addons.git/blob
2172001facb7ada06bf17c5eed4f616330dd2e4e
[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.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 /**
69  * The {@link JellyfinServerHandler} is responsible for handling commands, which are
70  * sent to one of the channels.
71  *
72  * @author Miguel Álvarez - Initial contribution
73  */
74 @NonNullByDefault
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;
82
83     public JellyfinServerHandler(Bridge bridge) {
84         super(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();
89     }
90
91     @Override
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.");
98             return;
99         }
100         jellyApiClient.setAccessToken(config.token);
101         jellyApiClient.setUserId(UUID.fromString(config.userId));
102         updateStatus(ThingStatus.UNKNOWN);
103         startChecker();
104     }
105
106     @Override
107     public void handleCommand(ChannelUID channelUID, Command command) {
108     }
109
110     @Override
111     public void dispose() {
112         super.dispose();
113         stopChecker();
114     }
115
116     @Override
117     public Collection<Class<? extends ThingHandlerService>> getServices() {
118         return Collections.singleton(JellyfinClientDiscoveryService.class);
119     }
120
121     public String getServerUrl() {
122         return (config.ssl ? "https" : "http") + "://" + config.hostname + ":" + config.port;
123     }
124
125     public boolean isOnline() {
126         var asyncResponse = new SyncResponse<String>();
127         new SystemApi(jellyApiClient).getPingSystem(asyncResponse);
128         try {
129             return asyncResponse.awaitResponse().getStatus() == 200;
130         } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
131             logger.warn("Response error: {}", e.getMessage());
132             return false;
133         }
134     }
135
136     public boolean isAuthenticated() {
137         if (config.token.isBlank() || config.userId.isBlank()) {
138             return false;
139         }
140         var asyncResponse = new SyncResponse<SystemInfo>();
141         new SystemApi(jellyApiClient).getSystemInfo(asyncResponse);
142         try {
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);
148             }
149             var version = systemInfo.getVersion();
150             if (version != null) {
151                 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, version);
152             }
153             updateProperties(properties);
154             return true;
155         } catch (SyncCallback.SyncCallbackError | ApiClientException e) {
156             return false;
157         }
158     }
159
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);
168     }
169
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);
175         initialize();
176     }
177
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.");
184     }
185
186     private void checkClientStates() {
187         var sessions = sessionsCache.getValue();
188         if (sessions != null) {
189             logger.debug("Got {} sessions", sessions.size());
190             updateClients(sessions);
191         } else {
192             sessionsCache.invalidateValue();
193         }
194     }
195
196     private @Nullable List<SessionInfo> tryGetSessions() {
197         try {
198             if (jellyApiClient.getAccessToken() == null) {
199                 return null;
200             }
201             var clientActiveWithInSeconds = config.clientActiveWithInSeconds != 0 ? config.clientActiveWithInSeconds
202                     : null;
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);
208         }
209         return null;
210     }
211
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();
218             if (status == 401) {
219                 unauthenticated = true;
220             }
221             logger.warn("Api error has invalid status: {}", status);
222         }
223         if (cause instanceof MissingUserIdException) {
224             unauthenticated = true;
225         }
226         if (unauthenticated) {
227             updateStatusUnauthenticated();
228         }
229     }
230
231     public void updateClientState(JellyfinClientHandler handler) {
232         var sessions = sessionsCache.getValue();
233         if (sessions != null) {
234             updateClientState(handler, sessions);
235         } else {
236             sessionsCache.invalidateValue();
237         }
238     }
239
240     public List<SessionInfo> getControllableSessions() throws SyncCallback.SyncCallbackError, ApiClientException {
241         return getControllableSessions(null);
242     }
243
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,
248                 asyncContinuation);
249         return asyncContinuation.awaitContent();
250     }
251
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();
258     }
259
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();
265     }
266
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();
273     }
274
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();
280     }
281
282     public @Nullable BaseItemDto getSeriesNextUpItem(UUID seriesId)
283             throws SyncCallback.SyncCallbackError, ApiClientException {
284         return getSeriesNextUpItems(seriesId, 1).stream().findFirst().orElse(null);
285     }
286
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());
294     }
295
296     public @Nullable BaseItemDto getSeriesResumeItem(UUID seriesId)
297             throws SyncCallback.SyncCallbackError, ApiClientException {
298         return getSeriesResumeItems(seriesId, 1).stream().findFirst().orElse(null);
299     }
300
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,
306                 asyncContinuation);
307         var result = asyncContinuation.awaitContent();
308         return Objects.requireNonNull(result.getItems());
309     }
310
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);
314     }
315
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());
323     }
324
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);
336     }
337
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);
341     }
342
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());
355     }
356
357     private void startChecker() {
358         stopChecker();
359         checkInterval = scheduler.scheduleWithFixedDelay(() -> {
360             if (!isOnline()) {
361                 updateStatus(ThingStatus.OFFLINE);
362                 return;
363             } else if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
364                 if (!isAuthenticated()) {
365                     updateStatusUnauthenticated();
366                     return;
367                 }
368                 updateStatus(ThingStatus.ONLINE);
369             }
370             checkClientStates();
371         }, 0, config.refreshSeconds, TimeUnit.SECONDS);
372     }
373
374     private void stopChecker() {
375         var checkInterval = this.checkInterval;
376         if (checkInterval != null) {
377             checkInterval.cancel(true);
378             this.checkInterval = null;
379         }
380     }
381
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) {
387                 return;
388             }
389             if (handler instanceof JellyfinClientHandler) {
390                 updateClientState((JellyfinClientHandler) handler, sessions);
391             } else {
392                 logger.warn("Found unknown thing-handler instance: {}", handler);
393             }
394         });
395     }
396
397     private void updateClientState(JellyfinClientHandler handler, List<SessionInfo> sessions) {
398         @Nullable
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);
403     }
404
405     public static class JellyfinCredentials {
406         private final String accessToken;
407         private final String userId;
408
409         public JellyfinCredentials(String accessToken, String userId) {
410             this.accessToken = accessToken;
411             this.userId = userId;
412         }
413
414         public String getUserId() {
415             return userId;
416         }
417
418         public String getAccessToken() {
419             return accessToken;
420         }
421     }
422 }