All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.deepsymmetry.beatlink.data.ArtFinder Maven / Gradle / Ivy

package org.deepsymmetry.beatlink.data;

import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
import org.deepsymmetry.beatlink.*;
import org.deepsymmetry.beatlink.dbserver.*;
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 album art when that happens.

* *

Maintains a hot cache of art 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. Also maintains a second-level * in-memory cache of artwork, discarding the least-recently-used art when the cache fills, because tracks * can share artwork, so the DJ may load another track with the same album art.

* *

Implicitly honors the active/passive setting of the {@link MetadataFinder} * (see {@link MetadataFinder#setPassive(boolean)}), because art is loaded in response to metadata updates.

* * @author James Elliott */ @SuppressWarnings("WeakerAccess") public class ArtFinder extends LifecycleParticipant { private static final Logger logger = LoggerFactory.getLogger(ArtFinder.class); /** * Keeps track of the current album art cached for each player. We hot cache art 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 artwork 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("ArtFinder 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. final Set keys = new HashSet(artCache.keySet()); for (DataReference artReference : keys) { if (SlotReference.getSlotReference(artReference) == slot) { logger.debug("Evicting cached artwork in response to unmount report {}", artReference); artCache.remove(artReference); } } // Again iterate over a copy to avoid concurrent modification issues. final Set> copy = new HashSet>(hotCache.entrySet()); for (Map.Entry entry : copy) { if (slot == SlotReference.getSlotReference(entry.getValue().artReference)) { logger.debug("Evicting hot cached artwork 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 ArtFinder to do when devices appear."); } @Override public void deviceLost(DeviceAnnouncement announcement) { logger.info("Clearing artwork in response to the loss of a device, {}", announcement); clearArt(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 album art from the appropriate player when a new track is loaded that is not found * in the hot cache, second-level memory cache, or an attached metadata cache file. * * @return true if album art is 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 any previous metadata for a player, so clear its art, 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 metadata for the associated player */ private void clearDeck(TrackMetadataUpdate update) { if (hotCache.remove(DeckReference.getDeckReference(update.player, 0)) != null) { deliverAlbumArtUpdate(update.player, null); } } /** * We have received notification that a device is no longer on the network, so clear out its artwork. * * @param announcement the packet which reported the device’s disappearance */ private void clearArt(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); } } // Again iterate over a copy to avoid concurrent modification issues for (DataReference art : new HashSet(artCache.keySet())) { if (art.player == player) { artCache.remove(art); } } } /** * We have obtained album art for a device, so store it and alert any listeners. * * @param update the update which caused us to retrieve this art * @param art the album art which we retrieved */ private void updateArt(TrackMetadataUpdate update, AlbumArt art) { hotCache.put(DeckReference.getDeckReference(update.player, 0), art); // 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), art); } } } deliverAlbumArtUpdate(update.player, art); } /** * Get the art available for all tracks currently loaded in any player, either on the play deck, or in a hot cue. * * @return the album art associated with all current players, including for any tracks loaded in their hot cue slots * * @throws IllegalStateException if the ArtFinder is not running */ public Map getLoadedArt() { ensureRunning(); // Make a copy so callers get an immutable snapshot of the current state. return Collections.unmodifiableMap(new HashMap(hotCache)); } /** * Look up the album art we have for the track loaded in the main deck of a given player number. * * @param player the device number whose album art for the playing track is desired * * @return the album art for the track loaded on that player, if available * * @throws IllegalStateException if the ArtFinder is not running */ public AlbumArt getLatestArtFor(int player) { ensureRunning(); return hotCache.get(DeckReference.getDeckReference(player, 0)); } /** * Look up the album art 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 album art is desired * * @return the album art for the track loaded on that player, if available * * @throws IllegalStateException if the ArtFinder is not running */ public AlbumArt getLatestArtFor(DeviceUpdate update) { return getLatestArtFor(update.getDeviceNumber()); } /** * The maximum number of artwork images we will retain in our cache. */ public static final int DEFAULT_ART_CACHE_SIZE = 100; /** * Establish the second-level artwork cache. Since multiple tracks share the same art, it can be worthwhile to keep * art around even for tracks that are not currently loaded, to save on having to request it again when another * track from the same album is loaded. */ private final ConcurrentLinkedHashMap artCache = new ConcurrentLinkedHashMap.Builder().maximumWeightedCapacity(DEFAULT_ART_CACHE_SIZE).build(); /** * Check how many album art images can be kept in the in-memory second-level cache. * * @return the maximum number of distinct album art images that will automatically be kept for reuse in the * in-memory art cache. */ public long getArtCacheSize() { return artCache.capacity(); } /** * Set how many album art images can be kept in the in-memory second-level cache. * * @param size the maximum number of distinct album art images that will automatically be kept for reuse in the * in-memory art cache; if you set this to a smaller number than are currently present in the cache, some * of the older images will be immediately discarded so that only the number you specified remain * * @throws IllegalArgumentException if {@code} size is less than 1 */ public void setArtCacheSize(int size) { if (size < 1) { throw new IllegalArgumentException("size must be at least 1"); } artCache.setCapacity(size); } /** * Ask the specified player for the album art 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 artReference uniquely identifies the desired album art * @param failIfPassive will prevent the request from taking place if we are in passive mode, so that automatic * artwork updates will use available caches only * * @return the album art found, if any */ private AlbumArt requestArtworkInternal(final DataReference artReference, final boolean failIfPassive) { // First check if we are using cached data for this slot ZipFile cache = MetadataFinder.getInstance().getMetadataCache(SlotReference.getSlotReference(artReference)); if (cache != null) { return getCachedArtwork(cache, artReference); } if (MetadataFinder.getInstance().isPassive() && failIfPassive) { // We are not allowed to perform actual requests in passive mode. return null; } // We have to actually request the art. ConnectionManager.ClientTask task = new ConnectionManager.ClientTask() { @Override public AlbumArt useClient(Client client) throws Exception { return getArtwork(artReference.rekordboxId, SlotReference.getSlotReference(artReference), client); } }; try { AlbumArt artwork = ConnectionManager.getInstance().invokeWithClientSession(artReference.player, task, "requesting artwork"); if (artwork != null) { // Our cache file load or network request succeeded, so add to the level 2 cache. artCache.put(artReference, artwork); } return artwork; } catch (Exception e) { logger.error("Problem requesting album art, returning null", e); } return null; } /** * Ask the specified player for the specified artwork from the specified media slot, first checking if we have a * cached copy. * * @param artReference uniquely identifies the desired artwork * * @return the artwork, if it was found, or {@code null} * * @throws IllegalStateException if the ArtFinder is not running */ public AlbumArt requestArtworkFrom(final DataReference artReference) { ensureRunning(); AlbumArt artwork = findArtInMemoryCaches(artReference); // First check the in-memory artwork caches. if (artwork == null) { artwork = requestArtworkInternal(artReference, false); } return artwork; } /** * Look up artwork from a cache file. * * @param cache the appropriate metadata cache file * @param artReference the unique database specification of the desired artwork * * @return the cached album art (if available), or {@code null} * * @throws IllegalStateException if the ArtFinder is not running */ public AlbumArt getCachedArtwork(ZipFile cache, DataReference artReference) { ensureRunning(); ZipEntry entry = cache.getEntry(MetadataFinder.getInstance().getArtworkEntryName(artReference.rekordboxId)); if (entry != null) { DataInputStream is = null; try { is = new DataInputStream(cache.getInputStream(entry)); byte[] imageBytes = new byte[(int)entry.getSize()]; is.readFully(imageBytes); AlbumArt result = new AlbumArt(artReference, ByteBuffer.wrap(imageBytes).asReadOnlyBuffer()); artCache.put(artReference, result); return result; } catch (IOException e) { logger.error("Problem reading artwork 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 artwork entry", e); } } } } return null; } /** * Request the artwork with a particular artwork ID, given a connection to a player that has already been set up. * * @param artworkId identifies the album art to retrieve * @param slot the slot identifier from which the associated track was loaded * @param client the dbserver client that is communicating with the appropriate player * * @return the track's artwork, or null if none is available * * @throws IOException if there is a problem communicating with the player */ AlbumArt getArtwork(int artworkId, SlotReference slot, Client client) throws IOException { // Send the artwork request Message response = client.simpleRequest(Message.KnownType.ALBUM_ART_REQ, Message.KnownType.ALBUM_ART, client.buildRMS1(Message.MenuIdentifier.DATA, slot.slot), new NumberField((long)artworkId)); // Create an image from the response bytes return new AlbumArt(new DataReference(slot, artworkId), ((BinaryField)response.arguments.get(3)).getValue()); } /** * Keep track of the devices we are currently trying to get artwork from in response to metadata updates. */ private final Set activeRequests = Collections.newSetFromMap(new ConcurrentHashMap()); /** * Look for the specified album art in both the hot cache of loaded tracks and the longer-lived LRU cache. * * @param artReference uniquely identifies the desired album art * * @return the art, if it was found in one of our caches, or {@code null} */ private AlbumArt findArtInMemoryCaches(DataReference artReference) { // First see if we can find the new track in the hot cache as a hot cue for (AlbumArt cached : hotCache.values()) { if (cached.artReference.equals(artReference)) { // Found a hot cue hit, use it. return cached; } } // Not in the hot cache, see if it is in our LRU cache return artCache.get(artReference); } /** * Keeps track of the registered track metadata update listeners. */ private final Set artListeners = Collections.newSetFromMap(new ConcurrentHashMap()); /** *

Adds the specified album art listener to receive updates when the album art 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 addAlbumArtListener(AlbumArtListener listener) { if (listener != null) { artListeners.add(listener); } } /** * Removes the specified album art listener so that it no longer receives updates when the * album art 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 album art update listener to remove */ public void removeAlbumArtListener(AlbumArtListener listener) { if (listener != null) { artListeners.remove(listener); } } /** * Get the set of currently-registered album art listeners. * * @return the listeners that are currently registered for album art updates */ @SuppressWarnings("WeakerAccess") public Set getAlbumArtListeners() { // Make a copy so callers get an immutable snapshot of the current state. return Collections.unmodifiableSet(new HashSet(artListeners)); } /** * Send an album art update announcement to all registered listeners. */ private void deliverAlbumArtUpdate(int player, AlbumArt art) { if (!getAlbumArtListeners().isEmpty()) { final AlbumArtUpdate update = new AlbumArtUpdate(player, art); for (final AlbumArtListener listener : getAlbumArtListeners()) { try { listener.albumArtChanged(update); } catch (Exception e) { logger.warn("Problem delivering album art update to listener", e); } } } } /** * Process a metadata update from the {@link MetadataFinder}, and see if it means the album art 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.getArtworkId() == 0)) { // Either we have no metadata, or the track has no album art clearDeck(update); } else { // We can offer artwork for this device; check if we have already looked up this art final AlbumArt lastArt = hotCache.get(DeckReference.getDeckReference(update.player, 0)); final DataReference artReference = new DataReference(update.metadata.trackReference.player, update.metadata.trackReference.slot, update.metadata.getArtworkId()); if (lastArt == null || !lastArt.artReference.equals(artReference)) { // We have something new! // First see if we can find the new track in one of our in-memory caches AlbumArt cached = findArtInMemoryCaches(artReference); if (cached != null) { // Found a cue hit, use it. updateArt(update, cached); return; } // Not in either cache so try actually retrieving it. if (activeRequests.add(update.player)) { clearDeck(update); // We won't know what it is until our request completes. // We had to make sure we were not already asking for this track. new Thread(new Runnable() { @Override public void run() { try { AlbumArt art = requestArtworkInternal(artReference, true); if (art != null) { updateArt(update, art); } } catch (Exception e) { logger.warn("Problem requesting album art from update" + update, e); } finally { activeRequests.remove(update.player); } } }).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 ArtFinder does not auto-start when {} does.", sender); } @Override public void stopped(LifecycleParticipant sender) { if (isRunning()) { logger.info("ArtFinder stopping because {} has.", sender); stop(); } } }; /** *

Start finding album art 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 art.

* * @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 } } } }); running.set(true); queueHandler.start(); deliverLifecycleAnnouncement(logger, true); // Send ourselves "updates" about any tracks that were loaded before we started, since we missed those. 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 album art for all active players. */ public synchronized void stop() { if (isRunning()) { MetadataFinder.getInstance().removeTrackMetadataListener(metadataListener); running.set(false); pendingUpdates.clear(); queueHandler.interrupt(); queueHandler = null; // Report the loss of our hot cached art and our shutdown, 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) { deliverAlbumArtUpdate(deck.player, null); } } } }); hotCache.clear(); artCache.clear(); deliverLifecycleAnnouncement(logger, false); } } /** * Holds the singleton instance of this class. */ private static final ArtFinder ourInstance = new ArtFinder(); /** * Get the singleton instance of this class. * * @return the only instance of this class which exists. */ public static ArtFinder getInstance() { return ourInstance; } /** * Prevent instantiation. */ private ArtFinder() { // Nothing to do } @Override public String toString() { StringBuilder sb = new StringBuilder("ArtFinder[running:").append(isRunning()).append(", passive:"); sb.append(MetadataFinder.getInstance().isPassive()).append(", artCacheSize:").append(getArtCacheSize()); if (isRunning()) { sb.append(", loadedArt:").append(getLoadedArt()).append(", cached art:").append(artCache.size()); } return sb.append("]").toString(); } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy