/* * Universal Media Server, for streaming any media 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 final 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 + "&fmt=xml"; 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.org 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; } finally { connection.getInputStream().close(); } 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()); int nodeListLength = nodeList.getLength(); for (int i = 0; i < nodeListLength; 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"); int releaseNodeListLength = releaseNodeList.getLength(); for (int j = 0; j < releaseNodeListLength; 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; } }