diff --git a/README.md b/README.md index 2c813d0..a407e4f 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,51 @@ ## mStream mStream is an music streaming server written in NodeJS. It's focus is on ease of installation and FLAC streaming. mStream will work right out of the box without any configuration. -### Live Demo +#### Demo Check it out: http://darncoyotes.mstream.io/ - -### Main Features +#### Main Features * Supports FLAC streaming -* Built in SQLite DB. No need to setup MySQL +* Built in DB using SQLite. No need to run a separate DB * Works on Mac, Linux and Windows * [Integrates easily with Beets DB](https://github.com/beetbox/beets) -* User system with one admin and one guest account +* Allows multiple users ## Installation -### Windows Executable - -There is work being done to port mStream to a Windows Executable. Check out the prototype here: -https://drive.google.com/file/d/0B1oiqEsIbjFidk8tVjR0TmZIb0k/view?usp=sharing - -### Default - +#### Dependencies mStream has the following dependencies: * NodeJS and NPM * Python 2 * GCC and G++ +* node-gyp -Once have all the dependencies you can install and setup mStream by doing the following - +#### Install on Ubuntu +Install NodeJS ```shell -npm install -g node-gyp -npm install -g mstream +curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash - +sudo-apt-get update +sudo apt-get install -y nodejs +``` + +Install GCC and node-gyp +```shell +sudo apt-get install -y build-essential +sudo npm install -g node-gyp +``` + +Install mStream +```shell +sudo npm install -g mstream cd /path/to/your/music - mstream ``` Make sure it's working by checking out http://localhost:3000/ - -### Install on Ubuntu -Copy and paste the following commands: - -```shell -curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash - -sudo apt-get install -y nodejs - -sudo apt-get install -y build-essential - -sudo npm install -g node-gyp -``` - - -### Using Docker +#### Using Docker Download the Dockerfile, or clone the repository, then run the following commands: @@ -72,57 +63,52 @@ default installation. docker run --rm -v /path/to/my/music:/music local/mstream -l -u username -x password ``` -## Options +## Usage +mStream can be configured by using a JSON config file or by using flags in the command line. JSON config files are more flexible but more difficult to use. + +This readme will not cover JSON config usage. See the examples folder to learn more. + +#### Set Port ```shell --p, --port -> set port number --l, --login -> enable user login --u, --user -> add user --x, --password -> set Password --G, --guest -> set guest username --X, --guestpassword -> set guest password --d, --database -> set the database file --t, --tunnel -> tunnel --g, --gateway -> set gateway for tunnelling --i, --userinterface -> use an alternative UI. Currently only the value 'jplayer' works +mstream -p 5050 ``` - - ## User System - -The current user system is a simple as it comes. There are two users you can have, main and guest. Guest users do not have any access to API functions that write to the file system. Currently guest users cannot access the save-playlist, recursive-scan, or delete-playlist functions +mStream can have a single user and guest when being setup using the command line. ```shell -mstream -l -u [username] -x [password] +# Set User +mstream -u [username] -x [password] +# Set user and guest +mstream -u [username] -x [password] -G [guest name] -X [guest password] ``` -The user system is simple for a few reasons. First, I wanted to have a user system that doesn't need a database to work. Secondly, mStream is a personal server and most users don't need anything more complex than this. +Multiple users can be set using JSON config files - -## Database - -mStream currently uses a SQLite database for a music library. You have the option of using a beets DB or having a mStream create it's own DB. +## Database Options +mStream uses sqlite by default. You can either use mStream's default database [or tap into BeetsDB](https://github.com/beetbox/beets) #### Beets DB -http://beets.io/ -mStream can use your beets database without any configuration. ```shell -mstream -d path/to/beets.db +mstream -D beets -d /path/to/beets.db ``` -Currently using beets is the recommended way to create a music database. +When using Beets, mStream is put into a read only mode. mStream will not be able to write to any tables that are managed by Beets. Playlist functionality is not affected by this since playlists are stored in a separate table. -#### use mStream to build your DB +#### Built In DB -Use the /db/recursive-scan API call to kickoff a full scan of your library. Currently this is the only way to add files to the library. Version 2 of mStream will include new functions to update the library more efficiently +mStream can read metadata and write it's own database. By default mstream will create a database in the folder it's launched in called 'mstreamdb.lite'. You can manually set the databse file with: + +```shell +mstream -d /path/to/mstream.db +``` ## Automatically setup port forwarding -#### Please note that this feature is still experimental mStream can try to automatically open a port to the internet. Use the '-t' command to try to setup port forwarding. Additionally you can use the '-g' command to set the gateway IP manually. If you don't include '-g', the program will use an extension to try to figure it out @@ -137,6 +123,12 @@ mstream musicDirectory/ -t -g 192.168.1.1 Please note that not all routers will allow this. Some routers may close this port after a period of time. +You can get around this by having mStream retry this on a regular interval + +``` +mstream -t -r [time in milliseconds] +mstream -t -r 10000 +``` ## Known Issues @@ -145,6 +137,11 @@ Please note that not all routers will allow this. Some routers may close this p ## TODO -- GET request to jump to playlist or directory -- Look into taglib for id3 info -- SSL support +- Album Art +- Reset Password Functions +- Ability to store hashed passwords +- Scripts that help construct configs +- MySQL DB plugin +- LokiJS or PuchDB plugin +- Move to LokiJS/PouchDB as default DB +- SSL Support diff --git a/database-private-beets.js b/database-private-beets.js new file mode 100644 index 0000000..e69de29 diff --git a/exampleJSON/allOptions.json b/exampleJSON/allOptions.json new file mode 100644 index 0000000..518a7d3 --- /dev/null +++ b/exampleJSON/allOptions.json @@ -0,0 +1,28 @@ +{ + "port":3031, + "users":{ + "paul":{ + "password":"glassjar98", + "email":"paul@wall.com", + "musicDir":"/path/to/music", + }, + "paul2":{ + "password":"glassjar98", + "email":"paul@wall2.com", + "musicDir":"/path/to/music2", + }, + "paul2-guest":{ + "password":"glassjar98", + "guestTo":"paul2", + } + }, + "userinterface":"public", + "database_plugin":{ + "type":"sqlite", + "dbPath":"/path/to/db", + }, + "tunnel":{ + "refreshInterval":10000, + "protocol":"upnp" + } +} diff --git a/modules/auto-port-forwarding.js b/modules/auto-port-forwarding.js index 85dc394..1fa882b 100644 --- a/modules/auto-port-forwarding.js +++ b/modules/auto-port-forwarding.js @@ -2,10 +2,15 @@ const natupnp = require('nat-upnp'); const getIP = require('external-ip')(); const natpmp = require('nat-pmp'); +var gateway; -exports.tunnel_uPNP = function(port){ +function set_gateway (gateIP){ + gateway = gateIP; +} + + +function tunnel_uPNP (port){ try{ - console.log('Preparing to tunnel via upnp protocol'); var client = natupnp.createClient(); @@ -29,22 +34,14 @@ exports.tunnel_uPNP = function(port){ } } -exports.tunnel_NAT_PMP = function tunnel_NAT_PMP(port){ +function tunnel_NAT_PMP(port){ try{ - console.log('Preparing to tunnel via nat-pmp protocol'); - - // Use the user supplied Gateway IP or try to find it manually - if(program.gateway){ - var gateway = program.gateway; - }else{ - var netroute = require('netroute'); - var gateway = netroute.getGateway(); + if(!gateway){ + gateway = require('netroute').getGateway(); } - console.log('Attempting to tunnel via gateway: ' + gateway); - var client = new natpmp.Client(gateway); client.portMapping({ public: port, private: port }, function (err, info) { if (err) { @@ -52,9 +49,6 @@ exports.tunnel_NAT_PMP = function tunnel_NAT_PMP(port){ } client.close(); }); - - - } catch (e) { console.log('WARNING: mStream nat-pmp tunnel functionality has failed. Your network may not allow functionality'); @@ -64,7 +58,7 @@ exports.tunnel_NAT_PMP = function tunnel_NAT_PMP(port){ -exports.logUrl = function(port){ +function logUrl (port){ getIP(function (err, ip) { if (err) { // every service in the list has failed @@ -73,3 +67,37 @@ exports.logUrl = function(port){ console.log('Access mStream on the internet: http://' + ip + ':' + port); }); } + + +exports.setup = function(args, port){ + if(args.gateway){ + tunnel.set_gateway(args.gateway); + } + + console.log('Preparing to tunnel via nat-pmp protocol'); + + // TODO: Clean this up, this it so lazy... + if(args.protocol && args.protocol === 'upnp'){ + // Run it on an interval ? + if(args.refreshInterval){ + setInterval( function() { + tunnel_uPNP(port); + }, args.refreshInterval); + }else{ + tunnel_uPNP(port); + } + }else{ + // Run it on an interval ? + if(args.refreshInterval){ + setInterval( function() { + tunnel_NAT_PMP(port); + }, argsrefreshInterval); + }else{ + tunnel_NAT_PMP(port); + } + } + + + + logUrl(port); +} diff --git a/modules/configure-commander.js b/modules/configure-commander.js index 39af183..7436f3d 100644 --- a/modules/configure-commander.js +++ b/modules/configure-commander.js @@ -1,27 +1,78 @@ exports.setup = function(args){ - - // Setup Command Line Interface - var program = require('commander'); + const program = require('commander'); program - .version('1.21.0') + .version('2.0.0') .option('-p, --port ', 'Select Port', /^\d+$/i, 3000) .option('-t, --tunnel', 'Use nat-pmp to configure port fowarding') - .option('-g, --gateip ', 'Manually set gateway IP for the tunnel option') - .option('-l, --login', 'Require users to login') + .option('-g, --gateway ', 'Manually set gateway IP for the tunnel option') + .option('-r, --refresh ', 'Refresh rate', /^\d+$/i) + .option('-o, --protocol ', 'Protocol for tunneling', /^(upnp|natpnp)$/i, 'natpnp') .option('-u, --user ', 'Set Username') .option('-x, --password ', 'Set Password') .option('-e, --email ', 'Set User Email (optional)') .option('-G, --guest ', 'Set Guest Username') .option('-X, --guestpassword ', 'Set Guest Password') - // .option('-k, --key ', 'Add SSL Key') - // .option('-c, --cert ', 'Add SSL Certificate') .option('-d, --database ', 'Specify Database Filepath', 'mstreamdb.lite') - .option('-b, --beetspath ', 'Specify Folder where Beets DB should import music from. This also overides the normal DB functions with functions that integrate with beets DB') - .option('-b, --databaseplugin ', '', /^(default|beets)$/i, 'default') .option('-i, --userinterface ', 'Specify folder name that will be served as the UI', 'public') - .option('-f, --filepath ', 'Set the path of your music directory', process.cwd()) .option('-s, --secret ', 'Set the login secret key') + .option('-D, --databaseplugin ', '', /^(sqlite|beets)$/i, 'sqlite') // TODO: Add support for other DBs when ready .parse(args); - return program; + + let program3 = { + port:program.port, + userinterface:program.userinterface, + } + + if(program.secret){ + program3.secret = program.sectet; + } + if(program.salt){ + program3.salt = program.salt; + } + + // User account + if(program.user && program.password){ + program3.users = {}; + program3.users[program.user] = { + password:program.password, + musicDir:process.cwd() + }; + + if(program.email){ + program3.users[program.user].email = program.email; + } + + // Guest account + if(program.guestname && program.guestpassword){ + program3.users[program.guestname] = { + password:program.guestpassword, + guestTo:program.user + }; + } + } + + // db plugins + program3.database_plugin = { + type:program.databaseplugin, + dbPath:program.database + }; + + // port forwarding + if(program.tunnel){ + program3.tunnel = {}; + + if(program.refresh){ + program3.tunnel.refreshInterval = program.refresh; + } + if(program.gateway){ + program3.tunnel.gateway = program.gateway; + } + if(program.protocol){ + program3.tunnel.protocol = program.protocol; + } + } + + + return program3; } diff --git a/modules/configure-json-file.js b/modules/configure-json-file.js index 1f13f37..db00e8e 100644 --- a/modules/configure-json-file.js +++ b/modules/configure-json-file.js @@ -1,30 +1,74 @@ -const fs = require('graceful-fs'); // File System +const fs = require('fs'); // File System +const fe = require('path'); -exports.setup = function(args){ - // Open File +exports.setup = function(args, rootDir){ + let loadJson; try{ - var loadJson = JSON.parse(fs.readFileSync(args[args.length-1], 'utf8')); - }catch(err){ - console.log('Failed to parse JSON file'); - return false; + if(fe.extname(args[args.length-1]) === '.json' && fs.statSync(args[args.length-1]).isFile()){ + loadJson = JSON.parse(fs.readFileSync(args[args.length-1], 'utf8')); + }else{ + return require('./configure-commander.js').setup(args); + } + }catch(error){ + console.log(error); + return {error:"Failed to parse JSON file"}; } - // Check for validity - if(!loadJson.filepath){ - loadJson.filepath = process.cwd(); + + if(!loadJson.port){ + loadJson.port = 5050; + } + if(!isInt(loadJson.port) || loadJson.port < 0 || loadJson.port > 65535){ + return {error:"BAD PORT, WILL ABORT"}; } - if(!loadJson.databaseplugin){ - loadJson.databaseplugin = 'default'; - }else{ - var re = new RegExp("^(default|beets)$"); - if(!re.test(loadJson.databaseplugin)){ - console.log('Incorrect database plugin. Please update and try again'); - return false; + // TODO: Add comprehensive DB checks + if(!loadJson.database_plugin){ + return {error:"Please Configure DB"}; + } + + + if(loadJson.userinterface){ + if(!fs.statSync( fe.join(rootDir, loadJson.userinterface) ).isDirectory()){ + return {error:"Could not find userinterface"}; + } + } + + + // Normalize for all OS + // Make sure it's a directory + // Loop through and makeure all user Dirs are real + if(loadJson.users){ + for (let username in loadJson.users) { + // TODO: Check usernames for forbidden chars + + // TODO: Make sure all music directories are unique + // TODO: No subsets/super-sets/duplicates + if(!loadJson.users[username].guestTo && !fs.statSync( loadJson.users[username].musicDir ).isDirectory()){ + return {error:loadJson.users[username].username + " music directory could not be found"}; + } + } + } + + // TODO: Preform a full range of checks + if(loadJson.tunnel){ + if(loadJson.tunnel.refreshInterval && !isInt(loadJson.tunnel.refreshInterval)){ + return {error:"Refresh interval must be an integer"}; } } // Export JSON return loadJson; } + + +function isInt(value) { + if (isNaN(value)) { + return false; + } + var x = parseFloat(value); + return (x | 0) === x; +} + +// TODO: This should sum up all errors before returing to user diff --git a/modules/database-beets.js b/modules/database-beets.js deleted file mode 100644 index c0eb1c7..0000000 --- a/modules/database-beets.js +++ /dev/null @@ -1,111 +0,0 @@ -// TODO: This thing has to be tested - -const spawn = require('child_process').spawn; -var scanLock = false; -var yetAnotherArrayOfSongs = []; -var totalFileCount = 0; - -exports.setup = function(mstream, program, rootDir, db){ - const scanThisDir = program.beetspath; // TODO: Check that this is a real directory - - - mstream.get('/db/recursive-scan', function(req,res){ - - if(scanLock === true){ - // Return error - res.status(401).send('{"error":"Scan in progress"}'); - return; - } - - scanLock = true; - var cmd = spawn('beet', [ 'import', '-A', '--group-albums' , scanThisDir]); - - cmd.stdout.on('data', (data) => { - console.log(`stdout: ${data}`); - }); - - cmd.stderr.on('data', (data) => { - console.log(`stderr: ${data}`); - scanLock = false; - - }); - - cmd.on('close', (code) => { - console.log(`child process exited with code ${code}`); - hashFileBeets(); - - // TODO: Remove all empty dirs - }); - }); - - - function hashFileBeets(){ - // var hashCmd = spawn('beet check -a'); - var hashCmd = spawn('beet', [ 'check', '-a']); - - - hashCmd.stdout.on('data', (data) => { - console.log(`stdout: ${data}`); - }); - - hashCmd.stderr.on('data', (data) => { - console.log(`stderr: ${data}`); - scanLock = false; - - }); - - hashCmd.on('close', (code) => { - console.log(`child process exited with code ${code}`); - scanLock = false; - - }); - } - - // TODO: Function that will remove all empty folders - function removeEmptyFolders(){ - var hashCmd = spawn('beet', [ 'check', '-a']); - // 'find ~ -type d -empty -delete' - } - - - - mstream.get('/db/status', function(req, res){ - var returnObject = {}; - - returnObject.locked = scanLock; - - - if(scanLock){ - - // Currently we don't support filecount stats when using beets DB - // Dummy data - returnObject.totalFileCount = 0; - returnObject.filesLeft = 0; - - - res.json(returnObject); - - }else{ - var sql = 'SELECT Count(*) FROM items'; - - db.get(sql, function(err, row){ - if(err){ - console.log(err.message); - - res.status(500).json({ error: err.message }); - return; - } - - - var fileCountDB = row['Count(*)']; // TODO: Is this correct??? - - returnObject.totalFileCount = fileCountDB; - res.json(returnObject); - - }); - } - - }); - - -} diff --git a/modules/database-default.js b/modules/database-default.js deleted file mode 100644 index 7077667..0000000 --- a/modules/database-default.js +++ /dev/null @@ -1,255 +0,0 @@ -const metadata = require('musicmetadata'); // TODO: Look into replacing with taglib -const fs = require('graceful-fs'); // File System -const fe = require('path'); -var dbCopy; - - -var arrayOfSongs = []; -var scanLock = false; -var yetAnotherArrayOfSongs = []; -var totalFileCount = 0; - - -function getFileType(filename){ - return filename.split(".").pop(); -} - -function parseFile(thisSong){ - var readableStream = fs.createReadStream(thisSong); - var parser = metadata(readableStream, function (err, songInfo) { - if(err){ - // TODO: Do something - } - - - // TODO: Hash the file here and add the hash to the DB - - console.log(songInfo); - - - // Close the stream - readableStream.close(); - - - songInfo.filePath = thisSong; - songInfo.format = getFileType(thisSong); - - arrayOfSongs.push(songInfo); - - - // if there are more than 100 entries, or if it's the last song - if(arrayOfSongs.length > 99){ - insertEntries(); - } - - // For the generator - parse.next(); - }); -} - -function *parseAllFiles(){ - - // Loop through local items - while(yetAnotherArrayOfSongs.length > 0) { - var file = yetAnotherArrayOfSongs.pop(); - - var resultX = yield parseFile(file); - - } - - insertEntries(); - scanLock = false; -} - - -var parse; - - - -// Insert -function insertEntries(){ - var sql2 = "insert into items (title,artist,year,album,path,format, track, disk) values "; - var sqlParser = []; - - while(arrayOfSongs.length > 0) { - var song = arrayOfSongs.pop(); - - // console.log(song); - - - var songTitle = null; - var songYear = null; - var songAlbum = null; - var artistString = null; - - if(song.artist && song.artist.length > 0){ - artistString = ''; - for (var i = 0; i < song.artist.length; i++) { - artistString += song.artist[i] + ', '; - } - artistString = artistString.slice(0, -2); - } - if(song.title && song.title.length > 0){ - songTitle = song.title; - } - if(song.year && song.year.length > 0){ - songYear = song.year; - } - if(song.album && song.album.length > 0){ - songAlbum = song.album; - } - - - sql2 += "(?, ?, ?, ?, ?, ?, ?, ?), "; - sqlParser.push(songTitle); - sqlParser.push(artistString); - sqlParser.push(songYear); - sqlParser.push(songAlbum); - sqlParser.push(song.filePath); - sqlParser.push(song.format); - sqlParser.push(song.track.no); - sqlParser.push(song.disk.no); - - } - - sql2 = sql2.slice(0, -2); - sql2 += ";"; - - console.log(sql2); - dbCopy.run(sql2, sqlParser); -} - - -// Count all files -function countFiles (dir, fileTypesArray) { - console.log('efwefwf'); - var files = fs.readdirSync( dir ); - console.log(files); - - - for (var i=0; i < files.length; i++) { - var filePath = fe.join(dir, files[i]); - var stat = fs.statSync(filePath); - console.log(filePath); - - - if(stat.isDirectory()){ - console.log('qqq'); - - countFiles(filePath , fileTypesArray); - }else{ - console.log('www'); - - var extension = getFileType(files[i]); - - if (fileTypesArray.indexOf(extension) > -1 ) { - - yetAnotherArrayOfSongs.push(filePath); - } - } - } -} - - - - -exports.setup = function(mstream, program, rootDir, db){ - const rootDirCopy = rootDir; - dbCopy = db; - - // scan and screate database - mstream.get('/db/recursive-scan', function(req,res){ - - console.log('xxx'); - - // Check if this is already running - if(scanLock === true){ - // Return error - res.status(401).send('{"error":"Scan in progress"}'); - return; - } - - try{ - // turn on scan lock - scanLock = true; - - - // Make sure directory exits - var fileTypesArray = ["mp3", "flac", "wav", "ogg", "aac", "m4a"]; - - - console.log(rootDirCopy); - - countFiles(rootDirCopy, fileTypesArray); - - - totalFileCount = yetAnotherArrayOfSongs.length; - - console.log(totalFileCount); - - - dbCopy.serialize(function() { - // These two queries will run sequentially. - dbCopy.run("drop table if exists items;"); - dbCopy.run("CREATE TABLE items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title varchar DEFAULT NULL, artist varchar DEFAULT NULL, year int DEFAULT NULL, album varchar DEFAULT NULL, path text, format varchar, track INTEGER, disk INTEGER);", function() { - // These queries will run in parallel and the second query will probably - // fail because the table might not exist yet. - console.log('TABLES CREATED'); - - parse = parseAllFiles(); - parse.next(); - }); - }); - - }catch(err){ - // Remove lock - scanLock = false; - - // // Log error - // res.status(500).send('{"error":"'+err+'"}'); - // console.log(err); - return; - } - - res.send("YA DID IT"); - - }); - - - - mstream.get('/db/status', function(req, res){ - var returnObject = {}; - - returnObject.locked = scanLock; - - - if(scanLock){ - - returnObject.totalFileCount = totalFileCount; - returnObject.filesLeft = yetAnotherArrayOfSongs.length; - - res.json(returnObject); - - }else{ - var sql = 'SELECT Count(*) FROM items'; - - dbCopy.get(sql, function(err, row){ - if(err){ - console.log(err.message); - - res.status(500).json({ error: err.message }); - return; - } - - - var fileCountDB = row['Count(*)']; // TODO: Is this correct??? - - returnObject.totalFileCount = fileCountDB; - res.json(returnObject); - - }); - } - - }); - -} diff --git a/modules/db-management/database-beets-manager.js b/modules/db-management/database-beets-manager.js new file mode 100644 index 0000000..69c3c13 --- /dev/null +++ b/modules/db-management/database-beets-manager.js @@ -0,0 +1,96 @@ +// TODO: Function that copies a BeetsDB(private) into the SQLite master(public) DB +const sqlite3 = require('sqlite3').verbose(); + +// This is designed to run as it's own process +// It takes in a json array +// { +// "username":"lol", +// "privateDBOptions":{ +// "privateDB":"BEETS", +// "importDB":"path/to/sqlite3.db", +// "beetspath":"/path/to/beets/music/dir", +// "quickSync": true +// }, +// "userDir":"/Users/psori/Desktop/Blockhead", +// "dbSettings":{ +// "type":"sqlite", +// "dbPath":"/Users/psori/Desktop/LATESTGREATEST.DB" +// } +// } + + +try{ + var loadJson = JSON.parse(process.argv[process.argv.length-1], 'utf8'); + +}catch(error){ + console.log('Cannot parse JSON input'); + process.exit(); +} + + +const dbPublic = require('../db-write/database-default-'+loadJson.dbSettings.type+'.js'); +const beetsDB = new sqlite3.Database(dbPath); + +if(loadJson.dbSettings.type == 'sqlite'){ + dbPublic.setup(loadJson.dbSettings.dbPath); // TODO: Pass this in +} + + +run(); + + // SELECT * FROM items LEFT JOIN item_attributes ON + // item_attributes.entity_id = items.id + // AND item_attributes.key = 'checksum' + // GROUP BY items.id, item_attributes.key; +function run(){ + + let sql = "SELECT * FROM items LEFT JOIN item_attributes ON item_attributes.entity_id = items.id AND item_attributes.key = 'checksum' GROUP BY items.id, item_attributes.key;"; + beetsDB.all(sql, function(err, files){ + files = dbPublic.reformatData(files); + + // TODO: We can make this more efficient by comparing the differences and just adding/deleting the changes + dbPublic.purgeDB(username); + + dbPublic.addToDB(files); + + if(loadJson.privateDBOptions.quickSync === false){ + smokeThatHash(); + } + }); +} + + +function insertEntries(numberToInsert = 99, loopToEnd = false){ + var insertThese = []; + + while(insertThese.length != numberToInsert ){ + if(arrayOfSongs.length == 0){ + break; + } + insertThese.push(arrayOfSongs.pop()); + } + + dbRead.insertEntries(insertThese, loadJson.username, function(){ + // Recursivly run this function until all songs have been added + if(loopToEnd && arrayOfSongs.length != 0){ + insertEntries(numberToInsert, true); + }else{ + // For the generator + parseFilesGenerator.next(); + } + }); +} + + +// TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO: +function smokeThatHash( blazeItEveryDay = false){ + + // Pull all files from DB + // Get hash + // Update DB + + + // if hash is available or blazeItEveryDay=true then hash file + // TODO: Shoudl we add the hash to the beets DB? +} +// TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO:TODO: diff --git a/modules/db-management/database-default-manager.js b/modules/db-management/database-default-manager.js new file mode 100644 index 0000000..ab14ed2 --- /dev/null +++ b/modules/db-management/database-default-manager.js @@ -0,0 +1,357 @@ +#!/usr/bin/env node +"use strict"; + + +// This is designed to run as it's own process +// It takes in a json array +// { +// "username":"lol", +// "userDir":"/Users/psori/Desktop/Blockhead", +// "dbSettings":{ +// "type":"sqlite", +// "dbPath":"/Users/psori/Desktop/LATESTGREATEST.DB" +// } +// } + +const metadata = require('musicmetadata'); +const fs = require('fs'); +const fe = require('path'); +const crypto = require('crypto'); + + +try{ + var loadJson = JSON.parse(process.argv[process.argv.length-1], 'utf8'); + +}catch(error){ + console.log('Cannot parse JSON input'); + process.exit(); +} + + +// TODO: Check JSON for nencessary info + + + + +// 2.0 + // Get all files from DB + // Hash file. If file and hash are found in array, then skip + // Seperate into new files and files that need to be updated + // Send these arrays to functions in database-default-X.js + + +var arrayOfSongs = []; // Holds songs for DB to process // TODO: Move out of global scope +var arrayOfScannedFiles = []; // Holds files for from recursive scan + + +// Pull in correct module +console.log(loadJson.dbSettings.type); +// TODO: Rename this var +const dbRead = require('../db-write/database-default-'+loadJson.dbSettings.type+'.js'); +if(loadJson.dbSettings.type == 'sqlite'){ + dbRead.setup(loadJson.dbSettings.dbPath); // TODO: Pass this in +} + + +// New way to start it +const parseFilesGenerator = rescanAllDirectories(loadJson.userDir); +parseFilesGenerator.next(); + + +function *rescanAllDirectories(directoryToScan){ + // Scan the directory for new, modified, and deleted files + var filesToProcess = yield rescanDirectory(directoryToScan); + + // Process all new files + if(filesToProcess.newFiles.length != 0){ + while(filesToProcess.newFiles.length > 0) { + yield parseFile(filesToProcess.newFiles.pop()); + } + // Finish inserting all new entries + yield insertEntries(50, true); + } + + + // process all updated files + while(filesToProcess.updatedFiles.length > 0) { + // Handle Editted songs + yield parseUpdatedSong(filesToProcess.updatedFiles.pop()); + yield insertEntries(50, true); + } + + // TODO: Process deleted files + while(filesToProcess.deletedFiles.length > 0) { + // Handle Editted songs + yield deleteFile(filesToProcess.deletedFiles.pop()); + } + + // Exit + process.exit(0); +} + +function rescanDirectory(dir){ + + // Get all files from DB + // TODO: Move This + dbRead.getUserFiles(loadJson, function(rows){ + + // Scan through files + var fileTypesArray = ["mp3", "flac", "wav", "ogg", "aac", "m4a"]; + recursiveScan(dir, fileTypesArray); + + var latestFileList = arrayOfScannedFiles; + arrayOfScannedFiles = []; + + + + var dbFileList = []; + var dbFileListTimestamp = []; + + for(var s of rows){ + console.log(s); + dbFileList.push(s.path); + + dbFileListTimestamp.push(s.path + '::' + s.file_modified_date); + } + + console.log(dbFileList); + console.log(dbFileListTimestamp); + + + + var arrayOfUpdatedSongsToProcess = []; + var arrayOfSongsToProcess = []; + var deletedFiles = []; // TODO: Global variable ??? + var checkForModifications = []; + + + + var latestFileListSet = new Set(latestFileList); + var dbFileListSet = new Set(dbFileList); + var dbFileListTimestampSet = new Set(dbFileListTimestamp); + + + // Get deleted files + dbFileList.filter(function(x) { + if( latestFileListSet.has(x) ){ + + }else{ + // It's deleted ( i think) + deletedFiles.push(x); + } + }); + + console.log('DELETED FILES'); + console.log(deletedFiles); + + + // Get new files + latestFileList.filter(function(x) { + + console.log(x); + + if(dbFileListSet.has(x)){ + checkForModifications.push(x + "::" + fs.statSync(x).mtime.getTime() ); + console.log('yes'); + + }else{ + // New files + arrayOfSongsToProcess.push(x); + console.log('no'); + + } + }); + + + console.log('NEW FILES'); + console.log(arrayOfSongsToProcess); + + // loop through checkForModifications + // Append timestamp to all path strings + // Compare dbFileList clone with path strings appended + checkForModifications.filter(function(x) { + if(dbFileListTimestampSet.has(x)){ + + }else{ + // File x has been updated + var filePath = x.split("::")[0]; + arrayOfUpdatedSongsToProcess.push(filePath); + } + }); + + console.log('POTENTIALLY UPDATED SONGS'); + console.log(arrayOfUpdatedSongsToProcess); + + + + // TODO: Handle deleted files + // We need to prompt users to see if they want to delete files on the server side + // We can store a default behaviour + + var returnArray = { + "newFiles":arrayOfSongsToProcess, + "updatedFiles":arrayOfUpdatedSongsToProcess, + "deletedFiles":deletedFiles + }; + parseFilesGenerator.next(returnArray); + }); + +} + +// TODO: Fix this +function parseUpdatedSong(filePath){ + // Check sha256 hash and confirm it has changed + // Update file status in DB accordingly + + + var fileStream = fs.createReadStream(filePath); + + var hash = crypto.createHash('sha256'); + hash.setEncoding('hex'); + + + fileStream.on('end', function () { + hash.end(); + + var hashIt = String(hash.read()); + + // compare hashes + //db.all("SELECT * FROM files WHERE path=? AND hash=?", [filePath, hashIt], function(err, rows){ + dbRead.getHashedEntry(hashIt, filePath, loadJson.username, function(rows){ + console.log(rows); + // No match found, file needs to be updated + if( !rows || rows.length === 0 ){ + // TODO: delete entry + dbRead.deleteFile(filePath, loadJson.username, function(){ + // Re-add entry + parseFile(filePath); + }); + + }else{ + parseFilesGenerator.next(); + + } + + }); + }); + + fileStream.pipe(hash); +} + + +function deleteFile(filepath){ + dbRead.deleteFile(filepath, loadJson.username, function(){ + // Re-add entry + parseFilesGenerator.next(); + }); +} + +function parseFile(thisSong){ + var filestat = fs.statSync(thisSong); + if(!filestat.isFile()){ + // TODO: Something is fucky, log it + parseFilesGenerator.next(); + return; + } + + // Stores all data that needs to be added to DB + var songInfo; + + // TODO: Hash the file here and add the hash to the DB + var hash = crypto.createHash('sha256'); + hash.setEncoding('hex'); + + var readableStream = fs.createReadStream(thisSong); + var parser = metadata(readableStream, function (err, thisMetadata) { + if(err){ + // TODO: Do something + } + console.log(songInfo); + console.log(filestat); + + songInfo = thisMetadata; + songInfo.filesize = filestat.size; + songInfo.created = filestat.birthtime.getTime(); + songInfo.modified = filestat.mtime.getTime(); + songInfo.filePath = thisSong; + songInfo.format = getFileType(thisSong); + + + + readableStream.on('end', function () { + hash.end(); + readableStream.close(); + + songInfo.hash = String(hash.read()); + + console.log('XXXXXXXXXXXXXXXxx'); + console.log(songInfo); + + arrayOfSongs.push(songInfo); + + // if there are more than 100 entries, or if it's the last song + if(arrayOfSongs.length > 99){ + // Insert entries into DB + insertEntries(99, false); + }else{ + // For the generator + parseFilesGenerator.next(); + } + + }); + + + }); + readableStream.pipe(hash); +} + +function getFileType(filename){ + return filename.split(".").pop(); +} + +function recursiveScan(dir, fileTypesArray){ + var files = fs.readdirSync( dir ); + + + // loop through files + for (var i=0; i < files.length; i++) { + // var filePath = dir + files[i]; + var filePath = fe.join(dir, files[i]); + // console.log(filePath); + var stat = fs.statSync(filePath); + + + if(stat.isDirectory()){ + recursiveScan(filePath, fileTypesArray); + }else{ + var extension = getFileType(files[i]); + + // Make sure this is in our list of allowed files + if (fileTypesArray.indexOf(extension) > -1 ) { + arrayOfScannedFiles.push(filePath); + } + } + } +} + + +function insertEntries(numberToInsert = 99, loopToEnd = false){ + var insertThese = []; + + while(insertThese.length != numberToInsert ){ + if(arrayOfSongs.length == 0){ + break; + } + insertThese.push(arrayOfSongs.pop()); + } + + dbRead.insertEntries(insertThese, loadJson.username, function(){ + // Recursivly run this function until all songs have been added + if(loopToEnd && arrayOfSongs.length != 0){ + insertEntries(numberToInsert, true); + }else{ + // For the generator + parseFilesGenerator.next(); + } + }); +} diff --git a/modules/db-management/database-master.js b/modules/db-management/database-master.js new file mode 100644 index 0000000..4ec7142 --- /dev/null +++ b/modules/db-management/database-master.js @@ -0,0 +1,158 @@ +//exports.setup = function(mstream, users, publicDBType, dbSettings){ +exports.setup = function(mstream, program){ + const child = require('child_process'); + + + // Load the public DB plugin + // sqlite3 and mysql to start, add lokiJS support latet (and maybe posgres later), maybe a NO DB option even later + // The following api calls are all handled on a public level + // They can be moved into a public plugin + // pull plugin from masterDBType + const mstreamReadPublicDB = require('../db-read/database-public-'+program.database_plugin.type+'.js'); + mstreamReadPublicDB.setup(mstream, program.database_plugin); + + + var userDBStatus = {}; + + // Certain setups are readonly + if(program.database_plugin.type != 'beets'){ + + mstream.get('/db/recursive-scan', function(req,res){ + // Check if user is already being scanned + if(userDBStatus[req.user.username] == true){ + res.send('In Process. Please check status.'); + return; + } + // + userDBStatus[req.user.username] = true; + + // Get user's db setup + if(!req.user.privateDB || req.user.privateDB == 'DEFAULT'){ + forkDefault(req.user, program.database_plugin); + res.send('IT\'S HAPPENING!'); + return; + } + + if(req.user.privateDB == 'BEETS'){ + forkBeets(req.user); + res.send('IT\'S HAPPENING! \n NOW WITH 60% MORE BEETS!'); + return; + } + + // YOUR CONFIG IS BAD AND YOU SHOULD FEEL BAD + userDBStatus[req.user.username] = false; + res.send('YOUR CONFIG IS BAD AND YOU SHOULD FEEL BAD. ABORTING!'); + + }); + + + /////////////////////////// + // TODO: Should we have a API call that can kill any process associated with a user and reset their scan value to false? + /////////////////////////// + + /////////////////////////// + // TODO: We could use some kind of manager to make sure we don't spawn to many child processes + // For now we spawn indiscriminately and let the CPU sort it out + /////////////////////////// + + // TODO: Fill this out + function forkBeets(user, publicDBType, dbSettings){ + // Pull beets commands from config + + // Run commands + // beet import -A --group-albums /path/to/music + // beet check -a + // find ~ -type d -empty -delete + } + + function forkDefault(user, dbSettings){ + // TODO: IMPLEMENT FORK PROPERLY + // SEND JSON DATA TO WORKER PROCESS + var jsonLoad = { + username:user.username, + userDir:user.musicDir, + dbSettings:dbSettings + } + + const forkedScan = child.fork(__dirname + '/database-default-manager.js', [JSON.stringify(jsonLoad)]); + + // forkedScan.stdout.on('data', (data) => { + // console.log(`stdout: ${data}`); + // }); + // + // forkedScan.stderr.on('data', (data) => { + // console.log(`stderr: ${data}`); + // }); + + forkedScan.on('close', (code) => { + userDBStatus[user.username] = false; + console.log(`child process exited with code ${code}`); + }); + } + + + } + + // TODO: Special function that just transfers fiels from users private DB to public DB + mstream.get('/db/import-DB', function(req,res){ + // Get user info + // Pull user's private DB config + // Return if user is not using private DB + // Delete users files + // Pull all files from DB and add to publicDB + + if(program.database_plugin.beets_command){ + // TODO: Run Command + }else if(req.user.privateDBOptions.beets_command){ + // TODO: Run Command + }else{ + // res.send('NO GO'); + } + + res.send('Coming Soon!'); + + }); + + + // TODO: Handle user status + mstream.get('/db/status-mstream', function(req, res){ + res.send('Coming Soon!'); + }); + + + + // TODO: Modify this to use the public DB + mstream.get('/db/download-db', function(req, res){ + // Check user for beets db + if(!req.user.privateDB || req.user.privateDB != 'BEETS'){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + // Download File + res.download(req.user.privateDBOptions.importDB); + }); + + + // Get hash of database + // TODO: Change the name of this endpoint + mstream.get( '/db/hash', function(req, res){ + // Check if user is using beets + if(!req.user.privateDB || req.user.privateDB != 'BEETS'){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + var hash = crypto.createHash('sha256'); + hash.setEncoding('hex'); + + var fileStream = fs.createReadStream(req.user.privateDBOptions.importDB); + fileStream.on('end', function () { + hash.end(); + res.json( {hash:String(hash.read())} ); + }); + + fileStream.pipe(hash, { end: false }); + }); + +} diff --git a/modules/db-read/database-public-beets.js b/modules/db-read/database-public-beets.js new file mode 100644 index 0000000..abdacaa --- /dev/null +++ b/modules/db-read/database-public-beets.js @@ -0,0 +1,264 @@ +const sqlite3 = require('sqlite3').verbose(); +const slash = require('slash'); +const fe = require('path'); +const crypto = require('crypto'); + + + +// function that takes in a json array of songs and saves them to the sqlite db + // must contain the username and filepath for each song + +// function that gets artist info and returns json array of albums +// function that searches db and returns json array of albums and artists +// function that takes ina playlist name and searchs db for that playlist and returns a json array of songs for that playlist +// BASICALLY, all the functions we have no but de-couple them from the Express API calls + + +// TODO: TODO: TODO: TODO: TODO: TODO: TODO: TODO: TODO: TODO: TODO: TODO: +// TODO: Pass in user music dir and compare it to the path using LIKE +// This way If the user is set to use a sub folder of the DB folder, we can ignore all folders above what the user has permission for + + +function getFileType(filename){ + return filename.split(".").pop(); +} + + +exports.setup = function(mstream, dbSettings){ + const db = new sqlite3.Database(dbSettings.dbPath); + + + // Create a playlist table + db.run("CREATE TABLE IF NOT EXISTS mstream_playlists ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_name varchar, filepath varchar, hide int DEFAULT 0, user VARCHAR, created datetime default current_timestamp);", function() { + }); + + + + // TODO: This needs to be tested to see if it works on extra large playlists (think thousands of entries) + // TODO: Ban saving playlists that are > 10,000 items long + mstream.post('/saveplaylist', function (req, res){ + var title = req.body.title; + var songs = req.body.stuff; + + // Check if this playlist already exists + db.all("SELECT id FROM mstream_playlists WHERE playlist_name = ?", [title], function(err, rows) { + + db.serialize(function() { + + // We need to delete anys existing entries + if(rows && rows.length > 0){ + db.run("DELETE FROM mstream_playlists WHERE playlist_name = ?;", [title]); + } + + // Now we add the new entries + var sql2 = "insert into mstream_playlists (playlist_name, filepath) values "; + var sqlParser = []; + + while(songs.length > 0) { + var song = songs.shift(); + + sql2 += "(?, ?), "; + sqlParser.push(title); + // TODO: We need to strip out the vPath + // We need to allow pre-set vPaths in the config file + // Then strip out the vPath here + sqlParser.push( fe.join(req.user.musicDir, song) ); + + } + + sql2 = sql2.slice(0, -2); + sql2 += ";"; + + db.run(sql2, sqlParser, function(){ + res.send('DONE'); + }); + + }); + }); + }); + + + // Attach API calls to functions + mstream.get('/getallplaylists', function (req, res){ + // TODO: In V2 we need to change this to ignore hidden playlists + // TODO: db.all("SELECT DISTINCT playlist_name FROM mstream_playlists WHERE hide=0;", function(err, rows){ + db.all("SELECT DISTINCT playlist_name FROM mstream_playlists", function(err, rows){ + var playlists = []; + + // loop through files + for (var i = 0; i < rows.length; i++) { + if(rows[i].playlist_name){ + playlists.push({name: rows[i].playlist_name}); + } + } + + res.send(JSON.stringify(playlists)); + }); + }); + mstream.get('/loadplaylist', function (req, res){ + var playlist = req.query.playlistname; + + db.all("SELECT * FROM mstream_playlists WHERE playlist_name = ? ORDER BY id COLLATE NOCASE ASC", [playlist], function(err, rows){ + var returnThis = []; + + for (var i = 0; i < rows.length; i++) { + + // var tempName = rows[i].filepath.split('/').slice(-1)[0]; + var tempName = fe.basename(rows[i].filepath); + var extension = getFileType(rows[i].filepath); + var filepath = slash(fe.relative(req.user.musicDir, rows[i].filepath)); + console.log(filepath); + returnThis.push({name: tempName, file: filepath, filetype: extension }); + } + + res.send(JSON.stringify(returnThis)); + }); + }); + mstream.get('/deleteplaylist', function(req, res){ + var playlistname = req.query.playlistname; + + // Handle a soft delete + if(req.query.hide && parseInt(req.query.hide) === 1 ){ + db.run("UPDATE mstream_playlists SET hide = 1 WHERE playlist_name = ?;", [playlistname], function(){ + res.send('DONE'); + + }); + }else{ // Permentaly delete + + // Delete playlist from DB + db.run("DELETE FROM mstream_playlists WHERE playlist_name = ?;", [playlistname], function(){ + res.send('DONE'); + + }); + } + }); + + + mstream.post('/db/search', function(req, res){ + var searchTerm = "%" + req.body.search + "%" ; + + var returnThis = {"albums":[], "artists":[]}; + + // TODO: Combine SQL calls into one + db.serialize(function() { + + var sqlAlbum = "SELECT DISTINCT album FROM items WHERE items.album LIKE ? ORDER BY album COLLATE NOCASE ASC;"; + db.all(sqlAlbum, [searchTerm], function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + for (var i = 0; i < rows.length; i++) { + if(rows[i].album){ + returnThis.albums.push(rows[i].album); + } + } + }); + + + var sqlArtist = "SELECT DISTINCT artist FROM items WHERE items.artist LIKE ? ORDER BY artist COLLATE NOCASE ASC;"; + db.all(sqlArtist, [searchTerm], function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + for (var i = 0; i < rows.length; i++) { + if(rows[i].artist){ + returnThis.artists.push(rows[i].artist); + } + } + + res.send(JSON.stringify(returnThis)); + }); + }); + }); + + mstream.get('/db/artists', function (req, res) { + var artists = {"artists":[]}; + + var sql = "SELECT DISTINCT artist FROM items ORDER BY artist COLLATE NOCASE ASC;"; + db.all(sql, function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + var returnArray = []; + for (var i = 0; i < rows.length; i++) { + if(rows[i].artist){ + // rows.splice(i, 1); + artists.artists.push(rows[i].artist); + } + } + + res.send(JSON.stringify(artists)); + }); + }); + + mstream.post('/db/artists-albums', function (req, res) { + var albums = {"albums":[]}; + + // TODO: Make a list of all songs without null albums and add them to the response + var sql = "SELECT DISTINCT album FROM items WHERE artist = ? ORDER BY album COLLATE NOCASE ASC;"; + db.all(sql, [req.body.artist], function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + var returnArray = []; + for (var i = 0; i < rows.length; i++) { + if(rows[i].album){ + albums.albums.push(rows[i].album); + } + } + + res.send(JSON.stringify(albums)); + }); + }); + + mstream.get('/db/albums', function (req, res) { + var albums = {"albums":[]}; + + var sql = "SELECT DISTINCT album FROM items ORDER BY album COLLATE NOCASE ASC;"; + db.all(sql, function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + var returnArray = []; + for (var i = 0; i < rows.length; i++) { + if(rows[i].album){ + albums.albums.push(rows[i].album); + } + } + + res.send(JSON.stringify(albums)); + }); + }); + + mstream.post('/db/album-songs', function (req, res) { + var sql = "SELECT title, artist, album, format, year, cast(path as TEXT), track FROM items WHERE album = ? ORDER BY track ASC;"; + db.all(sql, [req.body.album], function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + // Format data for API + for(var i in rows ){ + var path = String(rows[i]['cast(path as TEXT)']); + + rows[i].format = rows[i].format.toLowerCase(); // make sure the format is lowecase + rows[i].file_location = slash(fe.relative(req.user.musicDir, path)); // Get the local file location + rows[i].filename = fe.basename( path ); // Ge the filname + } + + res.send(JSON.stringify(rows)); + }); + }); + +} diff --git a/modules/db-read/database-public-pouch.js b/modules/db-read/database-public-pouch.js new file mode 100644 index 0000000..e69de29 diff --git a/modules/db-read/database-public-sqlite.js b/modules/db-read/database-public-sqlite.js new file mode 100644 index 0000000..c5d8fe2 --- /dev/null +++ b/modules/db-read/database-public-sqlite.js @@ -0,0 +1,272 @@ +const sqlite3 = require('sqlite3').verbose(); +const slash = require('slash'); +const fe = require('path'); +const crypto = require('crypto'); + + + +// function that takes in a json array of songs and saves them to the sqlite db + // must contain the username and filepath for each song + +// function that gets artist info and returns json array of albums +// function that searches db and returns json array of albums and artists +// function that takes ina playlsit name and searchs db for that playlist and returns a json array of songs for that playlist +// BASICALLY, all the functions we have no but de-couple them from the Express API calls + + + +function getFileType(filename){ + return filename.split(".").pop(); +} + + +exports.setup = function(mstream, dbSettings){ + const db = new sqlite3.Database(dbSettings.dbPath); + + // Setup DB + db.run("CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title varchar DEFAULT NULL, artist varchar DEFAULT NULL, year int DEFAULT NULL, album varchar DEFAULT NULL, path TEXT NOT NULL UNIQUE, format VARCHAR, track INTEGER, disk INTEGER, user VARCHAR, filesize INTEGER, file_created_date INTEGER, file_modified_date INTEGER);", function() { + }); + // Create a playlist table + db.run("CREATE TABLE IF NOT EXISTS mstream_playlists ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_name varchar, filepath varchar, hide int DEFAULT 0, user VARCHAR, created datetime default current_timestamp);", function() { + }); + + + + // TODO: This needs to be tested to see if it works on extra large playlists (think thousands of entries) + // TODO: Ban saving playlists that are > 10,000 items long + mstream.post('/saveplaylist', function (req, res){ + var title = req.body.title; + var songs = req.body.stuff; + + // Check if this playlist already exists + db.all("SELECT id FROM mstream_playlists WHERE playlist_name = ? AND user = ?;", [title, req.user.username], function(err, rows) { + + db.serialize(function() { + + // We need to delete anys existing entries + if(rows && rows.length > 0){ + db.run("DELETE FROM mstream_playlists WHERE playlist_name = ? AND user = ?;", [title, req.user.username]); + } + + // Now we add the new entries + var sql2 = "insert into mstream_playlists (playlist_name, filepath, user) values "; + var sqlParser = []; + + while(songs.length > 0) { + var song = songs.shift(); + + sql2 += "(?, ?, ?), "; + sqlParser.push(title); + // TODO: We need to strip out the vPath + // We need to allow pre-set vPaths in the config file + // Then strip out the vPath here + sqlParser.push( fe.join(req.user.musicDir, song) ); + sqlParser.push( req.user.username ); + + } + + sql2 = sql2.slice(0, -2); + sql2 += ";"; + + db.run(sql2, sqlParser, function(){ + res.send('DONE'); + }); + + }); + }); + }); + + + // Attach API calls to functions + mstream.get('/getallplaylists', function (req, res){ + // TODO: In V2 we need to change this to ignore hidden playlists + // TODO: db.all("SELECT DISTINCT playlist_name FROM mstream_playlists WHERE hide=0;", function(err, rows){ + db.all("SELECT DISTINCT playlist_name FROM mstream_playlists WHERE user = ?", [req.user.username], function(err, rows){ + var playlists = []; + + // loop through files + for (var i = 0; i < rows.length; i++) { + if(rows[i].playlist_name){ + playlists.push({name: rows[i].playlist_name}); + } + } + + res.send(JSON.stringify(playlists)); + }); + }); + mstream.get('/loadplaylist', function (req, res){ + var playlist = req.query.playlistname; + + db.all("SELECT * FROM mstream_playlists WHERE playlist_name = ? AND user = ? ORDER BY id COLLATE NOCASE ASC", [playlist, req.user.username], function(err, rows){ + var returnThis = []; + + for (var i = 0; i < rows.length; i++) { + + // var tempName = rows[i].filepath.split('/').slice(-1)[0]; + var tempName = fe.basename(rows[i].filepath); + var extension = getFileType(rows[i].filepath); + var filepath = slash(fe.relative(req.user.musicDir, rows[i].filepath)); // TODO + console.log(filepath); + returnThis.push({name: tempName, file: filepath, filetype: extension }); + } + + res.send(JSON.stringify(returnThis)); + }); + }); + mstream.get('/deleteplaylist', function(req, res){ + var playlistname = req.query.playlistname; + + // Handle a soft delete + if(req.query.hide && parseInt(req.query.hide) === 1 ){ + db.run("UPDATE mstream_playlists SET hide = 1 WHERE playlist_name = ? AND user = ?;", [playlistname, req.user.username], function(){ + res.send('DONE'); + + }); + }else{ // Permentaly delete + + // Delete playlist from DB + db.run("DELETE FROM mstream_playlists WHERE playlist_name = ? AND user = ?;", [playlistname, req.user.username], function(){ + res.send('DONE'); + + }); + } + }); + + + mstream.post('/db/search', function(req, res){ + var searchTerm = "%" + req.body.search + "%" ; + + var returnThis = {"albums":[], "artists":[]}; + + // TODO: Combine SQL calls into one + db.serialize(function() { + + var sqlAlbum = "SELECT DISTINCT album FROM items WHERE items.album LIKE ? AND user = ? ORDER BY album COLLATE NOCASE ASC;"; + db.all(sqlAlbum, [searchTerm, req.user.username], function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + for (var i = 0; i < rows.length; i++) { + if(rows[i].album){ + returnThis.albums.push(rows[i].album); + } + } + }); + + + var sqlArtist = "SELECT DISTINCT artist FROM items WHERE items.artist LIKE ? AND user = ? ORDER BY artist COLLATE NOCASE ASC;"; + db.all(sqlArtist, [searchTerm, req.user.username], function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + for (var i = 0; i < rows.length; i++) { + if(rows[i].artist){ + returnThis.artists.push(rows[i].artist); + } + } + + res.send(JSON.stringify(returnThis)); + }); + }); + }); + + mstream.get('/db/artists', function (req, res) { + var artists = {"artists":[]}; + + var sql = "SELECT DISTINCT artist FROM items WHERE user = ? ORDER BY artist COLLATE NOCASE ASC;"; + db.all(sql, [req.user.username], function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + var returnArray = []; + for (var i = 0; i < rows.length; i++) { + if(rows[i].artist){ + // rows.splice(i, 1); + artists.artists.push(rows[i].artist); + } + } + + res.send(JSON.stringify(artists)); + }); + }); + + mstream.post('/db/artists-albums', function (req, res) { + var albums = {"albums":[]}; + + // TODO: Make a list of all songs without null albums and add them to the response + var sql = "SELECT DISTINCT album FROM items WHERE artist = ? AND user = ? ORDER BY album COLLATE NOCASE ASC;"; + var searchTerms = []; + searchTerms.push(req.body.artist); + searchTerms.push(req.user.username); + + db.all(sql, searchTerms, function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + var returnArray = []; + for (var i = 0; i < rows.length; i++) { + if(rows[i].album){ + albums.albums.push(rows[i].album); + } + } + + res.send(JSON.stringify(albums)); + }); + }); + + mstream.get('/db/albums', function (req, res) { + var albums = {"albums":[]}; + + var sql = "SELECT DISTINCT album FROM items WHERE user = ? ORDER BY album COLLATE NOCASE ASC;"; + db.all(sql, req.user.username, function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + var returnArray = []; + for (var i = 0; i < rows.length; i++) { + if(rows[i].album){ + albums.albums.push(rows[i].album); + } + } + + res.send(JSON.stringify(albums)); + }); + }); + + mstream.post('/db/album-songs', function (req, res) { + var sql = "SELECT title, artist, album, format, year, cast(path as TEXT), track FROM items WHERE album = ? AND user = ? ORDER BY track ASC;"; + + var searchTerms = []; + searchTerms.push(req.body.album); + searchTerms.push(req.user.username); + + db.all(sql, searchTerms, function(err, rows) { + if(err){ + res.status(500).json({ error: 'DB Error' }); + return; + } + + // Format data for API + for(var i in rows ){ + var path = String(rows[i]['cast(path as TEXT)']); + + rows[i].format = rows[i].format.toLowerCase(); // make sure the format is lowecase + rows[i].file_location = slash(fe.relative(req.user.musicDir, path)); // Get the local file location + rows[i].filename = fe.basename( path ); // Ge the filname + } + + res.send(JSON.stringify(rows)); + }); + }); + +} diff --git a/modules/db-write/database-default-mysql.js b/modules/db-write/database-default-mysql.js new file mode 100644 index 0000000..b25a5c9 --- /dev/null +++ b/modules/db-write/database-default-mysql.js @@ -0,0 +1,2 @@ +// functions that store data into the MySQL DB + // These functions will take in JSON arrays of song data and then save that dat to the DB diff --git a/modules/db-write/database-default-pouch.js b/modules/db-write/database-default-pouch.js new file mode 100644 index 0000000..cc4fccb --- /dev/null +++ b/modules/db-write/database-default-pouch.js @@ -0,0 +1,87 @@ +const PouchDB = require('pouchdb'); + + +exports.setup = function(dbName){ + db = new PouchDB(dbName); +} + +exports.getUserFiles = function(thisUser, callback){ + db.all("SELECT path, file_modified_date FROM items WHERE user = ?;", thisUser.username, function(err, rows){ + // Format results + var returnThis = rows; + + // callback function + callback(returnThis); + }); +} + + + +exports.insertEntries = function(arrayOfSongs, username, callback){ + let bulkDocs = []; + + while(arrayOfSongs.length > 0) { + var song = arrayOfSongs.pop(); + + var songTitle = null; + var songYear = null; + var songAlbum = null; + var artistString = null; + + if(song.artist && song.artist.length > 0){ + artistString = ''; + for (var i = 0; i < song.artist.length; i++) { + artistString += song.artist[i] + ', '; + } + artistString = artistString.slice(0, -2); + } + if(song.title && song.title.length > 0){ + songTitle = song.title; + } + if(song.year && song.year.length > 0){ + songYear = song.year; + } + if(song.album && song.album.length > 0){ + songAlbum = song.album; + } + + // TODO: Update SQL + // sqlParser.push(songTitle); + // sqlParser.push(artistString); + // sqlParser.push(songYear); + // sqlParser.push(songAlbum); + // sqlParser.push(song.filePath); + // sqlParser.push(song.format); + // sqlParser.push(song.track.no); + // sqlParser.push(song.disk.no); + // sqlParser.push(username); + // sqlParser.push(song.filesize); + // sqlParser.push(song.modified); + // sqlParser.push(song.created); + +"insert into items (title,artist,year,album,path,format, track, disk, user, filesize, file_modified_date, file_created_date) values "; + + bulkDocs.push( { + _id: song.filePath, + title: songTitle, + artist: artistString, + year: songYear, + album: songAlbum, + path: song.filePath, // FIXME: Redundant data + format:song.format, + track:song.track.no, + disk: song.disk.no, + user:username, + filesize:song.filesize, + file_modified_date:song.modified, + file_created_date:song.created, + }); + + } + + + db.bulkDocs(bulkDocs, function() { + console.log('ITS DONE'); + callback(); + }); +} diff --git a/modules/db-write/database-default-sqlite.js b/modules/db-write/database-default-sqlite.js new file mode 100644 index 0000000..2b5c7fa --- /dev/null +++ b/modules/db-write/database-default-sqlite.js @@ -0,0 +1,108 @@ +// functions that store data into the SQLite DB + // These functions will take in JSON arrays of song data and then save that dat to the DB +const sqlite3 = require('sqlite3').verbose(); +var db; + +exports.setup = function(dbPath){ + db = new sqlite3.Database(dbPath); +} + +exports.getUserFiles = function(thisUser, callback){ + db.all("SELECT path, file_modified_date FROM items WHERE user = ?;", thisUser.username, function(err, rows){ + // Format results + var returnThis = rows; + + // callback function + callback(returnThis); + }); +} + + + +exports.insertEntries = function(arrayOfSongs, username, callback){ + var sql2 = "insert into items (title,artist,year,album,path,format, track, disk, user, filesize, file_modified_date, file_created_date) values "; + var sqlParser = []; + + while(arrayOfSongs.length > 0) { + var song = arrayOfSongs.pop(); + + var songTitle = null; + var songYear = null; + var songAlbum = null; + var artistString = null; + + if(song.artist && song.artist.length > 0){ + artistString = ''; + for (var i = 0; i < song.artist.length; i++) { + artistString += song.artist[i] + ', '; + } + artistString = artistString.slice(0, -2); + } + if(song.title && song.title.length > 0){ + songTitle = song.title; + } + if(song.year && song.year.length > 0){ + songYear = song.year; + } + if(song.album && song.album.length > 0){ + songAlbum = song.album; + } + + sql2 += "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), "; + sqlParser.push(songTitle); + sqlParser.push(artistString); + sqlParser.push(songYear); + sqlParser.push(songAlbum); + sqlParser.push(song.filePath); + sqlParser.push(song.format); + sqlParser.push(song.track.no); + sqlParser.push(song.disk.no); + sqlParser.push(username); + sqlParser.push(song.filesize); + sqlParser.push(song.modified); + sqlParser.push(song.created); + + } + + sql2 = sql2.slice(0, -2); + sql2 += ";"; + + console.log(sql2); + db.run(sql2, sqlParser, function() { + console.log('ITS DONE'); + callback(); + }); +} + + + +// Function that reformats data from beets +// TODO: Fix this +exports.reformatData = function(username){ + let sql = "DELETE FROM items WHERE user = ?;"; + db.all(sql, username, function(err, rows){ + }); +} + +// Function that removes all files from the given DB +exports.purgeDB = function(){ + +} + + +exports.deleteFile = function(path, user, callback){ + let sql = "DELETE FROM items WHERE path = ? AND user = ?;"; + db.run(sql, [path, user], function() { + console.log('ITS DONE'); + callback(); + }); + +} + +exports.getHashedEntry = function(hash, path, user, callback){ + db.all("SELECT path, hash FROM items WHERE hash = ? AND path = ? AND user = ?;", [hash, path, user], function(err, rows){ + console.log(rows); + // callback function + callback(rows); + }); +} diff --git a/mstream.js b/mstream.js index dab5260..7b7a844 100755 --- a/mstream.js +++ b/mstream.js @@ -3,141 +3,57 @@ const express = require('express'); const mstream = express(); -const fs = require('graceful-fs'); // File System +const fs = require('fs'); // File System const fe = require('path'); const bodyParser = require('body-parser'); const archiver = require('archiver'); // Zip Compression const os = require('os'); const crypto = require('crypto'); const slash = require('slash'); -const sqlite3 = require('sqlite3').verbose(); +const uuidV4 = require('uuid/v4'); - -var startup = 'configure-commander'; -// If the user gives a json file then try pulling the config from that -try{ - if(fe.extname(process.argv[process.argv.length-1]) == '.json' && fs.statSync(process.argv[process.argv.length-1]).isFile()){ - startup = 'configure-json-file'; - } -}catch(error){ - console.log('JSON file does not appear to exist'); +// Get the server config +const program = require('./modules/configure-json-file.js').setup(process.argv, __dirname); +if(program.error){ + console.log(program.error); process.exit(); } -const program = require('./modules/' + startup + '.js').setup(process.argv); -if(program == false){ - process.exit(); -} -const db = new sqlite3.Database(program.database); - - -// If we are not using Beets DB, we need to prep the DB -if(program.databaseplugin === 'default'){ - db.run("CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title varchar DEFAULT NULL, artist varchar DEFAULT NULL, year int DEFAULT NULL, album varchar DEFAULT NULL, path text, format varchar, track INTEGER, disk INTEGER);", function() { - // console.log('TABLES CREATED'); - }); -} -// Create a playlist table -db.run("CREATE TABLE IF NOT EXISTS mstream_playlists ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_name varchar, filepath varchar, hide int DEFAULT 0, created datetime default current_timestamp);", function() { - // console.log('PLAYLIST TABLE CREATED'); -}); - - - - -// Normalize for all OS -// Make sure it's a directory -if(!fs.statSync( program.filepath ).isDirectory()){ - console.log('GIVEN DIRECTORY DOES NOT APPEAR TO BE REAL'); - process.exit(); -} - -const rootDir = fe.normalize(program.filepath); - -// Normalize It -if(!fe.isAbsolute(program.filepath) ){ - rootDir = fe.join(process.cwd, rootDir); -} - - -// Check that this is a real dir -if(!fs.statSync( fe.join(__dirname, program.userinterface) ).isDirectory()){ - console.log('The userinterface was not found. Closing...'); - process.exit(); -} - -// Static files -mstream.use( express.static(fe.join(__dirname, program.userinterface) )); -mstream.use( '/' , express.static( rootDir )); - // Magic Middleware Things mstream.use(bodyParser.json()); // support json encoded bodies mstream.use(bodyParser.urlencoded({ extended: true })); // support encoded bodies +// Setup WebApp +if(program.userinterface){ + mstream.use( express.static(fe.join(__dirname, program.userinterface) )); -// Handle ports. Default is 3000 -const port = program.port; -console.log('Access mStream locally: http://localhost:' + port); + // Serve the webapp + mstream.get('/', function (req, res) { + res.sendFile( fe.join(program.userinterface, 'mstream.html'), { root: __dirname }); + }); +} + + +// Print the local network IP +console.log('Access mStream locally: http://localhost:' + program.port); +console.log('Access mStream on your local network: http://' + require('my-local-ip')() + ':' + program.port); // Handle Port Forwarding -// TODO: Portforwarding could use a feature that re-opens it on a timed interval // TODO: Switch between uPNP and nat-pmp if(program.tunnel){ - const tunnel = require('./modules/auto-port-forwarding.js'); - tunnel.tunnel_uPNP(program.port); - tunnel.logUrl(port); + const tunnel = require('./modules/auto-port-forwarding.js').setup(program.tunnel, program.port); } -// Print the local network IP -console.log('Access mStream on your local network: http://' + require('my-local-ip')() + ':' + port); - - - -// Serve the webapp -mstream.get('/', function (req, res) { - res.sendFile( fe.join(program.userinterface, 'mstream.html'), { root: __dirname }); -}); - - // Login functionality -if(program.login){ - if(!program.password || !program.user){ - console.log('User credentials are missing. Please make sure to supply both a username and password via the -u and -p commands respectivly. Aborting'); - process.exit(1); - } - - // TODO: password change function - if(program.email){ - mstream.get('/change-password-request', function (req, res) { - // Generate change password token - - // Invalidate all other change password tokens - - // Email the user the token - - res.sendFile( 'COMING SOON!' ); - }); - - mstream.post('/change-password', function (req, res){ - // Check token - - // Get new password - - // Hash password and update user array - - res.sendFile( 'COMING SOON!' ); - }); - } - +if(program.users){ // Use bcrypt for password storage const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); // used to create, sign, and verify tokens var secret; var secretIsFile = false; - // Check for filepath try{ if(fs.statSync(program.secret).isFile()){ @@ -145,45 +61,82 @@ if(program.login){ } }catch(error){} - if(secretIsFile === true){ + // If the given secret is a filepath secret = fs.readFileSync(program.secret, 'utf8'); }else if(program.secret){ + // Otherwise just use secret as is secret = String(program.secret); }else{ + // If no secret was given, generate one require('crypto').randomBytes(48, function(err, buffer) { secret = buffer.toString('hex'); }); } - // Create the user array - var Users = {}; - Users[program.user] = { - 'guest': false, - 'password':'', - } + // TODO: Add New user functionality + // Check for root user and password + // Add credentials to user array - // Encrypt the password - bcrypt.genSalt(10, function(err, salt) { - bcrypt.hash(program.password, salt, function(err, hash) { - // Store hash in your password DB. - Users[program.user]['password'] = hash; - }); + // TODO: Need a way to store and use already hashed passwords + + + // TODO: password change function + mstream.post('/change-password-request', function (req, res) { + // Get email address from request + // validate email against user array + // Generate change password token + // Invalidate all other change password tokens + // Email the user the token + + res.sendFile( 'COMING SOON!' ); }); - // Handle guest account - if(program.guest && program.guestpassword){ - Users[program.guest] = { - 'guest': true, - 'password':'', + mstream.post('/change-password', function (req, res){ + // Check token + // Get new password + // Hash password and update user array + + res.sendFile( 'COMING SOON!' ); + }); + + + // Create the user array + // var Users = {}; + + var Users = program.users; + for (let username in Users) { + let permissionsMap = {}; + + generateSaltedPassword(username, Users[username]["password"]); + + if(Users[username].guestTo){ + // DO NOTHING! + }else if ( !(Users[username].musicDir in permissionsMap) ){ + // Generate unique vPath if necessary + // Th best way is to store the vPath in the JSON file + if(!Users[username].vPath){ + Users[username].vPath = uuidV4(); + } + + // Add to permissionsMap + permissionsMap[Users[username].musicDir] = Users[username].vPath; + + // Add dir to express + mstream.use( '/' + Users[username].vPath + '/' , express.static( Users[username].musicDir )); + }else{ + Users[username].vPath = permissionsMap[Users[username].musicDir]; } - // Encrypt the password + } + + + function generateSaltedPassword(username, password){ bcrypt.genSalt(10, function(err, salt) { - bcrypt.hash(program.guestpassword, salt, function(err, hash) { + bcrypt.hash(password, salt, function(err, hash) { // Store hash in your password DB. - Users[program.guest]['password'] = hash; + Users[username]['password'] = hash; }); }); } @@ -223,19 +176,17 @@ if(program.login){ } var user = Users[username]; - user['id'] = username; - - // Make a token for the user - var token = jwt.sign(user, secret); + user['username'] = username; // return the information including token as JSON var sendThis = { success: true, message: 'Welcome To mStream', - token: token }; + vPath: user.vPath, + token: jwt.sign(user, secret) // Make the token + }; res.send(JSON.stringify(sendThis)); - }); }); @@ -259,25 +210,52 @@ if(program.login){ } // Deny guest access - req.decoded = decoded; - if(decoded.guest === true && forbiddenFunctions.indexOf(req.path) != -1){ + // TODO: Modify this based on parameters set in json file + if(decoded.guestTo && forbiddenFunctions.indexOf(req.path) != -1){ return res.redirect('/guest-access-denied'); } + // Set user request data + req.user = decoded; + // + if(decoded.guestTo){ + req.user.username = req.user.guestTo; + // TODO: We should probably set the vPath elsewhere + req.user.vPath = Users[req.user.guestTo].vPath; + req.user.musicDir = Users[req.user.guestTo].musicDir; + + } next(); }); }); + // TODO: Middleware that prevents users from accessing another users files + // TODO: Strip all password info out +}else{ - // TODO: Authenticate all HTTP requests for music files (mp3 and other formats) + // Dummy data + mstream.use(function(req, res, next) { + req.user = { + username:"", + musicDir:process.cwd() + }; + next(); + }); + + mstream.use( '/' , express.static( process.cwd() )); } + // Test function // Used to determine the user has a working login token mstream.get('/ping', function(req, res){ - res.send('pong'); + // TODO: Guest status + res.json({ + vPath: req.user.vPath, + guest: false + }); }); @@ -287,19 +265,18 @@ mstream.post('/dirparser', function (req, res) { var directories = []; var filesArray = []; - // Make sure directory exits - var path = req.body.dir; - if(path == ""){ - path = rootDir; - }else{ - path = fe.join(rootDir, path); + // TODO: Make sure path is a sub-path of the user's music dir + var path = fe.join(req.user.musicDir, req.body.dir); + // Make sure it's a directory + if(!fs.statSync( path).isDirectory()){ + res.status(500).json({ error: 'Not a directory' }); + return; } // Will only show these files. Prevents people from snooping around // TODO: Move to global vairable - var masterFileTypesArray = ["mp3", "flac", "wav", "ogg", "aac", "m4a"]; + const masterFileTypesArray = ["mp3", "flac", "wav", "ogg", "aac", "m4a"]; var fileTypesArray; - if(req.body.filetypes){ fileTypesArray = JSON.parse(req.body.filetypes); }else{ @@ -307,65 +284,46 @@ mstream.post('/dirparser', function (req, res) { } - // Make sure it's a directory - if(!fs.statSync( path).isDirectory()){ - // TODO: Write an error output - // 500 Output? - res.send(""); - return; - } - // get directory contents var files = fs.readdirSync( path); // loop through files - for (var i=0; i < files.length; i++) { - var tempDirArray = {}; - var tempFileArray = {}; + for (let i=0; i < files.length; i++) { - var filePath = fe.join(path, files[i]); try{ - var stat = fs.statSync(filePath); + var stat = fs.statSync(fe.join(path, files[i])); }catch(error){ // Bad file, ignore and continue // TODO: Log This continue; } - // Handle Directories if(stat.isDirectory()){ - tempDirArray["type"] = 'directory'; - tempDirArray["name"] = files[i]; - - directories.push(tempDirArray); + directories.push({ + type:"directory", + name:files[i] + }); }else{ // Handle Files - - // Get the file extension var extension = getFileType(files[i]); - - if (fileTypesArray.indexOf(extension) > -1 && masterFileTypesArray.indexOf(extension) > -1) { - tempFileArray["type"] = extension; - tempFileArray["name"] = files[i]; - - filesArray.push(tempFileArray); + filesArray.push({ + type:extension, + name:files[i] + }); } } - } - - var returnPath = slash( fe.relative(rootDir, path) ); - + var returnPath = slash( fe.relative(req.user.musicDir, path) ); if(returnPath.slice(-1) !== '/'){ returnPath += '/'; } - // Combine list of directories and mp3s - var finalArray = { path:returnPath, contents:filesArray.concat(directories)}; - // Send back some JSON - res.send(JSON.stringify(finalArray)); + // Send back combined list of directories and mp3s + res.send( + JSON.stringify({ path:returnPath, contents:filesArray.concat(directories)}) + ); }); @@ -375,121 +333,17 @@ function getFileType(filename){ } - - -mstream.post('/saveplaylist', function (req, res){ - var title = req.body.title; - var songs = req.body.stuff; - - // Check if this playlist already exists - db.all("SELECT id FROM mstream_playlists WHERE playlist_name = ?;", title, function(err, rows) { - - db.serialize(function() { - - // We need to delete anys existing entries - if(rows && rows.length > 0){ - db.run("DELETE FROM mstream_playlists WHERE playlist_name = ?;", title); - } - - // Now we add the new entries - var sql2 = "insert into mstream_playlists (playlist_name, filepath) values "; - var sqlParser = []; - - while(songs.length > 0) { - var song = songs.shift(); - - sql2 += "(?, ?), "; - sqlParser.push(title); - sqlParser.push( fe.join(rootDir, song) ); - } - - sql2 = sql2.slice(0, -2); - sql2 += ";"; - - db.run(sql2, sqlParser, function(){ - res.send('DONE'); - }); - - }); - }); -}); - - -mstream.get('/getallplaylists', function (req, res){ - - // TODO: In V2 we need to change this to ignore hidden playlists - // TODO: db.all("SELECT DISTINCT playlist_name FROM mstream_playlists WHERE hide=0;", function(err, rows){ - db.all("SELECT DISTINCT playlist_name FROM mstream_playlists", function(err, rows){ - var playlists = []; - - // loop through files - for (var i = 0; i < rows.length; i++) { - if(rows[i].playlist_name){ - playlists.push({name: rows[i].playlist_name}); - } - } - - res.send(JSON.stringify(playlists)); - }); -}); - -mstream.get('/loadplaylist', function (req, res){ - var playlist = req.query.playlistname; - - db.all("SELECT * FROM mstream_playlists WHERE playlist_name = ? ORDER BY id COLLATE NOCASE ASC", playlist, function(err, rows){ - var returnThis = []; - - for (var i = 0; i < rows.length; i++) { - - // var tempName = rows[i].filepath.split('/').slice(-1)[0]; - var tempName = fe.basename(rows[i].filepath); - var extension = getFileType(rows[i].filepath); - var filepath = slash(fe.relative(rootDir, rows[i].filepath)); - - returnThis.push({name: tempName, file: filepath, filetype: extension }); - } - - res.send(JSON.stringify(returnThis)); - }); - -}); - - -mstream.get('/deleteplaylist', function(req, res){ - var playlistname = req.query.playlistname; - - // Handle a soft delete - if(req.query.hide && parseInt(req.query.hide) === 1 ){ - db.run("UPDATE mstream_playlists SET hide = 1 WHERE playlist_name = ?;", playlistname, function(){ - res.send('DONE'); - - }); - }else{ // Permentaly delete - - // Delete playlist from DB - db.run("DELETE FROM mstream_playlists WHERE playlist_name = ?;", playlistname, function(){ - res.send('DONE'); - - }); - } - - -}); - - // Download a zip file of music mstream.post('/download', function (req, res){ var archive = archiver('zip'); - archive.on('error', function(err) { console.log(err.message); - res.status(500).send('{error: err.message}'); + res.status(500).send('{error: '+err.message+'}'); }); archive.on('end', function() { // TODO: add logging - console.log('Archive wrote %d bytes', archive.pointer()); }); //set the archive name @@ -502,256 +356,48 @@ mstream.post('/download', function (req, res){ // Get the POSTed files var fileArray = JSON.parse(req.body.fileArray); - + //////////////////////////////////////////////////////////// // TODO: Confirm each item in posted data is a real file // - /////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////// for(var i in fileArray) { var fileString = fileArray[i]; archive.file(fe.normalize( fileString), { name: fe.basename(fileString) }); } - - // TODO: Recursivly download a posted directory // - ////////////////////////////////////////////////// - // SEE: https://github.com/archiverjs/node-archiver/tree/master/examples - // var directory = req.body.directory; - archive.finalize(); }); -const mstreamDB = require('./modules/database-'+program.databaseplugin+'.js'); -mstreamDB.setup(mstream, program, rootDir, db); - - - -mstream.post('/db/search', function(req, res){ - var searchTerm = "%" + req.body.search + "%" ; - - var returnThis = {"albums":[], "artists":[]}; - - // TODO: Combine SQL calls into one - db.serialize(function() { - - var sqlAlbum = "SELECT DISTINCT album FROM items WHERE items.album LIKE ? ORDER BY album COLLATE NOCASE ASC;"; - db.all(sqlAlbum, searchTerm, function(err, rows) { - if(err){ - res.status(500).json({ error: 'DB Error' }); - return; - } - - for (var i = 0; i < rows.length; i++) { - if(rows[i].album){ - // rows.splice(i, 1); - returnThis.albums.push(rows[i].album); - } - } - }); - - - var sqlAlbum = "SELECT DISTINCT artist FROM items WHERE items.artist LIKE ? ORDER BY artist COLLATE NOCASE ASC;"; - db.all(sqlAlbum, searchTerm, function(err, rows) { - if(err){ - res.status(500).json({ error: 'DB Error' }); - return; - } - - for (var i = 0; i < rows.length; i++) { - if(rows[i].artist){ - // rows.splice(i, 1); - returnThis.artists.push(rows[i].artist); - } - } - - res.send(JSON.stringify(returnThis)); - - }); - }); -}); - - - -mstream.get('/db/artists', function (req, res) { - var sql = "SELECT DISTINCT artist FROM items ORDER BY artist COLLATE NOCASE ASC;"; - - var artists = {"artists":[]}; - - db.all(sql, function(err, rows) { - if(err){ - res.status(500).json({ error: 'DB Error' }); - return; - } - - var returnArray = []; - for (var i = 0; i < rows.length; i++) { - if(rows[i].artist){ - // rows.splice(i, 1); - artists.artists.push(rows[i].artist); - } - } - - res.send(JSON.stringify(artists)); - }); -}); - - - -mstream.post('/db/artists-albums', function (req, res) { - var sql = "SELECT DISTINCT album FROM items WHERE artist = ? ORDER BY album COLLATE NOCASE ASC;"; - - var searchTerm = req.body.artist ; - - var albums = {"albums":[]}; - - // TODO: Make a list of all songs without null albums and add them to the response - - - db.all(sql, searchTerm, function(err, rows) { - if(err){ - res.status(500).json({ error: 'DB Error' }); - return; - } - - - var returnArray = []; - for (var i = 0; i < rows.length; i++) { - if(rows[i].album){ - // rows.splice(i, 1); - albums.albums.push(rows[i].album); - } - } - - res.send(JSON.stringify(albums)); - }); -}); - - - -mstream.get('/db/albums', function (req, res) { - var sql = "SELECT DISTINCT album FROM items ORDER BY album COLLATE NOCASE ASC;"; - - var albums = {"albums":[]}; - - - db.all(sql, function(err, rows) { - if(err){ - res.status(500).json({ error: 'DB Error' }); - return; - } - - - var returnArray = []; - for (var i = 0; i < rows.length; i++) { - if(rows[i].album){ - albums.albums.push(rows[i].album); - - } - } - - console.log(JSON.stringify(albums)); - res.send(JSON.stringify(albums)); - }); -}); - - - -mstream.post('/db/album-songs', function (req, res) { - var sql = "SELECT title, artist, album, format, year, cast(path as TEXT), track FROM items WHERE album = ? ORDER BY track ASC;"; - var searchTerm = req.body.album ; - - - - db.all(sql, searchTerm, function(err, rows) { - if(err){ - res.status(500).json({ error: 'DB Error' }); - return; - } - - // Format data for API - rows = setLocalFileLocation(rows); - - - res.send(JSON.stringify(rows)); - }); -}); - - - - -function setLocalFileLocation(rows){ - - for(var i in rows ){ - var path = String(rows[i]['cast(path as TEXT)']); - - rows[i].format = rows[i].format.toLowerCase(); // make sure the format is lowecase - rows[i].file_location = slash(fe.relative(rootDir, path)); // Get the local file location - rows[i].filename = fe.basename( path ); // Ge the filname - } - - return rows; -} - - +// ============================================================================ +// // New Way +// // TODO: We need to pull this from the program var +var dbSettings = program.database_plugin; +const mstreamDB = require('./modules/db-management/database-master.js'); +mstreamDB.setup(mstream, program); +// ============================================================================ // TODO: Add individual song -// mstream.get('/db/add-songs', function(req, res){ -// // deseralize json array -// // Add all files -// }); - - -// Download the database -mstream.get('/db/download-db', function(req, res){ - var file = program.database; - - res.download(file); // Set disposition and send it. +mstream.get('/db/add-songs', function(req, res){ + res.send('Coming Soon!'); }); - -// Get hash of database -mstream.get( '/db/hash', function(req, res){ - var hash = crypto.createHash('sha256'); - var fileStream = fs.createReadStream(program.database); - - hash.setEncoding('hex'); - fileStream.pipe(hash, { end: false }); - - - fileStream.on('end', function () { - hash.end(); - - var returnThis = { - hash:String(hash.read()) - }; - - res.send(JSON.stringify(returnThis)); - - }); -}); - - // TODO: Get Album Art calls mstream.post( '/get-album-art', function(req, res){ // Get filepath from post - // Check if album art is in DB // Return If So - // Pull album art from file stream - // ??? Lookup album art via 3rd party ??? res.send('Coming Soon!'); - -} - -const server = mstream.listen(port, function () { - // var host = server.address().address; - // var port = server.address().port; - // console.log('Example app listening at http://%s:%s', host, port); }); + + +// Start the server! +// TODO: Check if port is in use befoe firing up server +const server = mstream.listen(program.port, function () {}); diff --git a/package.json b/package.json index e7956bf..3b86ff6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mstream", - "version": "1.28.0", + "version": "2.0.0", "description": "music streaming server", "main": "mstream.js", "bin": { @@ -21,12 +21,13 @@ "body-parser": "^1.15.1", "commander": "^2.9.0", "express": "^4.13.4", - "graceful-fs": "^4.1.4", "jsonwebtoken": "^7.1.9", "musicmetadata": "^2.0.3", "my-local-ip": "^1.0.0", + "pouchdb": "^6.0.7", "slash": "^1.0.0", - "sqlite3": "^3.1.4" + "sqlite3": "^3.1.4", + "uuid": "^3.0.1" }, "optionalDependencies": { "nat-upnp": "^1.0.2", diff --git a/public/js/mstream.js b/public/js/mstream.js index 2c88f96..308a828 100755 --- a/public/js/mstream.js +++ b/public/js/mstream.js @@ -37,6 +37,7 @@ $(document).ready(function(){ // Add the token the URL calls accessKey = token; + virtualDirectory = parsedResponse.vPath; loadFileExplorer(); // Remove the overlay @@ -57,7 +58,7 @@ $(document).ready(function(){ var accessKey = ''; - + var virtualDirectory = ''; $.ajaxPrefilter(function( options ) { options.beforeSend = function (xhr) { xhr.setRequestHeader('x-access-token', accessKey); @@ -67,7 +68,7 @@ $(document).ready(function(){ - // Determine if the user needs to log sin + // Determine if the user needs to log in function testIt(){ var token = Cookies.get('token'); if(token){ @@ -82,6 +83,9 @@ $(document).ready(function(){ request.done(function( msg ) { // Remove login screen + // set virtualDirectory + var decoded = msg; + virtualDirectory = decoded.vPath; }); request.fail(function( jqXHR, textStatus ) { @@ -95,6 +99,12 @@ $(document).ready(function(){ testIt(); + + // TODO: This var nees to be appened to the beginning of any music fileapath + // This var will either be the username or the value returned by the ping API call + var vPath = ''; + + ////////////////////////////// Initialization code // Supported file types @@ -185,6 +195,9 @@ $(document).ready(function(){ function jPlayerSetMedia(fileLocation, filetype){ + if(virtualDirectory){ + fileLocation = virtualDirectory + '/' + fileLocation; + } document.getElementById("mplayer").setAttribute("src", fileLocation); document.getElementById("mplayer").setAttribute("title", fileLocation.split('/').pop()); } @@ -195,6 +208,9 @@ $(document).ready(function(){ function addFile2(that){ var filename = $(that).attr("id"); var file_location = $(that).data("file_location"); + if(accessKey){ + file_location += '?token=' + accessKey; + } var filetype = $(that).data("filetype"); var title = $(that).find('span.title').html();