Compare commits
88 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a27807082a | ||
|
|
fae50a8d69 | ||
|
|
fa21bdc3d2 | ||
|
|
cb6077bc87 | ||
|
|
7ea657e0d4 | ||
|
|
0e3f87b8f1 | ||
|
|
ec7ebcfda1 | ||
|
|
bb1effc161 | ||
|
|
656d3068c4 | ||
|
|
9a24b61d81 | ||
|
|
6745455a74 | ||
|
|
26474c9002 | ||
|
|
35648a0a1d | ||
|
|
7527911bb2 | ||
|
|
3971dc65f6 | ||
|
|
92f0c80e9f | ||
|
|
d17157fcc9 | ||
|
|
45b8d28dcb | ||
|
|
4133c55fa7 | ||
|
|
53d1cbe9b0 | ||
|
|
fa8fbf60fc | ||
|
|
3c02511c9e | ||
|
|
d642629fd0 | ||
|
|
b861d4776c | ||
|
|
656aab43ab | ||
|
|
876e72db0c | ||
|
|
f36efe16c7 | ||
|
|
8c2b78efc8 | ||
|
|
8fe56b52bf | ||
|
|
b1a48d5b27 | ||
|
|
43d96fb348 | ||
|
|
8f13aa795a | ||
|
|
7d0ac9dfd4 | ||
|
|
2b3aff220f | ||
|
|
cc8f0abea4 | ||
|
|
145e39ff02 | ||
|
|
52e54b0152 | ||
|
|
e1bd006566 | ||
|
|
453143040d | ||
|
|
6dbfb820ea | ||
|
|
7cc928e1e9 | ||
|
|
deed3a7eb8 | ||
|
|
bf83c04902 | ||
|
|
ac69158091 | ||
|
|
94ea856fae | ||
|
|
f9c820bdbf | ||
|
|
78cd66d8df | ||
|
|
259f247290 | ||
|
|
cc7183ce53 | ||
|
|
e02c986e86 | ||
|
|
057a2e8776 | ||
|
|
c6ada32047 | ||
|
|
3325a248ee | ||
|
|
f6054e662b | ||
|
|
f91849db47 | ||
|
|
3fee3df88c | ||
|
|
9fa3ebac52 | ||
|
|
6a959e6d12 | ||
|
|
09463c9d0a | ||
|
|
5c8f45b6e9 | ||
|
|
4e560b93e8 | ||
|
|
c230081b3c | ||
|
|
0d6e6cb203 | ||
|
|
ab1b828f4d | ||
|
|
1ec74cea49 | ||
|
|
693e06a137 | ||
|
|
207f77e4c9 | ||
|
|
cde56a3e6b | ||
|
|
c7e2387325 | ||
|
|
dec92a1b39 | ||
|
|
b0bc750c9b | ||
|
|
398e3e9db8 | ||
|
|
9fa80e43ff | ||
|
|
1c0129a779 | ||
|
|
b0839774f0 | ||
|
|
113f008115 | ||
|
|
a4b0fa4a12 | ||
|
|
b693b9f6f6 | ||
|
|
de234fde4f | ||
|
|
825fa5d5d0 | ||
|
|
889e8b0bec | ||
|
|
540266c058 | ||
|
|
bf02f0d3c5 | ||
|
|
17f86d9735 | ||
|
|
42f66c817f | ||
|
|
11068623b5 | ||
|
|
3be4ad9cb6 | ||
|
|
1a7dc0e0a3 |
37 changed files with 2604 additions and 2589 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,5 +4,4 @@
|
|||
|
||||
# not shared with .npmignore
|
||||
/public/app.js
|
||||
/public/views.js
|
||||
/public/app.css
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -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
1
build
|
|
@ -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
3
docs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
_build
|
||||
_static
|
||||
_templates
|
||||
287
docs/changelog.rst
Normal file
287
docs/changelog.rst
Normal 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
242
docs/conf.py
Normal 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
69
docs/guides/main.rst
Normal 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
29
docs/index.rst
Normal 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`
|
||||
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
388
lib/player.js
388
lib/player.js
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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
62
lib/plugins/ytdl.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
21
package.json
21
package.json
|
|
@ -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",
|
||||
|
|
|
|||
1544
src/client/app.js
1544
src/client/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
31
src/client/styles/vendor/fileuploader.css
vendored
31
src/client/styles/vendor/fileuploader.css
vendored
|
|
@ -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;}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{{#if users}}
|
||||
<ul>
|
||||
{{#each users}}
|
||||
<li>
|
||||
<span class="{{class}}">{{userName}}</span>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
|
||||
{{/if}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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}}
|
||||
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>< <em>or</em> Ctrl + Left <em>and</em> > <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>
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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>< <em>or</em> Ctrl + Left <em>and</em> > <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 & 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>
|
||||
|
|
|
|||
1276
src/public/vendor/fileuploader/fileuploader.js
vendored
1276
src/public/vendor/fileuploader/fileuploader.js
vendored
File diff suppressed because it is too large
Load diff
BIN
src/public/vendor/fileuploader/loading.gif
vendored
BIN
src/public/vendor/fileuploader/loading.gif
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 455 B |
243
src/public/vendor/handlebars.runtime.js
vendored
243
src/public/vendor/handlebars.runtime.js
vendored
|
|
@ -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 = {
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"`": "`"
|
||||
};
|
||||
|
||||
var badChars = /&(?!\w+;)|[<>"'`]/g;
|
||||
var possible = /[&<>"'`]/;
|
||||
|
||||
var escapeChar = function(chr) {
|
||||
return escape[chr] || "&";
|
||||
};
|
||||
|
||||
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;
|
||||
;
|
||||
Loading…
Reference in a new issue