org.deepsymmetry.beatlink.dbserver.ConnectionManager Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of beat-link Show documentation
Show all versions of beat-link Show documentation
A Java library for communicating with Pioneer DJ Link equipment.
package org.deepsymmetry.beatlink.dbserver;
import org.deepsymmetry.beatlink.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Manges connections to dbserver ports on the players, offering sessions that can be used to perform transactions,
* and allowing the connections to close when there are no active sessions.
*
* @author James Elliott
*/
public class ConnectionManager extends LifecycleParticipant {
private static final Logger logger = LoggerFactory.getLogger(ConnectionManager.class);
/**
* An interface for all the kinds of activities that need a connection to the dbserver, so we can keep track
* of how many sessions are in effect, clean up after them, and know when the client is idle and can be closed.
*
* @param the type returned by the activity
*/
public interface ClientTask {
T useClient(Client client) throws Exception;
}
/**
* Keeps track of the clients that are currently active, indexed by player number
*/
private final Map openClients = new ConcurrentHashMap();
/**
* Keeps track of how many tasks are currently using each client.
*/
private final Map useCounts = new ConcurrentHashMap();
/**
* Keeps track of the last time each client was used, so we can time out our connection.
*/
private final Map timestamps = new ConcurrentHashMap();
/**
* How many seconds do we allow an idle connection to stay open?
*/
private final AtomicInteger idleLimit = new AtomicInteger(1);
/**
* Determine how long an idle connection will be kept open for reuse. Once this time has elapsed, the connection
* will be closed. Setting this to zero will close connections immediately after use, which might be somewhat
* inefficient when multiple queries need to happen in a row, since each will require the establishment of a
* new connection. The default value is 1.
*
* @param seconds how many seconds a connection will be kept open while not being used
*
* @throws IllegalArgumentException if a negative value is supplied
*/
public void setIdleLimit(int seconds) {
if (seconds < 0) {
throw new IllegalArgumentException("seconds cannot be negative");
}
idleLimit.set(seconds);
}
/**
* Check how long an idle connection will be kept open for reuse. Once this time has elapsed, the connection
* will be closed. Setting this to zero will close connections immediately after use, which might be somewhat
* inefficient when multiple queries need to happen in a row, since each will require the establishment of a
* new connection. The default value is 1.
*
* @return how many seconds a connection will be kept open while not being used
*/
public int getIdleLimit() {
return idleLimit.get();
}
/**
* Finds or opens a client to talk to the dbserver on the specified player, incrementing its use count.
*
* @param targetPlayer the player number whose database needs to be interacted with
* @param description a short description of the task being performed for error reporting if it fails,
* should be a verb phrase like "requesting track metadata"
*
* @return the communication client for talking to that player, or {@code null} if the player could not be found
*
* @throws IllegalStateException if we can't find the target player or there is no suitable player number for us
* to pretend to be
* @throws IOException if there is a problem communicating
*/
private synchronized Client allocateClient(int targetPlayer, String description) throws IOException {
Client result = openClients.get(targetPlayer);
if (result == null) {
// We need to open a new connection.
final DeviceAnnouncement deviceAnnouncement = DeviceFinder.getInstance().getLatestAnnouncementFrom(targetPlayer);
if (deviceAnnouncement == null) {
throw new IllegalStateException("Player " + targetPlayer + " could not be found " + description);
}
final int dbServerPort = getPlayerDBServerPort(targetPlayer);
if (dbServerPort < 0) {
throw new IllegalStateException("Player " + targetPlayer + " does not have a db server " + description);
}
final byte posingAsPlayerNumber = (byte) chooseAskingPlayerNumber(targetPlayer);
Socket socket = null;
try {
InetSocketAddress address = new InetSocketAddress(deviceAnnouncement.getAddress(), dbServerPort);
socket = new Socket();
socket.connect(address, socketTimeout.get());
socket.setSoTimeout(socketTimeout.get());
result = new Client(socket, targetPlayer, posingAsPlayerNumber);
} catch (IOException e) {
try {
socket.close();
} catch (IOException e2) {
logger.error("Problem closing socket for failed client creation attempt " + description);
}
throw e;
}
openClients.put(targetPlayer, result);
useCounts.put(result, 0);
}
useCounts.put(result, useCounts.get(result) + 1);
return result;
}
/**
* When it is time to actually close a client, do so, and clean up the related data structures.
*
* @param client the client which has been idle for long enough to be closed
*/
private void closeClient(Client client) {
logger.debug("Closing client {}", client);
client.close();
openClients.remove(client.targetPlayer);
useCounts.remove(client);
timestamps.remove(client);
}
/**
* Decrements the client's use count, and makes it eligible for closing if it is no longer in use.
*
* @param client the dbserver connection client which is no longer being used for a task
*/
private synchronized void freeClient(Client client) {
int current = useCounts.get(client);
if (current > 0) {
timestamps.put(client, System.currentTimeMillis()); // Mark that it was used until now.
useCounts.put(client, current - 1);
if ((current == 1) && (idleLimit.get() == 0)) {
closeClient(client); // This was the last use, and we are supposed to immediately close idle clients.
}
} else {
logger.error("Ignoring attempt to free a client that is not allocated: {}", client);
}
}
/**
* Obtain a dbserver client session that can be used to perform some task, call that task with the client,
* then release the client.
*
* @param targetPlayer the player number whose dbserver we wish to communicate with
* @param task the activity that will be performed with exclusive access to a dbserver connection
* @param description a short description of the task being performed for error reporting if it fails,
* should be a verb phrase like "requesting track metadata"
* @param the type that will be returned by the task to be performed
*
* @return the value returned by the completed task
*
* @throws IOException if there is a problem communicating
* @throws Exception from the underlying {@code task}, if any
*/
public T invokeWithClientSession(int targetPlayer, ClientTask task, String description)
throws Exception {
if (!isRunning()) {
throw new IllegalStateException("ConnectionManager is not running, aborting " + description);
}
final Client client = allocateClient(targetPlayer, description);
try {
return task.useClient(client);
} finally {
freeClient(client);
}
}
/**
* Keeps track of the database server ports of all the players we have seen on the network.
*/
private final Map dbServerPorts = new ConcurrentHashMap();
/**
* Look up the database server port reported by a given player. You should not use this port directly; instead
* ask this class for a session to use while you communicate with the database.
*
* @param player the player number of interest
*
* @return the port number on which its database server is running, or -1 if unknown
*
* @throws IllegalStateException if not running
*/
@SuppressWarnings("WeakerAccess")
public int getPlayerDBServerPort(int player) {
ensureRunning();
Integer result = dbServerPorts.get(player);
if (result == null) {
return -1;
}
return result;
}
/**
* Our announcement listener watches for devices to appear on the network so we can ask them for their database
* server port, and when they disappear discards all information about them.
*/
private final DeviceAnnouncementListener announcementListener = new DeviceAnnouncementListener() {
@Override
public void deviceFound(final DeviceAnnouncement announcement) {
new Thread(new Runnable() {
@Override
public void run() {
requestPlayerDBServerPort(announcement);
}
}).start();
}
@Override
public void deviceLost(DeviceAnnouncement announcement) {
setPlayerDBServerPort(announcement.getNumber(), -1);
}
};
/**
* Record the database server port reported by a player.
*
* @param player the player number whose server port has been determined.
* @param port the port number on which the player's database server is running.
*/
private void setPlayerDBServerPort(int player, int port) {
dbServerPorts.put(player, port);
}
/**
* The port on which we can request information about a player, including the port on which its database server
* is running.
*/
private static final int DB_SERVER_QUERY_PORT = 12523;
private static final byte[] DB_SERVER_QUERY_PACKET = {
0x00, 0x00, 0x00, 0x0f,
0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x44, 0x42, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, // RemoteDBServer
0x00
};
/**
* Query a player to determine the port on which its database server is running.
*
* @param announcement the device announcement with which we detected a new player on the network.
*/
private void requestPlayerDBServerPort(DeviceAnnouncement announcement) {
Socket socket = null;
try {
InetSocketAddress address = new InetSocketAddress(announcement.getAddress(), DB_SERVER_QUERY_PORT);
socket = new Socket();
socket.connect(address, socketTimeout.get());
InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream();
socket.setSoTimeout(socketTimeout.get());
os.write(DB_SERVER_QUERY_PACKET);
byte[] response = readResponseWithExpectedSize(is, 2, "database server port query packet");
if (response.length == 2) {
setPlayerDBServerPort(announcement.getNumber(), (int)Util.bytesToNumber(response, 0, 2));
}
} catch (java.net.ConnectException ce) {
logger.info("Player " + announcement.getNumber() +
" doesn't answer rekordbox port queries, connection refused. Won't attempt to request metadata.");
} catch (Exception e) {
logger.warn("Problem requesting database server port number", e);
} finally {
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
logger.warn("Problem closing database server port request socket", e);
}
}
}
}
/**
* The default value we will use for timeouts on opening and reading from sockets.
*/
@SuppressWarnings("WeakerAccess")
public static final int DEFAULT_SOCKET_TIMEOUT = 10000;
/**
* The number of milliseconds after which an attempt to open or read from a socket will fail.
*/
private final AtomicInteger socketTimeout = new AtomicInteger(DEFAULT_SOCKET_TIMEOUT);
/**
* Set how long we will wait for a socket to connect or for a read operation to complete.
* Adjust this if your players or network require it.
*
* @param timeout after how many milliseconds will an attempt to open or read from a socket fail
*/
public void setSocketTimeout(int timeout) {
socketTimeout.set(timeout);
}
/**
* Check how long we will wait for a socket to connect or for a read operation to complete.
* Adjust this if your players or network require it.
*
* @return the number of milliseconds after which an attempt to open or read from a socket will fail
*/
public int getSocketTimeout() {
return socketTimeout.get();
}
/**
* Receive some bytes from the player we are requesting metadata from.
*
* @param is the input stream associated with the player metadata socket.
* @return the bytes read.
*
* @throws IOException if there is a problem reading the response
*/
private byte[] receiveBytes(InputStream is) throws IOException {
byte[] buffer = new byte[8192];
int len = (is.read(buffer));
if (len < 1) {
throw new IOException("receiveBytes read " + len + " bytes.");
}
return Arrays.copyOf(buffer, len);
}
/**
* Receive an expected number of bytes from the player, logging a warning if we get a different number of them.
*
* @param is the input stream associated with the player metadata socket.
* @param size the number of bytes we expect to receive.
* @param description the type of response being processed, for use in the warning message.
* @return the bytes read.
*
* @throws IOException if there is a problem reading the response.
*/
@SuppressWarnings("SameParameterValue")
private byte[] readResponseWithExpectedSize(InputStream is, int size, String description) throws IOException {
byte[] result = receiveBytes(is);
if (result.length != size) {
logger.warn("Expected " + size + " bytes while reading " + description + " response, received " + result.length);
}
return result;
}
/**
* Finds a valid player number that is currently visible but which is different from the one specified, so it can
* be used as the source player for a query being sent to the specified one. If the virtual CDJ is running on an
* acceptable player number (which must be 1-4 to request metadata from an actual CDJ, but can be anything if we
* are talking to rekordbox), uses that, since it will always be safe. Otherwise, tries to borrow the player number
* of another actual CDJ on the network, but we can't do that if the player we want to impersonate has mounted
* a track from the player that we want to talk to.
*
* @param targetPlayer the player to which a metadata query is being sent
*
* @return some other currently active player number, ideally not a real player, but sometimes we have to
*
* @throws IllegalStateException if there is no other player number available to use
*/
private int chooseAskingPlayerNumber(int targetPlayer) {
final int fakeDevice = VirtualCdj.getInstance().getDeviceNumber();
if ((targetPlayer > 15) || (fakeDevice >= 1 && fakeDevice <= 4)) {
return fakeDevice;
}
for (DeviceAnnouncement candidate : DeviceFinder.getInstance().getCurrentDevices()) {
final int realDevice = candidate.getNumber();
if (realDevice != targetPlayer && realDevice >= 1 && realDevice <= 4) {
final DeviceUpdate lastUpdate = VirtualCdj.getInstance().getLatestStatusFor(realDevice);
if (lastUpdate instanceof CdjStatus &&
((CdjStatus) lastUpdate).getTrackSourcePlayer() != targetPlayer) {
return candidate.getNumber();
}
}
}
throw new IllegalStateException("No player number available to query player " + targetPlayer +
". If such a player is present on the network, it must be using Link to play a track from " +
"our target player, so we can't steal its channel number.");
}
/**
* Keep track of whether we are running
*/
private final AtomicBoolean running = new AtomicBoolean(false);
/**
* Check whether we are currently running.
*
* @return true if we are offering shared dbserver sessions
*/
@SuppressWarnings("WeakerAccess")
public boolean isRunning() {
return running.get();
}
private final LifecycleListener lifecycleListener = new LifecycleListener() {
@Override
public void started(LifecycleParticipant sender) {
logger.debug("ConnectionManager does not auto-start when {} starts.", sender);
}
@Override
public void stopped(LifecycleParticipant sender) {
if (isRunning()) {
logger.info("ConnectionManager stopping because DeviceFinder did.");
stop();
}
}
};
/**
* Finds any clients which are not currently in use, and which have been idle for longer than the
* idle timeout, and closes them.
*/
private synchronized void closeIdleClients() {
List candidates = new LinkedList(openClients.values());
logger.debug("Scanning for idle clients; " + candidates.size() + " candidates.");
for (Client client : candidates) {
if ((useCounts.get(client) < 1) &&
((timestamps.get(client) + idleLimit.get() * 1000) <= System.currentTimeMillis())) {
logger.debug("Idle time reached for unused client {}", client);
closeClient(client);
}
}
}
/**
* Start offering shared dbserver sessions.
*
* @throws SocketException if there is a problem opening connections
*/
public synchronized void start() throws SocketException {
if (!isRunning()) {
DeviceFinder.getInstance().addLifecycleListener(lifecycleListener);
DeviceFinder.getInstance().addDeviceAnnouncementListener(announcementListener);
DeviceFinder.getInstance().start();
for (DeviceAnnouncement device: DeviceFinder.getInstance().getCurrentDevices()) {
requestPlayerDBServerPort(device);
}
new Thread(null, new Runnable() {
@Override
public void run() {
while (isRunning()) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
logger.warn("Interrupted sleeping to close idle dbserver clients");
}
closeIdleClients();
}
logger.info("Idle dbserver client closer shutting down.");
}
}, "Idle dbserver client closer").start();
running.set(true);
deliverLifecycleAnnouncement(logger, true);
}
}
/**
* Stop offering shared dbserver sessions.
*/
public synchronized void stop() {
if (isRunning()) {
running.set(false);
DeviceFinder.getInstance().removeDeviceAnnouncementListener(announcementListener);
dbServerPorts.clear();
for (Client client : openClients.values()) {
try {
client.close();
} catch (Exception e) {
logger.warn("Problem closing " + client + " when stopping", e);
}
}
openClients.clear();
useCounts.clear();
deliverLifecycleAnnouncement(logger, false);
}
}
/**
* Holds the singleton instance of this class.
*/
private static final ConnectionManager ourInstance = new ConnectionManager();
/**
* Get the singleton instance of this class.
*
* @return the only instance of this class which exists.
*/
public static ConnectionManager getInstance() {
return ourInstance;
}
/**
* Prevent direct instantiation.
*/
private ConnectionManager() {
// Nothing to do.
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("ConnectionManager[running:").append(isRunning());
sb.append(", dbServerPorts:").append(dbServerPorts).append(", openClients:").append(openClients);
sb.append(", useCounts:").append(useCounts).append(", timestamps:").append(timestamps);
return sb.append(", idleLimit:").append(idleLimit.get()).append("]").toString();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy