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