org.deepsymmetry.beatlink.data.BeatGridFinder Maven / Gradle / Ivy
Show all versions of beat-link Show documentation
package org.deepsymmetry.beatlink.data;
import org.deepsymmetry.beatlink.*;
import org.deepsymmetry.beatlink.dbserver.Client;
import org.deepsymmetry.beatlink.dbserver.ConnectionManager;
import org.deepsymmetry.beatlink.dbserver.Message;
import org.deepsymmetry.beatlink.dbserver.NumberField;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.*;
import java.io.DataInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* Watches for new metadata to become available for tracks loaded on players, and queries the
* appropriate player for the track beat grid when that happens.
*
* Maintains a hot cache of beat grids for any track currently loaded in a player, either on the main playback
* deck, or as a hot cue, since those tracks could start playing instantly.
*
* Implicitly honors the active/passive setting of the {@link MetadataFinder}
* (see {@link MetadataFinder#setPassive(boolean)}), because beat grids are loaded in response to metadata updates.
*
*/
public class BeatGridFinder extends LifecycleParticipant {
private static final Logger logger = LoggerFactory.getLogger(BeatGridFinder.class);
/**
* Keeps track of the current beat grids cached for each player. We hot cache beat grids for any track which is
* currently on-deck in the player, as well as any that were loaded into a player's hot-cue slot.
*/
private final Map hotCache =
new ConcurrentHashMap();
/**
* A queue used to hold metadata updates we receive from the {@link MetadataFinder} so we can process them on a
* lower priority thread, and not hold up delivery to more time-sensitive listeners.
*/
private final LinkedBlockingDeque pendingUpdates =
new LinkedBlockingDeque(100);
/**
* Our metadata listener just puts metadata updates on our queue, so we can process them on a lower
* priority thread, and not hold up delivery to more time-sensitive listeners.
*/
private final TrackMetadataListener metadataListener = new TrackMetadataListener() {
@Override
public void metadataChanged(TrackMetadataUpdate update) {
logger.debug("Received metadata update {}", update);
if (!pendingUpdates.offerLast(update)) {
logger.warn("Discarding metadata update because our queue is backed up.");
}
}
};
/**
* Our mount listener evicts any cached beat grids that belong to media databases which have been unmounted, since
* they are no longer valid.
*/
private final MountListener mountListener = new MountListener() {
@Override
public void mediaMounted(SlotReference slot) {
logger.debug("BeatGridFinder doesn't yet need to do anything in response to a media mount.");
}
@Override
public void mediaUnmounted(SlotReference slot) {
// Iterate over a copy to avoid concurrent modification issues
for (Map.Entry entry : new HashMap(hotCache).entrySet()) {
if (slot == SlotReference.getSlotReference(entry.getValue().dataReference)) {
logger.debug("Evicting cached beat grid in response to unmount report {}", entry.getValue());
hotCache.remove(entry.getKey());
}
}
}
};
/**
* Our announcement listener watches for devices to disappear from the network so we can discard all information
* about them.
*/
private final DeviceAnnouncementListener announcementListener = new DeviceAnnouncementListener() {
@Override
public void deviceFound(final DeviceAnnouncement announcement) {
logger.debug("Currently nothing for BeatGridFinder to do when devices appear.");
}
@Override
public void deviceLost(DeviceAnnouncement announcement) {
logger.info("Clearing beat grids in response to the loss of a device, {}", announcement);
clearBeatGrids(announcement);
}
};
/**
* Keep track of whether we are running
*/
private final AtomicBoolean running = new AtomicBoolean(false);
/**
* Check whether we are currently running. Unless the {@link MetadataFinder} is in passive mode, we will
* automatically request beat grids from the appropriate player when a new track is loaded that is not found
* in the hot cache or an attached metadata cache file.
*
* @return true if beat grids are being kept track of for all active players
*
* @see MetadataFinder#isPassive()
*/
public boolean isRunning() {
return running.get();
}
/**
* We process our player status updates on a separate thread so as not to slow down the high-priority update
* delivery thread; we perform potentially slow I/O.
*/
private Thread queueHandler;
/**
* We have received an update that invalidates the beat grid for a player, so clear it and alert
* any listeners if this represents a change. This does not affect the hot cues; they will stick around until the
* player loads a new track that overwrites one or more of them.
*
* @param update the update which means we have no beat grid for the associated player
*/
private void clearDeck(TrackMetadataUpdate update) {
if (hotCache.remove(DeckReference.getDeckReference(update.player, 0)) != null) {
deliverBeatGridUpdate(update.player, null);
}
}
/**
* We have received notification that a device is no longer on the network, so clear out all its beat grids.
*
* @param announcement the packet which reported the device’s disappearance
*/
private void clearBeatGrids(DeviceAnnouncement announcement) {
final int player = announcement.getNumber();
// Iterate over a copy to avoid concurrent modification issues
for (DeckReference deck : new HashSet(hotCache.keySet())) {
if (deck.player == player) {
hotCache.remove(deck);
}
}
}
/**
* We have obtained a beat grid for a device, so store it and alert any listeners.
*
* @param update the update which caused us to retrieve this beat grid
* @param beatGrid the beat grid which we retrieved
*/
private void updateBeatGrid(TrackMetadataUpdate update, BeatGrid beatGrid) {
hotCache.put(DeckReference.getDeckReference(update.player, 0), beatGrid); // Main deck
if (update.metadata.getCueList() != null) { // Update the cache with any hot cues in this track as well
for (CueList.Entry entry : update.metadata.getCueList().entries) {
if (entry.hotCueNumber != 0) {
hotCache.put(DeckReference.getDeckReference(update.player, entry.hotCueNumber), beatGrid);
}
}
}
deliverBeatGridUpdate(update.player, beatGrid);
}
/**
* Get the beat grids available for all tracks currently loaded in any player, either on the play deck, or
* in a hot cue.
*
* @return the beat grids associated with all current players, including for any tracks loaded in their hot cue slots
*
* @throws IllegalStateException if the BeatGridFinder is not running
*/
@SuppressWarnings("WeakerAccess")
public Map getLoadedBeatGrids() {
ensureRunning();
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableMap(new HashMap(hotCache));
}
/**
* Look up the beat grid we have for the track loaded in the main deck of a given player number.
*
* @param player the device number whose beat grid for the playing track is desired
*
* @return the beat grid for the track loaded on that player, if available
*
* @throws IllegalStateException if the BeatGridFinder is not running
*/
@SuppressWarnings("WeakerAccess")
public BeatGrid getLatestBeatGridFor(int player) {
ensureRunning();
return hotCache.get(DeckReference.getDeckReference(player, 0));
}
/**
* Look up the beat grid we have for a given player, identified by a status update received from that player.
*
* @param update a status update from the player for which a beat grid is desired
*
* @return the beat grid for the track loaded on that player, if available
*
* @throws IllegalStateException if the BeatGridFinder is not running
*/
public BeatGrid getLatestBeatGridFor(DeviceUpdate update) {
BeatGrid result = getLatestBeatGridFor(update.getDeviceNumber());
if (result != null && (update instanceof CdjStatus) &&
result.dataReference.rekordboxId != ((CdjStatus) update).getRekordboxId()) {
return null;
}
return result;
}
/**
* Ask the specified player for the beat grid in the specified slot with the specified rekordbox ID,
* using cached media instead if it is available, and possibly giving up if we are in passive mode.
*
* @param trackReference uniquely identifies the desired beat grid
* @param failIfPassive will prevent the request from taking place if we are in passive mode, so that automatic
* beat grid updates will use available caches only
*
* @return the waveform preview found, if any
*/
private BeatGrid requestBeatGridInternal(final DataReference trackReference, final boolean failIfPassive) {
// First check if we are using cached data for this slot
ZipFile cache = MetadataFinder.getInstance().getMetadataCache(SlotReference.getSlotReference(trackReference));
if (cache != null) {
return getCachedBeatGrid(cache, trackReference);
}
if (MetadataFinder.getInstance().isPassive() && failIfPassive) {
// We are not allowed to perform actual requests in passive mode.
return null;
}
// We have to actually request the preview.
ConnectionManager.ClientTask task = new ConnectionManager.ClientTask() {
@Override
public BeatGrid useClient(Client client) throws Exception {
return getBeatGrid(trackReference.rekordboxId, SlotReference.getSlotReference(trackReference), client);
}
};
try {
return ConnectionManager.getInstance().invokeWithClientSession(trackReference.player, task, "requesting beat grid");
} catch (Exception e) {
logger.error("Problem requesting beat grid, returning null", e);
}
return null;
}
/**
* Ask the specified player for the beat grid of the track in the specified slot with the specified rekordbox ID,
* first checking if we have a cache we can use instead.
*
* @param track uniquely identifies the track whose beat grid is desired
*
* @return the beat grid, if any
*/
public BeatGrid requestBeatGridFrom(final DataReference track) {
for (BeatGrid cached : hotCache.values()) {
if (cached.dataReference.equals(track)) { // Found a hot cue hit, use it.
return cached;
}
}
return requestBeatGridInternal(track, false);
}
/**
* Look up a beat grid in a metadata cache.
*
* @param cache the appropriate metadata cache file
* @param reference the track whose beat grid is desired
*
* @return the cached beat grid (if available), or {@code null}
*/
@SuppressWarnings("WeakerAccess")
public BeatGrid getCachedBeatGrid(ZipFile cache, DataReference reference) {
ZipEntry entry = cache.getEntry(MetadataFinder.getInstance().getBeatGridEntryName(reference.rekordboxId));
if (entry != null) {
DataInputStream is = null;
try {
is = new DataInputStream(cache.getInputStream(entry));
byte[] gridBytes = new byte[(int)entry.getSize()];
is.readFully(gridBytes);
return new BeatGrid(reference, ByteBuffer.wrap(gridBytes).asReadOnlyBuffer());
} catch (IOException e) {
logger.error("Problem reading beat grid from cache file, returning null", e);
} finally {
if (is != null) {
try {
is.close();
} catch (Exception e) {
logger.error("Problem closing ZipFile input stream for reading beat grid entry", e);
}
}
}
}
return null;
}
/**
* Requests the beat grid for a specific track ID, given a connection to a player that has already been set up.
* @param rekordboxId the track of interest
* @param slot identifies the media slot we are querying
* @param client the dbserver client that is communicating with the appropriate player
*
* @return the retrieved beat grid, or {@code null} if there was none available
*
* @throws IOException if there is a communication problem
*/
BeatGrid getBeatGrid(int rekordboxId, SlotReference slot, Client client)
throws IOException {
Message response = client.simpleRequest(Message.KnownType.BEAT_GRID_REQ, null,
client.buildRMST(Message.MenuIdentifier.DATA, slot.slot), new NumberField(rekordboxId));
if (response.knownType == Message.KnownType.BEAT_GRID) {
return new BeatGrid(new DataReference(slot, rekordboxId), response);
}
logger.error("Unexpected response type when requesting beat grid: {}", response);
return null;
}
/**
* Keep track of the devices we are currently trying to get beat grids from in response to metadata updates.
*/
private final Set activeRequests = Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Keeps track of the registered beat grid listeners.
*/
private final Set beatGridListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified beat grid listener to receive updates when the beat grid information for a player changes.
* If {@code listener} is {@code null} or already present in the set of registered listeners, no exception is
* thrown and no action is performed.
*
* To reduce latency, updates are delivered to listeners directly on the thread that is receiving packets
* from the network, so if you want to interact with user interface objects in listener methods, you need to use
* javax.swing.SwingUtilities.invokeLater(Runnable)
* to do so on the Event Dispatch Thread.
*
* Even if you are not interacting with user interface objects, any code in the listener method
* must finish quickly, or it will add latency for other listeners, and updates will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the album art update listener to add
*/
public void addBeatGridListener(BeatGridListener listener) {
if (listener != null) {
beatGridListeners.add(listener);
}
}
/**
* Removes the specified beat grid listener so that it no longer receives updates when the
* beat grid information for a player changes. If {@code listener} is {@code null} or not present
* in the set of registered listeners, no exception is thrown and no action is performed.
*
* @param listener the waveform listener to remove
*/
public void removeBeatGridListener(BeatGridListener listener) {
if (listener != null) {
beatGridListeners.remove(listener);
}
}
/**
* Get the set of currently-registered beat grid listeners.
*
* @return the listeners that are currently registered for beat grid updates
*/
@SuppressWarnings("WeakerAccess")
public Set getBeatGridListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(beatGridListeners));
}
/**
* Send a beat grid update announcement to all registered listeners.
*
* @param player the player whose beat grid information has changed
* @param beatGrid the new beat grid associated with that player, if any
*/
private void deliverBeatGridUpdate(int player, BeatGrid beatGrid) {
if (!getBeatGridListeners().isEmpty()) {
final BeatGridUpdate update = new BeatGridUpdate(player, beatGrid);
for (final BeatGridListener listener : getBeatGridListeners()) {
try {
listener.beatGridChanged(update);
} catch (Throwable t) {
logger.warn("Problem delivering beat grid update to listener", t);
}
}
}
}
/**
* Process a metadata update from the {@link MetadataFinder}, and see if it means the beat grid information
* associated with any player has changed.
*
* @param update describes the new metadata we have for a player, if any
*/
private void handleUpdate(final TrackMetadataUpdate update) {
if (update.metadata == null || update.metadata.trackType != CdjStatus.TrackType.REKORDBOX) {
clearDeck(update);
} else {
// We can offer beat grid information for this device; check if we've already looked it up.
final BeatGrid lastBeatGrid = hotCache.get(DeckReference.getDeckReference(update.player, 0));
if (lastBeatGrid == null || !lastBeatGrid.dataReference.equals(update.metadata.trackReference)) { // We have something new!
// First see if we can find the new preview in the hot cache
for (BeatGrid cached : hotCache.values()) {
if (cached.dataReference.equals(update.metadata.trackReference)) { // Found a hot cue hit, use it.
updateBeatGrid(update, cached);
return;
}
}
// Not in the cache so try actually retrieving it.
if (activeRequests.add(update.player)) { // We had to make sure we were not already asking for this track.
clearDeck(update); // We won't know what it is until our request completes.
new Thread(new Runnable() {
@Override
public void run() {
try {
BeatGrid grid = requestBeatGridInternal(update.metadata.trackReference, true);
if (grid != null && grid.beatCount > 0) {
updateBeatGrid(update, grid);
}
} catch (Exception e) {
logger.warn("Problem requesting beat grid from update" + update, e);
} finally {
activeRequests.remove(update.player);
}
}
}, "Beat Grid request").start();
}
}
}
}
/**
* Set up to automatically stop if anything we depend on stops.
*/
private final LifecycleListener lifecycleListener = new LifecycleListener() {
@Override
public void started(LifecycleParticipant sender) {
logger.debug("The BeatGridFinder does not auto-start when {} does.", sender);
}
@Override
public void stopped(LifecycleParticipant sender) {
if (isRunning()) {
logger.info("BeatGridFinder stopping because {} has.", sender);
stop();
}
}
};
/**
* Start finding beat grids for all active players. Starts the {@link MetadataFinder} if it is not already
* running, because we need it to send us metadata updates to notice when new tracks are loaded. This in turn
* starts the {@link DeviceFinder}, so we can keep track of the comings and goings of players themselves.
* We also start the {@link ConnectionManager} in order to make queries to obtain beat grids.
*
* @throws Exception if there is a problem starting the required components
*/
public synchronized void start() throws Exception {
if (!isRunning()) {
ConnectionManager.getInstance().addLifecycleListener(lifecycleListener);
ConnectionManager.getInstance().start();
DeviceFinder.getInstance().addDeviceAnnouncementListener(announcementListener);
MetadataFinder.getInstance().addLifecycleListener(lifecycleListener);
MetadataFinder.getInstance().start();
MetadataFinder.getInstance().addTrackMetadataListener(metadataListener);
MetadataFinder.getInstance().addMountListener(mountListener);
queueHandler = new Thread(new Runnable() {
@Override
public void run() {
while (isRunning()) {
try {
handleUpdate(pendingUpdates.take());
} catch (InterruptedException e) {
// Interrupted due to MetadataFinder shutdown, presumably
} catch (Throwable t) {
logger.error("Problem processing metadata update", t);
}
}
}
});
running.set(true);
queueHandler.start();
deliverLifecycleAnnouncement(logger, true);
// Send ourselves "updates" about any tracks that were loaded before we started, since we missed those.
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
for (Map.Entry entry : MetadataFinder.getInstance().getLoadedTracks().entrySet()) {
if (entry.getKey().hotCue == 0) { // The track is currently loaded in a main player deck
handleUpdate(new TrackMetadataUpdate(entry.getKey().player, entry.getValue()));
}
}
}
});
}
}
/**
* Stop finding beat grids for all active players.
*/
@SuppressWarnings("WeakerAccess")
public synchronized void stop() {
if (isRunning()) {
MetadataFinder.getInstance().removeTrackMetadataListener(metadataListener);
running.set(false);
pendingUpdates.clear();
queueHandler.interrupt();
queueHandler = null;
// Report the loss of our previews, on the proper thread, and outside our lock.
final Set dyingCache = new HashSet(hotCache.keySet());
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
for (DeckReference deck : dyingCache) {
if (deck.hotCue == 0) {
deliverBeatGridUpdate(deck.player, null);
}
}
}
});
hotCache.clear();
deliverLifecycleAnnouncement(logger, false);
}
}
/**
* Holds the singleton instance of this class.
*/
private static final BeatGridFinder ourInstance = new BeatGridFinder();
/**
* Get the singleton instance of this class.
*
* @return the only instance of this class which exists.
*/
public static BeatGridFinder getInstance() {
return ourInstance;
}
/**
* Prevent instantiation.
*/
private BeatGridFinder() {
// Nothing to do
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("BeatGridFinder[running:").append(isRunning()).append(", passive:");
sb.append(MetadataFinder.getInstance().isPassive());
if (isRunning()) {
sb.append(", loadedBeatGrids:").append(getLoadedBeatGrids());
}
return sb.append("]").toString();
}
}