net.pms.util.CoverArtArchiveUtil.java Source code

Java tutorial

Introduction

Here is the source code for net.pms.util.CoverArtArchiveUtil.java

Source

/*
 * Universal Media Server, for streaming any medias to DLNA
 * compatible renderers based on the http://www.ps3mediaserver.org.
 * Copyright (C) 2012 UMS developers.
 *
 * This program is a free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; version 2
 * of the License only.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
package net.pms.util;

import fm.last.musicbrainz.coverart.CoverArt;
import fm.last.musicbrainz.coverart.CoverArtException;
import fm.last.musicbrainz.coverart.CoverArtImage;
import fm.last.musicbrainz.coverart.impl.DefaultCoverArtArchiveClient;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import net.pms.database.TableCoverArtArchive;
import net.pms.database.TableCoverArtArchive.CoverArtArchiveResult;
import net.pms.database.TableMusicBrainzReleases;
import net.pms.database.TableMusicBrainzReleases.MusicBrainzReleasesResult;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.HttpResponseException;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

/**
 * This class is responsible for fetching music covers from Cover Art Archive.
 * It handles database caching and http lookup of both MusicBrainz ID's (MBID)
 * and binary cover data from Cover Art Archive.
 *
 * @author Nadahar
 */

public class CoverArtArchiveUtil extends CoverUtil {

    private static final Logger LOGGER = LoggerFactory.getLogger(CoverArtArchiveUtil.class);
    private static final long WAIT_TIMEOUT_MS = 30000;
    private static long expireTime = 24 * 60 * 60 * 1000; // 24 hours
    private static final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();

    private static enum ReleaseType {
        Single, Album, EP, Broadcast, Other
    }

    private static class ReleaseRecord {

        String id;
        int score;
        String title;
        List<String> artists = new ArrayList<>();
        ReleaseType type;
        String year;

        public ReleaseRecord() {
        }

        public ReleaseRecord(ReleaseRecord source) {
            id = source.id;
            score = source.score;
            title = source.title;
            type = source.type;
            year = source.year;
            for (String artist : source.artists) {
                artists.add(artist);
            }
        }

    }

    /**
     * This class is a container to hold information used by
     * {@link CoverArtArchiveUtil} to look up covers.
     */
    public static class CoverArtArchiveTagInfo {
        public final String album;
        public final String artist;
        public final String title;
        public final String year;
        public final String artistId;
        public final String trackId;

        public boolean hasInfo() {
            return StringUtil.hasValue(album) || StringUtil.hasValue(artist) || StringUtil.hasValue(title)
                    || StringUtil.hasValue(year) || StringUtil.hasValue(artistId) || StringUtil.hasValue(trackId);
        }

        @Override
        public String toString() {
            StringBuilder result = new StringBuilder();
            if (StringUtil.hasValue(artist)) {
                result.append(artist);
            }
            if (StringUtil.hasValue(artistId)) {
                if (result.length() > 0) {
                    result.append(" (").append(artistId).append(")");
                } else {
                    result.append(artistId);
                }
            }
            if (result.length() > 0
                    && (StringUtil.hasValue(title) || StringUtil.hasValue(album) || StringUtil.hasValue(trackId))

            ) {
                result.append(" - ");
            }
            if (StringUtil.hasValue(album)) {
                result.append(album);
                if (StringUtil.hasValue(title) || StringUtil.hasValue(trackId)) {
                    result.append(": ");
                }
            }
            if (StringUtil.hasValue(title)) {
                result.append(title);
                if (StringUtil.hasValue(trackId)) {
                    result.append(" (").append(trackId).append(")");
                }
            } else if (StringUtil.hasValue(trackId)) {
                result.append(trackId);
            }
            if (StringUtil.hasValue(year)) {
                if (result.length() > 0) {
                    result.append(" (").append(year).append(")");
                } else {
                    result.append(year);
                }
            }
            return result.toString();
        }

        @Override
        public int hashCode() {
            final int prime = 31;
            int result = 1;
            result = prime * result + ((album == null) ? 0 : album.hashCode());
            result = prime * result + ((artist == null) ? 0 : artist.hashCode());
            result = prime * result + ((artistId == null) ? 0 : artistId.hashCode());
            result = prime * result + ((title == null) ? 0 : title.hashCode());
            result = prime * result + ((trackId == null) ? 0 : trackId.hashCode());
            result = prime * result + ((year == null) ? 0 : year.hashCode());
            return result;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (obj == null) {
                return false;
            }
            if (!(obj instanceof CoverArtArchiveTagInfo)) {
                return false;
            }
            CoverArtArchiveTagInfo other = (CoverArtArchiveTagInfo) obj;
            if (album == null) {
                if (other.album != null) {
                    return false;
                }
            } else if (!album.equals(other.album)) {
                return false;
            }
            if (artist == null) {
                if (other.artist != null) {
                    return false;
                }
            } else if (!artist.equals(other.artist)) {
                return false;
            }
            if (artistId == null) {
                if (other.artistId != null) {
                    return false;
                }
            } else if (!artistId.equals(other.artistId)) {
                return false;
            }
            if (title == null) {
                if (other.title != null) {
                    return false;
                }
            } else if (!title.equals(other.title)) {
                return false;
            }
            if (trackId == null) {
                if (other.trackId != null) {
                    return false;
                }
            } else if (!trackId.equals(other.trackId)) {
                return false;
            }
            if (year == null) {
                if (other.year != null) {
                    return false;
                }
            } else if (!year.equals(other.year)) {
                return false;
            }
            return true;
        }

        public CoverArtArchiveTagInfo(Tag tag) {
            if (AudioUtils.tagSupportsFieldKey(tag, FieldKey.ALBUM)) {
                album = tag.getFirst(FieldKey.ALBUM);
            } else {
                album = null;
            }

            if (AudioUtils.tagSupportsFieldKey(tag, FieldKey.ARTIST)) {
                artist = tag.getFirst(FieldKey.ARTIST);
            } else {
                artist = null;
            }

            if (AudioUtils.tagSupportsFieldKey(tag, FieldKey.TITLE)) {
                title = tag.getFirst(FieldKey.TITLE);
            } else {
                title = null;
            }

            if (AudioUtils.tagSupportsFieldKey(tag, FieldKey.YEAR)) {
                year = tag.getFirst(FieldKey.YEAR);
            } else {
                year = null;
            }

            if (AudioUtils.tagSupportsFieldKey(tag, FieldKey.MUSICBRAINZ_ARTISTID)) {
                artistId = tag.getFirst(FieldKey.MUSICBRAINZ_ARTISTID);
            } else {
                artistId = null;
            }

            if (AudioUtils.tagSupportsFieldKey(tag, FieldKey.MUSICBRAINZ_TRACK_ID)) {
                trackId = tag.getFirst(FieldKey.MUSICBRAINZ_TRACK_ID);
            } else {
                trackId = null;
            }
        }
    }

    private static class CoverArtArchiveTagLatch {
        final CoverArtArchiveTagInfo info;
        final CountDownLatch latch = new CountDownLatch(1);

        public CoverArtArchiveTagLatch(CoverArtArchiveTagInfo info) {
            this.info = info;
        }
    }

    private static class CoverArtArchiveCoverLatch {
        final String mBID;
        final CountDownLatch latch = new CountDownLatch(1);

        public CoverArtArchiveCoverLatch(String mBID) {
            this.mBID = mBID;
        }
    }

    /**
     * Do not instantiate this class, use {@link CoverUtil#get()}
     */
    protected CoverArtArchiveUtil() {
    }

    private static final Object tagLatchListLock = new Object();
    private static final List<CoverArtArchiveTagLatch> tagLatchList = new ArrayList<>();

    /**
     * Used to serialize search on a per {@link Tag} basis. Every thread doing
     * a search much hold a {@link CoverArtArchiveTagLatch} and release it when
     * the search is done and the result is written. Any other threads
     * attempting to search for the same {@link Tag} will wait for the existing
     * {@link CoverArtArchiveTagLatch} to be released, and can then use the
     * results from the previous thread instead of conducting it's own search.
     */
    private static CoverArtArchiveTagLatch reserveTagLatch(final CoverArtArchiveTagInfo tagInfo) {
        CoverArtArchiveTagLatch tagLatch = null;

        boolean owner = false;
        long startTime = System.currentTimeMillis();

        while (!owner && !Thread.currentThread().isInterrupted()) {

            // Find if any other tread is currently searching the same tag
            synchronized (tagLatchListLock) {
                for (CoverArtArchiveTagLatch latch : tagLatchList) {
                    if (latch.info.equals(tagInfo)) {
                        tagLatch = latch;
                        break;
                    }
                }
                // None found, our turn
                if (tagLatch == null) {
                    tagLatch = new CoverArtArchiveTagLatch(tagInfo);
                    tagLatchList.add(tagLatch);
                    owner = true;
                }
            }

            // Check for timeout here instead of in the while loop make logging
            // it easier.
            if (!owner && System.currentTimeMillis() - startTime > WAIT_TIMEOUT_MS) {
                LOGGER.debug("A MusicBrainz search timed out while waiting it's turn");
                return null;
            }

            if (!owner) {
                try {
                    tagLatch.latch.await();
                } catch (InterruptedException e) {
                    LOGGER.debug("A MusicBrainz search was interrupted while waiting it's turn");
                    Thread.currentThread().interrupt();
                    return null;
                } finally {
                    tagLatch = null;
                }
            }
        }

        return tagLatch;
    }

    private static void releaseTagLatch(CoverArtArchiveTagLatch tagLatch) {
        synchronized (tagLatchListLock) {
            if (!tagLatchList.remove(tagLatch)) {
                LOGGER.error("Concurrency error: Held tagLatch not found in latchList");
            }
        }
        tagLatch.latch.countDown();
    }

    private static final Object coverLatchListLock = new Object();
    private static final List<CoverArtArchiveCoverLatch> coverLatchList = new ArrayList<>();

    /**
     * Used to serialize search on a per MBID basis. Every thread doing
     * a search much hold a {@link CoverArtArchiveCoverLatch} and release it
     * when the search is done and the result is written. Any other threads
     * attempting to search for the same MBID will wait for the existing
     * {@link CoverArtArchiveCoverLatch} to be released, and can then use the
     * results from the previous thread instead of conducting it's own search.
     */
    private static CoverArtArchiveCoverLatch reserveCoverLatch(final String mBID) {
        CoverArtArchiveCoverLatch coverLatch = null;

        boolean owner = false;
        long startTime = System.currentTimeMillis();

        while (!owner && !Thread.currentThread().isInterrupted()) {

            // Find if any other tread is currently searching the same MBID
            synchronized (coverLatchListLock) {
                for (CoverArtArchiveCoverLatch latch : coverLatchList) {
                    if (latch.mBID.equals(mBID)) {
                        coverLatch = latch;
                        break;
                    }
                }
                // None found, our turn
                if (coverLatch == null) {
                    coverLatch = new CoverArtArchiveCoverLatch(mBID);
                    coverLatchList.add(coverLatch);
                    owner = true;
                }
            }

            // Check for timeout here instead of in the while loop make logging
            // it easier.
            if (!owner && System.currentTimeMillis() - startTime > WAIT_TIMEOUT_MS) {
                LOGGER.debug("A Cover Art Achive search timed out while waiting it's turn");
                return null;
            }

            if (!owner) {
                try {
                    coverLatch.latch.await();
                } catch (InterruptedException e) {
                    LOGGER.debug("A Cover Art Archive search was interrupted while waiting it's turn");
                    Thread.currentThread().interrupt();
                    return null;
                } finally {
                    coverLatch = null;
                }
            }
        }

        return coverLatch;
    }

    private static void releaseCoverLatch(CoverArtArchiveCoverLatch coverLatch) {
        synchronized (coverLatchListLock) {
            if (!coverLatchList.remove(coverLatch)) {
                LOGGER.error("Concurrency error: Held coverLatch not found in latchList");
            }
        }
        coverLatch.latch.countDown();
    }

    @Override
    protected byte[] doGetThumbnail(Tag tag, boolean externalNetwork) {
        String mBID = getMBID(tag, externalNetwork);
        if (mBID != null) {
            // Secure exclusive access to search for this tag
            CoverArtArchiveCoverLatch latch = reserveCoverLatch(mBID);
            if (latch == null) {
                // Couldn't reserve exclusive access, giving up
                return null;
            }
            try {
                // Check if it's cached first
                CoverArtArchiveResult result = TableCoverArtArchive.findMBID(mBID);
                if (result.found) {
                    if (result.cover != null) {
                        return result.cover;
                    } else if (System.currentTimeMillis() - result.modified.getTime() < expireTime) {
                        // If a lookup has been done within expireTime and no result,
                        // return null. Do another lookup after expireTime has passed
                        return null;
                    }
                }

                if (!externalNetwork) {
                    LOGGER.warn("Can't download cover from Cover Art Archive since external network is disabled");
                    LOGGER.info("Either enable external network or disable cover download");
                    return null;
                }

                DefaultCoverArtArchiveClient client = new DefaultCoverArtArchiveClient();

                CoverArt coverArt;
                try {
                    coverArt = client.getByMbid(UUID.fromString(mBID));
                } catch (CoverArtException e) {
                    LOGGER.debug("Could not get cover with MBID \"{}\": {}", mBID, e.getMessage());
                    LOGGER.trace("", e);
                    return null;
                }
                if (coverArt == null || coverArt.getImages().isEmpty()) {
                    LOGGER.debug("MBID \"{}\" has no cover at CoverArtArchive", mBID);
                    TableCoverArtArchive.writeMBID(mBID, null);
                    return null;
                }
                CoverArtImage image = coverArt.getFrontImage();
                if (image == null) {
                    image = coverArt.getImages().get(0);
                }
                try (InputStream is = image.getLargeThumbnail()) {
                    byte[] cover = IOUtils.toByteArray(is);
                    TableCoverArtArchive.writeMBID(mBID, cover);
                    return cover;
                } catch (HttpResponseException e) {
                    if (e.getStatusCode() == 404) {
                        LOGGER.debug("Cover for MBID \"{}\" was not found at CoverArtArchive", mBID);
                        TableCoverArtArchive.writeMBID(mBID, null);
                        return null;
                    } else {
                        LOGGER.warn(
                                "Got HTTP response {} while trying to download over for MBID \"{}\" from CoverArtArchive: {}",
                                e.getStatusCode(), mBID, e.getMessage());
                    }
                } catch (IOException e) {
                    LOGGER.error("An error occurred while downloading cover for MBID \"{}\": {}", mBID,
                            e.getMessage());
                    LOGGER.trace("", e);
                    return null;
                }
            } finally {
                releaseCoverLatch(latch);
            }
        }
        return null;
    }

    private String fuzzString(String s) {
        String[] words = s.split(" ");
        StringBuilder sb = new StringBuilder("(");
        for (String word : words) {
            sb.append(StringUtil.luceneEscape(word)).append("~ ");
        }
        sb.append(")");
        return sb.toString();
    }

    private String buildMBReleaseQuery(final CoverArtArchiveTagInfo tagInfo, final boolean fuzzy) {
        final String AND = urlEncode(" AND ");
        StringBuilder query = new StringBuilder("release/?query=");
        boolean added = false;

        if (StringUtil.hasValue(tagInfo.album)) {
            if (fuzzy) {
                query.append(urlEncode(fuzzString(tagInfo.album)));
            } else {
                query.append(urlEncode("\"" + StringUtil.luceneEscape(tagInfo.album) + "\""));
            }
            added = true;
        }

        if (StringUtil.hasValue(tagInfo.artistId)) {
            if (added) {
                query.append(AND);
            }
            query.append("arid:").append(tagInfo.artistId);
            added = true;
        } else if (StringUtil.hasValue(tagInfo.artist)) {
            if (added) {
                query.append(AND);
            }
            query.append("artistname:");
            if (fuzzy) {
                query.append(urlEncode(fuzzString(tagInfo.artist)));
            } else {
                query.append(urlEncode("\"" + StringUtil.luceneEscape(tagInfo.artist) + "\""));
            }
            added = true;
        }

        if (StringUtil.hasValue(tagInfo.trackId) && (!StringUtil.hasValue(tagInfo.album)
                || !(StringUtil.hasValue(tagInfo.artist) || StringUtil.hasValue(tagInfo.artistId)))) {
            if (added) {
                query.append(AND);
            }
            query.append("tid:").append(tagInfo.trackId);
            added = true;
        } else if (StringUtil.hasValue(tagInfo.title) && (!StringUtil.hasValue(tagInfo.album)
                || !(StringUtil.hasValue(tagInfo.artist) || StringUtil.hasValue(tagInfo.artistId)))) {
            if (added) {
                query.append(AND);
            }
            query.append("recording:");
            if (fuzzy) {
                query.append(urlEncode(fuzzString(tagInfo.title)));
            } else {
                query.append(urlEncode("\"" + StringUtil.luceneEscape(tagInfo.title) + "\""));
            }
            added = true;
        }

        if (StringUtil.hasValue(tagInfo.year)) {
            if (added) {
                query.append(AND);
            }
            query.append("date:").append(urlEncode(tagInfo.year)).append("*");
            added = true;
        }
        return query.toString();
    }

    private String buildMBRecordingQuery(final CoverArtArchiveTagInfo tagInfo, final boolean fuzzy) {
        final String AND = urlEncode(" AND ");
        StringBuilder query = new StringBuilder("recording/?query=");
        boolean added = false;

        if (StringUtil.hasValue(tagInfo.title)) {
            if (fuzzy) {
                query.append(urlEncode(fuzzString(tagInfo.title)));
            } else {
                query.append(urlEncode("\"" + StringUtil.luceneEscape(tagInfo.title) + "\""));
            }
            added = true;
        }

        if (StringUtil.hasValue(tagInfo.trackId)) {
            if (added) {
                query.append(AND);
            }
            query.append("tid:").append(tagInfo.trackId);
            added = true;
        }

        if (StringUtil.hasValue(tagInfo.artistId)) {
            if (added) {
                query.append(AND);
            }
            query.append("arid:").append(tagInfo.artistId);
            added = true;
        } else if (StringUtil.hasValue(tagInfo.artist)) {
            if (added) {
                query.append(AND);
            }
            query.append("artistname:");
            if (fuzzy) {
                query.append(urlEncode(fuzzString(tagInfo.artist)));
            } else {
                query.append(urlEncode("\"" + StringUtil.luceneEscape(tagInfo.artist) + "\""));
            }
        }

        if (StringUtil.hasValue(tagInfo.year)) {
            if (added) {
                query.append(AND);
            }
            query.append("date:").append(urlEncode(tagInfo.year)).append("*");
            added = true;
        }
        return query.toString();
    }

    private String getMBID(Tag tag, boolean externalNetwork) {
        if (tag == null) {
            return null;
        }

        // No need to look up MBID if it's already in the tag
        String mBID = null;
        if (AudioUtils.tagSupportsFieldKey(tag, FieldKey.MUSICBRAINZ_RELEASEID)) {
            mBID = tag.getFirst(FieldKey.MUSICBRAINZ_RELEASEID);
            if (StringUtil.hasValue(mBID)) {
                return mBID;
            }
        }

        DocumentBuilder builder = null;
        try {
            builder = factory.newDocumentBuilder();
        } catch (ParserConfigurationException e) {
            LOGGER.error("Error initializing XML parser: {}", e.getMessage());
            LOGGER.trace("", e);
            return null;
        }

        final CoverArtArchiveTagInfo tagInfo = new CoverArtArchiveTagInfo(tag);
        if (!tagInfo.hasInfo()) {
            LOGGER.trace("Tag has no information - aborting search");
            return null;
        }

        // Secure exclusive access to search for this tag
        CoverArtArchiveTagLatch latch = reserveTagLatch(tagInfo);
        if (latch == null) {
            // Couldn't reserve exclusive access, giving up
            LOGGER.error("Could not reserve tag latch for MBID search for \"{}\"", tagInfo);
            return null;
        }
        try {
            // Check if it's cached first
            MusicBrainzReleasesResult result = TableMusicBrainzReleases.findMBID(tagInfo);
            if (result.found) {
                if (StringUtil.hasValue(result.mBID)) {
                    return result.mBID;
                } else if (System.currentTimeMillis() - result.modified.getTime() < expireTime) {
                    // If a lookup has been done within expireTime and no result,
                    // return null. Do another lookup after expireTime has passed
                    return null;
                }
            }

            if (!externalNetwork) {
                LOGGER.warn("Can't look up cover MBID from MusicBrainz since external network is disabled");
                LOGGER.info("Either enable external network or disable cover download");
                return null;
            }

            /*
             * Rounds are defined as this:
             *
             *   1 - Exact release search
             *   2 - Fuzzy release search
             *   3 - Exact track search
             *   4 - Fuzzy track search
             *   5 - Give up
             */

            int round;
            if (StringUtil.hasValue(tagInfo.album) || StringUtil.hasValue(tagInfo.artist)
                    || StringUtil.hasValue(tagInfo.artistId)) {
                round = 1;
            } else {
                round = 3;
            }

            while (round < 5 && !StringUtil.hasValue(mBID)) {
                String query;

                if (round < 3) {
                    query = buildMBReleaseQuery(tagInfo, round > 1);
                } else {
                    query = buildMBRecordingQuery(tagInfo, round > 3);
                }

                if (query != null) {
                    final String url = "http://musicbrainz.org/ws/2/" + query;
                    if (LOGGER.isTraceEnabled()) {
                        LOGGER.trace("Performing release MBID lookup at musicbrainz: \"{}\"", url);
                    }

                    try {
                        HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
                        connection.setRequestProperty("Accept-Charset", StandardCharsets.UTF_8.name());
                        int status = connection.getResponseCode();
                        if (status != 200) {
                            LOGGER.error(
                                    "Could not lookup audio cover for \"{}\": musicbrainz.com replied with status code {}",
                                    tagInfo.title, status);
                            return null;
                        }

                        Document document;
                        try {
                            document = builder.parse(connection.getInputStream());
                        } catch (SAXException e) {
                            LOGGER.error("Failed to parse XML for \"{}\": {}", url, e.getMessage());
                            LOGGER.trace("", e);
                            return null;
                        }

                        ArrayList<ReleaseRecord> releaseList;
                        if (round < 3) {
                            releaseList = parseRelease(document, tagInfo);
                        } else {
                            releaseList = parseRecording(document, tagInfo);
                        }

                        if (releaseList != null && !releaseList.isEmpty()) {
                            // Try to find the best match - this logic can be refined if
                            // matching quality turns out to be to low
                            int maxScore = 0;
                            for (ReleaseRecord release : releaseList) {
                                if (StringUtil.hasValue(tagInfo.artist)) {
                                    boolean found = false;
                                    for (String s : release.artists) {
                                        if (s.equalsIgnoreCase(tagInfo.artist)) {
                                            found = true;
                                            break;
                                        }
                                    }
                                    if (found) {
                                        release.score += 30;
                                    }
                                }
                                if (StringUtil.hasValue(tagInfo.album)) {
                                    if (release.type == ReleaseType.Album) {
                                        release.score += 20;
                                        if (release.title.equalsIgnoreCase(tagInfo.album)) {
                                            release.score += 30;
                                        }
                                    }
                                } else if (StringUtil.hasValue(tagInfo.title)) {
                                    if ((round > 2 || release.type == ReleaseType.Single)
                                            && release.title.equalsIgnoreCase(tagInfo.title)) {
                                        release.score += 40;
                                    }
                                }
                                if (StringUtil.hasValue(tagInfo.year) && StringUtil.hasValue(release.year)) {
                                    if (tagInfo.year.equals(release.year)) {
                                        release.score += 20;
                                    }
                                }
                                maxScore = Math.max(maxScore, release.score);
                            }

                            for (ReleaseRecord release : releaseList) {
                                if (release.score == maxScore) {
                                    mBID = release.id;
                                    break;
                                }
                            }
                        }

                        if (StringUtil.hasValue(mBID)) {
                            LOGGER.trace("Music release \"{}\" found with \"{}\"", mBID, url);
                        } else {
                            LOGGER.trace("No music release found with \"{}\"", url);
                        }

                    } catch (IOException e) {
                        LOGGER.debug("Failed to find MBID for \"{}\": {}", query, e.getMessage());
                        LOGGER.trace("", e);
                        return null;
                    }
                }
                round++;
            }
            if (StringUtil.hasValue(mBID)) {
                LOGGER.debug("MusicBrainz release ID \"{}\" found for \"{}\"", mBID, tagInfo);
                TableMusicBrainzReleases.writeMBID(mBID, tagInfo);
                return mBID;
            } else {
                LOGGER.debug("No MusicBrainz release found for \"{}\"", tagInfo);
                TableMusicBrainzReleases.writeMBID(null, tagInfo);
                return null;
            }
        } finally {
            releaseTagLatch(latch);
        }
    }

    private ArrayList<ReleaseRecord> parseRelease(final Document document, final CoverArtArchiveTagInfo tagInfo) {
        NodeList nodeList = document.getDocumentElement().getElementsByTagName("release-list");
        if (nodeList.getLength() < 1) {
            return null;
        }
        Element listElement = (Element) nodeList.item(0); // release-list
        nodeList = listElement.getElementsByTagName("release");
        if (nodeList.getLength() < 1) {
            return null;
        }

        Pattern pattern = Pattern.compile("\\d{4}");
        ArrayList<ReleaseRecord> releaseList = new ArrayList<>(nodeList.getLength());
        for (int i = 0; i < nodeList.getLength(); i++) {
            if (nodeList.item(i) instanceof Element) {
                Element releaseElement = (Element) nodeList.item(i);
                ReleaseRecord release = new ReleaseRecord();
                release.id = releaseElement.getAttribute("id");
                try {
                    release.score = Integer.parseInt(releaseElement.getAttribute("ext:score"));
                } catch (NumberFormatException e) {
                    release.score = 0;
                }
                try {
                    release.title = getChildElement(releaseElement, "title").getTextContent();
                } catch (NullPointerException e) {
                    release.title = null;
                }
                Element releaseGroup = getChildElement(releaseElement, "release-group");
                if (releaseGroup != null) {
                    try {
                        release.type = ReleaseType
                                .valueOf(getChildElement(releaseGroup, "primary-type").getTextContent());
                    } catch (IllegalArgumentException | NullPointerException e) {
                        release.type = null;
                    }
                }
                Element releaseYear = getChildElement(releaseElement, "date");
                if (releaseYear != null) {
                    release.year = releaseYear.getTextContent();
                    Matcher matcher = pattern.matcher(release.year);
                    if (matcher.find()) {
                        release.year = matcher.group();
                    } else {
                        release.year = null;
                    }
                } else {
                    release.year = null;
                }
                Element artists = getChildElement(releaseElement, "artist-credit");
                if (artists != null && artists.getChildNodes().getLength() > 0) {
                    NodeList artistList = artists.getChildNodes();
                    for (int j = 0; j < artistList.getLength(); j++) {
                        Node node = artistList.item(j);
                        if (node.getNodeType() == Node.ELEMENT_NODE && node.getNodeName().equals("name-credit")
                                && node instanceof Element) {
                            Element artistElement = getChildElement((Element) node, "artist");
                            if (artistElement != null) {
                                Element artistNameElement = getChildElement(artistElement, "name");
                                if (artistNameElement != null) {
                                    release.artists.add(artistNameElement.getTextContent());
                                }
                            }

                        }
                    }
                }
                if (StringUtil.hasValue(release.id)) {
                    releaseList.add(release);
                }
            }
        }
        return releaseList;
    }

    private ArrayList<ReleaseRecord> parseRecording(final Document document, final CoverArtArchiveTagInfo tagInfo) {
        NodeList nodeList = document.getDocumentElement().getElementsByTagName("recording-list");
        if (nodeList.getLength() < 1) {
            return null;
        }
        Element listElement = (Element) nodeList.item(0); // recording-list
        nodeList = listElement.getElementsByTagName("recording");
        if (nodeList.getLength() < 1) {
            return null;
        }

        Pattern pattern = Pattern.compile("\\d{4}");
        ArrayList<ReleaseRecord> releaseList = new ArrayList<>(nodeList.getLength());
        for (int i = 0; i < nodeList.getLength(); i++) {
            if (nodeList.item(i) instanceof Element) {
                Element recordingElement = (Element) nodeList.item(i);
                ReleaseRecord releaseTemplate = new ReleaseRecord();

                try {
                    releaseTemplate.score = Integer.parseInt(recordingElement.getAttribute("ext:score"));
                } catch (NumberFormatException e) {
                    releaseTemplate.score = 0;
                }

                // A slight misuse of release.title here, we store the track name
                // here. It is accounted for in the matching logic.
                try {
                    releaseTemplate.title = getChildElement(recordingElement, "title").getTextContent();
                } catch (NullPointerException e) {
                    releaseTemplate.title = null;
                }

                Element artists = getChildElement(recordingElement, "artist-credit");
                if (artists != null && artists.getChildNodes().getLength() > 0) {
                    NodeList artistList = artists.getChildNodes();
                    for (int j = 0; j < artistList.getLength(); j++) {
                        Node node = artistList.item(j);
                        if (node.getNodeType() == Node.ELEMENT_NODE && node.getNodeName().equals("name-credit")
                                && node instanceof Element) {
                            Element artistElement = getChildElement((Element) node, "artist");
                            if (artistElement != null) {
                                Element artistNameElement = getChildElement(artistElement, "name");
                                if (artistNameElement != null) {
                                    releaseTemplate.artists.add(artistNameElement.getTextContent());
                                }
                            }

                        }
                    }
                }

                Element releaseListElement = getChildElement(recordingElement, "release-list");
                if (releaseListElement != null) {
                    NodeList releaseNodeList = releaseListElement.getElementsByTagName("release");
                    for (int j = 0; j < releaseNodeList.getLength(); j++) {
                        ReleaseRecord release = new ReleaseRecord(releaseTemplate);
                        Element releaseElement = (Element) releaseNodeList.item(j);
                        release.id = releaseElement.getAttribute("id");
                        Element releaseGroup = getChildElement(releaseElement, "release-group");
                        if (releaseGroup != null) {
                            try {
                                release.type = ReleaseType
                                        .valueOf(getChildElement(releaseGroup, "primary-type").getTextContent());
                            } catch (IllegalArgumentException | NullPointerException e) {
                                release.type = null;
                            }
                        }
                        Element releaseYear = getChildElement(releaseElement, "date");
                        if (releaseYear != null) {
                            release.year = releaseYear.getTextContent();
                            Matcher matcher = pattern.matcher(release.year);
                            if (matcher.find()) {
                                release.year = matcher.group();
                            } else {
                                release.year = null;
                            }
                        } else {
                            release.year = null;
                        }

                        if (StringUtil.hasValue(release.id)) {
                            releaseList.add(release);
                        }
                    }
                }
            }
        }
        return releaseList;
    }
}