tag in the HTML starting with
- ``starttag``. Returns None if parsing fails.
- """
-
- # Strip off the leading text before opening tag.
- try:
- _, html = html.split(starttag, 1)
- except ValueError:
- return
-
- # Walk through balanced DIV tags.
- level = 0
- parts = []
- pos = 0
- for match in DIV_RE.finditer(html):
- if match.group(1): # Closing tag.
- level -= 1
- if level == 0:
- pos = match.end()
- else: # Opening tag.
- if level == 0:
- parts.append(html[pos:match.start()])
- level += 1
-
- if level == -1:
- parts.append(html[pos:match.start()])
- break
- else:
- print('no closing tag found!')
- return
- return u''.join(parts)
-
-
def search_pairs(item):
"""Yield a pairs of artists and titles to search for.
@@ -136,32 +151,42 @@ def search_pairs(item):
In addition to the artist and title obtained from the `item` the
method tries to strip extra information like paranthesized suffixes
and featured artists from the strings and add them as candidates.
+ The artist sort name is added as a fallback candidate to help in
+ cases where artist name includes special characters or is in a
+ non-latin script.
The method also tries to split multiple titles separated with `/`.
"""
+ def generate_alternatives(string, patterns):
+ """Generate string alternatives by extracting first matching group for
+ each given pattern.
+ """
+ alternatives = [string]
+ for pattern in patterns:
+ match = re.search(pattern, string, re.IGNORECASE)
+ if match:
+ alternatives.append(match.group(1))
+ return alternatives
- title, artist = item.title, item.artist
- titles = [title]
- artists = [artist]
+ title, artist, artist_sort = item.title, item.artist, item.artist_sort
- # Remove any featuring artists from the artists name
- pattern = r"(.*?) {0}".format(plugins.feat_tokens())
- match = re.search(pattern, artist, re.IGNORECASE)
- if match:
- artists.append(match.group(1))
+ patterns = [
+ # Remove any featuring artists from the artists name
+ fr"(.*?) {plugins.feat_tokens()}"]
+ artists = generate_alternatives(artist, patterns)
+ # Use the artist_sort as fallback only if it differs from artist to avoid
+ # repeated remote requests with the same search terms
+ if artist != artist_sort:
+ artists.append(artist_sort)
- # Remove a parenthesized suffix from a title string. Common
- # examples include (live), (remix), and (acoustic).
- pattern = r"(.+?)\s+[(].*[)]$"
- match = re.search(pattern, title, re.IGNORECASE)
- if match:
- titles.append(match.group(1))
-
- # Remove any featuring artists from the title
- pattern = r"(.*?) {0}".format(plugins.feat_tokens(for_artist=False))
- for title in titles[:]:
- match = re.search(pattern, title, re.IGNORECASE)
- if match:
- titles.append(match.group(1))
+ patterns = [
+ # Remove a parenthesized suffix from a title string. Common
+ # examples include (live), (remix), and (acoustic).
+ r"(.+?)\s+[(].*[)]$",
+ # Remove any featuring artists from the title
+ r"(.*?) {}".format(plugins.feat_tokens(for_artist=False)),
+ # Remove part of title after colon ':' for songs with subtitles
+ r"(.+?)\s*:.*"]
+ titles = generate_alternatives(title, patterns)
# Check for a dual song (e.g. Pink Floyd - Speak to Me / Breathe)
# and each of them.
@@ -174,131 +199,300 @@ def search_pairs(item):
return itertools.product(artists, multi_titles)
-def _encode(s):
- """Encode the string for inclusion in a URL (common to both
- LyricsWiki and Lyrics.com).
+def slug(text):
+ """Make a URL-safe, human-readable version of the given text
+
+ This will do the following:
+
+ 1. decode unicode characters into ASCII
+ 2. shift everything to lowercase
+ 3. strip whitespace
+ 4. replace other non-word characters with dashes
+ 5. strip extra dashes
+
+ This somewhat duplicates the :func:`Google.slugify` function but
+ slugify is not as generic as this one, which can be reused
+ elsewhere.
"""
- if isinstance(s, unicode):
- for char, repl in URL_CHARACTERS.items():
- s = s.replace(char, repl)
- s = s.encode('utf8', 'ignore')
- return urllib.quote(s)
-
-# Musixmatch
-
-MUSIXMATCH_URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
+ return re.sub(r'\W+', '-', unidecode(text).lower().strip()).strip('-')
-def fetch_musixmatch(artist, title):
- url = MUSIXMATCH_URL_PATTERN % (_lw_encode(artist.title()),
- _lw_encode(title.title()))
- html = fetch_url(url)
- if not html:
- return
- lyrics = extract_text_between(html, '"lyrics_body":', '"lyrics_language":')
- return lyrics.strip(',"').replace('\\n', '\n')
-
-# LyricsWiki.
-
-LYRICSWIKI_URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
+if HAS_BEAUTIFUL_SOUP:
+ def try_parse_html(html, **kwargs):
+ try:
+ return bs4.BeautifulSoup(html, 'html.parser', **kwargs)
+ except HTMLParseError:
+ return None
+else:
+ def try_parse_html(html, **kwargs):
+ return None
-def _lw_encode(s):
- s = re.sub(r'\s+', '_', s)
- s = s.replace("<", "Less_Than")
- s = s.replace(">", "Greater_Than")
- s = s.replace("#", "Number_")
- s = re.sub(r'[\[\{]', '(', s)
- s = re.sub(r'[\]\}]', ')', s)
- return _encode(s)
+class Backend:
+ REQUIRES_BS = False
+
+ def __init__(self, config, log):
+ self._log = log
+
+ @staticmethod
+ def _encode(s):
+ """Encode the string for inclusion in a URL"""
+ if isinstance(s, str):
+ for char, repl in URL_CHARACTERS.items():
+ s = s.replace(char, repl)
+ s = s.encode('utf-8', 'ignore')
+ return urllib.parse.quote(s)
+
+ def build_url(self, artist, title):
+ return self.URL_PATTERN % (self._encode(artist.title()),
+ self._encode(title.title()))
+
+ def fetch_url(self, url):
+ """Retrieve the content at a given URL, or return None if the source
+ is unreachable.
+ """
+ try:
+ # Disable the InsecureRequestWarning that comes from using
+ # `verify=false`.
+ # https://github.com/kennethreitz/requests/issues/2214
+ # We're not overly worried about the NSA MITMing our lyrics scraper
+ with warnings.catch_warnings():
+ warnings.simplefilter('ignore')
+ r = requests.get(url, verify=False, headers={
+ 'User-Agent': USER_AGENT,
+ })
+ except requests.RequestException as exc:
+ self._log.debug('lyrics request failed: {0}', exc)
+ return
+ if r.status_code == requests.codes.ok:
+ return r.text
+ else:
+ self._log.debug('failed to fetch: {0} ({1})', url, r.status_code)
+ return None
+
+ def fetch(self, artist, title):
+ raise NotImplementedError()
-def fetch_lyricswiki(artist, title):
- """Fetch lyrics from LyricsWiki."""
- url = LYRICSWIKI_URL_PATTERN % (_lw_encode(artist), _lw_encode(title))
- html = fetch_url(url)
- if not html:
- return
+class MusiXmatch(Backend):
+ REPLACEMENTS = {
+ r'\s+': '-',
+ '<': 'Less_Than',
+ '>': 'Greater_Than',
+ '#': 'Number_',
+ r'[\[\{]': '(',
+ r'[\]\}]': ')',
+ }
- lyrics = extract_text_in(html, u"
")
- if lyrics and 'Unfortunately, we are not licensed' not in lyrics:
+ URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
+
+ @classmethod
+ def _encode(cls, s):
+ for old, new in cls.REPLACEMENTS.items():
+ s = re.sub(old, new, s)
+
+ return super()._encode(s)
+
+ def fetch(self, artist, title):
+ url = self.build_url(artist, title)
+
+ html = self.fetch_url(url)
+ if not html:
+ return None
+ if "We detected that your IP is blocked" in html:
+ self._log.warning('we are blocked at MusixMatch: url %s failed'
+ % url)
+ return None
+ html_parts = html.split('
')
- if not lyrics:
- return
- for not_found_str in LYRICSCOM_NOT_FOUND:
- if not_found_str in lyrics:
+ def fetch(self, artist, title):
+ """Fetch lyrics from genius.com
+
+ Because genius doesn't allow accesssing lyrics via the api,
+ we first query the api for a url matching our artist & title,
+ then attempt to scrape that url for the lyrics.
+ """
+ json = self._search(artist, title)
+ if not json:
+ self._log.debug('Genius API request returned invalid JSON')
+ return None
+
+ # find a matching artist in the json
+ for hit in json["response"]["hits"]:
+ hit_artist = hit["result"]["primary_artist"]["name"]
+
+ if slug(hit_artist) == slug(artist):
+ html = self.fetch_url(hit["result"]["url"])
+ if not html:
+ return None
+ return self._scrape_lyrics_from_html(html)
+
+ self._log.debug('Genius failed to find a matching artist for \'{0}\'',
+ artist)
+ return None
+
+ def _search(self, artist, title):
+ """Searches the genius api for a given artist and title
+
+ https://docs.genius.com/#search-h2
+
+ :returns: json response
+ """
+ search_url = self.base_url + "/search"
+ data = {'q': title + " " + artist.lower()}
+ try:
+ response = requests.get(
+ search_url, data=data, headers=self.headers)
+ except requests.RequestException as exc:
+ self._log.debug('Genius API request failed: {0}', exc)
+ return None
+
+ try:
+ return response.json()
+ except ValueError:
+ return None
+
+ def _scrape_lyrics_from_html(self, html):
+ """Scrape lyrics from a given genius.com html"""
+
+ soup = try_parse_html(html)
+ if not soup:
return
- parts = lyrics.split('\n---\nLyrics powered by', 1)
- if parts:
- return parts[0]
+ # Remove script tags that they put in the middle of the lyrics.
+ [h.extract() for h in soup('script')]
+
+ # Most of the time, the page contains a div with class="lyrics" where
+ # all of the lyrics can be found already correctly formatted
+ # Sometimes, though, it packages the lyrics into separate divs, most
+ # likely for easier ad placement
+ lyrics_div = soup.find("div", class_="lyrics")
+ if not lyrics_div:
+ self._log.debug('Received unusual song page html')
+ verse_div = soup.find("div",
+ class_=re.compile("Lyrics__Container"))
+ if not verse_div:
+ if soup.find("div",
+ class_=re.compile("LyricsPlaceholder__Message"),
+ string="This song is an instrumental"):
+ self._log.debug('Detected instrumental')
+ return "[Instrumental]"
+ else:
+ self._log.debug("Couldn't scrape page using known layouts")
+ return None
+
+ lyrics_div = verse_div.parent
+ for br in lyrics_div.find_all("br"):
+ br.replace_with("\n")
+ ads = lyrics_div.find_all("div",
+ class_=re.compile("InreadAd__Container"))
+ for ad in ads:
+ ad.replace_with("\n")
+
+ return lyrics_div.get_text()
-# Optional Google custom search API backend.
+class Tekstowo(Backend):
+ # Fetch lyrics from Tekstowo.pl.
+ REQUIRES_BS = True
-def slugify(text):
- """Normalize a string and remove non-alphanumeric characters.
- """
- text = re.sub(r"[-'_\s]", '_', text)
- text = re.sub(r"_+", '_', text).strip('_')
- pat = "([^,\(]*)\((.*?)\)" # Remove content within parentheses
- text = re.sub(pat, '\g<1>', text).strip()
- try:
- text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore')
- text = unicode(re.sub('[-\s]+', ' ', text))
- except UnicodeDecodeError:
- log.exception(u"Failing to normalize '{0}'".format(text))
- return text
+ BASE_URL = 'http://www.tekstowo.pl'
+ URL_PATTERN = BASE_URL + '/wyszukaj.html?search-title=%s&search-artist=%s'
+ def fetch(self, artist, title):
+ url = self.build_url(title, artist)
+ search_results = self.fetch_url(url)
+ if not search_results:
+ return None
-BY_TRANS = ['by', 'par', 'de', 'von']
-LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte']
+ song_page_url = self.parse_search_results(search_results)
+ if not song_page_url:
+ return None
+ song_page_html = self.fetch_url(song_page_url)
+ if not song_page_html:
+ return None
-def is_page_candidate(urlLink, urlTitle, title, artist):
- """Return True if the URL title makes it a good candidate to be a
- page that contains lyrics of title by artist.
- """
- title = slugify(title.lower())
- artist = slugify(artist.lower())
- sitename = re.search(u"//([^/]+)/.*", slugify(urlLink.lower())).group(1)
- urlTitle = slugify(urlTitle.lower())
- # Check if URL title contains song title (exact match)
- if urlTitle.find(title) != -1:
- return True
- # or try extracting song title from URL title and check if
- # they are close enough
- tokens = [by + '_' + artist for by in BY_TRANS] + \
- [artist, sitename, sitename.replace('www.', '')] + LYRICS_TRANS
- songTitle = re.sub(u'(%s)' % u'|'.join(tokens), u'', urlTitle)
- songTitle = songTitle.strip('_|')
- typoRatio = .9
- return difflib.SequenceMatcher(None, songTitle, title).ratio() >= typoRatio
+ return self.extract_lyrics(song_page_html)
+
+ def parse_search_results(self, html):
+ html = _scrape_strip_cruft(html)
+ html = _scrape_merge_paragraphs(html)
+
+ soup = try_parse_html(html)
+ if not soup:
+ return None
+
+ content_div = soup.find("div", class_="content")
+ if not content_div:
+ return None
+
+ card_div = content_div.find("div", class_="card")
+ if not card_div:
+ return None
+
+ song_rows = card_div.find_all("div", class_="box-przeboje")
+ if not song_rows:
+ return None
+
+ song_row = song_rows[0]
+ if not song_row:
+ return None
+
+ link = song_row.find('a')
+ if not link:
+ return None
+
+ return self.BASE_URL + link.get('href')
+
+ def extract_lyrics(self, html):
+ html = _scrape_strip_cruft(html)
+ html = _scrape_merge_paragraphs(html)
+
+ soup = try_parse_html(html)
+ if not soup:
+ return None
+
+ lyrics_div = soup.find("div", class_="song-text")
+ if not lyrics_div:
+ return None
+
+ return lyrics_div.get_text()
def remove_credits(text):
@@ -315,36 +509,6 @@ def remove_credits(text):
return text
-def is_lyrics(text, artist=None):
- """Determine whether the text seems to be valid lyrics.
- """
- if not text:
- return False
- badTriggersOcc = []
- nbLines = text.count('\n')
- if nbLines <= 1:
- log.debug(u"Ignoring too short lyrics '{0}'".format(text))
- return False
- elif nbLines < 5:
- badTriggersOcc.append('too_short')
- else:
- # Lyrics look legit, remove credits to avoid being penalized further
- # down
- text = remove_credits(text)
-
- badTriggers = ['lyrics', 'copyright', 'property', 'links']
- if artist:
- badTriggersOcc += [artist]
-
- for item in badTriggers:
- badTriggersOcc += [item] * len(re.findall(r'\W%s\W' % item,
- text, re.I))
-
- if badTriggersOcc:
- log.debug(u'Bad triggers detected: {0}'.format(badTriggersOcc))
- return len(badTriggersOcc) < 2
-
-
def _scrape_strip_cruft(html, plain_text_out=False):
"""Clean up HTML
"""
@@ -353,7 +517,8 @@ def _scrape_strip_cruft(html, plain_text_out=False):
html = html.replace('\r', '\n') # Normalize EOL.
html = re.sub(r' +', ' ', html) # Whitespaces collapse.
html = BREAK_RE.sub('\n', html) #
eats up surrounding '\n'.
- html = re.sub(r'<(script).*?\1>(?s)', '', html) # Strip script tags.
+ html = re.sub(r'(?s)<(script).*?\1>', '', html) # Strip script tags.
+ html = re.sub('\u2005', " ", html) # replace unicode with regular space
if plain_text_out: # Strip remaining HTML tags
html = COMMENT_RE.sub('', html)
@@ -373,11 +538,6 @@ def scrape_lyrics_from_html(html):
"""Scrape lyrics from a URL. If no lyrics can be found, return None
instead.
"""
- from bs4 import SoupStrainer, BeautifulSoup
-
- if not html:
- return None
-
def is_text_notcode(text):
length = len(text)
return (length > 20 and
@@ -387,123 +547,371 @@ def scrape_lyrics_from_html(html):
html = _scrape_merge_paragraphs(html)
# extract all long text blocks that are not code
- try:
- soup = BeautifulSoup(html, "html.parser",
- parse_only=SoupStrainer(text=is_text_notcode))
- except HTMLParseError:
+ soup = try_parse_html(html, parse_only=SoupStrainer(text=is_text_notcode))
+ if not soup:
+ return None
+
+ # Get the longest text element (if any).
+ strings = sorted(soup.stripped_strings, key=len, reverse=True)
+ if strings:
+ return strings[0]
+ else:
return None
- soup = sorted(soup.stripped_strings, key=len)[-1]
- return soup
-def fetch_google(artist, title):
- """Fetch lyrics from Google search results.
- """
- query = u"%s %s" % (artist, title)
- api_key = config['lyrics']['google_API_key'].get(unicode)
- engine_id = config['lyrics']['google_engine_ID'].get(unicode)
- url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' % \
- (api_key, engine_id, urllib.quote(query.encode('utf8')))
+class Google(Backend):
+ """Fetch lyrics from Google search results."""
- data = urllib.urlopen(url)
- data = json.load(data)
- if 'error' in data:
- reason = data['error']['errors'][0]['reason']
- log.debug(u'google lyrics backend error: {0}'.format(reason))
- return
+ REQUIRES_BS = True
- if 'items' in data.keys():
- for item in data['items']:
- urlLink = item['link']
- urlTitle = item.get('title', u'')
- if not is_page_candidate(urlLink, urlTitle, title, artist):
- continue
- html = fetch_url(urlLink)
- lyrics = scrape_lyrics_from_html(html)
- if not lyrics:
- continue
+ def __init__(self, config, log):
+ super().__init__(config, log)
+ self.api_key = config['google_API_key'].as_str()
+ self.engine_id = config['google_engine_ID'].as_str()
- if is_lyrics(lyrics, artist):
- log.debug(u'got lyrics from {0}'.format(item['displayLink']))
- return lyrics
+ def is_lyrics(self, text, artist=None):
+ """Determine whether the text seems to be valid lyrics.
+ """
+ if not text:
+ return False
+ bad_triggers_occ = []
+ nb_lines = text.count('\n')
+ if nb_lines <= 1:
+ self._log.debug("Ignoring too short lyrics '{0}'", text)
+ return False
+ elif nb_lines < 5:
+ bad_triggers_occ.append('too_short')
+ else:
+ # Lyrics look legit, remove credits to avoid being penalized
+ # further down
+ text = remove_credits(text)
+ bad_triggers = ['lyrics', 'copyright', 'property', 'links']
+ if artist:
+ bad_triggers += [artist]
-# Plugin logic.
+ for item in bad_triggers:
+ bad_triggers_occ += [item] * len(re.findall(r'\W%s\W' % item,
+ text, re.I))
-SOURCES = ['google', 'lyricwiki', 'lyrics.com', 'musixmatch']
-SOURCE_BACKENDS = {
- 'google': fetch_google,
- 'lyricwiki': fetch_lyricswiki,
- 'lyrics.com': fetch_lyricscom,
- 'musixmatch': fetch_musixmatch,
-}
+ if bad_triggers_occ:
+ self._log.debug('Bad triggers detected: {0}', bad_triggers_occ)
+ return len(bad_triggers_occ) < 2
+
+ def slugify(self, text):
+ """Normalize a string and remove non-alphanumeric characters.
+ """
+ text = re.sub(r"[-'_\s]", '_', text)
+ text = re.sub(r"_+", '_', text).strip('_')
+ pat = r"([^,\(]*)\((.*?)\)" # Remove content within parentheses
+ text = re.sub(pat, r'\g<1>', text).strip()
+ try:
+ text = unicodedata.normalize('NFKD', text).encode('ascii',
+ 'ignore')
+ text = str(re.sub(r'[-\s]+', ' ', text.decode('utf-8')))
+ except UnicodeDecodeError:
+ self._log.exception("Failing to normalize '{0}'", text)
+ return text
+
+ BY_TRANS = ['by', 'par', 'de', 'von']
+ LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte']
+
+ def is_page_candidate(self, url_link, url_title, title, artist):
+ """Return True if the URL title makes it a good candidate to be a
+ page that contains lyrics of title by artist.
+ """
+ title = self.slugify(title.lower())
+ artist = self.slugify(artist.lower())
+ sitename = re.search("//([^/]+)/.*",
+ self.slugify(url_link.lower())).group(1)
+ url_title = self.slugify(url_title.lower())
+
+ # Check if URL title contains song title (exact match)
+ if url_title.find(title) != -1:
+ return True
+
+ # or try extracting song title from URL title and check if
+ # they are close enough
+ tokens = [by + '_' + artist for by in self.BY_TRANS] + \
+ [artist, sitename, sitename.replace('www.', '')] + \
+ self.LYRICS_TRANS
+ tokens = [re.escape(t) for t in tokens]
+ song_title = re.sub('(%s)' % '|'.join(tokens), '', url_title)
+
+ song_title = song_title.strip('_|')
+ typo_ratio = .9
+ ratio = difflib.SequenceMatcher(None, song_title, title).ratio()
+ return ratio >= typo_ratio
+
+ def fetch(self, artist, title):
+ query = f"{artist} {title}"
+ url = 'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' \
+ % (self.api_key, self.engine_id,
+ urllib.parse.quote(query.encode('utf-8')))
+
+ data = self.fetch_url(url)
+ if not data:
+ self._log.debug('google backend returned no data')
+ return None
+ try:
+ data = json.loads(data)
+ except ValueError as exc:
+ self._log.debug('google backend returned malformed JSON: {}', exc)
+ if 'error' in data:
+ reason = data['error']['errors'][0]['reason']
+ self._log.debug('google backend error: {0}', reason)
+ return None
+
+ if 'items' in data.keys():
+ for item in data['items']:
+ url_link = item['link']
+ url_title = item.get('title', '')
+ if not self.is_page_candidate(url_link, url_title,
+ title, artist):
+ continue
+ html = self.fetch_url(url_link)
+ if not html:
+ continue
+ lyrics = scrape_lyrics_from_html(html)
+ if not lyrics:
+ continue
+
+ if self.is_lyrics(lyrics, artist):
+ self._log.debug('got lyrics from {0}',
+ item['displayLink'])
+ return lyrics
+
+ return None
class LyricsPlugin(plugins.BeetsPlugin):
+ SOURCES = ['google', 'musixmatch', 'genius', 'tekstowo']
+ SOURCE_BACKENDS = {
+ 'google': Google,
+ 'musixmatch': MusiXmatch,
+ 'genius': Genius,
+ 'tekstowo': Tekstowo,
+ }
+
def __init__(self):
- super(LyricsPlugin, self).__init__()
+ super().__init__()
self.import_stages = [self.imported]
self.config.add({
'auto': True,
+ 'bing_client_secret': None,
+ 'bing_lang_from': [],
+ 'bing_lang_to': None,
'google_API_key': None,
- 'google_engine_ID': u'009217259823014548361:lndtuqkycfu',
+ 'google_engine_ID': '009217259823014548361:lndtuqkycfu',
+ 'genius_api_key':
+ "Ryq93pUGm8bM6eUWwD_M3NOFFDAtp2yEE7W"
+ "76V-uFL5jks5dNvcGCdarqFjDhP9c",
'fallback': None,
'force': False,
- 'sources': SOURCES,
+ 'local': False,
+ 'sources': self.SOURCES,
})
+ self.config['bing_client_secret'].redact = True
+ self.config['google_API_key'].redact = True
+ self.config['google_engine_ID'].redact = True
+ self.config['genius_api_key'].redact = True
- available_sources = list(SOURCES)
- if not self.config['google_API_key'].get() and \
- 'google' in SOURCES:
- available_sources.remove('google')
- self.config['sources'] = plugins.sanitize_choices(
+ # State information for the ReST writer.
+ # First, the current artist we're writing.
+ self.artist = 'Unknown artist'
+ # The current album: False means no album yet.
+ self.album = False
+ # The current rest file content. None means the file is not
+ # open yet.
+ self.rest = None
+
+ available_sources = list(self.SOURCES)
+ sources = plugins.sanitize_choices(
self.config['sources'].as_str_seq(), available_sources)
- self.backends = []
- for key in self.config['sources'].as_str_seq():
- self.backends.append(SOURCE_BACKENDS[key])
+
+ if not HAS_BEAUTIFUL_SOUP:
+ sources = self.sanitize_bs_sources(sources)
+
+ if 'google' in sources:
+ if not self.config['google_API_key'].get():
+ # We log a *debug* message here because the default
+ # configuration includes `google`. This way, the source
+ # is silent by default but can be enabled just by
+ # setting an API key.
+ self._log.debug('Disabling google source: '
+ 'no API key configured.')
+ sources.remove('google')
+
+ self.config['bing_lang_from'] = [
+ x.lower() for x in self.config['bing_lang_from'].as_str_seq()]
+ self.bing_auth_token = None
+
+ if not HAS_LANGDETECT and self.config['bing_client_secret'].get():
+ self._log.warning('To use bing translations, you need to '
+ 'install the langdetect module. See the '
+ 'documentation for further details.')
+
+ self.backends = [self.SOURCE_BACKENDS[source](self.config, self._log)
+ for source in sources]
+
+ def sanitize_bs_sources(self, sources):
+ enabled_sources = []
+ for source in sources:
+ if self.SOURCE_BACKENDS[source].REQUIRES_BS:
+ self._log.debug('To use the %s lyrics source, you must '
+ 'install the beautifulsoup4 module. See '
+ 'the documentation for further details.'
+ % source)
+ else:
+ enabled_sources.append(source)
+
+ return enabled_sources
+
+ def get_bing_access_token(self):
+ params = {
+ 'client_id': 'beets',
+ 'client_secret': self.config['bing_client_secret'],
+ 'scope': "https://api.microsofttranslator.com",
+ 'grant_type': 'client_credentials',
+ }
+
+ oauth_url = 'https://datamarket.accesscontrol.windows.net/v2/OAuth2-13'
+ oauth_token = json.loads(requests.post(
+ oauth_url,
+ data=urllib.parse.urlencode(params)).content)
+ if 'access_token' in oauth_token:
+ return "Bearer " + oauth_token['access_token']
+ else:
+ self._log.warning('Could not get Bing Translate API access token.'
+ ' Check your "bing_client_secret" password')
def commands(self):
cmd = ui.Subcommand('lyrics', help='fetch song lyrics')
- cmd.parser.add_option('-p', '--print', dest='printlyr',
- action='store_true', default=False,
- help='print lyrics to console')
- cmd.parser.add_option('-f', '--force', dest='force_refetch',
- action='store_true', default=False,
- help='always re-download lyrics')
+ cmd.parser.add_option(
+ '-p', '--print', dest='printlyr',
+ action='store_true', default=False,
+ help='print lyrics to console',
+ )
+ cmd.parser.add_option(
+ '-r', '--write-rest', dest='writerest',
+ action='store', default=None, metavar='dir',
+ help='write lyrics to given directory as ReST files',
+ )
+ cmd.parser.add_option(
+ '-f', '--force', dest='force_refetch',
+ action='store_true', default=False,
+ help='always re-download lyrics',
+ )
+ cmd.parser.add_option(
+ '-l', '--local', dest='local_only',
+ action='store_true', default=False,
+ help='do not fetch missing lyrics',
+ )
def func(lib, opts, args):
# The "write to files" option corresponds to the
# import_write config value.
- write = config['import']['write'].get(bool)
- for item in lib.items(ui.decargs(args)):
- self.fetch_item_lyrics(
- lib, logging.INFO, item, write,
- opts.force_refetch or self.config['force'],
- )
- if opts.printlyr and item.lyrics:
- ui.print_(item.lyrics)
-
+ write = ui.should_write()
+ if opts.writerest:
+ self.writerest_indexes(opts.writerest)
+ items = lib.items(ui.decargs(args))
+ for item in items:
+ if not opts.local_only and not self.config['local']:
+ self.fetch_item_lyrics(
+ lib, item, write,
+ opts.force_refetch or self.config['force'],
+ )
+ if item.lyrics:
+ if opts.printlyr:
+ ui.print_(item.lyrics)
+ if opts.writerest:
+ self.appendrest(opts.writerest, item)
+ if opts.writerest and items:
+ # flush last artist & write to ReST
+ self.writerest(opts.writerest)
+ ui.print_('ReST files generated. to build, use one of:')
+ ui.print_(' sphinx-build -b html %s _build/html'
+ % opts.writerest)
+ ui.print_(' sphinx-build -b epub %s _build/epub'
+ % opts.writerest)
+ ui.print_((' sphinx-build -b latex %s _build/latex '
+ '&& make -C _build/latex all-pdf')
+ % opts.writerest)
cmd.func = func
return [cmd]
+ def appendrest(self, directory, item):
+ """Append the item to an ReST file
+
+ This will keep state (in the `rest` variable) in order to avoid
+ writing continuously to the same files.
+ """
+
+ if slug(self.artist) != slug(item.albumartist):
+ # Write current file and start a new one ~ item.albumartist
+ self.writerest(directory)
+ self.artist = item.albumartist.strip()
+ self.rest = "%s\n%s\n\n.. contents::\n :local:\n\n" \
+ % (self.artist,
+ '=' * len(self.artist))
+
+ if self.album != item.album:
+ tmpalbum = self.album = item.album.strip()
+ if self.album == '':
+ tmpalbum = 'Unknown album'
+ self.rest += "{}\n{}\n\n".format(tmpalbum, '-' * len(tmpalbum))
+ title_str = ":index:`%s`" % item.title.strip()
+ block = '| ' + item.lyrics.replace('\n', '\n| ')
+ self.rest += "{}\n{}\n\n{}\n\n".format(title_str,
+ '~' * len(title_str),
+ block)
+
+ def writerest(self, directory):
+ """Write self.rest to a ReST file
+ """
+ if self.rest is not None and self.artist is not None:
+ path = os.path.join(directory, 'artists',
+ slug(self.artist) + '.rst')
+ with open(path, 'wb') as output:
+ output.write(self.rest.encode('utf-8'))
+
+ def writerest_indexes(self, directory):
+ """Write conf.py and index.rst files necessary for Sphinx
+
+ We write minimal configurations that are necessary for Sphinx
+ to operate. We do not overwrite existing files so that
+ customizations are respected."""
+ try:
+ os.makedirs(os.path.join(directory, 'artists'))
+ except OSError as e:
+ if e.errno == errno.EEXIST:
+ pass
+ else:
+ raise
+ indexfile = os.path.join(directory, 'index.rst')
+ if not os.path.exists(indexfile):
+ with open(indexfile, 'w') as output:
+ output.write(REST_INDEX_TEMPLATE)
+ conffile = os.path.join(directory, 'conf.py')
+ if not os.path.exists(conffile):
+ with open(conffile, 'w') as output:
+ output.write(REST_CONF_TEMPLATE)
+
def imported(self, session, task):
"""Import hook for fetching lyrics automatically.
"""
if self.config['auto']:
for item in task.imported_items():
- self.fetch_item_lyrics(session.lib, logging.DEBUG, item,
+ self.fetch_item_lyrics(session.lib, item,
False, self.config['force'])
- def fetch_item_lyrics(self, lib, loglevel, item, write, force):
+ def fetch_item_lyrics(self, lib, item, write, force):
"""Fetch and store lyrics for a single item. If ``write``, then the
- lyrics will also be written to the file itself. The ``loglevel``
- parameter controls the visibility of the function's status log
- messages.
+ lyrics will also be written to the file itself.
"""
# Skip if the item already has lyrics.
if not force and item.lyrics:
- log.log(loglevel, u'lyrics already present: {0} - {1}'
- .format(item.artist, item.title))
+ self._log.info('lyrics already present: {0}', item)
return
lyrics = None
@@ -512,22 +920,26 @@ class LyricsPlugin(plugins.BeetsPlugin):
if any(lyrics):
break
- lyrics = u"\n\n---\n\n".join([l for l in lyrics if l])
+ lyrics = "\n\n---\n\n".join([l for l in lyrics if l])
if lyrics:
- log.log(loglevel, u'fetched lyrics: {0} - {1}'
- .format(item.artist, item.title))
+ self._log.info('fetched lyrics: {0}', item)
+ if HAS_LANGDETECT and self.config['bing_client_secret'].get():
+ lang_from = langdetect.detect(lyrics)
+ if self.config['bing_lang_to'].get() != lang_from and (
+ not self.config['bing_lang_from'] or (
+ lang_from in self.config[
+ 'bing_lang_from'].as_str_seq())):
+ lyrics = self.append_translation(
+ lyrics, self.config['bing_lang_to'])
else:
- log.log(loglevel, u'lyrics not found: {0} - {1}'
- .format(item.artist, item.title))
+ self._log.info('lyrics not found: {0}', item)
fallback = self.config['fallback'].get()
if fallback:
lyrics = fallback
else:
return
-
item.lyrics = lyrics
-
if write:
item.try_write()
item.store()
@@ -537,8 +949,36 @@ class LyricsPlugin(plugins.BeetsPlugin):
None if no lyrics were found.
"""
for backend in self.backends:
- lyrics = backend(artist, title)
+ lyrics = backend.fetch(artist, title)
if lyrics:
- log.debug(u'got lyrics from backend: {0}'
- .format(backend.__name__))
+ self._log.debug('got lyrics from backend: {0}',
+ backend.__class__.__name__)
return _scrape_strip_cruft(lyrics, True)
+
+ def append_translation(self, text, to_lang):
+ from xml.etree import ElementTree
+
+ if not self.bing_auth_token:
+ self.bing_auth_token = self.get_bing_access_token()
+ if self.bing_auth_token:
+ # Extract unique lines to limit API request size per song
+ text_lines = set(text.split('\n'))
+ url = ('https://api.microsofttranslator.com/v2/Http.svc/'
+ 'Translate?text=%s&to=%s' % ('|'.join(text_lines), to_lang))
+ r = requests.get(url,
+ headers={"Authorization ": self.bing_auth_token})
+ if r.status_code != 200:
+ self._log.debug('translation API error {}: {}', r.status_code,
+ r.text)
+ if 'token has expired' in r.text:
+ self.bing_auth_token = None
+ return self.append_translation(text, to_lang)
+ return text
+ lines_translated = ElementTree.fromstring(
+ r.text.encode('utf-8')).text
+ # Use a translation mapping dict to build resulting lyrics
+ translations = dict(zip(text_lines, lines_translated.split('|')))
+ result = ''
+ for line in text.split('\n'):
+ result += '{} / {}\n'.format(line, translations[line])
+ return result
diff --git a/lib/beetsplug/mbcollection.py b/lib/beetsplug/mbcollection.py
new file mode 100644
index 00000000..f4a0d161
--- /dev/null
+++ b/lib/beetsplug/mbcollection.py
@@ -0,0 +1,164 @@
+# This file is part of beets.
+# Copyright (c) 2011, Jeffrey Aylesworth
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+
+from beets.plugins import BeetsPlugin
+from beets.ui import Subcommand
+from beets import ui
+from beets import config
+import musicbrainzngs
+
+import re
+
+SUBMISSION_CHUNK_SIZE = 200
+FETCH_CHUNK_SIZE = 100
+UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$'
+
+
+def mb_call(func, *args, **kwargs):
+ """Call a MusicBrainz API function and catch exceptions.
+ """
+ try:
+ return func(*args, **kwargs)
+ except musicbrainzngs.AuthenticationError:
+ raise ui.UserError('authentication with MusicBrainz failed')
+ except (musicbrainzngs.ResponseError, musicbrainzngs.NetworkError) as exc:
+ raise ui.UserError(f'MusicBrainz API error: {exc}')
+ except musicbrainzngs.UsageError:
+ raise ui.UserError('MusicBrainz credentials missing')
+
+
+def submit_albums(collection_id, release_ids):
+ """Add all of the release IDs to the indicated collection. Multiple
+ requests are made if there are many release IDs to submit.
+ """
+ for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE):
+ chunk = release_ids[i:i + SUBMISSION_CHUNK_SIZE]
+ mb_call(
+ musicbrainzngs.add_releases_to_collection,
+ collection_id, chunk
+ )
+
+
+class MusicBrainzCollectionPlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+ config['musicbrainz']['pass'].redact = True
+ musicbrainzngs.auth(
+ config['musicbrainz']['user'].as_str(),
+ config['musicbrainz']['pass'].as_str(),
+ )
+ self.config.add({
+ 'auto': False,
+ 'collection': '',
+ 'remove': False,
+ })
+ if self.config['auto']:
+ self.import_stages = [self.imported]
+
+ def _get_collection(self):
+ collections = mb_call(musicbrainzngs.get_collections)
+ if not collections['collection-list']:
+ raise ui.UserError('no collections exist for user')
+
+ # Get all collection IDs, avoiding event collections
+ collection_ids = [x['id'] for x in collections['collection-list']]
+ if not collection_ids:
+ raise ui.UserError('No collection found.')
+
+ # Check that the collection exists so we can present a nice error
+ collection = self.config['collection'].as_str()
+ if collection:
+ if collection not in collection_ids:
+ raise ui.UserError('invalid collection ID: {}'
+ .format(collection))
+ return collection
+
+ # No specified collection. Just return the first collection ID
+ return collection_ids[0]
+
+ def _get_albums_in_collection(self, id):
+ def _fetch(offset):
+ res = mb_call(
+ musicbrainzngs.get_releases_in_collection,
+ id,
+ limit=FETCH_CHUNK_SIZE,
+ offset=offset
+ )['collection']
+ return [x['id'] for x in res['release-list']], res['release-count']
+
+ offset = 0
+ albums_in_collection, release_count = _fetch(offset)
+ for i in range(0, release_count, FETCH_CHUNK_SIZE):
+ albums_in_collection += _fetch(offset)[0]
+ offset += FETCH_CHUNK_SIZE
+
+ return albums_in_collection
+
+ def commands(self):
+ mbupdate = Subcommand('mbupdate',
+ help='Update MusicBrainz collection')
+ mbupdate.parser.add_option('-r', '--remove',
+ action='store_true',
+ default=None,
+ dest='remove',
+ help='Remove albums not in beets library')
+ mbupdate.func = self.update_collection
+ return [mbupdate]
+
+ def remove_missing(self, collection_id, lib_albums):
+ lib_ids = {x.mb_albumid for x in lib_albums}
+ albums_in_collection = self._get_albums_in_collection(collection_id)
+ remove_me = list(set(albums_in_collection) - lib_ids)
+ for i in range(0, len(remove_me), FETCH_CHUNK_SIZE):
+ chunk = remove_me[i:i + FETCH_CHUNK_SIZE]
+ mb_call(
+ musicbrainzngs.remove_releases_from_collection,
+ collection_id, chunk
+ )
+
+ def update_collection(self, lib, opts, args):
+ self.config.set_args(opts)
+ remove_missing = self.config['remove'].get(bool)
+ self.update_album_list(lib, lib.albums(), remove_missing)
+
+ def imported(self, session, task):
+ """Add each imported album to the collection.
+ """
+ if task.is_album:
+ self.update_album_list(session.lib, [task.album])
+
+ def update_album_list(self, lib, album_list, remove_missing=False):
+ """Update the MusicBrainz collection from a list of Beets albums
+ """
+ collection_id = self._get_collection()
+
+ # Get a list of all the album IDs.
+ album_ids = []
+ for album in album_list:
+ aid = album.mb_albumid
+ if aid:
+ if re.match(UUID_REGEX, aid):
+ album_ids.append(aid)
+ else:
+ self._log.info('skipping invalid MBID: {0}', aid)
+
+ # Submit to MusicBrainz.
+ self._log.info(
+ 'Updating MusicBrainz collection {0}...', collection_id
+ )
+ submit_albums(collection_id, album_ids)
+ if remove_missing:
+ self.remove_missing(collection_id, lib.albums())
+ self._log.info('...MusicBrainz collection updated.')
diff --git a/lib/beetsplug/mbsubmit.py b/lib/beetsplug/mbsubmit.py
new file mode 100644
index 00000000..3ede0125
--- /dev/null
+++ b/lib/beetsplug/mbsubmit.py
@@ -0,0 +1,57 @@
+# This file is part of beets.
+# Copyright 2016, Adrian Sampson and Diego Moreda.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Aid in submitting information to MusicBrainz.
+
+This plugin allows the user to print track information in a format that is
+parseable by the MusicBrainz track parser [1]. Programmatic submitting is not
+implemented by MusicBrainz yet.
+
+[1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
+"""
+
+
+from beets.autotag import Recommendation
+from beets.plugins import BeetsPlugin
+from beets.ui.commands import PromptChoice
+from beetsplug.info import print_data
+
+
+class MBSubmitPlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+
+ self.config.add({
+ 'format': '$track. $title - $artist ($length)',
+ 'threshold': 'medium',
+ })
+
+ # Validate and store threshold.
+ self.threshold = self.config['threshold'].as_choice({
+ 'none': Recommendation.none,
+ 'low': Recommendation.low,
+ 'medium': Recommendation.medium,
+ 'strong': Recommendation.strong
+ })
+
+ self.register_listener('before_choose_candidate',
+ self.before_choose_candidate_event)
+
+ def before_choose_candidate_event(self, session, task):
+ if task.rec <= self.threshold:
+ return [PromptChoice('p', 'Print tracks', self.print_tracks)]
+
+ def print_tracks(self, session, task):
+ for i in sorted(task.items, key=lambda i: i.track):
+ print_data(None, i, self.config['format'].as_str())
diff --git a/lib/beetsplug/mbsync.py b/lib/beetsplug/mbsync.py
new file mode 100644
index 00000000..26778830
--- /dev/null
+++ b/lib/beetsplug/mbsync.py
@@ -0,0 +1,178 @@
+# This file is part of beets.
+# Copyright 2016, Jakob Schnitzer.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Update library's tags using MusicBrainz.
+"""
+
+from beets.plugins import BeetsPlugin, apply_item_changes
+from beets import autotag, library, ui, util
+from beets.autotag import hooks
+from collections import defaultdict
+
+import re
+
+MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}"
+
+
+class MBSyncPlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+
+ def commands(self):
+ cmd = ui.Subcommand('mbsync',
+ help='update metadata from musicbrainz')
+ cmd.parser.add_option(
+ '-p', '--pretend', action='store_true',
+ help='show all changes but do nothing')
+ cmd.parser.add_option(
+ '-m', '--move', action='store_true', dest='move',
+ help="move files in the library directory")
+ cmd.parser.add_option(
+ '-M', '--nomove', action='store_false', dest='move',
+ help="don't move files in library")
+ cmd.parser.add_option(
+ '-W', '--nowrite', action='store_false',
+ default=None, dest='write',
+ help="don't write updated metadata to files")
+ cmd.parser.add_format_option()
+ cmd.func = self.func
+ return [cmd]
+
+ def func(self, lib, opts, args):
+ """Command handler for the mbsync function.
+ """
+ move = ui.should_move(opts.move)
+ pretend = opts.pretend
+ write = ui.should_write(opts.write)
+ query = ui.decargs(args)
+
+ self.singletons(lib, query, move, pretend, write)
+ self.albums(lib, query, move, pretend, write)
+
+ def singletons(self, lib, query, move, pretend, write):
+ """Retrieve and apply info from the autotagger for items matched by
+ query.
+ """
+ for item in lib.items(query + ['singleton:true']):
+ item_formatted = format(item)
+ if not item.mb_trackid:
+ self._log.info('Skipping singleton with no mb_trackid: {0}',
+ item_formatted)
+ continue
+
+ # Do we have a valid MusicBrainz track ID?
+ if not re.match(MBID_REGEX, item.mb_trackid):
+ self._log.info('Skipping singleton with invalid mb_trackid:' +
+ ' {0}', item_formatted)
+ continue
+
+ # Get the MusicBrainz recording info.
+ track_info = hooks.track_for_mbid(item.mb_trackid)
+ if not track_info:
+ self._log.info('Recording ID not found: {0} for track {0}',
+ item.mb_trackid,
+ item_formatted)
+ continue
+
+ # Apply.
+ with lib.transaction():
+ autotag.apply_item_metadata(item, track_info)
+ apply_item_changes(lib, item, move, pretend, write)
+
+ def albums(self, lib, query, move, pretend, write):
+ """Retrieve and apply info from the autotagger for albums matched by
+ query and their items.
+ """
+ # Process matching albums.
+ for a in lib.albums(query):
+ album_formatted = format(a)
+ if not a.mb_albumid:
+ self._log.info('Skipping album with no mb_albumid: {0}',
+ album_formatted)
+ continue
+
+ items = list(a.items())
+
+ # Do we have a valid MusicBrainz album ID?
+ if not re.match(MBID_REGEX, a.mb_albumid):
+ self._log.info('Skipping album with invalid mb_albumid: {0}',
+ album_formatted)
+ continue
+
+ # Get the MusicBrainz album information.
+ album_info = hooks.album_for_mbid(a.mb_albumid)
+ if not album_info:
+ self._log.info('Release ID {0} not found for album {1}',
+ a.mb_albumid,
+ album_formatted)
+ continue
+
+ # Map release track and recording MBIDs to their information.
+ # Recordings can appear multiple times on a release, so each MBID
+ # maps to a list of TrackInfo objects.
+ releasetrack_index = {}
+ track_index = defaultdict(list)
+ for track_info in album_info.tracks:
+ releasetrack_index[track_info.release_track_id] = track_info
+ track_index[track_info.track_id].append(track_info)
+
+ # Construct a track mapping according to MBIDs (release track MBIDs
+ # first, if available, and recording MBIDs otherwise). This should
+ # work for albums that have missing or extra tracks.
+ mapping = {}
+ for item in items:
+ if item.mb_releasetrackid and \
+ item.mb_releasetrackid in releasetrack_index:
+ mapping[item] = releasetrack_index[item.mb_releasetrackid]
+ else:
+ candidates = track_index[item.mb_trackid]
+ if len(candidates) == 1:
+ mapping[item] = candidates[0]
+ else:
+ # If there are multiple copies of a recording, they are
+ # disambiguated using their disc and track number.
+ for c in candidates:
+ if (c.medium_index == item.track and
+ c.medium == item.disc):
+ mapping[item] = c
+ break
+
+ # Apply.
+ self._log.debug('applying changes to {}', album_formatted)
+ with lib.transaction():
+ autotag.apply_metadata(album_info, mapping)
+ changed = False
+ # Find any changed item to apply MusicBrainz changes to album.
+ any_changed_item = items[0]
+ for item in items:
+ item_changed = ui.show_model_changes(item)
+ changed |= item_changed
+ if item_changed:
+ any_changed_item = item
+ apply_item_changes(lib, item, move, pretend, write)
+
+ if not changed:
+ # No change to any item.
+ continue
+
+ if not pretend:
+ # Update album structure to reflect an item in it.
+ for key in library.Album.item_keys:
+ a[key] = any_changed_item[key]
+ a.store()
+
+ # Move album art (and any inconsistent items).
+ if move and lib.directory in util.ancestry(items[0].path):
+ self._log.debug('moving album {0}', album_formatted)
+ a.move()
diff --git a/lib/beetsplug/metasync/__init__.py b/lib/beetsplug/metasync/__init__.py
new file mode 100644
index 00000000..361071fb
--- /dev/null
+++ b/lib/beetsplug/metasync/__init__.py
@@ -0,0 +1,138 @@
+# This file is part of beets.
+# Copyright 2016, Heinz Wiesinger.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Synchronize information from music player libraries
+"""
+
+
+from abc import abstractmethod, ABCMeta
+from importlib import import_module
+
+from confuse import ConfigValueError
+from beets import ui
+from beets.plugins import BeetsPlugin
+
+
+METASYNC_MODULE = 'beetsplug.metasync'
+
+# Dictionary to map the MODULE and the CLASS NAME of meta sources
+SOURCES = {
+ 'amarok': 'Amarok',
+ 'itunes': 'Itunes',
+}
+
+
+class MetaSource(metaclass=ABCMeta):
+ def __init__(self, config, log):
+ self.item_types = {}
+ self.config = config
+ self._log = log
+
+ @abstractmethod
+ def sync_from_source(self, item):
+ pass
+
+
+def load_meta_sources():
+ """ Returns a dictionary of all the MetaSources
+ E.g., {'itunes': Itunes} with isinstance(Itunes, MetaSource) true
+ """
+ meta_sources = {}
+
+ for module_path, class_name in SOURCES.items():
+ module = import_module(METASYNC_MODULE + '.' + module_path)
+ meta_sources[class_name.lower()] = getattr(module, class_name)
+
+ return meta_sources
+
+
+META_SOURCES = load_meta_sources()
+
+
+def load_item_types():
+ """ Returns a dictionary containing the item_types of all the MetaSources
+ """
+ item_types = {}
+ for meta_source in META_SOURCES.values():
+ item_types.update(meta_source.item_types)
+ return item_types
+
+
+class MetaSyncPlugin(BeetsPlugin):
+
+ item_types = load_item_types()
+
+ def __init__(self):
+ super().__init__()
+
+ def commands(self):
+ cmd = ui.Subcommand('metasync',
+ help='update metadata from music player libraries')
+ cmd.parser.add_option('-p', '--pretend', action='store_true',
+ help='show all changes but do nothing')
+ cmd.parser.add_option('-s', '--source', default=[],
+ action='append', dest='sources',
+ help='comma-separated list of sources to sync')
+ cmd.parser.add_format_option()
+ cmd.func = self.func
+ return [cmd]
+
+ def func(self, lib, opts, args):
+ """Command handler for the metasync function.
+ """
+ pretend = opts.pretend
+ query = ui.decargs(args)
+
+ sources = []
+ for source in opts.sources:
+ sources.extend(source.split(','))
+
+ sources = sources or self.config['source'].as_str_seq()
+
+ meta_source_instances = {}
+ items = lib.items(query)
+
+ # Avoid needlessly instantiating meta sources (can be expensive)
+ if not items:
+ self._log.info('No items found matching query')
+ return
+
+ # Instantiate the meta sources
+ for player in sources:
+ try:
+ cls = META_SOURCES[player]
+ except KeyError:
+ self._log.error('Unknown metadata source \'{}\''.format(
+ player))
+
+ try:
+ meta_source_instances[player] = cls(self.config, self._log)
+ except (ImportError, ConfigValueError) as e:
+ self._log.error('Failed to instantiate metadata source '
+ '\'{}\': {}'.format(player, e))
+
+ # Avoid needlessly iterating over items
+ if not meta_source_instances:
+ self._log.error('No valid metadata sources found')
+ return
+
+ # Sync the items with all of the meta sources
+ for item in items:
+ for meta_source in meta_source_instances.values():
+ meta_source.sync_from_source(item)
+
+ changed = ui.show_model_changes(item)
+
+ if changed and not pretend:
+ item.store()
diff --git a/lib/beetsplug/metasync/amarok.py b/lib/beetsplug/metasync/amarok.py
new file mode 100644
index 00000000..a49eecc3
--- /dev/null
+++ b/lib/beetsplug/metasync/amarok.py
@@ -0,0 +1,110 @@
+# This file is part of beets.
+# Copyright 2016, Heinz Wiesinger.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Synchronize information from amarok's library via dbus
+"""
+
+
+from os.path import basename
+from datetime import datetime
+from time import mktime
+from xml.sax.saxutils import quoteattr
+
+from beets.util import displayable_path
+from beets.dbcore import types
+from beets.library import DateType
+from beetsplug.metasync import MetaSource
+
+
+def import_dbus():
+ try:
+ return __import__('dbus')
+ except ImportError:
+ return None
+
+dbus = import_dbus()
+
+
+class Amarok(MetaSource):
+
+ item_types = {
+ 'amarok_rating': types.INTEGER,
+ 'amarok_score': types.FLOAT,
+ 'amarok_uid': types.STRING,
+ 'amarok_playcount': types.INTEGER,
+ 'amarok_firstplayed': DateType(),
+ 'amarok_lastplayed': DateType(),
+ }
+
+ query_xml = ' \
+ \
+ \
+ \
+ '
+
+ def __init__(self, config, log):
+ super().__init__(config, log)
+
+ if not dbus:
+ raise ImportError('failed to import dbus')
+
+ self.collection = \
+ dbus.SessionBus().get_object('org.kde.amarok', '/Collection')
+
+ def sync_from_source(self, item):
+ path = displayable_path(item.path)
+
+ # amarok unfortunately doesn't allow searching for the full path, only
+ # for the patch relative to the mount point. But the full path is part
+ # of the result set. So query for the filename and then try to match
+ # the correct item from the results we get back
+ results = self.collection.Query(
+ self.query_xml % quoteattr(basename(path))
+ )
+ for result in results:
+ if result['xesam:url'] != path:
+ continue
+
+ item.amarok_rating = result['xesam:userRating']
+ item.amarok_score = result['xesam:autoRating']
+ item.amarok_playcount = result['xesam:useCount']
+ item.amarok_uid = \
+ result['xesam:id'].replace('amarok-sqltrackuid://', '')
+
+ if result['xesam:firstUsed'][0][0] != 0:
+ # These dates are stored as timestamps in amarok's db, but
+ # exposed over dbus as fixed integers in the current timezone.
+ first_played = datetime(
+ result['xesam:firstUsed'][0][0],
+ result['xesam:firstUsed'][0][1],
+ result['xesam:firstUsed'][0][2],
+ result['xesam:firstUsed'][1][0],
+ result['xesam:firstUsed'][1][1],
+ result['xesam:firstUsed'][1][2]
+ )
+
+ if result['xesam:lastUsed'][0][0] != 0:
+ last_played = datetime(
+ result['xesam:lastUsed'][0][0],
+ result['xesam:lastUsed'][0][1],
+ result['xesam:lastUsed'][0][2],
+ result['xesam:lastUsed'][1][0],
+ result['xesam:lastUsed'][1][1],
+ result['xesam:lastUsed'][1][2]
+ )
+ else:
+ last_played = first_played
+
+ item.amarok_firstplayed = mktime(first_played.timetuple())
+ item.amarok_lastplayed = mktime(last_played.timetuple())
diff --git a/lib/beetsplug/metasync/itunes.py b/lib/beetsplug/metasync/itunes.py
new file mode 100644
index 00000000..e50a5713
--- /dev/null
+++ b/lib/beetsplug/metasync/itunes.py
@@ -0,0 +1,125 @@
+# This file is part of beets.
+# Copyright 2016, Tom Jaspers.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Synchronize information from iTunes's library
+"""
+
+
+from contextlib import contextmanager
+import os
+import shutil
+import tempfile
+import plistlib
+
+from urllib.parse import urlparse, unquote
+from time import mktime
+
+from beets import util
+from beets.dbcore import types
+from beets.library import DateType
+from confuse import ConfigValueError
+from beetsplug.metasync import MetaSource
+
+
+@contextmanager
+def create_temporary_copy(path):
+ temp_dir = tempfile.mkdtemp()
+ temp_path = os.path.join(temp_dir, 'temp_itunes_lib')
+ shutil.copyfile(path, temp_path)
+ try:
+ yield temp_path
+ finally:
+ shutil.rmtree(temp_dir)
+
+
+def _norm_itunes_path(path):
+ # Itunes prepends the location with 'file://' on posix systems,
+ # and with 'file://localhost/' on Windows systems.
+ # The actual path to the file is always saved as posix form
+ # E.g., 'file://Users/Music/bar' or 'file://localhost/G:/Music/bar'
+
+ # The entire path will also be capitalized (e.g., '/Music/Alt-J')
+ # Note that this means the path will always have a leading separator,
+ # which is unwanted in the case of Windows systems.
+ # E.g., '\\G:\\Music\\bar' needs to be stripped to 'G:\\Music\\bar'
+
+ return util.bytestring_path(os.path.normpath(
+ unquote(urlparse(path).path)).lstrip('\\')).lower()
+
+
+class Itunes(MetaSource):
+
+ item_types = {
+ 'itunes_rating': types.INTEGER, # 0..100 scale
+ 'itunes_playcount': types.INTEGER,
+ 'itunes_skipcount': types.INTEGER,
+ 'itunes_lastplayed': DateType(),
+ 'itunes_lastskipped': DateType(),
+ 'itunes_dateadded': DateType(),
+ }
+
+ def __init__(self, config, log):
+ super().__init__(config, log)
+
+ config.add({'itunes': {
+ 'library': '~/Music/iTunes/iTunes Library.xml'
+ }})
+
+ # Load the iTunes library, which has to be the .xml one (not the .itl)
+ library_path = config['itunes']['library'].as_filename()
+
+ try:
+ self._log.debug(
+ f'loading iTunes library from {library_path}')
+ with create_temporary_copy(library_path) as library_copy:
+ with open(library_copy, 'rb') as library_copy_f:
+ raw_library = plistlib.load(library_copy_f)
+ except OSError as e:
+ raise ConfigValueError('invalid iTunes library: ' + e.strerror)
+ except Exception:
+ # It's likely the user configured their '.itl' library (<> xml)
+ if os.path.splitext(library_path)[1].lower() != '.xml':
+ hint = ': please ensure that the configured path' \
+ ' points to the .XML library'
+ else:
+ hint = ''
+ raise ConfigValueError('invalid iTunes library' + hint)
+
+ # Make the iTunes library queryable using the path
+ self.collection = {_norm_itunes_path(track['Location']): track
+ for track in raw_library['Tracks'].values()
+ if 'Location' in track}
+
+ def sync_from_source(self, item):
+ result = self.collection.get(util.bytestring_path(item.path).lower())
+
+ if not result:
+ self._log.warning(f'no iTunes match found for {item}')
+ return
+
+ item.itunes_rating = result.get('Rating')
+ item.itunes_playcount = result.get('Play Count')
+ item.itunes_skipcount = result.get('Skip Count')
+
+ if result.get('Play Date UTC'):
+ item.itunes_lastplayed = mktime(
+ result.get('Play Date UTC').timetuple())
+
+ if result.get('Skip Date'):
+ item.itunes_lastskipped = mktime(
+ result.get('Skip Date').timetuple())
+
+ if result.get('Date Added'):
+ item.itunes_dateadded = mktime(
+ result.get('Date Added').timetuple())
diff --git a/lib/beetsplug/missing.py b/lib/beetsplug/missing.py
new file mode 100644
index 00000000..771978c1
--- /dev/null
+++ b/lib/beetsplug/missing.py
@@ -0,0 +1,226 @@
+# This file is part of beets.
+# Copyright 2016, Pedro Silva.
+# Copyright 2017, Quentin Young.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""List missing tracks.
+"""
+
+import musicbrainzngs
+
+from musicbrainzngs.musicbrainz import MusicBrainzError
+from collections import defaultdict
+from beets.autotag import hooks
+from beets.library import Item
+from beets.plugins import BeetsPlugin
+from beets.ui import decargs, print_, Subcommand
+from beets import config
+from beets.dbcore import types
+
+
+def _missing_count(album):
+ """Return number of missing items in `album`.
+ """
+ return (album.albumtotal or 0) - len(album.items())
+
+
+def _item(track_info, album_info, album_id):
+ """Build and return `item` from `track_info` and `album info`
+ objects. `item` is missing what fields cannot be obtained from
+ MusicBrainz alone (encoder, rg_track_gain, rg_track_peak,
+ rg_album_gain, rg_album_peak, original_year, original_month,
+ original_day, length, bitrate, format, samplerate, bitdepth,
+ channels, mtime.)
+ """
+ t = track_info
+ a = album_info
+
+ return Item(**{
+ 'album_id': album_id,
+ 'album': a.album,
+ 'albumartist': a.artist,
+ 'albumartist_credit': a.artist_credit,
+ 'albumartist_sort': a.artist_sort,
+ 'albumdisambig': a.albumdisambig,
+ 'albumstatus': a.albumstatus,
+ 'albumtype': a.albumtype,
+ 'artist': t.artist,
+ 'artist_credit': t.artist_credit,
+ 'artist_sort': t.artist_sort,
+ 'asin': a.asin,
+ 'catalognum': a.catalognum,
+ 'comp': a.va,
+ 'country': a.country,
+ 'day': a.day,
+ 'disc': t.medium,
+ 'disctitle': t.disctitle,
+ 'disctotal': a.mediums,
+ 'label': a.label,
+ 'language': a.language,
+ 'length': t.length,
+ 'mb_albumid': a.album_id,
+ 'mb_artistid': t.artist_id,
+ 'mb_releasegroupid': a.releasegroup_id,
+ 'mb_trackid': t.track_id,
+ 'media': t.media,
+ 'month': a.month,
+ 'script': a.script,
+ 'title': t.title,
+ 'track': t.index,
+ 'tracktotal': len(a.tracks),
+ 'year': a.year,
+ })
+
+
+class MissingPlugin(BeetsPlugin):
+ """List missing tracks
+ """
+
+ album_types = {
+ 'missing': types.INTEGER,
+ }
+
+ def __init__(self):
+ super().__init__()
+
+ self.config.add({
+ 'count': False,
+ 'total': False,
+ 'album': False,
+ })
+
+ self.album_template_fields['missing'] = _missing_count
+
+ self._command = Subcommand('missing',
+ help=__doc__,
+ aliases=['miss'])
+ self._command.parser.add_option(
+ '-c', '--count', dest='count', action='store_true',
+ help='count missing tracks per album')
+ self._command.parser.add_option(
+ '-t', '--total', dest='total', action='store_true',
+ help='count total of missing tracks')
+ self._command.parser.add_option(
+ '-a', '--album', dest='album', action='store_true',
+ help='show missing albums for artist instead of tracks')
+ self._command.parser.add_format_option()
+
+ def commands(self):
+ def _miss(lib, opts, args):
+ self.config.set_args(opts)
+ albms = self.config['album'].get()
+
+ helper = self._missing_albums if albms else self._missing_tracks
+ helper(lib, decargs(args))
+
+ self._command.func = _miss
+ return [self._command]
+
+ def _missing_tracks(self, lib, query):
+ """Print a listing of tracks missing from each album in the library
+ matching query.
+ """
+ albums = lib.albums(query)
+
+ count = self.config['count'].get()
+ total = self.config['total'].get()
+ fmt = config['format_album' if count else 'format_item'].get()
+
+ if total:
+ print(sum([_missing_count(a) for a in albums]))
+ return
+
+ # Default format string for count mode.
+ if count:
+ fmt += ': $missing'
+
+ for album in albums:
+ if count:
+ if _missing_count(album):
+ print_(format(album, fmt))
+
+ else:
+ for item in self._missing(album):
+ print_(format(item, fmt))
+
+ def _missing_albums(self, lib, query):
+ """Print a listing of albums missing from each artist in the library
+ matching query.
+ """
+ total = self.config['total'].get()
+
+ albums = lib.albums(query)
+ # build dict mapping artist to list of their albums in library
+ albums_by_artist = defaultdict(list)
+ for alb in albums:
+ artist = (alb['albumartist'], alb['mb_albumartistid'])
+ albums_by_artist[artist].append(alb)
+
+ total_missing = 0
+
+ # build dict mapping artist to list of all albums
+ for artist, albums in albums_by_artist.items():
+ if artist[1] is None or artist[1] == "":
+ albs_no_mbid = ["'" + a['album'] + "'" for a in albums]
+ self._log.info(
+ "No musicbrainz ID for artist '{}' found in album(s) {}; "
+ "skipping", artist[0], ", ".join(albs_no_mbid)
+ )
+ continue
+
+ try:
+ resp = musicbrainzngs.browse_release_groups(artist=artist[1])
+ release_groups = resp['release-group-list']
+ except MusicBrainzError as err:
+ self._log.info(
+ "Couldn't fetch info for artist '{}' ({}) - '{}'",
+ artist[0], artist[1], err
+ )
+ continue
+
+ missing = []
+ present = []
+ for rg in release_groups:
+ missing.append(rg)
+ for alb in albums:
+ if alb['mb_releasegroupid'] == rg['id']:
+ missing.remove(rg)
+ present.append(rg)
+ break
+
+ total_missing += len(missing)
+ if total:
+ continue
+
+ missing_titles = {rg['title'] for rg in missing}
+
+ for release_title in missing_titles:
+ print_("{} - {}".format(artist[0], release_title))
+
+ if total:
+ print(total_missing)
+
+ def _missing(self, album):
+ """Query MusicBrainz to determine items missing from `album`.
+ """
+ item_mbids = [x.mb_trackid for x in album.items()]
+ if len(list(album.items())) < album.albumtotal:
+ # fetch missing items
+ # TODO: Implement caching that without breaking other stuff
+ album_info = hooks.album_for_mbid(album.mb_albumid)
+ for track_info in getattr(album_info, 'tracks', []):
+ if track_info.track_id not in item_mbids:
+ item = _item(track_info, album_info, album.id)
+ self._log.debug('track {0} in album {1}',
+ track_info.track_id, album_info.album_id)
+ yield item
diff --git a/lib/beetsplug/mpdstats.py b/lib/beetsplug/mpdstats.py
new file mode 100644
index 00000000..96291cf4
--- /dev/null
+++ b/lib/beetsplug/mpdstats.py
@@ -0,0 +1,380 @@
+# This file is part of beets.
+# Copyright 2016, Peter Schnebel and Johann Klähn.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+
+import mpd
+import time
+import os
+
+from beets import ui
+from beets import config
+from beets import plugins
+from beets import library
+from beets.util import displayable_path
+from beets.dbcore import types
+
+# If we lose the connection, how many times do we want to retry and how
+# much time should we wait between retries?
+RETRIES = 10
+RETRY_INTERVAL = 5
+
+
+mpd_config = config['mpd']
+
+
+def is_url(path):
+ """Try to determine if the path is an URL.
+ """
+ if isinstance(path, bytes): # if it's bytes, then it's a path
+ return False
+ return path.split('://', 1)[0] in ['http', 'https']
+
+
+class MPDClientWrapper:
+ def __init__(self, log):
+ self._log = log
+
+ self.music_directory = mpd_config['music_directory'].as_str()
+ self.strip_path = mpd_config['strip_path'].as_str()
+
+ # Ensure strip_path end with '/'
+ if not self.strip_path.endswith('/'):
+ self.strip_path += '/'
+
+ self._log.debug('music_directory: {0}', self.music_directory)
+ self._log.debug('strip_path: {0}', self.strip_path)
+
+ self.client = mpd.MPDClient()
+
+ def connect(self):
+ """Connect to the MPD.
+ """
+ host = mpd_config['host'].as_str()
+ port = mpd_config['port'].get(int)
+
+ if host[0] in ['/', '~']:
+ host = os.path.expanduser(host)
+
+ self._log.info('connecting to {0}:{1}', host, port)
+ try:
+ self.client.connect(host, port)
+ except OSError as e:
+ raise ui.UserError(f'could not connect to MPD: {e}')
+
+ password = mpd_config['password'].as_str()
+ if password:
+ try:
+ self.client.password(password)
+ except mpd.CommandError as e:
+ raise ui.UserError(
+ f'could not authenticate to MPD: {e}'
+ )
+
+ def disconnect(self):
+ """Disconnect from the MPD.
+ """
+ self.client.close()
+ self.client.disconnect()
+
+ def get(self, command, retries=RETRIES):
+ """Wrapper for requests to the MPD server. Tries to re-connect if the
+ connection was lost (f.ex. during MPD's library refresh).
+ """
+ try:
+ return getattr(self.client, command)()
+ except (OSError, mpd.ConnectionError) as err:
+ self._log.error('{0}', err)
+
+ if retries <= 0:
+ # if we exited without breaking, we couldn't reconnect in time :(
+ raise ui.UserError('communication with MPD server failed')
+
+ time.sleep(RETRY_INTERVAL)
+
+ try:
+ self.disconnect()
+ except mpd.ConnectionError:
+ pass
+
+ self.connect()
+ return self.get(command, retries=retries - 1)
+
+ def currentsong(self):
+ """Return the path to the currently playing song, along with its
+ songid. Prefixes paths with the music_directory, to get the absolute
+ path.
+ In some cases, we need to remove the local path from MPD server,
+ we replace 'strip_path' with ''.
+ `strip_path` defaults to ''.
+ """
+ result = None
+ entry = self.get('currentsong')
+ if 'file' in entry:
+ if not is_url(entry['file']):
+ file = entry['file']
+ if file.startswith(self.strip_path):
+ file = file[len(self.strip_path):]
+ result = os.path.join(self.music_directory, file)
+ else:
+ result = entry['file']
+ self._log.debug('returning: {0}', result)
+ return result, entry.get('id')
+
+ def status(self):
+ """Return the current status of the MPD.
+ """
+ return self.get('status')
+
+ def events(self):
+ """Return list of events. This may block a long time while waiting for
+ an answer from MPD.
+ """
+ return self.get('idle')
+
+
+class MPDStats:
+ def __init__(self, lib, log):
+ self.lib = lib
+ self._log = log
+
+ self.do_rating = mpd_config['rating'].get(bool)
+ self.rating_mix = mpd_config['rating_mix'].get(float)
+ self.time_threshold = 10.0 # TODO: maybe add config option?
+
+ self.now_playing = None
+ self.mpd = MPDClientWrapper(log)
+
+ def rating(self, play_count, skip_count, rating, skipped):
+ """Calculate a new rating for a song based on play count, skip count,
+ old rating and the fact if it was skipped or not.
+ """
+ if skipped:
+ rolling = (rating - rating / 2.0)
+ else:
+ rolling = (rating + (1.0 - rating) / 2.0)
+ stable = (play_count + 1.0) / (play_count + skip_count + 2.0)
+ return (self.rating_mix * stable +
+ (1.0 - self.rating_mix) * rolling)
+
+ def get_item(self, path):
+ """Return the beets item related to path.
+ """
+ query = library.PathQuery('path', path)
+ item = self.lib.items(query).get()
+ if item:
+ return item
+ else:
+ self._log.info('item not found: {0}', displayable_path(path))
+
+ def update_item(self, item, attribute, value=None, increment=None):
+ """Update the beets item. Set attribute to value or increment the value
+ of attribute. If the increment argument is used the value is cast to
+ the corresponding type.
+ """
+ if item is None:
+ return
+
+ if increment is not None:
+ item.load()
+ value = type(increment)(item.get(attribute, 0)) + increment
+
+ if value is not None:
+ item[attribute] = value
+ item.store()
+
+ self._log.debug('updated: {0} = {1} [{2}]',
+ attribute,
+ item[attribute],
+ displayable_path(item.path))
+
+ def update_rating(self, item, skipped):
+ """Update the rating for a beets item. The `item` can either be a
+ beets `Item` or None. If the item is None, nothing changes.
+ """
+ if item is None:
+ return
+
+ item.load()
+ rating = self.rating(
+ int(item.get('play_count', 0)),
+ int(item.get('skip_count', 0)),
+ float(item.get('rating', 0.5)),
+ skipped)
+
+ self.update_item(item, 'rating', rating)
+
+ def handle_song_change(self, song):
+ """Determine if a song was skipped or not and update its attributes.
+ To this end the difference between the song's supposed end time
+ and the current time is calculated. If it's greater than a threshold,
+ the song is considered skipped.
+
+ Returns whether the change was manual (skipped previous song or not)
+ """
+ diff = abs(song['remaining'] - (time.time() - song['started']))
+
+ skipped = diff >= self.time_threshold
+
+ if skipped:
+ self.handle_skipped(song)
+ else:
+ self.handle_played(song)
+
+ if self.do_rating:
+ self.update_rating(song['beets_item'], skipped)
+
+ return skipped
+
+ def handle_played(self, song):
+ """Updates the play count of a song.
+ """
+ self.update_item(song['beets_item'], 'play_count', increment=1)
+ self._log.info('played {0}', displayable_path(song['path']))
+
+ def handle_skipped(self, song):
+ """Updates the skip count of a song.
+ """
+ self.update_item(song['beets_item'], 'skip_count', increment=1)
+ self._log.info('skipped {0}', displayable_path(song['path']))
+
+ def on_stop(self, status):
+ self._log.info('stop')
+
+ # if the current song stays the same it means that we stopped on the
+ # current track and should not record a skip.
+ if self.now_playing and self.now_playing['id'] != status.get('songid'):
+ self.handle_song_change(self.now_playing)
+
+ self.now_playing = None
+
+ def on_pause(self, status):
+ self._log.info('pause')
+ self.now_playing = None
+
+ def on_play(self, status):
+
+ path, songid = self.mpd.currentsong()
+
+ if not path:
+ return
+
+ played, duration = map(int, status['time'].split(':', 1))
+ remaining = duration - played
+
+ if self.now_playing:
+ if self.now_playing['path'] != path:
+ self.handle_song_change(self.now_playing)
+ else:
+ # In case we got mpd play event with same song playing
+ # multiple times,
+ # assume low diff means redundant second play event
+ # after natural song start.
+ diff = abs(time.time() - self.now_playing['started'])
+
+ if diff <= self.time_threshold:
+ return
+
+ if self.now_playing['path'] == path and played == 0:
+ self.handle_song_change(self.now_playing)
+
+ if is_url(path):
+ self._log.info('playing stream {0}', displayable_path(path))
+ self.now_playing = None
+ return
+
+ self._log.info('playing {0}', displayable_path(path))
+
+ self.now_playing = {
+ 'started': time.time(),
+ 'remaining': remaining,
+ 'path': path,
+ 'id': songid,
+ 'beets_item': self.get_item(path),
+ }
+
+ self.update_item(self.now_playing['beets_item'],
+ 'last_played', value=int(time.time()))
+
+ def run(self):
+ self.mpd.connect()
+ events = ['player']
+
+ while True:
+ if 'player' in events:
+ status = self.mpd.status()
+
+ handler = getattr(self, 'on_' + status['state'], None)
+
+ if handler:
+ handler(status)
+ else:
+ self._log.debug('unhandled status "{0}"', status)
+
+ events = self.mpd.events()
+
+
+class MPDStatsPlugin(plugins.BeetsPlugin):
+
+ item_types = {
+ 'play_count': types.INTEGER,
+ 'skip_count': types.INTEGER,
+ 'last_played': library.DateType(),
+ 'rating': types.FLOAT,
+ }
+
+ def __init__(self):
+ super().__init__()
+ mpd_config.add({
+ 'music_directory': config['directory'].as_filename(),
+ 'strip_path': '',
+ 'rating': True,
+ 'rating_mix': 0.75,
+ 'host': os.environ.get('MPD_HOST', 'localhost'),
+ 'port': int(os.environ.get('MPD_PORT', 6600)),
+ 'password': '',
+ })
+ mpd_config['password'].redact = True
+
+ def commands(self):
+ cmd = ui.Subcommand(
+ 'mpdstats',
+ help='run a MPD client to gather play statistics')
+ cmd.parser.add_option(
+ '--host', dest='host', type='string',
+ help='set the hostname of the server to connect to')
+ cmd.parser.add_option(
+ '--port', dest='port', type='int',
+ help='set the port of the MPD server to connect to')
+ cmd.parser.add_option(
+ '--password', dest='password', type='string',
+ help='set the password of the MPD server to connect to')
+
+ def func(lib, opts, args):
+ mpd_config.set_args(opts)
+
+ # Overrides for MPD settings.
+ if opts.host:
+ mpd_config['host'] = opts.host.decode('utf-8')
+ if opts.port:
+ mpd_config['host'] = int(opts.port)
+ if opts.password:
+ mpd_config['password'] = opts.password.decode('utf-8')
+
+ try:
+ MPDStats(lib, self._log).run()
+ except KeyboardInterrupt:
+ pass
+
+ cmd.func = func
+ return [cmd]
diff --git a/lib/beetsplug/mpdupdate.py b/lib/beetsplug/mpdupdate.py
new file mode 100644
index 00000000..e5264e18
--- /dev/null
+++ b/lib/beetsplug/mpdupdate.py
@@ -0,0 +1,126 @@
+# This file is part of beets.
+# Copyright 2016, Adrian Sampson.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Updates an MPD index whenever the library is changed.
+
+Put something like the following in your config.yaml to configure:
+ mpd:
+ host: localhost
+ port: 6600
+ password: seekrit
+"""
+
+from beets.plugins import BeetsPlugin
+import os
+import socket
+from beets import config
+
+
+# No need to introduce a dependency on an MPD library for such a
+# simple use case. Here's a simple socket abstraction to make things
+# easier.
+class BufferedSocket:
+ """Socket abstraction that allows reading by line."""
+ def __init__(self, host, port, sep=b'\n'):
+ if host[0] in ['/', '~']:
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self.sock.connect(os.path.expanduser(host))
+ else:
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sock.connect((host, port))
+ self.buf = b''
+ self.sep = sep
+
+ def readline(self):
+ while self.sep not in self.buf:
+ data = self.sock.recv(1024)
+ if not data:
+ break
+ self.buf += data
+ if self.sep in self.buf:
+ res, self.buf = self.buf.split(self.sep, 1)
+ return res + self.sep
+ else:
+ return b''
+
+ def send(self, data):
+ self.sock.send(data)
+
+ def close(self):
+ self.sock.close()
+
+
+class MPDUpdatePlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+ config['mpd'].add({
+ 'host': os.environ.get('MPD_HOST', 'localhost'),
+ 'port': int(os.environ.get('MPD_PORT', 6600)),
+ 'password': '',
+ })
+ config['mpd']['password'].redact = True
+
+ # For backwards compatibility, use any values from the
+ # plugin-specific "mpdupdate" section.
+ for key in config['mpd'].keys():
+ if self.config[key].exists():
+ config['mpd'][key] = self.config[key].get()
+
+ self.register_listener('database_change', self.db_change)
+
+ def db_change(self, lib, model):
+ self.register_listener('cli_exit', self.update)
+
+ def update(self, lib):
+ self.update_mpd(
+ config['mpd']['host'].as_str(),
+ config['mpd']['port'].get(int),
+ config['mpd']['password'].as_str(),
+ )
+
+ def update_mpd(self, host='localhost', port=6600, password=None):
+ """Sends the "update" command to the MPD server indicated,
+ possibly authenticating with a password first.
+ """
+ self._log.info('Updating MPD database...')
+
+ try:
+ s = BufferedSocket(host, port)
+ except OSError as e:
+ self._log.warning('MPD connection failed: {0}',
+ str(e.strerror))
+ return
+
+ resp = s.readline()
+ if b'OK MPD' not in resp:
+ self._log.warning('MPD connection failed: {0!r}', resp)
+ return
+
+ if password:
+ s.send(b'password "%s"\n' % password.encode('utf8'))
+ resp = s.readline()
+ if b'OK' not in resp:
+ self._log.warning('Authentication failed: {0!r}', resp)
+ s.send(b'close\n')
+ s.close()
+ return
+
+ s.send(b'update\n')
+ resp = s.readline()
+ if b'updating_db' not in resp:
+ self._log.warning('Update failed: {0!r}', resp)
+
+ s.send(b'close\n')
+ s.close()
+ self._log.info('Database updated.')
diff --git a/lib/beetsplug/parentwork.py b/lib/beetsplug/parentwork.py
new file mode 100644
index 00000000..75307b8f
--- /dev/null
+++ b/lib/beetsplug/parentwork.py
@@ -0,0 +1,211 @@
+# This file is part of beets.
+# Copyright 2017, Dorian Soergel.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Gets parent work, its disambiguation and id, composer, composer sort name
+and work composition date
+"""
+
+
+from beets import ui
+from beets.plugins import BeetsPlugin
+
+import musicbrainzngs
+
+
+def direct_parent_id(mb_workid, work_date=None):
+ """Given a Musicbrainz work id, find the id one of the works the work is
+ part of and the first composition date it encounters.
+ """
+ work_info = musicbrainzngs.get_work_by_id(mb_workid,
+ includes=["work-rels",
+ "artist-rels"])
+ if 'artist-relation-list' in work_info['work'] and work_date is None:
+ for artist in work_info['work']['artist-relation-list']:
+ if artist['type'] == 'composer':
+ if 'end' in artist.keys():
+ work_date = artist['end']
+
+ if 'work-relation-list' in work_info['work']:
+ for direct_parent in work_info['work']['work-relation-list']:
+ if direct_parent['type'] == 'parts' \
+ and direct_parent.get('direction') == 'backward':
+ direct_id = direct_parent['work']['id']
+ return direct_id, work_date
+ return None, work_date
+
+
+def work_parent_id(mb_workid):
+ """Find the parent work id and composition date of a work given its id.
+ """
+ work_date = None
+ while True:
+ new_mb_workid, work_date = direct_parent_id(mb_workid, work_date)
+ if not new_mb_workid:
+ return mb_workid, work_date
+ mb_workid = new_mb_workid
+ return mb_workid, work_date
+
+
+def find_parentwork_info(mb_workid):
+ """Get the MusicBrainz information dict about a parent work, including
+ the artist relations, and the composition date for a work's parent work.
+ """
+ parent_id, work_date = work_parent_id(mb_workid)
+ work_info = musicbrainzngs.get_work_by_id(parent_id,
+ includes=["artist-rels"])
+ return work_info, work_date
+
+
+class ParentWorkPlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+
+ self.config.add({
+ 'auto': False,
+ 'force': False,
+ })
+
+ if self.config['auto']:
+ self.import_stages = [self.imported]
+
+ def commands(self):
+
+ def func(lib, opts, args):
+ self.config.set_args(opts)
+ force_parent = self.config['force'].get(bool)
+ write = ui.should_write()
+
+ for item in lib.items(ui.decargs(args)):
+ changed = self.find_work(item, force_parent)
+ if changed:
+ item.store()
+ if write:
+ item.try_write()
+ command = ui.Subcommand(
+ 'parentwork',
+ help='fetch parent works, composers and dates')
+
+ command.parser.add_option(
+ '-f', '--force', dest='force',
+ action='store_true', default=None,
+ help='re-fetch when parent work is already present')
+
+ command.func = func
+ return [command]
+
+ def imported(self, session, task):
+ """Import hook for fetching parent works automatically.
+ """
+ force_parent = self.config['force'].get(bool)
+
+ for item in task.imported_items():
+ self.find_work(item, force_parent)
+ item.store()
+
+ def get_info(self, item, work_info):
+ """Given the parent work info dict, fetch parent_composer,
+ parent_composer_sort, parentwork, parentwork_disambig, mb_workid and
+ composer_ids.
+ """
+
+ parent_composer = []
+ parent_composer_sort = []
+ parentwork_info = {}
+
+ composer_exists = False
+ if 'artist-relation-list' in work_info['work']:
+ for artist in work_info['work']['artist-relation-list']:
+ if artist['type'] == 'composer':
+ composer_exists = True
+ parent_composer.append(artist['artist']['name'])
+ parent_composer_sort.append(artist['artist']['sort-name'])
+ if 'end' in artist.keys():
+ parentwork_info["parentwork_date"] = artist['end']
+
+ parentwork_info['parent_composer'] = ', '.join(parent_composer)
+ parentwork_info['parent_composer_sort'] = ', '.join(
+ parent_composer_sort)
+
+ if not composer_exists:
+ self._log.debug(
+ 'no composer for {}; add one at '
+ 'https://musicbrainz.org/work/{}',
+ item, work_info['work']['id'],
+ )
+
+ parentwork_info['parentwork'] = work_info['work']['title']
+ parentwork_info['mb_parentworkid'] = work_info['work']['id']
+
+ if 'disambiguation' in work_info['work']:
+ parentwork_info['parentwork_disambig'] = work_info[
+ 'work']['disambiguation']
+
+ else:
+ parentwork_info['parentwork_disambig'] = None
+
+ return parentwork_info
+
+ def find_work(self, item, force):
+ """Finds the parent work of a recording and populates the tags
+ accordingly.
+
+ The parent work is found recursively, by finding the direct parent
+ repeatedly until there are no more links in the chain. We return the
+ final, topmost work in the chain.
+
+ Namely, the tags parentwork, parentwork_disambig, mb_parentworkid,
+ parent_composer, parent_composer_sort and work_date are populated.
+ """
+
+ if not item.mb_workid:
+ self._log.info('No work for {}, \
+add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
+ return
+
+ hasparent = hasattr(item, 'parentwork')
+ work_changed = True
+ if hasattr(item, 'parentwork_workid_current'):
+ work_changed = item.parentwork_workid_current != item.mb_workid
+ if force or not hasparent or work_changed:
+ try:
+ work_info, work_date = find_parentwork_info(item.mb_workid)
+ except musicbrainzngs.musicbrainz.WebServiceError as e:
+ self._log.debug("error fetching work: {}", e)
+ return
+ parent_info = self.get_info(item, work_info)
+ parent_info['parentwork_workid_current'] = item.mb_workid
+ if 'parent_composer' in parent_info:
+ self._log.debug("Work fetched: {} - {}",
+ parent_info['parentwork'],
+ parent_info['parent_composer'])
+ else:
+ self._log.debug("Work fetched: {} - no parent composer",
+ parent_info['parentwork'])
+
+ elif hasparent:
+ self._log.debug("{}: Work present, skipping", item)
+ return
+
+ # apply all non-null values to the item
+ for key, value in parent_info.items():
+ if value:
+ item[key] = value
+
+ if work_date:
+ item['work_date'] = work_date
+ return ui.show_model_changes(
+ item, fields=['parentwork', 'parentwork_disambig',
+ 'mb_parentworkid', 'parent_composer',
+ 'parent_composer_sort', 'work_date',
+ 'parentwork_workid_current', 'parentwork_date'])
diff --git a/lib/beetsplug/permissions.py b/lib/beetsplug/permissions.py
new file mode 100644
index 00000000..f5aab056
--- /dev/null
+++ b/lib/beetsplug/permissions.py
@@ -0,0 +1,121 @@
+"""Fixes file permissions after the file gets written on import. Put something
+like the following in your config.yaml to configure:
+
+ permissions:
+ file: 644
+ dir: 755
+"""
+import os
+from beets import config, util
+from beets.plugins import BeetsPlugin
+from beets.util import ancestry
+
+
+def convert_perm(perm):
+ """Convert a string to an integer, interpreting the text as octal.
+ Or, if `perm` is an integer, reinterpret it as an octal number that
+ has been "misinterpreted" as decimal.
+ """
+ if isinstance(perm, int):
+ perm = str(perm)
+ return int(perm, 8)
+
+
+def check_permissions(path, permission):
+ """Check whether the file's permissions equal the given vector.
+ Return a boolean.
+ """
+ return oct(os.stat(path).st_mode & 0o777) == oct(permission)
+
+
+def assert_permissions(path, permission, log):
+ """Check whether the file's permissions are as expected, otherwise,
+ log a warning message. Return a boolean indicating the match, like
+ `check_permissions`.
+ """
+ if not check_permissions(util.syspath(path), permission):
+ log.warning(
+ 'could not set permissions on {}',
+ util.displayable_path(path),
+ )
+ log.debug(
+ 'set permissions to {}, but permissions are now {}',
+ permission,
+ os.stat(util.syspath(path)).st_mode & 0o777,
+ )
+
+
+def dirs_in_library(library, item):
+ """Creates a list of ancestor directories in the beets library path.
+ """
+ return [ancestor
+ for ancestor in ancestry(item)
+ if ancestor.startswith(library)][1:]
+
+
+class Permissions(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+
+ # Adding defaults.
+ self.config.add({
+ 'file': '644',
+ 'dir': '755',
+ })
+
+ self.register_listener('item_imported', self.fix)
+ self.register_listener('album_imported', self.fix)
+ self.register_listener('art_set', self.fix_art)
+
+ def fix(self, lib, item=None, album=None):
+ """Fix the permissions for an imported Item or Album.
+ """
+ files = []
+ dirs = set()
+ if item:
+ files.append(item.path)
+ dirs.update(dirs_in_library(lib.directory, item.path))
+ elif album:
+ for album_item in album.items():
+ files.append(album_item.path)
+ dirs.update(dirs_in_library(lib.directory, album_item.path))
+ self.set_permissions(files=files, dirs=dirs)
+
+ def fix_art(self, album):
+ """Fix the permission for Album art file.
+ """
+ if album.artpath:
+ self.set_permissions(files=[album.artpath])
+
+ def set_permissions(self, files=[], dirs=[]):
+ # Get the configured permissions. The user can specify this either a
+ # string (in YAML quotes) or, for convenience, as an integer so the
+ # quotes can be omitted. In the latter case, we need to reinterpret the
+ # integer as octal, not decimal.
+ file_perm = config['permissions']['file'].get()
+ dir_perm = config['permissions']['dir'].get()
+ file_perm = convert_perm(file_perm)
+ dir_perm = convert_perm(dir_perm)
+
+ for path in files:
+ # Changing permissions on the destination file.
+ self._log.debug(
+ 'setting file permissions on {}',
+ util.displayable_path(path),
+ )
+ os.chmod(util.syspath(path), file_perm)
+
+ # Checks if the destination path has the permissions configured.
+ assert_permissions(path, file_perm, self._log)
+
+ # Change permissions for the directories.
+ for path in dirs:
+ # Changing permissions on the destination directory.
+ self._log.debug(
+ 'setting directory permissions on {}',
+ util.displayable_path(path),
+ )
+ os.chmod(util.syspath(path), dir_perm)
+
+ # Checks if the destination path has the permissions configured.
+ assert_permissions(path, dir_perm, self._log)
diff --git a/lib/beetsplug/play.py b/lib/beetsplug/play.py
new file mode 100644
index 00000000..f4233490
--- /dev/null
+++ b/lib/beetsplug/play.py
@@ -0,0 +1,215 @@
+# This file is part of beets.
+# Copyright 2016, David Hamp-Gonsalves
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Send the results of a query to the configured music player as a playlist.
+"""
+
+from beets.plugins import BeetsPlugin
+from beets.ui import Subcommand
+from beets.ui.commands import PromptChoice
+from beets import config
+from beets import ui
+from beets import util
+from os.path import relpath
+from tempfile import NamedTemporaryFile
+import subprocess
+import shlex
+
+# Indicate where arguments should be inserted into the command string.
+# If this is missing, they're placed at the end.
+ARGS_MARKER = '$args'
+
+
+def play(command_str, selection, paths, open_args, log, item_type='track',
+ keep_open=False):
+ """Play items in paths with command_str and optional arguments. If
+ keep_open, return to beets, otherwise exit once command runs.
+ """
+ # Print number of tracks or albums to be played, log command to be run.
+ item_type += 's' if len(selection) > 1 else ''
+ ui.print_('Playing {} {}.'.format(len(selection), item_type))
+ log.debug('executing command: {} {!r}', command_str, open_args)
+
+ try:
+ if keep_open:
+ command = shlex.split(command_str)
+ command = command + open_args
+ subprocess.call(command)
+ else:
+ util.interactive_open(open_args, command_str)
+ except OSError as exc:
+ raise ui.UserError(
+ f"Could not play the query: {exc}")
+
+
+class PlayPlugin(BeetsPlugin):
+
+ def __init__(self):
+ super().__init__()
+
+ config['play'].add({
+ 'command': None,
+ 'use_folders': False,
+ 'relative_to': None,
+ 'raw': False,
+ 'warning_threshold': 100,
+ 'bom': False,
+ })
+
+ self.register_listener('before_choose_candidate',
+ self.before_choose_candidate_listener)
+
+ def commands(self):
+ play_command = Subcommand(
+ 'play',
+ help='send music to a player as a playlist'
+ )
+ play_command.parser.add_album_option()
+ play_command.parser.add_option(
+ '-A', '--args',
+ action='store',
+ help='add additional arguments to the command',
+ )
+ play_command.parser.add_option(
+ '-y', '--yes',
+ action="store_true",
+ help='skip the warning threshold',
+ )
+ play_command.func = self._play_command
+ return [play_command]
+
+ def _play_command(self, lib, opts, args):
+ """The CLI command function for `beet play`. Create a list of paths
+ from query, determine if tracks or albums are to be played.
+ """
+ use_folders = config['play']['use_folders'].get(bool)
+ relative_to = config['play']['relative_to'].get()
+ if relative_to:
+ relative_to = util.normpath(relative_to)
+ # Perform search by album and add folders rather than tracks to
+ # playlist.
+ if opts.album:
+ selection = lib.albums(ui.decargs(args))
+ paths = []
+
+ sort = lib.get_default_album_sort()
+ for album in selection:
+ if use_folders:
+ paths.append(album.item_dir())
+ else:
+ paths.extend(item.path
+ for item in sort.sort(album.items()))
+ item_type = 'album'
+
+ # Perform item query and add tracks to playlist.
+ else:
+ selection = lib.items(ui.decargs(args))
+ paths = [item.path for item in selection]
+ item_type = 'track'
+
+ if relative_to:
+ paths = [relpath(path, relative_to) for path in paths]
+
+ if not selection:
+ ui.print_(ui.colorize('text_warning',
+ f'No {item_type} to play.'))
+ return
+
+ open_args = self._playlist_or_paths(paths)
+ command_str = self._command_str(opts.args)
+
+ # Check if the selection exceeds configured threshold. If True,
+ # cancel, otherwise proceed with play command.
+ if opts.yes or not self._exceeds_threshold(
+ selection, command_str, open_args, item_type):
+ play(command_str, selection, paths, open_args, self._log,
+ item_type)
+
+ def _command_str(self, args=None):
+ """Create a command string from the config command and optional args.
+ """
+ command_str = config['play']['command'].get()
+ if not command_str:
+ return util.open_anything()
+ # Add optional arguments to the player command.
+ if args:
+ if ARGS_MARKER in command_str:
+ return command_str.replace(ARGS_MARKER, args)
+ else:
+ return f"{command_str} {args}"
+ else:
+ # Don't include the marker in the command.
+ return command_str.replace(" " + ARGS_MARKER, "")
+
+ def _playlist_or_paths(self, paths):
+ """Return either the raw paths of items or a playlist of the items.
+ """
+ if config['play']['raw']:
+ return paths
+ else:
+ return [self._create_tmp_playlist(paths)]
+
+ def _exceeds_threshold(self, selection, command_str, open_args,
+ item_type='track'):
+ """Prompt user whether to abort if playlist exceeds threshold. If
+ True, cancel playback. If False, execute play command.
+ """
+ warning_threshold = config['play']['warning_threshold'].get(int)
+
+ # Warn user before playing any huge playlists.
+ if warning_threshold and len(selection) > warning_threshold:
+ if len(selection) > 1:
+ item_type += 's'
+
+ ui.print_(ui.colorize(
+ 'text_warning',
+ 'You are about to queue {} {}.'.format(
+ len(selection), item_type)))
+
+ if ui.input_options(('Continue', 'Abort')) == 'a':
+ return True
+
+ return False
+
+ def _create_tmp_playlist(self, paths_list):
+ """Create a temporary .m3u file. Return the filename.
+ """
+ utf8_bom = config['play']['bom'].get(bool)
+ m3u = NamedTemporaryFile('wb', suffix='.m3u', delete=False)
+
+ if utf8_bom:
+ m3u.write(b'\xEF\xBB\xBF')
+
+ for item in paths_list:
+ m3u.write(item + b'\n')
+ m3u.close()
+ return m3u.name
+
+ def before_choose_candidate_listener(self, session, task):
+ """Append a "Play" choice to the interactive importer prompt.
+ """
+ return [PromptChoice('y', 'plaY', self.importer_play)]
+
+ def importer_play(self, session, task):
+ """Get items from current import task and send to play function.
+ """
+ selection = task.items
+ paths = [item.path for item in selection]
+
+ open_args = self._playlist_or_paths(paths)
+ command_str = self._command_str()
+
+ if not self._exceeds_threshold(selection, command_str, open_args):
+ play(command_str, selection, paths, open_args, self._log,
+ keep_open=True)
diff --git a/lib/beetsplug/playlist.py b/lib/beetsplug/playlist.py
new file mode 100644
index 00000000..265b8bad
--- /dev/null
+++ b/lib/beetsplug/playlist.py
@@ -0,0 +1,185 @@
+# This file is part of beets.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+
+import os
+import fnmatch
+import tempfile
+import beets
+from beets.util import path_as_posix
+
+
+class PlaylistQuery(beets.dbcore.Query):
+ """Matches files listed by a playlist file.
+ """
+ def __init__(self, pattern):
+ self.pattern = pattern
+ config = beets.config['playlist']
+
+ # Get the full path to the playlist
+ playlist_paths = (
+ pattern,
+ os.path.abspath(os.path.join(
+ config['playlist_dir'].as_filename(),
+ f'{pattern}.m3u',
+ )),
+ )
+
+ self.paths = []
+ for playlist_path in playlist_paths:
+ if not fnmatch.fnmatch(playlist_path, '*.[mM]3[uU]'):
+ # This is not am M3U playlist, skip this candidate
+ continue
+
+ try:
+ f = open(beets.util.syspath(playlist_path), mode='rb')
+ except OSError:
+ continue
+
+ if config['relative_to'].get() == 'library':
+ relative_to = beets.config['directory'].as_filename()
+ elif config['relative_to'].get() == 'playlist':
+ relative_to = os.path.dirname(playlist_path)
+ else:
+ relative_to = config['relative_to'].as_filename()
+ relative_to = beets.util.bytestring_path(relative_to)
+
+ for line in f:
+ if line[0] == '#':
+ # ignore comments, and extm3u extension
+ continue
+
+ self.paths.append(beets.util.normpath(
+ os.path.join(relative_to, line.rstrip())
+ ))
+ f.close()
+ break
+
+ def col_clause(self):
+ if not self.paths:
+ # Playlist is empty
+ return '0', ()
+ clause = 'path IN ({})'.format(', '.join('?' for path in self.paths))
+ return clause, (beets.library.BLOB_TYPE(p) for p in self.paths)
+
+ def match(self, item):
+ return item.path in self.paths
+
+
+class PlaylistPlugin(beets.plugins.BeetsPlugin):
+ item_queries = {'playlist': PlaylistQuery}
+
+ def __init__(self):
+ super().__init__()
+ self.config.add({
+ 'auto': False,
+ 'playlist_dir': '.',
+ 'relative_to': 'library',
+ 'forward_slash': False,
+ })
+
+ self.playlist_dir = self.config['playlist_dir'].as_filename()
+ self.changes = {}
+
+ if self.config['relative_to'].get() == 'library':
+ self.relative_to = beets.util.bytestring_path(
+ beets.config['directory'].as_filename())
+ elif self.config['relative_to'].get() != 'playlist':
+ self.relative_to = beets.util.bytestring_path(
+ self.config['relative_to'].as_filename())
+ else:
+ self.relative_to = None
+
+ if self.config['auto']:
+ self.register_listener('item_moved', self.item_moved)
+ self.register_listener('item_removed', self.item_removed)
+ self.register_listener('cli_exit', self.cli_exit)
+
+ def item_moved(self, item, source, destination):
+ self.changes[source] = destination
+
+ def item_removed(self, item):
+ if not os.path.exists(beets.util.syspath(item.path)):
+ self.changes[item.path] = None
+
+ def cli_exit(self, lib):
+ for playlist in self.find_playlists():
+ self._log.info(f'Updating playlist: {playlist}')
+ base_dir = beets.util.bytestring_path(
+ self.relative_to if self.relative_to
+ else os.path.dirname(playlist)
+ )
+
+ try:
+ self.update_playlist(playlist, base_dir)
+ except beets.util.FilesystemError:
+ self._log.error('Failed to update playlist: {}'.format(
+ beets.util.displayable_path(playlist)))
+
+ def find_playlists(self):
+ """Find M3U playlists in the playlist directory."""
+ try:
+ dir_contents = os.listdir(beets.util.syspath(self.playlist_dir))
+ except OSError:
+ self._log.warning('Unable to open playlist directory {}'.format(
+ beets.util.displayable_path(self.playlist_dir)))
+ return
+
+ for filename in dir_contents:
+ if fnmatch.fnmatch(filename, '*.[mM]3[uU]'):
+ yield os.path.join(self.playlist_dir, filename)
+
+ def update_playlist(self, filename, base_dir):
+ """Find M3U playlists in the specified directory."""
+ changes = 0
+ deletions = 0
+
+ with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as tempfp:
+ new_playlist = tempfp.name
+ with open(filename, mode='rb') as fp:
+ for line in fp:
+ original_path = line.rstrip(b'\r\n')
+
+ # Ensure that path from playlist is absolute
+ is_relative = not os.path.isabs(line)
+ if is_relative:
+ lookup = os.path.join(base_dir, original_path)
+ else:
+ lookup = original_path
+
+ try:
+ new_path = self.changes[beets.util.normpath(lookup)]
+ except KeyError:
+ if self.config['forward_slash']:
+ line = path_as_posix(line)
+ tempfp.write(line)
+ else:
+ if new_path is None:
+ # Item has been deleted
+ deletions += 1
+ continue
+
+ changes += 1
+ if is_relative:
+ new_path = os.path.relpath(new_path, base_dir)
+ line = line.replace(original_path, new_path)
+ if self.config['forward_slash']:
+ line = path_as_posix(line)
+ tempfp.write(line)
+
+ if changes or deletions:
+ self._log.info(
+ 'Updated playlist {} ({} changes, {} deletions)'.format(
+ filename, changes, deletions))
+ beets.util.copy(new_playlist, filename, replace=True)
+ beets.util.remove(new_playlist)
diff --git a/lib/beetsplug/plexupdate.py b/lib/beetsplug/plexupdate.py
new file mode 100644
index 00000000..2261a55f
--- /dev/null
+++ b/lib/beetsplug/plexupdate.py
@@ -0,0 +1,110 @@
+"""Updates an Plex library whenever the beets library is changed.
+
+Plex Home users enter the Plex Token to enable updating.
+Put something like the following in your config.yaml to configure:
+ plex:
+ host: localhost
+ port: 32400
+ token: token
+"""
+
+import requests
+from xml.etree import ElementTree
+from urllib.parse import urljoin, urlencode
+from beets import config
+from beets.plugins import BeetsPlugin
+
+
+def get_music_section(host, port, token, library_name, secure,
+ ignore_cert_errors):
+ """Getting the section key for the music library in Plex.
+ """
+ api_endpoint = append_token('library/sections', token)
+ url = urljoin('{}://{}:{}'.format(get_protocol(secure), host,
+ port), api_endpoint)
+
+ # Sends request.
+ r = requests.get(url, verify=not ignore_cert_errors)
+
+ # Parse xml tree and extract music section key.
+ tree = ElementTree.fromstring(r.content)
+ for child in tree.findall('Directory'):
+ if child.get('title') == library_name:
+ return child.get('key')
+
+
+def update_plex(host, port, token, library_name, secure,
+ ignore_cert_errors):
+ """Ignore certificate errors if configured to.
+ """
+ if ignore_cert_errors:
+ import urllib3
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+ """Sends request to the Plex api to start a library refresh.
+ """
+ # Getting section key and build url.
+ section_key = get_music_section(host, port, token, library_name,
+ secure, ignore_cert_errors)
+ api_endpoint = f'library/sections/{section_key}/refresh'
+ api_endpoint = append_token(api_endpoint, token)
+ url = urljoin('{}://{}:{}'.format(get_protocol(secure), host,
+ port), api_endpoint)
+
+ # Sends request and returns requests object.
+ r = requests.get(url, verify=not ignore_cert_errors)
+ return r
+
+
+def append_token(url, token):
+ """Appends the Plex Home token to the api call if required.
+ """
+ if token:
+ url += '?' + urlencode({'X-Plex-Token': token})
+ return url
+
+
+def get_protocol(secure):
+ if secure:
+ return 'https'
+ else:
+ return 'http'
+
+
+class PlexUpdate(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+
+ # Adding defaults.
+ config['plex'].add({
+ 'host': 'localhost',
+ 'port': 32400,
+ 'token': '',
+ 'library_name': 'Music',
+ 'secure': False,
+ 'ignore_cert_errors': False})
+
+ config['plex']['token'].redact = True
+ self.register_listener('database_change', self.listen_for_db_change)
+
+ def listen_for_db_change(self, lib, model):
+ """Listens for beets db change and register the update for the end"""
+ self.register_listener('cli_exit', self.update)
+
+ def update(self, lib):
+ """When the client exists try to send refresh request to Plex server.
+ """
+ self._log.info('Updating Plex library...')
+
+ # Try to send update request.
+ try:
+ update_plex(
+ config['plex']['host'].get(),
+ config['plex']['port'].get(),
+ config['plex']['token'].get(),
+ config['plex']['library_name'].get(),
+ config['plex']['secure'].get(bool),
+ config['plex']['ignore_cert_errors'].get(bool))
+ self._log.info('... started.')
+
+ except requests.exceptions.RequestException:
+ self._log.warning('Update failed.')
diff --git a/lib/beetsplug/random.py b/lib/beetsplug/random.py
new file mode 100644
index 00000000..ea9b7b98
--- /dev/null
+++ b/lib/beetsplug/random.py
@@ -0,0 +1,57 @@
+# This file is part of beets.
+# Copyright 2016, Philippe Mongeau.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Get a random song or album from the library.
+"""
+
+from beets.plugins import BeetsPlugin
+from beets.ui import Subcommand, decargs, print_
+from beets.random import random_objs
+
+
+def random_func(lib, opts, args):
+ """Select some random items or albums and print the results.
+ """
+ # Fetch all the objects matching the query into a list.
+ query = decargs(args)
+ if opts.album:
+ objs = list(lib.albums(query))
+ else:
+ objs = list(lib.items(query))
+
+ # Print a random subset.
+ objs = random_objs(objs, opts.album, opts.number, opts.time,
+ opts.equal_chance)
+ for obj in objs:
+ print_(format(obj))
+
+
+random_cmd = Subcommand('random',
+ help='choose a random track or album')
+random_cmd.parser.add_option(
+ '-n', '--number', action='store', type="int",
+ help='number of objects to choose', default=1)
+random_cmd.parser.add_option(
+ '-e', '--equal-chance', action='store_true',
+ help='each artist has the same chance')
+random_cmd.parser.add_option(
+ '-t', '--time', action='store', type="float",
+ help='total length in minutes of objects to choose')
+random_cmd.parser.add_all_common_options()
+random_cmd.func = random_func
+
+
+class Random(BeetsPlugin):
+ def commands(self):
+ return [random_cmd]
diff --git a/lib/beetsplug/replaygain.py b/lib/beetsplug/replaygain.py
new file mode 100644
index 00000000..b6297d93
--- /dev/null
+++ b/lib/beetsplug/replaygain.py
@@ -0,0 +1,1368 @@
+# This file is part of beets.
+# Copyright 2016, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+
+import collections
+import enum
+import math
+import os
+import signal
+import subprocess
+import sys
+import warnings
+from multiprocessing.pool import ThreadPool, RUN
+from six.moves import queue
+from threading import Thread, Event
+
+from beets import ui
+from beets.plugins import BeetsPlugin
+from beets.util import (syspath, command_output, displayable_path,
+ py3_path, cpu_count)
+
+
+# Utilities.
+
+class ReplayGainError(Exception):
+ """Raised when a local (to a track or an album) error occurs in one
+ of the backends.
+ """
+
+
+class FatalReplayGainError(Exception):
+ """Raised when a fatal error occurs in one of the backends.
+ """
+
+
+class FatalGstreamerPluginReplayGainError(FatalReplayGainError):
+ """Raised when a fatal error occurs in the GStreamerBackend when
+ loading the required plugins."""
+
+
+def call(args, **kwargs):
+ """Execute the command and return its output or raise a
+ ReplayGainError on failure.
+ """
+ try:
+ return command_output(args, **kwargs)
+ except subprocess.CalledProcessError as e:
+ raise ReplayGainError(
+ "{} exited with status {}".format(args[0], e.returncode)
+ )
+ except UnicodeEncodeError:
+ # Due to a bug in Python 2's subprocess on Windows, Unicode
+ # filenames can fail to encode on that platform. See:
+ # https://github.com/google-code-export/beets/issues/499
+ raise ReplayGainError("argument encoding failed")
+
+
+def after_version(version_a, version_b):
+ return tuple(int(s) for s in version_a.split('.')) \
+ >= tuple(int(s) for s in version_b.split('.'))
+
+
+def db_to_lufs(db):
+ """Convert db to LUFS.
+
+ According to https://wiki.hydrogenaud.io/index.php?title=
+ ReplayGain_2.0_specification#Reference_level
+ """
+ return db - 107
+
+
+def lufs_to_db(db):
+ """Convert LUFS to db.
+
+ According to https://wiki.hydrogenaud.io/index.php?title=
+ ReplayGain_2.0_specification#Reference_level
+ """
+ return db + 107
+
+
+# Backend base and plumbing classes.
+
+# gain: in LU to reference level
+# peak: part of full scale (FS is 1.0)
+Gain = collections.namedtuple("Gain", "gain peak")
+# album_gain: Gain object
+# track_gains: list of Gain objects
+AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains")
+
+
+class Peak(enum.Enum):
+ none = 0
+ true = 1
+ sample = 2
+
+
+class Backend:
+ """An abstract class representing engine for calculating RG values.
+ """
+
+ do_parallel = False
+
+ def __init__(self, config, log):
+ """Initialize the backend with the configuration view for the
+ plugin.
+ """
+ self._log = log
+
+ def compute_track_gain(self, items, target_level, peak):
+ """Computes the track gain of the given tracks, returns a list
+ of Gain objects.
+ """
+ raise NotImplementedError()
+
+ def compute_album_gain(self, items, target_level, peak):
+ """Computes the album gain of the given album, returns an
+ AlbumGain object.
+ """
+ raise NotImplementedError()
+
+
+# ffmpeg backend
+class FfmpegBackend(Backend):
+ """A replaygain backend using ffmpeg's ebur128 filter.
+ """
+
+ do_parallel = True
+
+ def __init__(self, config, log):
+ super().__init__(config, log)
+ self._ffmpeg_path = "ffmpeg"
+
+ # check that ffmpeg is installed
+ try:
+ ffmpeg_version_out = call([self._ffmpeg_path, "-version"])
+ except OSError:
+ raise FatalReplayGainError(
+ f"could not find ffmpeg at {self._ffmpeg_path}"
+ )
+ incompatible_ffmpeg = True
+ for line in ffmpeg_version_out.stdout.splitlines():
+ if line.startswith(b"configuration:"):
+ if b"--enable-libebur128" in line:
+ incompatible_ffmpeg = False
+ if line.startswith(b"libavfilter"):
+ version = line.split(b" ", 1)[1].split(b"/", 1)[0].split(b".")
+ version = tuple(map(int, version))
+ if version >= (6, 67, 100):
+ incompatible_ffmpeg = False
+ if incompatible_ffmpeg:
+ raise FatalReplayGainError(
+ "Installed FFmpeg version does not support ReplayGain."
+ "calculation. Either libavfilter version 6.67.100 or above or"
+ "the --enable-libebur128 configuration option is required."
+ )
+
+ def compute_track_gain(self, items, target_level, peak):
+ """Computes the track gain of the given tracks, returns a list
+ of Gain objects (the track gains).
+ """
+ gains = []
+ for item in items:
+ gains.append(
+ self._analyse_item(
+ item,
+ target_level,
+ peak,
+ count_blocks=False,
+ )[0] # take only the gain, discarding number of gating blocks
+ )
+ return gains
+
+ def compute_album_gain(self, items, target_level, peak):
+ """Computes the album gain of the given album, returns an
+ AlbumGain object.
+ """
+ target_level_lufs = db_to_lufs(target_level)
+
+ # analyse tracks
+ # list of track Gain objects
+ track_gains = []
+ # maximum peak
+ album_peak = 0
+ # sum of BS.1770 gating block powers
+ sum_powers = 0
+ # total number of BS.1770 gating blocks
+ n_blocks = 0
+
+ for item in items:
+ track_gain, track_n_blocks = self._analyse_item(
+ item, target_level, peak
+ )
+ track_gains.append(track_gain)
+
+ # album peak is maximum track peak
+ album_peak = max(album_peak, track_gain.peak)
+
+ # prepare album_gain calculation
+ # total number of blocks is sum of track blocks
+ n_blocks += track_n_blocks
+
+ # convert `LU to target_level` -> LUFS
+ track_loudness = target_level_lufs - track_gain.gain
+ # This reverses ITU-R BS.1770-4 p. 6 equation (5) to convert
+ # from loudness to power. The result is the average gating
+ # block power.
+ track_power = 10**((track_loudness + 0.691) / 10)
+
+ # Weight that average power by the number of gating blocks to
+ # get the sum of all their powers. Add that to the sum of all
+ # block powers in this album.
+ sum_powers += track_power * track_n_blocks
+
+ # calculate album gain
+ if n_blocks > 0:
+ # compare ITU-R BS.1770-4 p. 6 equation (5)
+ # Album gain is the replaygain of the concatenation of all tracks.
+ album_gain = -0.691 + 10 * math.log10(sum_powers / n_blocks)
+ else:
+ album_gain = -70
+ # convert LUFS -> `LU to target_level`
+ album_gain = target_level_lufs - album_gain
+
+ self._log.debug(
+ "{}: gain {} LU, peak {}"
+ .format(items, album_gain, album_peak)
+ )
+
+ return AlbumGain(Gain(album_gain, album_peak), track_gains)
+
+ def _construct_cmd(self, item, peak_method):
+ """Construct the shell command to analyse items."""
+ return [
+ self._ffmpeg_path,
+ "-nostats",
+ "-hide_banner",
+ "-i",
+ item.path,
+ "-map",
+ "a:0",
+ "-filter",
+ f"ebur128=peak={peak_method}",
+ "-f",
+ "null",
+ "-",
+ ]
+
+ def _analyse_item(self, item, target_level, peak, count_blocks=True):
+ """Analyse item. Return a pair of a Gain object and the number
+ of gating blocks above the threshold.
+
+ If `count_blocks` is False, the number of gating blocks returned
+ will be 0.
+ """
+ target_level_lufs = db_to_lufs(target_level)
+ peak_method = peak.name
+
+ # call ffmpeg
+ self._log.debug(f"analyzing {item}")
+ cmd = self._construct_cmd(item, peak_method)
+ self._log.debug(
+ 'executing {0}', ' '.join(map(displayable_path, cmd))
+ )
+ output = call(cmd).stderr.splitlines()
+
+ # parse output
+
+ if peak == Peak.none:
+ peak = 0
+ else:
+ line_peak = self._find_line(
+ output,
+ f" {peak_method.capitalize()} peak:".encode(),
+ start_line=len(output) - 1, step_size=-1,
+ )
+ peak = self._parse_float(
+ output[self._find_line(
+ output, b" Peak:",
+ line_peak,
+ )]
+ )
+ # convert TPFS -> part of FS
+ peak = 10**(peak / 20)
+
+ line_integrated_loudness = self._find_line(
+ output, b" Integrated loudness:",
+ start_line=len(output) - 1, step_size=-1,
+ )
+ gain = self._parse_float(
+ output[self._find_line(
+ output, b" I:",
+ line_integrated_loudness,
+ )]
+ )
+ # convert LUFS -> LU from target level
+ gain = target_level_lufs - gain
+
+ # count BS.1770 gating blocks
+ n_blocks = 0
+ if count_blocks:
+ gating_threshold = self._parse_float(
+ output[self._find_line(
+ output, b" Threshold:",
+ start_line=line_integrated_loudness,
+ )]
+ )
+ for line in output:
+ if not line.startswith(b"[Parsed_ebur128"):
+ continue
+ if line.endswith(b"Summary:"):
+ continue
+ line = line.split(b"M:", 1)
+ if len(line) < 2:
+ continue
+ if self._parse_float(b"M: " + line[1]) >= gating_threshold:
+ n_blocks += 1
+ self._log.debug(
+ "{}: {} blocks over {} LUFS"
+ .format(item, n_blocks, gating_threshold)
+ )
+
+ self._log.debug(
+ "{}: gain {} LU, peak {}"
+ .format(item, gain, peak)
+ )
+
+ return Gain(gain, peak), n_blocks
+
+ def _find_line(self, output, search, start_line=0, step_size=1):
+ """Return index of line beginning with `search`.
+
+ Begins searching at index `start_line` in `output`.
+ """
+ end_index = len(output) if step_size > 0 else -1
+ for i in range(start_line, end_index, step_size):
+ if output[i].startswith(search):
+ return i
+ raise ReplayGainError(
+ "ffmpeg output: missing {} after line {}"
+ .format(repr(search), start_line)
+ )
+
+ def _parse_float(self, line):
+ """Extract a float from a key value pair in `line`.
+
+ This format is expected: /[^:]:[[:space:]]*value.*/, where `value` is
+ the float.
+ """
+ # extract value
+ value = line.split(b":", 1)
+ if len(value) < 2:
+ raise ReplayGainError(
+ "ffmpeg output: expected key value pair, found {}"
+ .format(line)
+ )
+ value = value[1].lstrip()
+ # strip unit
+ value = value.split(b" ", 1)[0]
+ # cast value to float
+ try:
+ return float(value)
+ except ValueError:
+ raise ReplayGainError(
+ "ffmpeg output: expected float value, found {}"
+ .format(value)
+ )
+
+
+# mpgain/aacgain CLI tool backend.
+class CommandBackend(Backend):
+ do_parallel = True
+
+ def __init__(self, config, log):
+ super().__init__(config, log)
+ config.add({
+ 'command': "",
+ 'noclip': True,
+ })
+
+ self.command = config["command"].as_str()
+
+ if self.command:
+ # Explicit executable path.
+ if not os.path.isfile(self.command):
+ raise FatalReplayGainError(
+ 'replaygain command does not exist: {}'.format(
+ self.command)
+ )
+ else:
+ # Check whether the program is in $PATH.
+ for cmd in ('mp3gain', 'aacgain'):
+ try:
+ call([cmd, '-v'])
+ self.command = cmd
+ except OSError:
+ pass
+ if not self.command:
+ raise FatalReplayGainError(
+ 'no replaygain command found: install mp3gain or aacgain'
+ )
+
+ self.noclip = config['noclip'].get(bool)
+
+ def compute_track_gain(self, items, target_level, peak):
+ """Computes the track gain of the given tracks, returns a list
+ of TrackGain objects.
+ """
+ supported_items = list(filter(self.format_supported, items))
+ output = self.compute_gain(supported_items, target_level, False)
+ return output
+
+ def compute_album_gain(self, items, target_level, peak):
+ """Computes the album gain of the given album, returns an
+ AlbumGain object.
+ """
+ # TODO: What should be done when not all tracks in the album are
+ # supported?
+
+ supported_items = list(filter(self.format_supported, items))
+ if len(supported_items) != len(items):
+ self._log.debug('tracks are of unsupported format')
+ return AlbumGain(None, [])
+
+ output = self.compute_gain(supported_items, target_level, True)
+ return AlbumGain(output[-1], output[:-1])
+
+ def format_supported(self, item):
+ """Checks whether the given item is supported by the selected tool.
+ """
+ if 'mp3gain' in self.command and item.format != 'MP3':
+ return False
+ elif 'aacgain' in self.command and item.format not in ('MP3', 'AAC'):
+ return False
+ return True
+
+ def compute_gain(self, items, target_level, is_album):
+ """Computes the track or album gain of a list of items, returns
+ a list of TrackGain objects.
+
+ When computing album gain, the last TrackGain object returned is
+ the album gain
+ """
+ if len(items) == 0:
+ self._log.debug('no supported tracks to analyze')
+ return []
+
+ """Compute ReplayGain values and return a list of results
+ dictionaries as given by `parse_tool_output`.
+ """
+ # Construct shell command. The "-o" option makes the output
+ # easily parseable (tab-delimited). "-s s" forces gain
+ # recalculation even if tags are already present and disables
+ # tag-writing; this turns the mp3gain/aacgain tool into a gain
+ # calculator rather than a tag manipulator because we take care
+ # of changing tags ourselves.
+ cmd = [self.command, '-o', '-s', 's']
+ if self.noclip:
+ # Adjust to avoid clipping.
+ cmd = cmd + ['-k']
+ else:
+ # Disable clipping warning.
+ cmd = cmd + ['-c']
+ cmd = cmd + ['-d', str(int(target_level - 89))]
+ cmd = cmd + [syspath(i.path) for i in items]
+
+ self._log.debug('analyzing {0} files', len(items))
+ self._log.debug("executing {0}", " ".join(map(displayable_path, cmd)))
+ output = call(cmd).stdout
+ self._log.debug('analysis finished')
+ return self.parse_tool_output(output,
+ len(items) + (1 if is_album else 0))
+
+ def parse_tool_output(self, text, num_lines):
+ """Given the tab-delimited output from an invocation of mp3gain
+ or aacgain, parse the text and return a list of dictionaries
+ containing information about each analyzed file.
+ """
+ out = []
+ for line in text.split(b'\n')[1:num_lines + 1]:
+ parts = line.split(b'\t')
+ if len(parts) != 6 or parts[0] == b'File':
+ self._log.debug('bad tool output: {0}', text)
+ raise ReplayGainError('mp3gain failed')
+ d = {
+ 'file': parts[0],
+ 'mp3gain': int(parts[1]),
+ 'gain': float(parts[2]),
+ 'peak': float(parts[3]) / (1 << 15),
+ 'maxgain': int(parts[4]),
+ 'mingain': int(parts[5]),
+
+ }
+ out.append(Gain(d['gain'], d['peak']))
+ return out
+
+
+# GStreamer-based backend.
+
+class GStreamerBackend(Backend):
+ def __init__(self, config, log):
+ super().__init__(config, log)
+ self._import_gst()
+
+ # Initialized a GStreamer pipeline of the form filesrc ->
+ # decodebin -> audioconvert -> audioresample -> rganalysis ->
+ # fakesink The connection between decodebin and audioconvert is
+ # handled dynamically after decodebin figures out the type of
+ # the input file.
+ self._src = self.Gst.ElementFactory.make("filesrc", "src")
+ self._decbin = self.Gst.ElementFactory.make("decodebin", "decbin")
+ self._conv = self.Gst.ElementFactory.make("audioconvert", "conv")
+ self._res = self.Gst.ElementFactory.make("audioresample", "res")
+ self._rg = self.Gst.ElementFactory.make("rganalysis", "rg")
+
+ if self._src is None or self._decbin is None or self._conv is None \
+ or self._res is None or self._rg is None:
+ raise FatalGstreamerPluginReplayGainError(
+ "Failed to load required GStreamer plugins"
+ )
+
+ # We check which files need gain ourselves, so all files given
+ # to rganalsys should have their gain computed, even if it
+ # already exists.
+ self._rg.set_property("forced", True)
+ self._sink = self.Gst.ElementFactory.make("fakesink", "sink")
+
+ self._pipe = self.Gst.Pipeline()
+ self._pipe.add(self._src)
+ self._pipe.add(self._decbin)
+ self._pipe.add(self._conv)
+ self._pipe.add(self._res)
+ self._pipe.add(self._rg)
+ self._pipe.add(self._sink)
+
+ self._src.link(self._decbin)
+ self._conv.link(self._res)
+ self._res.link(self._rg)
+ self._rg.link(self._sink)
+
+ self._bus = self._pipe.get_bus()
+ self._bus.add_signal_watch()
+ self._bus.connect("message::eos", self._on_eos)
+ self._bus.connect("message::error", self._on_error)
+ self._bus.connect("message::tag", self._on_tag)
+ # Needed for handling the dynamic connection between decodebin
+ # and audioconvert
+ self._decbin.connect("pad-added", self._on_pad_added)
+ self._decbin.connect("pad-removed", self._on_pad_removed)
+
+ self._main_loop = self.GLib.MainLoop()
+
+ self._files = []
+
+ def _import_gst(self):
+ """Import the necessary GObject-related modules and assign `Gst`
+ and `GObject` fields on this object.
+ """
+
+ try:
+ import gi
+ except ImportError:
+ raise FatalReplayGainError(
+ "Failed to load GStreamer: python-gi not found"
+ )
+
+ try:
+ gi.require_version('Gst', '1.0')
+ except ValueError as e:
+ raise FatalReplayGainError(
+ f"Failed to load GStreamer 1.0: {e}"
+ )
+
+ from gi.repository import GObject, Gst, GLib
+ # Calling GObject.threads_init() is not needed for
+ # PyGObject 3.10.2+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ GObject.threads_init()
+ Gst.init([sys.argv[0]])
+
+ self.GObject = GObject
+ self.GLib = GLib
+ self.Gst = Gst
+
+ def compute(self, files, target_level, album):
+ self._error = None
+ self._files = list(files)
+
+ if len(self._files) == 0:
+ return
+
+ self._file_tags = collections.defaultdict(dict)
+
+ self._rg.set_property("reference-level", target_level)
+
+ if album:
+ self._rg.set_property("num-tracks", len(self._files))
+
+ if self._set_first_file():
+ self._main_loop.run()
+ if self._error is not None:
+ raise self._error
+
+ def compute_track_gain(self, items, target_level, peak):
+ self.compute(items, target_level, False)
+ if len(self._file_tags) != len(items):
+ raise ReplayGainError("Some tracks did not receive tags")
+
+ ret = []
+ for item in items:
+ ret.append(Gain(self._file_tags[item]["TRACK_GAIN"],
+ self._file_tags[item]["TRACK_PEAK"]))
+
+ return ret
+
+ def compute_album_gain(self, items, target_level, peak):
+ items = list(items)
+ self.compute(items, target_level, True)
+ if len(self._file_tags) != len(items):
+ raise ReplayGainError("Some items in album did not receive tags")
+
+ # Collect track gains.
+ track_gains = []
+ for item in items:
+ try:
+ gain = self._file_tags[item]["TRACK_GAIN"]
+ peak = self._file_tags[item]["TRACK_PEAK"]
+ except KeyError:
+ raise ReplayGainError("results missing for track")
+ track_gains.append(Gain(gain, peak))
+
+ # Get album gain information from the last track.
+ last_tags = self._file_tags[items[-1]]
+ try:
+ gain = last_tags["ALBUM_GAIN"]
+ peak = last_tags["ALBUM_PEAK"]
+ except KeyError:
+ raise ReplayGainError("results missing for album")
+
+ return AlbumGain(Gain(gain, peak), track_gains)
+
+ def close(self):
+ self._bus.remove_signal_watch()
+
+ def _on_eos(self, bus, message):
+ # A file finished playing in all elements of the pipeline. The
+ # RG tags have already been propagated. If we don't have a next
+ # file, we stop processing.
+ if not self._set_next_file():
+ self._pipe.set_state(self.Gst.State.NULL)
+ self._main_loop.quit()
+
+ def _on_error(self, bus, message):
+ self._pipe.set_state(self.Gst.State.NULL)
+ self._main_loop.quit()
+ err, debug = message.parse_error()
+ f = self._src.get_property("location")
+ # A GStreamer error, either an unsupported format or a bug.
+ self._error = ReplayGainError(
+ f"Error {err!r} - {debug!r} on file {f!r}"
+ )
+
+ def _on_tag(self, bus, message):
+ tags = message.parse_tag()
+
+ def handle_tag(taglist, tag, userdata):
+ # The rganalysis element provides both the existing tags for
+ # files and the new computes tags. In order to ensure we
+ # store the computed tags, we overwrite the RG values of
+ # received a second time.
+ if tag == self.Gst.TAG_TRACK_GAIN:
+ self._file_tags[self._file]["TRACK_GAIN"] = \
+ taglist.get_double(tag)[1]
+ elif tag == self.Gst.TAG_TRACK_PEAK:
+ self._file_tags[self._file]["TRACK_PEAK"] = \
+ taglist.get_double(tag)[1]
+ elif tag == self.Gst.TAG_ALBUM_GAIN:
+ self._file_tags[self._file]["ALBUM_GAIN"] = \
+ taglist.get_double(tag)[1]
+ elif tag == self.Gst.TAG_ALBUM_PEAK:
+ self._file_tags[self._file]["ALBUM_PEAK"] = \
+ taglist.get_double(tag)[1]
+ elif tag == self.Gst.TAG_REFERENCE_LEVEL:
+ self._file_tags[self._file]["REFERENCE_LEVEL"] = \
+ taglist.get_double(tag)[1]
+
+ tags.foreach(handle_tag, None)
+
+ def _set_first_file(self):
+ if len(self._files) == 0:
+ return False
+
+ self._file = self._files.pop(0)
+ self._pipe.set_state(self.Gst.State.NULL)
+ self._src.set_property("location", py3_path(syspath(self._file.path)))
+ self._pipe.set_state(self.Gst.State.PLAYING)
+ return True
+
+ def _set_file(self):
+ """Initialize the filesrc element with the next file to be analyzed.
+ """
+ # No more files, we're done
+ if len(self._files) == 0:
+ return False
+
+ self._file = self._files.pop(0)
+
+ # Ensure the filesrc element received the paused state of the
+ # pipeline in a blocking manner
+ self._src.sync_state_with_parent()
+ self._src.get_state(self.Gst.CLOCK_TIME_NONE)
+
+ # Ensure the decodebin element receives the paused state of the
+ # pipeline in a blocking manner
+ self._decbin.sync_state_with_parent()
+ self._decbin.get_state(self.Gst.CLOCK_TIME_NONE)
+
+ # Disconnect the decodebin element from the pipeline, set its
+ # state to READY to to clear it.
+ self._decbin.unlink(self._conv)
+ self._decbin.set_state(self.Gst.State.READY)
+
+ # Set a new file on the filesrc element, can only be done in the
+ # READY state
+ self._src.set_state(self.Gst.State.READY)
+ self._src.set_property("location", py3_path(syspath(self._file.path)))
+
+ self._decbin.link(self._conv)
+ self._pipe.set_state(self.Gst.State.READY)
+
+ return True
+
+ def _set_next_file(self):
+ """Set the next file to be analyzed while keeping the pipeline
+ in the PAUSED state so that the rganalysis element can correctly
+ handle album gain.
+ """
+ # A blocking pause
+ self._pipe.set_state(self.Gst.State.PAUSED)
+ self._pipe.get_state(self.Gst.CLOCK_TIME_NONE)
+
+ # Try setting the next file
+ ret = self._set_file()
+ if ret:
+ # Seek to the beginning in order to clear the EOS state of the
+ # various elements of the pipeline
+ self._pipe.seek_simple(self.Gst.Format.TIME,
+ self.Gst.SeekFlags.FLUSH,
+ 0)
+ self._pipe.set_state(self.Gst.State.PLAYING)
+
+ return ret
+
+ def _on_pad_added(self, decbin, pad):
+ sink_pad = self._conv.get_compatible_pad(pad, None)
+ assert(sink_pad is not None)
+ pad.link(sink_pad)
+
+ def _on_pad_removed(self, decbin, pad):
+ # Called when the decodebin element is disconnected from the
+ # rest of the pipeline while switching input files
+ peer = pad.get_peer()
+ assert(peer is None)
+
+
+class AudioToolsBackend(Backend):
+ """ReplayGain backend that uses `Python Audio Tools
+ `_ and its capabilities to read more
+ file formats and compute ReplayGain values using it replaygain module.
+ """
+
+ def __init__(self, config, log):
+ super().__init__(config, log)
+ self._import_audiotools()
+
+ def _import_audiotools(self):
+ """Check whether it's possible to import the necessary modules.
+ There is no check on the file formats at runtime.
+
+ :raises :exc:`ReplayGainError`: if the modules cannot be imported
+ """
+ try:
+ import audiotools
+ import audiotools.replaygain
+ except ImportError:
+ raise FatalReplayGainError(
+ "Failed to load audiotools: audiotools not found"
+ )
+ self._mod_audiotools = audiotools
+ self._mod_replaygain = audiotools.replaygain
+
+ def open_audio_file(self, item):
+ """Open the file to read the PCM stream from the using
+ ``item.path``.
+
+ :return: the audiofile instance
+ :rtype: :class:`audiotools.AudioFile`
+ :raises :exc:`ReplayGainError`: if the file is not found or the
+ file format is not supported
+ """
+ try:
+ audiofile = self._mod_audiotools.open(py3_path(syspath(item.path)))
+ except OSError:
+ raise ReplayGainError(
+ f"File {item.path} was not found"
+ )
+ except self._mod_audiotools.UnsupportedFile:
+ raise ReplayGainError(
+ f"Unsupported file type {item.format}"
+ )
+
+ return audiofile
+
+ def init_replaygain(self, audiofile, item):
+ """Return an initialized :class:`audiotools.replaygain.ReplayGain`
+ instance, which requires the sample rate of the song(s) on which
+ the ReplayGain values will be computed. The item is passed in case
+ the sample rate is invalid to log the stored item sample rate.
+
+ :return: initialized replagain object
+ :rtype: :class:`audiotools.replaygain.ReplayGain`
+ :raises: :exc:`ReplayGainError` if the sample rate is invalid
+ """
+ try:
+ rg = self._mod_replaygain.ReplayGain(audiofile.sample_rate())
+ except ValueError:
+ raise ReplayGainError(
+ f"Unsupported sample rate {item.samplerate}")
+ return
+ return rg
+
+ def compute_track_gain(self, items, target_level, peak):
+ """Compute ReplayGain values for the requested items.
+
+ :return list: list of :class:`Gain` objects
+ """
+ return [self._compute_track_gain(item, target_level) for item in items]
+
+ def _with_target_level(self, gain, target_level):
+ """Return `gain` relative to `target_level`.
+
+ Assumes `gain` is relative to 89 db.
+ """
+ return gain + (target_level - 89)
+
+ def _title_gain(self, rg, audiofile, target_level):
+ """Get the gain result pair from PyAudioTools using the `ReplayGain`
+ instance `rg` for the given `audiofile`.
+
+ Wraps `rg.title_gain(audiofile.to_pcm())` and throws a
+ `ReplayGainError` when the library fails.
+ """
+ try:
+ # The method needs an audiotools.PCMReader instance that can
+ # be obtained from an audiofile instance.
+ gain, peak = rg.title_gain(audiofile.to_pcm())
+ except ValueError as exc:
+ # `audiotools.replaygain` can raise a `ValueError` if the sample
+ # rate is incorrect.
+ self._log.debug('error in rg.title_gain() call: {}', exc)
+ raise ReplayGainError('audiotools audio data error')
+ return self._with_target_level(gain, target_level), peak
+
+ def _compute_track_gain(self, item, target_level):
+ """Compute ReplayGain value for the requested item.
+
+ :rtype: :class:`Gain`
+ """
+ audiofile = self.open_audio_file(item)
+ rg = self.init_replaygain(audiofile, item)
+
+ # Each call to title_gain on a ReplayGain object returns peak and gain
+ # of the track.
+ rg_track_gain, rg_track_peak = self._title_gain(
+ rg, audiofile, target_level
+ )
+
+ self._log.debug('ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}',
+ item.artist, item.title, rg_track_gain, rg_track_peak)
+ return Gain(gain=rg_track_gain, peak=rg_track_peak)
+
+ def compute_album_gain(self, items, target_level, peak):
+ """Compute ReplayGain values for the requested album and its items.
+
+ :rtype: :class:`AlbumGain`
+ """
+ # The first item is taken and opened to get the sample rate to
+ # initialize the replaygain object. The object is used for all the
+ # tracks in the album to get the album values.
+ item = list(items)[0]
+ audiofile = self.open_audio_file(item)
+ rg = self.init_replaygain(audiofile, item)
+
+ track_gains = []
+ for item in items:
+ audiofile = self.open_audio_file(item)
+ rg_track_gain, rg_track_peak = self._title_gain(
+ rg, audiofile, target_level
+ )
+ track_gains.append(
+ Gain(gain=rg_track_gain, peak=rg_track_peak)
+ )
+ self._log.debug('ReplayGain for track {0}: {1:.2f}, {2:.2f}',
+ item, rg_track_gain, rg_track_peak)
+
+ # After getting the values for all tracks, it's possible to get the
+ # album values.
+ rg_album_gain, rg_album_peak = rg.album_gain()
+ rg_album_gain = self._with_target_level(rg_album_gain, target_level)
+ self._log.debug('ReplayGain for album {0}: {1:.2f}, {2:.2f}',
+ items[0].album, rg_album_gain, rg_album_peak)
+
+ return AlbumGain(
+ Gain(gain=rg_album_gain, peak=rg_album_peak),
+ track_gains=track_gains
+ )
+
+
+class ExceptionWatcher(Thread):
+ """Monitors a queue for exceptions asynchronously.
+ Once an exception occurs, raise it and execute a callback.
+ """
+
+ def __init__(self, queue, callback):
+ self._queue = queue
+ self._callback = callback
+ self._stopevent = Event()
+ Thread.__init__(self)
+
+ def run(self):
+ while not self._stopevent.is_set():
+ try:
+ exc = self._queue.get_nowait()
+ self._callback()
+ raise exc[1].with_traceback(exc[2])
+ except queue.Empty:
+ # No exceptions yet, loop back to check
+ # whether `_stopevent` is set
+ pass
+
+ def join(self, timeout=None):
+ self._stopevent.set()
+ Thread.join(self, timeout)
+
+
+# Main plugin logic.
+
+class ReplayGainPlugin(BeetsPlugin):
+ """Provides ReplayGain analysis.
+ """
+
+ backends = {
+ "command": CommandBackend,
+ "gstreamer": GStreamerBackend,
+ "audiotools": AudioToolsBackend,
+ "ffmpeg": FfmpegBackend,
+ }
+
+ peak_methods = {
+ "true": Peak.true,
+ "sample": Peak.sample,
+ }
+
+ def __init__(self):
+ super().__init__()
+
+ # default backend is 'command' for backward-compatibility.
+ self.config.add({
+ 'overwrite': False,
+ 'auto': True,
+ 'backend': 'command',
+ 'threads': cpu_count(),
+ 'parallel_on_import': False,
+ 'per_disc': False,
+ 'peak': 'true',
+ 'targetlevel': 89,
+ 'r128': ['Opus'],
+ 'r128_targetlevel': lufs_to_db(-23),
+ })
+
+ self.overwrite = self.config['overwrite'].get(bool)
+ self.per_disc = self.config['per_disc'].get(bool)
+
+ # Remember which backend is used for CLI feedback
+ self.backend_name = self.config['backend'].as_str()
+
+ if self.backend_name not in self.backends:
+ raise ui.UserError(
+ "Selected ReplayGain backend {} is not supported. "
+ "Please select one of: {}".format(
+ self.backend_name,
+ ', '.join(self.backends.keys())
+ )
+ )
+ peak_method = self.config["peak"].as_str()
+ if peak_method not in self.peak_methods:
+ raise ui.UserError(
+ "Selected ReplayGain peak method {} is not supported. "
+ "Please select one of: {}".format(
+ peak_method,
+ ', '.join(self.peak_methods.keys())
+ )
+ )
+ self._peak_method = self.peak_methods[peak_method]
+
+ # On-import analysis.
+ if self.config['auto']:
+ self.register_listener('import_begin', self.import_begin)
+ self.register_listener('import', self.import_end)
+ self.import_stages = [self.imported]
+
+ # Formats to use R128.
+ self.r128_whitelist = self.config['r128'].as_str_seq()
+
+ try:
+ self.backend_instance = self.backends[self.backend_name](
+ self.config, self._log
+ )
+ except (ReplayGainError, FatalReplayGainError) as e:
+ raise ui.UserError(
+ f'replaygain initialization failed: {e}')
+
+ def should_use_r128(self, item):
+ """Checks the plugin setting to decide whether the calculation
+ should be done using the EBU R128 standard and use R128_ tags instead.
+ """
+ return item.format in self.r128_whitelist
+
+ def track_requires_gain(self, item):
+ return self.overwrite or \
+ (self.should_use_r128(item) and not item.r128_track_gain) or \
+ (not self.should_use_r128(item) and
+ (not item.rg_track_gain or not item.rg_track_peak))
+
+ def album_requires_gain(self, album):
+ # Skip calculating gain only when *all* files don't need
+ # recalculation. This way, if any file among an album's tracks
+ # needs recalculation, we still get an accurate album gain
+ # value.
+ return self.overwrite or \
+ any([self.should_use_r128(item) and
+ (not item.r128_track_gain or not item.r128_album_gain)
+ for item in album.items()]) or \
+ any([not self.should_use_r128(item) and
+ (not item.rg_album_gain or not item.rg_album_peak)
+ for item in album.items()])
+
+ def store_track_gain(self, item, track_gain):
+ item.rg_track_gain = track_gain.gain
+ item.rg_track_peak = track_gain.peak
+ item.store()
+ self._log.debug('applied track gain {0} LU, peak {1} of FS',
+ item.rg_track_gain, item.rg_track_peak)
+
+ def store_album_gain(self, item, album_gain):
+ item.rg_album_gain = album_gain.gain
+ item.rg_album_peak = album_gain.peak
+ item.store()
+ self._log.debug('applied album gain {0} LU, peak {1} of FS',
+ item.rg_album_gain, item.rg_album_peak)
+
+ def store_track_r128_gain(self, item, track_gain):
+ item.r128_track_gain = track_gain.gain
+ item.store()
+
+ self._log.debug('applied r128 track gain {0} LU',
+ item.r128_track_gain)
+
+ def store_album_r128_gain(self, item, album_gain):
+ item.r128_album_gain = album_gain.gain
+ item.store()
+ self._log.debug('applied r128 album gain {0} LU',
+ item.r128_album_gain)
+
+ def tag_specific_values(self, items):
+ """Return some tag specific values.
+
+ Returns a tuple (store_track_gain, store_album_gain, target_level,
+ peak_method).
+ """
+ if any([self.should_use_r128(item) for item in items]):
+ store_track_gain = self.store_track_r128_gain
+ store_album_gain = self.store_album_r128_gain
+ target_level = self.config['r128_targetlevel'].as_number()
+ peak = Peak.none # R128_* tags do not store the track/album peak
+ else:
+ store_track_gain = self.store_track_gain
+ store_album_gain = self.store_album_gain
+ target_level = self.config['targetlevel'].as_number()
+ peak = self._peak_method
+
+ return store_track_gain, store_album_gain, target_level, peak
+
+ def handle_album(self, album, write, force=False):
+ """Compute album and track replay gain store it in all of the
+ album's items.
+
+ If ``write`` is truthy then ``item.write()`` is called for each
+ item. If replay gain information is already present in all
+ items, nothing is done.
+ """
+ if not force and not self.album_requires_gain(album):
+ self._log.info('Skipping album {0}', album)
+ return
+
+ if (any([self.should_use_r128(item) for item in album.items()]) and not
+ all([self.should_use_r128(item) for item in album.items()])):
+ self._log.error(
+ "Cannot calculate gain for album {0} (incompatible formats)",
+ album)
+ return
+
+ self._log.info('analyzing {0}', album)
+
+ tag_vals = self.tag_specific_values(album.items())
+ store_track_gain, store_album_gain, target_level, peak = tag_vals
+
+ discs = {}
+ if self.per_disc:
+ for item in album.items():
+ if discs.get(item.disc) is None:
+ discs[item.disc] = []
+ discs[item.disc].append(item)
+ else:
+ discs[1] = album.items()
+
+ for discnumber, items in discs.items():
+ def _store_album(album_gain):
+ if not album_gain or not album_gain.album_gain \
+ or len(album_gain.track_gains) != len(items):
+ # In some cases, backends fail to produce a valid
+ # `album_gain` without throwing FatalReplayGainError
+ # => raise non-fatal exception & continue
+ raise ReplayGainError(
+ "ReplayGain backend `{}` failed "
+ "for some tracks in album {}"
+ .format(self.backend_name, album)
+ )
+ for item, track_gain in zip(items,
+ album_gain.track_gains):
+ store_track_gain(item, track_gain)
+ store_album_gain(item, album_gain.album_gain)
+ if write:
+ item.try_write()
+ self._log.debug('done analyzing {0}', item)
+
+ try:
+ self._apply(
+ self.backend_instance.compute_album_gain, args=(),
+ kwds={
+ "items": list(items),
+ "target_level": target_level,
+ "peak": peak
+ },
+ callback=_store_album
+ )
+ except ReplayGainError as e:
+ self._log.info("ReplayGain error: {0}", e)
+ except FatalReplayGainError as e:
+ raise ui.UserError(
+ f"Fatal replay gain error: {e}")
+
+ def handle_track(self, item, write, force=False):
+ """Compute track replay gain and store it in the item.
+
+ If ``write`` is truthy then ``item.write()`` is called to write
+ the data to disk. If replay gain information is already present
+ in the item, nothing is done.
+ """
+ if not force and not self.track_requires_gain(item):
+ self._log.info('Skipping track {0}', item)
+ return
+
+ tag_vals = self.tag_specific_values([item])
+ store_track_gain, store_album_gain, target_level, peak = tag_vals
+
+ def _store_track(track_gains):
+ if not track_gains or len(track_gains) != 1:
+ # In some cases, backends fail to produce a valid
+ # `track_gains` without throwing FatalReplayGainError
+ # => raise non-fatal exception & continue
+ raise ReplayGainError(
+ "ReplayGain backend `{}` failed for track {}"
+ .format(self.backend_name, item)
+ )
+
+ store_track_gain(item, track_gains[0])
+ if write:
+ item.try_write()
+ self._log.debug('done analyzing {0}', item)
+
+ try:
+ self._apply(
+ self.backend_instance.compute_track_gain, args=(),
+ kwds={
+ "items": [item],
+ "target_level": target_level,
+ "peak": peak,
+ },
+ callback=_store_track
+ )
+ except ReplayGainError as e:
+ self._log.info("ReplayGain error: {0}", e)
+ except FatalReplayGainError as e:
+ raise ui.UserError(f"Fatal replay gain error: {e}")
+
+ def _has_pool(self):
+ """Check whether a `ThreadPool` is running instance in `self.pool`
+ """
+ if hasattr(self, 'pool'):
+ if isinstance(self.pool, ThreadPool) and self.pool._state == RUN:
+ return True
+ return False
+
+ def open_pool(self, threads):
+ """Open a `ThreadPool` instance in `self.pool`
+ """
+ if not self._has_pool() and self.backend_instance.do_parallel:
+ self.pool = ThreadPool(threads)
+ self.exc_queue = queue.Queue()
+
+ signal.signal(signal.SIGINT, self._interrupt)
+
+ self.exc_watcher = ExceptionWatcher(
+ self.exc_queue, # threads push exceptions here
+ self.terminate_pool # abort once an exception occurs
+ )
+ self.exc_watcher.start()
+
+ def _apply(self, func, args, kwds, callback):
+ if self._has_pool():
+ def catch_exc(func, exc_queue, log):
+ """Wrapper to catch raised exceptions in threads
+ """
+ def wfunc(*args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except ReplayGainError as e:
+ log.info(e.args[0]) # log non-fatal exceptions
+ except Exception:
+ exc_queue.put(sys.exc_info())
+ return wfunc
+
+ # Wrap function and callback to catch exceptions
+ func = catch_exc(func, self.exc_queue, self._log)
+ callback = catch_exc(callback, self.exc_queue, self._log)
+
+ self.pool.apply_async(func, args, kwds, callback)
+ else:
+ callback(func(*args, **kwds))
+
+ def terminate_pool(self):
+ """Terminate the `ThreadPool` instance in `self.pool`
+ (e.g. stop execution in case of exception)
+ """
+ # Don't call self._as_pool() here,
+ # self.pool._state may not be == RUN
+ if hasattr(self, 'pool') and isinstance(self.pool, ThreadPool):
+ self.pool.terminate()
+ self.pool.join()
+ # self.exc_watcher.join()
+
+ def _interrupt(self, signal, frame):
+ try:
+ self._log.info('interrupted')
+ self.terminate_pool()
+ sys.exit(0)
+ except SystemExit:
+ # Silence raised SystemExit ~ exit(0)
+ pass
+
+ def close_pool(self):
+ """Close the `ThreadPool` instance in `self.pool` (if there is one)
+ """
+ if self._has_pool():
+ self.pool.close()
+ self.pool.join()
+ self.exc_watcher.join()
+
+ def import_begin(self, session):
+ """Handle `import_begin` event -> open pool
+ """
+ threads = self.config['threads'].get(int)
+
+ if self.config['parallel_on_import'] \
+ and self.config['auto'] \
+ and threads:
+ self.open_pool(threads)
+
+ def import_end(self, paths):
+ """Handle `import` event -> close pool
+ """
+ self.close_pool()
+
+ def imported(self, session, task):
+ """Add replay gain info to items or albums of ``task``.
+ """
+ if self.config['auto']:
+ if task.is_album:
+ self.handle_album(task.album, False)
+ else:
+ self.handle_track(task.item, False)
+
+ def command_func(self, lib, opts, args):
+ try:
+ write = ui.should_write(opts.write)
+ force = opts.force
+
+ # Bypass self.open_pool() if called with `--threads 0`
+ if opts.threads != 0:
+ threads = opts.threads or self.config['threads'].get(int)
+ self.open_pool(threads)
+
+ if opts.album:
+ albums = lib.albums(ui.decargs(args))
+ self._log.info(
+ "Analyzing {} albums ~ {} backend..."
+ .format(len(albums), self.backend_name)
+ )
+ for album in albums:
+ self.handle_album(album, write, force)
+ else:
+ items = lib.items(ui.decargs(args))
+ self._log.info(
+ "Analyzing {} tracks ~ {} backend..."
+ .format(len(items), self.backend_name)
+ )
+ for item in items:
+ self.handle_track(item, write, force)
+
+ self.close_pool()
+ except (SystemExit, KeyboardInterrupt):
+ # Silence interrupt exceptions
+ pass
+
+ def commands(self):
+ """Return the "replaygain" ui subcommand.
+ """
+ cmd = ui.Subcommand('replaygain', help='analyze for ReplayGain')
+ cmd.parser.add_album_option()
+ cmd.parser.add_option(
+ "-t", "--threads", dest="threads", type=int,
+ help='change the number of threads, \
+ defaults to maximum available processors'
+ )
+ cmd.parser.add_option(
+ "-f", "--force", dest="force", action="store_true", default=False,
+ help="analyze all files, including those that "
+ "already have ReplayGain metadata")
+ cmd.parser.add_option(
+ "-w", "--write", default=None, action="store_true",
+ help="write new metadata to files' tags")
+ cmd.parser.add_option(
+ "-W", "--nowrite", dest="write", action="store_false",
+ help="don't write metadata (opposite of -w)")
+ cmd.func = self.command_func
+ return [cmd]
diff --git a/lib/beetsplug/rewrite.py b/lib/beetsplug/rewrite.py
new file mode 100644
index 00000000..e02e4080
--- /dev/null
+++ b/lib/beetsplug/rewrite.py
@@ -0,0 +1,73 @@
+# This file is part of beets.
+# Copyright 2016, Adrian Sampson.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Uses user-specified rewriting rules to canonicalize names for path
+formats.
+"""
+
+import re
+from collections import defaultdict
+
+from beets.plugins import BeetsPlugin
+from beets import ui
+from beets import library
+
+
+def rewriter(field, rules):
+ """Create a template field function that rewrites the given field
+ with the given rewriting rules. ``rules`` must be a list of
+ (pattern, replacement) pairs.
+ """
+ def fieldfunc(item):
+ value = item._values_fixed[field]
+ for pattern, replacement in rules:
+ if pattern.match(value.lower()):
+ # Rewrite activated.
+ return replacement
+ # Not activated; return original value.
+ return value
+ return fieldfunc
+
+
+class RewritePlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+
+ self.config.add({})
+
+ # Gather all the rewrite rules for each field.
+ rules = defaultdict(list)
+ for key, view in self.config.items():
+ value = view.as_str()
+ try:
+ fieldname, pattern = key.split(None, 1)
+ except ValueError:
+ raise ui.UserError("invalid rewrite specification")
+ if fieldname not in library.Item._fields:
+ raise ui.UserError("invalid field name (%s) in rewriter" %
+ fieldname)
+ self._log.debug('adding template field {0}', key)
+ pattern = re.compile(pattern.lower())
+ rules[fieldname].append((pattern, value))
+ if fieldname == 'artist':
+ # Special case for the artist field: apply the same
+ # rewrite for "albumartist" as well.
+ rules['albumartist'].append((pattern, value))
+
+ # Replace each template field with the new rewriter function.
+ for fieldname, fieldrules in rules.items():
+ getter = rewriter(fieldname, fieldrules)
+ self.template_fields[fieldname] = getter
+ if fieldname in library.Album._fields:
+ self.album_template_fields[fieldname] = getter
diff --git a/lib/beetsplug/scrub.py b/lib/beetsplug/scrub.py
new file mode 100644
index 00000000..d8044668
--- /dev/null
+++ b/lib/beetsplug/scrub.py
@@ -0,0 +1,149 @@
+# This file is part of beets.
+# Copyright 2016, Adrian Sampson.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Cleans extraneous metadata from files' tags via a command or
+automatically whenever tags are written.
+"""
+
+
+from beets.plugins import BeetsPlugin
+from beets import ui
+from beets import util
+from beets import config
+import mediafile
+import mutagen
+
+_MUTAGEN_FORMATS = {
+ 'asf': 'ASF',
+ 'apev2': 'APEv2File',
+ 'flac': 'FLAC',
+ 'id3': 'ID3FileType',
+ 'mp3': 'MP3',
+ 'mp4': 'MP4',
+ 'oggflac': 'OggFLAC',
+ 'oggspeex': 'OggSpeex',
+ 'oggtheora': 'OggTheora',
+ 'oggvorbis': 'OggVorbis',
+ 'oggopus': 'OggOpus',
+ 'trueaudio': 'TrueAudio',
+ 'wavpack': 'WavPack',
+ 'monkeysaudio': 'MonkeysAudio',
+ 'optimfrog': 'OptimFROG',
+}
+
+
+class ScrubPlugin(BeetsPlugin):
+ """Removes extraneous metadata from files' tags."""
+ def __init__(self):
+ super().__init__()
+ self.config.add({
+ 'auto': True,
+ })
+
+ if self.config['auto']:
+ self.register_listener("import_task_files", self.import_task_files)
+
+ def commands(self):
+ def scrub_func(lib, opts, args):
+ # Walk through matching files and remove tags.
+ for item in lib.items(ui.decargs(args)):
+ self._log.info('scrubbing: {0}',
+ util.displayable_path(item.path))
+ self._scrub_item(item, opts.write)
+
+ scrub_cmd = ui.Subcommand('scrub', help='clean audio tags')
+ scrub_cmd.parser.add_option(
+ '-W', '--nowrite', dest='write',
+ action='store_false', default=True,
+ help='leave tags empty')
+ scrub_cmd.func = scrub_func
+
+ return [scrub_cmd]
+
+ @staticmethod
+ def _mutagen_classes():
+ """Get a list of file type classes from the Mutagen module.
+ """
+ classes = []
+ for modname, clsname in _MUTAGEN_FORMATS.items():
+ mod = __import__(f'mutagen.{modname}',
+ fromlist=[clsname])
+ classes.append(getattr(mod, clsname))
+ return classes
+
+ def _scrub(self, path):
+ """Remove all tags from a file.
+ """
+ for cls in self._mutagen_classes():
+ # Try opening the file with this type, but just skip in the
+ # event of any error.
+ try:
+ f = cls(util.syspath(path))
+ except Exception:
+ continue
+ if f.tags is None:
+ continue
+
+ # Remove the tag for this type.
+ try:
+ f.delete()
+ except NotImplementedError:
+ # Some Mutagen metadata subclasses (namely, ASFTag) do not
+ # support .delete(), presumably because it is impossible to
+ # remove them. In this case, we just remove all the tags.
+ for tag in f.keys():
+ del f[tag]
+ f.save()
+ except (OSError, mutagen.MutagenError) as exc:
+ self._log.error('could not scrub {0}: {1}',
+ util.displayable_path(path), exc)
+
+ def _scrub_item(self, item, restore=True):
+ """Remove tags from an Item's associated file and, if `restore`
+ is enabled, write the database's tags back to the file.
+ """
+ # Get album art if we need to restore it.
+ if restore:
+ try:
+ mf = mediafile.MediaFile(util.syspath(item.path),
+ config['id3v23'].get(bool))
+ except mediafile.UnreadableFileError as exc:
+ self._log.error('could not open file to scrub: {0}',
+ exc)
+ return
+ images = mf.images
+
+ # Remove all tags.
+ self._scrub(item.path)
+
+ # Restore tags, if enabled.
+ if restore:
+ self._log.debug('writing new tags after scrub')
+ item.try_write()
+ if images:
+ self._log.debug('restoring art')
+ try:
+ mf = mediafile.MediaFile(util.syspath(item.path),
+ config['id3v23'].get(bool))
+ mf.images = images
+ mf.save()
+ except mediafile.UnreadableFileError as exc:
+ self._log.error('could not write tags: {0}', exc)
+
+ def import_task_files(self, session, task):
+ """Automatically scrub imported files."""
+ for item in task.imported_items():
+ self._log.debug('auto-scrubbing {0}',
+ util.displayable_path(item.path))
+ self._scrub_item(item)
diff --git a/lib/beetsplug/smartplaylist.py b/lib/beetsplug/smartplaylist.py
new file mode 100644
index 00000000..4c921ecc
--- /dev/null
+++ b/lib/beetsplug/smartplaylist.py
@@ -0,0 +1,224 @@
+# This file is part of beets.
+# Copyright 2016, Dang Mai .
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Generates smart playlists based on beets queries.
+"""
+
+
+from beets.plugins import BeetsPlugin
+from beets import ui
+from beets.util import (mkdirall, normpath, sanitize_path, syspath,
+ bytestring_path, path_as_posix)
+from beets.library import Item, Album, parse_query_string
+from beets.dbcore import OrQuery
+from beets.dbcore.query import MultipleSort, ParsingError
+import os
+
+try:
+ from urllib.request import pathname2url
+except ImportError:
+ # python2 is a bit different
+ from urllib import pathname2url
+
+
+class SmartPlaylistPlugin(BeetsPlugin):
+
+ def __init__(self):
+ super().__init__()
+ self.config.add({
+ 'relative_to': None,
+ 'playlist_dir': '.',
+ 'auto': True,
+ 'playlists': [],
+ 'forward_slash': False,
+ 'prefix': '',
+ 'urlencode': False,
+ })
+
+ self.config['prefix'].redact = True # May contain username/password.
+ self._matched_playlists = None
+ self._unmatched_playlists = None
+
+ if self.config['auto']:
+ self.register_listener('database_change', self.db_change)
+
+ def commands(self):
+ spl_update = ui.Subcommand(
+ 'splupdate',
+ help='update the smart playlists. Playlist names may be '
+ 'passed as arguments.'
+ )
+ spl_update.func = self.update_cmd
+ return [spl_update]
+
+ def update_cmd(self, lib, opts, args):
+ self.build_queries()
+ if args:
+ args = set(ui.decargs(args))
+ for a in list(args):
+ if not a.endswith(".m3u"):
+ args.add(f"{a}.m3u")
+
+ playlists = {(name, q, a_q)
+ for name, q, a_q in self._unmatched_playlists
+ if name in args}
+ if not playlists:
+ raise ui.UserError(
+ 'No playlist matching any of {} found'.format(
+ [name for name, _, _ in self._unmatched_playlists])
+ )
+
+ self._matched_playlists = playlists
+ self._unmatched_playlists -= playlists
+ else:
+ self._matched_playlists = self._unmatched_playlists
+
+ self.update_playlists(lib)
+
+ def build_queries(self):
+ """
+ Instantiate queries for the playlists.
+
+ Each playlist has 2 queries: one or items one for albums, each with a
+ sort. We must also remember its name. _unmatched_playlists is a set of
+ tuples (name, (q, q_sort), (album_q, album_q_sort)).
+
+ sort may be any sort, or NullSort, or None. None and NullSort are
+ equivalent and both eval to False.
+ More precisely
+ - it will be NullSort when a playlist query ('query' or 'album_query')
+ is a single item or a list with 1 element
+ - it will be None when there are multiple items i a query
+ """
+ self._unmatched_playlists = set()
+ self._matched_playlists = set()
+
+ for playlist in self.config['playlists'].get(list):
+ if 'name' not in playlist:
+ self._log.warning("playlist configuration is missing name")
+ continue
+
+ playlist_data = (playlist['name'],)
+ try:
+ for key, model_cls in (('query', Item),
+ ('album_query', Album)):
+ qs = playlist.get(key)
+ if qs is None:
+ query_and_sort = None, None
+ elif isinstance(qs, str):
+ query_and_sort = parse_query_string(qs, model_cls)
+ elif len(qs) == 1:
+ query_and_sort = parse_query_string(qs[0], model_cls)
+ else:
+ # multiple queries and sorts
+ queries, sorts = zip(*(parse_query_string(q, model_cls)
+ for q in qs))
+ query = OrQuery(queries)
+ final_sorts = []
+ for s in sorts:
+ if s:
+ if isinstance(s, MultipleSort):
+ final_sorts += s.sorts
+ else:
+ final_sorts.append(s)
+ if not final_sorts:
+ sort = None
+ elif len(final_sorts) == 1:
+ sort, = final_sorts
+ else:
+ sort = MultipleSort(final_sorts)
+ query_and_sort = query, sort
+
+ playlist_data += (query_and_sort,)
+
+ except ParsingError as exc:
+ self._log.warning("invalid query in playlist {}: {}",
+ playlist['name'], exc)
+ continue
+
+ self._unmatched_playlists.add(playlist_data)
+
+ def matches(self, model, query, album_query):
+ if album_query and isinstance(model, Album):
+ return album_query.match(model)
+ if query and isinstance(model, Item):
+ return query.match(model)
+ return False
+
+ def db_change(self, lib, model):
+ if self._unmatched_playlists is None:
+ self.build_queries()
+
+ for playlist in self._unmatched_playlists:
+ n, (q, _), (a_q, _) = playlist
+ if self.matches(model, q, a_q):
+ self._log.debug(
+ "{0} will be updated because of {1}", n, model)
+ self._matched_playlists.add(playlist)
+ self.register_listener('cli_exit', self.update_playlists)
+
+ self._unmatched_playlists -= self._matched_playlists
+
+ def update_playlists(self, lib):
+ self._log.info("Updating {0} smart playlists...",
+ len(self._matched_playlists))
+
+ playlist_dir = self.config['playlist_dir'].as_filename()
+ playlist_dir = bytestring_path(playlist_dir)
+ relative_to = self.config['relative_to'].get()
+ if relative_to:
+ relative_to = normpath(relative_to)
+
+ # Maps playlist filenames to lists of track filenames.
+ m3us = {}
+
+ for playlist in self._matched_playlists:
+ name, (query, q_sort), (album_query, a_q_sort) = playlist
+ self._log.debug("Creating playlist {0}", name)
+ items = []
+
+ if query:
+ items.extend(lib.items(query, q_sort))
+ if album_query:
+ for album in lib.albums(album_query, a_q_sort):
+ items.extend(album.items())
+
+ # As we allow tags in the m3u names, we'll need to iterate through
+ # the items and generate the correct m3u file names.
+ for item in items:
+ m3u_name = item.evaluate_template(name, True)
+ m3u_name = sanitize_path(m3u_name, lib.replacements)
+ if m3u_name not in m3us:
+ m3us[m3u_name] = []
+ item_path = item.path
+ if relative_to:
+ item_path = os.path.relpath(item.path, relative_to)
+ if item_path not in m3us[m3u_name]:
+ m3us[m3u_name].append(item_path)
+
+ prefix = bytestring_path(self.config['prefix'].as_str())
+ # Write all of the accumulated track lists to files.
+ for m3u in m3us:
+ m3u_path = normpath(os.path.join(playlist_dir,
+ bytestring_path(m3u)))
+ mkdirall(m3u_path)
+ with open(syspath(m3u_path), 'wb') as f:
+ for path in m3us[m3u]:
+ if self.config['forward_slash'].get():
+ path = path_as_posix(path)
+ if self.config['urlencode']:
+ path = bytestring_path(pathname2url(path))
+ f.write(prefix + path + b'\n')
+
+ self._log.info("{0} playlists updated", len(self._matched_playlists))
diff --git a/lib/beetsplug/sonosupdate.py b/lib/beetsplug/sonosupdate.py
new file mode 100644
index 00000000..aeb211d8
--- /dev/null
+++ b/lib/beetsplug/sonosupdate.py
@@ -0,0 +1,46 @@
+# This file is part of beets.
+# Copyright 2018, Tobias Sauerwein.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Updates a Sonos library whenever the beets library is changed.
+This is based on the Kodi Update plugin.
+"""
+
+from beets.plugins import BeetsPlugin
+import soco
+
+
+class SonosUpdate(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+ self.register_listener('database_change', self.listen_for_db_change)
+
+ def listen_for_db_change(self, lib, model):
+ """Listens for beets db change and register the update"""
+ self.register_listener('cli_exit', self.update)
+
+ def update(self, lib):
+ """When the client exists try to send refresh request to a Sonos
+ controler.
+ """
+ self._log.info('Requesting a Sonos library update...')
+
+ device = soco.discovery.any_soco()
+
+ if device:
+ device.music_library.start_library_update()
+ else:
+ self._log.warning('Could not find a Sonos device.')
+ return
+
+ self._log.info('Sonos update triggered')
diff --git a/lib/beetsplug/spotify.py b/lib/beetsplug/spotify.py
new file mode 100644
index 00000000..2529160d
--- /dev/null
+++ b/lib/beetsplug/spotify.py
@@ -0,0 +1,527 @@
+# This file is part of beets.
+# Copyright 2019, Rahul Ahuja.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Adds Spotify release and track search support to the autotagger, along with
+Spotify playlist construction.
+"""
+
+import re
+import json
+import base64
+import webbrowser
+import collections
+
+import unidecode
+import requests
+import confuse
+
+from beets import ui
+from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.plugins import MetadataSourcePlugin, BeetsPlugin
+
+
+class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
+ data_source = 'Spotify'
+
+ # Base URLs for the Spotify API
+ # Documentation: https://developer.spotify.com/web-api
+ oauth_token_url = 'https://accounts.spotify.com/api/token'
+ open_track_url = 'https://open.spotify.com/track/'
+ search_url = 'https://api.spotify.com/v1/search'
+ album_url = 'https://api.spotify.com/v1/albums/'
+ track_url = 'https://api.spotify.com/v1/tracks/'
+
+ # Spotify IDs consist of 22 alphanumeric characters
+ # (zero-left-padded base62 representation of randomly generated UUID4)
+ id_regex = {
+ 'pattern': r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})',
+ 'match_group': 2,
+ }
+
+ def __init__(self):
+ super().__init__()
+ self.config.add(
+ {
+ 'mode': 'list',
+ 'tiebreak': 'popularity',
+ 'show_failures': False,
+ 'artist_field': 'albumartist',
+ 'album_field': 'album',
+ 'track_field': 'title',
+ 'region_filter': None,
+ 'regex': [],
+ 'client_id': '4e414367a1d14c75a5c5129a627fcab8',
+ 'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc',
+ 'tokenfile': 'spotify_token.json',
+ }
+ )
+ self.config['client_secret'].redact = True
+
+ self.tokenfile = self.config['tokenfile'].get(
+ confuse.Filename(in_app_dir=True)
+ ) # Path to the JSON file for storing the OAuth access token.
+ self.setup()
+
+ def setup(self):
+ """Retrieve previously saved OAuth token or generate a new one."""
+ try:
+ with open(self.tokenfile) as f:
+ token_data = json.load(f)
+ except OSError:
+ self._authenticate()
+ else:
+ self.access_token = token_data['access_token']
+
+ def _authenticate(self):
+ """Request an access token via the Client Credentials Flow:
+ https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
+ """
+ headers = {
+ 'Authorization': 'Basic {}'.format(
+ base64.b64encode(
+ ':'.join(
+ self.config[k].as_str()
+ for k in ('client_id', 'client_secret')
+ ).encode()
+ ).decode()
+ )
+ }
+ response = requests.post(
+ self.oauth_token_url,
+ data={'grant_type': 'client_credentials'},
+ headers=headers,
+ )
+ try:
+ response.raise_for_status()
+ except requests.exceptions.HTTPError as e:
+ raise ui.UserError(
+ 'Spotify authorization failed: {}\n{}'.format(
+ e, response.text
+ )
+ )
+ self.access_token = response.json()['access_token']
+
+ # Save the token for later use.
+ self._log.debug(
+ '{} access token: {}', self.data_source, self.access_token
+ )
+ with open(self.tokenfile, 'w') as f:
+ json.dump({'access_token': self.access_token}, f)
+
+ def _handle_response(self, request_type, url, params=None):
+ """Send a request, reauthenticating if necessary.
+
+ :param request_type: Type of :class:`Request` constructor,
+ e.g. ``requests.get``, ``requests.post``, etc.
+ :type request_type: function
+ :param url: URL for the new :class:`Request` object.
+ :type url: str
+ :param params: (optional) list of tuples or bytes to send
+ in the query string for the :class:`Request`.
+ :type params: dict
+ :return: JSON data for the class:`Response ` object.
+ :rtype: dict
+ """
+ response = request_type(
+ url,
+ headers={'Authorization': f'Bearer {self.access_token}'},
+ params=params,
+ )
+ if response.status_code != 200:
+ if 'token expired' in response.text:
+ self._log.debug(
+ '{} access token has expired. Reauthenticating.',
+ self.data_source,
+ )
+ self._authenticate()
+ return self._handle_response(request_type, url, params=params)
+ else:
+ raise ui.UserError(
+ '{} API error:\n{}\nURL:\n{}\nparams:\n{}'.format(
+ self.data_source, response.text, url, params
+ )
+ )
+ return response.json()
+
+ def album_for_id(self, album_id):
+ """Fetch an album by its Spotify ID or URL and return an
+ AlbumInfo object or None if the album is not found.
+
+ :param album_id: Spotify ID or URL for the album
+ :type album_id: str
+ :return: AlbumInfo object for album
+ :rtype: beets.autotag.hooks.AlbumInfo or None
+ """
+ spotify_id = self._get_id('album', album_id)
+ if spotify_id is None:
+ return None
+
+ album_data = self._handle_response(
+ requests.get, self.album_url + spotify_id
+ )
+ artist, artist_id = self.get_artist(album_data['artists'])
+
+ date_parts = [
+ int(part) for part in album_data['release_date'].split('-')
+ ]
+
+ release_date_precision = album_data['release_date_precision']
+ if release_date_precision == 'day':
+ year, month, day = date_parts
+ elif release_date_precision == 'month':
+ year, month = date_parts
+ day = None
+ elif release_date_precision == 'year':
+ year = date_parts[0]
+ month = None
+ day = None
+ else:
+ raise ui.UserError(
+ "Invalid `release_date_precision` returned "
+ "by {} API: '{}'".format(
+ self.data_source, release_date_precision
+ )
+ )
+
+ tracks = []
+ medium_totals = collections.defaultdict(int)
+ for i, track_data in enumerate(album_data['tracks']['items'], start=1):
+ track = self._get_track(track_data)
+ track.index = i
+ medium_totals[track.medium] += 1
+ tracks.append(track)
+ for track in tracks:
+ track.medium_total = medium_totals[track.medium]
+
+ return AlbumInfo(
+ album=album_data['name'],
+ album_id=spotify_id,
+ artist=artist,
+ artist_id=artist_id,
+ tracks=tracks,
+ albumtype=album_data['album_type'],
+ va=len(album_data['artists']) == 1
+ and artist.lower() == 'various artists',
+ year=year,
+ month=month,
+ day=day,
+ label=album_data['label'],
+ mediums=max(medium_totals.keys()),
+ data_source=self.data_source,
+ data_url=album_data['external_urls']['spotify'],
+ )
+
+ def _get_track(self, track_data):
+ """Convert a Spotify track object dict to a TrackInfo object.
+
+ :param track_data: Simplified track object
+ (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
+ :type track_data: dict
+ :return: TrackInfo object for track
+ :rtype: beets.autotag.hooks.TrackInfo
+ """
+ artist, artist_id = self.get_artist(track_data['artists'])
+ return TrackInfo(
+ title=track_data['name'],
+ track_id=track_data['id'],
+ artist=artist,
+ artist_id=artist_id,
+ length=track_data['duration_ms'] / 1000,
+ index=track_data['track_number'],
+ medium=track_data['disc_number'],
+ medium_index=track_data['track_number'],
+ data_source=self.data_source,
+ data_url=track_data['external_urls']['spotify'],
+ )
+
+ def track_for_id(self, track_id=None, track_data=None):
+ """Fetch a track by its Spotify ID or URL and return a
+ TrackInfo object or None if the track is not found.
+
+ :param track_id: (Optional) Spotify ID or URL for the track. Either
+ ``track_id`` or ``track_data`` must be provided.
+ :type track_id: str
+ :param track_data: (Optional) Simplified track object dict. May be
+ provided instead of ``track_id`` to avoid unnecessary API calls.
+ :type track_data: dict
+ :return: TrackInfo object for track
+ :rtype: beets.autotag.hooks.TrackInfo or None
+ """
+ if track_data is None:
+ spotify_id = self._get_id('track', track_id)
+ if spotify_id is None:
+ return None
+ track_data = self._handle_response(
+ requests.get, self.track_url + spotify_id
+ )
+ track = self._get_track(track_data)
+
+ # Get album's tracks to set `track.index` (position on the entire
+ # release) and `track.medium_total` (total number of tracks on
+ # the track's disc).
+ album_data = self._handle_response(
+ requests.get, self.album_url + track_data['album']['id']
+ )
+ medium_total = 0
+ for i, track_data in enumerate(album_data['tracks']['items'], start=1):
+ if track_data['disc_number'] == track.medium:
+ medium_total += 1
+ if track_data['id'] == track.track_id:
+ track.index = i
+ track.medium_total = medium_total
+ return track
+
+ @staticmethod
+ def _construct_search_query(filters=None, keywords=''):
+ """Construct a query string with the specified filters and keywords to
+ be provided to the Spotify Search API
+ (https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines).
+
+ :param filters: (Optional) Field filters to apply.
+ :type filters: dict
+ :param keywords: (Optional) Query keywords to use.
+ :type keywords: str
+ :return: Query string to be provided to the Search API.
+ :rtype: str
+ """
+ query_components = [
+ keywords,
+ ' '.join(':'.join((k, v)) for k, v in filters.items()),
+ ]
+ query = ' '.join([q for q in query_components if q])
+ if not isinstance(query, str):
+ query = query.decode('utf8')
+ return unidecode.unidecode(query)
+
+ def _search_api(self, query_type, filters=None, keywords=''):
+ """Query the Spotify Search API for the specified ``keywords``, applying
+ the provided ``filters``.
+
+ :param query_type: Item type to search across. Valid types are:
+ 'album', 'artist', 'playlist', and 'track'.
+ :type query_type: str
+ :param filters: (Optional) Field filters to apply.
+ :type filters: dict
+ :param keywords: (Optional) Query keywords to use.
+ :type keywords: str
+ :return: JSON data for the class:`Response ` object or None
+ if no search results are returned.
+ :rtype: dict or None
+ """
+ query = self._construct_search_query(
+ keywords=keywords, filters=filters
+ )
+ if not query:
+ return None
+ self._log.debug(
+ f"Searching {self.data_source} for '{query}'"
+ )
+ response_data = (
+ self._handle_response(
+ requests.get,
+ self.search_url,
+ params={'q': query, 'type': query_type},
+ )
+ .get(query_type + 's', {})
+ .get('items', [])
+ )
+ self._log.debug(
+ "Found {} result(s) from {} for '{}'",
+ len(response_data),
+ self.data_source,
+ query,
+ )
+ return response_data
+
+ def commands(self):
+ def queries(lib, opts, args):
+ success = self._parse_opts(opts)
+ if success:
+ results = self._match_library_tracks(lib, ui.decargs(args))
+ self._output_match_results(results)
+
+ spotify_cmd = ui.Subcommand(
+ 'spotify', help=f'build a {self.data_source} playlist'
+ )
+ spotify_cmd.parser.add_option(
+ '-m',
+ '--mode',
+ action='store',
+ help='"open" to open {} with playlist, '
+ '"list" to print (default)'.format(self.data_source),
+ )
+ spotify_cmd.parser.add_option(
+ '-f',
+ '--show-failures',
+ action='store_true',
+ dest='show_failures',
+ help='list tracks that did not match a {} ID'.format(
+ self.data_source
+ ),
+ )
+ spotify_cmd.func = queries
+ return [spotify_cmd]
+
+ def _parse_opts(self, opts):
+ if opts.mode:
+ self.config['mode'].set(opts.mode)
+
+ if opts.show_failures:
+ self.config['show_failures'].set(True)
+
+ if self.config['mode'].get() not in ['list', 'open']:
+ self._log.warning(
+ '{0} is not a valid mode', self.config['mode'].get()
+ )
+ return False
+
+ self.opts = opts
+ return True
+
+ def _match_library_tracks(self, library, keywords):
+ """Get a list of simplified track object dicts for library tracks
+ matching the specified ``keywords``.
+
+ :param library: beets library object to query.
+ :type library: beets.library.Library
+ :param keywords: Query to match library items against.
+ :type keywords: str
+ :return: List of simplified track object dicts for library items
+ matching the specified query.
+ :rtype: list[dict]
+ """
+ results = []
+ failures = []
+
+ items = library.items(keywords)
+
+ if not items:
+ self._log.debug(
+ 'Your beets query returned no items, skipping {}.',
+ self.data_source,
+ )
+ return
+
+ self._log.info('Processing {} tracks...', len(items))
+
+ for item in items:
+ # Apply regex transformations if provided
+ for regex in self.config['regex'].get():
+ if (
+ not regex['field']
+ or not regex['search']
+ or not regex['replace']
+ ):
+ continue
+
+ value = item[regex['field']]
+ item[regex['field']] = re.sub(
+ regex['search'], regex['replace'], value
+ )
+
+ # Custom values can be passed in the config (just in case)
+ artist = item[self.config['artist_field'].get()]
+ album = item[self.config['album_field'].get()]
+ keywords = item[self.config['track_field'].get()]
+
+ # Query the Web API for each track, look for the items' JSON data
+ query_filters = {'artist': artist, 'album': album}
+ response_data_tracks = self._search_api(
+ query_type='track', keywords=keywords, filters=query_filters
+ )
+ if not response_data_tracks:
+ query = self._construct_search_query(
+ keywords=keywords, filters=query_filters
+ )
+ failures.append(query)
+ continue
+
+ # Apply market filter if requested
+ region_filter = self.config['region_filter'].get()
+ if region_filter:
+ response_data_tracks = [
+ track_data
+ for track_data in response_data_tracks
+ if region_filter in track_data['available_markets']
+ ]
+
+ if (
+ len(response_data_tracks) == 1
+ or self.config['tiebreak'].get() == 'first'
+ ):
+ self._log.debug(
+ '{} track(s) found, count: {}',
+ self.data_source,
+ len(response_data_tracks),
+ )
+ chosen_result = response_data_tracks[0]
+ else:
+ # Use the popularity filter
+ self._log.debug(
+ 'Most popular track chosen, count: {}',
+ len(response_data_tracks),
+ )
+ chosen_result = max(
+ response_data_tracks, key=lambda x: x['popularity']
+ )
+ results.append(chosen_result)
+
+ failure_count = len(failures)
+ if failure_count > 0:
+ if self.config['show_failures'].get():
+ self._log.info(
+ '{} track(s) did not match a {} ID:',
+ failure_count,
+ self.data_source,
+ )
+ for track in failures:
+ self._log.info('track: {}', track)
+ self._log.info('')
+ else:
+ self._log.warning(
+ '{} track(s) did not match a {} ID:\n'
+ 'use --show-failures to display',
+ failure_count,
+ self.data_source,
+ )
+
+ return results
+
+ def _output_match_results(self, results):
+ """Open a playlist or print Spotify URLs for the provided track
+ object dicts.
+
+ :param results: List of simplified track object dicts
+ (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
+ :type results: list[dict]
+ """
+ if results:
+ spotify_ids = [track_data['id'] for track_data in results]
+ if self.config['mode'].get() == 'open':
+ self._log.info(
+ 'Attempting to open {} with playlist'.format(
+ self.data_source
+ )
+ )
+ spotify_url = 'spotify:trackset:Playlist:' + ','.join(
+ spotify_ids
+ )
+ webbrowser.open(spotify_url)
+ else:
+ for spotify_id in spotify_ids:
+ print(self.open_track_url + spotify_id)
+ else:
+ self._log.warning(
+ f'No {self.data_source} tracks found from beets query'
+ )
diff --git a/lib/beetsplug/subsonicplaylist.py b/lib/beetsplug/subsonicplaylist.py
new file mode 100644
index 00000000..ead78919
--- /dev/null
+++ b/lib/beetsplug/subsonicplaylist.py
@@ -0,0 +1,171 @@
+# This file is part of beets.
+# Copyright 2019, Joris Jensen
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+
+import random
+import string
+from xml.etree import ElementTree
+from hashlib import md5
+from urllib.parse import urlencode
+
+import requests
+
+from beets.dbcore import AndQuery
+from beets.dbcore.query import MatchQuery
+from beets.plugins import BeetsPlugin
+from beets.ui import Subcommand
+
+__author__ = 'https://github.com/MrNuggelz'
+
+
+def filter_to_be_removed(items, keys):
+ if len(items) > len(keys):
+ dont_remove = []
+ for artist, album, title in keys:
+ for item in items:
+ if artist == item['artist'] and \
+ album == item['album'] and \
+ title == item['title']:
+ dont_remove.append(item)
+ return [item for item in items if item not in dont_remove]
+ else:
+ def to_be_removed(item):
+ for artist, album, title in keys:
+ if artist == item['artist'] and\
+ album == item['album'] and\
+ title == item['title']:
+ return False
+ return True
+
+ return [item for item in items if to_be_removed(item)]
+
+
+class SubsonicPlaylistPlugin(BeetsPlugin):
+
+ def __init__(self):
+ super().__init__()
+ self.config.add(
+ {
+ 'delete': False,
+ 'playlist_ids': [],
+ 'playlist_names': [],
+ 'username': '',
+ 'password': ''
+ }
+ )
+ self.config['password'].redact = True
+
+ def update_tags(self, playlist_dict, lib):
+ with lib.transaction():
+ for query, playlist_tag in playlist_dict.items():
+ query = AndQuery([MatchQuery("artist", query[0]),
+ MatchQuery("album", query[1]),
+ MatchQuery("title", query[2])])
+ items = lib.items(query)
+ if not items:
+ self._log.warn("{} | track not found ({})", playlist_tag,
+ query)
+ continue
+ for item in items:
+ item.subsonic_playlist = playlist_tag
+ item.try_sync(write=True, move=False)
+
+ def get_playlist(self, playlist_id):
+ xml = self.send('getPlaylist', {'id': playlist_id}).text
+ playlist = ElementTree.fromstring(xml)[0]
+ if playlist.attrib.get('code', '200') != '200':
+ alt_error = 'error getting playlist, but no error message found'
+ self._log.warn(playlist.attrib.get('message', alt_error))
+ return
+
+ name = playlist.attrib.get('name', 'undefined')
+ tracks = [(t.attrib['artist'], t.attrib['album'], t.attrib['title'])
+ for t in playlist]
+ return name, tracks
+
+ def commands(self):
+ def build_playlist(lib, opts, args):
+ self.config.set_args(opts)
+ ids = self.config['playlist_ids'].as_str_seq()
+ if self.config['playlist_names'].as_str_seq():
+ playlists = ElementTree.fromstring(
+ self.send('getPlaylists').text)[0]
+ if playlists.attrib.get('code', '200') != '200':
+ alt_error = 'error getting playlists,' \
+ ' but no error message found'
+ self._log.warn(
+ playlists.attrib.get('message', alt_error))
+ return
+ for name in self.config['playlist_names'].as_str_seq():
+ for playlist in playlists:
+ if name == playlist.attrib['name']:
+ ids.append(playlist.attrib['id'])
+
+ playlist_dict = self.get_playlists(ids)
+
+ # delete old tags
+ if self.config['delete']:
+ existing = list(lib.items('subsonic_playlist:";"'))
+ to_be_removed = filter_to_be_removed(
+ existing,
+ playlist_dict.keys())
+ for item in to_be_removed:
+ item['subsonic_playlist'] = ''
+ with lib.transaction():
+ item.try_sync(write=True, move=False)
+
+ self.update_tags(playlist_dict, lib)
+
+ subsonicplaylist_cmds = Subcommand(
+ 'subsonicplaylist', help='import a subsonic playlist'
+ )
+ subsonicplaylist_cmds.parser.add_option(
+ '-d',
+ '--delete',
+ action='store_true',
+ help='delete tag from items not in any playlist anymore',
+ )
+ subsonicplaylist_cmds.func = build_playlist
+ return [subsonicplaylist_cmds]
+
+ def generate_token(self):
+ salt = ''.join(random.choices(string.ascii_lowercase + string.digits))
+ return md5(
+ (self.config['password'].get() + salt).encode()).hexdigest(), salt
+
+ def send(self, endpoint, params=None):
+ if params is None:
+ params = {}
+ a, b = self.generate_token()
+ params['u'] = self.config['username']
+ params['t'] = a
+ params['s'] = b
+ params['v'] = '1.12.0'
+ params['c'] = 'beets'
+ resp = requests.get('{}/rest/{}?{}'.format(
+ self.config['base_url'].get(),
+ endpoint,
+ urlencode(params))
+ )
+ return resp
+
+ def get_playlists(self, ids):
+ output = {}
+ for playlist_id in ids:
+ name, tracks = self.get_playlist(playlist_id)
+ for track in tracks:
+ if track not in output:
+ output[track] = ';'
+ output[track] += name + ';'
+ return output
diff --git a/lib/beetsplug/subsonicupdate.py b/lib/beetsplug/subsonicupdate.py
new file mode 100644
index 00000000..9480bcb4
--- /dev/null
+++ b/lib/beetsplug/subsonicupdate.py
@@ -0,0 +1,144 @@
+# This file is part of beets.
+# Copyright 2016, Adrian Sampson.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Updates Subsonic library on Beets import
+Your Beets configuration file should contain
+a "subsonic" section like the following:
+ subsonic:
+ url: https://mydomain.com:443/subsonic
+ user: username
+ pass: password
+ auth: token
+For older Subsonic versions, token authentication
+is not supported, use password instead:
+ subsonic:
+ url: https://mydomain.com:443/subsonic
+ user: username
+ pass: password
+ auth: pass
+"""
+
+import hashlib
+import random
+import string
+
+import requests
+
+from binascii import hexlify
+from beets import config
+from beets.plugins import BeetsPlugin
+
+__author__ = 'https://github.com/maffo999'
+
+
+class SubsonicUpdate(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+ # Set default configuration values
+ config['subsonic'].add({
+ 'user': 'admin',
+ 'pass': 'admin',
+ 'url': 'http://localhost:4040',
+ 'auth': 'token',
+ })
+ config['subsonic']['pass'].redact = True
+ self.register_listener('import', self.start_scan)
+
+ @staticmethod
+ def __create_token():
+ """Create salt and token from given password.
+
+ :return: The generated salt and hashed token
+ """
+ password = config['subsonic']['pass'].as_str()
+
+ # Pick the random sequence and salt the password
+ r = string.ascii_letters + string.digits
+ salt = "".join([random.choice(r) for _ in range(6)])
+ salted_password = password + salt
+ token = hashlib.md5(salted_password.encode('utf-8')).hexdigest()
+
+ # Put together the payload of the request to the server and the URL
+ return salt, token
+
+ @staticmethod
+ def __format_url(endpoint):
+ """Get the Subsonic URL to trigger the given endpoint.
+ Uses either the url config option or the deprecated host, port,
+ and context_path config options together.
+
+ :return: Endpoint for updating Subsonic
+ """
+
+ url = config['subsonic']['url'].as_str()
+ if url and url.endswith('/'):
+ url = url[:-1]
+
+ # @deprecated("Use url config option instead")
+ if not url:
+ host = config['subsonic']['host'].as_str()
+ port = config['subsonic']['port'].get(int)
+ context_path = config['subsonic']['contextpath'].as_str()
+ if context_path == '/':
+ context_path = ''
+ url = f"http://{host}:{port}{context_path}"
+
+ return url + f'/rest/{endpoint}'
+
+ def start_scan(self):
+ user = config['subsonic']['user'].as_str()
+ auth = config['subsonic']['auth'].as_str()
+ url = self.__format_url("startScan")
+ self._log.debug('URL is {0}', url)
+ self._log.debug('auth type is {0}', config['subsonic']['auth'])
+
+ if auth == "token":
+ salt, token = self.__create_token()
+ payload = {
+ 'u': user,
+ 't': token,
+ 's': salt,
+ 'v': '1.13.0', # Subsonic 5.3 and newer
+ 'c': 'beets',
+ 'f': 'json'
+ }
+ elif auth == "password":
+ password = config['subsonic']['pass'].as_str()
+ encpass = hexlify(password.encode()).decode()
+ payload = {
+ 'u': user,
+ 'p': f'enc:{encpass}',
+ 'v': '1.12.0',
+ 'c': 'beets',
+ 'f': 'json'
+ }
+ else:
+ return
+ try:
+ response = requests.get(url, params=payload)
+ json = response.json()
+
+ if response.status_code == 200 and \
+ json['subsonic-response']['status'] == "ok":
+ count = json['subsonic-response']['scanStatus']['count']
+ self._log.info(
+ f'Updating Subsonic; scanning {count} tracks')
+ elif response.status_code == 200 and \
+ json['subsonic-response']['status'] == "failed":
+ error_message = json['subsonic-response']['error']['message']
+ self._log.error(f'Error: {error_message}')
+ else:
+ self._log.error('Error: {0}', json)
+ except Exception as error:
+ self._log.error(f'Error: {error}')
diff --git a/lib/beetsplug/the.py b/lib/beetsplug/the.py
new file mode 100644
index 00000000..e6626d2b
--- /dev/null
+++ b/lib/beetsplug/the.py
@@ -0,0 +1,98 @@
+# This file is part of beets.
+# Copyright 2016, Blemjhoo Tezoulbr .
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Moves patterns in path formats (suitable for moving articles)."""
+
+
+import re
+from beets.plugins import BeetsPlugin
+
+__author__ = 'baobab@heresiarch.info'
+__version__ = '1.1'
+
+PATTERN_THE = '^the\\s'
+PATTERN_A = '^[a][n]?\\s'
+FORMAT = '{0}, {1}'
+
+
+class ThePlugin(BeetsPlugin):
+
+ patterns = []
+
+ def __init__(self):
+ super().__init__()
+
+ self.template_funcs['the'] = self.the_template_func
+
+ self.config.add({
+ 'the': True,
+ 'a': True,
+ 'format': '{0}, {1}',
+ 'strip': False,
+ 'patterns': [],
+ })
+
+ self.patterns = self.config['patterns'].as_str_seq()
+ for p in self.patterns:
+ if p:
+ try:
+ re.compile(p)
+ except re.error:
+ self._log.error('invalid pattern: {0}', p)
+ else:
+ if not (p.startswith('^') or p.endswith('$')):
+ self._log.warning('warning: \"{0}\" will not '
+ 'match string start/end', p)
+ if self.config['a']:
+ self.patterns = [PATTERN_A] + self.patterns
+ if self.config['the']:
+ self.patterns = [PATTERN_THE] + self.patterns
+ if not self.patterns:
+ self._log.warning('no patterns defined!')
+
+ def unthe(self, text, pattern):
+ """Moves pattern in the path format string or strips it
+
+ text -- text to handle
+ pattern -- regexp pattern (case ignore is already on)
+ strip -- if True, pattern will be removed
+ """
+ if text:
+ r = re.compile(pattern, flags=re.IGNORECASE)
+ try:
+ t = r.findall(text)[0]
+ except IndexError:
+ return text
+ else:
+ r = re.sub(r, '', text).strip()
+ if self.config['strip']:
+ return r
+ else:
+ fmt = self.config['format'].as_str()
+ return fmt.format(r, t.strip()).strip()
+ else:
+ return ''
+
+ def the_template_func(self, text):
+ if not self.patterns:
+ return text
+ if text:
+ for p in self.patterns:
+ r = self.unthe(text, p)
+ if r != text:
+ self._log.debug('\"{0}\" -> \"{1}\"', text, r)
+ break
+ return r
+ else:
+ return ''
diff --git a/lib/beetsplug/thumbnails.py b/lib/beetsplug/thumbnails.py
new file mode 100644
index 00000000..6bd9cbac
--- /dev/null
+++ b/lib/beetsplug/thumbnails.py
@@ -0,0 +1,291 @@
+# This file is part of beets.
+# Copyright 2016, Bruno Cauet
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Create freedesktop.org-compliant thumbnails for album folders
+
+This plugin is POSIX-only.
+Spec: standards.freedesktop.org/thumbnail-spec/latest/index.html
+"""
+
+
+from hashlib import md5
+import os
+import shutil
+from itertools import chain
+from pathlib import PurePosixPath
+import ctypes
+import ctypes.util
+
+from xdg import BaseDirectory
+
+from beets.plugins import BeetsPlugin
+from beets.ui import Subcommand, decargs
+from beets import util
+from beets.util.artresizer import ArtResizer, get_im_version, get_pil_version
+
+
+BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails")
+NORMAL_DIR = util.bytestring_path(os.path.join(BASE_DIR, "normal"))
+LARGE_DIR = util.bytestring_path(os.path.join(BASE_DIR, "large"))
+
+
+class ThumbnailsPlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+ self.config.add({
+ 'auto': True,
+ 'force': False,
+ 'dolphin': False,
+ })
+
+ self.write_metadata = None
+ if self.config['auto'] and self._check_local_ok():
+ self.register_listener('art_set', self.process_album)
+
+ def commands(self):
+ thumbnails_command = Subcommand("thumbnails",
+ help="Create album thumbnails")
+ thumbnails_command.parser.add_option(
+ '-f', '--force',
+ dest='force', action='store_true', default=False,
+ help='force regeneration of thumbnails deemed fine (existing & '
+ 'recent enough)')
+ thumbnails_command.parser.add_option(
+ '--dolphin', dest='dolphin', action='store_true', default=False,
+ help="create Dolphin-compatible thumbnail information (for KDE)")
+ thumbnails_command.func = self.process_query
+
+ return [thumbnails_command]
+
+ def process_query(self, lib, opts, args):
+ self.config.set_args(opts)
+ if self._check_local_ok():
+ for album in lib.albums(decargs(args)):
+ self.process_album(album)
+
+ def _check_local_ok(self):
+ """Check that's everythings ready:
+ - local capability to resize images
+ - thumbnail dirs exist (create them if needed)
+ - detect whether we'll use PIL or IM
+ - detect whether we'll use GIO or Python to get URIs
+ """
+ if not ArtResizer.shared.local:
+ self._log.warning("No local image resizing capabilities, "
+ "cannot generate thumbnails")
+ return False
+
+ for dir in (NORMAL_DIR, LARGE_DIR):
+ if not os.path.exists(dir):
+ os.makedirs(dir)
+
+ if get_im_version():
+ self.write_metadata = write_metadata_im
+ tool = "IM"
+ else:
+ assert get_pil_version() # since we're local
+ self.write_metadata = write_metadata_pil
+ tool = "PIL"
+ self._log.debug("using {0} to write metadata", tool)
+
+ uri_getter = GioURI()
+ if not uri_getter.available:
+ uri_getter = PathlibURI()
+ self._log.debug("using {0.name} to compute URIs", uri_getter)
+ self.get_uri = uri_getter.uri
+
+ return True
+
+ def process_album(self, album):
+ """Produce thumbnails for the album folder.
+ """
+ self._log.debug('generating thumbnail for {0}', album)
+ if not album.artpath:
+ self._log.info('album {0} has no art', album)
+ return
+
+ if self.config['dolphin']:
+ self.make_dolphin_cover_thumbnail(album)
+
+ size = ArtResizer.shared.get_size(album.artpath)
+ if not size:
+ self._log.warning('problem getting the picture size for {0}',
+ album.artpath)
+ return
+
+ wrote = True
+ if max(size) >= 256:
+ wrote &= self.make_cover_thumbnail(album, 256, LARGE_DIR)
+ wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR)
+
+ if wrote:
+ self._log.info('wrote thumbnail for {0}', album)
+ else:
+ self._log.info('nothing to do for {0}', album)
+
+ def make_cover_thumbnail(self, album, size, target_dir):
+ """Make a thumbnail of given size for `album` and put it in
+ `target_dir`.
+ """
+ target = os.path.join(target_dir, self.thumbnail_file_name(album.path))
+
+ if os.path.exists(target) and \
+ os.stat(target).st_mtime > os.stat(album.artpath).st_mtime:
+ if self.config['force']:
+ self._log.debug("found a suitable {1}x{1} thumbnail for {0}, "
+ "forcing regeneration", album, size)
+ else:
+ self._log.debug("{1}x{1} thumbnail for {0} exists and is "
+ "recent enough", album, size)
+ return False
+ resized = ArtResizer.shared.resize(size, album.artpath,
+ util.syspath(target))
+ self.add_tags(album, util.syspath(resized))
+ shutil.move(resized, target)
+ return True
+
+ def thumbnail_file_name(self, path):
+ """Compute the thumbnail file name
+ See https://standards.freedesktop.org/thumbnail-spec/latest/x227.html
+ """
+ uri = self.get_uri(path)
+ hash = md5(uri.encode('utf-8')).hexdigest()
+ return util.bytestring_path(f"{hash}.png")
+
+ def add_tags(self, album, image_path):
+ """Write required metadata to the thumbnail
+ See https://standards.freedesktop.org/thumbnail-spec/latest/x142.html
+ """
+ mtime = os.stat(album.artpath).st_mtime
+ metadata = {"Thumb::URI": self.get_uri(album.artpath),
+ "Thumb::MTime": str(mtime)}
+ try:
+ self.write_metadata(image_path, metadata)
+ except Exception:
+ self._log.exception("could not write metadata to {0}",
+ util.displayable_path(image_path))
+
+ def make_dolphin_cover_thumbnail(self, album):
+ outfilename = os.path.join(album.path, b".directory")
+ if os.path.exists(outfilename):
+ return
+ artfile = os.path.split(album.artpath)[1]
+ with open(outfilename, 'w') as f:
+ f.write('[Desktop Entry]\n')
+ f.write('Icon=./{}'.format(artfile.decode('utf-8')))
+ f.close()
+ self._log.debug("Wrote file {0}", util.displayable_path(outfilename))
+
+
+def write_metadata_im(file, metadata):
+ """Enrich the file metadata with `metadata` dict thanks to IM."""
+ command = ['convert', file] + \
+ list(chain.from_iterable(('-set', k, v)
+ for k, v in metadata.items())) + [file]
+ util.command_output(command)
+ return True
+
+
+def write_metadata_pil(file, metadata):
+ """Enrich the file metadata with `metadata` dict thanks to PIL."""
+ from PIL import Image, PngImagePlugin
+ im = Image.open(file)
+ meta = PngImagePlugin.PngInfo()
+ for k, v in metadata.items():
+ meta.add_text(k, v, 0)
+ im.save(file, "PNG", pnginfo=meta)
+ return True
+
+
+class URIGetter:
+ available = False
+ name = "Abstract base"
+
+ def uri(self, path):
+ raise NotImplementedError()
+
+
+class PathlibURI(URIGetter):
+ available = True
+ name = "Python Pathlib"
+
+ def uri(self, path):
+ return PurePosixPath(util.py3_path(path)).as_uri()
+
+
+def copy_c_string(c_string):
+ """Copy a `ctypes.POINTER(ctypes.c_char)` value into a new Python
+ string and return it. The old memory is then safe to free.
+ """
+ # This is a pretty dumb way to get a string copy, but it seems to
+ # work. A more surefire way would be to allocate a ctypes buffer and copy
+ # the data with `memcpy` or somesuch.
+ s = ctypes.cast(c_string, ctypes.c_char_p).value
+ return b'' + s
+
+
+class GioURI(URIGetter):
+ """Use gio URI function g_file_get_uri. Paths must be utf-8 encoded.
+ """
+ name = "GIO"
+
+ def __init__(self):
+ self.libgio = self.get_library()
+ self.available = bool(self.libgio)
+ if self.available:
+ self.libgio.g_type_init() # for glib < 2.36
+
+ self.libgio.g_file_get_uri.argtypes = [ctypes.c_char_p]
+ self.libgio.g_file_new_for_path.restype = ctypes.c_void_p
+
+ self.libgio.g_file_get_uri.argtypes = [ctypes.c_void_p]
+ self.libgio.g_file_get_uri.restype = ctypes.POINTER(ctypes.c_char)
+
+ self.libgio.g_object_unref.argtypes = [ctypes.c_void_p]
+
+ def get_library(self):
+ lib_name = ctypes.util.find_library("gio-2")
+ try:
+ if not lib_name:
+ return False
+ return ctypes.cdll.LoadLibrary(lib_name)
+ except OSError:
+ return False
+
+ def uri(self, path):
+ g_file_ptr = self.libgio.g_file_new_for_path(path)
+ if not g_file_ptr:
+ raise RuntimeError("No gfile pointer received for {}".format(
+ util.displayable_path(path)))
+
+ try:
+ uri_ptr = self.libgio.g_file_get_uri(g_file_ptr)
+ finally:
+ self.libgio.g_object_unref(g_file_ptr)
+ if not uri_ptr:
+ self.libgio.g_free(uri_ptr)
+ raise RuntimeError("No URI received from the gfile pointer for "
+ "{}".format(util.displayable_path(path)))
+
+ try:
+ uri = copy_c_string(uri_ptr)
+ finally:
+ self.libgio.g_free(uri_ptr)
+
+ try:
+ return uri.decode(util._fsencoding())
+ except UnicodeDecodeError:
+ raise RuntimeError(
+ f"Could not decode filename from GIO: {uri!r}"
+ )
diff --git a/lib/beetsplug/types.py b/lib/beetsplug/types.py
new file mode 100644
index 00000000..930d5e86
--- /dev/null
+++ b/lib/beetsplug/types.py
@@ -0,0 +1,50 @@
+# This file is part of beets.
+# Copyright 2016, Thomas Scholtes.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+
+from beets.plugins import BeetsPlugin
+from beets.dbcore import types
+from confuse import ConfigValueError
+from beets import library
+
+
+class TypesPlugin(BeetsPlugin):
+
+ @property
+ def item_types(self):
+ return self._types()
+
+ @property
+ def album_types(self):
+ return self._types()
+
+ def _types(self):
+ if not self.config.exists():
+ return {}
+
+ mytypes = {}
+ for key, value in self.config.items():
+ if value.get() == 'int':
+ mytypes[key] = types.INTEGER
+ elif value.get() == 'float':
+ mytypes[key] = types.FLOAT
+ elif value.get() == 'bool':
+ mytypes[key] = types.BOOLEAN
+ elif value.get() == 'date':
+ mytypes[key] = library.DateType()
+ else:
+ raise ConfigValueError(
+ "unknown type '{}' for the '{}' field"
+ .format(value, key))
+ return mytypes
diff --git a/lib/beetsplug/unimported.py b/lib/beetsplug/unimported.py
new file mode 100644
index 00000000..7714ec83
--- /dev/null
+++ b/lib/beetsplug/unimported.py
@@ -0,0 +1,68 @@
+# This file is part of beets.
+# Copyright 2019, Joris Jensen
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""
+List all files in the library folder which are not listed in the
+ beets library database, including art files
+"""
+
+import os
+
+from beets import util
+from beets.plugins import BeetsPlugin
+from beets.ui import Subcommand, print_
+
+__author__ = 'https://github.com/MrNuggelz'
+
+
+class Unimported(BeetsPlugin):
+
+ def __init__(self):
+ super().__init__()
+ self.config.add(
+ {
+ 'ignore_extensions': []
+ }
+ )
+
+ def commands(self):
+ def print_unimported(lib, opts, args):
+ ignore_exts = [
+ ('.' + x).encode()
+ for x in self.config["ignore_extensions"].as_str_seq()
+ ]
+ ignore_dirs = [
+ os.path.join(lib.directory, x.encode())
+ for x in self.config["ignore_subdirectories"].as_str_seq()
+ ]
+ in_folder = {
+ os.path.join(r, file)
+ for r, d, f in os.walk(lib.directory)
+ for file in f
+ if not any(
+ [file.endswith(ext) for ext in ignore_exts]
+ + [r in ignore_dirs]
+ )
+ }
+ in_library = {x.path for x in lib.items()}
+ art_files = {x.artpath for x in lib.albums()}
+ for f in in_folder - in_library - art_files:
+ print_(util.displayable_path(f))
+
+ unimported = Subcommand(
+ 'unimported',
+ help='list all files in the library folder which are not listed'
+ ' in the beets library database')
+ unimported.func = print_unimported
+ return [unimported]
diff --git a/lib/beetsplug/web/__init__.py b/lib/beetsplug/web/__init__.py
new file mode 100644
index 00000000..240126e9
--- /dev/null
+++ b/lib/beetsplug/web/__init__.py
@@ -0,0 +1,526 @@
+# This file is part of beets.
+# Copyright 2016, Adrian Sampson.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""A Web interface to beets."""
+
+from beets.plugins import BeetsPlugin
+from beets import ui
+from beets import util
+import beets.library
+import flask
+from flask import g, jsonify
+from werkzeug.routing import BaseConverter, PathConverter
+import os
+from unidecode import unidecode
+import json
+import base64
+
+
+# Utilities.
+
+def _rep(obj, expand=False):
+ """Get a flat -- i.e., JSON-ish -- representation of a beets Item or
+ Album object. For Albums, `expand` dictates whether tracks are
+ included.
+ """
+ out = dict(obj)
+
+ if isinstance(obj, beets.library.Item):
+ if app.config.get('INCLUDE_PATHS', False):
+ out['path'] = util.displayable_path(out['path'])
+ else:
+ del out['path']
+
+ # Filter all bytes attributes and convert them to strings.
+ for key, value in out.items():
+ if isinstance(out[key], bytes):
+ out[key] = base64.b64encode(value).decode('ascii')
+
+ # Get the size (in bytes) of the backing file. This is useful
+ # for the Tomahawk resolver API.
+ try:
+ out['size'] = os.path.getsize(util.syspath(obj.path))
+ except OSError:
+ out['size'] = 0
+
+ return out
+
+ elif isinstance(obj, beets.library.Album):
+ if app.config.get('INCLUDE_PATHS', False):
+ out['artpath'] = util.displayable_path(out['artpath'])
+ else:
+ del out['artpath']
+ if expand:
+ out['items'] = [_rep(item) for item in obj.items()]
+ return out
+
+
+def json_generator(items, root, expand=False):
+ """Generator that dumps list of beets Items or Albums as JSON
+
+ :param root: root key for JSON
+ :param items: list of :class:`Item` or :class:`Album` to dump
+ :param expand: If true every :class:`Album` contains its items in the json
+ representation
+ :returns: generator that yields strings
+ """
+ yield '{"%s":[' % root
+ first = True
+ for item in items:
+ if first:
+ first = False
+ else:
+ yield ','
+ yield json.dumps(_rep(item, expand=expand))
+ yield ']}'
+
+
+def is_expand():
+ """Returns whether the current request is for an expanded response."""
+
+ return flask.request.args.get('expand') is not None
+
+
+def is_delete():
+ """Returns whether the current delete request should remove the selected
+ files.
+ """
+
+ return flask.request.args.get('delete') is not None
+
+
+def get_method():
+ """Returns the HTTP method of the current request."""
+ return flask.request.method
+
+
+def resource(name, patchable=False):
+ """Decorates a function to handle RESTful HTTP requests for a resource.
+ """
+ def make_responder(retriever):
+ def responder(ids):
+ entities = [retriever(id) for id in ids]
+ entities = [entity for entity in entities if entity]
+
+ if get_method() == "DELETE":
+
+ if app.config.get('READONLY', True):
+ return flask.abort(405)
+
+ for entity in entities:
+ entity.remove(delete=is_delete())
+
+ return flask.make_response(jsonify({'deleted': True}), 200)
+
+ elif get_method() == "PATCH" and patchable:
+ if app.config.get('READONLY', True):
+ return flask.abort(405)
+
+ for entity in entities:
+ entity.update(flask.request.get_json())
+ entity.try_sync(True, False) # write, don't move
+
+ if len(entities) == 1:
+ return flask.jsonify(_rep(entities[0], expand=is_expand()))
+ elif entities:
+ return app.response_class(
+ json_generator(entities, root=name),
+ mimetype='application/json'
+ )
+
+ elif get_method() == "GET":
+ if len(entities) == 1:
+ return flask.jsonify(_rep(entities[0], expand=is_expand()))
+ elif entities:
+ return app.response_class(
+ json_generator(entities, root=name),
+ mimetype='application/json'
+ )
+ else:
+ return flask.abort(404)
+
+ else:
+ return flask.abort(405)
+
+ responder.__name__ = f'get_{name}'
+
+ return responder
+ return make_responder
+
+
+def resource_query(name, patchable=False):
+ """Decorates a function to handle RESTful HTTP queries for resources.
+ """
+ def make_responder(query_func):
+ def responder(queries):
+ entities = query_func(queries)
+
+ if get_method() == "DELETE":
+
+ if app.config.get('READONLY', True):
+ return flask.abort(405)
+
+ for entity in entities:
+ entity.remove(delete=is_delete())
+
+ return flask.make_response(jsonify({'deleted': True}), 200)
+
+ elif get_method() == "PATCH" and patchable:
+ if app.config.get('READONLY', True):
+ return flask.abort(405)
+
+ for entity in entities:
+ entity.update(flask.request.get_json())
+ entity.try_sync(True, False) # write, don't move
+
+ return app.response_class(
+ json_generator(entities, root=name),
+ mimetype='application/json'
+ )
+
+ elif get_method() == "GET":
+ return app.response_class(
+ json_generator(
+ entities,
+ root='results', expand=is_expand()
+ ),
+ mimetype='application/json'
+ )
+
+ else:
+ return flask.abort(405)
+
+ responder.__name__ = f'query_{name}'
+
+ return responder
+
+ return make_responder
+
+
+def resource_list(name):
+ """Decorates a function to handle RESTful HTTP request for a list of
+ resources.
+ """
+ def make_responder(list_all):
+ def responder():
+ return app.response_class(
+ json_generator(list_all(), root=name, expand=is_expand()),
+ mimetype='application/json'
+ )
+ responder.__name__ = f'all_{name}'
+ return responder
+ return make_responder
+
+
+def _get_unique_table_field_values(model, field, sort_field):
+ """ retrieve all unique values belonging to a key from a model """
+ if field not in model.all_keys() or sort_field not in model.all_keys():
+ raise KeyError
+ with g.lib.transaction() as tx:
+ rows = tx.query('SELECT DISTINCT "{}" FROM "{}" ORDER BY "{}"'
+ .format(field, model._table, sort_field))
+ return [row[0] for row in rows]
+
+
+class IdListConverter(BaseConverter):
+ """Converts comma separated lists of ids in urls to integer lists.
+ """
+
+ def to_python(self, value):
+ ids = []
+ for id in value.split(','):
+ try:
+ ids.append(int(id))
+ except ValueError:
+ pass
+ return ids
+
+ def to_url(self, value):
+ return ','.join(str(v) for v in value)
+
+
+class QueryConverter(PathConverter):
+ """Converts slash separated lists of queries in the url to string list.
+ """
+
+ def to_python(self, value):
+ queries = value.split('/')
+ """Do not do path substitution on regex value tests"""
+ return [query if '::' in query else query.replace('\\', os.sep)
+ for query in queries]
+
+ def to_url(self, value):
+ return ','.join([v.replace(os.sep, '\\') for v in value])
+
+
+class EverythingConverter(PathConverter):
+ regex = '.*?'
+
+
+# Flask setup.
+
+app = flask.Flask(__name__)
+app.url_map.converters['idlist'] = IdListConverter
+app.url_map.converters['query'] = QueryConverter
+app.url_map.converters['everything'] = EverythingConverter
+
+
+@app.before_request
+def before_request():
+ g.lib = app.config['lib']
+
+
+# Items.
+
+@app.route('/item/', methods=["GET", "DELETE", "PATCH"])
+@resource('items', patchable=True)
+def get_item(id):
+ return g.lib.get_item(id)
+
+
+@app.route('/item/')
+@app.route('/item/query/')
+@resource_list('items')
+def all_items():
+ return g.lib.items()
+
+
+@app.route('/item//file')
+def item_file(item_id):
+ item = g.lib.get_item(item_id)
+
+ # On Windows under Python 2, Flask wants a Unicode path. On Python 3, it
+ # *always* wants a Unicode path.
+ if os.name == 'nt':
+ item_path = util.syspath(item.path)
+ else:
+ item_path = util.py3_path(item.path)
+
+ try:
+ unicode_item_path = util.text_string(item.path)
+ except (UnicodeDecodeError, UnicodeEncodeError):
+ unicode_item_path = util.displayable_path(item.path)
+
+ base_filename = os.path.basename(unicode_item_path)
+ try:
+ # Imitate http.server behaviour
+ base_filename.encode("latin-1", "strict")
+ except UnicodeEncodeError:
+ safe_filename = unidecode(base_filename)
+ else:
+ safe_filename = base_filename
+
+ response = flask.send_file(
+ item_path,
+ as_attachment=True,
+ attachment_filename=safe_filename
+ )
+ response.headers['Content-Length'] = os.path.getsize(item_path)
+ return response
+
+
+@app.route('/item/query/', methods=["GET", "DELETE", "PATCH"])
+@resource_query('items', patchable=True)
+def item_query(queries):
+ return g.lib.items(queries)
+
+
+@app.route('/item/path/')
+def item_at_path(path):
+ query = beets.library.PathQuery('path', path.encode('utf-8'))
+ item = g.lib.items(query).get()
+ if item:
+ return flask.jsonify(_rep(item))
+ else:
+ return flask.abort(404)
+
+
+@app.route('/item/values/')
+def item_unique_field_values(key):
+ sort_key = flask.request.args.get('sort_key', key)
+ try:
+ values = _get_unique_table_field_values(beets.library.Item, key,
+ sort_key)
+ except KeyError:
+ return flask.abort(404)
+ return flask.jsonify(values=values)
+
+
+# Albums.
+
+@app.route('/album/', methods=["GET", "DELETE"])
+@resource('albums')
+def get_album(id):
+ return g.lib.get_album(id)
+
+
+@app.route('/album/')
+@app.route('/album/query/')
+@resource_list('albums')
+def all_albums():
+ return g.lib.albums()
+
+
+@app.route('/album/query/', methods=["GET", "DELETE"])
+@resource_query('albums')
+def album_query(queries):
+ return g.lib.albums(queries)
+
+
+@app.route('/album//art')
+def album_art(album_id):
+ album = g.lib.get_album(album_id)
+ if album and album.artpath:
+ return flask.send_file(album.artpath.decode())
+ else:
+ return flask.abort(404)
+
+
+@app.route('/album/values/')
+def album_unique_field_values(key):
+ sort_key = flask.request.args.get('sort_key', key)
+ try:
+ values = _get_unique_table_field_values(beets.library.Album, key,
+ sort_key)
+ except KeyError:
+ return flask.abort(404)
+ return flask.jsonify(values=values)
+
+
+# Artists.
+
+@app.route('/artist/')
+def all_artists():
+ with g.lib.transaction() as tx:
+ rows = tx.query("SELECT DISTINCT albumartist FROM albums")
+ all_artists = [row[0] for row in rows]
+ return flask.jsonify(artist_names=all_artists)
+
+
+# Library information.
+
+@app.route('/stats')
+def stats():
+ with g.lib.transaction() as tx:
+ item_rows = tx.query("SELECT COUNT(*) FROM items")
+ album_rows = tx.query("SELECT COUNT(*) FROM albums")
+ return flask.jsonify({
+ 'items': item_rows[0][0],
+ 'albums': album_rows[0][0],
+ })
+
+
+# UI.
+
+@app.route('/')
+def home():
+ return flask.render_template('index.html')
+
+
+# Plugin hook.
+
+class WebPlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+ self.config.add({
+ 'host': '127.0.0.1',
+ 'port': 8337,
+ 'cors': '',
+ 'cors_supports_credentials': False,
+ 'reverse_proxy': False,
+ 'include_paths': False,
+ 'readonly': True,
+ })
+
+ def commands(self):
+ cmd = ui.Subcommand('web', help='start a Web interface')
+ cmd.parser.add_option('-d', '--debug', action='store_true',
+ default=False, help='debug mode')
+
+ def func(lib, opts, args):
+ args = ui.decargs(args)
+ if args:
+ self.config['host'] = args.pop(0)
+ if args:
+ self.config['port'] = int(args.pop(0))
+
+ app.config['lib'] = lib
+ # Normalizes json output
+ app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
+
+ app.config['INCLUDE_PATHS'] = self.config['include_paths']
+ app.config['READONLY'] = self.config['readonly']
+
+ # Enable CORS if required.
+ if self.config['cors']:
+ self._log.info('Enabling CORS with origin: {0}',
+ self.config['cors'])
+ from flask_cors import CORS
+ app.config['CORS_ALLOW_HEADERS'] = "Content-Type"
+ app.config['CORS_RESOURCES'] = {
+ r"/*": {"origins": self.config['cors'].get(str)}
+ }
+ CORS(
+ app,
+ supports_credentials=self.config[
+ 'cors_supports_credentials'
+ ].get(bool)
+ )
+
+ # Allow serving behind a reverse proxy
+ if self.config['reverse_proxy']:
+ app.wsgi_app = ReverseProxied(app.wsgi_app)
+
+ # Start the web application.
+ app.run(host=self.config['host'].as_str(),
+ port=self.config['port'].get(int),
+ debug=opts.debug, threaded=True)
+ cmd.func = func
+ return [cmd]
+
+
+class ReverseProxied:
+ '''Wrap the application in this middleware and configure the
+ front-end server to add these headers, to let you quietly bind
+ this to a URL other than / and to an HTTP scheme that is
+ different than what is used locally.
+
+ In nginx:
+ location /myprefix {
+ proxy_pass http://192.168.0.1:5001;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Scheme $scheme;
+ proxy_set_header X-Script-Name /myprefix;
+ }
+
+ From: http://flask.pocoo.org/snippets/35/
+
+ :param app: the WSGI application
+ '''
+ def __init__(self, app):
+ self.app = app
+
+ def __call__(self, environ, start_response):
+ script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
+ if script_name:
+ environ['SCRIPT_NAME'] = script_name
+ path_info = environ['PATH_INFO']
+ if path_info.startswith(script_name):
+ environ['PATH_INFO'] = path_info[len(script_name):]
+
+ scheme = environ.get('HTTP_X_SCHEME', '')
+ if scheme:
+ environ['wsgi.url_scheme'] = scheme
+ return self.app(environ, start_response)
diff --git a/lib/beetsplug/web/static/backbone.js b/lib/beetsplug/web/static/backbone.js
new file mode 100644
index 00000000..b2e49322
--- /dev/null
+++ b/lib/beetsplug/web/static/backbone.js
@@ -0,0 +1,1158 @@
+// Backbone.js 0.5.3
+// (c) 2010 Jeremy Ashkenas, DocumentCloud Inc.
+// Backbone may be freely distributed under the MIT license.
+// For all details and documentation:
+// http://documentcloud.github.com/backbone
+
+(function(){
+
+ // Initial Setup
+ // -------------
+
+ // Save a reference to the global object.
+ var root = this;
+
+ // Save the previous value of the `Backbone` variable.
+ var previousBackbone = root.Backbone;
+
+ // The top-level namespace. All public Backbone classes and modules will
+ // be attached to this. Exported for both CommonJS and the browser.
+ var Backbone;
+ if (typeof exports !== 'undefined') {
+ Backbone = exports;
+ } else {
+ Backbone = root.Backbone = {};
+ }
+
+ // Current version of the library. Keep in sync with `package.json`.
+ Backbone.VERSION = '0.5.3';
+
+ // Require Underscore, if we're on the server, and it's not already present.
+ var _ = root._;
+ if (!_ && (typeof require !== 'undefined')) _ = require('underscore')._;
+
+ // For Backbone's purposes, jQuery or Zepto owns the `$` variable.
+ var $ = root.jQuery || root.Zepto;
+
+ // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable
+ // to its previous owner. Returns a reference to this Backbone object.
+ Backbone.noConflict = function() {
+ root.Backbone = previousBackbone;
+ return this;
+ };
+
+ // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option will
+ // fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and set a
+ // `X-Http-Method-Override` header.
+ Backbone.emulateHTTP = false;
+
+ // Turn on `emulateJSON` to support legacy servers that can't deal with direct
+ // `application/json` requests ... will encode the body as
+ // `application/x-www-form-urlencoded` instead and will send the model in a
+ // form param named `model`.
+ Backbone.emulateJSON = false;
+
+ // Backbone.Events
+ // -----------------
+
+ // A module that can be mixed in to *any object* in order to provide it with
+ // custom events. You may `bind` or `unbind` a callback function to an event;
+ // `trigger`-ing an event fires all callbacks in succession.
+ //
+ // var object = {};
+ // _.extend(object, Backbone.Events);
+ // object.bind('expand', function(){ alert('expanded'); });
+ // object.trigger('expand');
+ //
+ Backbone.Events = {
+
+ // Bind an event, specified by a string name, `ev`, to a `callback` function.
+ // Passing `"all"` will bind the callback to all events fired.
+ bind : function(ev, callback, context) {
+ var calls = this._callbacks || (this._callbacks = {});
+ var list = calls[ev] || (calls[ev] = []);
+ list.push([callback, context]);
+ return this;
+ },
+
+ // Remove one or many callbacks. If `callback` is null, removes all
+ // callbacks for the event. If `ev` is null, removes all bound callbacks
+ // for all events.
+ unbind : function(ev, callback) {
+ var calls;
+ if (!ev) {
+ this._callbacks = {};
+ } else if (calls = this._callbacks) {
+ if (!callback) {
+ calls[ev] = [];
+ } else {
+ var list = calls[ev];
+ if (!list) return this;
+ for (var i = 0, l = list.length; i < l; i++) {
+ if (list[i] && callback === list[i][0]) {
+ list[i] = null;
+ break;
+ }
+ }
+ }
+ }
+ return this;
+ },
+
+ // Trigger an event, firing all bound callbacks. Callbacks are passed the
+ // same arguments as `trigger` is, apart from the event name.
+ // Listening for `"all"` passes the true event name as the first argument.
+ trigger : function(eventName) {
+ var list, calls, ev, callback, args;
+ var both = 2;
+ if (!(calls = this._callbacks)) return this;
+ while (both--) {
+ ev = both ? eventName : 'all';
+ if (list = calls[ev]) {
+ for (var i = 0, l = list.length; i < l; i++) {
+ if (!(callback = list[i])) {
+ list.splice(i, 1); i--; l--;
+ } else {
+ args = both ? Array.prototype.slice.call(arguments, 1) : arguments;
+ callback[0].apply(callback[1] || this, args);
+ }
+ }
+ }
+ }
+ return this;
+ }
+
+ };
+
+ // Backbone.Model
+ // --------------
+
+ // Create a new model, with defined attributes. A client id (`cid`)
+ // is automatically generated and assigned for you.
+ Backbone.Model = function(attributes, options) {
+ var defaults;
+ attributes || (attributes = {});
+ if (defaults = this.defaults) {
+ if (_.isFunction(defaults)) defaults = defaults.call(this);
+ attributes = _.extend({}, defaults, attributes);
+ }
+ this.attributes = {};
+ this._escapedAttributes = {};
+ this.cid = _.uniqueId('c');
+ this.set(attributes, {silent : true});
+ this._changed = false;
+ this._previousAttributes = _.clone(this.attributes);
+ if (options && options.collection) this.collection = options.collection;
+ this.initialize(attributes, options);
+ };
+
+ // Attach all inheritable methods to the Model prototype.
+ _.extend(Backbone.Model.prototype, Backbone.Events, {
+
+ // A snapshot of the model's previous attributes, taken immediately
+ // after the last `"change"` event was fired.
+ _previousAttributes : null,
+
+ // Has the item been changed since the last `"change"` event?
+ _changed : false,
+
+ // The default name for the JSON `id` attribute is `"id"`. MongoDB and
+ // CouchDB users may want to set this to `"_id"`.
+ idAttribute : 'id',
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize : function(){},
+
+ // Return a copy of the model's `attributes` object.
+ toJSON : function() {
+ return _.clone(this.attributes);
+ },
+
+ // Get the value of an attribute.
+ get : function(attr) {
+ return this.attributes[attr];
+ },
+
+ // Get the HTML-escaped value of an attribute.
+ escape : function(attr) {
+ var html;
+ if (html = this._escapedAttributes[attr]) return html;
+ var val = this.attributes[attr];
+ return this._escapedAttributes[attr] = escapeHTML(val == null ? '' : '' + val);
+ },
+
+ // Returns `true` if the attribute contains a value that is not null
+ // or undefined.
+ has : function(attr) {
+ return this.attributes[attr] != null;
+ },
+
+ // Set a hash of model attributes on the object, firing `"change"` unless you
+ // choose to silence it.
+ set : function(attrs, options) {
+
+ // Extract attributes and options.
+ options || (options = {});
+ if (!attrs) return this;
+ if (attrs.attributes) attrs = attrs.attributes;
+ var now = this.attributes, escaped = this._escapedAttributes;
+
+ // Run validation.
+ if (!options.silent && this.validate && !this._performValidation(attrs, options)) return false;
+
+ // Check for changes of `id`.
+ if (this.idAttribute in attrs) this.id = attrs[this.idAttribute];
+
+ // We're about to start triggering change events.
+ var alreadyChanging = this._changing;
+ this._changing = true;
+
+ // Update attributes.
+ for (var attr in attrs) {
+ var val = attrs[attr];
+ if (!_.isEqual(now[attr], val)) {
+ now[attr] = val;
+ delete escaped[attr];
+ this._changed = true;
+ if (!options.silent) this.trigger('change:' + attr, this, val, options);
+ }
+ }
+
+ // Fire the `"change"` event, if the model has been changed.
+ if (!alreadyChanging && !options.silent && this._changed) this.change(options);
+ this._changing = false;
+ return this;
+ },
+
+ // Remove an attribute from the model, firing `"change"` unless you choose
+ // to silence it. `unset` is a noop if the attribute doesn't exist.
+ unset : function(attr, options) {
+ if (!(attr in this.attributes)) return this;
+ options || (options = {});
+ var value = this.attributes[attr];
+
+ // Run validation.
+ var validObj = {};
+ validObj[attr] = void 0;
+ if (!options.silent && this.validate && !this._performValidation(validObj, options)) return false;
+
+ // Remove the attribute.
+ delete this.attributes[attr];
+ delete this._escapedAttributes[attr];
+ if (attr == this.idAttribute) delete this.id;
+ this._changed = true;
+ if (!options.silent) {
+ this.trigger('change:' + attr, this, void 0, options);
+ this.change(options);
+ }
+ return this;
+ },
+
+ // Clear all attributes on the model, firing `"change"` unless you choose
+ // to silence it.
+ clear : function(options) {
+ options || (options = {});
+ var attr;
+ var old = this.attributes;
+
+ // Run validation.
+ var validObj = {};
+ for (attr in old) validObj[attr] = void 0;
+ if (!options.silent && this.validate && !this._performValidation(validObj, options)) return false;
+
+ this.attributes = {};
+ this._escapedAttributes = {};
+ this._changed = true;
+ if (!options.silent) {
+ for (attr in old) {
+ this.trigger('change:' + attr, this, void 0, options);
+ }
+ this.change(options);
+ }
+ return this;
+ },
+
+ // Fetch the model from the server. If the server's representation of the
+ // model differs from its current attributes, they will be overriden,
+ // triggering a `"change"` event.
+ fetch : function(options) {
+ options || (options = {});
+ var model = this;
+ var success = options.success;
+ options.success = function(resp, status, xhr) {
+ if (!model.set(model.parse(resp, xhr), options)) return false;
+ if (success) success(model, resp);
+ };
+ options.error = wrapError(options.error, model, options);
+ return (this.sync || Backbone.sync).call(this, 'read', this, options);
+ },
+
+ // Set a hash of model attributes, and sync the model to the server.
+ // If the server returns an attributes hash that differs, the model's
+ // state will be `set` again.
+ save : function(attrs, options) {
+ options || (options = {});
+ if (attrs && !this.set(attrs, options)) return false;
+ var model = this;
+ var success = options.success;
+ options.success = function(resp, status, xhr) {
+ if (!model.set(model.parse(resp, xhr), options)) return false;
+ if (success) success(model, resp, xhr);
+ };
+ options.error = wrapError(options.error, model, options);
+ var method = this.isNew() ? 'create' : 'update';
+ return (this.sync || Backbone.sync).call(this, method, this, options);
+ },
+
+ // Destroy this model on the server if it was already persisted. Upon success, the model is removed
+ // from its collection, if it has one.
+ destroy : function(options) {
+ options || (options = {});
+ if (this.isNew()) return this.trigger('destroy', this, this.collection, options);
+ var model = this;
+ var success = options.success;
+ options.success = function(resp) {
+ model.trigger('destroy', model, model.collection, options);
+ if (success) success(model, resp);
+ };
+ options.error = wrapError(options.error, model, options);
+ return (this.sync || Backbone.sync).call(this, 'delete', this, options);
+ },
+
+ // Default URL for the model's representation on the server -- if you're
+ // using Backbone's restful methods, override this to change the endpoint
+ // that will be called.
+ url : function() {
+ var base = getUrl(this.collection) || this.urlRoot || urlError();
+ if (this.isNew()) return base;
+ return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + encodeURIComponent(this.id);
+ },
+
+ // **parse** converts a response into the hash of attributes to be `set` on
+ // the model. The default implementation is just to pass the response along.
+ parse : function(resp, xhr) {
+ return resp;
+ },
+
+ // Create a new model with identical attributes to this one.
+ clone : function() {
+ return new this.constructor(this);
+ },
+
+ // A model is new if it has never been saved to the server, and lacks an id.
+ isNew : function() {
+ return this.id == null;
+ },
+
+ // Call this method to manually fire a `change` event for this model.
+ // Calling this will cause all objects observing the model to update.
+ change : function(options) {
+ this.trigger('change', this, options);
+ this._previousAttributes = _.clone(this.attributes);
+ this._changed = false;
+ },
+
+ // Determine if the model has changed since the last `"change"` event.
+ // If you specify an attribute name, determine if that attribute has changed.
+ hasChanged : function(attr) {
+ if (attr) return this._previousAttributes[attr] != this.attributes[attr];
+ return this._changed;
+ },
+
+ // Return an object containing all the attributes that have changed, or false
+ // if there are no changed attributes. Useful for determining what parts of a
+ // view need to be updated and/or what attributes need to be persisted to
+ // the server.
+ changedAttributes : function(now) {
+ now || (now = this.attributes);
+ var old = this._previousAttributes;
+ var changed = false;
+ for (var attr in now) {
+ if (!_.isEqual(old[attr], now[attr])) {
+ changed = changed || {};
+ changed[attr] = now[attr];
+ }
+ }
+ return changed;
+ },
+
+ // Get the previous value of an attribute, recorded at the time the last
+ // `"change"` event was fired.
+ previous : function(attr) {
+ if (!attr || !this._previousAttributes) return null;
+ return this._previousAttributes[attr];
+ },
+
+ // Get all of the attributes of the model at the time of the previous
+ // `"change"` event.
+ previousAttributes : function() {
+ return _.clone(this._previousAttributes);
+ },
+
+ // Run validation against a set of incoming attributes, returning `true`
+ // if all is well. If a specific `error` callback has been passed,
+ // call that instead of firing the general `"error"` event.
+ _performValidation : function(attrs, options) {
+ var error = this.validate(attrs);
+ if (error) {
+ if (options.error) {
+ options.error(this, error, options);
+ } else {
+ this.trigger('error', this, error, options);
+ }
+ return false;
+ }
+ return true;
+ }
+
+ });
+
+ // Backbone.Collection
+ // -------------------
+
+ // Provides a standard collection class for our sets of models, ordered
+ // or unordered. If a `comparator` is specified, the Collection will maintain
+ // its models in sort order, as they're added and removed.
+ Backbone.Collection = function(models, options) {
+ options || (options = {});
+ if (options.comparator) this.comparator = options.comparator;
+ _.bindAll(this, '_onModelEvent', '_removeReference');
+ this._reset();
+ if (models) this.reset(models, {silent: true});
+ this.initialize.apply(this, arguments);
+ };
+
+ // Define the Collection's inheritable methods.
+ _.extend(Backbone.Collection.prototype, Backbone.Events, {
+
+ // The default model for a collection is just a **Backbone.Model**.
+ // This should be overridden in most cases.
+ model : Backbone.Model,
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize : function(){},
+
+ // The JSON representation of a Collection is an array of the
+ // models' attributes.
+ toJSON : function() {
+ return this.map(function(model){ return model.toJSON(); });
+ },
+
+ // Add a model, or list of models to the set. Pass **silent** to avoid
+ // firing the `added` event for every new model.
+ add : function(models, options) {
+ if (_.isArray(models)) {
+ for (var i = 0, l = models.length; i < l; i++) {
+ this._add(models[i], options);
+ }
+ } else {
+ this._add(models, options);
+ }
+ return this;
+ },
+
+ // Remove a model, or a list of models from the set. Pass silent to avoid
+ // firing the `removed` event for every model removed.
+ remove : function(models, options) {
+ if (_.isArray(models)) {
+ for (var i = 0, l = models.length; i < l; i++) {
+ this._remove(models[i], options);
+ }
+ } else {
+ this._remove(models, options);
+ }
+ return this;
+ },
+
+ // Get a model from the set by id.
+ get : function(id) {
+ if (id == null) return null;
+ return this._byId[id.id != null ? id.id : id];
+ },
+
+ // Get a model from the set by client id.
+ getByCid : function(cid) {
+ return cid && this._byCid[cid.cid || cid];
+ },
+
+ // Get the model at the given index.
+ at: function(index) {
+ return this.models[index];
+ },
+
+ // Force the collection to re-sort itself. You don't need to call this under normal
+ // circumstances, as the set will maintain sort order as each item is added.
+ sort : function(options) {
+ options || (options = {});
+ if (!this.comparator) throw new Error('Cannot sort a set without a comparator');
+ this.models = this.sortBy(this.comparator);
+ if (!options.silent) this.trigger('reset', this, options);
+ return this;
+ },
+
+ // Pluck an attribute from each model in the collection.
+ pluck : function(attr) {
+ return _.map(this.models, function(model){ return model.get(attr); });
+ },
+
+ // When you have more items than you want to add or remove individually,
+ // you can reset the entire set with a new list of models, without firing
+ // any `added` or `removed` events. Fires `reset` when finished.
+ reset : function(models, options) {
+ models || (models = []);
+ options || (options = {});
+ this.each(this._removeReference);
+ this._reset();
+ this.add(models, {silent: true});
+ if (!options.silent) this.trigger('reset', this, options);
+ return this;
+ },
+
+ // Fetch the default set of models for this collection, resetting the
+ // collection when they arrive. If `add: true` is passed, appends the
+ // models to the collection instead of resetting.
+ fetch : function(options) {
+ options || (options = {});
+ var collection = this;
+ var success = options.success;
+ options.success = function(resp, status, xhr) {
+ collection[options.add ? 'add' : 'reset'](collection.parse(resp, xhr), options);
+ if (success) success(collection, resp);
+ };
+ options.error = wrapError(options.error, collection, options);
+ return (this.sync || Backbone.sync).call(this, 'read', this, options);
+ },
+
+ // Create a new instance of a model in this collection. After the model
+ // has been created on the server, it will be added to the collection.
+ // Returns the model, or 'false' if validation on a new model fails.
+ create : function(model, options) {
+ var coll = this;
+ options || (options = {});
+ model = this._prepareModel(model, options);
+ if (!model) return false;
+ var success = options.success;
+ options.success = function(nextModel, resp, xhr) {
+ coll.add(nextModel, options);
+ if (success) success(nextModel, resp, xhr);
+ };
+ model.save(null, options);
+ return model;
+ },
+
+ // **parse** converts a response into a list of models to be added to the
+ // collection. The default implementation is just to pass it through.
+ parse : function(resp, xhr) {
+ return resp;
+ },
+
+ // Proxy to _'s chain. Can't be proxied the same way the rest of the
+ // underscore methods are proxied because it relies on the underscore
+ // constructor.
+ chain: function () {
+ return _(this.models).chain();
+ },
+
+ // Reset all internal state. Called when the collection is reset.
+ _reset : function(options) {
+ this.length = 0;
+ this.models = [];
+ this._byId = {};
+ this._byCid = {};
+ },
+
+ // Prepare a model to be added to this collection
+ _prepareModel: function(model, options) {
+ if (!(model instanceof Backbone.Model)) {
+ var attrs = model;
+ model = new this.model(attrs, {collection: this});
+ if (model.validate && !model._performValidation(attrs, options)) model = false;
+ } else if (!model.collection) {
+ model.collection = this;
+ }
+ return model;
+ },
+
+ // Internal implementation of adding a single model to the set, updating
+ // hash indexes for `id` and `cid` lookups.
+ // Returns the model, or 'false' if validation on a new model fails.
+ _add : function(model, options) {
+ options || (options = {});
+ model = this._prepareModel(model, options);
+ if (!model) return false;
+ var already = this.getByCid(model);
+ if (already) throw new Error(["Can't add the same model to a set twice", already.id]);
+ this._byId[model.id] = model;
+ this._byCid[model.cid] = model;
+ var index = options.at != null ? options.at :
+ this.comparator ? this.sortedIndex(model, this.comparator) :
+ this.length;
+ this.models.splice(index, 0, model);
+ model.bind('all', this._onModelEvent);
+ this.length++;
+ if (!options.silent) model.trigger('add', model, this, options);
+ return model;
+ },
+
+ // Internal implementation of removing a single model from the set, updating
+ // hash indexes for `id` and `cid` lookups.
+ _remove : function(model, options) {
+ options || (options = {});
+ model = this.getByCid(model) || this.get(model);
+ if (!model) return null;
+ delete this._byId[model.id];
+ delete this._byCid[model.cid];
+ this.models.splice(this.indexOf(model), 1);
+ this.length--;
+ if (!options.silent) model.trigger('remove', model, this, options);
+ this._removeReference(model);
+ return model;
+ },
+
+ // Internal method to remove a model's ties to a collection.
+ _removeReference : function(model) {
+ if (this == model.collection) {
+ delete model.collection;
+ }
+ model.unbind('all', this._onModelEvent);
+ },
+
+ // Internal method called every time a model in the set fires an event.
+ // Sets need to update their indexes when models change ids. All other
+ // events simply proxy through. "add" and "remove" events that originate
+ // in other collections are ignored.
+ _onModelEvent : function(ev, model, collection, options) {
+ if ((ev == 'add' || ev == 'remove') && collection != this) return;
+ if (ev == 'destroy') {
+ this._remove(model, options);
+ }
+ if (model && ev === 'change:' + model.idAttribute) {
+ delete this._byId[model.previous(model.idAttribute)];
+ this._byId[model.id] = model;
+ }
+ this.trigger.apply(this, arguments);
+ }
+
+ });
+
+ // Underscore methods that we want to implement on the Collection.
+ var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find', 'detect',
+ 'filter', 'select', 'reject', 'every', 'all', 'some', 'any', 'include',
+ 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex', 'toArray', 'size',
+ 'first', 'rest', 'last', 'without', 'indexOf', 'lastIndexOf', 'isEmpty', 'groupBy'];
+
+ // Mix in each Underscore method as a proxy to `Collection#models`.
+ _.each(methods, function(method) {
+ Backbone.Collection.prototype[method] = function() {
+ return _[method].apply(_, [this.models].concat(_.toArray(arguments)));
+ };
+ });
+
+ // Backbone.Router
+ // -------------------
+
+ // Routers map faux-URLs to actions, and fire events when routes are
+ // matched. Creating a new one sets its `routes` hash, if not set statically.
+ Backbone.Router = function(options) {
+ options || (options = {});
+ if (options.routes) this.routes = options.routes;
+ this._bindRoutes();
+ this.initialize.apply(this, arguments);
+ };
+
+ // Cached regular expressions for matching named param parts and splatted
+ // parts of route strings.
+ var namedParam = /:([\w\d]+)/g;
+ var splatParam = /\*([\w\d]+)/g;
+ var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g;
+
+ // Set up all inheritable **Backbone.Router** properties and methods.
+ _.extend(Backbone.Router.prototype, Backbone.Events, {
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize : function(){},
+
+ // Manually bind a single named route to a callback. For example:
+ //
+ // this.route('search/:query/p:num', 'search', function(query, num) {
+ // ...
+ // });
+ //
+ route : function(route, name, callback) {
+ Backbone.history || (Backbone.history = new Backbone.History);
+ if (!_.isRegExp(route)) route = this._routeToRegExp(route);
+ Backbone.history.route(route, _.bind(function(fragment) {
+ var args = this._extractParameters(route, fragment);
+ callback.apply(this, args);
+ this.trigger.apply(this, ['route:' + name].concat(args));
+ }, this));
+ },
+
+ // Simple proxy to `Backbone.history` to save a fragment into the history.
+ navigate : function(fragment, triggerRoute) {
+ Backbone.history.navigate(fragment, triggerRoute);
+ },
+
+ // Bind all defined routes to `Backbone.history`. We have to reverse the
+ // order of the routes here to support behavior where the most general
+ // routes can be defined at the bottom of the route map.
+ _bindRoutes : function() {
+ if (!this.routes) return;
+ var routes = [];
+ for (var route in this.routes) {
+ routes.unshift([route, this.routes[route]]);
+ }
+ for (var i = 0, l = routes.length; i < l; i++) {
+ this.route(routes[i][0], routes[i][1], this[routes[i][1]]);
+ }
+ },
+
+ // Convert a route string into a regular expression, suitable for matching
+ // against the current location hash.
+ _routeToRegExp : function(route) {
+ route = route.replace(escapeRegExp, "\\$&")
+ .replace(namedParam, "([^\/]*)")
+ .replace(splatParam, "(.*?)");
+ return new RegExp('^' + route + '$');
+ },
+
+ // Given a route, and a URL fragment that it matches, return the array of
+ // extracted parameters.
+ _extractParameters : function(route, fragment) {
+ return route.exec(fragment).slice(1);
+ }
+
+ });
+
+ // Backbone.History
+ // ----------------
+
+ // Handles cross-browser history management, based on URL fragments. If the
+ // browser does not support `onhashchange`, falls back to polling.
+ Backbone.History = function() {
+ this.handlers = [];
+ _.bindAll(this, 'checkUrl');
+ };
+
+ // Cached regex for cleaning hashes.
+ var hashStrip = /^#*/;
+
+ // Cached regex for detecting MSIE.
+ var isExplorer = /msie [\w.]+/;
+
+ // Has the history handling already been started?
+ var historyStarted = false;
+
+ // Set up all inheritable **Backbone.History** properties and methods.
+ _.extend(Backbone.History.prototype, {
+
+ // The default interval to poll for hash changes, if necessary, is
+ // twenty times a second.
+ interval: 50,
+
+ // Get the cross-browser normalized URL fragment, either from the URL,
+ // the hash, or the override.
+ getFragment : function(fragment, forcePushState) {
+ if (fragment == null) {
+ if (this._hasPushState || forcePushState) {
+ fragment = window.location.pathname;
+ var search = window.location.search;
+ if (search) fragment += search;
+ if (fragment.indexOf(this.options.root) == 0) fragment = fragment.substr(this.options.root.length);
+ } else {
+ fragment = window.location.hash;
+ }
+ }
+ return decodeURIComponent(fragment.replace(hashStrip, ''));
+ },
+
+ // Start the hash change handling, returning `true` if the current URL matches
+ // an existing route, and `false` otherwise.
+ start : function(options) {
+
+ // Figure out the initial configuration. Do we need an iframe?
+ // Is pushState desired ... is it available?
+ if (historyStarted) throw new Error("Backbone.history has already been started");
+ this.options = _.extend({}, {root: '/'}, this.options, options);
+ this._wantsPushState = !!this.options.pushState;
+ this._hasPushState = !!(this.options.pushState && window.history && window.history.pushState);
+ var fragment = this.getFragment();
+ var docMode = document.documentMode;
+ var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7));
+ if (oldIE) {
+ this.iframe = $('').hide().appendTo('body')[0].contentWindow;
+ this.navigate(fragment);
+ }
+
+ // Depending on whether we're using pushState or hashes, and whether
+ // 'onhashchange' is supported, determine how we check the URL state.
+ if (this._hasPushState) {
+ $(window).bind('popstate', this.checkUrl);
+ } else if ('onhashchange' in window && !oldIE) {
+ $(window).bind('hashchange', this.checkUrl);
+ } else {
+ setInterval(this.checkUrl, this.interval);
+ }
+
+ // Determine if we need to change the base url, for a pushState link
+ // opened by a non-pushState browser.
+ this.fragment = fragment;
+ historyStarted = true;
+ var loc = window.location;
+ var atRoot = loc.pathname == this.options.root;
+ if (this._wantsPushState && !this._hasPushState && !atRoot) {
+ this.fragment = this.getFragment(null, true);
+ window.location.replace(this.options.root + '#' + this.fragment);
+ // Return immediately as browser will do redirect to new url
+ return true;
+ } else if (this._wantsPushState && this._hasPushState && atRoot && loc.hash) {
+ this.fragment = loc.hash.replace(hashStrip, '');
+ window.history.replaceState({}, document.title, loc.protocol + '//' + loc.host + this.options.root + this.fragment);
+ }
+
+ if (!this.options.silent) {
+ return this.loadUrl();
+ }
+ },
+
+ // Add a route to be tested when the fragment changes. Routes added later may
+ // override previous routes.
+ route : function(route, callback) {
+ this.handlers.unshift({route : route, callback : callback});
+ },
+
+ // Checks the current URL to see if it has changed, and if it has,
+ // calls `loadUrl`, normalizing across the hidden iframe.
+ checkUrl : function(e) {
+ var current = this.getFragment();
+ if (current == this.fragment && this.iframe) current = this.getFragment(this.iframe.location.hash);
+ if (current == this.fragment || current == decodeURIComponent(this.fragment)) return false;
+ if (this.iframe) this.navigate(current);
+ this.loadUrl() || this.loadUrl(window.location.hash);
+ },
+
+ // Attempt to load the current URL fragment. If a route succeeds with a
+ // match, returns `true`. If no defined routes matches the fragment,
+ // returns `false`.
+ loadUrl : function(fragmentOverride) {
+ var fragment = this.fragment = this.getFragment(fragmentOverride);
+ var matched = _.any(this.handlers, function(handler) {
+ if (handler.route.test(fragment)) {
+ handler.callback(fragment);
+ return true;
+ }
+ });
+ return matched;
+ },
+
+ // Save a fragment into the hash history. You are responsible for properly
+ // URL-encoding the fragment in advance. This does not trigger
+ // a `hashchange` event.
+ navigate : function(fragment, triggerRoute) {
+ var frag = (fragment || '').replace(hashStrip, '');
+ if (this.fragment == frag || this.fragment == decodeURIComponent(frag)) return;
+ if (this._hasPushState) {
+ var loc = window.location;
+ if (frag.indexOf(this.options.root) != 0) frag = this.options.root + frag;
+ this.fragment = frag;
+ window.history.pushState({}, document.title, loc.protocol + '//' + loc.host + frag);
+ } else {
+ window.location.hash = this.fragment = frag;
+ if (this.iframe && (frag != this.getFragment(this.iframe.location.hash))) {
+ this.iframe.document.open().close();
+ this.iframe.location.hash = frag;
+ }
+ }
+ if (triggerRoute) this.loadUrl(fragment);
+ }
+
+ });
+
+ // Backbone.View
+ // -------------
+
+ // Creating a Backbone.View creates its initial element outside of the DOM,
+ // if an existing element is not provided...
+ Backbone.View = function(options) {
+ this.cid = _.uniqueId('view');
+ this._configure(options || {});
+ this._ensureElement();
+ this.delegateEvents();
+ this.initialize.apply(this, arguments);
+ };
+
+ // Element lookup, scoped to DOM elements within the current view.
+ // This should be prefered to global lookups, if you're dealing with
+ // a specific view.
+ var selectorDelegate = function(selector) {
+ return $(selector, this.el);
+ };
+
+ // Cached regex to split keys for `delegate`.
+ var eventSplitter = /^(\S+)\s*(.*)$/;
+
+ // List of view options to be merged as properties.
+ var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName'];
+
+ // Set up all inheritable **Backbone.View** properties and methods.
+ _.extend(Backbone.View.prototype, Backbone.Events, {
+
+ // The default `tagName` of a View's element is `"div"`.
+ tagName : 'div',
+
+ // Attach the `selectorDelegate` function as the `$` property.
+ $ : selectorDelegate,
+
+ // Initialize is an empty function by default. Override it with your own
+ // initialization logic.
+ initialize : function(){},
+
+ // **render** is the core function that your view should override, in order
+ // to populate its element (`this.el`), with the appropriate HTML. The
+ // convention is for **render** to always return `this`.
+ render : function() {
+ return this;
+ },
+
+ // Remove this view from the DOM. Note that the view isn't present in the
+ // DOM by default, so calling this method may be a no-op.
+ remove : function() {
+ $(this.el).remove();
+ return this;
+ },
+
+ // For small amounts of DOM Elements, where a full-blown template isn't
+ // needed, use **make** to manufacture elements, one at a time.
+ //
+ // var el = this.make('li', {'class': 'row'}, this.model.escape('title'));
+ //
+ make : function(tagName, attributes, content) {
+ var el = document.createElement(tagName);
+ if (attributes) $(el).attr(attributes);
+ if (content) $(el).html(content);
+ return el;
+ },
+
+ // Set callbacks, where `this.callbacks` is a hash of
+ //
+ // *{"event selector": "callback"}*
+ //
+ // {
+ // 'mousedown .title': 'edit',
+ // 'click .button': 'save'
+ // }
+ //
+ // pairs. Callbacks will be bound to the view, with `this` set properly.
+ // Uses event delegation for efficiency.
+ // Omitting the selector binds the event to `this.el`.
+ // This only works for delegate-able events: not `focus`, `blur`, and
+ // not `change`, `submit`, and `reset` in Internet Explorer.
+ delegateEvents : function(events) {
+ if (!(events || (events = this.events))) return;
+ if (_.isFunction(events)) events = events.call(this);
+ $(this.el).unbind('.delegateEvents' + this.cid);
+ for (var key in events) {
+ var method = this[events[key]];
+ if (!method) throw new Error('Event "' + events[key] + '" does not exist');
+ var match = key.match(eventSplitter);
+ var eventName = match[1], selector = match[2];
+ method = _.bind(method, this);
+ eventName += '.delegateEvents' + this.cid;
+ if (selector === '') {
+ $(this.el).bind(eventName, method);
+ } else {
+ $(this.el).delegate(selector, eventName, method);
+ }
+ }
+ },
+
+ // Performs the initial configuration of a View with a set of options.
+ // Keys with special meaning *(model, collection, id, className)*, are
+ // attached directly to the view.
+ _configure : function(options) {
+ if (this.options) options = _.extend({}, this.options, options);
+ for (var i = 0, l = viewOptions.length; i < l; i++) {
+ var attr = viewOptions[i];
+ if (options[attr]) this[attr] = options[attr];
+ }
+ this.options = options;
+ },
+
+ // Ensure that the View has a DOM element to render into.
+ // If `this.el` is a string, pass it through `$()`, take the first
+ // matching element, and re-assign it to `el`. Otherwise, create
+ // an element from the `id`, `className` and `tagName` proeprties.
+ _ensureElement : function() {
+ if (!this.el) {
+ var attrs = this.attributes || {};
+ if (this.id) attrs.id = this.id;
+ if (this.className) attrs['class'] = this.className;
+ this.el = this.make(this.tagName, attrs);
+ } else if (_.isString(this.el)) {
+ this.el = $(this.el).get(0);
+ }
+ }
+
+ });
+
+ // The self-propagating extend function that Backbone classes use.
+ var extend = function (protoProps, classProps) {
+ var child = inherits(this, protoProps, classProps);
+ child.extend = this.extend;
+ return child;
+ };
+
+ // Set up inheritance for the model, collection, and view.
+ Backbone.Model.extend = Backbone.Collection.extend =
+ Backbone.Router.extend = Backbone.View.extend = extend;
+
+ // Map from CRUD to HTTP for our default `Backbone.sync` implementation.
+ var methodMap = {
+ 'create': 'POST',
+ 'update': 'PUT',
+ 'delete': 'DELETE',
+ 'read' : 'GET'
+ };
+
+ // Backbone.sync
+ // -------------
+
+ // Override this function to change the manner in which Backbone persists
+ // models to the server. You will be passed the type of request, and the
+ // model in question. By default, uses makes a RESTful Ajax request
+ // to the model's `url()`. Some possible customizations could be:
+ //
+ // * Use `setTimeout` to batch rapid-fire updates into a single request.
+ // * Send up the models as XML instead of JSON.
+ // * Persist models via WebSockets instead of Ajax.
+ //
+ // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests
+ // as `POST`, with a `_method` parameter containing the true HTTP method,
+ // as well as all requests with the body as `application/x-www-form-urlencoded` instead of
+ // `application/json` with the model in a param named `model`.
+ // Useful when interfacing with server-side languages like **PHP** that make
+ // it difficult to read the body of `PUT` requests.
+ Backbone.sync = function(method, model, options) {
+ var type = methodMap[method];
+
+ // Default JSON-request options.
+ var params = _.extend({
+ type: type,
+ dataType: 'json'
+ }, options);
+
+ // Ensure that we have a URL.
+ if (!params.url) {
+ params.url = getUrl(model) || urlError();
+ }
+
+ // Ensure that we have the appropriate request data.
+ if (!params.data && model && (method == 'create' || method == 'update')) {
+ params.contentType = 'application/json';
+ params.data = JSON.stringify(model.toJSON());
+ }
+
+ // For older servers, emulate JSON by encoding the request into an HTML-form.
+ if (Backbone.emulateJSON) {
+ params.contentType = 'application/x-www-form-urlencoded';
+ params.data = params.data ? {model : params.data} : {};
+ }
+
+ // For older servers, emulate HTTP by mimicking the HTTP method with `_method`
+ // And an `X-HTTP-Method-Override` header.
+ if (Backbone.emulateHTTP) {
+ if (type === 'PUT' || type === 'DELETE') {
+ if (Backbone.emulateJSON) params.data._method = type;
+ params.type = 'POST';
+ params.beforeSend = function(xhr) {
+ xhr.setRequestHeader('X-HTTP-Method-Override', type);
+ };
+ }
+ }
+
+ // Don't process data on a non-GET request.
+ if (params.type !== 'GET' && !Backbone.emulateJSON) {
+ params.processData = false;
+ }
+
+ // Make the request.
+ return $.ajax(params);
+ };
+
+ // Helpers
+ // -------
+
+ // Shared empty constructor function to aid in prototype-chain creation.
+ var ctor = function(){};
+
+ // Helper function to correctly set up the prototype chain, for subclasses.
+ // Similar to `goog.inherits`, but uses a hash of prototype properties and
+ // class properties to be extended.
+ var inherits = function(parent, protoProps, staticProps) {
+ var child;
+
+ // The constructor function for the new subclass is either defined by you
+ // (the "constructor" property in your `extend` definition), or defaulted
+ // by us to simply call `super()`.
+ if (protoProps && protoProps.hasOwnProperty('constructor')) {
+ child = protoProps.constructor;
+ } else {
+ child = function(){ return parent.apply(this, arguments); };
+ }
+
+ // Inherit class (static) properties from parent.
+ _.extend(child, parent);
+
+ // Set the prototype chain to inherit from `parent`, without calling
+ // `parent`'s constructor function.
+ ctor.prototype = parent.prototype;
+ child.prototype = new ctor();
+
+ // Add prototype properties (instance properties) to the subclass,
+ // if supplied.
+ if (protoProps) _.extend(child.prototype, protoProps);
+
+ // Add static properties to the constructor function, if supplied.
+ if (staticProps) _.extend(child, staticProps);
+
+ // Correctly set child's `prototype.constructor`.
+ child.prototype.constructor = child;
+
+ // Set a convenience property in case the parent's prototype is needed later.
+ child.__super__ = parent.prototype;
+
+ return child;
+ };
+
+ // Helper function to get a URL from a Model or Collection as a property
+ // or as a function.
+ var getUrl = function(object) {
+ if (!(object && object.url)) return null;
+ return _.isFunction(object.url) ? object.url() : object.url;
+ };
+
+ // Throw an error when a URL is needed, and none is supplied.
+ var urlError = function() {
+ throw new Error('A "url" property or function must be specified');
+ };
+
+ // Wrap an optional error callback with a fallback error event.
+ var wrapError = function(onError, model, options) {
+ return function(resp) {
+ if (onError) {
+ onError(model, resp, options);
+ } else {
+ model.trigger('error', model, resp, options);
+ }
+ };
+ };
+
+ // Helper function to escape a string for HTML rendering.
+ var escapeHTML = function(string) {
+ return string.replace(/&(?!\w+;|#\d+;|#x[\da-f]+;)/gi, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g,'/');
+ };
+
+}).call(this);
diff --git a/lib/beetsplug/web/static/beets.css b/lib/beetsplug/web/static/beets.css
new file mode 100644
index 00000000..2ca7fc83
--- /dev/null
+++ b/lib/beetsplug/web/static/beets.css
@@ -0,0 +1,160 @@
+body {
+ font-family: Helvetica, Arial, sans-serif;
+}
+
+#header {
+ position: fixed;
+ left: 0;
+ right: 0;
+ top: 0;
+ height: 36px;
+
+ color: white;
+
+ cursor: default;
+
+ /* shadowy border */
+ box-shadow: 0 0 20px #999;
+ -webkit-box-shadow: 0 0 20px #999;
+ -moz-box-shadow: 0 0 20px #999;
+
+ /* background gradient */
+ background: #0e0e0e;
+ background: -moz-linear-gradient(top, #6b6b6b 0%, #0e0e0e 100%);
+ background: -webkit-linear-gradient(top, #6b6b6b 0%,#0e0e0e 100%);
+}
+#header h1 {
+ font-size: 1.1em;
+ font-weight: bold;
+ color: white;
+ margin: 0.35em;
+ float: left;
+}
+
+#entities {
+ width: 17em;
+
+ position: fixed;
+ top: 36px;
+ left: 0;
+ bottom: 0;
+ margin: 0;
+
+ z-index: 1;
+ background: #dde4eb;
+
+ /* shadowy border */
+ box-shadow: 0 0 20px #666;
+ -webkit-box-shadow: 0 0 20px #666;
+ -moz-box-shadow: 0 0 20px #666;
+}
+#queryForm {
+ display: block;
+ text-align: center;
+ margin: 0.25em 0;
+}
+#query {
+ width: 95%;
+ font-size: 1em;
+}
+#entities ul {
+ width: 17em;
+
+ position: fixed;
+ top: 36px;
+ left: 0;
+ bottom: 0;
+ margin: 2.2em 0 0 0;
+ padding: 0;
+
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+#entities ul li {
+ list-style: none;
+ padding: 4px 8px;
+ margin: 0;
+ cursor: default;
+}
+#entities ul li.selected {
+ background: #7abcff;
+ background: -moz-linear-gradient(top, #7abcff 0%, #60abf8 44%, #4096ee 100%);
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#7abcff), color-stop(44%,#60abf8), color-stop(100%,#4096ee));
+ color: white;
+}
+#entities ul li .playing {
+ margin-left: 5px;
+ font-size: 0.9em;
+}
+
+
+#main-detail, #extra-detail {
+ position: fixed;
+ left: 17em;
+ margin: 1.0em 0 0 1.5em;
+}
+#main-detail {
+ top: 36px;
+ height: 98px;
+}
+#main-detail .artist, #main-detail .album, #main-detail .title {
+ display: block;
+}
+#main-detail .title {
+ font-size: 1.3em;
+ font-weight: bold;
+}
+#main-detail .albumtitle {
+ font-style: italic;
+}
+
+#extra-detail {
+ overflow-x: hidden;
+ overflow-y: auto;
+ top: 134px;
+ bottom: 0;
+ right: 0;
+}
+/*Fix for correctly displaying line breaks in lyrics*/
+#extra-detail .lyrics {
+ white-space: pre-wrap;
+}
+#extra-detail dl dt, #extra-detail dl dd {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+#extra-detail dl dt {
+ width: 10em;
+ float: left;
+ text-align: right;
+ font-weight: bold;
+ clear: both;
+}
+#extra-detail dl dd {
+ margin-left: 10.5em;
+}
+
+
+#player {
+ float: left;
+ width: 150px;
+ height: 36px;
+}
+#player .play, #player .pause, #player .disabled {
+ -webkit-appearance: none;
+ font-size: 1em;
+ font-family: Helvetica, Arial, sans-serif;
+ background: none;
+ border: none;
+ color: white;
+ padding: 5px;
+ margin: 0;
+ text-align: center;
+
+ width: 36px;
+ height: 36px;
+}
+#player .disabled {
+ color: #666;
+}
diff --git a/lib/beetsplug/web/static/beets.js b/lib/beetsplug/web/static/beets.js
new file mode 100644
index 00000000..97af7011
--- /dev/null
+++ b/lib/beetsplug/web/static/beets.js
@@ -0,0 +1,314 @@
+// Format times as minutes and seconds.
+var timeFormat = function(secs) {
+ if (secs == undefined || isNaN(secs)) {
+ return '0:00';
+ }
+ secs = Math.round(secs);
+ var mins = '' + Math.floor(secs / 60);
+ secs = '' + (secs % 60);
+ if (secs.length < 2) {
+ secs = '0' + secs;
+ }
+ return mins + ':' + secs;
+}
+
+// jQuery extension encapsulating event hookups for audio element controls.
+$.fn.player = function(debug) {
+ // Selected element should contain an HTML5 Audio element.
+ var audio = $('audio', this).get(0);
+
+ // Control elements that may be present, identified by class.
+ var playBtn = $('.play', this);
+ var pauseBtn = $('.pause', this);
+ var disabledInd = $('.disabled', this);
+ var timesEl = $('.times', this);
+ var curTimeEl = $('.currentTime', this);
+ var totalTimeEl = $('.totalTime', this);
+ var sliderPlayedEl = $('.slider .played', this);
+ var sliderLoadedEl = $('.slider .loaded', this);
+
+ // Button events.
+ playBtn.click(function() {
+ audio.play();
+ });
+ pauseBtn.click(function(ev) {
+ audio.pause();
+ });
+
+ // Utilities.
+ var timePercent = function(cur, total) {
+ if (cur == undefined || isNaN(cur) ||
+ total == undefined || isNaN(total) || total == 0) {
+ return 0;
+ }
+ var ratio = cur / total;
+ if (ratio > 1.0) {
+ ratio = 1.0;
+ }
+ return (Math.round(ratio * 10000) / 100) + '%';
+ }
+
+ // Event helpers.
+ var dbg = function(msg) {
+ if (debug)
+ console.log(msg);
+ }
+ var showState = function() {
+ if (audio.duration == undefined || isNaN(audio.duration)) {
+ playBtn.hide();
+ pauseBtn.hide();
+ disabledInd.show();
+ timesEl.hide();
+ } else if (audio.paused) {
+ playBtn.show();
+ pauseBtn.hide();
+ disabledInd.hide();
+ timesEl.show();
+ } else {
+ playBtn.hide();
+ pauseBtn.show();
+ disabledInd.hide();
+ timesEl.show();
+ }
+ }
+ var showTimes = function() {
+ curTimeEl.text(timeFormat(audio.currentTime));
+ totalTimeEl.text(timeFormat(audio.duration));
+
+ sliderPlayedEl.css('width',
+ timePercent(audio.currentTime, audio.duration));
+
+ // last time buffered
+ var bufferEnd = 0;
+ for (var i = 0; i < audio.buffered.length; ++i) {
+ if (audio.buffered.end(i) > bufferEnd)
+ bufferEnd = audio.buffered.end(i);
+ }
+ sliderLoadedEl.css('width',
+ timePercent(bufferEnd, audio.duration));
+ }
+
+ // Initialize controls.
+ showState();
+ showTimes();
+
+ // Bind events.
+ $('audio', this).bind({
+ playing: function() {
+ dbg('playing');
+ showState();
+ },
+ pause: function() {
+ dbg('pause');
+ showState();
+ },
+ ended: function() {
+ dbg('ended');
+ showState();
+ },
+ progress: function() {
+ dbg('progress ' + audio.buffered);
+ },
+ timeupdate: function() {
+ dbg('timeupdate ' + audio.currentTime);
+ showTimes();
+ },
+ durationchange: function() {
+ dbg('durationchange ' + audio.duration);
+ showState();
+ showTimes();
+ },
+ loadeddata: function() {
+ dbg('loadeddata');
+ },
+ loadedmetadata: function() {
+ dbg('loadedmetadata');
+ }
+ });
+}
+
+// Simple selection disable for jQuery.
+// Cut-and-paste from:
+// https://stackoverflow.com/questions/2700000
+$.fn.disableSelection = function() {
+ $(this).attr('unselectable', 'on')
+ .css('-moz-user-select', 'none')
+ .each(function() {
+ this.onselectstart = function() { return false; };
+ });
+};
+
+$(function() {
+
+// Routes.
+var BeetsRouter = Backbone.Router.extend({
+ routes: {
+ "item/query/:query": "itemQuery",
+ },
+ itemQuery: function(query) {
+ var queryURL = query.split(/\s+/).map(encodeURIComponent).join('/');
+ $.getJSON('item/query/' + queryURL, function(data) {
+ var models = _.map(
+ data['results'],
+ function(d) { return new Item(d); }
+ );
+ var results = new Items(models);
+ app.showItems(results);
+ });
+ }
+});
+var router = new BeetsRouter();
+
+// Model.
+var Item = Backbone.Model.extend({
+ urlRoot: 'item'
+});
+var Items = Backbone.Collection.extend({
+ model: Item
+});
+
+// Item views.
+var ItemEntryView = Backbone.View.extend({
+ tagName: "li",
+ template: _.template($('#item-entry-template').html()),
+ events: {
+ 'click': 'select',
+ 'dblclick': 'play'
+ },
+ initialize: function() {
+ this.playing = false;
+ },
+ render: function() {
+ $(this.el).html(this.template(this.model.toJSON()));
+ this.setPlaying(this.playing);
+ return this;
+ },
+ select: function() {
+ app.selectItem(this);
+ },
+ play: function() {
+ app.playItem(this.model);
+ },
+ setPlaying: function(val) {
+ this.playing = val;
+ if (val)
+ this.$('.playing').show();
+ else
+ this.$('.playing').hide();
+ }
+});
+//Holds Title, Artist, Album etc.
+var ItemMainDetailView = Backbone.View.extend({
+ tagName: "div",
+ template: _.template($('#item-main-detail-template').html()),
+ events: {
+ 'click .play': 'play',
+ },
+ render: function() {
+ $(this.el).html(this.template(this.model.toJSON()));
+ return this;
+ },
+ play: function() {
+ app.playItem(this.model);
+ }
+});
+// Holds Track no., Format, MusicBrainz link, Lyrics, Comments etc.
+var ItemExtraDetailView = Backbone.View.extend({
+ tagName: "div",
+ template: _.template($('#item-extra-detail-template').html()),
+ render: function() {
+ $(this.el).html(this.template(this.model.toJSON()));
+ return this;
+ }
+});
+// Main app view.
+var AppView = Backbone.View.extend({
+ el: $('body'),
+ events: {
+ 'submit #queryForm': 'querySubmit',
+ },
+ querySubmit: function(ev) {
+ ev.preventDefault();
+ router.navigate('item/query/' + encodeURIComponent($('#query').val()), true);
+ },
+ initialize: function() {
+ this.playingItem = null;
+ this.shownItems = null;
+
+ // Not sure why these events won't bind automatically.
+ this.$('audio').bind({
+ 'play': _.bind(this.audioPlay, this),
+ 'pause': _.bind(this.audioPause, this),
+ 'ended': _.bind(this.audioEnded, this)
+ });
+ },
+ showItems: function(items) {
+ this.shownItems = items;
+ $('#results').empty();
+ items.each(function(item) {
+ var view = new ItemEntryView({model: item});
+ item.entryView = view;
+ $('#results').append(view.render().el);
+ });
+ },
+ selectItem: function(view) {
+ // Mark row as selected.
+ $('#results li').removeClass("selected");
+ $(view.el).addClass("selected");
+
+ // Show main and extra detail.
+ var mainDetailView = new ItemMainDetailView({model: view.model});
+ $('#main-detail').empty().append(mainDetailView.render().el);
+
+ var extraDetailView = new ItemExtraDetailView({model: view.model});
+ $('#extra-detail').empty().append(extraDetailView.render().el);
+ },
+ playItem: function(item) {
+ var url = 'item/' + item.get('id') + '/file';
+ $('#player audio').attr('src', url);
+ $('#player audio').get(0).play();
+
+ if (this.playingItem != null) {
+ this.playingItem.entryView.setPlaying(false);
+ }
+ item.entryView.setPlaying(true);
+ this.playingItem = item;
+ },
+
+ audioPause: function() {
+ this.playingItem.entryView.setPlaying(false);
+ },
+ audioPlay: function() {
+ if (this.playingItem != null)
+ this.playingItem.entryView.setPlaying(true);
+ },
+ audioEnded: function() {
+ this.playingItem.entryView.setPlaying(false);
+
+ // Try to play the next track.
+ var idx = this.shownItems.indexOf(this.playingItem);
+ if (idx == -1) {
+ // Not in current list.
+ return;
+ }
+ var nextIdx = idx + 1;
+ if (nextIdx >= this.shownItems.size()) {
+ // End of list.
+ return;
+ }
+ this.playItem(this.shownItems.at(nextIdx));
+ }
+});
+var app = new AppView();
+
+// App setup.
+Backbone.history.start({pushState: false});
+
+// Disable selection on UI elements.
+$('#entities ul').disableSelection();
+$('#header').disableSelection();
+
+// Audio player setup.
+$('#player').player();
+
+});
diff --git a/lib/beetsplug/web/static/jquery.js b/lib/beetsplug/web/static/jquery.js
new file mode 100644
index 00000000..e1414212
--- /dev/null
+++ b/lib/beetsplug/web/static/jquery.js
@@ -0,0 +1,9266 @@
+/*!
+ * jQuery JavaScript Library v1.7.1
+ * http://jquery.com/
+ *
+ * Copyright 2016, John Resig
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * Includes Sizzle.js
+ * http://sizzlejs.com/
+ * Copyright 2016, The Dojo Foundation
+ * Released under the MIT, BSD, and GPL Licenses.
+ *
+ * Date: Mon Nov 21 21:11:03 2011 -0500
+ */
+(function( window, undefined ) {
+
+// Use the correct document accordingly with window argument (sandbox)
+var document = window.document,
+ navigator = window.navigator,
+ location = window.location;
+var jQuery = (function() {
+
+// Define a local copy of jQuery
+var jQuery = function( selector, context ) {
+ // The jQuery object is actually just the init constructor 'enhanced'
+ return new jQuery.fn.init( selector, context, rootjQuery );
+ },
+
+ // Map over jQuery in case of overwrite
+ _jQuery = window.jQuery,
+
+ // Map over the $ in case of overwrite
+ _$ = window.$,
+
+ // A central reference to the root jQuery(document)
+ rootjQuery,
+
+ // A simple way to check for HTML strings or ID strings
+ // Prioritize #id over to avoid XSS via location.hash (#9521)
+ quickExpr = /^(?:[^#<]*(<[\w\W]+>)[^>]*$|#([\w\-]*)$)/,
+
+ // Check if a string has a non-whitespace character in it
+ rnotwhite = /\S/,
+
+ // Used for trimming whitespace
+ trimLeft = /^\s+/,
+ trimRight = /\s+$/,
+
+ // Match a standalone tag
+ rsingleTag = /^<(\w+)\s*\/?>(?:<\/\1>)?$/,
+
+ // JSON RegExp
+ rvalidchars = /^[\],:{}\s]*$/,
+ rvalidescape = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,
+ rvalidtokens = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,
+ rvalidbraces = /(?:^|:|,)(?:\s*\[)+/g,
+
+ // Useragent RegExp
+ rwebkit = /(webkit)[ \/]([\w.]+)/,
+ ropera = /(opera)(?:.*version)?[ \/]([\w.]+)/,
+ rmsie = /(msie) ([\w.]+)/,
+ rmozilla = /(mozilla)(?:.*? rv:([\w.]+))?/,
+
+ // Matches dashed string for camelizing
+ rdashAlpha = /-([a-z]|[0-9])/ig,
+ rmsPrefix = /^-ms-/,
+
+ // Used by jQuery.camelCase as callback to replace()
+ fcamelCase = function( all, letter ) {
+ return ( letter + "" ).toUpperCase();
+ },
+
+ // Keep a UserAgent string for use with jQuery.browser
+ userAgent = navigator.userAgent,
+
+ // For matching the engine and version of the browser
+ browserMatch,
+
+ // The deferred used on DOM ready
+ readyList,
+
+ // The ready event handler
+ DOMContentLoaded,
+
+ // Save a reference to some core methods
+ toString = Object.prototype.toString,
+ hasOwn = Object.prototype.hasOwnProperty,
+ push = Array.prototype.push,
+ slice = Array.prototype.slice,
+ trim = String.prototype.trim,
+ indexOf = Array.prototype.indexOf,
+
+ // [[Class]] -> type pairs
+ class2type = {};
+
+jQuery.fn = jQuery.prototype = {
+ constructor: jQuery,
+ init: function( selector, context, rootjQuery ) {
+ var match, elem, ret, doc;
+
+ // Handle $(""), $(null), or $(undefined)
+ if ( !selector ) {
+ return this;
+ }
+
+ // Handle $(DOMElement)
+ if ( selector.nodeType ) {
+ this.context = this[0] = selector;
+ this.length = 1;
+ return this;
+ }
+
+ // The body element only exists once, optimize finding it
+ if ( selector === "body" && !context && document.body ) {
+ this.context = document;
+ this[0] = document.body;
+ this.selector = selector;
+ this.length = 1;
+ return this;
+ }
+
+ // Handle HTML strings
+ if ( typeof selector === "string" ) {
+ // Are we dealing with HTML string or an ID?
+ if ( selector.charAt(0) === "<" && selector.charAt( selector.length - 1 ) === ">" && selector.length >= 3 ) {
+ // Assume that strings that start and end with <> are HTML and skip the regex check
+ match = [ null, selector, null ];
+
+ } else {
+ match = quickExpr.exec( selector );
+ }
+
+ // Verify a match, and that no context was specified for #id
+ if ( match && (match[1] || !context) ) {
+
+ // HANDLE: $(html) -> $(array)
+ if ( match[1] ) {
+ context = context instanceof jQuery ? context[0] : context;
+ doc = ( context ? context.ownerDocument || context : document );
+
+ // If a single string is passed in and it's a single tag
+ // just do a createElement and skip the rest
+ ret = rsingleTag.exec( selector );
+
+ if ( ret ) {
+ if ( jQuery.isPlainObject( context ) ) {
+ selector = [ document.createElement( ret[1] ) ];
+ jQuery.fn.attr.call( selector, context, true );
+
+ } else {
+ selector = [ doc.createElement( ret[1] ) ];
+ }
+
+ } else {
+ ret = jQuery.buildFragment( [ match[1] ], [ doc ] );
+ selector = ( ret.cacheable ? jQuery.clone(ret.fragment) : ret.fragment ).childNodes;
+ }
+
+ return jQuery.merge( this, selector );
+
+ // HANDLE: $("#id")
+ } else {
+ elem = document.getElementById( match[2] );
+
+ // Check parentNode to catch when Blackberry 4.6 returns
+ // nodes that are no longer in the document #6963
+ if ( elem && elem.parentNode ) {
+ // Handle the case where IE and Opera return items
+ // by name instead of ID
+ if ( elem.id !== match[2] ) {
+ return rootjQuery.find( selector );
+ }
+
+ // Otherwise, we inject the element directly into the jQuery object
+ this.length = 1;
+ this[0] = elem;
+ }
+
+ this.context = document;
+ this.selector = selector;
+ return this;
+ }
+
+ // HANDLE: $(expr, $(...))
+ } else if ( !context || context.jquery ) {
+ return ( context || rootjQuery ).find( selector );
+
+ // HANDLE: $(expr, context)
+ // (which is just equivalent to: $(context).find(expr)
+ } else {
+ return this.constructor( context ).find( selector );
+ }
+
+ // HANDLE: $(function)
+ // Shortcut for document ready
+ } else if ( jQuery.isFunction( selector ) ) {
+ return rootjQuery.ready( selector );
+ }
+
+ if ( selector.selector !== undefined ) {
+ this.selector = selector.selector;
+ this.context = selector.context;
+ }
+
+ return jQuery.makeArray( selector, this );
+ },
+
+ // Start with an empty selector
+ selector: "",
+
+ // The current version of jQuery being used
+ jquery: "1.7.1",
+
+ // The default length of a jQuery object is 0
+ length: 0,
+
+ // The number of elements contained in the matched element set
+ size: function() {
+ return this.length;
+ },
+
+ toArray: function() {
+ return slice.call( this, 0 );
+ },
+
+ // Get the Nth element in the matched element set OR
+ // Get the whole matched element set as a clean array
+ get: function( num ) {
+ return num == null ?
+
+ // Return a 'clean' array
+ this.toArray() :
+
+ // Return just the object
+ ( num < 0 ? this[ this.length + num ] : this[ num ] );
+ },
+
+ // Take an array of elements and push it onto the stack
+ // (returning the new matched element set)
+ pushStack: function( elems, name, selector ) {
+ // Build a new jQuery matched element set
+ var ret = this.constructor();
+
+ if ( jQuery.isArray( elems ) ) {
+ push.apply( ret, elems );
+
+ } else {
+ jQuery.merge( ret, elems );
+ }
+
+ // Add the old object onto the stack (as a reference)
+ ret.prevObject = this;
+
+ ret.context = this.context;
+
+ if ( name === "find" ) {
+ ret.selector = this.selector + ( this.selector ? " " : "" ) + selector;
+ } else if ( name ) {
+ ret.selector = this.selector + "." + name + "(" + selector + ")";
+ }
+
+ // Return the newly-formed element set
+ return ret;
+ },
+
+ // Execute a callback for every element in the matched set.
+ // (You can seed the arguments with an array of args, but this is
+ // only used internally.)
+ each: function( callback, args ) {
+ return jQuery.each( this, callback, args );
+ },
+
+ ready: function( fn ) {
+ // Attach the listeners
+ jQuery.bindReady();
+
+ // Add the callback
+ readyList.add( fn );
+
+ return this;
+ },
+
+ eq: function( i ) {
+ i = +i;
+ return i === -1 ?
+ this.slice( i ) :
+ this.slice( i, i + 1 );
+ },
+
+ first: function() {
+ return this.eq( 0 );
+ },
+
+ last: function() {
+ return this.eq( -1 );
+ },
+
+ slice: function() {
+ return this.pushStack( slice.apply( this, arguments ),
+ "slice", slice.call(arguments).join(",") );
+ },
+
+ map: function( callback ) {
+ return this.pushStack( jQuery.map(this, function( elem, i ) {
+ return callback.call( elem, i, elem );
+ }));
+ },
+
+ end: function() {
+ return this.prevObject || this.constructor(null);
+ },
+
+ // For internal use only.
+ // Behaves like an Array's method, not like a jQuery method.
+ push: push,
+ sort: [].sort,
+ splice: [].splice
+};
+
+// Give the init function the jQuery prototype for later instantiation
+jQuery.fn.init.prototype = jQuery.fn;
+
+jQuery.extend = jQuery.fn.extend = function() {
+ var options, name, src, copy, copyIsArray, clone,
+ target = arguments[0] || {},
+ i = 1,
+ length = arguments.length,
+ deep = false;
+
+ // Handle a deep copy situation
+ if ( typeof target === "boolean" ) {
+ deep = target;
+ target = arguments[1] || {};
+ // skip the boolean and the target
+ i = 2;
+ }
+
+ // Handle case when target is a string or something (possible in deep copy)
+ if ( typeof target !== "object" && !jQuery.isFunction(target) ) {
+ target = {};
+ }
+
+ // extend jQuery itself if only one argument is passed
+ if ( length === i ) {
+ target = this;
+ --i;
+ }
+
+ for ( ; i < length; i++ ) {
+ // Only deal with non-null/undefined values
+ if ( (options = arguments[ i ]) != null ) {
+ // Extend the base object
+ for ( name in options ) {
+ src = target[ name ];
+ copy = options[ name ];
+
+ // Prevent never-ending loop
+ if ( target === copy ) {
+ continue;
+ }
+
+ // Recurse if we're merging plain objects or arrays
+ if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) {
+ if ( copyIsArray ) {
+ copyIsArray = false;
+ clone = src && jQuery.isArray(src) ? src : [];
+
+ } else {
+ clone = src && jQuery.isPlainObject(src) ? src : {};
+ }
+
+ // Never move original objects, clone them
+ target[ name ] = jQuery.extend( deep, clone, copy );
+
+ // Don't bring in undefined values
+ } else if ( copy !== undefined ) {
+ target[ name ] = copy;
+ }
+ }
+ }
+ }
+
+ // Return the modified object
+ return target;
+};
+
+jQuery.extend({
+ noConflict: function( deep ) {
+ if ( window.$ === jQuery ) {
+ window.$ = _$;
+ }
+
+ if ( deep && window.jQuery === jQuery ) {
+ window.jQuery = _jQuery;
+ }
+
+ return jQuery;
+ },
+
+ // Is the DOM ready to be used? Set to true once it occurs.
+ isReady: false,
+
+ // A counter to track how many items to wait for before
+ // the ready event fires. See #6781
+ readyWait: 1,
+
+ // Hold (or release) the ready event
+ holdReady: function( hold ) {
+ if ( hold ) {
+ jQuery.readyWait++;
+ } else {
+ jQuery.ready( true );
+ }
+ },
+
+ // Handle when the DOM is ready
+ ready: function( wait ) {
+ // Either a released hold or an DOMready/load event and not yet ready
+ if ( (wait === true && !--jQuery.readyWait) || (wait !== true && !jQuery.isReady) ) {
+ // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443).
+ if ( !document.body ) {
+ return setTimeout( jQuery.ready, 1 );
+ }
+
+ // Remember that the DOM is ready
+ jQuery.isReady = true;
+
+ // If a normal DOM Ready event fired, decrement, and wait if need be
+ if ( wait !== true && --jQuery.readyWait > 0 ) {
+ return;
+ }
+
+ // If there are functions bound, to execute
+ readyList.fireWith( document, [ jQuery ] );
+
+ // Trigger any bound ready events
+ if ( jQuery.fn.trigger ) {
+ jQuery( document ).trigger( "ready" ).off( "ready" );
+ }
+ }
+ },
+
+ bindReady: function() {
+ if ( readyList ) {
+ return;
+ }
+
+ readyList = jQuery.Callbacks( "once memory" );
+
+ // Catch cases where $(document).ready() is called after the
+ // browser event has already occurred.
+ if ( document.readyState === "complete" ) {
+ // Handle it asynchronously to allow scripts the opportunity to delay ready
+ return setTimeout( jQuery.ready, 1 );
+ }
+
+ // Mozilla, Opera and webkit nightlies currently support this event
+ if ( document.addEventListener ) {
+ // Use the handy event callback
+ document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false );
+
+ // A fallback to window.onload, that will always work
+ window.addEventListener( "load", jQuery.ready, false );
+
+ // If IE event model is used
+ } else if ( document.attachEvent ) {
+ // ensure firing before onload,
+ // maybe late but safe also for iframes
+ document.attachEvent( "onreadystatechange", DOMContentLoaded );
+
+ // A fallback to window.onload, that will always work
+ window.attachEvent( "onload", jQuery.ready );
+
+ // If IE and not a frame
+ // continually check to see if the document is ready
+ var toplevel = false;
+
+ try {
+ toplevel = window.frameElement == null;
+ } catch(e) {}
+
+ if ( document.documentElement.doScroll && toplevel ) {
+ doScrollCheck();
+ }
+ }
+ },
+
+ // See test/unit/core.js for details concerning isFunction.
+ // Since version 1.3, DOM methods and functions like alert
+ // aren't supported. They return false on IE (#2968).
+ isFunction: function( obj ) {
+ return jQuery.type(obj) === "function";
+ },
+
+ isArray: Array.isArray || function( obj ) {
+ return jQuery.type(obj) === "array";
+ },
+
+ // A crude way of determining if an object is a window
+ isWindow: function( obj ) {
+ return obj && typeof obj === "object" && "setInterval" in obj;
+ },
+
+ isNumeric: function( obj ) {
+ return !isNaN( parseFloat(obj) ) && isFinite( obj );
+ },
+
+ type: function( obj ) {
+ return obj == null ?
+ String( obj ) :
+ class2type[ toString.call(obj) ] || "object";
+ },
+
+ isPlainObject: function( obj ) {
+ // Must be an Object.
+ // Because of IE, we also have to check the presence of the constructor property.
+ // Make sure that DOM nodes and window objects don't pass through, as well
+ if ( !obj || jQuery.type(obj) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) {
+ return false;
+ }
+
+ try {
+ // Not own constructor property must be Object
+ if ( obj.constructor &&
+ !hasOwn.call(obj, "constructor") &&
+ !hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) {
+ return false;
+ }
+ } catch ( e ) {
+ // IE8,9 Will throw exceptions on certain host objects #9897
+ return false;
+ }
+
+ // Own properties are enumerated firstly, so to speed up,
+ // if last one is own, then all properties are own.
+
+ var key;
+ for ( key in obj ) {}
+
+ return key === undefined || hasOwn.call( obj, key );
+ },
+
+ isEmptyObject: function( obj ) {
+ for ( var name in obj ) {
+ return false;
+ }
+ return true;
+ },
+
+ error: function( msg ) {
+ throw new Error( msg );
+ },
+
+ parseJSON: function( data ) {
+ if ( typeof data !== "string" || !data ) {
+ return null;
+ }
+
+ // Make sure leading/trailing whitespace is removed (IE can't handle it)
+ data = jQuery.trim( data );
+
+ // Attempt to parse using the native JSON parser first
+ if ( window.JSON && window.JSON.parse ) {
+ return window.JSON.parse( data );
+ }
+
+ // Make sure the incoming data is actual JSON
+ // Logic borrowed from http://json.org/json2.js
+ if ( rvalidchars.test( data.replace( rvalidescape, "@" )
+ .replace( rvalidtokens, "]" )
+ .replace( rvalidbraces, "")) ) {
+
+ return ( new Function( "return " + data ) )();
+
+ }
+ jQuery.error( "Invalid JSON: " + data );
+ },
+
+ // Cross-browser xml parsing
+ parseXML: function( data ) {
+ var xml, tmp;
+ try {
+ if ( window.DOMParser ) { // Standard
+ tmp = new DOMParser();
+ xml = tmp.parseFromString( data , "text/xml" );
+ } else { // IE
+ xml = new ActiveXObject( "Microsoft.XMLDOM" );
+ xml.async = "false";
+ xml.loadXML( data );
+ }
+ } catch( e ) {
+ xml = undefined;
+ }
+ if ( !xml || !xml.documentElement || xml.getElementsByTagName( "parsererror" ).length ) {
+ jQuery.error( "Invalid XML: " + data );
+ }
+ return xml;
+ },
+
+ noop: function() {},
+
+ // Evaluates a script in a global context
+ // Workarounds based on findings by Jim Driscoll
+ // http://weblogs.java.net/blog/driscoll/archive/2009/09/08/eval-javascript-global-context
+ globalEval: function( data ) {
+ if ( data && rnotwhite.test( data ) ) {
+ // We use execScript on Internet Explorer
+ // We use an anonymous function so that context is window
+ // rather than jQuery in Firefox
+ ( window.execScript || function( data ) {
+ window[ "eval" ].call( window, data );
+ } )( data );
+ }
+ },
+
+ // Convert dashed to camelCase; used by the css and data modules
+ // Microsoft forgot to hump their vendor prefix (#9572)
+ camelCase: function( string ) {
+ return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase );
+ },
+
+ nodeName: function( elem, name ) {
+ return elem.nodeName && elem.nodeName.toUpperCase() === name.toUpperCase();
+ },
+
+ // args is for internal usage only
+ each: function( object, callback, args ) {
+ var name, i = 0,
+ length = object.length,
+ isObj = length === undefined || jQuery.isFunction( object );
+
+ if ( args ) {
+ if ( isObj ) {
+ for ( name in object ) {
+ if ( callback.apply( object[ name ], args ) === false ) {
+ break;
+ }
+ }
+ } else {
+ for ( ; i < length; ) {
+ if ( callback.apply( object[ i++ ], args ) === false ) {
+ break;
+ }
+ }
+ }
+
+ // A special, fast, case for the most common use of each
+ } else {
+ if ( isObj ) {
+ for ( name in object ) {
+ if ( callback.call( object[ name ], name, object[ name ] ) === false ) {
+ break;
+ }
+ }
+ } else {
+ for ( ; i < length; ) {
+ if ( callback.call( object[ i ], i, object[ i++ ] ) === false ) {
+ break;
+ }
+ }
+ }
+ }
+
+ return object;
+ },
+
+ // Use native String.trim function wherever possible
+ trim: trim ?
+ function( text ) {
+ return text == null ?
+ "" :
+ trim.call( text );
+ } :
+
+ // Otherwise use our own trimming functionality
+ function( text ) {
+ return text == null ?
+ "" :
+ text.toString().replace( trimLeft, "" ).replace( trimRight, "" );
+ },
+
+ // results is for internal usage only
+ makeArray: function( array, results ) {
+ var ret = results || [];
+
+ if ( array != null ) {
+ // The window, strings (and functions) also have 'length'
+ // Tweaked logic slightly to handle Blackberry 4.7 RegExp issues #6930
+ var type = jQuery.type( array );
+
+ if ( array.length == null || type === "string" || type === "function" || type === "regexp" || jQuery.isWindow( array ) ) {
+ push.call( ret, array );
+ } else {
+ jQuery.merge( ret, array );
+ }
+ }
+
+ return ret;
+ },
+
+ inArray: function( elem, array, i ) {
+ var len;
+
+ if ( array ) {
+ if ( indexOf ) {
+ return indexOf.call( array, elem, i );
+ }
+
+ len = array.length;
+ i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0;
+
+ for ( ; i < len; i++ ) {
+ // Skip accessing in sparse arrays
+ if ( i in array && array[ i ] === elem ) {
+ return i;
+ }
+ }
+ }
+
+ return -1;
+ },
+
+ merge: function( first, second ) {
+ var i = first.length,
+ j = 0;
+
+ if ( typeof second.length === "number" ) {
+ for ( var l = second.length; j < l; j++ ) {
+ first[ i++ ] = second[ j ];
+ }
+
+ } else {
+ while ( second[j] !== undefined ) {
+ first[ i++ ] = second[ j++ ];
+ }
+ }
+
+ first.length = i;
+
+ return first;
+ },
+
+ grep: function( elems, callback, inv ) {
+ var ret = [], retVal;
+ inv = !!inv;
+
+ // Go through the array, only saving the items
+ // that pass the validator function
+ for ( var i = 0, length = elems.length; i < length; i++ ) {
+ retVal = !!callback( elems[ i ], i );
+ if ( inv !== retVal ) {
+ ret.push( elems[ i ] );
+ }
+ }
+
+ return ret;
+ },
+
+ // arg is for internal usage only
+ map: function( elems, callback, arg ) {
+ var value, key, ret = [],
+ i = 0,
+ length = elems.length,
+ // jquery objects are treated as arrays
+ isArray = elems instanceof jQuery || length !== undefined && typeof length === "number" && ( ( length > 0 && elems[ 0 ] && elems[ length -1 ] ) || length === 0 || jQuery.isArray( elems ) ) ;
+
+ // Go through the array, translating each of the items to their
+ if ( isArray ) {
+ for ( ; i < length; i++ ) {
+ value = callback( elems[ i ], i, arg );
+
+ if ( value != null ) {
+ ret[ ret.length ] = value;
+ }
+ }
+
+ // Go through every key on the object,
+ } else {
+ for ( key in elems ) {
+ value = callback( elems[ key ], key, arg );
+
+ if ( value != null ) {
+ ret[ ret.length ] = value;
+ }
+ }
+ }
+
+ // Flatten any nested arrays
+ return ret.concat.apply( [], ret );
+ },
+
+ // A global GUID counter for objects
+ guid: 1,
+
+ // Bind a function to a context, optionally partially applying any
+ // arguments.
+ proxy: function( fn, context ) {
+ if ( typeof context === "string" ) {
+ var tmp = fn[ context ];
+ context = fn;
+ fn = tmp;
+ }
+
+ // Quick check to determine if target is callable, in the spec
+ // this throws a TypeError, but we will just return undefined.
+ if ( !jQuery.isFunction( fn ) ) {
+ return undefined;
+ }
+
+ // Simulated bind
+ var args = slice.call( arguments, 2 ),
+ proxy = function() {
+ return fn.apply( context, args.concat( slice.call( arguments ) ) );
+ };
+
+ // Set the guid of unique handler to the same of original handler, so it can be removed
+ proxy.guid = fn.guid = fn.guid || proxy.guid || jQuery.guid++;
+
+ return proxy;
+ },
+
+ // Mutifunctional method to get and set values to a collection
+ // The value/s can optionally be executed if it's a function
+ access: function( elems, key, value, exec, fn, pass ) {
+ var length = elems.length;
+
+ // Setting many attributes
+ if ( typeof key === "object" ) {
+ for ( var k in key ) {
+ jQuery.access( elems, k, key[k], exec, fn, value );
+ }
+ return elems;
+ }
+
+ // Setting one attribute
+ if ( value !== undefined ) {
+ // Optionally, function values get executed if exec is true
+ exec = !pass && exec && jQuery.isFunction(value);
+
+ for ( var i = 0; i < length; i++ ) {
+ fn( elems[i], key, exec ? value.call( elems[i], i, fn( elems[i], key ) ) : value, pass );
+ }
+
+ return elems;
+ }
+
+ // Getting an attribute
+ return length ? fn( elems[0], key ) : undefined;
+ },
+
+ now: function() {
+ return ( new Date() ).getTime();
+ },
+
+ // Use of jQuery.browser is frowned upon.
+ // More details: http://docs.jquery.com/Utilities/jQuery.browser
+ uaMatch: function( ua ) {
+ ua = ua.toLowerCase();
+
+ var match = rwebkit.exec( ua ) ||
+ ropera.exec( ua ) ||
+ rmsie.exec( ua ) ||
+ ua.indexOf("compatible") < 0 && rmozilla.exec( ua ) ||
+ [];
+
+ return { browser: match[1] || "", version: match[2] || "0" };
+ },
+
+ sub: function() {
+ function jQuerySub( selector, context ) {
+ return new jQuerySub.fn.init( selector, context );
+ }
+ jQuery.extend( true, jQuerySub, this );
+ jQuerySub.superclass = this;
+ jQuerySub.fn = jQuerySub.prototype = this();
+ jQuerySub.fn.constructor = jQuerySub;
+ jQuerySub.sub = this.sub;
+ jQuerySub.fn.init = function init( selector, context ) {
+ if ( context && context instanceof jQuery && !(context instanceof jQuerySub) ) {
+ context = jQuerySub( context );
+ }
+
+ return jQuery.fn.init.call( this, selector, context, rootjQuerySub );
+ };
+ jQuerySub.fn.init.prototype = jQuerySub.fn;
+ var rootjQuerySub = jQuerySub(document);
+ return jQuerySub;
+ },
+
+ browser: {}
+});
+
+// Populate the class2type map
+jQuery.each("Boolean Number String Function Array Date RegExp Object".split(" "), function(i, name) {
+ class2type[ "[object " + name + "]" ] = name.toLowerCase();
+});
+
+browserMatch = jQuery.uaMatch( userAgent );
+if ( browserMatch.browser ) {
+ jQuery.browser[ browserMatch.browser ] = true;
+ jQuery.browser.version = browserMatch.version;
+}
+
+// Deprecated, use jQuery.browser.webkit instead
+if ( jQuery.browser.webkit ) {
+ jQuery.browser.safari = true;
+}
+
+// IE doesn't match non-breaking spaces with \s
+if ( rnotwhite.test( "\xA0" ) ) {
+ trimLeft = /^[\s\xA0]+/;
+ trimRight = /[\s\xA0]+$/;
+}
+
+// All jQuery objects should point back to these
+rootjQuery = jQuery(document);
+
+// Cleanup functions for the document ready method
+if ( document.addEventListener ) {
+ DOMContentLoaded = function() {
+ document.removeEventListener( "DOMContentLoaded", DOMContentLoaded, false );
+ jQuery.ready();
+ };
+
+} else if ( document.attachEvent ) {
+ DOMContentLoaded = function() {
+ // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443).
+ if ( document.readyState === "complete" ) {
+ document.detachEvent( "onreadystatechange", DOMContentLoaded );
+ jQuery.ready();
+ }
+ };
+}
+
+// The DOM ready check for Internet Explorer
+function doScrollCheck() {
+ if ( jQuery.isReady ) {
+ return;
+ }
+
+ try {
+ // If IE is used, use the trick by Diego Perini
+ // http://javascript.nwbox.com/IEContentLoaded/
+ document.documentElement.doScroll("left");
+ } catch(e) {
+ setTimeout( doScrollCheck, 1 );
+ return;
+ }
+
+ // and execute any waiting functions
+ jQuery.ready();
+}
+
+return jQuery;
+
+})();
+
+
+// String to Object flags format cache
+var flagsCache = {};
+
+// Convert String-formatted flags into Object-formatted ones and store in cache
+function createFlags( flags ) {
+ var object = flagsCache[ flags ] = {},
+ i, length;
+ flags = flags.split( /\s+/ );
+ for ( i = 0, length = flags.length; i < length; i++ ) {
+ object[ flags[i] ] = true;
+ }
+ return object;
+}
+
+/*
+ * Create a callback list using the following parameters:
+ *
+ * flags: an optional list of space-separated flags that will change how
+ * the callback list behaves
+ *
+ * By default a callback list will act like an event callback list and can be
+ * "fired" multiple times.
+ *
+ * Possible flags:
+ *
+ * once: will ensure the callback list can only be fired once (like a Deferred)
+ *
+ * memory: will keep track of previous values and will call any callback added
+ * after the list has been fired right away with the latest "memorized"
+ * values (like a Deferred)
+ *
+ * unique: will ensure a callback can only be added once (no duplicate in the list)
+ *
+ * stopOnFalse: interrupt callings when a callback returns false
+ *
+ */
+jQuery.Callbacks = function( flags ) {
+
+ // Convert flags from String-formatted to Object-formatted
+ // (we check in cache first)
+ flags = flags ? ( flagsCache[ flags ] || createFlags( flags ) ) : {};
+
+ var // Actual callback list
+ list = [],
+ // Stack of fire calls for repeatable lists
+ stack = [],
+ // Last fire value (for non-forgettable lists)
+ memory,
+ // Flag to know if list is currently firing
+ firing,
+ // First callback to fire (used internally by add and fireWith)
+ firingStart,
+ // End of the loop when firing
+ firingLength,
+ // Index of currently firing callback (modified by remove if needed)
+ firingIndex,
+ // Add one or several callbacks to the list
+ add = function( args ) {
+ var i,
+ length,
+ elem,
+ type,
+ actual;
+ for ( i = 0, length = args.length; i < length; i++ ) {
+ elem = args[ i ];
+ type = jQuery.type( elem );
+ if ( type === "array" ) {
+ // Inspect recursively
+ add( elem );
+ } else if ( type === "function" ) {
+ // Add if not in unique mode and callback is not in
+ if ( !flags.unique || !self.has( elem ) ) {
+ list.push( elem );
+ }
+ }
+ }
+ },
+ // Fire callbacks
+ fire = function( context, args ) {
+ args = args || [];
+ memory = !flags.memory || [ context, args ];
+ firing = true;
+ firingIndex = firingStart || 0;
+ firingStart = 0;
+ firingLength = list.length;
+ for ( ; list && firingIndex < firingLength; firingIndex++ ) {
+ if ( list[ firingIndex ].apply( context, args ) === false && flags.stopOnFalse ) {
+ memory = true; // Mark as halted
+ break;
+ }
+ }
+ firing = false;
+ if ( list ) {
+ if ( !flags.once ) {
+ if ( stack && stack.length ) {
+ memory = stack.shift();
+ self.fireWith( memory[ 0 ], memory[ 1 ] );
+ }
+ } else if ( memory === true ) {
+ self.disable();
+ } else {
+ list = [];
+ }
+ }
+ },
+ // Actual Callbacks object
+ self = {
+ // Add a callback or a collection of callbacks to the list
+ add: function() {
+ if ( list ) {
+ var length = list.length;
+ add( arguments );
+ // Do we need to add the callbacks to the
+ // current firing batch?
+ if ( firing ) {
+ firingLength = list.length;
+ // With memory, if we're not firing then
+ // we should call right away, unless previous
+ // firing was halted (stopOnFalse)
+ } else if ( memory && memory !== true ) {
+ firingStart = length;
+ fire( memory[ 0 ], memory[ 1 ] );
+ }
+ }
+ return this;
+ },
+ // Remove a callback from the list
+ remove: function() {
+ if ( list ) {
+ var args = arguments,
+ argIndex = 0,
+ argLength = args.length;
+ for ( ; argIndex < argLength ; argIndex++ ) {
+ for ( var i = 0; i < list.length; i++ ) {
+ if ( args[ argIndex ] === list[ i ] ) {
+ // Handle firingIndex and firingLength
+ if ( firing ) {
+ if ( i <= firingLength ) {
+ firingLength--;
+ if ( i <= firingIndex ) {
+ firingIndex--;
+ }
+ }
+ }
+ // Remove the element
+ list.splice( i--, 1 );
+ // If we have some unicity property then
+ // we only need to do this once
+ if ( flags.unique ) {
+ break;
+ }
+ }
+ }
+ }
+ }
+ return this;
+ },
+ // Control if a given callback is in the list
+ has: function( fn ) {
+ if ( list ) {
+ var i = 0,
+ length = list.length;
+ for ( ; i < length; i++ ) {
+ if ( fn === list[ i ] ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+ // Remove all callbacks from the list
+ empty: function() {
+ list = [];
+ return this;
+ },
+ // Have the list do nothing anymore
+ disable: function() {
+ list = stack = memory = undefined;
+ return this;
+ },
+ // Is it disabled?
+ disabled: function() {
+ return !list;
+ },
+ // Lock the list in its current state
+ lock: function() {
+ stack = undefined;
+ if ( !memory || memory === true ) {
+ self.disable();
+ }
+ return this;
+ },
+ // Is it locked?
+ locked: function() {
+ return !stack;
+ },
+ // Call all callbacks with the given context and arguments
+ fireWith: function( context, args ) {
+ if ( stack ) {
+ if ( firing ) {
+ if ( !flags.once ) {
+ stack.push( [ context, args ] );
+ }
+ } else if ( !( flags.once && memory ) ) {
+ fire( context, args );
+ }
+ }
+ return this;
+ },
+ // Call all the callbacks with the given arguments
+ fire: function() {
+ self.fireWith( this, arguments );
+ return this;
+ },
+ // To know if the callbacks have already been called at least once
+ fired: function() {
+ return !!memory;
+ }
+ };
+
+ return self;
+};
+
+
+
+
+var // Static reference to slice
+ sliceDeferred = [].slice;
+
+jQuery.extend({
+
+ Deferred: function( func ) {
+ var doneList = jQuery.Callbacks( "once memory" ),
+ failList = jQuery.Callbacks( "once memory" ),
+ progressList = jQuery.Callbacks( "memory" ),
+ state = "pending",
+ lists = {
+ resolve: doneList,
+ reject: failList,
+ notify: progressList
+ },
+ promise = {
+ done: doneList.add,
+ fail: failList.add,
+ progress: progressList.add,
+
+ state: function() {
+ return state;
+ },
+
+ // Deprecated
+ isResolved: doneList.fired,
+ isRejected: failList.fired,
+
+ then: function( doneCallbacks, failCallbacks, progressCallbacks ) {
+ deferred.done( doneCallbacks ).fail( failCallbacks ).progress( progressCallbacks );
+ return this;
+ },
+ always: function() {
+ deferred.done.apply( deferred, arguments ).fail.apply( deferred, arguments );
+ return this;
+ },
+ pipe: function( fnDone, fnFail, fnProgress ) {
+ return jQuery.Deferred(function( newDefer ) {
+ jQuery.each( {
+ done: [ fnDone, "resolve" ],
+ fail: [ fnFail, "reject" ],
+ progress: [ fnProgress, "notify" ]
+ }, function( handler, data ) {
+ var fn = data[ 0 ],
+ action = data[ 1 ],
+ returned;
+ if ( jQuery.isFunction( fn ) ) {
+ deferred[ handler ](function() {
+ returned = fn.apply( this, arguments );
+ if ( returned && jQuery.isFunction( returned.promise ) ) {
+ returned.promise().then( newDefer.resolve, newDefer.reject, newDefer.notify );
+ } else {
+ newDefer[ action + "With" ]( this === deferred ? newDefer : this, [ returned ] );
+ }
+ });
+ } else {
+ deferred[ handler ]( newDefer[ action ] );
+ }
+ });
+ }).promise();
+ },
+ // Get a promise for this deferred
+ // If obj is provided, the promise aspect is added to the object
+ promise: function( obj ) {
+ if ( obj == null ) {
+ obj = promise;
+ } else {
+ for ( var key in promise ) {
+ obj[ key ] = promise[ key ];
+ }
+ }
+ return obj;
+ }
+ },
+ deferred = promise.promise({}),
+ key;
+
+ for ( key in lists ) {
+ deferred[ key ] = lists[ key ].fire;
+ deferred[ key + "With" ] = lists[ key ].fireWith;
+ }
+
+ // Handle state
+ deferred.done( function() {
+ state = "resolved";
+ }, failList.disable, progressList.lock ).fail( function() {
+ state = "rejected";
+ }, doneList.disable, progressList.lock );
+
+ // Call given func if any
+ if ( func ) {
+ func.call( deferred, deferred );
+ }
+
+ // All done!
+ return deferred;
+ },
+
+ // Deferred helper
+ when: function( firstParam ) {
+ var args = sliceDeferred.call( arguments, 0 ),
+ i = 0,
+ length = args.length,
+ pValues = new Array( length ),
+ count = length,
+ pCount = length,
+ deferred = length <= 1 && firstParam && jQuery.isFunction( firstParam.promise ) ?
+ firstParam :
+ jQuery.Deferred(),
+ promise = deferred.promise();
+ function resolveFunc( i ) {
+ return function( value ) {
+ args[ i ] = arguments.length > 1 ? sliceDeferred.call( arguments, 0 ) : value;
+ if ( !( --count ) ) {
+ deferred.resolveWith( deferred, args );
+ }
+ };
+ }
+ function progressFunc( i ) {
+ return function( value ) {
+ pValues[ i ] = arguments.length > 1 ? sliceDeferred.call( arguments, 0 ) : value;
+ deferred.notifyWith( promise, pValues );
+ };
+ }
+ if ( length > 1 ) {
+ for ( ; i < length; i++ ) {
+ if ( args[ i ] && args[ i ].promise && jQuery.isFunction( args[ i ].promise ) ) {
+ args[ i ].promise().then( resolveFunc(i), deferred.reject, progressFunc(i) );
+ } else {
+ --count;
+ }
+ }
+ if ( !count ) {
+ deferred.resolveWith( deferred, args );
+ }
+ } else if ( deferred !== firstParam ) {
+ deferred.resolveWith( deferred, length ? [ firstParam ] : [] );
+ }
+ return promise;
+ }
+});
+
+
+
+
+jQuery.support = (function() {
+
+ var support,
+ all,
+ a,
+ select,
+ opt,
+ input,
+ marginDiv,
+ fragment,
+ tds,
+ events,
+ eventName,
+ i,
+ isSupported,
+ div = document.createElement( "div" ),
+ documentElement = document.documentElement;
+
+ // Preliminary tests
+ div.setAttribute("className", "t");
+ div.innerHTML = " a";
+
+ all = div.getElementsByTagName( "*" );
+ a = div.getElementsByTagName( "a" )[ 0 ];
+
+ // Can't get basic test support
+ if ( !all || !all.length || !a ) {
+ return {};
+ }
+
+ // First batch of supports tests
+ select = document.createElement( "select" );
+ opt = select.appendChild( document.createElement("option") );
+ input = div.getElementsByTagName( "input" )[ 0 ];
+
+ support = {
+ // IE strips leading whitespace when .innerHTML is used
+ leadingWhitespace: ( div.firstChild.nodeType === 3 ),
+
+ // Make sure that tbody elements aren't automatically inserted
+ // IE will insert them into empty tables
+ tbody: !div.getElementsByTagName("tbody").length,
+
+ // Make sure that link elements get serialized correctly by innerHTML
+ // This requires a wrapper element in IE
+ htmlSerialize: !!div.getElementsByTagName("link").length,
+
+ // Get the style information from getAttribute
+ // (IE uses .cssText instead)
+ style: /top/.test( a.getAttribute("style") ),
+
+ // Make sure that URLs aren't manipulated
+ // (IE normalizes it by default)
+ hrefNormalized: ( a.getAttribute("href") === "/a" ),
+
+ // Make sure that element opacity exists
+ // (IE uses filter instead)
+ // Use a regex to work around a WebKit issue. See #5145
+ opacity: /^0.55/.test( a.style.opacity ),
+
+ // Verify style float existence
+ // (IE uses styleFloat instead of cssFloat)
+ cssFloat: !!a.style.cssFloat,
+
+ // Make sure that if no value is specified for a checkbox
+ // that it defaults to "on".
+ // (WebKit defaults to "" instead)
+ checkOn: ( input.value === "on" ),
+
+ // Make sure that a selected-by-default option has a working selected property.
+ // (WebKit defaults to false instead of true, IE too, if it's in an optgroup)
+ optSelected: opt.selected,
+
+ // Test setAttribute on camelCase class. If it works, we need attrFixes when doing get/setAttribute (ie6/7)
+ getSetAttribute: div.className !== "t",
+
+ // Tests for enctype support on a form(#6743)
+ enctype: !!document.createElement("form").enctype,
+
+ // Makes sure cloning an html5 element does not cause problems
+ // Where outerHTML is undefined, this still works
+ html5Clone: document.createElement("nav").cloneNode( true ).outerHTML !== "<:nav>",
+
+ // Will be defined later
+ submitBubbles: true,
+ changeBubbles: true,
+ focusinBubbles: false,
+ deleteExpando: true,
+ noCloneEvent: true,
+ inlineBlockNeedsLayout: false,
+ shrinkWrapBlocks: false,
+ reliableMarginRight: true
+ };
+
+ // Make sure checked status is properly cloned
+ input.checked = true;
+ support.noCloneChecked = input.cloneNode( true ).checked;
+
+ // Make sure that the options inside disabled selects aren't marked as disabled
+ // (WebKit marks them as disabled)
+ select.disabled = true;
+ support.optDisabled = !opt.disabled;
+
+ // Test to see if it's possible to delete an expando from an element
+ // Fails in Internet Explorer
+ try {
+ delete div.test;
+ } catch( e ) {
+ support.deleteExpando = false;
+ }
+
+ if ( !div.addEventListener && div.attachEvent && div.fireEvent ) {
+ div.attachEvent( "onclick", function() {
+ // Cloning a node shouldn't copy over any
+ // bound event handlers (IE does this)
+ support.noCloneEvent = false;
+ });
+ div.cloneNode( true ).fireEvent( "onclick" );
+ }
+
+ // Check if a radio maintains its value
+ // after being appended to the DOM
+ input = document.createElement("input");
+ input.value = "t";
+ input.setAttribute("type", "radio");
+ support.radioValue = input.value === "t";
+
+ input.setAttribute("checked", "checked");
+ div.appendChild( input );
+ fragment = document.createDocumentFragment();
+ fragment.appendChild( div.lastChild );
+
+ // WebKit doesn't clone checked state correctly in fragments
+ support.checkClone = fragment.cloneNode( true ).cloneNode( true ).lastChild.checked;
+
+ // Check if a disconnected checkbox will retain its checked
+ // value of true after appended to the DOM (IE6/7)
+ support.appendChecked = input.checked;
+
+ fragment.removeChild( input );
+ fragment.appendChild( div );
+
+ div.innerHTML = "";
+
+ // Check if div with explicit width and no margin-right incorrectly
+ // gets computed margin-right based on width of container. For more
+ // info see bug #3333
+ // Fails in WebKit before Feb 2011 nightlies
+ // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right
+ if ( window.getComputedStyle ) {
+ marginDiv = document.createElement( "div" );
+ marginDiv.style.width = "0";
+ marginDiv.style.marginRight = "0";
+ div.style.width = "2px";
+ div.appendChild( marginDiv );
+ support.reliableMarginRight =
+ ( parseInt( ( window.getComputedStyle( marginDiv, null ) || { marginRight: 0 } ).marginRight, 10 ) || 0 ) === 0;
+ }
+
+ // Technique from Juriy Zaytsev
+ // http://perfectionkills.com/detecting-event-support-without-browser-sniffing/
+ // We only care about the case where non-standard event systems
+ // are used, namely in IE. Short-circuiting here helps us to
+ // avoid an eval call (in setAttribute) which can cause CSP
+ // to go haywire. See: https://developer.mozilla.org/en/Security/CSP
+ if ( div.attachEvent ) {
+ for( i in {
+ submit: 1,
+ change: 1,
+ focusin: 1
+ }) {
+ eventName = "on" + i;
+ isSupported = ( eventName in div );
+ if ( !isSupported ) {
+ div.setAttribute( eventName, "return;" );
+ isSupported = ( typeof div[ eventName ] === "function" );
+ }
+ support[ i + "Bubbles" ] = isSupported;
+ }
+ }
+
+ fragment.removeChild( div );
+
+ // Null elements to avoid leaks in IE
+ fragment = select = opt = marginDiv = div = input = null;
+
+ // Run tests that need a body at doc ready
+ jQuery(function() {
+ var container, outer, inner, table, td, offsetSupport,
+ conMarginTop, ptlm, vb, style, html,
+ body = document.getElementsByTagName("body")[0];
+
+ if ( !body ) {
+ // Return for frameset docs that don't have a body
+ return;
+ }
+
+ conMarginTop = 1;
+ ptlm = "position:absolute;top:0;left:0;width:1px;height:1px;margin:0;";
+ vb = "visibility:hidden;border:0;";
+ style = "style='" + ptlm + "border:5px solid #000;padding:0;'";
+ html = "" +
+ "";
+
+ container = document.createElement("div");
+ container.style.cssText = vb + "width:0;height:0;position:static;top:0;margin-top:" + conMarginTop + "px";
+ body.insertBefore( container, body.firstChild );
+
+ // Construct the test element
+ div = document.createElement("div");
+ container.appendChild( div );
+
+ // Check if table cells still have offsetWidth/Height when they are set
+ // to display:none and there are still other visible table cells in a
+ // table row; if so, offsetWidth/Height are not reliable for use when
+ // determining if an element has been hidden directly using
+ // display:none (it is still safe to use offsets if a parent element is
+ // hidden; don safety goggles and see bug #4512 for more information).
+ // (only IE 8 fails this test)
+ div.innerHTML = "";
+ tds = div.getElementsByTagName( "td" );
+ isSupported = ( tds[ 0 ].offsetHeight === 0 );
+
+ tds[ 0 ].style.display = "";
+ tds[ 1 ].style.display = "none";
+
+ // Check if empty table cells still have offsetWidth/Height
+ // (IE <= 8 fail this test)
+ support.reliableHiddenOffsets = isSupported && ( tds[ 0 ].offsetHeight === 0 );
+
+ // Figure out if the W3C box model works as expected
+ div.innerHTML = "";
+ div.style.width = div.style.paddingLeft = "1px";
+ jQuery.boxModel = support.boxModel = div.offsetWidth === 2;
+
+ if ( typeof div.style.zoom !== "undefined" ) {
+ // Check if natively block-level elements act like inline-block
+ // elements when setting their display to 'inline' and giving
+ // them layout
+ // (IE < 8 does this)
+ div.style.display = "inline";
+ div.style.zoom = 1;
+ support.inlineBlockNeedsLayout = ( div.offsetWidth === 2 );
+
+ // Check if elements with layout shrink-wrap their children
+ // (IE 6 does this)
+ div.style.display = "";
+ div.innerHTML = "";
+ support.shrinkWrapBlocks = ( div.offsetWidth !== 2 );
+ }
+
+ div.style.cssText = ptlm + vb;
+ div.innerHTML = html;
+
+ outer = div.firstChild;
+ inner = outer.firstChild;
+ td = outer.nextSibling.firstChild.firstChild;
+
+ offsetSupport = {
+ doesNotAddBorder: ( inner.offsetTop !== 5 ),
+ doesAddBorderForTableAndCells: ( td.offsetTop === 5 )
+ };
+
+ inner.style.position = "fixed";
+ inner.style.top = "20px";
+
+ // safari subtracts parent border width here which is 5px
+ offsetSupport.fixedPosition = ( inner.offsetTop === 20 || inner.offsetTop === 15 );
+ inner.style.position = inner.style.top = "";
+
+ outer.style.overflow = "hidden";
+ outer.style.position = "relative";
+
+ offsetSupport.subtractsBorderForOverflowNotVisible = ( inner.offsetTop === -5 );
+ offsetSupport.doesNotIncludeMarginInBodyOffset = ( body.offsetTop !== conMarginTop );
+
+ body.removeChild( container );
+ div = container = null;
+
+ jQuery.extend( support, offsetSupport );
+ });
+
+ return support;
+})();
+
+
+
+
+var rbrace = /^(?:\{.*\}|\[.*\])$/,
+ rmultiDash = /([A-Z])/g;
+
+jQuery.extend({
+ cache: {},
+
+ // Please use with caution
+ uuid: 0,
+
+ // Unique for each copy of jQuery on the page
+ // Non-digits removed to match rinlinejQuery
+ expando: "jQuery" + ( jQuery.fn.jquery + Math.random() ).replace( /\D/g, "" ),
+
+ // The following elements throw uncatchable exceptions if you
+ // attempt to add expando properties to them.
+ noData: {
+ "embed": true,
+ // Ban all objects except for Flash (which handle expandos)
+ "object": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",
+ "applet": true
+ },
+
+ hasData: function( elem ) {
+ elem = elem.nodeType ? jQuery.cache[ elem[jQuery.expando] ] : elem[ jQuery.expando ];
+ return !!elem && !isEmptyDataObject( elem );
+ },
+
+ data: function( elem, name, data, pvt /* Internal Use Only */ ) {
+ if ( !jQuery.acceptData( elem ) ) {
+ return;
+ }
+
+ var privateCache, thisCache, ret,
+ internalKey = jQuery.expando,
+ getByName = typeof name === "string",
+
+ // We have to handle DOM nodes and JS objects differently because IE6-7
+ // can't GC object references properly across the DOM-JS boundary
+ isNode = elem.nodeType,
+
+ // Only DOM nodes need the global jQuery cache; JS object data is
+ // attached directly to the object so GC can occur automatically
+ cache = isNode ? jQuery.cache : elem,
+
+ // Only defining an ID for JS objects if its cache already exists allows
+ // the code to shortcut on the same path as a DOM node with no cache
+ id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey,
+ isEvents = name === "events";
+
+ // Avoid doing any more work than we need to when trying to get data on an
+ // object that has no data at all
+ if ( (!id || !cache[id] || (!isEvents && !pvt && !cache[id].data)) && getByName && data === undefined ) {
+ return;
+ }
+
+ if ( !id ) {
+ // Only DOM nodes need a new unique ID for each element since their data
+ // ends up in the global cache
+ if ( isNode ) {
+ elem[ internalKey ] = id = ++jQuery.uuid;
+ } else {
+ id = internalKey;
+ }
+ }
+
+ if ( !cache[ id ] ) {
+ cache[ id ] = {};
+
+ // Avoids exposing jQuery metadata on plain JS objects when the object
+ // is serialized using JSON.stringify
+ if ( !isNode ) {
+ cache[ id ].toJSON = jQuery.noop;
+ }
+ }
+
+ // An object can be passed to jQuery.data instead of a key/value pair; this gets
+ // shallow copied over onto the existing cache
+ if ( typeof name === "object" || typeof name === "function" ) {
+ if ( pvt ) {
+ cache[ id ] = jQuery.extend( cache[ id ], name );
+ } else {
+ cache[ id ].data = jQuery.extend( cache[ id ].data, name );
+ }
+ }
+
+ privateCache = thisCache = cache[ id ];
+
+ // jQuery data() is stored in a separate object inside the object's internal data
+ // cache in order to avoid key collisions between internal data and user-defined
+ // data.
+ if ( !pvt ) {
+ if ( !thisCache.data ) {
+ thisCache.data = {};
+ }
+
+ thisCache = thisCache.data;
+ }
+
+ if ( data !== undefined ) {
+ thisCache[ jQuery.camelCase( name ) ] = data;
+ }
+
+ // Users should not attempt to inspect the internal events object using jQuery.data,
+ // it is undocumented and subject to change. But does anyone listen? No.
+ if ( isEvents && !thisCache[ name ] ) {
+ return privateCache.events;
+ }
+
+ // Check for both converted-to-camel and non-converted data property names
+ // If a data property was specified
+ if ( getByName ) {
+
+ // First Try to find as-is property data
+ ret = thisCache[ name ];
+
+ // Test for null|undefined property data
+ if ( ret == null ) {
+
+ // Try to find the camelCased property
+ ret = thisCache[ jQuery.camelCase( name ) ];
+ }
+ } else {
+ ret = thisCache;
+ }
+
+ return ret;
+ },
+
+ removeData: function( elem, name, pvt /* Internal Use Only */ ) {
+ if ( !jQuery.acceptData( elem ) ) {
+ return;
+ }
+
+ var thisCache, i, l,
+
+ // Reference to internal data cache key
+ internalKey = jQuery.expando,
+
+ isNode = elem.nodeType,
+
+ // See jQuery.data for more information
+ cache = isNode ? jQuery.cache : elem,
+
+ // See jQuery.data for more information
+ id = isNode ? elem[ internalKey ] : internalKey;
+
+ // If there is already no cache entry for this object, there is no
+ // purpose in continuing
+ if ( !cache[ id ] ) {
+ return;
+ }
+
+ if ( name ) {
+
+ thisCache = pvt ? cache[ id ] : cache[ id ].data;
+
+ if ( thisCache ) {
+
+ // Support array or space separated string names for data keys
+ if ( !jQuery.isArray( name ) ) {
+
+ // try the string as a key before any manipulation
+ if ( name in thisCache ) {
+ name = [ name ];
+ } else {
+
+ // split the camel cased version by spaces unless a key with the spaces exists
+ name = jQuery.camelCase( name );
+ if ( name in thisCache ) {
+ name = [ name ];
+ } else {
+ name = name.split( " " );
+ }
+ }
+ }
+
+ for ( i = 0, l = name.length; i < l; i++ ) {
+ delete thisCache[ name[i] ];
+ }
+
+ // If there is no data left in the cache, we want to continue
+ // and let the cache object itself get destroyed
+ if ( !( pvt ? isEmptyDataObject : jQuery.isEmptyObject )( thisCache ) ) {
+ return;
+ }
+ }
+ }
+
+ // See jQuery.data for more information
+ if ( !pvt ) {
+ delete cache[ id ].data;
+
+ // Don't destroy the parent cache unless the internal data object
+ // had been the only thing left in it
+ if ( !isEmptyDataObject(cache[ id ]) ) {
+ return;
+ }
+ }
+
+ // Browsers that fail expando deletion also refuse to delete expandos on
+ // the window, but it will allow it on all other JS objects; other browsers
+ // don't care
+ // Ensure that `cache` is not a window object #10080
+ if ( jQuery.support.deleteExpando || !cache.setInterval ) {
+ delete cache[ id ];
+ } else {
+ cache[ id ] = null;
+ }
+
+ // We destroyed the cache and need to eliminate the expando on the node to avoid
+ // false lookups in the cache for entries that no longer exist
+ if ( isNode ) {
+ // IE does not allow us to delete expando properties from nodes,
+ // nor does it have a removeAttribute function on Document nodes;
+ // we must handle all of these cases
+ if ( jQuery.support.deleteExpando ) {
+ delete elem[ internalKey ];
+ } else if ( elem.removeAttribute ) {
+ elem.removeAttribute( internalKey );
+ } else {
+ elem[ internalKey ] = null;
+ }
+ }
+ },
+
+ // For internal use only.
+ _data: function( elem, name, data ) {
+ return jQuery.data( elem, name, data, true );
+ },
+
+ // A method for determining if a DOM node can handle the data expando
+ acceptData: function( elem ) {
+ if ( elem.nodeName ) {
+ var match = jQuery.noData[ elem.nodeName.toLowerCase() ];
+
+ if ( match ) {
+ return !(match === true || elem.getAttribute("classid") !== match);
+ }
+ }
+
+ return true;
+ }
+});
+
+jQuery.fn.extend({
+ data: function( key, value ) {
+ var parts, attr, name,
+ data = null;
+
+ if ( typeof key === "undefined" ) {
+ if ( this.length ) {
+ data = jQuery.data( this[0] );
+
+ if ( this[0].nodeType === 1 && !jQuery._data( this[0], "parsedAttrs" ) ) {
+ attr = this[0].attributes;
+ for ( var i = 0, l = attr.length; i < l; i++ ) {
+ name = attr[i].name;
+
+ if ( name.indexOf( "data-" ) === 0 ) {
+ name = jQuery.camelCase( name.substring(5) );
+
+ dataAttr( this[0], name, data[ name ] );
+ }
+ }
+ jQuery._data( this[0], "parsedAttrs", true );
+ }
+ }
+
+ return data;
+
+ } else if ( typeof key === "object" ) {
+ return this.each(function() {
+ jQuery.data( this, key );
+ });
+ }
+
+ parts = key.split(".");
+ parts[1] = parts[1] ? "." + parts[1] : "";
+
+ if ( value === undefined ) {
+ data = this.triggerHandler("getData" + parts[1] + "!", [parts[0]]);
+
+ // Try to fetch any internally stored data first
+ if ( data === undefined && this.length ) {
+ data = jQuery.data( this[0], key );
+ data = dataAttr( this[0], key, data );
+ }
+
+ return data === undefined && parts[1] ?
+ this.data( parts[0] ) :
+ data;
+
+ } else {
+ return this.each(function() {
+ var self = jQuery( this ),
+ args = [ parts[0], value ];
+
+ self.triggerHandler( "setData" + parts[1] + "!", args );
+ jQuery.data( this, key, value );
+ self.triggerHandler( "changeData" + parts[1] + "!", args );
+ });
+ }
+ },
+
+ removeData: function( key ) {
+ return this.each(function() {
+ jQuery.removeData( this, key );
+ });
+ }
+});
+
+function dataAttr( elem, key, data ) {
+ // If nothing was found internally, try to fetch any
+ // data from the HTML5 data-* attribute
+ if ( data === undefined && elem.nodeType === 1 ) {
+
+ var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase();
+
+ data = elem.getAttribute( name );
+
+ if ( typeof data === "string" ) {
+ try {
+ data = data === "true" ? true :
+ data === "false" ? false :
+ data === "null" ? null :
+ jQuery.isNumeric( data ) ? parseFloat( data ) :
+ rbrace.test( data ) ? jQuery.parseJSON( data ) :
+ data;
+ } catch( e ) {}
+
+ // Make sure we set the data so it isn't changed later
+ jQuery.data( elem, key, data );
+
+ } else {
+ data = undefined;
+ }
+ }
+
+ return data;
+}
+
+// checks a cache object for emptiness
+function isEmptyDataObject( obj ) {
+ for ( var name in obj ) {
+
+ // if the public data object is empty, the private is still empty
+ if ( name === "data" && jQuery.isEmptyObject( obj[name] ) ) {
+ continue;
+ }
+ if ( name !== "toJSON" ) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+
+
+
+function handleQueueMarkDefer( elem, type, src ) {
+ var deferDataKey = type + "defer",
+ queueDataKey = type + "queue",
+ markDataKey = type + "mark",
+ defer = jQuery._data( elem, deferDataKey );
+ if ( defer &&
+ ( src === "queue" || !jQuery._data(elem, queueDataKey) ) &&
+ ( src === "mark" || !jQuery._data(elem, markDataKey) ) ) {
+ // Give room for hard-coded callbacks to fire first
+ // and eventually mark/queue something else on the element
+ setTimeout( function() {
+ if ( !jQuery._data( elem, queueDataKey ) &&
+ !jQuery._data( elem, markDataKey ) ) {
+ jQuery.removeData( elem, deferDataKey, true );
+ defer.fire();
+ }
+ }, 0 );
+ }
+}
+
+jQuery.extend({
+
+ _mark: function( elem, type ) {
+ if ( elem ) {
+ type = ( type || "fx" ) + "mark";
+ jQuery._data( elem, type, (jQuery._data( elem, type ) || 0) + 1 );
+ }
+ },
+
+ _unmark: function( force, elem, type ) {
+ if ( force !== true ) {
+ type = elem;
+ elem = force;
+ force = false;
+ }
+ if ( elem ) {
+ type = type || "fx";
+ var key = type + "mark",
+ count = force ? 0 : ( (jQuery._data( elem, key ) || 1) - 1 );
+ if ( count ) {
+ jQuery._data( elem, key, count );
+ } else {
+ jQuery.removeData( elem, key, true );
+ handleQueueMarkDefer( elem, type, "mark" );
+ }
+ }
+ },
+
+ queue: function( elem, type, data ) {
+ var q;
+ if ( elem ) {
+ type = ( type || "fx" ) + "queue";
+ q = jQuery._data( elem, type );
+
+ // Speed up dequeue by getting out quickly if this is just a lookup
+ if ( data ) {
+ if ( !q || jQuery.isArray(data) ) {
+ q = jQuery._data( elem, type, jQuery.makeArray(data) );
+ } else {
+ q.push( data );
+ }
+ }
+ return q || [];
+ }
+ },
+
+ dequeue: function( elem, type ) {
+ type = type || "fx";
+
+ var queue = jQuery.queue( elem, type ),
+ fn = queue.shift(),
+ hooks = {};
+
+ // If the fx queue is dequeued, always remove the progress sentinel
+ if ( fn === "inprogress" ) {
+ fn = queue.shift();
+ }
+
+ if ( fn ) {
+ // Add a progress sentinel to prevent the fx queue from being
+ // automatically dequeued
+ if ( type === "fx" ) {
+ queue.unshift( "inprogress" );
+ }
+
+ jQuery._data( elem, type + ".run", hooks );
+ fn.call( elem, function() {
+ jQuery.dequeue( elem, type );
+ }, hooks );
+ }
+
+ if ( !queue.length ) {
+ jQuery.removeData( elem, type + "queue " + type + ".run", true );
+ handleQueueMarkDefer( elem, type, "queue" );
+ }
+ }
+});
+
+jQuery.fn.extend({
+ queue: function( type, data ) {
+ if ( typeof type !== "string" ) {
+ data = type;
+ type = "fx";
+ }
+
+ if ( data === undefined ) {
+ return jQuery.queue( this[0], type );
+ }
+ return this.each(function() {
+ var queue = jQuery.queue( this, type, data );
+
+ if ( type === "fx" && queue[0] !== "inprogress" ) {
+ jQuery.dequeue( this, type );
+ }
+ });
+ },
+ dequeue: function( type ) {
+ return this.each(function() {
+ jQuery.dequeue( this, type );
+ });
+ },
+ // Based off of the plugin by Clint Helfers, with permission.
+ // http://blindsignals.com/index.php/2009/07/jquery-delay/
+ delay: function( time, type ) {
+ time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time;
+ type = type || "fx";
+
+ return this.queue( type, function( next, hooks ) {
+ var timeout = setTimeout( next, time );
+ hooks.stop = function() {
+ clearTimeout( timeout );
+ };
+ });
+ },
+ clearQueue: function( type ) {
+ return this.queue( type || "fx", [] );
+ },
+ // Get a promise resolved when queues of a certain type
+ // are emptied (fx is the type by default)
+ promise: function( type, object ) {
+ if ( typeof type !== "string" ) {
+ object = type;
+ type = undefined;
+ }
+ type = type || "fx";
+ var defer = jQuery.Deferred(),
+ elements = this,
+ i = elements.length,
+ count = 1,
+ deferDataKey = type + "defer",
+ queueDataKey = type + "queue",
+ markDataKey = type + "mark",
+ tmp;
+ function resolve() {
+ if ( !( --count ) ) {
+ defer.resolveWith( elements, [ elements ] );
+ }
+ }
+ while( i-- ) {
+ if (( tmp = jQuery.data( elements[ i ], deferDataKey, undefined, true ) ||
+ ( jQuery.data( elements[ i ], queueDataKey, undefined, true ) ||
+ jQuery.data( elements[ i ], markDataKey, undefined, true ) ) &&
+ jQuery.data( elements[ i ], deferDataKey, jQuery.Callbacks( "once memory" ), true ) )) {
+ count++;
+ tmp.add( resolve );
+ }
+ }
+ resolve();
+ return defer.promise();
+ }
+});
+
+
+
+
+var rclass = /[\n\t\r]/g,
+ rspace = /\s+/,
+ rreturn = /\r/g,
+ rtype = /^(?:button|input)$/i,
+ rfocusable = /^(?:button|input|object|select|textarea)$/i,
+ rclickable = /^a(?:rea)?$/i,
+ rboolean = /^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i,
+ getSetAttribute = jQuery.support.getSetAttribute,
+ nodeHook, boolHook, fixSpecified;
+
+jQuery.fn.extend({
+ attr: function( name, value ) {
+ return jQuery.access( this, name, value, true, jQuery.attr );
+ },
+
+ removeAttr: function( name ) {
+ return this.each(function() {
+ jQuery.removeAttr( this, name );
+ });
+ },
+
+ prop: function( name, value ) {
+ return jQuery.access( this, name, value, true, jQuery.prop );
+ },
+
+ removeProp: function( name ) {
+ name = jQuery.propFix[ name ] || name;
+ return this.each(function() {
+ // try/catch handles cases where IE balks (such as removing a property on window)
+ try {
+ this[ name ] = undefined;
+ delete this[ name ];
+ } catch( e ) {}
+ });
+ },
+
+ addClass: function( value ) {
+ var classNames, i, l, elem,
+ setClass, c, cl;
+
+ if ( jQuery.isFunction( value ) ) {
+ return this.each(function( j ) {
+ jQuery( this ).addClass( value.call(this, j, this.className) );
+ });
+ }
+
+ if ( value && typeof value === "string" ) {
+ classNames = value.split( rspace );
+
+ for ( i = 0, l = this.length; i < l; i++ ) {
+ elem = this[ i ];
+
+ if ( elem.nodeType === 1 ) {
+ if ( !elem.className && classNames.length === 1 ) {
+ elem.className = value;
+
+ } else {
+ setClass = " " + elem.className + " ";
+
+ for ( c = 0, cl = classNames.length; c < cl; c++ ) {
+ if ( !~setClass.indexOf( " " + classNames[ c ] + " " ) ) {
+ setClass += classNames[ c ] + " ";
+ }
+ }
+ elem.className = jQuery.trim( setClass );
+ }
+ }
+ }
+ }
+
+ return this;
+ },
+
+ removeClass: function( value ) {
+ var classNames, i, l, elem, className, c, cl;
+
+ if ( jQuery.isFunction( value ) ) {
+ return this.each(function( j ) {
+ jQuery( this ).removeClass( value.call(this, j, this.className) );
+ });
+ }
+
+ if ( (value && typeof value === "string") || value === undefined ) {
+ classNames = ( value || "" ).split( rspace );
+
+ for ( i = 0, l = this.length; i < l; i++ ) {
+ elem = this[ i ];
+
+ if ( elem.nodeType === 1 && elem.className ) {
+ if ( value ) {
+ className = (" " + elem.className + " ").replace( rclass, " " );
+ for ( c = 0, cl = classNames.length; c < cl; c++ ) {
+ className = className.replace(" " + classNames[ c ] + " ", " ");
+ }
+ elem.className = jQuery.trim( className );
+
+ } else {
+ elem.className = "";
+ }
+ }
+ }
+ }
+
+ return this;
+ },
+
+ toggleClass: function( value, stateVal ) {
+ var type = typeof value,
+ isBool = typeof stateVal === "boolean";
+
+ if ( jQuery.isFunction( value ) ) {
+ return this.each(function( i ) {
+ jQuery( this ).toggleClass( value.call(this, i, this.className, stateVal), stateVal );
+ });
+ }
+
+ return this.each(function() {
+ if ( type === "string" ) {
+ // toggle individual class names
+ var className,
+ i = 0,
+ self = jQuery( this ),
+ state = stateVal,
+ classNames = value.split( rspace );
+
+ while ( (className = classNames[ i++ ]) ) {
+ // check each className given, space seperated list
+ state = isBool ? state : !self.hasClass( className );
+ self[ state ? "addClass" : "removeClass" ]( className );
+ }
+
+ } else if ( type === "undefined" || type === "boolean" ) {
+ if ( this.className ) {
+ // store className if set
+ jQuery._data( this, "__className__", this.className );
+ }
+
+ // toggle whole className
+ this.className = this.className || value === false ? "" : jQuery._data( this, "__className__" ) || "";
+ }
+ });
+ },
+
+ hasClass: function( selector ) {
+ var className = " " + selector + " ",
+ i = 0,
+ l = this.length;
+ for ( ; i < l; i++ ) {
+ if ( this[i].nodeType === 1 && (" " + this[i].className + " ").replace(rclass, " ").indexOf( className ) > -1 ) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ val: function( value ) {
+ var hooks, ret, isFunction,
+ elem = this[0];
+
+ if ( !arguments.length ) {
+ if ( elem ) {
+ hooks = jQuery.valHooks[ elem.nodeName.toLowerCase() ] || jQuery.valHooks[ elem.type ];
+
+ if ( hooks && "get" in hooks && (ret = hooks.get( elem, "value" )) !== undefined ) {
+ return ret;
+ }
+
+ ret = elem.value;
+
+ return typeof ret === "string" ?
+ // handle most common string cases
+ ret.replace(rreturn, "") :
+ // handle cases where value is null/undef or number
+ ret == null ? "" : ret;
+ }
+
+ return;
+ }
+
+ isFunction = jQuery.isFunction( value );
+
+ return this.each(function( i ) {
+ var self = jQuery(this), val;
+
+ if ( this.nodeType !== 1 ) {
+ return;
+ }
+
+ if ( isFunction ) {
+ val = value.call( this, i, self.val() );
+ } else {
+ val = value;
+ }
+
+ // Treat null/undefined as ""; convert numbers to string
+ if ( val == null ) {
+ val = "";
+ } else if ( typeof val === "number" ) {
+ val += "";
+ } else if ( jQuery.isArray( val ) ) {
+ val = jQuery.map(val, function ( value ) {
+ return value == null ? "" : value + "";
+ });
+ }
+
+ hooks = jQuery.valHooks[ this.nodeName.toLowerCase() ] || jQuery.valHooks[ this.type ];
+
+ // If set returns undefined, fall back to normal setting
+ if ( !hooks || !("set" in hooks) || hooks.set( this, val, "value" ) === undefined ) {
+ this.value = val;
+ }
+ });
+ }
+});
+
+jQuery.extend({
+ valHooks: {
+ option: {
+ get: function( elem ) {
+ // attributes.value is undefined in Blackberry 4.7 but
+ // uses .value. See #6932
+ var val = elem.attributes.value;
+ return !val || val.specified ? elem.value : elem.text;
+ }
+ },
+ select: {
+ get: function( elem ) {
+ var value, i, max, option,
+ index = elem.selectedIndex,
+ values = [],
+ options = elem.options,
+ one = elem.type === "select-one";
+
+ // Nothing was selected
+ if ( index < 0 ) {
+ return null;
+ }
+
+ // Loop through all the selected options
+ i = one ? index : 0;
+ max = one ? index + 1 : options.length;
+ for ( ; i < max; i++ ) {
+ option = options[ i ];
+
+ // Don't return options that are disabled or in a disabled optgroup
+ if ( option.selected && (jQuery.support.optDisabled ? !option.disabled : option.getAttribute("disabled") === null) &&
+ (!option.parentNode.disabled || !jQuery.nodeName( option.parentNode, "optgroup" )) ) {
+
+ // Get the specific value for the option
+ value = jQuery( option ).val();
+
+ // We don't need an array for one selects
+ if ( one ) {
+ return value;
+ }
+
+ // Multi-Selects return an array
+ values.push( value );
+ }
+ }
+
+ // Fixes Bug #2551 -- select.val() broken in IE after form.reset()
+ if ( one && !values.length && options.length ) {
+ return jQuery( options[ index ] ).val();
+ }
+
+ return values;
+ },
+
+ set: function( elem, value ) {
+ var values = jQuery.makeArray( value );
+
+ jQuery(elem).find("option").each(function() {
+ this.selected = jQuery.inArray( jQuery(this).val(), values ) >= 0;
+ });
+
+ if ( !values.length ) {
+ elem.selectedIndex = -1;
+ }
+ return values;
+ }
+ }
+ },
+
+ attrFn: {
+ val: true,
+ css: true,
+ html: true,
+ text: true,
+ data: true,
+ width: true,
+ height: true,
+ offset: true
+ },
+
+ attr: function( elem, name, value, pass ) {
+ var ret, hooks, notxml,
+ nType = elem.nodeType;
+
+ // don't get/set attributes on text, comment and attribute nodes
+ if ( !elem || nType === 3 || nType === 8 || nType === 2 ) {
+ return;
+ }
+
+ if ( pass && name in jQuery.attrFn ) {
+ return jQuery( elem )[ name ]( value );
+ }
+
+ // Fallback to prop when attributes are not supported
+ if ( typeof elem.getAttribute === "undefined" ) {
+ return jQuery.prop( elem, name, value );
+ }
+
+ notxml = nType !== 1 || !jQuery.isXMLDoc( elem );
+
+ // All attributes are lowercase
+ // Grab necessary hook if one is defined
+ if ( notxml ) {
+ name = name.toLowerCase();
+ hooks = jQuery.attrHooks[ name ] || ( rboolean.test( name ) ? boolHook : nodeHook );
+ }
+
+ if ( value !== undefined ) {
+
+ if ( value === null ) {
+ jQuery.removeAttr( elem, name );
+ return;
+
+ } else if ( hooks && "set" in hooks && notxml && (ret = hooks.set( elem, value, name )) !== undefined ) {
+ return ret;
+
+ } else {
+ elem.setAttribute( name, "" + value );
+ return value;
+ }
+
+ } else if ( hooks && "get" in hooks && notxml && (ret = hooks.get( elem, name )) !== null ) {
+ return ret;
+
+ } else {
+
+ ret = elem.getAttribute( name );
+
+ // Non-existent attributes return null, we normalize to undefined
+ return ret === null ?
+ undefined :
+ ret;
+ }
+ },
+
+ removeAttr: function( elem, value ) {
+ var propName, attrNames, name, l,
+ i = 0;
+
+ if ( value && elem.nodeType === 1 ) {
+ attrNames = value.toLowerCase().split( rspace );
+ l = attrNames.length;
+
+ for ( ; i < l; i++ ) {
+ name = attrNames[ i ];
+
+ if ( name ) {
+ propName = jQuery.propFix[ name ] || name;
+
+ // See #9699 for explanation of this approach (setting first, then removal)
+ jQuery.attr( elem, name, "" );
+ elem.removeAttribute( getSetAttribute ? name : propName );
+
+ // Set corresponding property to false for boolean attributes
+ if ( rboolean.test( name ) && propName in elem ) {
+ elem[ propName ] = false;
+ }
+ }
+ }
+ }
+ },
+
+ attrHooks: {
+ type: {
+ set: function( elem, value ) {
+ // We can't allow the type property to be changed (since it causes problems in IE)
+ if ( rtype.test( elem.nodeName ) && elem.parentNode ) {
+ jQuery.error( "type property can't be changed" );
+ } else if ( !jQuery.support.radioValue && value === "radio" && jQuery.nodeName(elem, "input") ) {
+ // Setting the type on a radio button after the value resets the value in IE6-9
+ // Reset value to it's default in case type is set after value
+ // This is for element creation
+ var val = elem.value;
+ elem.setAttribute( "type", value );
+ if ( val ) {
+ elem.value = val;
+ }
+ return value;
+ }
+ }
+ },
+ // Use the value property for back compat
+ // Use the nodeHook for button elements in IE6/7 (#1954)
+ value: {
+ get: function( elem, name ) {
+ if ( nodeHook && jQuery.nodeName( elem, "button" ) ) {
+ return nodeHook.get( elem, name );
+ }
+ return name in elem ?
+ elem.value :
+ null;
+ },
+ set: function( elem, value, name ) {
+ if ( nodeHook && jQuery.nodeName( elem, "button" ) ) {
+ return nodeHook.set( elem, value, name );
+ }
+ // Does not return so that setAttribute is also used
+ elem.value = value;
+ }
+ }
+ },
+
+ propFix: {
+ tabindex: "tabIndex",
+ readonly: "readOnly",
+ "for": "htmlFor",
+ "class": "className",
+ maxlength: "maxLength",
+ cellspacing: "cellSpacing",
+ cellpadding: "cellPadding",
+ rowspan: "rowSpan",
+ colspan: "colSpan",
+ usemap: "useMap",
+ frameborder: "frameBorder",
+ contenteditable: "contentEditable"
+ },
+
+ prop: function( elem, name, value ) {
+ var ret, hooks, notxml,
+ nType = elem.nodeType;
+
+ // don't get/set properties on text, comment and attribute nodes
+ if ( !elem || nType === 3 || nType === 8 || nType === 2 ) {
+ return;
+ }
+
+ notxml = nType !== 1 || !jQuery.isXMLDoc( elem );
+
+ if ( notxml ) {
+ // Fix name and attach hooks
+ name = jQuery.propFix[ name ] || name;
+ hooks = jQuery.propHooks[ name ];
+ }
+
+ if ( value !== undefined ) {
+ if ( hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) {
+ return ret;
+
+ } else {
+ return ( elem[ name ] = value );
+ }
+
+ } else {
+ if ( hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) {
+ return ret;
+
+ } else {
+ return elem[ name ];
+ }
+ }
+ },
+
+ propHooks: {
+ tabIndex: {
+ get: function( elem ) {
+ // elem.tabIndex doesn't always return the correct value when it hasn't been explicitly set
+ // http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/
+ var attributeNode = elem.getAttributeNode("tabindex");
+
+ return attributeNode && attributeNode.specified ?
+ parseInt( attributeNode.value, 10 ) :
+ rfocusable.test( elem.nodeName ) || rclickable.test( elem.nodeName ) && elem.href ?
+ 0 :
+ undefined;
+ }
+ }
+ }
+});
+
+// Add the tabIndex propHook to attrHooks for back-compat (different case is intentional)
+jQuery.attrHooks.tabindex = jQuery.propHooks.tabIndex;
+
+// Hook for boolean attributes
+boolHook = {
+ get: function( elem, name ) {
+ // Align boolean attributes with corresponding properties
+ // Fall back to attribute presence where some booleans are not supported
+ var attrNode,
+ property = jQuery.prop( elem, name );
+ return property === true || typeof property !== "boolean" && ( attrNode = elem.getAttributeNode(name) ) && attrNode.nodeValue !== false ?
+ name.toLowerCase() :
+ undefined;
+ },
+ set: function( elem, value, name ) {
+ var propName;
+ if ( value === false ) {
+ // Remove boolean attributes when set to false
+ jQuery.removeAttr( elem, name );
+ } else {
+ // value is true since we know at this point it's type boolean and not false
+ // Set boolean attributes to the same name and set the DOM property
+ propName = jQuery.propFix[ name ] || name;
+ if ( propName in elem ) {
+ // Only set the IDL specifically if it already exists on the element
+ elem[ propName ] = true;
+ }
+
+ elem.setAttribute( name, name.toLowerCase() );
+ }
+ return name;
+ }
+};
+
+// IE6/7 do not support getting/setting some attributes with get/setAttribute
+if ( !getSetAttribute ) {
+
+ fixSpecified = {
+ name: true,
+ id: true
+ };
+
+ // Use this for any attribute in IE6/7
+ // This fixes almost every IE6/7 issue
+ nodeHook = jQuery.valHooks.button = {
+ get: function( elem, name ) {
+ var ret;
+ ret = elem.getAttributeNode( name );
+ return ret && ( fixSpecified[ name ] ? ret.nodeValue !== "" : ret.specified ) ?
+ ret.nodeValue :
+ undefined;
+ },
+ set: function( elem, value, name ) {
+ // Set the existing or create a new attribute node
+ var ret = elem.getAttributeNode( name );
+ if ( !ret ) {
+ ret = document.createAttribute( name );
+ elem.setAttributeNode( ret );
+ }
+ return ( ret.nodeValue = value + "" );
+ }
+ };
+
+ // Apply the nodeHook to tabindex
+ jQuery.attrHooks.tabindex.set = nodeHook.set;
+
+ // Set width and height to auto instead of 0 on empty string( Bug #8150 )
+ // This is for removals
+ jQuery.each([ "width", "height" ], function( i, name ) {
+ jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], {
+ set: function( elem, value ) {
+ if ( value === "" ) {
+ elem.setAttribute( name, "auto" );
+ return value;
+ }
+ }
+ });
+ });
+
+ // Set contenteditable to false on removals(#10429)
+ // Setting to empty string throws an error as an invalid value
+ jQuery.attrHooks.contenteditable = {
+ get: nodeHook.get,
+ set: function( elem, value, name ) {
+ if ( value === "" ) {
+ value = "false";
+ }
+ nodeHook.set( elem, value, name );
+ }
+ };
+}
+
+
+// Some attributes require a special call on IE
+if ( !jQuery.support.hrefNormalized ) {
+ jQuery.each([ "href", "src", "width", "height" ], function( i, name ) {
+ jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], {
+ get: function( elem ) {
+ var ret = elem.getAttribute( name, 2 );
+ return ret === null ? undefined : ret;
+ }
+ });
+ });
+}
+
+if ( !jQuery.support.style ) {
+ jQuery.attrHooks.style = {
+ get: function( elem ) {
+ // Return undefined in the case of empty string
+ // Normalize to lowercase since IE uppercases css property names
+ return elem.style.cssText.toLowerCase() || undefined;
+ },
+ set: function( elem, value ) {
+ return ( elem.style.cssText = "" + value );
+ }
+ };
+}
+
+// Safari mis-reports the default selected property of an option
+// Accessing the parent's selectedIndex property fixes it
+if ( !jQuery.support.optSelected ) {
+ jQuery.propHooks.selected = jQuery.extend( jQuery.propHooks.selected, {
+ get: function( elem ) {
+ var parent = elem.parentNode;
+
+ if ( parent ) {
+ parent.selectedIndex;
+
+ // Make sure that it also works with optgroups, see #5701
+ if ( parent.parentNode ) {
+ parent.parentNode.selectedIndex;
+ }
+ }
+ return null;
+ }
+ });
+}
+
+// IE6/7 call enctype encoding
+if ( !jQuery.support.enctype ) {
+ jQuery.propFix.enctype = "encoding";
+}
+
+// Radios and checkboxes getter/setter
+if ( !jQuery.support.checkOn ) {
+ jQuery.each([ "radio", "checkbox" ], function() {
+ jQuery.valHooks[ this ] = {
+ get: function( elem ) {
+ // Handle the case where in Webkit "" is returned instead of "on" if a value isn't specified
+ return elem.getAttribute("value") === null ? "on" : elem.value;
+ }
+ };
+ });
+}
+jQuery.each([ "radio", "checkbox" ], function() {
+ jQuery.valHooks[ this ] = jQuery.extend( jQuery.valHooks[ this ], {
+ set: function( elem, value ) {
+ if ( jQuery.isArray( value ) ) {
+ return ( elem.checked = jQuery.inArray( jQuery(elem).val(), value ) >= 0 );
+ }
+ }
+ });
+});
+
+
+
+
+var rformElems = /^(?:textarea|input|select)$/i,
+ rtypenamespace = /^([^\.]*)?(?:\.(.+))?$/,
+ rhoverHack = /\bhover(\.\S+)?\b/,
+ rkeyEvent = /^key/,
+ rmouseEvent = /^(?:mouse|contextmenu)|click/,
+ rfocusMorph = /^(?:focusinfocus|focusoutblur)$/,
+ rquickIs = /^(\w*)(?:#([\w\-]+))?(?:\.([\w\-]+))?$/,
+ quickParse = function( selector ) {
+ var quick = rquickIs.exec( selector );
+ if ( quick ) {
+ // 0 1 2 3
+ // [ _, tag, id, class ]
+ quick[1] = ( quick[1] || "" ).toLowerCase();
+ quick[3] = quick[3] && new RegExp( "(?:^|\\s)" + quick[3] + "(?:\\s|$)" );
+ }
+ return quick;
+ },
+ quickIs = function( elem, m ) {
+ var attrs = elem.attributes || {};
+ return (
+ (!m[1] || elem.nodeName.toLowerCase() === m[1]) &&
+ (!m[2] || (attrs.id || {}).value === m[2]) &&
+ (!m[3] || m[3].test( (attrs[ "class" ] || {}).value ))
+ );
+ },
+ hoverHack = function( events ) {
+ return jQuery.event.special.hover ? events : events.replace( rhoverHack, "mouseenter$1 mouseleave$1" );
+ };
+
+/*
+ * Helper functions for managing events -- not part of the public interface.
+ * Props to Dean Edwards' addEvent library for many of the ideas.
+ */
+jQuery.event = {
+
+ add: function( elem, types, handler, data, selector ) {
+
+ var elemData, eventHandle, events,
+ t, tns, type, namespaces, handleObj,
+ handleObjIn, quick, handlers, special;
+
+ // Don't attach events to noData or text/comment nodes (allow plain objects tho)
+ if ( elem.nodeType === 3 || elem.nodeType === 8 || !types || !handler || !(elemData = jQuery._data( elem )) ) {
+ return;
+ }
+
+ // Caller can pass in an object of custom data in lieu of the handler
+ if ( handler.handler ) {
+ handleObjIn = handler;
+ handler = handleObjIn.handler;
+ }
+
+ // Make sure that the handler has a unique ID, used to find/remove it later
+ if ( !handler.guid ) {
+ handler.guid = jQuery.guid++;
+ }
+
+ // Init the element's event structure and main handler, if this is the first
+ events = elemData.events;
+ if ( !events ) {
+ elemData.events = events = {};
+ }
+ eventHandle = elemData.handle;
+ if ( !eventHandle ) {
+ elemData.handle = eventHandle = function( e ) {
+ // Discard the second event of a jQuery.event.trigger() and
+ // when an event is called after a page has unloaded
+ return typeof jQuery !== "undefined" && (!e || jQuery.event.triggered !== e.type) ?
+ jQuery.event.dispatch.apply( eventHandle.elem, arguments ) :
+ undefined;
+ };
+ // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events
+ eventHandle.elem = elem;
+ }
+
+ // Handle multiple events separated by a space
+ // jQuery(...).bind("mouseover mouseout", fn);
+ types = jQuery.trim( hoverHack(types) ).split( " " );
+ for ( t = 0; t < types.length; t++ ) {
+
+ tns = rtypenamespace.exec( types[t] ) || [];
+ type = tns[1];
+ namespaces = ( tns[2] || "" ).split( "." ).sort();
+
+ // If event changes its type, use the special event handlers for the changed type
+ special = jQuery.event.special[ type ] || {};
+
+ // If selector defined, determine special event api type, otherwise given type
+ type = ( selector ? special.delegateType : special.bindType ) || type;
+
+ // Update special based on newly reset type
+ special = jQuery.event.special[ type ] || {};
+
+ // handleObj is passed to all event handlers
+ handleObj = jQuery.extend({
+ type: type,
+ origType: tns[1],
+ data: data,
+ handler: handler,
+ guid: handler.guid,
+ selector: selector,
+ quick: quickParse( selector ),
+ namespace: namespaces.join(".")
+ }, handleObjIn );
+
+ // Init the event handler queue if we're the first
+ handlers = events[ type ];
+ if ( !handlers ) {
+ handlers = events[ type ] = [];
+ handlers.delegateCount = 0;
+
+ // Only use addEventListener/attachEvent if the special events handler returns false
+ if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) {
+ // Bind the global event handler to the element
+ if ( elem.addEventListener ) {
+ elem.addEventListener( type, eventHandle, false );
+
+ } else if ( elem.attachEvent ) {
+ elem.attachEvent( "on" + type, eventHandle );
+ }
+ }
+ }
+
+ if ( special.add ) {
+ special.add.call( elem, handleObj );
+
+ if ( !handleObj.handler.guid ) {
+ handleObj.handler.guid = handler.guid;
+ }
+ }
+
+ // Add to the element's handler list, delegates in front
+ if ( selector ) {
+ handlers.splice( handlers.delegateCount++, 0, handleObj );
+ } else {
+ handlers.push( handleObj );
+ }
+
+ // Keep track of which events have ever been used, for event optimization
+ jQuery.event.global[ type ] = true;
+ }
+
+ // Nullify elem to prevent memory leaks in IE
+ elem = null;
+ },
+
+ global: {},
+
+ // Detach an event or set of events from an element
+ remove: function( elem, types, handler, selector, mappedTypes ) {
+
+ var elemData = jQuery.hasData( elem ) && jQuery._data( elem ),
+ t, tns, type, origType, namespaces, origCount,
+ j, events, special, handle, eventType, handleObj;
+
+ if ( !elemData || !(events = elemData.events) ) {
+ return;
+ }
+
+ // Once for each type.namespace in types; type may be omitted
+ types = jQuery.trim( hoverHack( types || "" ) ).split(" ");
+ for ( t = 0; t < types.length; t++ ) {
+ tns = rtypenamespace.exec( types[t] ) || [];
+ type = origType = tns[1];
+ namespaces = tns[2];
+
+ // Unbind all events (on this namespace, if provided) for the element
+ if ( !type ) {
+ for ( type in events ) {
+ jQuery.event.remove( elem, type + types[ t ], handler, selector, true );
+ }
+ continue;
+ }
+
+ special = jQuery.event.special[ type ] || {};
+ type = ( selector? special.delegateType : special.bindType ) || type;
+ eventType = events[ type ] || [];
+ origCount = eventType.length;
+ namespaces = namespaces ? new RegExp("(^|\\.)" + namespaces.split(".").sort().join("\\.(?:.*\\.)?") + "(\\.|$)") : null;
+
+ // Remove matching events
+ for ( j = 0; j < eventType.length; j++ ) {
+ handleObj = eventType[ j ];
+
+ if ( ( mappedTypes || origType === handleObj.origType ) &&
+ ( !handler || handler.guid === handleObj.guid ) &&
+ ( !namespaces || namespaces.test( handleObj.namespace ) ) &&
+ ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) {
+ eventType.splice( j--, 1 );
+
+ if ( handleObj.selector ) {
+ eventType.delegateCount--;
+ }
+ if ( special.remove ) {
+ special.remove.call( elem, handleObj );
+ }
+ }
+ }
+
+ // Remove generic event handler if we removed something and no more handlers exist
+ // (avoids potential for endless recursion during removal of special event handlers)
+ if ( eventType.length === 0 && origCount !== eventType.length ) {
+ if ( !special.teardown || special.teardown.call( elem, namespaces ) === false ) {
+ jQuery.removeEvent( elem, type, elemData.handle );
+ }
+
+ delete events[ type ];
+ }
+ }
+
+ // Remove the expando if it's no longer used
+ if ( jQuery.isEmptyObject( events ) ) {
+ handle = elemData.handle;
+ if ( handle ) {
+ handle.elem = null;
+ }
+
+ // removeData also checks for emptiness and clears the expando if empty
+ // so use it instead of delete
+ jQuery.removeData( elem, [ "events", "handle" ], true );
+ }
+ },
+
+ // Events that are safe to short-circuit if no handlers are attached.
+ // Native DOM events should not be added, they may have inline handlers.
+ customEvent: {
+ "getData": true,
+ "setData": true,
+ "changeData": true
+ },
+
+ trigger: function( event, data, elem, onlyHandlers ) {
+ // Don't do events on text and comment nodes
+ if ( elem && (elem.nodeType === 3 || elem.nodeType === 8) ) {
+ return;
+ }
+
+ // Event object or event type
+ var type = event.type || event,
+ namespaces = [],
+ cache, exclusive, i, cur, old, ontype, special, handle, eventPath, bubbleType;
+
+ // focus/blur morphs to focusin/out; ensure we're not firing them right now
+ if ( rfocusMorph.test( type + jQuery.event.triggered ) ) {
+ return;
+ }
+
+ if ( type.indexOf( "!" ) >= 0 ) {
+ // Exclusive events trigger only for the exact event (no namespaces)
+ type = type.slice(0, -1);
+ exclusive = true;
+ }
+
+ if ( type.indexOf( "." ) >= 0 ) {
+ // Namespaced trigger; create a regexp to match event type in handle()
+ namespaces = type.split(".");
+ type = namespaces.shift();
+ namespaces.sort();
+ }
+
+ if ( (!elem || jQuery.event.customEvent[ type ]) && !jQuery.event.global[ type ] ) {
+ // No jQuery handlers for this event type, and it can't have inline handlers
+ return;
+ }
+
+ // Caller can pass in an Event, Object, or just an event type string
+ event = typeof event === "object" ?
+ // jQuery.Event object
+ event[ jQuery.expando ] ? event :
+ // Object literal
+ new jQuery.Event( type, event ) :
+ // Just the event type (string)
+ new jQuery.Event( type );
+
+ event.type = type;
+ event.isTrigger = true;
+ event.exclusive = exclusive;
+ event.namespace = namespaces.join( "." );
+ event.namespace_re = event.namespace? new RegExp("(^|\\.)" + namespaces.join("\\.(?:.*\\.)?") + "(\\.|$)") : null;
+ ontype = type.indexOf( ":" ) < 0 ? "on" + type : "";
+
+ // Handle a global trigger
+ if ( !elem ) {
+
+ // TODO: Stop taunting the data cache; remove global events and always attach to document
+ cache = jQuery.cache;
+ for ( i in cache ) {
+ if ( cache[ i ].events && cache[ i ].events[ type ] ) {
+ jQuery.event.trigger( event, data, cache[ i ].handle.elem, true );
+ }
+ }
+ return;
+ }
+
+ // Clean up the event in case it is being reused
+ event.result = undefined;
+ if ( !event.target ) {
+ event.target = elem;
+ }
+
+ // Clone any incoming data and prepend the event, creating the handler arg list
+ data = data != null ? jQuery.makeArray( data ) : [];
+ data.unshift( event );
+
+ // Allow special events to draw outside the lines
+ special = jQuery.event.special[ type ] || {};
+ if ( special.trigger && special.trigger.apply( elem, data ) === false ) {
+ return;
+ }
+
+ // Determine event propagation path in advance, per W3C events spec (#9951)
+ // Bubble up to document, then to window; watch for a global ownerDocument var (#9724)
+ eventPath = [[ elem, special.bindType || type ]];
+ if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) {
+
+ bubbleType = special.delegateType || type;
+ cur = rfocusMorph.test( bubbleType + type ) ? elem : elem.parentNode;
+ old = null;
+ for ( ; cur; cur = cur.parentNode ) {
+ eventPath.push([ cur, bubbleType ]);
+ old = cur;
+ }
+
+ // Only add window if we got to document (e.g., not plain obj or detached DOM)
+ if ( old && old === elem.ownerDocument ) {
+ eventPath.push([ old.defaultView || old.parentWindow || window, bubbleType ]);
+ }
+ }
+
+ // Fire handlers on the event path
+ for ( i = 0; i < eventPath.length && !event.isPropagationStopped(); i++ ) {
+
+ cur = eventPath[i][0];
+ event.type = eventPath[i][1];
+
+ handle = ( jQuery._data( cur, "events" ) || {} )[ event.type ] && jQuery._data( cur, "handle" );
+ if ( handle ) {
+ handle.apply( cur, data );
+ }
+ // Note that this is a bare JS function and not a jQuery handler
+ handle = ontype && cur[ ontype ];
+ if ( handle && jQuery.acceptData( cur ) && handle.apply( cur, data ) === false ) {
+ event.preventDefault();
+ }
+ }
+ event.type = type;
+
+ // If nobody prevented the default action, do it now
+ if ( !onlyHandlers && !event.isDefaultPrevented() ) {
+
+ if ( (!special._default || special._default.apply( elem.ownerDocument, data ) === false) &&
+ !(type === "click" && jQuery.nodeName( elem, "a" )) && jQuery.acceptData( elem ) ) {
+
+ // Call a native DOM method on the target with the same name name as the event.
+ // Can't use an .isFunction() check here because IE6/7 fails that test.
+ // Don't do default actions on window, that's where global variables be (#6170)
+ // IE<9 dies on focus/blur to hidden element (#1486)
+ if ( ontype && elem[ type ] && ((type !== "focus" && type !== "blur") || event.target.offsetWidth !== 0) && !jQuery.isWindow( elem ) ) {
+
+ // Don't re-trigger an onFOO event when we call its FOO() method
+ old = elem[ ontype ];
+
+ if ( old ) {
+ elem[ ontype ] = null;
+ }
+
+ // Prevent re-triggering of the same event, since we already bubbled it above
+ jQuery.event.triggered = type;
+ elem[ type ]();
+ jQuery.event.triggered = undefined;
+
+ if ( old ) {
+ elem[ ontype ] = old;
+ }
+ }
+ }
+ }
+
+ return event.result;
+ },
+
+ dispatch: function( event ) {
+
+ // Make a writable jQuery.Event from the native event object
+ event = jQuery.event.fix( event || window.event );
+
+ var handlers = ( (jQuery._data( this, "events" ) || {} )[ event.type ] || []),
+ delegateCount = handlers.delegateCount,
+ args = [].slice.call( arguments, 0 ),
+ run_all = !event.exclusive && !event.namespace,
+ handlerQueue = [],
+ i, j, cur, jqcur, ret, selMatch, matched, matches, handleObj, sel, related;
+
+ // Use the fix-ed jQuery.Event rather than the (read-only) native event
+ args[0] = event;
+ event.delegateTarget = this;
+
+ // Determine handlers that should run if there are delegated events
+ // Avoid disabled elements in IE (#6911) and non-left-click bubbling in Firefox (#3861)
+ if ( delegateCount && !event.target.disabled && !(event.button && event.type === "click") ) {
+
+ // Pregenerate a single jQuery object for reuse with .is()
+ jqcur = jQuery(this);
+ jqcur.context = this.ownerDocument || this;
+
+ for ( cur = event.target; cur != this; cur = cur.parentNode || this ) {
+ selMatch = {};
+ matches = [];
+ jqcur[0] = cur;
+ for ( i = 0; i < delegateCount; i++ ) {
+ handleObj = handlers[ i ];
+ sel = handleObj.selector;
+
+ if ( selMatch[ sel ] === undefined ) {
+ selMatch[ sel ] = (
+ handleObj.quick ? quickIs( cur, handleObj.quick ) : jqcur.is( sel )
+ );
+ }
+ if ( selMatch[ sel ] ) {
+ matches.push( handleObj );
+ }
+ }
+ if ( matches.length ) {
+ handlerQueue.push({ elem: cur, matches: matches });
+ }
+ }
+ }
+
+ // Add the remaining (directly-bound) handlers
+ if ( handlers.length > delegateCount ) {
+ handlerQueue.push({ elem: this, matches: handlers.slice( delegateCount ) });
+ }
+
+ // Run delegates first; they may want to stop propagation beneath us
+ for ( i = 0; i < handlerQueue.length && !event.isPropagationStopped(); i++ ) {
+ matched = handlerQueue[ i ];
+ event.currentTarget = matched.elem;
+
+ for ( j = 0; j < matched.matches.length && !event.isImmediatePropagationStopped(); j++ ) {
+ handleObj = matched.matches[ j ];
+
+ // Triggered event must either 1) be non-exclusive and have no namespace, or
+ // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace).
+ if ( run_all || (!event.namespace && !handleObj.namespace) || event.namespace_re && event.namespace_re.test( handleObj.namespace ) ) {
+
+ event.data = handleObj.data;
+ event.handleObj = handleObj;
+
+ ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler )
+ .apply( matched.elem, args );
+
+ if ( ret !== undefined ) {
+ event.result = ret;
+ if ( ret === false ) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ }
+ }
+ }
+
+ return event.result;
+ },
+
+ // Includes some event props shared by KeyEvent and MouseEvent
+ // *** attrChange attrName relatedNode srcElement are not normalized, non-W3C, deprecated, will be removed in 1.8 ***
+ props: "attrChange attrName relatedNode srcElement altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),
+
+ fixHooks: {},
+
+ keyHooks: {
+ props: "char charCode key keyCode".split(" "),
+ filter: function( event, original ) {
+
+ // Add which for key events
+ if ( event.which == null ) {
+ event.which = original.charCode != null ? original.charCode : original.keyCode;
+ }
+
+ return event;
+ }
+ },
+
+ mouseHooks: {
+ props: "button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),
+ filter: function( event, original ) {
+ var eventDoc, doc, body,
+ button = original.button,
+ fromElement = original.fromElement;
+
+ // Calculate pageX/Y if missing and clientX/Y available
+ if ( event.pageX == null && original.clientX != null ) {
+ eventDoc = event.target.ownerDocument || document;
+ doc = eventDoc.documentElement;
+ body = eventDoc.body;
+
+ event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 );
+ event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 );
+ }
+
+ // Add relatedTarget, if necessary
+ if ( !event.relatedTarget && fromElement ) {
+ event.relatedTarget = fromElement === event.target ? original.toElement : fromElement;
+ }
+
+ // Add which for click: 1 === left; 2 === middle; 3 === right
+ // Note: button is not normalized, so don't use it
+ if ( !event.which && button !== undefined ) {
+ event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) );
+ }
+
+ return event;
+ }
+ },
+
+ fix: function( event ) {
+ if ( event[ jQuery.expando ] ) {
+ return event;
+ }
+
+ // Create a writable copy of the event object and normalize some properties
+ var i, prop,
+ originalEvent = event,
+ fixHook = jQuery.event.fixHooks[ event.type ] || {},
+ copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props;
+
+ event = jQuery.Event( originalEvent );
+
+ for ( i = copy.length; i; ) {
+ prop = copy[ --i ];
+ event[ prop ] = originalEvent[ prop ];
+ }
+
+ // Fix target property, if necessary (#1925, IE 6/7/8 & Safari2)
+ if ( !event.target ) {
+ event.target = originalEvent.srcElement || document;
+ }
+
+ // Target should not be a text node (#504, Safari)
+ if ( event.target.nodeType === 3 ) {
+ event.target = event.target.parentNode;
+ }
+
+ // For mouse/key events; add metaKey if it's not there (#3368, IE6/7/8)
+ if ( event.metaKey === undefined ) {
+ event.metaKey = event.ctrlKey;
+ }
+
+ return fixHook.filter? fixHook.filter( event, originalEvent ) : event;
+ },
+
+ special: {
+ ready: {
+ // Make sure the ready event is setup
+ setup: jQuery.bindReady
+ },
+
+ load: {
+ // Prevent triggered image.load events from bubbling to window.load
+ noBubble: true
+ },
+
+ focus: {
+ delegateType: "focusin"
+ },
+ blur: {
+ delegateType: "focusout"
+ },
+
+ beforeunload: {
+ setup: function( data, namespaces, eventHandle ) {
+ // We only want to do this special case on windows
+ if ( jQuery.isWindow( this ) ) {
+ this.onbeforeunload = eventHandle;
+ }
+ },
+
+ teardown: function( namespaces, eventHandle ) {
+ if ( this.onbeforeunload === eventHandle ) {
+ this.onbeforeunload = null;
+ }
+ }
+ }
+ },
+
+ simulate: function( type, elem, event, bubble ) {
+ // Piggyback on a donor event to simulate a different one.
+ // Fake originalEvent to avoid donor's stopPropagation, but if the
+ // simulated event prevents default then we do the same on the donor.
+ var e = jQuery.extend(
+ new jQuery.Event(),
+ event,
+ { type: type,
+ isSimulated: true,
+ originalEvent: {}
+ }
+ );
+ if ( bubble ) {
+ jQuery.event.trigger( e, null, elem );
+ } else {
+ jQuery.event.dispatch.call( elem, e );
+ }
+ if ( e.isDefaultPrevented() ) {
+ event.preventDefault();
+ }
+ }
+};
+
+// Some plugins are using, but it's undocumented/deprecated and will be removed.
+// The 1.7 special event interface should provide all the hooks needed now.
+jQuery.event.handle = jQuery.event.dispatch;
+
+jQuery.removeEvent = document.removeEventListener ?
+ function( elem, type, handle ) {
+ if ( elem.removeEventListener ) {
+ elem.removeEventListener( type, handle, false );
+ }
+ } :
+ function( elem, type, handle ) {
+ if ( elem.detachEvent ) {
+ elem.detachEvent( "on" + type, handle );
+ }
+ };
+
+jQuery.Event = function( src, props ) {
+ // Allow instantiation without the 'new' keyword
+ if ( !(this instanceof jQuery.Event) ) {
+ return new jQuery.Event( src, props );
+ }
+
+ // Event object
+ if ( src && src.type ) {
+ this.originalEvent = src;
+ this.type = src.type;
+
+ // Events bubbling up the document may have been marked as prevented
+ // by a handler lower down the tree; reflect the correct value.
+ this.isDefaultPrevented = ( src.defaultPrevented || src.returnValue === false ||
+ src.getPreventDefault && src.getPreventDefault() ) ? returnTrue : returnFalse;
+
+ // Event type
+ } else {
+ this.type = src;
+ }
+
+ // Put explicitly provided properties onto the event object
+ if ( props ) {
+ jQuery.extend( this, props );
+ }
+
+ // Create a timestamp if incoming event doesn't have one
+ this.timeStamp = src && src.timeStamp || jQuery.now();
+
+ // Mark it as fixed
+ this[ jQuery.expando ] = true;
+};
+
+function returnFalse() {
+ return false;
+}
+function returnTrue() {
+ return true;
+}
+
+// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding
+// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html
+jQuery.Event.prototype = {
+ preventDefault: function() {
+ this.isDefaultPrevented = returnTrue;
+
+ var e = this.originalEvent;
+ if ( !e ) {
+ return;
+ }
+
+ // if preventDefault exists run it on the original event
+ if ( e.preventDefault ) {
+ e.preventDefault();
+
+ // otherwise set the returnValue property of the original event to false (IE)
+ } else {
+ e.returnValue = false;
+ }
+ },
+ stopPropagation: function() {
+ this.isPropagationStopped = returnTrue;
+
+ var e = this.originalEvent;
+ if ( !e ) {
+ return;
+ }
+ // if stopPropagation exists run it on the original event
+ if ( e.stopPropagation ) {
+ e.stopPropagation();
+ }
+ // otherwise set the cancelBubble property of the original event to true (IE)
+ e.cancelBubble = true;
+ },
+ stopImmediatePropagation: function() {
+ this.isImmediatePropagationStopped = returnTrue;
+ this.stopPropagation();
+ },
+ isDefaultPrevented: returnFalse,
+ isPropagationStopped: returnFalse,
+ isImmediatePropagationStopped: returnFalse
+};
+
+// Create mouseenter/leave events using mouseover/out and event-time checks
+jQuery.each({
+ mouseenter: "mouseover",
+ mouseleave: "mouseout"
+}, function( orig, fix ) {
+ jQuery.event.special[ orig ] = {
+ delegateType: fix,
+ bindType: fix,
+
+ handle: function( event ) {
+ var target = this,
+ related = event.relatedTarget,
+ handleObj = event.handleObj,
+ selector = handleObj.selector,
+ ret;
+
+ // For mousenter/leave call the handler if related is outside the target.
+ // NB: No relatedTarget if the mouse left/entered the browser window
+ if ( !related || (related !== target && !jQuery.contains( target, related )) ) {
+ event.type = handleObj.origType;
+ ret = handleObj.handler.apply( this, arguments );
+ event.type = fix;
+ }
+ return ret;
+ }
+ };
+});
+
+// IE submit delegation
+if ( !jQuery.support.submitBubbles ) {
+
+ jQuery.event.special.submit = {
+ setup: function() {
+ // Only need this for delegated form submit events
+ if ( jQuery.nodeName( this, "form" ) ) {
+ return false;
+ }
+
+ // Lazy-add a submit handler when a descendant form may potentially be submitted
+ jQuery.event.add( this, "click._submit keypress._submit", function( e ) {
+ // Node name check avoids a VML-related crash in IE (#9807)
+ var elem = e.target,
+ form = jQuery.nodeName( elem, "input" ) || jQuery.nodeName( elem, "button" ) ? elem.form : undefined;
+ if ( form && !form._submit_attached ) {
+ jQuery.event.add( form, "submit._submit", function( event ) {
+ // If form was submitted by the user, bubble the event up the tree
+ if ( this.parentNode && !event.isTrigger ) {
+ jQuery.event.simulate( "submit", this.parentNode, event, true );
+ }
+ });
+ form._submit_attached = true;
+ }
+ });
+ // return undefined since we don't need an event listener
+ },
+
+ teardown: function() {
+ // Only need this for delegated form submit events
+ if ( jQuery.nodeName( this, "form" ) ) {
+ return false;
+ }
+
+ // Remove delegated handlers; cleanData eventually reaps submit handlers attached above
+ jQuery.event.remove( this, "._submit" );
+ }
+ };
+}
+
+// IE change delegation and checkbox/radio fix
+if ( !jQuery.support.changeBubbles ) {
+
+ jQuery.event.special.change = {
+
+ setup: function() {
+
+ if ( rformElems.test( this.nodeName ) ) {
+ // IE doesn't fire change on a check/radio until blur; trigger it on click
+ // after a propertychange. Eat the blur-change in special.change.handle.
+ // This still fires onchange a second time for check/radio after blur.
+ if ( this.type === "checkbox" || this.type === "radio" ) {
+ jQuery.event.add( this, "propertychange._change", function( event ) {
+ if ( event.originalEvent.propertyName === "checked" ) {
+ this._just_changed = true;
+ }
+ });
+ jQuery.event.add( this, "click._change", function( event ) {
+ if ( this._just_changed && !event.isTrigger ) {
+ this._just_changed = false;
+ jQuery.event.simulate( "change", this, event, true );
+ }
+ });
+ }
+ return false;
+ }
+ // Delegated event; lazy-add a change handler on descendant inputs
+ jQuery.event.add( this, "beforeactivate._change", function( e ) {
+ var elem = e.target;
+
+ if ( rformElems.test( elem.nodeName ) && !elem._change_attached ) {
+ jQuery.event.add( elem, "change._change", function( event ) {
+ if ( this.parentNode && !event.isSimulated && !event.isTrigger ) {
+ jQuery.event.simulate( "change", this.parentNode, event, true );
+ }
+ });
+ elem._change_attached = true;
+ }
+ });
+ },
+
+ handle: function( event ) {
+ var elem = event.target;
+
+ // Swallow native change events from checkbox/radio, we already triggered them above
+ if ( this !== elem || event.isSimulated || event.isTrigger || (elem.type !== "radio" && elem.type !== "checkbox") ) {
+ return event.handleObj.handler.apply( this, arguments );
+ }
+ },
+
+ teardown: function() {
+ jQuery.event.remove( this, "._change" );
+
+ return rformElems.test( this.nodeName );
+ }
+ };
+}
+
+// Create "bubbling" focus and blur events
+if ( !jQuery.support.focusinBubbles ) {
+ jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) {
+
+ // Attach a single capturing handler while someone wants focusin/focusout
+ var attaches = 0,
+ handler = function( event ) {
+ jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true );
+ };
+
+ jQuery.event.special[ fix ] = {
+ setup: function() {
+ if ( attaches++ === 0 ) {
+ document.addEventListener( orig, handler, true );
+ }
+ },
+ teardown: function() {
+ if ( --attaches === 0 ) {
+ document.removeEventListener( orig, handler, true );
+ }
+ }
+ };
+ });
+}
+
+jQuery.fn.extend({
+
+ on: function( types, selector, data, fn, /*INTERNAL*/ one ) {
+ var origFn, type;
+
+ // Types can be a map of types/handlers
+ if ( typeof types === "object" ) {
+ // ( types-Object, selector, data )
+ if ( typeof selector !== "string" ) {
+ // ( types-Object, data )
+ data = selector;
+ selector = undefined;
+ }
+ for ( type in types ) {
+ this.on( type, selector, data, types[ type ], one );
+ }
+ return this;
+ }
+
+ if ( data == null && fn == null ) {
+ // ( types, fn )
+ fn = selector;
+ data = selector = undefined;
+ } else if ( fn == null ) {
+ if ( typeof selector === "string" ) {
+ // ( types, selector, fn )
+ fn = data;
+ data = undefined;
+ } else {
+ // ( types, data, fn )
+ fn = data;
+ data = selector;
+ selector = undefined;
+ }
+ }
+ if ( fn === false ) {
+ fn = returnFalse;
+ } else if ( !fn ) {
+ return this;
+ }
+
+ if ( one === 1 ) {
+ origFn = fn;
+ fn = function( event ) {
+ // Can use an empty set, since event contains the info
+ jQuery().off( event );
+ return origFn.apply( this, arguments );
+ };
+ // Use same guid so caller can remove using origFn
+ fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ );
+ }
+ return this.each( function() {
+ jQuery.event.add( this, types, fn, data, selector );
+ });
+ },
+ one: function( types, selector, data, fn ) {
+ return this.on.call( this, types, selector, data, fn, 1 );
+ },
+ off: function( types, selector, fn ) {
+ if ( types && types.preventDefault && types.handleObj ) {
+ // ( event ) dispatched jQuery.Event
+ var handleObj = types.handleObj;
+ jQuery( types.delegateTarget ).off(
+ handleObj.namespace? handleObj.type + "." + handleObj.namespace : handleObj.type,
+ handleObj.selector,
+ handleObj.handler
+ );
+ return this;
+ }
+ if ( typeof types === "object" ) {
+ // ( types-object [, selector] )
+ for ( var type in types ) {
+ this.off( type, selector, types[ type ] );
+ }
+ return this;
+ }
+ if ( selector === false || typeof selector === "function" ) {
+ // ( types [, fn] )
+ fn = selector;
+ selector = undefined;
+ }
+ if ( fn === false ) {
+ fn = returnFalse;
+ }
+ return this.each(function() {
+ jQuery.event.remove( this, types, fn, selector );
+ });
+ },
+
+ bind: function( types, data, fn ) {
+ return this.on( types, null, data, fn );
+ },
+ unbind: function( types, fn ) {
+ return this.off( types, null, fn );
+ },
+
+ live: function( types, data, fn ) {
+ jQuery( this.context ).on( types, this.selector, data, fn );
+ return this;
+ },
+ die: function( types, fn ) {
+ jQuery( this.context ).off( types, this.selector || "**", fn );
+ return this;
+ },
+
+ delegate: function( selector, types, data, fn ) {
+ return this.on( types, selector, data, fn );
+ },
+ undelegate: function( selector, types, fn ) {
+ // ( namespace ) or ( selector, types [, fn] )
+ return arguments.length == 1? this.off( selector, "**" ) : this.off( types, selector, fn );
+ },
+
+ trigger: function( type, data ) {
+ return this.each(function() {
+ jQuery.event.trigger( type, data, this );
+ });
+ },
+ triggerHandler: function( type, data ) {
+ if ( this[0] ) {
+ return jQuery.event.trigger( type, data, this[0], true );
+ }
+ },
+
+ toggle: function( fn ) {
+ // Save reference to arguments for access in closure
+ var args = arguments,
+ guid = fn.guid || jQuery.guid++,
+ i = 0,
+ toggler = function( event ) {
+ // Figure out which function to execute
+ var lastToggle = ( jQuery._data( this, "lastToggle" + fn.guid ) || 0 ) % i;
+ jQuery._data( this, "lastToggle" + fn.guid, lastToggle + 1 );
+
+ // Make sure that clicks stop
+ event.preventDefault();
+
+ // and execute the function
+ return args[ lastToggle ].apply( this, arguments ) || false;
+ };
+
+ // link all the functions, so any of them can unbind this click handler
+ toggler.guid = guid;
+ while ( i < args.length ) {
+ args[ i++ ].guid = guid;
+ }
+
+ return this.click( toggler );
+ },
+
+ hover: function( fnOver, fnOut ) {
+ return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver );
+ }
+});
+
+jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblclick " +
+ "mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave " +
+ "change select submit keydown keypress keyup error contextmenu").split(" "), function( i, name ) {
+
+ // Handle event binding
+ jQuery.fn[ name ] = function( data, fn ) {
+ if ( fn == null ) {
+ fn = data;
+ data = null;
+ }
+
+ return arguments.length > 0 ?
+ this.on( name, null, data, fn ) :
+ this.trigger( name );
+ };
+
+ if ( jQuery.attrFn ) {
+ jQuery.attrFn[ name ] = true;
+ }
+
+ if ( rkeyEvent.test( name ) ) {
+ jQuery.event.fixHooks[ name ] = jQuery.event.keyHooks;
+ }
+
+ if ( rmouseEvent.test( name ) ) {
+ jQuery.event.fixHooks[ name ] = jQuery.event.mouseHooks;
+ }
+});
+
+
+
+/*!
+ * Sizzle CSS Selector Engine
+ * Copyright 2016, The Dojo Foundation
+ * Released under the MIT, BSD, and GPL Licenses.
+ * More information: http://sizzlejs.com/
+ */
+(function(){
+
+var chunker = /((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^\[\]]*\]|['"][^'"]*['"]|[^\[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g,
+ expando = "sizcache" + (Math.random() + '').replace('.', ''),
+ done = 0,
+ toString = Object.prototype.toString,
+ hasDuplicate = false,
+ baseHasDuplicate = true,
+ rBackslash = /\\/g,
+ rReturn = /\r\n/g,
+ rNonWord = /\W/;
+
+// Here we check if the JavaScript engine is using some sort of
+// optimization where it does not always call our comparision
+// function. If that is the case, discard the hasDuplicate value.
+// Thus far that includes Google Chrome.
+[0, 0].sort(function() {
+ baseHasDuplicate = false;
+ return 0;
+});
+
+var Sizzle = function( selector, context, results, seed ) {
+ results = results || [];
+ context = context || document;
+
+ var origContext = context;
+
+ if ( context.nodeType !== 1 && context.nodeType !== 9 ) {
+ return [];
+ }
+
+ if ( !selector || typeof selector !== "string" ) {
+ return results;
+ }
+
+ var m, set, checkSet, extra, ret, cur, pop, i,
+ prune = true,
+ contextXML = Sizzle.isXML( context ),
+ parts = [],
+ soFar = selector;
+
+ // Reset the position of the chunker regexp (start from head)
+ do {
+ chunker.exec( "" );
+ m = chunker.exec( soFar );
+
+ if ( m ) {
+ soFar = m[3];
+
+ parts.push( m[1] );
+
+ if ( m[2] ) {
+ extra = m[3];
+ break;
+ }
+ }
+ } while ( m );
+
+ if ( parts.length > 1 && origPOS.exec( selector ) ) {
+
+ if ( parts.length === 2 && Expr.relative[ parts[0] ] ) {
+ set = posProcess( parts[0] + parts[1], context, seed );
+
+ } else {
+ set = Expr.relative[ parts[0] ] ?
+ [ context ] :
+ Sizzle( parts.shift(), context );
+
+ while ( parts.length ) {
+ selector = parts.shift();
+
+ if ( Expr.relative[ selector ] ) {
+ selector += parts.shift();
+ }
+
+ set = posProcess( selector, set, seed );
+ }
+ }
+
+ } else {
+ // Take a shortcut and set the context if the root selector is an ID
+ // (but not if it'll be faster if the inner selector is an ID)
+ if ( !seed && parts.length > 1 && context.nodeType === 9 && !contextXML &&
+ Expr.match.ID.test(parts[0]) && !Expr.match.ID.test(parts[parts.length - 1]) ) {
+
+ ret = Sizzle.find( parts.shift(), context, contextXML );
+ context = ret.expr ?
+ Sizzle.filter( ret.expr, ret.set )[0] :
+ ret.set[0];
+ }
+
+ if ( context ) {
+ ret = seed ?
+ { expr: parts.pop(), set: makeArray(seed) } :
+ Sizzle.find( parts.pop(), parts.length === 1 && (parts[0] === "~" || parts[0] === "+") && context.parentNode ? context.parentNode : context, contextXML );
+
+ set = ret.expr ?
+ Sizzle.filter( ret.expr, ret.set ) :
+ ret.set;
+
+ if ( parts.length > 0 ) {
+ checkSet = makeArray( set );
+
+ } else {
+ prune = false;
+ }
+
+ while ( parts.length ) {
+ cur = parts.pop();
+ pop = cur;
+
+ if ( !Expr.relative[ cur ] ) {
+ cur = "";
+ } else {
+ pop = parts.pop();
+ }
+
+ if ( pop == null ) {
+ pop = context;
+ }
+
+ Expr.relative[ cur ]( checkSet, pop, contextXML );
+ }
+
+ } else {
+ checkSet = parts = [];
+ }
+ }
+
+ if ( !checkSet ) {
+ checkSet = set;
+ }
+
+ if ( !checkSet ) {
+ Sizzle.error( cur || selector );
+ }
+
+ if ( toString.call(checkSet) === "[object Array]" ) {
+ if ( !prune ) {
+ results.push.apply( results, checkSet );
+
+ } else if ( context && context.nodeType === 1 ) {
+ for ( i = 0; checkSet[i] != null; i++ ) {
+ if ( checkSet[i] && (checkSet[i] === true || checkSet[i].nodeType === 1 && Sizzle.contains(context, checkSet[i])) ) {
+ results.push( set[i] );
+ }
+ }
+
+ } else {
+ for ( i = 0; checkSet[i] != null; i++ ) {
+ if ( checkSet[i] && checkSet[i].nodeType === 1 ) {
+ results.push( set[i] );
+ }
+ }
+ }
+
+ } else {
+ makeArray( checkSet, results );
+ }
+
+ if ( extra ) {
+ Sizzle( extra, origContext, results, seed );
+ Sizzle.uniqueSort( results );
+ }
+
+ return results;
+};
+
+Sizzle.uniqueSort = function( results ) {
+ if ( sortOrder ) {
+ hasDuplicate = baseHasDuplicate;
+ results.sort( sortOrder );
+
+ if ( hasDuplicate ) {
+ for ( var i = 1; i < results.length; i++ ) {
+ if ( results[i] === results[ i - 1 ] ) {
+ results.splice( i--, 1 );
+ }
+ }
+ }
+ }
+
+ return results;
+};
+
+Sizzle.matches = function( expr, set ) {
+ return Sizzle( expr, null, null, set );
+};
+
+Sizzle.matchesSelector = function( node, expr ) {
+ return Sizzle( expr, null, null, [node] ).length > 0;
+};
+
+Sizzle.find = function( expr, context, isXML ) {
+ var set, i, len, match, type, left;
+
+ if ( !expr ) {
+ return [];
+ }
+
+ for ( i = 0, len = Expr.order.length; i < len; i++ ) {
+ type = Expr.order[i];
+
+ if ( (match = Expr.leftMatch[ type ].exec( expr )) ) {
+ left = match[1];
+ match.splice( 1, 1 );
+
+ if ( left.substr( left.length - 1 ) !== "\\" ) {
+ match[1] = (match[1] || "").replace( rBackslash, "" );
+ set = Expr.find[ type ]( match, context, isXML );
+
+ if ( set != null ) {
+ expr = expr.replace( Expr.match[ type ], "" );
+ break;
+ }
+ }
+ }
+ }
+
+ if ( !set ) {
+ set = typeof context.getElementsByTagName !== "undefined" ?
+ context.getElementsByTagName( "*" ) :
+ [];
+ }
+
+ return { set: set, expr: expr };
+};
+
+Sizzle.filter = function( expr, set, inplace, not ) {
+ var match, anyFound,
+ type, found, item, filter, left,
+ i, pass,
+ old = expr,
+ result = [],
+ curLoop = set,
+ isXMLFilter = set && set[0] && Sizzle.isXML( set[0] );
+
+ while ( expr && set.length ) {
+ for ( type in Expr.filter ) {
+ if ( (match = Expr.leftMatch[ type ].exec( expr )) != null && match[2] ) {
+ filter = Expr.filter[ type ];
+ left = match[1];
+
+ anyFound = false;
+
+ match.splice(1,1);
+
+ if ( left.substr( left.length - 1 ) === "\\" ) {
+ continue;
+ }
+
+ if ( curLoop === result ) {
+ result = [];
+ }
+
+ if ( Expr.preFilter[ type ] ) {
+ match = Expr.preFilter[ type ]( match, curLoop, inplace, result, not, isXMLFilter );
+
+ if ( !match ) {
+ anyFound = found = true;
+
+ } else if ( match === true ) {
+ continue;
+ }
+ }
+
+ if ( match ) {
+ for ( i = 0; (item = curLoop[i]) != null; i++ ) {
+ if ( item ) {
+ found = filter( item, match, i, curLoop );
+ pass = not ^ found;
+
+ if ( inplace && found != null ) {
+ if ( pass ) {
+ anyFound = true;
+
+ } else {
+ curLoop[i] = false;
+ }
+
+ } else if ( pass ) {
+ result.push( item );
+ anyFound = true;
+ }
+ }
+ }
+ }
+
+ if ( found !== undefined ) {
+ if ( !inplace ) {
+ curLoop = result;
+ }
+
+ expr = expr.replace( Expr.match[ type ], "" );
+
+ if ( !anyFound ) {
+ return [];
+ }
+
+ break;
+ }
+ }
+ }
+
+ // Improper expression
+ if ( expr === old ) {
+ if ( anyFound == null ) {
+ Sizzle.error( expr );
+
+ } else {
+ break;
+ }
+ }
+
+ old = expr;
+ }
+
+ return curLoop;
+};
+
+Sizzle.error = function( msg ) {
+ throw new Error( "Syntax error, unrecognized expression: " + msg );
+};
+
+/**
+ * Utility function for retreiving the text value of an array of DOM nodes
+ * @param {Array|Element} elem
+ */
+var getText = Sizzle.getText = function( elem ) {
+ var i, node,
+ nodeType = elem.nodeType,
+ ret = "";
+
+ if ( nodeType ) {
+ if ( nodeType === 1 || nodeType === 9 ) {
+ // Use textContent || innerText for elements
+ if ( typeof elem.textContent === 'string' ) {
+ return elem.textContent;
+ } else if ( typeof elem.innerText === 'string' ) {
+ // Replace IE's carriage returns
+ return elem.innerText.replace( rReturn, '' );
+ } else {
+ // Traverse it's children
+ for ( elem = elem.firstChild; elem; elem = elem.nextSibling) {
+ ret += getText( elem );
+ }
+ }
+ } else if ( nodeType === 3 || nodeType === 4 ) {
+ return elem.nodeValue;
+ }
+ } else {
+
+ // If no nodeType, this is expected to be an array
+ for ( i = 0; (node = elem[i]); i++ ) {
+ // Do not traverse comment nodes
+ if ( node.nodeType !== 8 ) {
+ ret += getText( node );
+ }
+ }
+ }
+ return ret;
+};
+
+var Expr = Sizzle.selectors = {
+ order: [ "ID", "NAME", "TAG" ],
+
+ match: {
+ ID: /#((?:[\w\u00c0-\uFFFF\-]|\\.)+)/,
+ CLASS: /\.((?:[\w\u00c0-\uFFFF\-]|\\.)+)/,
+ NAME: /\[name=['"]*((?:[\w\u00c0-\uFFFF\-]|\\.)+)['"]*\]/,
+ ATTR: /\[\s*((?:[\w\u00c0-\uFFFF\-]|\\.)+)\s*(?:(\S?=)\s*(?:(['"])(.*?)\3|(#?(?:[\w\u00c0-\uFFFF\-]|\\.)*)|)|)\s*\]/,
+ TAG: /^((?:[\w\u00c0-\uFFFF\*\-]|\\.)+)/,
+ CHILD: /:(only|nth|last|first)-child(?:\(\s*(even|odd|(?:[+\-]?\d+|(?:[+\-]?\d*)?n\s*(?:[+\-]\s*\d+)?))\s*\))?/,
+ POS: /:(nth|eq|gt|lt|first|last|even|odd)(?:\((\d*)\))?(?=[^\-]|$)/,
+ PSEUDO: /:((?:[\w\u00c0-\uFFFF\-]|\\.)+)(?:\((['"]?)((?:\([^\)]+\)|[^\(\)]*)+)\2\))?/
+ },
+
+ leftMatch: {},
+
+ attrMap: {
+ "class": "className",
+ "for": "htmlFor"
+ },
+
+ attrHandle: {
+ href: function( elem ) {
+ return elem.getAttribute( "href" );
+ },
+ type: function( elem ) {
+ return elem.getAttribute( "type" );
+ }
+ },
+
+ relative: {
+ "+": function(checkSet, part){
+ var isPartStr = typeof part === "string",
+ isTag = isPartStr && !rNonWord.test( part ),
+ isPartStrNotTag = isPartStr && !isTag;
+
+ if ( isTag ) {
+ part = part.toLowerCase();
+ }
+
+ for ( var i = 0, l = checkSet.length, elem; i < l; i++ ) {
+ if ( (elem = checkSet[i]) ) {
+ while ( (elem = elem.previousSibling) && elem.nodeType !== 1 ) {}
+
+ checkSet[i] = isPartStrNotTag || elem && elem.nodeName.toLowerCase() === part ?
+ elem || false :
+ elem === part;
+ }
+ }
+
+ if ( isPartStrNotTag ) {
+ Sizzle.filter( part, checkSet, true );
+ }
+ },
+
+ ">": function( checkSet, part ) {
+ var elem,
+ isPartStr = typeof part === "string",
+ i = 0,
+ l = checkSet.length;
+
+ if ( isPartStr && !rNonWord.test( part ) ) {
+ part = part.toLowerCase();
+
+ for ( ; i < l; i++ ) {
+ elem = checkSet[i];
+
+ if ( elem ) {
+ var parent = elem.parentNode;
+ checkSet[i] = parent.nodeName.toLowerCase() === part ? parent : false;
+ }
+ }
+
+ } else {
+ for ( ; i < l; i++ ) {
+ elem = checkSet[i];
+
+ if ( elem ) {
+ checkSet[i] = isPartStr ?
+ elem.parentNode :
+ elem.parentNode === part;
+ }
+ }
+
+ if ( isPartStr ) {
+ Sizzle.filter( part, checkSet, true );
+ }
+ }
+ },
+
+ "": function(checkSet, part, isXML){
+ var nodeCheck,
+ doneName = done++,
+ checkFn = dirCheck;
+
+ if ( typeof part === "string" && !rNonWord.test( part ) ) {
+ part = part.toLowerCase();
+ nodeCheck = part;
+ checkFn = dirNodeCheck;
+ }
+
+ checkFn( "parentNode", part, doneName, checkSet, nodeCheck, isXML );
+ },
+
+ "~": function( checkSet, part, isXML ) {
+ var nodeCheck,
+ doneName = done++,
+ checkFn = dirCheck;
+
+ if ( typeof part === "string" && !rNonWord.test( part ) ) {
+ part = part.toLowerCase();
+ nodeCheck = part;
+ checkFn = dirNodeCheck;
+ }
+
+ checkFn( "previousSibling", part, doneName, checkSet, nodeCheck, isXML );
+ }
+ },
+
+ find: {
+ ID: function( match, context, isXML ) {
+ if ( typeof context.getElementById !== "undefined" && !isXML ) {
+ var m = context.getElementById(match[1]);
+ // Check parentNode to catch when Blackberry 4.6 returns
+ // nodes that are no longer in the document #6963
+ return m && m.parentNode ? [m] : [];
+ }
+ },
+
+ NAME: function( match, context ) {
+ if ( typeof context.getElementsByName !== "undefined" ) {
+ var ret = [],
+ results = context.getElementsByName( match[1] );
+
+ for ( var i = 0, l = results.length; i < l; i++ ) {
+ if ( results[i].getAttribute("name") === match[1] ) {
+ ret.push( results[i] );
+ }
+ }
+
+ return ret.length === 0 ? null : ret;
+ }
+ },
+
+ TAG: function( match, context ) {
+ if ( typeof context.getElementsByTagName !== "undefined" ) {
+ return context.getElementsByTagName( match[1] );
+ }
+ }
+ },
+ preFilter: {
+ CLASS: function( match, curLoop, inplace, result, not, isXML ) {
+ match = " " + match[1].replace( rBackslash, "" ) + " ";
+
+ if ( isXML ) {
+ return match;
+ }
+
+ for ( var i = 0, elem; (elem = curLoop[i]) != null; i++ ) {
+ if ( elem ) {
+ if ( not ^ (elem.className && (" " + elem.className + " ").replace(/[\t\n\r]/g, " ").indexOf(match) >= 0) ) {
+ if ( !inplace ) {
+ result.push( elem );
+ }
+
+ } else if ( inplace ) {
+ curLoop[i] = false;
+ }
+ }
+ }
+
+ return false;
+ },
+
+ ID: function( match ) {
+ return match[1].replace( rBackslash, "" );
+ },
+
+ TAG: function( match, curLoop ) {
+ return match[1].replace( rBackslash, "" ).toLowerCase();
+ },
+
+ CHILD: function( match ) {
+ if ( match[1] === "nth" ) {
+ if ( !match[2] ) {
+ Sizzle.error( match[0] );
+ }
+
+ match[2] = match[2].replace(/^\+|\s*/g, '');
+
+ // parse equations like 'even', 'odd', '5', '2n', '3n+2', '4n-1', '-n+6'
+ var test = /(-?)(\d*)(?:n([+\-]?\d*))?/.exec(
+ match[2] === "even" && "2n" || match[2] === "odd" && "2n+1" ||
+ !/\D/.test( match[2] ) && "0n+" + match[2] || match[2]);
+
+ // calculate the numbers (first)n+(last) including if they are negative
+ match[2] = (test[1] + (test[2] || 1)) - 0;
+ match[3] = test[3] - 0;
+ }
+ else if ( match[2] ) {
+ Sizzle.error( match[0] );
+ }
+
+ // TODO: Move to normal caching system
+ match[0] = done++;
+
+ return match;
+ },
+
+ ATTR: function( match, curLoop, inplace, result, not, isXML ) {
+ var name = match[1] = match[1].replace( rBackslash, "" );
+
+ if ( !isXML && Expr.attrMap[name] ) {
+ match[1] = Expr.attrMap[name];
+ }
+
+ // Handle if an un-quoted value was used
+ match[4] = ( match[4] || match[5] || "" ).replace( rBackslash, "" );
+
+ if ( match[2] === "~=" ) {
+ match[4] = " " + match[4] + " ";
+ }
+
+ return match;
+ },
+
+ PSEUDO: function( match, curLoop, inplace, result, not ) {
+ if ( match[1] === "not" ) {
+ // If we're dealing with a complex expression, or a simple one
+ if ( ( chunker.exec(match[3]) || "" ).length > 1 || /^\w/.test(match[3]) ) {
+ match[3] = Sizzle(match[3], null, null, curLoop);
+
+ } else {
+ var ret = Sizzle.filter(match[3], curLoop, inplace, true ^ not);
+
+ if ( !inplace ) {
+ result.push.apply( result, ret );
+ }
+
+ return false;
+ }
+
+ } else if ( Expr.match.POS.test( match[0] ) || Expr.match.CHILD.test( match[0] ) ) {
+ return true;
+ }
+
+ return match;
+ },
+
+ POS: function( match ) {
+ match.unshift( true );
+
+ return match;
+ }
+ },
+
+ filters: {
+ enabled: function( elem ) {
+ return elem.disabled === false && elem.type !== "hidden";
+ },
+
+ disabled: function( elem ) {
+ return elem.disabled === true;
+ },
+
+ checked: function( elem ) {
+ return elem.checked === true;
+ },
+
+ selected: function( elem ) {
+ // Accessing this property makes selected-by-default
+ // options in Safari work properly
+ if ( elem.parentNode ) {
+ elem.parentNode.selectedIndex;
+ }
+
+ return elem.selected === true;
+ },
+
+ parent: function( elem ) {
+ return !!elem.firstChild;
+ },
+
+ empty: function( elem ) {
+ return !elem.firstChild;
+ },
+
+ has: function( elem, i, match ) {
+ return !!Sizzle( match[3], elem ).length;
+ },
+
+ header: function( elem ) {
+ return (/h\d/i).test( elem.nodeName );
+ },
+
+ text: function( elem ) {
+ var attr = elem.getAttribute( "type" ), type = elem.type;
+ // IE6 and 7 will map elem.type to 'text' for new HTML5 types (search, etc)
+ // use getAttribute instead to test this case
+ return elem.nodeName.toLowerCase() === "input" && "text" === type && ( attr === type || attr === null );
+ },
+
+ radio: function( elem ) {
+ return elem.nodeName.toLowerCase() === "input" && "radio" === elem.type;
+ },
+
+ checkbox: function( elem ) {
+ return elem.nodeName.toLowerCase() === "input" && "checkbox" === elem.type;
+ },
+
+ file: function( elem ) {
+ return elem.nodeName.toLowerCase() === "input" && "file" === elem.type;
+ },
+
+ password: function( elem ) {
+ return elem.nodeName.toLowerCase() === "input" && "password" === elem.type;
+ },
+
+ submit: function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return (name === "input" || name === "button") && "submit" === elem.type;
+ },
+
+ image: function( elem ) {
+ return elem.nodeName.toLowerCase() === "input" && "image" === elem.type;
+ },
+
+ reset: function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return (name === "input" || name === "button") && "reset" === elem.type;
+ },
+
+ button: function( elem ) {
+ var name = elem.nodeName.toLowerCase();
+ return name === "input" && "button" === elem.type || name === "button";
+ },
+
+ input: function( elem ) {
+ return (/input|select|textarea|button/i).test( elem.nodeName );
+ },
+
+ focus: function( elem ) {
+ return elem === elem.ownerDocument.activeElement;
+ }
+ },
+ setFilters: {
+ first: function( elem, i ) {
+ return i === 0;
+ },
+
+ last: function( elem, i, match, array ) {
+ return i === array.length - 1;
+ },
+
+ even: function( elem, i ) {
+ return i % 2 === 0;
+ },
+
+ odd: function( elem, i ) {
+ return i % 2 === 1;
+ },
+
+ lt: function( elem, i, match ) {
+ return i < match[3] - 0;
+ },
+
+ gt: function( elem, i, match ) {
+ return i > match[3] - 0;
+ },
+
+ nth: function( elem, i, match ) {
+ return match[3] - 0 === i;
+ },
+
+ eq: function( elem, i, match ) {
+ return match[3] - 0 === i;
+ }
+ },
+ filter: {
+ PSEUDO: function( elem, match, i, array ) {
+ var name = match[1],
+ filter = Expr.filters[ name ];
+
+ if ( filter ) {
+ return filter( elem, i, match, array );
+
+ } else if ( name === "contains" ) {
+ return (elem.textContent || elem.innerText || getText([ elem ]) || "").indexOf(match[3]) >= 0;
+
+ } else if ( name === "not" ) {
+ var not = match[3];
+
+ for ( var j = 0, l = not.length; j < l; j++ ) {
+ if ( not[j] === elem ) {
+ return false;
+ }
+ }
+
+ return true;
+
+ } else {
+ Sizzle.error( name );
+ }
+ },
+
+ CHILD: function( elem, match ) {
+ var first, last,
+ doneName, parent, cache,
+ count, diff,
+ type = match[1],
+ node = elem;
+
+ switch ( type ) {
+ case "only":
+ case "first":
+ while ( (node = node.previousSibling) ) {
+ if ( node.nodeType === 1 ) {
+ return false;
+ }
+ }
+
+ if ( type === "first" ) {
+ return true;
+ }
+
+ node = elem;
+
+ case "last":
+ while ( (node = node.nextSibling) ) {
+ if ( node.nodeType === 1 ) {
+ return false;
+ }
+ }
+
+ return true;
+
+ case "nth":
+ first = match[2];
+ last = match[3];
+
+ if ( first === 1 && last === 0 ) {
+ return true;
+ }
+
+ doneName = match[0];
+ parent = elem.parentNode;
+
+ if ( parent && (parent[ expando ] !== doneName || !elem.nodeIndex) ) {
+ count = 0;
+
+ for ( node = parent.firstChild; node; node = node.nextSibling ) {
+ if ( node.nodeType === 1 ) {
+ node.nodeIndex = ++count;
+ }
+ }
+
+ parent[ expando ] = doneName;
+ }
+
+ diff = elem.nodeIndex - last;
+
+ if ( first === 0 ) {
+ return diff === 0;
+
+ } else {
+ return ( diff % first === 0 && diff / first >= 0 );
+ }
+ }
+ },
+
+ ID: function( elem, match ) {
+ return elem.nodeType === 1 && elem.getAttribute("id") === match;
+ },
+
+ TAG: function( elem, match ) {
+ return (match === "*" && elem.nodeType === 1) || !!elem.nodeName && elem.nodeName.toLowerCase() === match;
+ },
+
+ CLASS: function( elem, match ) {
+ return (" " + (elem.className || elem.getAttribute("class")) + " ")
+ .indexOf( match ) > -1;
+ },
+
+ ATTR: function( elem, match ) {
+ var name = match[1],
+ result = Sizzle.attr ?
+ Sizzle.attr( elem, name ) :
+ Expr.attrHandle[ name ] ?
+ Expr.attrHandle[ name ]( elem ) :
+ elem[ name ] != null ?
+ elem[ name ] :
+ elem.getAttribute( name ),
+ value = result + "",
+ type = match[2],
+ check = match[4];
+
+ return result == null ?
+ type === "!=" :
+ !type && Sizzle.attr ?
+ result != null :
+ type === "=" ?
+ value === check :
+ type === "*=" ?
+ value.indexOf(check) >= 0 :
+ type === "~=" ?
+ (" " + value + " ").indexOf(check) >= 0 :
+ !check ?
+ value && result !== false :
+ type === "!=" ?
+ value !== check :
+ type === "^=" ?
+ value.indexOf(check) === 0 :
+ type === "$=" ?
+ value.substr(value.length - check.length) === check :
+ type === "|=" ?
+ value === check || value.substr(0, check.length + 1) === check + "-" :
+ false;
+ },
+
+ POS: function( elem, match, i, array ) {
+ var name = match[2],
+ filter = Expr.setFilters[ name ];
+
+ if ( filter ) {
+ return filter( elem, i, match, array );
+ }
+ }
+ }
+};
+
+var origPOS = Expr.match.POS,
+ fescape = function(all, num){
+ return "\\" + (num - 0 + 1);
+ };
+
+for ( var type in Expr.match ) {
+ Expr.match[ type ] = new RegExp( Expr.match[ type ].source + (/(?![^\[]*\])(?![^\(]*\))/.source) );
+ Expr.leftMatch[ type ] = new RegExp( /(^(?:.|\r|\n)*?)/.source + Expr.match[ type ].source.replace(/\\(\d+)/g, fescape) );
+}
+
+var makeArray = function( array, results ) {
+ array = Array.prototype.slice.call( array, 0 );
+
+ if ( results ) {
+ results.push.apply( results, array );
+ return results;
+ }
+
+ return array;
+};
+
+// Perform a simple check to determine if the browser is capable of
+// converting a NodeList to an array using builtin methods.
+// Also verifies that the returned array holds DOM nodes
+// (which is not the case in the Blackberry browser)
+try {
+ Array.prototype.slice.call( document.documentElement.childNodes, 0 )[0].nodeType;
+
+// Provide a fallback method if it does not work
+} catch( e ) {
+ makeArray = function( array, results ) {
+ var i = 0,
+ ret = results || [];
+
+ if ( toString.call(array) === "[object Array]" ) {
+ Array.prototype.push.apply( ret, array );
+
+ } else {
+ if ( typeof array.length === "number" ) {
+ for ( var l = array.length; i < l; i++ ) {
+ ret.push( array[i] );
+ }
+
+ } else {
+ for ( ; array[i]; i++ ) {
+ ret.push( array[i] );
+ }
+ }
+ }
+
+ return ret;
+ };
+}
+
+var sortOrder, siblingCheck;
+
+if ( document.documentElement.compareDocumentPosition ) {
+ sortOrder = function( a, b ) {
+ if ( a === b ) {
+ hasDuplicate = true;
+ return 0;
+ }
+
+ if ( !a.compareDocumentPosition || !b.compareDocumentPosition ) {
+ return a.compareDocumentPosition ? -1 : 1;
+ }
+
+ return a.compareDocumentPosition(b) & 4 ? -1 : 1;
+ };
+
+} else {
+ sortOrder = function( a, b ) {
+ // The nodes are identical, we can exit early
+ if ( a === b ) {
+ hasDuplicate = true;
+ return 0;
+
+ // Fallback to using sourceIndex (in IE) if it's available on both nodes
+ } else if ( a.sourceIndex && b.sourceIndex ) {
+ return a.sourceIndex - b.sourceIndex;
+ }
+
+ var al, bl,
+ ap = [],
+ bp = [],
+ aup = a.parentNode,
+ bup = b.parentNode,
+ cur = aup;
+
+ // If the nodes are siblings (or identical) we can do a quick check
+ if ( aup === bup ) {
+ return siblingCheck( a, b );
+
+ // If no parents were found then the nodes are disconnected
+ } else if ( !aup ) {
+ return -1;
+
+ } else if ( !bup ) {
+ return 1;
+ }
+
+ // Otherwise they're somewhere else in the tree so we need
+ // to build up a full list of the parentNodes for comparison
+ while ( cur ) {
+ ap.unshift( cur );
+ cur = cur.parentNode;
+ }
+
+ cur = bup;
+
+ while ( cur ) {
+ bp.unshift( cur );
+ cur = cur.parentNode;
+ }
+
+ al = ap.length;
+ bl = bp.length;
+
+ // Start walking down the tree looking for a discrepancy
+ for ( var i = 0; i < al && i < bl; i++ ) {
+ if ( ap[i] !== bp[i] ) {
+ return siblingCheck( ap[i], bp[i] );
+ }
+ }
+
+ // We ended someplace up the tree so do a sibling check
+ return i === al ?
+ siblingCheck( a, bp[i], -1 ) :
+ siblingCheck( ap[i], b, 1 );
+ };
+
+ siblingCheck = function( a, b, ret ) {
+ if ( a === b ) {
+ return ret;
+ }
+
+ var cur = a.nextSibling;
+
+ while ( cur ) {
+ if ( cur === b ) {
+ return -1;
+ }
+
+ cur = cur.nextSibling;
+ }
+
+ return 1;
+ };
+}
+
+// Check to see if the browser returns elements by name when
+// querying by getElementById (and provide a workaround)
+(function(){
+ // We're going to inject a fake input element with a specified name
+ var form = document.createElement("div"),
+ id = "script" + (new Date()).getTime(),
+ root = document.documentElement;
+
+ form.innerHTML = "";
+
+ // Inject it into the root element, check its status, and remove it quickly
+ root.insertBefore( form, root.firstChild );
+
+ // The workaround has to do additional checks after a getElementById
+ // Which slows things down for other browsers (hence the branching)
+ if ( document.getElementById( id ) ) {
+ Expr.find.ID = function( match, context, isXML ) {
+ if ( typeof context.getElementById !== "undefined" && !isXML ) {
+ var m = context.getElementById(match[1]);
+
+ return m ?
+ m.id === match[1] || typeof m.getAttributeNode !== "undefined" && m.getAttributeNode("id").nodeValue === match[1] ?
+ [m] :
+ undefined :
+ [];
+ }
+ };
+
+ Expr.filter.ID = function( elem, match ) {
+ var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id");
+
+ return elem.nodeType === 1 && node && node.nodeValue === match;
+ };
+ }
+
+ root.removeChild( form );
+
+ // release memory in IE
+ root = form = null;
+})();
+
+(function(){
+ // Check to see if the browser returns only elements
+ // when doing getElementsByTagName("*")
+
+ // Create a fake element
+ var div = document.createElement("div");
+ div.appendChild( document.createComment("") );
+
+ // Make sure no comments are found
+ if ( div.getElementsByTagName("*").length > 0 ) {
+ Expr.find.TAG = function( match, context ) {
+ var results = context.getElementsByTagName( match[1] );
+
+ // Filter out possible comments
+ if ( match[1] === "*" ) {
+ var tmp = [];
+
+ for ( var i = 0; results[i]; i++ ) {
+ if ( results[i].nodeType === 1 ) {
+ tmp.push( results[i] );
+ }
+ }
+
+ results = tmp;
+ }
+
+ return results;
+ };
+ }
+
+ // Check to see if an attribute returns normalized href attributes
+ div.innerHTML = "";
+
+ if ( div.firstChild && typeof div.firstChild.getAttribute !== "undefined" &&
+ div.firstChild.getAttribute("href") !== "#" ) {
+
+ Expr.attrHandle.href = function( elem ) {
+ return elem.getAttribute( "href", 2 );
+ };
+ }
+
+ // release memory in IE
+ div = null;
+})();
+
+if ( document.querySelectorAll ) {
+ (function(){
+ var oldSizzle = Sizzle,
+ div = document.createElement("div"),
+ id = "__sizzle__";
+
+ div.innerHTML = "";
+
+ // Safari can't handle uppercase or unicode characters when
+ // in quirks mode.
+ if ( div.querySelectorAll && div.querySelectorAll(".TEST").length === 0 ) {
+ return;
+ }
+
+ Sizzle = function( query, context, extra, seed ) {
+ context = context || document;
+
+ // Only use querySelectorAll on non-XML documents
+ // (ID selectors don't work in non-HTML documents)
+ if ( !seed && !Sizzle.isXML(context) ) {
+ // See if we find a selector to speed up
+ var match = /^(\w+$)|^\.([\w\-]+$)|^#([\w\-]+$)/.exec( query );
+
+ if ( match && (context.nodeType === 1 || context.nodeType === 9) ) {
+ // Speed-up: Sizzle("TAG")
+ if ( match[1] ) {
+ return makeArray( context.getElementsByTagName( query ), extra );
+
+ // Speed-up: Sizzle(".CLASS")
+ } else if ( match[2] && Expr.find.CLASS && context.getElementsByClassName ) {
+ return makeArray( context.getElementsByClassName( match[2] ), extra );
+ }
+ }
+
+ if ( context.nodeType === 9 ) {
+ // Speed-up: Sizzle("body")
+ // The body element only exists once, optimize finding it
+ if ( query === "body" && context.body ) {
+ return makeArray( [ context.body ], extra );
+
+ // Speed-up: Sizzle("#ID")
+ } else if ( match && match[3] ) {
+ var elem = context.getElementById( match[3] );
+
+ // Check parentNode to catch when Blackberry 4.6 returns
+ // nodes that are no longer in the document #6963
+ if ( elem && elem.parentNode ) {
+ // Handle the case where IE and Opera return items
+ // by name instead of ID
+ if ( elem.id === match[3] ) {
+ return makeArray( [ elem ], extra );
+ }
+
+ } else {
+ return makeArray( [], extra );
+ }
+ }
+
+ try {
+ return makeArray( context.querySelectorAll(query), extra );
+ } catch(qsaError) {}
+
+ // qSA works strangely on Element-rooted queries
+ // We can work around this by specifying an extra ID on the root
+ // and working up from there (Thanks to Andrew Dupont for the technique)
+ // IE 8 doesn't work on object elements
+ } else if ( context.nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) {
+ var oldContext = context,
+ old = context.getAttribute( "id" ),
+ nid = old || id,
+ hasParent = context.parentNode,
+ relativeHierarchySelector = /^\s*[+~]/.test( query );
+
+ if ( !old ) {
+ context.setAttribute( "id", nid );
+ } else {
+ nid = nid.replace( /'/g, "\\$&" );
+ }
+ if ( relativeHierarchySelector && hasParent ) {
+ context = context.parentNode;
+ }
+
+ try {
+ if ( !relativeHierarchySelector || hasParent ) {
+ return makeArray( context.querySelectorAll( "[id='" + nid + "'] " + query ), extra );
+ }
+
+ } catch(pseudoError) {
+ } finally {
+ if ( !old ) {
+ oldContext.removeAttribute( "id" );
+ }
+ }
+ }
+ }
+
+ return oldSizzle(query, context, extra, seed);
+ };
+
+ for ( var prop in oldSizzle ) {
+ Sizzle[ prop ] = oldSizzle[ prop ];
+ }
+
+ // release memory in IE
+ div = null;
+ })();
+}
+
+(function(){
+ var html = document.documentElement,
+ matches = html.matchesSelector || html.mozMatchesSelector || html.webkitMatchesSelector || html.msMatchesSelector;
+
+ if ( matches ) {
+ // Check to see if it's possible to do matchesSelector
+ // on a disconnected node (IE 9 fails this)
+ var disconnectedMatch = !matches.call( document.createElement( "div" ), "div" ),
+ pseudoWorks = false;
+
+ try {
+ // This should fail with an exception
+ // Gecko does not error, returns false instead
+ matches.call( document.documentElement, "[test!='']:sizzle" );
+
+ } catch( pseudoError ) {
+ pseudoWorks = true;
+ }
+
+ Sizzle.matchesSelector = function( node, expr ) {
+ // Make sure that attribute selectors are quoted
+ expr = expr.replace(/\=\s*([^'"\]]*)\s*\]/g, "='$1']");
+
+ if ( !Sizzle.isXML( node ) ) {
+ try {
+ if ( pseudoWorks || !Expr.match.PSEUDO.test( expr ) && !/!=/.test( expr ) ) {
+ var ret = matches.call( node, expr );
+
+ // IE 9's matchesSelector returns false on disconnected nodes
+ if ( ret || !disconnectedMatch ||
+ // As well, disconnected nodes are said to be in a document
+ // fragment in IE 9, so check for that
+ node.document && node.document.nodeType !== 11 ) {
+ return ret;
+ }
+ }
+ } catch(e) {}
+ }
+
+ return Sizzle(expr, null, null, [node]).length > 0;
+ };
+ }
+})();
+
+(function(){
+ var div = document.createElement("div");
+
+ div.innerHTML = "";
+
+ // Opera can't find a second classname (in 9.6)
+ // Also, make sure that getElementsByClassName actually exists
+ if ( !div.getElementsByClassName || div.getElementsByClassName("e").length === 0 ) {
+ return;
+ }
+
+ // Safari caches class attributes, doesn't catch changes (in 3.2)
+ div.lastChild.className = "e";
+
+ if ( div.getElementsByClassName("e").length === 1 ) {
+ return;
+ }
+
+ Expr.order.splice(1, 0, "CLASS");
+ Expr.find.CLASS = function( match, context, isXML ) {
+ if ( typeof context.getElementsByClassName !== "undefined" && !isXML ) {
+ return context.getElementsByClassName(match[1]);
+ }
+ };
+
+ // release memory in IE
+ div = null;
+})();
+
+function dirNodeCheck( dir, cur, doneName, checkSet, nodeCheck, isXML ) {
+ for ( var i = 0, l = checkSet.length; i < l; i++ ) {
+ var elem = checkSet[i];
+
+ if ( elem ) {
+ var match = false;
+
+ elem = elem[dir];
+
+ while ( elem ) {
+ if ( elem[ expando ] === doneName ) {
+ match = checkSet[elem.sizset];
+ break;
+ }
+
+ if ( elem.nodeType === 1 && !isXML ){
+ elem[ expando ] = doneName;
+ elem.sizset = i;
+ }
+
+ if ( elem.nodeName.toLowerCase() === cur ) {
+ match = elem;
+ break;
+ }
+
+ elem = elem[dir];
+ }
+
+ checkSet[i] = match;
+ }
+ }
+}
+
+function dirCheck( dir, cur, doneName, checkSet, nodeCheck, isXML ) {
+ for ( var i = 0, l = checkSet.length; i < l; i++ ) {
+ var elem = checkSet[i];
+
+ if ( elem ) {
+ var match = false;
+
+ elem = elem[dir];
+
+ while ( elem ) {
+ if ( elem[ expando ] === doneName ) {
+ match = checkSet[elem.sizset];
+ break;
+ }
+
+ if ( elem.nodeType === 1 ) {
+ if ( !isXML ) {
+ elem[ expando ] = doneName;
+ elem.sizset = i;
+ }
+
+ if ( typeof cur !== "string" ) {
+ if ( elem === cur ) {
+ match = true;
+ break;
+ }
+
+ } else if ( Sizzle.filter( cur, [elem] ).length > 0 ) {
+ match = elem;
+ break;
+ }
+ }
+
+ elem = elem[dir];
+ }
+
+ checkSet[i] = match;
+ }
+ }
+}
+
+if ( document.documentElement.contains ) {
+ Sizzle.contains = function( a, b ) {
+ return a !== b && (a.contains ? a.contains(b) : true);
+ };
+
+} else if ( document.documentElement.compareDocumentPosition ) {
+ Sizzle.contains = function( a, b ) {
+ return !!(a.compareDocumentPosition(b) & 16);
+ };
+
+} else {
+ Sizzle.contains = function() {
+ return false;
+ };
+}
+
+Sizzle.isXML = function( elem ) {
+ // documentElement is verified for cases where it doesn't yet exist
+ // (such as loading iframes in IE - #4833)
+ var documentElement = (elem ? elem.ownerDocument || elem : 0).documentElement;
+
+ return documentElement ? documentElement.nodeName !== "HTML" : false;
+};
+
+var posProcess = function( selector, context, seed ) {
+ var match,
+ tmpSet = [],
+ later = "",
+ root = context.nodeType ? [context] : context;
+
+ // Position selectors must be done after the filter
+ // And so must :not(positional) so we move all PSEUDOs to the end
+ while ( (match = Expr.match.PSEUDO.exec( selector )) ) {
+ later += match[0];
+ selector = selector.replace( Expr.match.PSEUDO, "" );
+ }
+
+ selector = Expr.relative[selector] ? selector + "*" : selector;
+
+ for ( var i = 0, l = root.length; i < l; i++ ) {
+ Sizzle( selector, root[i], tmpSet, seed );
+ }
+
+ return Sizzle.filter( later, tmpSet );
+};
+
+// EXPOSE
+// Override sizzle attribute retrieval
+Sizzle.attr = jQuery.attr;
+Sizzle.selectors.attrMap = {};
+jQuery.find = Sizzle;
+jQuery.expr = Sizzle.selectors;
+jQuery.expr[":"] = jQuery.expr.filters;
+jQuery.unique = Sizzle.uniqueSort;
+jQuery.text = Sizzle.getText;
+jQuery.isXMLDoc = Sizzle.isXML;
+jQuery.contains = Sizzle.contains;
+
+
+})();
+
+
+var runtil = /Until$/,
+ rparentsprev = /^(?:parents|prevUntil|prevAll)/,
+ // Note: This RegExp should be improved, or likely pulled from Sizzle
+ rmultiselector = /,/,
+ isSimple = /^.[^:#\[\.,]*$/,
+ slice = Array.prototype.slice,
+ POS = jQuery.expr.match.POS,
+ // methods guaranteed to produce a unique set when starting from a unique set
+ guaranteedUnique = {
+ children: true,
+ contents: true,
+ next: true,
+ prev: true
+ };
+
+jQuery.fn.extend({
+ find: function( selector ) {
+ var self = this,
+ i, l;
+
+ if ( typeof selector !== "string" ) {
+ return jQuery( selector ).filter(function() {
+ for ( i = 0, l = self.length; i < l; i++ ) {
+ if ( jQuery.contains( self[ i ], this ) ) {
+ return true;
+ }
+ }
+ });
+ }
+
+ var ret = this.pushStack( "", "find", selector ),
+ length, n, r;
+
+ for ( i = 0, l = this.length; i < l; i++ ) {
+ length = ret.length;
+ jQuery.find( selector, this[i], ret );
+
+ if ( i > 0 ) {
+ // Make sure that the results are unique
+ for ( n = length; n < ret.length; n++ ) {
+ for ( r = 0; r < length; r++ ) {
+ if ( ret[r] === ret[n] ) {
+ ret.splice(n--, 1);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ return ret;
+ },
+
+ has: function( target ) {
+ var targets = jQuery( target );
+ return this.filter(function() {
+ for ( var i = 0, l = targets.length; i < l; i++ ) {
+ if ( jQuery.contains( this, targets[i] ) ) {
+ return true;
+ }
+ }
+ });
+ },
+
+ not: function( selector ) {
+ return this.pushStack( winnow(this, selector, false), "not", selector);
+ },
+
+ filter: function( selector ) {
+ return this.pushStack( winnow(this, selector, true), "filter", selector );
+ },
+
+ is: function( selector ) {
+ return !!selector && (
+ typeof selector === "string" ?
+ // If this is a positional selector, check membership in the returned set
+ // so $("p:first").is("p:last") won't return true for a doc with two "p".
+ POS.test( selector ) ?
+ jQuery( selector, this.context ).index( this[0] ) >= 0 :
+ jQuery.filter( selector, this ).length > 0 :
+ this.filter( selector ).length > 0 );
+ },
+
+ closest: function( selectors, context ) {
+ var ret = [], i, l, cur = this[0];
+
+ // Array (deprecated as of jQuery 1.7)
+ if ( jQuery.isArray( selectors ) ) {
+ var level = 1;
+
+ while ( cur && cur.ownerDocument && cur !== context ) {
+ for ( i = 0; i < selectors.length; i++ ) {
+
+ if ( jQuery( cur ).is( selectors[ i ] ) ) {
+ ret.push({ selector: selectors[ i ], elem: cur, level: level });
+ }
+ }
+
+ cur = cur.parentNode;
+ level++;
+ }
+
+ return ret;
+ }
+
+ // String
+ var pos = POS.test( selectors ) || typeof selectors !== "string" ?
+ jQuery( selectors, context || this.context ) :
+ 0;
+
+ for ( i = 0, l = this.length; i < l; i++ ) {
+ cur = this[i];
+
+ while ( cur ) {
+ if ( pos ? pos.index(cur) > -1 : jQuery.find.matchesSelector(cur, selectors) ) {
+ ret.push( cur );
+ break;
+
+ } else {
+ cur = cur.parentNode;
+ if ( !cur || !cur.ownerDocument || cur === context || cur.nodeType === 11 ) {
+ break;
+ }
+ }
+ }
+ }
+
+ ret = ret.length > 1 ? jQuery.unique( ret ) : ret;
+
+ return this.pushStack( ret, "closest", selectors );
+ },
+
+ // Determine the position of an element within
+ // the matched set of elements
+ index: function( elem ) {
+
+ // No argument, return index in parent
+ if ( !elem ) {
+ return ( this[0] && this[0].parentNode ) ? this.prevAll().length : -1;
+ }
+
+ // index in selector
+ if ( typeof elem === "string" ) {
+ return jQuery.inArray( this[0], jQuery( elem ) );
+ }
+
+ // Locate the position of the desired element
+ return jQuery.inArray(
+ // If it receives a jQuery object, the first element is used
+ elem.jquery ? elem[0] : elem, this );
+ },
+
+ add: function( selector, context ) {
+ var set = typeof selector === "string" ?
+ jQuery( selector, context ) :
+ jQuery.makeArray( selector && selector.nodeType ? [ selector ] : selector ),
+ all = jQuery.merge( this.get(), set );
+
+ return this.pushStack( isDisconnected( set[0] ) || isDisconnected( all[0] ) ?
+ all :
+ jQuery.unique( all ) );
+ },
+
+ andSelf: function() {
+ return this.add( this.prevObject );
+ }
+});
+
+// A painfully simple check to see if an element is disconnected
+// from a document (should be improved, where feasible).
+function isDisconnected( node ) {
+ return !node || !node.parentNode || node.parentNode.nodeType === 11;
+}
+
+jQuery.each({
+ parent: function( elem ) {
+ var parent = elem.parentNode;
+ return parent && parent.nodeType !== 11 ? parent : null;
+ },
+ parents: function( elem ) {
+ return jQuery.dir( elem, "parentNode" );
+ },
+ parentsUntil: function( elem, i, until ) {
+ return jQuery.dir( elem, "parentNode", until );
+ },
+ next: function( elem ) {
+ return jQuery.nth( elem, 2, "nextSibling" );
+ },
+ prev: function( elem ) {
+ return jQuery.nth( elem, 2, "previousSibling" );
+ },
+ nextAll: function( elem ) {
+ return jQuery.dir( elem, "nextSibling" );
+ },
+ prevAll: function( elem ) {
+ return jQuery.dir( elem, "previousSibling" );
+ },
+ nextUntil: function( elem, i, until ) {
+ return jQuery.dir( elem, "nextSibling", until );
+ },
+ prevUntil: function( elem, i, until ) {
+ return jQuery.dir( elem, "previousSibling", until );
+ },
+ siblings: function( elem ) {
+ return jQuery.sibling( elem.parentNode.firstChild, elem );
+ },
+ children: function( elem ) {
+ return jQuery.sibling( elem.firstChild );
+ },
+ contents: function( elem ) {
+ return jQuery.nodeName( elem, "iframe" ) ?
+ elem.contentDocument || elem.contentWindow.document :
+ jQuery.makeArray( elem.childNodes );
+ }
+}, function( name, fn ) {
+ jQuery.fn[ name ] = function( until, selector ) {
+ var ret = jQuery.map( this, fn, until );
+
+ if ( !runtil.test( name ) ) {
+ selector = until;
+ }
+
+ if ( selector && typeof selector === "string" ) {
+ ret = jQuery.filter( selector, ret );
+ }
+
+ ret = this.length > 1 && !guaranteedUnique[ name ] ? jQuery.unique( ret ) : ret;
+
+ if ( (this.length > 1 || rmultiselector.test( selector )) && rparentsprev.test( name ) ) {
+ ret = ret.reverse();
+ }
+
+ return this.pushStack( ret, name, slice.call( arguments ).join(",") );
+ };
+});
+
+jQuery.extend({
+ filter: function( expr, elems, not ) {
+ if ( not ) {
+ expr = ":not(" + expr + ")";
+ }
+
+ return elems.length === 1 ?
+ jQuery.find.matchesSelector(elems[0], expr) ? [ elems[0] ] : [] :
+ jQuery.find.matches(expr, elems);
+ },
+
+ dir: function( elem, dir, until ) {
+ var matched = [],
+ cur = elem[ dir ];
+
+ while ( cur && cur.nodeType !== 9 && (until === undefined || cur.nodeType !== 1 || !jQuery( cur ).is( until )) ) {
+ if ( cur.nodeType === 1 ) {
+ matched.push( cur );
+ }
+ cur = cur[dir];
+ }
+ return matched;
+ },
+
+ nth: function( cur, result, dir, elem ) {
+ result = result || 1;
+ var num = 0;
+
+ for ( ; cur; cur = cur[dir] ) {
+ if ( cur.nodeType === 1 && ++num === result ) {
+ break;
+ }
+ }
+
+ return cur;
+ },
+
+ sibling: function( n, elem ) {
+ var r = [];
+
+ for ( ; n; n = n.nextSibling ) {
+ if ( n.nodeType === 1 && n !== elem ) {
+ r.push( n );
+ }
+ }
+
+ return r;
+ }
+});
+
+// Implement the identical functionality for filter and not
+function winnow( elements, qualifier, keep ) {
+
+ // Can't pass null or undefined to indexOf in Firefox 4
+ // Set to 0 to skip string check
+ qualifier = qualifier || 0;
+
+ if ( jQuery.isFunction( qualifier ) ) {
+ return jQuery.grep(elements, function( elem, i ) {
+ var retVal = !!qualifier.call( elem, i, elem );
+ return retVal === keep;
+ });
+
+ } else if ( qualifier.nodeType ) {
+ return jQuery.grep(elements, function( elem, i ) {
+ return ( elem === qualifier ) === keep;
+ });
+
+ } else if ( typeof qualifier === "string" ) {
+ var filtered = jQuery.grep(elements, function( elem ) {
+ return elem.nodeType === 1;
+ });
+
+ if ( isSimple.test( qualifier ) ) {
+ return jQuery.filter(qualifier, filtered, !keep);
+ } else {
+ qualifier = jQuery.filter( qualifier, filtered );
+ }
+ }
+
+ return jQuery.grep(elements, function( elem, i ) {
+ return ( jQuery.inArray( elem, qualifier ) >= 0 ) === keep;
+ });
+}
+
+
+
+
+function createSafeFragment( document ) {
+ var list = nodeNames.split( "|" ),
+ safeFrag = document.createDocumentFragment();
+
+ if ( safeFrag.createElement ) {
+ while ( list.length ) {
+ safeFrag.createElement(
+ list.pop()
+ );
+ }
+ }
+ return safeFrag;
+}
+
+var nodeNames = "abbr|article|aside|audio|canvas|datalist|details|figcaption|figure|footer|" +
+ "header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",
+ rinlinejQuery = / jQuery\d+="(?:\d+|null)"/g,
+ rleadingWhitespace = /^\s+/,
+ rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/ig,
+ rtagName = /<([\w:]+)/,
+ rtbody = /", "" ],
+ legend: [ 1, "" ],
+ thead: [ 1, "" ],
+ tr: [ 2, "" ],
+ td: [ 3, "" ],
+ col: [ 2, "" ],
+ area: [ 1, "" ],
+ _default: [ 0, "", "" ]
+ },
+ safeFragment = createSafeFragment( document );
+
+wrapMap.optgroup = wrapMap.option;
+wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;
+wrapMap.th = wrapMap.td;
+
+// IE can't serialize and
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+