2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.spotify.internal.handler;
15 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_ACCESSTOKEN;
16 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_CONFIG_IMAGE_INDEX;
17 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEACTIVE;
18 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEID;
19 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICENAME;
20 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICES;
21 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICESHUFFLE;
22 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICETYPE;
23 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_DEVICEVOLUME;
24 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMHREF;
25 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMID;
26 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMIMAGE;
27 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMIMAGEURL;
28 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMNAME;
29 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMTYPE;
30 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ALBUMURI;
31 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTHREF;
32 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTID;
33 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTNAME;
34 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTTYPE;
35 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_ARTISTURI;
36 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDISCNUMBER;
37 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDURATION_FMT;
38 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKDURATION_MS;
39 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKEXPLICIT;
40 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKHREF;
41 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKID;
42 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKNAME;
43 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKNUMBER;
44 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPOPULARITY;
45 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPROGRESS_FMT;
46 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKPROGRESS_MS;
47 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKTYPE;
48 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYED_TRACKURI;
49 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTNAME;
50 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS;
51 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS_LIMIT;
52 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_PLAYLISTS_OFFSET;
53 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_TRACKPLAYER;
54 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.CHANNEL_TRACKREPEAT;
55 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.PROPERTY_SPOTIFY_USER;
56 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_API_TOKEN_URL;
57 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_AUTHORIZE_URL;
58 import static org.openhab.binding.spotify.internal.SpotifyBindingConstants.SPOTIFY_SCOPES;
60 import java.io.IOException;
61 import java.math.BigDecimal;
62 import java.text.SimpleDateFormat;
63 import java.time.Duration;
64 import java.util.Collection;
65 import java.util.Collections;
66 import java.util.Date;
67 import java.util.List;
69 import java.util.Optional;
70 import java.util.concurrent.Future;
71 import java.util.concurrent.TimeUnit;
73 import org.eclipse.jdt.annotation.NonNullByDefault;
74 import org.eclipse.jdt.annotation.Nullable;
75 import org.eclipse.jetty.client.HttpClient;
76 import org.openhab.binding.spotify.internal.SpotifyAccountHandler;
77 import org.openhab.binding.spotify.internal.SpotifyBridgeConfiguration;
78 import org.openhab.binding.spotify.internal.actions.SpotifyActions;
79 import org.openhab.binding.spotify.internal.api.SpotifyApi;
80 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
81 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
82 import org.openhab.binding.spotify.internal.api.model.Album;
83 import org.openhab.binding.spotify.internal.api.model.Artist;
84 import org.openhab.binding.spotify.internal.api.model.Context;
85 import org.openhab.binding.spotify.internal.api.model.CurrentlyPlayingContext;
86 import org.openhab.binding.spotify.internal.api.model.Device;
87 import org.openhab.binding.spotify.internal.api.model.Image;
88 import org.openhab.binding.spotify.internal.api.model.Item;
89 import org.openhab.binding.spotify.internal.api.model.Me;
90 import org.openhab.binding.spotify.internal.api.model.Playlist;
91 import org.openhab.binding.spotify.internal.discovery.SpotifyDeviceDiscoveryService;
92 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
93 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
94 import org.openhab.core.auth.client.oauth2.OAuthClientService;
95 import org.openhab.core.auth.client.oauth2.OAuthException;
96 import org.openhab.core.auth.client.oauth2.OAuthFactory;
97 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
98 import org.openhab.core.cache.ExpiringCache;
99 import org.openhab.core.io.net.http.HttpUtil;
100 import org.openhab.core.library.types.DecimalType;
101 import org.openhab.core.library.types.OnOffType;
102 import org.openhab.core.library.types.PercentType;
103 import org.openhab.core.library.types.PlayPauseType;
104 import org.openhab.core.library.types.RawType;
105 import org.openhab.core.library.types.StringType;
106 import org.openhab.core.thing.Bridge;
107 import org.openhab.core.thing.Channel;
108 import org.openhab.core.thing.ChannelUID;
109 import org.openhab.core.thing.ThingStatus;
110 import org.openhab.core.thing.ThingStatusDetail;
111 import org.openhab.core.thing.ThingUID;
112 import org.openhab.core.thing.binding.BaseBridgeHandler;
113 import org.openhab.core.thing.binding.ThingHandlerService;
114 import org.openhab.core.types.Command;
115 import org.openhab.core.types.RefreshType;
116 import org.openhab.core.types.State;
117 import org.openhab.core.types.UnDefType;
118 import org.slf4j.Logger;
119 import org.slf4j.LoggerFactory;
122 * The {@link SpotifyBridgeHandler} is the main class to manage Spotify WebAPI connection and update status of things.
124 * @author Andreas Stenlund - Initial contribution
125 * @author Hilbrand Bouwkamp - Just a lot of refactoring
128 public class SpotifyBridgeHandler extends BaseBridgeHandler
129 implements SpotifyAccountHandler, AccessTokenRefreshListener {
131 private static final CurrentlyPlayingContext EMPTY_CURRENTLY_PLAYING_CONTEXT = new CurrentlyPlayingContext();
132 private static final Album EMPTY_ALBUM = new Album();
133 private static final Artist EMPTY_ARTIST = new Artist();
134 private static final Item EMPTY_ITEM = new Item();
135 private static final Device EMPTY_DEVICE = new Device();
136 private static final SimpleDateFormat MUSIC_TIME_FORMAT = new SimpleDateFormat("m:ss");
137 private static final int MAX_IMAGE_SIZE = 500000;
139 * Only poll playlist once per hour (or when refresh is called).
141 private static final Duration POLL_PLAY_LIST_HOURS = Duration.ofHours(1);
143 * After a command is handles. With the given delay a status poll request is triggered. The delay is to give Spotify
144 * some time to handle the update.
146 private static final int POLL_DELAY_AFTER_COMMAND_S = 2;
148 * Time between track progress status updates.
150 private static final int PROGRESS_STEP_S = 1;
151 private static final long PROGRESS_STEP_MS = TimeUnit.SECONDS.toMillis(PROGRESS_STEP_S);
153 private final Logger logger = LoggerFactory.getLogger(SpotifyBridgeHandler.class);
154 // Object to synchronize poll status on
155 private final Object pollSynchronization = new Object();
156 private final ProgressUpdater progressUpdater = new ProgressUpdater();
157 private final AlbumUpdater albumUpdater = new AlbumUpdater();
158 private final OAuthFactory oAuthFactory;
159 private final HttpClient httpClient;
160 private final SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider;
161 private final ChannelUID devicesChannelUID;
162 private final ChannelUID playlistsChannelUID;
164 // Field members assigned in initialize method
165 private @NonNullByDefault({}) Future<?> pollingFuture;
166 private @NonNullByDefault({}) OAuthClientService oAuthService;
167 private @NonNullByDefault({}) SpotifyApi spotifyApi;
168 private @NonNullByDefault({}) SpotifyBridgeConfiguration configuration;
169 private @NonNullByDefault({}) SpotifyHandleCommands handleCommand;
170 private @NonNullByDefault({}) ExpiringCache<CurrentlyPlayingContext> playingContextCache;
171 private @NonNullByDefault({}) ExpiringCache<List<Playlist>> playlistCache;
172 private @NonNullByDefault({}) ExpiringCache<List<Device>> devicesCache;
175 * Keep track if this instance is disposed. This avoids new scheduling to be started after dispose is called.
177 private volatile boolean active;
178 private volatile State lastTrackId = StringType.EMPTY;
179 private volatile String lastKnownDeviceId = "";
180 private volatile boolean lastKnownDeviceActive;
181 private int imageChannelAlbumImageIndex;
182 private int imageChannelAlbumImageUrlIndex;
184 public SpotifyBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient,
185 SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider) {
187 this.oAuthFactory = oAuthFactory;
188 this.httpClient = httpClient;
189 this.spotifyDynamicStateDescriptionProvider = spotifyDynamicStateDescriptionProvider;
190 devicesChannelUID = new ChannelUID(bridge.getUID(), CHANNEL_DEVICES);
191 playlistsChannelUID = new ChannelUID(bridge.getUID(), CHANNEL_PLAYLISTS);
195 public Collection<Class<? extends ThingHandlerService>> getServices() {
196 return List.of(SpotifyActions.class, SpotifyDeviceDiscoveryService.class);
200 public void handleCommand(ChannelUID channelUID, Command command) {
201 if (command instanceof RefreshType) {
202 switch (channelUID.getId()) {
203 case CHANNEL_PLAYED_ALBUMIMAGE:
204 albumUpdater.refreshAlbumImage(channelUID);
206 case CHANNEL_PLAYLISTS:
207 playlistCache.invalidateValue();
209 case CHANNEL_ACCESSTOKEN:
210 onAccessTokenResponse(getAccessTokenResponse());
213 lastTrackId = StringType.EMPTY;
218 if (handleCommand != null
219 && handleCommand.handleCommand(channelUID, command, lastKnownDeviceActive, lastKnownDeviceId)) {
220 scheduler.schedule(this::scheduledPollingRestart, POLL_DELAY_AFTER_COMMAND_S, TimeUnit.SECONDS);
222 } catch (final SpotifyException e) {
223 logger.debug("Handle Spotify command failed: ", e);
224 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
230 public void dispose() {
232 if (oAuthService != null) {
233 oAuthService.removeAccessTokenRefreshListener(this);
235 oAuthFactory.ungetOAuthService(thing.getUID().getAsString());
240 public ThingUID getUID() {
241 return thing.getUID();
245 public String getLabel() {
246 return thing.getLabel() == null ? "" : thing.getLabel().toString();
250 public boolean isAuthorized() {
251 final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
253 return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
254 && accessTokenResponse.getRefreshToken() != null;
257 private @Nullable AccessTokenResponse getAccessTokenResponse() {
259 return oAuthService == null ? null : oAuthService.getAccessTokenResponse();
260 } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
261 logger.debug("Exception checking authorization: ", e);
267 public String getUser() {
268 return thing.getProperties().getOrDefault(PROPERTY_SPOTIFY_USER, "");
272 public boolean isOnline() {
273 return thing.getStatus() == ThingStatus.ONLINE;
277 public SpotifyApi getSpotifyApi() {
282 public boolean equalsThingUID(String thingUID) {
283 return getThing().getUID().getAsString().equals(thingUID);
287 public String formatAuthorizationUrl(String redirectUri) {
289 return oAuthService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString());
290 } catch (final OAuthException e) {
291 logger.debug("Error constructing AuthorizationUrl: ", e);
297 public String authorize(String redirectUri, String reqCode) {
299 logger.debug("Make call to Spotify to get access token.");
300 final AccessTokenResponse credentials = oAuthService.getAccessTokenResponseByAuthorizationCode(reqCode,
302 final String user = updateProperties(credentials);
303 logger.debug("Authorized for user: {}", user);
306 } catch (RuntimeException | OAuthException | IOException e) {
307 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
308 throw new SpotifyException(e.getMessage(), e);
309 } catch (final OAuthResponseException e) {
310 throw new SpotifyAuthorizationException(e.getMessage(), e);
314 private String updateProperties(AccessTokenResponse credentials) {
315 if (spotifyApi != null) {
316 final Me me = spotifyApi.getMe();
317 final String user = me.getDisplayName() == null ? me.getId() : me.getDisplayName();
318 final Map<String, String> props = editProperties();
320 props.put(PROPERTY_SPOTIFY_USER, user);
321 updateProperties(props);
328 public void initialize() {
329 updateStatus(ThingStatus.UNKNOWN);
331 configuration = getConfigAs(SpotifyBridgeConfiguration.class);
332 oAuthService = oAuthFactory.createOAuthClientService(thing.getUID().getAsString(), SPOTIFY_API_TOKEN_URL,
333 SPOTIFY_AUTHORIZE_URL, configuration.clientId, configuration.clientSecret, SPOTIFY_SCOPES, true);
334 oAuthService.addAccessTokenRefreshListener(SpotifyBridgeHandler.this);
335 spotifyApi = new SpotifyApi(oAuthService, scheduler, httpClient);
336 handleCommand = new SpotifyHandleCommands(spotifyApi);
337 final Duration expiringPeriod = Duration.ofSeconds(configuration.refreshPeriod);
339 playingContextCache = new ExpiringCache<>(expiringPeriod, spotifyApi::getPlayerInfo);
340 final int offset = getIntChannelParameter(CHANNEL_PLAYLISTS, CHANNEL_PLAYLISTS_OFFSET, 0);
341 final int limit = getIntChannelParameter(CHANNEL_PLAYLISTS, CHANNEL_PLAYLISTS_LIMIT, 20);
342 playlistCache = new ExpiringCache<>(POLL_PLAY_LIST_HOURS, () -> spotifyApi.getPlaylists(offset, limit));
343 devicesCache = new ExpiringCache<>(expiringPeriod, spotifyApi::getDevices);
345 // Start with update status by calling Spotify. If no credentials available no polling should be started.
346 scheduler.execute(() -> {
351 imageChannelAlbumImageIndex = getIntChannelParameter(CHANNEL_PLAYED_ALBUMIMAGE, CHANNEL_CONFIG_IMAGE_INDEX, 0);
352 imageChannelAlbumImageUrlIndex = getIntChannelParameter(CHANNEL_PLAYED_ALBUMIMAGEURL,
353 CHANNEL_CONFIG_IMAGE_INDEX, 0);
356 private int getIntChannelParameter(String channelName, String parameter, int _default) {
357 final Channel channel = thing.getChannel(channelName);
358 final BigDecimal index = channel == null ? null : (BigDecimal) channel.getConfiguration().get(parameter);
360 return index == null ? _default : index.intValue();
364 public List<Device> listDevices() {
365 final List<Device> listDevices = devicesCache.getValue();
367 return listDevices == null ? Collections.emptyList() : listDevices;
371 * Scheduled method to restart polling in case polling is not running.
373 private void scheduledPollingRestart() {
374 synchronized (pollSynchronization) {
376 final boolean pollingNotRunning = pollingFuture == null || pollingFuture.isCancelled();
379 if (pollStatus() && pollingNotRunning) {
382 } catch (final RuntimeException e) {
383 logger.debug("Restarting polling failed: ", e);
389 * This method initiates a new thread for polling the available Spotify Connect devices and update the player
392 private void startPolling() {
393 synchronized (pollSynchronization) {
397 pollingFuture = scheduler.scheduleWithFixedDelay(this::pollStatus, 0, configuration.refreshPeriod,
403 private void expireCache() {
404 playingContextCache.invalidateValue();
405 playlistCache.invalidateValue();
406 devicesCache.invalidateValue();
410 * Calls the Spotify API and collects user data. Returns true if method completed without errors.
412 * @return true if method completed without errors.
414 private boolean pollStatus() {
415 synchronized (pollSynchronization) {
417 onAccessTokenResponse(getAccessTokenResponse());
418 // Collect currently playing context.
419 final CurrentlyPlayingContext pc = playingContextCache.getValue();
420 // If Spotify returned a 204. Meaning everything is ok, but we got no data.
421 // Happens when no song is playing. And we know no device was active
422 // No need to continue because no new information will be available.
423 final boolean hasPlayData = pc != null && pc.getDevice() != null;
424 final CurrentlyPlayingContext playingContext = pc == null ? EMPTY_CURRENTLY_PLAYING_CONTEXT : pc;
426 // Collect devices and populate selection with available devices.
428 final List<Device> ld = devicesCache.getValue();
429 final List<Device> devices = ld == null ? Collections.emptyList() : ld;
430 spotifyDynamicStateDescriptionProvider.setDevices(devicesChannelUID, devices);
431 handleCommand.setDevices(devices);
432 updateDevicesStatus(devices, playingContext.isPlaying());
435 // Update play status information.
436 if (hasPlayData || getThing().getStatus() == ThingStatus.UNKNOWN) {
437 final List<Playlist> lp = playlistCache.getValue();
438 final List<Playlist> playlists = lp == null ? Collections.emptyList() : lp;
439 handleCommand.setPlaylists(playlists);
440 updatePlayerInfo(playingContext, playlists);
441 spotifyDynamicStateDescriptionProvider.setPlayLists(playlistsChannelUID, playlists);
443 updateStatus(ThingStatus.ONLINE);
445 } catch (final SpotifyAuthorizationException e) {
446 logger.debug("Authorization error during polling: ", e);
448 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
450 devicesCache.invalidateValue();
451 } catch (final SpotifyException e) {
452 logger.info("Spotify returned an error during polling: {}", e.getMessage());
454 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
455 } catch (final RuntimeException e) {
456 // This only should catch RuntimeException as the apiCall don't throw other exceptions.
457 logger.info("Unexpected error during polling status, please report if this keeps occurring: ", e);
459 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
466 * Cancels all running schedulers.
468 private synchronized void cancelSchedulers() {
469 if (pollingFuture != null) {
470 pollingFuture.cancel(true);
472 progressUpdater.cancelProgressScheduler();
476 public void onAccessTokenResponse(@Nullable AccessTokenResponse tokenResponse) {
477 updateChannelState(CHANNEL_ACCESSTOKEN,
478 new StringType(tokenResponse == null ? null : tokenResponse.getAccessToken()));
482 * Updates the status of all child Spotify Device Things.
484 * @param spotifyDevices list of Spotify devices
485 * @param playing true if the current active device is playing
487 private void updateDevicesStatus(List<Device> spotifyDevices, boolean playing) {
488 getThing().getThings().stream() //
489 .filter(thing -> thing.getHandler() instanceof SpotifyDeviceHandler) //
490 .filter(thing -> !spotifyDevices.stream()
491 .anyMatch(sd -> ((SpotifyDeviceHandler) thing.getHandler()).updateDeviceStatus(sd, playing)))
492 .forEach(thing -> ((SpotifyDeviceHandler) thing.getHandler()).setStatusGone());
496 * Update the player data.
498 * @param playerInfo The object with the current playing context
499 * @param playlists List of available playlists
501 private void updatePlayerInfo(CurrentlyPlayingContext playerInfo, List<Playlist> playlists) {
502 updateChannelState(CHANNEL_TRACKPLAYER, playerInfo.isPlaying() ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
503 updateChannelState(CHANNEL_DEVICESHUFFLE, OnOffType.from(playerInfo.isShuffleState()));
504 updateChannelState(CHANNEL_TRACKREPEAT, playerInfo.getRepeatState());
506 final boolean hasItem = playerInfo.getItem() != null;
507 final Item item = hasItem ? playerInfo.getItem() : EMPTY_ITEM;
508 final State trackId = valueOrEmpty(item.getId());
510 progressUpdater.updateProgress(active, playerInfo.isPlaying(), item.getDurationMs(),
511 playerInfo.getProgressMs());
512 if (!lastTrackId.equals(trackId)) {
513 lastTrackId = trackId;
514 updateChannelState(CHANNEL_PLAYED_TRACKDURATION_MS, new DecimalType(item.getDurationMs()));
515 final String formattedProgress;
516 synchronized (MUSIC_TIME_FORMAT) {
517 // synchronize because SimpleDateFormat is not thread safe
518 formattedProgress = MUSIC_TIME_FORMAT.format(new Date(item.getDurationMs()));
520 updateChannelState(CHANNEL_PLAYED_TRACKDURATION_FMT, formattedProgress);
522 updateChannelsPlayList(playerInfo, playlists);
523 updateChannelState(CHANNEL_PLAYED_TRACKID, lastTrackId);
524 updateChannelState(CHANNEL_PLAYED_TRACKHREF, valueOrEmpty(item.getHref()));
525 updateChannelState(CHANNEL_PLAYED_TRACKURI, valueOrEmpty(item.getUri()));
526 updateChannelState(CHANNEL_PLAYED_TRACKNAME, valueOrEmpty(item.getName()));
527 updateChannelState(CHANNEL_PLAYED_TRACKTYPE, valueOrEmpty(item.getType()));
528 updateChannelState(CHANNEL_PLAYED_TRACKNUMBER, valueOrZero(item.getTrackNumber()));
529 updateChannelState(CHANNEL_PLAYED_TRACKDISCNUMBER, valueOrZero(item.getDiscNumber()));
530 updateChannelState(CHANNEL_PLAYED_TRACKPOPULARITY, valueOrZero(item.getPopularity()));
531 updateChannelState(CHANNEL_PLAYED_TRACKEXPLICIT, OnOffType.from(item.isExplicit()));
533 final boolean hasAlbum = hasItem && item.getAlbum() != null;
534 final Album album = hasAlbum ? item.getAlbum() : EMPTY_ALBUM;
535 updateChannelState(CHANNEL_PLAYED_ALBUMID, valueOrEmpty(album.getId()));
536 updateChannelState(CHANNEL_PLAYED_ALBUMHREF, valueOrEmpty(album.getHref()));
537 updateChannelState(CHANNEL_PLAYED_ALBUMURI, valueOrEmpty(album.getUri()));
538 updateChannelState(CHANNEL_PLAYED_ALBUMNAME, valueOrEmpty(album.getName()));
539 updateChannelState(CHANNEL_PLAYED_ALBUMTYPE, valueOrEmpty(album.getType()));
540 albumUpdater.updateAlbumImage(album);
542 final Artist firstArtist = hasItem && item.getArtists() != null && !item.getArtists().isEmpty()
543 ? item.getArtists().get(0)
546 updateChannelState(CHANNEL_PLAYED_ARTISTID, valueOrEmpty(firstArtist.getId()));
547 updateChannelState(CHANNEL_PLAYED_ARTISTHREF, valueOrEmpty(firstArtist.getHref()));
548 updateChannelState(CHANNEL_PLAYED_ARTISTURI, valueOrEmpty(firstArtist.getUri()));
549 updateChannelState(CHANNEL_PLAYED_ARTISTNAME, valueOrEmpty(firstArtist.getName()));
550 updateChannelState(CHANNEL_PLAYED_ARTISTTYPE, valueOrEmpty(firstArtist.getType()));
552 final Device device = playerInfo.getDevice() == null ? EMPTY_DEVICE : playerInfo.getDevice();
553 // Only update lastKnownDeviceId if it has a value, otherwise keep old value.
554 if (device.getId() != null) {
555 lastKnownDeviceId = device.getId();
556 updateChannelState(CHANNEL_DEVICEID, valueOrEmpty(lastKnownDeviceId));
557 updateChannelState(CHANNEL_DEVICES, valueOrEmpty(lastKnownDeviceId));
558 updateChannelState(CHANNEL_DEVICENAME, valueOrEmpty(device.getName()));
560 lastKnownDeviceActive = device.isActive();
561 updateChannelState(CHANNEL_DEVICEACTIVE, OnOffType.from(lastKnownDeviceActive));
562 updateChannelState(CHANNEL_DEVICETYPE, valueOrEmpty(device.getType()));
564 // experienced situations where volume seemed to be undefined...
565 updateChannelState(CHANNEL_DEVICEVOLUME,
566 device.getVolumePercent() == null ? UnDefType.UNDEF : new PercentType(device.getVolumePercent()));
569 private void updateChannelsPlayList(CurrentlyPlayingContext playerInfo, @Nullable List<Playlist> playlists) {
570 final Context context = playerInfo.getContext();
571 final String playlistId;
572 String playlistName = "";
574 if (context != null && "playlist".equals(context.getType())) {
575 playlistId = "spotify:playlist" + context.getUri().substring(context.getUri().lastIndexOf(':'));
577 if (playlists != null) {
578 final Optional<Playlist> optionalPlaylist = playlists.stream()
579 .filter(pl -> playlistId.equals(pl.getUri())).findFirst();
581 playlistName = optionalPlaylist.isPresent() ? optionalPlaylist.get().getName() : "";
586 updateChannelState(CHANNEL_PLAYLISTS, valueOrEmpty(playlistId));
587 updateChannelState(CHANNEL_PLAYLISTNAME, valueOrEmpty(playlistName));
591 * @param value Integer value to return as {@link DecimalType}
592 * @return value as {@link DecimalType} or ZERO if the value is null
594 private DecimalType valueOrZero(@Nullable Integer value) {
595 return value == null ? DecimalType.ZERO : new DecimalType(value);
599 * @param value String value to return as {@link StringType}
600 * @return value as {@link StringType} or EMPTY if the value is null or empty
602 private StringType valueOrEmpty(@Nullable String value) {
603 return value == null || value.isEmpty() ? StringType.EMPTY : new StringType(value);
607 * Convenience method to update the channel state as {@link StringType} with a {@link String} value
609 * @param channelId id of the channel to update
610 * @param value String value to set as {@link StringType}
612 private void updateChannelState(String channelId, String value) {
613 updateChannelState(channelId, new StringType(value));
617 * Convenience method to update the channel state but only if the channel is linked.
619 * @param channelId id of the channel to update
620 * @param state State to set on the channel
622 private void updateChannelState(String channelId, State state) {
623 final Channel channel = thing.getChannel(channelId);
625 if (channel != null && isLinked(channel.getUID())) {
626 updateState(channel.getUID(), state);
631 * Class that manages the current progress of a track. The actual progress is tracked with the user specified
632 * interval, This class fills the in between seconds so the status will show a continues updating of the progress.
634 * @author Hilbrand Bouwkamp - Initial contribution
636 private class ProgressUpdater {
637 private long progress;
638 private long duration;
639 private @NonNullByDefault({}) Future<?> progressFuture;
642 * Updates the progress with its actual values as provided by Spotify. Based on if the track is running or not
643 * update the progress scheduler.
645 * @param active true if this instance is not disposed
646 * @param playing true if the track if playing
647 * @param duration duration of the track
648 * @param progress current progress of the track
650 public synchronized void updateProgress(boolean active, boolean playing, long duration, long progress) {
651 this.duration = duration;
652 setProgress(progress);
653 if (!playing || !active) {
654 cancelProgressScheduler();
655 } else if ((progressFuture == null || progressFuture.isCancelled()) && active) {
656 progressFuture = scheduler.scheduleWithFixedDelay(this::incrementProgress, PROGRESS_STEP_S,
657 PROGRESS_STEP_S, TimeUnit.SECONDS);
662 * Increments the progress with PROGRESS_STEP_MS, but limits it on the duration.
664 private synchronized void incrementProgress() {
665 setProgress(Math.min(duration, progress + PROGRESS_STEP_MS));
669 * Sets the progress on the channels.
671 * @param progress progress value to set
673 private void setProgress(long progress) {
674 this.progress = progress;
675 final String formattedProgress;
677 synchronized (MUSIC_TIME_FORMAT) {
678 formattedProgress = MUSIC_TIME_FORMAT.format(new Date(progress));
680 updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_MS, new DecimalType(progress));
681 updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_FMT, formattedProgress);
685 * Cancels the progress future.
687 public synchronized void cancelProgressScheduler() {
688 if (progressFuture != null) {
689 progressFuture.cancel(true);
690 progressFuture = null;
696 * Class to manager Album image updates.
698 * @author Hilbrand Bouwkamp - Initial contribution
700 private class AlbumUpdater {
701 private String lastAlbumImageUrl = "";
704 * Updates the album image status, but only refreshes the image when a new image should be shown.
706 * @param album album data
708 public void updateAlbumImage(Album album) {
709 final Channel imageChannel = thing.getChannel(CHANNEL_PLAYED_ALBUMIMAGE);
710 final List<Image> images = album.getImages();
712 // Update album image url channel
713 final String albumImageUrlUrl = albumUrl(images, imageChannelAlbumImageUrlIndex);
714 updateChannelState(CHANNEL_PLAYED_ALBUMIMAGEURL,
715 albumImageUrlUrl == null ? UnDefType.UNDEF : StringType.valueOf(albumImageUrlUrl));
717 // Trigger image refresh of album image channel
718 final String albumImageUrl = albumUrl(images, imageChannelAlbumImageIndex);
719 if (imageChannel != null && albumImageUrl != null) {
720 if (!lastAlbumImageUrl.equals(albumImageUrl)) {
721 // Download the cover art in a different thread to not delay the other operations
722 lastAlbumImageUrl = albumImageUrl;
723 refreshAlbumImage(imageChannel.getUID());
724 } // else album image still the same so nothing to do
726 lastAlbumImageUrl = "";
727 updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, UnDefType.UNDEF);
731 private @Nullable String albumUrl(@Nullable List<Image> images, int index) {
732 return images == null || index >= images.size() || images.isEmpty() ? null : images.get(index).getUrl();
736 * Refreshes the image asynchronously, but only downloads the image if the channel is linked to avoid
737 * unnecessary downloading of the image.
739 * @param channelUID UID of the album channel
741 public void refreshAlbumImage(ChannelUID channelUID) {
742 if (!lastAlbumImageUrl.isEmpty() && isLinked(channelUID)) {
743 final String imageUrl = lastAlbumImageUrl;
744 scheduler.execute(() -> refreshAlbumAsynced(channelUID, imageUrl));
748 private void refreshAlbumAsynced(ChannelUID channelUID, String imageUrl) {
750 if (lastAlbumImageUrl.equals(imageUrl) && isLinked(channelUID)) {
751 final RawType image = HttpUtil.downloadImage(imageUrl, true, MAX_IMAGE_SIZE);
752 updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, image == null ? UnDefType.UNDEF : image);
754 } catch (final RuntimeException e) {
755 logger.debug("Async call to refresh Album image failed: ", e);