From b546c81fa1b1535dc97394faf8211498881e14fc Mon Sep 17 00:00:00 2001 From: Markil3 <75867393+Markil3@users.noreply.github.com> Date: Sun, 15 Aug 2021 18:18:02 -0600 Subject: [PATCH] Adds the ability to add internet songs to the library. --- .../universeplayer/data/DatabaseManager.java | 49 ++ .../data/InternetSongProvider.java | 469 ++++++++++++++++++ .../data/LocalSongProvider.java | 38 +- .../universeplayer/data/SongProvider.java | 2 +- .../universeplayer/player/Collections.java | 14 + .../player/InternetSongDialog.java | 172 +++++++ .../universeplayer/player/PlayerControls.java | 5 +- 7 files changed, 714 insertions(+), 35 deletions(-) create mode 100644 interface/src/main/java/edu/regis/universeplayer/data/DatabaseManager.java create mode 100644 interface/src/main/java/edu/regis/universeplayer/data/InternetSongProvider.java create mode 100644 interface/src/main/java/edu/regis/universeplayer/player/InternetSongDialog.java diff --git a/interface/src/main/java/edu/regis/universeplayer/data/DatabaseManager.java b/interface/src/main/java/edu/regis/universeplayer/data/DatabaseManager.java new file mode 100644 index 0000000..983f57d --- /dev/null +++ b/interface/src/main/java/edu/regis/universeplayer/data/DatabaseManager.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2021 William Hubbard. All Rights Reserved. + */ + +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.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.SQLWarning; + +/** + * Manages the application song database + */ +public class DatabaseManager +{ + private static final Logger logger = LoggerFactory.getLogger(DatabaseManager.class); + private static Connection db; + + public static synchronized Connection getDb() + { + SQLWarning warning; + try + { + if (db == null || db.isClosed()) + { + Class.forName("org.sqlite.JDBC"); + db = DriverManager.getConnection("jdbc:sqlite:" + new File(Interface.getDataDir().getAbsolutePath(), "universalmusic.db").getAbsolutePath()); + } + warning = db.getWarnings(); + while (warning != null) + { + logger.warn("SQL Warning: ", warning); + warning = warning.getNextWarning(); + } + db.clearWarnings(); + } + catch (SQLException | ClassNotFoundException e) + { + logger.error("Could not store caching DIR."); + } + return db; + } +} diff --git a/interface/src/main/java/edu/regis/universeplayer/data/InternetSongProvider.java b/interface/src/main/java/edu/regis/universeplayer/data/InternetSongProvider.java new file mode 100644 index 0000000..d81653b --- /dev/null +++ b/interface/src/main/java/edu/regis/universeplayer/data/InternetSongProvider.java @@ -0,0 +1,469 @@ +/* + * Copyright (c) 2021 William Hubbard. All Rights Reserved. + */ + +package edu.regis.universeplayer.data; + +import edu.regis.universeplayer.browser.InternetSong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.MalformedURLException; +import java.net.URL; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +public class InternetSongProvider implements SongProvider +{ + private static final Logger logger = LoggerFactory.getLogger(InternetSongProvider.class); + private static final ExecutorService service = Executors.newSingleThreadExecutor(); + private static InternetSongProvider INSTANCE; + + public static InternetSongProvider getInstance() + { + if (INSTANCE == null) + { + INSTANCE = new InternetSongProvider(); + } + return INSTANCE; + } + + private final HashMap songs = new HashMap<>(); + private final HashMap albums = new HashMap<>(); + /** + * A cache of all song artists. + */ + private final HashSet artists = new HashSet<>(); + /** + * A cache of all album genres. + */ + private final HashSet genres = new HashSet<>(); + /** + * A cache of all album artists. + */ + private final HashSet albumArtists = new HashSet<>(); + /** + * A cache of all album release years. + */ + private final HashSet years = new HashSet<>(); + + private int updatedSongs; + private int totalUpdate; + private final LinkedList listeners = new LinkedList<>(); + + private InternetSongProvider() + { + this.getSongCache(); + } + + private void getSongCache() + { + service.submit(new SongQuery()); + } + + /** + * Obtains all albums within the collection. + * + * @return A list of albums. + */ + @Override + public Collection getAlbums() + { + return this.albums.values(); + } + + /** + * Obtains all songs within the collection. + * + * @return A list of songs. + */ + @Override + public Collection getSongs() + { + synchronized (this.songs) + { + return Collections.unmodifiableCollection(this.songs.values()); + } + } + + /** + * Obtains a list of all artists. + * + * @return All artists. + */ + @Override + public Collection getArtists() + { + return this.artists; + } + + /** + * Obtains a list of all album artists. + * + * @return All album artists. + */ + @Override + public Collection getAlbumArtists() + { + return this.albumArtists; + } + + /** + * Obtains a list of all genres. + * + * @return All genres. + */ + @Override + public Collection getGenres() + { + return this.genres; + } + + /** + * Obtains a list of all years that have albums. + * + * @return All years. + */ + @Override + public Collection getYears() + { + return this.years; + } + + /** + * Obtains all songs from an album. + * + * @param album - The album to obtain + * @return All songs from the requested album, or null if that album is not in the database. + */ + @Override + public Collection getSongsFromAlbum(Album album) + { + synchronized (this.songs) + { + return this.songs.values().stream().filter(song -> song.album.equals(album)).collect(Collectors.toUnmodifiableSet()); + } + } + + /** + * Obtains all songs written by a given artist. + * + * @param artist - The artist to search for + * @return A list of all songs from the specified artist, or null if that artist is not in the + * database. + */ + @Override + public Collection getSongsFromArtist(String artist) + { + synchronized (this.songs) + { + return this.songs.values().stream().filter(song -> Arrays.asList(song.artists).contains(artist)).collect(Collectors.toUnmodifiableSet()); + } + } + + /** + * Obtains an album by a specific name. + * + * @param name - The name to search for. + * @return - The first album that matches the given name, or null if that album name is not in + * the database. + */ + @Override + public Album getAlbumByName(String name) + { + synchronized (this.albums) + { + return this.albums.get(name); + } + } + + /** + * Obtains all albums that were written by a certain artist. + * + * @param artist - The artist to search for. + * @return - The collection on matching albums. + */ + @Override + public Collection getAlbumsFromArtist(String artist) + { + synchronized (this.albums) + { + return this.albums.values().stream().filter(album -> Arrays.asList(album.artists).contains(artist)).collect(Collectors.toUnmodifiableSet()); + } + } + + /** + * Obtains all albums that match a certain genre + * + * @param genre - The genre to search for. + * @return - The collection on matching albums. + */ + @Override + public Collection getAlbumsFromGenre(String genre) + { + synchronized (this.albums) + { + return this.albums.values().stream().filter(album -> Arrays.asList(album.genres).contains(genre)).collect(Collectors.toUnmodifiableSet()); + } + } + + /** + * Obtains all albums that were released a certain year. + * + * @param year - The year to search for. + * @return - The collection on matching albums. + */ + @Override + public Collection getAlbumsFromYear(int year) + { + synchronized (this.albums) + { + return this.albums.values().stream().filter(album -> album.year == year).collect(Collectors.toUnmodifiableSet()); + } + } + + @Override + public int getUpdateProgress() + { + return this.updatedSongs; + } + + @Override + public int getTotalUpdateSongs() + { + return this.totalUpdate; + } + + @Override + public String getUpdateText() + { + return ""; + } + + @Override + public void addUpdateListener(UpdateListener listener) + { + this.listeners.add(listener); + } + + @Override + public void removeUpdateListener(UpdateListener listener) + { + this.listeners.remove(listener); + } + + protected void triggerUpdateListeners() + { + this.listeners.forEach(listener -> listener.onUpdate(this.getUpdateProgress(), this.getTotalUpdateSongs(), this.getUpdateText())); + } + + public Future addSong(URL url, String title, String albumName, String artists, String genres) + { + return service.submit(() -> { + Album album = albums.get(albumName); + Statement state = DatabaseManager.getDb().createStatement(); + if (album == null) + { + album = new Album(); + album.name = albumName; + albums.put(albumName, album); + synchronized (DatabaseManager.getDb()) + { + state.executeUpdate("INSERT INTO internet_albums (album) VALUES ('" + albumName + "');"); + } + } + album.artists = Arrays.stream(artists.split(";")).map(String::trim).toArray(String[]::new); + album.genres = Arrays.stream(genres.split(";")).map(String::trim).toArray(String[]::new); + synchronized (DatabaseManager.getDb()) + { + state.executeUpdate("UPDATE internet_albums SET artists='" + String.join(";", album.artists) + "' WHERE album='" + albumName + "';"); + state.executeUpdate("UPDATE internet_albums SET genres='" + String.join(";", album.genres) + "' WHERE album='" + albumName + "';"); + } + InternetSong song = new InternetSong(); + song.location = url; + song.title = title; + song.album = album; + song.artists = album.artists.clone(); + + // TODO - Evaluate the song + + songs.put(url, song); + + StringBuilder sql = new StringBuilder("INSERT INTO internet_songs "); + StringBuilder columns = new StringBuilder("("); + StringBuilder values = new StringBuilder("("); + + columns.append("url,"); + values.append('\'').append(url.toString().replaceAll("'", "''")).append("',"); + if (title != null && !title.isEmpty()) + { + columns.append("title,"); + values.append('\'').append(title.replaceAll("'", "''")).append("',"); + } + if (song.artists != null) + { + columns.append("artists,"); + values.append('\'').append(String.join(";", song.artists)).append("',"); + } + columns.append("album"); + values.append('\'').append(albumName).append("'"); + + columns.append(") VALUES "); + values.append(");"); + sql.append(columns); + sql.append(values); + synchronized (DatabaseManager.getDb()) + { + state.executeUpdate(sql.toString()); + } + this.triggerUpdateListeners(); + return song; + }); + } + + private class SongQuery implements Runnable + { + SongQuery() + { + } + + @Override + public void run() + { + Statement state; + ResultSet result; + Album album; + InternetSong song; + int numAlbums = 0, numSongs = 0; + + try + { + logger.debug("Querying database."); + /* + * Check if the table exists + */ + synchronized (DatabaseManager.getDb()) + { + /* + * Make sure that a "null" album is available + */ + + if (albums.get(null) == null) + { + album = new Album(); + album.name = "Unknown"; + albums.put(null, album); + } + + if (albums.get("Unknown") == null) + { + albums.put("Unknown", albums.get(null)); + } + + state = DatabaseManager.getDb().createStatement(); + result = state.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='INTERNET_ALBUMS';"); + if (!result.next()) + { + logger.debug("Creating album table."); + /* + * Create the table + */ + state.executeUpdate("CREATE TABLE INTERNET_ALBUMS" + + "(ALBUM TEXT PRIMARY KEY NOT NULL," + + "ARTISTS TEXT," + + "YEAR INTEGER," + + "GENRES TEXT," + + "TRACKS INTEGER," + + "DISCS INTEGER);"); + } + else + { + result = state.executeQuery("SELECT * FROM INTERNET_ALBUMS;"); + while (result.next()) + { + album = albums.get(result.getString("album")); + if (album == null) + { + album = new Album(); + album.name = result.getString("album"); + albums.put(album.name, 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"); + numAlbums++; + } + } + result = state.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='INTERNET_SONGS';"); + if (!result.next()) + { + logger.debug("Creating song table."); + /* + * Create the table + */ + state.executeUpdate("CREATE TABLE INTERNET_SONGS" + + "(URL TEXT PRIMARY KEY NOT NULL," + + "TITLE TEXT," + + "ARTISTS TEXT," + + "TRACK INTEGER," + + "DISC INTEGER," + + "DURATION BIGINT," + + "ALBUM TEXT);"); + } + else + { + result = state.executeQuery("SELECT * FROM INTERNET_SONGS;"); + while (result.next()) + { + if (result.getString("url") == null) + { + continue; + } + URL url; + try + { + url = new URL(result.getString("url")); + } + catch (MalformedURLException e) + { + logger.error("Could not parse URL " + result.getString("url"), e); + continue; + } + song = songs.get(url); + if (song == null) + { + song = new InternetSong(); + song.location = url; + songs.put(song.location, song); + } + 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(albums.get("Unknown")); + numSongs++; + } + } + state.close(); + } + } + catch (SQLException e) + { + logger.error("Could not query SQL database.", e); + } + logger.debug("Query complete, retrieved {} albums and {} songs", numAlbums, numSongs); + updatedSongs = 0; + totalUpdate = 0; + triggerUpdateListeners(); + } + } +} 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 682c78f..729da46 100644 --- a/interface/src/main/java/edu/regis/universeplayer/data/LocalSongProvider.java +++ b/interface/src/main/java/edu/regis/universeplayer/data/LocalSongProvider.java @@ -24,7 +24,6 @@ public class LocalSongProvider implements SongProvider private static final HashSet codecs = new HashSet<>(); private final File source; - private Connection db; private final HashMap songs = new HashMap<>(); private final HashMap albums = new HashMap<>(); @@ -152,31 +151,6 @@ public class LocalSongProvider implements SongProvider return codecs; } - 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()); - } - warning = this.db.getWarnings(); - while (warning != null) - { - logger.warn("SQL Warning: ", warning); - warning = warning.getNextWarning(); - } - this.db.clearWarnings(); - } - catch (SQLException | ClassNotFoundException e) - { - logger.error("Could not store caching DIR."); - } - return this.db; - } - public LocalSongProvider(File source) { Connection dbL = null; @@ -185,8 +159,6 @@ public class LocalSongProvider implements SongProvider { throw new IllegalArgumentException("File source must be existing directory"); } - - this.getDb(); getSongCache(); } @@ -434,9 +406,9 @@ public class LocalSongProvider implements SongProvider { try { - synchronized (db) + synchronized (DatabaseManager.getDb()) { - state = getDb().createStatement(); + state = DatabaseManager.getDb().createStatement(); result = state.executeQuery("SELECT mod FROM local_songs WHERE file='" + this.file.getAbsolutePath().replaceAll("'", "''") + "';"); if (result.next()) { @@ -603,7 +575,7 @@ public class LocalSongProvider implements SongProvider /* * Update album information. */ - synchronized (db) + synchronized (DatabaseManager.getDb()) { /* * This part in particular is prone to thread-safety issues. @@ -829,7 +801,7 @@ public class LocalSongProvider implements SongProvider /* * Check if the table exists */ - synchronized (db) + synchronized (DatabaseManager.getDb()) { /* * Make sure that a "null" album is available @@ -847,7 +819,7 @@ public class LocalSongProvider implements SongProvider albums.put("Unknown", albums.get(null)); } - state = getDb().createStatement(); + state = DatabaseManager.getDb().createStatement(); result = state.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='LOCAL_ALBUMS';"); if (!result.next()) { diff --git a/interface/src/main/java/edu/regis/universeplayer/data/SongProvider.java b/interface/src/main/java/edu/regis/universeplayer/data/SongProvider.java index a4f32de..3353cad 100644 --- a/interface/src/main/java/edu/regis/universeplayer/data/SongProvider.java +++ b/interface/src/main/java/edu/regis/universeplayer/data/SongProvider.java @@ -18,7 +18,7 @@ public interface SongProvider /** * A SongProvider instance designed to */ - SongProvider INSTANCE = new CompiledSongProvider(new LocalSongProvider(new File(System.getProperty("user.home"), "Music"))); + SongProvider INSTANCE = new CompiledSongProvider(new LocalSongProvider(new File(System.getProperty("user.home"), "Music")), InternetSongProvider.getInstance()); /** * Obtains all albums within the collection. diff --git a/interface/src/main/java/edu/regis/universeplayer/player/Collections.java b/interface/src/main/java/edu/regis/universeplayer/player/Collections.java index 3dee1cf..63c17b9 100644 --- a/interface/src/main/java/edu/regis/universeplayer/player/Collections.java +++ b/interface/src/main/java/edu/regis/universeplayer/player/Collections.java @@ -71,6 +71,10 @@ public class Collections extends JPanel this.add(new JLabel("\u23AF".repeat(6))); this.add(label = this.createButton("Playlists")); label.setMnemonic('P'); + this.add(new JLabel("\u23AF".repeat(6))); + this.add(label = this.createButton("Add External Song")); + label.setMnemonic('E'); + label.addActionListener(mouseEvent -> this.addNewSong()); addMouseListener(new MouseAdapter() { @@ -106,6 +110,16 @@ public class Collections extends JPanel }); } + private void addNewSong() + { + Container parent = this.getParent(); + while (!(parent instanceof JFrame) && parent != null) + { + parent = parent.getParent(); + } + new InternetSongDialog((JFrame) parent).setVisible(true); + } + private JButton createButton(String text) { JButton label = new JButton(text); diff --git a/interface/src/main/java/edu/regis/universeplayer/player/InternetSongDialog.java b/interface/src/main/java/edu/regis/universeplayer/player/InternetSongDialog.java new file mode 100644 index 0000000..121f921 --- /dev/null +++ b/interface/src/main/java/edu/regis/universeplayer/player/InternetSongDialog.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2021 William Hubbard. All Rights Reserved. + */ + +package edu.regis.universeplayer.player; + +import edu.regis.universeplayer.browser.InternetSong; +import edu.regis.universeplayer.data.InternetSongProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +/** + * This dialog is used to add songs + */ +public class InternetSongDialog extends JDialog +{ + private static final Logger logger = LoggerFactory.getLogger(InternetSongDialog.class); + + private final JTextField urlBox; + private final JTextField titleBox; + private final JTextField albumBox; + private final JTextField artistBox; + private final JTextField genreBox; + + public InternetSongDialog(Frame owner) + { + super(owner, true); + + JLabel label; + GridBagConstraints c = new GridBagConstraints(); + this.getContentPane().setLayout(new GridBagLayout()); + + int y = 0; + + c.gridx = 0; + c.gridy = y; + c.gridwidth = 2; + c.fill = GridBagConstraints.HORIZONTAL; + c.anchor = GridBagConstraints.WEST; + c.insets = new Insets(0, 2, 5, 2); + + this.getContentPane().add(label = new JLabel("URL"), c); + this.urlBox = new JTextField(); + this.urlBox.addFocusListener(new FocusAdapter() + { + @Override + public void focusLost(FocusEvent e) + { + try + { + new URL(((JTextField) e.getSource()).getText()); + ((JTextField) e.getSource()).setForeground(Color.BLACK); + } + catch (MalformedURLException ex) + { + ((JTextField) e.getSource()).requestFocusInWindow(); + ((JTextField) e.getSource()).setForeground(Color.RED); + } + } + }); + c.gridy = ++y; + this.getContentPane().add(this.urlBox, c); + label.setLabelFor(this.urlBox); + + c.gridx = 0; + c.gridy = ++y; + this.getContentPane().add(label = new JLabel("Song Title"), c); + this.titleBox = new JTextField(); + c.gridy = ++y; + this.getContentPane().add(this.titleBox, c); + label.setLabelFor(this.titleBox); + + c.gridx = 0; + c.gridy = ++y; + this.getContentPane().add(label = new JLabel("Album"), c); + this.albumBox = new JTextField(); + c.gridy = ++y; + this.getContentPane().add(this.albumBox, c); + label.setLabelFor(this.albumBox); + + c.gridx = 0; + c.gridy = ++y; + this.getContentPane().add(label = new JLabel("Artists"), c); + this.artistBox = new JTextField(); + c.gridy = ++y; + this.getContentPane().add(this.artistBox, c); + label.setLabelFor(this.artistBox); + + c.gridx = 0; + c.gridy = ++y; + this.getContentPane().add(label = new JLabel("Genre"), c); + this.genreBox = new JTextField(); + c.gridy = ++y; + this.getContentPane().add(this.genreBox, c); + label.setLabelFor(this.genreBox); + + c.gridwidth = 1; + c.gridx = 0; + c.gridy = ++y; + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(e -> this.dispose()); + this.getContentPane().add(cancelButton, c); + c.gridx = 1; + JButton addButton = new JButton("Add"); + addButton.addActionListener(e -> this.confirm()); + this.getContentPane().add(addButton, c); + + this.pack(); + } + + private void confirm() + { + URL url; + if (this.urlBox.getText().isEmpty()) + { + this.urlBox.requestFocusInWindow(); + this.urlBox.setForeground(Color.RED); + return; + } + try + { + url = new URL(this.urlBox.getText()); + } + catch (MalformedURLException e) + { + this.urlBox.requestFocusInWindow(); + return; + } + + for (Component comp: this.getContentPane().getComponents()) + { + comp.setEnabled(false); + } + + SwingUtilities.invokeLater(() -> { + Future future = InternetSongProvider.getInstance().addSong(url, this.titleBox.getText(), this.albumBox.getText(), this.artistBox.getText(), this.genreBox.getText()); + InternetSong song = null; + try + { + song = future.get(); + } + catch (InterruptedException | ExecutionException e) + { + logger.error("Could not evaluate song", e); + JOptionPane.showMessageDialog(InternetSongDialog.this, e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + } + finally + { + if (song != null) + { + dispose(); + } + else + { + for (Component comp: this.getContentPane().getComponents()) + { + comp.setEnabled(true); + } + } + } + }); + } +} diff --git a/interface/src/main/java/edu/regis/universeplayer/player/PlayerControls.java b/interface/src/main/java/edu/regis/universeplayer/player/PlayerControls.java index b634e11..9d7b9be 100644 --- a/interface/src/main/java/edu/regis/universeplayer/player/PlayerControls.java +++ b/interface/src/main/java/edu/regis/universeplayer/player/PlayerControls.java @@ -291,7 +291,10 @@ public class PlayerControls extends JPanel implements Queue.SongChangeListener, { if (this.currentSong != null) { - this.currentPlayer.stopSong(); + if (this.currentPlayer != null) + { + this.currentPlayer.stopSong(); + } } this.currentSong = queue.getCurrentSong(); this.playButton.setIcon(PLAY_ICON);