diff --git a/interface/build.gradle b/interface/build.gradle index a2e9894..fbaefb6 100644 --- a/interface/build.gradle +++ b/interface/build.gradle @@ -21,6 +21,8 @@ dependencies { implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.13.3' implementation 'com.google.code.gson:gson:2.8.7' + implementation 'net.harawata:appdirs:1.2.1' + implementation 'org.xerial:sqlite-jdbc:3.36.0.1' implementation 'com.googlecode.soundlibs:jlayer:1.0.1.4' implementation 'com.mpatric:mp3agic:0.9.1' implementation project(":libwave") diff --git a/interface/src/main/java/edu/regis/universeplayer/data/LocalSongProvider.java b/interface/src/main/java/edu/regis/universeplayer/data/LocalSongProvider.java index 927564c..20899ed 100644 --- a/interface/src/main/java/edu/regis/universeplayer/data/LocalSongProvider.java +++ b/interface/src/main/java/edu/regis/universeplayer/data/LocalSongProvider.java @@ -4,15 +4,15 @@ package edu.regis.universeplayer.data; +import edu.regis.universeplayer.player.Interface; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; +import java.sql.*; import java.util.*; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -24,6 +24,7 @@ public class LocalSongProvider implements SongProvider private static final HashSet codecs = new HashSet<>(); private final File source; + private Connection db; private final HashSet songs = new HashSet<>(); private final HashMap albums = new HashMap<>(); @@ -151,12 +152,11 @@ public class LocalSongProvider implements SongProvider return codecs; } - private class SongScanner implements Runnable + private class SongScanner extends RecursiveAction { - private static final int serviceThreads = Math.max(Runtime.getRuntime().availableProcessors() - 2, 1); - private static final ExecutorService service = Executors.newFixedThreadPool(serviceThreads); + private static final ForkJoinPool service = new ForkJoinPool(); private static String currentFolder; - + private final File file; SongScanner(File folder) @@ -165,7 +165,7 @@ public class LocalSongProvider implements SongProvider } @Override - public void run() + public void compute() { Process process; String line; @@ -191,12 +191,11 @@ public class LocalSongProvider implements SongProvider if (file.isDirectory()) { totalUpdate--; - for (File subFile : Objects.requireNonNull(file.listFiles())) - { - totalUpdate++; - triggerUpdateListeners(); - service.submit(new SongScanner(subFile)); - } + List tasks = Arrays.stream(Objects.requireNonNullElse(file.listFiles(), new File[0])) + .map(SongScanner::new).collect(Collectors.toList()); + totalUpdate += tasks.size(); + triggerUpdateListeners(); + invokeAll(tasks); } else if (file.getName().lastIndexOf(".") < file.getName().length() - 1) { @@ -421,7 +420,7 @@ public class LocalSongProvider implements SongProvider } logger.debug("Caching song {} ({})", song, song.file); - + updatedSongs++; triggerUpdateListeners(); synchronized (songs) @@ -471,37 +470,207 @@ public class LocalSongProvider implements SongProvider } } + private Connection getDb() + { + SQLWarning warning; + try + { + if (this.db == null || this.db.isClosed()) + { + Class.forName("org.sqlite.JDBC"); + this.db = DriverManager.getConnection("jdbc:sqlite:" + new File(Interface.getDataDir().getAbsolutePath(), "universalmusic.db").getAbsolutePath()); + this.db.setAutoCommit(false); + } + warning = this.db.getWarnings(); + while (warning != null) + { + logger.warn("SQL Warning: ", warning); + warning = warning.getNextWarning(); + } + } + catch (SQLException | ClassNotFoundException e) + { + logger.error("Could not store caching DIR."); + } + return this.db; + } + public LocalSongProvider(File source) { + Connection dbL = null; this.source = source; if (this.source == null || !this.source.isDirectory()) { throw new IllegalArgumentException("File source must be existing directory"); } - // TODO - Add some sort of caching system - totalUpdate = 1; + this.getDb(); + SongScanner.service.submit(() -> { + Statement state; + ResultSet result; + Album album; + LocalSong song; + + try + { + logger.debug("Querying database."); + state = this.getDb().createStatement(); + result = state.executeQuery("SELECT * FROM LOCAL_ALBUMS;"); + while (result.next()) + { + album = new Album(); + album.name = result.getString("album"); + album.artists = Optional.ofNullable(result.getString("artists")).map(s -> s.split(";")).orElse(new String[0]); + album.year = result.getInt("year"); + album.genres = Optional.ofNullable(result.getString("genres")).map(s -> s.split(";")).orElse(new String[0]); + album.totalTracks = result.getInt("tracks"); + album.totalDiscs = result.getInt("discs"); + albums.put(album.name, album); + } + result = state.executeQuery("SELECT * FROM LOCAL_SONGS;"); + while (result.next()) + { + song = new LocalSong(); + song.file = new File(result.getString("file")); + song.codec = result.getString("codec"); + song.type = result.getString("type"); + song.title = result.getString("title"); + song.artists = Optional.ofNullable(result.getString("artists")).map(s -> s.split(";")).orElse(new String[0]); + song.trackNum = result.getInt("track"); + song.disc = result.getInt("disc"); + song.duration = result.getLong("duration"); + song.album = Optional.ofNullable(result.getString("album")).map(albums::get).orElse(null); + songs.add(song); + } + result.close(); + state.close(); + } + catch (SQLException e) + { + logger.error("Could not query SQL database.", e); + } + if (songs.isEmpty()) + { + logger.debug("No songs within database. Scanning..."); + totalUpdate = 1; + updateSongs(); + } + else + { + try + { + this.getDb().commit(); + this.getDb().close(); + updatedSongs = 0; + totalUpdate = 0; + triggerUpdateListeners(); + } + catch (SQLException e) + { + logger.error("Could not close database.", e); + } + } + }); + } + + /** + * Scans the local file system for song files. + */ + private void updateSongs() + { SongScanner.service.submit(new SongScanner(source)); /* * Resets the song scanner when ready. */ SongScanner.service.submit(() -> { + Statement stat; + ResultSet tableResult; while (true) { - try + if (SongScanner.service.awaitQuiescence(60, TimeUnit.SECONDS)) { - if (!SongScanner.service.awaitTermination(60, TimeUnit.SECONDS)) break; - } - catch (InterruptedException e) - { - logger.error("Error in waiting for song scan.", e); + break; } } SongScanner.currentFolder = ""; updatedSongs = 0; totalUpdate = 0; triggerUpdateListeners(); + + /* + * Update the SQL database. + */ + try + { + logger.debug("Updating local song database."); + stat = this.getDb().createStatement(); + tableResult = stat.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='LOCAL_SONGS';"); + logger.debug("Table result: {}", tableResult.getCursorName()); + if (!tableResult.next()) + { + logger.debug("Creating song table."); + /* + * Create the table + */ + stat.executeUpdate("CREATE TABLE LOCAL_SONGS" + + "(FILE TEXT PRIMARY KEY NOT NULL," + + "CODEC CHAR(5)," + + "TYPE CHAR(5)," + + "TITLE TEXT," + + "ARTISTS TEXT," + + "TRACK INTEGER," + + "DISC INTEGER," + + "DURATION BIGINT," + + "ALBUM TEXT);"); + } + else + { + logger.debug("Clearing song table."); + stat.executeUpdate("DELETE FROM LOCAL_SONGS;"); + } + tableResult = stat.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='LOCAL_ALBUMS';"); + if (!tableResult.next()) + { + logger.debug("Creating album table."); + /* + * Create the table + */ + stat.executeUpdate("CREATE TABLE LOCAL_ALBUMS" + + "(ALBUM TEXT PRIMARY KEY NOT NULL," + + "ARTISTS TEXT," + + "YEAR INTEGER," + + "GENRES TEXT," + + "TRACKS INTEGER," + + "DISCS INTEGER);"); + } + else + { + logger.debug("Clearing album table."); + stat.executeUpdate("DELETE FROM LOCAL_ALBUMS;"); + } + + tableResult.close(); + + for (Album album : this.albums.values()) + { + stat.executeUpdate("INSERT INTO LOCAL_ALBUMS (ALBUM,ARTISTS,YEAR,GENRES,TRACKS,DISCS) " + + "VALUES ('" + album.name + "', '" + String.join(";", album.artists) + "', " + album.year + ", '" + String.join(";", album.genres) + "', " + album.totalTracks + ", " + album.totalDiscs + ");"); + } + for (LocalSong song : this.songs) + { + stat.executeUpdate("INSERT INTO LOCAL_SONGS (FILE,CODEC,TYPE,TITLE,ARTISTS,TRACK,DISC,DURATION,ALBUM) " + + "VALUES ('" + song.file.getAbsolutePath() + "', '" + song.codec + "', '" + song.type + "', '" + song.title + "', '" + String.join(";", song.artists) + "', " + song.trackNum + ", " + song.disc + ", " + song.duration + ", '" + song.album.name + "');"); + } + stat.close(); + this.getDb().commit(); + this.getDb().close(); + logger.debug("Cache updated."); + } + catch (SQLException e) + { + logger.error("Error in saving SQLite database", e); + } }); } diff --git a/interface/src/main/java/edu/regis/universeplayer/player/Interface.java b/interface/src/main/java/edu/regis/universeplayer/player/Interface.java index 35acf1b..531a68a 100644 --- a/interface/src/main/java/edu/regis/universeplayer/player/Interface.java +++ b/interface/src/main/java/edu/regis/universeplayer/player/Interface.java @@ -10,6 +10,7 @@ import edu.regis.universeplayer.data.CollectionType; import edu.regis.universeplayer.data.Song; import edu.regis.universeplayer.data.SongProvider; import edu.regis.universeplayer.data.UpdateListener; +import net.harawata.appdirs.AppDirsFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,6 +20,7 @@ import java.awt.event.ComponentEvent; import java.awt.event.ComponentListener; import java.awt.event.WindowEvent; import java.awt.event.WindowListener; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; @@ -35,6 +37,9 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL { private static final Logger logger = LoggerFactory.getLogger(Interface.class); + private static File dataDir; + private static File configDir; + /** * A reference to the panel containing links to different collection views. */ @@ -123,6 +128,48 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL } } + /** + * Obtains the data storage directory for the application, creating it if + * needed. + * @return The data storage directory. + */ + public static File getDataDir() + { + if (dataDir == null) + { + dataDir = new File(AppDirsFactory.getInstance().getUserDataDir("universalmusic", null, null, true)); + if (!dataDir.exists()) + { + if (!dataDir.mkdir()) + { + logger.error("Could not create data directory {}", dataDir); + } + } + } + return dataDir; + } + + /** + * Obtains the configuration directory for the application, creating it if + * needed. + * @return The configuration directory. + */ + public static File getConfigDir() + { + if (configDir == null) + { + configDir = new File(AppDirsFactory.getInstance().getUserConfigDir("universalmusic", null, null, true)); + if (!configDir.exists()) + { + if (!configDir.mkdir()) + { + logger.error("Could not create configuration directory {}", configDir); + } + } + } + return configDir; + } + /** * Creates an interface */ diff --git a/interface/src/main/resources/log4j2.xml b/interface/src/main/resources/log4j2.xml index 5d0d69d..1fb81cc 100644 --- a/interface/src/main/resources/log4j2.xml +++ b/interface/src/main/resources/log4j2.xml @@ -17,7 +17,7 @@ - + diff --git a/interface/src/test/java/DBTest.java b/interface/src/test/java/DBTest.java new file mode 100644 index 0000000..dfaf91a --- /dev/null +++ b/interface/src/test/java/DBTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2021 William Hubbard. All Rights Reserved. + */ + +import edu.regis.universeplayer.player.Interface; +import org.junit.Test; + +import java.io.File; +import java.sql.*; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertTrue; + +public class DBTest +{ + @Test + public void test() throws ClassNotFoundException, SQLException + { + try + { + Class.forName("org.sqlite.JDBC"); + Connection db; + Statement stat; + ResultSet tableResult; +// db = DriverManager.getConnection("jdbc:sqlite:" + new File(Interface.getDataDir().getAbsolutePath(), "universalmusictest.db").getAbsolutePath()); +// db.setAutoCommit(false); +// stat = db.createStatement(); +// tableResult = stat.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='INFO';"); +// if (!tableResult.next()) +// { +// System.out.println("Creating song table"); +// /* +// * Create the table +// */ +// stat.executeUpdate("CREATE TABLE INFO" + +// "(DATA INTEGER PRIMARY KEY NOT NULL);"); +// } +// else +// { +// System.out.println("Clearing table"); +// stat.executeUpdate("DELETE FROM INFO;"); +// } +// tableResult.close(); +// stat.executeUpdate("INSERT INTO INFO (DATA) VALUES (1);"); +// stat.close(); +// db.commit(); +// db.close(); + + db = DriverManager.getConnection("jdbc:sqlite:" + new File(Interface.getDataDir().getAbsolutePath(), "universalmusictest.db").getAbsolutePath()); + db.setAutoCommit(false); + stat = db.createStatement(); + tableResult = stat.executeQuery("SELECT (DATA) FROM INFO"); + assertTrue(tableResult.next()); + assertEquals(1, tableResult.getInt("DATA")); + tableResult.close(); + stat.close(); + db.close(); + } + catch (SQLException e) + { + e.printStackTrace(); + throw e; + } + } +}