From 62e8b139236f48a5112a5649e74fd68ee99e9fad Mon Sep 17 00:00:00 2001 From: Andrew Kelley Date: Mon, 20 Oct 2014 20:17:36 -0300 Subject: Import groovebasin_1.4.0.orig.tar.gz [dgit import orig groovebasin_1.4.0.orig.tar.gz] --- .gitignore | 7 + .jshintrc | 74 + .npmignore | 5 + CHANGELOG.md | 438 + LICENSE | 24 + README.md | 111 + build | 4 + certs/self-signed-cert.pem | 13 + certs/self-signed-key.pem | 15 + lib/db_iterate.js | 34 + lib/deduped_queue.js | 108 + lib/download.js | 24 + lib/groovebasin.js | 492 ++ lib/import_url_filters.js | 112 + lib/log.js | 28 + lib/mpd_api_server.js | 73 + lib/mpd_protocol.js | 1612 ++++ lib/player.js | 2653 ++++++ lib/player_server.js | 1343 +++ lib/plugins/lastfm.js | 239 + lib/protocol_parser.js | 66 + lib/safe_path.js | 11 + lib/server.js | 13 + lib/uuid.js | 24 + lib/web_socket_api_client.js | 52 + package.json | 57 + src/client/app.js | 3602 ++++++++ src/client/event_emitter.js | 30 + src/client/inherits.js | 11 + src/client/playerclient.js | 796 ++ src/client/socket.js | 58 + src/client/styles/app.styl | 396 + src/client/styles/vendor/cssreset.css | 116 + .../styles/vendor/jquery-ui-1.11.1.custom.css | 908 ++ .../styles/vendor/jquery-ui-1.11.1.structure.css | 516 ++ .../styles/vendor/jquery-ui-1.11.1.theme.css | 410 + src/client/uuid.js | 26 + src/public/favicon.png | Bin 0 -> 972 bytes .../ui-bg_diagonals-thick_15_0b3e6f_40x40.png | Bin 0 -> 424 bytes .../images/ui-bg_dots-medium_30_0b58a2_4x4.png | Bin 0 -> 237 bytes .../images/ui-bg_dots-small_20_333333_2x2.png | Bin 0 -> 206 bytes .../images/ui-bg_dots-small_30_a32d00_2x2.png | Bin 0 -> 218 bytes .../images/ui-bg_dots-small_40_00498f_2x2.png | Bin 0 -> 224 bytes src/public/images/ui-bg_flat_0_aaaaaa_40x100.png | Bin 0 -> 212 bytes src/public/images/ui-bg_flat_40_292929_40x100.png | Bin 0 -> 230 bytes .../images/ui-bg_gloss-wave_20_111111_500x100.png | Bin 0 -> 3769 bytes src/public/images/ui-icons_00498f_256x240.png | Bin 0 -> 4549 bytes src/public/images/ui-icons_98d2fb_256x240.png | Bin 0 -> 4549 bytes src/public/images/ui-icons_9ccdfc_256x240.png | Bin 0 -> 4549 bytes src/public/images/ui-icons_ffffff_256x240.png | Bin 0 -> 6299 bytes src/public/img/bright-10.png | Bin 0 -> 141 bytes src/public/index.html | 449 + src/public/vendor/jquery-2.1.1.js | 9190 ++++++++++++++++++++ src/public/vendor/jquery-2.1.1.min.js | 4 + src/public/vendor/jquery-ui-1.11.1.custom.js | 6958 +++++++++++++++ src/public/vendor/jquery-ui-1.11.1.custom.min.js | 9 + 56 files changed, 31111 insertions(+) create mode 100644 .gitignore create mode 100644 .jshintrc create mode 100644 .npmignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100755 build create mode 100644 certs/self-signed-cert.pem create mode 100644 certs/self-signed-key.pem create mode 100644 lib/db_iterate.js create mode 100644 lib/deduped_queue.js create mode 100644 lib/download.js create mode 100644 lib/groovebasin.js create mode 100644 lib/import_url_filters.js create mode 100644 lib/log.js create mode 100644 lib/mpd_api_server.js create mode 100644 lib/mpd_protocol.js create mode 100644 lib/player.js create mode 100644 lib/player_server.js create mode 100644 lib/plugins/lastfm.js create mode 100644 lib/protocol_parser.js create mode 100644 lib/safe_path.js create mode 100755 lib/server.js create mode 100644 lib/uuid.js create mode 100644 lib/web_socket_api_client.js create mode 100644 package.json create mode 100644 src/client/app.js create mode 100644 src/client/event_emitter.js create mode 100644 src/client/inherits.js create mode 100644 src/client/playerclient.js create mode 100644 src/client/socket.js create mode 100644 src/client/styles/app.styl create mode 100644 src/client/styles/vendor/cssreset.css create mode 100644 src/client/styles/vendor/jquery-ui-1.11.1.custom.css create mode 100644 src/client/styles/vendor/jquery-ui-1.11.1.structure.css create mode 100644 src/client/styles/vendor/jquery-ui-1.11.1.theme.css create mode 100644 src/client/uuid.js create mode 100644 src/public/favicon.png create mode 100644 src/public/images/ui-bg_diagonals-thick_15_0b3e6f_40x40.png create mode 100644 src/public/images/ui-bg_dots-medium_30_0b58a2_4x4.png create mode 100644 src/public/images/ui-bg_dots-small_20_333333_2x2.png create mode 100644 src/public/images/ui-bg_dots-small_30_a32d00_2x2.png create mode 100644 src/public/images/ui-bg_dots-small_40_00498f_2x2.png create mode 100644 src/public/images/ui-bg_flat_0_aaaaaa_40x100.png create mode 100644 src/public/images/ui-bg_flat_40_292929_40x100.png create mode 100644 src/public/images/ui-bg_gloss-wave_20_111111_500x100.png create mode 100644 src/public/images/ui-icons_00498f_256x240.png create mode 100644 src/public/images/ui-icons_98d2fb_256x240.png create mode 100644 src/public/images/ui-icons_9ccdfc_256x240.png create mode 100644 src/public/images/ui-icons_ffffff_256x240.png create mode 100644 src/public/img/bright-10.png create mode 100644 src/public/index.html create mode 100644 src/public/vendor/jquery-2.1.1.js create mode 100644 src/public/vendor/jquery-2.1.1.min.js create mode 100644 src/public/vendor/jquery-ui-1.11.1.custom.js create mode 100644 src/public/vendor/jquery-ui-1.11.1.custom.min.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dd1d08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/node_modules +/groovebasin.db +/config.json + +# not shared with .npmignore +/public/app.js +/public/app.css diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..016475a --- /dev/null +++ b/.jshintrc @@ -0,0 +1,74 @@ +{ + // Settings + "passfail" : false, // Stop on first error. + "maxerr" : 100, // Maximum errors before stopping. + + + // Predefined globals whom JSHint will ignore. + "browser" : true, // Standard browser globals e.g. `window`, `document`. + + "node" : true, + + "predef" : [ + "setImmediate", + "clearImmediate" + ], + + + "rhino" : false, + "couch" : false, + "wsh" : false, // Windows Scripting Host. + + "jquery" : false, + "prototypejs" : false, + "mootools" : false, + "dojo" : false, + + + + // Development. + "debug" : true, // Allow debugger statements e.g. browser breakpoints. + "devel" : true, // Allow development statements e.g. `console.log();`. + + + // EcmaScript 5. + "es5" : true, // Allow EcmaScript 5 syntax. + "strict" : false, // Require `use strict` pragma in every file. + "globalstrict" : true, // Allow global "use strict" (also enables 'strict'). + + + // The Good Parts. + "asi" : false, // Tolerate Automatic Semicolon Insertion (no semicolons). + "laxbreak" : false, // Tolerate unsafe line breaks e.g. `return [\n] x` without semicolons. + "laxcomma" : true, + "bitwise" : false, // Prohibit bitwise operators (&, |, ^, etc.). + "boss" : true, // Tolerate assignments inside if, for & while. Usually conditions & loops are for comparison, not assignments. + "curly" : false, // Require {} for every new block or scope. + "eqeqeq" : true, // Require triple equals i.e. `===`. + "eqnull" : true, // Tolerate use of `== null`. + "evil" : false, // Tolerate use of `eval`. + "expr" : false, // Tolerate `ExpressionStatement` as Programs. + "forin" : false, // Prohibt `for in` loops without `hasOwnProperty`. + "immed" : true, // Require immediate invocations to be wrapped in parens e.g. `( function(){}() );` + "latedef" : false, // Prohibit variable use before definition. + "loopfunc" : false, // Allow functions to be defined within loops. + "noarg" : true, // Prohibit use of `arguments.caller` and `arguments.callee`. + "regexp" : false, // Prohibit `.` and `[^...]` in regular expressions. + "regexdash" : false, // Tolerate unescaped last dash i.e. `[-...]`. + "scripturl" : false, // Tolerate script-targeted URLs. + "shadow" : false, // Allows re-define variables later in code e.g. `var x=1; x=2;`. + "supernew" : false, // Tolerate `new function () { ... };` and `new Object;`. + "undef" : true, // Require all non-global variables be declared before they are used. + + + // Persone styling prefrences. + "newcap" : true, // Require capitalization of all constructor functions e.g. `new F()`. + "noempty" : true, // Prohibit use of empty blocks. + "nonew" : true, // Prohibit use of constructors for side-effects. + "nomen" : false, // Prohibit use of initial or trailing underbars in names. + "onevar" : false, // Allow only one `var` statement per function. + "plusplus" : false, // Prohibit use of `++` & `--`. + "sub" : false, // Tolerate all forms of subscript notation besides dot notation e.g. `dict['key']` instead of `dict.key`. + "trailing" : true, // Prohibit trailing whitespaces. + "white" : false // Check against strict whitespace and indentation rules. +} diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..e1c94f1 --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +/node_modules +/groovebasin.db +/config.json + +# not shared with .gitignore diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cc55c06 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,438 @@ +### Version 1.4.0 (2014-10-16) + + * Andrew Kelley: + - client: fix showing filter without filtered results when server restarts + - fix auto pause behavior and add event for it + - fix symlink behavior in music library + - import by url: respect content-disposition header + - fix serving invalid content-disposition header + - no longer accidentally shipping config.json in npm module + - uploaded files are imported in a streaming fashion instead of after all + files are finishing uploading. + - fix an uploading crash + - ability to import and upload .zip files. + - auto queue happens server side. + - play queue displays total duration and selection duration + - add progress reporting for ongoing imports + - fix aborted uploads getting stuck + - Remove the easter eggs. It was fun while it lasted. Maybe someday we will + live in a society where nothing is copyrighted. + - add Cache-Control header to static assets to help enforce caching rules. + + * Josh Wolfe: + - fix crash when uploading 0 byte .zip file + + * Felipe Sateler: + - open stream and homepage links in new tabs/windows + + * Melissa Noelle: + - client supports /nick command to change name + +### Version 1.3.2 (2014-10-06) + + * Andrew Kelley: + - style: fix messed up menus and volume slider from upgrading jquery ui + - config file is config.json instead of config.js + +### Version 1.3.1 (2014-10-03) + + * Andrew Kelley: + - update to jquery 2.1.1; include unminified source + - correctly report error when fail to parse config + - use cssreset source instead of minified file + - update to jquery ui 1.11.1; include source + +### Version 1.3.0 (2014-10-03) + + * Andrew Kelley: + - if songs have no track numbers then never use album loudness + - fix YouTube import + - fix streaming not pausing and playing reliably + - fix glitch in streaming when resuming after a long pause + - add client side volume slider + - use SSL by default with a public self signed cert + - import URL allows downloading from https with invalid certs + - replace uuid dependency with a simpler, faster, and more robust + random string + - rewrite user login and permissions support. MPD users can log in with + (username) + '/' + (password) + - user accounts and permissions are managed via the browser interface + instead of with the configuration file + - add events tab which tells what actions have happened recently, supports + chat, and displays which users are streaming + - fix permissions checking for downloading anonymous requests + - remove 'l' hotkey for library and add 'e' hotkey for settings + - rename legacy protocol message names + - quieter log by default; ability to run with --verbose + - fix bug where all files on play queue would be preloaded; now only the + next and previous few files are preloaded + - client: shift+delete only attempts to delete tracks when you have the + necessary permissions + - stream endpoint obeys permission settings + - stream count is number of logged in users with an activated stream button + plus number of anonymous users connected to the http endpoint + - auto-pause is now instant instead of half second timer + - client: fix cutting/pasting text filter box behavior + - fix crash when removing a nested directory in the music directory + - client: fix player preduction when currently playing track is removed + - fix handling of slashes when importing from YouTube + - client: disable hardware playback toggle button when not admin + - cut the client javascript bundle size in half + - build: /bin/sh instead of /bin/bash + + * Josh Wolfe: + - implement and switch to more robust zip generating module. Fixes unicode + file names in zips and enables the download progress bar. + - implement and switch to simpler object diffing module. Reduces client-side + JavaScript bundle size as well as bandwidth needed to stay connected to + Groove Basin when other users are making edits. + - Multi-file downloads use a GET request. This lets you copy a download URL + which downloads multiple files to the clipboard. + + * David Renshaw: + - Fix crash when import URL fails to download. + +### Version 1.2.1 (2014-07-04) + + * Andrew Kelley: + - fix ytdl-core version locking. Fixes YouTube import. + +### Version 1.2.0 (2014-07-04) + + * Andrew Kelley: + - client uses relative stream URL so reverse proxies can work. + - client uses wss if protocol is https. + - client UI indicates how many people are streaming + - automatically pause when last streamer disconnects + - client: remove dotted outline of links. + - uploading is permission add, not control + - rename the Upload tab to the Import tab + - fix not being able to see client with anonymous read-only permissions set + - fix library scan errors deleting songs from database. + - streaming: less chance of glitches + - streaming: no hiccup sound on skip + + * Josh Wolfe: + - fix unable to download songs with hashtags in the URL + (but first, let me take a #selfie) + +### Version 1.1.0 (2014-06-20) + + * Andrew Kelley: + - Serve static assets gzipped from memory and use etags. Client loads faster. + - Fix upload for multiple files. + - Uploading has a progress bar and queues things in the correct order. + - Client: UI renders faster. No longer depends on handlebars HTML templating. + - Client: Status update no longer interfere with user input in settings pane. + - Client: Fix incorrectly displaying songs as random + - Client: Use textContent instead of innerText. Fixes incompatibility with + some browsers. + - Client: Fix incorrect expand icon shown sometimes. + - Update duration info in DB when loudness scan finishes. + - Default streaming buffer size tuned carefully to work well with browsers. + - Fix crash - writing to closed web socket. + - Prevent imported track filenames from ending directory names with '.'. + - Import by URL: Fix race condition. + - Import by URL: Prevent needless file copy operation when importing in + situations where the music directory is in a different device than /tmp. + - Import by URL: Support importing from YouTube. + - Import by URL: URI decode filename + - Fix not watching music root folder + - Client: Fix filenames with percent (%) having invalid download URL. + - Client: Fix displaying incorrect track number when track number is unknown + - Client: Fix library items not always expanding consistently + - Recognize TPA and TCM tags. + - Fix queue failing to persist on shuffle. + - Ability to edit tags. Note these edits are currently only saved to the DB + and not written to the music files. + - Client: Fix selection behaving erradically for albums in a list. + - Client: Keyboard shortcuts window scrollable with arrows. + - Client: Fix UI issues with buttons + - Client: Default selected queue item is the current track. + - Client: Fix repeat one and repeat all behavior swapped. + - Fix Dynamic Mode not weighting last queue date properly when selecting + random songs. + - Fix potential crash when users disconnect from client. + - Fix segfault when deleting tracks. + - Save CPU cycles by only encoding audio when streamers are connected. + - Ability to toggle server-side audio playback. + - Loudness adjustment: Avoid soft limiting when possible based on looking + at the true peak of the song. + - Add check for correct version of libgroove on startup. This prevents + users from accidentally using an outdated version and getting bugs. + - Ability to start even if MPD protocol port cannot be bound. + - Preserve volume over application restarts. + - Improved streaming playback reliability. + - Fix downloading zip for artist and album. + - Deleting currently playing track goes to next song. + - Client: Fix stream button not always in correct state. + - Add header so that downloading always results in download. + - Start at last play position on server restart. + - Various improvements to how tracks are filed in the library browser. + - When playlist changes, reprioritize scanning queue. + - Scanning progress is reported to the client. + - HTTP commands go through permissions framework. + - Fix sometimes player stops and does not go to next track automatically. + - Ignore folders in music directory beginning with a dot. + - Client: Fix freezing and stuttering when many library or playlist updates + happen quickly. + - Client: Preserve library selection state on library update. + + * Josh Wolfe: + - Client: Fix client side crash when 2 clients delete the same queue item. + - Client: Fix cursor selection not showing up. + - Client: Ctrl+Space to toggle selection under the cursor. + - Client: Queue now uses Ctrl to move the cursor without selecting, and Alt + to bump selected tracks up or down. + - Client: Ctrl+Arrows and Ctrl+Space in library now work like in the queue. + - Client: Shift+Arrows in queue now works as expected. + - Client: Fix Shift Up/Down behavior in library. + - Seeking no longer automatically starts playing. + - Client: Hide the password in the UI. + - Client: Library deletions are anticipated. + + * Caleb Morris: + - Add filter delay to wait for user to finish typing before beginning search. + + * jeffrom: + - Fix disabled menu item focus jumping. + + * jimmy: + - MPD: Make "search" a substring match. + - MPD: Support "any" as a search type in find and search. + + * jprjr: + - Fix hardware playback fallback behavior. + + * seansaleh: + - encodeQueueDuration is now a configurable option. + + * Ronak Buch: + - Client style: Add margin to URL upload bar. + + * Jeff Epler: + - README: Mention nodejs-legacy Debian package. + +### Version 1.0.1 (2014-03-18) + +* Andrew Kelley: + * Fix race condition when removing tracks from playlist. Closes #160 + * Default import path includes artist directory. + * Also recognize "TCMP" ID3 tag as compilation album flag + * Fix Last.fm authentication + +### Version 1.0.0 (2014-03-15) + +* Andrew Kelley: + * Remove dependency on MPD. Groove Basin now works independently of MPD. + It uses [libgroove](https://github.com/andrewrk/libgroove) for audio + playback and streaming support. + * Support MPD protocol on (default) port 6600. Groove Basin now functions as + an MPD server. + * Fix regression for handling unknown artist/album + * Fix playlist to display artist name + * Plug upload security hole + * Groove Basin is no longer written in coco. Hopefully this will enable more + code contributions. + * Simpler config file that can survive new version releases. + * Simpler and more efficient protocol between client and server. + * Pressing prev on first track with repeat all on goes to end + * Automatic loudness detection (ReplayGain) using EBU R128. + - Lazy playlist scanning. + - Automatic switching between album and track mode. + - Takes advantage of multi-core systems. + * Faster rebuilding of album table index + * HTTP audio stream buffers much more quickly and flushes the buffer on seek. + * Fix volume ui going higher than 1.0. + * Fix changing volume not showing up on other clients. + * Native html5 audio streaming instead of soundmanager 2 + * Streaming shows when it is buffering + * add meta charset=utf8 to index.html. + * fix volume keyboard shortcuts in firefox. + * Watches music library for updates and quickly updates library. + * Route dynamicmode through permissions framework + * Better default password generation + * web ui: fix current track not displayed sometimes + * upgrade jquery and jquery ui to latest stable. Fixes some UI glitches. + * static assets are gzipped and held permanently in memory. Makes the + web interface load faster. + * player: set "don't cache this" headers on stream + * Remove chat. It's not quite ready yet. Chat will be reimplemented better + in a future release. + * Remove stored playlist stub from UI. Stored playlists will be reimplemented + better in a future release. +* Josh Wolfe: + * Converting the code to not use MPD + * fix multiselect shiftIds + * deleting library items removes them from the queue as well. + * fix shift click going up in the queue + * after deleting tracks, select the next one, not some random one. + +### Version 0.2.0 (2012-10-16) + +* Andrew Kelley: + * ability to import songs by pasting a URL + * improve build and development setup + * update style to not resize on selection. closes #23 + * better connection error messages. closes #21 + * separate [mpd.js](https://github.com/andrewrk/mpd.js) into an open source module. closes #25 + * fix dynamicmode; use higher level sticker api. closes #22 + * search uses ascii folding so that 'jonsi' matches 'Jónsi'. closes #29 + * server restarts if it crashes + * server runs as daemon + * server logs to rotating log files + * remove setuid feature. use authbind if you want to run as port 80 + * ability to download albums and artists as zip. see #9 + * ability to download arbitrary selection as zip. closes #9 + * fix track 08 and 09 displaying as 0. closes #65 + * fix right click for IE + * better error reporting when state json file is corrupted + * log chats + * fix edge case with unicode characters. closes #67 + * fix next and previous while stopped behavior. closes #19 + * handle uploading errors. fixes #59 + * put link to stream URL in settings. closes #69 + * loads faster and renders faster + * send a 404 when downloading can't find artist or album. closes #70 + * read-only stored playlist support + * fix playlist display when empty + * add uploaded songs to "Incoming" playlist. closes #80 + * fix resize weirdness when you click library tab. closes #75 + * don't bold menu option text + * add color to the first part of the track slider. closes #15 +* Josh Wolfe: + * fix dynamic mode glitch + * fix dynamic mode with no library or no tags file + * uploading with mpd <0.17 falls back to upload name + + +### Version 0.1.2 (2012-07-12) + +* Andrew Kelley: + * lock in the major versions of dependencies + * more warnings about mpd conf settings + * remove "alert" text on no connection + * better build system + * move dynamic mode configuration to server + * server handles permissions in mpd.conf correctly + * clients can set a password + * ability to delete from library + * use soundmanager2 instead of jplayer for streaming + * buffering status on stream button + * stream button has a paused state + * use .npmignore to only deploy generated files + * update to work with node 0.8.2 +* Josh Wolfe: + * pointing at mpd's own repository in readme. #12 + * fixing null pointer error for when streaming is disabled + * fixing blank search on library update + * fixing username on reconnect + * backend support for configurable dynamic history and future sizes + * ui for configuring dynamic mode history and future sizes + * coloring yourself different in chat + * scrubbing stale user ids in my_user_ids + * better chat name setting ui + * scrolling chat window properly + * moar chat history + * formatting the state file + * fixing chat window resize on join/left + * validation on dynamic mode settings + * clearer wording in Get Started section and louder mpd version dependency + documentation + +### Version 0.0.6 (2012-04-27) + +* Josh Wolfe: + * fixing not queuing before random when pressing enter in the search box + * fixing streaming hotkey not updating button ui + * stopping and starting streaming in sync with mpd.status.state. + * fixing weird bug with Stream button checked state + * warning when bind_to_address is not also configured for localhost + * fixing derpy log reference + * fixing negative trackNumber scrobbling + * directory urls download .zip files. #9 + * document dependency on mpd version 0.17 +* Andrew Kelley: + * fix regression: not queuing before random songs client side + * uploaded songs are queued in the correct place + * support restarting mpd without restarting daemon + * ability to reconnect without refreshing + * log.info instead of console.info for track uploaded msg + * avoid the use of 'static' keyword +* David Banham: + * Make jPlayer aware of which stream format is set + * Removed extra constructor. Changed tabs to 2spaces + + +### Version 0.0.5 (2012-03-11) + +* Note: Requires you to pull from latest mpd git code and recompile. +* Andrew Kelley: + * disable volume slider when mpd reports volume as -1. fixes #8 + * on last.fm callback, do minimal work then refresh. fixes #7 + * warnings output the actual mpd.conf path instead of "mpd conf". see #5 + * resize things *after* rendering things. fixes #6 + * put uploaded files in an intelligent place, and fix #2 + * ability to retain server state file even when structure changes + * downgrade user permissions ASAP + * label playlist items upon status update + * use blank user_id to avoid error message + * use jplayer for streaming +* Josh Wolfe: + * do not show ugly "user_n" text after usernames in chat. + +### Version 0.0.4 (Mar 6 2012) + +* Andrew Kelley: + * update keyboard shortcuts dialog + * fix enter not queuing library songs in firefox + * ability to authenticate with last.fm, last.fm scrobbling + * last.fm scrobbling works + * fix issues with empty playlist. fixes #4 + * fix bug with dynamic mode when playlist is clear +* Josh Wolfe: + * easter eggs + * daemon uses a state file + +### Version 0.0.3 (2012-03-04) + +* Andrew Kelley: + * ability to select artists, albums, tracks in library + * prevents sticker race conditions from crashing the server (#3) + * escape clears the selection cursor too + * ability to shift+click select in library + * right-click queuing in library works + * do not show download menu option since it is not supported yet + * show selection on expanded elements + * download button works for single tracks in right click library menu + * library up/down to change selection + * nextLibPos/prevLibPos respects whether tree items are expanded or collapse + * library window scrolls down when you press up/down to move selection + * double click artists and albums in library to queue + * left/right expands/collapses library tree when lib has selection + * handle enter in playlist and library + * ability to drag artists, albums, tracks to playlist +* Josh Wolfe: + * implement chat room + * users can set their name in the chat room + * users can change their name multiple times + * storing username persistently. disambiguating conflicting usernames. + * loading recent chat history on connect + * normalizing usernames and sanitizing username display + * canot send blank chats + * supporting /nick renames in chat box + * hotkey to focus chat box + +### Version 0.0.2 (2012-03-01) + +* Andrew Kelley: + * learn mpd host and port in mpd conf + * render unknown albums and unknown artists the same in the playlist (blank) + * auto-scroll playlist window and library window appropriately + * fix server crash when no top-level files exist + * fix some songs error message when uploading + * edit file uploader spinny gif to fit the theme + * move chat stuff to another tab +* Josh Wolfe: + * tracking who is online + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fe41905 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +The MIT License + +Copyright (c) 2011 Andrew Kelley + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca51bbc --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# ![Groove Basin](http://groovebasin.com.s3.amazonaws.com/img/logo-text.png) + +Music player server with a web-based user interface inspired by Amarok 1.4. + +Run it on a server connected to some speakers in your home or office. +Guests can control the music player by connecting with a laptop, tablet, +or smart phone. Further, you can stream your music library remotely. + +Groove Basin works with your personal music library; not an external music +service. Groove Basin will never support DRM content. + +Try out the [live demo](http://demo.groovebasin.com/). + +## Feature Highlights + +* Fast and responsive. It feels like a desktop app, not a web app. + +* Dynamic Mode which automatically queues random songs, favoring songs + that have not been queued recently. + +* Drag and drop upload. Drag and drop playlist editing. Keyboard shortcuts + galore. + +* Lazy multi-core + [EBU R128 loudness scanning](http://tech.ebu.ch/loudness) (tags compatible + with [ReplayGain](http://wiki.hydrogenaudio.org/index.php?title=ReplayGain_1.0_specification)) + and automatic switching between track and album mode. + ["Loudness Zen"](http://www.youtube.com/watch?v=iuEtQqC-Sqo) + +* Streaming support. You can listen to your music library - or share it with + your friends - even when you are not physically near your home speakers. + +* MPD protocol support. This means you already have a selection of + [clients](http://mpd.wikia.com/wiki/Clients) which integrate with + Groove Basin. For example [MPDroid](https://github.com/abarisain/dmix). + If you're writing a new client, upgrade to the Groove Basin Protocol with + the `protocolupgrade` command. + +* [Last.fm](http://www.last.fm/) scrobbling. + +* File system monitoring. Add songs anywhere inside your music directory and + they instantly appear in your library in real time. + +## Install + +### Pre-Built Packages + +#### Ubuntu + +``` +sudo apt-add-repository ppa:andrewrk/libgroove +sudo apt-get update +sudo apt-get install groovebasin +groovebasin +``` + +### From Source + +1. Install [Node.js](http://nodejs.org) v0.10.x. On Debian and + Ubuntu, `sudo apt-get install nodejs-dev nodejs-legacy npm`. +2. Install [libgroove](https://github.com/andrewrk/libgroove). + libgroove is available in many package managers. See the libgroove README + for more details. +3. Clone the source and cd to it. +4. `npm run build` +5. `npm start` + +## Configuration + +When Groove Basin starts it will look for `config.json` in the current +directory. If not found it creates one for you with default values. + +Use this to set your music library location and other settings. + +It is recommended that you generate a self-signed certificate and use that +instead of using the public one bundled with this source code. + +## Screenshots + +![Search + drag/drop support](http://groovebasin.com/img/groovebasin-1.3.2-searchdragdrop.png) +![Multi-select and context menu](http://groovebasin.com/img/groovebasin-1.3.2-libmenu.png) +![Keyboard shortcuts](http://groovebasin.com/img/groovebasin-1.3.2-shortcuts.png) +![Settings](http://groovebasin.com/img/groovebasin-1.3.2-settings.png) +![Import](http://groovebasin.com/img/groovebasin-1.3.2-import.png) +![Events](http://groovebasin.com/img/groovebasin-1.3.2-events.png) + +## Developing + +``` +$ npm run dev +``` + +This will install dependencies, build generated files, and then start the +sever. It is up to you to restart it when you modify assets or server files. + +### Community + +Pull requests, feature requests, and bug reports are welcome! Live discussion +in #libgroove on Freenode. + +#### Articles + + * [My Quest to Build the Ultimate Music Player](http://andrewkelley.me/post/quest-build-ultimate-music-player.html) + * [Turn Your Raspberry Pi into a Music Player Server](http://andrewkelley.me/post/raspberry-pi-music-player-server.html) + +### Roadmap + + 0. Music library organization + 0. Playlists + 0. Accoustid Integration + 0. Finalize GrooveBasin protocol spec diff --git a/build b/build new file mode 100755 index 0000000..a420605 --- /dev/null +++ b/build @@ -0,0 +1,4 @@ +#!/bin/sh +mkdir -p public +./node_modules/.bin/stylus -o public/ -c --include-css src/client/styles +./node_modules/.bin/browserify-lite ./src/client/app.js --outfile public/app.js diff --git a/certs/self-signed-cert.pem b/certs/self-signed-cert.pem new file mode 100644 index 0000000..665f30b --- /dev/null +++ b/certs/self-signed-cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICBzCCAXACCQDVkWABMBBTKDANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJV +UzEQMA4GA1UECAwHQXJpem9uYTEQMA4GA1UEBwwHUGhvZW5peDEVMBMGA1UECgwM +R3Jvb3ZlIEJhc2luMB4XDTE0MDgxODAxMDczM1oXDTM0MDgxMzAxMDczM1owSDEL +MAkGA1UEBhMCVVMxEDAOBgNVBAgMB0FyaXpvbmExEDAOBgNVBAcMB1Bob2VuaXgx +FTATBgNVBAoMDEdyb292ZSBCYXNpbjCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkC +gYEA3T0DvkbQrSJaNVXLmZSxVzVZBrPs/1Ht4ZWPcSt2aJifZKBYytrHUkhqpN0r +K6xH9QCGYPAp4MDO9BlNp8s4KqAhuc4cUyc/fcfWOrBIb3/nrBtZKDlt2jkV1JUo +dD0mjFqdjU/mVkZQSbMRMCzV6hQXul7kdcVnUxqtlh86FBUCAwEAATANBgkqhkiG +9w0BAQsFAAOBgQCBHR3zJtB0N693WRgX8YmlGIisZHzXjtWDhk8bJiPHxWV5nx1u +UDOdwA5Vqv2gRp4BvS2+o2fSB9m3Gi80A9+jxrtIYDXBNvbFSH/LTjxTD372pPIf +ZjGgWuwUJUVdeh3GLFhnzdGE/b4PoqyhaT3yVUAc0ap0XgHTrzwz/vm5yw== +-----END CERTIFICATE----- diff --git a/certs/self-signed-key.pem b/certs/self-signed-key.pem new file mode 100644 index 0000000..2fbfd54 --- /dev/null +++ b/certs/self-signed-key.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQDdPQO+RtCtIlo1VcuZlLFXNVkGs+z/Ue3hlY9xK3ZomJ9koFjK +2sdSSGqk3SsrrEf1AIZg8CngwM70GU2nyzgqoCG5zhxTJz99x9Y6sEhvf+esG1ko +OW3aORXUlSh0PSaMWp2NT+ZWRlBJsxEwLNXqFBe6XuR1xWdTGq2WHzoUFQIDAQAB +AoGATfhn7lJUzv/RXQSsqabOzVZe1s7okp8UQDGOiSrxIzHO0w7z3CI4pxYgh5Pu +2Ahyn7UcpuNdTvmEtmCIjr8/PpZs1I1YaacHDfmbxOW8qnqh5sIj0GfAZB/dSdXZ +1e4UvMHUrj07qY6/vMrmirwpZyrO9DEezrOf6sUoX7KuwO0CQQD+VC8BjzjmezYC +9qC0+kRKJElqP/zOhcjlA64dwm5YnVRCiXN/bNcwGAc0qRMzrU3Lysh37iWNtK8W +l2pdMwBXAkEA3rErDmCrBfW8Wdz9wF37mi1f4EZ2bP4UzkwZYAEnHIjVPk/5Ymol +2PIjaHUQHpYzziKBZuPfP3A+uKY/2h5bcwJBAK0rBsKKEVUliZYk9TGkwgC1imNU +5D5+a1Y71j8fFuExZqDTVBfsNOzjP2zEvnVOSA09qpe2SE2fPCQmvt5sjosCQQCd +HWaBSFahZ9Sxmic1t5kyF91TAKPBFipbunkUsPuFOE0rH4WVl8qIG547rovm6JY4 +U0P08cSqn2jBIhpeq5hdAkEA6UD86Rd7Yq1YiUUrZ/E8ABeZ7SYTeDerB44oRFwV +vY59FCnq1+YOy+QL2oh0bfgvQ06nPCZbA3bCRblI0bdvqg== +-----END RSA PRIVATE KEY----- diff --git a/lib/db_iterate.js b/lib/db_iterate.js new file mode 100644 index 0000000..2a56d9b --- /dev/null +++ b/lib/db_iterate.js @@ -0,0 +1,34 @@ +var log = require('./log'); + +module.exports = dbIterate; + +function dbIterate(db, keyPrefix, processOne, cb) { + var it = db.iterator({ + gte: keyPrefix, + keyAsBuffer: false, + valueAsBuffer: false, + }); + itOne(); + function itOne() { + it.next(function(err, key, value) { + if (err) { + it.end(onItEndErr); + cb(err); + return; + } + if (!key || key.indexOf(keyPrefix) !== 0) { + it.end(cb); + } else { + processOne(key, value); + itOne(); + } + }); + } +} + +function onItEndErr(err) { + if (err) { + log.error("Unable to close iterator:", err.stack); + } +} + diff --git a/lib/deduped_queue.js b/lib/deduped_queue.js new file mode 100644 index 0000000..d203f84 --- /dev/null +++ b/lib/deduped_queue.js @@ -0,0 +1,108 @@ +var cpuCount = require('os').cpus().length; +var EventEmitter = require('events').EventEmitter; +var util = require('util'); + +module.exports = DedupedQueue; + +util.inherits(DedupedQueue, EventEmitter); +function DedupedQueue(options) { + EventEmitter.call(this); + + this.maxAsync = options.maxAsync || cpuCount; + this.processOne = options.processOne; + + this.pendingQueue = []; + this.pendingSet = {}; + + this.processingCount = 0; + this.processingSet = {}; +} + +DedupedQueue.prototype.clear = function() { + this.pendingQueue.forEach(function(queueItem) { + var err = new Error("dequeued"); + err.code = 'EDEQ'; + queueItem.cbs.forEach(function(cb) { + cb(err); + }); + }); + this.pendingQueue = []; + this.pendingSet = {}; +}; + +DedupedQueue.prototype.idInQueue = function(id) { + return !!(this.pendingSet[id] || this.processingSet[id]); +}; + +DedupedQueue.prototype.add = function(id, item, cb) { + var queueItem = this.pendingSet[id]; + if (queueItem) { + if (cb) queueItem.cbs.push(cb); + return; + } + queueItem = new QueueItem(id, item); + if (cb) queueItem.cbs.push(cb); + this.pendingSet[id] = queueItem; + this.pendingQueue.push(queueItem); + this.flush(); +}; + +DedupedQueue.prototype.waitForId = function(id, cb) { + var queueItem = this.pendingSet[id] || this.processingSet[id]; + if (!queueItem) return cb(); + queueItem.cbs.push(cb); +}; + +DedupedQueue.prototype.flush = function() { + // if an item cannot go into the processing queue because an item with the + // same ID is already there, it goes into deferred + var deferred = []; + while (this.processingCount < this.maxAsync && this.pendingQueue.length > 0) { + var queueItem = this.pendingQueue.shift(); + if (this.processingSet[queueItem.id]) { + deferred.push(queueItem); + } else { + delete this.pendingSet[queueItem.id]; + this.processingSet[queueItem.id] = queueItem; + this.processingCount += 1; + this.startOne(queueItem); + } + } + for (var i = 0; i < deferred.length; i += 1) { + this.pendingQueue.push(deferred[i]); + } +}; + +DedupedQueue.prototype.startOne = function(queueItem) { + var self = this; + var callbackCalled = false; + self.processOne(queueItem.item, function(err) { + if (callbackCalled) { + self.emit('error', new Error("callback called more than once")); + return; + } + callbackCalled = true; + + delete self.processingSet[queueItem.id]; + self.processingCount -= 1; + if (queueItem.cbs.length === 0) { + defaultCb(err); + } else { + for (var i = 0; i < queueItem.cbs.length; i += 1) { + queueItem.cbs[i](err); + } + } + self.flush(); + + function defaultCb(err) { + if (err) self.emit('error', err); + self.emit('oneEnd'); + } + }); +}; + +function QueueItem(id, item) { + this.id = id; + this.item = item; + this.cbs = []; +} diff --git a/lib/download.js b/lib/download.js new file mode 100644 index 0000000..4bb55e9 --- /dev/null +++ b/lib/download.js @@ -0,0 +1,24 @@ +var http = require('http'); +var https = require('https'); +var url = require('url'); + +exports.download = download; + +var whichHttpLib = { + 'http:': http, + 'https:': https, +}; + +function download(urlString, cb) { + var parsedUrl = url.parse(urlString); + var httpLib = whichHttpLib[parsedUrl.protocol]; + if (!httpLib) return cb(new Error("Invalid URL")); + + parsedUrl.agent = false; + parsedUrl.rejectUnauthorized = false; + httpLib.get(parsedUrl, function(res) { + cb(null, res); + }).on('error', function(err) { + cb(err, null); + }); +} diff --git a/lib/groovebasin.js b/lib/groovebasin.js new file mode 100644 index 0000000..5fca286 --- /dev/null +++ b/lib/groovebasin.js @@ -0,0 +1,492 @@ +var EventEmitter = require('events').EventEmitter; +var http = require('http'); +var https = require('https'); +var assert = require('assert'); +var WebSocketServer = require('ws').Server; +var fs = require('fs'); +var yazl = require('yazl'); +var util = require('util'); +var path = require('path'); +var Pend = require('pend'); +var express = require('express'); +var osenv = require('osenv'); +var plugins = [ + require('./plugins/lastfm'), +]; +var Player = require('./player'); +var PlayerServer = require('./player_server'); +var MpdProtocol = require('./mpd_protocol'); +var MpdApiServer = require('./mpd_api_server'); +var WebSocketApiClient = require('./web_socket_api_client'); +var leveldown = require('leveldown'); +var net = require('net'); +var safePath = require('./safe_path'); +var MultipartForm = require('multiparty').Form; +var createGzipStatic = require('connect-static'); +var serveStatic = require('serve-static'); +var Cookies = require('cookies'); +var log = require('./log'); +var contentDisposition = require('content-disposition'); + +module.exports = GrooveBasin; + +var defaultConfig = { + host: '0.0.0.0', + port: 16242, + dbPath: "groovebasin.db", + musicDirectory: path.join(osenv.home(), "music"), + lastFmApiKey: "bb9b81026cd44fd086fa5533420ac9b4", + lastFmApiSecret: "2309a40ae3e271de966bf320498a8f09", + mpdHost: '0.0.0.0', + mpdPort: 6600, + acoustidAppKey: 'bgFvC4vW', + encodeQueueDuration: 8, + sslKey: 'certs/self-signed-key.pem', + sslCert: 'certs/self-signed-cert.pem', +}; + +util.inherits(GrooveBasin, EventEmitter); +function GrooveBasin() { + EventEmitter.call(this); + + this.app = express(); +} + +GrooveBasin.prototype.initConfigVar = function(name, defaultValue) { + this.configVars.push(name); + this[name] = defaultValue; +}; + +GrooveBasin.prototype.loadConfig = function(cb) { + var self = this; + var pathToConfig = "config.json"; + fs.readFile(pathToConfig, {encoding: 'utf8'}, function(err, contents) { + var anythingAdded = false; + var config; + if (err) { + if (err.code === 'ENOENT') { + anythingAdded = true; + self.config = defaultConfig; + log.warn("No " + pathToConfig + " found; writing default."); + } else { + return cb(err); + } + } else { + try { + self.config = JSON.parse(contents); + } catch (err) { + cb(new Error("Unable to parse " + pathToConfig + ": " + err.message)); + return; + } + } + // this ensures that even old files get new config values when we add them + for (var key in defaultConfig) { + if (self.config[key] === undefined) { + anythingAdded = true; + self.config[key] = defaultConfig[key]; + } + } + if (anythingAdded) { + fs.writeFile(pathToConfig, JSON.stringify(self.config, null, 4), cb); + } else { + cb(); + } + }); +}; + +GrooveBasin.prototype.start = function() { + var self = this; + + if (process.argv.indexOf('--verbose') > 0) { + log.level = log.levels.Debug; + } else { + log.level = log.levels.Warn; + } + + var pend = new Pend(); + pend.go(function(cb) { + self.loadConfig(cb); + }); + pend.go(function(cb) { + var options = { + dir: path.join(__dirname, "../public"), + aliases: [], + }; + createGzipStatic(options, function(err, middleware) { + if (err) return cb(err); + self.app.use(middleware); + cb(); + }); + }); + pend.go(function(cb) { + createGzipStatic({dir: path.join(__dirname, "../src/public")}, function(err, middleware) { + if (err) return cb(err); + self.app.use(middleware); + cb(); + }); + }); + pend.wait(function(err) { + if (err) { + log.error(err.stack); + return; + } + pend.go(function(cb) { + self.db = leveldown(self.config.dbPath); + self.db.open(cb); + }); + pend.wait(makePlayer); + }); + function makePlayer(err) { + if (err) { + log.error(err.stack); + return; + } + self.httpHost = self.config.host; + self.httpPort = self.config.port; + if (process.argv.indexOf('--delete-all-users') > 0) { + // this will call process.exit when done + PlayerServer.deleteAllUsers(self.db); + return; + } + + + self.player = new Player(self.db, self.config.musicDirectory, self.config.encodeQueueDuration); + self.player.initialize(function(err) { + if (err) { + log.error("unable to initialize player:", err.stack); + return; + } + log.debug("Player initialization complete."); + + var pend = new Pend(); + plugins.forEach(function(PluginClass) { + var plugin = new PluginClass(self); + if (plugin.initialize) pend.go(plugin.initialize.bind(plugin)); + }); + pend.wait(function(err) { + if (err) { + log.error("Error initializing plugin:", err.stack); + return; + } + self.startServer(); + }); + }); + } + +}; + +GrooveBasin.prototype.initializeDownload = function() { + var self = this; + var musicDir = self.config.musicDirectory; + var serve = serveStatic(musicDir); + self.app.use('/library', serveStaticMiddleware); + self.app.get('/library/', self.hasPermRead, function(req, resp) { + downloadPath("", "library.zip", req, resp); + }); + self.app.get(/^\/library\/(.*)\/$/, self.hasPermRead, function(req, resp){ + var reqDir = req.params[0]; + var zipName = safePath(reqDir.replace(/\//g, " - ")) + ".zip"; + downloadPath(reqDir, zipName, req, resp); + }); + self.app.get('/download/keys', self.hasPermRead, function(req, resp) { + var reqKeys = Object.keys(req.query); + var files = []; + var commonArtistName, commonAlbumName; + reqKeys.forEach(function(key) { + var dbFile = self.player.libraryIndex.trackTable[key]; + if (!dbFile) return; + files.push(path.join(musicDir, dbFile.file)); + if (commonAlbumName === undefined) commonAlbumName = dbFile.albumName || ""; + else if (commonAlbumName !== dbFile.albumName) commonAlbumName = null; + if (commonArtistName === undefined) commonArtistName = dbFile.artistName || ""; + else if (commonArtistName !== dbFile.artistName) commonArtistName = null; + }); + var reqZipName = commonAlbumName || commonArtistName || "songs"; + var zipName = safePath(reqZipName) + ".zip"; + sendZipOfFiles(zipName, files, req, resp); + }); + + function downloadPath(dirName, zipName, req, resp) { + var files = []; + var dirEntry = self.player.dirs[dirName]; + if (!dirEntry) { + resp.statusCode = 404; + resp.end("Not found"); + return; + } + sendZipOfFiles(zipName, files, req, resp); + + function addOneDir(dirEntry) { + var baseName, relPath; + for (baseName in dirEntry.entries) { + relPath = path.join(dirEntry.dirName, baseName); + var dbTrack = self.player.dbFilesByPath[relPath]; + if (dbTrack) files.push(dbTrack.file); + } + for (baseName in dirEntry.dirEntries) { + relPath = path.join(dirEntry.dirName, baseName); + var childEntry = self.player.dirs[relPath]; + if (childEntry) addOneDir(childEntry); + } + } + } + + function sendZipOfFiles(zipName, files, req, resp) { + req.on('close', cleanupEverything); + + resp.setHeader("Content-Type", "application/zip"); + resp.setHeader("Content-Disposition", contentDisposition(zipName, {type: "attachment"})); + + var zipfile = new yazl.ZipFile(); + zipfile.on('error', function(err) { + log.error("Error while sending zip of files:", err.stack); + cleanupEverything(); + }); + + files.forEach(function(file) { + zipfile.addFile(file, path.relative(self.config.musicDirectory, file), {compress: false}); + }); + zipfile.end(function(finalSize) { + resp.setHeader("Content-Length", finalSize.toString()); + zipfile.outputStream.pipe(resp); + }); + + function cleanupEverything() { + resp.end(); + } + } + + function serveStaticMiddleware(req, res, next) { + self.hasPermRead(req, res, function() { + res.setHeader('Content-Disposition', 'attachment'); + serve(req, res, function(err) { + res.removeHeader('Content-Disposition'); + next(err); + }); + }); + } +}; + +GrooveBasin.prototype.initializeUpload = function() { + var self = this; + self.app.post('/upload', self.hasPermAdd, function(request, response, next) { + var form = new MultipartForm({ + maxFields: 100000, // let them eat cake + autoFields: true, + }); + var allDbFiles = []; + var pend = new Pend(); + var autoQueue = false; + var size; + + form.on('error', next); + form.on('part', function(part) { + pend.go(function(cb) { + log.debug("import part", part.filename); + self.player.importStream(part, part.filename, size, function(err, dbFiles) { + if (err) { + log.error("Unable to import stream:", err.stack); + } else if (!dbFiles) { + log.warn("Unable to import stream, unrecognized format"); + } else { + allDbFiles = allDbFiles.concat(dbFiles); + } + log.debug("done importing part", part.filename); + cb(); + }); + }); + }); + form.on('field', function(name, value) { + if (name === 'autoQueue') { + autoQueue = true; + } else if (name === 'size') { + size = parseInt(value, 10); + } + }); + form.on('close', function() { + pend.wait(function() { + if (allDbFiles.length >= 1) { + var user = request.client && request.client.user; + self.playerServer.addEvent(user, 'import', null, allDbFiles[0].key, allDbFiles.length); + if (autoQueue) { + self.player.sortAndQueueTracks(allDbFiles); + self.playerServer.addEvent(user, 'queue', null, allDbFiles[0].key, allDbFiles.length); + } + } + response.json({}); + }); + }); + form.parse(request); + }); +}; + +GrooveBasin.prototype.initializeStream = function() { + var self = this; + self.app.get('/stream.mp3', self.hasPermRead, function(req, resp, next) { + resp.setHeader('Content-Type', 'audio/mpeg'); + resp.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + resp.setHeader('Pragma', 'no-cache'); + resp.setHeader('Expires', '0'); + resp.statusCode = 200; + + self.player.startStreaming(resp); + + req.on('close', function() { + self.player.stopStreaming(resp); + resp.end(); + }); + }); +}; + +GrooveBasin.prototype.createHasPermMiddleware = function(permName) { + var self = this; + return function(req, resp, next) { + var cookies = new Cookies(req, resp); + var token = cookies.get('token'); + var client = self.playerServer.clients[token]; + var hasPermission = self.playerServer.userHasPerm(client && client.user, permName); + if (hasPermission) { + req.client = client; + resp.client = client; + next(); + } else { + resp.statusCode = 403; + resp.end("not authorized"); + } + }; +}; + +GrooveBasin.prototype.startServer = function() { + var self = this; + + assert.ok(self.httpServer == null); + + self.playerServer = new PlayerServer({ + player: self.player, + db: self.db, + }); + + self.hasPermRead = self.createHasPermMiddleware('read'); + self.hasPermAdd = self.createHasPermMiddleware('add'); + self.mpdApiServer = new MpdApiServer(self.player); + + self.initializeDownload(); + self.initializeUpload(); + self.initializeStream(); + + + self.playerServer.init(function(err) { + if (err) throw err; + createHttpServer(attachWebSocketServer); + }); + + function createHttpServer(cb) { + if (!self.config.sslKey || !self.config.sslCert) { + log.warn("WARNING: SSL disabled, using HTTP."); + self.httpServer = http.createServer(self.app); + self.httpProtocol = 'http'; + cb(); + } else { + if (self.config.sslKey === defaultConfig.sslKey || + self.config.sslCert === defaultConfig.sslCert) + { + log.warn("WARNING: Using public self-signed certificate.\n" + + "For better security, provide your own self-signed certificate, \n" + + "or better yet, one signed by a certificate authority.\n"); + } + self.httpProtocol = 'https'; + readKeyAndCert(cb); + } + function readKeyAndCert(cb) { + var pend = new Pend(); + var options = {}; + pend.go(function(cb) { + fs.readFile(self.config.sslKey, function(err, data) { + if (err) { + log.fatal("Unable to read SSL key file: " + err.message); + process.exit(1); + return; + } + options.key = data; + cb(); + }); + }); + pend.go(function(cb) { + fs.readFile(self.config.sslCert, function(err, data) { + if (err) { + log.fatal("Unable to read SSL cert file: " + err.message); + process.exit(1); + return; + } + options.cert = data; + cb(); + }); + }); + pend.wait(function() { + self.httpServer = https.createServer(options, self.app); + cb(); + }); + } + } + + function attachWebSocketServer() { + self.wss = new WebSocketServer({ + server: self.httpServer, + clientTracking: false, + }); + self.wss.on('connection', function(ws) { + self.playerServer.handleNewClient(new WebSocketApiClient(ws)); + }); + self.httpServer.listen(self.httpPort, self.httpHost, function() { + self.emit('listening'); + log.info("Web interface listening at " + self.httpProtocol + "://" + self.httpHost + ":" + self.httpPort + "/"); + }); + self.httpServer.on('close', function() { + log.debug("server closed"); + }); + var mpdPort = self.config.mpdPort; + var mpdHost = self.config.mpdHost; + if (mpdPort == null || mpdHost == null) { + log.info("MPD Protocol disabled"); + } else { + self.protocolServer = net.createServer(function(socket) { + socket.setEncoding('utf8'); + var protocol = new MpdProtocol({ + player: self.player, + playerServer: self.playerServer, + apiServer: self.mpdApiServer, + }); + protocol.on('error', handleError); + socket.on('error', handleError); + socket.pipe(protocol).pipe(socket); + socket.on('close', cleanup); + self.mpdApiServer.handleNewClient(protocol); + + function handleError(err) { + log.error("socket error:", err.stack); + socket.destroy(); + cleanup(); + } + function cleanup() { + self.mpdApiServer.handleClientEnd(protocol); + } + }); + self.protocolServer.on('error', function(err) { + if (err.code === 'EADDRINUSE') { + log.error("Failed to bind MPD protocol to port " + mpdPort + + ": Address in use."); + } else { + throw err; + } + }); + self.protocolServer.listen(mpdPort, mpdHost, function() { + log.info("MPD/GrooveBasin Protocol listening at " + + mpdHost + ":" + mpdPort); + }); + } + } +}; + +function getKey(o) { + return o.key; +} diff --git a/lib/import_url_filters.js b/lib/import_url_filters.js new file mode 100644 index 0000000..e51f90e --- /dev/null +++ b/lib/import_url_filters.js @@ -0,0 +1,112 @@ +var ytdl = require('ytdl-core'); +var url = require('url'); +var log = require('./log'); +var path = require('path'); +var download = require('./download').download; +var parseContentDisposition = require('content-disposition').parse; + +// sorted from worst to best +var YTDL_AUDIO_ENCODINGS = [ + 'mp3', + 'aac', + 'wma', + 'vorbis', + 'wav', + 'flac', +]; + +module.exports = [ + { + name: "YouTube Download", + fn: ytdlImportUrl, + }, + { + name: "Raw Download", + fn: downloadRawImportUrl, + }, +]; + +function ytdlImportUrl(urlString, cb) { + var parsedUrl = url.parse(urlString); + + var isYouTube = (parsedUrl.pathname === '/watch' && + (parsedUrl.hostname === 'youtube.com' || + parsedUrl.hostname === 'www.youtube.com' || + parsedUrl.hostname === 'm.youtube.com')) || + parsedUrl.hostname === 'youtu.be' || + parsedUrl.hostname === 'www.youtu.be'; + + if (!isYouTube) { + cb(); + return; + } + + var bestFormat = null; + ytdl.getInfo(urlString, {downloadURL: true}, gotYouTubeInfo); + + function gotYouTubeInfo(err, info) { + if (err) return cb(err); + for (var i = 0; i < info.formats.length; i += 1) { + var format = info.formats[i]; + if (bestFormat == null || format.audioBitrate > bestFormat.audioBitrate || + (format.audioBitrate === bestFormat.audioBitrate && + YTDL_AUDIO_ENCODINGS.indexOf(format.audioEncoding) > + YTDL_AUDIO_ENCODINGS.indexOf(bestFormat.audioEncoding))) + { + bestFormat = format; + } + } + if (YTDL_AUDIO_ENCODINGS.indexOf(bestFormat.audioEncoding) === -1) { + log.warn("YouTube Import: unrecognized audio format:", bestFormat.audioEncoding); + } + var req = ytdl.downloadFromInfo(info, {filter: filter}); + var filenameHintWithoutPath = info.title + '.' + bestFormat.container; + var callbackCalled = false; + req.on('error', onError); + req.on('format', function(format) { + req.removeListener('error', onError); + if (callbackCalled) return; + callbackCalled = true; + cb(null, req, filenameHintWithoutPath, format.size); + }); + + function onError(err) { + if (callbackCalled) return; + callbackCalled = true; + cb(err); + } + + function filter(format) { + return format.audioBitrate === bestFormat.audioBitrate && + format.audioEncoding === bestFormat.audioEncoding; + } + } +} + +function downloadRawImportUrl(urlString, cb) { + var parsedUrl = url.parse(urlString); + var remoteFilename = path.basename(parsedUrl.pathname); + var decodedFilename; + try { + decodedFilename = decodeURI(remoteFilename); + } catch (err) { + decodedFilename = remoteFilename; + } + download(urlString, function(err, resp) { + if (err) return cb(err); + var contentDisposition = resp.headers['content-disposition']; + if (contentDisposition) { + var filename; + try { + filename = parseContentDisposition(contentDisposition).parameters.filename; + } catch (err) { + // do nothing + } + if (filename) { + decodedFilename = filename; + } + } + var contentLength = parseInt(resp.headers['content-length'], 10); + cb(null, resp, decodedFilename, contentLength); + }); +} diff --git a/lib/log.js b/lib/log.js new file mode 100644 index 0000000..6d45122 --- /dev/null +++ b/lib/log.js @@ -0,0 +1,28 @@ +var levels = { + Fatal: 0, + Error: 1, + Info: 2, + Warn: 3, + Debug: 4, +}; + +exports.levels = levels; +exports.level = levels.Info; +exports.log = log; +exports.fatal = makeLogFn(levels.Fatal); +exports.error = makeLogFn(levels.Error); +exports.info = makeLogFn(levels.Info); +exports.warn = makeLogFn(levels.Warn); +exports.debug = makeLogFn(levels.Debug); + + +function makeLogFn(level) { + return function() { + log.apply(this, [level].concat(Array.prototype.slice.call(arguments, 0))); + }; +} + +function log(level) { + if (level > exports.level) return; + console.error.apply(console, Array.prototype.slice.call(arguments, 1)); +} diff --git a/lib/mpd_api_server.js b/lib/mpd_api_server.js new file mode 100644 index 0000000..cd45738 --- /dev/null +++ b/lib/mpd_api_server.js @@ -0,0 +1,73 @@ +var EventEmitter = require('events').EventEmitter; +var util = require('util'); + +module.exports = MpdApiServer; + +// stuff that is global to all connected mpd clients +util.inherits(MpdApiServer, EventEmitter); +function MpdApiServer(player) { + var self = this; + EventEmitter.call(self); + self.gbIdToMpdId = {}; + self.mpdIdToGbId = {}; + self.nextMpdId = 0; + self.singleMode = false; + self.clients = []; + + player.on('volumeUpdate', onVolumeUpdate); + player.on('repeatUpdate', updateOptionsSubsystem); + player.on('dynamicModeOn', updateOptionsSubsystem); + player.on('queueUpdate', onQueueUpdate); + player.on('deleteDbTrack', updateDatabaseSubsystem); + player.on('addDbTrack', updateDatabaseSubsystem); + player.on('updateDbTrack', updateDatabaseSubsystem); + + function onVolumeUpdate() { + self.subsystemUpdate('mixer'); + } + function onQueueUpdate() { + // TODO make these updates more fine grained + self.subsystemUpdate('playlist'); + self.subsystemUpdate('player'); + } + function updateOptionsSubsystem() { + self.subsystemUpdate('options'); + } + function updateDatabaseSubsystem() { + self.subsystemUpdate('database'); + } +} + +MpdApiServer.prototype.handleClientEnd = function(client) { + var index = this.clients.indexOf(client); + if (index !== -1) this.clients.splice(index, 1); +}; +MpdApiServer.prototype.handleNewClient = function(client) { + this.clients.push(client); +}; + +MpdApiServer.prototype.subsystemUpdate = function(subsystem) { + this.clients.forEach(function(client) { + client.updatedSubsystems[subsystem] = true; + if (client.isIdle) client.handleIdle(); + }); +}; + +MpdApiServer.prototype.toMpdId = function(grooveBasinId) { + var mpdId = this.gbIdToMpdId[grooveBasinId]; + if (!mpdId) { + mpdId = this.nextMpdId++; + this.gbIdToMpdId[grooveBasinId] = mpdId; + this.mpdIdToGbId[mpdId] = grooveBasinId; + } + return mpdId; +}; + +MpdApiServer.prototype.fromMpdId = function(mpdId) { + return this.mpdIdToGbId[mpdId]; +}; + +MpdApiServer.prototype.setSingleMode = function(mode) { + this.singleMode = mode; + this.subsystemUpdate('options'); +}; diff --git a/lib/mpd_protocol.js b/lib/mpd_protocol.js new file mode 100644 index 0000000..e412df2 --- /dev/null +++ b/lib/mpd_protocol.js @@ -0,0 +1,1612 @@ +var Duplex = require('stream').Duplex; +var util = require('util'); +var Player = require('./player'); +var path = require('path'); +var GrooveBasinProtocol = require('./protocol_parser'); +var log = require('./log'); + +module.exports = MpdProtocol; + +var GROOVEBASIN_PROTOCOL_UUID = "b417684f-52af-4880-be4c-cb85593fb736"; + +var ERR_CODE_NOT_LIST = 1; +var ERR_CODE_ARG = 2; +var ERR_CODE_PASSWORD = 3; +var ERR_CODE_PERMISSION = 4; +var ERR_CODE_UNKNOWN = 5; +var ERR_CODE_NO_EXIST = 50; +var ERR_CODE_PLAYLIST_MAX = 51; +var ERR_CODE_SYSTEM = 52; +var ERR_CODE_PLAYLIST_LOAD = 53; +var ERR_CODE_UPDATE_ALREADY = 54; +var ERR_CODE_PLAYER_SYNC = 55; +var ERR_CODE_EXIST = 56; + +var tagTypes = { + file: { + caseCorrect: "File", + grooveTag: "file", + }, + artist: { + caseCorrect: "Artist", + grooveTag: "artistName", + }, + artistsort: { + caseCorrect: "ArtistSort", + grooveTag: "artistName", + sort: true, + }, + album: { + caseCorrect: "Album", + grooveTag: "albumName", + }, + albumartist: { + caseCorrect: "AlbumArtist", + grooveTag: "albumArtistName", + }, + albumartistsort: { + caseCorrect: "AlbumArtistSort", + grooveTag: "albumArtistName", + sort: true, + }, + title: { + caseCorrect: "Title", + grooveTag: "name", + }, + track: { + caseCorrect: "Track", + grooveTag: "track", + }, + name: { + caseCorrect: "Name", + grooveTag: "name", + }, + genre: { + caseCorrect: "Genre", + grooveTag: "genre", + }, + date: { + caseCorrect: "Date", + grooveTag: "year", + }, + composer: { + caseCorrect: "Composer", + grooveTag: "composerName", + }, + performer: { + caseCorrect: "Performer", + grooveTag: "performerName", + }, + disc: { + caseCorrect: "Disc", + grooveTag: "disc", + }, +}; + +var commands = { + "add": { + fn: addCmd, + permission: 'add', + args: [ + { + name: 'uri', + type: 'string', + }, + ], + }, + "addid": { + permission: 'add', + args: [ + { + name: 'uri', + type: 'string', + }, + { + name: 'position', + type: 'integer', + optional: true, + }, + ], + fn: function (self, args, cb) { + var pos = args.position == null ? self.player.tracksInOrder.length : args.position; + var dbFile = self.player.dbFilesByPath[args.uri]; + if (!dbFile) return cb(ERR_CODE_NO_EXIST, "Not found"); + var ids = self.player.insertTracks(pos, [dbFile.key], false); + self.push("Id: " + self.apiServer.toMpdId(ids[0]) + "\n"); + cb(); + }, + }, + "channels": { + permission: 'read', + fn: function (self, args, cb) { + cb(); + }, + }, + "clear": { + permission: 'control', + fn: function (self, args, cb) { + self.player.clearQueue(); + cb(); + }, + }, + "clearerror": { + permission: 'control', + fn: function (self, args, cb) { + cb(); + }, + }, + "close": { + fn: function (self, args, cb) { + self.push(null); + }, + }, + "commands": { + fn: function (self, args, cb) { + for (var commandName in commands) { + self.push("command: " + commandName + "\n"); + } + cb(); + }, + }, + "consume": { + permission: 'control', + args: [ + { + name: 'state', + type: 'boolean', + }, + ], + fn: function (self, args, cb) { + self.player.setDynamicModeOn(args.state); + cb(); + }, + }, + "count": { + permission: 'read', + args: [ + { + name: 'tag', + type: 'string', + }, + { + name: 'needle', + type: 'string', + }, + ], + fn: function (self, args, cb) { + var tagType = tagTypes[args.tag.toLowerCase()]; + if (!tagType) return cb(ERR_CODE_ARG, "incorrect arguments"); + var filters = [{ + field: tagType.grooveTag, + value: args.needle, + }]; + var songs = 0; + forEachMatchingTrack(self, filters, true, function(track) { + songs += 1; + }); + self.push("songs: " + songs + "\n"); + self.push("playtime: 0\n"); + cb(); + }, + }, + "currentsong": { + permission: 'read', + fn: function (self, args, cb) { + var currentTrack = self.player.currentTrack; + if (!currentTrack) return cb(); + + var start = currentTrack.index; + var end = start + 1; + writePlaylistInfo(self, start, end); + cb(); + }, + }, + "delete": { + permission: 'control', + args: [ + { + name: 'indexRange', + type: 'range', + }, + ], + fn: function (self, args, cb) { + var start = args.indexRange.start; + var end = args.indexRange.end; + var ids = []; + for (var i = start; i < end; i += 1) { + var track = self.player.tracksInOrder[i]; + if (!track) { + cb(ERR_CODE_ARG, "Bad song index"); + return; + } + ids.push(track.id); + } + self.player.removeQueueItems(ids); + cb(); + }, + }, + "deleteid": { + permission: 'control', + args: [ + { + name: 'id', + type: 'id', + }, + ], + fn: function(self, args, cb) { + self.player.removeQueueItems([args.id]); + cb(); + }, + }, + "find": { + permission: 'read', + manualArgParsing: true, + fn: function (self, args, cb) { + findOrSearch(self, args, true, cb); + }, + }, + "findadd": { + permission: 'control', + manualArgParsing: true, + fn: function (self, args, cb) { + findOrSearchAdd(self, args, true, cb); + }, + }, + "idle": {}, // handled in a special case + "list": { + permission: 'read', + manualArgParsing: true, + fn: function (self, args, cb) { + if (args.length < 1) { + cb(ERR_CODE_ARG, "too few arguments for \"list\""); + return; + } + var tagTypeId = args[0].toLowerCase(); + if (args.length === 2 && tagTypeId !== 'album') { + cb(ERR_CODE_ARG, "should be \"Album\" for 3 arguments"); + return; + } + if (args.length !== 2 && args.length % 2 !== 1) { + cb(ERR_CODE_ARG, "not able to parse args"); + return; + } + var targetTagType = tagTypes[tagTypeId]; + if (!targetTagType) return cb(ERR_CODE_ARG, "\"" + args[0] + "\" is not known"); + var caseCorrect = targetTagType.caseCorrect; + + var filters = []; + if (args.length === 2) { + filters.push({ + field: 'artistName', + value: args[1], + }); + } else { + for (var i = 1; i < args.length; i += 2) { + var tagType = tagTypes[args[i].toLowerCase()]; + if (!tagType) return cb(ERR_CODE_ARG, "\"" + args[i] + "\" is not known"); + filters.push({ + field: tagType.grooveTag, + value: args[i+1], + }); + } + } + + var set = {}; + forEachMatchingTrack(self, filters, true, function(track) { + var field = track[targetTagType.grooveTag]; + if (!set[field]) { + set[field] = true; + self.push(caseCorrect + ": " + field + "\n"); + } + }); + + cb(); + }, + }, + "listall": { + permission: 'read', + args: [ + { + name: 'uri', + type: 'string', + optional: true, + }, + ], + fn: function (self, args, cb) { + forEachListAll(self, args, writeFileOnly, cb); + + function writeFileOnly(track) { + self.push("file: " + track.file + "\n"); + } + }, + }, + "listallinfo": { + permission: 'read', + args: [ + { + name: 'uri', + type: 'string', + optional: true, + }, + ], + fn: function (self, args, cb) { + forEachListAll(self, args, doWriteTrackInfo, cb); + + function doWriteTrackInfo(track) { + writeTrackInfo(self, track); + } + } + }, + "lsinfo": { + permission: 'read', + args: [ + { + name: 'uri', + type: 'string', + optional: true, + }, + ], + fn: function (self, args, cb) { + var dirName = args.uri || ""; + var dirEntry = self.player.dirs[dirName]; + if (!dirEntry) return cb(ERR_CODE_NO_EXIST, "Not found"); + var baseName, relPath; + var dbFilesByPath = self.player.dbFilesByPath; + for (baseName in dirEntry.entries) { + relPath = path.join(dirName, baseName); + var dbTrack = dbFilesByPath[relPath]; + if (dbTrack) writeTrackInfo(self, dbTrack); + } + for (baseName in dirEntry.dirEntries) { + relPath = path.join(dirName, baseName); + var childEntry = self.player.dirs[relPath]; + self.push("directory: " + relPath + "\n"); + self.push("Last-Modified: " + new Date(childEntry.mtime).toISOString() + "\n"); + } + cb(); + }, + }, + "move": { + permission: 'control', + args: [ + { + name: 'fromRange', + type: 'range', + }, + { + name: 'pos', + type: 'integer', + }, + ], + fn: function (self, args, cb) { + self.player.moveRangeToPos(args.fromRange.start, args.fromRange.end, args.pos); + cb(); + }, + }, + "moveid": { + permission: 'control', + args: [ + { + name: 'id', + type: 'id', + }, + { + name: 'pos', + type: 'integer', + }, + ], + fn: function (self, args, cb) { + self.player.moveIdsToPos([args.id], args.pos); + cb(); + }, + }, + "next": { + permission: 'control', + fn: function (self, args, cb) { + self.player.next(); + cb(); + } + }, + "notcommands": { + fn: function (self, args, cb) { + for (var commandName in commands) { + var cmd = commands[commandName]; + if (cmd.permission != null && !self.havePermission(cmd.permission)) { + self.push("command: " + commandName + "\n"); + } + } + cb(); + }, + }, + "outputs": { + permission: 'read', + fn: function (self, args, cb) { + self.push("outputid: 0\n"); + self.push("outputname: default detected output\n"); + self.push("outputenabled: 1\n"); + self.push("outputid: 1\n"); + self.push("outputname: GrooveBasin HTTP Stream\n"); + self.push("outputenabled: 1\n"); + cb(); + }, + }, + "password": { + args: [ + { + name: 'password', + type: 'string', + }, + ], + fn: function (self, args, cb) { + var user = self.playerServer.getOneLineAuth(args.password); + var success = user != null; + if (!success) return cb(ERR_CODE_PASSWORD, "incorrect password"); + self.user = user; + cb(); + }, + }, + "pause": { + permission: 'control', + args: [ + { + name: 'pause', + type: 'boolean', + optional: true, + }, + ], + fn: function (self, args, cb) { + if (args.pause == null) { + // toggle + if (self.player.isPlaying) { + self.player.pause(); + } else { + self.player.play(); + } + } else { + if (args.pause) { + self.player.pause(); + } else { + self.player.play(); + } + } + cb(); + }, + }, + "ping": { + fn: function (self, args, cb) { + cb(); + } + }, + "play": { + permission: 'control', + fn: function (self, args, cb) { + var currentTrack = self.player.currentTrack; + if ((args.songPos == null || args.songPos === -1) && currentTrack) { + self.player.play(); + cb(); + return; + } + var currentIndex = currentTrack ? currentTrack.index : 0; + var index = (args.songPos == null || args.songPos === -1) ? currentIndex : args.songPos; + self.player.seekToIndex(index, 0); + self.player.play(); + cb(); + }, + args: [ + { + name: 'songPos', + type: 'integer', + optional: true, + }, + ], + }, + "playid": { + permission: 'control', + args: [ + { + name: 'id', + type: 'id', + optional: true, + }, + ], + fn: function (self, args, cb) { + var id = args.id == null ? self.player.tracksInOrder[0].id : args.id; + var item = self.player.playlist[id]; + if (!item) return cb(ERR_CODE_NO_EXIST, "No such song"); + self.player.seek(id, 0); + self.player.play(); + cb(); + }, + }, + "playlist": { + permission: 'read', + fn: function (self, args, cb) { + var trackTable = self.player.libraryIndex.trackTable; + self.player.tracksInOrder.forEach(function(track, index) { + var dbTrack = trackTable[track.key]; + self.push(index + ":file: " + dbTrack.file + "\n"); + }); + cb(); + } + }, + "playlistid": { + permission: 'read', + args: [ + { + name: 'id', + type: 'id', + optional: true, + }, + ], + fn: function (self, args, cb) { + var start = 0; + var end = self.player.tracksInOrder.length; + if (args.id != null) { + start = self.player.playlist[args.id].index; + end = start + 1; + } + writePlaylistInfo(self, start, end); + cb(); + }, + }, + "playlistinfo": { + permission: 'read', + args: [ + { + name: 'indexRange', + type: 'range', + optional: true, + }, + ], + fn: function (self, args, cb) { + var start = 0; + var end = self.player.tracksInOrder.length; + + if (args.indexRange != null) { + start = args.indexRange.start; + end = args.indexRange.end; + } + + writePlaylistInfo(self, start, end); + cb(); + }, + }, + "plchanges": { + permission: 'read', + args: [ + { + name: "version", + type: "integer", + }, + ], + fn: function(self, args, cb) { + writePlaylistInfo(self, 0, self.player.tracksInOrder.length); + cb(); + }, + }, + "plchangesposid": { + permission: 'read', + args: [ + { + name: "version", + type: "integer", + }, + ], + fn: function (self, args, cb) { + var tracksInOrder = self.player.tracksInOrder; + for (var i = 0; i < tracksInOrder.length; i += 1) { + var item = tracksInOrder[i]; + self.push("cpos: " + i + "\n"); + self.push("Id: " + self.apiServer.toMpdId(item.id) + "\n"); + } + cb(); + }, + }, + "previous": { + permission: 'control', + fn: function (self, args, cb) { + self.player.prev(); + cb(); + } + }, + "repeat": { + permission: 'control', + args: [ + { + name: 'on', + type: 'boolean', + }, + ], + fn: function (self, args, cb) { + if (args.on && self.player.repeat === Player.REPEAT_OFF) { + self.player.setRepeat(self.apiServer.singleMode ? Player.REPEAT_ONE : Player.REPEAT_ALL); + } else if (!args.on && self.player.repeat !== Player.REPEAT_OFF) { + self.player.setRepeat(Player.REPEAT_OFF); + } + cb(); + }, + }, + "replay_gain_status": { + permission: 'read', + fn: function (self, args, cb) { + self.push("replay_gain_mode: auto\n"); + cb(); + }, + }, + "rescan": { + permission: 'admin', + args: [ + { + name: 'uri', + type: 'string', + optional: true, + }, + ], + fn: function (self, args, cb) { + handleUpdate(self, args, true, cb); + }, + }, + "search": { + permission: 'read', + manualArgParsing: true, + fn: function (self, args, cb) { + findOrSearch(self, args, false, cb); + }, + }, + "searchadd": { + permission: 'add', + manualArgParsing: true, + fn: function (self, args, cb) { + findOrSearchAdd(self, args, false, cb); + }, + }, + "seek": { + permission: 'control', + args: [ + { + name: 'index', + type: 'integer', + }, + { + name: 'pos', + type: 'float', + }, + ], + fn: function (self, args, cb) { + self.player.seekToIndex(args.index, args.pos); + self.player.play(); + cb(); + }, + }, + "seekcur": { + permission: 'control', + args: [ + { + name: 'pos', + type: 'float', + }, + ], + fn: function (self, args, cb) { + var currentTrack = self.player.currentTrack; + if (!currentTrack) return cb(ERR_CODE_PLAYER_SYNC, "Not playing"); + self.player.seek(currentTrack.id, args.pos); + self.player.play(); + cb(); + }, + }, + "seekid": { + permission: 'control', + args: [ + { + name: 'id', + type: 'id', + }, + { + name: 'pos', + type: 'float', + }, + ], + fn: function (self, args, cb) { + self.player.seek(args.id, args.pos); + self.player.play(); + cb(); + }, + }, + "setvol": { + permission: 'control', + args: [ + { + name: 'vol', + type: 'float', + }, + ], + fn: function (self, args, cb) { + self.player.setVolume(args.vol / 100); + cb(); + }, + }, + "shuffle": { + permission: 'control', + fn: function (self, args, cb) { + self.player.shufflePlaylist(); + cb(); + }, + }, + "single": { + permission: 'control', + args: [ + { + name: 'single', + type: 'boolean', + }, + ], + fn: function (self, args, cb) { + self.apiServer.setSingleMode(args.single); + if (self.apiServer.singleMode && self.player.repeat === Player.REPEAT_ALL) { + self.player.setRepeat(Player.REPEAT_ONE); + } else if (!self.apiServer.singleMode && self.player.repeat === Player.REPEAT_ONE) { + self.player.setRepeat(Player.REPEAT_ALL); + } + cb(); + }, + }, + "stats": { + permission: 'read', + fn: statsCmd, + }, + "status": { + permission: 'read', + fn: statusCmd, + }, + "stop": { + permission: 'control', + fn: function (self, args, cb) { + self.player.stop(); + cb(); + }, + }, + "swap": { + permission: 'control', + args: [ + { + name: 'pos1', + type: 'integer', + }, + { + name: 'pos2', + type: 'integer', + }, + ], + fn: function (self, args, cb) { + swapItems(self, + self.player.tracksInOrder[args.pos1], + self.player.tracksInOrder[args.pos2], cb); + }, + }, + "swapid": { + permission: 'control', + args: [ + { + name: 'id1', + type: 'id', + }, + { + name: 'id2', + type: 'id', + }, + ], + fn: function (self, args, cb) { + swapItems(self, self.player.playlist[args.id1], self.player.playlist[args.id2], cb); + }, + }, + "tagtypes": { + permission: 'read', + fn: function (self, args, cb) { + for (var tagTypeId in tagTypes) { + var tagType = tagTypes[tagTypeId]; + self.push("tagtype: " + tagType.caseCorrect + "\n"); + } + cb(); + }, + }, + "update": { + permission: 'control', + args: [ + { + name: 'uri', + type: 'string', + optional: true, + }, + ], + fn: function (self, args, cb) { + handleUpdate(self, args, false, cb); + }, + }, + "urlhandlers": { + permission: 'read', + fn: function (self, args, cb) { + cb(); // no URL handlers + }, + }, +}; + +var argParsers = { + 'integer': parseInteger, + 'float': parseFloat, + 'range': parseRange, + 'boolean': parseBoolean, + 'string': parseString, + 'id': parseId, +}; + +var stateCount = 0; +var STATE_CMD = stateCount++; +var STATE_CMD_SPACE = stateCount++; +var STATE_ARG = stateCount++; +var STATE_ARG_QUOTE = stateCount++; +var STATE_ARG_ESC = stateCount++; + +var cmdListStateCount = 0; +var CMD_LIST_STATE_NONE = cmdListStateCount++; +var CMD_LIST_STATE_LIST = cmdListStateCount++; + +var bootTime = new Date(); + +util.inherits(MpdProtocol, Duplex); +function MpdProtocol(options) { + var streamOptions = extend(extend({}, options.streamOptions || {}), {decodeStrings: false}); + Duplex.call(this, streamOptions); + this.player = options.player; + this.apiServer = options.apiServer; + this.playerServer = options.playerServer; + this.user = null; + + this.buffer = ""; + this.bufferIndex = 0; + this.cmdListState = CMD_LIST_STATE_NONE; + this.cmdList = []; + this.okMode = false; + this.isIdle = false; + this.commandQueue = []; + this.ongoingCommand = false; + this.grooveBasinProtocol = new GrooveBasinProtocol(options); + this.updatedSubsystems = { + database: false, + update: false, + stored_playlist: false, + playlist: false, + player: false, + mixer: false, + output: false, + options: false, + sticker: false, + subscription: false, + message: false, + }; + this._read = mpdRead; + this._write = mpdWrite; + this.initialize(); +} + +MpdProtocol.prototype.havePermission = function(permName) { + return this.playerServer.userHasPerm(this.user, permName); +}; + +MpdProtocol.prototype.initialize = function() { + this.push("OK MPD 0.19.0\n"); +}; + +MpdProtocol.prototype.upgradeProtocol = function() { + var self = this; + self.apiServer.handleClientEnd(self); + self.grooveBasinProtocol.on('end', function() { + self.push(null); + }); + self.playerServer.handleNewClient(self.grooveBasinProtocol); + self._read = grooveBasinRead; + self._write = grooveBasinWrite; + self.grooveBasinProtocol.on('readable', function() { + var chunk; + while (chunk = self.grooveBasinProtocol.read()) { + self.push(chunk); + } + }); + self.grooveBasinProtocol.write(self.buffer, 'utf8', noop); + self.buffer = ""; +}; + +function grooveBasinRead(size) { } + +function grooveBasinWrite(chunk, encoding, callback) { + this.grooveBasinProtocol.write(chunk, encoding, callback); +} + +function mpdRead(size) {} + +function mpdWrite(chunk, encoding, callback) { + var self = this; + + this.buffer += chunk; + while (this.buffer.length) { + var newlinePos = this.buffer.indexOf("\n", this.bufferIndex); + if (newlinePos === -1) { + this.bufferIndex = this.buffer.length; + callback(); + return; + } + var lineLength = newlinePos - 1; + if (this.buffer[lineLength] !== "\r") lineLength += 1; + + var line = this.buffer.substring(0, lineLength); + this.buffer = this.buffer.substring(newlinePos + 1); + this.bufferIndex = 0; + handleLine(line); + } + callback(); + + function handleLine(line) { + var state = STATE_CMD; + var cmd = ""; + var args = []; + var curArg = ""; + for (var i = 0; i < line.length; i += 1) { + var c = line[i]; + switch (state) { + case STATE_CMD: + if (isSpace(c)) { + state = STATE_CMD_SPACE; + } else { + cmd += c; + } + break; + case STATE_CMD_SPACE: + if (c === '"') { + curArg = ""; + state = STATE_ARG_QUOTE; + } else if (!isSpace(c)) { + curArg = c; + state = STATE_ARG; + } + break; + case STATE_ARG: + if (isSpace(c)) { + args.push(curArg); + curArg = ""; + state = STATE_CMD_SPACE; + } else { + curArg += c; + } + break; + case STATE_ARG_QUOTE: + if (c === '"') { + args.push(curArg); + curArg = ""; + state = STATE_CMD_SPACE; + } else if (c === "\\") { + state = STATE_ARG_ESC; + } else { + curArg += c; + } + break; + case STATE_ARG_ESC: + curArg += c; + state = STATE_ARG_QUOTE; + break; + default: + throw new Error("unrecognized state"); + } + } + if (state === STATE_ARG) { + args.push(curArg); + } + self.commandQueue.push([cmd, args]); + flushQueue(); + } + + function flushQueue() { + if (self.ongoingCommand) return; + var queueItem = self.commandQueue.shift(); + if (!queueItem) return; + var cmd = queueItem[0]; + var args = queueItem[1]; + self.ongoingCommand = true; + handleCommand(cmd, args, function() { + self.ongoingCommand = false; + flushQueue(); + }); + } + + function handleCommand(cmdName, args, cb) { + var cmdIndex = 0; + + switch (self.cmdListState) { + case CMD_LIST_STATE_NONE: + if (cmdName === 'command_list_begin' && args.length === 0) { + self.cmdListState = CMD_LIST_STATE_LIST; + self.cmdList = []; + self.okMode = false; + cb(); + return; + } else if (cmdName === 'command_list_ok_begin' && args.length === 0) { + self.cmdListState = CMD_LIST_STATE_LIST; + self.cmdList = []; + self.okMode = true; + cb(); + return; + } else { + runOneCommand(cmdName, args, 0, function(ok) { + if (ok) self.push("OK\n"); + cb(); + }); + return; + } + break; + case CMD_LIST_STATE_LIST: + if (cmdName === 'command_list_end' && args.length === 0) { + self.cmdListState = CMD_LIST_STATE_NONE; + + runAndCheckOneCommand(); + return; + } else { + self.cmdList.push([cmdName, args]); + cb(); + return; + } + break; + default: + throw new Error("unrecognized state"); + } + + function runAndCheckOneCommand() { + var commandPayload = self.cmdList.shift(); + if (!commandPayload) { + self.push("OK\n"); + cb(); + return; + } + var thisCmdName = commandPayload[0]; + var thisCmdArgs = commandPayload[1]; + runOneCommand(thisCmdName, thisCmdArgs, cmdIndex++, function(ok) { + if (!ok) { + cb(); + return; + } + if (self.okMode) self.push("list_OK\n"); + runAndCheckOneCommand(); + }); + } + + function runOneCommand(cmdName, args, index, cb) { + if (cmdName === 'noidle') { + var ok = self.isIdle; + self.isIdle = false; + cb(ok); + return; + } + if (self.isIdle) { + self.push(null); + cb(false); + return; + } + if (cmdName === 'idle') { + if (!self.havePermission('read')) { + cmdDone(ERR_CODE_PERMISSION, "you don't have permission for \"" + cmdName + "\""); + } else { + self.handleIdle(args); + } + cb(false); + return; + } + if (cmdName === 'protocolupgrade') { + if (args.length !== 1 || args[0] !== GROOVEBASIN_PROTOCOL_UUID) { + cmdDone(ERR_CODE_ARG, "invalid arguments"); + return; + } + self.upgradeProtocol(); + return; + } + execOneCommand(cmdName, args, cmdDone); + + function cmdDone(code, msg) { + if (code) { + log.warn("cmd err:", cmdName, JSON.stringify(args), msg); + if (code === ERR_CODE_UNKNOWN) cmdName = ""; + self.push("ACK [" + code + "@" + index + "] {" + cmdName + "} " + msg + "\n"); + cb(false); + return; + } + cb(true); + } + } + + function execOneCommand(cmdName, args, cb) { + if (!cmdName.length) return cb(ERR_CODE_UNKNOWN, "No command given"); + var cmd = commands[cmdName]; + if (!cmd) return cb(ERR_CODE_UNKNOWN, "unknown command \"" + cmdName + "\""); + + var perm = cmd.permission; + if (perm != null && !self.havePermission(perm)) { + cb(ERR_CODE_PERMISSION, "you don't have permission for \"" + cmdName + "\""); + return; + } + + var argsParam; + if (cmd.manualArgParsing) { + argsParam = args; + } else { + var min = 0; + var max = 0; + var i; + var cmdArgs = cmd.args || []; + for (i = 0; i < cmdArgs.length; i += 1) { + if (!cmdArgs[i].optional) min += 1; + max += 1; + } + if (args.length < min) { + cb(ERR_CODE_ARG, "too few arguments for \"" + cmdName + "\""); + return; + } + if (args.length > max) { + cb(ERR_CODE_ARG, "too many arguments for \"" + cmdName + "\""); + return; + } + var namedArgs = {}; + for (i = 0; i < args.length; i += 1) { + var arg = args[i]; + var argInfo = cmdArgs[i]; + + var parseArg = argParsers[argInfo.type]; + if (!parseArg) throw new Error("unrecognized arg type: " + argInfo.type); + var ret = parseArg.call(self, arg, argInfo); + if (ret.msg) { + cb(ERR_CODE_ARG, ret.msg); + return; + } + namedArgs[argInfo.name] = ret.value; + } + argsParam = namedArgs; + } + log.debug("ok mpd command", cmdName, JSON.stringify(argsParam)); + cmd.fn(self, argsParam, cb); + } + } +} + +MpdProtocol.prototype.handleIdle = function(args) { + var anyUpdated = false; + for (var subsystem in this.updatedSubsystems) { + var isUpdated = this.updatedSubsystems[subsystem]; + if (isUpdated) { + this.push("changed: " + subsystem + "\n"); + anyUpdated = true; + this.updatedSubsystems[subsystem] = false; + } + } + if (anyUpdated) { + this.push("OK\n"); + this.isIdle = false; + return; + } + this.isIdle = true; +}; + +function isSpace(c) { + return c === '\t' || c === ' '; +} + +function parseBoolean(str) { + return { + value: !!parseInt(str, 10), + msg: null, + }; +} + +function parseFloat(str) { + var x = parseInt(str, 10); + return { + value: x, + msg: isNaN(x) ? ("Number expected: " + str) : null, + }; +} + +function parseInteger(str) { + var x = parseInt(str, 10); + return { + value: x, + msg: isNaN(x) ? ("Integer expected: " + str) : null, + }; +} + +function parseRange(str, argInfo) { + var msg = null; + var start = null; + var end = null; + var parts = str.split(":"); + if (parts.length === 2) { + start = parseInt(parts[0], 10); + end = parseInt(parts[1], 10); + } else if (parts.length === 1) { + start = parseInt(parts[0], 10); + if (start === -1 && argInfo.optional) { + return { + value: null, + msg: null, + }; + } + end = start + 1; + } + if (start == null || end == null || isNaN(start) || isNaN(end)) { + msg = "Integer or range expected: " + str; + } else if (start < 0 || end < 0) { + msg = "Number is negative: " + str; + } else if (end < start) { + msg = "Bad song index"; + } + return { + value: { + start: start, + end: end, + }, + msg: msg, + }; +} + +function parseString(str) { + return { + value: str, + msg: null, + }; +} + +function parseId(str) { + var results = parseInteger.call(this, str); + if (results.msg) return results; + var grooveBasinId = this.apiServer.fromMpdId(results.value); + var msg = grooveBasinId ? null : "No such song"; + return { + value: grooveBasinId, + msg: null, + }; +} + +function writeTrackInfo(self, dbTrack) { + self.push("file: " + dbTrack.file + "\n"); + if (dbTrack.mtime != null) { + self.push("Last-Modified: " + new Date(dbTrack.mtime).toISOString() + "\n"); + } + if (dbTrack.duration != null) { + self.push("Time: " + Math.round(dbTrack.duration) + "\n"); + } + if (dbTrack.artistName) { + self.push("Artist: " + dbTrack.artistName + "\n"); + } + if (dbTrack.albumName) { + self.push("Album: " + dbTrack.albumName + "\n"); + } + if (dbTrack.albumArtistName) { + self.push("AlbumArtist: " + dbTrack.albumArtistName + "\n"); + } + if (dbTrack.genre) { + self.push("Genre: " + dbTrack.genre + "\n"); + } + if (dbTrack.name) { + self.push("Title: " + dbTrack.name + "\n"); + } + if (dbTrack.track != null) { + if (dbTrack.trackCount != null) { + self.push("Track: " + dbTrack.track + "/" + dbTrack.trackCount + "\n"); + } else { + self.push("Track: " + dbTrack.track + "\n"); + } + } + if (dbTrack.composerName) { + self.push("Composer: " + dbTrack.composerName + "\n"); + } + if (dbTrack.disc != null) { + if (dbTrack.discCount != null) { + self.push("Disc: " + dbTrack.disc + "/" + dbTrack.discCount + "\n"); + } else { + self.push("Disc: " + dbTrack.disc + "\n"); + } + } + if (dbTrack.year != null) { + self.push("Date: " + dbTrack.year + "\n"); + } +} + +function writePlaylistInfo(self, start, end) { + var trackTable = self.player.libraryIndex.trackTable; + for (var i = start; i < end; i += 1) { + var item = self.player.tracksInOrder[i]; + var track = trackTable[item.key]; + writeTrackInfo(self, track); + self.push("Pos: " + i + "\n"); + self.push("Id: " + self.apiServer.toMpdId(item.id) + "\n"); + } +} + +function forEachMatchingTrack(self, filters, caseSensitive, fn) { + // TODO: support 'in' as tag type + var trackTable = self.player.libraryIndex.trackTable; + if (!caseSensitive) { + filters.forEach(function(filter) { + filter.value = filter.value.toLowerCase(); + }); + } + for (var key in trackTable) { + var track = trackTable[key]; + var matches = false; + for (var filterIndex = 0; filterIndex < filters.length; filterIndex += 1) { + var filter = filters[filterIndex]; + var filterField = String(track[filter.field]); + if (!filterField) continue; + if (!caseSensitive && filterField) filterField = filterField.toLowerCase(); + + /* assumes: + * caseSensitive implies "find" + * !caseSensitive implies "search" + */ + if (caseSensitive) { + if (filterField === filter.value) { + matches = true; + break; + } + } else if (filterField.indexOf(filter.value) > -1) { + matches = true; + break; + } + } + if (matches) fn(track); + } +} + +function forEachListAll(self, args, onTrack, cb) { + var dirName = args.uri || ""; + var dirEntry = self.player.dirs[dirName]; + if (!dirEntry) return cb(ERR_CODE_NO_EXIST, "Not found"); + printOneDir(dirEntry); + cb(); + + function printOneDir(dirEntry) { + var baseName, relPath; + if (dirEntry.dirName) { // exclude root + self.push("directory: " + dirEntry.dirName + "\n"); + self.push("Last-Modified: " + new Date(dirEntry.mtime).toISOString() + "\n"); + } + var dbFilesByPath = self.player.dbFilesByPath; + for (baseName in dirEntry.entries) { + relPath = path.join(dirEntry.dirName, baseName); + var dbTrack = dbFilesByPath[relPath]; + if (dbTrack) onTrack(dbTrack); + } + for (baseName in dirEntry.dirEntries) { + relPath = path.join(dirEntry.dirName, baseName); + var childEntry = self.player.dirs[relPath]; + if (childEntry) { + printOneDir(childEntry); + } + } + } +} + +function parseFindArgs(self, args, caseSensitive, onTrack, cb, onFinish) { + if (args.length < 2) { + cb(ERR_CODE_ARG, "too few arguments for \"find\""); + return; + } + if (args.length % 2 !== 0) { + cb(ERR_CODE_ARG, "incorrect arguments"); + return; + } + var filters = []; + var tagType; + for (var i = 0; i < args.length; i += 2) { + var tagsToSearch = []; + if (args[i].toLowerCase() === "any") { + // Special case the any key. Just search everything. + for (tagType in tagTypes) { + tagsToSearch.push(tagTypes[tagType]); + } + } else { + tagType = tagTypes[args[i].toLowerCase()]; + if (!tagType) return cb(ERR_CODE_ARG, "\"" + args[i] + "\" is not known"); + tagsToSearch.push(tagType); + } + for (var j = 0; j < tagsToSearch.length; j++) { + filters.push({ + field: tagsToSearch[j].grooveTag, + value: args[i+1], + }); + } + forEachMatchingTrack(self, filters, caseSensitive, onTrack); + } + onFinish(); +} + +function handleUpdate(self, args, forceRescan, cb) { + var dirEntry = self.player.dirs[args.uri || ""]; + if (!dirEntry) { + cb(ERR_CODE_ARG, "Malformed path"); + return; + } + self.player.requestUpdateDb(dirEntry.dirName, forceRescan); + self.push("updating_db: 1\n"); + cb(); +} + +function findOrSearch(self, args, caseSensitive, cb) { + parseFindArgs(self, args, caseSensitive, onTrack, cb, cb); + function onTrack(track) { + writeTrackInfo(self, track); + } +} + +function findOrSearchAdd(self, args, caseSensitive, cb) { + var keys = []; + parseFindArgs(self, args, caseSensitive, onTrack, cb, onFinish); + + function onTrack(track) { + keys.push(track.key); + } + + function onFinish() { + self.player.appendTracks(keys, false); + cb(); + } +} + +function swapItems(self, item1, item2, cb) { + if (!item1 || !item2) return cb(ERR_CODE_ARG, "No such song"); + var o = {}; + o[item1.id] = {sortKey: item2.sortKey}; + o[item2.id] = {sortKey: item1.sortKey}; + self.player.moveQueueItems(o); + cb(); +} + +function addCmd(self, args, cb) { + var dbFilesByPath = self.player.dbFilesByPath; + var dbFile = dbFilesByPath[args.uri]; + + if (dbFile) { + self.player.appendTracks([dbFile.key], false); + cb(); + return; + } + + var keys = []; + var dirEntry = self.player.dirs[args.uri]; + if (!dirEntry) return cb(ERR_CODE_NO_EXIST, "Not found"); + addDir(dirEntry); + if (keys.length === 0) { + cb(ERR_CODE_NO_EXIST, "Not found"); + return; + } + self.player.appendTracks(keys, false); + cb(); + + function addDir(dirEntry) { + var baseName; + for (baseName in dirEntry.entries) { + var relPath = path.join(dirEntry.dirName, baseName); + var dbFile = dbFilesByPath[relPath]; + if (dbFile) keys.push(dbFile.key); + } + for (baseName in dirEntry.dirEntries) { + var childEntry = self.player.dirs[path.join(dirEntry.dirName, baseName)]; + addDir(childEntry); + } + } +} + +function statsCmd(self, args, cb) { + var uptime = Math.floor((new Date() - bootTime) / 1000); + + var libraryIndex = self.player.libraryIndex; + var artists = libraryIndex.artistList.length; + var albums = libraryIndex.albumList.length; + var songs = 0; + var trackTable = libraryIndex.trackTable; + var dbPlaytime = 0; + for (var key in trackTable) { + var dbTrack = trackTable[key]; + songs += 1; + dbPlaytime += dbTrack.duration; + } + dbPlaytime = Math.floor(dbPlaytime); + var dbUpdate = Math.floor(new Date().getTime() / 1000); + self.push("artists: " + artists + "\n"); + self.push("albums: " + albums + "\n"); + self.push("songs: " + songs + "\n"); + self.push("uptime: " + uptime + "\n"); + self.push("playtime: 0\n"); // TODO keep track of this? + self.push("db_playtime: " + dbPlaytime + "\n"); + self.push("db_update: " + dbUpdate + "\n"); + + cb(); +} + +function statusCmd(self, args, cb) { + var volume = Math.round(self.player.volume * 100); + + var repeat, single; + switch (self.player.repeat) { + case Player.REPEAT_ONE: + repeat = 1; + single = 1; + break; + case Player.REPEAT_ALL: + repeat = 1; + single = 0; + break; + case Player.REPEAT_OFF: + repeat = 0; + single = +self.apiServer.singleMode; + break; + } + var consume = +self.player.dynamicModeOn; + var playlistLength = self.player.tracksInOrder.length; + var currentTrack = self.player.currentTrack; + var state; + if (self.player.isPlaying) { + state = 'play'; + } else if (currentTrack) { + state = 'pause'; + } else { + state = 'stop'; + } + + var song = null; + var songId = null; + var nextSong = null; + var nextSongId = null; + var elapsed = null; + var time = null; + var trackTable = self.player.libraryIndex.trackTable; + if (currentTrack) { + song = currentTrack.index; + songId = self.apiServer.toMpdId(currentTrack.id); + var nextTrack = self.player.tracksInOrder[currentTrack.index + 1]; + if (nextTrack) { + nextSong = nextTrack.index; + nextSongId = self.apiServer.toMpdId(nextTrack.id); + } + + var dbTrack = trackTable[currentTrack.key]; + elapsed = self.player.getCurPos(); + time = Math.round(elapsed) + ":" + Math.round(dbTrack.duration); + } + + self.push("volume: " + volume + "\n"); + self.push("repeat: " + repeat + "\n"); + self.push("random: 0\n"); + self.push("single: " + single + "\n"); + self.push("consume: " + consume + "\n"); + self.push("playlist: 0\n"); // TODO what to do with this? + self.push("playlistlength: " + playlistLength + "\n"); + self.push("xfade: 0\n"); + self.push("mixrampdb: 0.000000\n"); + self.push("mixrampdelay: nan\n"); + self.push("state: " + state + "\n"); + if (song != null) { + self.push("song: " + song + "\n"); + self.push("songid: " + songId + "\n"); + if (nextSong != null) { + self.push("nextsong: " + nextSong + "\n"); + self.push("nextsongid: " + nextSongId + "\n"); + } + self.push("time: " + time + "\n"); + self.push("elapsed: " + elapsed + "\n"); + self.push("bitrate: 192\n"); // TODO make this not hardcoded? + self.push("audio: 44100:24:2\n"); // TODO make this not hardcoded? + } + + cb(); +} + +function extend(o, src) { + for (var key in src) o[key] = src[key]; + return o; +} + +function noop() {} diff --git a/lib/player.js b/lib/player.js new file mode 100644 index 0000000..2405022 --- /dev/null +++ b/lib/player.js @@ -0,0 +1,2653 @@ +var groove = require('groove'); +var semver = require('semver'); +var EventEmitter = require('events').EventEmitter; +var util = require('util'); +var mkdirp = require('mkdirp'); +var fs = require('fs'); +var uuid = require('./uuid'); +var path = require('path'); +var Pend = require('pend'); +var DedupedQueue = require('./deduped_queue'); +var findit = require('findit2'); +var shuffle = require('mess'); +var mv = require('mv'); +var MusicLibraryIndex = require('music-library-index'); +var keese = require('keese'); +var safePath = require('./safe_path'); +var PassThrough = require('stream').PassThrough; +var url = require('url'); +var dbIterate = require('./db_iterate'); +var log = require('./log'); +var importUrlFilters = require('./import_url_filters'); +var yauzl = require('yauzl'); + +var importFileFilters = [ + { + name: 'zip', + fn: importFileAsZip, + }, + { + name: 'song', + fn: importFileAsSong, + }, +]; + +module.exports = Player; + +ensureGrooveVersionIsOk(); + +var cpuCount = require('os').cpus().length; + +var PLAYER_KEY_PREFIX = "Player."; +var LIBRARY_KEY_PREFIX = "Library."; +var LIBRARY_DIR_PREFIX = "LibraryDir."; +var QUEUE_KEY_PREFIX = "Playlist."; +var PLAYLIST_KEY_PREFIX = "StoredPlaylist."; +var PLAYLIST_META_KEY_PREFIX = "StoredPlaylistMeta."; + +// db: store in the DB +// read: send to clients +// write: accept updates from clients +var DB_PROPS = { + key: { + db: true, + read: true, + write: false, + type: 'string', + }, + name: { + db: true, + read: true, + write: true, + type: 'string', + }, + artistName: { + db: true, + read: true, + write: true, + type: 'string', + }, + albumArtistName: { + db: true, + read: true, + write: true, + type: 'string', + }, + albumName: { + db: true, + read: true, + write: true, + type: 'string', + }, + compilation: { + db: true, + read: true, + write: true, + type: 'boolean', + }, + track: { + db: true, + read: true, + write: true, + type: 'integer', + }, + trackCount: { + db: true, + read: true, + write: true, + type: 'integer', + }, + disc: { + db: true, + read: true, + write: true, + type: 'integer', + }, + discCount: { + db: true, + read: true, + write: true, + type: 'integer', + }, + duration: { + db: true, + read: true, + write: false, + type: 'float', + }, + year: { + db: true, + read: true, + write: true, + type: 'integer', + }, + genre: { + db: true, + read: true, + write: true, + type: 'string', + }, + file: { + db: true, + read: true, + write: false, + type: 'string', + }, + mtime: { + db: true, + read: false, + write: false, + type: 'integer', + }, + replayGainAlbumGain: { + db: true, + read: false, + write: false, + type: 'float', + }, + replayGainAlbumPeak: { + db: true, + read: false, + write: false, + type: 'float', + }, + replayGainTrackGain: { + db: true, + read: false, + write: false, + type: 'float', + }, + replayGainTrackPeak: { + db: true, + read: false, + write: false, + type: 'float', + }, + composerName: { + db: true, + read: true, + write: true, + type: 'string', + }, + performerName: { + db: true, + read: true, + write: true, + type: 'string', + }, + lastQueueDate: { + db: true, + read: false, + write: false, + type: 'date', + }, + fingerprint: { + db: true, + read: false, + write: false, + type: 'array', + }, +}; + +var PROP_TYPE_PARSERS = { + 'string': function(value) { + return value ? String(value) : ""; + }, + 'date': function(value) { + if (!value) return null; + var date = new Date(value); + if (isNaN(date.getTime())) return null; + return date; + }, + 'integer': parseIntOrNull, + 'float': parseFloatOrNull, + 'boolean': function(value) { + return value == null ? null : !!value; + }, + 'array': function(value) { + return Array.isArray(value) ? value : null; + }, +}; + +// how many GrooveFiles to keep open, ready to be decoded +var OPEN_FILE_COUNT = 8; +var PREV_FILE_COUNT = Math.floor(OPEN_FILE_COUNT / 2); +var NEXT_FILE_COUNT = OPEN_FILE_COUNT - PREV_FILE_COUNT; + +var DB_SCALE = Math.log(10.0) * 0.05; +var REPLAYGAIN_PREAMP = 0.75; +var REPLAYGAIN_DEFAULT = 0.25; + +Player.REPEAT_OFF = 0; +Player.REPEAT_ONE = 1; +Player.REPEAT_ALL = 2; + +Player.trackWithoutIndex = trackWithoutIndex; + +util.inherits(Player, EventEmitter); +function Player(db, musicDirectory, encodeQueueDuration) { + EventEmitter.call(this); + this.setMaxListeners(0); + + setGrooveLoggingLevel(); + + this.db = db; + this.musicDirectory = musicDirectory; + this.dbFilesByPath = {}; + this.libraryIndex = new MusicLibraryIndex(); + this.addQueue = new DedupedQueue({ + processOne: this.addToLibrary.bind(this), + // limit to 1 async operation because we're blocking on the hard drive, + // it's faster to read one file at a time. + maxAsync: 1, + }); + + this.dirs = {}; + this.dirScanQueue = new DedupedQueue({ + processOne: this.refreshFilesIndex.bind(this), + // only 1 dir scanning can happen at a time + // we'll pass the dir to scan as the ID so that not more than 1 of the + // same dir can queue up + maxAsync: 1, + }); + this.dirScanQueue.on('error', function(err) { + log.error("library scanning error:", err.stack); + }); + + this.playlist = {}; + this.playlists = {}; + this.currentTrack = null; + this.tracksInOrder = []; // another way to look at playlist + this.grooveItems = {}; // maps groove item id to track + this.seekRequestPos = -1; // set to >= 0 when we want to seek + this.invalidPaths = {}; // files that could not be opened + this.playlistItemDeleteQueue = []; + this.dontBelieveTheEndOfPlaylistSentinelItsATrap = false; + this.queueClearEncodedBuffers = false; + + this.repeat = Player.REPEAT_OFF; + this.desiredPlayerHardwareState = null; // true: normal hardware playback. false: dummy + this.pendingPlayerAttachDetach = null; + this.isPlaying = false; + this.trackStartDate = null; + this.pausedTime = 0; + this.dynamicModeOn = false; + this.dynamicModeHistorySize = 10; + this.dynamicModeFutureSize = 10; + + this.ongoingScans = {}; + this.scanQueue = new DedupedQueue({ + processOne: this.performScan.bind(this), + maxAsync: cpuCount, + }); + + this.headerBuffers = []; + this.recentBuffers = []; + this.newHeaderBuffers = []; + this.openStreamers = []; + this.expectHeaders = true; + // when a streaming client connects we send them many buffers quickly + // in order to get the stream started, then we slow down. + this.encodeQueueDuration = encodeQueueDuration; + + this.groovePlaylist = groove.createPlaylist(); + this.groovePlayer = null; + this.grooveEncoder = groove.createEncoder(); + this.grooveEncoder.encodedBufferSize = 128 * 1024; + + this.detachEncoderTimeout = null; + this.pendingEncoderAttachDetach = null; + this.desiredEncoderAttachState = false; + this.flushEncodedInterval = null; + this.groovePlaylist.pause(); + this.volume = this.groovePlaylist.gain; + this.grooveEncoder.formatShortName = "mp3"; + this.grooveEncoder.codecShortName = "mp3"; + this.grooveEncoder.bitRate = 256 * 1000; + + this.importProgress = {}; + this.lastImportProgressEvent = new Date(); +} + +Player.prototype.initialize = function(cb) { + var self = this; + var startupTrackInfo = null; + + initLibrary(function(err) { + if (err) return cb(err); + cacheTracksArray(self); + self.requestUpdateDb(); + cacheAllOptions(function(err) { + if (err) return cb(err); + setInterval(doPersistCurrentTrack, 10000); + if (startupTrackInfo) { + self.seek(startupTrackInfo.id, startupTrackInfo.pos); + } else { + playlistChanged(self); + } + lazyReplayGainScanPlaylist(self); + cb(); + }); + }); + + function initLibrary(cb) { + var pend = new Pend(); + pend.go(cacheAllDb); + pend.go(cacheAllDirs); + pend.go(cacheAllQueue); + pend.go(cacheAllPlaylists); + pend.wait(cb); + } + + function cacheAllPlaylists(cb) { + cacheAllPlaylistMeta(function(err) { + if (err) return cb(err); + cacheAllPlaylistItems(cb); + }); + + function cacheAllPlaylistMeta(cb) { + dbIterate(self.db, PLAYLIST_META_KEY_PREFIX, processOne, cb); + function processOne(key, value) { + log.debug("key:", key, "value:", value); + var playlist = deserializePlaylist(value); + self.playlists[playlist.id] = playlist; + } + } + + function cacheAllPlaylistItems(cb) { + dbIterate(self.db, PLAYLIST_KEY_PREFIX, processOne, cb); + function processOne(key, value) { + var playlistIdEnd = key.indexOf('.', PLAYLIST_KEY_PREFIX.length); + var playlistId = key.substring(PLAYLIST_KEY_PREFIX.length, playlistIdEnd); + // TODO remove this once verified that it's working + log.debug("playlistId", playlistId); + var playlistItem = JSON.parse(value); + self.playlists[playlistId].items[playlistItem.id] = playlistItem; + } + } + } + + function cacheAllQueue(cb) { + dbIterate(self.db, QUEUE_KEY_PREFIX, processOne, cb); + function processOne(key, value) { + var plEntry = JSON.parse(value); + self.playlist[plEntry.id] = plEntry; + } + } + + function cacheAllOptions(cb) { + var options = { + repeat: null, + dynamicModeOn: null, + dynamicModeHistorySize: null, + dynamicModeFutureSize: null, + hardwarePlayback: null, + volume: null, + currentTrackInfo: null, + }; + var pend = new Pend(); + for (var name in options) { + pend.go(makeGetFn(name)); + } + pend.wait(function(err) { + if (err) return cb(err); + if (options.repeat != null) { + self.setRepeat(options.repeat); + } + if (options.dynamicModeOn != null) { + self.setDynamicModeOn(options.dynamicModeOn); + } + if (options.dynamicModeHistorySize != null) { + self.setDynamicModeHistorySize(options.dynamicModeHistorySize); + } + if (options.dynamicModeFutureSize != null) { + self.setDynamicModeFutureSize(options.dynamicModeFutureSize); + } + if (options.volume != null) { + self.setVolume(options.volume); + } + startupTrackInfo = options.currentTrackInfo; + var hardwarePlaybackValue = options.hardwarePlayback == null ? true : options.hardwarePlayback; + // start the hardware player first + // fall back to dummy + self.setHardwarePlayback(hardwarePlaybackValue, function(err) { + if (err) { + log.error("Unable to attach hardware player, falling back to dummy.", err.stack); + self.setHardwarePlayback(false); + } + cb(); + }); + }); + + function makeGetFn(name) { + return function(cb) { + self.db.get(PLAYER_KEY_PREFIX + name, function(err, value) { + if (!err && value != null) { + try { + options[name] = JSON.parse(value); + } catch (err) { + cb(err); + return; + } + } + cb(); + }); + }; + } + } + + function cacheAllDirs(cb) { + dbIterate(self.db, LIBRARY_DIR_PREFIX, processOne, cb); + function processOne(key, value) { + var dirEntry = JSON.parse(value); + self.dirs[dirEntry.dirName] = dirEntry; + } + } + + function cacheAllDb(cb) { + var scrubCmds = []; + dbIterate(self.db, LIBRARY_KEY_PREFIX, processOne, scrubAndCb); + function processOne(key, value) { + var dbFile = deserializeFileData(value); + // scrub duplicates + if (self.dbFilesByPath[dbFile.file]) { + scrubCmds.push({type: 'del', key: key}); + } else { + self.libraryIndex.addTrack(dbFile); + self.dbFilesByPath[dbFile.file] = dbFile; + } + } + function scrubAndCb() { + if (scrubCmds.length === 0) return cb(); + log.warn("Scrubbing " + scrubCmds.length + " duplicate db entries"); + self.db.batch(scrubCmds, function(err) { + if (err) log.error("Unable to scrub duplicate tracks from db:", err.stack); + cb(); + }); + } + } + + function doPersistCurrentTrack() { + if (self.isPlaying) { + self.persistCurrentTrack(); + } + } +}; + +function startEncoderAttach(self, cb) { + if (self.desiredEncoderAttachState) return; + self.desiredEncoderAttachState = true; + if (!self.pendingEncoderAttachDetach) { + self.pendingEncoderAttachDetach = true; + self.grooveEncoder.attach(self.groovePlaylist, function(err) { + if (err) return cb(err); + self.pendingEncoderAttachDetach = false; + if (!self.desiredEncoderAttachState) startEncoderDetach(self, cb); + }); + } +} + +function startEncoderDetach(self, cb) { + if (!self.desiredEncoderAttachState) return; + self.desiredEncoderAttachState = false; + if (!self.pendingEncoderAttachDetach) { + self.pendingEncoderAttachDetach = true; + self.grooveEncoder.detach(function(err) { + if (err) return cb(err); + self.pendingEncoderAttachDetach = false; + if (self.desiredEncoderAttachState) startEncoderAttach(self, cb); + }); + } +} + +Player.prototype.getBufferedSeconds = function() { + if (this.recentBuffers.length < 2) return 0; + var firstPts = this.recentBuffers[0].pts; + var lastPts = this.recentBuffers[this.recentBuffers.length - 1].pts; + var frameCount = lastPts - firstPts; + var sampleRate = this.grooveEncoder.actualAudioFormat.sampleRate; + return frameCount / sampleRate; +}; + +Player.prototype.attachEncoder = function(cb) { + var self = this; + + cb = cb || logIfError; + + if (self.flushEncodedInterval) return cb(); + + log.debug("first streamer connected - attaching encoder"); + self.flushEncodedInterval = setInterval(flushEncoded, 100); + + startEncoderAttach(self, cb); + + function flushEncoded() { + if (!self.desiredEncoderAttachState || self.pendingEncoderAttachDetach) return; + + var playHead = self.groovePlayer.position(); + if (!playHead.item) return; + + var plItems = self.groovePlaylist.items(); + + // get rid of old items + var buf; + while (buf = self.recentBuffers[0]) { + /* + log.debug(" buf.item " + buf.item.file.filename + "\n" + + "playHead.item " + playHead.item.file.filename + "\n" + + " playHead.pos " + playHead.pos + "\n" + + " buf.pos " + buf.pos); + */ + if (isBufOld(buf)) { + self.recentBuffers.shift(); + } else { + break; + } + } + + // poll the encoder for more buffers until either there are no buffers + // available or we get enough buffered + while (self.getBufferedSeconds() < self.encodeQueueDuration) { + buf = self.grooveEncoder.getBuffer(); + if (!buf) break; + if (buf.buffer) { + if (buf.item) { + if (self.expectHeaders) { + log.debug("encoder: got first non-header"); + self.headerBuffers = self.newHeaderBuffers; + self.newHeaderBuffers = []; + self.expectHeaders = false; + } + self.recentBuffers.push(buf); + for (var i = 0; i < self.openStreamers.length; i += 1) { + self.openStreamers[i].write(buf.buffer); + } + } else if (self.expectHeaders) { + // this is a header + log.debug("encoder: got header"); + self.newHeaderBuffers.push(buf.buffer); + } else { + // it's a footer, ignore the fuck out of it + log.debug("ignoring encoded audio footer"); + } + } else { + // end of playlist sentinel + log.debug("encoder: end of playlist sentinel"); + if (self.queueClearEncodedBuffers) { + self.queueClearEncodedBuffers = false; + self.clearEncodedBuffer(); + self.emit('seek'); + } + self.expectHeaders = true; + } + } + + function isBufOld(buf) { + // typical case + if (buf.item.id === playHead.item.id) { + return playHead.pos > buf.pos; + } + // edge case + var playHeadIndex = -1; + var bufItemIndex = -1; + for (var i = 0; i < plItems.length; i += 1) { + var plItem = plItems[i]; + if (plItem.id === playHead.item.id) { + playHeadIndex = i; + } else if (plItem.id === buf.item.id) { + bufItemIndex = i; + } + } + return playHeadIndex > bufItemIndex; + } + } + + function logIfError(err) { + if (err) { + log.error("Unable to attach encoder:", err.stack); + } + } +}; + +Player.prototype.detachEncoder = function(cb) { + cb = cb || logIfError; + + this.clearEncodedBuffer(); + this.queueClearEncodedBuffers = false; + clearInterval(this.flushEncodedInterval); + this.flushEncodedInterval = null; + startEncoderDetach(this, cb); + this.grooveEncoder.removeAllListeners(); + + function logIfError(err) { + if (err) { + log.error("Unable to attach encoder:", err.stack); + } + } +}; + +Player.prototype.requestUpdateDb = function(dirName, forceRescan, cb) { + var fullPath = path.resolve(this.musicDirectory, dirName || ""); + this.dirScanQueue.add(fullPath, { + dir: fullPath, + forceRescan: forceRescan, + }, cb); +}; + +Player.prototype.refreshFilesIndex = function(args, cb) { + var self = this; + var dir = args.dir; + var forceRescan = args.forceRescan; + var dirWithSlash = ensureSep(dir); + var walker = findit(dirWithSlash, {followSymlinks: true}); + var thisScanId = uuid(); + walker.on('directory', function(fullDirPath, stat, stop) { + var dirName = path.relative(self.musicDirectory, fullDirPath); + var baseName = path.basename(dirName); + if (isFileIgnored(baseName)) { + stop(); + return; + } + var dirEntry = self.getOrCreateDir(dirName, stat); + if (fullDirPath === dirWithSlash) return; // ignore root search path + var parentDirName = path.dirname(dirName); + if (parentDirName === '.') parentDirName = ''; + var parentDirEntry = self.getOrCreateDir(parentDirName); + parentDirEntry.dirEntries[baseName] = thisScanId; + }); + walker.on('file', function(fullPath, stat) { + var relPath = path.relative(self.musicDirectory, fullPath); + var dirName = path.dirname(relPath); + if (dirName === '.') dirName = ''; + var baseName = path.basename(relPath); + if (isFileIgnored(baseName)) return; + var dirEntry = self.getOrCreateDir(dirName); + dirEntry.entries[baseName] = thisScanId; + onAddOrChange(relPath, stat); + }); + walker.on('error', function(err) { + walker.stop(); + cb(err); + }); + walker.on('end', function() { + var dirName = path.relative(self.musicDirectory, dir); + checkDirEntry(self.dirs[dirName]); + cb(); + + function checkDirEntry(dirEntry) { + if (!dirEntry) return; + var id; + var baseName; + var i; + var deletedFiles = []; + var deletedDirs = []; + for (baseName in dirEntry.entries) { + id = dirEntry.entries[baseName]; + if (id !== thisScanId) deletedFiles.push(baseName); + } + for (i = 0; i < deletedFiles.length; i += 1) { + baseName = deletedFiles[i]; + delete dirEntry.entries[baseName]; + onFileMissing(dirEntry, baseName); + } + + for (baseName in dirEntry.dirEntries) { + id = dirEntry.dirEntries[baseName]; + var childEntry = self.dirs[path.join(dirEntry.dirName, baseName)]; + checkDirEntry(childEntry); + if (id !== thisScanId) deletedDirs.push(baseName); + } + for (i = 0; i < deletedDirs.length; i += 1) { + baseName = deletedDirs[i]; + delete dirEntry.dirEntries[baseName]; + onDirMissing(dirEntry, baseName); + } + + self.persistDirEntry(dirEntry); + } + + }); + + function onDirMissing(parentDirEntry, baseName) { + var dirName = path.join(parentDirEntry.dirName, baseName); + log.debug("directory deleted:", dirName); + var dirEntry = self.dirs[dirName]; + var watcher = dirEntry.watcher; + if (watcher) watcher.close(); + delete self.dirs[dirName]; + delete parentDirEntry.dirEntries[baseName]; + } + + function onFileMissing(parentDirEntry, baseName) { + var relPath = path.join(parentDirEntry.dirName, baseName); + log.debug("file deleted:", relPath); + delete parentDirEntry.entries[baseName]; + var dbFile = self.dbFilesByPath[relPath]; + if (dbFile) self.delDbEntry(dbFile); + } + + function onAddOrChange(relPath, stat) { + // check the mtime against the mtime of the same file in the db + var dbFile = self.dbFilesByPath[relPath]; + var fileMtime = stat.mtime.getTime(); + + if (dbFile && !forceRescan) { + var dbMtime = dbFile.mtime; + + if (dbMtime >= fileMtime) { + // the info we have in our db for this file is fresh + return; + } + } + self.addQueue.add(relPath, { + relPath: relPath, + mtime: fileMtime, + }); + } +}; + +Player.prototype.watchDirEntry = function(dirEntry) { + var self = this; + var changeTriggered = null; + var fullDirPath = path.join(self.musicDirectory, dirEntry.dirName); + var watcher; + try { + watcher = fs.watch(fullDirPath, onChange); + watcher.on('error', onWatchError); + } catch (err) { + log.error("Unable to fs.watch:", err.stack); + watcher = null; + } + dirEntry.watcher = watcher; + + function onChange(eventName) { + if (changeTriggered) clearTimeout(changeTriggered); + changeTriggered = setTimeout(function() { + changeTriggered = null; + log.debug("dir updated:", dirEntry.dirName); + self.dirScanQueue.add(fullDirPath, { dir: fullDirPath }); + }, 100); + } + + function onWatchError(err) { + log.error("watch error:", err.stack); + } +}; + +Player.prototype.getOrCreateDir = function (dirName, stat) { + var dirEntry = this.dirs[dirName]; + + if (!dirEntry) { + dirEntry = this.dirs[dirName] = { + dirName: dirName, + entries: {}, + dirEntries: {}, + watcher: null, // will be set just below + mtime: stat && stat.mtime, + }; + } else if (stat && dirEntry.mtime !== stat.mtime) { + dirEntry.mtime = stat.mtime; + } + if (!dirEntry.watcher) this.watchDirEntry(dirEntry); + return dirEntry; +}; + + +Player.prototype.getCurPos = function() { + return this.isPlaying ? + ((new Date() - this.trackStartDate) / 1000.0) : this.pausedTime; +}; + +function startPlayerSwitchDevice(self, wantHardware, cb) { + self.desiredPlayerHardwareState = wantHardware; + if (self.pendingPlayerAttachDetach) return; + + self.pendingPlayerAttachDetach = true; + if (self.groovePlayer) { + self.groovePlayer.removeAllListeners(); + self.groovePlayer.detach(onDetachComplete); + } else { + onDetachComplete(); + } + + function onDetachComplete(err) { + if (err) return cb(err); + self.groovePlayer = groove.createPlayer(); + self.groovePlayer.deviceIndex = wantHardware ? null : groove.DUMMY_DEVICE; + self.groovePlayer.attach(self.groovePlaylist, function(err) { + self.pendingPlayerAttachDetach = false; + if (err) return cb(err); + if (self.desiredPlayerHardwareState !== wantHardware) { + startPlayerSwitchDevice(self, self.desiredPlayerHardwareState, cb); + } else { + cb(); + } + }); + } +} + +Player.prototype.setHardwarePlayback = function(value, cb) { + var self = this; + + cb = cb || logIfError; + value = !!value; + + if (value === self.desiredPlayerHardwareState) return cb(); + + startPlayerSwitchDevice(self, value, function(err) { + if (err) return cb(err); + + self.clearEncodedBuffer(); + self.emit('seek'); + self.groovePlayer.on('nowplaying', onNowPlaying); + self.persistOption('hardwarePlayback', self.desiredPlayerHardwareState); + self.emit('hardwarePlayback', self.desiredPlayerHardwareState); + cb(); + }); + + function onNowPlaying() { + var playHead = self.groovePlayer.position(); + var decodeHead = self.groovePlaylist.position(); + if (playHead.item) { + var nowMs = (new Date()).getTime(); + var posMs = playHead.pos * 1000; + self.trackStartDate = new Date(nowMs - posMs); + self.currentTrack = self.grooveItems[playHead.item.id]; + playlistChanged(self); + self.currentTrackChanged(); + } else if (!decodeHead.item) { + if (!self.dontBelieveTheEndOfPlaylistSentinelItsATrap) { + // both play head and decode head are null. end of playlist. + log.debug("end of playlist"); + self.currentTrack = null; + playlistChanged(self); + self.currentTrackChanged(); + } + } + } + + function logIfError(err) { + if (err) { + log.error("Unable to set hardware playback mode:", err.stack); + } + } +}; + +Player.prototype.startStreaming = function(resp) { + this.headerBuffers.forEach(function(headerBuffer) { + resp.write(headerBuffer); + }); + this.recentBuffers.forEach(function(recentBuffer) { + resp.write(recentBuffer.buffer); + }); + this.cancelDetachEncoderTimeout(); + this.attachEncoder(); + this.openStreamers.push(resp); + this.emit('streamerConnect', resp.client); +}; + +Player.prototype.stopStreaming = function(resp) { + for (var i = 0; i < this.openStreamers.length; i += 1) { + if (this.openStreamers[i] === resp) { + this.openStreamers.splice(i, 1); + this.emit('streamerDisconnect', resp.client); + break; + } + } +}; + +Player.prototype.lastStreamerDisconnected = function() { + log.debug("last streamer disconnected"); + this.startDetachEncoderTimeout(); + if (!this.desiredPlayerHardwareState && this.isPlaying) { + this.emit("autoPause"); + this.pause(); + } +}; + +Player.prototype.cancelDetachEncoderTimeout = function() { + if (this.detachEncoderTimeout) { + clearTimeout(this.detachEncoderTimeout); + this.detachEncoderTimeout = null; + } +}; + +Player.prototype.startDetachEncoderTimeout = function() { + var self = this; + self.cancelDetachEncoderTimeout(); + // we use encodeQueueDuration for the encoder timeout so that we are + // guaranteed to have audio available for the encoder in the case of + // detaching and reattaching the encoder. + self.detachEncoderTimeout = setTimeout(timeout, self.encodeQueueDuration * 1000); + + function timeout() { + if (self.openStreamers.length === 0 && self.isPlaying) { + log.debug("detaching encoder"); + self.detachEncoder(); + } + } +}; + +Player.prototype.deleteFile = function(key) { + var self = this; + var dbFile = self.libraryIndex.trackTable[key]; + if (!dbFile) { + log.error("Error deleting file - no entry:", key); + return; + } + var fullPath = path.join(self.musicDirectory, dbFile.file); + fs.unlink(fullPath, function(err) { + if (err) { + log.error("Error deleting", dbFile.file, err.stack); + } + }); + self.delDbEntry(dbFile); +}; + +Player.prototype.delDbEntry = function(dbFile) { + // delete items from the queue that are being deleted from the library + var deleteQueueItems = []; + for (var queueId in this.playlist) { + var queueItem = this.playlist[queueId]; + if (queueItem.key === dbFile.key) { + deleteQueueItems.push(queueId); + } + } + this.removeQueueItems(deleteQueueItems); + + this.libraryIndex.removeTrack(dbFile.key); + delete this.dbFilesByPath[dbFile.file]; + var baseName = path.basename(dbFile.file); + var parentDirName = path.dirname(dbFile.file); + if (parentDirName === '.') parentDirName = ''; + var parentDirEntry = this.dirs[parentDirName]; + if (parentDirEntry) delete parentDirEntry[baseName]; + this.emit('deleteDbTrack', dbFile); + this.db.del(LIBRARY_KEY_PREFIX + dbFile.key, function(err) { + if (err) { + log.error("Error deleting db entry", dbFile.key, err.stack); + } + }); +}; + +Player.prototype.setVolume = function(value) { + value = Math.min(2.0, value); + value = Math.max(0.0, value); + this.volume = value; + this.groovePlaylist.setGain(value); + this.persistOption('volume', this.volume); + this.emit("volumeUpdate"); +}; + +Player.prototype.importUrl = function(urlString, cb) { + var self = this; + cb = cb || logIfError; + + var filterIndex = 0; + tryImportFilter(); + + function tryImportFilter() { + var importFilter = importUrlFilters[filterIndex]; + if (!importFilter) return cb(); + importFilter.fn(urlString, callNextFilter); + function callNextFilter(err, dlStream, filenameHintWithoutPath, size) { + if (err || !dlStream) { + if (err) { + log.error(importFilter.name + " import filter error, skipping:", err.stack); + } + filterIndex += 1; + tryImportFilter(); + return; + } + self.importStream(dlStream, filenameHintWithoutPath, size, cb); + } + } + + function logIfError(err) { + if (err) { + log.error("Unable to import by URL.", err.stack, "URL:", urlString); + } + } +}; + +Player.prototype.importStream = function(readStream, filenameHintWithoutPath, size, cb) { + var self = this; + var ext = path.extname(filenameHintWithoutPath); + var tmpDir = path.join(self.musicDirectory, '.tmp'); + var id = uuid(); + var destPath = path.join(tmpDir, id + ext); + var calledCallback = false; + var writeStream = null; + var progressTimer = null; + var importEvent = { + id: id, + filenameHintWithoutPath: filenameHintWithoutPath, + bytesWritten: 0, + size: size, + date: new Date(), + }; + + readStream.on('error', cleanAndCb); + self.importProgress[importEvent.id] = importEvent; + self.emit('importStart', importEvent); + + mkdirp(tmpDir, function(err) { + if (calledCallback) return; + if (err) return cleanAndCb(err); + + writeStream = fs.createWriteStream(destPath); + readStream.pipe(writeStream); + progressTimer = setInterval(checkProgress, 100); + writeStream.on('close', onClose); + writeStream.on('error', cleanAndCb); + + function checkProgress() { + importEvent.bytesWritten = writeStream.bytesWritten; + self.maybeEmitImportProgress(); + } + function onClose(){ + if (calledCallback) return; + checkProgress(); + self.importFile(destPath, filenameHintWithoutPath, function(err, dbFiles) { + if (calledCallback) return; + if (err) { + cleanAndCb(err); + } else { + calledCallback = true; + cleanTimer(); + delete self.importProgress[importEvent.id]; + self.emit('importEnd', importEvent); + cb(null, dbFiles); + } + }); + } + }); + + function cleanTimer() { + if (progressTimer) { + clearInterval(progressTimer); + progressTimer = null; + } + } + + function cleanAndCb(err) { + if (writeStream) { + fs.unlink(destPath, onUnlinkDone); + writeStream = null; + } + cleanTimer(); + if (calledCallback) return; + calledCallback = true; + delete self.importProgress[importEvent.id]; + self.emit('importAbort', importEvent); + cb(err); + + function onUnlinkDone(err) { + if (err) { + log.warn("Unable to clean up temp file:", err.stack); + } + } + } +}; + +Player.prototype.importFile = function(srcFullPath, filenameHintWithoutPath, cb) { + var self = this; + cb = cb || logIfError; + var filterIndex = 0; + + log.debug("importFile open file:", srcFullPath); + + tryImportFilter(); + + function tryImportFilter() { + var importFilter = importFileFilters[filterIndex]; + if (!importFilter) return cb(); + importFilter.fn(self, srcFullPath, filenameHintWithoutPath, callNextFilter); + function callNextFilter(err, dbFiles) { + if (err || !dbFiles) { + if (err) { + log.debug(importFilter.name + " import filter error, skipping:", err.message); + } + filterIndex += 1; + tryImportFilter(); + return; + } + cb(null, dbFiles); + } + } + + function logIfError(err) { + if (err) { + log.error("unable to import file:", err.stack); + } + } +}; + +Player.prototype.maybeEmitImportProgress = function() { + var now = new Date(); + var passedTime = now - this.lastImportProgressEvent; + if (passedTime > 500) { + this.lastImportProgressEvent = now; + this.emit("importProgress"); + } +}; + +Player.prototype.persistDirEntry = function(dirEntry, cb) { + cb = cb || logIfError; + this.db.put(LIBRARY_DIR_PREFIX + dirEntry.dirName, serializeDirEntry(dirEntry), cb); + + function logIfError(err) { + if (err) { + log.error("unable to persist db entry:", dirEntry, err.stack); + } + } +}; + +Player.prototype.persist = function(dbFile, cb) { + cb = cb || logIfError; + var prevDbFile = this.libraryIndex.trackTable[dbFile.key]; + this.libraryIndex.addTrack(dbFile); + this.dbFilesByPath[dbFile.file] = dbFile; + this.emit('update', prevDbFile, dbFile); + this.db.put(LIBRARY_KEY_PREFIX + dbFile.key, serializeFileData(dbFile), cb); + + function logIfError(err) { + if (err) { + log.error("unable to persist db entry:", dbFile, err.stack); + } + } +}; + +Player.prototype.persistPlaylistItem = function(playlist, item, cb) { + var key = playlistItemKey(playlist, item); + this.db.put(key, serializePlaylistItem(item), cb || logIfError); + + function logIfError(err) { + if (err) { + log.error("unable to persist playlist item:", item, err.stack); + } + } +}; + +Player.prototype.persistQueueItem = function(item, cb) { + this.db.put(QUEUE_KEY_PREFIX + item.id, serializeQueueItem(item), cb || logIfError); + + function logIfError(err) { + if (err) { + log.error("unable to persist queue item:", item, err.stack); + } + } +}; + +Player.prototype.persistOption = function(name, value, cb) { + this.db.put(PLAYER_KEY_PREFIX + name, JSON.stringify(value), cb || logIfError); + function logIfError(err) { + if (err) { + log.error("unable to persist player option:", err.stack); + } + } +}; + +Player.prototype.addToLibrary = function(args, cb) { + var self = this; + var relPath = args.relPath; + var mtime = args.mtime; + var fullPath = path.join(self.musicDirectory, relPath); + log.debug("addToLibrary open file:", fullPath); + groove.open(fullPath, function(err, file) { + if (err) { + self.invalidPaths[relPath] = err.message; + cb(); + return; + } + var dbFile = self.dbFilesByPath[relPath]; + var eventType = dbFile ? 'updateDbTrack' : 'addDbTrack'; + var filenameHintWithoutPath = path.basename(relPath); + var newDbFile = grooveFileToDbFile(file, filenameHintWithoutPath, dbFile); + newDbFile.file = relPath; + newDbFile.mtime = mtime; + var pend = new Pend(); + pend.go(function(cb) { + log.debug("addToLibrary close file:", file.filename); + file.close(cb); + }); + pend.go(function(cb) { + self.persist(newDbFile, function(err) { + if (err) log.error("Error saving", relPath, "to db:", err.stack); + cb(); + }); + }); + self.emit(eventType, newDbFile); + pend.wait(cb); + }); +}; + +Player.prototype.updateTags = function(obj) { + for (var key in obj) { + var track = this.libraryIndex.trackTable[key]; + if (!track) continue; + var props = obj[key]; + if (!props || typeof props !== 'object') continue; + for (var propName in DB_PROPS) { + var prop = DB_PROPS[propName]; + if (! prop.write) continue; + if (! (propName in props)) continue; + var parser = PROP_TYPE_PARSERS[prop.type]; + track[propName] = parser(props[propName]); + } + this.persist(track); + this.emit('updateDbTrack', track); + } +}; + +Player.prototype.insertTracks = function(index, keys, tagAsRandom) { + if (keys.length === 0) return; + if (index < 0) index = 0; + if (index > this.tracksInOrder.length) index = this.tracksInOrder.length; + + var trackBeforeIndex = this.tracksInOrder[index - 1]; + var trackAtIndex = this.tracksInOrder[index]; + + var prevSortKey = trackBeforeIndex ? trackBeforeIndex.sortKey : null; + var nextSortKey = trackAtIndex ? trackAtIndex.sortKey : null; + + var items = {}; + var ids = []; + keys.forEach(function(key) { + var id = uuid(); + var thisSortKey = keese(prevSortKey, nextSortKey); + prevSortKey = thisSortKey; + items[id] = { + key: key, + sortKey: thisSortKey, + }; + ids.push(id); + }); + this.addItems(items, tagAsRandom); + return ids; +}; + +Player.prototype.appendTracks = function(keys, tagAsRandom) { + return this.insertTracks(this.tracksInOrder.length, keys, tagAsRandom); +}; + +// items looks like {id: {key, sortKey}} +Player.prototype.addItems = function(items, tagAsRandom) { + var self = this; + tagAsRandom = !!tagAsRandom; + for (var id in items) { + var item = items[id]; + var dbFile = self.libraryIndex.trackTable[item.key]; + if (!dbFile) continue; + dbFile.lastQueueDate = new Date(); + self.persist(dbFile); + var queueItem = { + id: id, + key: item.key, + sortKey: item.sortKey, + isRandom: tagAsRandom, + grooveFile: null, + pendingGrooveFile: false, + deleted: false, + }; + self.playlist[id] = queueItem; + self.persistQueueItem(queueItem); + } + playlistChanged(self); + lazyReplayGainScanPlaylist(self); +}; + +Player.prototype.playlistCreate = function(id, name) { + var playlist = { + id: id, + name: name, + items: {}, + }; + this.playlists[playlist.id] = playlist; + this.persistPlaylist(playlist); + this.emit('playlistCreate', playlist); +}; + +Player.prototype.playlistRename = function(playlistId, newName) { + var playlist = this.playlists[playlistId]; + if (!playlist) return; + + playlist.name = newName; + this.persistPlaylist(playlist); + this.emit('playlistUpdate', playlist); +}; + +Player.prototype.playlistDelete = function(playlistIds) { + var delCmds = []; + for (var i = 0; i < playlistIds.length; i += 1) { + var playlistId = playlistIds[i]; + var playlist = this.playlists[playlistId]; + if (!playlist) continue; + + for (var id in playlist.items) { + var item = playlist[id].items[id]; + if (!item) continue; + + delCmds.push({type: 'del', key: playlistItemKey(playlist, item)}); + delete playlist.items[id]; + } + delCmds.push({type: 'del', key: playlistKey(playlist)}); + delete this.playlists[playlistId]; + } + + if (delCmds.length > 0) { + this.db.batch(delCmds, logIfError); + this.emit('playlistDelete'); + } + + function logIfError(err) { + if (err) { + log.error("Error deleting playlist entries from db:", err.stack); + } + } +}; + +Player.prototype.playlistAddItems = function(playlistId, items) { + var playlist = this.playlists[playlistId]; + if (!playlist) return; + + for (var id in items) { + var item = items[id]; + var dbFile = this.libraryIndex.trackTable[item.key]; + if (!dbFile) continue; + var playlistItem = { + id: id, + key: item.key, + sortKey: item.sortKey, + }; + playlist[id] = playlistItem; + this.persistPlaylistItem(playlist, playlistItem); + } + + this.emit('playlistUpdate'); +}; + +Player.prototype.playlistRemoveItems = function(playlistId, ids) { + if (ids.length === 0) return; + + var playlist = this.playlists[playlistId]; + if (!playlist) return; + + var delCmds = []; + for (var i = 0; i < ids.length; i += 1) { + var id = ids[i]; + var item = playlist[id]; + if (!item) continue; + + delCmds.push({type: 'del', key: playlistItemKey(playlist, item)}); + delete playlist[id]; + } + if (delCmds.length > 0) { + this.db.batch(delCmds, logIfError); + this.emit('playlistUpdate', playlist); + } + + function logIfError(err) { + if (err) { + log.error("Error deleting playlist entries from db:", err.stack); + } + } +}; + +// items looks like {id: {sortKey}} +Player.prototype.playlistMoveItems = function(playlistId, items) { + var playlist = this.playlists[playlistId]; + if (!playlist) return; + + for (var id in items) { + var item = playlist[id]; + if (!item) continue; // race conditions, etc. + item.sortKey = items[id].sortKey; + this.persistPlaylistItem(playlist, item); + } + this.emit('playlistUpdate', playlist); +}; + +Player.prototype.persistPlaylist = function(playlist, cb) { + cb = cb || logIfError; + var key = playlistKey(playlist); + var payload = serializePlaylist(playlist); + this.db.put(key, payload, cb); + + function logIfError(err) { + if (err) { + log.error("unable to persist playlist:", err.stack); + } + } +}; + +Player.prototype.clearQueue = function() { + this.removeQueueItems(Object.keys(this.playlist)); +}; + +Player.prototype.shufflePlaylist = function() { + shuffle(this.tracksInOrder); + // fix sortKey and index properties + var nextSortKey = keese(null, null); + for (var i = 0; i < this.tracksInOrder.length; i += 1) { + var track = this.tracksInOrder[i]; + track.index = i; + track.sortKey = nextSortKey; + this.persistQueueItem(track); + nextSortKey = keese(nextSortKey, null); + } + playlistChanged(this); +}; + +Player.prototype.removeQueueItems = function(ids) { + if (ids.length === 0) return; + var delCmds = []; + var currentTrackChanged = false; + for (var i = 0; i < ids.length; i += 1) { + var id = ids[i]; + var item = this.playlist[id]; + if (!item) continue; + + delCmds.push({type: 'del', key: QUEUE_KEY_PREFIX + id}); + + if (item.grooveFile) this.playlistItemDeleteQueue.push(item); + if (item === this.currentTrack) { + var nextPos = this.currentTrack.index + 1; + for (;;) { + var nextTrack = this.tracksInOrder[nextPos]; + var nextTrackId = nextTrack && nextTrack.id; + this.currentTrack = nextTrackId && this.playlist[nextTrack.id]; + if (!this.currentTrack && nextPos < this.tracksInOrder.length) { + nextPos += 1; + continue; + } + break; + } + if (this.currentTrack) { + this.seekRequestPos = 0; + } + currentTrackChanged = true; + } + + delete this.playlist[id]; + } + if (delCmds.length > 0) this.db.batch(delCmds, logIfError); + + playlistChanged(this); + if (currentTrackChanged) { + this.currentTrackChanged(); + } + + function logIfError(err) { + if (err) { + log.error("Error deleting playlist entries from db:", err.stack); + } + } +}; + +// items looks like {id: {sortKey}} +Player.prototype.moveQueueItems = function(items) { + for (var id in items) { + var track = this.playlist[id]; + if (!track) continue; // race conditions, etc. + track.sortKey = items[id].sortKey; + this.persistQueueItem(track); + } + playlistChanged(this); +}; + +Player.prototype.moveRangeToPos = function(startPos, endPos, toPos) { + var ids = []; + for (var i = startPos; i < endPos; i += 1) { + var track = this.tracksInOrder[i]; + if (!track) continue; + + ids.push(track.id); + } + this.moveIdsToPos(ids, toPos); +}; + +Player.prototype.moveIdsToPos = function(ids, toPos) { + var trackBeforeIndex = this.tracksInOrder[toPos - 1]; + var trackAtIndex = this.tracksInOrder[toPos]; + + var prevSortKey = trackBeforeIndex ? trackBeforeIndex.sortKey : null; + var nextSortKey = trackAtIndex ? trackAtIndex.sortKey : null; + + for (var i = 0; i < ids.length; i += 1) { + var id = ids[i]; + var queueItem = this.playlist[id]; + if (!queueItem) continue; + + var thisSortKey = keese(prevSortKey, nextSortKey); + prevSortKey = thisSortKey; + queueItem.sortKey = thisSortKey; + this.persistQueueItem(queueItem); + } + playlistChanged(this); +}; + +Player.prototype.pause = function() { + if (!this.isPlaying) return; + this.isPlaying = false; + this.pausedTime = (new Date() - this.trackStartDate) / 1000; + this.groovePlaylist.pause(); + this.cancelDetachEncoderTimeout(); + playlistChanged(this); + this.currentTrackChanged(); +}; + +Player.prototype.play = function() { + if (!this.currentTrack) { + this.currentTrack = this.tracksInOrder[0]; + } else if (!this.isPlaying) { + this.trackStartDate = new Date(new Date() - this.pausedTime * 1000); + } + this.groovePlaylist.play(); + this.startDetachEncoderTimeout(); + this.isPlaying = true; + playlistChanged(this); + this.currentTrackChanged(); +}; + +// This function should be avoided in favor of seek. Note that it is called by +// some MPD protocol commands, because the MPD protocol is stupid. +Player.prototype.seekToIndex = function(index, pos) { + this.currentTrack = this.tracksInOrder[index]; + this.seekRequestPos = pos; + playlistChanged(this); + this.currentTrackChanged(); +}; + +Player.prototype.seek = function(id, pos) { + this.currentTrack = this.playlist[id]; + this.seekRequestPos = pos; + playlistChanged(this); + this.currentTrackChanged(); +}; + +Player.prototype.next = function() { + this.skipBy(1); +}; + +Player.prototype.prev = function() { + this.skipBy(-1); +}; + +Player.prototype.skipBy = function(amt) { + var defaultIndex = amt > 0 ? -1 : this.tracksInOrder.length; + var currentIndex = this.currentTrack ? this.currentTrack.index : defaultIndex; + var newIndex = currentIndex + amt; + this.seekToIndex(newIndex, 0); +}; + +Player.prototype.setRepeat = function(value) { + value = Math.floor(value); + if (value !== Player.REPEAT_ONE && + value !== Player.REPEAT_ALL && + value !== Player.REPEAT_OFF) + { + return; + } + if (value === this.repeat) return; + this.repeat = value; + this.persistOption('repeat', this.repeat); + playlistChanged(this); + this.emit('repeatUpdate'); +}; + +Player.prototype.setDynamicModeOn = function(value) { + value = !!value; + if (value === this.dynamicModeOn) return; + this.dynamicModeOn = value; + this.persistOption('dynamicModeOn', this.dynamicModeOn); + this.emit('dynamicModeOn'); + this.checkDynamicMode(); +}; + +Player.prototype.setDynamicModeHistorySize = function(value) { + value = Math.floor(value); + if (value === this.dynamicModeHistorySize) return; + this.dynamicModeHistorySize = value; + this.persistOption('dynamicModeHistorySize', this.dynamicModeHistorySize); + this.emit('dynamicModeHistorySize'); + this.checkDynamicMode(); +}; + +Player.prototype.setDynamicModeFutureSize = function(value) { + value = Math.floor(value); + if (value === this.dynamicModeFutureSize) return; + this.dynamicModeFutureSize = value; + this.persistOption('dynamicModeFutureSize', this.dynamicModeFutureSize); + this.emit('dynamicModeFutureSize'); + this.checkDynamicMode(); +}; + +Player.prototype.stop = function() { + this.isPlaying = false; + this.cancelDetachEncoderTimeout(); + this.groovePlaylist.pause(); + this.seekRequestPos = 0; + this.pausedTime = 0; + playlistChanged(this); +}; + +Player.prototype.clearEncodedBuffer = function() { + while (this.recentBuffers.length > 0) { + this.recentBuffers.shift(); + } +}; + +Player.prototype.getSuggestedPath = function(track, filenameHint) { + var p = ""; + if (track.albumArtistName) { + p = path.join(p, safePath(track.albumArtistName)); + } else if (track.compilation) { + p = path.join(p, safePath(this.libraryIndex.variousArtistsName)); + } else if (track.artistName) { + p = path.join(p, safePath(track.artistName)); + } + if (track.albumName) { + p = path.join(p, safePath(track.albumName)); + } + var t = ""; + if (track.track != null) { + t += safePath(zfill(track.track, 2)) + " "; + } + t += safePath(track.name + path.extname(filenameHint)); + return path.join(p, t); +}; + +Player.prototype.queueScan = function(dbFile) { + var self = this; + + var scanKey, scanType; + if (dbFile.albumName) { + scanType = 'album'; + scanKey = self.libraryIndex.getAlbumKey(dbFile); + } else { + scanType = 'track'; + scanKey = dbFile.key; + } + + if (self.scanQueue.idInQueue(scanKey)) { + return; + } + self.scanQueue.add(scanKey, { + type: scanType, + key: scanKey, + }); +}; + +Player.prototype.performScan = function(args, cb) { + var self = this; + var scanType = args.type; + var scanKey = args.key; + + // build list of files we want to open + var dbFilesToOpen; + if (scanType === 'album') { + var albumKey = scanKey; + self.libraryIndex.rebuild(); + var album = self.libraryIndex.albumTable[albumKey]; + if (!album) { + log.warn("wanted to scan album with key", JSON.stringify(albumKey), "but no longer exists."); + cb(); + return; + } + log.debug("Scanning album for loudness:", JSON.stringify(albumKey)); + dbFilesToOpen = album.trackList; + } else if (scanType === 'track') { + var trackKey = scanKey; + var dbFile = self.libraryIndex.trackTable[trackKey]; + if (!dbFile) { + log.warn("wanted to scan track with key", JSON.stringify(trackKey), "but no longer exists."); + cb(); + return; + } + log.debug("Scanning track for loudness:", JSON.stringify(trackKey)); + dbFilesToOpen = [dbFile]; + } else { + throw new Error("unexpected scan type: " + scanType); + } + + // open all the files in the list + var pend = new Pend(); + // we're already doing multiple parallel scans. within each scan let's + // read one thing at a time to avoid slamming the system. + pend.max = 1; + + var grooveFileList = []; + var files = {}; + dbFilesToOpen.forEach(function(dbFile) { + pend.go(function(cb) { + var fullPath = path.join(self.musicDirectory, dbFile.file); + log.debug("performScan open file:", fullPath); + groove.open(fullPath, function(err, file) { + if (err) { + log.error("Error opening", fullPath, "in order to scan:", err.stack); + } else { + var fileInfo; + files[file.id] = fileInfo = { + dbFile: dbFile, + loudnessDone: false, + fingerprintDone: false, + }; + self.ongoingScans[dbFile.key] = fileInfo; + grooveFileList.push(file); + } + cb(); + }); + }); + }); + + var scanPlaylist; + var endOfPlaylistPend = new Pend(); + + var scanDetector; + var scanDetectorAttached = false; + var endOfDetectorCb; + + var scanFingerprinter; + var scanFingerprinterAttached = false; + var endOfFingerprinterCb; + + pend.wait(function() { + // emit this because we updated ongoingScans + self.emit('scanProgress'); + + scanPlaylist = groove.createPlaylist(); + scanPlaylist.setFillMode(groove.ANY_SINK_FULL); + scanDetector = groove.createLoudnessDetector(); + scanFingerprinter = groove.createFingerprinter(); + + scanDetector.on('info', onLoudnessInfo); + scanFingerprinter.on('info', onFingerprinterInfo); + + var pend = new Pend(); + pend.go(attachLoudnessDetector); + pend.go(attachFingerprinter); + pend.wait(onEverythingAttached); + }); + + function onEverythingAttached(err) { + if (err) { + log.error("Error attaching:", err.stack); + cleanupAndCb(); + return; + } + + grooveFileList.forEach(function(file) { + scanPlaylist.insert(file); + }); + + endOfPlaylistPend.wait(function() { + for (var fileId in files) { + var fileInfo = files[fileId]; + var dbFile = fileInfo.dbFile; + self.persist(dbFile); + self.emit('scanComplete', dbFile); + } + cleanupAndCb(); + }); + } + + function attachLoudnessDetector(cb) { + scanDetector.attach(scanPlaylist, function(err) { + if (err) return cb(err); + scanDetectorAttached = true; + endOfPlaylistPend.go(function(cb) { + endOfDetectorCb = cb; + }); + cb(); + }); + } + + function attachFingerprinter(cb) { + scanFingerprinter.attach(scanPlaylist, function(err) { + if (err) return cb(err); + scanFingerprinterAttached = true; + endOfPlaylistPend.go(function(cb) { + endOfFingerprinterCb = cb; + }); + cb(); + }); + } + + function onLoudnessInfo() { + var info; + while (info = scanDetector.getInfo()) { + var gain = groove.loudnessToReplayGain(info.loudness); + var dbFile; + var fileInfo; + if (info.item) { + fileInfo = files[info.item.file.id]; + fileInfo.loudnessDone = true; + dbFile = fileInfo.dbFile; + log.info("loudness scan file complete:", dbFile.name, + "gain", gain, "duration", info.duration); + dbFile.replayGainTrackGain = gain; + dbFile.replayGainTrackPeak = info.peak; + dbFile.duration = info.duration; + checkUpdateGroovePlaylist(self); + self.emit('scanProgress'); + } else { + log.debug("loudness scan complete:", JSON.stringify(scanKey), "gain", gain); + for (var fileId in files) { + fileInfo = files[fileId]; + dbFile = fileInfo.dbFile; + dbFile.replayGainAlbumGain = gain; + dbFile.replayGainAlbumPeak = info.peak; + } + checkUpdateGroovePlaylist(self); + if (endOfDetectorCb) { + endOfDetectorCb(); + endOfDetectorCb = null; + } + return; + } + } + } + + function onFingerprinterInfo() { + var info; + while (info = scanFingerprinter.getInfo()) { + if (info.item) { + var fileInfo = files[info.item.file.id]; + fileInfo.fingerprintDone = true; + var dbFile = fileInfo.dbFile; + log.info("fingerprint scan file complete:", dbFile.name); + dbFile.fingerprint = info.fingerprint; + self.emit('scanProgress'); + } else { + log.debug("fingerprint scan complete:", JSON.stringify(scanKey)); + if (endOfFingerprinterCb) { + endOfFingerprinterCb(); + endOfFingerprinterCb = null; + } + return; + } + } + } + + function cleanupAndCb() { + grooveFileList.forEach(function(file) { + pend.go(function(cb) { + var fileInfo = files[file.id]; + var dbFile = fileInfo.dbFile; + delete self.ongoingScans[dbFile.key]; + log.debug("performScan close file:", file.filename); + file.close(cb); + }); + }); + if (scanDetectorAttached) pend.go(detachLoudnessScanner); + if (scanFingerprinterAttached) pend.go(detachFingerprinter); + pend.wait(function(err) { + // emit this because we changed ongoingScans above + self.emit('scanProgress'); + cb(err); + }); + } + + function detachLoudnessScanner(cb) { + scanDetector.detach(cb); + } + + function detachFingerprinter(cb) { + scanFingerprinter.detach(cb); + } +}; + +Player.prototype.checkDynamicMode = function() { + var self = this; + if (!self.dynamicModeOn) return; + + // if no track is playing, assume the first track is about to be + var currentIndex = self.currentTrack ? self.currentTrack.index : 0; + + var deleteCount = Math.max(currentIndex - self.dynamicModeHistorySize, 0); + if (self.dynamicModeHistorySize < 0) deleteCount = 0; + var addCount = Math.max(self.dynamicModeFutureSize + 1 - (self.tracksInOrder.length - currentIndex), 0); + + var idsToDelete = []; + for (var i = 0; i < deleteCount; i += 1) { + idsToDelete.push(self.tracksInOrder[i].id); + } + var keys = getRandomSongKeys(addCount); + self.removeQueueItems(idsToDelete); + self.appendTracks(keys, true); + + function getRandomSongKeys(count) { + if (count === 0) return []; + var neverQueued = []; + var sometimesQueued = []; + for (var key in self.libraryIndex.trackTable) { + var dbFile = self.libraryIndex.trackTable[key]; + if (dbFile.lastQueueDate == null) { + neverQueued.push(dbFile); + } else { + sometimesQueued.push(dbFile); + } + } + // backwards by time + sometimesQueued.sort(function(a, b) { + return b.lastQueueDate - a.lastQueueDate; + }); + // distribution is a triangle for ever queued, and a rectangle for never queued + // ___ + // /| | + // / | | + // /__|_| + var maxWeight = sometimesQueued.length; + var triangleArea = Math.floor(maxWeight * maxWeight / 2); + if (maxWeight === 0) maxWeight = 1; + var rectangleArea = maxWeight * neverQueued.length; + var totalSize = triangleArea + rectangleArea; + if (totalSize === 0) return []; + // decode indexes through the distribution shape + var keys = []; + for (var i = 0; i < count; i += 1) { + var index = Math.random() * totalSize; + if (index < triangleArea) { + // triangle + keys.push(sometimesQueued[Math.floor(Math.sqrt(index))].key); + } else { + keys.push(neverQueued[Math.floor((index - triangleArea) / maxWeight)].key); + } + } + return keys; + } +}; + +Player.prototype.currentTrackChanged = function() { + this.persistCurrentTrack(); + this.emit('currentTrack'); +}; + +Player.prototype.persistCurrentTrack = function(cb) { + // save the current track and time to db + var currentTrackInfo = { + id: this.currentTrack && this.currentTrack.id, + pos: this.getCurPos(), + }; + this.persistOption('currentTrackInfo', currentTrackInfo, cb); +}; + +Player.prototype.sortAndQueueTracks = function(tracks) { + // given an array of tracks, sort them according to the library sorting + // and then queue them in the best place + if (!tracks.length) return; + var sortedTracks = sortTracks(tracks); + this.queueTracks(sortTracks(tracks)); +}; + +Player.prototype.queueTracks = function(tracks, previousKey, nextKey) { + // given an array of tracks, and a previous sort key and a next sort key, + // call addItems correctly + if (!tracks.length) return; + if (previousKey == null && nextKey == null) { + var defaultPos = this.getDefaultQueuePosition(); + previousKey = defaultPos.previousKey; + nextKey = defaultPos.nextKey; + } + + var items = {}; + for (var i = 0; i < tracks.length; i += 1) { + var track = tracks[i]; + var sortKey = keese(previousKey, nextKey); + var id = uuid(); + items[id] = { + key: track.key, + sortKey: sortKey, + }; + previousKey = sortKey; + } + this.addItems(items, false); +}; + +Player.prototype.getDefaultQueuePosition = function() { + var previousKey = this.currentTrack && this.currentTrack.sortKey; + var nextKey = null; + var startPos = this.currentTrack ? this.currentTrack.index + 1 : 0; + for (var i = startPos; i < this.tracksInOrder.length; i += 1) { + var track = this.tracksInOrder[i]; + var sortKey = track.sortKey; + if (track.isRandom) { + nextKey = sortKey; + break; + } + previousKey = sortKey; + } + return { + previousKey: previousKey, + nextKey: nextKey + }; +}; + +function operatorCompare(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} + +function disambiguateSortKeys(self) { + var previousUniqueKey = null; + var previousKey = null; + self.tracksInOrder.forEach(function(track, i) { + if (track.sortKey === previousKey) { + // move the repeat back + track.sortKey = keese(previousUniqueKey, track.sortKey); + previousUniqueKey = track.sortKey; + } else { + previousUniqueKey = previousKey; + previousKey = track.sortKey; + } + }); +} + +// generate self.tracksInOrder from self.playlist +function cacheTracksArray(self) { + self.tracksInOrder = Object.keys(self.playlist).map(trackById); + self.tracksInOrder.sort(asc); + self.tracksInOrder.forEach(function(track, index) { + track.index = index; + }); + + function asc(a, b) { + return operatorCompare(a.sortKey, b.sortKey); + } + function trackById(id) { + return self.playlist[id]; + } +} + +function lazyReplayGainScanPlaylist(self) { + // clear the queue since we're going to completely rebuild it anyway + // this allows the following priority code to work. + self.scanQueue.clear(); + + // prioritize the currently playing track, followed by the next tracks, + // followed by the previous tracks + var albumGain = {}; + var start1 = self.currentTrack ? self.currentTrack.index : 0; + var i; + for (i = start1; i < self.tracksInOrder.length; i += 1) { + checkScan(self.tracksInOrder[i]); + } + for (i = 0; i < start1; i += 1) { + checkScan(self.tracksInOrder[i]); + } + + function checkScan(track) { + var dbFile = self.libraryIndex.trackTable[track.key]; + if (!dbFile) return; + var albumKey = self.libraryIndex.getAlbumKey(dbFile); + var needScan = + dbFile.fingerprint == null || + dbFile.replayGainAlbumGain == null || + dbFile.replayGainTrackGain == null || + (dbFile.albumName && albumGain[albumKey] && albumGain[albumKey] !== dbFile.replayGainAlbumGain); + if (needScan) { + self.queueScan(dbFile); + } else { + albumGain[albumKey] = dbFile.replayGainAlbumGain; + } + } +} + +function playlistChanged(self) { + cacheTracksArray(self); + disambiguateSortKeys(self); + + if (self.currentTrack) { + self.tracksInOrder.forEach(function(track, index) { + var prevDiff = self.currentTrack.index - index; + var nextDiff = index - self.currentTrack.index; + var withinPrev = prevDiff <= PREV_FILE_COUNT && prevDiff >= 0; + var withinNext = nextDiff <= NEXT_FILE_COUNT && nextDiff >= 0; + var shouldHaveGrooveFile = withinPrev || withinNext; + var hasGrooveFile = track.grooveFile != null || track.pendingGrooveFile; + if (hasGrooveFile && !shouldHaveGrooveFile) { + self.playlistItemDeleteQueue.push(track); + } else if (!hasGrooveFile && shouldHaveGrooveFile) { + preloadFile(self, track); + } + }); + } else { + self.isPlaying = false; + self.cancelDetachEncoderTimeout(); + self.trackStartDate = null; + self.pausedTime = 0; + } + checkUpdateGroovePlaylist(self); + performGrooveFileDeletes(self); + + self.checkDynamicMode(); + + self.emit('queueUpdate'); +} + +function performGrooveFileDeletes(self) { + while (self.playlistItemDeleteQueue.length) { + var item = self.playlistItemDeleteQueue.shift(); + + // we set this so that any callbacks that return which were trying to + // set the grooveItem can check if the item got deleted + item.deleted = true; + + if (!item.grooveFile) continue; + + log.debug("performGrooveFileDeletes close file:", item.grooveFile.filename); + var grooveFile = item.grooveFile; + item.grooveFile = null; + closeFile(grooveFile); + } +} + +function preloadFile(self, track) { + var relPath = self.libraryIndex.trackTable[track.key].file; + var fullPath = path.join(self.musicDirectory, relPath); + track.pendingGrooveFile = true; + + log.debug("preloadFile open file:", fullPath); + + // set this so that we know we want the file preloaded + track.deleted = false; + + groove.open(fullPath, function(err, file) { + track.pendingGrooveFile = false; + if (err) { + log.error("Error opening", relPath, err.stack); + return; + } + if (track.deleted) { + log.debug("preloadFile close file (already deleted):", file.filename); + closeFile(file); + return; + } + track.grooveFile = file; + checkUpdateGroovePlaylist(self); + }); +} + +function checkUpdateGroovePlaylist(self) { + if (!self.currentTrack) { + self.groovePlaylist.clear(); + self.grooveItems = {}; + return; + } + + var groovePlaylist = self.groovePlaylist.items(); + var playHead = self.groovePlayer.position(); + var playHeadItemId = playHead.item && playHead.item.id; + var groovePlIndex = 0; + var grooveItem; + + if (playHeadItemId) { + while (groovePlIndex < groovePlaylist.length) { + grooveItem = groovePlaylist[groovePlIndex]; + if (grooveItem.id === playHeadItemId) break; + // this groove playlist item is before the current playhead. delete it! + self.groovePlaylist.remove(grooveItem); + delete self.grooveItems[grooveItem.id]; + groovePlIndex += 1; + } + } + + var plItemIndex = self.currentTrack.index; + var plTrack; + var currentGrooveItem = null; // might be different than playHead.item + var groovePlItemCount = 0; + var gainAndPeak; + while (groovePlIndex < groovePlaylist.length) { + grooveItem = groovePlaylist[groovePlIndex]; + var grooveTrack = self.grooveItems[grooveItem.id]; + // now we have deleted all items before the current track. we are now + // comparing the libgroove playlist and the groovebasin playlist + // side by side. + plTrack = self.tracksInOrder[plItemIndex]; + if (grooveTrack === plTrack) { + // if they're the same, we advance + // but we might have to correct the gain + gainAndPeak = calcGainAndPeak(plTrack); + self.groovePlaylist.setItemGain(grooveItem, gainAndPeak.gain); + self.groovePlaylist.setItemPeak(grooveItem, gainAndPeak.peak); + currentGrooveItem = currentGrooveItem || grooveItem; + groovePlIndex += 1; + incrementPlIndex(); + continue; + } + + // this groove track is wrong. delete it. + self.groovePlaylist.remove(grooveItem); + delete self.grooveItems[grooveItem.id]; + groovePlIndex += 1; + } + + // we still need to add more libgroove playlist items, but this one has + // not yet finished loading from disk. We must take note of this so that + // if we receive the end of playlist sentinel, we start playback again + // once this track has finished loading. + self.dontBelieveTheEndOfPlaylistSentinelItsATrap = true; + while (groovePlItemCount < NEXT_FILE_COUNT) { + plTrack = self.tracksInOrder[plItemIndex]; + if (!plTrack) { + // we hit the end of the groove basin playlist. we're done adding tracks + // to the libgroove playlist. + self.dontBelieveTheEndOfPlaylistSentinelItsATrap = false; + break; + } + if (!plTrack.grooveFile) { + break; + } + // compute the gain adjustment + gainAndPeak = calcGainAndPeak(plTrack); + grooveItem = self.groovePlaylist.insert(plTrack.grooveFile, gainAndPeak.gain, gainAndPeak.peak); + self.grooveItems[grooveItem.id] = plTrack; + currentGrooveItem = currentGrooveItem || grooveItem; + incrementPlIndex(); + } + + if (currentGrooveItem && self.seekRequestPos >= 0) { + var seekPos = self.seekRequestPos; + // we want to clear encoded buffers after the seek completes, e.g. after + // we get the end of playlist sentinel + self.clearEncodedBuffer(); + self.queueClearEncodedBuffers = true; + self.groovePlaylist.seek(currentGrooveItem, seekPos); + self.seekRequestPos = -1; + if (self.isPlaying) { + var nowMs = (new Date()).getTime(); + var posMs = seekPos * 1000; + self.trackStartDate = new Date(nowMs - posMs); + } else { + self.pausedTime = seekPos; + } + self.currentTrackChanged(); + } + + function calcGainAndPeak(plTrack) { + // if the previous item is the previous item from the album, or the + // next item is the next item from the album, use album replaygain. + // else, use track replaygain. + var dbFile = self.libraryIndex.trackTable[plTrack.key]; + var albumMode = albumInfoMatch(-1) || albumInfoMatch(1); + + var gain = REPLAYGAIN_PREAMP; + var peak; + if (dbFile.replayGainAlbumGain != null && albumMode) { + gain *= dBToFloat(dbFile.replayGainAlbumGain); + peak = dbFile.replayGainAlbumPeak || 1.0; + } else if (dbFile.replayGainTrackGain != null) { + gain *= dBToFloat(dbFile.replayGainTrackGain); + peak = dbFile.replayGainTrackPeak || 1.0; + } else { + gain *= REPLAYGAIN_DEFAULT; + peak = 1.0; + } + return {gain: gain, peak: peak}; + + function albumInfoMatch(dir) { + var otherPlTrack = self.tracksInOrder[plTrack.index + dir]; + if (!otherPlTrack) return false; + + var otherDbFile = self.libraryIndex.trackTable[otherPlTrack.key]; + if (!otherDbFile) return false; + + var albumMatch = self.libraryIndex.getAlbumKey(dbFile) === self.libraryIndex.getAlbumKey(otherDbFile); + if (!albumMatch) return false; + + // if there are no track numbers then it's hardly an album, is it? + if (dbFile.track == null || otherDbFile.track == null) { + return false; + } + + var trackMatch = dbFile.track + dir === otherDbFile.track; + if (!trackMatch) return false; + + return true; + } + } + + function incrementPlIndex() { + groovePlItemCount += 1; + if (self.repeat !== Player.REPEAT_ONE) { + plItemIndex += 1; + if (self.repeat === Player.REPEAT_ALL && plItemIndex >= self.tracksInOrder.length) { + plItemIndex = 0; + } + } + } +} + +function isFileIgnored(basename) { + return (/^\./).test(basename) || (/~$/).test(basename); +} + +function deserializeFileData(dataStr) { + var dbFile = JSON.parse(dataStr); + for (var propName in DB_PROPS) { + var propInfo = DB_PROPS[propName]; + if (!propInfo) continue; + var parser = PROP_TYPE_PARSERS[propInfo.type]; + dbFile[propName] = parser(dbFile[propName]); + } + return dbFile; +} + +function serializeQueueItem(item) { + return JSON.stringify({ + id: item.id, + key: item.key, + sortKey: item.sortKey, + isRandom: item.isRandom, + }); +} + +function serializePlaylistItem(item) { + return JSON.stringify({ + id: item.id, + key: item.key, + sortKey: item.sortKey, + }); +} + +function trackWithoutIndex(category, dbFile) { + var out = {}; + for (var propName in DB_PROPS) { + var prop = DB_PROPS[propName]; + if (!prop[category]) continue; + // save space by leaving out null and undefined values + var value = dbFile[propName]; + if (value == null) continue; + out[propName] = value; + } + return out; +} + +function serializeFileData(dbFile) { + return JSON.stringify(trackWithoutIndex('db', dbFile)); +} + +function serializeDirEntry(dirEntry) { + return JSON.stringify({ + dirName: dirEntry.dirName, + entries: dirEntry.entries, + dirEntries: dirEntry.dirEntries, + mtime: dirEntry.mtime, + }); +} + +function filenameWithoutExt(filename) { + var ext = path.extname(filename); + return filename.substring(0, filename.length - ext.length); +} + +function closeFile(file) { + file.close(function(err) { + if (err) { + log.error("Error closing", file, err.stack); + } + }); +} + +function parseTrackString(trackStr) { + if (!trackStr) return {}; + var parts = trackStr.split('/'); + if (parts.length > 1) { + return { + value: parseIntOrNull(parts[0]), + total: parseIntOrNull(parts[1]), + }; + } + return { + value: parseIntOrNull(parts[0]), + }; +} + +function parseIntOrNull(n) { + n = parseInt(n, 10); + if (isNaN(n)) return null; + return n; +} + +function parseFloatOrNull(n) { + n = parseFloat(n); + if (isNaN(n)) return null; + return n; +} + +function grooveFileToDbFile(file, filenameHintWithoutPath, object) { + object = object || {key: uuid()}; + var parsedTrack = parseTrackString(file.getMetadata("track")); + var parsedDisc = parseTrackString(file.getMetadata("disc") || file.getMetadata("TPA")); + object.name = (file.getMetadata("title") || filenameWithoutExt(filenameHintWithoutPath) || "").trim(); + object.artistName = (file.getMetadata("artist") || "").trim(); + object.composerName = (file.getMetadata("composer") || + file.getMetadata("TCM") || "").trim(); + object.performerName = (file.getMetadata("performer") || "").trim(); + object.albumArtistName = (file.getMetadata("album_artist") || "").trim(); + object.albumName = (file.getMetadata("album") || "").trim(); + object.compilation = !!(parseInt(file.getMetadata("TCP"), 10) || + parseInt(file.getMetadata("TCMP"), 10)); + object.track = parsedTrack.value; + object.trackCount = parsedTrack.total; + object.disc = parsedDisc.value; + object.discCount = parsedDisc.total; + object.duration = file.duration(); + object.year = parseIntOrNull(file.getMetadata("date")); + object.genre = file.getMetadata("genre"); + object.replayGainTrackGain = parseFloatOrNull(file.getMetadata("REPLAYGAIN_TRACK_GAIN")); + object.replayGainTrackPeak = parseFloatOrNull(file.getMetadata("REPLAYGAIN_TRACK_PEAK")); + object.replayGainAlbumGain = parseFloatOrNull(file.getMetadata("REPLAYGAIN_ALBUM_GAIN")); + object.replayGainAlbumPeak = parseFloatOrNull(file.getMetadata("REPLAYGAIN_ALBUM_PEAK")); + return object; +} + +function uniqueFilename(filename) { + // break into parts + var dirname = path.dirname(filename); + var basename = path.basename(filename); + var extname = path.extname(filename); + + var withoutExt = basename.substring(0, basename.length - extname.length); + + var match = withoutExt.match(/_(\d+)$/); + var withoutMatch; + var number; + if (match) { + number = parseInt(match[1], 10); + if (!number) number = 0; + withoutMatch = withoutExt.substring(0, match.index); + } else { + number = 0; + withoutMatch = withoutExt; + } + + number += 1; + + // put it back together + var newBasename = withoutMatch + "_" + number + extname; + return path.join(dirname, newBasename); +} + +function dBToFloat(dB) { + return Math.exp(dB * DB_SCALE); +} + +function ensureSep(dir) { + return (dir[dir.length - 1] === path.sep) ? dir : (dir + path.sep); +} + +function ensureGrooveVersionIsOk() { + var ver = groove.getVersion(); + var verStr = ver.major + '.' + ver.minor + '.' + ver.patch; + var reqVer = '>=4.1.1'; + + if (semver.satisfies(verStr, reqVer)) return; + + log.fatal("Found libgroove", verStr, "need", reqVer); + process.exit(1); +} + +function playlistItemKey(playlist, item) { + return PLAYLIST_KEY_PREFIX + playlist.id + '.' + item.id; +} + +function playlistKey(playlist) { + return PLAYLIST_META_KEY_PREFIX + playlist.id; +} + +function serializePlaylist(playlist) { + return JSON.stringify({ + id: playlist.id, + name: playlist.name, + }); +} + +function deserializePlaylist(str) { + var playlist = JSON.parse(str); + playlist.items = {}; + return playlist; +} + +function zfill(number, size) { + number = String(number); + while (number.length < size) number = "0" + number; + return number; +} + +function setGrooveLoggingLevel() { + switch (log.level) { + case log.levels.Fatal: + groove.setLogging(groove.LOG_QUIET); + break; + case log.levels.Error: + groove.setLogging(groove.LOG_QUIET); + break; + case log.levels.Info: + groove.setLogging(groove.LOG_QUIET); + break; + case log.levels.Warn: + groove.setLogging(groove.LOG_WARNING); + break; + case log.levels.Debug: + groove.setLogging(groove.LOG_INFO); + break; + } +} + +function importFileAsSong(self, srcFullPath, filenameHintWithoutPath, cb) { + groove.open(srcFullPath, function(err, file) { + if (err) return cb(err); + var newDbFile = grooveFileToDbFile(file, filenameHintWithoutPath); + var suggestedPath = self.getSuggestedPath(newDbFile, filenameHintWithoutPath); + var pend = new Pend(); + pend.go(function(cb) { + log.debug("importFileAsSong close file:", file.filename); + file.close(cb); + }); + pend.go(function(cb) { + tryMv(suggestedPath, cb); + }); + pend.wait(function(err) { + if (err) return cb(err); + cb(null, [newDbFile]); + }); + + function tryMv(destRelPath, cb) { + var destFullPath = path.join(self.musicDirectory, destRelPath); + mv(srcFullPath, destFullPath, {mkdirp: true, clobber: false}, function(err) { + if (err) { + if (err.code === 'EEXIST') { + tryMv(uniqueFilename(destRelPath), cb); + } else { + cb(err); + } + return; + } + // in case it doesn't get picked up by a watcher + self.requestUpdateDb(path.dirname(destRelPath), false, function(err) { + if (err) return cb(err); + self.addQueue.waitForId(destRelPath, function(err) { + if (err) return cb(err); + newDbFile = self.dbFilesByPath[destRelPath]; + cb(); + }); + }); + }); + } + }); +} + +function importFileAsZip(self, srcFullPath, filenameHintWithoutPath, cb) { + yauzl.open(srcFullPath, function(err, zipfile) { + if (err) return cb(err); + var allDbFiles = []; + var pend = new Pend(); + zipfile.on('error', handleError); + zipfile.on('entry', onEntry); + zipfile.on('end', onEnd); + + function onEntry(entry) { + if (/\/$/.test(entry.fileName)) { + // ignore directories + return; + } + pend.go(function(cb) { + zipfile.openReadStream(entry, function(err, readStream) { + if (err) { + log.warn("Error reading zip file:", err.stack); + cb(); + return; + } + var entryBaseName = path.basename(entry.fileName); + self.importStream(readStream, entryBaseName, entry.uncompressedSize, function(err, dbFiles) { + if (err) { + log.warn("unable to import entry from zip file:", err.stack); + } else { + allDbFiles = allDbFiles.concat(dbFiles); + } + cb(); + }); + }); + }); + } + + function onEnd() { + pend.wait(function() { + unlinkZipFile(); + cb(null, allDbFiles); + }); + } + + function handleError(err) { + unlinkZipFile(); + cb(err); + } + + function unlinkZipFile() { + fs.unlink(srcFullPath, function(err) { + if (err) { + log.error("Unable to remove zip file after importing:", err.stack); + } + }); + } + }); +} + +// sort keys according to how they appear in the library +function sortTracks(tracks) { + var lib = new MusicLibraryIndex(); + tracks.forEach(function(track) { + lib.addTrack(track); + }); + lib.rebuild(); + var results = []; + lib.artistList.forEach(function(artist) { + artist.albumList.forEach(function(album) { + album.trackList.forEach(function(track) { + results.push(track); + }); + }); + }); + return results; +} diff --git a/lib/player_server.js b/lib/player_server.js new file mode 100644 index 0000000..3b22fa1 --- /dev/null +++ b/lib/player_server.js @@ -0,0 +1,1343 @@ +var uuid = require('./uuid'); +var curlydiff = require('curlydiff'); +var Player = require('./player'); +var Pend = require('pend'); +var util = require('util'); +var EventEmitter = require('events').EventEmitter; +var keese = require('keese'); +var dbIterate = require('./db_iterate'); +var log = require('./log'); + +var USERS_KEY_PREFIX = "Users."; +var EVENTS_KEY_PREFIX = "Events."; +var GUEST_USER_ID = "(guest)"; // uses characters not in the uuid() character set + +var MAX_EVENT_COUNT = 400; +var MAX_NAME_LEN = 64; +var MAX_PASSWORD_LEN = 1024; +var UUID_LEN = uuid().length; + +module.exports = PlayerServer; + +PlayerServer.plugins = []; + +PlayerServer.actions = { + 'approve': { + permission: 'admin', + args: 'array', + fn: function(self, client, approvals) { + self.processApprovals(approvals); + }, + }, + 'clear': { + permission: 'control', + fn: function(self) { + self.player.clearQueue(); + }, + }, + 'chat': { + permission: 'control', + args: 'string', + fn: function(self, client, text) { + self.addEvent(client.user, 'chat', text); + }, + }, + 'deleteTracks': { + permission: 'admin', + args: 'array', + fn: function(self, client, keys) { + for (var i = 0; i < keys.length; i += 1) { + var key = keys[i]; + self.player.deleteFile(key); + } + }, + }, + 'deleteUsers': { + permission: 'admin', + args: 'array', + fn: function(self, client, ids) { + self.deleteUsers(ids); + }, + }, + 'dynamicModeOn': { + permission: 'control', + args: 'boolean', + fn: function(self, client, on) { + self.player.setDynamicModeOn(on); + }, + }, + 'dynamicModeHistorySize': { + permission: 'control', + args: 'number', + fn: function(self, client, size) { + self.player.setDynamicModeHistorySize(size); + }, + }, + 'dynamicModeFutureSize': { + permission: 'control', + args: 'number', + fn: function(self, client, size) { + self.player.setDynamicModeFutureSize(size); + }, + }, + 'ensureAdminUser': { + permission: null, + fn: function(self) { + self.ensureAdminUser(); + }, + }, + 'hardwarePlayback': { + permission: 'admin', + args: 'boolean', + fn: function(self, client, isOn) { + self.player.setHardwarePlayback(isOn); + }, + }, + 'importUrl': { + permission: 'control', + args: 'object', + fn: function(self, client, args) { + var urlString = args.url; + var id = args.id; + var autoQueue = args.autoQueue; + if (!self.validateString(client, urlString)) return; + if (!self.validateString(client, id, UUID_LEN)) return; + if (!self.validateBoolean(client, autoQueue)) return; + self.player.importUrl(urlString, function(err, dbFiles) { + if (err) { + log.error("Unable to import url:", urlString, err.stack); + } else if (!dbFiles) { + log.warn("Unable to import url, unrecognized format"); + } else if (dbFiles.length > 0) { + self.addEvent(client.user, 'import', null, dbFiles[0].key, dbFiles.length); + if (autoQueue) { + self.player.sortAndQueueTracks(dbFiles); + self.addEvent(client.user, 'queue', null, dbFiles[0].key, dbFiles.length); + } + } + }); + }, + }, + 'login': { + permission: null, + args: 'object', + fn: function(self, client, args) { + if (!self.validateString(client, args.username, MAX_NAME_LEN)) return; + if (!self.validateString(client, args.password, MAX_PASSWORD_LEN)) return; + self.login(client, args.username, args.password); + self.sendUserMessage(client); + }, + }, + 'logout': { + permission: null, + fn: function(self, client) { + self.logout(client); + }, + }, + 'subscribe': { + permission: 'read', + args: 'object', + fn: function(self, client, args) { + var errText; + var name = args.name; + var subscription = self.subscriptions[name]; + if (!subscription) { + errText = "Invalid subscription item: " + JSON.stringify(name); + log.warn(errText); + client.sendMessage("error", errText); + return; + } + if (!self.userHasPerm(client.user, subscription.perm)) { + errText = "subscribing to " + JSON.stringify(name) + + " requires permission " + JSON.stringify(subscription.perm); + log.warn(errText); + client.sendMessage("error", errText); + return; + } + if (args.delta && client.subscriptions[name] !== 'delta') { + client.subscriptions[name] = 'delta'; + if (args.version !== subscription.version) { + client.sendMessage(name, { + version: subscription.version, + reset: true, + delta: curlydiff.diff(undefined, subscription.value), + }); + } + } else if (client.subscriptions[name] !== 'simple') { + client.subscriptions[name] = 'simple'; + client.sendMessage(name, subscription.value); + } + }, + }, + 'updateTags': { + permission: 'admin', + args: 'object', + fn: function(self, client, obj) { + self.player.updateTags(obj); + }, + }, + 'updateUser': { + permission: 'admin', + args: 'object', + fn: function(self, client, args) { + if (!self.validateString(client, args.userId, UUID_LEN)) return; + self.updateUser(client, args.userId, args.perms); + }, + }, + 'unsubscribe': { + permission: 'read', + args: 'string', + fn: function(self, client, name) { + self.unsubscribe(client, name); + }, + }, + 'move': { + permission: 'control', + args: 'object', + fn: function(self, client, items) { + self.player.moveQueueItems(items); + self.addEvent(client.user, 'move', null, null, null, true); + }, + }, + 'pause': { + permission: 'control', + fn: function(self, client) { + self.addEvent(client.user, 'pause'); + self.player.pause(); + }, + }, + 'play': { + permission: 'control', + fn: function(self, client) { + self.addEvent(client.user, 'play'); + self.player.play(); + }, + }, + 'queue': { + permission: 'add', + args: 'object', + fn: function(self, client, items) { + var id, item; + var trackCount = 0; + var trackKey = null; + for (id in items) { + item = items[id]; + if (!self.validateObject(client, item)) return; + trackCount += 1; + trackKey = trackKey || item.key; + } + + if (trackCount !== 1) { + trackKey = null; + } + self.addEvent(client.user, 'queue', null, trackKey, trackCount); + self.player.addItems(items); + }, + }, + 'seek': { + permission: 'control', + args: 'object', + fn: function(self, client, args) { + var id = args.id; + var pos = parseFloat(args.pos); + + if (!self.validateString(client, id, UUID_LEN)) return; + if (!self.validateFloat(client, pos)) return; + + var track = self.player.playlist[id]; + if (track) { + self.addEvent(client.user, 'seek', null, track.key, pos); + } + + self.player.seek(id, pos); + }, + }, + 'setStreaming': { + args: 'boolean', + fn: function(self, client, streamOn) { + if (client.streaming === streamOn) return; + client.streaming = streamOn; + if (streamOn) { + self.emit('streamStart', client); + } else { + self.emit('streamStop', client); + } + }, + }, + 'remove': { + permission: 'control', + args: 'array', + fn: function(self, client, ids) { + var item = (ids.length === 1) && self.player.playlist[ids[0]]; + var key = item && item.key; + self.addEvent(client.user, 'remove', null, key, ids.length); + self.player.removeQueueItems(ids); + }, + }, + 'repeat': { + permission: 'control', + args: 'number', + fn: function(self, client, mode) { + self.player.setRepeat(mode); + }, + }, + 'requestApproval': { + permission: null, + fn: function(self, client) { + self.requestApproval(client); + self.sendUserMessage(client); + }, + }, + 'setvol': { + permission: 'control', + args: 'number', + fn: function(self, client, vol) { + self.player.setVolume(vol); + }, + }, + 'shuffle': { + permission: 'control', + fn: function(self, client) { + self.player.shufflePlaylist(); + }, + }, + 'stop': { + permission: 'control', + fn: function(self, client) { + self.player.stop(); + self.addEvent(client.user, 'stop'); + }, + }, + 'playlistCreate': { + permission: 'control', + args: 'object', + fn: function(self, client, args) { + self.player.playlistCreate(args.id, args.name); + }, + }, + 'playlistRename': { + permission: 'control', + args: 'object', + fn: function(self, client, args) { + self.player.playlistRename(args.id, args.name); + }, + }, + 'playlistDelete': { + permission: 'control', + args: 'array', + fn: function(self, client, ids) { + self.player.playlistDelete(ids); + }, + }, + 'playlistAddItems': { + permission: 'control', + args: 'object', + fn: function(self, client, args) { + self.player.playlistAddItems(args.id, args.items); + }, + }, + 'playlistRemoveItems': { + permission: 'control', + args: 'object', + fn: function(self, client, args) { + self.player.playlistRemoveItems(args.id, args.items); + }, + }, + 'playlistMoveItems': { + permission: 'control', + args: 'object', + fn: function(self, client, args) { + self.player.playlistMoveItems(args.id, args.items); + }, + }, +}; + +util.inherits(PlayerServer, EventEmitter); +function PlayerServer(options) { + EventEmitter.call(this); + + this.player = options.player; + this.db = options.db; + this.subscriptions = {}; + this.users = {}; + this.addGuestUser(); + this.usernameIndex = null; // username -> user + this.oneLineAuth = null; // username/password -> perms + this.computeUsersIndex(); + + this.clients = {}; + + this.events = {}; + this.eventsInOrder = []; + + + this.playlistId = uuid(); + this.libraryId = uuid(); + this.initialize(); +} + +PlayerServer.prototype.ensureGuestUser = function() { + this.guestUser = this.users[GUEST_USER_ID]; + if (!this.guestUser) { + this.addGuestUser(); + } +}; + +PlayerServer.prototype.addGuestUser = function() { + // default guest user. overridden by db if present + this.guestUser = { + id: GUEST_USER_ID, + name: 'Guest', + password: "", + registered: true, + requested: true, + approved: true, + perms: { + read: true, + add: true, + control: true, + admin: false, + }, + }; + this.users[this.guestUser.id] = this.guestUser; +}; + +PlayerServer.prototype.haveAdminUser = function() { + for (var id in this.users) { + var user = this.users[id]; + if (user.perms.admin) { + return true; + } + } + return false; +}; + +PlayerServer.prototype.ensureAdminUser = function() { + if (this.haveAdminUser()) { + return; + } + + var user = true; + var name; + while (user) { + name = "Admin-" + uuid.len(6); + user = this.usernameIndex[name]; + } + + var adminUser = { + id: uuid(), + name: name, + password: uuid(), + registered: true, + requested: true, + approved: true, + perms: { + read: true, + add: true, + control: true, + admin: true, + }, + }; + this.users[adminUser.id] = adminUser; + this.saveUser(adminUser); + + log.info("No admin account found. Created one:"); + log.info("Username: " + adminUser.name); + log.info("Password: " + adminUser.password); + + this.emit("haveAdminUser"); + this.emit("users"); +}; + +PlayerServer.prototype.initialize = function() { + var self = this; + self.player.on('currentTrack', addSubscription('currentTrack', getCurrentTrack)); + self.player.on('dynamicModeOn', addSubscription('dynamicModeOn', getDynamicModeOn)); + self.player.on('dynamicModeHistorySize', addSubscription('dynamicModeHistorySize', getDynamicModeHistorySize)); + self.player.on('dynamicModeFutureSize', addSubscription('dynamicModeFutureSize', getDynamicModeFutureSize)); + self.player.on('repeatUpdate', addSubscription('repeat', getRepeat)); + self.player.on('volumeUpdate', addSubscription('volume', getVolume)); + self.player.on('queueUpdate', addSubscription('queue', serializeQueue)); + self.player.on('hardwarePlayback', addSubscription('hardwarePlayback', getHardwarePlayback)); + + var onLibraryUpdate = addSubscription('library', serializeLibrary); + self.player.on('addDbTrack', onLibraryUpdate); + self.player.on('updateDbTrack', onLibraryUpdate); + self.player.on('deleteDbTrack', onLibraryUpdate); + self.player.on('scanComplete', onLibraryUpdate); + + + self.player.on('scanProgress', addSubscription('scanning', serializeScanState)); + + var onPlaylistUpdate = addSubscription('playlists', serializePlaylists); + self.player.on('playlistCreate', onPlaylistUpdate); + self.player.on('playlistUpdate', onPlaylistUpdate); + self.player.on('playlistDelete', onPlaylistUpdate); + + self.player.on('seek', function() { + self.forEachClient(function(client) { + client.sendMessage('seek'); + }); + }); + + var onImportProgress = addSubscription('importProgress', serializeImportProgress); + self.player.on('importStart', onImportProgress); + self.player.on('importEnd', onImportProgress); + self.player.on('importAbort', onImportProgress); + self.player.on('importProgress', onImportProgress); + + // this is only anonymous streamers + var onStreamersUpdate = addSubscription('streamers', serializeStreamers); + self.player.on('streamerConnect', onStreamersUpdate); + self.player.on('streamerDisconnect', onStreamersUpdate); + + setInterval(function() { + self.forEachClient(function(client) { + client.sendMessage('time', new Date()); + }); + }, 30000); + + self.on('haveAdminUser', addSubscription('haveAdminUser', getHaveAdminUser)); + self.on('events', addSubscription('events', getEvents)); + + var onUsersUpdate = addSubscription('users', getUsers); + self.on('users', onUsersUpdate); + self.on('streamStart', onUsersUpdate); + self.on('streamStop', onUsersUpdate); + + // events + self.player.on('currentTrack', addCurrentTrackEvent); + self.on('streamStart', addStreamerConnectEvent); + self.on('streamStop', addStreamerDisconnectEvent); + self.player.on('streamerConnect', maybeAddAnonStreamerConnectEvent); + self.player.on('streamerDisconnect', maybeAddAnonStreamerDisconnectEvent); + + self.player.on('streamerDisconnect', self.checkLastStreamerDisconnected.bind(self)); + self.on('streamStop', self.checkLastStreamerDisconnected.bind(self)); + + self.player.on('autoPause', addAutoPauseEvent); + + var prevCurrentTrackKey = null; + function addCurrentTrackEvent() { + var currentTrackKey = self.player.currentTrack ? self.player.currentTrack.key : null; + if (currentTrackKey !== prevCurrentTrackKey) { + prevCurrentTrackKey = currentTrackKey; + self.addEvent(null, 'currentTrack', null, currentTrackKey); + } + } + + function addAutoPauseEvent() { + self.addEvent(null, 'autoPause'); + } + + function addStreamerConnectEvent(client) { + self.addEvent(client.user, 'streamStart'); + } + + function addStreamerDisconnectEvent(client) { + self.addEvent(client.user, 'streamStop'); + } + + function maybeAddAnonStreamerConnectEvent(client) { + if (!client) { + self.addEvent(null, 'streamStart'); + } + } + + function maybeAddAnonStreamerDisconnectEvent(client) { + if (!client) { + self.addEvent(null, 'streamStop'); + } + } + + function addSubscription(name, serializeFn) { + return addPermSubscription(name, null, serializeFn); + } + + function addPermSubscription(name, perm, serializeFn) { + var subscription = self.subscriptions[name] = { + version: uuid(), + value: serializeFn(), + perm: perm, + }; + return function() { + var newValue = serializeFn(); + var delta = curlydiff.diff(subscription.value, newValue); + if (delta === undefined) return; // no delta, nothing to send! + subscription.value = newValue; + subscription.version = uuid(); + self.forEachClient(function(client) { + var clientSubscription = client.subscriptions[name]; + if (clientSubscription === 'simple') { + client.sendMessage(name, newValue); + } else if (clientSubscription === 'delta') { + client.sendMessage(name, { + version: subscription.version, + delta: delta, + }); + } + }); + }; + } + + function getVolume(client) { + return self.player.volume; + } + + function getTime(client) { + return new Date(); + } + + function getHardwarePlayback(client) { + return self.player.desiredPlayerHardwareState; + } + + function getRepeat(client) { + return self.player.repeat; + } + + function getCurrentTrack() { + return { + currentItemId: self.player.currentTrack && self.player.currentTrack.id, + isPlaying: self.player.isPlaying, + trackStartDate: self.player.trackStartDate, + pausedTime: self.player.pausedTime, + }; + } + + function getDynamicModeOn() { + return self.player.dynamicModeOn; + } + + function getDynamicModeFutureSize() { + return self.player.dynamicModeFutureSize; + } + + function getDynamicModeHistorySize() { + return self.player.dynamicModeHistorySize; + } + + function serializeQueue() { + var playlist = self.player.playlist; + var o = {}; + for (var id in playlist) { + var item = playlist[id]; + o[id] = { + key: item.key, + sortKey: item.sortKey, + isRandom: item.isRandom, + }; + } + return o; + } + + function serializeLibrary() { + var table = {}; + for (var key in self.player.libraryIndex.trackTable) { + var track = self.player.libraryIndex.trackTable[key]; + table[key] = Player.trackWithoutIndex('read', track); + } + return table; + } + + function serializeScanState() { + var ongoingScans = self.player.ongoingScans; + var o = {}; + for (var key in ongoingScans) { + var item = ongoingScans[key]; + o[key] = { + fingerprintDone: item.fingerprintDone, + loudnessDone: item.loudnessDone, + }; + } + return o; + } + + function serializePlaylists() { + return self.player.playlists; + } + + function serializeStreamers() { + var anonCount = 0; + self.player.openStreamers.forEach(function(openStreamer) { + if (!openStreamer.client) { + anonCount += 1; + } + }); + return anonCount; + } + + function getUsers() { + var users = {}; + var outUser; + for (var id in self.users) { + var user = self.users[id]; + outUser = { + name: user.name, + perms: extend({}, user.perms), + }; + if (user.requested) outUser.requested = true; + if (user.approved) outUser.approved = true; + users[id] = outUser; + } + for (var clientId in self.clients) { + var client = self.clients[clientId]; + outUser = users[client.user.id]; + outUser.connected = true; + if (client.streaming) outUser.streaming = true; + } + return users; + } + + function getEvents() { + var events = {}; + for (var id in self.events) { + var ev = self.events[id]; + var outEvent = { + date: ev.date, + type: ev.type, + sortKey: ev.sortKey, + }; + events[ev.id] = outEvent; + if (ev.userId) { + outEvent.userId = ev.userId; + } + if (ev.text) { + outEvent.text = ev.text; + } + if (ev.trackId) { + outEvent.trackId = ev.trackId; + } + if (ev.pos) { + outEvent.pos = ev.pos; + } + } + return events; + } + + function getHaveAdminUser() { + return self.haveAdminUser(); + } + + function serializeImportProgress() { + var out = {}; + for (var id in self.player.importProgress) { + var ev = self.player.importProgress[id]; + var outEvent = { + date: ev.date, + filenameHintWithoutPath: ev.filenameHintWithoutPath, + bytesWritten: ev.bytesWritten, + size: ev.size, + }; + out[ev.id] = outEvent; + } + return out; + } +}; + +PlayerServer.prototype.checkLastStreamerDisconnected = function() { + var streamerCount = 0; + this.forEachClient(function(client) { + streamerCount += client.streaming; + }); + if (this.player.openStreamers.length === 0 && streamerCount === 0) { + this.player.lastStreamerDisconnected(); + } +}; + +PlayerServer.prototype.init = function(cb) { + var self = this; + + var pend = new Pend(); + pend.go(loadAllUsers); + pend.go(loadAllEvents); + pend.wait(cb); + + function loadAllUsers(cb) { + dbIterate(self.db, USERS_KEY_PREFIX, processOne, function(err) { + if (err) return cb(err); + self.ensureGuestUser(); + self.computeUsersIndex(); + self.emit('users'); + self.emit('haveAdminUser'); + cb(); + }); + function processOne(key, value) { + var user = deserializeUser(value); + self.users[user.id] = user; + } + } + + function loadAllEvents(cb) { + dbIterate(self.db, EVENTS_KEY_PREFIX, processOne, function(err) { + if (err) return cb(err); + self.cacheEventsArray(); + self.emit('events'); + cb(); + }); + function processOne(key, value) { + var ev = deserializeEvent(value); + self.events[ev.id] = ev; + } + } +}; + +PlayerServer.prototype.forEachClient = function(fn) { + for (var id in this.clients) { + var client = this.clients[id]; + fn(client); + } +}; + +PlayerServer.prototype.createGuestUser = function() { + var user = true; + var name; + while (user) { + name = this.guestUser.name + "-" + uuid.len(6); + user = this.usernameIndex[name]; + } + user = { + id: uuid(), + name: name, + password: "", + registered: false, + requested: false, + approved: false, + perms: extend({}, this.guestUser.perms), + }; + this.users[user.id] = user; + this.computeUsersIndex(); + this.saveUser(user); + return user; +}; + +PlayerServer.prototype.unsubscribe = function(client, name) { + delete client.subscriptions[name]; +}; + +PlayerServer.prototype.logout = function(client) { + client.user = this.createGuestUser(); + // unsubscribe from subscriptions that the client no longer has permissions for + for (var name in client.subscriptions) { + var subscription = this.subscriptions[name]; + if (!this.userHasPerm(client.user, subscription.perm)) { + this.unsubscribe(client, name); + } + } + this.sendUserMessage(client); +}; + +PlayerServer.prototype.handleNewClient = function(client) { + var self = this; + client.subscriptions = {}; + + // this is a secret; if a user finds out the client.id they can execute + // commands on behalf of that user. + client.id = uuid(); + + client.user = self.createGuestUser(); + client.streaming = false; + self.clients[client.id] = client; + client.on('message', onMessage); + self.sendUserMessage(client); + client.sendMessage('time', new Date()); + client.sendMessage('token', client.id); + client.on('close', onClose); + PlayerServer.plugins.forEach(function(plugin) { + plugin.handleNewClient(client); + }); + + function onClose() { + self.addEvent(client.user, 'part'); + delete self.clients[client.id]; + self.emit('users'); + self.checkLastStreamerDisconnected(); + } + + function onMessage(name, args) { + var action = PlayerServer.actions[name]; + if (!action) { + log.warn("Invalid command:", name); + client.sendMessage("error", "invalid command: " + JSON.stringify(name)); + return; + } + var perm = action.permission; + if (perm != null && !self.userHasPerm(client.user, perm)) { + var errText = "command " + JSON.stringify(name) + + " requires permission " + JSON.stringify(perm); + log.warn("permissions error:", errText); + client.sendMessage("error", errText); + return; + } + var argsType = Array.isArray(args) ? 'array' : typeof args; + if (action.args && argsType !== action.args) { + log.warn("expected arg type", action.args, args); + client.sendMessage("error", "expected " + action.args + ": " + JSON.stringify(args)); + return; + } + log.debug("ok command", name, args); + action.fn(self, client, args); + } +}; + +PlayerServer.prototype.userHasPerm = function(user, perm) { + if (!perm) { + return true; + } + user = user ? this.users[user.id] : null; + var perms = this.getUserPerms(user); + return perms[perm]; +}; + +PlayerServer.prototype.getUserPerms = function(user) { + return (!user || !user.approved) ? this.guestUser.perms : user.perms; +}; + +PlayerServer.prototype.requestApproval = function(client) { + client.user.requested = true; + client.user.registered = true; + this.saveUser(client.user); + this.emit('users'); +}; + +PlayerServer.prototype.login = function(client, username, password) { + var errText; + if (!password) { + errText = "empty password"; + log.warn("Refusing to login:", errText); + client.sendMessage('error', errText); + return; + } + var user = this.usernameIndex[username]; + if (!user) { + client.user.name = username; + client.user.password = password; + client.user.registered = true; + + this.computeUsersIndex(); + this.saveUser(client.user); + + this.emit('users'); + + this.addEvent(client.user, 'register'); + return; + } + + if (user === client.user) { + user.name = username; + user.password = password; + this.computeUsersIndex(); + this.saveUser(user); + this.emit('users'); + return; + } + + if (!user.password || user.password !== password) { + errText = "invalid login"; + log.warn(errText); + client.sendMessage('error', errText); + return; + } + + var oldUser = client.user; + client.user = user; + + if (!oldUser.registered) { + var cmds = []; + this.mergeUsers(cmds, oldUser, user); + if (cmds.length > 0) { + this.db.batch(cmds, logIfError); + } + } + + this.emit('users'); + + this.addEvent(client.user, 'login'); + + function logIfError(err) { + if (err) { + log.error("Unable to modify users:", err.stack); + } + } +}; + +PlayerServer.prototype.mergeUsers = function(cmds, dupeUser, canonicalUser) { + for (var eventId in this.events) { + var ev = this.events[eventId]; + if (ev.userId === dupeUser.id) { + ev.userId = canonicalUser.id; + cmds.push({type: 'put', key: eventKey(ev), value: serializeEvent(ev)}); + } + } + this.forEachClient(function(client) { + if (client.user === dupeUser) { + client.user = canonicalUser; + } + }); + cmds.push({type: 'del', key: userKey(dupeUser)}); + cmds.push({type: 'put', key: userKey(canonicalUser), value: serializeUser(canonicalUser)}); + delete this.users[dupeUser.id]; +}; + +PlayerServer.prototype.computeUsersIndex = function() { + this.usernameIndex = {}; + this.oneLineAuth = {}; + for (var id in this.users) { + var user = this.users[id]; + this.usernameIndex[user.name] = user; + this.oneLineAuth[user.name + '/' + user.password] = user; + } +}; + +PlayerServer.prototype.sendUserMessage = function(client) { + client.sendMessage('user', { + id: client.user.id, + name: client.user.name, + perms: this.getUserPerms(client.user), + registered: client.user.registered, + requested: client.user.requested, + approved: client.user.approved, + }); +}; + +PlayerServer.prototype.saveUser = function(user) { + this.db.put(userKey(user), serializeUser(user), function(err) { + if (err) { + log.error("Unable to save user:", err.stack); + } + }); +}; + +PlayerServer.prototype.processApprovals = function(approvals) { + var cmds = []; + var eventsModified = false; + + var connectedUserIds = {}; + for (var id in this.clients) { + var client = this.clients[id]; + connectedUserIds[client.user.id] = true; + } + + for (var i = 0; i < approvals.length; i += 1) { + var approval = approvals[i]; + var user = this.users[approval.id]; + var replaceUser = this.users[approval.replaceId]; + if (!user) continue; + if (!approval.approved) { + user.requested = false; + cmds.push({type: 'put', key: userKey(user), value: serializeUser(user)}); + } else if (replaceUser && user !== replaceUser) { + replaceUser.name = approval.name; + + eventsModified = true; + this.mergeUsers(cmds, user, replaceUser); + } else { + user.name = approval.name; + user.approved = true; + cmds.push({type: 'put', key: userKey(user), value: serializeUser(user)}); + } + } + + if (cmds.length > 0) { + this.computeUsersIndex(); + this.db.batch(cmds, logIfError); + if (eventsModified) { + this.emit('events'); + } + this.emit('users'); + } + + function logIfError(err) { + if (err) { + log.error("Unable to modify users:", err.stack); + } + } +}; + +PlayerServer.prototype.cacheEventsArray = function() { + var self = this; + self.eventsInOrder = Object.keys(self.events).map(eventById); + self.eventsInOrder.sort(asc); + self.eventsInOrder.forEach(function(ev, index) { + ev.index = index; + }); + + function asc(a, b) { + return operatorCompare(a.sortKey, b.sortKey); + } + function eventById(id) { + return self.events[id]; + } +}; + +PlayerServer.prototype.addEvent = function(user, type, text, trackKey, pos, dedupe) { + var lastEvent = this.eventsInOrder[this.eventsInOrder.length - 1]; + if (dedupe && lastEvent.type === type && lastEvent.userId === user.id) { + return; + } + var ev = { + id: uuid(), + date: new Date(), + userId: user && user.id, + type: type, + sortKey: keese(lastEvent && lastEvent.sortKey, null), + text: text, + trackId: trackKey, + pos: pos, + }; + this.events[ev.id] = ev; + this.eventsInOrder.push(ev); + var extraEvents = this.eventsInOrder.length - MAX_EVENT_COUNT; + var cmds = []; + var usersChanged = 0; + var haveAdminUserChange = false; + if (extraEvents > 0) { + var scrubUserIds = {}; + var i; + for (i = 0; i < extraEvents; i += 1) { + var thisEvent = this.eventsInOrder[i]; + if (thisEvent.user && !thisEvent.user.approved) { + scrubUserIds[thisEvent.user.id] = true; + } + deleteEventCmd(cmds, thisEvent); + delete this.events[thisEvent.id]; + } + this.eventsInOrder.splice(0, extraEvents); + // scrub users associated with these deleted events if they are not + // referenced anywhere else + for (i = 0; i < this.eventsInOrder.length; i += 1) { + delete scrubUserIds[this.eventsInOrder[i].userId]; + } + for (var clientId in this.clients) { + delete scrubUserIds[this.clients[clientId].user.id]; + } + for (var userId in scrubUserIds) { + usersChanged += 1; + var deletedUser = this.users[userId]; + delete this.users[userId]; + cmds.push({type: 'del', key: userKey(deletedUser)}); + haveAdminUserChange = haveAdminUserChange || deletedUser.perms.admin; + } + } + cmds.push({type: 'put', key: eventKey(ev), value: serializeEvent(ev)}); + this.db.batch(cmds, logIfError); + this.emit('events'); + if (usersChanged > 0) { + this.emit('users'); + } + if (haveAdminUserChange) { + this.emit('haveAdminUser'); + } + + function logIfError(err) { + if (err) { + log.error("Unable to modify events:", err.stack); + } + } +}; + +PlayerServer.prototype.updateUser = function(client, userId, perms) { + var user = this.users[userId]; + if (!user) { + var errText = "invalid user id"; + log.warn("unable to update user: " + errText); + client.sendMessage('error', errText); + return; + } + + var guestUserChanged = (user === this.guestUser); + + extend(user.perms, perms); + this.saveUser(user); + + + for (var id in this.clients) { + client = this.clients[id]; + if (client.user === user || (guestUserChanged && !client.user.approved)) { + this.sendUserMessage(client); + } + } + this.emit('haveAdminUser'); + this.emit('users'); +}; + +PlayerServer.prototype.validateObject = function(client, val) { + if (typeof val !== 'object' || Array.isArray(val)) { + var errText = "expected object"; + log.warn("invalid command: " + errText); + client.sendMessage('error', errText); + return false; + } + return true; +}; + +PlayerServer.prototype.validateFloat = function(client, val) { + if (typeof val !== 'number' || isNaN(val)) { + var errText = "expected number"; + log.warn("invalid command: " + errText); + client.sendMessage('error', errText); + return false; + } + return true; +}; + +PlayerServer.prototype.validateString = function(client, val, maxLength) { + var errText; + if (typeof val !== 'string') { + errText = "expected string"; + log.warn("invalid command: " + errText); + client.sendMessage('error', errText); + return false; + } + + if (maxLength != null && val.length > maxLength) { + errText = "string too long"; + log.warn("invalid command:", errText); + client.sendMessage('error', errText); + return false; + } + + return true; +}; + +PlayerServer.prototype.validateBoolean = function(client, val) { + var errText; + if (typeof val !== 'boolean') { + errText = "expected boolean"; + log.warn("invalid command: " + errText); + client.sendMessage('error', errText); + return false; + } + + return true; +}; + +PlayerServer.prototype.deleteUsers = function(ids) { + var cmds = []; + + var haveAdminUserChange = false; + var eventsChange = false; + for (var i = 0; i < ids.length; i += 1) { + var userId = ids[i]; + var user = this.users[userId]; + if (!user || user === this.guestUser) continue; + + var deleteEvents = []; + var ev; + for (var eventId in this.events) { + ev = this.events[eventId]; + if (ev.userId === userId) { + deleteEvents.push(ev); + } + } + eventsChange = eventsChange || (deleteEvents.length > 0); + for (var j = 0; j < deleteEvents.length; j += 1) { + ev = deleteEvents[j]; + cmds.push({type: 'del', key: eventKey(ev)}); + delete this.events[ev.id]; + } + + cmds.push({type: 'del', key: userKey(user)}); + haveAdminUserChange = haveAdminUserChange || user.perms.admin; + delete this.users[userId]; + for (var clientId in this.clients) { + var client = this.clients[clientId]; + if (client.user === user) { + this.logout(client); + break; + } + } + } + + if (cmds.length > 0) { + this.computeUsersIndex(); + this.db.batch(cmds, logIfError); + } + + if (eventsChange) { + this.emit('events'); + } + this.emit('users'); + if (haveAdminUserChange) { + this.emit('haveAdminUser'); + } + + function logIfError(err) { + if (err) { + log.error("Unable to delete users:", err.stack); + } + } +}; + +PlayerServer.prototype.getOneLineAuth = function(passwordString) { + return this.oneLineAuth[passwordString]; +}; + +PlayerServer.deleteAllUsers = function(db) { + var cmds = []; + var usersDeleted = 0; + var eventsDeleted = 0; + + var pend = new Pend(); + pend.go(function(cb) { + dbIterate(db, USERS_KEY_PREFIX, processOne, cb); + + function processOne(key, value) { + cmds.push({type: 'del', key: key}); + usersDeleted += 1; + } + }); + pend.go(function(cb) { + dbIterate(db, EVENTS_KEY_PREFIX, processOne, cb); + function processOne(key, value) { + cmds.push({type: 'del', key: key}); + eventsDeleted += 1; + } + }); + pend.wait(function(err) { + if (err) throw err; + db.batch(cmds, function(err) { + if (err) throw err; + log.info("Users deleted: " + usersDeleted); + log.info("Events deleted: " + eventsDeleted); + process.exit(0); + }); + }); +}; + +function deleteEventCmd(cmds, ev) { + cmds.push({type: 'del', key: eventKey(ev)}); +} + +function serializeUser(user) { + return JSON.stringify(user); +} + +function deserializeUser(payload) { + return JSON.parse(payload); +} + +function serializeEvent(ev) { + return JSON.stringify(ev); +} + +function deserializeEvent(payload) { + return JSON.parse(payload); +} + +function extend(o, src) { + for (var key in src) o[key] = src[key]; + return o; +} + +function userKey(user) { + return USERS_KEY_PREFIX + user.id; +} + +function eventKey(ev) { + return EVENTS_KEY_PREFIX + ev.id; +} + +function operatorCompare(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/lib/plugins/lastfm.js b/lib/plugins/lastfm.js new file mode 100644 index 0000000..7b6c29b --- /dev/null +++ b/lib/plugins/lastfm.js @@ -0,0 +1,239 @@ +var LastFmNode = require('lastfm').LastFmNode; +var PlayerServer = require('../player_server'); +var log = require('../log'); + +module.exports = LastFm; + +var DB_KEY = 'Plugin.lastfm'; + +function LastFm(gb) { + this.gb = gb; + + this.previousNowPlaying = null; + this.lastPlayingItem = null; + this.playingStart = new Date(); + this.playingTime = 0; + this.previousIsPlaying = false; + this.scrobblers = {}; + this.scrobbles = []; + + this.lastFm = new LastFmNode({ + api_key: this.gb.config.lastFmApiKey, + secret: this.gb.config.lastFmApiSecret, + }); + + this.gb.player.on('queueUpdate', checkScrobble.bind(this)); + this.gb.player.on('queueUpdate', updateNowPlaying.bind(this)); + + this.initActions(); +} + +LastFm.prototype.initialize = function(cb) { + var self = this; + + self.gb.db.get(DB_KEY, function(err, value) { + if (err) { + var notFoundError = /^NotFound/.test(err.message); + if (!notFoundError) return cb(err); + } else { + var state = JSON.parse(value); + self.scrobblers = state.scrobblers; + self.scrobbles = state.scrobbles; + } + // in case scrobbling fails and then the user presses stop, this will still + // flush the queue. + setInterval(self.flushScrobbleQueue.bind(self), 120000); + cb(); + }); +}; + +LastFm.prototype.persist = function() { + var self = this; + var state = { + scrobblers: self.scrobblers, + scrobbles: self.scrobbles, + }; + self.gb.db.put(DB_KEY, JSON.stringify(state), function(err) { + if (err) { + log.error("Unable to persist lastfm state to db:", err.stack); + } + }); +}; + +LastFm.prototype.initActions = function() { + var self = this; + + PlayerServer.plugins.push({ + handleNewClient: function(client) { + client.sendMessage('LastFmApiKey', self.gb.config.lastFmApiKey); + }, + }); + + PlayerServer.actions.LastFmGetSession = { + permission: 'read', + args: 'string', + fn: function(playerServer, client, token){ + self.lastFm.request("auth.getSession", { + token: token, + handlers: { + success: function(data){ + delete self.scrobblers[data.session.name]; + client.sendMessage('LastFmGetSessionSuccess', data); + }, + error: function(error){ + log.error("error from last.fm auth.getSession:", error.message); + client.sendMessage('LastFmGetSessionError', error.message); + } + } + }); + } + }; + + PlayerServer.actions.LastFmScrobblersAdd = { + permission: 'read', + args: 'object', + fn: function(playerServer, client, params) { + var existingUser = self.scrobblers[params.username]; + if (existingUser) { + log.warn("Trying to overwrite a scrobbler:", params.username); + return; + } + self.scrobblers[params.username] = params.session_key; + self.persist(); + }, + }; + + PlayerServer.actions.LastFmScrobblersRemove = { + permission: 'read', + args: 'object', + fn: function(playerServer, client, params) { + var sessionKey = self.scrobblers[params.username]; + if (sessionKey !== params.session_key) { + log.warn("Invalid session key from user trying to remove scrobbler:", params.username); + return; + } + delete self.scrobblers[params.username]; + self.persist(); + }, + }; +}; + +LastFm.prototype.flushScrobbleQueue = function() { + var self = this; + var params; + var maxSimultaneous = 10; + var count = 0; + while ((params = self.scrobbles.shift()) != null && count++ < maxSimultaneous) { + log.debug("scrobbling " + params.track + " for session " + params.sk); + params.handlers = { + error: onError, + }; + self.lastFm.request('track.scrobble', params); + } + self.persist(); + + function onError(error){ + log.error("error from last.fm track.scrobble:", error.stack); + if (!error.code || error.code === 11 || error.code === 16) { + // try again + self.scrobbles.push(params); + self.persist(); + } + } +}; + +LastFm.prototype.queueScrobble = function(params){ + log.debug("queueScrobble", params); + this.scrobbles.push(params); + this.persist(); +}; + +function checkScrobble() { + var self = this; + + if (self.gb.player.isPlaying && !self.previousIsPlaying) { + self.playingStart = new Date(new Date() - self.playingTime); + self.previousIsPlaying = true; + } + self.playingTime = new Date() - self.playingStart; + + var thisItem = self.gb.player.currentTrack; + if (thisItem === self.lastPlayingItem) return; + + if (self.lastPlayingItem) { + + var dbFile = self.gb.player.libraryIndex.trackTable[self.lastPlayingItem.key]; + + var minAmt = 15 * 1000; + var maxAmt = 4 * 60 * 1000; + var halfAmt = dbFile.duration / 2 * 1000; + + if (self.playingTime >= minAmt && (self.playingTime >= maxAmt || self.playingTime >= halfAmt)) { + if (dbFile.artistName) { + for (var username in self.scrobblers) { + var sessionKey = self.scrobblers[username]; + self.queueScrobble({ + sk: sessionKey, + chosenByUser: +!self.lastPlayingItem.isRandom, + timestamp: Math.round(self.playingStart.getTime() / 1000), + album: dbFile.albumName, + track: dbFile.name, + artist: dbFile.artistName, + albumArtist: dbFile.albumArtistName, + duration: Math.round(dbFile.duration), + trackNumber: dbFile.track, + }); + } + self.flushScrobbleQueue(); + } else { + log.debug("Not scrobbling " + dbFile.name + " - missing artist."); + } + } else { + log.debug("not scrobbling", dbFile.name, " - only listened for", self.playingTime); + } + } + self.lastPlayingItem = thisItem; + self.previousIsPlaying = self.gb.player.isPlaying; + self.playingStart = new Date(); + self.playingTime = 0; +} + +function updateNowPlaying() { + var self = this; + + if (!self.gb.player.isPlaying) return; + + var track = self.gb.player.currentTrack; + if (!track) return; + + if (self.previousNowPlaying === track) return; + self.previousNowPlaying = track; + + var dbFile = self.gb.player.libraryIndex.trackTable[track.key]; + if (!dbFile.artistName) { + log.debug("Not updating last.fm now playing for " + dbFile.name + ": missing artist"); + return; + } + + for (var username in self.scrobblers) { + var sessionKey = self.scrobblers[username]; + var props = { + sk: sessionKey, + track: dbFile.name, + artist: dbFile.artistName, + album: dbFile.albumName, + albumArtist: dbFile.albumArtistName, + trackNumber: dbFile.track, + duration: Math.round(dbFile.duration), + handlers: { + error: onError + } + }; + log.debug("updateNowPlaying", props); + self.lastFm.request("track.updateNowPlaying", props); + } + + function onError(error){ + log.error("unable to update last.fm now playing:", error.message); + } +} diff --git a/lib/protocol_parser.js b/lib/protocol_parser.js new file mode 100644 index 0000000..3390478 --- /dev/null +++ b/lib/protocol_parser.js @@ -0,0 +1,66 @@ +var Duplex = require('stream').Duplex; +var util = require('util'); +var log = require('./log'); + +module.exports = ProtocolParser; + +util.inherits(ProtocolParser, Duplex); +function ProtocolParser(options) { + var streamOptions = extend(extend({}, options.streamOptions || {}), {decodeStrings: false}); + Duplex.call(this, streamOptions); + this.player = options.player; + + this.buffer = ""; + this.alreadyClosed = false; +} + +ProtocolParser.prototype._read = function(size) {}; + +ProtocolParser.prototype._write = function(chunk, encoding, callback) { + var self = this; + + var lines = chunk.split("\n"); + self.buffer += lines[0]; + if (lines.length === 1) return callback(); + handleLine(self.buffer); + var lastIndex = lines.length - 1; + for (var i = 1; i < lastIndex; i += 1) { + handleLine(lines[i]); + } + self.buffer = lines[lastIndex]; + callback(); + + function handleLine(line) { + var jsonObject; + try { + jsonObject = JSON.parse(line); + } catch (err) { + log.warn("received invalid json:", err.message); + self.sendMessage("error", "invalid json: " + err.message); + return; + } + if (typeof jsonObject !== 'object') { + log.warn("received json not an object:", jsonObject); + self.sendMessage("error", "expected json object"); + return; + } + self.emit('message', jsonObject.name, jsonObject.args); + } +}; + +ProtocolParser.prototype.sendMessage = function(name, args) { + if (this.alreadyClosed) return; + var jsonObject = {name: name, args: args}; + this.push(JSON.stringify(jsonObject)); +}; + +ProtocolParser.prototype.close = function() { + if (this.alreadyClosed) return; + this.push(null); + this.alreadyClosed = true; +}; + +function extend(o, src) { + for (var key in src) o[key] = src[key]; + return o; +} diff --git a/lib/safe_path.js b/lib/safe_path.js new file mode 100644 index 0000000..7e25ecd --- /dev/null +++ b/lib/safe_path.js @@ -0,0 +1,11 @@ +module.exports = safePath; + +var MAX_LEN = 100; + +function safePath(string) { + string = string.replace(/[<>:"\/\\|?*%]/g, "_"); + string = string.substring(0, MAX_LEN); + string = string.replace(/\.$/, "_"); + string = string.replace(/^\./, "_"); + return string; +} diff --git a/lib/server.js b/lib/server.js new file mode 100755 index 0000000..df84b1c --- /dev/null +++ b/lib/server.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +if (!process.env.NODE_ENV) process.env.NODE_ENV = "dev"; + +var GrooveBasin = require('./groovebasin'); +var gb = new GrooveBasin(); +gb.on('listening', function() { + if (process.send) process.send('online'); +}); +process.on('message', function(message){ + if (message === 'shutdown') process.exit(0); +}); +gb.start(); diff --git a/lib/uuid.js b/lib/uuid.js new file mode 100644 index 0000000..aeb4c6f --- /dev/null +++ b/lib/uuid.js @@ -0,0 +1,24 @@ +var crypto = require('crypto'); +var htmlSafe = {'/': '_', '+': '-'}; + +// random string which is safe to put in an html id +module.exports = uuid; +uuid.len = len; + +function uuid() { + return len(24); +} + +function len(size) { + return rando(size).toString('base64').replace(/[\/\+]/g, function(x) { + return htmlSafe[x]; + }); +} + +function rando(size) { + try { + return crypto.randomBytes(size); + } catch (err) { + return crypto.pseudoRandomBytes(size); + } +} diff --git a/lib/web_socket_api_client.js b/lib/web_socket_api_client.js new file mode 100644 index 0000000..dc320ea --- /dev/null +++ b/lib/web_socket_api_client.js @@ -0,0 +1,52 @@ +var EventEmitter = require('events').EventEmitter; +var util = require('util'); +var log = require('./log'); + +module.exports = WebSocketApiClient; + +util.inherits(WebSocketApiClient, EventEmitter); +function WebSocketApiClient(ws) { + EventEmitter.call(this); + this.ws = ws; + this.initialize(); +} + +WebSocketApiClient.prototype.sendMessage = function(name, args) { + try { + this.ws.send(JSON.stringify({ + name: name, + args: args, + })); + } catch (err) { + // nothing to do + // client might have disconnected by now + } +}; + +WebSocketApiClient.prototype.close = function() { + this.ws.close(); +}; + +WebSocketApiClient.prototype.initialize = function() { + var self = this; + self.ws.on('message', function(data, flags) { + if (flags.binary) { + log.warn("ignoring binary web socket message"); + return; + } + var msg; + try { + msg = JSON.parse(data); + } catch (err) { + log.warn("received invalid JSON from web socket:", err.message); + return; + } + self.emit('message', msg.name, msg.args); + }); + self.ws.on('error', function(err) { + log.error("web socket error:", err.stack); + }); + self.ws.on('close', function() { + self.emit('close'); + }); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..b8b580f --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "groovebasin", + "description": "Music player server with a web-based interface inspired by Amarok 1.4", + "author": "Andrew Kelley ", + "version": "1.4.0", + "licenses": [ + { + "type": "MIT", + "url": "https://raw.github.com/andrewrk/groovebasin/master/LICENSE" + } + ], + "engines": { + "node": ">=0.10.20" + }, + "bin": { + "groovebasin": "lib/server.js" + }, + "repository": { + "type": "git", + "url": "git://github.com/andrewrk/groovebasin.git" + }, + "dependencies": { + "connect-static": "~1.3.1", + "cookies": "~0.5.0", + "curlydiff": "~2.0.1", + "express": "~4.9.7", + "groove": "~2.2.6", + "keese": "~1.0.4", + "lastfm": "~0.9.2", + "leveldown": "~1.0.0", + "mess": "~0.1.2", + "mkdirp": "~0.5.0", + "multiparty": "~4.0.0", + "music-library-index": "~1.2.2", + "mv": "~2.0.3", + "osenv": "~0.1.0", + "pend": "~1.1.3", + "semver": "~4.1.0", + "serve-static": "~1.7.0", + "ws": "~0.4.32", + "yazl": "~2.0.2", + "ytdl-core": ">=0.2.4", + "findit2": "~2.2.2", + "content-disposition": "~0.5.0", + "yauzl": "~2.1.0" + }, + "devDependencies": { + "stylus": "~0.49.1", + "browserify-lite": "~0.2.3", + "human-size": "~1.1.0" + }, + "scripts": { + "start": "node lib/server.js", + "build": "npm install && ./build", + "dev": "npm run build && node lib/server.js --verbose" + } +} diff --git a/src/client/app.js b/src/client/app.js new file mode 100644 index 0000000..2ca229e --- /dev/null +++ b/src/client/app.js @@ -0,0 +1,3602 @@ +var $ = window.$; + +var shuffle = require('mess'); +var humanSize = require('human-size'); +var PlayerClient = require('./playerclient'); +var Socket = require('./socket'); +var uuid = require('./uuid'); + +var dynamicModeOn = false; +var hardwarePlaybackOn = false; +var haveAdminUser = true; + +var eventsListScrolledToBottom = true; +var isBrowserTabActive = true; + +var tryingToStream = false; +var actuallyStreaming = false; +var actuallyPlaying = false; +var stillBuffering = false; +var streamAudio = new Audio(); + +var selection = { + ids: { + queue: {}, + artist: {}, + album: {}, + track: {}, + stored_playlist: {}, + stored_playlist_item: {} + }, + cursor: null, + rangeSelectAnchor: null, + rangeSelectAnchorType: null, + type: null, + isLibrary: function(){ + return this.type === 'artist' || this.type === 'album' || this.type === 'track'; + }, + isQueue: function(){ + return this.type === 'queue'; + }, + isStoredPlaylist: function(){ + return this.type === 'stored_playlist' || this.type === 'stored_playlist_item'; + }, + clear: function(){ + this.ids.artist = {}; + this.ids.album = {}; + this.ids.track = {}; + this.ids.queue = {}; + this.ids.stored_playlist = {}; + this.ids.stored_playlist_item = {}; + }, + fullClear: function(){ + this.clear(); + this.type = null; + this.cursor = null; + this.rangeSelectAnchor = null; + this.rangeSelectAnchorType = null; + }, + selectOnly: function(selName, key){ + this.clear(); + this.type = selName; + this.ids[selName][key] = true; + this.cursor = key; + this.rangeSelectAnchor = key; + this.rangeSelectAnchorType = selName; + }, + isMulti: function(){ + var result, k; + if (this.isLibrary()) { + result = 2; + for (k in this.ids.artist) { + if (!--result) return true; + } + for (k in this.ids.album) { + if (!--result) return true; + } + for (k in this.ids.track) { + if (!--result) return true; + } + return false; + } else if (this.isQueue()) { + result = 2; + for (k in this.ids.queue) { + if (!--result) return true; + } + return false; + } else if (this.isStoredPlaylist()) { + result = 2; + for (k in this.ids.stored_playlist) { + if (!--result) return true; + } + for (k in this.ids.stored_playlist_item) { + if (!--result) return true; + } + return false; + } else { + return false; + } + }, + getPos: function(type, key){ + if (type == null) type = this.type; + if (key == null) key = this.cursor; + var val; + if (this.isLibrary()) { + val = { + type: 'library', + artist: null, + album: null, + track: null + }; + if (key != null) { + switch (type) { + case 'track': + val.track = player.searchResults.trackTable[key]; + val.album = val.track.album; + val.artist = val.album.artist; + break; + case 'album': + val.album = player.searchResults.albumTable[key]; + val.artist = val.album.artist; + break; + case 'artist': + val.artist = player.searchResults.artistTable[key]; + break; + } + } else { + val.artist = player.searchResults.artistList[0]; + } + } else if (this.isStoredPlaylist()) { + val = { + type: 'stored_playlist', + stored_playlist: null, + stored_playlist_item: null + }; + if (key != null) { + switch (type) { + case 'stored_playlist_item': + val.stored_playlist_item = player.stored_playlist_item_table[key]; + val.stored_playlist = val.stored_playlist_item.playlist; + break; + case 'stored_playlist': + val.stored_playlist = player.stored_playlist_table[key]; + break; + } + } else { + val.stored_playlist = player.stored_playlists[0]; + } + } else { + throw new Error("NothingSelected"); + } + return val; + }, + posToArr: function(pos){ + var ref$; + if (pos.type === 'library') { + return [(ref$ = pos.artist) != null ? ref$.index : void 8, (ref$ = pos.album) != null ? ref$.index : void 8, (ref$ = pos.track) != null ? ref$.index : void 8]; + } else if (pos.type === 'stored_playlist') { + return [(ref$ = pos.stored_playlist) != null ? ref$.index : void 8, (ref$ = pos.stored_playlist_item) != null ? ref$.index : void 8]; + } else { + throw new Error("NothingSelected"); + } + }, + posEqual: function(pos1, pos2){ + var arr1 = this.posToArr(pos1); + var arr2 = this.posToArr(pos2); + return compareArrays(arr1, arr2) === 0; + }, + posInBounds: function(pos){ + if (pos.type === 'library') { + return pos.artist != null; + } else if (pos.type === 'stored_playlist') { + return pos.stored_playlist != null; + } else { + throw new Error("NothingSelected"); + } + }, + selectPos: function(pos){ + if (pos.type === 'library') { + if (pos.track != null) { + selection.ids.track[pos.track.key] = true; + } else if (pos.album != null) { + selection.ids.album[pos.album.key] = true; + } else if (pos.artist != null) { + selection.ids.artist[pos.artist.key] = true; + } + } else if (pos.type === 'stored_playlist') { + if (pos.stored_playlist_item != null) { + selection.ids.stored_playlist_item[pos.stored_playlist_item] = true; + } else if (pos.stored_playlist != null) { + selection.ids.stored_playlist[pos.stored_playlist] = true; + } + } else { + throw new Error("NothingSelected"); + } + }, + incrementPos: function(pos){ + if (pos.type === 'library') { + if (pos.track != null) { + pos.track = pos.track.album.trackList[pos.track.index + 1]; + if (pos.track == null) { + pos.album = pos.artist.albumList[pos.album.index + 1]; + if (pos.album == null) { + pos.artist = player.searchResults.artistList[pos.artist.index + 1]; + } + } + } else if (pos.album != null) { + if (isAlbumExpanded(pos.album)) { + pos.track = pos.album.trackList[0]; + } else { + var nextAlbum = pos.artist.albumList[pos.album.index + 1]; + if (nextAlbum) { + pos.album = nextAlbum; + } else { + pos.artist = player.searchResults.artistList[pos.artist.index + 1]; + pos.album = null; + } + } + } else if (pos.artist != null) { + if (isArtistExpanded(pos.artist)) { + pos.album = pos.artist.albumList[0]; + } else { + pos.artist = player.searchResults.artistList[pos.artist.index + 1]; + } + } + } else if (pos.type === 'stored_playlist') { + if (pos.stored_playlist_item != null) { + pos.stored_playlist_item = pos.stored_playlist_item.playlist.itemList[pos.stored_playlist_item.index + 1]; + if (pos.stored_playlist_item == null) { + pos.stored_playlist = player.stored_playlists[pos.stored_playlist.index + 1]; + } + } else if (pos.stored_playlist != null) { + if (isStoredPlaylistExpanded(pos.stored_playlist)) { + pos.stored_playlist_item = pos.stored_playlist.itemList[0]; + if (pos.stored_playlist_item == null) { + pos.stored_playlist = player.stored_playlists[pos.stored_playlist.index + 1]; + } + } else { + pos.stored_playlist = player.stored_playlists[pos.stored_playlist.index + 1]; + } + } + } else { + throw new Error("NothingSelected"); + } + }, + toTrackKeys: function(random){ + var this$ = this; + if (random == null) random = false; + if (this.isLibrary()) { + return libraryToTrackKeys(); + } else if (this.isQueue()) { + return queueToTrackKeys(); + } else if (this.isStoredPlaylist()) { + return storedPlaylistToTrackKeys(); + } else { + throw new Error("NothingSelected"); + } + + function libraryToTrackKeys() { + var key; + var track_set = {}; + function selRenderArtist(artist){ + var i, ref$, len$, album; + for (i = 0, len$ = (ref$ = artist.albumList).length; i < len$; ++i) { + album = ref$[i]; + selRenderAlbum(album); + } + } + function selRenderAlbum(album){ + var i, ref$, len$, track; + for (i = 0, len$ = (ref$ = album.trackList).length; i < len$; ++i) { + track = ref$[i]; + selRenderTrack(track); + } + } + function selRenderTrack(track){ + track_set[track.key] = this$.posToArr(getTrackSelPos(track)); + } + function getTrackSelPos(track){ + return { + type: 'library', + artist: track.album.artist, + album: track.album, + track: track + }; + } + for (key in selection.ids.artist) { + selRenderArtist(player.searchResults.artistTable[key]); + } + for (key in selection.ids.album) { + selRenderAlbum(player.searchResults.albumTable[key]); + } + for (key in selection.ids.track) { + selRenderTrack(player.searchResults.trackTable[key]); + } + return trackSetToKeys(track_set); + } + function queueToTrackKeys(){ + var keys = []; + for (var key in selection.ids.queue) { + keys.push(player.queue.itemTable[key].track.key); + } + if (random) shuffle(keys); + return keys; + } + function storedPlaylistToTrackKeys(){ + var track_set = {}; + function renderQueue(playlist){ + var i, ref$, len$, item; + for (i = 0, len$ = (ref$ = playlist.itemList).length; i < len$; ++i) { + item = ref$[i]; + renderPlaylistItem(item); + } + } + function renderPlaylistItem(item){ + track_set[item.track.key] = this$.posToArr(getItemSelPos(item)); + } + function getItemSelPos(item){ + return { + type: 'stored_playlist', + stored_playlist: item.playlist, + stored_playlist_item: item + }; + } + for (var key in selection.ids.stored_playlist) { + renderQueue(player.stored_playlist_table[key]); + } + for (key in selection.ids.stored_playlist_item) { + renderPlaylistItem(player.stored_playlist_item_table[key]); + } + return trackSetToKeys(track_set); + } + + function trackSetToKeys(track_set){ + var key; + var keys = []; + if (random) { + for (key in track_set) { + keys.push(key); + } + shuffle(keys); + return keys; + } + var track_arr = []; + for (key in track_set) { + track_arr.push({ + key: key, + pos: track_set[key], + }); + } + track_arr.sort(function(a, b) { + return compareArrays(a.pos, b.pos); + }); + for (var i = 0; i < track_arr.length; i += 1) { + var track = track_arr[i]; + keys.push(track.key); + } + return keys; + } + } +}; +var BASE_TITLE = document.title; +var MARGIN = 10; +var AUTO_EXPAND_LIMIT = 30; +var ICON_COLLAPSED = 'ui-icon-triangle-1-e'; +var ICON_EXPANDED = 'ui-icon-triangle-1-se'; +var myUser = { + perms: {}, +}; +var socket = null; +var player = null; +var userIsSeeking = false; +var userIsVolumeSliding = false; +var started_drag = false; +var abortDrag = function(){}; +var lastFmApiKey = null; +var LoadStatus = { + Init: 'Loading...', + NoServer: 'Server is down.', + GoodToGo: '[good to go]' +}; +var repeatModeNames = ["Off", "One", "All"]; +var loadStatus = LoadStatus.Init; + +var localState = { + lastfm: { + username: null, + session_key: null, + scrobbling_on: false + }, + authUsername: null, + authPassword: null, + autoQueueUploads: true, +}; +var $document = $(document); +var $window = $(window); +var $streamBtn = $('#stream-btn'); +var $clientVolSlider = $('#client-vol-slider'); +var $clientVol = $('#client-vol'); +var $queueWindow = $('#queue-window'); +var $leftWindow = $('#left-window'); +var $queueItems = $('#queue-items'); +var $dynamicMode = $('#dynamic-mode'); +var $queueBtnRepeat = $('#queue-btn-repeat'); +var $tabs = $('#tabs'); +var $library = $('#library'); +var $libFilter = $('#lib-filter'); +var $trackSlider = $('#track-slider'); +var $nowplaying = $('#nowplaying'); +var $nowplaying_elapsed = $nowplaying.find('.elapsed'); +var $nowplaying_left = $nowplaying.find('.left'); +var $volSlider = $('#vol-slider'); +var $settings = $('#settings'); +var $uploadByUrl = $('#upload-by-url'); +var $mainErrMsg = $('#main-err-msg'); +var $mainErrMsgText = $('#main-err-msg-text'); +var $playlistsList = $('#playlists-list'); +var $playlists = $('#playlists'); +var $upload = $('#upload'); +var $trackDisplay = $('#track-display'); +var $libHeader = $('#lib-window-header'); +var $queueHeader = $('#queue-header'); +var $autoQueueUploads = $('#auto-queue-uploads'); +var uploadInput = document.getElementById("upload-input"); +var $uploadWidget = $("#upload-widget"); +var $settingsRegister = $('#settings-register'); +var $settingsShowAuth = $('#settings-show-auth'); +var $settingsAuthCancel = $('#settings-auth-cancel'); +var $settingsAuthSave = $('#settings-auth-save'); +var $settingsAuthEdit = $('#settings-auth-edit'); +var $settingsAuthRequest = $('#settings-auth-request'); +var $settingsAuthLogout = $('#settings-auth-logout'); +var streamUrlDom = document.getElementById('settings-stream-url'); +var $authPermRead = $('#auth-perm-read'); +var $authPermAdd = $('#auth-perm-add'); +var $authPermControl = $('#auth-perm-control'); +var $authPermAdmin = $('#auth-perm-admin'); +var $lastFmSignOut = $('#lastfm-sign-out'); +var lastFmAuthUrlDom = document.getElementById('lastfm-auth-url'); +var $settingsLastFmIn = $('#settings-lastfm-in'); +var $settingsLastFmOut = $('#settings-lastfm-out'); +var settingsLastFmUserDom = document.getElementById('settings-lastfm-user'); +var $toggleScrobble = $('#toggle-scrobble'); +var $shortcuts = $('#shortcuts'); +var $editTagsDialog = $('#edit-tags'); +var $queueMenu = $('#menu-queue'); +var $libraryMenu = $('#menu-library'); +var $toggleHardwarePlayback = $('#toggle-hardware-playback'); +var $toggleHardwarePlaybackLabel = $('#toggle-hardware-playback-label'); +var $newPlaylistBtn = $('#new-playlist-btn'); +var $emptyLibraryMessage = $('#empty-library-message'); +var $libraryNoItems = $('#library-no-items'); +var $libraryArtists = $('#library-artists'); +var $volNum = $('#vol-num'); +var $volWarning = $('#vol-warning'); +var $ensureAdminDiv = $('#ensure-admin'); +var $ensureAdminBtn = $('#ensure-admin-btn'); +var $authShowPassword = $('#auth-show-password'); +var $authUsername = $('#auth-username'); +var $authUsernameDisplay = $('#auth-username-display'); +var $authPassword = $('#auth-password'); +var $settingsUsers = $('#settings-users'); +var $settingsUsersSelect = $('#settings-users-select'); +var $settingsRequests = $('#settings-requests'); +var $settingsRequest = $('#settings-request'); +var $userPermRead = $('#user-perm-read'); +var $userPermAdd = $('#user-perm-add'); +var $userPermControl = $('#user-perm-control'); +var $userPermAdmin = $('#user-perm-admin'); +var $settingsDeleteUser = $('#settings-delete-user'); +var $requestReplace = $('#request-replace'); +var $requestName = $('#request-name'); +var $requestApprove = $('#request-approve'); +var $requestDeny = $('#request-deny'); +var $eventsOnlineUsers = $('#events-online-users'); +var $eventsList = $('#events-list'); +var $chatBox = $('#chat-box'); +var $chatBoxInput = $('#chat-box-input'); +var $queueDuration = $('#queue-duration'); +var $queueDurationLabel = $('#queue-duration-label'); +var $importProgress = $('#import-progress'); +var $importProgressList = $('#import-progress-list'); + +var tabs = { + library: { + $pane: $('#library-pane'), + $tab: $('#library-tab'), + }, + upload: { + $pane: $('#upload-pane'), + $tab: $('#upload-tab'), + }, + playlists: { + $pane: $('#playlists-pane'), + $tab: $('#playlists-tab'), + }, + events: { + $pane: $('#events-pane'), + $tab: $('#events-tab'), + }, + settings: { + $pane: $('#settings-pane'), + $tab: $('#settings-tab'), + }, +}; +var activeTab = tabs.library; +var $eventsTabSpan = tabs.events.$tab.find('span'); +var $importTabSpan = tabs.upload.$tab.find('span'); + +function saveLocalState(){ + localStorage.setItem('state', JSON.stringify(localState)); +} + +function loadLocalState() { + var stateString = localStorage.getItem('state'); + if (!stateString) return; + var obj; + try { + obj = JSON.parse(stateString); + } catch (err) { + return; + } + // this makes sure it still works when we change the format of localState + for (var key in localState) { + if (obj[key] !== undefined) { + localState[key] = obj[key]; + } + } +} + +function scrollLibraryToSelection() { + var helpers = getSelectionHelpers(); + if (!helpers) return; + delete helpers.queue; + scrollThingToSelection($library, helpers); +} + +function scrollPlaylistToSelection(){ + var helpers = getSelectionHelpers(); + if (!helpers) return; + delete helpers.track; + delete helpers.artist; + delete helpers.album; + scrollThingToSelection($queueItems, helpers); +} + +function scrollThingToSelection($scrollArea, helpers){ + var topPos = null; + var bottomPos = null; + for (var selName in helpers) { + var helper = helpers[selName]; + for (var id in helper.ids) { + var $div = helper.$getDiv(id); + var itemTop = $div.offset().top; + var itemBottom = itemTop + $div.height(); + if (topPos == null || itemTop < topPos) { + topPos = itemTop; + } + if (bottomPos == null || itemBottom > bottomPos) { + bottomPos = itemBottom; + } + } + } + if (topPos != null) { + var scrollAreaTop = $scrollArea.offset().top; + var selectionTop = topPos - scrollAreaTop; + var selectionBottom = bottomPos - scrollAreaTop - $scrollArea.height(); + var scrollAmt = $scrollArea.scrollTop(); + if (selectionTop < 0) { + return $scrollArea.scrollTop(scrollAmt + selectionTop); + } else if (selectionBottom > 0) { + return $scrollArea.scrollTop(scrollAmt + selectionBottom); + } + } +} + +function getDragPosition(x, y){ + var ref$; + var result = {}; + for (var i = 0, len$ = (ref$ = $queueItems.find(".pl-item").get()).length; i < len$; ++i) { + var item = ref$[i]; + var $item = $(item); + var middle = $item.offset().top + $item.height() / 2; + var track = player.queue.itemTable[$item.attr('data-id')]; + if (middle < y) { + if (result.previous_key == null || track.sortKey > result.previous_key) { + result.$previous = $item; + result.previous_key = track.sortKey; + } + } else { + if (result.next_key == null || track.sortKey < result.next_key) { + result.$next = $item; + result.next_key = track.sortKey; + } + } + } + return result; +} + +function renderPlaylistButtons(){ + $dynamicMode + .prop("checked", dynamicModeOn) + .button("refresh"); + var repeatModeName = repeatModeNames[player.repeat]; + $queueBtnRepeat + .button("option", "label", "Repeat: " + repeatModeName) + .prop("checked", player.repeat !== PlayerClient.REPEAT_OFF) + .button("refresh"); +} + +function updateHaveAdminUserUi() { + $ensureAdminDiv.toggle(!haveAdminUser); +} + +function renderQueue(){ + var itemList = player.queue.itemList || []; + var scrollTop = $queueItems.scrollTop(); + + // add the missing dom entries + var i; + var playlistItemsDom = $queueItems.get(0); + for (i = playlistItemsDom.childElementCount; i < itemList.length; i += 1) { + $queueItems.append( + '
' + + '' + + '' + + '' + + '' + + '' + + '
'); + } + // remove the extra dom entries + var domItem; + while (itemList.length < playlistItemsDom.childElementCount) { + playlistItemsDom.removeChild(playlistItemsDom.lastChild); + } + + // overwrite existing dom entries + var $domItems = $queueItems.children(); + for (i = 0; i < itemList.length; i += 1) { + var $domItem = $($domItems[i]); + var item = itemList[i]; + $domItem.attr('id', 'playlist-track-' + item.id); + $domItem.attr('data-id', item.id); + var track = item.track; + $domItem.find('.track').text(track.track || ""); + $domItem.find('.title').text(track.name || ""); + $domItem.find('.artist').text(track.artistName || ""); + $domItem.find('.album').text(track.albumName || ""); + var timeText = player.isScanning(track) ? "scan" : formatTime(track.duration); + $domItem.find('.time').text(timeText); + } + + refreshSelection(); + labelPlaylistItems(); + $queueItems.scrollTop(scrollTop); +} + +function updateQueueDuration() { + var duration = 0; + + if (selection.isQueue()) { + selection.toTrackKeys().forEach(addKeyDuration); + $queueDurationLabel.text("Selection:"); + } else { + player.queue.itemList.forEach(addItemDuration); + $queueDurationLabel.text("Play Queue:"); + } + $queueDuration.text(formatTime(duration)); + + function addKeyDuration(key) { + var track = player.library.trackTable[key]; + if (track) { + duration += track.duration; + } + } + + function addItemDuration(item) { + duration += item.track.duration; + } +} + +function labelPlaylistItems() { + var item; + var curItem = player.currentItem; + $queueItems.find(".pl-item") + .removeClass('current') + .removeClass('old') + .removeClass('random'); + if (curItem != null && dynamicModeOn) { + for (var index = 0; index < curItem.index; ++index) { + item = player.queue.itemList[index]; + var itemId = item && item.id; + if (itemId != null) { + $("#playlist-track-" + itemId).addClass('old'); + } + } + } + for (var i = 0; i < player.queue.itemList.length; i += 1) { + item = player.queue.itemList[i]; + if (item.isRandom) { + $("#playlist-track-" + item.id).addClass('random'); + } + } + if (curItem != null) { + $("#playlist-track-" + curItem.id).addClass('current'); + } +} + +function getSelectionHelpers(){ + if (player == null) return null; + if (player.queue == null) return null; + if (player.queue.itemTable == null) return null; + if (player.searchResults == null) return null; + if (player.searchResults.artistTable == null) return null; + return { + queue: { + ids: selection.ids.queue, + table: player.queue.itemTable, + $getDiv: function(id){ + return $("#playlist-track-" + id); + }, + }, + artist: { + ids: selection.ids.artist, + table: player.searchResults.artistTable, + $getDiv: function(id){ + return $("#lib-artist-" + toHtmlId(id)); + }, + }, + album: { + ids: selection.ids.album, + table: player.searchResults.albumTable, + $getDiv: function(id){ + return $("#lib-album-" + toHtmlId(id)); + }, + }, + track: { + ids: selection.ids.track, + table: player.searchResults.trackTable, + $getDiv: function(id){ + return $("#lib-track-" + toHtmlId(id)); + }, + }, + stored_playlist: { + ids: selection.ids.stored_playlist, + table: player.stored_playlist_table, + $getDiv: function(id){ + return $("#stored-pl-pl-" + toHtmlId(id)); + }, + }, + stored_playlist_item: { + ids: selection.ids.stored_playlist_item, + table: player.stored_playlist_item_table, + $getDiv: function(id){ + return $("#stored-pl-item-" + toHtmlId(id)); + }, + }, + }; +} + +function refreshSelection() { + var helpers = getSelectionHelpers(); + if (!helpers) { + updateQueueDuration(); + return; + } + $queueItems.find(".pl-item").removeClass('selected').removeClass('cursor'); + $libraryArtists.find(".clickable").removeClass('selected').removeClass('cursor'); + $playlistsList.find(".clickable").removeClass('selected').removeClass('cursor'); + if (selection.type == null) { + updateQueueDuration(); + return; + } + for (var selectionType in helpers) { + var helper = helpers[selectionType]; + var id; + // clean out stale ids + for (id in helper.ids) { + if (helper.table[id] == null) { + delete helper.ids[id]; + } + } + for (id in helper.ids) { + helper.$getDiv(id).addClass('selected'); + } + if (selection.cursor != null && selectionType === selection.type) { + var validIds = getValidIds(selectionType); + if (validIds[selection.cursor] == null) { + // server just deleted our current cursor item. + // select another of our ids randomly, if we have any. + selection.cursor = Object.keys(helper.ids)[0]; + selection.rangeSelectAnchor = selection.cursor; + selection.rangeSelectAnchorType = selectionType; + if (selection.cursor == null) { + // no selected items + selection.fullClear(); + } + } + if (selection.cursor != null) { + helper.$getDiv(selection.cursor).addClass('cursor'); + } + } + } + updateQueueDuration(); +} + +function getValidIds(selectionType) { + switch (selectionType) { + case 'queue': return player.queue.itemTable; + case 'artist': return player.library.artistTable; + case 'album': return player.library.albumTable; + case 'track': return player.library.trackTable; + case 'stored_playlist': return player.stored_playlist_table; + case 'stored_playlist_item': return player.stored_playlist_item_table; + } + throw new Error("BadSelectionType"); +} + +function artistId(s) { + return "lib-artist-" + toHtmlId(s); +} + +function artistDisplayName(name) { + return name || '[Unknown Artist]'; +} + +var triggerRenderLibrary = makeRenderCall(renderLibrary, 100); +var triggerRenderQueue = makeRenderCall(renderQueue, 100); +var triggerPlaylistsUpdate = makeRenderCall(renderPlaylists, 100); + +function makeRenderCall(renderFn, interval) { + var renderTimeout = null; + var renderWanted = false; + + return ensureRenderHappensSoon; + + function ensureRenderHappensSoon() { + if (renderTimeout) { + renderWanted = true; + return; + } + + renderFn(); + renderWanted = false; + renderTimeout = setTimeout(checkRender, interval); + } + + function checkRender() { + renderTimeout = null; + if (renderWanted) { + ensureRenderHappensSoon(); + } + } +} + +function renderPlaylists() { + var playlistList = player.stored_playlists; + var scrollTop = $playlists.scrollTop(); + + // add the missing dom entries + var i; + var playlistListDom = $playlistsList.get(0); + for (i = playlistListDom.childElementCount; i < playlistList.length; i += 1) { + $playlistsList.append( + '
  • ' + + '' + + '
      ' + + '
    • '); + } + // remove the extra dom entries + var domItem; + while (playlistList.length < playlistListDom.childElementCount) { + playlistListDom.removeChild(playlistListDom.lastChild); + } + + // overwrite existing dom entries + var playlist; + var $domItems = $playlistsList.children(); + for (i = 0; i < playlistList.length; i += 1) { + domItem = $domItems[i]; + playlist = playlistList[i]; + $(domItem).data('cached', false); + var divDom = domItem.children[0]; + divDom.setAttribute('id', toStoredPlaylistId(playlist.id)); + divDom.setAttribute('data-key', playlist.id); + var iconDom = divDom.children[0]; + $(iconDom) + .addClass(ICON_COLLAPSED) + .removeClass(ICON_EXPANDED); + var spanDom = divDom.children[1]; + spanDom.textContent = playlist.name; + var ulDom = domItem.children[1]; + ulDom.style.display = 'block'; + while (ulDom.firstChild) { + ulDom.removeChild(ulDom.firstChild); + } + } + + $playlists.scrollTop(scrollTop); + refreshSelection(); + // TODO expandPlaylistsToSelection() +} + +function renderLibrary() { + var artistList = player.searchResults.artistList || []; + var scrollTop = $library.scrollTop(); + + $emptyLibraryMessage.text(player.haveFileListCache ? "No Results" : "loading..."); + $libraryNoItems.toggle(!artistList.length); + + // add the missing dom entries + var i; + var artistListDom = $libraryArtists.get(0); + for (i = artistListDom.childElementCount; i < artistList.length; i += 1) { + $libraryArtists.append( + '
    • ' + + '' + + '
        ' + + '
      • '); + } + // remove the extra dom entries + var domItem; + while (artistList.length < artistListDom.childElementCount) { + artistListDom.removeChild(artistListDom.lastChild); + } + + // overwrite existing dom entries + var artist; + var $domItems = $libraryArtists.children(); + for (i = 0; i < artistList.length; i += 1) { + domItem = $domItems[i]; + artist = artistList[i]; + $(domItem).data('cached', false); + var divDom = domItem.children[0]; + divDom.setAttribute('id', artistId(artist.key)); + divDom.setAttribute('data-key', artist.key); + var iconDom = divDom.children[0]; + $(iconDom) + .addClass(ICON_COLLAPSED) + .removeClass(ICON_EXPANDED); + var spanDom = divDom.children[1]; + spanDom.textContent = artistDisplayName(artist.name); + var ulDom = domItem.children[1]; + ulDom.style.display = 'block'; + while (ulDom.firstChild) { + ulDom.removeChild(ulDom.firstChild); + } + } + + var $artists = $library.children("ul").children("li"); + var nodeCount = $artists.length; + expandStuff($artists); + $library.scrollTop(scrollTop); + refreshSelection(); + expandLibraryToSelection(); + + function expandStuff($liSet) { + if (nodeCount >= AUTO_EXPAND_LIMIT) return; + for (var i = 0; i < $liSet.length; i += 1) { + var li = $liSet[i]; + var $li = $(li); + var $ul = $li.children("ul"); + var $subLiSet = $ul.children("li"); + var proposedNodeCount = nodeCount + $subLiSet.length; + if (proposedNodeCount <= AUTO_EXPAND_LIMIT) { + toggleLibraryExpansion($li); + $ul = $li.children("ul"); + $subLiSet = $ul.children("li"); + nodeCount = proposedNodeCount; + expandStuff($subLiSet); + } + } + } + +} + +function getCurrentTrackPosition(){ + if (player.trackStartDate != null && player.isPlaying === true) { + return (new Date() - player.trackStartDate) / 1000; + } else { + return player.pausedTime; + } +} + +function updateSliderPos() { + if (userIsSeeking) return; + + var duration, disabled, elapsed, sliderPos; + if (player.currentItem && player.isPlaying != null && player.currentItem.track) { + disabled = false; + elapsed = getCurrentTrackPosition(); + duration = player.currentItem.track.duration; + sliderPos = elapsed / duration; + } else { + disabled = true; + elapsed = duration = sliderPos = 0; + } + $trackSlider.slider("option", "disabled", disabled).slider("option", "value", sliderPos); + $nowplaying_elapsed.html(formatTime(elapsed)); + $nowplaying_left.html(formatTime(duration)); +} + +function renderVolumeSlider() { + if (userIsVolumeSliding) return; + + $volSlider.slider('option', 'value', player.volume); + $volNum.text(Math.round(player.volume * 100)); + $volWarning.toggle(player.volume > 1); +} + +function getNowPlayingText(track) { + if (!track) { + return "(Deleted Track)"; + } + var str = track.name + " - " + track.artistName; + if (track.albumName) { + str += " - " + track.albumName; + } + return str; +} + +function renderNowPlaying() { + var track = null; + if (player.currentItem != null) { + track = player.currentItem.track; + } + + updateTitle(); + var trackDisplay; + if (track != null) { + trackDisplay = getNowPlayingText(track); + } else { + trackDisplay = " "; + } + $trackDisplay.html(trackDisplay); + var oldClass; + var newClass; + if (player.isPlaying === true) { + oldClass = 'ui-icon-play'; + newClass = 'ui-icon-pause'; + } else { + oldClass = 'ui-icon-pause'; + newClass = 'ui-icon-play'; + } + $nowplaying.find(".toggle span").removeClass(oldClass).addClass(newClass); + $trackSlider.slider("option", "disabled", player.isPlaying == null); + updateSliderPos(); + renderVolumeSlider(); +} + +function render(){ + var hideMainErr = loadStatus === LoadStatus.GoodToGo; + $queueWindow.toggle(hideMainErr); + $leftWindow.toggle(hideMainErr); + $nowplaying.toggle(hideMainErr); + $mainErrMsg.toggle(!hideMainErr); + if (!hideMainErr) { + document.title = BASE_TITLE; + $mainErrMsgText.text(loadStatus); + return; + } + renderQueue(); + renderPlaylistButtons(); + renderLibrary(); + renderNowPlaying(); + updateSettingsAuthUi(); + updateLastFmSettingsUi(); + handleResize(); +} + +function renderArtist($ul, albumList) { + albumList.forEach(function(album) { + $ul.append( + '
      • ' + + '' + + '
          ' + + '
        • '); + var liDom = $ul.get(0).lastChild; + var divDom = liDom.children[0]; + divDom.setAttribute('id', toAlbumId(album.key)); + divDom.setAttribute('data-key', album.key); + var spanDom = divDom.children[1]; + spanDom.textContent = album.name || '[Unknown Album]'; + + var artistUlDom = liDom.children[1]; + var $artistUlDom = $(artistUlDom); + album.trackList.forEach(function(track) { + $artistUlDom.append( + '
        • ' + + '
          ' + + '' + + '
          ' + + '
        • '); + var trackLiDom = artistUlDom.lastChild; + var trackDivDom = trackLiDom.children[0]; + trackDivDom.setAttribute('id', toTrackId(track.key)); + trackDivDom.setAttribute('data-key', track.key); + var trackSpanDom = trackDivDom.children[0]; + var caption = ""; + if (track.track) { + caption += track.track + ". "; + } + if (track.compilation) { + caption += track.artistName + " - "; + } + caption += track.name; + trackSpanDom.textContent = caption; + }); + }); +} + +function renderPlaylist($ul, playlist) { + playlist.itemList.forEach(function(item) { + debugger; + $ul.append( + '
        • ' + + '
          ' + + '' + + '
          ' + + '
        • '); + var liDom = $ul.get(0).lastChild; + var divDom = liDom.children[0]; + divDom.setAttribute('id', toStoredPlaylistItemId(item.id)); + divDom.setAttribute('data-key', item.id); + var spanDom = divDom.children[0]; + var track = item.track; + var caption = track.artistName + " - " + track.name; + spanDom.textContent = caption; + }); +} + +function genericToggleExpansion($li, options) { + var topLevelType = options.topLevelType; + var renderDom = options.renderDom; + var $div = $li.find("> div"); + var $ul = $li.find("> ul"); + if ($div.attr('data-type') === topLevelType) { + if (!$li.data('cached')) { + $li.data('cached', true); + var key = $div.attr('data-key'); + renderDom($ul, key); + + $ul.toggle(); + refreshSelection(); + } + } + $ul.toggle(); + var oldClass = ICON_EXPANDED; + var newClass = ICON_COLLAPSED; + if ($ul.is(":visible")) { + var tmp = oldClass; + oldClass = newClass; + newClass = tmp; + } + $div.find("div").removeClass(oldClass).addClass(newClass); +} + +function toggleLibraryExpansion($li) { + genericToggleExpansion($li, { + topLevelType: 'artist', + renderDom: function($ul, key) { + var albumList = player.searchResults.artistTable[key].albumList; + renderArtist($ul, albumList); + }, + }); +} + +function togglePlaylistExpansion($li) { + genericToggleExpansion($li, { + topLevelType: 'stored_playlist', + renderDom: function($ul, key) { + var playlist = player.stored_playlist_table[key]; + renderPlaylist($ul, playlist); + }, + }); +} + +function maybeDeleteTracks(keysList) { + var fileList = keysList.map(function(key) { + return player.library.trackTable[key].file; + }); + var listText = fileList.slice(0, 7).join("\n "); + if (fileList.length > 7) { + listText += "\n ..."; + } + var songText = fileList.length === 1 ? "song" : "songs"; + var message = "You are about to delete " + fileList.length + " " + songText + " permanently:\n\n " + listText; + if (!confirm(message)) return false; + player.deleteTracks(keysList); + return true; +} + +function handleDeletePressed(shift) { + var keysList; + if (selection.isLibrary()) { + keysList = selection.toTrackKeys(); + maybeDeleteTracks(keysList); + } else if (selection.isStoredPlaylist()) { + if (shift) { + keysList = selection.toTrackKeys(); + maybeDeleteTracks(keysList); + } else { + maybeDeleteSelectedPlaylists(); + } + } else if (selection.isQueue()) { + if (shift) { + keysList = []; + for (var id in selection.ids.queue) { + keysList.push(player.queue.itemTable[id].track.key); + } + if (!maybeDeleteTracks(keysList)) return; + } + var sortKey = player.queue.itemTable[selection.cursor].sortKey; + player.removeIds(Object.keys(selection.ids.queue)); + var item = null; + for (var i = 0; i < player.queue.itemList.length; i++) { + item = player.queue.itemList[i]; + if (item.sortKey > sortKey) { + // select the very next one + break; + } + // if we deleted the last item, select the new last item. + } + // if there's no items, select nothing. + if (item != null) { + selection.selectOnly('queue', item.id); + } + refreshSelection(); + } +} + +function togglePlayback(){ + if (player.isPlaying === true) { + player.pause(); + } else if (player.isPlaying === false) { + player.play(); + } + // else we haven't received state from server yet +} + +function setDynamicMode(value) { + dynamicModeOn = value; + player.sendCommand('dynamicModeOn', dynamicModeOn); +} + +function toggleDynamicMode(){ + setDynamicMode(!dynamicModeOn); +} + +function nextRepeatState(){ + player.setRepeatMode((player.repeat + 1) % repeatModeNames.length); +} + +var keyboardHandlers = (function(){ + function upDownHandler(event){ + var defaultIndex, dir, nextPos; + if (event.which === 38) { + // up + defaultIndex = player.currentItem ? player.currentItem.index - 1 : player.queue.itemList.length - 1; + dir = -1; + } else { + // down + defaultIndex = player.currentItem ? player.currentItem.index + 1 : 0; + dir = 1; + } + if (defaultIndex >= player.queue.itemList.length) { + defaultIndex = player.queue.itemList.length - 1; + } else if (defaultIndex < 0) { + defaultIndex = 0; + } + if (event.altKey) { + if (selection.isQueue()) { + player.shiftIds(selection.ids.queue, dir); + } + } else { + if (selection.isQueue()) { + nextPos = player.queue.itemTable[selection.cursor].index + dir; + if (nextPos < 0 || nextPos >= player.queue.itemList.length) { + return; + } + selection.cursor = player.queue.itemList[nextPos].id; + if (!event.ctrlKey && !event.shiftKey) { + // single select + selection.clear(); + selection.ids.queue[selection.cursor] = true; + selection.rangeSelectAnchor = selection.cursor; + selection.rangeSelectAnchorType = selection.type; + } else if (!event.ctrlKey && event.shiftKey) { + // range select + selectPlaylistRange(); + } else { + // ghost selection + selection.rangeSelectAnchor = selection.cursor; + selection.rangeSelectAnchorType = selection.type; + } + } else if (selection.isLibrary()) { + nextPos = selection.getPos(); + if (dir > 0) { + selection.incrementPos(nextPos); + } else { + prevLibPos(nextPos); + } + if (nextPos.artist == null) return; + if (nextPos.track != null) { + selection.type = 'track'; + selection.cursor = nextPos.track.key; + } else if (nextPos.album != null) { + selection.type = 'album'; + selection.cursor = nextPos.album.key; + } else { + selection.type = 'artist'; + selection.cursor = nextPos.artist.key; + } + if (!event.ctrlKey && !event.shiftKey) { + // single select + selection.selectOnly(selection.type, selection.cursor); + } else if (!event.ctrlKey && event.shiftKey) { + // range select + selectTreeRange(); + } else { + // ghost selection + selection.rangeSelectAnchor = selection.cursor; + selection.rangeSelectAnchorType = selection.type; + } + } else { + if (player.queue.itemList.length === 0) return; + selection.selectOnly('queue', player.queue.itemList[defaultIndex].id); + } + refreshSelection(); + } + if (selection.isQueue()) scrollPlaylistToSelection(); + if (selection.isLibrary()) scrollLibraryToSelection(); + } + function leftRightHandler(event){ + var dir = event.which === 37 ? -1 : 1; + if (selection.isLibrary()) { + var helpers = getSelectionHelpers(); + if (!helpers) return; + var helper = helpers[selection.type]; + var selected_item = helper.table[selection.cursor]; + var is_expanded_funcs = { + artist: isArtistExpanded, + album: isAlbumExpanded, + track: function(){ + return true; + } + }; + var is_expanded = is_expanded_funcs[selection.type](selected_item); + var $li = helper.$getDiv(selection.cursor).closest("li"); + if (dir > 0) { + if (!is_expanded) { + toggleLibraryExpansion($li); + } + } else { + if (is_expanded) { + toggleLibraryExpansion($li); + } + } + } else { + if (event.ctrlKey) { + if (dir > 0) { + player.next(); + } else { + player.prev(); + } + } else if (event.shiftKey) { + if (!player.currentItem) return; + player.seek(null, getCurrentTrackPosition() + dir * player.currentItem.track.duration * 0.10); + } else { + player.seek(null, getCurrentTrackPosition() + dir * 10); + } + } + } + var volumeDownHandler = { + ctrl: false, + alt: false, + shift: null, + handler: function(){ + bumpVolume(-0.1); + } + }; + var volumeUpHandler = { + ctrl: false, + alt: false, + shift: null, + handler: function(){ + bumpVolume(0.1); + } + }; + return { + // Enter + 13: { + ctrl: false, + alt: null, + shift: null, + handler: function(event){ + if (selection.isQueue()) { + player.seek(selection.cursor, 0); + player.play(); + } else if (selection.isLibrary()) { + queueSelection(event); + } + return false; + }, + }, + // Escape + 27: { + ctrl: false, + alt: false, + shift: false, + handler: function(){ + if (started_drag) { + abortDrag(); + return; + } + if (removeContextMenu()) return; + selection.fullClear(); + refreshSelection(); + }, + }, + // Space + 32: { + ctrl: null, + alt: false, + shift: false, + handler: function() { + if (event.ctrlKey) { + toggleSelectionUnderCursor(); + refreshSelection(); + } else { + togglePlayback(); + } + }, + }, + // Left + 37: { + ctrl: null, + alt: false, + shift: null, + handler: leftRightHandler, + }, + // Up + 38: { + ctrl: null, + alt: null, + shift: null, + handler: upDownHandler, + }, + // Right + 39: { + ctrl: null, + alt: false, + shift: null, + handler: leftRightHandler, + }, + // Down + 40: { + ctrl: null, + alt: null, + shift: null, + handler: upDownHandler, + }, + // Delete + 46: { + ctrl: false, + alt: false, + shift: null, + handler: function(event) { + if ((havePerm('admin') && event.shiftKey) || + (havePerm('control') && !event.shiftKey)) + { + handleDeletePressed(event.shiftKey); + } + }, + }, + // = + 61: volumeUpHandler, + // C + 67: { + ctrl: false, + alt: false, + shift: true, + handler: function(){ + player.clear(); + }, + }, + // d + 68: { + ctrl: false, + alt: false, + shift: false, + handler: toggleDynamicMode, + }, + // e + 69: { + ctrl: false, + alt: false, + shift: false, + handler: function(){ + clickTab(tabs.settings); + }, + }, + // S + 72: { + ctrl: false, + alt: false, + shift: true, + handler: function(){ + player.shuffle(); + }, + }, + // r + 82: { + ctrl: false, + alt: false, + shift: false, + handler: nextRepeatState + }, + // s + 83: { + ctrl: false, + alt: false, + shift: false, + handler: toggleStreamStatus + }, + // t + 84: { + ctrl: false, + alt: false, + shift: false, + handler: function() { + clickTab(tabs.events); + $chatBoxInput.focus().select(); + scrollEventsToBottom(); + }, + }, + // i + 73: { + ctrl: false, + alt: false, + shift: false, + handler: function(){ + clickTab(tabs.upload); + $uploadByUrl.focus().select(); + }, + }, + // - maybe? + 173: volumeDownHandler, + // + + 187: volumeUpHandler, + // , < + 188: { + ctrl: false, + alt: false, + shift: null, + handler: function(){ + player.prev(); + }, + }, + // _ maybe? + 189: volumeDownHandler, + // . > + 190: { + ctrl: false, + alt: false, + shift: null, + handler: function(){ + player.next(); + }, + }, + // ? + 191: { + ctrl: false, + alt: false, + shift: null, + handler: function(event){ + if (event.shiftKey) { + $shortcuts.dialog({ + modal: true, + title: "Keyboard Shortcuts", + minWidth: 600, + height: $document.height() - 40, + }); + $shortcuts.focus(); + } else { + clickTab(tabs.library); + $libFilter.focus().select(); + } + }, + }, + }; +})(); + +function bumpVolume(v) { + if (tryingToStream) { + setStreamVolume(streamAudio.volume + v); + } else { + player.setVolume(player.volume + v); + } +} + +function removeContextMenu() { + if ($queueMenu.is(":visible")) { + $queueMenu.hide(); + return true; + } + if ($libraryMenu.is(":visible")) { + $libraryMenu.hide(); + return true; + } + return false; +} + +function isArtistExpanded(artist){ + var artistHtmlId = artistId(artist.key); + var artistElem = document.getElementById(artistHtmlId); + var $li = $(artistElem).closest('li'); + if (!$li.data('cached')) return false; + return $li.find("> ul").is(":visible"); +} + +function expandArtist(artist) { + if (isArtistExpanded(artist)) return; + + var artistElem = document.getElementById(artistId(artist.key)); + var $li = $(artistElem).closest('li'); + toggleLibraryExpansion($li); +} + +function isAlbumExpanded(album){ + var albumElem = document.getElementById(toAlbumId(album.key)); + var $li = $(albumElem).closest('li'); + return $li.find("> ul").is(":visible"); +} + +function expandAlbum(album) { + if (isAlbumExpanded(album)) return; + + expandArtist(album.artist); + var elem = document.getElementById(toAlbumId(album.key)); + var $li = $(elem).closest('li'); + toggleLibraryExpansion($li); +} + +function expandLibraryToSelection() { + if (!selection.isLibrary()) return; + for (var trackKey in selection.ids.track) { + var track = player.library.trackTable[trackKey]; + expandAlbum(track.album); + } + for (var albumKey in selection.ids.album) { + var album = player.library.albumTable[albumKey]; + expandArtist(album.artist); + } + scrollLibraryToSelection(); +} + +function isStoredPlaylistExpanded(stored_playlist){ + var $li = $("#stored-pl-pl-" + toHtmlId(stored_playlist.name)).closest("li"); + return $li.find("> ul").is(":visible"); +} + +function prevLibPos(libPos){ + if (libPos.track != null) { + libPos.track = libPos.track.album.trackList[libPos.track.index - 1]; + } else if (libPos.album != null) { + libPos.album = libPos.artist.albumList[libPos.album.index - 1]; + if (libPos.album != null && isAlbumExpanded(libPos.album)) { + libPos.track = libPos.album.trackList[libPos.album.trackList.length - 1]; + } + } else if (libPos.artist != null) { + libPos.artist = player.searchResults.artistList[libPos.artist.index - 1]; + if (libPos.artist != null && isArtistExpanded(libPos.artist)) { + libPos.album = libPos.artist.albumList[libPos.artist.albumList.length - 1]; + if (libPos.album != null && isAlbumExpanded(libPos.album)) { + libPos.track = libPos.album.trackList[libPos.album.trackList.length - 1]; + } + } + } +} +function queueSelection(event){ + var keys = selection.toTrackKeys(event.altKey); + if (event.shiftKey) { + player.queueTracksNext(keys); + } else { + player.queueTracks(keys); + } + return false; +} + +function toggleSelectionUnderCursor() { + var key = selection.cursor; + var type = selection.type; + if (selection.ids[type][key] != null) { + delete selection.ids[type][key]; + } else { + selection.ids[type][key] = true; + } +} + +function selectPlaylistRange() { + selection.clear(); + var anchor = selection.rangeSelectAnchor; + if (anchor == null) anchor = selection.cursor; + var min_pos = player.queue.itemTable[anchor].index; + var max_pos = player.queue.itemTable[selection.cursor].index; + if (max_pos < min_pos) { + var tmp = min_pos; + min_pos = max_pos; + max_pos = tmp; + } + for (var i = min_pos; i <= max_pos; i++) { + selection.ids.queue[player.queue.itemList[i].id] = true; + } +} +function selectTreeRange() { + selection.clear(); + var old_pos = selection.getPos(selection.rangeSelectAnchorType, selection.rangeSelectAnchor); + var new_pos = selection.getPos(selection.type, selection.cursor); + if (compareArrays(selection.posToArr(old_pos), selection.posToArr(new_pos)) > 0) { + var tmp = old_pos; + old_pos = new_pos; + new_pos = tmp; + } + while (selection.posInBounds(old_pos)) { + selection.selectPos(old_pos); + if (selection.posEqual(old_pos, new_pos)) { + break; + } + selection.incrementPos(old_pos); + } +} + +function sendAuth() { + if (!localState.authPassword || !localState.authUsername) return; + socket.send('login', { + username: localState.authUsername, + password: localState.authPassword, + }); +} + +function settingsAuthSave() { + localState.authUsername = $authUsername.val(); + localState.authPassword = $authPassword.val(); + saveLocalState(); + sendAuth(); + hideShowAuthEdit(false); +} + +function changeUserName(username) { + localState.authUsername = username; + saveLocalState(); + sendAuth(); +} + +function settingsAuthCancel() { + hideShowAuthEdit(false); +} + +function hideShowAuthEdit(visible) { + $settingsRegister.toggle(visible); + $settingsShowAuth.toggle(!visible); +} + +function performDrag(event, callbacks){ + abortDrag(); + var start_drag_x = event.pageX; + var start_drag_y = event.pageY; + abortDrag = function(){ + $document.off('mousemove', onDragMove).off('mouseup', onDragEnd); + if (started_drag) { + $queueItems.find(".pl-item").removeClass('border-top').removeClass('border-bottom'); + started_drag = false; + } + abortDrag = function(){}; + }; + function onDragMove(event){ + var dist, result; + if (!started_drag) { + dist = Math.pow(event.pageX - start_drag_x, 2) + Math.pow(event.pageY - start_drag_y, 2); + if (dist > 64) { + started_drag = true; + } + if (!started_drag) { + return; + } + } + result = getDragPosition(event.pageX, event.pageY); + $queueItems.find(".pl-item").removeClass('border-top').removeClass('border-bottom'); + if (result.$next != null) { + result.$next.addClass("border-top"); + } else if (result.$previous != null) { + result.$previous.addClass("border-bottom"); + } + } + function onDragEnd(event){ + if (event.which !== 1) { + return false; + } + if (started_drag) { + callbacks.complete(getDragPosition(event.pageX, event.pageY), event); + } else { + callbacks.cancel(); + } + abortDrag(); + } + $document.on('mousemove', onDragMove).on('mouseup', onDragEnd); + onDragMove(event); +} + +function setUpGenericUi(){ + $document.on('mouseover', '.hoverable', function(event){ + $(this).addClass("ui-state-hover"); + }); + $document.on('mouseout', '.hoverable', function(event){ + $(this).removeClass("ui-state-hover"); + }); + $(".jquery-button").button().on('click', blur); + $document.on('mousedown', function(){ + removeContextMenu(); + selection.fullClear(); + refreshSelection(); + }); + $document.on('keydown', function(event){ + var handler = keyboardHandlers[event.which]; + if (handler == null) return true; + if (handler.ctrl != null && handler.ctrl !== event.ctrlKey) return true; + if (handler.alt != null && handler.alt !== event.altKey) return true; + if (handler.shift != null && handler.shift !== event.shiftKey) return true; + handler.handler(event); + return false; + }); + $shortcuts.on('keydown', function(event) { + event.stopPropagation(); + if (event.which === 27) { + $shortcuts.dialog('close'); + } + }); +} + +function blur() { + $(this).blur(); +} + +var dynamicModeLabel = document.getElementById('dynamic-mode-label'); +var plBtnRepeatLabel = document.getElementById('queue-btn-repeat-label'); +function setUpPlayQueueUi() { + $queueWindow.on('click', 'button.clear', function(event){ + player.clear(); + }); + $queueWindow.on('mousedown', 'button.clear', stopPropagation); + + $queueWindow.on('click', 'button.shuffle', function(){ + player.shuffle(); + }); + $queueWindow.on('mousedown', 'button.shuffle', stopPropagation); + + $queueBtnRepeat.on('click', nextRepeatState); + plBtnRepeatLabel.addEventListener('mousedown', stopPropagation, false); + + $dynamicMode.on('click', function(){ + var value = $(this).prop("checked"); + setDynamicMode(value); + return false; + }); + dynamicModeLabel.addEventListener('mousedown', stopPropagation, false); + + $queueItems.on('dblclick', '.pl-item', function(event){ + var trackId = $(this).attr('data-id'); + player.seek(trackId, 0); + player.play(); + }); + $queueItems.on('contextmenu', function(event){ + return event.altKey; + }); + $queueItems.on('mousedown', '.pl-item', function(event){ + var trackId, skipDrag; + if (started_drag) return true; + $(document.activeElement).blur(); + if (event.which === 1) { + event.preventDefault(); + removeContextMenu(); + trackId = $(this).attr('data-id'); + skipDrag = false; + if (!selection.isQueue()) { + selection.selectOnly('queue', trackId); + } else if (event.ctrlKey || event.shiftKey) { + skipDrag = true; + if (event.shiftKey && !event.ctrlKey) { + // range select click + selection.cursor = trackId; + selectPlaylistRange(); + } else if (!event.shiftKey && event.ctrlKey) { + // individual item selection toggle + selection.cursor = trackId; + selection.rangeSelectAnchor = trackId; + selection.rangeSelectAnchorType = selection.type; + toggleSelectionUnderCursor(); + } + } else if (selection.ids.queue[trackId] == null) { + selection.selectOnly('queue', trackId); + } + refreshSelection(); + if (!skipDrag) { + return performDrag(event, { + complete: function(result, event){ + var delta, id; + delta = { + top: 0, + bottom: 1 + }; + player.moveIds((function(){ + var results$ = []; + for (var id in selection.ids.queue) { + results$.push(id); + } + return results$; + })(), result.previous_key, result.next_key); + }, + cancel: function(){ + selection.selectOnly('queue', trackId); + refreshSelection(); + } + }); + } + } else if (event.which === 3) { + if (event.altKey) return; + event.preventDefault(); + removeContextMenu(); + trackId = $(this).attr('data-id'); + if (!selection.isQueue() || selection.ids.queue[trackId] == null) { + selection.selectOnly('queue', trackId); + refreshSelection(); + } + if (!selection.isMulti()) { + var item = player.queue.itemTable[trackId]; + $queueMenu.find('.download').attr('href', encodeDownloadHref(item.track.file)); + } else { + $queueMenu.find('.download').attr('href', makeMultifileDownloadHref()); + } + $queueMenu.show().offset({ + left: event.pageX + 1, + top: event.pageY + 1 + }); + updateMenuDisableState($queueMenu); + } + }); + $queueItems.on('mousedown', function(){ + return false; + }); + $queueMenu.menu(); + $queueMenu.on('mousedown', function(){ + return false; + }); + $queueMenu.on('click', '.remove', function(){ + handleDeletePressed(false); + removeContextMenu(); + return false; + }); + $queueMenu.on('click', '.download', onDownloadContextMenu); + $queueMenu.on('click', '.delete', onDeleteContextMenu); + $queueMenu.on('click', '.edit-tags', onEditTagsContextMenu); +} + +function niceDateString() { + var now = new Date(); + var year = 1900 + now.getYear(); + var month = zfill(now.getMonth() + 1, 2); + var day = zfill(now.getDate(), 2); + return year + '-' + month + '-' + day; +} + +function setUpPlaylistsUi() { + $newPlaylistBtn.on('click', function(event) { + player.createPlaylist("New Playlist " + niceDateString()); + }); + genericTreeUi($playlistsList, { + toggleExpansion: togglePlaylistExpansion, + isSelectionOwner: function() { + return selection.isStoredPlaylist(); + }, + }); +} + +function stopPropagation(event) { + event.stopPropagation(); +} + +function onDownloadContextMenu() { + removeContextMenu(); + return true; +} +function onDeleteContextMenu() { + if (!havePerm('admin')) return false; + removeContextMenu(); + handleDeletePressed(true); + return false; +} +var editTagsTrackKeys = null; +var editTagsTrackIndex = null; +function onEditTagsContextMenu() { + if (!havePerm('admin')) return false; + removeContextMenu(); + editTagsTrackKeys = selection.toTrackKeys(); + editTagsTrackIndex = 0; + showEditTags(); + return false; +} +var EDITABLE_PROPS = { + name: { + type: 'string', + write: true, + }, + artistName: { + type: 'string', + write: true, + }, + albumArtistName: { + type: 'string', + write: true, + }, + albumName: { + type: 'string', + write: true, + }, + compilation: { + type: 'boolean', + write: true, + }, + track: { + type: 'integer', + write: true, + }, + trackCount: { + type: 'integer', + write: true, + }, + disc: { + type: 'integer', + write: true, + }, + discCount: { + type: 'integer', + write: true, + }, + year: { + type: 'integer', + write: true, + }, + genre: { + type: 'string', + write: true, + }, + composerName: { + type: 'string', + write: true, + }, + performerName: { + type: 'string', + write: true, + }, + file: { + type: 'string', + write: false, + }, +}; +var EDIT_TAG_TYPES = { + 'string': { + get: function(domItem) { + return domItem.value; + }, + set: function(domItem, value) { + domItem.value = value || ""; + }, + }, + 'integer': { + get: function(domItem) { + var n = parseInt(domItem.value, 10); + if (isNaN(n)) return null; + return n; + }, + set: function(domItem, value) { + domItem.value = value == null ? "" : value; + }, + }, + 'boolean': { + get: function(domItem) { + return domItem.checked; + }, + set: function(domItem, value) { + domItem.checked = !!value; + }, + }, +}; +var perDom = document.getElementById('edit-tags-per'); +var perLabelDom = document.getElementById('edit-tags-per-label'); +var prevDom = document.getElementById('edit-tags-prev'); +var nextDom = document.getElementById('edit-tags-next'); +var editTagsFocusDom = document.getElementById('edit-tag-name'); +function updateEditTagsUi() { + var multiple = editTagsTrackKeys.length > 1; + prevDom.disabled = !perDom.checked || editTagsTrackIndex === 0; + nextDom.disabled = !perDom.checked || (editTagsTrackIndex === editTagsTrackKeys.length - 1); + prevDom.style.visibility = multiple ? 'visible' : 'hidden'; + nextDom.style.visibility = multiple ? 'visible' : 'hidden'; + perLabelDom.style.visibility = multiple ? 'visible' : 'hidden'; + var multiCheckBoxVisible = multiple && !perDom.checked; + var trackKeysToUse = perDom.checked ? [editTagsTrackKeys[editTagsTrackIndex]] : editTagsTrackKeys; + + for (var propName in EDITABLE_PROPS) { + var propInfo = EDITABLE_PROPS[propName]; + var type = propInfo.type; + var setter = EDIT_TAG_TYPES[type].set; + var domItem = document.getElementById('edit-tag-' + propName); + domItem.disabled = !propInfo.write; + var multiCheckBoxDom = document.getElementById('edit-tag-multi-' + propName); + multiCheckBoxDom.style.visibility = (multiCheckBoxVisible && propInfo.write) ? 'visible' : 'hidden'; + var commonValue = null; + var consistent = true; + for (var i = 0; i < trackKeysToUse.length; i += 1) { + var key = trackKeysToUse[i]; + var track = player.library.trackTable[key]; + var value = track[propName]; + if (commonValue == null) { + commonValue = value; + } else if (commonValue !== value) { + consistent = false; + break; + } + } + multiCheckBoxDom.checked = consistent; + setter(domItem, consistent ? commonValue : null); + } +} +function showEditTags() { + $editTagsDialog.dialog({ + modal: true, + title: "Edit Tags", + minWidth: 800, + height: $document.height() - 40, + }); + perDom.checked = false; + updateEditTagsUi(); + editTagsFocusDom.focus(); +} + +function setUpEditTagsUi() { + $editTagsDialog.find("input").on("keydown", function(event) { + event.stopPropagation(); + if (event.which === 27) { + $editTagsDialog.dialog('close'); + } else if (event.which === 13) { + saveAndClose(); + } + }); + for (var propName in EDITABLE_PROPS) { + var domItem = document.getElementById('edit-tag-' + propName); + var multiCheckBoxDom = document.getElementById('edit-tag-multi-' + propName); + var listener = createChangeListener(multiCheckBoxDom); + domItem.addEventListener('change', listener, false); + domItem.addEventListener('keypress', listener, false); + domItem.addEventListener('focus', onFocus, false); + } + + function onFocus(event) { + editTagsFocusDom = event.target; + } + + function createChangeListener(multiCheckBoxDom) { + return function() { + multiCheckBoxDom.checked = true; + }; + } + $("#edit-tags-ok").on('click', saveAndClose); + $("#edit-tags-cancel").on('click', closeDialog); + perDom.addEventListener('click', updateEditTagsUi, false); + nextDom.addEventListener('click', saveAndNext, false); + prevDom.addEventListener('click', saveAndPrev, false); + + function saveAndMoveOn(dir) { + save(); + editTagsTrackIndex += dir; + updateEditTagsUi(); + editTagsFocusDom.focus(); + editTagsFocusDom.select(); + } + + function saveAndNext() { + saveAndMoveOn(1); + } + + function saveAndPrev() { + saveAndMoveOn(-1); + } + + function save() { + var trackKeysToUse = perDom.checked ? [editTagsTrackKeys[editTagsTrackIndex]] : editTagsTrackKeys; + var cmd = {}; + for (var i = 0; i < trackKeysToUse.length; i += 1) { + var key = trackKeysToUse[i]; + var track = player.library.trackTable[key]; + var props = cmd[track.key] = {}; + for (var propName in EDITABLE_PROPS) { + var propInfo = EDITABLE_PROPS[propName]; + var type = propInfo.type; + var getter = EDIT_TAG_TYPES[type].get; + var domItem = document.getElementById('edit-tag-' + propName); + var multiCheckBoxDom = document.getElementById('edit-tag-multi-' + propName); + if (multiCheckBoxDom.checked && propInfo.write) { + props[propName] = getter(domItem); + } + } + } + player.sendCommand('updateTags', cmd); + } + + function saveAndClose() { + save(); + closeDialog(); + } + + function closeDialog() { + $editTagsDialog.dialog('close'); + } +} + +function updateSliderUi(value){ + var percent = value * 100; + $trackSlider.css('background-size', percent + "% 100%"); +} + +function setUpNowPlayingUi(){ + var actions = { + toggle: togglePlayback, + prev: function(){ + player.prev(); + }, + next: function(){ + player.next(); + }, + stop: function(){ + player.stop(); + } + }; + for (var cls in actions) { + var action = actions[cls]; + setUpMouseDownListener(cls, action); + } + $trackSlider.slider({ + step: 0.0001, + min: 0, + max: 1, + change: function(event, ui){ + updateSliderUi(ui.value); + if (event.originalEvent == null) { + return; + } + if (!player.currentItem) return; + player.seek(null, ui.value * player.currentItem.track.duration); + }, + slide: function(event, ui){ + updateSliderUi(ui.value); + if (!player.currentItem) return; + $nowplaying_elapsed.html(formatTime(ui.value * player.currentItem.track.duration)); + }, + start: function(event, ui){ + userIsSeeking = true; + }, + stop: function(event, ui){ + userIsSeeking = false; + } + }); + function setVol(event, ui){ + if (event.originalEvent == null) return; + var snap = 0.05; + var val = ui.value; + if (Math.abs(val - 1) < snap) { + val = 1; + } + player.setVolume(val); + $volNum.text(Math.round(val * 100)); + $volWarning.toggle(val > 1); + } + $volSlider.slider({ + step: 0.01, + min: 0, + max: 2, + change: setVol, + slide: setVol, + start: function(event, ui){ + userIsVolumeSliding = true; + }, + stop: function(event, ui){ + userIsVolumeSliding = false; + } + }); + setInterval(updateSliderPos, 100); + function setUpMouseDownListener(cls, action){ + $nowplaying.on('mousedown', "li." + cls, function(event){ + action(); + return false; + }); + } +} + +function clickTab(tab) { + unselectTabs(); + tab.$tab.addClass('ui-state-active'); + tab.$pane.show(); + activeTab = tab; + handleResize(); + if (tab === tabs.events) { + player.markAllEventsSeen(); + renderUnseenChatCount(); + } +} + +function setUpTabListener(tab) { + tab.$tab.on('click', function(event) { + clickTab(tab); + }); +} + +function setUpTabsUi() { + for (var name in tabs) { + var tab = tabs[name]; + setUpTabListener(tab); + } +} + +function unselectTabs() { + for (var name in tabs) { + var tab = tabs[name]; + tab.$tab.removeClass('ui-state-active'); + tab.$pane.hide(); + } +} + +function uploadFiles(files) { + if (files.length === 0) return; + + var formData = new FormData(); + + if (localState.autoQueueUploads) { + formData.append('autoQueue', '1'); + } + + for (var i = 0; i < files.length; i += 1) { + var file = files[i]; + formData.append("size", String(file.size)); + formData.append("file", file); + } + + var $progressBar = $('
          '); + $progressBar.progressbar(); + var $cancelBtn = $(''); + $cancelBtn.on('click', onCancel); + + $uploadWidget.append($progressBar); + $uploadWidget.append($cancelBtn); + + var req = new XMLHttpRequest(); + req.upload.addEventListener('progress', onProgress, false); + req.addEventListener('load', onLoad, false); + req.open('POST', '/upload'); + req.send(formData); + uploadInput.value = null; + + function onProgress(e) { + if (!e.lengthComputable) return; + var progress = e.loaded / e.total; + $progressBar.progressbar("option", "value", progress * 100); + } + + function onLoad(e) { + cleanup(); + } + + function onCancel() { + req.abort(); + cleanup(); + } + + function cleanup() { + $progressBar.remove(); + $cancelBtn.remove(); + } +} + +function setAutoUploadBtnState() { + $autoQueueUploads + .button('option', 'label', localState.autoQueueUploads ? 'On' : 'Off') + .prop('checked', localState.autoQueueUploads) + .button('refresh'); +} + +function setUpUploadUi(){ + $autoQueueUploads.button({ label: "..." }); + setAutoUploadBtnState(); + $autoQueueUploads.on('click', function(event) { + var value = $(this).prop('checked'); + localState.autoQueueUploads = value; + saveLocalState(); + setAutoUploadBtnState(); + }); + uploadInput.addEventListener('change', onChange, false); + + function onChange(e) { + uploadFiles(this.files); + } + + $uploadByUrl.on('keydown', function(event){ + event.stopPropagation(); + if (event.which === 27) { + $uploadByUrl.val("").blur(); + } else if (event.which === 13) { + importUrl(); + } + }); + + function importUrl() { + var url = $uploadByUrl.val(); + var id = uuid(); + $uploadByUrl.val("").blur(); + socket.send('importUrl', { + url: url, + id: id, + autoQueue: !!localState.autoQueueUploads, + }); + } +} + +function updateLastFmApiKey(key) { + lastFmApiKey = key; + updateLastFmSettingsUi(); +} + +function updateLastFmSettingsUi() { + if (localState.lastfm.username) { + $settingsLastFmIn.show(); + $settingsLastFmOut.hide(); + } else { + $settingsLastFmIn.hide(); + $settingsLastFmOut.show(); + } + settingsLastFmUserDom.setAttribute('href', "http://last.fm/user/" + + encodeURIComponent(localState.lastfm.username)); + settingsLastFmUserDom.textContent = localState.lastfm.username; + var authUrl = "https://www.last.fm/api/auth?api_key=" + + encodeURIComponent(lastFmApiKey) + "&cb=" + + encodeURIComponent(location.protocol + "//" + location.host + "/"); + lastFmAuthUrlDom.setAttribute('href', authUrl); + $toggleScrobble + .button('option', 'label', localState.lastfm.scrobbling_on ? 'On' : 'Off') + .prop('checked', localState.lastfm.scrobbling_on) + .button('refresh'); +} + +function updateSettingsAuthUi() { + var i, user; + var request = null; + var selectedUserId = $settingsUsersSelect.val(); + $settingsUsersSelect.empty(); + for (i = 0; i < player.usersList.length; i += 1) { + user = player.usersList[i]; + if (user.approved) { + $settingsUsersSelect.append($("