xyz.gianlu.librespot.player.state.DeviceStateHandler 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.state;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.TextFormat;
import com.spotify.connectstate.Connect;
import com.spotify.connectstate.Player;
import com.spotify.context.ContextTrackOuterClass.ContextTrack;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import xyz.gianlu.librespot.Version;
import xyz.gianlu.librespot.common.AsyncWorker;
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.dealer.DealerClient.RequestResult;
import xyz.gianlu.librespot.mercury.MercuryClient;
import xyz.gianlu.librespot.mercury.MercuryRequests;
import xyz.gianlu.librespot.player.PlayerConfiguration;
import java.io.Closeable;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.*;
import java.util.concurrent.RejectedExecutionException;
/**
* @author Gianlu
*/
public final class DeviceStateHandler implements Closeable, DealerClient.MessageListener, DealerClient.RequestListener {
private static final Logger LOGGER = LoggerFactory.getLogger(DeviceStateHandler.class);
static {
try {
ProtoUtils.overrideDefaultValue(Connect.PutStateRequest.getDescriptor().findFieldByName("has_been_playing_for_ms"), -1);
} catch (IllegalAccessException | NoSuchFieldException ex) {
LOGGER.warn("Failed changing default value!", ex);
}
}
private final Session session;
private final Connect.DeviceInfo.Builder deviceInfo;
private final List listeners = Collections.synchronizedList(new ArrayList<>());
private final Connect.PutStateRequest.Builder putState;
private final AsyncWorker putStateWorker;
private volatile String connectionId = null;
private volatile boolean closing = false;
private String lastCommandSentByDeviceId;
public DeviceStateHandler(@NotNull Session session, @NotNull PlayerConfiguration conf) {
this.session = session;
this.deviceInfo = initializeDeviceInfo(session, conf);
this.putStateWorker = new AsyncWorker<>("put-state-worker", this::putConnectState);
this.putState = Connect.PutStateRequest.newBuilder()
.setMemberType(Connect.MemberType.CONNECT_STATE)
.setDevice(Connect.Device.newBuilder()
.setDeviceInfo(deviceInfo)
.build());
session.dealer().addMessageListener(this, "hm://pusher/v1/connections/", "hm://connect-state/v1/connect/volume", "hm://connect-state/v1/cluster");
session.dealer().addRequestListener(this, "hm://connect-state/v1/");
}
@NotNull
private static Connect.DeviceInfo.Builder initializeDeviceInfo(@NotNull Session session, @NotNull PlayerConfiguration conf) {
return Connect.DeviceInfo.newBuilder()
.setCanPlay(true)
.setVolume(conf.initialVolume)
.setName(session.deviceName())
.setDeviceId(session.deviceId())
.setDeviceType(session.deviceType())
.setDeviceSoftwareVersion(Version.versionString())
.setClientId(MercuryRequests.KEYMASTER_CLIENT_ID)
.setSpircVersion("3.2.6")
.setCapabilities(Connect.Capabilities.newBuilder()
.setCanBePlayer(true).setGaiaEqConnectId(true).setSupportsLogout(true)
.setIsObservable(true).setCommandAcks(true).setSupportsRename(false)
.setSupportsPlaylistV2(true).setIsControllable(true).setSupportsTransferCommand(true)
.setSupportsCommandRequest(true).setVolumeSteps(conf.volumeSteps)
.setSupportsGzipPushes(true).setNeedsFullPlayerState(false)
.addSupportedTypes("audio/episode")
.addSupportedTypes("audio/track")
.build());
}
public void addListener(@NotNull Listener listener) {
listeners.add(listener);
}
public void removeListener(@NotNull Listener listener) {
listeners.remove(listener);
}
private void notifyReady() {
for (Listener listener : new ArrayList<>(listeners))
listener.ready();
}
private void notifyCommand(@NotNull Endpoint endpoint, @NotNull CommandBody data) {
if (listeners.isEmpty()) {
LOGGER.warn("Cannot dispatch command because there are no listeners. {command: {}}", endpoint);
return;
}
for (Listener listener : new ArrayList<>(listeners)) {
try {
listener.command(endpoint, data);
} catch (InvalidProtocolBufferException ex) {
LOGGER.error("Failed parsing command!", ex);
}
}
}
private void notifyVolumeChange() {
for (Listener listener : new ArrayList<>(listeners))
listener.volumeChanged();
}
private void notifyNotActive() {
for (Listener listener : new ArrayList<>(listeners))
listener.notActive();
}
private synchronized void updateConnectionId(@NotNull String newer) {
try {
newer = URLDecoder.decode(newer, "UTF-8");
} catch (UnsupportedEncodingException ignored) {
}
if (connectionId == null || !connectionId.equals(newer)) {
connectionId = newer;
LOGGER.debug("Updated Spotify-Connection-Id: " + connectionId);
notifyReady();
}
}
@Override
public void onMessage(@NotNull String uri, @NotNull Map headers, @NotNull byte[] payload) throws IOException {
if (uri.startsWith("hm://pusher/v1/connections/")) {
updateConnectionId(headers.get("Spotify-Connection-Id"));
} else if (Objects.equals(uri, "hm://connect-state/v1/connect/volume")) {
Connect.SetVolumeCommand cmd = Connect.SetVolumeCommand.parseFrom(payload);
setVolume(cmd.getVolume());
} else if (Objects.equals(uri, "hm://connect-state/v1/cluster")) {
Connect.ClusterUpdate update = Connect.ClusterUpdate.parseFrom(payload);
long now = TimeProvider.currentTimeMillis();
if (LOGGER.isTraceEnabled())
LOGGER.trace("Received cluster update at {}: {}", now, TextFormat.shortDebugString(update));
long ts = update.getCluster().getTimestamp() - 3000; // Workaround
if (!session.deviceId().equals(update.getCluster().getActiveDeviceId()) && isActive() && now > startedPlayingAt() && ts > startedPlayingAt())
notifyNotActive();
} else {
LOGGER.warn("Message left unhandled! {uri: {}}", uri);
}
}
@NotNull
@Override
public RequestResult onRequest(@NotNull String mid, int pid, @NotNull String sender, @NotNull JsonObject command) {
lastCommandSentByDeviceId = sender;
Endpoint endpoint = Endpoint.parse(command.get("endpoint").getAsString());
notifyCommand(endpoint, new CommandBody(command));
return RequestResult.SUCCESS;
}
@Nullable
public synchronized String getLastCommandSentByDeviceId() {
return lastCommandSentByDeviceId;
}
private synchronized long startedPlayingAt() {
return putState.getStartedPlayingAt();
}
public synchronized boolean isActive() {
return putState.getIsActive();
}
public synchronized void setIsActive(boolean active) {
if (active) {
if (!putState.getIsActive()) {
long now = TimeProvider.currentTimeMillis();
putState.setIsActive(true).setStartedPlayingAt(now);
LOGGER.debug("Device is now active. {ts: {}}", now);
}
} else {
putState.setIsActive(false).clearStartedPlayingAt();
}
}
public synchronized void updateState(@NotNull Connect.PutStateReason reason, int playerTime, @NotNull Player.PlayerState state) {
if (connectionId == null) throw new IllegalStateException();
long timestamp = TimeProvider.currentTimeMillis();
if (playerTime == -1)
putState.clearHasBeenPlayingForMs();
else
putState.setHasBeenPlayingForMs(Math.min(playerTime, timestamp - putState.getStartedPlayingAt()));
putState.setPutStateReason(reason)
.setClientSideTimestamp(timestamp)
.getDeviceBuilder()
.setDeviceInfo(deviceInfo)
.setPlayerState(state);
try {
putStateWorker.submit(putState.build());
} catch (RejectedExecutionException ex) {
if (!closing) LOGGER.error("Failed to submit update state task.", ex);
}
}
public synchronized int getVolume() {
return deviceInfo.getVolume();
}
public void setVolume(int val) {
synchronized (this) {
deviceInfo.setVolume(val);
}
notifyVolumeChange();
LOGGER.trace("Update volume. {volume: {}/{}}", val, xyz.gianlu.librespot.player.Player.VOLUME_MAX);
}
@Override
public void close() {
closing = true;
session.dealer().removeMessageListener(this);
session.dealer().removeRequestListener(this);
putStateWorker.close();
listeners.clear();
}
/**
* Performs the network request related to {@link Connect.PutStateRequest}. This MUST be called only from {@link DeviceStateHandler#putStateWorker}.
*
* @param req The {@link Connect.PutStateRequest}
*/
private void putConnectState(@NotNull Connect.PutStateRequest req) {
try {
session.api().putConnectState(connectionId, req);
if (LOGGER.isTraceEnabled()) {
LOGGER.info("Put state. {ts: {}, connId: {}, reason: {}, request: {}}", req.getClientSideTimestamp(),
Utils.truncateMiddle(connectionId, 10), req.getPutStateReason(), TextFormat.shortDebugString(putState));
} else {
LOGGER.info("Put state. {ts: {}, connId: {}, reason: {}}", req.getClientSideTimestamp(),
Utils.truncateMiddle(connectionId, 10), req.getPutStateReason());
}
} catch (IOException | MercuryClient.MercuryException ex) {
LOGGER.error("Failed updating state.", ex);
}
}
public enum Endpoint {
Play("play"), Pause("pause"), Resume("resume"), SeekTo("seek_to"), SkipNext("skip_next"),
SkipPrev("skip_prev"), SetShufflingContext("set_shuffling_context"), SetRepeatingContext("set_repeating_context"),
SetRepeatingTrack("set_repeating_track"), UpdateContext("update_context"), SetQueue("set_queue"),
AddToQueue("add_to_queue"), Transfer("transfer");
private final String val;
Endpoint(@NotNull String val) {
this.val = val;
}
@NotNull
public static Endpoint parse(@NotNull String value) {
for (Endpoint e : values())
if (e.val.equals(value))
return e;
throw new IllegalArgumentException("Unknown endpoint for " + value);
}
}
public interface Listener {
void ready();
void command(@NotNull Endpoint endpoint, @NotNull CommandBody data) throws InvalidProtocolBufferException;
void volumeChanged();
void notActive();
}
public static final class PlayCommandHelper {
private PlayCommandHelper() {
}
@Nullable
public static Boolean isInitiallyPaused(@NotNull JsonObject obj) {
JsonObject options = obj.getAsJsonObject("options");
if (options == null) return null;
JsonElement elm;
if ((elm = options.get("initially_paused")) != null && elm.isJsonPrimitive()) return elm.getAsBoolean();
else return null;
}
@Nullable
public static String getContextUri(JsonObject obj) {
JsonObject context = obj.getAsJsonObject("context");
if (context == null) return null;
JsonElement elm;
if ((elm = context.get("uri")) != null && elm.isJsonPrimitive()) return elm.getAsString();
else return null;
}
@NotNull
public static JsonObject getPlayOrigin(@NotNull JsonObject obj) {
return obj.getAsJsonObject("play_origin");
}
@NotNull
public static JsonObject getContext(@NotNull JsonObject obj) {
return obj.getAsJsonObject("context");
}
@Nullable
public static JsonObject getPlayerOptionsOverride(@NotNull JsonObject obj) {
return obj.getAsJsonObject("options").getAsJsonObject("player_options_override");
}
public static boolean willSkipToSomething(@NotNull JsonObject obj) {
JsonObject parent = obj.getAsJsonObject("options");
if (parent == null) return false;
parent = parent.getAsJsonObject("skip_to");
if (parent == null) return false;
return parent.has("track_uid") || parent.has("track_uri") || parent.has("track_index");
}
@Nullable
public static String getSkipToUid(@NotNull JsonObject obj) {
JsonObject parent = obj.getAsJsonObject("options");
if (parent == null) return null;
parent = parent.getAsJsonObject("skip_to");
if (parent == null) return null;
JsonElement elm;
if ((elm = parent.get("track_uid")) != null && elm.isJsonPrimitive()) return elm.getAsString();
else return null;
}
@Nullable
public static String getSkipToUri(@NotNull JsonObject obj) {
JsonObject parent = obj.getAsJsonObject("options");
if (parent == null) return null;
parent = parent.getAsJsonObject("skip_to");
if (parent == null) return null;
JsonElement elm;
if ((elm = parent.get("track_uri")) != null && elm.isJsonPrimitive()) return elm.getAsString();
else return null;
}
@Nullable
public static List getNextTracks(@NotNull JsonObject obj) {
JsonArray prevTracks = obj.getAsJsonArray("next_tracks");
if (prevTracks == null) return null;
return ProtoUtils.jsonToContextTracks(prevTracks);
}
@Nullable
public static List getPrevTracks(@NotNull JsonObject obj) {
JsonArray prevTracks = obj.getAsJsonArray("prev_tracks");
if (prevTracks == null) return null;
return ProtoUtils.jsonToContextTracks(prevTracks);
}
@Nullable
public static ContextTrack getTrack(@NotNull JsonObject obj) {
JsonObject track = obj.getAsJsonObject("track");
if (track == null) return null;
return ProtoUtils.jsonToContextTrack(track);
}
@Nullable
public static Integer getSkipToIndex(@NotNull JsonObject obj) {
JsonObject parent = obj.getAsJsonObject("options");
if (parent == null) return null;
parent = parent.getAsJsonObject("skip_to");
if (parent == null) return null;
JsonElement elm;
if ((elm = parent.get("track_index")) != null && elm.isJsonPrimitive()) return elm.getAsInt();
else return null;
}
@Nullable
public static Integer getSeekTo(@NotNull JsonObject obj) {
JsonObject options = obj.getAsJsonObject("options");
if (options == null) return null;
JsonElement elm;
if ((elm = options.get("seek_to")) != null && elm.isJsonPrimitive()) return elm.getAsInt();
else return null;
}
}
public static class CommandBody {
private final JsonObject obj;
private final byte[] data;
private final String value;
private CommandBody(@NotNull JsonObject obj) {
this.obj = obj;
if (obj.has("data")) data = Utils.fromBase64(obj.get("data").getAsString());
else data = null;
if (obj.has("value")) value = obj.get("value").getAsString();
else value = null;
}
@NotNull
public JsonObject obj() {
return obj;
}
public byte[] data() {
return data;
}
public String value() {
return value;
}
public Integer valueInt() {
return value == null ? null : Integer.parseInt(value);
}
public Boolean valueBool() {
return value == null ? null : Boolean.parseBoolean(value);
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy