]> git.basschouten.com Git - openhab-addons.git/blob
961c85d1802133fd18988ac3f7d9ac6d800ca321
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.spotify.internal.handler;
14
15 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.*;
16
17 import java.io.IOException;
18 import java.text.SimpleDateFormat;
19 import java.time.Duration;
20 import java.util.Collection;
21 import java.util.Collections;
22 import java.util.Date;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Optional;
26 import java.util.concurrent.Future;
27 import java.util.concurrent.TimeUnit;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.openhab.binding.spotify.internal.SpotifyAccountHandler;
33 import org.openhab.binding.spotify.internal.SpotifyBridgeConfiguration;
34 import org.openhab.binding.spotify.internal.api.SpotifyApi;
35 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
36 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
37 import org.openhab.binding.spotify.internal.api.model.Album;
38 import org.openhab.binding.spotify.internal.api.model.Artist;
39 import org.openhab.binding.spotify.internal.api.model.Context;
40 import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
41 import org.openhab.binding.spotify.internal.api.model.Device;
42 import org.openhab.binding.spotify.internal.api.model.Image;
43 import org.openhab.binding.spotify.internal.api.model.Item;
44 import org.openhab.binding.spotify.internal.api.model.Me;
45 import org.openhab.binding.spotify.internal.api.model.Playlist;
46 import org.openhab.binding.spotify.internal.discovery.SpotifyDeviceDiscoveryService;
47 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
48 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
49 import org.openhab.core.auth.client.oauth2.OAuthClientService;
50 import org.openhab.core.auth.client.oauth2.OAuthException;
51 import org.openhab.core.auth.client.oauth2.OAuthFactory;
52 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
53 import org.openhab.core.cache.ExpiringCache;
54 import org.openhab.core.io.net.http.HttpUtil;
55 import org.openhab.core.library.types.DecimalType;
56 import org.openhab.core.library.types.OnOffType;
57 import org.openhab.core.library.types.PercentType;
58 import org.openhab.core.library.types.PlayPauseType;
59 import org.openhab.core.library.types.RawType;
60 import org.openhab.core.library.types.StringType;
61 import org.openhab.core.thing.Bridge;
62 import org.openhab.core.thing.Channel;
63 import org.openhab.core.thing.ChannelUID;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.ThingUID;
67 import org.openhab.core.thing.binding.BaseBridgeHandler;
68 import org.openhab.core.thing.binding.ThingHandlerService;
69 import org.openhab.core.types.Command;
70 import org.openhab.core.types.RefreshType;
71 import org.openhab.core.types.State;
72 import org.openhab.core.types.UnDefType;
73 import org.slf4j.Logger;
74 import org.slf4j.LoggerFactory;
75
76 /**
77  * The {@link SpotifyBridgeHandler} is the main class to manage Spotify WebAPI connection and update status of things.
78  *
79  * @author Andreas Stenlund - Initial contribution
80  * @author Hilbrand Bouwkamp - Just a lot of refactoring
81  */
82 @NonNullByDefault
83 public class SpotifyBridgeHandler extends BaseBridgeHandler
84         implements SpotifyAccountHandler, AccessTokenRefreshListener {
85
86     private static final CurrentlyPlayingContext EMPTY_CURRENTLY_PLAYING_CONTEXT = new CurrentlyPlayingContext();
87     private static final Album EMPTY_ALBUM = new Album();
88     private static final Artist EMPTY_ARTIST = new Artist();
89     private static final Item EMPTY_ITEM = new Item();
90     private static final Device EMPTY_DEVICE = new Device();
91     private static final SimpleDateFormat MUSIC_TIME_FORMAT = new SimpleDateFormat("m:ss");
92     private static final int MAX_IMAGE_SIZE = 500000;
93     /**
94      * Only poll playlist once per hour (or when refresh is called).
95      */
96     private static final Duration POLL_PLAY_LIST_HOURS = Duration.ofHours(1);
97     /**
98      * After a command is handles. With the given delay a status poll request is triggered. The delay is to give Spotify
99      * some time to handle the update.
100      */
101     private static final int POLL_DELAY_AFTER_COMMAND_S = 2;
102     /**
103      * Time between track progress status updates.
104      */
105     private static final int PROGRESS_STEP_S = 1;
106     private static final long PROGRESS_STEP_MS = TimeUnit.SECONDS.toMillis(PROGRESS_STEP_S);
107
108     private final Logger logger = LoggerFactory.getLogger(SpotifyBridgeHandler.class);
109     // Object to synchronize poll status on
110     private final Object pollSynchronization = new Object();
111     private final ProgressUpdater progressUpdater = new ProgressUpdater();
112     private final AlbumUpdater albumUpdater = new AlbumUpdater();
113     private final OAuthFactory oAuthFactory;
114     private final HttpClient httpClient;
115     private final SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider;
116     private final ChannelUID devicesChannelUID;
117     private final ChannelUID playlistsChannelUID;
118
119     // Field members assigned in initialize method
120     private @NonNullByDefault({}) Future<?> pollingFuture;
121     private @NonNullByDefault({}) OAuthClientService oAuthService;
122     private @NonNullByDefault({}) SpotifyApi spotifyApi;
123     private @NonNullByDefault({}) SpotifyBridgeConfiguration configuration;
124     private @NonNullByDefault({}) SpotifyHandleCommands handleCommand;
125     private @NonNullByDefault({}) ExpiringCache<CurrentlyPlayingContext> playingContextCache;
126     private @NonNullByDefault({}) ExpiringCache<List<Playlist>> playlistCache;
127     private @NonNullByDefault({}) ExpiringCache<List<Device>> devicesCache;
128
129     /**
130      * Keep track if this instance is disposed. This avoids new scheduling to be started after dispose is called.
131      */
132     private volatile boolean active;
133     private volatile State lastTrackId = StringType.EMPTY;
134     private volatile String lastKnownDeviceId = "";
135     private volatile boolean lastKnownDeviceActive;
136
137     public SpotifyBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient,
138             SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider) {
139         super(bridge);
140         this.oAuthFactory = oAuthFactory;
141         this.httpClient = httpClient;
142         this.spotifyDynamicStateDescriptionProvider = spotifyDynamicStateDescriptionProvider;
143         devicesChannelUID = new ChannelUID(bridge.getUID(), CHANNEL_DEVICES);
144         playlistsChannelUID = new ChannelUID(bridge.getUID(), CHANNEL_PLAYLISTS);
145     }
146
147     @Override
148     public Collection<Class<? extends ThingHandlerService>> getServices() {
149         return Collections.singleton(SpotifyDeviceDiscoveryService.class);
150     }
151
152     @Override
153     public void handleCommand(ChannelUID channelUID, Command command) {
154         if (command instanceof RefreshType) {
155             switch (channelUID.getId()) {
156                 case CHANNEL_PLAYED_ALBUMIMAGE:
157                     albumUpdater.refreshAlbumImage(channelUID);
158                     break;
159                 case CHANNEL_PLAYLISTS:
160                     playlistCache.invalidateValue();
161                     break;
162                 case CHANNEL_ACCESSTOKEN:
163                     onAccessTokenResponse(getAccessTokenResponse());
164                     break;
165                 default:
166                     lastTrackId = StringType.EMPTY;
167                     break;
168             }
169         } else {
170             try {
171                 if (handleCommand != null
172                         && handleCommand.handleCommand(channelUID, command, lastKnownDeviceActive, lastKnownDeviceId)) {
173                     scheduler.schedule(this::scheduledPollingRestart, POLL_DELAY_AFTER_COMMAND_S, TimeUnit.SECONDS);
174                 }
175             } catch (SpotifyException e) {
176                 logger.debug("Handle Spotify command failed: ", e);
177                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
178             }
179         }
180     }
181
182     @Override
183     public void dispose() {
184         active = false;
185         if (oAuthService != null) {
186             oAuthService.removeAccessTokenRefreshListener(this);
187         }
188         oAuthFactory.ungetOAuthService(thing.getUID().getAsString());
189         cancelSchedulers();
190     }
191
192     @Override
193     public ThingUID getUID() {
194         return thing.getUID();
195     }
196
197     @Override
198     public String getLabel() {
199         return thing.getLabel() == null ? "" : thing.getLabel().toString();
200     }
201
202     @Override
203     public boolean isAuthorized() {
204         final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
205
206         return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
207                 && accessTokenResponse.getRefreshToken() != null;
208     }
209
210     private @Nullable AccessTokenResponse getAccessTokenResponse() {
211         try {
212             return oAuthService == null ? null : oAuthService.getAccessTokenResponse();
213         } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
214             logger.debug("Exception checking authorization: ", e);
215             return null;
216         }
217     }
218
219     @Override
220     public String getUser() {
221         return thing.getProperties().getOrDefault(PROPERTY_SPOTIFY_USER, "");
222     }
223
224     @Override
225     public boolean isOnline() {
226         return thing.getStatus() == ThingStatus.ONLINE;
227     }
228
229     @Nullable
230     SpotifyApi getSpotifyApi() {
231         return spotifyApi;
232     }
233
234     @Override
235     public boolean equalsThingUID(String thingUID) {
236         return getThing().getUID().getAsString().equals(thingUID);
237     }
238
239     @Override
240     public String formatAuthorizationUrl(String redirectUri) {
241         try {
242             return oAuthService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString());
243         } catch (OAuthException e) {
244             logger.debug("Error constructing AuthorizationUrl: ", e);
245             return "";
246         }
247     }
248
249     @Override
250     public String authorize(String redirectUri, String reqCode) {
251         try {
252             logger.debug("Make call to Spotify to get access token.");
253             final AccessTokenResponse credentials = oAuthService.getAccessTokenResponseByAuthorizationCode(reqCode,
254                     redirectUri);
255             final String user = updateProperties(credentials);
256             logger.debug("Authorized for user: {}", user);
257             startPolling();
258             return user;
259         } catch (RuntimeException | OAuthException | IOException e) {
260             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
261             throw new SpotifyException(e.getMessage(), e);
262         } catch (OAuthResponseException e) {
263             throw new SpotifyAuthorizationException(e.getMessage(), e);
264         }
265     }
266
267     private String updateProperties(AccessTokenResponse credentials) {
268         if (spotifyApi != null) {
269             final Me me = spotifyApi.getMe();
270             final String user = me.getDisplayName() == null ? me.getId() : me.getDisplayName();
271             final Map<String, String> props = editProperties();
272
273             props.put(PROPERTY_SPOTIFY_USER, user);
274             updateProperties(props);
275             return user;
276         }
277         return "";
278     }
279
280     @Override
281     public void initialize() {
282         updateStatus(ThingStatus.UNKNOWN);
283         active = true;
284         configuration = getConfigAs(SpotifyBridgeConfiguration.class);
285         oAuthService = oAuthFactory.createOAuthClientService(thing.getUID().getAsString(), SPOTIFY_API_TOKEN_URL,
286                 SPOTIFY_AUTHORIZE_URL, configuration.clientId, configuration.clientSecret, SPOTIFY_SCOPES, true);
287         oAuthService.addAccessTokenRefreshListener(SpotifyBridgeHandler.this);
288         spotifyApi = new SpotifyApi(oAuthService, scheduler, httpClient);
289         handleCommand = new SpotifyHandleCommands(spotifyApi);
290         playingContextCache = new ExpiringCache<>(configuration.refreshPeriod, spotifyApi::getPlayerInfo);
291         playlistCache = new ExpiringCache<>(POLL_PLAY_LIST_HOURS, spotifyApi::getPlaylists);
292         devicesCache = new ExpiringCache<>(configuration.refreshPeriod, spotifyApi::getDevices);
293
294         // Start with update status by calling Spotify. If no credentials available no polling should be started.
295         scheduler.execute(() -> {
296             if (pollStatus()) {
297                 startPolling();
298             }
299         });
300     }
301
302     @Override
303     public List<Device> listDevices() {
304         final List<Device> listDevices = devicesCache.getValue();
305
306         return listDevices == null ? Collections.emptyList() : listDevices;
307     }
308
309     /**
310      * Scheduled method to restart polling in case polling is not running.
311      */
312     private void scheduledPollingRestart() {
313         synchronized (pollSynchronization) {
314             try {
315                 final boolean pollingNotRunning = pollingFuture == null || pollingFuture.isCancelled();
316
317                 expireCache();
318                 if (pollStatus() && pollingNotRunning) {
319                     startPolling();
320                 }
321             } catch (RuntimeException e) {
322                 logger.debug("Restarting polling failed: ", e);
323             }
324         }
325     }
326
327     /**
328      * This method initiates a new thread for polling the available Spotify Connect devices and update the player
329      * information.
330      */
331     private void startPolling() {
332         synchronized (pollSynchronization) {
333             cancelSchedulers();
334             if (active) {
335                 expireCache();
336                 pollingFuture = scheduler.scheduleWithFixedDelay(this::pollStatus, 0, configuration.refreshPeriod,
337                         TimeUnit.SECONDS);
338             }
339         }
340     }
341
342     private void expireCache() {
343         playingContextCache.invalidateValue();
344         playlistCache.invalidateValue();
345         devicesCache.invalidateValue();
346     }
347
348     /**
349      * Calls the Spotify API and collects user data. Returns true if method completed without errors.
350      *
351      * @return true if method completed without errors.
352      */
353     private boolean pollStatus() {
354         synchronized (pollSynchronization) {
355             try {
356                 onAccessTokenResponse(getAccessTokenResponse());
357                 // Collect currently playing context.
358                 final CurrentlyPlayingContext pc = playingContextCache.getValue();
359                 // If Spotify returned a 204. Meaning everything is ok, but we got no data.
360                 // Happens when no song is playing. And we know no device was active
361                 // No need to continue because no new information will be available.
362                 final boolean hasPlayData = pc != null && pc.getDevice() != null;
363                 final CurrentlyPlayingContext playingContext = pc == null ? EMPTY_CURRENTLY_PLAYING_CONTEXT : pc;
364
365                 // Collect devices and populate selection with available devices.
366                 if (hasPlayData || hasAnyDeviceStatusUnknown()) {
367                     final List<Device> ld = devicesCache.getValue();
368                     final List<Device> devices = ld == null ? Collections.emptyList() : ld;
369                     spotifyDynamicStateDescriptionProvider.setDevices(devicesChannelUID, devices);
370                     handleCommand.setDevices(devices);
371                     updateDevicesStatus(devices, playingContext.isPlaying());
372                 }
373
374                 // Update play status information.
375                 if (hasPlayData || getThing().getStatus() == ThingStatus.UNKNOWN) {
376                     final List<Playlist> lp = playlistCache.getValue();
377                     final List<Playlist> playlists = lp == null ? Collections.emptyList() : lp;
378                     handleCommand.setPlaylists(playlists);
379                     updatePlayerInfo(playingContext, playlists);
380                     spotifyDynamicStateDescriptionProvider.setPlayLists(playlistsChannelUID, playlists);
381                 }
382                 updateStatus(ThingStatus.ONLINE);
383                 return true;
384             } catch (SpotifyAuthorizationException e) {
385                 logger.debug("Authorization error during polling: ", e);
386
387                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
388                 cancelSchedulers();
389                 devicesCache.invalidateValue();
390             } catch (SpotifyException e) {
391                 logger.info("Spotify returned an error during polling: {}", e.getMessage());
392
393                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
394             } catch (RuntimeException e) {
395                 // This only should catch RuntimeException as the apiCall don't throw other exceptions.
396                 logger.info("Unexpected error during polling status, please report if this keeps occurring: ", e);
397
398                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
399             }
400         }
401         return false;
402     }
403
404     /**
405      * Cancels all running schedulers.
406      */
407     private synchronized void cancelSchedulers() {
408         if (pollingFuture != null) {
409             pollingFuture.cancel(true);
410         }
411         progressUpdater.cancelProgressScheduler();
412     }
413
414     @Override
415     public void onAccessTokenResponse(@Nullable AccessTokenResponse tokenResponse) {
416         updateChannelState(CHANNEL_ACCESSTOKEN,
417                 new StringType(tokenResponse == null ? null : tokenResponse.getAccessToken()));
418     }
419
420     /**
421      * Updates the status of all child Spotify Device Things.
422      *
423      * @param spotifyDevices list of Spotify devices
424      * @param playing true if the current active device is playing
425      */
426     private void updateDevicesStatus(List<Device> spotifyDevices, boolean playing) {
427         getThing().getThings().stream() //
428                 .filter(thing -> thing.getHandler() instanceof SpotifyDeviceHandler) //
429                 .filter(thing -> !spotifyDevices.stream()
430                         .anyMatch(sd -> ((SpotifyDeviceHandler) thing.getHandler()).updateDeviceStatus(sd, playing)))
431                 .forEach(thing -> ((SpotifyDeviceHandler) thing.getHandler()).setStatusGone());
432     }
433
434     private boolean hasAnyDeviceStatusUnknown() {
435         return getThing().getThings().stream() //
436                 .filter(thing -> thing.getHandler() instanceof SpotifyDeviceHandler) //
437                 .anyMatch(sd -> ((SpotifyDeviceHandler) sd.getHandler()).getThing().getStatus() == ThingStatus.UNKNOWN);
438     }
439
440     /**
441      * Update the player data.
442      *
443      * @param playerInfo The object with the current playing context
444      * @param playlists List of available playlists
445      */
446     private void updatePlayerInfo(CurrentlyPlayingContext playerInfo, List<Playlist> playlists) {
447         updateChannelState(CHANNEL_TRACKPLAYER, playerInfo.isPlaying() ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
448         updateChannelState(CHANNEL_DEVICESHUFFLE, OnOffType.from(playerInfo.isShuffleState()));
449         updateChannelState(CHANNEL_TRACKREPEAT, playerInfo.getRepeatState());
450
451         final boolean hasItem = playerInfo.getItem() != null;
452         final Item item = hasItem ? playerInfo.getItem() : EMPTY_ITEM;
453         final State trackId = valueOrEmpty(item.getId());
454
455         progressUpdater.updateProgress(active, playerInfo.isPlaying(), item.getDurationMs(),
456                 playerInfo.getProgressMs());
457         if (!lastTrackId.equals(trackId)) {
458             lastTrackId = trackId;
459             updateChannelState(CHANNEL_PLAYED_TRACKDURATION_MS, new DecimalType(item.getDurationMs()));
460             final String formattedProgress;
461             synchronized (MUSIC_TIME_FORMAT) {
462                 // synchronize because SimpleDateFormat is not thread safe
463                 formattedProgress = MUSIC_TIME_FORMAT.format(new Date(item.getDurationMs()));
464             }
465             updateChannelState(CHANNEL_PLAYED_TRACKDURATION_FMT, formattedProgress);
466
467             updateChannelsPlayList(playerInfo, playlists);
468             updateChannelState(CHANNEL_PLAYED_TRACKID, lastTrackId);
469             updateChannelState(CHANNEL_PLAYED_TRACKHREF, valueOrEmpty(item.getHref()));
470             updateChannelState(CHANNEL_PLAYED_TRACKURI, valueOrEmpty(item.getUri()));
471             updateChannelState(CHANNEL_PLAYED_TRACKNAME, valueOrEmpty(item.getName()));
472             updateChannelState(CHANNEL_PLAYED_TRACKTYPE, valueOrEmpty(item.getType()));
473             updateChannelState(CHANNEL_PLAYED_TRACKNUMBER, valueOrZero(item.getTrackNumber()));
474             updateChannelState(CHANNEL_PLAYED_TRACKDISCNUMBER, valueOrZero(item.getDiscNumber()));
475             updateChannelState(CHANNEL_PLAYED_TRACKPOPULARITY, valueOrZero(item.getPopularity()));
476             updateChannelState(CHANNEL_PLAYED_TRACKEXPLICIT, OnOffType.from(item.isExplicit()));
477
478             final boolean hasAlbum = hasItem && item.getAlbum() != null;
479             final Album album = hasAlbum ? item.getAlbum() : EMPTY_ALBUM;
480             updateChannelState(CHANNEL_PLAYED_ALBUMID, valueOrEmpty(album.getId()));
481             updateChannelState(CHANNEL_PLAYED_ALBUMHREF, valueOrEmpty(album.getHref()));
482             updateChannelState(CHANNEL_PLAYED_ALBUMURI, valueOrEmpty(album.getUri()));
483             updateChannelState(CHANNEL_PLAYED_ALBUMNAME, valueOrEmpty(album.getName()));
484             updateChannelState(CHANNEL_PLAYED_ALBUMTYPE, valueOrEmpty(album.getType()));
485             albumUpdater.updateAlbumImage(album);
486
487             final Artist firstArtist = hasItem && item.getArtists() != null && !item.getArtists().isEmpty()
488                     ? item.getArtists().get(0)
489                     : EMPTY_ARTIST;
490
491             updateChannelState(CHANNEL_PLAYED_ARTISTID, valueOrEmpty(firstArtist.getId()));
492             updateChannelState(CHANNEL_PLAYED_ARTISTHREF, valueOrEmpty(firstArtist.getHref()));
493             updateChannelState(CHANNEL_PLAYED_ARTISTURI, valueOrEmpty(firstArtist.getUri()));
494             updateChannelState(CHANNEL_PLAYED_ARTISTNAME, valueOrEmpty(firstArtist.getName()));
495             updateChannelState(CHANNEL_PLAYED_ARTISTTYPE, valueOrEmpty(firstArtist.getType()));
496         }
497         final Device device = playerInfo.getDevice() == null ? EMPTY_DEVICE : playerInfo.getDevice();
498         // Only update lastKnownDeviceId if it has a value, otherwise keep old value.
499         if (device.getId() != null) {
500             lastKnownDeviceId = device.getId();
501             updateChannelState(CHANNEL_DEVICEID, valueOrEmpty(lastKnownDeviceId));
502             updateChannelState(CHANNEL_DEVICES, valueOrEmpty(lastKnownDeviceId));
503             updateChannelState(CHANNEL_DEVICENAME, valueOrEmpty(device.getName()));
504         }
505         lastKnownDeviceActive = device.isActive();
506         updateChannelState(CHANNEL_DEVICEACTIVE, OnOffType.from(lastKnownDeviceActive));
507         updateChannelState(CHANNEL_DEVICETYPE, valueOrEmpty(device.getType()));
508
509         // experienced situations where volume seemed to be undefined...
510         updateChannelState(CHANNEL_DEVICEVOLUME,
511                 device.getVolumePercent() == null ? UnDefType.UNDEF : new PercentType(device.getVolumePercent()));
512     }
513
514     private void updateChannelsPlayList(CurrentlyPlayingContext playerInfo, @Nullable List<Playlist> playlists) {
515         final Context context = playerInfo.getContext();
516         final String playlistId;
517         String playlistName = "";
518
519         if (context != null && "playlist".equals(context.getType())) {
520             playlistId = "spotify:playlist" + context.getUri().substring(context.getUri().lastIndexOf(':'));
521
522             if (playlists != null) {
523                 final Optional<Playlist> optionalPlaylist = playlists.stream()
524                         .filter(pl -> playlistId.equals(pl.getUri())).findFirst();
525
526                 playlistName = optionalPlaylist.isPresent() ? optionalPlaylist.get().getName() : "";
527             }
528         } else {
529             playlistId = "";
530         }
531         updateChannelState(CHANNEL_PLAYLISTS, valueOrEmpty(playlistId));
532         updateChannelState(CHANNEL_PLAYLISTNAME, valueOrEmpty(playlistName));
533     }
534
535     /**
536      * @param value Integer value to return as {@link DecimalType}
537      * @return value as {@link DecimalType} or ZERO if the value is null
538      */
539     private DecimalType valueOrZero(@Nullable Integer value) {
540         return value == null ? DecimalType.ZERO : new DecimalType(value);
541     }
542
543     /**
544      * @param value String value to return as {@link StringType}
545      * @return value as {@link StringType} or EMPTY if the value is null or empty
546      */
547     private StringType valueOrEmpty(@Nullable String value) {
548         return value == null || value.isEmpty() ? StringType.EMPTY : new StringType(value);
549     }
550
551     /**
552      * Convenience method to update the channel state as {@link StringType} with a {@link String} value
553      *
554      * @param channelId id of the channel to update
555      * @param value String value to set as {@link StringType}
556      */
557     private void updateChannelState(String channelId, String value) {
558         updateChannelState(channelId, new StringType(value));
559     }
560
561     /**
562      * Convenience method to update the channel state but only if the channel is linked.
563      *
564      * @param channelId id of the channel to update
565      * @param state State to set on the channel
566      */
567     private void updateChannelState(String channelId, State state) {
568         final Channel channel = thing.getChannel(channelId);
569
570         if (channel != null && isLinked(channel.getUID())) {
571             updateState(channel.getUID(), state);
572         }
573     }
574
575     /**
576      * Class that manages the current progress of a track. The actual progress is tracked with the user specified
577      * interval, This class fills the in between seconds so the status will show a continues updating of the progress.
578      *
579      * @author Hilbrand Bouwkamp - Initial contribution
580      */
581     private class ProgressUpdater {
582         private long progress;
583         private long duration;
584         private @NonNullByDefault({}) Future<?> progressFuture;
585
586         /**
587          * Updates the progress with its actual values as provided by Spotify. Based on if the track is running or not
588          * update the progress scheduler.
589          *
590          * @param active true if this instance is not disposed
591          * @param playing true if the track if playing
592          * @param duration duration of the track
593          * @param progress current progress of the track
594          */
595         public synchronized void updateProgress(boolean active, boolean playing, long duration, long progress) {
596             this.duration = duration;
597             setProgress(progress);
598             if (!playing || !active) {
599                 cancelProgressScheduler();
600             } else if ((progressFuture == null || progressFuture.isCancelled()) && active) {
601                 progressFuture = scheduler.scheduleWithFixedDelay(this::incrementProgress, PROGRESS_STEP_S,
602                         PROGRESS_STEP_S, TimeUnit.SECONDS);
603             }
604         }
605
606         /**
607          * Increments the progress with PROGRESS_STEP_MS, but limits it on the duration.
608          */
609         private synchronized void incrementProgress() {
610             setProgress(Math.min(duration, progress + PROGRESS_STEP_MS));
611         }
612
613         /**
614          * Sets the progress on the channels.
615          *
616          * @param progress progress value to set
617          */
618         private void setProgress(long progress) {
619             this.progress = progress;
620             final String formattedProgress;
621
622             synchronized (MUSIC_TIME_FORMAT) {
623                 formattedProgress = MUSIC_TIME_FORMAT.format(new Date(progress));
624             }
625             updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_MS, new DecimalType(progress));
626             updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_FMT, formattedProgress);
627         }
628
629         /**
630          * Cancels the progress future.
631          */
632         public synchronized void cancelProgressScheduler() {
633             if (progressFuture != null) {
634                 progressFuture.cancel(true);
635                 progressFuture = null;
636             }
637         }
638     }
639
640     /**
641      * Class to manager Album image updates.
642      *
643      * @author Hilbrand Bouwkamp - Initial contribution
644      */
645     private class AlbumUpdater {
646         private String lastAlbumImageUrl = "";
647
648         /**
649          * Updates the album image status, but only refreshes the image when a new image should be shown.
650          *
651          * @param album album data
652          */
653         public void updateAlbumImage(Album album) {
654             final Channel channel = thing.getChannel(CHANNEL_PLAYED_ALBUMIMAGE);
655             final List<Image> images = album.getImages();
656
657             if (channel != null && images != null && !images.isEmpty()) {
658                 final String imageUrl = images.get(0).getUrl();
659
660                 if (!lastAlbumImageUrl.equals(imageUrl)) {
661                     // Download the cover art in a different thread to not delay the other operations
662                     lastAlbumImageUrl = imageUrl == null ? "" : imageUrl;
663                     refreshAlbumImage(channel.getUID());
664                 }
665             } else {
666                 updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, UnDefType.UNDEF);
667             }
668         }
669
670         /**
671          * Refreshes the image asynchronously, but only downloads the image if the channel is linked to avoid
672          * unnecessary downloading of the image.
673          *
674          * @param channelUID UID of the album channel
675          */
676         public void refreshAlbumImage(ChannelUID channelUID) {
677             if (!lastAlbumImageUrl.isEmpty() && isLinked(channelUID)) {
678                 final String imageUrl = lastAlbumImageUrl;
679                 scheduler.execute(() -> refreshAlbumAsynced(channelUID, imageUrl));
680             }
681         }
682
683         private void refreshAlbumAsynced(ChannelUID channelUID, String imageUrl) {
684             try {
685                 if (lastAlbumImageUrl.equals(imageUrl) && isLinked(channelUID)) {
686                     final RawType image = HttpUtil.downloadImage(imageUrl, true, MAX_IMAGE_SIZE);
687                     updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, image == null ? UnDefType.UNDEF : image);
688                 }
689             } catch (RuntimeException e) {
690                 logger.debug("Async call to refresh Album image failed: ", e);
691             }
692         }
693     }
694 }