Compare commits

...

88 commits

Author SHA1 Message Date
Andrew Kelley
a27807082a Merge pull request #251 from CalebMorris/master
Changed doc link to redirect locally.
2014-05-07 22:41:31 -07:00
Caleb Morris
fae50a8d69 Changed doc link to redirect locally. 2014-05-07 20:31:54 -07:00
Andrew Kelley
fa21bdc3d2 refactor import by youtube url into plugin 2014-05-04 20:28:07 -07:00
Andrew Kelley
cb6077bc87 Merge pull request #237 from seansaleh/master
Chrome waits for 2mb before playing song, leading to long buffering times
2014-04-28 11:23:22 -07:00
seansaleh
7ea657e0d4 instantBufferBytes are now a configurable option 2014-04-28 11:19:54 -07:00
Andrew Kelley
0e3f87b8f1 Merge pull request #238 from yoasif/master
added conf.py to docs because of build failure.
2014-04-27 20:28:34 -07:00
Asif Youssuff
ec7ebcfda1 added conf.py to docs because of build failure. 2014-04-27 23:23:33 -04:00
Andrew Kelley
bb1effc161 Merge pull request #231 from yoasif/master
Initial documentation commit
2014-04-27 20:09:05 -07:00
Andrew Kelley
656d3068c4 Merge pull request #233 from toofar/mpd
Make MPD server more like MPD.
2014-04-27 20:06:10 -07:00
jimmy
9a24b61d81 MPD: Support "any" as a search type in find and search.
Currently I am just searching all the defined tag types. I am not sure if
there should be a specific order they are searched in or whether we should
just be searching string ones.

Now coerces the tag value to a string.
2014-04-27 21:29:54 +12:00
jimmy
6745455a74 MPD: Make "search" a substring match.
This seems to be what mpd does I and am rather used to that behaviour. I am
switching on caseSensitive here which may not be what it is intended for but
for now that only get passed through as false from one place so everything
else should still work fine.
2014-04-27 21:29:54 +12:00
Asif Youssuff
26474c9002 Initial documentation commit 2014-04-25 22:56:29 -04:00
Andrew Kelley
35648a0a1d README: mention memory constraints of rpi. see #228 2014-04-24 09:41:12 -07:00
Andrew Kelley
7527911bb2 Merge pull request #226 from jeffrom/menuglitch
fix disabled menu item jumping on focus
2014-04-23 21:36:51 -07:00
jeffrom
3971dc65f6 fix disabled menu item focus jumping
unfortunately this causes a new issue. the container gets 1px larger i
think due to box model issues. but this is better than before. le sigh...
2014-04-23 20:13:43 -07:00
Andrew Kelley
92f0c80e9f close grooveFile instances at the correct time. closes #200 2014-04-23 19:40:30 -07:00
Andrew Kelley
d17157fcc9 fix potential crash when users disconnect from client 2014-04-23 17:06:56 -07:00
Andrew Kelley
45b8d28dcb README: add note about debian and ubuntu node package. closes #215 2014-04-23 00:42:38 -07:00
Andrew Kelley
4133c55fa7 Merge pull request #208 from rbuch/patch-1
Add margin to URL upload bar
2014-04-22 15:48:40 -07:00
Ronak Buch
53d1cbe9b0 Add margin to URL upload bar
This makes margins for text input consistent across tabs.
2014-04-22 17:40:40 -05:00
Andrew Kelley
fa8fbf60fc update node-groove. closes #203 2014-04-21 10:06:39 -07:00
Andrew Kelley
3c02511c9e add acoustid app key 2014-04-19 15:41:40 -07:00
Andrew Kelley
d642629fd0 update to node-groove 1.4.0 which has acoustid scanning support 2014-04-19 15:34:11 -07:00
Andrew Kelley
b861d4776c fix dynamic mode not sorting songs correctly
Previously when track information was loaded from the DB dates (such as
lastQueueDate) were read as strings since they were simply deserialized
with JSON.parse. Now type information is used to turn date properties
into actual dates so that sorting and date operations can work.
2014-04-18 12:16:28 -07:00
Andrew Kelley
656aab43ab player: add debug statement for nowplaying event 2014-04-15 14:42:26 -07:00
Andrew Kelley
876e72db0c readme: link to the live demo 2014-04-15 13:51:46 -07:00
Andrew Kelley
f36efe16c7 client: fix incorrect enum values for repeat. closes #189 2014-04-14 18:36:21 -07:00
Andrew Kelley
8c2b78efc8 client: default selected queue item is the current track. closes #162 2014-04-13 20:55:22 -07:00
Andrew Kelley
8fe56b52bf update dependencies 2014-04-13 20:42:35 -07:00
Andrew Kelley
b1a48d5b27 client: fix UI issues with buttons
* fix hover state getting stuck after clicking a button. closes #166
 * fix clicking buttons clearing the selection. closes #183
 * minor code style cleanups
2014-04-13 20:31:41 -07:00
Andrew Kelley
43d96fb348 client: shortcuts window scrollable with arrows. closes #28 2014-04-13 19:36:20 -07:00
Andrew Kelley
8f13aa795a client: wire up cancel button on edit tags dialog 2014-04-13 19:17:10 -07:00
Andrew Kelley
7d0ac9dfd4 tag editing: support per track mode and access keys 2014-04-13 18:58:09 -07:00
Andrew Kelley
2b3aff220f client: support editing multiple tags at once 2014-04-13 16:12:52 -07:00
Andrew Kelley
cc8f0abea4 update music-library-index to fix library selection bug 2014-04-13 01:43:04 -07:00
Andrew Kelley
145e39ff02 client code cleanup 2014-04-13 01:42:20 -07:00
Andrew Kelley
52e54b0152 Merge branch 'master' into edit-tags
Conflicts:
	src/client/app.js
2014-04-13 00:15:34 -07:00
Andrew Kelley
e1bd006566 client: code cleanup 2014-04-13 00:13:55 -07:00
Andrew Kelley
453143040d client: fix not stopping keyboard propagation for text boxes 2014-04-12 23:58:43 -07:00
Andrew Kelley
6dbfb820ea server: parse updateTags messages more carefully 2014-04-12 23:58:40 -07:00
Andrew Kelley
7cc928e1e9 refactor db props listings 2014-04-12 23:10:21 -07:00
Josh Wolfe
deed3a7eb8 obstruct hotkeys in the edit tags dialog 2014-04-12 23:01:35 -07:00
Josh Wolfe
bf83c04902 edit dialog edits all the tags 2014-04-12 22:16:59 -07:00
Josh Wolfe
ac69158091 edit tags dialog can edit some tags 2014-04-12 20:58:56 -07:00
Andrew Kelley
94ea856fae client: fix selection.incrementPos for albums in a list. closes #38 2014-04-12 20:48:57 -07:00
Josh Wolfe
f9c820bdbf edit tags button appends a lol to the song name 2014-04-12 19:28:29 -07:00
Andrew Kelley
78cd66d8df server: add updateTags message to update db 2014-04-12 19:26:13 -07:00
Andrew Kelley
259f247290 persist queue on shuffle. closes #190 2014-04-10 09:50:13 -07:00
Andrew Kelley
cc7183ce53 recognize TPA and TCM tags
TPA is an alternate "disc" tag
TCM is an alternate "composer" tag
2014-04-08 11:05:46 -07:00
Andrew Kelley
e02c986e86 client: fix library items not always expanding consistently 2014-03-31 18:27:49 -07:00
Andrew Kelley
057a2e8776 client: fix displaying incorrect track number
when the track number is not known.
2014-03-31 17:48:04 -07:00
Andrew Kelley
c6ada32047 importURL: URI decode filename
Fixes _20 etc showing up in filenames.
2014-03-30 18:48:36 -07:00
Andrew Kelley
3325a248ee send update to client when replaygain scan completes 2014-03-30 18:40:24 -07:00
Josh Wolfe
f6054e662b fixing shift up/down in library. closes #46. 2014-03-29 22:07:04 -07:00
Andrew Kelley
f91849db47 update to latest express/connect 2014-03-28 14:56:00 -07:00
Andrew Kelley
3fee3df88c client: use encodeURI on download links. closes #168 2014-03-28 14:55:23 -07:00
Andrew Kelley
9fa3ebac52 fix not watching music root folder 2014-03-28 13:34:45 -07:00
Andrew Kelley
6a959e6d12 import by URL supports YouTube. closes #49
There will be a constant arms race with this node module keeping up
with YouTube. Currently YouTube is ahead but soon the ytdl module will
catch up.

This means the feature will not always be reliable, but this is the best
support we can get for it.
2014-03-28 13:04:44 -07:00
Andrew Kelley
09463c9d0a update keyboard shortcut documentation 2014-03-27 09:40:47 -07:00
Andrew Kelley
5c8f45b6e9 improve import by URL feature. closes #171
* Drop dependency on temp module
 * Download files to music-folder/.tmp/ before moving them.
 * Don't watch folders that start with a '.'
 * Don't allow generated paths to start with a '.'

Fixes import file race condition.

Prevents needless file copy operation when importing in situations where
the music directory is in a different device than /tmp.
2014-03-26 17:17:07 -07:00
Andrew Kelley
4e560b93e8 don't end directory names with ".". closes #177 2014-03-26 16:21:33 -07:00
Andrew Kelley
c230081b3c fix crash - writing to closed web socket. closes #176 2014-03-26 16:14:02 -07:00
Andrew Kelley
0d6e6cb203 streaming: increase instant buffer size by 20KB 2014-03-26 13:42:46 -07:00
Andrew Kelley
ab1b828f4d update duration info in db when replaygain scan finishes 2014-03-26 08:59:21 -07:00
Andrew Kelley
1ec74cea49 README: update MPDroid project link 2014-03-25 23:57:25 -07:00
Andrew Kelley
693e06a137 add roadmap to README 2014-03-25 23:26:35 -07:00
Andrew Kelley
207f77e4c9 I can render html with no handlebars, no handlebars, no handlebars... 2014-03-25 17:50:59 -07:00
Andrew Kelley
cde56a3e6b client: fix incorrect expand icon shown sometimes 2014-03-25 14:27:04 -07:00
Andrew Kelley
c7e2387325 client: use textContent instead of innerText
Fixes UI for Firefox
2014-03-25 13:57:20 -07:00
Andrew Kelley
dec92a1b39 client: fix incorrectly displaying songs as random 2014-03-25 13:00:05 -07:00
Andrew Kelley
b0bc750c9b client: ditch handlebars for rendering artists 2014-03-25 12:53:24 -07:00
Andrew Kelley
398e3e9db8 client: ditch handlebars for playlist rendering 2014-03-25 10:56:10 -07:00
Josh Wolfe
9fa80e43ff implementing shift+arrows in queue. 2014-03-25 01:50:54 -07:00
Josh Wolfe
1c0129a779 ctrl+arrows and ctrl+space in library work like in the queue. 2014-03-25 00:02:32 -07:00
Josh Wolfe
b0839774f0 queue uses ctrl to move the cursor without selecting, and alt to bump selected tracks up or down. 2014-03-25 00:02:22 -07:00
Andrew Kelley
113f008115 client: ditch handlebars for stored playlists 2014-03-24 23:50:49 -07:00
Andrew Kelley
a4b0fa4a12 client: ditch handlebars for context menus 2014-03-24 23:42:05 -07:00
Josh Wolfe
b693b9f6f6 ctrl+space to toggle selection under the cursor 2014-03-24 23:04:23 -07:00
Josh Wolfe
de234fde4f fix cursor selection not showing up 2014-03-24 23:04:23 -07:00
Andrew Kelley
825fa5d5d0 client: ditch handlebars for stored playlists 2014-03-24 22:05:55 -07:00
Andrew Kelley
889e8b0bec client: ditch handlebars for shortcuts dialog 2014-03-24 21:58:49 -07:00
Andrew Kelley
540266c058 client: ditch handlebars in settings pane. closes #35 2014-03-24 21:51:52 -07:00
Josh Wolfe
bf02f0d3c5 recover from others deleting our selection.cursor. closes #158 2014-03-24 21:23:33 -07:00
Josh Wolfe
17f86d9735 uncocoify some of app.js 2014-03-24 21:02:18 -07:00
Andrew Kelley
42f66c817f Revert "ability to disable streaming in config"
This reverts commit baf5ebeb4e.

Instead of this, we're going to detach the encoder when nobody is
connected and re-attach it when somebody connects.
2014-03-24 17:38:42 -07:00
Andrew Kelley
11068623b5 fix upload for multiple files
also when auto queuing, sort by position in library
2014-03-24 17:12:54 -07:00
Andrew Kelley
3be4ad9cb6 upload improvements. closes #45
* ditch qq file uploader in favor of native xhr2
 * implement client-side optional auto-queuing
2014-03-24 15:49:51 -07:00
Andrew Kelley
1a7dc0e0a3 update connect-static to latest. enables static asset caching with etags 2014-03-18 16:11:16 -07:00
37 changed files with 2604 additions and 2589 deletions

1
.gitignore vendored
View file

@ -4,5 +4,4 @@
# not shared with .npmignore
/public/app.js
/public/views.js
/public/app.css

View file

@ -2,7 +2,7 @@
Music player server with a web-based user interface inspired by Amarok 1.4.
Run it on a server (such as a
Run it on a server (such as a 512MB
[Raspberry Pi](http://www.raspberrypi.org/)) 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
@ -11,7 +11,7 @@ library remotely.
Groove Basin works with your personal music library; not an external music
service. Groove Basin will never support DRM content.
Live discussion in #libgroove on Freenode.
Try out the [live demo](http://demo.groovebasin.com/).
## Features
@ -34,7 +34,7 @@ Live discussion in #libgroove on Freenode.
* 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://play.google.com/store/apps/details?id=com.namelessdev.mpdroid).
For example [MPDroid](https://github.com/abarisain/dmix).
* [Last.fm](http://www.last.fm/) scrobbling.
@ -46,7 +46,10 @@ Live discussion in #libgroove on Freenode.
## Install
1. Install [Node.js](http://nodejs.org) v0.10.x.
1. Install [Node.js](http://nodejs.org) v0.10.x. Note that on Debian and
Ubuntu, sadly the official node package is not sufficient. You will either
have to use [Chris Lea's PPA](https://launchpad.net/~chris-lea/+archive/node.js/)
or compile from source.
2. Install [libgroove](https://github.com/andrewrk/libgroove).
3. Clone the source.
4. `npm run build`
@ -73,6 +76,21 @@ $ 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.
### Roadmap
1. Tag Editing
2. Music library organization
3. Accoustid Integration
4. Playlists
5. User accounts / permissions rehaul
6. Event history / chat
7. Finalize GrooveBasin protocol spec
## Release Notes
### 1.0.1 (Mar 18 2014)

1
build
View file

@ -2,4 +2,3 @@
mkdir -p public
./node_modules/.bin/stylus -o public/ -c --include-css src/client/styles
./node_modules/.bin/browserify src/client/app.js --outfile public/app.js
./node_modules/.bin/handlebars -f public/views.js src/client/views/

3
docs/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
_build
_static
_templates

287
docs/changelog.rst Normal file
View file

@ -0,0 +1,287 @@
Changelog
=========
1.0.1 (March 18, 2014)
----------------------
.. What does this mean?
* Default import path includes artist directory.
* Groove Basin now recognizes the `TCMP`_ ID3 tag as a compilation album flag.
.. _TCMP: http://id3.org/iTunes%20Compilation%20Flag
Fixes:
* Fixed Last.fm authentication.
* Fixed a race condition when removing tracks from playlist.
1.0.0 (March 15, 2014)
----------------------
In the 1.0.0 release, Groove Basin has removed its dependency on MPD, using
`libgroove`_ for audio playback and streaming support. Groove Basin is also not
written in `coco`_ anymore. Hopefully this will encourage more contributors to
join the project!
Major features include `ReplayGain`_ style automatic loudness detection using the
`EBU R128`_ recommendation. Scanning takes place on the fly, taking advantage of
multi-core systems. Groove Basin automatically switches between album and track
mode depending on the next item in the play queue.
Chat and playlist functionality have been removed as they are not quite ready
yet. These features will be reimplemented better in a future release.
.. _libgroove: https://github.com/andrewrk/libgroove
.. _coco: https://github.com/satyr/coco
.. _ReplayGain: https://en.wikipedia.org/wiki/ReplayGain
.. _EBU R128: https://tech.ebu.ch/loudness
Other features:
* Groove Basin now functions as an MPD server. MPD clients can connect to port
6600 by default.
* The config file is simpler and should survive new version releases.
* Client and server communications now use a simpler and more efficient protocol.
* Rebuilding the album index is faster.
* The HTTP audio stream buffers much more quickly and flushes the buffer on seek.
* Streaming shows when it is buffering.
* The web UI now specifies a `UTF-8`_ character set.
* Groove Basin's music library now updates automatically by watching the music
folder for changes.
* HTTP streaming now uses native HTML5 audio, instead of `SoundManager 2`_
* `jQuery`_ and `jQuery UI`_ have been updated to the latest stable version, fixing
some UI glitches.
* Static assets are gzipped and held permanently in memory, making the web
interface load faster.
* Now routing Dynamic mode through the permissions framework.
* Better default password generation.
.. _UTF-8: https://en.wikipedia.org/wiki/UTF-8
.. _SoundManager 2: http://www.schillmania.com/projects/soundmanager2/
.. _jQuery: https://jquery.com/
.. _jQuery UI: https://jqueryui.com/
Fixes:
* Fixed a regression for handling unknown artists or albums.
* Fixed play queue to display the artist name of tracks.
* Plugged an upload security hole.
* Pressing the previous track button on the first track in the play queue when
"repeat all" is turned on now plays the last track in the play queue.
* The volume widget no longer goes higher than 100%.
* Changing the volume now shows up on other clients.
* The volume keyboard shortcuts now work in Firefox.
* Ensured that no-cache headers are set for the stream.
* Fixed an issue in the Web UI where the current track was sometimes not
displayed.
Thanks to Josh Wolfe, who worked to fix some issues around deleting library
items, ensuring that deleting library items removes them from the play queue,
and that the play queue correctly reacts to deleted library entries.
In addition, he worked to:
* Convert Groove Basin to not use MPD.
* fix multiselect shiftIds.
* fix shift click going up in the queue.
.. What does this mean?
0.2.0 (October 16, 2012)
-------------------------
* 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
0.1.2 (July 12, 2012)
---------------------
* 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
0.0.6 (April 27, 2012)
----------------------
* 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
0.0.5 (March 11, 2012)
----------------------
* 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.
0.0.4 (March 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
0.0.3 (March 4, 2012)
---------------------
* 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
0.0.2 (March 1, 2012)
-------------------------
* 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

242
docs/conf.py Normal file
View file

@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
#
# Groove Basin documentation build configuration file, created by
# sphinx-quickstart on Thu Apr 24 14:07:20 2014.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = []
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'Groove Basin'
copyright = u'2014, Andrew Kelley'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '1.0.1'
# The full version, including alpha/beta/rc tags.
release = '1.0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'GrooveBasindoc'
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'GrooveBasin.tex', u'Groove Basin Documentation',
u'Andrew Kelley', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'groovebasin', u'Groove Basin Documentation',
[u'Andrew Kelley'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'GrooveBasin', u'Groove Basin Documentation',
u'Andrew Kelley', 'GrooveBasin', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'

69
docs/guides/main.rst Normal file
View file

@ -0,0 +1,69 @@
Getting Started
===============
Welcome to Groove Basin! This guide will help you begin using it to listen to music.
Installing
----------
Installing on Ubuntu
^^^^^^^^^^^^^^^^^^^^
Groove Basin is still in development and has not yet been packaged by Ubuntu, so you will have to build it from source.
Install `Node.js`_ v0.10.x or greater. We recommend using `Chris Lea's PPA`_ for Node. If you want to use the PPA, run:
``add-apt-repository ppa:chris-lea/node.js``
``apt-get update && apt-get install nodejs``
.. _Node.js: http://nodejs.org
.. _Chris Lea's PPA: https://launchpad.net/~chris-lea/+archive/node.js/
Install `libgroove`_ from the `libgroove PPA`_:
.. _libgroove: https://github.com/andrewrk/libgroove
.. _libgroove PPA: https://launchpad.net/~andrewrk/+archive/libgroove
``apt-add-repository ppa:andrewrk/libgroove``
``apt-get update && apt-get install libgroove-dev libgrooveplayer-dev libgrooveloudness-dev libgroovefingerprinter-dev``
Install `Git`_ if it is not already installed:
``apt-get install git``
.. _Git: http://git-scm.com/
Clone the Groove Basin git repository somewhere:
``git clone https://github.com/andrewrk/groovebasin.git``
Build Groove Basin:
``cd groovebasin``
``npm run build``
Running Groove Basin
--------------------
To start Groove Basin:
``npm start``
Importing Your Library
----------------------
Groove Basin currently supports a single music library folder. Open the ``config.js`` file that Groove Basin creates on first run and edit the ``musicDirectory`` key to point to your music directory.
Playing Your Music
------------------
Now that you have Groove Basin set up and indexing your music, you can start playing your music!
Open your favorite web browser and point it to:
http://localhost:16242
You should now see Groove Basin and can add songs to the play queue for playback. Double click on a song to play it.

29
docs/index.rst Normal file
View file

@ -0,0 +1,29 @@
.. Groove Basin documentation master file, created by
sphinx-quickstart on Thu Apr 24 14:07:20 2014.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Groove Basin: the ultimate music player
========================================
Welcome to the documentation for Groove Basin, a music player with a web-based user interface inspired by Amarok 1.4..
If you're new to Groove Basin, begin with the `:docs:guides/main`_ guide. That guide walks you through installing Groove Basin, setting it up how you like it, and starting to build your music library.
If you still need help, your can drop by the #libgroove IRC channel on Freenode or file a bug in the issue tracker. Please let us know where you think this documentation can be improved.
.. _:docs:guides/main: guides/main.rst
Contents:
.. toctree::
:maxdepth: 2
Index
==================
* :ref:`genindex`
* :ref:`search`

View file

@ -22,14 +22,25 @@ DedupedQueue.prototype.idInQueue = function(id) {
return !!(this.pendingSet[id] || this.processingSet[id]);
};
DedupedQueue.prototype.add = function(id, item) {
if (this.pendingSet[id]) return;
var queueItem = new QueueItem(id, item);
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
@ -59,15 +70,27 @@ DedupedQueue.prototype.startOne = function(queueItem) {
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');
self.flush();
}
});
};
function QueueItem(id, item) {
this.id = id;
this.item = item;
this.cbs = [];
}

View file

@ -21,8 +21,10 @@ var levelup = require('level');
var crypto = require('crypto');
var net = require('net');
var safePath = require('./safe_path');
var multipart = require('connect-multiparty')();
var MultipartForm = require('multiparty').Form;
var createGzipStatic = require('connect-static');
var serveStatic = require('serve-static');
var bodyParser = require('body-parser');
module.exports = GrooveBasin;
@ -41,7 +43,8 @@ var defaultConfig = {
lastFmApiSecret: "8713e8e893c5264608e584a232dd10a0",
mpdHost: '0.0.0.0',
mpdPort: 6600,
stream: true,
acoustidAppKey: 'bgFvC4vW',
instantBufferBytes: 220 * 1024,
};
defaultConfig.permissions[genPassword()] = {
@ -137,7 +140,7 @@ GrooveBasin.prototype.start = function() {
self.initializeDownload();
self.initializeUpload();
self.player = new Player(self.db, self.config.musicDirectory, self.config.stream);
self.player = new Player(self.db, self.config.musicDirectory, self.config.instantBufferBytes);
self.player.initialize(function(err) {
if (err) {
console.error("unable to initialize player:", err.stack);
@ -167,7 +170,7 @@ GrooveBasin.prototype.start = function() {
GrooveBasin.prototype.initializeDownload = function() {
var self = this;
var musicDir = self.config.musicDirectory;
self.app.use('/library', express.static(musicDir));
self.app.use('/library', serveStatic(musicDir));
self.app.get('/library/', function(req, resp) {
downloadPath("", "library.zip", req, resp);
});
@ -176,7 +179,7 @@ GrooveBasin.prototype.initializeDownload = function() {
var zipName = safePath(reqDir.replace(/\//g, " - ")) + ".zip";
downloadPath(reqDir, zipName, req, resp);
});
self.app.post('/download/custom', express.urlencoded(), function(req, resp) {
self.app.post('/download/custom', bodyParser(), function(req, resp) {
var reqKeys = req.body.key;
if (!Array.isArray(reqKeys)) {
reqKeys = [reqKeys];
@ -268,12 +271,39 @@ GrooveBasin.prototype.initializeDownload = function() {
GrooveBasin.prototype.initializeUpload = function() {
var self = this;
self.app.post('/upload', multipart, function(request, response) {
for (var name in request.files) {
var file = request.files[name];
self.player.importFile(file.path, file.originalFilename);
response.end();
self.app.post('/upload', function(request, response, next) {
var form = new MultipartForm();
form.parse(request, function(err, fields, files) {
if (err) return next(err);
var keys = [];
var pend = new Pend();
for (var key in files) {
var arr = files[key];
for (var i = 0; i < arr.length; i += 1) {
var file = arr[i];
pend.go(makeImportFn(file));
}
}
pend.wait(function() {
response.json(keys);
});
function makeImportFn(file) {
return function(cb) {
self.player.importFile(file.path, file.originalFilename, function(err, dbFile) {
if (err) {
console.error("Unable to import file:", file.path, "error:", err.stack);
} else if (!dbFile) {
console.error("Unable to locate new file due to race condition");
} else {
keys.push(dbFile.key);
}
cb();
});
};
}
});
});
};

View file

@ -1320,7 +1320,7 @@ function writePlaylistInfo(self, start, end) {
}
function forEachMatchingTrack(self, filters, caseSensitive, fn) {
// TODO: support 'any' and 'in' as tag types
// TODO: support 'in' as tag type
var trackTable = self.player.libraryIndex.trackTable;
if (!caseSensitive) {
filters.forEach(function(filter) {
@ -1329,13 +1329,24 @@ function forEachMatchingTrack(self, filters, caseSensitive, fn) {
}
for (var key in trackTable) {
var track = trackTable[key];
var matches = true;
var matches = false;
for (var filterIndex = 0; filterIndex < filters.length; filterIndex += 1) {
var filter = filters[filterIndex];
var filterField = track[filter.field];
var filterField = String(track[filter.field]);
if (!filterField) continue;
if (!caseSensitive && filterField) filterField = filterField.toLowerCase();
if (filterField !== filter.value) {
matches = false;
/* 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;
}
}
@ -1383,12 +1394,23 @@ function parseFindArgs(self, args, caseSensitive, onTrack, cb, onFinish) {
}
var filters = [];
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 (var tagType in tagTypes) {
tagsToSearch.push(tagTypes[tagType]);
}
} else {
var 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: tagType.grooveTag,
field: tagsToSearch[j].grooveTag,
value: args[i+1],
});
}
forEachMatchingTrack(self, filters, caseSensitive, onTrack);
}
onFinish();

View file

@ -1,6 +1,7 @@
var groove = require('groove');
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');
@ -16,7 +17,6 @@ var safePath = require('./safe_path');
var PassThrough = require('stream').PassThrough;
var url = require('url');
var superagent = require('superagent');
var temp = require('temp').track();
module.exports = Player;
@ -24,30 +24,172 @@ groove.setLogging(groove.LOG_WARNING);
var cpuCount = require('os').cpus().length;
var PLAYER_KEY_PREFIX = "Player.";
var LIBRARY_KEY_PREFIX = "Library.";
var LIBRARY_DIR_PREFIX = "LibraryDir.";
var PLAYLIST_KEY_PREFIX = "Playlist.";
// these are the ones we store in the DB, not the ones we send to the web
var DB_FILE_PROPS = [
'key', 'name', 'artistName', 'albumArtistName',
'albumName', 'compilation', 'track', 'trackCount',
'disc', 'discCount', 'duration', 'year', 'genre',
'file', 'mtime', 'replayGainAlbumGain', 'replayGainAlbumPeak',
'replayGainTrackGain', 'replayGainTrackPeak',
'composerName', 'performerName', 'lastQueueDate',
];
// 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',
},
};
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;
},
};
// 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;
// when a streaming client connects we send them many buffers quickly
// in order to get the stream started, then we slow down.
var instantBufferBytes = 200 * 1024;
var DB_SCALE = Math.log(10.0) * 0.05;
var REPLAYGAIN_PREAMP = 0.75;
var REPLAYGAIN_DEFAULT = 0.25;
@ -59,17 +201,20 @@ Player.REPEAT_ALL = 2;
Player.trackWithoutIndex = trackWithoutIndex;
util.inherits(Player, EventEmitter);
function Player(db, musicDirectory, stream) {
function Player(db, musicDirectory, instantBufferBytes) {
EventEmitter.call(this);
this.setMaxListeners(0);
this.db = db;
this.musicDirectory = musicDirectory;
this.stream = stream;
this.dbFilesByPath = {};
this.libraryIndex = new MusicLibraryIndex();
this.addQueue = new DedupedQueue({processOne: this.addToLibrary.bind(this)});
// when a streaming client connects we send them many buffers quickly
// in order to get the stream started, then we slow down.
this.instantBufferBytes = instantBufferBytes;
this.dirs = {};
this.dirScanQueue = new DedupedQueue({
processOne: this.refreshFilesIndex.bind(this),
@ -110,6 +255,10 @@ function Player(db, musicDirectory, stream) {
this.lastEncodeItem = null;
this.lastEncodePos = null;
this.expectHeaders = true;
this.playlistItemDeleteQueue = [];
this.importUrlFilters = [];
}
Player.prototype.initialize = function(cb) {
@ -129,21 +278,18 @@ Player.prototype.initialize = function(cb) {
function initPlayer(cb) {
var groovePlaylist = groove.createPlaylist();
var groovePlayer = groove.createPlayer();
var pend = new Pend();
if (self.stream) {
var grooveEncoder = groove.createEncoder();
grooveEncoder.formatShortName = "mp3";
grooveEncoder.codecShortName = "mp3";
grooveEncoder.bitRate = 256 * 1000;
pend.go(function(cb) {
grooveEncoder.attach(groovePlaylist, cb);
self.grooveEncoder = grooveEncoder;
});
}
var pend = new Pend();
pend.go(function(cb) {
groovePlayer.attach(groovePlaylist, cb);
});
pend.go(function(cb) {
grooveEncoder.attach(groovePlaylist, cb);
});
pend.wait(doneAttaching);
function doneAttaching(err) {
@ -153,12 +299,11 @@ Player.prototype.initialize = function(cb) {
}
self.groovePlaylist = groovePlaylist;
self.groovePlayer = groovePlayer;
self.grooveEncoder = grooveEncoder;
self.groovePlaylist.pause();
self.volume = self.groovePlaylist.volume;
self.groovePlayer.on('nowplaying', onNowPlaying);
if (self.stream) {
self.flushEncodedInterval = setInterval(flushEncoded, 10);
}
cb();
function flushEncoded() {
@ -166,7 +311,7 @@ Player.prototype.initialize = function(cb) {
// available or we get enough buffered
while (1) {
var bufferedSeconds = self.secondsIntoFuture(self.lastEncodeItem, self.lastEncodePos);
if (bufferedSeconds > 0.5 && self.recentBuffersByteCount >= instantBufferBytes) return;
if (bufferedSeconds > 0.5 && self.recentBuffersByteCount >= self.instantBufferBytes) return;
var buf = self.grooveEncoder.getBuffer();
if (!buf) return;
if (buf.buffer) {
@ -180,7 +325,7 @@ Player.prototype.initialize = function(cb) {
self.recentBuffers.push(buf.buffer);
self.recentBuffersByteCount += buf.buffer.length;
while (self.recentBuffers.length > 0 &&
self.recentBuffersByteCount - self.recentBuffers[0].length >= instantBufferBytes)
self.recentBuffersByteCount - self.recentBuffers[0].length >= self.instantBufferBytes)
{
self.recentBuffersByteCount -= self.recentBuffers.shift().length;
}
@ -208,6 +353,13 @@ Player.prototype.initialize = function(cb) {
function onNowPlaying() {
var playHead = self.groovePlayer.position();
var decodeHead = self.groovePlaylist.position();
var playHeadDbKey = playHead.item && self.grooveItems[playHead.item.id].key;
var playHeadDbFile = playHeadDbKey && self.libraryIndex.trackTable[playHeadDbKey];
var playHeadFile = playHeadDbFile && playHeadDbFile.file;
console.info("onNowPlaying event. playhead:", playHeadFile);
if (playHead.item) {
var nowMs = (new Date()).getTime();
var posMs = playHead.pos * 1000;
@ -367,12 +519,12 @@ Player.prototype.initialize = function(cb) {
}
};
Player.prototype.requestUpdateDb = function(dirName, forceRescan) {
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) {
@ -384,9 +536,10 @@ Player.prototype.refreshFilesIndex = function(args, cb) {
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)) return;
var dirEntry = self.getOrCreateDir(dirName, stat);
if (fullDirPath === dirWithSlash) return; // ignore root search path
var baseName = path.basename(dirName);
var parentDirName = path.dirname(dirName);
if (parentDirName === '.') parentDirName = '';
var parentDirEntry = self.getOrCreateDir(parentDirName);
@ -551,7 +704,6 @@ Player.prototype.secondsIntoFuture = function(groovePlaylistItem, pos) {
Player.prototype.streamMiddleware = function(req, resp, next) {
var self = this;
if (req.path !== '/stream.mp3') return next();
if (!self.stream) return next();
resp.setHeader('Content-Type', 'audio/mpeg');
resp.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
@ -631,24 +783,87 @@ Player.prototype.setVolume = function(value) {
this.emit("volumeUpdate");
};
Player.prototype.importUrl = function(urlString) {
Player.prototype.importUrl = function(urlString, cb) {
var self = this;
cb = cb || logIfError;
var tmpDir = path.join(self.musicDirectory, '.tmp');
var filterIndex = 0;
mkdirp(tmpDir, function(err) {
if (err) return cb(err);
tryImportFilter();
});
function tryImportFilter() {
var importPlugin = self.importUrlFilters[filterIndex];
if (importPlugin) {
importPlugin.importUrl(urlString, callNextFilter);
} else {
downloadRaw();
}
function callNextFilter(err, dlStream, filename) {
if (err || !dlStream) {
if (err) console.warn("import filter error, skipping:", err.stack);
filterIndex += 1;
tryImportFilter();
return;
}
handleDownload(dlStream, filename);
}
}
function downloadRaw() {
var parsedUrl = url.parse(urlString);
var remoteFilename = path.basename(parsedUrl.pathname);
var decodedFilename;
try {
decodedFilename = decodeURI(remoteFilename);
} catch (err) {
decodedFilename = remoteFilename;
}
var req = superagent.get(urlString);
var ws = temp.createWriteStream({suffix: path.extname(urlString)});
handleDownload(req, decodedFilename);
}
function handleDownload(req, remoteFilename) {
var ext = path.extname(remoteFilename);
var destPath = path.join(tmpDir, uuid() + ext);
var ws = fs.createWriteStream(destPath);
var calledCallback = false;
req.pipe(ws);
ws.on('close', function(){
self.importFile(ws.path, remoteFilename, cleanAndLogIfErr);
if (calledCallback) return;
self.importFile(ws.path, remoteFilename, function(err, dbFile) {
if (err) {
cleanAndCb(err);
} else {
calledCallback = true;
cb(null, dbFile);
}
});
ws.on('error', cleanAndLogIfErr);
req.on('error', cleanAndLogIfErr);
});
ws.on('error', cleanAndCb);
req.on('error', cleanAndCb);
function cleanAndLogIfErr(err) {
function cleanAndCb(err) {
fs.unlink(destPath, function(err) {
if (err) {
console.warn("Unable to clean up temp file:", err.stack);
}
});
if (calledCallback) return;
calledCallback = true;
cb(err);
}
}
function logIfError(err) {
if (err) {
console.error("Unable to import by URL.", err.stack, "URL:", urlString);
}
temp.cleanup();
}
};
@ -668,12 +883,15 @@ Player.prototype.importFile = function(srcFullPath, filenameHint, cb) {
pend.go(function(cb) {
tryMv(suggestedPath, cb);
});
pend.wait(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 && err.code === 'EEXIST') {
if (err) {
if (err.code === 'EEXIST') {
tryMv(uniqueFilename(destRelPath), cb);
} else {
@ -682,8 +900,14 @@ Player.prototype.importFile = function(srcFullPath, filenameHint, cb) {
return;
}
// in case it doesn't get picked up by a watcher
self.requestUpdateDb(path.dirname(destRelPath));
cb(err);
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();
});
});
});
}
});
@ -771,6 +995,24 @@ Player.prototype.addToLibrary = function(args, 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;
@ -836,11 +1078,13 @@ Player.prototype.shufflePlaylist = function() {
shuffle(this.tracksInOrder);
// fix sortKey and index properties
var nextSortKey = keese(null, null);
this.tracksInOrder.forEach(function(track, index) {
track.index = index;
for (var i = 0; i < this.tracksInOrder.length; i += 1) {
var track = this.tracksInOrder[i];
track.index = i;
track.sortKey = nextSortKey;
this.persistPlaylistItem(track);
nextSortKey = keese(nextSortKey, null);
});
}
playlistChanged(this);
};
@ -855,10 +1099,7 @@ Player.prototype.removePlaylistItems = function(ids) {
delCmds.push({type: 'del', key: PLAYLIST_KEY_PREFIX + id});
if (item.grooveFile) closeFile(item.grooveFile);
// 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) this.playlistItemDeleteQueue.push(item);
if (item === this.currentTrack) {
this.currentTrack = null;
currentTrackChanged = true;
@ -1139,12 +1380,13 @@ Player.prototype.performScan = function(dbFile) {
var gain = groove.loudnessToReplayGain(info.loudness);
if (info.item) {
var fileInfo = scanContext.files[info.item.file.id];
console.info("replaygain scan file complete:", fileInfo.track.name, "gain", gain);
console.info("replaygain scan file complete:", fileInfo.track.name, "gain", gain, "duration", info.duration);
fileInfo.progress = 1.0;
fileInfo.gain = gain;
fileInfo.peak = info.peak;
fileInfo.track.replayGainTrackGain = gain;
fileInfo.track.replayGainTrackPeak = info.peak;
fileInfo.track.duration = info.duration;
checkUpdateGroovePlaylist(self);
} else {
if (scanContext.aborted) return cleanupAndCb();
@ -1157,6 +1399,7 @@ Player.prototype.performScan = function(dbFile) {
dbFile.replayGainAlbumGain = gain;
dbFile.replayGainAlbumPeak = info.peak;
self.persist(dbFile);
self.emit('scanComplete', dbFile);
}
checkUpdateGroovePlaylist(self);
cleanupAndCb();
@ -1334,11 +1577,23 @@ function playlistChanged(self) {
self.pausedTime = 0;
}
checkUpdateGroovePlaylist(self);
performGrooveFileDeletes(self);
self.checkDynamicMode();
self.emit('playlistUpdate');
}
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;
closeFile(item.grooveFile);
}
}
function preloadFile(self, track) {
var relPath = self.libraryIndex.trackTable[track.key].file;
var fullPath = path.join(self.musicDirectory, relPath);
@ -1494,7 +1749,14 @@ function isFileIgnored(basename) {
}
function deserializeFileData(dataStr) {
return JSON.parse(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 serializePlaylistItem(item) {
@ -1506,18 +1768,21 @@ function serializePlaylistItem(item) {
});
}
function trackWithoutIndex(props, dbFile) {
function trackWithoutIndex(category, dbFile) {
var out = {};
props.forEach(function(propName) {
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];
// save space by leaving out null or undefined values
if (value != null) out[propName] = value;
});
if (value == null) continue;
out[propName] = value;
}
return out;
}
function serializeFileData(dbFile) {
return JSON.stringify(trackWithoutIndex(DB_FILE_PROPS, dbFile));
return JSON.stringify(trackWithoutIndex('db', dbFile));
}
function serializeDirEntry(dirEntry) {
@ -1572,10 +1837,11 @@ function parseFloatOrNull(n) {
function grooveFileToDbFile(file, filenameHint, object) {
object = object || {key: uuid()};
var parsedTrack = parseTrackString(file.getMetadata("track"));
var parsedDisc = parseTrackString(file.getMetadata("disc"));
object.name = file.getMetadata("title") || trackNameFromFile(filenameHint);
var parsedDisc = parseTrackString(file.getMetadata("disc") || file.getMetadata("TPA"));
object.name = (file.getMetadata("title") || trackNameFromFile(filenameHint) || "").trim();
object.artistName = (file.getMetadata("artist") || "").trim();
object.composerName = (file.getMetadata("composer") || "").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();
@ -1586,7 +1852,7 @@ function grooveFileToDbFile(file, filenameHint, object) {
object.disc = parsedDisc.value;
object.discCount = parsedDisc.total;
object.duration = file.duration();
object.year = parseInt(file.getMetadata("date") || "0", 10);
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"));

View file

@ -4,14 +4,6 @@ var Player = require('./player');
module.exports = PlayerServer;
// these are the ones we send to the web, not the ones we store in the DB
var DB_FILE_PROPS = [
'key', 'name', 'artistName', 'albumArtistName',
'albumName', 'compilation', 'track', 'trackCount',
'disc', 'discCount', 'duration', 'year', 'genre',
'file'
];
PlayerServer.plugins = [];
PlayerServer.actions = {
@ -68,9 +60,21 @@ PlayerServer.actions = {
},
'importUrl': {
permission: 'control',
args: 'string',
fn: function(self, client, urlString) {
self.player.importUrl(urlString);
args: 'object',
fn: function(self, client, args) {
var urlString = String(args.url);
var id = args.id;
self.player.importUrl(urlString, function(err, dbFile) {
var key = null;
if (err) {
console.error("Unable to import url:", urlString, "error:", err.stack);
} else if (!dbFile) {
console.error("Unable to import file due to race condition.");
} else {
key = dbFile.key;
}
client.sendMessage('importUrl', {id: id, key: key});
});
},
},
'subscribe': {
@ -98,6 +102,13 @@ PlayerServer.actions = {
}
},
},
'updateTags': {
permission: 'admin',
args: 'object',
fn: function(self, client, obj) {
self.player.updateTags(obj);
},
},
'unsubscribe': {
permission: 'read',
args: 'string',
@ -195,6 +206,7 @@ PlayerServer.prototype.initialize = function() {
self.player.on('addDbTrack', onLibraryUpdate);
self.player.on('updateDbTrack', onLibraryUpdate);
self.player.on('deleteDbTrack', onLibraryUpdate);
self.player.on('scanComplete', onLibraryUpdate);
self.player.on('seek', function() {
self.clients.forEach(function(client) {
@ -284,7 +296,7 @@ PlayerServer.prototype.initialize = function() {
var table = {};
for (var key in self.player.libraryIndex.trackTable) {
var track = self.player.libraryIndex.trackTable[key];
table[key] = Player.trackWithoutIndex(DB_FILE_PROPS, track);
table[key] = Player.trackWithoutIndex('read', track);
}
return table;
}

62
lib/plugins/ytdl.js Normal file
View file

@ -0,0 +1,62 @@
var ytdl = require('ytdl');
var url = require('url');
module.exports = YtDlPlugin;
// sorted from worst to best
var YTDL_AUDIO_ENCODINGS = [
'mp3',
'aac',
'wma',
'vorbis',
'wav',
'flac',
];
function YtDlPlugin(gb) {
gb.player.importUrlFilters.push(this);
}
YtDlPlugin.prototype.importUrl = function(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, 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) {
console.warn("YouTube Import: unrecognized audio format:", bestFormat.audioEncoding);
}
var req = ytdl(urlString, {filter: filter});
var filename = info.title + '.' + bestFormat.container;
cb(null, req, filename);
function filter(format) {
return format.audioBitrate === bestFormat.audioBitrate &&
format.audioEncoding === bestFormat.audioEncoding;
}
}
};

View file

@ -10,9 +10,10 @@ function ProtocolParser(options) {
this.player = options.player;
this.buffer = "";
this.alreadyClosed = false;
}
ProtocolParser.prototype._read = function(size) {}
ProtocolParser.prototype._read = function(size) {};
ProtocolParser.prototype._write = function(chunk, encoding, callback) {
var self = this;
@ -44,15 +45,18 @@ ProtocolParser.prototype._write = function(chunk, encoding, callback) {
}
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) {

View file

@ -5,5 +5,7 @@ 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;
}

View file

@ -11,10 +11,15 @@ function WebSocketApiClient(ws) {
}
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() {

View file

@ -18,7 +18,7 @@
},
"dependencies": {
"lastfm": "~0.9.0",
"express": "~3.4.0",
"express": "^4.0.0",
"superagent": "^0.17.0",
"mkdirp": "~0.3.5",
"mv": "~2.0.0",
@ -26,24 +26,25 @@
"zfill": "0.0.1",
"requireindex": "^1.1.0",
"mess": "~0.1.1",
"groove": "~1.3.0",
"groove": "~1.4.1",
"osenv": "0.0.3",
"level": "~0.18.0",
"level": "^0.18.0",
"findit": "~1.1.1",
"archiver": "^0.6.1",
"uuid": "~1.4.1",
"music-library-index": "^1.1.0",
"music-library-index": "^1.1.1",
"keese": "~1.0.0",
"temp": "~0.7.0",
"connect-multiparty": "~1.0.3",
"ws": "~0.4.31",
"ws": "^0.4.31",
"jsondiffpatch": "~0.1.4",
"connect-static": "^1.0.2"
"connect-static": "^1.1.0",
"multiparty": "^3.2.4",
"ytdl": "^0.2.4",
"serve-static": "^1.0.3",
"body-parser": "^1.0.1"
},
"devDependencies": {
"handlebars": "1.0.7",
"stylus": "^0.42.3",
"browserify": "~3.32.0"
"browserify": "^3.41.0"
},
"scripts": {
"start": "node lib/server.js",

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,8 @@ module.exports = PlayerClient;
var compareSortKeyAndId = makeCompareProps(['sortKey', 'id']);
PlayerClient.REPEAT_OFF = 0;
PlayerClient.REPEAT_ALL = 1;
PlayerClient.REPEAT_ONE = 2;
PlayerClient.REPEAT_ONE = 1;
PlayerClient.REPEAT_ALL = 2;
util.inherits(PlayerClient, EventEmitter);
function PlayerClient(socket) {
@ -111,7 +111,7 @@ PlayerClient.prototype.handleConnectionStart = function(){
PlayerClient.prototype.updateTrackStartDate = function() {
this.trackStartDate = (this.serverTrackStartDate != null) ?
new Date(new Date(this.serverTrackStartDate) - this.serverTimeOffset) : null;
}
};
PlayerClient.prototype.updateCurrentItem = function() {
this.currentItem = (this.currentItemId != null) ?
@ -134,7 +134,7 @@ PlayerClient.prototype.updatePlaylistIndex = function() {
}
this.refreshPlaylistList();
this.updateCurrentItem();
}
};
PlayerClient.prototype.search = function(query) {
query = query.trim();
@ -470,6 +470,26 @@ PlayerClient.prototype.refreshPlaylistList = function(){
}
};
// sort keys according to how they appear in the library
PlayerClient.prototype.sortKeys = function(keys) {
var realLib = this.library;
var lib = new MusicLibraryIndex();
keys.forEach(function(key) {
var track = realLib.trackTable[key];
if (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.key);
});
});
});
return results;
};
PlayerClient.prototype.resetServerState = function(){
this.haveFileListCache = false;
this.library = new MusicLibraryIndex({

View file

@ -12,6 +12,11 @@ audio.addEventListener('playing', onPlaying, false);
var $ = window.$;
var $streamBtn = $('#stream-btn');
document.getElementById('stream-btn-label').addEventListener('mousedown', onLabelDown, false);
function onLabelDown(event) {
event.stopPropagation();
}
function getButtonLabel() {
if (tryingToStream) {
@ -29,14 +34,9 @@ function getButtonLabel() {
}
}
function getButtonDisabled() {
return false;
}
function renderStreamButton(){
var label = getButtonLabel();
$streamBtn
.button("option", "disabled", getButtonDisabled())
.button("option", "label", label)
.prop("checked", tryingToStream)
.button("refresh");

View file

@ -1,6 +1,5 @@
@import "vendor/reset.min.css"
@import "vendor/jquery-ui-1.10.4.custom.min.css"
@import "vendor/fileuploader.css"
user-select()
-moz-user-select arguments
@ -223,6 +222,7 @@ body
color #75abff
div.cursor
span
text-decoration underline
div.border-bottom
@ -244,17 +244,21 @@ body
padding 10px
#upload-by-url
margin: 4px
width: 90%
#menu
position absolute
padding 2px
li
a
display block
text-decoration none
padding 6px
font-weight normal
.ui-menu
width: 240px
font-size: 1em
#menu-library .ui-state-disabled.ui-state-focus,
#menu-playlist .ui-state-disabled.ui-state-focus
margin: .3em -1px .2em
#menu-library .menu-item-last.ui-state-disabled.ui-state-focus,
#menu-playlist .menu-item-last.ui-state-disabled.ui-state-focus
margin: 5px -1px .2em
height: 23px
#shortcuts
h1
@ -311,3 +315,6 @@ body
font-size .9em
li:before
content "\2713"
.accesskey
text-decoration: underline

View file

@ -1,31 +0,0 @@
.qq-uploader { position:relative; width: 100%;}
.qq-upload-button {
display:block; /* or inline-block */
width: 105px; padding: 7px 0; text-align:center;
background:#880000; border-bottom:1px solid #ddd;color:#fff;
}
.qq-upload-button-hover {background:#cc0000;}
.qq-upload-button-focus {outline:1px dotted black;}
.qq-upload-drop-area {
position:absolute; top:0; left:0; width:100%; height:100%; min-height: 70px; z-index:2;
background:#FF9797; text-align:center;
}
.qq-upload-drop-area span {
display:block; position:absolute; top: 50%; width:100%; margin-top:-8px; font-size:16px;
}
.qq-upload-drop-area-active {background:#FF7171;}
.qq-upload-list {margin:15px 35px; padding:0; list-style:disc;}
.qq-upload-list li { margin:0; padding:0; line-height:15px; font-size:12px;}
.qq-upload-file, .qq-upload-spinner, .qq-upload-size, .qq-upload-cancel, .qq-upload-failed-text {
margin-right: 7px;
}
.qq-upload-file {}
.qq-upload-spinner {display:inline-block; background: url("loading.gif"); width:15px; height:15px; vertical-align:text-bottom;}
.qq-upload-size,.qq-upload-cancel {font-size:11px;}
.qq-upload-failed-text {display:none;}
.qq-upload-fail .qq-upload-failed-text {display:inline;}

View file

@ -1,17 +0,0 @@
{{#albumList}}
<li>
<div class="clickable expandable" id="{{albumid key}}" data-key="{{key}}" data-type="album">
<div class="ui-icon ui-icon-triangle-1-e"></div>
<span>{{#if name}}{{name}}{{else}}[Unknown Album]{{/if}}</span>
</div>
<ul style="display: none;">
{{#trackList}}
<li>
<div class="clickable" id="{{trackid key}}" data-key="{{key}}" data-type="track">
<span>{{#if track}}{{track}}. {{/if}}{{#if compilation}}{{artistName}} - {{/if}}{{name}}</span>
</div>
</li>
{{/trackList}}
</ul>
</li>
{{/albumList}}

View file

@ -1,11 +0,0 @@
{{#if users}}
<ul>
{{#each users}}
<li>
<span class="{{class}}">{{userName}}</span>
</li>
{{/each}}
</ul>
{{else}}
{{/if}}

View file

@ -1,18 +0,0 @@
{{#if artistList}}
<ul>
{{#artistList}}
<li>
<div class="clickable expandable" id="{{artistid key}}" data-key="{{key}}" data-type="artist">
<div class="ui-icon ui-icon-triangle-1-e"></div>
<span>{{#if name}}{{name}}{{else}}[Unknown Artist]{{/if}}</span>
</div>
<ul></ul>
</li>
{{/artistList}}
</ul>
{{else}}
<p class="ui-state-highlight ui-corner-all">
<span class="ui-icon ui-icon-info"></span>
<strong>{{emptyLibraryMessage}}</strong>
</p>
{{/if}}

View file

@ -1,26 +0,0 @@
<ul id="menu" class="ui-widget-content ui-corner-all">
<li><a href="#" class="queue hoverable">Queue</a></li>
<li><a href="#" class="queue-next hoverable">Queue Next</a></li>
<li><a href="#" class="queue-random hoverable">Queue in Random Order</a></li>
<li><a href="#" class="queue-next-random hoverable">Queue Next in Random Order</a></li>
<li>
{{#if permissions.admin}}
<a href="#" class="delete hoverable">Delete</a>
{{else}}
<span title="Delete is disabled: insufficient privileges. See Settings.">Delete</span>
{{/if}}
</li>
<li>
{{#if downloadEnabled}}
{{#if track}}
<a href="library/{{track.file}}" class="download hoverable" target="_blank">Download</a>
{{/if}}
{{#if download_multi}}
<a href="#" class="download-multi hoverable">Download</a>
{{/if}}
{{else}}
<span title="Download is disabled due to invalid server configuration.">Download</span>
{{/if}}
</li>
</ul>

View file

@ -1,12 +0,0 @@
{{#if playlist}}
{{#playlist}}
<div class="pl-item" id="playlist-track-{{id}}" data-id="{{id}}">
<span class="track">{{track.track}}</span>
<span class="title">{{track.name}}</span>
<span class="artist">{{track.artistName}}</span>
<span class="album">{{track.albumName}}</span>
<span class="time">{{time track.duration}}</span>
</div>
{{/playlist}}
{{/if}}

View file

@ -1,11 +0,0 @@
{{#itemList}}
<li>
<div class="clickable" id="{{storedplaylistitemid id}}" data-key="{{id}}" data-type="stored_playlist_item">
<span>
{{#track}}
{{#if artistName}}{{artistName}} - {{/if}}{{name}}
{{/track}}
</span>
</div>
</li>
{{/itemList}}

View file

@ -1,22 +0,0 @@
<ul id="menu" class="ui-widget-content ui-corner-all">
<li><a href="#" class="remove hoverable">Remove</a></li>
<li>
{{#if permissions.admin}}
<a href="#" class="delete hoverable">Delete From Library</a>
{{else}}
<span title="Delete is disabled: insufficient privileges. See Settings.">Delete From Library</span>
{{/if}}
</li>
<li>
{{#if downloadEnabled}}
{{#if item}}
<a href="library/{{item.track.file}}" class="download hoverable" target="_blank">Download</a>
{{/if}}
{{#if download_multi}}
<a href="#" class="download-multi hoverable">Download</a>
{{/if}}
{{else}}
<span title="Download is disabled due to invalid server configuration.">Download</span>
{{/if}}
</li>
</ul>

View file

@ -1,55 +0,0 @@
<div class="section">
<h1>Authentication</h1>
<p>
{{#if auth.show_edit}}
<input type="text" id="auth-password" placeholder="password" />
<button class="auth-save">Save</button>
{{#if auth.password }}
<button class="auth-cancel">Cancel</button>
{{/if}}
{{else}}
Using password <em>{{auth.password}}</em>
<button class="auth-edit">Edit</button>
<button class="auth-clear">Clear</button>
{{/if}}
</p>
<h2>Permissions</h2>
<ul>
{{#if auth.permissions.read}}
<li>Reading the library, current playlist, and playback status</li>
{{/if}}
{{#if auth.permissions.add}}
<li>Adding songs, loading playlists, and uploading songs.</li>
{{/if}}
{{#if auth.permissions.control}}
<li>Control playback state, and manipulate playlists.</li>
{{/if}}
{{#if auth.permissions.admin}}
<li>Deleting songs, updating tags, organizing library.</li>
{{/if}}
</ul>
</div>
<div class="section">
<h1>Last.fm</h1>
{{#if lastfm.username}}
<p>
Authenticated as
<a href="http://last.fm/user/{{lastfm.username}}">{{lastfm.username}}</a>.
<button class="signout">Sign out</button>
</p>
<p>
Scrobbling is <input type="checkbox" id="toggle-scrobble"{{#if lastfm.scrobbling_on}} checked="checked"{{/if}}><label for="toggle-scrobble">{{#if lastfm.scrobbling_on}}On{{else}}Off{{/if}}</label>
</p>
{{else}}
<p>
<a href="{{lastfm.auth_url}}">Authenticate with Last.fm</a>
</p>
{{/if}}
</div>
<div class="section">
<h1>About</h1>
<ul>
<li><a href="{{misc.stream_url}}">Stream URL</a></li>
<li><a href="http://github.com/andrewrk/groovebasin">GrooveBasin on GitHub</a></li>
</ul>
</div>

View file

@ -1,141 +0,0 @@
<div id="shortcuts" style="display: none">
<h1>Playback</h1>
<dl>
<dt>Space</dt>
<dd>Toggle playback</dd>
</dl>
<dl>
<dt>Left <em>and</em> Right</dt>
<dd>Skip 10 seconds in the song</dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold to skip by 10% instead of 10 seconds</dd>
</dl>
<dl>
<dt>&lt; <em>or</em> Ctrl + Left <em>and</em> &gt; <em>or</em> Ctrl + Right</dt>
<dd>Skip track</dd>
</dl>
<dl>
<dt>- <em>and</em> +</dt>
<dd>Change volume</dd>
</dl>
<dl>
<dt>s</dt>
<dd>Toggle streaming</dd>
</dl>
<h1>Playlist</h1>
<dl>
<dt>Up <em>and</em> Down</dt>
<dd>Select the next song</dd>
</dl>
<dl>
<dt>Ctrl + Up <em>and</em> Ctrl + Down</dt>
<dd>Move selection up or down one</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Play the selected song</dd>
</dl>
<dl>
<dt>C</dt>
<dd>Clear playlist</dd>
</dl>
<dl>
<dt>H</dt>
<dd>Shuffle playlist</dd>
</dl>
<dl>
<dt>d</dt>
<dd>Toggle dynamic playlist mode</dd>
</dl>
<dl>
<dt>r</dt>
<dd>Change repeat state</dd>
</dl>
<dl>
<dt>Del</dt>
<dd>Remove selected songs from playlist</dd>
</dl>
<dl>
<dt>Shift + Del</dt>
<dd>Delete selected songs from library</dd>
</dl>
<h1>Navigation</h1>
<dl>
<dt>l</dt>
<dd>Switch to Library tab</dd>
</dl>
<dl>
<dt>u</dt>
<dd>Switch to Upload tab and focus the upload by URL box</dd>
</dl>
<h1>Library Search Box</h1>
<dl>
<dt>/</dt>
<dd>Focus library search</dd>
</dl>
<dl>
<dt>Esc</dt>
<dd>Clear filter. If filter is already clear, remove focus.</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Queue all search results</dd>
</dl>
<dl>
<dt>Down</dt>
<dd>Select the first search result</dd>
</dl>
<h1>Library</h1>
<dl>
<dt>Up <em>and</em> Down</dt>
<dd>Select the next item up or down</dd>
</dl>
<dl>
<dt>Left <em>and</em> Right</dt>
<dd>Expand or collapse selected item</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Queue selected items<dd>
</dl>
<dl>
<dt>Del</dt>
<dd>Delete selected songs from library</dd>
</dl>
<h1>Miscellaneous</h1>
<dl>
<dt>?</dt>
<dd>Displays keyboard shortcuts</dd>
</dl>
<dl>
<dt>Esc</dt>
<dd>Close menu, cancel drag, clear selection</dd>
</dl>
<dl>
<dt>Alt</dt>
<dd>Hold when right clicking to get the normal browser menu</dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold while queuing to queue next<dd>
</dl>
<dl>
<dt>Alt</dt>
<dd>Hold while queuing to queue in random order<dd>
</dl>
<dl>
<dt>Ctrl</dt>
<dd>Hold to select multiple items<dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold while selecting to select all items in between<dd>
</dl>
</div>

View file

@ -1,18 +0,0 @@
{{#if stored_playlists}}
<ul>
{{#stored_playlists}}
<li>
<div class="clickable expandable" id="{{storedplaylistid name}}" data-key="{{name}}" data-type="stored_playlist">
<div class="ui-icon ui-icon-triangle-1-e"></div>
<span>{{name}}</span>
</div>
<ul></ul>
</li>
{{/stored_playlists}}
</ul>
{{else}}
<p class="ui-state-highlight ui-corner-all">
<span class="ui-icon ui-icon-info"></span>
<strong>No Playlists</strong>
</p>
{{/if}}

View file

@ -28,7 +28,7 @@
<span class="ui-icon ui-icon-volume-on"></span>
</div>
<div id="more-playback-btns">
<input type="checkbox" id="stream-btn"><label for="stream-btn">Stream</label>
<input class="jquery-button" type="checkbox" id="stream-btn"><label id="stream-btn-label" for="stream-btn">Stream</label>
</div>
<h1 id="track-display"></h1>
<div id="track-slider"></div>
@ -52,16 +52,73 @@
</select>
</div>
<div id="library">
<ul id="library-artists">
</ul>
<p id="library-no-items" class="ui-state-highlight ui-corner-all">
<span class="ui-icon ui-icon-info"></span>
<strong id="empty-library-message">loading...</strong>
</p>
</div>
</div>
<div id="upload-pane" class="ui-widget-content ui-corner-all" style="display: none">
<div id="upload">
<input id="upload-by-url" type="text" placeholder="Paste URL here">
<div id="upload-widget"></div>
<div id="upload-widget">
<input type="file" id="upload-input" multiple="multiple" placeholder="Drag and drop or click to browse">
</div>
<div>
Automatically queue uploads: <input class="jquery-button" type="checkbox" id="auto-queue-uploads"><label for="auto-queue-uploads">On</label>
</div>
</div>
</div>
<div id="settings-pane" class="ui-widget-content ui-corner-all" style="display: none">
<div id="settings">
<div class="section">
<h1>Authentication</h1>
<div id="settings-edit-password">
<input type="text" id="auth-password" placeholder="password" />
<button id="settings-auth-save">Save</button>
<button id="settings-auth-cancel">Cancel</button>
</div>
<div id="settings-show-password">
Using password <em id="password-display">...</em>
<button id="settings-auth-edit">Edit</button>
<button id="settings-auth-clear">Clear</button>
</div>
<h2>Permissions</h2>
<ul>
<li id="auth-perm-read">Reading the library, current playlist, and playback status</li>
<li id="auth-perm-add">Adding songs, loading playlists, and uploading songs.</li>
<li id="auth-perm-control">Control playback state, and manipulate playlists.</li>
<li id="auth-perm-admin">Deleting songs, updating tags, organizing library.</li>
</ul>
</div>
<div class="section">
<h1>Last.fm</h1>
<div id="settings-lastfm-in">
<p>
Authenticated as
<a id="settings-lastfm-user" href="#">...</a>.
<button id="lastfm-sign-out">Sign out</button>
</p>
<p>
Scrobbling is
<input class="jquery-button" type="checkbox" id="toggle-scrobble"><label for="toggle-scrobble">Off</label>
</p>
</div>
<div id="settings-lastfm-out">
<p>
<a id="lastfm-auth-url" href="#">Authenticate with Last.fm</a>
</p>
</div>
</div>
<div class="section">
<h1>About</h1>
<ul>
<li><a id="settings-stream-url" href="#">Stream URL</a></li>
<li><a href="http://github.com/andrewrk/groovebasin">GrooveBasin on GitHub</a></li>
</ul>
</div>
</div>
</div>
</div>
@ -69,8 +126,8 @@
<div class="window-header">
<button class="jquery-button clear">Clear</button>
<button class="jquery-button shuffle">Shuffle</button>
<input class="jquery-button" type="checkbox" id="dynamic-mode"><label for="dynamic-mode">Dynamic Mode</label>
<input class="jquery-button" type="checkbox" id="pl-btn-repeat"><label for="pl-btn-repeat">Repeat: Off</label>
<input class="jquery-button" type="checkbox" id="dynamic-mode"><label id="dynamic-mode-label" for="dynamic-mode">Dynamic Mode</label>
<input class="jquery-button" type="checkbox" id="pl-btn-repeat"><label id="pl-btn-repeat-label" for="pl-btn-repeat">Repeat: Off</label>
</div>
<div id="playlist">
<div class="header">
@ -91,11 +148,218 @@
<div id="main-err-msg-text">Loading...</div>
</p>
</div>
<div id="shortcuts" style="display: none" tabindex="-1">
<h1>Playback</h1>
<dl>
<dt>Space</dt>
<dd>Toggle playback</dd>
</dl>
<dl>
<dt>Left <em>and</em> Right</dt>
<dd>Skip 10 seconds in the song</dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold to skip by 10% instead of 10 seconds</dd>
</dl>
<dl>
<dt>&lt; <em>or</em> Ctrl + Left <em>and</em> &gt; <em>or</em> Ctrl + Right</dt>
<dd>Skip track</dd>
</dl>
<dl>
<dt>- <em>and</em> +</dt>
<dd>Change volume</dd>
</dl>
<dl>
<dt>s</dt>
<dd>Toggle streaming</dd>
</dl>
<h1>Playlist</h1>
<dl>
<dt>Up <em>and</em> Down</dt>
<dd>Select the next song</dd>
</dl>
<dl>
<dt>Alt + Up <em>and</em> Alt + Down</dt>
<dd>Move selected tracks up or down one</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Play the selected song</dd>
</dl>
<dl>
<dt>C</dt>
<dd>Clear playlist</dd>
</dl>
<dl>
<dt>H</dt>
<dd>Shuffle playlist</dd>
</dl>
<dl>
<dt>d</dt>
<dd>Toggle dynamic playlist mode</dd>
</dl>
<dl>
<dt>r</dt>
<dd>Change repeat state</dd>
</dl>
<dl>
<dt>Del</dt>
<dd>Remove selected songs from playlist</dd>
</dl>
<dl>
<dt>Shift + Del</dt>
<dd>Delete selected songs from library</dd>
</dl>
<h1>Navigation</h1>
<dl>
<dt>l</dt>
<dd>Switch to Library tab</dd>
</dl>
<dl>
<dt>u</dt>
<dd>Switch to Upload tab and focus the upload by URL box</dd>
</dl>
<h1>Library Search Box</h1>
<dl>
<dt>/</dt>
<dd>Focus library search</dd>
</dl>
<dl>
<dt>Esc</dt>
<dd>Clear filter. If filter is already clear, remove focus.</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Queue all search results</dd>
</dl>
<dl>
<dt>Down</dt>
<dd>Select the first search result</dd>
</dl>
<h1>Library</h1>
<dl>
<dt>Up <em>and</em> Down</dt>
<dd>Select the next item up or down</dd>
</dl>
<dl>
<dt>Left <em>and</em> Right</dt>
<dd>Expand or collapse selected item</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Queue selected items<dd>
</dl>
<dl>
<dt>Del</dt>
<dd>Delete selected songs from library</dd>
</dl>
<h1>Miscellaneous</h1>
<dl>
<dt>?</dt>
<dd>Displays keyboard shortcuts</dd>
</dl>
<dl>
<dt>Esc</dt>
<dd>Close menu, cancel drag, clear selection</dd>
</dl>
<dl>
<dt>Alt</dt>
<dd>Hold when right clicking to get the normal browser menu</dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold while queuing to queue next<dd>
</dl>
<dl>
<dt>Alt</dt>
<dd>Hold while queuing to queue in random order<dd>
</dl>
<dl>
<dt>Ctrl</dt>
<dd>Hold to select multiple items<dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold while selecting to select all items in between<dd>
</dl>
</div>
<div id="edit-tags" style="display: none">
<input type="checkbox" id="edit-tag-multi-name">
<label accesskey="i">T<span class="accesskey">i</span>tle: <input id="edit-tag-name"></label><br>
<input type="checkbox" id="edit-tag-multi-track">
<label accesskey="k">Trac<span class="accesskey">k</span> Number: <input id="edit-tag-track"></label><br>
<input type="checkbox" id="edit-tag-multi-file">
<label>Filename: <input id="edit-tag-file"></label><br>
<hr>
<input type="checkbox" id="edit-tag-multi-artistName">
<label accesskey="a"><span class="accesskey">A</span>rtist: <input id="edit-tag-artistName"></label><br>
<input type="checkbox" id="edit-tag-multi-composerName">
<label accesskey="c"><span class="accesskey">C</span>omposer: <input id="edit-tag-composerName"></label><br>
<input type="checkbox" id="edit-tag-multi-performerName">
<label>Performer: <input id="edit-tag-performerName"></label><br>
<input type="checkbox" id="edit-tag-multi-genre">
<label accesskey="g"><span class="accesskey">G</span>enre: <input id="edit-tag-genre"></label><br>
<hr>
<input type="checkbox" id="edit-tag-multi-albumName">
<label accesskey="b">Al<span class="accesskey">b</span>um: <input id="edit-tag-albumName"></label><br>
<input type="checkbox" id="edit-tag-multi-albumArtistName">
<label>Album Artist: <input id="edit-tag-albumArtistName"></label><br>
<input type="checkbox" id="edit-tag-multi-trackCount">
<label>Track Count: <input id="edit-tag-trackCount"></label><br>
<input type="checkbox" id="edit-tag-multi-year">
<label accesskey="y"><span class="accesskey">Y</span>ear: <input id="edit-tag-year"></label><br>
<input type="checkbox" id="edit-tag-multi-disc">
<label accesskey="d"><span class="accesskey">D</span>isc Number: <input id="edit-tag-disc"></label><br>
<input type="checkbox" id="edit-tag-multi-discCount">
<label>Disc Count: <input id="edit-tag-discCount"></label><br>
<input type="checkbox" id="edit-tag-multi-compilation">
<label accesskey="m">Co<span class="accesskey">m</span>pilation: <input type="checkbox" id="edit-tag-compilation"></label><br>
<hr>
<div style="float: right">
<button id="edit-tags-ok" accesskey="v">Sa<span class="accesskey">v</span>e &amp; Close</button>
<button id="edit-tags-cancel">Cancel</button>
</div>
<button id="edit-tags-prev" type="button" accesskey="p"><span class="accesskey">P</span>revious</button>
<button id="edit-tags-next" type="button" accesskey="n"><span class="accesskey">N</span>ext</button>
<label accesskey="r" id="edit-tags-per-label"><input id="edit-tags-per" type="checkbox">Pe<span class="accesskey">r</span> Track</label>
</div>
<ul id="menu-playlist" style="display: none">
<li><a href="#" class="remove">Remove</a></li>
<li><a href="#" class="delete">Delete From Library</a></li>
<li><a href="#" class="download" target="_blank">Download</a></li>
<li><a href="#" class="edit-tags">Edit Tags</a></li>
</ul>
<ul id="menu-library" style="display: none">
<li><a href="#" class="queue">Queue</a></li>
<li><a href="#" class="queue-next">Queue Next</a></li>
<li><a href="#" class="queue-random">Queue in Random Order</a></li>
<li><a href="#" class="queue-next-random">Queue Next in Random Order</a></li>
<li><a href="#" class="delete">Delete</a></li>
<li><a href="#" class="download" target="_blank">Download</a></li>
<li><a href="#" class="edit-tags menu-item-last">Edit Tags</a></li>
</ul>
<script src="vendor/jquery-2.1.0.min.js"></script>
<script src="vendor/jquery-ui-1.10.4.custom.min.js"></script>
<script src="vendor/fileuploader/fileuploader.js"></script>
<script src="vendor/handlebars.runtime.js"></script>
<script src="views.js"></script>
<script src="app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 B

View file

@ -1,243 +0,0 @@
// lib/handlebars/base.js
/*jshint eqnull:true*/
this.Handlebars = {};
(function() {
Handlebars.VERSION = "1.0.rc.1";
Handlebars.helpers = {};
Handlebars.partials = {};
Handlebars.registerHelper = function(name, fn, inverse) {
if(inverse) { fn.not = inverse; }
this.helpers[name] = fn;
};
Handlebars.registerPartial = function(name, str) {
this.partials[name] = str;
};
Handlebars.registerHelper('helperMissing', function(arg) {
if(arguments.length === 2) {
return undefined;
} else {
throw new Error("Could not find property '" + arg + "'");
}
});
var toString = Object.prototype.toString, functionType = "[object Function]";
Handlebars.registerHelper('blockHelperMissing', function(context, options) {
var inverse = options.inverse || function() {}, fn = options.fn;
var ret = "";
var type = toString.call(context);
if(type === functionType) { context = context.call(this); }
if(context === true) {
return fn(this);
} else if(context === false || context == null) {
return inverse(this);
} else if(type === "[object Array]") {
if(context.length > 0) {
for(var i=0, j=context.length; i<j; i++) {
ret = ret + fn(context[i]);
}
} else {
ret = inverse(this);
}
return ret;
} else {
return fn(context);
}
});
Handlebars.K = function() {};
Handlebars.createFrame = Object.create || function(object) {
Handlebars.K.prototype = object;
var obj = new Handlebars.K();
Handlebars.K.prototype = null;
return obj;
};
Handlebars.registerHelper('each', function(context, options) {
var fn = options.fn, inverse = options.inverse;
var ret = "", data;
if (options.data) {
data = Handlebars.createFrame(options.data);
}
if(context && context.length > 0) {
for(var i=0, j=context.length; i<j; i++) {
if (data) { data.index = i; }
ret = ret + fn(context[i], { data: data });
}
} else {
ret = inverse(this);
}
return ret;
});
Handlebars.registerHelper('if', function(context, options) {
var type = toString.call(context);
if(type === functionType) { context = context.call(this); }
if(!context || Handlebars.Utils.isEmpty(context)) {
return options.inverse(this);
} else {
return options.fn(this);
}
});
Handlebars.registerHelper('unless', function(context, options) {
var fn = options.fn, inverse = options.inverse;
options.fn = inverse;
options.inverse = fn;
return Handlebars.helpers['if'].call(this, context, options);
});
Handlebars.registerHelper('with', function(context, options) {
return options.fn(context);
});
Handlebars.registerHelper('log', function(context) {
Handlebars.log(context);
});
}());
;
// lib/handlebars/utils.js
Handlebars.Exception = function(message) {
var tmp = Error.prototype.constructor.apply(this, arguments);
for (var p in tmp) {
if (tmp.hasOwnProperty(p)) { this[p] = tmp[p]; }
}
this.message = tmp.message;
};
Handlebars.Exception.prototype = new Error();
// Build out our basic SafeString type
Handlebars.SafeString = function(string) {
this.string = string;
};
Handlebars.SafeString.prototype.toString = function() {
return this.string.toString();
};
(function() {
var escape = {
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#x27;",
"`": "&#x60;"
};
var badChars = /&(?!\w+;)|[<>"'`]/g;
var possible = /[&<>"'`]/;
var escapeChar = function(chr) {
return escape[chr] || "&amp;";
};
Handlebars.Utils = {
escapeExpression: function(string) {
// don't escape SafeStrings, since they're already safe
if (string instanceof Handlebars.SafeString) {
return string.toString();
} else if (string == null || string === false) {
return "";
}
if(!possible.test(string)) { return string; }
return string.replace(badChars, escapeChar);
},
isEmpty: function(value) {
if (typeof value === "undefined") {
return true;
} else if (value === null) {
return true;
} else if (value === false) {
return true;
} else if(Object.prototype.toString.call(value) === "[object Array]" && value.length === 0) {
return true;
} else {
return false;
}
}
};
})();;
// lib/handlebars/runtime.js
Handlebars.VM = {
template: function(templateSpec) {
// Just add water
var container = {
escapeExpression: Handlebars.Utils.escapeExpression,
invokePartial: Handlebars.VM.invokePartial,
programs: [],
program: function(i, fn, data) {
var programWrapper = this.programs[i];
if(data) {
return Handlebars.VM.program(fn, data);
} else if(programWrapper) {
return programWrapper;
} else {
programWrapper = this.programs[i] = Handlebars.VM.program(fn);
return programWrapper;
}
},
programWithDepth: Handlebars.VM.programWithDepth,
noop: Handlebars.VM.noop
};
return function(context, options) {
options = options || {};
return templateSpec.call(container, Handlebars, context, options.helpers, options.partials, options.data);
};
},
programWithDepth: function(fn, data, $depth) {
var args = Array.prototype.slice.call(arguments, 2);
return function(context, options) {
options = options || {};
return fn.apply(this, [context, options.data || data].concat(args));
};
},
program: function(fn, data) {
return function(context, options) {
options = options || {};
return fn(context, options.data || data);
};
},
noop: function() { return ""; },
invokePartial: function(partial, name, context, helpers, partials, data) {
var options = { helpers: helpers, partials: partials, data: data };
if(partial === undefined) {
throw new Handlebars.Exception("The partial " + name + " could not be found");
} else if(partial instanceof Function) {
return partial(context, options);
} else if (!Handlebars.compile) {
throw new Handlebars.Exception("The partial " + name + " could not be compiled when running in runtime-only mode");
} else {
partials[name] = Handlebars.compile(partial);
return partials[name](context, options);
}
}
};
Handlebars.template = Handlebars.VM.template;
;