From dbe518faaadf9a6fd6beee40c6d062a8224ef1bf Mon Sep 17 00:00:00 2001 From: Nathan Cannon Date: Sat, 30 Jan 2016 23:32:00 +0000 Subject: [PATCH] Added library and indexing functionality for a music player. --- .gitignore | 70 +++++++++ pom.xml | 42 ++++++ src/main/java/musicplayer/Application.java | 60 ++++++++ .../java/musicplayer/db/DatabaseManager.java | 73 +++++++++ src/main/java/musicplayer/db/Gateway.java | 85 +++++++++++ src/main/java/musicplayer/model/Album.java | 51 +++++++ src/main/java/musicplayer/model/Artist.java | 51 +++++++ .../musicplayer/model/ExtractedMetadata.java | 31 ++++ src/main/java/musicplayer/model/Song.java | 114 +++++++++++++++ src/main/resources/hibernate.cfg.xml | 24 +++ src/test/java/musicplayer/db/GatewayTest.java | 138 ++++++++++++++++++ 11 files changed, 739 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/musicplayer/Application.java create mode 100644 src/main/java/musicplayer/db/DatabaseManager.java create mode 100644 src/main/java/musicplayer/db/Gateway.java create mode 100644 src/main/java/musicplayer/model/Album.java create mode 100644 src/main/java/musicplayer/model/Artist.java create mode 100644 src/main/java/musicplayer/model/ExtractedMetadata.java create mode 100644 src/main/java/musicplayer/model/Song.java create mode 100644 src/main/resources/hibernate.cfg.xml create mode 100644 src/test/java/musicplayer/db/GatewayTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70ee542 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +### Java template +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +.idea/ +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +### Maven template +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties + +# Created by .ignore support plugin (hsz.mobi) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7012068 --- /dev/null +++ b/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + uk.co.neviyn + musicplayer + DEVELOPMENT + + + + + net.jthink + jaudiotagger + 2.2.3 + + + org.hibernate + hibernate-core + 5.0.7.Final + + + org.hsqldb + hsqldb + 2.3.3 + + + + + + org.jboss.repository.releases + JBoss Maven Release Repository + https://repository.jboss.org/nexus/content/repositories/releases + + + jaudiotagger-repository + https://dl.bintray.com/ijabz/maven + + + + \ No newline at end of file diff --git a/src/main/java/musicplayer/Application.java b/src/main/java/musicplayer/Application.java new file mode 100644 index 0000000..04bdcca --- /dev/null +++ b/src/main/java/musicplayer/Application.java @@ -0,0 +1,60 @@ +package musicplayer; + +import musicplayer.db.DatabaseManager; +import musicplayer.db.Gateway; +import musicplayer.model.ExtractedMetadata; +import org.jaudiotagger.audio.AudioFileIO; +import org.jaudiotagger.audio.exceptions.CannotReadException; +import org.jaudiotagger.audio.exceptions.InvalidAudioFrameException; +import org.jaudiotagger.audio.exceptions.ReadOnlyFileException; +import org.jaudiotagger.tag.Tag; +import org.jaudiotagger.tag.TagException; + +import java.io.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + + +public class Application { + + String musicFileExtensionRegex = ".*\\.(mp3|mp4|flac)"; + + public static void main(String[] args) { + new Application(); + } + + public Application(){ + DatabaseManager.init(); + } + + /** + * Walk through all files and directories recursively and index any music files with a correct extension + * @param rootDirectory Directory from which to start searching + */ + public void processSongs(Path rootDirectory){ + try { + Files.walk(rootDirectory) + .filter(f -> f.toString().matches(musicFileExtensionRegex)) + .map(this::autoParse).filter(Optional::isPresent).map(Optional::get).forEach(Gateway::addSong); + } catch (IOException e) { + e.printStackTrace(); + } + } + + /** + * Extract music metadata from the target file. + * @param targetFile Path to file to extract metadata from. + * @return Metadata contained in targetFile. + */ + public Optional autoParse(Path targetFile){ + Tag audioTags = null; + try { + audioTags = AudioFileIO.read(targetFile.toFile()).getTag(); + } catch (CannotReadException | IOException | ReadOnlyFileException | TagException | InvalidAudioFrameException e) { + e.printStackTrace(); + } + return audioTags == null ? Optional.empty() : Optional.of(new ExtractedMetadata(audioTags, targetFile.toFile())); + } +} diff --git a/src/main/java/musicplayer/db/DatabaseManager.java b/src/main/java/musicplayer/db/DatabaseManager.java new file mode 100644 index 0000000..205756b --- /dev/null +++ b/src/main/java/musicplayer/db/DatabaseManager.java @@ -0,0 +1,73 @@ +package musicplayer.db; + +import musicplayer.model.Album; +import musicplayer.model.Artist; +import musicplayer.model.Song; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.cfg.Configuration; + +import java.util.Properties; + + +/** + * Singleton for managing connection to Hibernate. + */ +public class DatabaseManager { + private static DatabaseManager ourInstance = new DatabaseManager(); + + private SessionFactory sessionFactory; + + public static DatabaseManager getInstance() { + return ourInstance; + } + + private DatabaseManager() { + } + + /** + * Open a SessionFactory to the database as defined in hibernate.cfg.xml + */ + public static void init(){ + if(getInstance().sessionFactory != null) + getInstance().sessionFactory.close(); + getInstance().sessionFactory = new Configuration().configure() + .addAnnotatedClass(Album.class) + .addAnnotatedClass(Artist.class) + .addAnnotatedClass(Song.class) + .buildSessionFactory(); + } + + /** + * Open a SessionFactory to an in-memory database for testing + */ + public static void testMode(){ + if(getInstance().sessionFactory != null) + getInstance().sessionFactory.close(); + Properties properties = new Properties(); + properties.put("hibernate.dialect", "org.hibernate.dialect.HSQLDialect"); + properties.put("hibernate.connection.driver_class", "org.hsqldb.jdbc.JDBCDriver"); + properties.put("hibernate.connection.url", "jdbc:hsqldb:mem:."); + properties.put("hibernate.hbm2ddl.auto", "create-drop"); + getInstance().sessionFactory = new Configuration() + .addProperties(properties) + .addAnnotatedClass(Album.class) + .addAnnotatedClass(Artist.class) + .addAnnotatedClass(Song.class) + .buildSessionFactory(); + } + + + /** + * @return Session for database interaction. + */ + public Session getSession(){ + return sessionFactory.openSession(); + } + + @SuppressWarnings("CloneDoesntCallSuperClone") + @Override + public Object clone() throws CloneNotSupportedException{ + throw new CloneNotSupportedException(); + } +} diff --git a/src/main/java/musicplayer/db/Gateway.java b/src/main/java/musicplayer/db/Gateway.java new file mode 100644 index 0000000..d4022e8 --- /dev/null +++ b/src/main/java/musicplayer/db/Gateway.java @@ -0,0 +1,85 @@ +package musicplayer.db; + +import musicplayer.model.Album; +import musicplayer.model.Artist; +import musicplayer.model.ExtractedMetadata; +import musicplayer.model.Song; +import org.hibernate.Criteria; +import org.hibernate.Session; +import org.hibernate.criterion.Restrictions; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Contains database queries. + */ +public class Gateway { + + /** + * @param name Name of the album to find. + * @return Album found with name or Optional.empty() + */ + public static Optional getOneAlbum(String name){ + try(Session session = DatabaseManager.getInstance().getSession()){ + Criteria criteria = session.createCriteria(Album.class); + Album album = (Album)criteria.add(Restrictions.eq("name", name)).uniqueResult(); + return (album == null) ? Optional.empty() : Optional.of(album); + } + } + + /** + * @param name Name of the artist to find. + * @return Artist found with name or Optional.empty() + */ + public static Optional getOneArtist(String name){ + try(Session session = DatabaseManager.getInstance().getSession()){ + Criteria criteria = session.createCriteria(Artist.class); + Artist artist = (Artist) criteria.add(Restrictions.eq("name", name)).uniqueResult(); + return (artist == null) ? Optional.empty() : Optional.of(artist); + } + } + + /** + * @return List of all songs currently stored in the database. + */ + public static Optional> listAllSongs(){ + try(Session session = DatabaseManager.getInstance().getSession()){ + @SuppressWarnings("unchecked") + List songs = session.createCriteria(Song.class).list(); + return (songs == null || songs.isEmpty()) ? Optional.empty() : Optional.of(songs); + } + } + + /** + * @return All songs currently in the database, grouped by album. + */ + public static Optional>> listAllSongsGroupedByAlbum(){ + Optional> songList = listAllSongs(); + return (songList.isPresent()) ? + Optional.of(songList.get().stream().collect(Collectors.groupingBy(Song::getAlbum))) + : Optional.empty(); + } + + /** + * Add a new song to the database. + * @param metadata New song information. + */ + public static void addSong(ExtractedMetadata metadata){ + try(Session session = DatabaseManager.getInstance().getSession()) { + Optional albumObj = getOneAlbum(metadata.album); + if(!albumObj.isPresent()) + albumObj = Optional.of(new Album(metadata.album)); + Optional artistObj = getOneArtist(metadata.artist); + if(!artistObj.isPresent()) + artistObj = Optional.of(new Artist(metadata.artist)); + session.beginTransaction(); + session.save(new Song(metadata.trackNumber, metadata.title, artistObj.get(), albumObj.get(), metadata.genre, metadata.songFile)); + session.getTransaction().commit(); + } + } + + +} diff --git a/src/main/java/musicplayer/model/Album.java b/src/main/java/musicplayer/model/Album.java new file mode 100644 index 0000000..758936d --- /dev/null +++ b/src/main/java/musicplayer/model/Album.java @@ -0,0 +1,51 @@ +package musicplayer.model; + +import javax.persistence.*; + +@Entity +public class Album { + + @Id + @GeneratedValue(strategy=GenerationType.AUTO) + @Column(name = "id") + private long id; + @Column(name = "name") + private String name; + + protected Album(){} + + public Album(String name){ + this.name = name; + } + + public String toString(){ + return String.format("ID: %d, Name: %s", id, name); + } + + @SuppressWarnings("MethodWithMultipleReturnPoints") + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Album album = (Album) o; + + return id == album.id && name.equals(album.name); + + } + + @Override + public int hashCode() { + int result = (int) (id ^ (id >>> 32)); + result = 31 * result + name.hashCode(); + return result; + } + + public String getName(){ + return name; + } + + public long getId(){ + return id; + } +} diff --git a/src/main/java/musicplayer/model/Artist.java b/src/main/java/musicplayer/model/Artist.java new file mode 100644 index 0000000..bb4b5c5 --- /dev/null +++ b/src/main/java/musicplayer/model/Artist.java @@ -0,0 +1,51 @@ +package musicplayer.model; + +import javax.persistence.*; + +@Entity +public class Artist { + + @Id + @GeneratedValue(strategy=GenerationType.AUTO) + @Column(name = "id") + private long id; + @Column(name = "name") + private String name; + + protected Artist(){} + + public Artist(String name){ + this.name = name; + } + + @Override + public int hashCode() { + int result = (int) (id ^ (id >>> 32)); + result = 31 * result + name.hashCode(); + return result; + } + + public String toString(){ + return String.format("ID: %d, Name: %s", id, name); + } + + @SuppressWarnings("MethodWithMultipleReturnPoints") + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Artist artist = (Artist) o; + + return id == artist.id && name.equals(artist.name); + + } + + public String getName(){ + return name; + } + + public long getId(){ + return id; + } +} diff --git a/src/main/java/musicplayer/model/ExtractedMetadata.java b/src/main/java/musicplayer/model/ExtractedMetadata.java new file mode 100644 index 0000000..84fc2db --- /dev/null +++ b/src/main/java/musicplayer/model/ExtractedMetadata.java @@ -0,0 +1,31 @@ +package musicplayer.model; + +import org.jaudiotagger.tag.FieldKey; +import org.jaudiotagger.tag.Tag; + +import java.io.File; + +/** + * Internal class representing metadata extracted from a music file. + */ +public class ExtractedMetadata { + public final String title; + public final String album; + public final String artist; + public final String genre; + public final String songFile; + public final String trackNumber; + + /** + * @param audioTags jaudiotagger tag data. + * @param songFile Location of the song file on the filesystem. + */ + public ExtractedMetadata(Tag audioTags, File songFile){ + this.trackNumber = audioTags.getFirst(FieldKey.TRACK); + this.title = audioTags.getFirst(FieldKey.TITLE); + this.album = audioTags.getFirst(FieldKey.ALBUM); + this.artist = audioTags.getFirst(FieldKey.ARTIST); + this.genre = audioTags.getFirst(FieldKey.GENRE); + this.songFile = songFile.getAbsolutePath(); + } +} diff --git a/src/main/java/musicplayer/model/Song.java b/src/main/java/musicplayer/model/Song.java new file mode 100644 index 0000000..431b42f --- /dev/null +++ b/src/main/java/musicplayer/model/Song.java @@ -0,0 +1,114 @@ +package musicplayer.model; + +import javax.imageio.ImageIO; +import javax.persistence.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +@Entity +public class Song { + + @Id + @GeneratedValue(strategy=GenerationType.AUTO) + @Column(name = "id") + private long id; + @ManyToOne(fetch=FetchType.EAGER,cascade=CascadeType.ALL) + private Artist artist; + @Column(name = "genre") + private String genre; + @Column(name = "title") + private String title; + @ManyToOne(fetch=FetchType.EAGER,cascade=CascadeType.ALL) + private Album album; + @Column(name = "songFile") + private String songFile; + @Column(name = "trackNumber") + private String trackNumber; + + protected Song(){} + + public Song(String trackNumber, String title, Artist artist, Album album, String genre, String songFile){ + this.trackNumber = trackNumber; + this.title = title; + this.artist = artist; + this.genre = genre; + this.album = album; + this.songFile = songFile; + } + + @Override + public String toString(){ + return String.format("Artist: %s, Title: %s, Album: %s, Genre: %s", artist.getName(), title, album.getName(), genre); + } + + @SuppressWarnings("MethodWithMultipleReturnPoints") + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Song song = (Song) o; + + return id == song.id && artist.equals(song.artist) && genre.equals(song.genre) && title.equals(song.title) + && album.equals(song.album) && songFile.equals(song.songFile) && trackNumber.equals(song.trackNumber); + + } + + @Override + public int hashCode() { + int result = (int) (id ^ (id >>> 32)); + result = 31 * result + artist.hashCode(); + result = 31 * result + genre.hashCode(); + result = 31 * result + title.hashCode(); + result = 31 * result + album.hashCode(); + result = 31 * result + songFile.hashCode(); + result = 31 * result + trackNumber.hashCode(); + return result; + } + + /** + * Try to find album art for this song based on likely image file names in the folder. + * @return BufferedImage of album art or Optional.empty() + */ + public Optional getAlbumArt(){ + try { + Optional imageFile = Files.walk(getSongFile().getParentFile().toPath()) + .filter(f -> f.toString().matches("(Folder|Cover).jpg")).findFirst(); + return (imageFile.isPresent()) ? Optional.of(ImageIO.read(imageFile.get().toFile())) : Optional.empty(); + } catch (IOException e) { + return Optional.empty(); + } + } + + public long getId() { + return id; + } + + public Artist getArtist() { + return artist; + } + + public String getGenre() { + return genre; + } + + public String getTitle() { + return title; + } + + public Album getAlbum() { + return album; + } + + public File getSongFile() { + return new File(songFile); + } + + public String getTrackNumber() { + return trackNumber; + } +} diff --git a/src/main/resources/hibernate.cfg.xml b/src/main/resources/hibernate.cfg.xml new file mode 100644 index 0000000..16f8bea --- /dev/null +++ b/src/main/resources/hibernate.cfg.xml @@ -0,0 +1,24 @@ + + + + + + org.hibernate.dialect.HSQLDialect + + + org.hsqldb.jdbc.JDBCDriver + + + jdbc:hsqldb:file:E:/dev/testdb;shutdown=true + + + root + + + root123 + + + update + + + \ No newline at end of file diff --git a/src/test/java/musicplayer/db/GatewayTest.java b/src/test/java/musicplayer/db/GatewayTest.java new file mode 100644 index 0000000..894400a --- /dev/null +++ b/src/test/java/musicplayer/db/GatewayTest.java @@ -0,0 +1,138 @@ +package musicplayer.db; + +import musicplayer.model.Album; +import musicplayer.model.Artist; +import musicplayer.model.ExtractedMetadata; +import musicplayer.model.Song; +import org.hibernate.Session; +import org.jaudiotagger.tag.FieldKey; +import org.jaudiotagger.tag.Tag; +import org.jaudiotagger.tag.id3.ID3v1Tag; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class GatewayTest { + + private Session session; + + @Before + public void setUp(){ + DatabaseManager.testMode(); + session = DatabaseManager.getInstance().getSession(); + } + + @After + public void tearDown(){ + session.close(); + session = null; + } + + @Test + public void testGetOneAlbum() throws Exception { + String albumName = "Test Album"; + Album testAlbum = new Album(albumName); + session.beginTransaction(); + session.save(testAlbum); + session.getTransaction().commit(); + assertTrue(Gateway.getOneAlbum(albumName).isPresent()); + Album retrievedAlbum = Gateway.getOneAlbum(albumName).get(); + assertEquals(testAlbum, retrievedAlbum); + } + + @Test + public void testGetOneAlbumEmptyDB() throws Exception { + assertTrue(!Gateway.getOneAlbum("").isPresent()); + } + + @Test + public void testGetOneArtist() throws Exception { + String artistName = "Test Artist"; + Artist testArtist = new Artist(artistName); + session.beginTransaction(); + session.save(testArtist); + session.getTransaction().commit(); + assertTrue(Gateway.getOneArtist(artistName).isPresent()); + Artist retrievedArtist = Gateway.getOneArtist(artistName).get(); + assertEquals(testArtist, retrievedArtist); + } + + @Test + public void testGetOneArtistEmptyDB() throws Exception { + assertTrue(!Gateway.getOneArtist("").isPresent()); + } + + @Test + public void testListAllSongs() throws Exception { + Song song1 = new Song("1", "s1", new Artist("a"), new Album("a"), "", ""); + Song song2 = new Song("2", "s2", new Artist("b"), new Album("a"), "", ""); + Song song3 = new Song("1", "t1", new Artist("c"), new Album("b"), "", ""); + session.beginTransaction(); + session.save(song1); + session.save(song2); + session.save(song3); + session.getTransaction().commit(); + assertTrue(Gateway.listAllSongs().isPresent()); + List result = Gateway.listAllSongs().get(); + assertTrue(result.size() == 3); + assertTrue(result.contains(song1)); + assertTrue(result.contains(song2)); + assertTrue(result.contains(song3)); + } + + @Test + public void testListAllSongsEmptyDB() throws Exception { + assertTrue(!Gateway.listAllSongs().isPresent()); + } + + @Test + public void testListAllSongsGroupedByAlbum() throws Exception { + Album album1 = new Album("Test 1"); + Album album2 = new Album("Test 2"); + Song song1 = new Song("1", "s1", new Artist("a"), album1, "", ""); + Song song2 = new Song("2", "s2", new Artist("b"), album1, "", ""); + Song song3 = new Song("1", "t1", new Artist("c"), album2, "", ""); + session.beginTransaction(); + session.save(song1); + session.save(song2); + session.save(song3); + session.getTransaction().commit(); + assertTrue(Gateway.listAllSongsGroupedByAlbum().isPresent()); + Map> result = Gateway.listAllSongsGroupedByAlbum().get(); + assertTrue(result.size() == 2); + assertTrue(result.get(album1).size() == 2); + assertTrue(result.get(album2).size() == 1); + assertTrue(result.get(album1).contains(song1)); + assertTrue(result.get(album1).contains(song2)); + assertTrue(result.get(album2).contains(song3)); + } + + @Test + public void testListAllSongsGroupedByAlbumEmptyDB() throws Exception { + assertTrue(!Gateway.listAllSongsGroupedByAlbum().isPresent()); + } + + @Test + public void testAddSong() throws Exception { + Tag tags = new ID3v1Tag(); + tags.addField(FieldKey.ALBUM, "Test Album"); + tags.addField(FieldKey.ARTIST, "Test Artist"); + tags.addField(FieldKey.TRACK, "1"); + tags.addField(FieldKey.TITLE, "Test Song"); + tags.addField(FieldKey.GENRE, "Test Genre"); + ExtractedMetadata metadata = new ExtractedMetadata(tags, new File("")); + Gateway.addSong(metadata); + Song song = (Song)session.createCriteria(Song.class).uniqueResult(); + assertEquals(song.getAlbum().getName(), metadata.album); + assertEquals(song.getArtist().getName(), metadata.artist); + assertEquals(song.getTrackNumber(), metadata.trackNumber); + assertEquals(song.getTitle(), metadata.title); + assertEquals(song.getGenre(), metadata.genre); + } +} \ No newline at end of file