2 * Copyright (c) 2010-2020 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.*;
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;
25 import java.util.Optional;
26 import java.util.concurrent.Future;
27 import java.util.concurrent.TimeUnit;
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;
77 * The {@link SpotifyBridgeHandler} is the main class to manage Spotify WebAPI connection and update status of things.
79 * @author Andreas Stenlund - Initial contribution
80 * @author Hilbrand Bouwkamp - Just a lot of refactoring
83 public class SpotifyBridgeHandler extends BaseBridgeHandler
84 implements SpotifyAccountHandler, AccessTokenRefreshListener {
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;
94 * Only poll playlist once per hour (or when refresh is called).
96 private static final Duration POLL_PLAY_LIST_HOURS = Duration.ofHours(1);
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.
101 private static final int POLL_DELAY_AFTER_COMMAND_S = 2;
103 * Time between track progress status updates.
105 private static final int PROGRESS_STEP_S = 1;
106 private static final long PROGRESS_STEP_MS = TimeUnit.SECONDS.toMillis(PROGRESS_STEP_S);
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;
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;
130 * Keep track if this instance is disposed. This avoids new scheduling to be started after dispose is called.
132 private volatile boolean active;
133 private volatile State lastTrackId = StringType.EMPTY;
134 private volatile String lastKnownDeviceId = "";
135 private volatile boolean lastKnownDeviceActive;
137 public SpotifyBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient,
138 SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider) {
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);
148 public Collection<Class<? extends ThingHandlerService>> getServices() {
149 return Collections.singleton(SpotifyDeviceDiscoveryService.class);
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);
159 case CHANNEL_PLAYLISTS:
160 playlistCache.invalidateValue();
162 case CHANNEL_ACCESSTOKEN:
163 onAccessTokenResponse(getAccessTokenResponse());
166 lastTrackId = StringType.EMPTY;
171 if (handleCommand != null
172 && handleCommand.handleCommand(channelUID, command, lastKnownDeviceActive, lastKnownDeviceId)) {
173 scheduler.schedule(this::scheduledPollingRestart, POLL_DELAY_AFTER_COMMAND_S, TimeUnit.SECONDS);
175 } catch (SpotifyException e) {
176 logger.debug("Handle Spotify command failed: ", e);
177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
183 public void dispose() {
185 if (oAuthService != null) {
186 oAuthService.removeAccessTokenRefreshListener(this);
188 oAuthFactory.ungetOAuthService(thing.getUID().getAsString());
193 public ThingUID getUID() {
194 return thing.getUID();
198 public String getLabel() {
199 return thing.getLabel() == null ? "" : thing.getLabel().toString();
203 public boolean isAuthorized() {
204 final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
206 return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
207 && accessTokenResponse.getRefreshToken() != null;
210 private @Nullable AccessTokenResponse getAccessTokenResponse() {
212 return oAuthService == null ? null : oAuthService.getAccessTokenResponse();
213 } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
214 logger.debug("Exception checking authorization: ", e);
220 public String getUser() {
221 return thing.getProperties().getOrDefault(PROPERTY_SPOTIFY_USER, "");
225 public boolean isOnline() {
226 return thing.getStatus() == ThingStatus.ONLINE;
230 SpotifyApi getSpotifyApi() {
235 public boolean equalsThingUID(String thingUID) {
236 return getThing().getUID().getAsString().equals(thingUID);
240 public String formatAuthorizationUrl(String redirectUri) {
242 return oAuthService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString());
243 } catch (OAuthException e) {
244 logger.debug("Error constructing AuthorizationUrl: ", e);
250 public String authorize(String redirectUri, String reqCode) {
252 logger.debug("Make call to Spotify to get access token.");
253 final AccessTokenResponse credentials = oAuthService.getAccessTokenResponseByAuthorizationCode(reqCode,
255 final String user = updateProperties(credentials);
256 logger.debug("Authorized for user: {}", 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);
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();
273 props.put(PROPERTY_SPOTIFY_USER, user);
274 updateProperties(props);
281 public void initialize() {
282 updateStatus(ThingStatus.UNKNOWN);
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);
294 // Start with update status by calling Spotify. If no credentials available no polling should be started.
295 scheduler.execute(() -> {
303 public List<Device> listDevices() {
304 final List<Device> listDevices = devicesCache.getValue();
306 return listDevices == null ? Collections.emptyList() : listDevices;
310 * Scheduled method to restart polling in case polling is not running.
312 private void scheduledPollingRestart() {
313 synchronized (pollSynchronization) {
315 final boolean pollingNotRunning = pollingFuture == null || pollingFuture.isCancelled();
318 if (pollStatus() && pollingNotRunning) {
321 } catch (RuntimeException e) {
322 logger.debug("Restarting polling failed: ", e);
328 * This method initiates a new thread for polling the available Spotify Connect devices and update the player
331 private void startPolling() {
332 synchronized (pollSynchronization) {
336 pollingFuture = scheduler.scheduleWithFixedDelay(this::pollStatus, 0, configuration.refreshPeriod,
342 private void expireCache() {
343 playingContextCache.invalidateValue();
344 playlistCache.invalidateValue();
345 devicesCache.invalidateValue();
349 * Calls the Spotify API and collects user data. Returns true if method completed without errors.
351 * @return true if method completed without errors.
353 private boolean pollStatus() {
354 synchronized (pollSynchronization) {
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;
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());
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);
382 updateStatus(ThingStatus.ONLINE);
384 } catch (SpotifyAuthorizationException e) {
385 logger.debug("Authorization error during polling: ", e);
387 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
389 devicesCache.invalidateValue();
390 } catch (SpotifyException e) {
391 logger.info("Spotify returned an error during polling: {}", e.getMessage());
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);
398 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
405 * Cancels all running schedulers.
407 private synchronized void cancelSchedulers() {
408 if (pollingFuture != null) {
409 pollingFuture.cancel(true);
411 progressUpdater.cancelProgressScheduler();
415 public void onAccessTokenResponse(@Nullable AccessTokenResponse tokenResponse) {
416 updateChannelState(CHANNEL_ACCESSTOKEN,
417 new StringType(tokenResponse == null ? null : tokenResponse.getAccessToken()));
421 * Updates the status of all child Spotify Device Things.
423 * @param spotifyDevices list of Spotify devices
424 * @param playing true if the current active device is playing
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());
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);
441 * Update the player data.
443 * @param playerInfo The object with the current playing context
444 * @param playlists List of available playlists
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());
451 final boolean hasItem = playerInfo.getItem() != null;
452 final Item item = hasItem ? playerInfo.getItem() : EMPTY_ITEM;
453 final State trackId = valueOrEmpty(item.getId());
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()));
465 updateChannelState(CHANNEL_PLAYED_TRACKDURATION_FMT, formattedProgress);
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()));
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);
487 final Artist firstArtist = hasItem && item.getArtists() != null && !item.getArtists().isEmpty()
488 ? item.getArtists().get(0)
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()));
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()));
505 lastKnownDeviceActive = device.isActive();
506 updateChannelState(CHANNEL_DEVICEACTIVE, OnOffType.from(lastKnownDeviceActive));
507 updateChannelState(CHANNEL_DEVICETYPE, valueOrEmpty(device.getType()));
509 // experienced situations where volume seemed to be undefined...
510 updateChannelState(CHANNEL_DEVICEVOLUME,
511 device.getVolumePercent() == null ? UnDefType.UNDEF : new PercentType(device.getVolumePercent()));
514 private void updateChannelsPlayList(CurrentlyPlayingContext playerInfo, @Nullable List<Playlist> playlists) {
515 final Context context = playerInfo.getContext();
516 final String playlistId;
517 String playlistName = "";
519 if (context != null && "playlist".equals(context.getType())) {
520 playlistId = "spotify:playlist" + context.getUri().substring(context.getUri().lastIndexOf(':'));
522 if (playlists != null) {
523 final Optional<Playlist> optionalPlaylist = playlists.stream()
524 .filter(pl -> playlistId.equals(pl.getUri())).findFirst();
526 playlistName = optionalPlaylist.isPresent() ? optionalPlaylist.get().getName() : "";
531 updateChannelState(CHANNEL_PLAYLISTS, valueOrEmpty(playlistId));
532 updateChannelState(CHANNEL_PLAYLISTNAME, valueOrEmpty(playlistName));
536 * @param value Integer value to return as {@link DecimalType}
537 * @return value as {@link DecimalType} or ZERO if the value is null
539 private DecimalType valueOrZero(@Nullable Integer value) {
540 return value == null ? DecimalType.ZERO : new DecimalType(value);
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
547 private StringType valueOrEmpty(@Nullable String value) {
548 return value == null || value.isEmpty() ? StringType.EMPTY : new StringType(value);
552 * Convenience method to update the channel state as {@link StringType} with a {@link String} value
554 * @param channelId id of the channel to update
555 * @param value String value to set as {@link StringType}
557 private void updateChannelState(String channelId, String value) {
558 updateChannelState(channelId, new StringType(value));
562 * Convenience method to update the channel state but only if the channel is linked.
564 * @param channelId id of the channel to update
565 * @param state State to set on the channel
567 private void updateChannelState(String channelId, State state) {
568 final Channel channel = thing.getChannel(channelId);
570 if (channel != null && isLinked(channel.getUID())) {
571 updateState(channel.getUID(), state);
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.
579 * @author Hilbrand Bouwkamp - Initial contribution
581 private class ProgressUpdater {
582 private long progress;
583 private long duration;
584 private @NonNullByDefault({}) Future<?> progressFuture;
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.
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
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);
607 * Increments the progress with PROGRESS_STEP_MS, but limits it on the duration.
609 private synchronized void incrementProgress() {
610 setProgress(Math.min(duration, progress + PROGRESS_STEP_MS));
614 * Sets the progress on the channels.
616 * @param progress progress value to set
618 private void setProgress(long progress) {
619 this.progress = progress;
620 final String formattedProgress;
622 synchronized (MUSIC_TIME_FORMAT) {
623 formattedProgress = MUSIC_TIME_FORMAT.format(new Date(progress));
625 updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_MS, new DecimalType(progress));
626 updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_FMT, formattedProgress);
630 * Cancels the progress future.
632 public synchronized void cancelProgressScheduler() {
633 if (progressFuture != null) {
634 progressFuture.cancel(true);
635 progressFuture = null;
641 * Class to manager Album image updates.
643 * @author Hilbrand Bouwkamp - Initial contribution
645 private class AlbumUpdater {
646 private String lastAlbumImageUrl = "";
649 * Updates the album image status, but only refreshes the image when a new image should be shown.
651 * @param album album data
653 public void updateAlbumImage(Album album) {
654 final Channel channel = thing.getChannel(CHANNEL_PLAYED_ALBUMIMAGE);
655 final List<Image> images = album.getImages();
657 if (channel != null && images != null && !images.isEmpty()) {
658 final String imageUrl = images.get(0).getUrl();
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());
666 updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, UnDefType.UNDEF);
671 * Refreshes the image asynchronously, but only downloads the image if the channel is linked to avoid
672 * unnecessary downloading of the image.
674 * @param channelUID UID of the album channel
676 public void refreshAlbumImage(ChannelUID channelUID) {
677 if (!lastAlbumImageUrl.isEmpty() && isLinked(channelUID)) {
678 final String imageUrl = lastAlbumImageUrl;
679 scheduler.execute(() -> refreshAlbumAsynced(channelUID, imageUrl));
683 private void refreshAlbumAsynced(ChannelUID channelUID, String imageUrl) {
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);
689 } catch (RuntimeException e) {
690 logger.debug("Async call to refresh Album image failed: ", e);