Compare commits

19 Commits
v0.1 ... master

Author SHA1 Message Date
Markil3
858236d181 More documentation improvements 2022-01-22 08:01:50 -07:00
Markil3
ad568b2ec2 Improves some documentation.
I was starting to get confused.
2022-01-22 08:00:27 -07:00
Markil3
d2473d3873 Adds a system for transferring console input between the main instance and remote consoles.
This will allow the main interface to dynamically prompt the user for further information.
2021-11-13 10:48:11 -07:00
Markil3
6ef844c8b8 Fixes a test to work better with the JSON return value.
BigDecimals are returned, not Doubles.
2021-11-13 09:02:55 -07:00
Markil3
36514031f8 Ensures that every query creates a submission to the service.
This is the only way that a Future will work, even if it doesn't do anything.
2021-11-13 08:53:01 -07:00
Markil3
afce1a8628 Moves some interface activities to the Swing thread.
We really should be doing this anyway.
2021-10-31 16:01:07 -06:00
Markil3
8022104373 Makes some modifications to console output.
This should hopefully make things easier to parse for the end user.
2021-10-31 16:00:14 -06:00
Markil3
66d803e301 Improves commenting and thread naming.
This should hopefully help debugging a little.
2021-10-31 15:59:03 -06:00
Markil3
6a1c7d86e3 Added better CLI support.
This approach is more flexible.
2021-10-31 14:05:27 -06:00
Markil3
0de20568a7 Gives the interface the ability to pull information from the YouTube page.
This will make filling in song data much easier.
2021-10-10 16:20:01 -06:00
Markil3
a92d686ddb Allowed all logging to be viewed by the interface.
It is just much easier this way.
2021-10-10 15:00:31 -06:00
Markil3
72ccfbc444 Stabilized the browser shutdown process. 2021-10-09 14:31:47 -06:00
Markil3
0ffa626cb7 Moved most player tasks to ForkJoin.
This system works a bit better.
2021-09-18 16:04:37 -06:00
Markil3
2e2748f9e9 Merges the two album tables into one.
Albums are independent of how the song is stored. We don't need to separate them.
2021-09-18 15:00:12 -06:00
William Hubbard
8bb7086a44 Moved command-line options to the wiki.
It would be best to keep everything centralized.
2021-09-17 18:44:55 +00:00
William Hubbard
9a89d32c9c Adds images to the readme 2021-09-15 22:08:59 +00:00
William Hubbard
2e4763390d Adds some demo images 2021-09-15 22:04:42 +00:00
William Hubbard
ccc013df30 Create README.md 2021-09-15 21:55:43 +00:00
Markil3
b5b1d495f0 Adds a couple of unit tests for the browser 2021-09-15 12:13:21 -06:00
75 changed files with 6790 additions and 3566 deletions

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# UniversalMusicPlayer
A music player designed to integrate local songs on the computer with songs linked on the internet, all under one interface.
# Requirements
Users will need both FFMPEG and VLC media player installed in order to run this program
https://ffmpeg.org/download.html
https://www.videolan.org/vlc/
This program has been tested on Windows and Linux. While MacOS may be supported, it will not as of yet run out of the box (primarily due to extra work needed to get Firefox properly installed there).
![Interface Preview](https://raw.githubusercontent.com/Markil3/UniversalMusicPlayer/master/UniversalMusicPlayer1.png)
![Artists View](https://raw.githubusercontent.com/Markil3/UniversalMusicPlayer/master/UniversalMusicPlayer2.png)
![Viewing Single Artist](https://raw.githubusercontent.com/Markil3/UniversalMusicPlayer/master/UniversalMusicPlayer3.png)
![Adding a Song](https://raw.githubusercontent.com/Markil3/UniversalMusicPlayer/master/UniversalMusicPlayer4.png)
## Using
This program has both a GUI and a CLI. If you launch multiple instances, any commands entered in the secondary instances will be forwarded to the main application.
You can view command-line options on [the wiki](https://github.com/Markil3/UniversalMusicPlayer/wiki/Command-Line-Interface).
## How it Works
This program integrates two background programs for audio. Local files are played through VLC media player, courtesy of the [VLCJ](https://github.com/caprica/vlcj) library. This is done with the [edu.regis.universeplayer.player.LocalPlayer](https://github.com/Markil3/UniversalMusicPlayer/blob/master/interface/src/main/java/edu/regis/universeplayer/player/LocalPlayer.java) class. Playback commands are forwarded to VLC in the background.
Internet-based songs (YouTube, etc.) work a little differently. Most providers require users to view their content directly through their website. As such, a browser is needed. To accomplish this, the build script will download and install a local copy of Firefox Developer Edition, with a custom addon to facilitate communications between the music player and the browser. Since Firefox addons cannot directly communicate with a running application, the installer will also install a small intermediary application that will automatically connect to a running instance of the music player and forward messages back and forth. All of this is handled by [edu.regis.universeplayer.player.BrowserPlayer](https://github.com/Markil3/UniversalMusicPlayer/blob/master/interface/src/main/java/edu/regis/universeplayer/player/BrowserPlayer.java).
The Firefox program uses a separate profile with its own settings to isolate it from any existing Firefox installations. It can be found under the profile name "Universal."
Both of these solutions are seamlessly integrated under [edu.regis.universeplayer.player.PlayerManager](https://github.com/Markil3/UniversalMusicPlayer/blob/master/interface/src/main/java/edu/regis/universeplayer/player/PlayerManager.java).
## Building
To start, just download the repository. Then, cd into the project directory and run "./gradlew :interface:run" (without the quotes).
## Installing
To create an installer, run "./gradlew buildscriptNix" for Linux. Windows build scripts are pending.
To run, launch "universalplayer."
## Todo list
This project is still in early development, and there are still important features that are needed before it can replace anything else I'm using.
* Stabilize the YouTube interface
* Add support for playLists
* Fix the scrolling bug for Artists/Album views
* Add a song search utility in the GUI
* Add better error checking for the song add dialogue.
* Add support for Spotify and Amazon Music
* Expand the CLI, with the goal of parity between the two interfaces.
* A Windows installer (as soon as I learn how to write batch scripts...)
## License
Copyright (c) William Hubbard. All rights reserved.

BIN
UniversalMusicPlayer1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
UniversalMusicPlayer2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
UniversalMusicPlayer3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
UniversalMusicPlayer4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -1,3 +1,12 @@
/*
* Copyright (c) 2021 William Hubbard. All Rights Reserved.
*
* This module contains the browser addon that handles most of the logic for internet songs. When the :browser module is
* built, this addon is added to the browser's list of extensions. Then, when it is run, the addon will start the
* intermediary program. Then, it will receive commands from the interface through the intermediary application and
* manipulate the browser state based on those commands. It will then send the results of those commands back through
* the intermediary. The addon will also periodically shoot off updates to the browser.
*/
import org.apache.tools.ant.taskdefs.condition.Os import org.apache.tools.ant.taskdefs.condition.Os
import java.nio.file.Files import java.nio.file.Files
@@ -6,10 +15,17 @@ import java.nio.file.StandardCopyOption
import java.util.stream.Stream import java.util.stream.Stream
configurations { configurations {
/**
* This configuration is used to export the packaged addon for installation into a browser.
*/
addonBuild { addonBuild {
canBeConsumed = true canBeConsumed = true
canBeResolved = false canBeResolved = false
} }
/**
* The nativeApp configuration is used to import a native application that this addon will start when the browser
* does.
*/
nativeApp.extendsFrom runtime nativeApp.extendsFrom runtime
} }
@@ -30,7 +46,7 @@ extension.description = "A link between the Universal Music Player and the web b
extension.id = "universalmusic@regis.edu" extension.id = "universalmusic@regis.edu"
/** /**
* Creates an addon manifest for running * Creates an addon manifest that defines details for the native application that is run when the app starts.
*/ */
abstract class BuildManifest extends DefaultTask { abstract class BuildManifest extends DefaultTask {
@OutputFile @OutputFile
@@ -66,7 +82,7 @@ abstract class BuildManifest extends DefaultTask {
} }
/** /**
* * Installs the addon manifest, adding registery keys on Windows and creating the appropriate files on Mac and Linux.
*/ */
abstract class InstallAddon extends DefaultTask { abstract class InstallAddon extends DefaultTask {
@InputFile @InputFile
@@ -97,16 +113,25 @@ dependencies {
nativeApp project(path: ":addonInter", configuration: 'nativeBuild') nativeApp project(path: ":addonInter", configuration: 'nativeBuild')
} }
/**
* Creates a build manifest that defines details for the native application defined by nativeApp dependencies that will
* be run.
*/
tasks.register('buildManifest', BuildManifest) { tasks.register('buildManifest', BuildManifest) {
manifests = new File(buildDir, "${extension.name.get()}.json") manifests = new File(buildDir, "${extension.name.get()}.json")
} }
/**
* Registers the build manifest to the OS, allowing for the native application to run properly.
* This task depends on buildManifest
*/
def installAddon = tasks.register('installAddon', InstallAddon) { def installAddon = tasks.register('installAddon', InstallAddon) {
inputFile = buildManifest.manifests inputFile = buildManifest.manifests
} }
/** /**
* Creates a packaged (albiet unsigned) Firefox addon from the src/main directory. * Creates a packaged (albiet unsigned) Firefox addon from the src/main directory.
* This task is finished by calling installAddon
*/ */
tasks.register('zipAddon', Zip) { tasks.register('zipAddon', Zip) {
dependsOn configurations.nativeApp dependsOn configurations.nativeApp
@@ -146,6 +171,9 @@ tasks.register("clean", Delete) {
followSymlinks = false followSymlinks = false
} }
/*
* Exports the packaged addon task.
*/
artifacts { artifacts {
addonBuild(zipAddon) addonBuild(zipAddon)
} }

View File

@@ -1,42 +1,302 @@
/*
* Copyright (c) 2021 William Hubbard. All Rights Reserved.
*
* The background script forms the core of the addon. Running when the browser starts, it starts the native application
* (the intermediary program that connects to the interface). The native application will send it commands in the
* format of a JSON object containing a "messageNum" field (containing a session-unique identifier for that message) and
* "message" (containing the actual message, of which can be of any type). The message data will be forwarded to several
* listeners. The listeners will output a single value (or throw an error). In response to either, the addon will send
* a JSON object back to the native application in the following format:
*
* <code>
* {
* "messageNum": 0, // The ID of the message that this response corresponds to
* "message": { //A wrapper that contains, among other things, the return data
* "type": "edu.regis.universeplayer.browserCommands.CommandReturn", // An identifier that tells the interface to treat this data as a return value for a command
* "returnValue": null, // The actual data returned by the listeners. In the event of an error, this will be null.
* "confirmation": { // Contains extra metadata about how the command was executed
* "type": "edu.regis.universeplayer.browserCommands.CommandConfirmation", // Another identifier for the interface
* "message": "Done", // A human-readable message on the results of execution. For non-error returns, this will simply be "done."
"errorCode": { // If an error was thrown, this object will contain data on that. Otherwise, it will be null
"type": "edu.regis.universeplayer.browserCommands.BrowserError",
"name": "",
"message": "",
"stack", []
* }
* }
* }
* </code>
*
* The default one (registered by this script) does the majority of processing. This default listener takes an object
* with a single "type" field that corresponds to an instance of edu.regis.universeplayer.browserCommands.BrowserQuery
* in the Java interface. Using that "type" field, it determines what action to take and uses extra data within the
* message object. Should the message be something delegated to a foreground tab (i.e. controlling a YouTube video),
* the listener will forward the message to the tab in the following format before returning:
*
* <code>
* {
* "num": 0, // The ID of the tab-specific message. This is independent of the "messageNum" field above
* "message": {} // A copy of the actual message sent.
* }
* </code>
*
* Either way,
*
* Then, whenever it receives a command from the application,
* it will forward that command to several listeners. One of those listeners, a default one registered by this script,
* does the majority of processing. If the message is marked for a specific tab (one running the foreground.js script),
* the message is forwarded to that tab for further processing before retrieving the value. Either way,
*/
let logger = new Logger("background");
logger.pushUpdate = function (message)
{
if (interfacePort)
{
interfacePort.postMessage({
"messageNum": -1,
"message": message
});
return true;
}
else
{
return false;
}
};
console.log("Hello from Universal Music addon!") console.log("Hello from Universal Music addon!")
/**
* This variable contains a mapping of message IDs to the message promises they correspond to, as
* long as those messages are still pending a response.
*/
const tabMessages = new Map(); const tabMessages = new Map();
/**
* This counter variable is used to generate session-unique message IDs between this background and
* the various tabs.
*/
var numTabMessages = 0; var numTabMessages = 0;
/* /*
On startup, connect to the "ping_pong" app. On startup, connect to the "ping_pong" app.
*/ */
var interfacePort = browser.runtime.connectNative("universalmusic"); var interfacePort = browser.runtime.connectNative("universalmusic");
var tabPort = null; /**
* This variable contains a mapping between tab IDs and resolution functions that are to be called
* as soon as a connection port is established.
*/
let waitingPorts = new Map();
/**
* This variable contains a mapping between tab IDs and respective communication ports.
*/
let ports = new Map();
/**
* Obtains the host name of a URL
*/
function getHost(url)
{
return url.replace(/^\w+:\/{2,3}/, "").replace(/[\/#?:]\w+.+$/, "")
}
/**
* Obtains the domain of a URL
*/
function getDomain(url)
{
let host = getHost(url);
let parts = host.split(".");
return parts[parts.length - 2] + "." + parts[parts.length - 1];
}
/**
* Loads up a tab and returns a promise for when it has completely loaded.
*
* @param {string} url The URL to load.
* @param {boolean} pinned Whether the tab should be pinned or not. Pinned tabs can be reused.
* Defaults to true.
* @return {Promise} A promise that loads the tab. The resolution function is passed the tab
* object upon completion.
*/
function loadTab(url, pinned)
{
if (typeof pinned != "boolean" && !pinned)
{
pinned = true;
}
let chosen;
return new Promise((resolve, reject) => {
let domain = getDomain(url);
browser.tabs.query({
url: "*://*." + domain + "/*",
muted: false
}).then(tabs => {
let callback = (tabId, changed, tab) => {
if (tab.status == "complete")
{
logger.debug("Tab %d complete", tab.id);
if (browser.tabs.onUpdated.hasListener(callback))
{
browser.tabs.onUpdated.removeListener(callback)
}
resolve(tab);
}
};
let navigate = (tabId, changed, tab) => {
if (tab.status == "complete")
{
if (browser.tabs.onUpdated.hasListener(navigate))
{
browser.tabs.onUpdated.removeListener(navigate)
}
chosen = tabId;
logger.debug("Redirecting tab %d %o", tabId, tab);
/*
* Reloading the tab disconnects the port. We will have to wait for a
* reconnection.
*/
waitingPorts.set(tab.id, {
"resolve": callback,
"reject": reject
});
// browser.tabs.onUpdated.addListener(callback, {
// urls: [url],
// properties: ["status"]
// });
browser.tabs.update(tabId, {
url: url
}).then(() => {}, reject);
}
};
let tab;
/*
* If there are no available tabs (or if we are instructed to create a new tab for this
* task), then create a new tab and wait for a connection to be established.
*/
logger.log("Found %o tabs", tabs);
if (tabs.length == 0 || !pinned)
{
// browser.tabs.onUpdated.addListener(callback, {
// urls: [url],
// properties: ["status"]
// });
browser.tabs.create({
url: url
}).then(tab => {
chosen = tab.id;
waitingPorts.set(tab.id, {
"resolve": callback,
"reject": reject
});
browser.tabs.update(tab.id, {
muted: !pinned
});
}, reject);
}
/*
* Otherwise, use an existing tab and set it to a new URL.
*/
else
{
let i = 0;
while (tabs[i].mutedInfo.muted)
{
i++;
}
tab = tabs[i];
if (tab.status == "loading")
{
/*
* Only navigate if the tab has finished its task already.
*/
browser.tabs.onUpdated.addListener(navigate, {
urls: [tab.url],
properties: ["status"],
tabId: tab.id
});
}
else
{
navigate(tab.id, {}, tab);
}
}
}, reject);
});
}
/**
* This callback is called every time a new tab attempts to connect to this background process.
*
* @param {runtime.Port} port - The port object that the tab will communicate through.
* @return {boolean} true if the connection was successful, false otherwise.
*/
function setupTab(port) function setupTab(port)
{ {
if (port.sender) if (port.sender)
{ {
console.info("Tab " + port.sender.tab.url + " loaded."); logger.info("Tab " + port.sender.tab.url + " loaded.");
} }
else else
{ {
console.info("Tab loaded"); logger.info("Tab loaded");
} }
tabPort = port; ports.set(port.sender.tab.id, port);
tabPort.onDisconnect.addListener(e => { if (waitingPorts.has(port.sender.tab.id))
tabPort = null; {
console.log("Setting up port %o", port.sender.tab);
waitingPorts.get(port.sender.tab.id).resolve(port.sender.tab.id, {}, port.sender.tab);
waitingPorts.delete(port.sender.tab.id);
}
else
{
logger.warn("We just connected to a tab that we didn't request.");
}
port.onDisconnect.addListener(e => {
ports.delete(e.sender.tab.id);
if (e.error) if (e.error)
{ {
console.error("Tab error: ", e.error) logger.error("Tab error: %o", e.error)
} }
else else
{ {
console.info("Tab " + e.sender.tab.url + " disconnected."); logger.info("Tab " + e.sender.tab.url + " disconnected.");
} }
browser.tabs.remove(e.sender.tab.id); // browser.tabs.remove(e.sender.tab.id);
}); });
tabPort.onMessage.addListener(message => {
/**
* Listens for messages from the tab and processes their promises
*/
port.onMessage.addListener(message => {
let returnValue;
if (typeof message == "object") if (typeof message == "object")
{ {
if (message.type == "response") if (message.type == "response")
{ {
tabMessages.set(message.num, message.data); returnValue = tabMessages.get(message.num);
logger.trace("Returning message %d, %o", message.num, returnValue)
if (returnValue)
{
tabMessages.delete(message.num);
if (message.data instanceof Error)
{
returnValue.reject(message.data);
}
else
{
returnValue.resolve(message.data);
}
}
else
{
logger.warn("Couldn't find message number %d", message.num);
}
} }
else if (message.type == "update") else if (message.type == "update")
{ {
@@ -44,51 +304,156 @@ function setupTab(port)
"messageNum": -1, "messageNum": -1,
"message": message.data "message": message.data
} }
console.trace("Sending update ", returnValue) logger.trace("Sending update %o", returnValue)
interfacePort.postMessage(returnValue); interfacePort.postMessage(returnValue);
} }
else
{
logger.warn("Unknown message type %s", message.type);
}
}
else
{
logger.warn("Unknown message type %s", typeof message, message);
} }
}); });
return true; return true;
} }
function queryTab(message) /**
* Sends a message to specified tab and handles the response.
* @param {id,string,tabs.Tab} tab -The tab to send to. This can be the ID of the tab, the hostname
* or domain name of the tab, an array of url matchers, or the tab
* object itself.
* @return {Promise} A promise that passes the tab's response to the resolver.
*/
function queryTab(tab, message)
{ {
let num = numTabMessages++; let num = numTabMessages++;
let promise = new Promise((resolve, reject) => {
tabPort.postMessage({ let tabId;
num: num, if (typeof tab == "string")
data: message {
}); if (tab.match(/^\w+\.\w+$/))
(function awaitResponse() { {
if (tabMessages.has(num)) tab = "*://*." + tab + "/*";
}
else if (tab.match(/^(\w\.)+\w+\.\w+$/))
{
tab = "*://" + tab + "/*";
}
tab = [tab];
}
else if ("id" in tab)
{
tab = tab.id;
}
return new Promise((resolve, reject) => {
let query = port => {
let messageReturn = {
resolve: resolve,
reject: reject
};
tabMessages.set(num, messageReturn);
logger.log("Querying tab %o", message);
port.postMessage({
num: num,
data: message
})
};
if (typeof tab == "number")
{
if (ports.has(tab))
{ {
response = tabMessages.get(num); query(ports.get(tab));
tabMessages.delete(num);
return resolve(response);
} }
else else
{ {
setTimeout(awaitResponse, 30); reject(new ReferenceError("Could not find tab with communications port under " + tab));
} }
})(); }
else if (tab instanceof Array)
{
tabs = tabs.query({
url: tab,
status: "complete"
}).then(tabs => {
if (tabs && tabs.length > 0)
{
for (let i = 0; i < tabs.length; i++)
{
if (ports.has(tabs[i].id))
{
query(ports.get(tabs[i].id));
return;
}
}
/*
* We only reach here if we couldn't find a valid tab. While we could just fall
* through to the other reject statement, this one can provide a clearer error
* message.
*/
reject(new ReferenceError("Could not find tab with communications under " + tab));
}
else
{
reject(new ReferenceError("Could not find tab under " + tab));
}
}, reject);
}
}); });
tabMessages.set(numTabMessages, promise);
numTabMessages++;
return promise;
} }
var listeners = [function (message, returnValue) { /**
* Contains a list of listeners that will act upon messages from the native app.
*
* @see handleMessage(message)
*/
var listeners = [
/**
* A callback for when the native application sends a message.
*
* @param {object} message - The message to process.
* @param {object} returnValue -The value returned by the last message listener. Return either this
* or a new value.
* @return Either the value passed on returnValue or a new value.
*/
function (message, returnValue) {
if (typeof message == "object" && "type" in message) if (typeof message == "object" && "type" in message)
{ {
let type = message.type; let type = message.type;
lastPeriod = type.lastIndexOf("."); let lastPeriod = type.lastIndexOf(".");
if (lastPeriod != -1) if (lastPeriod != -1)
{ {
type = type.substring(lastPeriod + 1); type = type.substring(lastPeriod + 1);
} }
switch (type) switch (type)
{ {
case "NumberPing":
if (message.url)
{
let tabId;
return loadTab(message.url).then(tab => {
tabId = tab.id;
return queryTab(tab, message);
}).then(data => {
// browser.tabs.remove(tabId);
return data;
});
}
else
{
logger.log("Pinging back %d from background", message.number);
returnValue = message.number;
}
break;
case "QuerySongData":
return loadTab(message.url, false).then(tab => {
logger.log("Tab " + tab.url + " loaded. Querying.");
return queryTab(tab, message);
});
case "CommandLoadSong": case "CommandLoadSong":
returnValue = () => { returnValue = () => {
if (message.song) if (message.song)
@@ -98,7 +463,7 @@ var listeners = [function (message, returnValue) {
} }
if (tabPort) if (tabPort)
{ {
console.log("Replacing tab"); logger.log("Replacing tab");
/* /*
* Closes the existing tab first * Closes the existing tab first
*/ */
@@ -106,7 +471,7 @@ var listeners = [function (message, returnValue) {
} }
else else
{ {
console.log("Opening tab"); logger.log("Opening tab");
returnValue = returnValue(); returnValue = returnValue();
} }
break; break;
@@ -126,7 +491,7 @@ var listeners = [function (message, returnValue) {
returnValue = quit(); returnValue = quit();
break; break;
case "CommandError": case "CommandError":
console.error("Error message", message); logger.error("Error message %o", message);
if (message.forward) if (message.forward)
{ {
returnValue = queryTab(message); returnValue = queryTab(message);
@@ -150,9 +515,14 @@ var listeners = [function (message, returnValue) {
return returnValue; return returnValue;
}]; }];
/**
* Closes all tabs in the browser, thus closing the entire browser.
*
* @return {Promise} The promise that closes the tabs.
*/
function quit() function quit()
{ {
console.log("Quitting browser"); logger.log("Quitting browser");
return browser.tabs.query({}).then(tabs => { return browser.tabs.query({}).then(tabs => {
for (let tab of tabs) for (let tab of tabs)
{ {
@@ -165,6 +535,15 @@ function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
/**
* This method is triggered when the interface sends a message to the browser. This method will
* trigger all of the relevant message listeners and return a promise for whichever value they
* decide to output.
*
* @param {object} message -The message to analyze.
* @return {Promise} A promise for the method completion, resolving to a value to return to
* the interface.
*/
function handleMessage(message) function handleMessage(message)
{ {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -175,7 +554,7 @@ function handleMessage(message)
{ {
returnValue = listeners[index](message, returnValue); returnValue = listeners[index](message, returnValue);
} }
console.debug("Message returned ", returnValue); logger.debug("Message returned: %o", returnValue);
if (returnValue instanceof Promise) if (returnValue instanceof Promise)
{ {
returnValue.then(resolve).catch(reject); returnValue.then(resolve).catch(reject);
@@ -196,7 +575,7 @@ function handleMessage(message)
* Listen for messages from the app. * Listen for messages from the app.
*/ */
interfacePort.onMessage.addListener((message) => { interfacePort.onMessage.addListener((message) => {
console.log("Received from interface: ", message); logger.log("Received from interface: %o", message);
handleMessage(message.message).then((response) => { handleMessage(message.message).then((response) => {
returnValue = { returnValue = {
@@ -211,10 +590,10 @@ interfacePort.onMessage.addListener((message) => {
} }
} }
} }
console.log("Sending ", returnValue) logger.log("Sending %o", returnValue)
interfacePort.postMessage(returnValue); interfacePort.postMessage(returnValue);
}).catch(error => { }).catch(error => {
// console.error("Error in evaluating message: ", error); // logger.error("Error in evaluating message: %o", error);
interfacePort.postMessage({ interfacePort.postMessage({
"messageNum": message.messageNum, "messageNum": message.messageNum,
"message": { "message": {
@@ -223,7 +602,12 @@ interfacePort.onMessage.addListener((message) => {
"confirmation": { "confirmation": {
type: "edu.regis.universeplayer.browserCommands.CommandConfirmation", type: "edu.regis.universeplayer.browserCommands.CommandConfirmation",
message: "Error in executing request", message: "Error in executing request",
errorCode: JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error))) errorCode: {
type: "edu.regis.universeplayer.browserCommands.BrowserError",
name: error.name,
message: error.message,
stack: error.stack
}
} }
} }
}); });
@@ -231,3 +615,5 @@ interfacePort.onMessage.addListener((message) => {
}); });
browser.runtime.onConnect.addListener(setupTab); browser.runtime.onConnect.addListener(setupTab);
logger.log("Background script setup complete!")

View File

@@ -0,0 +1,49 @@
class Album
{
type = "edu.regis.universeplayer.data.Album";
name;
artists;
year;
genres;
totalTracks;
totalDiscs;
}
class Song
{
type = "edu.regis.universeplayer.data.Song";
/**
* The name of the song.
*/
title;
/**
* Artists who contributed to the song.
*/
artists;
/**
* Which track number in the album the song belongs to.
*/
trackNum;
/**
* Which disc
*/
disc;
/**
* How long the song is in milliseconds.
*/
duration;
/**
* A reference to the album this song is part of.
*/
album = new Album();
}
class InternetSong extends Song
{
type = "edu.regis.universeplayer.data.InternetSong";
/**
* The location of the song.
*/
location;
}

View File

@@ -1,6 +1,37 @@
console.debug("Loading foreground.js") /*
* Copyright (c) 2021 William Hubbard. All Rights Reserved.
*
* The foreground script serves as the core for managing playback of a song. It receives messages forwarded from the background
*/
let logger = new Logger("foreground");
console.debug("Loading foreground.js");
let background; let background;
/**
* Contains functions to call before establishing a connection.
*/
let preload = [];
logger.pushUpdate = function (message)
{
if (background)
{
sendUpdate(message);
return true;
}
else
{
return false;
}
};
/**
* Handles messages sent from the background script.
*
* @param {object} message -The object containing the request.
* @return {Promise} A promise containing the result of the request.
*/
function handleMessage(message) function handleMessage(message)
{ {
if (typeof message == "object" && "type" in message) if (typeof message == "object" && "type" in message)
@@ -13,6 +44,12 @@ function handleMessage(message)
} }
switch (type) switch (type)
{ {
case "NumberPing":
logger.log("Pinging back %d from foreground", message.number);
return Promise.resolve(message.number);
case "QuerySongData":
logger.log("Gathering song data");
return getSongData();
case "QueryStatus": case "QueryStatus":
return getState(); return getState();
case "QueryTime": case "QueryTime":
@@ -37,6 +74,24 @@ function handleMessage(message)
} }
} }
/**
* Calling this function will forward playback data to the browser background (and by extension, the
* interface) as specified by the parameters. The sent data will be in the following format:
*
* <code>
* {
* "type": "edu.regis.universeplayer.PlaybackInfo",
* "currentSong": {},
* "status": "STATUS",
* "playTime": 0
* }
* </code>
*
* @param {string} status The playback status. This can be "PLAYING", "PAUSED", "STOPPED",
* "FINISHED", or "EMPTY"
* @param {number} time The current playback time in seconds.
* @param {object} songData Data on the song currently playing.
*/
function onStatusUpdate(status, time, songData) function onStatusUpdate(status, time, songData)
{ {
sendUpdate({ sendUpdate({
@@ -47,9 +102,22 @@ function onStatusUpdate(status, time, songData)
}); });
} }
/**
* Forwards an update to the background (and by extension, the interface). The data will be in the following format:
*
* <code>
* {
* "type": "update",
* "data": {} // The actual data sent.
* }
* </code>
*
* @param {object|Promise} response The data to forward. If this data is a Promise, then the data
* will be forwarded upon completion.
*/
function sendUpdate(response) function sendUpdate(response)
{ {
post = data => background.postMessage({ let post = data => background.postMessage({
type: "update", type: "update",
data: data data: data
}); });
@@ -63,33 +131,134 @@ function sendUpdate(response)
} }
} }
background = browser.runtime.connect({name:"universalMusic"}); $(function () {
background.onMessage.addListener(message => { /*
let num = message.num; * Preinitializes a few things
response = handleMessage(message.data); */
post = data => background.postMessage({ while (preload.length > 0)
type: "response",
num: num,
data: data
});
if (response instanceof Promise)
{ {
response.then(post); preload.pop()();
} }
else /*
{ * Connects to the background.
post(response); */
} background = browser.runtime.connect({name:"universalMusic"});
});
if (document.readyState === "complete") // sendUpdate("loaded");
{
sendUpdate("loaded"); /**
} * Listens for messages from the browser background and sends responses back.
else */
{ background.onMessage.addListener(message => {
sendUpdate("loaded"); let num = message.num;
window.addEventListener("load", () => { post = data => {
sendUpdate("loaded"); logger.trace("Sending to interface %o", data);
background.postMessage({
type: "response",
num: num,
data: data
});
}
try
{
let response = handleMessage(message.data);
if (response instanceof Promise)
{
logger.log("Posting promised information.");
response.then(post);
}
else
{
logger.log("Posting non-promise information.");
post(response);
}
}
catch (e)
{
logger.error(e);
post(e);
}
}); });
})
/**
* Obtains information on the song currently playing.
*
* @return {Promise<InternetSong>} A serialized form of the song data for this tab.
*/
function getSongData()
{
return Promise.reject(new ReferenceError("getSongData unimplemented for " + location.href));
}
/**
* Obtains the current tab's playback state.
*
* @return {Promise<string>} The playback status. This can be "PLAYING", "PAUSED", "STOPPED",
* "FINISHED", or "EMPTY"
*/
function getState()
{
return Promise.reject(new ReferenceError("getState unimplemented for " + location.href));
}
/**
* Obtains the current playback time.
*
* @return {Promise<number>} The current song playback time in milliseconds.
*/
function getTime()
{
return Promise.reject(new ReferenceError("getTime unimplemented for " + location.href));
}
/**
* Obtains the total length of the song.
*
* @return {Promise<number>} The total song length, in milliseconds.
*/
function getLength()
{
return Promise.reject(new ReferenceError("getLength unimplemented for " + location.href));
}
/**
* Enables playback of the current song.
*
* @return {Promise} The success of the command.
*/
function play()
{
return Promise.reject(new ReferenceError("play unimplemented for " + location.href));
}
/**
* Pauses playback of the current song.
*
* @return {Promise} The success of the command.
*/
function pause()
{
return Promise.reject(new ReferenceError("play unimplemented for " + location.href));
}
/**
* Halts playback of the current song.
*
* @return {Promise} The success of the command.
*/
function stop()
{
return Promise.reject(new ReferenceError("stop unimplemented for " + location.href));
}
/**
* Sets the playback to a certain time.
*
* @param {number} time The time to skip to, in milliseconds.
* @return {Promise} The success of the command.
*/
function seek(time)
{
return Promise.reject(new ReferenceError("seek unimplemented for " + location.href));
} }

2
add-on/src/addon/javascript/jquery.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,148 @@
/**
* Message data is used internally as a wrapper for a log message that is sent to the interface.
*/
class MessageData {
/**
* This is used by the interface parser to determine what type of data this is.
*/
type = "edu.regis.universeplayer.Log";
/**
* This contains the name of the logger that created the message.
*/
logger;
/**
* The log level this message was sent at.
* @type string
*/
level;
/**
* The actual message sent, as an array of arguments.
* @type array
*/
message;
/**
* Creates a message package.
*
* @param {string} level The log level.
* @param {array-like} message The message to send, as an array-like object.
*/
constructor(logger, level, message)
{
this.logger = logger;
this.level = level;
this.message = Array.from(message);
}
}
class Logger {
name;
/**
* Messages that are scheduled to be sent.
*/
queuedMessages = [];
constructor(name)
{
this.name = name;
}
/**
* This will format the message in a way SLF4j can understand the log message before scheduling
* it for forwarding.
*/
passLog(level, message)
{
let messageData;
if (message.length > 1)
{
if (typeof message[0] == "string")
{
/*
* Format the initial string in a way that SLF4J can understand it.
*/
message[0] = message[0].replaceAll(/%[a-z]/gi, "{}");
}
}
for (let i = 0; i < message.length; i++)
{
if (message[i] instanceof Error)
{
message[i] = {
"type": "edu.regis.universeplayer.browserCommands.BrowserError",
"name": message[i].name,
"message": message[i].message,
"fileName": message[i].fileName,
"lineNumber": message[i].lineNumber,
"columnNumber": message[i].columnNumber,
"stack": message[i].stack
}
}
}
this.queuedMessages.push(new MessageData(this.name, level, message));
message = this.queuedMessages.pop();
try
{
while (message && this.pushUpdate(message))
{
message = this.queuedMessages.pop();
}
}
catch (e)
{
console.error("Could not post log message", e)
}
/*
* If we popped a message but got here, then a message was not sent. Put it back.
*/
if (message)
{
this.queuedMessages.unshift(message);
}
}
/**
* This method is used to actually push data to the interface. Abstract.
*
* @param [MessageData] message The message to push.
* @return [boolean] Whether or not the push was successful.
*/
pushUpdate(message)
{
return false;
}
trace()
{
console.trace.apply(console, arguments);
this.passLog("trace", arguments);
}
debug()
{
console.debug.apply(console, arguments);
this.passLog("debug", arguments);
}
info()
{
console.info.apply(console, arguments);
this.passLog("info", arguments);
}
log()
{
console.log.apply(console, arguments);
this.passLog("info", arguments);
}
warn()
{
console.warn.apply(console, arguments);
this.passLog("warn", arguments);
}
error()
{
console.error.apply(console, arguments);
this.passLog("error", arguments);
}
}

View File

@@ -1,9 +1,26 @@
let ylogger = new Logger("youtube");
logger.pushUpdate = function (message)
{
if (background)
{
sendUpdate(message);
return true;
}
else
{
return false;
}
};
console.debug("Loading youtube.js") console.debug("Loading youtube.js")
var video; var video;
function onload() function onload()
{ {
video = document.getElementsByClassName('video-stream html5-main-video')[0]; ylogger.info("YouTube loaded");
$(".more-button").click();
video = $('.video-stream.html5-main-video')[0];
statusUpdate = e => { statusUpdate = e => {
return onStatusUpdate(getState(), e.srcElement.currentTime, getSongData()); return onStatusUpdate(getState(), e.srcElement.currentTime, getSongData());
}; };
@@ -19,16 +36,177 @@ function onload()
function getSongData() function getSongData()
{ {
return { let song = getAutogeneratedMetadata();
type: "edu.regis.universeplayer.browser.InternetSong", if (!song)
location: "window.location.href", {
title: getTitle(), song = getDetectedMetadata();
artists: getArtists(), if (!song)
trackNum: 0, {
discNum: 0, song = getBasicMetadata();
duration: parseInt(getLength() * 1000), }
album: null
} }
return Promise.resolve(song);
}
/**
* Obtains basic song metadata for if nothing else is detected.
*/
function getBasicMetadata()
{
let data = new InternetSong();
data.location = window.location.href;
data.title = getTitle();
data.artists = getArtists();
data.duration = getLength();
data.album = new Album();
data.album.art = getArt();
return Promise.resolve(data);
}
/**
* Obtains song metadata detected by YouTube.
*
* @param {string} type The type of metadata to get. Can be "Song", "Artist", "Album",
* "Writers", or "Licensed to YouTube by". May be null.
* @return {jQuery|InternetSong} If a type is provided, this returns a jQuery array of string
* values matching that type. Otherwise, it returns an InternetSong
* created from detected metadata.
*/
function getDetectedMetadata(type)
{
if (type)
{
return $("ytd-metadata-row-container-renderer.ytd-video-secondary-info-renderer ytd-metadata-row-renderer:contains('" + type + "') div>yt-formatted-string").map(function () {return $(this).text()});
}
else
{
let song = new InternetSong();
song.title = Array.prototype.join.apply(getDetectedMetadata("Song"), [" / "]);
if (!song.title)
{
return undefined;
}
song.artists = ";".join(getDetectedMetadata("Artist"));
song.album.name = getDetectedMetadata("Album")[0];
song.album.artists = ";".join(getDetectedMetadata("Artist"));
song.location = location.href;
song.duration = getLength();
song.album.art = getArt();
return song;
}
}
/**
* Obtains song metadata detected by YouTube as provided in the description.
*
* @return {InternetSong} The internet song data detected. This will be undefined if no
* autogenerated data was found.
*/
function getAutogeneratedMetadata()
{
let desc = $("yt-formatted-string.ytd-video-secondary-info-renderer").text().replaceAll(/\n\w*\n/g, "\n").split("\n");
if (!desc || !desc.length || !desc[desc.length - 1].startsWith("Auto-generated"))
{
return undefined;
}
let data = new InternetSong();
desc.forEach(str => {
let provHead = "Provided to YouTube by ";
let names;
if (str.startsWith(provHead))
{
data.provider = str.substring(provHead.length);
}
else if (str.indexOf(" · ") > -1)
{
names = str.split(" · ");
data.title = names[0];
data.artists = names.slice(1);
if (!data.album.artists)
{
data.album.artists = data.artists;
}
}
else if (str.startsWith('℗'))
{
data.album.artists = [str.substr(str.match(/^℗( [\d]{4})? /)[0].length)]
names = str.match(/\d{4}/);
if (!data.album.year && names)
{
data.album.year = names[0];
}
}
else if (str.startsWith('Released on: '))
{
names = str.match(/[\d]{4}/);
if (names)
{
data.album.year = names[0];
}
}
else if (data.title && !data.album.name && str)
{
ylogger.debug("Album name: %s", str);
data.album.name = str;
}
else
{
ylogger.debug("Ignoring line %s", str);
}
});
data.location = location.href;
data.duration = getLength();
data.album.art = getArt();
return data;
}
/**
* Obtains the song title.
*
* @return {string} The song title, or null if one could not be found.
*/
function getTitle()
{
return $(".title.ytd-video-primary-info-renderer").text()
}
/**
* Obtains the artists who made this song.
*
* @return {string[]} The song artists, or an empty array if none could be found.
*/
function getArtists()
{
return [$("#channel-name.ytd-video-owner-renderer a:first").text()];
}
/**
* Obtains the YouTube ID for this song
*
* @return {string}
*/
function getSongId()
{
let match = location.search.match(/v=.+(\&|$)/);
if (match)
{
return match[0].substring(2)
}
return "";
}
/**
* Obtains the album art
*
* @return {blob|string} Either a blob for the album art or a URL for where it can be downloaded.
*/
function getArt()
{
return "https://i.ytimg.com/vi/" + getSongId() + "/maxresdefault.jpg"
} }
function getState() function getState()
@@ -53,22 +231,12 @@ function getState()
function getTime() function getTime()
{ {
return video.currentTime; return parseInt(video.currentTime * 1000);
} }
function getLength() function getLength()
{ {
return video.duration; return parseInt(video.duration * 1000);
}
function getTitle()
{
return document.getElementsByTagName("meta").title.content;
}
function getArtists()
{
return [document.getElementById("channel-name").getElementsByTagName("a")[0].text];
} }
function play() function play()
@@ -101,11 +269,4 @@ function seek(time)
return false; return false;
} }
if (document.readyState === "complete") preload.push(onload);
{
onload();
}
else
{
window.addEventListener("load", onload);
}

View File

@@ -19,13 +19,13 @@
}, },
"background": { "background": {
"scripts": ["background.js"] "scripts": ["logger.js", "defs/songs.js", "background.js"]
}, },
"content_scripts": [ "content_scripts": [
{ {
"matches": ["*://*.youtube.com/watch?v=*"], "matches": ["*://*.youtube.com/watch?v=*"],
"js": ["foreground.js", "youtube.js"] "js": ["jquery.js", "logger.js", "defs/songs.js", "foreground.js", "youtube.js"]
} }
], ],
@@ -33,5 +33,5 @@
"default_icon": "icon.svg" "default_icon": "icon.svg"
}, },
"permissions": ["nativeMessaging"] "permissions": ["nativeMessaging", "tabs"]
} }

View File

@@ -1,13 +1,11 @@
import org.apache.tools.ant.taskdefs.condition.Os
import java.lang.reflect.Method
import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.stream.Stream
/* /*
* Copyright (c) 2021 William Hubbard. All Rights Reserved. * Copyright (c) 2021 William Hubbard. All Rights Reserved.
*
* This module contains a program separate from both the browser and the interface. Firefox's native messaging API can
* only start a process, it can't communicate with a process that is already running. That is where this module comes
* in. The browser will start this module, and the interface will create a server over the localhost. When this program
* is started, it connects to that server. It will then relay messages between the browser and interface, translating
* between JSON and serialized Java objects as needed.
*/ */
plugins { plugins {
id 'java' id 'java'
@@ -16,10 +14,17 @@ plugins {
} }
configurations { configurations {
/**
* The native build artifact is used by the addon build process to ensure that it knows where the executable can be
* found. This is because the addon needs to have the executable registered on install.
*/
nativeBuild { nativeBuild {
canBeConsumed = true canBeConsumed = true
canBeResolved = false canBeResolved = false
} }
/**
* The install configuration is used for when we are packaging up the intermediary program.
*/
install { install {
canBeConsumed = true canBeConsumed = true
canBeResolved = false canBeResolved = false
@@ -62,4 +67,10 @@ artifacts {
builtBy installDist builtBy installDist
} }
install(distTar) install(distTar)
} }
processResources {
filesMatching("**/log4j2.xml") {
expand(rootProject.properties)
}
}

View File

@@ -0,0 +1,92 @@
package edu.regis.universeplayer;
import org.apache.logging.log4j.core.*;
import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.apache.logging.log4j.core.config.Property;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory;
import java.io.Serializable;
import java.util.LinkedList;
import java.util.List;
/**
* This appender will store all messages sent to it, for the interface link to
* later collect and forward to the interface.
*
* @author William Hubbard
*/
@Plugin(
name = "Queue",
category = Core.CATEGORY_NAME,
elementType = Appender.ELEMENT_TYPE)
public class QueueAppender extends AbstractAppender
{
private static final LinkedList<LogEvent> events = new LinkedList<>();
/**
* Builds QueueAppender instances.
*
* @param <B> The type to build
*/
public static class Builder<B extends Builder<B>> extends AbstractAppender.Builder<B>
implements org.apache.logging.log4j.core.util.Builder<QueueAppender>
{
@Override
public QueueAppender build()
{
return new QueueAppender(getName(), getFilter(), getOrCreateLayout(), isIgnoreExceptions(), getPropertyArray());
}
}
@PluginBuilderFactory
public static <B extends Builder<B>> B newBuilder()
{
return new Builder<B>().asBuilder();
}
public QueueAppender(final String name, final Filter filter, final Layout<? extends Serializable> layout,
final boolean ignoreExceptions, final Property[] properties)
{
super(name, filter, layout, ignoreExceptions, properties);
}
/**
* Logs a LogEvent using whatever logic this Appender wishes to use. It is
* typically recommended to use a bridge pattern not only for the benefits
* from decoupling an Appender from its implementation, but it is also handy
* for sharing resources which may require some form of locking.
*
* @param event The LogEvent.
*/
@Override
public void append(LogEvent event)
{
synchronized (events)
{
events.add(event);
}
}
/**
* Checks to see if any logs are available.
* @return
*/
public static boolean hasLogs()
{
synchronized (events)
{
return !events.isEmpty();
}
}
public static List<LogEvent> retrieveLogEvents()
{
synchronized (events)
{
List<LogEvent> logs = events.stream().toList();
events.clear();
return logs;
}
}
}

View File

@@ -0,0 +1,47 @@
package edu.regis.universeplayer.addon;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import java.lang.reflect.Type;
import java.util.Arrays;
import edu.regis.universeplayer.browserCommands.BrowserError;
public class BrowserErrorSerializer implements JsonSerializer<BrowserError>, JsonDeserializer<BrowserError>
{
@Override
public JsonElement serialize(BrowserError src, Type typeOfSrc,
JsonSerializationContext context)
{
JsonObject ob = new JsonObject();
ob.addProperty("message", src.getMessage());
String stack =
Arrays.stream(src.getStackTrace())
.map(trace -> trace.getMethodName() + "@" + trace
.getFileName() + ":" + trace
.getLineNumber() + ":0").reduce("",
(s1, s2) -> s1.isEmpty() ? s2 : s1 + "\n" + s2);
ob.addProperty("stack", stack);
JsonArray suppressed = new JsonArray();
for (Throwable throwable : src.getSuppressed())
{
suppressed.add(context.serialize(throwable));
}
return ob;
}
@Override
public BrowserError deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException
{
JsonObject ob = json.getAsJsonObject();
return new BrowserError(ob.get("name").getAsString(), ob.get(
"message").getAsString(), ob.get("stack").getAsString());
}
}

View File

@@ -6,14 +6,21 @@ package edu.regis.universeplayer.addon;
import com.google.gson.*; import com.google.gson.*;
import edu.regis.universeplayer.browserCommands.CommandConfirmation; import com.google.gson.typeadapters.RuntimeTypeAdapterFactory;
import edu.regis.universeplayer.browserCommands.MessageRunner; import edu.regis.universeplayer.PlaybackInfo;
import edu.regis.universeplayer.PlaybackStatus;
import edu.regis.universeplayer.browserCommands.*;
import edu.regis.universeplayer.data.Album;
import edu.regis.universeplayer.data.InternetSong;
import edu.regis.universeplayer.data.Song;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.Serializable;
import java.net.URL;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.ByteOrder; import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -22,7 +29,10 @@ import java.util.HashMap;
/** /**
* The browser link serves as a communication between this process and the * The browser link serves as a communication between this process and the
* browser. It automatically converts information as needed as it passes it to * browser. It automatically converts information as needed as it passes it to
* and from the browser process. * and from the browser process. It receives browser JSON data from the
* system input and converts it to Java objects that the interface can read.
* Likewise, it will convert Java objects into JSON objects that will be sent
* to the browser through the system output.
* *
* @author William Hubbard * @author William Hubbard
* @version 0.1 * @version 0.1
@@ -37,6 +47,8 @@ public class BrowserLink extends MessageRunner
GsonBuilder builder = new GsonBuilder(); GsonBuilder builder = new GsonBuilder();
builder.registerTypeAdapter(StackTraceElement.class, new StackTraceElementSerializer()); builder.registerTypeAdapter(StackTraceElement.class, new StackTraceElementSerializer());
builder.registerTypeAdapter(Throwable.class, new ThrowableSerializer()); builder.registerTypeAdapter(Throwable.class, new ThrowableSerializer());
builder.registerTypeAdapter(BrowserError.class, new BrowserErrorSerializer());
builder.registerTypeAdapter(CommandReturn.class, new CommandReturnSerializer());
gson = builder.create(); gson = builder.create();
} }
@@ -48,6 +60,13 @@ public class BrowserLink extends MessageRunner
super(name, System.in, System.out); super(name, System.in, System.out);
} }
/**
* Converts a Java object into a data stream for sending to the browser.
*
* @param message - The Java object to send.
* @return The byte stream representation of the JSON object that will be
* sent via {@link #writeMessage(OutputStream, int, byte[])}
*/
@Override @Override
public byte[] serializeObject(Object message) public byte[] serializeObject(Object message)
{ {
@@ -59,6 +78,14 @@ public class BrowserLink extends MessageRunner
return val.toString().getBytes(StandardCharsets.UTF_8); return val.toString().getBytes(StandardCharsets.UTF_8);
} }
/**
* Converts data string received from the browser into a Java object.
*
* @param message - The message data stream received, as received by
* {@link #readMessage(InputStream)}.
* @return The Java that maps to the JSON data the browser sent.
* @throws IOException
*/
@Override @Override
public Object deserializeObject(byte[] message) throws IOException public Object deserializeObject(byte[] message) throws IOException
{ {
@@ -66,6 +93,21 @@ public class BrowserLink extends MessageRunner
return getMessage(val); return getMessage(val);
} }
/**
* Writes a message to the output stream. It takes a byte array of JSON
* data and then wraps it in another object containing the "messageNum"
* and "message" properties before writing that stream (preceded by the
* message length) to the browser.
* <p>
* This implementation
*
* @param out - The output stream to write to.
* @param messageNum - The ID of the message being sent. This will help keep
* track of responses.
* @param message - The actual message contents to write. This will be
* a byte stream of encoded JSON data.
* @throws IOException
*/
@Override @Override
public void writeMessage(OutputStream out, int messageNum, byte[] message) throws IOException public void writeMessage(OutputStream out, int messageNum, byte[] message) throws IOException
{ {
@@ -92,6 +134,18 @@ public class BrowserLink extends MessageRunner
out.flush(); out.flush();
} }
/**
* Reads a message from the input stream. It expects a message length
* followed by an encoded JSON message. This JSON message takes the form
* of two objects containing a "messageNum" property and "message"
* (containing the actual message).
*
* @param in - The input stream to read from.
* @return Two byte arrays, the first one containing the identifier of the
* message, and the second containing the byte stream of the actual JSON
* message sent.
* @throws IOException
*/
@Override @Override
public byte[][] readMessage(InputStream in) throws IOException public byte[][] readMessage(InputStream in) throws IOException
{ {
@@ -130,7 +184,7 @@ public class BrowserLink extends MessageRunner
lengthBuffer.clear(); lengthBuffer.clear();
lengthBuffer.putInt(messageNum); lengthBuffer.putInt(messageNum);
getLogger().debug("Reading message {}", messageNum); getLogger().debug("Reading message {}", messageNum);
return new byte[][] {lengthBuffer.array(), message}; return new byte[][]{lengthBuffer.array(), message};
} }
/** /**
@@ -144,6 +198,7 @@ public class BrowserLink extends MessageRunner
*/ */
private Object getMessage(JsonElement message) throws IOException private Object getMessage(JsonElement message) throws IOException
{ {
logger.debug("Deserializing {}", message);
if (message.isJsonPrimitive()) if (message.isJsonPrimitive())
{ {
return getPrimitiveMessage((JsonPrimitive) message); return getPrimitiveMessage((JsonPrimitive) message);
@@ -268,7 +323,7 @@ public class BrowserLink extends MessageRunner
} }
return returnValue; return returnValue;
} }
@Override @Override
public Object getErrorObject(Throwable e) public Object getErrorObject(Throwable e)
{ {

View File

@@ -0,0 +1,169 @@
package edu.regis.universeplayer.addon;
import com.google.gson.*;
import edu.regis.universeplayer.browserCommands.CommandConfirmation;
import edu.regis.universeplayer.browserCommands.CommandReturn;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;
public class CommandReturnSerializer implements JsonSerializer<CommandReturn<?>>, JsonDeserializer<CommandReturn<?>>
{
private static final Logger logger = LoggerFactory.getLogger(CommandReturnSerializer.class);
@Override
public CommandReturn<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException
{
logger.debug("Deserializing return value {}", json);
CommandReturn<?> returnVal;
CommandConfirmation confirmation;
Object value = null;
JsonObject jsonOb = json.getAsJsonObject();
JsonElement jsonVal = jsonOb.get("returnValue");
confirmation = context.deserialize(jsonOb.getAsJsonObject("confirmation"), CommandConfirmation.class);
try
{
value = deserializeObject(jsonVal, context);
}
catch (ClassNotFoundException e)
{
logger.error("Could not deserialize object {}.", json, e);
}
finally
{
returnVal = new CommandReturn<>(value, confirmation);
}
return returnVal;
}
/**
* Attempts to determine what type of object is provided and deserializes
* it.
*
* @param jsonVal The element to deserialize. If it is an object, a "type"
* field will be used to obtain the class.
* @param context The deserialization context.
* @return The deserialized object.
* @throws ClassNotFoundException If the provided "type" field did not
* contain a known class.
*/
private Object deserializeObject(JsonElement jsonVal, JsonDeserializationContext context) throws ClassNotFoundException
{
Object value;
if (jsonVal.isJsonPrimitive())
{
JsonPrimitive primVal = jsonVal.getAsJsonPrimitive();
if (primVal.isString())
{
value = primVal.getAsString();
}
else if (primVal.isBoolean())
{
value = primVal.getAsBoolean();
}
else if (primVal.isNumber())
{
value = primVal.getAsNumber();
}
else
{
value = 0;
}
}
else if (jsonVal.isJsonArray())
{
JsonArray arrVal = jsonVal.getAsJsonArray();
if (arrVal.size() > 0)
{
value = new ArrayList<>();
for (JsonElement el : arrVal)
{
((ArrayList) value).add(deserializeObject(el, context));
}
}
else
{
value = new ArrayList<>();
}
}
else if (jsonVal.isJsonObject())
{
JsonObject obVal = jsonVal.getAsJsonObject();
Class type;
if (obVal.has("type"))
{
type = Class.forName(obVal.get("type").getAsString());
value = context.deserialize(obVal, type);
logger.debug("Deserializing object of type {} {}", obVal.get("type").getAsString(), value);
}
else
{
value = context.deserialize(obVal, Object.class);
logger.debug("Deserializing object of type {} {}", value.getClass(), value);
}
}
else
{
value = null;
}
if (!jsonVal.isJsonObject())
{
logger.debug("Deserializing non-object {}", jsonVal);
}
return value;
}
@Override
public JsonElement serialize(CommandReturn<?> src, Type typeOfSrc, JsonSerializationContext context)
{
return serializeObject(src, context);
}
/**
* Attempts to determine what type of object is provided and deserializes
* it.
*
* @param value The element to deserialize. If it is an object, a "type"
* field will be used to obtain the class.
* @param context The serialization context.
* @return The serialized object.
*/
private JsonElement serializeObject(Object value, JsonSerializationContext context)
{
JsonElement jsonVal;
if (value == null)
{
jsonVal = JsonNull.INSTANCE;
}
else
{
if (value.getClass().isArray())
{
value = Arrays.stream((Object[]) value).collect(Collectors.toList());
}
if (value instanceof String || value instanceof Number || value instanceof Boolean)
{
jsonVal = context.serialize(value);
}
else if (value instanceof Collection)
{
jsonVal = new JsonArray();
for (Object val : (Collection) value)
{
jsonVal.getAsJsonArray().add(serializeObject(val, context));
}
}
else
{
jsonVal = context.serialize(value, value.getClass());
jsonVal.getAsJsonObject().addProperty("type", value.getClass().getName());
}
}
return jsonVal;
}
}

View File

@@ -0,0 +1,134 @@
package edu.regis.universeplayer.addon;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.lang.reflect.Type;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Arrays;
import java.util.Map;
import edu.regis.universeplayer.browserCommands.BrowserError;
public class GenericSerializer implements JsonSerializer<Object>,
JsonDeserializer<Object>
{
private static final Logger logger =
LoggerFactory.getLogger(GenericSerializer.class);
@Override
public JsonElement serialize(Object src, Type typeOfSrc,
JsonSerializationContext context)
{
JsonObject val = new JsonObject();
for (Field field : src.getClass().getFields())
{
try
{
if (!Modifier.isTransient(field.getModifiers()))
{
val.add(field.getName(), context.serialize(field.get(src)));
}
}
catch (IllegalAccessException e)
{
logger.error("Could not store field " + field.getName() + " " +
"of class " + src.getClass().getName());
}
}
val.addProperty("type", src.getClass().getName());
return val;
}
@Override
public Object deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException
{
Class<?> clazz;
Field field;
Object ob = null;
if (json.isJsonObject() && json.getAsJsonObject().has("type"))
{
JsonObject jsonOb = json.getAsJsonObject();
try
{
clazz = Class.forName(jsonOb.remove("type").getAsString());
ob = clazz.getConstructor().newInstance();
for (Map.Entry<String, JsonElement> fields: jsonOb.entrySet())
{
field = clazz.getField(fields.getKey());
if (field.getType().isAssignableFrom(String.class))
{
field.set(ob, fields.getValue().getAsString());
}
else if (field.getType().isAssignableFrom(int.class))
{
field.setInt(ob, fields.getValue().getAsInt());
}
else if (field.getType().isAssignableFrom(double.class))
{
field.setDouble(ob, fields.getValue().getAsDouble());
}
else if (field.getType().isAssignableFrom(byte.class))
{
field.setByte(ob, fields.getValue().getAsByte());
}
else if (field.getType().isAssignableFrom(String.class))
{
field.set(ob, fields.getValue().getAsString());
}
else if (field.getType().isAssignableFrom(boolean.class))
{
field.setBoolean(ob, fields.getValue().getAsBoolean());
}
else if (field.getType().isAssignableFrom(float.class))
{
field.setFloat(ob, fields.getValue().getAsFloat());
}
else if (field.getType().isAssignableFrom(long.class))
{
field.setLong(ob, fields.getValue().getAsLong());
}
else if (field.getType().isAssignableFrom(short.class))
{
field.setShort(ob, fields.getValue().getAsShort());
}
else if (field.getType().isAssignableFrom(URL.class))
{
try
{
field.set(ob, new URL(fields.getValue().getAsString()));
}
catch (MalformedURLException e)
{
logger.error("Could not create url {}",
fields.getValue().getAsString(), e);
}
}
else if (fields.getValue().isJsonObject())
{
// clazz = fields.getValue().getAsJsonObject().
// field.set(ob, context.);
}
}
}
catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchFieldException e)
{
logger.error("Could not deserialize {} ", json, e);
}
}
return ob;
}
}

View File

@@ -4,8 +4,10 @@
package edu.regis.universeplayer.addon; package edu.regis.universeplayer.addon;
import edu.regis.universeplayer.QueueAppender;
import edu.regis.universeplayer.browserCommands.BrowserConstants; import edu.regis.universeplayer.browserCommands.BrowserConstants;
import edu.regis.universeplayer.browserCommands.MessageHandler; import edu.regis.universeplayer.browserCommands.MessageHandler;
import org.apache.logging.log4j.core.LogEvent;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -15,10 +17,20 @@ import java.util.LinkedList;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future; import java.util.concurrent.Future;
/**
* This class is responsible for setting up the intermediary program. The
* program is launched by the browser addon when the browser (not a tab, the
* browser) has launched and the addon has loaded. It launches two forwarding
* streams: one between this application and the browser that translates
* between the JSON format the browser uses and the serialized Java objects
* that this program uses (working over console I/O), and a second link to
* communicate between the interface and the browser link over the localhost
* (as defined by the socket server the interface sets up).
*/
public class Main public class Main
{ {
private static final Logger logger = LoggerFactory.getLogger(Main.class); private static final Logger logger = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) throws IOException public static void main(String[] args) throws IOException
{ {
MessageHandler interfaceLink; MessageHandler interfaceLink;
@@ -27,31 +39,62 @@ public class Main
Socket socket = null; Socket socket = null;
try try
{ {
/*
* Sets up a two-way data stream to the browser through the
* process I/O streams. This converts between JSON data used by
* the browser to serializable Java objects used by the interface.
*/
logger.debug("Connecting to browser"); logger.debug("Connecting to browser");
browserLink = new BrowserLink("BrowserLink"); browserLink = new BrowserLink("BrowserLink");
try try
{ {
logger.debug("Setting up connection"); logger.debug("Setting up connection");
socket = new Socket(BrowserConstants.IP,BrowserConstants.PORT); socket = new Socket(BrowserConstants.IP, BrowserConstants.PORT);
logger.debug("Connection established"); logger.debug("Connection established");
/*
* Sets up the data stream between this application and the
* interface.
*/
Socket finalSocket = socket; Socket finalSocket = socket;
interfaceLink = new MessageHandler("InterfaceHandler", finalSocket.getInputStream(), finalSocket.getOutputStream()) { interfaceLink = new MessageHandler("InterfaceHandler", finalSocket.getInputStream(), finalSocket.getOutputStream())
{
private long lastPing = 0;
@Override @Override
protected boolean onRun() protected boolean onRun()
{ {
/*
* How many milliseconds must pass between browser
* pings before this stream shuts down.
*/
final long PING_RATE = 5000;
if (!finalSocket.isConnected() || finalSocket.isClosed() || finalSocket.isInputShutdown() || finalSocket.isOutputShutdown()) if (!finalSocket.isConnected() || finalSocket.isClosed() || finalSocket.isInputShutdown() || finalSocket.isOutputShutdown())
{ {
logger.debug("Interface socket closed, shutting down"); logger.debug("Interface socket closed, shutting down");
return true; return true;
} }
/** /*
* Make sure that it is active. * Make sure that it is active, either with logs from
* the browser or just with ping messages sent from
* here.
*/ */
this.sendUpdate("ping"); if (QueueAppender.hasLogs())
{
lastPing = System.currentTimeMillis();
for (LogEvent event : QueueAppender.retrieveLogEvents())
{
this.sendUpdate(event);
}
}
else if (System.currentTimeMillis() - lastPing >= PING_RATE)
{
lastPing = System.currentTimeMillis();
this.sendUpdate("ping");
}
return false; return false;
} }
@Override @Override
protected void onClose() protected void onClose()
{ {
@@ -70,26 +113,36 @@ public class Main
logger.debug("Connection received"); logger.debug("Connection received");
/* /*
* Pretty much just forwards any messages to the browser and * Pretty much just forwards any messages to the browser and
* returns their value. * returns their value. This is primarily a one-way
* relationship, where the interface sends commands, and the
* browser returns updates and responses.
*/ */
browserLink.addUpdateListener((update, link) -> { browserLink.addUpdateListener((update, link) ->
{
logger.debug("Sending update {}", update); logger.debug("Sending update {}", update);
interfaceLink.sendUpdate(update); interfaceLink.sendUpdate(update);
}); });
interfaceLink.addListener((providedValue, previousReturn) -> { interfaceLink.addListener((providedValue, previousReturn) ->
{
logger.debug("Forwarding message to browser: {}", providedValue); logger.debug("Forwarding message to browser: {}", providedValue);
Object returnValue = browserLink.sendObject(providedValue).get(); Object returnValue = browserLink.sendObject(providedValue).get();
logger.debug("Forwarding return to interface: {}", returnValue); logger.debug("Forwarding return to interface: {}", returnValue);
return returnValue; return returnValue;
}); });
/*
* Sets up both streams on their own threads.
*/
browserThread = new Thread(browserLink); browserThread = new Thread(browserLink);
interfaceThread = new Thread(interfaceLink); interfaceThread = new Thread(interfaceLink);
logger.debug("Starting threads."); logger.debug("Starting threads.");
browserThread.start(); browserThread.start();
interfaceThread.start(); interfaceThread.start();
logger.debug("Joining threads."); logger.debug("Joining threads.");
/*
* Waits for both threads to shut down.
*/
try try
{ {
browserThread.join(); browserThread.join();
@@ -136,7 +189,7 @@ public class Main
} }
} }
} }
private static void testMain() throws ExecutionException, InterruptedException private static void testMain() throws ExecutionException, InterruptedException
{ {
LinkedList<Future<Object>> requests = new LinkedList<>(); LinkedList<Future<Object>> requests = new LinkedList<>();

View File

@@ -2,16 +2,29 @@
<!-- <!--
~ Copyright (c) 2021 William Hubbard. All Rights Reserved. ~ Copyright (c) 2021 William Hubbard. All Rights Reserved.
--> -->
<Configuration status="WARN"> <Configuration packages="edu.regis.universeplayer" status="WARN">
<Appenders> <Appenders>
<Console name="Console" target="SYSTEM_ERR"> <Console name="Console" target="SYSTEM_ERR">
<PatternLayout pattern="%d%c%L %-5level - %msg%n"/> <PatternLayout pattern="%d%c%L %-5level - %msg%n"/>
</Console> </Console>
<File name="File" fileName="addonInter.log" append="false"> <RollingFile name="File" fileName="$LOG_DIR/addonInter.log"
filePattern="$LOG_DIR/addonInter.%i.log.gz"
ignoreExceptions="false">
<PatternLayout> <PatternLayout>
<Pattern>%d{HH:mm:ss.SSS} - %c.%M(%F:%L) - %-5level - %msg%n</Pattern> <Pattern>%d{HH:mm:ss.SSS} - %c.%M(%F:%L) - %-5level - %msg%n
</Pattern>
</PatternLayout> </PatternLayout>
</File> <Policies>
<OnStartupTriggeringPolicy/>
</Policies>
<DefaultRolloverStrategy max="5"/>
</RollingFile>
<Queue name="Queue"/>
<Async name="Async">
<AppenderRef ref="File"/>
<AppenderRef ref="Console"/>
<AppenderRef ref="Queue"/>
</Async>
</Appenders> </Appenders>
<Loggers> <Loggers>
<Root level="debug"> <Root level="debug">

View File

@@ -0,0 +1,5 @@
package edu.regis.universeplayer.addon;
public class BrowserLinkTest
{
}

View File

@@ -1,5 +1,13 @@
/* /*
* Copyright (c) 2021 William Hubbard. All Rights Reserved. * Copyright (c) 2021 William Hubbard. All Rights Reserved.
*
* This module is responsible for handling an instance of the Firefox web browser during runtime. This will install the
* appropriate version of Firefox Developer edition to the application installation directory, downloading it directly
* from Mozilla (administrator privileges are needed on Windows). It will also compile addon dependencies and add them
* to the install.
*
* This is all done prior to compiling the Java component of this module. The Java component is a single library that
* contains the appropriate methods to launch the browser and relay messages to and from the running instance.
*/ */
import org.apache.tools.ant.taskdefs.condition.Os import org.apache.tools.ant.taskdefs.condition.Os
@@ -12,10 +20,16 @@ plugins {
} }
configurations { configurations {
/**
* Addons are dependencies that will be added to the Firefox installation as an extension.
*/
addon { addon {
canBeConsumed = false canBeConsumed = false
canBeResolved = true canBeResolved = true
} }
/**
* This will be how the browser policy and addon configuration will be exported.
*/
install { install {
canBeConsumed = true canBeConsumed = true
canBeResolved = false canBeResolved = false
@@ -49,20 +63,21 @@ repositories {
} }
} }
abstract class InstallBrowserTask extends DefaultTask {
@OutputFile
final abstract DirectoryProperty installation = project.objects.directoryProperty()
}
dependencies { dependencies {
implementation 'org.slf4j:slf4j-api:1.7.30' implementation 'org.slf4j:slf4j-api:1.7.30'
implementation 'org.apache.logging.log4j:log4j-api:2.13.3' implementation 'org.apache.logging.log4j:log4j-api:2.13.3'
implementation 'org.apache.logging.log4j:log4j-core:2.13.3' implementation 'org.apache.logging.log4j:log4j-core:2.13.3'
implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.13.3' implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.13.3'
implementation project(":browserCommands") implementation project(":browserCommands")
addon project(path: ":add-on", configuration: 'addonBuild') testImplementation 'junit:junit:4.12'
addon project(path: ":add-on", configuration: 'addonBuild')
} }
/**
* Downloads the 64-bit Linux Firefox installer.
*
* @return The msi file that installs firefox.
*/
task downloadWindows_x86_64(type: Download) { task downloadWindows_x86_64(type: Download) {
src "https://download-installer.cdn.mozilla.net/pub/${firefox_module}/releases/${firefox_revision}/win64/en-US/Firefox%20Setup%20${firefox_revision}.msi" src "https://download-installer.cdn.mozilla.net/pub/${firefox_module}/releases/${firefox_revision}/win64/en-US/Firefox%20Setup%20${firefox_revision}.msi"
dest layout.buildDirectory.file("installer.msi") dest layout.buildDirectory.file("installer.msi")
@@ -70,6 +85,11 @@ task downloadWindows_x86_64(type: Download) {
onlyIfModified true onlyIfModified true
} }
/**
* Downloads the 32-bit Linux Firefox installer.
*
* @return The msi file that installs firefox.
*/
task downloadWindows_x86(type: Download) { task downloadWindows_x86(type: Download) {
src "https://download-installer.cdn.mozilla.net/pub/${firefox_module}/releases/${firefox_revision}/win32/en-US/Firefox%20Setup%20${firefox_revision}.msi" src "https://download-installer.cdn.mozilla.net/pub/${firefox_module}/releases/${firefox_revision}/win32/en-US/Firefox%20Setup%20${firefox_revision}.msi"
dest layout.buildDirectory.file("installer.msi") dest layout.buildDirectory.file("installer.msi")
@@ -77,6 +97,11 @@ task downloadWindows_x86(type: Download) {
onlyIfModified true onlyIfModified true
} }
/**
* Downloads the 64-bit Linux Firefox installation.
*
* @return The tar file that contains the Firefox download.
*/
task downloadLinux_x86_64(type: Download) { task downloadLinux_x86_64(type: Download) {
src "https://download-installer.cdn.mozilla.net/pub/${firefox_module}/releases/${firefox_revision}/linux-x86_64/en-US/firefox-${firefox_revision}.tar.bz2" src "https://download-installer.cdn.mozilla.net/pub/${firefox_module}/releases/${firefox_revision}/linux-x86_64/en-US/firefox-${firefox_revision}.tar.bz2"
dest layout.buildDirectory.file("installer.tar.bz2") dest layout.buildDirectory.file("installer.tar.bz2")
@@ -84,6 +109,11 @@ task downloadLinux_x86_64(type: Download) {
onlyIfModified true onlyIfModified true
} }
/**
* Downloads the 32-bit Linux Firefox installation.
*
* @return The tar file that contains the Firefox download.
*/
task downloadLinux_i686(type: Download) { task downloadLinux_i686(type: Download) {
src "https://download-installer.cdn.mozilla.net/pub/${firefox_module}/releases/${firefox_revision}/linux-i686/en-US/firefox-${firefox_revision}.tar.bz2" src "https://download-installer.cdn.mozilla.net/pub/${firefox_module}/releases/${firefox_revision}/linux-i686/en-US/firefox-${firefox_revision}.tar.bz2"
dest layout.buildDirectory.file("installer.tar.bz2") dest layout.buildDirectory.file("installer.tar.bz2")
@@ -91,6 +121,14 @@ task downloadLinux_i686(type: Download) {
onlyIfModified true onlyIfModified true
} }
/**
* Installs the 64-bit Windows Firefox to the firefox directory within the project root ($rootDir/firefox). Note that
* administrator privileges will be needed to properly install.
*
* This depends on the output of downloadWindows_x86_64.
*
* @return The directory Firefox was installed to.
*/
task installWindows_x86_64(dependsOn: downloadWindows_x86_64, type: Exec) { task installWindows_x86_64(dependsOn: downloadWindows_x86_64, type: Exec) {
workingDir layout.buildDirectory workingDir layout.buildDirectory
commandLine 'msiexec', '/i', '"' + downloadWindows_x86_64.dest + '"', '/li', '"install.log"', '/qb', "INSTALL_DIRECTORY_PATH=\"$rootDir\\firefox\"", 'TASKBAR_SHORTCUT=false', 'DESKTOP_SHORTCUT=false', 'INSTALL_MAINTENANCE_SERVICE=false' commandLine 'msiexec', '/i', '"' + downloadWindows_x86_64.dest + '"', '/li', '"install.log"', '/qb', "INSTALL_DIRECTORY_PATH=\"$rootDir\\firefox\"", 'TASKBAR_SHORTCUT=false', 'DESKTOP_SHORTCUT=false', 'INSTALL_MAINTENANCE_SERVICE=false'
@@ -101,6 +139,14 @@ installWindows_x86_64.doFirst {
println "Administrator privileges needed for installing Firefox. Please confirm on the popup." println "Administrator privileges needed for installing Firefox. Please confirm on the popup."
} }
/**
* Installs the 32-bit Windows Firefox to the firefox directory within the project root ($rootDir/firefox). Note that
* administrator privileges will be needed to properly install.
*
* This depends on the output of downloadWindows_x86.
*
* @return The directory Firefox was installed to.
*/
task installWindows_x86(dependsOn: downloadWindows_x86, type: Exec) { task installWindows_x86(dependsOn: downloadWindows_x86, type: Exec) {
workingDir layout.buildDirectory workingDir layout.buildDirectory
commandLine 'msiexec', '/i', '"' + downloadWindows_x86.dest + '"', '/li', '"install.log"', '/qb', "INSTALL_DIRECTORY_PATH=\"$rootDir/firefox\"", 'TASKBAR_SHORTCUT=false', 'DESKTOP_SHORTCUT=false', 'INSTALL_MAINTENANCE_SERVICE=false' commandLine 'msiexec', '/i', '"' + downloadWindows_x86.dest + '"', '/li', '"install.log"', '/qb', "INSTALL_DIRECTORY_PATH=\"$rootDir/firefox\"", 'TASKBAR_SHORTCUT=false', 'DESKTOP_SHORTCUT=false', 'INSTALL_MAINTENANCE_SERVICE=false'
@@ -111,10 +157,20 @@ installWindows_x86.doFirst {
println "Administrator privileges needed for installing Firefox. Please confirm on the popup." println "Administrator privileges needed for installing Firefox. Please confirm on the popup."
} }
/**
* Removes the Firefox installation directory, $rootDir/firefox.
*/
task deleteFirefoxWindows(type: Delete) { task deleteFirefoxWindows(type: Delete) {
delete "$rootDir/firefox" delete "$rootDir/firefox"
} }
/**
* Invoke's the Firefox's Windows uninstaller.
*
* This task will call deleteFirefoxWindows.
*
* @return The directory Firefox was installed to.
*/
task uninstallFirefoxWindows(type: Exec) { task uninstallFirefoxWindows(type: Exec) {
workingDir layout.buildDirectory workingDir layout.buildDirectory
commandLine 'cmd', '/c', "$rootDir\\firefox\\uninstall\\helper.exe", '/S' commandLine 'cmd', '/c', "$rootDir\\firefox\\uninstall\\helper.exe", '/S'
@@ -125,6 +181,13 @@ uninstallFirefoxWindows.doFirst {
} }
uninstallFirefoxWindows.finalizedBy deleteFirefoxWindows uninstallFirefoxWindows.finalizedBy deleteFirefoxWindows
/**
* Installs the 64-bit Linux Firefox to the firefox directory within the project root ($rootDir/firefox).
*
* This depends on the output of downloadLinux_x86_64.
*
* @return The directory Firefox was installed to.
*/
task installLinux_x86_64(dependsOn: downloadLinux_x86_64, type: Copy) { task installLinux_x86_64(dependsOn: downloadLinux_x86_64, type: Copy) {
from(tarTree(downloadLinux_x86_64.dest)) { from(tarTree(downloadLinux_x86_64.dest)) {
include "firefox/**" include "firefox/**"
@@ -136,6 +199,13 @@ task installLinux_x86_64(dependsOn: downloadLinux_x86_64, type: Copy) {
// outputs.dir(new File(rootDir, "firefox")) // outputs.dir(new File(rootDir, "firefox"))
} }
/**
* Installs the 32-bit Linux Firefox to the firefox directory within the project root ($rootDir/firefox).
*
* This depends on the output of downloadLinux_i686.
*
* @return The directory Firefox was installed to.
*/
task installLinux_i686(dependsOn: downloadLinux_i686, type: Copy) { task installLinux_i686(dependsOn: downloadLinux_i686, type: Copy) {
from(tarTree(downloadLinux_i686.dest)) { from(tarTree(downloadLinux_i686.dest)) {
include "firefox/**" include "firefox/**"
@@ -147,6 +217,14 @@ task installLinux_i686(dependsOn: downloadLinux_i686, type: Copy) {
outputs.dir("$rootDir/firefox") outputs.dir("$rootDir/firefox")
} }
/**
* Installs the version of Firefox appropriate for the current system.
*
* This depends on the output of installWindows_x86_64, installWindows_x86, installLinux_x86_64, or installLinux_i686,
* depending on the architecture.
*
* @return The directory Firefox was installed in.
*/
task installFirefox() { task installFirefox() {
if (Os.isFamily(Os.FAMILY_WINDOWS)) if (Os.isFamily(Os.FAMILY_WINDOWS))
{ {
@@ -200,6 +278,11 @@ task installFirefox() {
} }
} }
/**
* Sets up the folders that addons will be stored in, the /distribution/extensions folder.
*
* This task depends on installFirefox.
*/
task setupProfile { task setupProfile {
dependsOn installFirefox dependsOn installFirefox
doFirst { doFirst {
@@ -214,10 +297,19 @@ task setupProfile {
} }
} }
/**
* This task sets up the policies that Firefox will use when creating new profiles. These are copied from the
* browserConf directory.
*
* This task depends on setupProfile.
*/
task movePolicies(type: Copy) { task movePolicies(type: Copy) {
dependsOn setupProfile dependsOn setupProfile
from files("browserConf") from files("browserConf")
into "$rootDir/firefox/" into "$rootDir/firefox/"
/*
* Registers any required addons as needed.
*/
filesMatching('**/policies.json'){ filesMatching('**/policies.json'){
def pre = "" def pre = ""
for (File addon: configurations.addon.resolve()) for (File addon: configurations.addon.resolve())
@@ -239,6 +331,11 @@ task movePolicies(type: Copy) {
} }
} }
/**
* Builds all addon dependencies and copies them to the Firefox installation's distribution/extensions folder.
*
* Depends on installFirefox, setupProfile, and addon dependencies.
*/
task installAddons(type: Copy) { task installAddons(type: Copy) {
dependsOn setupProfile dependsOn setupProfile
dependsOn configurations.addon dependsOn configurations.addon
@@ -249,6 +346,11 @@ task installAddons(type: Copy) {
} }
} }
/**
* Zips all addons and browser policies into a conf.tar file.
*
* I don't actually remember what this is supposed to do.
*/
task zipPolicies(type: Tar) { task zipPolicies(type: Tar) {
archiveFileName = "conf.tar" archiveFileName = "conf.tar"
destinationDirectory = file("$buildDir") destinationDirectory = file("$buildDir")
@@ -279,9 +381,15 @@ tasks.named('clean') {
} }
println configurations.getNames() println configurations.getNames()
/*
* The java runtime requires the browser to be set up beforehand.
*/
compileJava.dependsOn installAddons compileJava.dependsOn installAddons
compileJava.dependsOn movePolicies compileJava.dependsOn movePolicies
/*
* Exports the addons and policy configuration.
*/
artifacts { artifacts {
install(zipPolicies) install(zipPolicies)
} }

View File

@@ -4,53 +4,90 @@
package edu.regis.universeplayer.browser; package edu.regis.universeplayer.browser;
import edu.regis.universeplayer.ConfigManager;
import edu.regis.universeplayer.Log;
import edu.regis.universeplayer.browserCommands.BrowserConstants;
import edu.regis.universeplayer.browserCommands.MessageRunner;
import org.apache.logging.log4j.core.DefaultLoggerContextAccessor;
import org.apache.logging.log4j.core.LogEvent;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File; import java.io.File;
import java.io.FileWriter;
import java.io.IOException; import java.io.IOException;
import java.io.PrintStream; import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.ConnectException; import java.net.ConnectException;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.util.Arrays;
import java.util.Scanner; import java.util.Scanner;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import edu.regis.universeplayer.ConfigManager; /**
import edu.regis.universeplayer.browserCommands.BrowserConstants; * This message runner is responsible for starting an instance of a browser
import edu.regis.universeplayer.browserCommands.MessageRunner; * and running messages to and from it. This runner provides a static method,
* {@link #createBrowser()}, that starts an instance of the Firefox browser.
* That instance contains an addon that starts up a third process, the
* intermediary program. The intermediary program connects to the interface
* and relays messages between the browser and this runner.
*/
public class Browser extends MessageRunner public class Browser extends MessageRunner
{ {
private static final Logger logger = LoggerFactory.getLogger(Browser.class); private static final Logger logger = LoggerFactory.getLogger(Browser.class);
private static final Logger browserLogger =
LoggerFactory.getLogger("browser");
private static Browser INSTANCE; private static Browser INSTANCE;
/*
* This boolean keeps track of whether the browser has started or not.
* Threads that need to wait for the browser to start can wait upon this
* object's monitor.
*/
private static final AtomicBoolean instanceWaiter = new AtomicBoolean(); private static final AtomicBoolean instanceWaiter = new AtomicBoolean();
/**
* Obtains the running instance of the browser.
*
* @return The running Browser instance, or null if it is not running.
*/
public static Browser getInstance() public static Browser getInstance()
{ {
return INSTANCE; return INSTANCE;
} }
private final Process process; private final Process process;
private final ServerSocket server; private final ServerSocket server;
private final Socket socket; private final Socket socket;
private boolean running = true; private boolean running = true;
/**
* Launches a browser instance.
*
* @return The instance launched.
* @throws IOException
* @throws InterruptedException
*/
public static Browser createBrowser() throws IOException, InterruptedException public static Browser createBrowser() throws IOException, InterruptedException
{ {
if (INSTANCE != null) if (INSTANCE != null)
{ {
return INSTANCE; return INSTANCE;
} }
/*
* Creates a server on the localhost. When the browser starts the
* intermediary program, that program will attempt to connect to this
* server. Any messages the browser outputs will eventually be received
* here, and any messages to send will first be pushed through this
* server.
*/
ServerSocket server = new ServerSocket(BrowserConstants.PORT, 50, InetAddress ServerSocket server = new ServerSocket(BrowserConstants.PORT, 50, InetAddress
.getByName(null)); .getByName(null));
logger.debug("Server started."); logger.debug("Server started.");
int startExit; int startExit;
Process browserProcess = launchBrowser(); Process browserProcess = launchBrowser();
/* /*
@@ -75,7 +112,10 @@ public class Browser extends MessageRunner
} }
} }
logger.debug("Browser started."); logger.debug("Browser started.");
/*
* Waits for the intermediary program to connect to our server.
*/
ConnectException connErr = null; ConnectException connErr = null;
logger.debug("Attempting connection"); logger.debug("Attempting connection");
Socket socket = server.accept(); Socket socket = server.accept();
@@ -103,12 +143,25 @@ public class Browser extends MessageRunner
{ {
logger.debug("Connection established."); logger.debug("Connection established.");
} }
/*
* Now that the socket connection has been set up, we can create the
* message runner.
*/
INSTANCE = new Browser(socket, server, browserProcess); INSTANCE = new Browser(socket, server, browserProcess);
instanceWaiter.set(true); instanceWaiter.set(true);
notifyAllInstance(); notifyAllInstance();
return INSTANCE; return INSTANCE;
} }
/**
* Creates a browser message runner,
*
* @param socket - The socket that the browser's intermediary program is
* using.
* @param server - The server that is hosting the above socket.
* @param process - The process controlling the browser instance.
* @throws IOException
*/
private Browser(Socket socket, ServerSocket server, Process process) throws IOException private Browser(Socket socket, ServerSocket server, Process process) throws IOException
{ {
super("BrowserRunner", socket.getInputStream(), socket super("BrowserRunner", socket.getInputStream(), socket
@@ -116,8 +169,89 @@ public class Browser extends MessageRunner
this.socket = socket; this.socket = socket;
this.server = server; this.server = server;
this.process = process; this.process = process;
/*
* Automatically sends browser logs that come through to the main log.
*/
this.addUpdateListener((object, runner) ->
{
Log log;
LogEvent logEvent;
if (object instanceof Log)
{
log = (Log) object;
String methodName;
Class<?>[] types = null;
Object[] params = null;
if (log.message.length == 1 && !(log.message[0] instanceof String))
{
log.message = new Object[]{"{}", log.message[0]};
}
if (log.message.length == 1)
{
types = new Class<?>[]{String.class};
params = new Object[]{log.message[0]};
}
else if (log.message.length == 2)
{
types = new Class<?>[]{String.class, Object.class};
params = new Object[]{log.message[0], log.message[1]};
}
else if (log.message.length == 3)
{
types = new Class<?>[]{String.class, Object.class,
Object[].class};
params = new Object[]{log.message[0], log.message[1],
log.message[1]};
}
else if (log.message.length > 4)
{
types = new Class<?>[]{String.class, Object[].class};
params = new Object[]{log.message[0],
Arrays.copyOfRange(log.message, 1,
log.message.length)};
}
methodName = log.level.toLowerCase();
if (types != null)
{
Method method = null;
try
{
method = Logger.class.getMethod(methodName, types);
method.invoke(LoggerFactory.getLogger(log.logger), params);
}
catch (NoSuchMethodException e)
{
/*
* This can happen if a zero-argument message was
* received. This is expected.
*/
}
catch (IllegalAccessException | InvocationTargetException e)
{
logger.error("Could not process logger message {}", log,
e);
}
catch (IllegalArgumentException e)
{
logger.error("Could not call logger {} with arguments {}", method,
params);
}
}
}
else if (object instanceof LogEvent)
{
DefaultLoggerContextAccessor.INSTANCE.getLoggerContext().getRootLogger().get().log((LogEvent) object);
}
});
} }
/**
* {@inheritDoc} This keeps the thread running for as long as the
* connection exists.
*
* @return Whether we chould stop this thread or not.
*/
@Override @Override
protected boolean onRun() protected boolean onRun()
{ {
@@ -129,7 +263,10 @@ public class Browser extends MessageRunner
} }
return !this.running; return !this.running;
} }
/**
* {@inheritDoc} This shuts down the socket, server, and browser process.
*/
@Override @Override
protected void onClose() protected void onClose()
{ {
@@ -158,18 +295,19 @@ public class Browser extends MessageRunner
} }
} }
} }
/** /**
* * Called to stop the browser.
*/ */
public void stop() public void stop()
{ {
this.running = false; this.running = false;
} }
/** /**
* Utility method for launching a browser instance * Utility method for launching a browser instance process.
* *
* @return The process responsible for the browser.
* @throws IOException - Thrown if there is a problem launching the * @throws IOException - Thrown if there is a problem launching the
* browser. * browser.
*/ */
@@ -205,15 +343,23 @@ public class Browser extends MessageRunner
{ {
throw new IOException("Could not find Firefox installation for OS " + os + " " + arch); throw new IOException("Could not find Firefox installation for OS " + os + " " + arch);
} }
return process; return process;
} }
public static void notifyInstance() /**
* Notifies a single random waiting thread that the browser message
* runner has started.
*/
private static void notifyInstance()
{ {
instanceWaiter.notify(); instanceWaiter.notify();
} }
/**
* Alerts all threads waiting for the browser to start that the browser has
* started.
*/
public static void notifyAllInstance() public static void notifyAllInstance()
{ {
synchronized (instanceWaiter) synchronized (instanceWaiter)
@@ -221,7 +367,13 @@ public class Browser extends MessageRunner
instanceWaiter.notifyAll(); instanceWaiter.notifyAll();
} }
} }
/**
* Causes the current thread to wait for the browser process to start and
* the message runner to properly set up.
*
* @throws InterruptedException
*/
public static void waitInstance() throws InterruptedException public static void waitInstance() throws InterruptedException
{ {
synchronized (instanceWaiter) synchronized (instanceWaiter)
@@ -229,7 +381,13 @@ public class Browser extends MessageRunner
instanceWaiter.wait(); instanceWaiter.wait();
} }
} }
/**
* Causes the current thread to wait for the browser process to start and
* the message runner to properly set up.
*
* @throws InterruptedException
*/
public static void waitInstance(long timeoutMillis) throws InterruptedException public static void waitInstance(long timeoutMillis) throws InterruptedException
{ {
synchronized (instanceWaiter) synchronized (instanceWaiter)
@@ -237,7 +395,13 @@ public class Browser extends MessageRunner
instanceWaiter.wait(timeoutMillis); instanceWaiter.wait(timeoutMillis);
} }
} }
/**
* Causes the current thread to wait for the browser process to start and
* the message runner to properly set up.
*
* @throws InterruptedException
*/
public static void waitInstance(long timeoutMillis, int nanos) throws InterruptedException public static void waitInstance(long timeoutMillis, int nanos) throws InterruptedException
{ {
synchronized (instanceWaiter) synchronized (instanceWaiter)

View File

@@ -0,0 +1,77 @@
import static org.junit.Assert.assertEquals;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import edu.regis.universeplayer.NumberPing;
import edu.regis.universeplayer.browser.Browser;
import edu.regis.universeplayer.browserCommands.CommandReturn;
public class PingTest
{
@BeforeClass
public static void setupBrowser() throws IOException, InterruptedException
{
System.out.println("Creating browser");
Browser.createBrowser();
System.out.println("Browser created");
if (Browser.getInstance() == null)
{
Browser.waitInstance();
}
Thread thread = new Thread(Browser.getInstance());
thread.start();
System.out.println("Browser fully initialized");
}
@AfterClass
public static void closeBrowser() throws IOException, InterruptedException
{
Browser.getInstance().stop();
System.out.println("Browser closed");
}
@Test
public void testPing() throws IOException, InterruptedException, ExecutionException
{
ArrayList<Future<Object>> futures = new ArrayList<>();
for (int i = 0; i < 3; i++)
{
futures.add(Browser.getInstance().sendObject("ping"));
}
System.out.println("Pings sent");
for (int i = 0; i < futures.size(); i++)
{
assertEquals("pong",
((CommandReturn<String>) futures.get(i).get())
.getReturnValue());
}
System.out.println("Objects received");
}
@Test
public void testNumPing() throws IOException, InterruptedException,
ExecutionException
{
ArrayList<Future<Object>> futures = new ArrayList<>();
for (int i = 0; i < 3; i++)
{
futures.add(Browser.getInstance().sendObject(new NumberPing(i)));
}
System.out.println("Numbered pings sent");
for (int i = 0; i < futures.size(); i++)
{
assertEquals(new BigDecimal(i),
((CommandReturn<BigDecimal>) futures.get(i).get())
.getReturnValue());
}
System.out.println("Objects received");
}
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
~ Copyright (c) 2021 William Hubbard. All Rights Reserved.
-->
<Configuration packages="edu.regis.universeplayer.browser" status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_ERR">
<PatternLayout pattern="%c:%L %-5level - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="debug">
<AppenderRef ref="Console"/>
</Root>
<Logger name="BrowserRunner" level="info"/>
</Loggers>
</Configuration>

View File

@@ -1,5 +1,9 @@
/* /*
* Copyright (c) 2021 William Hubbard. All Rights Reserved. * Copyright (c) 2021 William Hubbard. All Rights Reserved.
*
* This module contains a series of classes used by multiple modules (notably, the :browser and :interface modules).
* Notably, it contains commands that the browser reacts to, the response structure, and data structures that is
* frequently passed to and from the interface and browser.
*/ */
plugins { plugins {
@@ -24,6 +28,8 @@ dependencies {
implementation 'net.harawata:appdirs:1.2.1' implementation 'net.harawata:appdirs:1.2.1'
implementation 'net.harawata:appdirs:1.2.1'
// Declare the dependency for your favourite test framework you want to use in your tests. // Declare the dependency for your favourite test framework you want to use in your tests.
// TestNG is also supported by the Gradle Test task. Just change the // TestNG is also supported by the Gradle Test task. Just change the
// testCompile dependency to testCompile 'org.testng:testng:6.8.1' and add // testCompile dependency to testCompile 'org.testng:testng:6.8.1' and add

View File

@@ -6,6 +6,15 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File; import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Optional;
import java.util.Properties;
public class ConfigManager public class ConfigManager
{ {
@@ -23,6 +32,13 @@ public class ConfigManager
private static File appDir; private static File appDir;
private static File firefoxDir; private static File firefoxDir;
private static final File propsFile = new File(getConfigDir(), "config" +
".prop");
private static Properties props;
private static Path[] musicDirs;
private static Path[] musicIgnoreDirs;
/** /**
* Obtains the install directory for the application. * Obtains the install directory for the application.
* *
@@ -53,7 +69,8 @@ public class ConfigManager
} }
else else
{ {
firefoxDir = new File(firefoxDir.getParentFile().getParent(), firefoxDir = new File(firefoxDir.getParentFile()
.getParent(),
"firefox"); "firefox");
if (firefoxDir.exists()) if (firefoxDir.exists())
{ {
@@ -155,4 +172,141 @@ public class ConfigManager
} }
return commDir; return commDir;
} }
/**
* Gets the properties file
*
* @return The settings contained within the properties object.
*/
public static Properties getProperties()
{
if (props == null)
{
Properties defaultProps = new Properties();
defaultProps.setProperty("musicInclude", System.getProperty("user" +
".home") + File.separator + "Music" + File.pathSeparator + System
.getProperty("user.home") + File.separator + "My Music");
defaultProps.setProperty("musicExclude", "");
props = new Properties(defaultProps);
if (propsFile.exists())
{
loadProperties();
}
else
{
saveProperties();
}
}
return props;
}
public static void loadProperties()
{
try
{
props.load(new FileReader(propsFile));
}
catch (IOException e)
{
logger.error("Error reading configuration file", e);
}
}
public static void saveProperties()
{
try
{
props.store(new FileWriter(propsFile), "Universal Music " +
"Player Properties");
}
catch (IOException e)
{
logger.error("Error writing configuration file", e);
}
}
/**
* Obtains folders to scan for music.
*
* @return A list of folders to pay attention to.
*/
public static Path[] getMusicDirs()
{
if (musicDirs == null)
{
musicDirs =
Optional.ofNullable(getProperties().getProperty(
"musicInclude"))
.map(s -> s.split(File.pathSeparator)).stream()
.flatMap(Arrays::stream)
.map(String::trim).map(Paths::get)
.sorted(Path::compareTo)
.toArray(Path[]::new);
}
return musicDirs;
}
/**
* Obtains folders to ignore when scanning for music.
*
* @return A list of folders to ignore.
*/
public static Path[] getMusicExcludeDirs()
{
if (musicIgnoreDirs == null)
{
musicIgnoreDirs =
Optional.ofNullable(getProperties().getProperty(
"musicExclude"))
.map(s -> s.split(File.pathSeparator)).stream()
.flatMap(Arrays::stream)
.map(String::trim).map(Paths::get)
.sorted(Path::compareTo)
.toArray(Path[]::new);
}
return musicIgnoreDirs;
}
/**
* Checks to see whether a file should be scanned.
*
* @param file - The file to scan.
* @return Whether or not a file is scanned.
*/
public static boolean scanFolder(Path file)
{
for (Path musicIgnoreDir : musicIgnoreDirs)
{
if (file.startsWith(musicIgnoreDir) || musicIgnoreDir
.endsWith(file))
{
/*
* Make sure that we don't have an include path that takes
* precedence. Working backwards from more specific paths is
* more likely to get our results faster.
*/
for (int j = musicDirs.length - 1; j >= 0; j--)
{
if (file.startsWith(musicDirs[j]) || musicDirs[j]
.endsWith(file))
{
return true;
}
}
return false;
}
}
/*
* Considering that there was nothing in the ignore list, look to
* make sure that we are allowed to scan it.
*/
for (Path musicDir : musicDirs)
{
if (file.startsWith(musicDir) || musicDir.endsWith(file))
{
return true;
}
}
return false;
}
} }

View File

@@ -0,0 +1,18 @@
package edu.regis.universeplayer;
import java.io.Serializable;
import java.util.Arrays;
public class Log implements Serializable
{
public String logger;
public String level;
public Object[] message;
@Override
public String toString()
{
return "LOG " + this.level.toUpperCase() + ": " +
Arrays.toString(message);
}
}

View File

@@ -0,0 +1,30 @@
package edu.regis.universeplayer;
import java.io.Serializable;
import java.net.URL;
/**
* A test class for pinging the browser.
*/
public class NumberPing implements Serializable
{
private final URL url;
public double number;
public NumberPing()
{
this(0);
}
public NumberPing(double number)
{
this.url = null;
this.number = number;
}
public NumberPing(URL url, double number)
{
this.url = url;
this.number = number;
}
}

View File

@@ -60,4 +60,14 @@ public class PlaybackInfo implements Serializable
{ {
return this.status; return this.status;
} }
@Override
public String toString()
{
return "PlaybackInfo{" +
"playTime=" + playTime +
", status=" + status +
", currentSong=" + currentSong +
'}';
}
} }

View File

@@ -79,7 +79,8 @@ public abstract class MessageRunner implements Runnable, MessageSerializer
BufferedInputStream browserIn = null; BufferedInputStream browserIn = null;
BufferedOutputStream browserOut = null; BufferedOutputStream browserOut = null;
MessagePacket packet; MessagePacket packet;
boolean running = true;
int messageNum = -1; int messageNum = -1;
byte[][] returnMessage; byte[][] returnMessage;
ByteBuffer numBuffer = ByteBuffer.allocate(4); ByteBuffer numBuffer = ByteBuffer.allocate(4);
@@ -89,7 +90,7 @@ public abstract class MessageRunner implements Runnable, MessageSerializer
browserIn = new BufferedInputStream(this.input); browserIn = new BufferedInputStream(this.input);
browserOut = new BufferedOutputStream(this.output); browserOut = new BufferedOutputStream(this.output);
while (!this.onRun()) while (running && !this.onRun())
{ {
/* /*
* Sends a messages * Sends a messages
@@ -104,17 +105,19 @@ public abstract class MessageRunner implements Runnable, MessageSerializer
{ {
messageNum = this.messagesSent; messageNum = this.messagesSent;
packet.returnValue.index = messageNum; packet.returnValue.index = messageNum;
writeMessage(browserOut, messageNum, packet.message);
synchronized (this.sentQueue) synchronized (this.sentQueue)
{ {
this.sentQueue.put(messageNum, packet); this.sentQueue.put(messageNum, packet);
this.messagesSent++; this.messagesSent++;
} }
writeMessage(browserOut, messageNum, packet.message);
} }
catch (IOException e) catch (IOException e)
{ {
logger.error("Could not send message " + messageNum + " " + new String(packet.message, StandardCharsets.UTF_8), e); logger.error("Could not send message {} {}", messageNum, new String(packet.message, StandardCharsets.UTF_8), e);
running = false;
} }
} }
@@ -183,6 +186,7 @@ public abstract class MessageRunner implements Runnable, MessageSerializer
catch (IOException e) catch (IOException e)
{ {
logger.error("Could not retrieve message", e); logger.error("Could not retrieve message", e);
running = false;
} }
} }
} }
@@ -195,14 +199,11 @@ public abstract class MessageRunner implements Runnable, MessageSerializer
/* /*
* Release locks for any messages still waiting. * Release locks for any messages still waiting.
*/ */
synchronized (this.sendQueue) synchronized (this.sentQueue)
{ {
logger.debug("Clearing up {} messages", this.sendQueue.size()); logger.debug("Clearing up {} messages", this.sentQueue.size());
while (this.sendQueue.size() > 0) this.sentQueue.values().forEach(foundPacket -> foundPacket.returnMessage = new byte[0]);
{ this.sentQueue.clear();
packet = this.sendQueue.poll();
packet.returnMessage = null;
}
} }
synchronized (this.readLock) synchronized (this.readLock)
{ {

View File

@@ -14,7 +14,7 @@ import java.util.Arrays;
public interface MessageSerializer public interface MessageSerializer
{ {
Logger getLogger(); Logger getLogger();
/** /**
* Converts an object into a form that can be sent. * Converts an object into a form that can be sent.
* *
@@ -33,7 +33,7 @@ public interface MessageSerializer
return byteStream.toByteArray(); return byteStream.toByteArray();
} }
} }
/** /**
* Converts a byte stream into an object. * Converts a byte stream into an object.
* *
@@ -57,15 +57,20 @@ public interface MessageSerializer
} }
} }
} }
/** /**
* Writes a message to the output stream. * Writes a message to the output stream.
* <p>
* By default, it will first write the number of the message (one integer
* of 4 bytes), and then the length of the message in bytes (another
* integer of four bytes). It finally writes the data stream before
* flushing the stream.
* *
* @param out - The output stream to write to. * @param out - The output stream to write to.
* @param messageNum - The ID of the message being sent. This will help keep * @param messageNum - The ID of the message being sent. This will help keep
* track of responses. * track of responses.
* @param message - The actual message contents to write. * @param message - The actual message contents to write.
* @throws IOException Thrown when an exception occures * @throws IOException Thrown when an exception occurs
*/ */
default void writeMessage(OutputStream out, int messageNum, byte[] message) throws IOException default void writeMessage(OutputStream out, int messageNum, byte[] message) throws IOException
{ {
@@ -94,9 +99,9 @@ public interface MessageSerializer
out.write(message); out.write(message);
out.flush(); out.flush();
} }
/** /**
* Reads a message from the input stream * Reads a message from the input stream.
* *
* @param in - The input stream to read from. * @param in - The input stream to read from.
* @return Two byte arrays, each with their own value encoded. The first is * @return Two byte arrays, each with their own value encoded. The first is
@@ -153,7 +158,7 @@ public interface MessageSerializer
getLogger().trace("Reading message"); getLogger().trace("Reading message");
return new byte[][]{messageNum, message}; return new byte[][]{messageNum, message};
} }
/** /**
* This method is called when an error in deserialization occurs. * This method is called when an error in deserialization occurs.
* *

View File

@@ -0,0 +1,73 @@
/*
* Copyright (c) 2021 William Hubbard. All Rights Reserved.
*/
package edu.regis.universeplayer.browserCommands;
import java.net.URL;
import edu.regis.universeplayer.data.InternetSong;
/**
* Asks the browser for information on a certain song.
*
* @author William Hubbard
* @version 0.2
*/
public class QuerySongData implements BrowserQuery<InternetSong>
{
/**
* The song to get data for.
*/
private URL url;
/**
* For serialization only. Do not use.
*/
public QuerySongData()
{
}
/**
* Creates a song data request.
*
* @param url - The URL to request data for.
*/
public QuerySongData(URL url)
{
this.url = url;
}
/**
* Obtains the name of the command.
*
* @return The command name.
*/
@Override
public String getCommandName()
{
return "getSongData";
}
/**
* Obtains the type of value this command returns.
*
* @return The return type.
*/
@Override
public Class<InternetSong> getReturnType()
{
return InternetSong.class;
}
/**
* Gets the location of the song to query.
*
* @return The song location.
*/
public URL getUrl()
{
return this.url;
}
}

View File

@@ -4,11 +4,13 @@
package edu.regis.universeplayer.data; package edu.regis.universeplayer.data;
import java.io.Serializable;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects;
import javax.swing.ImageIcon; import javax.swing.ImageIcon;
public class Album implements Comparable<Album> public class Album implements Comparable<Album>, Serializable
{ {
public int id; public int id;
public String name; public String name;
@@ -18,26 +20,81 @@ public class Album implements Comparable<Album>
public String[] genres; public String[] genres;
public int totalTracks; public int totalTracks;
public int totalDiscs; public int totalDiscs;
@Override
public boolean equals(Object o)
{
if (this == o)
{
return true;
}
if (!(o instanceof Album album))
{
return false;
}
return year == album.year && totalTracks == album.totalTracks && totalDiscs == album.totalDiscs && name.equals(album.name) && Arrays.equals(artists, album.artists) && Arrays.equals(genres, album.genres);
}
@Override
public int hashCode()
{
int result = Objects.hash(name, year, totalTracks, totalDiscs);
result = 31 * result + Arrays.hashCode(artists);
result = 31 * result + Arrays.hashCode(genres);
return result;
}
@Override @Override
public int compareTo(Album o) public int compareTo(Album o)
{ {
if (o != null && o.name != null) if (o != null && o.name != null)
{ {
return this.name.compareToIgnoreCase(o.name); if (this.name == null)
{
return 1;
}
else
{
return this.name.compareToIgnoreCase(o.name);
}
} }
else else
{ {
return -1; if (this.name == null)
{
return 0;
}
else
{
return -1;
}
} }
} }
@Override @Override
public String toString() public String toString()
{ {
return "Album{" + StringBuilder builder = new StringBuilder();
"name='" + name + '\'' + builder.append('"');
", artists=" + Arrays.toString(artists) + builder.append(this.name);
'}'; builder.append('"');
if (this.artists != null && this.artists.length > 0)
{
builder.append(" by ");
builder.append(this.artists[0]);
if (this.artists.length > 1)
{
for (int i = 1; i < this.artists.length; i++)
{
builder.append(", ");
builder.append(this.artists[i]);
}
}
}
return builder.toString();
// return "Album{" +
// "name='" + name + '\'' +
// ", artists=" + Arrays.toString(artists) +
// '}';
} }
} }

View File

@@ -10,20 +10,52 @@ import org.slf4j.LoggerFactory;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects;
/**
* An internet song is a specific type of song that is accessed from a webpage.
*/
public class InternetSong extends Song public class InternetSong extends Song
{ {
private static final Logger logger = LoggerFactory private static final Logger logger = LoggerFactory
.getLogger(InternetSong.class); .getLogger(InternetSong.class);
/**
* The location of the song.
*/
public URL location; public URL location;
@Override
public boolean equals(Object o)
{
if (this == o)
{
return true;
}
if (!(o instanceof InternetSong that))
{
return false;
}
if (!super.equals(o))
{
return false;
}
return Objects.equals(location, that.location);
}
@Override
public int hashCode()
{
return Objects.hash(super.hashCode(), location);
}
@Override @Override
public int compareTo(Song o) public int compareTo(Song o)
{ {
int compare = super.compareTo(o); int compare = super.compareTo(o);
if (compare == 0) if (compare == 0)
{ {
if (o instanceof LocalSong) if (o instanceof InternetSong)
{ {
try try
{ {
@@ -41,15 +73,4 @@ public class InternetSong extends Song
} }
return compare; return compare;
} }
@Override
public String toString()
{
return "Song{" +
"title='" + title + '\'' +
", artists=" + Arrays.toString(artists) +
", album=" + album +
", url=" + location +
'}';
}
} }

View File

@@ -6,15 +6,54 @@ package edu.regis.universeplayer.data;
import java.io.File; import java.io.File;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects;
/** /**
* This song represents a song found on the local file system. * This song represents a song found on the local file system.
*/ */
public class LocalSong extends Song public class LocalSong extends Song
{ {
/**
* The file the song is stored at.
*/
public File file; public File file;
/**
* The format type the song is stored in.
*/
public String type; public String type;
/**
* The encoding format the song is recorded in.
*/
public String codec; public String codec;
/**
* The last modification time of this song file, as returned by {@link
* File#lastModified()}.
*/
public long lastMod;
@Override
public boolean equals(Object o)
{
if (this == o)
{
return true;
}
if (!(o instanceof LocalSong localSong))
{
return false;
}
if (!super.equals(o))
{
return false;
}
return file.equals(localSong.file);
}
@Override
public int hashCode()
{
return Objects.hash(super.hashCode(), file);
}
@Override @Override
public int compareTo(Song o) public int compareTo(Song o)
@@ -29,15 +68,4 @@ public class LocalSong extends Song
} }
return compare; return compare;
} }
@Override
public String toString()
{
return "Song{" +
"title='" + title + '\'' +
", artists=" + Arrays.toString(artists) +
", album=" + album +
", url=" + file.getAbsolutePath() +
'}';
}
} }

View File

@@ -6,16 +6,36 @@ package edu.regis.universeplayer.data;
import java.io.Serializable; import java.io.Serializable;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects;
/** /**
* Contains data for a song. * Contains data for a song.
*/ */
public class Song implements Comparable<Song>, Serializable public class Song implements Comparable<Song>, Serializable
{ {
/**
* The internal ID representing this song in the database.
*/
public int id;
/**
* The name of the song.
*/
public String title; public String title;
/**
* Artists who contributed to the song.
*/
public String[] artists; public String[] artists;
/**
* Which track number in the album the song belongs to.
*/
public int trackNum; public int trackNum;
/**
* Which disc
*/
public int disc; public int disc;
/**
* How long the song is in milliseconds.
*/
public long duration; public long duration;
/** /**
@@ -23,6 +43,28 @@ public class Song implements Comparable<Song>, Serializable
*/ */
public Album album; public Album album;
@Override
public boolean equals(Object o)
{
if (this == o)
{
return true;
}
if (!(o instanceof Song song))
{
return false;
}
return trackNum == song.trackNum && disc == song.disc && title.equals(song.title) && Arrays.equals(artists, song.artists) && album.equals(song.album);
}
@Override
public int hashCode()
{
int result = Objects.hash(title, trackNum, disc, album);
result = 31 * result + Arrays.hashCode(artists);
return result;
}
@Override @Override
public int compareTo(Song o) public int compareTo(Song o)
{ {
@@ -57,10 +99,33 @@ public class Song implements Comparable<Song>, Serializable
@Override @Override
public String toString() public String toString()
{ {
return "Song{" + StringBuilder builder = new StringBuilder();
"title='" + title + '\'' + builder.append('"');
", artists=" + Arrays.toString(artists) + builder.append(this.title);
", album=" + album + builder.append('"');
'}'; if (this.artists != null && this.artists.length > 0)
{
builder.append(" by ");
builder.append(this.artists[0]);
if (this.artists.length > 1)
{
for (int i = 1; i < this.artists.length; i++)
{
builder.append(", ");
builder.append(this.artists[i]);
}
}
}
if (this.album != null)
{
builder.append(" in ");
builder.append(this.album.name);
}
return builder.toString();
// return "Song{" +
// "title='" + title + '\'' +
// ", artists=" + Arrays.toString(artists) +
// ", album=" + album +
// '}';
} }
} }

View File

@@ -0,0 +1,234 @@
package edu.regis.universeplayer.browserCommands;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.*;
import java.net.SocketException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.*;
public class MessageRunnerTest
{
/**
* The input to the runner. This corresponds to the output from the
* handler.
*
* @see #handlerOut
*/
private static PipedInputStream runnerIn;
/**
* The output from the handler and to the runner. This corresponds to the
* input to the runner.
*
* @see #runnerIn
*/
private static PipedOutputStream handlerOut;
/**
* The input to the handler from the runner. This corresponds to the output
* from the runner.
*
* @see #runnerOut
*/
private static PipedInputStream handlerIn;
/**
* The output from the runner and to the handler. This corresponds to the
* input to the handler.
*
* @see #handlerIn
*/
private static PipedOutputStream runnerOut;
private static TestRunner runner;
private static TestHandler handler;
private static Thread runnerThread;
private static Thread handlerThread;
private static class TestRunner extends MessageRunner
{
/**
* Creates a message runner.
*
* @param name - The name of the runner. This is used in logging.
* @param input - The input from our external source.
* @param output - The output to the external source.
*/
public TestRunner(String name, InputStream input, OutputStream output)
{
super(name, input, output);
}
}
private static class TestHandler extends MessageHandler
{
/**
* Creates a message handler.
*
* @param name - The name of the handler. This is used in logging.
* @param input - The input from our external source.
* @param output - The output to the external source.
*/
public TestHandler(String name, InputStream input, OutputStream output)
{
super(name, input, output);
}
}
@BeforeClass
public static void before()
{
try
{
runnerIn = new PipedInputStream();
handlerOut = new PipedOutputStream(runnerIn);
handlerIn = new PipedInputStream();
runnerOut = new PipedOutputStream(handlerIn);
runner = new TestRunner("TestRunner", runnerIn, runnerOut);
handler = new TestHandler("TestHandler", handlerIn, handlerOut);
runnerThread = new Thread(runner);
handlerThread = new Thread(handler);
runnerThread.start();
handlerThread.start();
}
catch (IOException e)
{
e.printStackTrace();
}
}
@Test
public void testMessage() throws IOException, ExecutionException, InterruptedException
{
String mess = "Hello, world!";
String returnMess = "Goodnight, moon!";
MessageHandler.MessageListener listener = new MessageHandler.MessageListener()
{
@Override
public Object onMessage(Object providedValue, Object previousReturn) throws IOException, ExecutionException, InterruptedException
{
assertEquals(mess, providedValue);
return returnMess;
}
};
handler.addListener(listener);
Future<Object> messFuture = runner.sendObject(mess);
assertEquals(returnMess, messFuture.get());
handler.removeListener(listener);
}
/**
* Tests messages that arrive in a different order than they were sent.
*
* @throws IOException
* @throws ExecutionException
* @throws InterruptedException
*/
@Test
public void testAsyncMessage() throws IOException, ExecutionException, InterruptedException
{
final String mess1 = "Hello, world!";
final String mess2 = "Wake up, sun!";
final String mess3 = "Good morning, house!";
final String returnMess1 = "Goodnight, moon!";
final String returnMess2 = "Good evening, star!";
final String returnMess3 = "Goodbye, friend!";
MessageHandler.MessageListener listener = new MessageHandler.MessageListener()
{
AtomicBoolean received2 = new AtomicBoolean();
@Override
public Object onMessage(Object providedValue, Object previousReturn) throws IOException, ExecutionException, InterruptedException
{
assertTrue(providedValue instanceof String);
switch ((String) providedValue)
{
case mess1:
synchronized (received2)
{
while (!received2.get())
{
received2.wait();
}
}
return returnMess1;
case mess3:
synchronized (received2)
{
received2.set(true);
received2.notifyAll();
}
return returnMess3;
case mess2:
return returnMess2;
default:
Assert.fail("Expected \"" + mess1 + "\" or \"" + mess1 + "\", actual \"" + providedValue + "\"");
return null;
}
}
};
handler.addListener(listener);
Future<Object> messFuture = runner.sendObject(mess1);
Thread.sleep(250);
assertFalse(messFuture.isDone());
Future<Object> messFuture2 = runner.sendObject(mess2);
Thread.sleep(250);
assertFalse(messFuture.isDone());
assertTrue(messFuture2.isDone());
Future<Object> messFuture3 = runner.sendObject(mess3);
Thread.sleep(250);
assertTrue(messFuture.isDone());
assertTrue(messFuture2.isDone());
assertTrue(messFuture3.isDone());
assertEquals(returnMess1, messFuture.get());
assertEquals(returnMess2, messFuture2.get());
assertEquals(returnMess3, messFuture3.get());
handler.removeListener(listener);
}
/**
* Tests the handler's update feature.
*
* @throws IOException
* @throws ExecutionException
* @throws InterruptedException
*/
@Test
public void testUpdate() throws IOException, ExecutionException, InterruptedException
{
final String mess1 = "Hello, world!";
final AtomicBoolean received = new AtomicBoolean();
UpdateListener listener = (object, runner) ->
{
assertEquals(mess1, object);
synchronized (received)
{
received.set(true);
received.notifyAll();
}
};
runner.addUpdateListener(listener);
handler.sendUpdate(mess1);
synchronized (received)
{
while (!received.get())
{
received.wait();
}
}
runner.removeUpdateListener(listener);
}
@AfterClass
public static void closeRunner() throws IOException
{
runnerOut.close();
runnerIn.close();
handler.sendUpdate("ping");
}
}

View File

@@ -0,0 +1,177 @@
package edu.regis.universeplayer.browserCommands;
import org.junit.Before;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.util.concurrent.ExecutionException;
import static org.junit.Assert.assertNull;
public class RunnerCloseTest
{
private static Logger logger = LoggerFactory.getLogger(RunnerCloseTest.class);
/**
* The input to the runner. This corresponds to the output from the
* handler.
*
* @see #handlerOut
*/
private PipedInputStream runnerIn;
/**
* The output from the handler and to the runner. This corresponds to the
* input to the runner.
*
* @see #runnerIn
*/
private PipedOutputStream handlerOut;
/**
* The input to the handler from the runner. This corresponds to the output
* from the runner.
*
* @see #runnerOut
*/
private PipedInputStream handlerIn;
/**
* The output from the runner and to the handler. This corresponds to the
* input to the handler.
*
* @see #handlerIn
*/
private PipedOutputStream runnerOut;
private TestRunner runner;
private TestHandler handler;
private Thread runnerThread;
private Thread handlerThread;
private class TestRunner extends MessageRunner
{
/**
* Creates a message runner.
*
* @param name - The name of the runner. This is used in logging.
* @param input - The input from our external source.
* @param output - The output to the external source.
*/
public TestRunner(String name, InputStream input, OutputStream output)
{
super(name, input, output);
}
}
private class TestHandler extends MessageHandler
{
/**
* Creates a message handler.
*
* @param name - The name of the handler. This is used in logging.
* @param input - The input from our external source.
* @param output - The output to the external source.
*/
public TestHandler(String name, InputStream input, OutputStream output)
{
super(name, input, output);
}
}
@Before
public void before()
{
try
{
runnerIn = new PipedInputStream();
handlerOut = new PipedOutputStream(runnerIn);
handlerIn = new PipedInputStream();
runnerOut = new PipedOutputStream(handlerIn);
runner = new TestRunner("TestRunner", runnerIn, runnerOut);
handler = new TestHandler("TestHandler", handlerIn, handlerOut);
handler.addListener((providedValue, previousReturn) -> "ping");
runnerThread = new Thread(runner);
handlerThread = new Thread(handler);
runnerThread.start();
handlerThread.start();
}
catch (IOException e)
{
e.printStackTrace();
}
}
/**
* Sees what happens when the runner is closed but sends a message anyway.
*
* @throws IOException
* @throws ExecutionException
* @throws InterruptedException
*/
@Test
public void testRunnerClose() throws IOException, ExecutionException, InterruptedException
{
logger.info("Testing runner close with message");
runnerIn.close();
runnerOut.close();
assertNull(runner.sendObject("pong").get());
logger.info("Runner test complete");
}
/**
* Sees what happens when the runner is closed and an update is sent from
* the handler.
*
* @throws IOException
* @throws ExecutionException
* @throws InterruptedException
*/
@Test
public void testRunnerCloseUpdate() throws IOException, ExecutionException, InterruptedException
{
logger.info("Testing runner close with update");
runnerIn.close();
runnerOut.close();
handler.sendUpdate("ping");
Thread.sleep(1000);
logger.info("Runner test complete");
}
/**
* Sees what happens when the handler is closed and the runner attempts to
* send a message.
*
* @throws IOException
* @throws ExecutionException
* @throws InterruptedException
*/
@Test
public void testHandlerClose() throws IOException, ExecutionException, InterruptedException
{
logger.info("Testing handler close with message");
handlerIn.close();
handlerOut.close();
assertNull(runner.sendObject("pong").get());
logger.info("Handler test complete");
}
/**
* Sees what happens when the handler is closed but attempts to send an
* update anyway.
*
* @throws IOException
* @throws ExecutionException
* @throws InterruptedException
*/
@Test
public void testHandlerCloseUpdate() throws IOException, ExecutionException, InterruptedException
{
logger.info("Testing handler close with update");
handlerIn.close();
handlerOut.close();
handler.sendUpdate("ping");
Thread.sleep(1000);
logger.info("Handler test complete");
}
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
~ Copyright (c) 2021 William Hubbard. All Rights Reserved.
-->
<Configuration packages="edu.regis.universeplayer.browserCommands" status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_ERR">
<PatternLayout pattern="%c:%L %-5level - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="debug">
<AppenderRef ref="Console"/>
</Root>
<Logger name="BrowserRunner" level="info"/>
</Loggers>
</Configuration>

View File

@@ -1,9 +1,20 @@
import net.harawata.appdirs.AppDirsFactory
import java.util.stream.Collectors import java.util.stream.Collectors
/* /*
* Copyright (c) 2021 William Hubbard. All Rights Reserved. * Copyright (c) 2021 William Hubbard. All Rights Reserved.
*/ */
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'net.harawata:appdirs:1.2.1'
}
}
/* /*
* This build file was generated by the Gradle 'init' task. * This build file was generated by the Gradle 'init' task.
* *
@@ -63,4 +74,6 @@ task buildscriptNix(type: Copy) {
file("${buildDir}/install.sh").append "\n__APPDATA__\n".bytes file("${buildDir}/install.sh").append "\n__APPDATA__\n".bytes
file("${buildDir}/install.sh").append mergeApps.archiveFile.get().asFile.bytes file("${buildDir}/install.sh").append mergeApps.archiveFile.get().asFile.bytes
} }
} }
ext.LOG_DIR = AppDirsFactory.getInstance().getUserLogDir("universemusic", "0.1", "edu.regis");

View File

@@ -31,6 +31,7 @@ dependencies {
implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.13.3' implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.13.3'
implementation 'com.google.code.gson:gson:2.8.7' implementation 'com.google.code.gson:gson:2.8.7'
implementation 'commons-cli:commons-cli:1.4'
implementation 'net.harawata:appdirs:1.2.1' implementation 'net.harawata:appdirs:1.2.1'
implementation 'org.xerial:sqlite-jdbc:3.36.0.1' implementation 'org.xerial:sqlite-jdbc:3.36.0.1'
implementation 'com.googlecode.soundlibs:jlayer:1.0.1.4' implementation 'com.googlecode.soundlibs:jlayer:1.0.1.4'
@@ -56,4 +57,10 @@ mainClassName = defaultPackage + '.PlayerEnvironment'
artifacts { artifacts {
install(distTar) install(distTar)
} }
processResources {
filesMatching("**/log4j2.xml") {
expand(rootProject.properties)
}
}

View File

@@ -0,0 +1,41 @@
package edu.regis.universeplayer;
import java.util.concurrent.ForkJoinTask;
/**
* An abstract {@link ForkJoinTask} that has getters and setters automatically
* set up.
*
* @param <T>
*/
public abstract class AbstractTask<T> extends ForkJoinTask<T>
{
private T value;
/**
* Returns the result that would be returned by {@link #join}, even if this
* task completed abnormally, or {@code null} if this task is not known to
* have been completed. This method is designed to aid debugging, as well
* as to support extensions. Its use in any other context is discouraged.
*
* @return the result, or {@code null} if not completed
*/
@Override
public T getRawResult()
{
return this.value;
}
/**
* Forces the given value to be returned as a result. This method is
* designed to support extensions, and should not in general be called
* otherwise.
*
* @param value the value
*/
@Override
protected void setRawResult(T value)
{
this.value = value;
}
}

View File

@@ -1,21 +1,19 @@
package edu.regis.universeplayer; package edu.regis.universeplayer;
import org.apache.commons.cli.CommandLine;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.*;
import java.io.PrintStream;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.*;
import java.util.InputMismatchException;
import java.util.LinkedHashMap;
import java.util.Scanner;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/** /**
* This runner is used to handle other instances of the program wanting to * This runner is used to handle other instances of the program wanting to
@@ -43,78 +41,95 @@ public class InstanceConnector implements Runnable
while (this.running.get()) while (this.running.get())
{ {
Socket socket = server.accept(); Socket socket = server.accept();
service.submit(() -> { service.submit(() ->
try (Scanner scanner = new Scanner(socket.getInputStream())) {
try
{ {
String[] args = scanner.nextLine().trim().split(" "); ObjectInputStream scanner = new ObjectInputStream(socket.getInputStream());
ArrayList<String> parsedArgs = new ArrayList<>(); PrintStream stream = new PrintStream(socket.getOutputStream());
StringBuilder cachedString = null; PipedOutputStream consoleInProxy = new PipedOutputStream();
for (String arg : args) PipedInputStream consoleInput = new PipedInputStream(consoleInProxy);
AtomicBoolean consoleRunning = new AtomicBoolean(true);
AtomicInteger consoleCode = new AtomicInteger(0);
Thread consoleRunner = new Thread(() ->
{ {
if (cachedString != null) try (PrintStream consoleStream = new PrintStream(consoleInProxy))
{ {
if (arg.endsWith("\"")) synchronized (consoleCode)
{ {
cachedString while (consoleRunning.get())
.append(arg, 0, arg.length() - 1); {
parsedArgs.add(cachedString.toString()); consoleCode.set(scanner.readInt());
cachedString = null; consoleCode.notifyAll();
} if (consoleCode.get() == 5)
else {
{ consoleStream.println((String) scanner.readObject());
cachedString.append(arg); }
else if (consoleCode.get() == -1)
{
break;
}
}
} }
} }
if (arg.startsWith("\"")) catch (IOException | ClassNotFoundException e)
{ {
if (arg.endsWith("\"")) if (!(e instanceof EOFException))
{ {
parsedArgs.add(arg.substring(1, logger.error("Error reading remote console input", e);
arg.length() - 1));
} }
else synchronized (consoleCode)
{ {
cachedString = consoleCode.set(-1);
new StringBuilder(arg.substring(1)); consoleCode.notifyAll();
} }
} }
else }, "ConsoleRunner");
{
parsedArgs.add(arg); CommandLine cmd = (CommandLine) scanner.readObject();
} consoleRunner.start();
} logger.debug("Receiving commands: " + Arrays.toString(cmd.getArgs()));
args = parsedArgs.toArray(String[]::new); if (cmd.hasOption("help"))
LinkedHashMap<String, Object> ops = new LinkedHashMap<>();
parsedArgs.clear();
PlayerEnvironment.parseArgs(args, ops, parsedArgs);
if (ops.containsKey("h") || ops.containsKey("help"))
{ {
PlayerEnvironment.printHelp(); PlayerEnvironment.printHelp(stream);
return; }
else
{
PlayerEnvironment.runArguments(cmd,
stream, consoleInput);
} }
PlayerEnvironment.runArguments(ops, parsedArgs,
new PrintStream(socket.getOutputStream()));
socket.shutdownOutput();
/* /*
* Wait for the client to acknowledge the print * Wait for the client to acknowledge the print
* before closing the stream. * before closing the stream.
*/ */
while (true) stream.println();
stream.println("END");
synchronized (consoleCode)
{ {
try while (consoleCode.get() != -1)
{
scanner.nextInt();
break;
}
catch (InputMismatchException e)
{ {
try
{
consoleCode.wait();
}
catch (InterruptedException e)
{
logger.error("Interrupted while waiting for remote to confirm exit", e);
}
} }
} }
logger.debug("Finished request"); logger.debug("Finished request");
} }
catch (IOException e) catch (EOFException e)
{ {
logger.error("Error reading socket stream"); /*
* Doesn't really matter
*/
}
catch (IOException | ClassNotFoundException e)
{
logger.error("Error reading socket stream", e);
} }
finally finally
{ {

View File

@@ -1,34 +1,22 @@
package edu.regis.universeplayer; package edu.regis.universeplayer;
import edu.regis.universeplayer.data.Queue;
import edu.regis.universeplayer.data.*;
import edu.regis.universeplayer.gui.Interface;
import edu.regis.universeplayer.player.PlayerManager;
import org.apache.commons.cli.*;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File; import javax.swing.*;
import java.io.IOException; import java.io.*;
import java.io.PrintStream;
import java.net.Socket; import java.net.Socket;
import java.util.ArrayList; import java.util.*;
import java.util.Collections; import java.util.concurrent.ForkJoinPool;
import java.util.HashMap; import java.util.concurrent.ForkJoinTask;
import java.util.LinkedHashMap; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.Scanner;
import java.util.concurrent.ExecutionException;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.swing.JOptionPane;
import edu.regis.universeplayer.browserCommands.QueryFuture;
import edu.regis.universeplayer.data.Queue;
import edu.regis.universeplayer.data.Song;
import edu.regis.universeplayer.data.SongProvider;
import edu.regis.universeplayer.gui.Interface;
import edu.regis.universeplayer.player.PlayerManager;
/** /**
* A centralized spot to link up all of the components. * A centralized spot to link up all of the components.
* *
@@ -42,24 +30,55 @@ public class PlayerEnvironment
private static final ResourceBundle langs = ResourceBundle private static final ResourceBundle langs = ResourceBundle
.getBundle("lang.interface", Locale.getDefault()); .getBundle("lang.interface", Locale.getDefault());
private static Options OPTIONS;
private static AlbumProvider ALBUMS_INSTANCE;
private static SongProvider<?> SONGS_INSTANCE;
public static AlbumProvider getAlbums()
{
return ALBUMS_INSTANCE;
}
public static SongProvider<?> getSongs()
{
return SONGS_INSTANCE;
}
/** /**
* Initializes the various components, setting up listeners as needed. * Initializes the various components, setting up listeners as needed.
*/ */
public static void main(String[] args) public static void main(String[] args)
{ {
LinkedHashMap<String, Object> ops = new LinkedHashMap<>(); Options ops = setupCLIArgs();
ArrayList<String> params = new ArrayList<>(); CommandLineParser parser = new DefaultParser();
parseArgs(args, ops, params); CommandLine cmd = null;//not a good practice, it serves it purpose
if (ops.containsKey("h") || ops.containsKey("help")) try
{ {
cmd = parser.parse(ops, args);
}
catch (ParseException e)
{
System.err.println(e.getMessage());
printHelp(); printHelp();
return;
System.exit(1);
} }
if (!connectToServer(args)) if (cmd.hasOption("help"))
{ {
init(ops, params); printHelp();
System.exit(0);
}
/*
* Attempts to forward the args to the running instance, or start a new
* instance if there is no instance running.
*/
if (!connectToServer(cmd))
{
init(cmd);
} }
} }
@@ -69,243 +88,325 @@ public class PlayerEnvironment
* @param args - The argument list to forward. * @param args - The argument list to forward.
* @return True if the server exists, false if not. * @return True if the server exists, false if not.
*/ */
private static boolean connectToServer(String[] args) private static boolean connectToServer(CommandLine args)
{ {
String data;
Socket socket; Socket socket;
boolean success = false;
AtomicBoolean streamRunning = new AtomicBoolean(true);
Thread inputRunner;
try try
{ {
socket = new Socket("localhost", ConfigManager.PORT); socket = new Socket("localhost", ConfigManager.PORT);
try (PrintStream out = new PrintStream(socket.getOutputStream())) ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream());
Scanner remoteOut = new Scanner(socket.getInputStream());
logger.debug("Connecting to remote");
out.writeObject(args);
/*
* Start a stream for
*/
inputRunner = new Thread(() -> {
Scanner sysIn = new Scanner(System.in);
String line;
while (streamRunning.get())
{
if (sysIn.hasNextLine())
{
line = sysIn.nextLine();
try
{
out.writeInt(5);
out.writeObject(line);
}
catch (IOException e)
{
logger.error("Could not write console input to remote instance", e);
}
}
else
{
try
{
Thread.sleep(100);
}
catch (InterruptedException e)
{
logger.error("Error in input runner while sleeping", e);
}
}
}
sysIn.close();
}, "InputRunner");
inputRunner.start();
logger.debug("Waiting for output");
while (remoteOut.hasNextLine())
{ {
boolean quote; data = remoteOut.nextLine();
for (String arg : args) if (data.equals("END"))
{ {
quote = arg.contains(" "); break;
if (quote)
{
out.print('"');
}
out.print(arg);
if (quote)
{
out.print('"');
}
out.print(' ');
} }
out.println(); System.out.println(data);
try (Scanner in = new Scanner(socket.getInputStream()))
{
while (in.hasNextLine())
{
System.out.println(in.nextLine());
}
}
out.print(1);
} }
return true; streamRunning.set(false);
success = true;
logger.debug("Finished reading return output.");
out.writeInt(-1);
} }
catch (IOException e) catch (IOException e)
{ {
logger.debug("Couldn't connect to server", e); logger.debug("Couldn't connect to server", e);
return false;
} }
return success;
} }
private static void init(HashMap<String, Object> ops, public static void init(CommandLine cmd)
ArrayList<String> params)
{ {
logger.info("Starting application {}", cmd);
/* /*
* Add this just in case of a crash or something. It won't work if the * Create a new thread to handle new instances of the player.
* program is forcibly terminated by the OS, but it could be helpful
* otherwise.
*/ */
logger.info("Starting application {} {}", params, ops);
InstanceConnector connector = new InstanceConnector(); InstanceConnector connector = new InstanceConnector();
new Thread(connector).start(); new Thread(connector, "InstanceConnector").start();
/*
* Initialize the song and album collections. Both of these will
* automatically populate themselves upon construction on separate
* threads.
*/
ALBUMS_INSTANCE = new DefaultAlbumProvider();
SONGS_INSTANCE =
new CompiledSongProvider(new LocalSongProvider(ALBUMS_INSTANCE),
new InternetSongProvider(ALBUMS_INSTANCE));
/*
* Initializes the song queue and the various players used.
*/
Queue queue = Queue.getInstance(); Queue queue = Queue.getInstance();
PlayerManager playback = PlayerManager.getPlayers(); PlayerManager playback = PlayerManager.getPlayers();
queue.addSongChangeListener(queue1 -> { /*
QueryFuture<Void> command = PlayerManager.getPlayers() * Every time the queue triggers a change, this callback will tell the
.stopSong(); * player manager to play the next one.
try */
queue.addSongChangeListener(queue1 -> ForkJoinPool.commonPool().submit(() ->
{
ForkJoinTask<Void> command = PlayerManager.getPlayers()
.stopSong();
if (command != null)
{ {
if (command != null && !command.getConfirmation() command.join();
.wasSuccessful()) if (command.isCompletedAbnormally())
{ {
logger.error("Could not run command",
command.getException());
if (Interface.getInstance() != null) if (Interface.getInstance() != null)
{ {
JOptionPane JOptionPane
.showMessageDialog(Interface.getInstance(), .showMessageDialog(Interface.getInstance(),
command.getConfirmation() command.getException(),
.getError(), command.getException().getMessage(),
command.getConfirmation()
.getMessage(),
JOptionPane.ERROR_MESSAGE); JOptionPane.ERROR_MESSAGE);
} }
}
}
if (queue1.getCurrentSong() != null)
{
command =
PlayerManager.getPlayers()
.playSong(queue1.getCurrentSong());
command.join();
if (command.isCompletedAbnormally())
{
logger.error("Could not run command", logger.error("Could not run command",
command.getConfirmation().getError()); command.getException());
} if (Interface.getInstance() != null)
else if (queue1.getCurrentSong() != null)
{
command =
PlayerManager.getPlayers()
.playSong(queue1.getCurrentSong());
if (!command.getConfirmation().wasSuccessful())
{
if (Interface.getInstance() != null)
{
JOptionPane.showMessageDialog(Interface
.getInstance(),
command.getConfirmation().getError(),
command.getConfirmation().getMessage(),
JOptionPane.ERROR_MESSAGE);
}
logger.error("Could not run command",
command.getConfirmation().getError());
}
else
{ {
JOptionPane
.showMessageDialog(Interface.getInstance(),
command.getException(),
command.getException().getMessage(),
JOptionPane.ERROR_MESSAGE);
} }
} }
} }
catch (ExecutionException | InterruptedException e) }));
/*
* Whenever the playback manager finishes a song, this callback will
* tell the queue to skip to the next song. This triggers the above
* callback.
*/
playback.addPlaybackListener(status ->
{
logger.info("Receiving {} from {}", status.getInfo(), status
.getSource());
if (status.getInfo().getStatus() == PlaybackStatus.FINISHED)
{ {
logger.error("Could not get current playback status", e); Queue.getInstance().skipNext();
if (Interface.getInstance() != null)
{
JOptionPane
.showMessageDialog(Interface.getInstance(), e
.getMessage()
, langs
.getString(
"error.command"), JOptionPane.ERROR_MESSAGE);
}
}
});
playback.addPlaybackListener(status -> {
switch (status.getInfo().getStatus())
{
case FINISHED -> Queue.getInstance().skipNext();
} }
}); });
if (!ops.containsKey("headless")) /*
* Show the GUI, if necessary.
*/
if (!cmd.hasOption("headless"))
{ {
Interface inter = new Interface(); Interface inter = new Interface();
inter.setSize(700, 500); inter.setSize(700, 500);
SongProvider.INSTANCE.addUpdateListener(inter); SONGS_INSTANCE.addUpdateListener(inter);
inter.setVisible(true); inter.setVisible(true);
} }
/* /*
* Open up the relevant songs * Open up the relevant songs
*/ */
runArguments(ops, params, System.out); runArguments(cmd, System.out, System.in);
Runtime.getRuntime().addShutdownHook(new Thread(() -> { /*
* Stops the interface connector and shut down players as needed when we
* close the program.
*/
Runtime.getRuntime().addShutdownHook(new Thread(() ->
{
connector.stop(); connector.stop();
PlayerManager.getPlayers().shutdownPlayers(); PlayerManager.getPlayers().shutdownPlayers();
})); }));
} }
/** private static Options setupCLIArgs()
* Converts an array of string arguments into a map of options and extra
* parameters.
*
* @param args - The arguments to parse.
* @param options - The map to dump the options into.
* @param params - The list to dump the other parameters into.
*/
public static void parseArgs(String[] args,
Map<String, Object> options,
List<String> params)
{ {
int equals = -1; if (OPTIONS == null)
Object value;
String key;
String valStr;
int i, j, l, l2;
for (i = 0, l = args.length; i < l; i++)
{ {
valStr = null; OPTIONS = new Options();
if (args[i].startsWith("-")) /*
{ * Launch options
if (args[i].startsWith("--")) */
{ Option headless = Option.builder().longOpt("headless")
equals = args[i].indexOf('='); .desc("Runs the player without a GUI.")
if (equals != -1) .build();
{ OPTIONS.addOption(headless);
valStr = args[i].substring(equals + 1); Option help = Option.builder("h").longOpt("help")
} .desc("Prints this help message.")
else .build();
{ OPTIONS.addOption(help);
equals = args[i].length();
valStr = null; /*
} * Playback options
key = args[i].substring(2, equals); */
} Option play = Option.builder().longOpt("play")
else .desc("Starts playback.")
{ .build();
for (j = 1, l2 = args[i].length() - 1; j < l2; j++) OPTIONS.addOption(play);
{ Option pause = Option.builder().longOpt("pause")
options.put(args[i].substring(j, j + 1), null); .desc("Pauses playback.")
} .build();
key = args[i].substring(j, j + 1); OPTIONS.addOption(pause);
} Option toggle = Option.builder("t").longOpt("toggle")
if (valStr == null && i < args.length - 1 && !args[i + 1] .desc("Toggles playback.")
.startsWith("-")) .build();
{ OPTIONS.addOption(toggle);
valStr = args[++i]; Option seek = Option.builder().longOpt("seek")
} .hasArg().argName("time").desc("Seeks the player to the provided time, in seconds.")
if (valStr != null) .build();
{ OPTIONS.addOption(seek);
if (Pattern.matches("\\d+", valStr)) Option clear = Option.builder("c").longOpt("clear")
{ .desc("Clears the queue before adding songs.")
value = Integer.parseInt(valStr); .build();
} OPTIONS.addOption(clear);
else if (Pattern.matches("\\d*\\.\\d+", valStr)) Option next = Option.builder("n").longOpt("next")
{ .hasArg().optionalArg(true).argName("skipBy")
value = Double.parseDouble(valStr); .desc("Skips to the next song by skipBy songs. Defaults to 1.")
} .build();
else OPTIONS.addOption(next);
{ Option prev = Option.builder("p").longOpt("prev")
value = valStr; .hasArg().optionalArg(true).argName("skipBy")
} .desc("Skips to the previous song by skipBy songs. Defaults to 1.")
} .build();
else OPTIONS.addOption(prev);
{ Option skip = Option.builder().longOpt("skip")
value = true; .hasArg().optionalArg(true).argName("skipTo")
} .desc("Skips to the requested song in the queue. Defaults to the next song.")
options.put(key, value); .build();
} OPTIONS.addOption(skip);
else Option status = Option.builder().longOpt("status")
{ .desc("Obtains the playback status.")
params.add(args[i]); .build();
} OPTIONS.addOption(status);
Option queue = Option.builder().longOpt("queue")
.desc("Gets the queue.")
.build();
OPTIONS.addOption(queue);
Option search = Option.builder().longOpt("search")
.desc("Searches the library for songs matching the arguments, instead of enqueuing them.")
.build();
OPTIONS.addOption(search);
Option addAlbum = Option.builder().longOpt("addAlbum")
.hasArg().optionalArg(true).argName("name")
.desc("Adds an album to the library")
.build();
OPTIONS.addOption(addAlbum);
Option addRemote = Option.builder().longOpt("add")
.numberOfArgs(6).argName("url title artist1;artist2 track disc albumName")
.desc("Adds an internet song to the library.")
.build();
OPTIONS.addOption(addRemote);
Option name = Option.builder().longOpt("name")
.hasArg().argName("title")
.desc("Used for searching and adding elements to specify the name of what song you want to find/add.")
.build();
OPTIONS.addOption(name);
Option artists = Option.builder("a").longOpt("artist")
.hasArg().argName("artist1;artist2")
.desc("A semicolon-separated list used for searching and adding elements to specify artists to find/add to your element.")
.build();
OPTIONS.addOption(artists);
Option year = Option.builder("y").longOpt("year")
.hasArg().argName("year")
.desc("Used for searching and adding elements to specify the year of the album to find/add.")
.build();
OPTIONS.addOption(year);
Option genres = Option.builder("g").longOpt("genre")
.hasArg().argName("genre1;genre2")
.desc("A semicolon-separated list used for searching and adding elements to specify genres to find/add to your album.")
.build();
OPTIONS.addOption(genres);
Option albumName = Option.builder("b").longOpt("album")
.hasArg().argName("name")
.desc("Used for searching and adding songs to specify the name of the album to find/add to.")
.build();
OPTIONS.addOption(albumName);
Option track = Option.builder().longOpt("track")
.hasArg().argName("trackNum")
.desc("Used for searching and adding elements to specify the track number of the song or the number of tracks in the album, depending on the context.")
.build();
OPTIONS.addOption(track);
Option disc = Option.builder("d").longOpt("disc")
.hasArg().argName("discNum")
.desc("Used for searching and adding elements to specify the disc the song is on or the number of discs in the album, depending on the context.")
.build();
OPTIONS.addOption(disc);
} }
return OPTIONS;
} }
/** /**
* Runs command-line arguments * Runs command-line arguments
* *
* @param options - Run options * @param cmd - A list of song terms to search for and enqueue.
* @param params - A list of song terms to search for and enqueue. * @param out - Program output. While this may simply be System .out, this
* @param out - Program output. While this may simply be System .out, * is just as likely going to be outputting to another program
* this is just as likely going to be outputting to another * instance.
* program instance.
*/ */
public static void runArguments(Map<String, Object> options, public static void runArguments(CommandLine cmd, PrintStream out, InputStream in)
List<String> params, PrintStream out)
{ {
try try
{ {
out.println(options.toString()); // out.println(cmd.toString());
out.println(params.toString()); for (Option op : cmd.getOptions())
for (String op : options.keySet())
{ {
switch (op) switch (op.getLongOpt())
{ {
case "play" -> { case "play" -> {
PlayerManager.getPlayers().play(); PlayerManager.getPlayers().play();
@@ -321,23 +422,15 @@ public class PlayerEnvironment
} }
case "seek" -> { case "seek" -> {
PlayerManager.getPlayers() PlayerManager.getPlayers()
.seek((int) options.get("seek")); .seek(Float.parseFloat(op.getValue()));
out.println("Seeking"); out.println("Seeking");
} }
case "clear", "c" -> { case "clear" -> {
out.println("Clearing"); out.println("Clearing");
Queue.getInstance().clear(); Queue.getInstance().clear();
} }
case "next", "n" -> { case "next" -> {
Integer number = null; String number = op.getValue();
if (options.get("next") instanceof Integer)
{
number = (Integer) options.get("next");
}
else if (options.get("n") instanceof Integer)
{
number = (Integer) options.get("n");
}
if (number == null) if (number == null)
{ {
Queue.getInstance().skipNext(); Queue.getInstance().skipNext();
@@ -345,20 +438,12 @@ public class PlayerEnvironment
else else
{ {
Queue.getInstance().skipToSong(Queue.getInstance() Queue.getInstance().skipToSong(Queue.getInstance()
.getCurrentIndex() + number); .getCurrentIndex() + Integer.parseInt(number));
} }
out.println("Next Song"); out.println("Next Song");
} }
case "prev", "p" -> { case "prev" -> {
Integer number = null; String number = op.getValue();
if (options.get("prev") instanceof Integer)
{
number = (Integer) options.get("prev");
}
else if (options.get("p") instanceof Integer)
{
number = (Integer) options.get("p");
}
if (number == null) if (number == null)
{ {
Queue.getInstance().skipNext(); Queue.getInstance().skipNext();
@@ -366,37 +451,26 @@ public class PlayerEnvironment
else else
{ {
Queue.getInstance().skipToSong(Queue.getInstance() Queue.getInstance().skipToSong(Queue.getInstance()
.getCurrentIndex() - number); .getCurrentIndex() - Integer.parseInt(number));
} }
out.println("Clearing"); out.println("Previous Song");
} }
case "skip" -> { case "skip" -> {
Integer number = null; String number = op.getValue();
if (options.get("skip") instanceof Integer)
{
number = (Integer) options.get("skip");
}
if (number == null) if (number == null)
{ {
Queue.getInstance().skipNext(); Queue.getInstance().skipNext();
} }
else else
{ {
Queue.getInstance().skipToSong(number); Queue.getInstance().skipToSong(Integer.parseInt(number));
} }
out.println("Clearing"); out.println("Skipping to song");
} }
case "status" -> { case "status" -> {
QueryFuture<PlaybackStatus> status = ForkJoinTask<PlaybackStatus> status =
PlayerManager.getPlayers().getStatus(); PlayerManager.getPlayers().getStatus();
try out.println(status.join());
{
out.println(status.get());
}
catch (InterruptedException | ExecutionException e)
{
e.printStackTrace(out);
}
} }
case "song" -> { case "song" -> {
Song song = PlayerManager.getPlayers().getCurrentSong(); Song song = PlayerManager.getPlayers().getCurrentSong();
@@ -417,21 +491,134 @@ public class PlayerEnvironment
out.print(" "); out.print(" ");
out.println(song.toString()); out.println(song.toString());
} }
}
case "addAlbum" -> {
String albumName = op.getValue();
String[] artists = null;
String[] genres = null;
int year = -1;
int tracks = -1;
int discs = -1;
if (albumName == null)
{
if (cmd.hasOption("name"))
{
albumName = cmd.getOptionValue("name");
}
if (albumName == null)
{
if (cmd.hasOption("album"))
{
albumName = cmd.getOptionValue("album");
}
}
}
if (cmd.hasOption("artist") && cmd.getOptionValue("artist") != null)
{
artists = cmd.getOptionValue("artist").split("\\s*;\\s*");
}
if (cmd.hasOption("genre") && cmd.getOptionValue("genre") != null)
{
genres = cmd.getOptionValue("genre").split("\\s*;\\s*");
}
if (cmd.hasOption("year") && cmd.getOptionValue("year") != null)
{
year = Integer.parseInt(cmd.getOptionValue("year"));
}
if (cmd.hasOption("track") && cmd.getOptionValue("track") != null)
{
tracks = Integer.parseInt(cmd.getOptionValue("track"));
}
if (cmd.hasOption("disc") && cmd.getOptionValue("disc") != null)
{
discs = Integer.parseInt(cmd.getOptionValue("disc"));
}
/*
* Prompt the user for missing information
*/
try (Scanner scanner = new Scanner(in))
{
if (albumName == null)
{
out.println("What is the name of the album: ");
albumName = scanner.nextLine();
}
if (artists == null)
{
out.println("Please enter the album artists as a semicolon-separated list: ");
artists = scanner.nextLine().split("\\s*;\\s*");
}
if (genres == null)
{
out.println("Please enter the genres as a semicolon-separated list: ");
genres = scanner.nextLine().split("\\s*;\\s*");
}
while (year == -1)
{
out.println("Please enter the album year: ");
try
{
year = Integer.parseInt(scanner.nextLine());
}
catch (NumberFormatException e)
{
out.println("Invalid number entered.");
}
}
while (tracks == -1)
{
out.println("Please enter the number of tracks on the album: ");
try
{
tracks = Integer.parseInt(scanner.nextLine());
}
catch (NumberFormatException e)
{
out.println("Invalid number entered.");
}
}
while (discs == -1)
{
out.println("Please enter the number of discs on the album: ");
try
{
discs = Integer.parseInt(scanner.nextLine());
}
catch (NumberFormatException e)
{
out.println("Invalid number entered.");
}
}
}
Album album = new Album();
album.name = albumName;
album.artists = artists;
album.genres = genres;
album.year = year;
album.totalTracks = tracks;
album.totalDiscs = discs;
out.println(album.toString());
}
case "add" -> {
} }
} }
} }
HashMap<Song, Integer> matchMap = new HashMap<>(); HashMap<Song, Integer> matchMap = new HashMap<>();
for (String param : params) for (String param : cmd.getArgList())
{ {
String finalParam = param.toLowerCase(); String finalParam = param.toLowerCase();
SongProvider.INSTANCE.getSongs().forEach(song -> { getSongs().getSongs().forEach(song ->
{
Integer matches = matchMap.get(song); Integer matches = matchMap.get(song);
if (matches == null) if (matches == null)
{ {
matches = 0; matches = 0;
} }
if (song.title != null && song.title.toLowerCase() if (song.title != null && song.title.toLowerCase()
.contains(finalParam)) .contains(finalParam))
{ {
matches++; matches++;
} }
@@ -473,13 +660,45 @@ public class PlayerEnvironment
if (matchMap.size() > 0) if (matchMap.size() > 0)
{ {
List<Song> songs = matchMap.entrySet().stream().sorted(Map.Entry List<Song> songs = matchMap.entrySet().stream().sorted(Map.Entry
.comparingByValue()).filter(entry -> (entry .comparingByValue()).filter(entry -> (entry
.getValue() / (float) params.size()) >= 0.65F) .getValue() / (float) cmd.getArgList().size()) >= 0.65F)
.map(Map.Entry::getKey) .map(Map.Entry::getKey)
.collect(Collectors.toList()); .collect(Collectors.toList());
Collections.reverse(songs); Collections.reverse(songs);
out.println("Playing " + songs.toString()); if (!cmd.hasOption("search"))
Queue.getInstance().addAll(songs); {
if (songs.size() > 0)
{
out.print("Playing ");
}
}
if (songs.size() > 0)
{
out.print(songs.get(0));
if (songs.size() > 1)
{
Song song;
ListIterator<Song> iter = songs.listIterator(1);
while (iter.hasNext())
{
song = iter.next();
if (!cmd.hasOption("search"))
{
out.print("; ");
}
else
{
out.println();
}
out.print(song.toString());
}
}
}
out.println();
if (!cmd.hasOption("search"))
{
Queue.getInstance().addAll(songs);
}
} }
} }
catch (Throwable e) catch (Throwable e)
@@ -488,40 +707,24 @@ public class PlayerEnvironment
} }
} }
/**
* Prints command line arguments to the error console.
*/
public static void printHelp() public static void printHelp()
{ {
System.out.println("Universal Music Player"); printHelp(System.err);
System.out.println("----------------------"); }
System.out.println("CLI Arguments");
System.out.println("\t--headless"); /**
System.out.println("\t\tRuns the player without a GUI."); * Prints command line arguments to the provided print stream.
System.out.println("\t--play"); *
System.out.println("\t\tStarts playback"); * @param stream - The stream to print the options to.
System.out.println("\t--pause"); */
System.out.println("\t\tPauses playback"); public static void printHelp(PrintStream stream)
System.out.println("\t--toggle, -t"); {
System.out.println("\t\tToggles playback"); final HelpFormatter formatter = new HelpFormatter();
System.out.println("\t--seek <time>"); PrintWriter writer = new PrintWriter(stream);
System.out.println("\t\tSeeks the player to the provided time, in " + formatter.printHelp(formatter.getWidth(), "utility-name", "CLI Arguments", setupCLIArgs(), null);
"seconds"); writer.flush();
System.out.println("\t--clear, -c");
System.out.println("\t\tClears the queue before adding songs");
System.out.println("\t--next, -n [skipBy]");
System.out.println("\t\tSkips to the next song by skipBy songs. " +
"Defaults to 1.");
System.out.println("\t--prev, -p [skipBy]");
System.out.println("\t\tSkips to the previous song by skipBy songs. " +
"Defaults to 1.");
System.out.println("\t--skip [skipTo]");
System.out.println("\t\tSkips to the requested song in the queue. " +
"Defaults to the next song.");
System.out.println("\t--status");
System.out.println("\t\tObtains the playback status");
System.out.println("\t--song");
System.out.println("\t\tObtains the current song");
System.out.println("\t--queue");
System.out.println("\t\tGets the queue");
System.out.println("\t--help, -h");
System.out.println("\t\tPrints this help message");
} }
} }

View File

@@ -0,0 +1,77 @@
package edu.regis.universeplayer.data;
import java.util.Collection;
import java.util.concurrent.Future;
/**
* Manages all albums that are part of the collection.
*/
public interface AlbumProvider extends DataProvider<Album>
{
/**
* Obtains all albums within the collection.
*
* @return A list of albums.
*/
Collection<Album> getAlbums();
/**
* Obtains a list of all album artists.
*
* @return All album artists.
*/
Collection<String> getAlbumArtists();
/**
* Obtains a list of all genres.
*
* @return All genres.
*/
Collection<String> getGenres();
/**
* Obtains a list of all years that have albums.
*
* @return All years.
*/
Collection<Integer> getYears();
/**
* 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.
*/
Album getAlbumByName(String name);
/**
* Obtains all albums that were written by a certain artist.
*
* @param artist - The artist to search for.
* @return - The collection on matching albums.
*/
Collection<Album> getAlbumsFromArtist(String artist);
/**
* Obtains all albums that match a certain genre
*
* @param genre - The genre to search for.
* @return - The collection on matching albums.
*/
Collection<Album> getAlbumsFromGenre(String genre);
/**
* Obtains all albums that were released a certain year.
*
* @param year - The year to search for.
* @return - The collection on matching albums.
*/
Collection<Album> getAlbumsFromYear(int year);
/**
* Writes an album to the collection.
* @param album - The album to add.
*/
Future<Album> writeItem(Album album);
}

View File

@@ -5,6 +5,7 @@
package edu.regis.universeplayer.data; package edu.regis.universeplayer.data;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
/** /**
* A song provider that serves as a central point for any and all song * A song provider that serves as a central point for any and all song
@@ -13,153 +14,51 @@ import java.util.*;
* @author William Hubbard * @author William Hubbard
* @version 0.1 * @version 0.1
*/ */
public class CompiledSongProvider implements SongProvider<Song> public class CompiledSongProvider implements SongProvider<Song>, UpdateListener
{ {
private final LinkedList<UpdateListener> listeners = new LinkedList<>(); private final LinkedList<UpdateListener> listeners = new LinkedList<>();
/** /**
* A set of all providers we pull from * A set of all providers we pull from
*/ */
private final HashMap<SongProvider<?>, Set<Song>> providers = new HashMap<>(); private final HashSet<SongProvider<? extends Song>> providers =
new HashSet<>();
private AlbumProvider albums;
/** /**
* The collection of update listeners for each provider. * Creates a new CompiledSongProvider containing a set of existing
*/ * providers.
private final HashMap<SongProvider<?>, UpdateListener> updateListener = new HashMap<>();
/**
* A cache of all albums used.
*/
private final HashMap<Album, Set<Song>> cachedAlbums = new HashMap<>();
/**
* A cache of all album names.
*/
private final HashMap<String, Album> cachedAlbumNames = new HashMap<>();
/**
* A cache of all songs used.
*/
private final HashSet<Song> cachedSongs = new HashSet<>();
/**
* A cache of all song artists used.
*/
private final HashMap<String, Set<Song>> cachedArtists = new HashMap<>();
/**
* A cache of all album artists used.
*/
private final HashMap<String, Set<Album>> cachedAlbumArtists = new HashMap<>();
/**
* A cache of all genres used.
*/
private final HashMap<String, Set<Album>> cachedGenres = new HashMap<>();
/**
* A cache of all years used.
*/
private final HashMap<Integer, Set<Album>> cachedYears = new HashMap<>();
/**
* Creates a new CompiledSongProvider containing a set of existing providers.
* *
* @param providers - Providers to add. * @param providers - Providers to add.
*/ */
public CompiledSongProvider(SongProvider<?>... providers) public CompiledSongProvider(SongProvider<?
>... providers)
{ {
for (SongProvider<?> provider : providers) for (SongProvider<?> provider : providers)
{ {
this.addProvider(provider); this.addProvider(provider);
} }
} }
/** /**
* Adds a provider to the list * Adds a provider to the list
* *
* @param provider - The provider to add. * @param provider - The provider to add.
*/ */
public void addProvider(SongProvider<?> provider) public <T extends Song> void addProvider(SongProvider<T> provider)
{ {
UpdateListener listener; UpdateListener listener;
if (!this.providers.containsKey(provider)) if (this.providers.add(provider))
{ {
this.providers.put(provider, new HashSet<>(provider.getSongs())); if (this.albums == null)
listener = (song, totalSongs, updateText) -> { {
/* this.albums = provider.getAlbumProvider();
* Resets the cache. }
*/ provider.addUpdateListener(this);
if (song == totalSongs || totalSongs == 0) triggerUpdateListeners();
{
this.removeFromCache(this.providers.get(provider));
this.addToCache(provider);
}
this.triggerUpdateListeners();
};
provider.addUpdateListener(listener);
this.addToCache(provider);
} }
} }
/**
* Caches all the songs contained in a provider.
*
* @param provider - The provider to cache.
*/
private <T extends Song> void addToCache(SongProvider<T> provider)
{
for (T song : provider.getSongs())
{
if (this.cachedSongs.add(song))
{
this.cachedAlbumNames.put(song.album.name, song.album);
if (!this.cachedAlbums.containsKey(song.album))
{
this.cachedAlbums.put(song.album, new HashSet<>());
}
this.cachedAlbums.get(song.album).add(song);
for (String artist : song.artists)
{
if (!this.cachedArtists.containsKey(artist))
{
this.cachedArtists.put(artist, new HashSet<>());
}
this.cachedArtists.get(artist).add(song);
}
for (String artist : song.album.artists)
{
if (!this.cachedAlbumArtists.containsKey(artist))
{
this.cachedAlbumArtists.put(artist, new HashSet<>());
}
this.cachedAlbumArtists.get(artist).add(song.album);
}
for (String genre : song.album.genres)
{
if (!this.cachedGenres.containsKey(genre))
{
this.cachedGenres.put(genre, new HashSet<>());
}
this.cachedGenres.get(genre).add(song.album);
}
if (!this.cachedYears.containsKey(song.album.year))
{
this.cachedYears.put(song.album.year, new HashSet<>());
}
this.cachedYears.get(song.album.year).add(song.album);
}
else
{
/*
* If we couldn't add it, then another provider has provided that song already. We
* should remove it from our collection just to ensure that there is no confusion.
*/
this.providers.get(provider).remove(song);
}
}
}
/** /**
* Removes a provider from the compilation. * Removes a provider from the compilation.
* *
@@ -167,75 +66,38 @@ public class CompiledSongProvider implements SongProvider<Song>
*/ */
public void removeProvider(SongProvider<?> provider) public void removeProvider(SongProvider<?> provider)
{ {
this.removeFromCache(this.providers.remove(provider)); provider.removeUpdateListener(this);
provider.removeUpdateListener(this.updateListener.remove(provider)); this.providers.remove(provider);
} }
/** @Override
* Removes all songs from a provider from a cache. public AlbumProvider getAlbumProvider()
*
* @param songs - The songs to move out.
*/
private void removeFromCache(Set<Song> songs)
{ {
if (songs != null) return this.albums;
}
@Override
public void joinUpdate() throws InterruptedException
{
for (SongProvider provider: this.providers)
{ {
for (Song song : songs) provider.joinUpdate();
{
this.cachedSongs.remove(song);
this.cachedAlbums.get(song.album).remove(song);
/*
* Remove empty albums
*/
if (this.cachedAlbums.get(song.album).isEmpty())
{
this.cachedAlbums.remove(song.album);
this.cachedAlbumNames.remove(song.album.name);
}
for (String artist : song.artists)
{
this.cachedArtists.get(artist).remove(song);
if (this.cachedArtists.get(artist).isEmpty())
{
this.cachedArtists.remove(artist);
}
}
for (String artist : song.album.artists)
{
this.cachedAlbumArtists.get(artist).remove(song.album);
if (this.cachedAlbumArtists.get(artist).isEmpty())
{
this.cachedAlbumArtists.remove(artist);
}
}
for (String genre : song.album.genres)
{
this.cachedGenres.get(genre).remove(song.album);
if (this.cachedGenres.get(genre).isEmpty())
{
this.cachedGenres.remove(genre);
}
}
this.cachedYears.get(song.album.year).remove(song.album);
if (this.cachedYears.get(song.album.year).isEmpty())
{
this.cachedYears.remove(song.album.year);
}
}
} }
} }
/** /**
* Obtains all albums within the collection. * Obtains the collection of items.
* *
* @return A list of albums. * @return A collection of items parsed from the database.
*/ */
@Override @Override
public Collection<Album> getAlbums() public Set<Song> getCollection()
{ {
return this.cachedAlbums.keySet(); return this.providers.stream().map(SongProvider::getCollection)
.flatMap(Collection::stream)
.collect(Collectors.toSet());
} }
/** /**
* Obtains all songs within the collection. * Obtains all songs within the collection.
* *
@@ -244,9 +106,9 @@ public class CompiledSongProvider implements SongProvider<Song>
@Override @Override
public Collection<Song> getSongs() public Collection<Song> getSongs()
{ {
return this.cachedSongs; return this.getCollection();
} }
/** /**
* Obtains a list of all artists. * Obtains a list of all artists.
* *
@@ -255,149 +117,76 @@ public class CompiledSongProvider implements SongProvider<Song>
@Override @Override
public Collection<String> getArtists() public Collection<String> getArtists()
{ {
return this.cachedArtists.keySet(); return this.providers.stream().map(SongProvider::getArtists)
.flatMap(Collection::stream)
.collect(Collectors.toSet());
} }
/**
* Obtains a list of all album artists.
*
* @return All album artists.
*/
@Override
public Collection<String> getAlbumArtists()
{
return this.cachedAlbumArtists.keySet();
}
/**
* Obtains a list of all genres.
*
* @return All genres.
*/
@Override
public Collection<String> getGenres()
{
return this.cachedGenres.keySet();
}
/**
* Obtains a list of all years that have albums.
*
* @return All years.
*/
@Override
public Collection<Integer> getYears()
{
return this.cachedYears.keySet();
}
/** /**
* Obtains all songs from an album. * Obtains all songs from an album.
* *
* @param album - The album to obtain * @param album - The album to obtain
* @return All songs from the requested album, or null if that album is not in the database. * @return All songs from the requested album, or null if that album is not
* in the database.
*/ */
@Override @Override
public Collection<Song> getSongsFromAlbum(Album album) public Collection<Song> getSongsFromAlbum(Album album)
{ {
return this.cachedAlbums.get(album); return this.providers.stream()
.map(p -> p.getSongsFromAlbum(album))
.flatMap(Collection::stream)
.collect(Collectors.toSet());
} }
/** /**
* Obtains all songs written by a given artist. * Obtains all songs written by a given artist.
* *
* @param artist - The artist to search for * @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 * @return A list of all songs from the specified artist, or null if that
* database. * artist is not in the database.
*/ */
@Override @Override
public Collection<Song> getSongsFromArtist(String artist) public Collection<Song> getSongsFromArtist(String artist)
{ {
return this.cachedArtists.get(artist); return this.providers.stream()
.map(p -> p.getSongsFromArtist(artist))
.flatMap(Collection::stream)
.collect(Collectors.toSet());
} }
/**
* 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)
{
return this.cachedAlbumNames.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<Album> getAlbumsFromArtist(String artist)
{
return this.cachedAlbumArtists.get(artist);
}
/**
* Obtains all albums that match a certain genre
*
* @param genre - The genre to search for.
* @return - The collection on matching albums.
*/
@Override
public Collection<Album> getAlbumsFromGenre(String genre)
{
return this.cachedGenres.get(genre);
}
/**
* 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<Album> getAlbumsFromYear(int year)
{
return this.cachedYears.get(year);
}
@Override @Override
public int getUpdateProgress() public int getUpdateProgress()
{ {
int totalUpdate = 0; int totalUpdate = 0;
for (SongProvider<?> provider : this.providers.keySet()) for (SongProvider<?> provider : this.providers)
{ {
totalUpdate += provider.getUpdateProgress(); totalUpdate += provider.getUpdateProgress();
} }
return totalUpdate; return totalUpdate;
} }
@Override @Override
public int getTotalUpdateSongs() public int getTotalUpdates()
{ {
int totalUpdate = 0; int totalUpdate = 0;
for (SongProvider<?> provider : this.providers.keySet()) for (SongProvider<?> provider : this.providers)
{ {
if (provider.getTotalUpdateSongs() == -1) if (provider.getTotalUpdates() == -1)
{ {
return -1; return -1;
} }
else else
{ {
totalUpdate += provider.getTotalUpdateSongs(); totalUpdate += provider.getTotalUpdates();
} }
} }
return totalUpdate; return totalUpdate;
} }
@Override @Override
public String getUpdateText() public String getUpdateText()
{ {
for (SongProvider<?> provider: this.providers.keySet()) for (SongProvider<?> provider : this.providers)
{ {
if (provider.getUpdateText() != null) if (provider.getUpdateText() != null)
{ {
@@ -406,19 +195,19 @@ public class CompiledSongProvider implements SongProvider<Song>
} }
return null; return null;
} }
@Override @Override
public void addUpdateListener(UpdateListener listener) public void addUpdateListener(UpdateListener listener)
{ {
this.listeners.add(listener); this.listeners.add(listener);
} }
@Override @Override
public void removeUpdateListener(UpdateListener listener) public void removeUpdateListener(UpdateListener listener)
{ {
this.listeners.remove(listener); this.listeners.remove(listener);
} }
/** /**
* Triggers all update listeners. * Triggers all update listeners.
*/ */
@@ -426,7 +215,23 @@ public class CompiledSongProvider implements SongProvider<Song>
{ {
for (UpdateListener listener : this.listeners) for (UpdateListener listener : this.listeners)
{ {
listener.onUpdate(this.getUpdateProgress(), this.getTotalUpdateSongs(), this.getUpdateText()); listener.onUpdate(this, this.getUpdateProgress(),
this.getTotalUpdates(), this.getUpdateText());
} }
} }
/**
* Called when the update status of the player has changed.
*
* @param provider - The provider that triggered the listener.
* @param updated - The number of songs updated.
* @param totalUpdate - The total number of songs to update, or -1 if we are
* still determining that.
* @param updating - The text to display on update bars.
*/
@Override
public <T> void onUpdate(DataProvider<T> provider, int updated, int totalUpdate, String updating)
{
this.triggerUpdateListeners();
}
} }

View File

@@ -0,0 +1,73 @@
package edu.regis.universeplayer.data;
import java.util.Set;
/**
* A data provider serves as a place to get songs, albums, or any other
* collection of information needed by the music player.
*/
public interface DataProvider<T>
{
/**
* Checks to see if we are updating any songs.
*
* @return True if we are updating the song cache.
*/
default boolean isUpdating()
{
return this.getTotalUpdates() != 0;
}
/**
* If there is an update in progress, this halts the calling thread until
* the update is complete.
*/
void joinUpdate() throws InterruptedException;
/**
* When we are updating the cache, this method obtains the number of items
* already updated.
*
* @return The number of items successfully updated.
*/
int getUpdateProgress();
/**
* Determines whether or not we are updating the cache and, if so, gets the
* total number of items we need to update
*
* @return The total number of items to update. A 0 means that we do not
* have any items to update, and a negative number means that we are
* currently calculating how many items we need to update.
*/
int getTotalUpdates();
/**
* Determines the text displayed for the progress of updates
*
* @return The update status text.
*/
String getUpdateText();
/**
* Obtains the collection of items.
*
* @return A collection of items parsed from the database.
*/
Set<T> getCollection();
/**
* Adds a listener for song updates.
*
* @param listener - The listener to add.
*/
void addUpdateListener(UpdateListener listener);
/**
* Removes a listener for song updates.
*
* @param listener - The listener to remove.
*/
void removeUpdateListener(UpdateListener listener);
}

View File

@@ -0,0 +1,488 @@
/*
* Copyright (c) 2021 William Hubbard. All Rights Reserved.
*/
package edu.regis.universeplayer.data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Formatter;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.Future;
import java.util.concurrent.RecursiveAction;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
/**
* A default data provider that pulls information from a database.
*/
public abstract class DatabaseProvider<T> implements DataProvider<T>
{
private static final Logger logger =
LoggerFactory.getLogger(DatabaseProvider.class);
private static final ResourceBundle langs = ResourceBundle
.getBundle("lang.interface", Locale.getDefault());
protected final ForkJoinPool service = new ForkJoinPool();
private final LinkedList<UpdateListener> listeners = new LinkedList<>();
private final AtomicInteger progress = new AtomicInteger(0);
private final AtomicInteger updating = new AtomicInteger(0);
private String updateItem;
private final HashSet<T> collection = new HashSet<>();
public DatabaseProvider()
{
updateCache();
}
/**
* Searches the database and updates the collection from there.
*/
public final void updateCache()
{
updating.set(-1);
service.submit(new DatabaseProvider.SongQuery(true));
}
@Override
public final void joinUpdate() throws InterruptedException
{
synchronized (this.updating)
{
if (this.isUpdating())
{
this.updating.wait();
}
}
}
@Override
public int getUpdateProgress()
{
return this.progress.get();
}
@Override
public int getTotalUpdates()
{
return this.updating.get();
}
@Override
public String getUpdateText()
{
synchronized (this.collection)
{
if (this.updateItem != null)
{
return new Formatter().format(langs.getString("update" +
".database"), this.updateItem).toString();
}
else
{
return null;
}
}
}
/**
* {@inheritDoc}
* <p>
* Note that listeners are triggered in the same thread that handles
* updates, which does block the updates. It is recommended to manually
* redirect the event to another thread.
* </p>
*
* @param listener - The listener to add.
*/
@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, this.getUpdateProgress(), this
.getTotalUpdates(), this.getUpdateText()));
}
/**
* Obtains the name of
*
* @return The name of the database table. This is case insensitive.
*/
protected abstract String getDatabaseTable();
/**
* Obtains the SQL string used to create the database table should it be
* necessary.
*
* @return The SQL command that creates the database table. It should take
* the format of "CREATE TABLE name (param1 type, param2 type);"
*/
protected abstract String createDatabaseTable();
/**
* Called when an entry is read from the database and is ready to be
* parsed.
* <p>
* Note that this method is called for every row. Do NOT call {@link
* ResultSet#next()}!
*
* @param result The result that is read from.
*/
protected abstract T readResult(ResultSet result) throws SQLException;
/**
* Called to obtain the properties of an object to write.
*
* @param item - The item to serialize.
* @return Properties to write.
*/
protected abstract Map<String, Object> serializeItem(T item);
/**
* Adds an item to the database.
*
* @param item - The item to write.
*/
public final Future<T> writeItem(T item)
{
synchronized (this.collection)
{
// logger.debug("Writing {}", item);
this.collection.add(item);
}
WriterAction action = new WriterAction(item);
service.execute(action);
return action;
}
/**
* Converts a piece of data into a string that will display in the update
* text.
*
* @param data - The data to stringify.
* @return A string representation of the data being updated.
*/
protected abstract String stringifyResult(T data);
/**
* A callback for when the database scan is complete.
* <p>
* Note that this method is called from the same thread that the scanner is
* from.
* </p>
*
* @return A fork-join task to invoke. This may be null.
*/
protected abstract ForkJoinTask[] onComplete();
private void createDatabaseTable(Statement state, String table) throws SQLException
{
String rawStatement;
logger.debug("Creating {} table.", table);
/*
* Create the table
*/
rawStatement = createDatabaseTable();
if (!Pattern
.compile("^\\s*CREATE\\s+TABLE\\s*" + table + "\\s*\\(" +
"(\\w+\\s+[a-zA-Z0-9()]+(\\s+\\w+)*\\s*)(,\\s*\\w+\\s+[a-zA-Z0-9()]+(\\s+\\w+)*\\s*)*\\);$",
Pattern.CASE_INSENSITIVE).matcher(
rawStatement).matches())
{
throw new IllegalArgumentException("Invalid " +
"table creation statement: " +
"\"" + rawStatement +
"\"");
}
state.executeUpdate(rawStatement);
}
/**
* Obtains the collection of items.
*
* @return A collection of items parsed from the database.
*/
@Override
public final Set<T> getCollection()
{
synchronized (this.collection)
{
return new HashSet<>(this.collection);
}
}
private class WriterAction extends ForkJoinTask<T>
{
private T item;
private Map<String, Object> string;
public WriterAction(T item)
{
this.item = item;
}
/**
* Returns the result that would be returned by {@link #join}, even if
* this task completed abnormally, or {@code null} if this task is not
* known to have been completed. This method is designed to aid
* debugging, as well as to support extensions. Its use in any other
* context is discouraged.
*
* @return the result, or {@code null} if not completed
*/
@Override
public T getRawResult()
{
return item;
}
/**
* Forces the given value to be returned as a result. This method is
* designed to support extensions, and should not in general be called
* otherwise.
*
* @param value the value
*/
@Override
protected void setRawResult(T value)
{
this.item = value;
}
/**
* Immediately performs the base action of this task and returns true
* if, upon return from this method, this task is guaranteed to have
* completed. This method may return false otherwise, to indicate that
* this task is not necessarily complete (or is not known to be
* complete), for example in asynchronous actions that require explicit
* invocations of completion methods. This method may also throw an
* (unchecked) exception to indicate abnormal exit. This method is
* designed to support extensions, and should not in general be called
* otherwise.
*
* @return {@code true} if this task is known to have completed normally
*/
@Override
protected boolean exec()
{
ResultSet result;
Statement state;
String table = getDatabaseTable();
this.string = serializeItem(this.item);
String index =
this.string.keySet().stream().findFirst().orElse(null);
Object indexValue = this.string.get(index);
synchronized (DatabaseManager.getDb())
{
try
{
state = DatabaseManager.getDb().createStatement();
result = state
.executeQuery("SELECT name FROM sqlite_master" +
" WHERE type='table' AND name='" + table + "';");
if (!result.next())
{
createDatabaseTable(state, table);
}
PreparedStatement prepState = DatabaseManager.getDb()
.prepareStatement(
"SELECT * FROM " + table + " WHERE " + index + " " +
"= ?");
prepState.setObject(1, indexValue);
result = prepState.executeQuery();
if (result.next())
{
logger.debug("Updating {}", this.string);
this.string.forEach((key, value) -> {
try
{
PreparedStatement prepState1 =
DatabaseManager.getDb()
.prepareStatement(
"UPDATE " + table + " SET " + key +
" = ? WHERE " +
index + " = ?");
prepState1.setObject(1, indexValue);
prepState1.setObject(2, value);
}
catch (SQLException throwables)
{
logger.error("Could not update " + key, throwables);
}
});
}
else
{
logger.debug("Inserting {}", this.string);
AtomicInteger i = new AtomicInteger(1);
PreparedStatement finalPrepState = DatabaseManager
.getDb().prepareStatement(
"INSERT INTO " + table + " VALUES (?" + ", ?"
.repeat(this.string
.size() - 1) +
")");
this.string.forEach((key, value) -> {
try
{
finalPrepState
.setObject(i.getAndIncrement(), value);
}
catch (SQLException throwables)
{
logger.error("Could not update " + key, throwables);
}
});
int count = finalPrepState.executeUpdate();
if (count == 0)
{
logger.error("Failed to insert {}", this.string);
}
}
}
catch (Exception e)
{
logger.error("Could not write object {}", this.string, e);
}
}
return true;
}
}
/**
* Scans the database for information
*/
private class SongQuery extends ForkJoinTask<Void>
{
private final boolean scan;
SongQuery(boolean scan)
{
this.scan = scan;
}
@Override
public Void getRawResult()
{
return null;
}
@Override
protected void setRawResult(Void value)
{
}
@Override
protected boolean exec()
{
Statement state;
String table = getDatabaseTable();
ResultSet result;
T item;
synchronized (updating)
{
updating.set(-1);
try
{
logger.debug("Querying database.");
/*
* Check if the table exists
*/
synchronized (DatabaseManager.getDb())
{
state = DatabaseManager.getDb().createStatement();
result = state
.executeQuery("SELECT name FROM sqlite_master" +
" WHERE type='table' AND name='" + table + "';");
if (!result.next())
{
createDatabaseTable(state, table);
}
else
{
result = state
.executeQuery("SELECT count(*) FROM " + table + ";");
updating.set(result.getInt(1));
triggerUpdateListeners();
result = state
.executeQuery("SELECT * FROM " + table + ";");
while (result.next())
{
item = readResult(result);
synchronized (collection)
{
updateItem = stringifyResult(item);
collection.add(item);
progress.incrementAndGet();
}
triggerUpdateListeners();
}
}
state.close();
}
}
catch (SQLException e)
{
logger.error("Could not query SQL database.", e);
}
finally
{
logger.debug("Query complete, retrieved {} items",
collection.size());
progress.set(0);
updating.set(0);
updateItem = null;
updating.notifyAll();
triggerUpdateListeners();
logger.debug("Searching for post-query tasks");
try
{
ForkJoinTask[] runners = onComplete();
if (runners != null)
{
logger.debug("Running {} post-query tasks", runners.length);
invokeAll(runners);
}
}
catch (Exception e)
{
logger.error("Could not get runners", e);
}
}
}
return true;
}
}
}

View File

@@ -0,0 +1,282 @@
package edu.regis.universeplayer.data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ForkJoinTask;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
public class DefaultAlbumProvider extends DatabaseProvider<Album> implements AlbumProvider
{
private static final Logger logger =
LoggerFactory.getLogger(DefaultAlbumProvider.class);
/**
* Obtains all albums within the collection.
*
* @return A list of albums.
*/
@Override
public Collection<Album> getAlbums()
{
return this.getCollection();
}
/**
* Obtains a list of all album artists.
*
* @return All album artists.
*/
@Override
public Collection<String> getAlbumArtists()
{
return this.getCollection().stream().filter(a -> a.artists != null)
.map(a -> new HashSet<>(Arrays.asList(a.artists)))
.reduce(new HashSet<>(), (strings, strings2) -> {
HashSet<String> comb = new HashSet<>();
comb.addAll(strings);
comb.addAll(strings2);
return comb;
});
}
/**
* Obtains a list of all genres.
*
* @return All genres.
*/
@Override
public Collection<String> getGenres()
{
return this.getCollection().stream().filter(a -> a.genres != null)
.map(a -> new HashSet<>(Arrays.asList(a.genres)))
.reduce(new HashSet<>(), (strings, strings2) -> {
HashSet<String> comb = new HashSet<>();
comb.addAll(strings);
comb.addAll(strings2);
return comb;
});
}
/**
* Obtains a list of all years that have albums.
*
* @return All years.
*/
@Override
public Collection<Integer> getYears()
{
return this.getCollection().stream().map(a -> a.year)
.collect(Collectors.toSet());
}
/**
* 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)
{
return this.getCollection().stream()
.filter(a -> a.name == null && name == null ||
a.name != null && a.name.equals(name))
.findFirst().orElse(null);
}
/**
* 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<Album> getAlbumsFromArtist(String artist)
{
return this.getCollection().stream().filter(a -> {
if (a.artists != null)
{
for (int i = 0; i < a.artists.length; i++)
{
if (a.artists[i].equals(artist))
{
return true;
}
}
}
return false;
}).collect(Collectors.toSet());
}
/**
* Obtains all albums that match a certain genre
*
* @param genre - The genre to search for.
* @return - The collection on matching albums.
*/
@Override
public Collection<Album> getAlbumsFromGenre(String genre)
{
return this.getCollection().stream().filter(a -> {
if (a.genres != null)
{
for (int i = 0; i < a.genres.length; i++)
{
if (a.genres[i].equals(genre))
{
return true;
}
}
}
return false;
}).collect(Collectors.toSet());
}
/**
* 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<Album> getAlbumsFromYear(int year)
{
return this.getCollection().stream().filter(a -> a.year == year)
.collect(Collectors.toSet());
}
/**
* Obtains the name of
*
* @return The name of the database table. This is case insensitive.
*/
@Override
protected String getDatabaseTable()
{
return "albums";
}
/**
* Obtains the SQL string used to create the database table should it be
* necessary.
*
* @return The SQL command that creates the database table. It should take
* the format of "CREATE TABLE name (param1 type, param2 type);"
*/
@Override
protected String createDatabaseTable()
{
return "CREATE TABLE albums (album TEXT PRIMARY KEY," +
"artists TEXT," +
"year INTEGER," +
"genres TEXT," +
"tracks INTEGER," +
"discs INTEGER);";
}
/**
* Called when an entry is read from the database and is ready to be
* parsed.
* <p>
* Note that this method is called for every row. Do NOT call {@link
* ResultSet#next()}!
*
* @param result The result that is read from.
*/
@Override
protected Album readResult(ResultSet result) throws SQLException
{
Album album = new Album();
album.id = result.getRow();
album.name = result.getString("album");
album.artists =
Optional.ofNullable(result.getString("artists"))
.map(s -> s.split(";")).stream()
.mapMulti((BiConsumer<String[], Consumer<String>>) (strings, objectConsumer) -> {
for (String string : strings)
{
if (!string.isEmpty())
{
objectConsumer.accept(string);
}
}
}).map(String::trim).toArray(String[]::new);
album.year = result.getInt("year");
album.genres =
Optional.ofNullable(result.getString("genres"))
.map(s -> s.split(";")).stream()
.mapMulti((BiConsumer<String[], Consumer<String>>) (strings, objectConsumer) -> {
for (String string : strings)
{
if (!string.isEmpty())
{
objectConsumer.accept(string);
}
}
}).map(String::trim).toArray(String[]::new);
album.totalTracks = result.getInt("tracks");
album.totalDiscs = result.getInt("discs");
return album;
}
/**
* Called to obtain the properties of an object to write.
*
* @param item - The item to serialize.
* @return Properties to write.
*/
@Override
protected Map<String, Object> serializeItem(Album item)
{
LinkedHashMap<String, Object> returnValue = new LinkedHashMap<>();
returnValue.put("album", item.name);
returnValue.put("artists", Arrays.stream(item.artists).reduce("",
(s1, s2) -> s1.isEmpty() ? s2 : s1 + ";" + s2));
returnValue.put("year", item.year);
returnValue.put("genres", Arrays.stream(item.genres).reduce("",
(s1, s2) -> s1.isEmpty() ? s2 : s1 + ";" + s2));
returnValue.put("tracks", item.totalTracks);
returnValue.put("discs", item.totalDiscs);
return returnValue;
}
/**
* Converts a piece of data into a string that will display in the update
* text.
*
* @param data - The data to stringify.
* @return A string representation of the data being updated.
*/
@Override
protected String stringifyResult(Album data)
{
return data.name;
}
/**
* A callback for when the database scan is complete.
* <p>
* Note that this method is called from the same thread that the scanner is
* from.
* </p>
*
* @return A fork-join task to invoke. This may be null.
*/
@Override
protected ForkJoinTask[] onComplete()
{
return null;
}
}

View File

@@ -7,657 +7,296 @@ package edu.regis.universeplayer.data;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URL; import java.net.URL;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement; import java.sql.Statement;
import java.util.*; import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class InternetSongProvider implements SongProvider<InternetSong> import edu.regis.universeplayer.browser.Browser;
import edu.regis.universeplayer.browserCommands.QuerySongData;
public class InternetSongProvider extends DatabaseProvider<InternetSong> implements SongProvider<InternetSong>
{ {
private static final Logger logger = LoggerFactory.getLogger(InternetSongProvider.class); private static final Logger logger = LoggerFactory.getLogger(InternetSongProvider.class);
private static final ExecutorService service = Executors.newSingleThreadExecutor();
private static InternetSongProvider INSTANCE; private static InternetSongProvider INSTANCE;
private final AtomicInteger progress = new AtomicInteger(0);
private final AtomicInteger updating = new AtomicInteger(0);
private String updateItem;
private final AlbumProvider albums;
public static InternetSongProvider getInstance() public static InternetSongProvider getInstance()
{ {
if (INSTANCE == null)
{
INSTANCE = new InternetSongProvider();
}
return INSTANCE; return INSTANCE;
} }
private AtomicBoolean updating = new AtomicBoolean();
private final HashMap<URL, InternetSong> songs = new HashMap<>();
private final HashMap<String, Album> albums = new HashMap<>();
/**
* A cache of all song artists.
*/
private final HashSet<String> artists = new HashSet<>();
/**
* A cache of all album genres.
*/
private final HashSet<String> genres = new HashSet<>();
/**
* A cache of all album artists.
*/
private final HashSet<String> albumArtists = new HashSet<>();
/**
* A cache of all album release years.
*/
private final HashSet<Integer> years = new HashSet<>();
private int updatedSongs;
private int totalUpdate;
private final LinkedList<UpdateListener> listeners = new LinkedList<>(); private final LinkedList<UpdateListener> listeners = new LinkedList<>();
private InternetSongProvider() public InternetSongProvider(AlbumProvider albums)
{ {
this.getSongCache(); this.albums = albums;
INSTANCE = this;
} }
private void getSongCache()
{
this.updating.set(true);
service.submit(new SongQuery());
}
/**
* Obtains all albums within the collection.
*
* @return A list of albums.
*/
@Override
public Collection<Album> getAlbums()
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
return this.albums.values();
}
/**
* Obtains all songs within the collection.
*
* @return A list of songs.
*/
@Override
public Collection<InternetSong> getSongs()
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
synchronized (this.songs)
{
return Collections.unmodifiableCollection(this.songs.values());
}
}
/**
* Obtains a list of all artists.
*
* @return All artists.
*/
@Override
public Collection<String> getArtists()
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
return this.artists;
}
/**
* Obtains a list of all album artists.
*
* @return All album artists.
*/
@Override
public Collection<String> getAlbumArtists()
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
return this.albumArtists;
}
/**
* Obtains a list of all genres.
*
* @return All genres.
*/
@Override
public Collection<String> getGenres()
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
return this.genres;
}
/**
* Obtains a list of all years that have albums.
*
* @return All years.
*/
@Override
public Collection<Integer> getYears()
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
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<InternetSong> getSongsFromAlbum(Album album)
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
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<InternetSong> getSongsFromArtist(String artist)
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
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.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
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<Album> getAlbumsFromArtist(String artist)
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
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<Album> getAlbumsFromGenre(String genre)
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
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<Album> getAlbumsFromYear(int year)
{
synchronized (this.updating)
{
while (this.updating.get())
{
try
{
this.updating.wait();
}
catch (InterruptedException e)
{
logger.error("Error while waiting for update lock", e);
}
}
}
synchronized (this.albums)
{
return this.albums.values().stream().filter(album -> album.year == year).collect(Collectors.toUnmodifiableSet());
}
}
@Override @Override
public int getUpdateProgress() public int getUpdateProgress()
{ {
return this.updatedSongs; int sup = super.getUpdateProgress();
if (sup == 0)
{
sup = this.progress.get();
}
return sup;
} }
@Override @Override
public int getTotalUpdateSongs() public int getTotalUpdates()
{ {
return this.totalUpdate; int sup = super.getTotalUpdates();
if (sup == 0)
{
sup = this.updating.get();
}
return sup;
} }
@Override @Override
public String getUpdateText() public String getUpdateText()
{ {
return ""; String sup = super.getUpdateText();
} if (sup == null || sup.isEmpty())
@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<InternetSong> 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()
{ {
sup = this.updateItem;
} }
return sup;
@Override }
public void run()
{
Statement state;
ResultSet result;
Album album;
InternetSong song;
int numAlbums = 0, numSongs = 0;
synchronized (updating) /**
* Obtains the name of
*
* @return The name of the database table. This is case insensitive.
*/
@Override
protected String getDatabaseTable()
{
return "internet_songs";
}
/**
* Obtains the SQL string used to create the database table should it be
* necessary.
*
* @return The SQL command that creates the database table. It should take
* the format of "CREATE TABLE name (param1 type, param2 type);"
*/
@Override
protected String createDatabaseTable()
{
/*
* Create the table
*/
return "CREATE TABLE internet_songs" +
"(url TEXT PRIMARY KEY NOT NULL," +
"title TEXT," +
"artists TEXT," +
"track INTEGER," +
"disc INTEGER," +
"duration BIGINT," +
"album TEXT);";
}
@Override
public AlbumProvider getAlbumProvider()
{
return this.albums;
}
/**
* Called when an entry is read from the database and is ready to be
* parsed.
* <p>
* Note that this method is called for every row. Do NOT call {@link
* ResultSet#next()}!
*
* @param result The result that is read from.
*/
@Override
protected InternetSong readResult(ResultSet result) throws SQLException
{
InternetSong song = new InternetSong();
song.location = result.getURL("url");
song.title = result.getString("title");
song.artists =
Arrays.stream(result.getString("artists").split(";"))
.map(String::trim).toArray(String[]::new);
song.trackNum = result.getInt("track");
song.disc = result.getInt("disc");
song.duration = result.getLong("duration");
try
{
getAlbumProvider().joinUpdate();
song.album = getAlbumProvider().getAlbumByName(result.getString(
"album"));
}
catch (InterruptedException e)
{
logger.error("Couldn't wait for album provider for song {}", song
, e);
}
return song;
}
/**
* Called to obtain the properties of an object to write.
*
* @param item - The item to serialize.
* @return Properties to write.
*/
@Override
protected Map<String, Object> serializeItem(InternetSong item)
{
LinkedHashMap<String, Object> map = new LinkedHashMap<>();
map.put("url", item.location);
map.put("title", item.title);
map.put("artists", Arrays.stream(item.artists).reduce("",
(s1, s2) -> s1 + ";" + s2));
map.put("track", item.trackNum);
map.put("disc", item.disc);
map.put("duration", item.duration);
map.put("album",
Optional.ofNullable(item.album).map(a -> a.name).orElse(null));
return map;
}
/**
* Converts a piece of data into a string that will display in the update
* text.
*
* @param data - The data to stringify.
* @return A string representation of the data being updated.
*/
@Override
protected String stringifyResult(InternetSong data)
{
return Optional.ofNullable(data.album).map(a -> a.name).orElse(null) +
"/" + data.title;
}
/**
* A callback for when the database scan is complete.
* <p>
* Note that this method is called from the same thread that the scanner is
* from.
* </p>
*
* @return A fork-join task to invoke. This may be null.
*/
@Override
protected ForkJoinTask[] onComplete()
{
return null;
}
public ForkJoinTask<InternetSong> addSong(URL url)
{
AddInternetTask task = new AddInternetTask(url);
this.service.execute(task);
return task;
}
private class AddInternetTask extends ForkJoinTask<InternetSong>
{
private final URL loc;
private InternetSong item;
public AddInternetTask(URL song)
{
this.loc = song;
}
/**
* Returns the result that would be returned by {@link #join}, even if
* this task completed abnormally, or {@code null} if this task is not
* known to have been completed. This method is designed to aid
* debugging, as well as to support extensions. Its use in any other
* context is discouraged.
*
* @return the result, or {@code null} if not completed
*/
@Override
public InternetSong getRawResult()
{
return this.item;
}
/**
* Forces the given value to be returned as a result. This method is
* designed to support extensions, and should not in general be called
* otherwise.
*
* @param value the value
*/
@Override
protected void setRawResult(InternetSong value)
{
this.item = value;
}
/**
* Immediately performs the base action of this task and returns true
* if, upon return from this method, this task is guaranteed to have
* completed. This method may return false otherwise, to indicate that
* this task is not necessarily complete (or is not known to be
* complete), for example in asynchronous actions that require explicit
* invocations of completion methods. This method may also throw an
* (unchecked) exception to indicate abnormal exit. This method is
* designed to support extensions, and should not in general be called
* otherwise.
*
* @return {@code true} if this task is known to have completed normally
*/
@Override
protected boolean exec()
{
InternetSong data;
try
{
data =
(InternetSong) Browser.getInstance().sendObject(new QuerySongData(this.loc)).get();
}
catch (InterruptedException | ExecutionException | IOException e)
{
this.completeExceptionally(e);
return false;
}
if (data != null)
{ {
updating.set(true);
try try
{ {
logger.debug("Querying database."); writeItem(data).get();
/*
* 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) catch (InterruptedException | ExecutionException e)
{ {
logger.error("Could not query SQL database.", e); this.completeExceptionally(e);
return false;
} }
logger.debug("Query complete, retrieved {} albums and {} songs", numAlbums, numSongs);
updatedSongs = 0;
totalUpdate = 0;
updating.set(false);
updating.notifyAll();
} }
triggerUpdateListeners(); this.complete(data);
return true;
} }
} }
} }

View File

@@ -1,208 +0,0 @@
/*
* Copyright (c) 2021 William Hubbard. All Rights Reserved.
*/
package edu.regis.universeplayer.data;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Random;
import java.util.stream.Collectors;
/**
* A test song database that automatically generates a handful of songs.
*
* @author William Hubbard
* @version 0.1
*/
public class SimpleSongProvider implements SongProvider<Song>
{
private ArrayList<Album> albums;
private ArrayList<Song> songs;
@Override
public Collection<Album> getAlbums()
{
if (albums == null)
{
albums = new ArrayList<>();
Random random = new Random();
StringBuilder builder;
String albumName, albumArtist;
Album album;
for (int albumNum = 0, numAlbums = random
.nextInt(20) + 20; albumNum < numAlbums; albumNum++)
{
builder = new StringBuilder();
for (int i = 0, l = random.nextInt(10) + 10; i < l; i++)
{
builder.append((char) (random.nextInt(26) + 97));
}
albumName = builder.toString();
builder = new StringBuilder();
for (int i = 0, l = random.nextInt(10) + 10; i < l; i++)
{
builder.append((char) (random.nextInt(26) + 97));
}
albumArtist = builder.toString();
album = new Album()
{
};
album.name = albumName;
album.artists = new String[]{albumArtist};
album.genres = new String[]{"Soundtrack"};
album.year = 2019;
album.totalTracks = random.nextInt(10) + 10;
album.id = albumNum;
albums.add(album);
}
}
return albums;
}
@Override
public Collection<Song> getSongs()
{
if (songs == null)
{
/*
* Generate a list of songs
*/
Random random = new Random();
StringBuilder builder;
String songTitle;
Song song;
int songNum, numSongs;
songs = new ArrayList<>();
for (Album album : this.getAlbums())
{
for (songNum = 0, numSongs = album.totalTracks; songNum < numSongs; songNum++)
{
builder = new StringBuilder();
for (int i = 0, l = random.nextInt(10) + 10; i < l; i++)
{
builder.append((char) (random.nextInt(26) + 97));
}
songTitle = builder.toString();
song = new Song()
{
};
song.title = songTitle;
song.disc = 1;
song.trackNum = songNum + 1;
song.artists = album.artists.clone();
song.album = album;
songs.add(song);
}
album.totalTracks = numSongs;
}
}
return this.songs;
}
@Override
public Collection<String> getArtists()
{
return this.songs.stream().flatMap(song -> Arrays.stream(song.artists)).sorted()
.collect(Collectors.toList());
}
@Override
public Collection<String> getGenres()
{
return this.songs.stream().flatMap(song -> Arrays.stream(song.album.genres)).sorted()
.collect(Collectors.toList());
}
@Override
public Collection<String> getAlbumArtists()
{
return this.albums.stream().flatMap(album -> Arrays.stream(album.artists)).sorted()
.collect(Collectors.toList());
}
@Override
public Collection<Integer> getYears()
{
return this.albums.stream().map(album -> album.year).sorted()
.collect(Collectors.toList());
}
@Override
public Collection<Song> getSongsFromAlbum(Album album)
{
return this.songs.stream().filter(song -> song.album == album).sorted()
.collect(Collectors.toList());
}
@Override
public Collection<Song> getSongsFromArtist(String artist)
{
return this.songs.stream().filter(song -> Arrays.asList(song.artists).contains(artist)).sorted()
.collect(Collectors.toList());
}
@Override
public Album getAlbumByName(String name)
{
return this.albums.stream().filter(album -> album.name.equals(name)).findFirst().orElse(null);
}
@Override
public Collection<Album> getAlbumsFromArtist(String artist)
{
return this.albums.stream().filter(album -> Arrays.asList(album.artists).contains(artist))
.sorted().collect(Collectors.toList());
}
@Override
public Collection<Album> getAlbumsFromGenre(String genre)
{
return this.albums.stream().filter(album -> Arrays.asList(album.genres).contains(genre))
.sorted().collect(Collectors.toList());
}
@Override
public Collection<Album> getAlbumsFromYear(int year)
{
return this.albums.stream().filter(album -> album.year == year)
.sorted().collect(Collectors.toList());
}
@Override
public int getUpdateProgress()
{
return 0;
}
@Override
public int getTotalUpdateSongs()
{
return -1;
}
@Override
public String getUpdateText()
{
return null;
}
@Override
public void addUpdateListener(UpdateListener listener)
{
// We don't need update listeners here
}
@Override
public void removeUpdateListener(UpdateListener listener)
{
// We don't need update listeners here
}
}

View File

@@ -6,6 +6,9 @@ package edu.regis.universeplayer.data;
import java.io.File; import java.io.File;
import java.util.Collection; import java.util.Collection;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/** /**
* The song provider interface serves to give the application access to any form of song database as needed. * The song provider interface serves to give the application access to any form of song database as needed.
@@ -13,151 +16,84 @@ import java.util.Collection;
* @author William Hubbard * @author William Hubbard
* @version 0.1 * @version 0.1
*/ */
public interface SongProvider<T extends Song> public interface SongProvider<T extends Song> extends DataProvider<T>
{ {
// SongProvider<Song> INSTANCE = new CompiledSongProvider(new LocalSongProvider(new File(System.getProperty("user.home"), "Music")), InternetSongProvider.getInstance());
/** /**
* A SongProvider instance designed to * Obtains a reference to the album provider.
* @return The album provider used.
*/ */
SongProvider<Song> INSTANCE = new CompiledSongProvider(new LocalSongProvider(new File(System.getProperty("user.home"), "Music")), InternetSongProvider.getInstance()); AlbumProvider getAlbumProvider();
/** /**
* Obtains all albums within the collection. * Obtains all albums within the collection.
* *
* @return A list of albums. * @return A list of albums.
*/ */
Collection<Album> getAlbums(); default Collection<Album> getAlbums()
{
return this.getCollection().stream().map(s -> s.album).collect(Collectors
.toSet());
}
/** /**
* Obtains all songs within the collection. * Obtains all songs within the collection.
* *
* @return A list of songs. * @return A list of songs.
*/ */
Collection<T> getSongs(); default Collection<T> getSongs()
{
return this.getCollection();
}
/** /**
* Obtains a list of all artists. * Obtains a list of all artists.
* *
* @return All artists. * @return All artists.
*/ */
Collection<String> getArtists(); default Collection<String> getArtists()
{
/** return this.getCollection().stream().filter(s -> s.artists != null).map(s -> s.artists)
* Obtains a list of all album artists. .mapMulti((BiConsumer<String[], Consumer<String>>) (strings, objectConsumer) -> {
* for (String string : strings)
* @return All album artists. {
*/ objectConsumer.accept(string);
Collection<String> getAlbumArtists(); }
}).collect(Collectors.toSet());
/** }
* Obtains a list of all genres.
*
* @return All genres.
*/
Collection<String> getGenres();
/**
* Obtains a list of all years that have albums.
*
* @return All years.
*/
Collection<Integer> getYears();
/** /**
* Obtains all songs from an album. * Obtains all songs from an album.
* *
* @param album - The album to obtain * @param album - The album to obtain
* @return All songs from the requested album, or null if that album is not in the database. * @return All songs from the requested album, or null if that album is not
* in the database.
*/ */
Collection<T> getSongsFromAlbum(Album album); default Collection<T> getSongsFromAlbum(Album album)
{
return this.getCollection().stream().filter(s -> s.album == album)
.collect(Collectors.toSet());
}
/** /**
* Obtains all songs written by a given artist. * Obtains all songs written by a given artist.
* *
* @param artist - The artist to search for * @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 * @return A list of all songs from the specified artist, or null if that
* database. * artist is not in the database.
*/ */
Collection<T> getSongsFromArtist(String artist); default Collection<T> getSongsFromArtist(String artist)
/**
* 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.
*/
Album getAlbumByName(String name);
/**
* Obtains all albums that were written by a certain artist.
*
* @param artist - The artist to search for.
* @return - The collection on matching albums.
*/
Collection<Album> getAlbumsFromArtist(String artist);
/**
* Obtains all albums that match a certain genre
*
* @param genre - The genre to search for.
* @return - The collection on matching albums.
*/
Collection<Album> getAlbumsFromGenre(String genre);
/**
* Obtains all albums that were released a certain year.
*
* @param year - The year to search for.
* @return - The collection on matching albums.
*/
Collection<Album> getAlbumsFromYear(int year);
/**
* Checks to see if we are updating any songs.
*
* @return True if we are updating the song cache.
*/
default boolean isUpdating()
{ {
return this.getTotalUpdateSongs() != 0; return this.getCollection().stream().filter(s -> {
for (int i = 0; i < s.artists.length; i++)
{
if (s.artists[i].equals(artist))
{
return true;
}
}
return false;
}).collect(Collectors.toSet());
} }
/**
* When we are updating the song cache, this method obtains the number of
* songs already updated.
*
* @return The number of songs successfully updated.
*/
int getUpdateProgress();
/**
* Determines whether or not we are updating the cache and, if so, gets the
* total number of songs we need to update
*
* @return The total number of songs to update. A 0 means that we do not
* have any songs to update, and a negative number means that we are
* currently calculating how many songs we need to update.
*/
int getTotalUpdateSongs();
/**
* Determines the text displayed for the progress of updates
*
* @return The update status text.
*/
String getUpdateText();
/**
* Adds a listener for song updates.
*
* @param listener - The listener to add.
*/
void addUpdateListener(UpdateListener listener);
/**
* Removes a listener for song updates.
*
* @param listener - The listener to remove.
*/
void removeUpdateListener(UpdateListener listener);
} }

View File

@@ -15,10 +15,12 @@ public interface UpdateListener
{ {
/** /**
* Called when the update status of the player has changed. * Called when the update status of the player has changed.
* @param provider - The provider that triggered the listener.
* @param updated - The number of songs updated. * @param updated - The number of songs updated.
* @param totalUpdate - The total number of songs to update, or -1 if we are * @param totalUpdate - The total number of songs to update, or -1 if we are
* still determining that. * still determining that.
* @param updating - The text to display on update bars. * @param updating - The text to display on update bars.
*/ */
void onUpdate(int updated, int totalUpdate, String updating); <T> void onUpdate(DataProvider<T> provider, int updated, int totalUpdate,
String updating);
} }

View File

@@ -15,7 +15,9 @@ import javax.swing.*;
import com.wordpress.tips4java.ScrollablePanel; import com.wordpress.tips4java.ScrollablePanel;
import edu.regis.universeplayer.PlayerEnvironment;
import edu.regis.universeplayer.data.Album; import edu.regis.universeplayer.data.Album;
import edu.regis.universeplayer.data.AlbumProvider;
import edu.regis.universeplayer.data.CollectionType; import edu.regis.universeplayer.data.CollectionType;
import edu.regis.universeplayer.data.Song; import edu.regis.universeplayer.data.Song;
import edu.regis.universeplayer.data.SongProvider; import edu.regis.universeplayer.data.SongProvider;
@@ -144,15 +146,15 @@ public class CollectionList extends ScrollablePanel
artistLabel.addActionListener(mouseEvent -> { artistLabel.addActionListener(mouseEvent -> {
if (album) if (album)
{ {
this.triggerSongDisplayListeners(SongProvider.INSTANCE this.triggerSongDisplayListeners(PlayerEnvironment.getAlbums()
.getAlbumsFromArtist(artist).stream() .getAlbumsFromArtist(artist).stream()
.flatMap(album2 -> SongProvider.INSTANCE.getSongsFromAlbum(album2) .flatMap(album2 -> PlayerEnvironment.getSongs().getSongsFromAlbum(album2)
.stream()) .stream())
.collect(Collectors.toList())); .collect(Collectors.toList()));
} }
else else
{ {
this.triggerSongDisplayListeners(new ArrayList<>(SongProvider.INSTANCE this.triggerSongDisplayListeners(new ArrayList<>(PlayerEnvironment.getSongs()
.getSongsFromArtist(artist))); .getSongsFromArtist(artist)));
} }
}); });
@@ -184,7 +186,7 @@ public class CollectionList extends ScrollablePanel
albumLabel.setText(album.name); albumLabel.setText(album.name);
albumLabel.setHorizontalTextPosition(JLabel.CENTER); albumLabel.setHorizontalTextPosition(JLabel.CENTER);
albumLabel.setVerticalTextPosition(JLabel.BOTTOM); albumLabel.setVerticalTextPosition(JLabel.BOTTOM);
albumLabel.addActionListener(mouseEvent -> this.triggerSongDisplayListeners(new ArrayList<>(SongProvider.INSTANCE albumLabel.addActionListener(mouseEvent -> this.triggerSongDisplayListeners(new ArrayList<>(PlayerEnvironment.getSongs()
.getSongsFromAlbum(album)))); .getSongsFromAlbum(album))));
this.add(albumLabel); this.add(albumLabel);
this.labelMap.put(albumLabel, album); this.labelMap.put(albumLabel, album);
@@ -222,9 +224,9 @@ public class CollectionList extends ScrollablePanel
genreLabel.setText(genre); genreLabel.setText(genre);
genreLabel.setHorizontalTextPosition(JLabel.CENTER); genreLabel.setHorizontalTextPosition(JLabel.CENTER);
genreLabel.setVerticalTextPosition(JLabel.BOTTOM); genreLabel.setVerticalTextPosition(JLabel.BOTTOM);
genreLabel.addActionListener(mouseEvent -> this.triggerSongDisplayListeners(SongProvider.INSTANCE genreLabel.addActionListener(mouseEvent -> this.triggerSongDisplayListeners(PlayerEnvironment.getAlbums()
.getAlbumsFromGenre(genre).stream() .getAlbumsFromGenre(genre).stream()
.flatMap(album2 -> SongProvider.INSTANCE.getSongsFromAlbum(album2) .flatMap(album2 -> PlayerEnvironment.getSongs().getSongsFromAlbum(album2)
.stream()) .stream())
.collect(Collectors.toList()))); .collect(Collectors.toList())));
this.add(genreLabel); this.add(genreLabel);
@@ -263,9 +265,9 @@ public class CollectionList extends ScrollablePanel
yearLabel.setText(year.toString()); yearLabel.setText(year.toString());
yearLabel.setHorizontalTextPosition(JLabel.CENTER); yearLabel.setHorizontalTextPosition(JLabel.CENTER);
yearLabel.setVerticalTextPosition(JLabel.BOTTOM); yearLabel.setVerticalTextPosition(JLabel.BOTTOM);
yearLabel.addActionListener(mouseEvent -> this.triggerSongDisplayListeners(SongProvider.INSTANCE yearLabel.addActionListener(mouseEvent -> this.triggerSongDisplayListeners(PlayerEnvironment.getAlbums()
.getAlbumsFromYear(year).stream() .getAlbumsFromYear(year).stream()
.flatMap(album2 -> SongProvider.INSTANCE.getSongsFromAlbum(album2) .flatMap(album2 -> PlayerEnvironment.getSongs().getSongsFromAlbum(album2)
.stream()) .stream())
.collect(Collectors.toList()))); .collect(Collectors.toList())));
this.add(yearLabel); this.add(yearLabel);

View File

@@ -4,6 +4,7 @@
package edu.regis.universeplayer.gui; package edu.regis.universeplayer.gui;
import edu.regis.universeplayer.PlayerEnvironment;
import edu.regis.universeplayer.player.Player; import edu.regis.universeplayer.player.Player;
import edu.regis.universeplayer.browser.Browser; import edu.regis.universeplayer.browser.Browser;
import edu.regis.universeplayer.player.BrowserPlayer; import edu.regis.universeplayer.player.BrowserPlayer;
@@ -28,6 +29,7 @@ import java.util.Locale;
import java.util.ResourceBundle; import java.util.ResourceBundle;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinTask;
/** /**
* The Interface class serves as the primary GUI that the player interacts * The Interface class serves as the primary GUI that the player interacts
@@ -75,8 +77,6 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
*/ */
private final PlayerControls controls; private final PlayerControls controls;
private int currentPlayer = -1;
public static Interface getInstance() public static Interface getInstance()
{ {
return INSTANCE; return INSTANCE;
@@ -137,7 +137,7 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
/** /**
* Invoked when a window has been closed. * Invoked when a window has been closed.
* *
* @param e * @param e - Event data
*/ */
@Override @Override
public void windowClosed(WindowEvent e) public void windowClosed(WindowEvent e)
@@ -229,39 +229,23 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
* thread. * thread.
* *
* @return the computed result * @return the computed result
* @throws Exception if unable to compute a result
*/ */
@Override @Override
protected Void doInBackground() throws Exception protected Void doInBackground()
{ {
QueryFuture<Void> command = PlayerManager.getPlayers().throwError(false); ForkJoinTask<Void> command = PlayerManager
try .getPlayers().throwError(false);
command.join();
if (command.isCompletedAbnormally())
{ {
if (!command.getConfirmation().wasSuccessful()) logger.error("Could not run command",
{ command.getException());
logger.error("Could not run command",
command.getConfirmation()
.getError());
JOptionPane
.showMessageDialog(Interface.this,
command.getConfirmation()
.getError(),
command.getConfirmation()
.getMessage(),
JOptionPane.ERROR_MESSAGE);
}
}
catch (ExecutionException | InterruptedException executionException)
{
logger.error("Error" +
" creating " +
"debug " +
"message", executionException);
JOptionPane JOptionPane
.showMessageDialog(Interface.this, executionException .showMessageDialog(Interface.this,
.getMessage(), langs command.getException(),
.getString( command.getException()
"error.command"), JOptionPane.ERROR_MESSAGE); .getMessage(),
JOptionPane.ERROR_MESSAGE);
} }
return null; return null;
} }
@@ -295,7 +279,8 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
if (this.isEnabled()) if (this.isEnabled())
{ {
collectionTypes collectionTypes
.triggerSongDisplayListeners(new ArrayList<>(SongProvider.INSTANCE .triggerSongDisplayListeners(new ArrayList<>(PlayerEnvironment
.getSongs()
.getSongs())); .getSongs()));
} }
} }
@@ -311,7 +296,8 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
if (this.isEnabled()) if (this.isEnabled())
{ {
collectionTypes collectionTypes
.triggerCollectionDisplayListeners(CollectionType.albumArtist, SongProvider.INSTANCE .triggerCollectionDisplayListeners(CollectionType.albumArtist, PlayerEnvironment
.getAlbums()
.getAlbumArtists()); .getAlbumArtists());
} }
} }
@@ -327,7 +313,8 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
if (this.isEnabled()) if (this.isEnabled())
{ {
collectionTypes collectionTypes
.triggerCollectionDisplayListeners(CollectionType.album, SongProvider.INSTANCE .triggerCollectionDisplayListeners(CollectionType.album, PlayerEnvironment
.getSongs()
.getAlbums()); .getAlbums());
} }
} }
@@ -343,7 +330,8 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
if (this.isEnabled()) if (this.isEnabled())
{ {
collectionTypes collectionTypes
.triggerCollectionDisplayListeners(CollectionType.genre, SongProvider.INSTANCE .triggerCollectionDisplayListeners(CollectionType.genre, PlayerEnvironment
.getAlbums()
.getGenres()); .getGenres());
} }
} }
@@ -359,7 +347,8 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
if (this.isEnabled()) if (this.isEnabled())
{ {
collectionTypes collectionTypes
.triggerCollectionDisplayListeners(CollectionType.year, SongProvider.INSTANCE .triggerCollectionDisplayListeners(CollectionType.year, PlayerEnvironment
.getAlbums()
.getYears()); .getYears());
} }
} }
@@ -472,7 +461,7 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
this.setFocusCycleRoot(true); this.setFocusCycleRoot(true);
this.getContentPane() this.getContentPane()
.add(this.collectionTypes, BorderLayout.LINE_START); .add(this.collectionTypes, BorderLayout.LINE_START);
this.collectionTypes.addFocusListener(this); this.collectionTypes.addFocusListener(this);
this.collectionTypes.addSongDisplayListener(this); this.collectionTypes.addSongDisplayListener(this);
@@ -573,18 +562,24 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
@Override @Override
public void updateSongs(Collection<? extends Song> songs) public void updateSongs(Collection<? extends Song> songs)
{ {
this.songList.listAlbums(songs); SwingUtilities.invokeLater(() ->
this.centerView.setViewportView(this.songList); {
this.centerView.revalidate(); this.songList.listAlbums(songs);
this.centerView.setViewportView(this.songList);
this.centerView.revalidate();
});
} }
@Override @Override
public void updateCollections(CollectionType type, Collection<?> collections) public void updateCollections(CollectionType type, Collection<?> collections)
{ {
this.collectionList.listCollection(type, collections); SwingUtilities.invokeLater(() ->
this.centerView.setViewportView(this.collectionList); {
this.collectionList.revalidate(); this.collectionList.listCollection(type, collections);
this.centerView.revalidate(); this.centerView.setViewportView(this.collectionList);
this.collectionList.revalidate();
this.centerView.revalidate();
});
} }
@Override @Override
@@ -652,19 +647,24 @@ public class Interface extends JFrame implements SongDisplayListener, ComponentL
} }
@Override @Override
public void onUpdate(int updated, int totalUpdate, String updating) public <T> void onUpdate(DataProvider<T> provider, int updated,
int totalUpdate, String updating)
{ {
this.controls.setUpdateProgress(updated, totalUpdate, updating); SwingUtilities.invokeLater(() ->
if (updated == totalUpdate || totalUpdate == 0)
{ {
Collection<Song> songs = SongProvider.INSTANCE.getSongs(); this.controls.setUpdateProgress(updated, totalUpdate, updating);
logger.debug("Resetting the song provider with {} songs.", if (updated == totalUpdate || totalUpdate == 0)
songs.size()); {
/* Collection<? extends Song> songs =
* TODO - Add some way to get back to the current view, just updated PlayerEnvironment.getSongs().getSongs();
*/ logger.debug("Resetting the song provider with {} songs.",
this.updateSongs(songs); songs.size());
} /*
* TODO - Add some way to get back to the current view, just updated
*/
this.updateSongs(songs);
}
});
} }
@Override @Override

View File

@@ -145,7 +145,7 @@ public class InternetSongDialog extends JDialog
} }
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
Future<InternetSong> future = InternetSongProvider.getInstance().addSong(url, this.titleBox.getText(), this.albumBox.getText(), this.artistBox.getText(), this.genreBox.getText()); Future<InternetSong> future = InternetSongProvider.getInstance().addSong(url);
InternetSong song = null; InternetSong song = null;
try try
{ {

View File

@@ -29,6 +29,7 @@ import java.util.Locale;
import java.util.ResourceBundle; import java.util.ResourceBundle;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
/** /**
* This panel contains the buttons necessary for controlling the playback of * This panel contains the buttons necessary for controlling the playback of
@@ -181,27 +182,15 @@ public class PlayerControls extends JPanel implements PlaybackListener
private void seek(float value) private void seek(float value)
{ {
this.service.execute(() -> { this.service.execute(() -> {
QueryFuture<Void> command = PlayerManager.getPlayers().seek(value); ForkJoinTask<Void> status = PlayerManager.getPlayers().seek(value);
try status.join();
{ if (status.isCompletedAbnormally())
if (command != null && !command.getConfirmation()
.wasSuccessful())
{
logger.error("Could not run command",
command.getConfirmation().getError());
JOptionPane.showMessageDialog(this,
command.getConfirmation().getError(),
command.getConfirmation().getMessage(),
JOptionPane.ERROR_MESSAGE);
}
}
catch (ExecutionException | InterruptedException e)
{ {
logger.error("Could not run command", logger.error("Could not run command",
e); status.getException());
JOptionPane.showMessageDialog(this, JOptionPane.showMessageDialog(this,
e, status.getException(),
e.getMessage(), status.getException().getMessage(),
JOptionPane.ERROR_MESSAGE); JOptionPane.ERROR_MESSAGE);
} }
}); });
@@ -213,16 +202,15 @@ public class PlayerControls extends JPanel implements PlaybackListener
this.service.execute(() -> { this.service.execute(() -> {
try try
{ {
QueryFuture<PlaybackStatus> status = ForkJoinTask status =
PlayerManager.getPlayers().getStatus(); PlayerManager.getPlayers().getStatus();
CommandConfirmation confirmation = status.getConfirmation(); status.join();
if (status.getConfirmation().wasSuccessful()) if (status.isCompletedNormally())
{ {
switch (status.get()) switch ((PlaybackStatus) status.get())
{ {
case PAUSED -> confirmation = PlayerManager.getPlayers() case PAUSED -> status = PlayerManager.getPlayers()
.play() .play();
.getConfirmation();
case STOPPED, EMPTY -> { case STOPPED, EMPTY -> {
if (Queue.getInstance().size() > 0) if (Queue.getInstance().size() > 0)
{ {
@@ -230,23 +218,30 @@ public class PlayerControls extends JPanel implements PlaybackListener
.getCurrentSong() == null) .getCurrentSong() == null)
{ {
Queue.getInstance().skipToSong(0); Queue.getInstance().skipToSong(0);
status = null;
} }
else else
{ {
confirmation = PlayerManager.getPlayers().play() status = PlayerManager.getPlayers().play();
.getConfirmation();
} }
} }
} }
default -> {
status = null;
}
}
if (status != null)
{
status.join();
} }
} }
if (confirmation != null && !confirmation.wasSuccessful()) if (status != null && status.isCompletedAbnormally())
{ {
logger.error("Could not run command", logger.error("Could not run command",
confirmation.getError()); status.getException());
JOptionPane.showMessageDialog(this, JOptionPane.showMessageDialog(this,
confirmation.getError(), status.getException(),
confirmation.getMessage(), status.getException().getMessage(),
JOptionPane.ERROR_MESSAGE); JOptionPane.ERROR_MESSAGE);
} }
} }
@@ -266,25 +261,25 @@ public class PlayerControls extends JPanel implements PlaybackListener
this.service.execute(() -> { this.service.execute(() -> {
try try
{ {
QueryFuture<PlaybackStatus> status = ForkJoinTask status =
PlayerManager.getPlayers().getStatus(); PlayerManager.getPlayers().getStatus();
CommandConfirmation confirmation = status.getConfirmation(); status.join();
if (status.getConfirmation().wasSuccessful()) if (status.isCompletedNormally())
{ {
switch (status.get()) if (status.get() == PlaybackStatus.PLAYING)
{ {
case PLAYING -> confirmation = status =
PlayerManager.getPlayers().pause() PlayerManager.getPlayers().pause();
.getConfirmation(); status.join();
} }
} }
if (confirmation != null && !confirmation.wasSuccessful()) if (status.isCompletedAbnormally())
{ {
logger.error("Could not run command", logger.error("Could not run command",
confirmation.getError()); status.getException());
JOptionPane.showMessageDialog(this, JOptionPane.showMessageDialog(this,
confirmation.getError(), status.getException(),
confirmation.getMessage(), status.getException().getMessage(),
JOptionPane.ERROR_MESSAGE); JOptionPane.ERROR_MESSAGE);
} }
} }
@@ -307,19 +302,17 @@ public class PlayerControls extends JPanel implements PlaybackListener
this.service.execute(() -> { this.service.execute(() -> {
try try
{ {
QueryFuture<PlaybackStatus> status = ForkJoinTask status =
PlayerManager.getPlayers().getStatus(); PlayerManager.getPlayers().getStatus();
CommandConfirmation confirmation = status.getConfirmation(); status.join();
if (status.getConfirmation().wasSuccessful()) if (status.isCompletedNormally())
{ {
switch (status.get()) switch ((PlaybackStatus) status.get())
{ {
case PAUSED -> confirmation = PlayerManager.getPlayers() case PAUSED -> status = PlayerManager.getPlayers()
.play() .play();
.getConfirmation(); case PLAYING -> status = PlayerManager.getPlayers()
case PLAYING -> confirmation = PlayerManager.getPlayers() .pause();
.pause()
.getConfirmation();
case STOPPED, EMPTY -> { case STOPPED, EMPTY -> {
if (Queue.getInstance().size() > 0) if (Queue.getInstance().size() > 0)
{ {
@@ -327,24 +320,29 @@ public class PlayerControls extends JPanel implements PlaybackListener
.getCurrentSong() == null) .getCurrentSong() == null)
{ {
Queue.getInstance().skipToSong(0); Queue.getInstance().skipToSong(0);
status = null;
} }
else else
{ {
confirmation = PlayerManager.getPlayers().play() status = PlayerManager.getPlayers().play();
.getConfirmation();
} }
} }
} }
default -> status = null;
}
if (status != null)
{
status.join();
} }
} }
if (confirmation != null && !confirmation.wasSuccessful()) if (status != null && status.isCompletedAbnormally())
{ {
JOptionPane.showMessageDialog(this,
confirmation.getError(),
confirmation.getMessage(),
JOptionPane.ERROR_MESSAGE);
logger.error("Could not run command", logger.error("Could not run command",
confirmation.getError()); status.getException());
JOptionPane.showMessageDialog(this,
status.getException(),
status.getException().getMessage(),
JOptionPane.ERROR_MESSAGE);
} }
} }
catch (ExecutionException | InterruptedException e) catch (ExecutionException | InterruptedException e)
@@ -379,7 +377,7 @@ public class PlayerControls extends JPanel implements PlaybackListener
void setUpdateProgress(int updated, int toUpdate, String updating) void setUpdateProgress(int updated, int toUpdate, String updating)
{ {
this.updateProgress.setString(updating); this.updateProgress.setString(updating);
if (toUpdate == 0) if (toUpdate == 0 || updated == toUpdate)
{ {
this.updateProgress.setVisible(false); this.updateProgress.setVisible(false);
// this.updateProgress.setPreferredSize(new Dimension(this.updateProgress.getPreferredSize().width, 0)); // this.updateProgress.setPreferredSize(new Dimension(this.updateProgress.getPreferredSize().width, 0));
@@ -399,6 +397,7 @@ public class PlayerControls extends JPanel implements PlaybackListener
this.updateProgress.setValue(updated); this.updateProgress.setValue(updated);
} }
} }
this.repaint();
} }
/** /**

View File

@@ -10,6 +10,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import edu.regis.universeplayer.ClickListener; import edu.regis.universeplayer.ClickListener;
import edu.regis.universeplayer.PlayerEnvironment;
import edu.regis.universeplayer.data.Queue; import edu.regis.universeplayer.data.Queue;
import edu.regis.universeplayer.data.*; import edu.regis.universeplayer.data.*;
@@ -44,7 +45,7 @@ public class SongList extends ScrollablePanel
this.setFocusTraversalPolicyProvider(true); this.setFocusTraversalPolicyProvider(true);
this.setLayout(layout); this.setLayout(layout);
SongProvider<?> provider = SongProvider.INSTANCE; SongProvider<?> provider = PlayerEnvironment.getSongs();
this.listAlbums(provider.getSongs()); this.listAlbums(provider.getSongs());
this.setScrollableWidth(ScrollableSizeHint.FIT); this.setScrollableWidth(ScrollableSizeHint.FIT);
@@ -98,230 +99,250 @@ public class SongList extends ScrollablePanel
* thread. * thread.
* *
* @return the computed result * @return the computed result
* @throws Exception if unable to compute a result
*/ */
@Override @Override
protected Object doInBackground() throws Exception protected Object doInBackground()
{ {
logger.debug("Sorting {} songs...", songs.size()); try
Map<Album, List<Song>> albums = songs.stream().sorted().collect(Collectors {
.groupingBy(song -> song.album, Collectors logger.debug("Sorting {} songs...", songs.size());
.mapping(song -> (Song) song, Collectors.toList()))); Map<Album, List<Song>> albums =
logger.debug("Listing {} albums ({} songs)", songs.stream().filter(s -> s.album != null).sorted()
albums.size(), songs.size()); .collect(Collectors
GridBagConstraints c = new GridBagConstraints(); .groupingBy(song -> song.album, Collectors
c.fill = GridBagConstraints.HORIZONTAL; .mapping(song -> (Song) song, Collectors
AtomicInteger i = new AtomicInteger(0); .toList())));
logger.debug("Listing {} albums ({} songs)",
albums.size(), songs.size());
GridBagConstraints c = new GridBagConstraints();
c.fill = GridBagConstraints.HORIZONTAL;
AtomicInteger i = new AtomicInteger(0);
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
labelMap.clear(); labelMap.clear();
artMap.clear(); artMap.clear();
removeAll(); removeAll();
currentAlbums = albums; currentAlbums = albums;
}); });
LinkedHashMap<JComponent, GridBagConstraints> albumInfos = LinkedHashMap<JComponent, GridBagConstraints> albumInfos =
new LinkedHashMap<>(); new LinkedHashMap<>();
albums.keySet().stream().sorted().forEach((album) -> { albums.keySet().stream().sorted().forEach((album) -> {
List<Song> songCollection = albums.get(album); List<Song> songCollection = albums.get(album);
AlbumInfo albumInfo = new AlbumInfo(album); AlbumInfo albumInfo = new AlbumInfo(album);
c.gridx = 0; c.gridx = 0;
c.gridy = i.get();
c.gridwidth = 1;
c.gridheight = songCollection.size();
c.weightx = 0;
c.anchor = GridBagConstraints.NORTHWEST;
c.insets = new Insets(0, 0, 20, 10);
List<Song> finalSongCollection = songCollection;
albumInfo.addMouseListener((ClickListener) e -> {
if (e.getClickCount() == 2)
{
Queue.getInstance().addAll(finalSongCollection);
}
});
albumInfo.albumName.addMouseListener((ClickListener) e -> {
if (e.getClickCount() == 2)
{
Container inter = SongList.this;
do
{
inter = inter.getParent();
}
while (!(inter instanceof Interface) && inter
.getParent() != null);
if (inter instanceof Interface)
{
((Interface) inter).updateSongs(SongProvider.INSTANCE
.getSongsFromAlbum(albumInfo.album));
}
}
});
albumInfo.artists.addMouseListener((ClickListener) e -> {
if (e.getClickCount() == 2)
{
Container inter = SongList.this;
do
{
inter = inter.getParent();
}
while (!(inter instanceof Interface) && inter
.getParent() != null);
if (inter instanceof Interface)
{
((Interface) inter)
.updateCollections(CollectionType.album, Arrays
.stream(albumInfo.album.artists)
.flatMap(s -> SongProvider.INSTANCE
.getAlbumsFromArtist(s)
.stream())
.collect(Collectors.toList()));
}
}
});
albumInfo.genres.addMouseListener((ClickListener) e -> {
if (e.getClickCount() == 2)
{
Container inter = SongList.this;
do
{
inter = inter.getParent();
}
while (!(inter instanceof Interface) && inter
.getParent() != null);
if (inter instanceof Interface)
{
((Interface) inter)
.updateCollections(CollectionType.album, Arrays
.stream(albumInfo.album.genres)
.flatMap(s -> SongProvider.INSTANCE
.getAlbumsFromGenre(s).stream())
.collect(Collectors.toList()));
}
}
});
albumInfo.year.addMouseListener((ClickListener) e -> {
if (e.getClickCount() == 2)
{
Container inter = SongList.this;
do
{
inter = inter.getParent();
}
while (!(inter instanceof Interface) && inter
.getParent() != null);
if (inter instanceof Interface)
{
((Interface) inter)
.updateCollections(CollectionType.album, SongProvider.INSTANCE
.getAlbumsFromYear(albumInfo.album.year));
}
}
});
albumInfos.put(albumInfo, (GridBagConstraints) c.clone());
artMap.put(albumInfo, album);
JButton firstSong = null;
JLabel songNum;
JButton songTitle;
AtomicInteger numSongs = new AtomicInteger();
for (Song song : songCollection)
{
songNum = new JLabel(String.valueOf(song.trackNum));
songNum.setFocusable(false);
c.gridx = 1;
c.gridy = i.get(); c.gridy = i.get();
c.gridheight = 1; c.gridwidth = 1;
c.gridheight = songCollection.size();
c.weightx = 0; c.weightx = 0;
c.anchor = GridBagConstraints.NORTHEAST; c.anchor = GridBagConstraints.NORTHWEST;
c.insets = new Insets(0, 0, 0, 0); c.insets = new Insets(0, 0, 20, 10);
albumInfos.put(songNum, (GridBagConstraints) c.clone()); albumInfo.addMouseListener((ClickListener) e -> {
labelMap.put(songNum, song); if (e.getClickCount() == 2)
songTitle = new JButton(song.title);
if (song.title == null || song.title.isEmpty())
{
if (song instanceof LocalSong)
{ {
songTitle.setText(((LocalSong) song).file.getName()); Queue.getInstance().addAll(songCollection);
}
}
songTitle.setHorizontalAlignment(JButton.LEFT);
songTitle.setFocusPainted(true);
songTitle.setMargin(new Insets(0, 0, 0, 0));
songTitle.setContentAreaFilled(false);
songTitle.setBorderPainted(false);
songTitle.setOpaque(false);
songTitle.addActionListener(new AbstractAction()
{
@Override
public void actionPerformed(ActionEvent e)
{
Queue.getInstance().add(song);
Queue.getInstance()
.skipToSong(Queue.getInstance().size() - 1);
} }
}); });
c.gridx = 2; albumInfo.albumName
c.gridy = i.get(); .addMouseListener((ClickListener) e -> {
c.weightx = 1.0; if (e.getClickCount() == 2)
c.anchor = GridBagConstraints.NORTHWEST; {
c.insets = new Insets(0, 10, 0, 0); Container inter = SongList.this;
albumInfos.put(songTitle, (GridBagConstraints) c.clone()); do
labelMap.put(songTitle, song); {
// TODO - Add song length or something inter = inter.getParent();
}
while (!(inter instanceof Interface) && inter
.getParent() != null);
if (inter instanceof Interface)
{
((Interface) inter)
.updateSongs(PlayerEnvironment.getSongs()
.getSongsFromAlbum(albumInfo.album));
}
}
});
albumInfo.artists
.addMouseListener((ClickListener) e -> {
if (e.getClickCount() == 2)
{
Container inter = SongList.this;
do
{
inter = inter.getParent();
}
while (!(inter instanceof Interface) && inter
.getParent() != null);
if (inter instanceof Interface)
{
((Interface) inter)
.updateCollections(CollectionType.album, Arrays
.stream(albumInfo.album.artists)
.flatMap(s -> PlayerEnvironment.getAlbums()
.getAlbumsFromArtist(s)
.stream())
.collect(Collectors
.toList()));
}
}
});
albumInfo.genres.addMouseListener((ClickListener) e -> {
if (e.getClickCount() == 2)
{
Container inter = SongList.this;
do
{
inter = inter.getParent();
}
while (!(inter instanceof Interface) && inter
.getParent() != null);
if (inter instanceof Interface)
{
((Interface) inter)
.updateCollections(CollectionType.album, Arrays
.stream(albumInfo.album.genres)
.flatMap(s -> PlayerEnvironment.getAlbums()
.getAlbumsFromGenre(s)
.stream())
.collect(Collectors
.toList()));
}
}
});
albumInfo.year.addMouseListener((ClickListener) e -> {
if (e.getClickCount() == 2)
{
Container inter = SongList.this;
do
{
inter = inter.getParent();
}
while (!(inter instanceof Interface) && inter
.getParent() != null);
if (inter instanceof Interface)
{
((Interface) inter)
.updateCollections(CollectionType.album, PlayerEnvironment.getAlbums()
.getAlbumsFromYear(albumInfo.album.year));
}
}
});
albumInfos
.put(albumInfo, (GridBagConstraints) c.clone());
artMap.put(albumInfo, album);
if (firstSong == null) JButton firstSong = null;
JLabel songNum;
JButton songTitle;
AtomicInteger numSongs = new AtomicInteger();
for (Song song : songCollection)
{ {
firstSong = songTitle; songNum = new JLabel(String.valueOf(song.trackNum));
JButton finalFirstSong = firstSong; songNum.setFocusable(false);
albumInfo.setAction(new AbstractAction() c.gridx = 1;
c.gridy = i.get();
c.gridheight = 1;
c.weightx = 0;
c.anchor = GridBagConstraints.NORTHEAST;
c.insets = new Insets(0, 0, 0, 0);
albumInfos.put(songNum, (GridBagConstraints) c
.clone());
labelMap.put(songNum, song);
songTitle = new JButton(song.title);
if (song.title == null || song.title.isEmpty())
{
if (song instanceof LocalSong)
{
songTitle.setText(((LocalSong) song).file
.getName());
}
}
songTitle.setHorizontalAlignment(JButton.LEFT);
songTitle.setFocusPainted(true);
songTitle.setMargin(new Insets(0, 0, 0, 0));
songTitle.setContentAreaFilled(false);
songTitle.setBorderPainted(false);
songTitle.setOpaque(false);
songTitle.addActionListener(new AbstractAction()
{ {
@Override @Override
public void actionPerformed(ActionEvent e) public void actionPerformed(ActionEvent e)
{ {
finalFirstSong.requestFocusInWindow(); Queue.getInstance().add(song);
Queue.getInstance()
.skipToSong(Queue.getInstance()
.size() - 1);
} }
}); });
List<Song> finalSongCollection1 = songCollection; c.gridx = 2;
albumInfo.addKeyListener(new KeyAdapter() c.gridy = i.get();
c.weightx = 1.0;
c.anchor = GridBagConstraints.NORTHWEST;
c.insets = new Insets(0, 10, 0, 0);
albumInfos.put(songTitle, (GridBagConstraints) c
.clone());
labelMap.put(songTitle, song);
// TODO - Add song length or something
if (firstSong == null)
{ {
@Override firstSong = songTitle;
public void keyTyped(KeyEvent e) JButton finalFirstSong = firstSong;
albumInfo.setAction(new AbstractAction()
{ {
if (e.getKeyCode() == KeyEvent.VK_ENTER) @Override
public void actionPerformed(ActionEvent e)
{ {
Queue.getInstance() finalFirstSong.requestFocusInWindow();
.addAll(finalSongCollection1);
} }
} });
}); albumInfo.addKeyListener(new KeyAdapter()
{
@Override
public void keyTyped(KeyEvent e)
{
if (e.getKeyCode() == KeyEvent.VK_ENTER)
{
Queue.getInstance()
.addAll(songCollection);
}
}
});
}
i.getAndIncrement();
this.setProgress((int) (numSongs
.incrementAndGet() / (float) songs
.size() * 100F));
} }
i.getAndIncrement();
this.setProgress((int) (numSongs.incrementAndGet() / (float) songs.size() * 100F));
}
// this.add(new JLabel(new ImageIcon(this.getClass().getResource("/gui/icons/defaultart.png"), "Default")), c); // this.add(new JLabel(new ImageIcon(this.getClass().getResource("/gui/icons/defaultart.png"), "Default")), c);
c.gridx = 0; c.gridx = 0;
c.gridy = i.getAndIncrement(); c.gridy = i.getAndIncrement();
c.gridwidth = 3; c.gridwidth = 3;
c.anchor = GridBagConstraints.NORTH; c.anchor = GridBagConstraints.NORTH;
albumInfos.put(new JSeparator(SwingConstants.HORIZONTAL), albumInfos
(GridBagConstraints) c.clone()); .put(new JSeparator(SwingConstants.HORIZONTAL),
(GridBagConstraints) c.clone());
i.getAndIncrement(); i.getAndIncrement();
});
logger.debug("Song list built {} components",
albumInfos.size());
SwingUtilities.invokeLater(() -> {
albumInfos.forEach((component, c1) -> {
add(component, c1);
}); });
revalidate(); logger.debug("Song list built {} components",
logger.debug("Components added"); albumInfos.size());
}); SwingUtilities.invokeLater(() -> {
return null; albumInfos.forEach((component, c1) -> add(component, c1));
revalidate();
logger.debug("Components added");
});
return null;
}
catch (Exception e)
{
logger.error("Could not list songs", e);
return null;
}
} }
}; };
worker.execute(); worker.execute();

View File

@@ -4,6 +4,8 @@
package edu.regis.universeplayer.player; package edu.regis.universeplayer.player;
import edu.regis.universeplayer.AbstractTask;
import edu.regis.universeplayer.NumberPing;
import edu.regis.universeplayer.PlaybackInfo; import edu.regis.universeplayer.PlaybackInfo;
import edu.regis.universeplayer.PlaybackListener; import edu.regis.universeplayer.PlaybackListener;
import edu.regis.universeplayer.PlaybackStatus; import edu.regis.universeplayer.PlaybackStatus;
@@ -32,6 +34,20 @@ public class BrowserPlayer implements Player<InternetSong>, UpdateListener
private static final Logger logger = LoggerFactory private static final Logger logger = LoggerFactory
.getLogger(BrowserPlayer.class); .getLogger(BrowserPlayer.class);
/**
* The player instance. This should only be used for debugging..
*/
private static BrowserPlayer INSTANCE;
/**
* Obtains the player instance. This should only be used for debugging.
*/
public static BrowserPlayer getInstance()
{
return INSTANCE;
}
private final ForkJoinPool service = new ForkJoinPool();
private final LinkedList<PlaybackListener> listeners = new LinkedList<>(); private final LinkedList<PlaybackListener> listeners = new LinkedList<>();
private boolean error = false; private boolean error = false;
@@ -65,6 +81,7 @@ public class BrowserPlayer implements Player<InternetSong>, UpdateListener
public BrowserPlayer() public BrowserPlayer()
{ {
INSTANCE = this;
Thread browserThread = new Thread(() -> { Thread browserThread = new Thread(() -> {
try try
{ {
@@ -77,7 +94,7 @@ public class BrowserPlayer implements Player<InternetSong>, UpdateListener
this.error = true; this.error = true;
Browser.notifyAllInstance(); Browser.notifyAllInstance();
} }
}); }, "BrowserThread");
browserThread.start(); browserThread.start();
} }
@@ -87,38 +104,119 @@ public class BrowserPlayer implements Player<InternetSong>, UpdateListener
return this.currentSong; return this.currentSong;
} }
@Override /**
public QueryFuture<Void> loadSong(InternetSong song) * Obtains data on a specified song.
*
* @param url - The url to get the data from.
* @return A confirmation of command success.
*/
public ForkJoinTask<InternetSong> getSongData(URL url)
{ {
try return this.service.submit(new AbstractTask<>()
{ {
return new ForwardedFuture(getBrowser() @Override
.sendObject(new CommandLoadSong(song.location))); protected boolean exec()
} {
catch (IOException e) try
{
Future<?> command =
getBrowser()
.sendObject(new QuerySongData(url));
CommandReturn<InternetSong> returnOb =
(CommandReturn<InternetSong>) command
.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
this.complete(returnOb.getReturnValue());
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
}
@Override
public ForkJoinTask<Void> loadSong(InternetSong song)
{
return this.service.submit(new AbstractTask<>()
{ {
logger.error("Could not send message", e); @Override
return null; protected boolean exec()
} {
try
{
Future<?> command =
getBrowser()
.sendObject(new CommandLoadSong(song.location));
CommandReturn<Boolean> returnOb = (CommandReturn<Boolean>) command
.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
} }
/** /**
* Tells the browser process to shut down. * Tells the browser process to shut down.
*/ */
@Override @Override
public QueryFuture<Void> close() public ForkJoinTask<Void> close()
{ {
try return this.service.submit(new AbstractTask<>()
{ {
QueryFuture<Void> future = new ForwardedFuture(getBrowser() @Override
.sendObject(new CommandQuit())); protected boolean exec()
return future; {
} try
catch (IOException e) {
{ Future<?> command =
logger.error("Could not send message", e); getBrowser()
return null; .sendObject(new CommandQuit());
} CommandReturn<Boolean> returnOb = (CommandReturn<Boolean>) command
.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
} }
/** /**
@@ -156,81 +254,201 @@ public class BrowserPlayer implements Player<InternetSong>, UpdateListener
} }
@Override @Override
public QueryFuture<Void> play() public ForkJoinTask<Void> play()
{ {
try return this.service.submit(new AbstractTask<>()
{ {
return new ForwardedFuture(getBrowser() @Override
.sendObject(new CommandSetPlayback(CommandSetPlayback.Playback.PLAY))); protected boolean exec()
} {
catch (IOException e) try
{ {
logger.error("Could not send message", e); Future<?> command =
return null; getBrowser()
} .sendObject(new CommandSetPlayback(CommandSetPlayback.Playback.PLAY));
CommandReturn<Boolean> returnOb = (CommandReturn<Boolean>) command
.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return true;
}
else
{
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
} }
@Override @Override
public QueryFuture<Void> pause() public ForkJoinTask<Void> pause()
{ {
try return this.service.submit(new AbstractTask<>()
{ {
return new ForwardedFuture(getBrowser() @Override
.sendObject(new CommandSetPlayback(CommandSetPlayback.Playback.PAUSE))); protected boolean exec()
} {
catch (IOException e) try
{ {
logger.error("Could not send message", e); Future<?> command =
return null; getBrowser()
} .sendObject(new CommandSetPlayback(CommandSetPlayback.Playback.PAUSE));
CommandReturn<Boolean> returnOb = (CommandReturn<Boolean>) command
.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
} }
@Override @Override
public QueryFuture<Void> togglePlayback() public ForkJoinTask<Void> togglePlayback()
{ {
try return this.service.submit(new AbstractTask<Void>()
{ {
return new ForwardedFuture(getBrowser() @Override
.sendObject(new CommandSetPlayback(CommandSetPlayback.Playback.PAUSE))); protected boolean exec()
} {
catch (IOException e) try
{ {
logger.error("Could not send message", e); Future<?> command = getBrowser()
return null; .sendObject(new QueryStatus());
} CommandReturn<String> returnOb =
(CommandReturn<String>) command.get();
CommandReturn<?> confirmation;
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
switch (PlaybackStatus
.valueOf(returnOb.getReturnValue()))
{
case PLAYING -> command =
getBrowser()
.sendObject(new CommandSetPlayback(CommandSetPlayback.Playback.PAUSE));
case PAUSED, STOPPED, FINISHED -> command = getBrowser()
.sendObject(new CommandSetPlayback(CommandSetPlayback.Playback.PLAY));
}
confirmation =
(CommandReturn<?>) command.get();
if (!confirmation.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb
.getConfirmation().getError());
return false;
}
else
{
return true;
}
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
} }
/** /**
* Stops playback of the current song. * Stops playback of the current song.
*/ */
@Override @Override
public QueryFuture<Void> stopSong() public ForkJoinTask<Void> stopSong()
{ {
try return this.service.submit(new AbstractTask<>()
{ {
return new ForwardedFuture(getBrowser() @Override
.sendObject(new CommandLoadSong((URL) null))); protected boolean exec()
} {
catch (IOException e) try
{ {
logger.error("Could not send message", e); Future<?> command =
return null; getBrowser()
} .sendObject(new CommandLoadSong((URL) null));
CommandReturn<Boolean> returnOb = (CommandReturn<Boolean>) command
.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
} }
@Override @Override
public QueryFuture<Void> seek(float time) public ForkJoinTask<Void> seek(float time)
{ {
try return this.service.submit(new AbstractTask<>()
{ {
return new ForwardedFuture(getBrowser() @Override
.sendObject(new CommandSeek(time))); protected boolean exec()
} {
catch (IOException e) try
{ {
logger.error("Could not send message", e); Future<?> command =
return null; getBrowser()
} .sendObject(new CommandSeek(time));
CommandReturn<Boolean> returnOb = (CommandReturn<Boolean>) command
.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
} }
/** /**
@@ -239,84 +457,49 @@ public class BrowserPlayer implements Player<InternetSong>, UpdateListener
* @return A future for the request. * @return A future for the request.
*/ */
@Override @Override
public QueryFuture<PlaybackStatus> getStatus() public ForkJoinTask<PlaybackStatus> getStatus()
{ {
try return this.service.submit(new AbstractTask<>()
{ {
Future future = getBrowser().sendObject(new QueryStatus()); @Override
return new QueryFuture<>() protected boolean exec()
{ {
try
private CommandReturn<String> getVal() throws ExecutionException, InterruptedException
{ {
return ((CommandReturn<String>) future.get()); Future<?> command = getBrowser()
.sendObject(new QueryStatus());
CommandReturn<String> returnOb =
(CommandReturn<String>) command.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
this.complete(PlaybackStatus
.valueOf(returnOb.getReturnValue()));
return true;
}
} }
catch (IOException | InterruptedException | ExecutionException e)
private CommandReturn<String> getVal(long timeout, TimeUnit unit) throws ExecutionException, InterruptedException, TimeoutException
{ {
return ((CommandReturn<String>) future.get(timeout, unit)); this.completeExceptionally(e);
return false;
} }
}
@Override });
public CommandConfirmation getConfirmation() throws CancellationException, ExecutionException, InterruptedException
{
return this.getVal().getConfirmation();
}
@Override
public CommandConfirmation getConfirmation(long timeout, TimeUnit unit) throws ExecutionException, InterruptedException, TimeoutException
{
return this.getVal(timeout, unit).getConfirmation();
}
@Override
public boolean cancel(boolean mayInterruptIfRunning)
{
return future.cancel(mayInterruptIfRunning);
}
@Override
public boolean isCancelled()
{
return future.isCancelled();
}
@Override
public boolean isDone()
{
return future.isDone();
}
@Override
public PlaybackStatus get() throws InterruptedException, ExecutionException
{
String value = getVal().getReturnValue();
return PlaybackStatus.valueOf(value);
}
@Override
public PlaybackStatus get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException
{
String value = getVal(timeout, unit).getReturnValue();
return PlaybackStatus.valueOf(value);
}
};
}
catch (IOException e)
{
logger.error("Could not send message", e);
return null;
}
} }
@Override @Override
public QueryFuture<Float> getCurrentTime() public ForkJoinTask<Float> getCurrentTime()
{ {
return null; return null;
} }
@Override @Override
public QueryFuture<Float> getLength() public ForkJoinTask<Float> getLength()
{ {
return null; return null;
} }
@@ -326,20 +509,92 @@ public class BrowserPlayer implements Player<InternetSong>, UpdateListener
* *
* @param forward - Whether the error should be thrown from the foreground * @param forward - Whether the error should be thrown from the foreground
* script or the background script. * script or the background script.
* @return * @return The return value containing error details.
*/ */
public QueryFuture<Void> throwError(boolean forward) public ForkJoinTask<Void> throwError(boolean forward)
{ {
try return this.service.submit(new AbstractTask<>()
{ {
return new ForwardedFuture(getBrowser() @Override
.sendObject(new CommandError(forward))); protected boolean exec()
} {
catch (IOException e) try
{
Future<?> command =
getBrowser()
.sendObject(new CommandError(forward));
CommandReturn<Boolean> returnOb = (CommandReturn<Boolean>) command
.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
}
/**
* Pings the browser background.
*
* @param i - The number to send.
* @return A future hopefully returning the same value.
*/
public ForkJoinTask<Double> ping(double i)
{
return this.ping(null, i);
}
/**
* Pings the browser foreground.
*
* @param url - The url to open.
* @param i - The number to send.
* @return A future hopefully returning the same value.
*/
public ForkJoinTask<Double> ping(URL url, double i)
{
return this.service.submit(new AbstractTask<>()
{ {
logger.error("Could not send message", e); @Override
return null; protected boolean exec()
} {
try
{
Future<?> command = getBrowser()
.sendObject(new NumberPing(url, i));
CommandReturn<Number> returnOb =
(CommandReturn<Number>) command.get();
if (!returnOb.getConfirmation().wasSuccessful())
{
this.completeExceptionally(returnOb.getConfirmation()
.getError());
return false;
}
else
{
this.complete(returnOb.getReturnValue().doubleValue());
return true;
}
}
catch (IOException | InterruptedException | ExecutionException e)
{
this.completeExceptionally(e);
return false;
}
}
});
} }
@Override @Override
@@ -349,69 +604,8 @@ public class BrowserPlayer implements Player<InternetSong>, UpdateListener
if (object instanceof PlaybackInfo) if (object instanceof PlaybackInfo)
{ {
status = new PlaybackEvent(this, (PlaybackInfo) object); status = new PlaybackEvent(this, (PlaybackInfo) object);
logger.info("Internet playback {}", status.getInfo());
this.listeners.forEach(l -> l.onPlaybackChanged(status)); this.listeners.forEach(l -> l.onPlaybackChanged(status));
} }
} }
private class ForwardedFuture<T> implements QueryFuture<T>
{
private final Future<T> future;
ForwardedFuture(Future<T> future)
{
this.future = future;
}
private CommandReturn<T> getVal() throws ExecutionException, InterruptedException
{
return ((CommandReturn<T>) this.future.get());
}
private CommandReturn<T> getVal(long timeout, TimeUnit unit) throws ExecutionException, InterruptedException, TimeoutException
{
return ((CommandReturn<T>) this.future.get(timeout, unit));
}
@Override
public CommandConfirmation getConfirmation() throws CancellationException, ExecutionException, InterruptedException
{
return this.getVal().getConfirmation();
}
@Override
public CommandConfirmation getConfirmation(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException
{
return this.getVal(timeout, unit).getConfirmation();
}
@Override
public boolean cancel(boolean mayInterruptIfRunning)
{
return this.future.cancel(mayInterruptIfRunning);
}
@Override
public boolean isCancelled()
{
return this.future.isCancelled();
}
@Override
public boolean isDone()
{
return this.future.isDone();
}
@Override
public T get() throws InterruptedException, ExecutionException
{
return this.getVal().getReturnValue();
}
@Override
public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException
{
return this.getVal(timeout, unit).getReturnValue();
}
}
} }

View File

@@ -5,6 +5,7 @@ package edu.regis.universeplayer.player;
import com.intervigil.wave.WaveReader; import com.intervigil.wave.WaveReader;
import edu.regis.universeplayer.AbstractTask;
import edu.regis.universeplayer.PlaybackInfo; import edu.regis.universeplayer.PlaybackInfo;
import edu.regis.universeplayer.PlaybackListener; import edu.regis.universeplayer.PlaybackListener;
import edu.regis.universeplayer.PlaybackStatus; import edu.regis.universeplayer.PlaybackStatus;
@@ -48,6 +49,7 @@ public class LocalPlayer implements Player<LocalSong>, MediaPlayerEventListener
private final MediaPlayerFactory playerFactory; private final MediaPlayerFactory playerFactory;
private final AudioPlayerComponent player; private final AudioPlayerComponent player;
private final ForkJoinPool service = new ForkJoinPool();
private final LinkedList<PlaybackListener> listeners = new LinkedList<>(); private final LinkedList<PlaybackListener> listeners = new LinkedList<>();
private int currentId; private int currentId;
@@ -55,8 +57,6 @@ public class LocalPlayer implements Player<LocalSong>, MediaPlayerEventListener
private LocalSong currentSong; private LocalSong currentSong;
private AudioFile currentFile; private AudioFile currentFile;
private final ExecutorService service = Executors.newSingleThreadExecutor();
public LocalPlayer() public LocalPlayer()
{ {
this.playerFactory = new MediaPlayerFactory(); this.playerFactory = new MediaPlayerFactory();
@@ -118,112 +118,196 @@ public class LocalPlayer implements Player<LocalSong>, MediaPlayerEventListener
} }
@Override @Override
public QueryFuture<Void> loadSong(LocalSong song) public ForkJoinTask<Void> loadSong(LocalSong song)
{ {
if (this.currentSong != null) return this.service.submit(new AbstractTask<>()
{ {
this.stopSong(); @Override
} protected boolean exec()
this.currentSong = song; {
if (!this.player.mediaPlayer().media() if (currentSong != null)
.play(song.file.getAbsolutePath())) {
{ stopSong();
return new NullFuture<>(new RuntimeException("Could not play " + }
"song " + song)); currentSong = song;
} if (!player.mediaPlayer().media()
else .play(song.file.getAbsolutePath()))
{ {
return new NullFuture<>(); this.completeExceptionally(new RuntimeException("Could not play " +
} "song " + song));
return false;
}
else
{
return true;
}
}
});
} }
@Override @Override
public QueryFuture<Void> play() public ForkJoinTask<Void> play()
{
this.player.mediaPlayer().submit(() -> this.player.mediaPlayer()
.controls()
.play());
return this.service.submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
return true;
}
});
}
@Override
public ForkJoinTask<Void> pause()
{ {
this.player.mediaPlayer() this.player.mediaPlayer()
.submit((WrappedRunnable) () -> this.player.mediaPlayer() .submit(() -> this.player.mediaPlayer()
.controls() .controls()
.play()); .pause());
return new NullFuture<>(); return this.service.submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
return true;
}
});
} }
@Override @Override
public QueryFuture<Void> pause() public ForkJoinTask<Void> togglePlayback()
{
return this.service.submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
ForkJoinTask<Void> command;
if (player.mediaPlayer().status().isPlaying())
{
command = pause();
}
else
{
command = play();
}
command.join();
if (command.isCompletedAbnormally())
{
this.completeExceptionally(command.getException());
return false;
}
else
{
return true;
}
}
});
}
@Override
public ForkJoinTask<Void> stopSong()
{ {
this.player.mediaPlayer() this.player.mediaPlayer()
.submit((WrappedRunnable) () -> this.player.mediaPlayer() .submit(() -> this.player.mediaPlayer()
.controls() .controls()
.pause()); .stop());
return new NullFuture<>(); return this.service.submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
return true;
}
});
} }
@Override @Override
public QueryFuture<Void> togglePlayback() public ForkJoinTask<Void> seek(float time)
{
if (this.player.mediaPlayer().status().isPlaying())
{
return this.pause();
}
else
{
return this.play();
}
}
@Override
public QueryFuture<Void> stopSong()
{ {
this.player.mediaPlayer() this.player.mediaPlayer()
.submit((WrappedRunnable) () -> this.player.mediaPlayer() .submit(() -> this.player.mediaPlayer()
.controls() .controls()
.stop()); .setTime((long) (time * 1000)));
return new NullFuture<>(); return this.service.submit(new AbstractTask<>()
}
@Override
public QueryFuture<Void> seek(float time)
{
this.player.mediaPlayer()
.submit((WrappedRunnable) () -> this.player.mediaPlayer()
.controls()
.setTime((long) (time * 1000)));
return new NullFuture<>();
}
@Override
public QueryFuture<PlaybackStatus> getStatus()
{
PlaybackStatus returnStatus;
StatusApi status = this.player.mediaPlayer().status();
switch (status.state())
{ {
case NOTHING_SPECIAL -> returnStatus = PlaybackStatus.EMPTY; @Override
case PLAYING -> returnStatus = PlaybackStatus.PLAYING; protected boolean exec()
case PAUSED -> returnStatus = PlaybackStatus.PAUSED; {
default -> returnStatus = PlaybackStatus.STOPPED; return true;
} }
return new NullFuture<>(returnStatus); });
} }
@Override @Override
public QueryFuture<Float> getCurrentTime() public ForkJoinTask<PlaybackStatus> getStatus()
{ {
return new NullFuture<>(this.player.mediaPlayer().status() return this.service.submit(new AbstractTask<>()
.time() / 1000F); {
@Override
protected boolean exec()
{
PlaybackStatus returnStatus;
StatusApi status = player.mediaPlayer().status();
switch (status.state())
{
case NOTHING_SPECIAL -> returnStatus = PlaybackStatus.EMPTY;
case PLAYING -> returnStatus = PlaybackStatus.PLAYING;
case PAUSED -> returnStatus = PlaybackStatus.PAUSED;
default -> returnStatus = PlaybackStatus.STOPPED;
}
this.complete(returnStatus);
return true;
}
});
} }
@Override @Override
public QueryFuture<Float> getLength() public ForkJoinTask<Float> getCurrentTime()
{ {
return new NullFuture<>(this.player.mediaPlayer().status() return this.service.submit(new AbstractTask<>()
.length() / 1000F); {
@Override
protected boolean exec()
{
complete(player.mediaPlayer().status()
.time() / 1000F);
return true;
}
});
} }
@Override @Override
public QueryFuture<Void> close() public ForkJoinTask<Float> getLength()
{ {
this.player.release(); return this.service.submit(new AbstractTask<>()
return new NullFuture<>(); {
@Override
protected boolean exec()
{
complete(player.mediaPlayer().status()
.length() / 1000F);
return true;
}
});
}
@Override
public ForkJoinTask<Void> close()
{
return this.service.submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
player.release();
return true;
}
});
} }
@Override @Override

View File

@@ -10,6 +10,7 @@ import edu.regis.universeplayer.browserCommands.QueryFuture;
import edu.regis.universeplayer.data.Song; import edu.regis.universeplayer.data.Song;
import java.util.HashMap; import java.util.HashMap;
import java.util.concurrent.ForkJoinTask;
/** /**
* This interface serves as the connection to a music player of some sort, whether it be * This interface serves as the connection to a music player of some sort, whether it be
@@ -34,33 +35,33 @@ public interface Player<T extends Song>
* @param song - The song to load. * @param song - The song to load.
* @return A confirmation of whether the command was successful or not. * @return A confirmation of whether the command was successful or not.
*/ */
QueryFuture<Void> loadSong(T song); ForkJoinTask<Void> loadSong(T song);
/** /**
* Enables playback of the current song, if one is active. * Enables playback of the current song, if one is active.
* *
* @return A confirmation of whether the command was successful or not. * @return A confirmation of whether the command was successful or not.
*/ */
QueryFuture<Void> play(); ForkJoinTask<Void> play();
/** /**
* Pauses playback of the current song. * Pauses playback of the current song.
* *
* @return A confirmation of whether the command was successful or not. * @return A confirmation of whether the command was successful or not.
*/ */
QueryFuture<Void> pause(); ForkJoinTask<Void> pause();
/** /**
* Toggles between playing and pausing the current song. * Toggles between playing and pausing the current song.
* *
* @return A confirmation of whether the command was successful or not. * @return A confirmation of whether the command was successful or not.
*/ */
QueryFuture<Void> togglePlayback(); ForkJoinTask<Void> togglePlayback();
/** /**
* Stops playback of the current song. * Stops playback of the current song.
*/ */
QueryFuture<Void> stopSong(); ForkJoinTask<Void> stopSong();
/** /**
* Sets the current song time to the specified position. * Sets the current song time to the specified position.
@@ -68,34 +69,34 @@ public interface Player<T extends Song>
* @param time - The specified time in the song, in seconds. * @param time - The specified time in the song, in seconds.
* @return A confirmation of whether the command was successful or not. * @return A confirmation of whether the command was successful or not.
*/ */
QueryFuture<Void> seek(float time); ForkJoinTask<Void> seek(float time);
/** /**
* Obtains the player's current playback status. * Obtains the player's current playback status.
* @return A future for the request. * @return A future for the request.
*/ */
QueryFuture<PlaybackStatus> getStatus(); ForkJoinTask<PlaybackStatus> getStatus();
/** /**
* Obtains the time we are currently at in the current song. * Obtains the time we are currently at in the current song.
* *
* @return - The current song position in seconds, or -1 if no song is playing. * @return - The current song position in seconds, or -1 if no song is playing.
*/ */
QueryFuture<Float> getCurrentTime(); ForkJoinTask<Float> getCurrentTime();
/** /**
* Gets the length of the current song. * Gets the length of the current song.
* *
* @return The song length, in seconds. * @return The song length, in seconds.
*/ */
QueryFuture<Float> getLength(); ForkJoinTask<Float> getLength();
/** /**
* Closes the player. * Closes the player.
* *
* @return A confirmation of whether the player was successfully closed. * @return A confirmation of whether the player was successfully closed.
*/ */
QueryFuture<Void> close(); ForkJoinTask<Void> close();
/** /**
* Adds a listener for playback status updates. * Adds a listener for playback status updates.

View File

@@ -1,17 +1,6 @@
package edu.regis.universeplayer.player; package edu.regis.universeplayer.player;
import org.slf4j.Logger; import edu.regis.universeplayer.AbstractTask;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.swing.JOptionPane;
import edu.regis.universeplayer.PlaybackListener; import edu.regis.universeplayer.PlaybackListener;
import edu.regis.universeplayer.PlaybackStatus; import edu.regis.universeplayer.PlaybackStatus;
import edu.regis.universeplayer.browserCommands.CommandConfirmation; import edu.regis.universeplayer.browserCommands.CommandConfirmation;
@@ -19,9 +8,16 @@ import edu.regis.universeplayer.browserCommands.QueryFuture;
import edu.regis.universeplayer.data.InternetSong; import edu.regis.universeplayer.data.InternetSong;
import edu.regis.universeplayer.data.LocalSong; import edu.regis.universeplayer.data.LocalSong;
import edu.regis.universeplayer.data.PlaybackEvent; import edu.regis.universeplayer.data.PlaybackEvent;
import edu.regis.universeplayer.data.Queue;
import edu.regis.universeplayer.data.Song; import edu.regis.universeplayer.data.Song;
import edu.regis.universeplayer.gui.Interface; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.TimeUnit;
/** /**
* The PlayerManager serves as the central access point for playing songs of any * The PlayerManager serves as the central access point for playing songs of any
@@ -150,10 +146,10 @@ public class PlayerManager implements PlaybackListener
*/ */
private <T> QueryFuture<T> getNullPlayer(String message, T returnValue) private <T> QueryFuture<T> getNullPlayer(String message, T returnValue)
{ {
return new QueryFuture<T>() return new QueryFuture<>()
{ {
@Override @Override
public CommandConfirmation getConfirmation() throws CancellationException, ExecutionException, InterruptedException public CommandConfirmation getConfirmation() throws CancellationException
{ {
if (returnValue instanceof Throwable) if (returnValue instanceof Throwable)
{ {
@@ -166,7 +162,7 @@ public class PlayerManager implements PlaybackListener
} }
@Override @Override
public CommandConfirmation getConfirmation(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException public CommandConfirmation getConfirmation(long timeout, TimeUnit unit)
{ {
return this.getConfirmation(); return this.getConfirmation();
} }
@@ -190,13 +186,13 @@ public class PlayerManager implements PlaybackListener
} }
@Override @Override
public T get() throws InterruptedException, ExecutionException public T get()
{ {
return returnValue; return returnValue;
} }
@Override @Override
public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException public T get(long timeout, TimeUnit unit)
{ {
return this.get(); return this.get();
} }
@@ -211,16 +207,16 @@ public class PlayerManager implements PlaybackListener
*/ */
private <T> QueryFuture<T> getNullPlayer(Throwable message, T returnValue) private <T> QueryFuture<T> getNullPlayer(Throwable message, T returnValue)
{ {
return new QueryFuture<T>() return new QueryFuture<>()
{ {
@Override @Override
public CommandConfirmation getConfirmation() throws CancellationException, ExecutionException, InterruptedException public CommandConfirmation getConfirmation() throws CancellationException
{ {
return new CommandConfirmation(message); return new CommandConfirmation(message);
} }
@Override @Override
public CommandConfirmation getConfirmation(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException public CommandConfirmation getConfirmation(long timeout, TimeUnit unit)
{ {
return this.getConfirmation(); return this.getConfirmation();
} }
@@ -244,89 +240,161 @@ public class PlayerManager implements PlaybackListener
} }
@Override @Override
public T get() throws InterruptedException, ExecutionException public T get()
{ {
return returnValue; return returnValue;
} }
@Override @Override
public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException public T get(long timeout, TimeUnit unit)
{ {
return this.get(); return this.get();
} }
}; };
} }
public QueryFuture<Void> playSong(Song song) /**
* Loads up a requested song and immedietally begins playback.
*
* @param song - The song to load.
* @return The task that handles the request.
*/
public ForkJoinTask<Void> playSong(Song song)
{ {
if (this.currentSong != null) return ForkJoinPool.commonPool().submit(new AbstractTask<Void>()
{ {
this.currentPlayer.stopSong(); @Override
} protected boolean exec()
this.currentSong = null; {
this.currentPlayer = this.getCompatiblePlayer(song); if (currentSong != null)
if (this.currentPlayer == null) {
{ currentPlayer.stopSong().join();
throw new IllegalArgumentException("Unknown song type " + song }
.getClass()); currentSong = null;
} currentPlayer = getCompatiblePlayer(song);
this.currentSong = song; if (currentPlayer == null)
return this.currentPlayer.loadSong(song); {
throw new IllegalArgumentException("Unknown song type " + song
.getClass());
}
currentSong = song;
currentPlayer.loadSong(song).join();
return true;
}
});
} }
public QueryFuture<PlaybackStatus> getStatus() /**
* Obtains the playback status of the current player.
*
* @return A task containing the status, or EMPTY if no player is being
* used.
*/
public ForkJoinTask<PlaybackStatus> getStatus()
{ {
if (this.currentPlayer != null) if (this.currentPlayer != null)
{ {
return this.currentPlayer.getStatus(); return this.currentPlayer.getStatus();
} }
return this.getNullPlayer("Command successful", PlaybackStatus.EMPTY); /*
* If we have no player, then we return EMPTY.
*/
return ForkJoinPool.commonPool().submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
complete(PlaybackStatus.EMPTY);
return true;
}
});
} }
public QueryFuture<Void> seek(float time) /**
* Seeks to the specified time stamp.
*
* @param time - The time to seek to, in seconds.
* @return The task running this task.
*/
public ForkJoinTask<Void> seek(float time)
{ {
if (this.currentPlayer != null) if (this.currentPlayer != null)
{ {
return this.currentPlayer.seek(time); return this.currentPlayer.seek(time);
} }
return null; return ForkJoinPool.commonPool().submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
return true;
}
});
} }
public QueryFuture<Void> play() public ForkJoinTask<Void> play()
{ {
if (this.currentPlayer != null && this.currentSong != null) if (this.currentPlayer != null && this.currentSong != null)
{ {
return this.currentPlayer.play(); return this.currentPlayer.play();
} }
return null; return ForkJoinPool.commonPool().submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
return true;
}
});
} }
public QueryFuture<Void> pause() public ForkJoinTask<Void> pause()
{ {
if (this.currentPlayer != null && this.currentSong != null) if (this.currentPlayer != null && this.currentSong != null)
{ {
return this.currentPlayer.pause(); return this.currentPlayer.pause();
} }
return null; return ForkJoinPool.commonPool().submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
return true;
}
});
} }
public QueryFuture<Void> toggle() public ForkJoinTask<Void> toggle()
{ {
if (this.currentPlayer != null && this.currentSong != null) if (this.currentPlayer != null && this.currentSong != null)
{ {
return this.currentPlayer.togglePlayback(); return this.currentPlayer.togglePlayback();
} }
return null; return ForkJoinPool.commonPool().submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
return true;
}
});
} }
public QueryFuture<Void> stopSong() public ForkJoinTask<Void> stopSong()
{ {
if (this.currentPlayer != null && this.currentSong != null) if (this.currentPlayer != null && this.currentSong != null)
{ {
this.currentSong = null; this.currentSong = null;
return this.currentPlayer.stopSong(); return this.currentPlayer.stopSong();
} }
return null; return ForkJoinPool.commonPool().submit(new AbstractTask<>()
{
@Override
protected boolean exec()
{
return true;
}
});
} }
public void addPlaybackListener(PlaybackListener listener) public void addPlaybackListener(PlaybackListener listener)
@@ -366,18 +434,27 @@ public class PlayerManager implements PlaybackListener
/** /**
* Sends an error to the browser player. * Sends an error to the browser player.
*
* @param forward - Whether the error should be thrown in a foreground * @param forward - Whether the error should be thrown in a foreground
* script or a background script. * script or a background script.
* @return The command future. * @return The command future.
*/ */
public QueryFuture<Void> throwError(boolean forward) public ForkJoinTask<Void> throwError(boolean forward)
{ {
BrowserPlayer player = BrowserPlayer player =
(BrowserPlayer) this.players.get(InternetSong.class); (BrowserPlayer) this.players.get(InternetSong.class);
if (player == null) if (player == null)
{ {
return this.getNullPlayer(new NullPointerException( return new AbstractTask<>()
"No browser found"), null); {
@Override
protected boolean exec()
{
completeExceptionally(new NullPointerException("Couldn't " +
"find internet player"));
return false;
}
};
} }
return player.throwError(forward); return player.throwError(forward);
} }

View File

@@ -21,6 +21,8 @@ albumInfo.artists=Artists
albumInfo.genres=Genres albumInfo.genres=Genres
albumInfo.year=Year albumInfo.year=Year
update.database=Querying Database: %s
update.local=Querying File System: %s
interface.queue.title=Queue interface.queue.title=Queue

View File

@@ -0,0 +1,88 @@
import org.apache.commons.cli.CommandLine;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.HashMap;
import edu.regis.universeplayer.PlayerEnvironment;
import edu.regis.universeplayer.browser.Browser;
import edu.regis.universeplayer.browserCommands.QuerySongData;
import edu.regis.universeplayer.data.Album;
import edu.regis.universeplayer.data.InternetSong;
import edu.regis.universeplayer.player.BrowserPlayer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.junit.Assert.*;
public class BrowserTest
{
private static final Logger logger = LoggerFactory.getLogger(BrowserTest.class);
@BeforeClass
public static void setupBrowser()
{
HashMap<String, Object> props = new HashMap<>();
props.put("headless", true);
PlayerEnvironment.init(new CommandLine.Builder().build());
}
@Test
public void testPing()
{
logger.info("Pinging background");
logger.info("Background test 1");
assertEquals(20.0,
BrowserPlayer.getInstance().ping(20).join(), 0.01);
logger.info("Background test 2");
assertEquals(43.1,
BrowserPlayer.getInstance().ping(43.1).join(), 0.01);
}
@Test
public void testPingForeground() throws MalformedURLException
{
logger.info("Pinging foreground");
logger.info("Forground test 1");
assertEquals(20.0,
BrowserPlayer.getInstance()
.ping(new URL("https://www.youtube.com/watch?v=FtutLA63Cp8"), 20)
.join(), 0.01);
logger.info("Forground test 2");
assertEquals(39.5,
BrowserPlayer.getInstance()
.ping(new URL("https://www.youtube" +
".com/watch?v=FtutLA63Cp8"), 39.5)
.join(), 0.01);
logger.info("Foreground test 3");
assertEquals(5.1,
BrowserPlayer.getInstance()
.ping(new URL("https://www.youtube.com/watch?v=grMqiZKmUeE"), 5.1)
.join(), 0.01);
}
@Test
public void testDataQuery() throws IOException
{
logger.info("Retrieving song data");
InternetSong song = new InternetSong();
URL url = new URL("https://www.youtube.com/watch?v=cvX4B7GjU6s");
song.location = url;
song.title = "First Wave";
song.artists = new String[]{"Trocadero"};
song.duration = 224541;
song.album = new Album();
song.album.name = "Ghosts That Linger";
song.album.year = 2009;
song.album.artists = new String[]{"Rooster Teeth Records / Trocadero"};
assertEquals(song,
BrowserPlayer.getInstance()
.getSongData(url).join());
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
~ Copyright (c) 2021 William Hubbard. All Rights Reserved.
-->
<Configuration packages="edu.regis.universeplayer" status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%c:%L %-5level - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="debug">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>

Binary file not shown.

Binary file not shown.

Binary file not shown.