xyz.gianlu.librespot.player.StateWrapper Maven / Gradle / Ivy
The newest version!
/*
* Copyright 2021 devgianlu
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.gianlu.librespot.player;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.protobuf.ByteString;
import com.google.protobuf.TextFormat;
import com.spotify.connectstate.Connect;
import com.spotify.connectstate.Player.*;
import com.spotify.context.ContextOuterClass.Context;
import com.spotify.context.ContextPageOuterClass.ContextPage;
import com.spotify.context.ContextTrackOuterClass.ContextTrack;
import com.spotify.metadata.Metadata;
import com.spotify.playlist4.Playlist4ApiProto;
import com.spotify.playlist4.Playlist4ApiProto.PlaylistModificationInfo;
import com.spotify.transfer.PlaybackOuterClass;
import com.spotify.transfer.QueueOuterClass;
import com.spotify.transfer.SessionOuterClass;
import com.spotify.transfer.TransferStateOuterClass;
import okhttp3.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import xyz.gianlu.librespot.audio.MetadataWrapper;
import xyz.gianlu.librespot.common.FisherYatesShuffle;
import xyz.gianlu.librespot.common.ProtoUtils;
import xyz.gianlu.librespot.common.Utils;
import xyz.gianlu.librespot.core.Session;
import xyz.gianlu.librespot.core.TimeProvider;
import xyz.gianlu.librespot.dealer.DealerClient;
import xyz.gianlu.librespot.mercury.MercuryClient;
import xyz.gianlu.librespot.metadata.*;
import xyz.gianlu.librespot.player.contexts.AbsSpotifyContext;
import xyz.gianlu.librespot.player.state.DeviceStateHandler;
import xyz.gianlu.librespot.player.state.DeviceStateHandler.PlayCommandHelper;
import xyz.gianlu.librespot.player.state.RestrictionsManager;
import xyz.gianlu.librespot.player.state.RestrictionsManager.Action;
import java.io.Closeable;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* @author Gianlu
*/
public class StateWrapper implements DeviceStateHandler.Listener, DealerClient.MessageListener, Closeable {
private static final Logger LOGGER = LoggerFactory.getLogger(StateWrapper.class);
static {
try {
ProtoUtils.overrideDefaultValue(ContextIndex.getDescriptor().findFieldByName("track"), -1);
ProtoUtils.overrideDefaultValue(com.spotify.connectstate.Player.PlayerState.getDescriptor().findFieldByName("position_as_of_timestamp"), -1);
ProtoUtils.overrideDefaultValue(ContextPlayerOptions.getDescriptor().findFieldByName("shuffling_context"), "");
ProtoUtils.overrideDefaultValue(ContextPlayerOptions.getDescriptor().findFieldByName("repeating_track"), "");
ProtoUtils.overrideDefaultValue(ContextPlayerOptions.getDescriptor().findFieldByName("repeating_context"), "");
} catch (IllegalAccessException | NoSuchFieldException ex) {
LOGGER.warn("Failed changing default value!", ex);
}
}
private final PlayerState.Builder state;
private final Session session;
private final Player player;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final DeviceStateHandler device;
private AbsSpotifyContext context;
private PagesLoader pages;
private TracksKeeper tracksKeeper;
private Future> volumeChangedFuture = null;
StateWrapper(@NotNull Session session, @NotNull Player player, @NotNull PlayerConfiguration conf) {
this.session = session;
this.player = player;
this.device = new DeviceStateHandler(session, conf);
this.state = initState(PlayerState.newBuilder());
device.addListener(this);
session.dealer().addMessageListener(this, "spotify:user:attributes:update", "hm://playlist/", "hm://collection/collection/" + session.username() + "/json");
}
@NotNull
private static PlayerState.Builder initState(@NotNull PlayerState.Builder builder) {
return builder.setPlaybackSpeed(1.0)
.clearSessionId().clearPlaybackId()
.setSuppressions(Suppressions.newBuilder().build())
.setContextRestrictions(Restrictions.newBuilder().build())
.setOptions(ContextPlayerOptions.newBuilder()
.setRepeatingContext(false)
.setShufflingContext(false)
.setRepeatingTrack(false))
.setPositionAsOfTimestamp(0)
.setPosition(0)
.setIsPlaying(false);
}
@NotNull
public static String generatePlaybackId(@NotNull Random random) {
byte[] bytes = new byte[16];
random.nextBytes(bytes);
bytes[0] = 1;
return Utils.bytesToHex(bytes).toLowerCase();
}
@NotNull
private static String generateSessionId(@NotNull Random random) {
byte[] bytes = new byte[16];
random.nextBytes(bytes);
return Utils.toBase64NoPadding(bytes);
}
private boolean shouldPlay(@NotNull ContextTrack track) {
if (!track.getMetadataOrDefault("force_remove_reasons", "").isEmpty())
return false;
if (track.hasUri()) {
if (PlayableId.isDelimiter(track.getUri()))
return false;
if (PlayableId.isLocal(track.getUri()))
return false;
}
boolean filterExplicit = "1".equals(session.getUserAttribute("filter-explicit-content"));
if (!filterExplicit) return true;
return !Boolean.parseBoolean(track.getMetadataOrDefault("is_explicit", "false"));
}
private boolean areAllUnplayable(List tracks) {
for (ContextTrack track : tracks)
if (shouldPlay(track))
return false;
return true;
}
boolean isActive() {
return device.isActive();
}
synchronized void setState(boolean playing, boolean paused, boolean buffering) {
if (paused && !playing) throw new IllegalStateException();
else if (buffering && !playing) throw new IllegalStateException();
boolean wasPaused = isPaused();
state.setIsPlaying(playing).setIsPaused(paused).setIsBuffering(buffering);
if (wasPaused && !paused) // Assume the position was set immediately before pausing
setPosition(state.getPositionAsOfTimestamp());
}
synchronized boolean isPaused() {
return state.getIsPlaying() && state.getIsPaused();
}
synchronized void setBuffering(boolean buffering) {
setState(true, state.getIsPaused(), buffering);
}
private boolean isShufflingContext() {
return state.getOptions().getShufflingContext();
}
void setShufflingContext(boolean value) {
if (context == null || tracksKeeper == null) return;
boolean old = isShufflingContext();
state.getOptionsBuilder().setShufflingContext(value && context.restrictions.can(Action.SHUFFLE));
if (old != isShufflingContext()) tracksKeeper.toggleShuffle(isShufflingContext());
}
private boolean isRepeatingContext() {
return state.getOptions().getRepeatingContext();
}
void setRepeatingContext(boolean value) {
if (context == null) return;
state.getOptionsBuilder().setRepeatingContext(value && context.restrictions.can(Action.REPEAT_CONTEXT));
}
private boolean isRepeatingTrack() {
return state.getOptions().getRepeatingTrack();
}
void setRepeatingTrack(boolean value) {
if (context == null) return;
state.getOptionsBuilder().setRepeatingTrack(value && context.restrictions.can(Action.REPEAT_TRACK));
}
@NotNull
public DeviceStateHandler device() {
return device;
}
@Nullable
public String getContextUri() {
return state.getContextUri();
}
@Nullable
public String getContextUrl() {
return state.getContextUrl();
}
private void loadTransforming() {
if (tracksKeeper == null) throw new IllegalStateException();
String url = state.getContextMetadataOrDefault("transforming.url", null);
if (url == null) return;
boolean shuffle = false;
if (state.containsContextMetadata("transforming.shuffle"))
shuffle = Boolean.parseBoolean(state.getContextMetadataOrThrow("transforming.shuffle"));
boolean willRequest = !tracksKeeper.getCurrentTrack().getMetadataMap().containsKey("audio.fwdbtn.fade_overlap"); // I don't see another way to do this
LOGGER.info("Context has transforming! {url: {}, shuffle: {}, willRequest: {}}", url, shuffle, willRequest);
if (!willRequest) return;
JsonObject obj = ProtoUtils.craftContextStateCombo(state, tracksKeeper.tracks);
try (Response resp = session.api().send("POST", HttpUrl.get(url).encodedPath(), null, RequestBody.create(obj.toString(), MediaType.get("application/json")))) {
ResponseBody body = resp.body();
if (resp.code() != 200) {
LOGGER.warn("Failed loading cuepoints! {code: {}, msg: {}, body: {}}", resp.code(), resp.message(), body == null ? null : body.string());
return;
}
if (body != null) updateContext(JsonParser.parseString(body.string()).getAsJsonObject());
else throw new IllegalArgumentException();
LOGGER.debug("Updated context with transforming information!");
} catch (MercuryClient.MercuryException | IOException ex) {
LOGGER.warn("Failed loading cuepoints!", ex);
}
}
@NotNull
private String setContext(@NotNull String uri) {
this.context = AbsSpotifyContext.from(uri);
this.state.setContextUri(uri);
if (!context.isFinite()) {
setRepeatingContext(false);
setShufflingContext(false);
}
this.state.clearContextUrl();
this.state.clearRestrictions();
this.state.clearContextRestrictions();
this.state.clearContextMetadata();
this.pages = PagesLoader.from(session, uri);
this.tracksKeeper = new TracksKeeper();
this.device.setIsActive(true);
return renewSessionId();
}
@NotNull
private String setContext(@NotNull Context ctx) {
String uri = ctx.getUri();
this.context = AbsSpotifyContext.from(uri);
this.state.setContextUri(uri);
if (!context.isFinite()) {
setRepeatingContext(false);
setShufflingContext(false);
}
if (ctx.hasUrl()) this.state.setContextUrl(ctx.getUrl());
else this.state.clearContextUrl();
state.clearContextMetadata();
ProtoUtils.copyOverMetadata(ctx, state);
this.pages = PagesLoader.from(session, ctx);
this.tracksKeeper = new TracksKeeper();
this.device.setIsActive(true);
return renewSessionId();
}
private void updateRestrictions() {
if (context == null) return;
if (tracksKeeper.isPlayingFirst() && !isRepeatingContext())
context.restrictions.disallow(Action.SKIP_PREV, RestrictionsManager.REASON_NO_PREV_TRACK);
else
context.restrictions.allow(Action.SKIP_PREV);
if (tracksKeeper.isPlayingLast() && !isRepeatingContext())
context.restrictions.disallow(Action.SKIP_NEXT, RestrictionsManager.REASON_NO_NEXT_TRACK);
else
context.restrictions.allow(Action.SKIP_NEXT);
state.setRestrictions(context.restrictions.toProto());
state.setContextRestrictions(context.restrictions.toProto());
}
synchronized void updated() {
updateRestrictions();
device.updateState(Connect.PutStateReason.PLAYER_STATE_CHANGED, player.time(), state.build());
}
void addListener(@NotNull DeviceStateHandler.Listener listener) {
device.addListener(listener);
}
public boolean isReady() {
return state.getIsSystemInitiated();
}
@Override
public synchronized void ready() {
state.setIsSystemInitiated(true);
device.updateState(Connect.PutStateReason.NEW_DEVICE, player.time(), state.build());
LOGGER.info("Notified new device (us)!");
}
@Override
public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull DeviceStateHandler.CommandBody data) {
// Not interested
}
@Override
public synchronized void volumeChanged() {
if (volumeChangedFuture != null) volumeChangedFuture.cancel(false);
volumeChangedFuture = scheduler.schedule(() -> device.updateState(Connect.PutStateReason.VOLUME_CHANGED, player.time(), state.build()), 500, TimeUnit.MILLISECONDS);
}
@Override
public synchronized void notActive() {
state.clear();
initState(state);
device.setIsActive(false);
device.updateState(Connect.PutStateReason.BECAME_INACTIVE, player.time(), state.build());
LOGGER.info("Notified inactivity!");
}
synchronized int getVolume() {
return device.getVolume();
}
void setVolume(int val) {
device.setVolume(val);
}
void enrichWithMetadata(@NotNull MetadataWrapper metadata) {
if (metadata.isTrack()) enrichWithMetadata(metadata.track);
else if (metadata.isEpisode()) enrichWithMetadata(metadata.episode);
}
private synchronized void enrichWithMetadata(@NotNull Metadata.Track track) {
if (state.getTrack() == null) throw new IllegalStateException();
if (!ProtoUtils.isTrack(state.getTrack(), track)) {
LOGGER.warn("Failed updating metadata: tracks do not match. {current: {}, expected: {}}", ProtoUtils.toString(state.getTrack()), ProtoUtils.toString(track));
return;
}
if (track.hasDuration()) tracksKeeper.updateTrackDuration(track.getDuration());
ProvidedTrack.Builder builder = state.getTrackBuilder();
if (track.hasPopularity()) builder.putMetadata("popularity", String.valueOf(track.getPopularity()));
if (track.hasExplicit()) builder.putMetadata("is_explicit", String.valueOf(track.getExplicit()));
if (track.hasHasLyrics()) builder.putMetadata("has_lyrics", String.valueOf(track.getHasLyrics()));
if (track.hasName()) builder.putMetadata("title", track.getName());
if (track.hasDiscNumber()) builder.putMetadata("album_disc_number", String.valueOf(track.getDiscNumber()));
for (int i = 0; i < track.getArtistCount(); i++) {
Metadata.Artist artist = track.getArtist(i);
if (artist.hasName()) builder.putMetadata("artist_name" + (i == 0 ? "" : (":" + i)), artist.getName());
if (artist.hasGid()) builder.putMetadata("artist_uri" + (i == 0 ? "" : (":" + i)),
ArtistId.fromHex(Utils.bytesToHex(artist.getGid())).toSpotifyUri());
}
if (track.hasAlbum()) {
Metadata.Album album = track.getAlbum();
if (album.getDiscCount() > 0) {
builder.putMetadata("album_track_count", String.valueOf(ProtoUtils.getTrackCount(album)));
builder.putMetadata("album_disc_count", String.valueOf(album.getDiscCount()));
}
if (album.hasName()) builder.putMetadata("album_title", album.getName());
if (album.hasGid()) builder.putMetadata("album_uri",
AlbumId.fromHex(Utils.bytesToHex(album.getGid())).toSpotifyUri());
for (int i = 0; i < album.getArtistCount(); i++) {
Metadata.Artist artist = album.getArtist(i);
if (artist.hasName())
builder.putMetadata("album_artist_name" + (i == 0 ? "" : (":" + i)), artist.getName());
if (artist.hasGid()) builder.putMetadata("album_artist_uri" + (i == 0 ? "" : (":" + i)),
ArtistId.fromHex(Utils.bytesToHex(artist.getGid())).toSpotifyUri());
}
if (track.hasDiscNumber()) {
for (Metadata.Disc disc : album.getDiscList()) {
if (disc.getNumber() != track.getDiscNumber()) continue;
for (int i = 0; i < disc.getTrackCount(); i++) {
if (disc.getTrack(i).getGid().equals(track.getGid())) {
builder.putMetadata("album_track_number", String.valueOf(i + 1));
break;
}
}
}
}
if (album.hasCoverGroup()) ImageId.putAsMetadata(builder, album.getCoverGroup());
}
ProtoUtils.putFilesAsMetadata(builder, track.getFileList());
state.setTrack(builder.build());
}
private synchronized void enrichWithMetadata(@NotNull Metadata.Episode episode) {
if (state.getTrack() == null) throw new IllegalStateException();
if (!ProtoUtils.isEpisode(state.getTrack(), episode)) {
LOGGER.warn("Failed updating metadata: episodes do not match. {current: {}, expected: {}}", ProtoUtils.toString(state.getTrack()), ProtoUtils.toString(episode));
return;
}
if (episode.hasDuration()) tracksKeeper.updateTrackDuration(episode.getDuration());
ProvidedTrack.Builder builder = state.getTrackBuilder();
if (episode.hasExplicit()) builder.putMetadata("is_explicit", String.valueOf(episode.getExplicit()));
if (episode.hasName()) builder.putMetadata("title", episode.getName());
if (episode.hasShow()) {
Metadata.Show show = episode.getShow();
if (show.hasName()) builder.putMetadata("album_title", show.getName());
if (show.hasCoverImage()) ImageId.putAsMetadata(builder, show.getCoverImage());
}
if (episode.getAudioCount() > 0 && episode.getVideoCount() == 0) {
builder.putMetadata("media.type", "audio");
} else if (episode.getVideoCount() > 0) {
builder.putMetadata("media.type", "video");
}
ProtoUtils.putFilesAsMetadata(builder, episode.getAudioList());
state.setTrack(builder.build());
}
synchronized int getPosition() {
int diff = (int) (TimeProvider.currentTimeMillis() - state.getTimestamp());
return (int) (state.getPositionAsOfTimestamp() + diff);
}
synchronized void setPosition(long pos) {
state.setTimestamp(TimeProvider.currentTimeMillis());
state.setPositionAsOfTimestamp(pos);
state.clearPosition();
}
@NotNull
String loadContextWithTracks(@NotNull String uri, @NotNull List tracks) throws MercuryClient.MercuryException, IOException, AbsSpotifyContext.UnsupportedContextException {
state.setPlayOrigin(PlayOrigin.newBuilder().build());
state.setOptions(ContextPlayerOptions.newBuilder().build());
String sessionId = setContext(uri);
pages.putFirstPage(tracks, uri);
tracksKeeper.initializeStart();
setPosition(0);
loadTransforming();
return sessionId;
}
@NotNull
String loadContext(@NotNull String uri) throws MercuryClient.MercuryException, IOException, AbsSpotifyContext.UnsupportedContextException {
state.setPlayOrigin(PlayOrigin.newBuilder().build());
state.setOptions(ContextPlayerOptions.newBuilder().build());
String sessionId = setContext(uri);
tracksKeeper.initializeStart();
setPosition(0);
loadTransforming();
return sessionId;
}
@NotNull
String transfer(@NotNull TransferStateOuterClass.TransferState cmd) throws AbsSpotifyContext.UnsupportedContextException, IOException, MercuryClient.MercuryException {
SessionOuterClass.Session ps = cmd.getCurrentSession();
state.setPlayOrigin(ProtoUtils.convertPlayOrigin(ps.getPlayOrigin()));
state.setOptions(ProtoUtils.convertPlayerOptions(cmd.getOptions()));
String sessionId = setContext(ps.getContext());
PlaybackOuterClass.Playback pb = cmd.getPlayback();
try {
tracksKeeper.initializeFrom(tracks -> {
for (int i = 0; i < tracks.size(); i++) {
ContextTrack track = tracks.get(i);
if ((track.hasUid() && ps.getCurrentUid().equals(track.getUid())) || ProtoUtils.trackEquals(track, pb.getCurrentTrack()))
return i;
}
return -1;
}, pb.getCurrentTrack(), cmd.getQueue());
} catch (IllegalStateException ex) {
LOGGER.warn("Failed initializing tracks, falling back to start. {uid: {}}", ps.getCurrentUid());
tracksKeeper.initializeStart();
}
state.setPositionAsOfTimestamp(pb.getPositionAsOfTimestamp());
if (pb.getIsPaused()) state.setTimestamp(TimeProvider.currentTimeMillis());
else state.setTimestamp(pb.getTimestamp());
loadTransforming();
return sessionId;
}
@NotNull
String load(@NotNull JsonObject obj) throws AbsSpotifyContext.UnsupportedContextException, IOException, MercuryClient.MercuryException {
state.setPlayOrigin(ProtoUtils.jsonToPlayOrigin(PlayCommandHelper.getPlayOrigin(obj)));
state.setOptions(ProtoUtils.jsonToPlayerOptions(PlayCommandHelper.getPlayerOptionsOverride(obj), state.getOptions()));
String sessionId = setContext(ProtoUtils.jsonToContext(PlayCommandHelper.getContext(obj)));
String trackUid = PlayCommandHelper.getSkipToUid(obj);
String trackUri = PlayCommandHelper.getSkipToUri(obj);
Integer trackIndex = PlayCommandHelper.getSkipToIndex(obj);
try {
if (trackUri != null && !trackUri.isEmpty()) {
tracksKeeper.initializeFrom(tracks -> ProtoUtils.indexOfTrackByUri(tracks, trackUri), null, null);
} else if (trackUid != null && !trackUid.isEmpty()) {
tracksKeeper.initializeFrom(tracks -> ProtoUtils.indexOfTrackByUid(tracks, trackUid), null, null);
} else if (trackIndex != null) {
tracksKeeper.initializeFrom(tracks -> {
if (trackIndex < tracks.size()) return trackIndex;
else return -1;
}, null, null);
} else {
tracksKeeper.initializeStart();
}
} catch (IllegalStateException ex) {
LOGGER.warn("Failed initializing tracks, falling back to start. {uri: {}, uid: {}, index: {}}", trackUri, trackUid, trackIndex);
tracksKeeper.initializeStart();
}
Integer seekTo = PlayCommandHelper.getSeekTo(obj);
if (seekTo != null) setPosition(seekTo);
else setPosition(0);
loadTransforming();
return sessionId;
}
synchronized void updateContext(@NotNull JsonObject obj) {
String uri = obj.get("uri").getAsString();
if (!context.uri().equals(uri)) {
LOGGER.warn("Received update for the wrong context! {context: {}, newUri: {}}", context, uri);
return;
}
ProtoUtils.copyOverMetadata(obj.getAsJsonObject("metadata"), state);
tracksKeeper.updateContext(ProtoUtils.jsonToContextPages(obj.getAsJsonArray("pages")));
}
void skipTo(@NotNull ContextTrack track) {
tracksKeeper.skipTo(track);
setPosition(0);
}
@Nullable
public PlayableId getCurrentPlayable() {
return tracksKeeper == null ? null : PlayableId.from(tracksKeeper.getCurrentTrack());
}
@NotNull
PlayableId getCurrentPlayableOrThrow() {
PlayableId id = getCurrentPlayable();
if (id == null) throw new IllegalStateException();
return id;
}
@NotNull
NextPlayable nextPlayable(boolean autoplayEnabled) {
if (tracksKeeper == null) return NextPlayable.MISSING_TRACKS;
try {
return tracksKeeper.nextPlayable(autoplayEnabled);
} catch (IOException | MercuryClient.MercuryException ex) {
LOGGER.error("Failed fetching next playable.", ex);
return NextPlayable.MISSING_TRACKS;
}
}
@Nullable
PlayableId nextPlayableDoNotSet() {
try {
PlayableIdWithIndex id = tracksKeeper.nextPlayableDoNotSet();
return id == null ? null : id.id;
} catch (IOException | MercuryClient.MercuryException ex) {
LOGGER.error("Failed fetching next playable.", ex);
return null;
}
}
@NotNull
PreviousPlayable previousPlayable() {
if (tracksKeeper == null) return PreviousPlayable.MISSING_TRACKS;
return tracksKeeper.previousPlayable();
}
void removeListener(@NotNull DeviceStateHandler.Listener listener) {
device.removeListener(listener);
}
synchronized void addToQueue(@NotNull ContextTrack track) {
tracksKeeper.addToQueue(track);
}
synchronized void removeFromQueue(@NotNull String uri) {
tracksKeeper.removeFromQueue(uri);
}
synchronized void setQueue(@Nullable List prevTracks, @Nullable List nextTracks) {
tracksKeeper.setQueue(prevTracks, nextTracks);
}
@NotNull
Optional
© 2015 - 2025 Weber Informatics LLC | Privacy Policy