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.*;
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;
26 import java.util.Optional;
27 import java.util.concurrent.Future;
28 import java.util.concurrent.TimeUnit;
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;
79 * The {@link SpotifyBridgeHandler} is the main class to manage Spotify WebAPI connection and update status of things.
81 * @author Andreas Stenlund - Initial contribution
82 * @author Hilbrand Bouwkamp - Just a lot of refactoring
85 public class SpotifyBridgeHandler extends BaseBridgeHandler
86 implements SpotifyAccountHandler, AccessTokenRefreshListener {
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;
96 * Only poll playlist once per hour (or when refresh is called).
98 private static final Duration POLL_PLAY_LIST_HOURS = Duration.ofHours(1);
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.
103 private static final int POLL_DELAY_AFTER_COMMAND_S = 2;
105 * Time between track progress status updates.
107 private static final int PROGRESS_STEP_S = 1;
108 private static final long PROGRESS_STEP_MS = TimeUnit.SECONDS.toMillis(PROGRESS_STEP_S);
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;
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;
132 * Keep track if this instance is disposed. This avoids new scheduling to be started after dispose is called.
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;
141 public SpotifyBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient,
142 SpotifyDynamicStateDescriptionProvider spotifyDynamicStateDescriptionProvider) {
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);
152 public Collection<Class<? extends ThingHandlerService>> getServices() {
153 return List.of(SpotifyActions.class, SpotifyDeviceDiscoveryService.class);
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);
163 case CHANNEL_PLAYLISTS:
164 playlistCache.invalidateValue();
166 case CHANNEL_ACCESSTOKEN:
167 onAccessTokenResponse(getAccessTokenResponse());
170 lastTrackId = StringType.EMPTY;
175 if (handleCommand != null
176 && handleCommand.handleCommand(channelUID, command, lastKnownDeviceActive, lastKnownDeviceId)) {
177 scheduler.schedule(this::scheduledPollingRestart, POLL_DELAY_AFTER_COMMAND_S, TimeUnit.SECONDS);
179 } catch (final SpotifyException e) {
180 logger.debug("Handle Spotify command failed: ", e);
181 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
187 public void dispose() {
190 OAuthClientService oAuthService = this.oAuthService;
191 if (oAuthService != null) {
192 oAuthService.removeAccessTokenRefreshListener(this);
193 oAuthFactory.ungetOAuthService(thing.getUID().getAsString());
194 this.oAuthService = null;
199 public void handleRemoval() {
200 oAuthFactory.deleteServiceAndAccessToken(thing.getUID().getAsString());
201 super.handleRemoval();
205 public ThingUID getUID() {
206 return thing.getUID();
210 public String getLabel() {
211 return thing.getLabel() == null ? "" : thing.getLabel().toString();
215 public boolean isAuthorized() {
216 final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
218 return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
219 && accessTokenResponse.getRefreshToken() != null;
222 private @Nullable AccessTokenResponse getAccessTokenResponse() {
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);
233 public String getUser() {
234 return thing.getProperties().getOrDefault(PROPERTY_SPOTIFY_USER, "");
238 public boolean isOnline() {
239 return thing.getStatus() == ThingStatus.ONLINE;
243 public SpotifyApi getSpotifyApi() {
248 public boolean equalsThingUID(String thingUID) {
249 return getThing().getUID().getAsString().equals(thingUID);
253 public String formatAuthorizationUrl(String redirectUri) {
255 OAuthClientService oAuthService = this.oAuthService;
256 if (oAuthService == null) {
257 throw new OAuthException("OAuth service is not initialized");
259 return oAuthService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString());
260 } catch (final OAuthException e) {
261 logger.debug("Error constructing AuthorizationUrl: ", e);
267 public String authorize(String redirectUri, String reqCode) {
269 OAuthClientService oAuthService = this.oAuthService;
270 if (oAuthService == null) {
271 throw new OAuthException("OAuth service is not initialized");
273 logger.debug("Make call to Spotify to get access token.");
274 final AccessTokenResponse credentials = oAuthService.getAccessTokenResponseByAuthorizationCode(reqCode,
276 final String user = updateProperties(credentials);
277 logger.debug("Authorized for user: {}", 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);
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();
294 props.put(PROPERTY_SPOTIFY_USER, user);
295 updateProperties(props);
302 public void initialize() {
303 updateStatus(ThingStatus.UNKNOWN);
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);
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);
321 // Start with update status by calling Spotify. If no credentials available no polling should be started.
322 scheduler.execute(() -> {
327 imageChannelAlbumImageIndex = getIntChannelParameter(CHANNEL_PLAYED_ALBUMIMAGE, CHANNEL_CONFIG_IMAGE_INDEX, 0);
328 imageChannelAlbumImageUrlIndex = getIntChannelParameter(CHANNEL_PLAYED_ALBUMIMAGEURL,
329 CHANNEL_CONFIG_IMAGE_INDEX, 0);
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);
336 return index == null ? _default : index.intValue();
340 public List<Device> listDevices() {
341 final List<Device> listDevices = devicesCache.getValue();
343 return listDevices == null ? Collections.emptyList() : listDevices;
347 * Scheduled method to restart polling in case polling is not running.
349 private void scheduledPollingRestart() {
350 synchronized (pollSynchronization) {
352 final boolean pollingNotRunning = pollingFuture == null || pollingFuture.isCancelled();
355 if (pollStatus() && pollingNotRunning) {
358 } catch (final RuntimeException e) {
359 logger.debug("Restarting polling failed: ", e);
365 * This method initiates a new thread for polling the available Spotify Connect devices and update the player
368 private void startPolling() {
369 synchronized (pollSynchronization) {
373 pollingFuture = scheduler.scheduleWithFixedDelay(this::pollStatus, 0, configuration.refreshPeriod,
379 private void expireCache() {
380 playingContextCache.invalidateValue();
381 playlistCache.invalidateValue();
382 devicesCache.invalidateValue();
386 * Calls the Spotify API and collects user data. Returns true if method completed without errors.
388 * @return true if method completed without errors.
390 private boolean pollStatus() {
391 synchronized (pollSynchronization) {
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;
402 // Collect devices and populate selection with available devices.
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());
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);
419 updateStatus(ThingStatus.ONLINE);
421 } catch (final SpotifyAuthorizationException e) {
422 logger.debug("Authorization error during polling: ", e);
424 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
426 devicesCache.invalidateValue();
427 } catch (final SpotifyException e) {
428 logger.info("Spotify returned an error during polling: {}", e.getMessage());
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);
435 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
442 * Cancels all running schedulers.
444 private synchronized void cancelSchedulers() {
445 if (pollingFuture != null) {
446 pollingFuture.cancel(true);
448 progressUpdater.cancelProgressScheduler();
452 public void onAccessTokenResponse(@Nullable AccessTokenResponse tokenResponse) {
453 updateChannelState(CHANNEL_ACCESSTOKEN,
454 new StringType(tokenResponse == null ? null : tokenResponse.getAccessToken()));
458 * Updates the status of all child Spotify Device Things.
460 * @param spotifyDevices list of Spotify devices
461 * @param playing true if the current active device is playing
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());
472 * Update the player data.
474 * @param playerInfo The object with the current playing context
475 * @param playlists List of available playlists
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());
482 final boolean hasItem = playerInfo.getItem() != null;
483 final Item item = hasItem ? playerInfo.getItem() : EMPTY_ITEM;
484 final State trackId = valueOrEmpty(item.getId());
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()));
496 updateChannelState(CHANNEL_PLAYED_TRACKDURATION_FMT, formattedProgress);
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()));
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);
518 final Artist firstArtist = hasItem && item.getArtists() != null && !item.getArtists().isEmpty()
519 ? item.getArtists().get(0)
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()));
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()));
536 lastKnownDeviceActive = device.isActive();
537 updateChannelState(CHANNEL_DEVICEACTIVE, OnOffType.from(lastKnownDeviceActive));
538 updateChannelState(CHANNEL_DEVICETYPE, valueOrEmpty(device.getType()));
540 // experienced situations where volume seemed to be undefined...
541 updateChannelState(CHANNEL_DEVICEVOLUME,
542 device.getVolumePercent() == null ? UnDefType.UNDEF : new PercentType(device.getVolumePercent()));
545 private void updateChannelsPlayList(CurrentlyPlayingContext playerInfo, @Nullable List<Playlist> playlists) {
546 final Context context = playerInfo.getContext();
547 final String playlistId;
548 String playlistName = "";
550 if (context != null && "playlist".equals(context.getType())) {
551 playlistId = "spotify:playlist" + context.getUri().substring(context.getUri().lastIndexOf(':'));
553 if (playlists != null) {
554 final Optional<Playlist> optionalPlaylist = playlists.stream()
555 .filter(pl -> playlistId.equals(pl.getUri())).findFirst();
557 playlistName = optionalPlaylist.isPresent() ? optionalPlaylist.get().getName() : "";
562 updateChannelState(CHANNEL_PLAYLISTS, valueOrEmpty(playlistId));
563 updateChannelState(CHANNEL_PLAYLISTNAME, valueOrEmpty(playlistName));
567 * @param value Integer value to return as {@link DecimalType}
568 * @return value as {@link DecimalType} or ZERO if the value is null
570 private DecimalType valueOrZero(@Nullable Integer value) {
571 return value == null ? DecimalType.ZERO : new DecimalType(value);
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
578 private StringType valueOrEmpty(@Nullable String value) {
579 return value == null || value.isEmpty() ? StringType.EMPTY : new StringType(value);
583 * Convenience method to update the channel state as {@link StringType} with a {@link String} value
585 * @param channelId id of the channel to update
586 * @param value String value to set as {@link StringType}
588 private void updateChannelState(String channelId, String value) {
589 updateChannelState(channelId, new StringType(value));
593 * Convenience method to update the channel state but only if the channel is linked.
595 * @param channelId id of the channel to update
596 * @param state State to set on the channel
598 private void updateChannelState(String channelId, State state) {
599 final Channel channel = thing.getChannel(channelId);
601 if (channel != null && isLinked(channel.getUID())) {
602 updateState(channel.getUID(), state);
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.
610 * @author Hilbrand Bouwkamp - Initial contribution
612 private class ProgressUpdater {
613 private long progress;
614 private long duration;
615 private @NonNullByDefault({}) Future<?> progressFuture;
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.
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
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);
638 * Increments the progress with PROGRESS_STEP_MS, but limits it on the duration.
640 private synchronized void incrementProgress() {
641 setProgress(Math.min(duration, progress + PROGRESS_STEP_MS));
645 * Sets the progress on the channels.
647 * @param progress progress value to set
649 private void setProgress(long progress) {
650 this.progress = progress;
651 final String formattedProgress;
653 synchronized (MUSIC_TIME_FORMAT) {
654 formattedProgress = MUSIC_TIME_FORMAT.format(new Date(progress));
656 updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_MS, new DecimalType(progress));
657 updateChannelState(CHANNEL_PLAYED_TRACKPROGRESS_FMT, formattedProgress);
661 * Cancels the progress future.
663 public synchronized void cancelProgressScheduler() {
664 if (progressFuture != null) {
665 progressFuture.cancel(true);
666 progressFuture = null;
672 * Class to manager Album image updates.
674 * @author Hilbrand Bouwkamp - Initial contribution
676 private class AlbumUpdater {
677 private String lastAlbumImageUrl = "";
680 * Updates the album image status, but only refreshes the image when a new image should be shown.
682 * @param album album data
684 public void updateAlbumImage(Album album) {
685 final Channel imageChannel = thing.getChannel(CHANNEL_PLAYED_ALBUMIMAGE);
686 final List<Image> images = album.getImages();
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));
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
702 lastAlbumImageUrl = "";
703 updateChannelState(CHANNEL_PLAYED_ALBUMIMAGE, UnDefType.UNDEF);
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();
712 * Refreshes the image asynchronously, but only downloads the image if the channel is linked to avoid
713 * unnecessary downloading of the image.
715 * @param channelUID UID of the album channel
717 public void refreshAlbumImage(ChannelUID channelUID) {
718 if (!lastAlbumImageUrl.isEmpty() && isLinked(channelUID)) {
719 final String imageUrl = lastAlbumImageUrl;
720 scheduler.execute(() -> refreshAlbumAsynced(channelUID, imageUrl));
724 private void refreshAlbumAsynced(ChannelUID channelUID, String imageUrl) {
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);
730 } catch (final RuntimeException e) {
731 logger.debug("Async call to refresh Album image failed: ", e);