diff --git a/pom.xml b/pom.xml index e8cbd5a..c2cc454 100644 --- a/pom.xml +++ b/pom.xml @@ -109,5 +109,24 @@ test + + + com.google.guava + guava + 27.1-jre + + + + + org.xerial + sqlite-jdbc + 3.27.2.1 + + + org.jetbrains + annotations + 17.0.0 + compile + \ No newline at end of file diff --git a/src/main/java/net/simon987/musicgraph/Main.java b/src/main/java/net/simon987/musicgraph/Main.java index d00d706..b572185 100644 --- a/src/main/java/net/simon987/musicgraph/Main.java +++ b/src/main/java/net/simon987/musicgraph/Main.java @@ -1,6 +1,9 @@ package net.simon987.musicgraph; +import net.simon987.musicgraph.io.*; import net.simon987.musicgraph.logging.LogManager; +import net.simon987.musicgraph.webapi.ArtistController; +import net.simon987.musicgraph.webapi.CoverController; import net.simon987.musicgraph.webapi.Index; import org.glassfish.grizzly.http.server.HttpServer; import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; @@ -12,12 +15,10 @@ import java.util.logging.Logger; public class Main { - private final static Logger LOGGER = LogManager.getLogger(); + private final static Logger logger = LogManager.getLogger(); public static void main(String[] args) { - LOGGER.info("test"); - startHttpServer(); } @@ -25,8 +26,12 @@ public class Main { ResourceConfig rc = new ResourceConfig(); - rc.registerClasses(Index.class); + rc.registerInstances(new MusicDatabase()); + rc.registerInstances(new SQLiteCoverArtDatabase("covers.db")); + rc.registerClasses(Index.class); + rc.registerClasses(ArtistController.class); + rc.registerClasses(CoverController.class); rc.registerClasses(JacksonFeature.class); try { diff --git a/src/main/java/net/simon987/musicgraph/entities/Artist.java b/src/main/java/net/simon987/musicgraph/entities/Artist.java new file mode 100644 index 0000000..43c27aa --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/entities/Artist.java @@ -0,0 +1,29 @@ +package net.simon987.musicgraph.entities; + + +import java.util.List; + +public class Artist { + + public Long id; + public String mbid; + public String name; + public List labels; + public int listeners; + public int playCount; + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Artist) { + return ((Artist) obj).id.equals(id); + } + return false; + } +} + + diff --git a/src/main/java/net/simon987/musicgraph/entities/ArtistDetails.java b/src/main/java/net/simon987/musicgraph/entities/ArtistDetails.java new file mode 100644 index 0000000..fcf1d40 --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/entities/ArtistDetails.java @@ -0,0 +1,16 @@ +package net.simon987.musicgraph.entities; + +import java.util.ArrayList; +import java.util.List; + +public class ArtistDetails { + + public ArtistDetails() { + releases = new ArrayList<>(); + tags = new ArrayList<>(); + } + + public String name; + public List releases; + public List tags; +} diff --git a/src/main/java/net/simon987/musicgraph/entities/Relation.java b/src/main/java/net/simon987/musicgraph/entities/Relation.java new file mode 100644 index 0000000..dadee2e --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/entities/Relation.java @@ -0,0 +1,8 @@ +package net.simon987.musicgraph.entities; + + +public class Relation { + public float weight; + public long source; + public long target; +} diff --git a/src/main/java/net/simon987/musicgraph/entities/SearchResult.java b/src/main/java/net/simon987/musicgraph/entities/SearchResult.java new file mode 100644 index 0000000..09399c4 --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/entities/SearchResult.java @@ -0,0 +1,18 @@ +package net.simon987.musicgraph.entities; + + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class SearchResult { + + public Set artists; + public List relations; + + public SearchResult() { + artists = new HashSet<>(); + relations = new ArrayList<>(); + } +} diff --git a/src/main/java/net/simon987/musicgraph/entities/Tag.java b/src/main/java/net/simon987/musicgraph/entities/Tag.java new file mode 100644 index 0000000..2d4be31 --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/entities/Tag.java @@ -0,0 +1,12 @@ +package net.simon987.musicgraph.entities; + +public class Tag { + public String name; + public long weight; + + + public Tag(String name, long weight) { + this.name = name; + this.weight = weight; + } +} diff --git a/src/main/java/net/simon987/musicgraph/io/ICoverArtDatabase.java b/src/main/java/net/simon987/musicgraph/io/ICoverArtDatabase.java new file mode 100644 index 0000000..2347444 --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/io/ICoverArtDatabase.java @@ -0,0 +1,11 @@ +package net.simon987.musicgraph.io; + +public interface ICoverArtDatabase { + + /** + * @param mbid MusicBrainz id + * @return null if not found + * @throws Exception if unexpected error + */ + byte[] getCover(String mbid) throws Exception; +} diff --git a/src/main/java/net/simon987/musicgraph/io/MusicDatabase.java b/src/main/java/net/simon987/musicgraph/io/MusicDatabase.java new file mode 100644 index 0000000..dc056b5 --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/io/MusicDatabase.java @@ -0,0 +1,158 @@ +package net.simon987.musicgraph.io; + +import com.google.common.collect.ImmutableList; +import net.simon987.musicgraph.entities.*; +import net.simon987.musicgraph.logging.LogManager; +import org.glassfish.jersey.internal.inject.AbstractBinder; +import org.neo4j.driver.v1.*; +import org.neo4j.driver.v1.types.Node; +import org.neo4j.driver.v1.types.Relationship; + +import javax.inject.Singleton; +import java.util.*; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +@Singleton +public class MusicDatabase extends AbstractBinder { + + private Driver driver; + + private static Logger logger = LogManager.getLogger(); + + @Override + protected void configure() { + bind(this).to(MusicDatabase.class); + } + + public MusicDatabase() { + driver = GraphDatabase.driver("bolt://localhost:7687", + AuthTokens.basic("neo4j", "neo4j")); + } + + private StatementResult query(Session session, String query, Map args) { + + long start = System.nanoTime(); + StatementResult result = session.run(query, args); + long end = System.nanoTime(); + long took = (end - start) / 1000000; + + logger.info(String.format("Query %s (Took %dms)", query, took)); + + return result; + } + + public ArtistDetails getArtistDetails(String mbid) { + + Map params = new HashMap<>(); + params.put("mbid", mbid); + + try (Session session = driver.session()) { + + StatementResult result = query(session, + "MATCH (a:Artist {id: $mbid})-[:CREDITED_FOR]->(r:Release)\n" + + "WITH collect(r.id) as releases, a\n" + + "OPTIONAL MATCH (a)-[r:IS_TAGGED]->(t:Tag)\n" + + "RETURN a {name:a.name, releases:releases, tags:collect({weight: r.weight, name: t.name})}\n" + + "LIMIT 1", + params); + + ArtistDetails details = new ArtistDetails(); + + try { + + if (result.hasNext()) { + Map map = result.next().get("a").asMap(); + + details.name = (String) map.get("name"); + details.releases.addAll((Collection) map.get("releases")); + details.tags.addAll( + ((List) map.get("tags")) + .stream() + .filter(x -> x.get("name") != null) + .map(x -> new Tag( + (String) x.get("name"), + (Long) x.get("weight") + )) + .collect(Collectors.toList()) + ); + } + } catch (Exception e) { + e.printStackTrace(); + } + + return details; + } + } + + public SearchResult getRelated(String mbid) { + + try (Session session = driver.session()) { + + Map params = new HashMap<>(); + params.put("mbid", mbid); + + StatementResult result = query(session, + "MATCH (a:Artist)-[r:IS_RELATED_TO]-(b)\n" + + "WHERE a.id = $mbid\n" + + // Only match artists with > 0 releases + "MATCH (b)-[:CREDITED_FOR]->(:Release)\n" + + "WHERE r.weight > 0.25\n" + + "RETURN a as artist, a {rels: collect(DISTINCT r), nodes: collect(DISTINCT b)} as rank1\n" + + "LIMIT 1", + params); + + SearchResult out = new SearchResult(); + + long start = System.nanoTime(); + while (result.hasNext()) { + Record row = result.next(); + out.artists.add(makeArtist(row.get(0).asNode())); + + var rank1 = row.get(1).asMap(); + + out.relations.addAll(((List) rank1.get("rels")) + .stream() + .map(MusicDatabase::makeRelation) + .collect(Collectors.toList() + )); + out.artists.addAll(((List) rank1.get("nodes")) + .stream() + .map(MusicDatabase::makeArtist) + .collect(Collectors.toList() + )); + + } + long end = System.nanoTime(); + long took = (end - start) / 1000000; + logger.info(String.format("Fetched search result (Took %dms)", took)); + + return out; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + private static Artist makeArtist(Node node) { + Artist artist = new Artist(); + artist.id = node.id(); + artist.mbid = node.get("id").asString(); + artist.name = node.get("name").asString(); + artist.labels = ImmutableList.copyOf(node.labels()); + artist.listeners = node.get("listeners").asInt(); + artist.playCount = node.get("playcount").asInt(); + return artist; + } + + private static Relation makeRelation(Relationship rel) { + Relation relation = new Relation(); + + relation.source = rel.startNodeId(); + relation.target = rel.endNodeId(); + relation.weight = rel.get("weight").asFloat(); + + return relation; + } + +} diff --git a/src/main/java/net/simon987/musicgraph/io/SQLiteCoverArtDatabase.java b/src/main/java/net/simon987/musicgraph/io/SQLiteCoverArtDatabase.java new file mode 100644 index 0000000..5888e77 --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/io/SQLiteCoverArtDatabase.java @@ -0,0 +1,61 @@ +package net.simon987.musicgraph.io; + +import net.simon987.musicgraph.logging.LogManager; +import org.glassfish.jersey.internal.inject.AbstractBinder; + +import java.sql.*; +import java.util.logging.Logger; + +public class SQLiteCoverArtDatabase extends AbstractBinder implements ICoverArtDatabase { + + private String dbFile; + private Connection connection; + + private static Logger logger = LogManager.getLogger(); + + public SQLiteCoverArtDatabase(String dbFile) { + this.dbFile = dbFile; + } + + @Override + protected void configure() { + bind(this).to(ICoverArtDatabase.class); + } + + public byte[] getCover(String mbid) throws SQLException { + + try { + setupConn(); + + PreparedStatement stmt = connection.prepareStatement("SELECT cover FROM covers WHERE id=?"); + stmt.setString(1, mbid); + ResultSet rs = stmt.executeQuery(); + + if (rs.next()) { + byte[] bytes = rs.getBytes(1); + rs.close(); + return bytes; + } else { + rs.close(); + return null; + } + + } catch (SQLException e) { + logger.severe(String.format("Exception during cover art query mbid=%s ex=%s", + mbid, e.getMessage())); + throw e; + } + } + + private void setupConn() throws SQLException { + try { + if (connection == null || connection.isClosed()) { + logger.fine("Connecting to SQLite cover art DB"); + connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile); + } + } catch (SQLException e) { + logger.fine("Connecting to SQLite cover art DB"); + connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile); + } + } +} diff --git a/src/main/java/net/simon987/musicgraph/logging/LogManager.java b/src/main/java/net/simon987/musicgraph/logging/LogManager.java index 321b6d9..e4328cf 100644 --- a/src/main/java/net/simon987/musicgraph/logging/LogManager.java +++ b/src/main/java/net/simon987/musicgraph/logging/LogManager.java @@ -1,6 +1,7 @@ package net.simon987.musicgraph.logging; import java.util.logging.Handler; +import java.util.logging.Level; import java.util.logging.Logger; public class LogManager { @@ -14,6 +15,7 @@ public class LogManager { logger.setUseParentHandlers(false); logger.addHandler(handler); + logger.setLevel(Level.ALL); } public static Logger getLogger() { diff --git a/src/main/java/net/simon987/musicgraph/webapi/ArtistController.java b/src/main/java/net/simon987/musicgraph/webapi/ArtistController.java new file mode 100644 index 0000000..b086b15 --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/webapi/ArtistController.java @@ -0,0 +1,41 @@ +package net.simon987.musicgraph.webapi; + +import net.simon987.musicgraph.entities.ArtistDetails; +import net.simon987.musicgraph.io.MusicDatabase; +import net.simon987.musicgraph.entities.SearchResult; +import net.simon987.musicgraph.logging.LogManager; + +import javax.inject.Inject; +import javax.ws.rs.*; +import javax.ws.rs.core.MediaType; +import java.util.logging.Logger; + +@Path("/artist") +public class ArtistController { + + private static Logger logger = LogManager.getLogger(); + + @Inject + private MusicDatabase db; + + public ArtistController() { + } + + @GET + @Path("related/{mbid}") + @Produces(MediaType.APPLICATION_JSON) + public SearchResult getRelated(@PathParam("mbid") String mbid) { + + logger.info(String.format("Searching for %s", mbid)); + return db.getRelated(mbid); + } + + @GET + @Path("details/{mbid}") + @Produces(MediaType.APPLICATION_JSON) + public ArtistDetails getDetails(@PathParam("mbid") String mbid) { + + logger.info(String.format("Details for %s", mbid)); + return db.getArtistDetails(mbid); + } +} diff --git a/src/main/java/net/simon987/musicgraph/webapi/CoverController.java b/src/main/java/net/simon987/musicgraph/webapi/CoverController.java new file mode 100644 index 0000000..511840b --- /dev/null +++ b/src/main/java/net/simon987/musicgraph/webapi/CoverController.java @@ -0,0 +1,39 @@ +package net.simon987.musicgraph.webapi; + +import net.simon987.musicgraph.io.ICoverArtDatabase; +import net.simon987.musicgraph.logging.LogManager; + +import javax.inject.Inject; +import javax.ws.rs.*; +import java.util.logging.Logger; + +@Path("/cover/{mbid}") +public class CoverController { + + private static Logger logger = LogManager.getLogger(); + + @Inject + private ICoverArtDatabase db; + + public CoverController() { + } + + @GET + @Produces("image/jpg") + public byte[] getByReleaseId(@PathParam("mbid") String mbid) throws Exception { + + if (mbid == null) { + throw new BadRequestException(); + } + + logger.info(String.format("Cover for %s", mbid)); + + byte[] cover = db.getCover(mbid); + + if (cover == null) { + throw new NotFoundException(); + } + + return cover; + } +}