beets-1.3.1/0000755000076500000240000000000012226377756013603 5ustar asampsonstaff00000000000000beets-1.3.1/beets/0000755000076500000240000000000012226377756014705 5ustar asampsonstaff00000000000000beets-1.3.1/beets/__init__.py0000644000076500000240000000152312214741277017005 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. __version__ = '1.3.1' __author__ = 'Adrian Sampson ' import beets.library from beets.util import confit Library = beets.library.Library config = confit.LazyConfig('beets', __name__) beets-1.3.1/beets/autotag/0000755000076500000240000000000012226377756016351 5ustar asampsonstaff00000000000000beets-1.3.1/beets/autotag/__init__.py0000644000076500000240000002214112217447715020453 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Facilities for automatically determining files' correct metadata. """ import os import logging import re from beets import library, mediafile, config from beets.util import sorted_walk, ancestry, displayable_path # Parts of external interface. from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch from .match import tag_item, tag_album from .match import recommendation # Global logger. log = logging.getLogger('beets') # Constants for directory walker. MULTIDISC_MARKERS = (r'dis[ck]', r'cd') MULTIDISC_PAT_FMT = r'^(.*%s[\W_]*)\d' # Additional utilities for the main interface. def albums_in_dir(path): """Recursively searches the given directory and returns an iterable of (paths, items) where paths is a list of directories and items is a list of Items that is probably an album. Specifically, any folder containing any media files is an album. """ collapse_pat = collapse_paths = collapse_items = None for root, dirs, files in sorted_walk(path, ignore=config['ignore'].as_str_seq(), logger=log): # Get a list of items in the directory. items = [] for filename in files: try: i = library.Item.from_path(os.path.join(root, filename)) except mediafile.FileTypeError: pass except mediafile.UnreadableFileError: log.warn(u'unreadable file: {0}'.format( displayable_path(filename)) ) else: items.append(i) # If we're currently collapsing the constituent directories in a # multi-disc album, check whether we should continue collapsing # and add the current directory. If so, just add the directory # and move on to the next directory. If not, stop collapsing. if collapse_paths: if (not collapse_pat and collapse_paths[0] in ancestry(root)) or \ (collapse_pat and collapse_pat.match(os.path.basename(root))): # Still collapsing. collapse_paths.append(root) collapse_items += items continue else: # Collapse finished. Yield the collapsed directory and # proceed to process the current one. if collapse_items: yield collapse_paths, collapse_items collapse_pat = collapse_paths = collapse_items = None # Check whether this directory looks like the *first* directory # in a multi-disc sequence. There are two indicators: the file # is named like part of a multi-disc sequence (e.g., "Title Disc # 1") or it contains no items but only directories that are # named in this way. start_collapsing = False for marker in MULTIDISC_MARKERS: marker_pat = re.compile(MULTIDISC_PAT_FMT % marker, re.I) match = marker_pat.match(os.path.basename(root)) # Is this directory the root of a nested multi-disc album? if dirs and not items: # Check whether all subdirectories have the same prefix. start_collapsing = True subdir_pat = None for subdir in dirs: # The first directory dictates the pattern for # the remaining directories. if not subdir_pat: match = marker_pat.match(subdir) if match: subdir_pat = re.compile(r'^%s\d' % re.escape(match.group(1)), re.I) else: start_collapsing = False break # Subsequent directories must match the pattern. elif not subdir_pat.match(subdir): start_collapsing = False break # If all subdirectories match, don't check other # markers. if start_collapsing: break # Is this directory the first in a flattened multi-disc album? elif match: start_collapsing = True # Set the current pattern to match directories with the same # prefix as this one, followed by a digit. collapse_pat = re.compile(r'^%s\d' % re.escape(match.group(1)), re.I) break # If either of the above heuristics indicated that this is the # beginning of a multi-disc album, initialize the collapsed # directory and item lists and check the next directory. if start_collapsing: # Start collapsing; continue to the next iteration. collapse_paths = [root] collapse_items = items continue # If it's nonempty, yield it. if items: yield [root], items # Clear out any unfinished collapse. if collapse_paths and collapse_items: yield collapse_paths, collapse_items def apply_item_metadata(item, track_info): """Set an item's metadata from its matched TrackInfo object. """ item.artist = track_info.artist item.artist_sort = track_info.artist_sort item.artist_credit = track_info.artist_credit item.title = track_info.title item.mb_trackid = track_info.track_id if track_info.artist_id: item.mb_artistid = track_info.artist_id # At the moment, the other metadata is left intact (including album # and track number). Perhaps these should be emptied? def apply_metadata(album_info, mapping): """Set the items' metadata to match an AlbumInfo object using a mapping from Items to TrackInfo objects. """ for item, track_info in mapping.iteritems(): # Album, artist, track count. if track_info.artist: item.artist = track_info.artist else: item.artist = album_info.artist item.albumartist = album_info.artist item.album = album_info.album # Artist sort and credit names. item.artist_sort = track_info.artist_sort or album_info.artist_sort item.artist_credit = track_info.artist_credit or \ album_info.artist_credit item.albumartist_sort = album_info.artist_sort item.albumartist_credit = album_info.artist_credit # Release date. for prefix in '', 'original_': if config['original_date'] and not prefix: # Ignore specific release date. continue for suffix in 'year', 'month', 'day': key = prefix + suffix value = getattr(album_info, key) if value: setattr(item, key, value) if config['original_date']: # If we're using original release date for both # fields, set item.year = info.original_year, # etc. setattr(item, suffix, value) # Title. item.title = track_info.title if config['per_disc_numbering']: item.track = track_info.medium_index item.tracktotal = track_info.medium_total else: item.track = track_info.index item.tracktotal = len(album_info.tracks) # Disc and disc count. item.disc = track_info.medium item.disctotal = album_info.mediums # MusicBrainz IDs. item.mb_trackid = track_info.track_id item.mb_albumid = album_info.album_id if track_info.artist_id: item.mb_artistid = track_info.artist_id else: item.mb_artistid = album_info.artist_id item.mb_albumartistid = album_info.artist_id item.mb_releasegroupid = album_info.releasegroup_id # Compilation flag. item.comp = album_info.va # Miscellaneous metadata. item.albumtype = album_info.albumtype if album_info.label: item.label = album_info.label item.asin = album_info.asin item.catalognum = album_info.catalognum item.script = album_info.script item.language = album_info.language item.country = album_info.country item.albumstatus = album_info.albumstatus item.media = album_info.media item.albumdisambig = album_info.albumdisambig item.disctitle = track_info.disctitle beets-1.3.1/beets/autotag/hooks.py0000644000076500000240000004573112217447314020044 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Glue between metadata sources and the matching logic.""" import logging from collections import namedtuple import re from beets import plugins from beets import config from beets.autotag import mb from beets.util import levenshtein from unidecode import unidecode log = logging.getLogger('beets') # Classes used to represent candidate options. class AlbumInfo(object): """Describes a canonical release that may be used to match a release in the library. Consists of these data members: - ``album``: the release title - ``album_id``: MusicBrainz ID; UUID fragment only - ``artist``: name of the release's primary artist - ``artist_id`` - ``tracks``: list of TrackInfo objects making up the release - ``asin``: Amazon ASIN - ``albumtype``: string describing the kind of release - ``va``: boolean: whether the release has "various artists" - ``year``: release year - ``month``: release month - ``day``: release day - ``label``: music label responsible for the release - ``mediums``: the number of discs in this release - ``artist_sort``: name of the release's artist for sorting - ``releasegroup_id``: MBID for the album's release group - ``catalognum``: the label's catalog number for the release - ``script``: character set used for metadata - ``language``: human language of the metadata - ``country``: the release country - ``albumstatus``: MusicBrainz release status (Official, etc.) - ``media``: delivery mechanism (Vinyl, etc.) - ``albumdisambig``: MusicBrainz release disambiguation comment - ``artist_credit``: Release-specific artist name - ``data_source``: The original data source (MusicBrainz, Discogs, etc.) - ``data_url``: The data source release URL. The fields up through ``tracks`` are required. The others are optional and may be None. """ def __init__(self, album, album_id, artist, artist_id, tracks, asin=None, albumtype=None, va=False, year=None, month=None, day=None, label=None, mediums=None, artist_sort=None, releasegroup_id=None, catalognum=None, script=None, language=None, country=None, albumstatus=None, media=None, albumdisambig=None, artist_credit=None, original_year=None, original_month=None, original_day=None, data_source=None, data_url=None): self.album = album self.album_id = album_id self.artist = artist self.artist_id = artist_id self.tracks = tracks self.asin = asin self.albumtype = albumtype self.va = va self.year = year self.month = month self.day = day self.label = label self.mediums = mediums self.artist_sort = artist_sort self.releasegroup_id = releasegroup_id self.catalognum = catalognum self.script = script self.language = language self.country = country self.albumstatus = albumstatus self.media = media self.albumdisambig = albumdisambig self.artist_credit = artist_credit self.original_year = original_year self.original_month = original_month self.original_day = original_day self.data_source = data_source self.data_url = data_url # Work around a bug in python-musicbrainz-ngs that causes some # strings to be bytes rather than Unicode. # https://github.com/alastair/python-musicbrainz-ngs/issues/85 def decode(self, codec='utf8'): """Ensure that all string attributes on this object, and the constituent `TrackInfo` objects, are decoded to Unicode. """ for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort', 'catalognum', 'script', 'language', 'country', 'albumstatus', 'albumdisambig', 'artist_credit', 'media']: value = getattr(self, fld) if isinstance(value, str): setattr(self, fld, value.decode(codec, 'ignore')) if self.tracks: for track in self.tracks: track.decode(codec) class TrackInfo(object): """Describes a canonical track present on a release. Appears as part of an AlbumInfo's ``tracks`` list. Consists of these data members: - ``title``: name of the track - ``track_id``: MusicBrainz ID; UUID fragment only - ``artist``: individual track artist name - ``artist_id`` - ``length``: float: duration of the track in seconds - ``index``: position on the entire release - ``medium``: the disc number this track appears on in the album - ``medium_index``: the track's position on the disc - ``medium_total``: the number of tracks on the item's disc - ``artist_sort``: name of the track artist for sorting - ``disctitle``: name of the individual medium (subtitle) - ``artist_credit``: Recording-specific artist name Only ``title`` and ``track_id`` are required. The rest of the fields may be None. The indices ``index``, ``medium``, and ``medium_index`` are all 1-based. """ def __init__(self, title, track_id, artist=None, artist_id=None, length=None, index=None, medium=None, medium_index=None, medium_total=None, artist_sort=None, disctitle=None, artist_credit=None, data_source=None, data_url=None): self.title = title self.track_id = track_id self.artist = artist self.artist_id = artist_id self.length = length self.index = index self.medium = medium self.medium_index = medium_index self.medium_total = medium_total self.artist_sort = artist_sort self.disctitle = disctitle self.artist_credit = artist_credit self.data_source = data_source self.data_url = data_url # As above, work around a bug in python-musicbrainz-ngs. def decode(self, codec='utf8'): """Ensure that all string attributes on this object are decoded to Unicode. """ for fld in ['title', 'artist', 'medium', 'artist_sort', 'disctitle', 'artist_credit']: value = getattr(self, fld) if isinstance(value, str): setattr(self, fld, value.decode(codec, 'ignore')) # Candidate distance scoring. # Parameters for string distance function. # Words that can be moved to the end of a string using a comma. SD_END_WORDS = ['the', 'a', 'an'] # Reduced weights for certain portions of the string. SD_PATTERNS = [ (r'^the ', 0.1), (r'[\[\(]?(ep|single)[\]\)]?', 0.0), (r'[\[\(]?(featuring|feat|ft)[\. :].+', 0.1), (r'\(.*?\)', 0.3), (r'\[.*?\]', 0.3), (r'(, )?(pt\.|part) .+', 0.2), ] # Replacements to use before testing distance. SD_REPLACE = [ (r'&', 'and'), ] def _string_dist_basic(str1, str2): """Basic edit distance between two strings, ignoring non-alphanumeric characters and case. Comparisons are based on a transliteration/lowering to ASCII characters. Normalized by string length. """ str1 = unidecode(str1) str2 = unidecode(str2) str1 = re.sub(r'[^a-z0-9]', '', str1.lower()) str2 = re.sub(r'[^a-z0-9]', '', str2.lower()) if not str1 and not str2: return 0.0 return levenshtein(str1, str2) / float(max(len(str1), len(str2))) def string_dist(str1, str2): """Gives an "intuitive" edit distance between two strings. This is an edit distance, normalized by the string length, with a number of tweaks that reflect intuition about text. """ str1 = str1.lower() str2 = str2.lower() # Don't penalize strings that move certain words to the end. For # example, "the something" should be considered equal to # "something, the". for word in SD_END_WORDS: if str1.endswith(', %s' % word): str1 = '%s %s' % (word, str1[:-len(word)-2]) if str2.endswith(', %s' % word): str2 = '%s %s' % (word, str2[:-len(word)-2]) # Perform a couple of basic normalizing substitutions. for pat, repl in SD_REPLACE: str1 = re.sub(pat, repl, str1) str2 = re.sub(pat, repl, str2) # Change the weight for certain string portions matched by a set # of regular expressions. We gradually change the strings and build # up penalties associated with parts of the string that were # deleted. base_dist = _string_dist_basic(str1, str2) penalty = 0.0 for pat, weight in SD_PATTERNS: # Get strings that drop the pattern. case_str1 = re.sub(pat, '', str1) case_str2 = re.sub(pat, '', str2) if case_str1 != str1 or case_str2 != str2: # If the pattern was present (i.e., it is deleted in the # the current case), recalculate the distances for the # modified strings. case_dist = _string_dist_basic(case_str1, case_str2) case_delta = max(0.0, base_dist - case_dist) if case_delta == 0.0: continue # Shift our baseline strings down (to avoid rematching the # same part of the string) and add a scaled distance # amount to the penalties. str1 = case_str1 str2 = case_str2 base_dist = case_dist penalty += weight * case_delta dist = base_dist + penalty return dist class Distance(object): """Keeps track of multiple distance penalties. Provides a single weighted distance for all penalties as well as a weighted distance for each individual penalty. """ def __init__(self): self._penalties = {} weights_view = config['match']['distance_weights'] self._weights = {} for key in weights_view.keys(): self._weights[key] = weights_view[key].as_number() # Access the components and their aggregates. @property def distance(self): """Return a weighted and normalized distance across all penalties. """ dist_max = self.max_distance if dist_max: return self.raw_distance / self.max_distance return 0.0 @property def max_distance(self): """Return the maximum distance penalty (normalization factor). """ dist_max = 0.0 for key, penalty in self._penalties.iteritems(): dist_max += len(penalty) * self._weights[key] return dist_max @property def raw_distance(self): """Return the raw (denormalized) distance. """ dist_raw = 0.0 for key, penalty in self._penalties.iteritems(): dist_raw += sum(penalty) * self._weights[key] return dist_raw def items(self): """Return a list of (key, dist) pairs, with `dist` being the weighted distance, sorted from highest to lowest. Does not include penalties with a zero value. """ list_ = [] for key in self._penalties: dist = self[key] if dist: list_.append((key, dist)) # Convert distance into a negative float we can sort items in # ascending order (for keys, when the penalty is equal) and # still get the items with the biggest distance first. return sorted(list_, key=lambda (key, dist): (0-dist, key)) # Behave like a float. def __cmp__(self, other): return cmp(self.distance, other) def __float__(self): return self.distance def __sub__(self, other): return self.distance - other def __rsub__(self, other): return other - self.distance # Behave like a dict. def __getitem__(self, key): """Returns the weighted distance for a named penalty. """ dist = sum(self._penalties[key]) * self._weights[key] dist_max = self.max_distance if dist_max: return dist / dist_max return 0.0 def __iter__(self): return iter(self.items()) def __len__(self): return len(self.items()) def keys(self): return [key for key, _ in self.items()] def update(self, dist): """Adds all the distance penalties from `dist`. """ if not isinstance(dist, Distance): raise ValueError( '`dist` must be a Distance object. It is: %r' % dist) for key, penalties in dist._penalties.iteritems(): self._penalties.setdefault(key, []).extend(penalties) # Adding components. def _eq(self, value1, value2): """Returns True if `value1` is equal to `value2`. `value1` may be a compiled regular expression, in which case it will be matched against `value2`. """ if isinstance(value1, re._pattern_type): return bool(value1.match(value2)) return value1 == value2 def add(self, key, dist): """Adds a distance penalty. `key` must correspond with a configured weight setting. `dist` must be a float between 0.0 and 1.0, and will be added to any existing distance penalties for the same key. """ if not 0.0 <= dist <= 1.0: raise ValueError( '`dist` must be between 0.0 and 1.0. It is: %r' % dist) self._penalties.setdefault(key, []).append(dist) def add_equality(self, key, value, options): """Adds a distance penalty of 1.0 if `value` doesn't match any of the values in `options`. If an option is a compiled regular expression, it will be considered equal if it matches against `value`. """ if not isinstance(options, (list, tuple)): options = [options] for opt in options: if self._eq(opt, value): dist = 0.0 break else: dist = 1.0 self.add(key, dist) def add_expr(self, key, expr): """Adds a distance penalty of 1.0 if `expr` evaluates to True, or 0.0. """ if expr: self.add(key, 1.0) else: self.add(key, 0.0) def add_number(self, key, number1, number2): """Adds a distance penalty of 1.0 for each number of difference between `number1` and `number2`, or 0.0 when there is no difference. Use this when there is no upper limit on the difference between the two numbers. """ diff = abs(number1 - number2) if diff: for i in range(diff): self.add(key, 1.0) else: self.add(key, 0.0) def add_priority(self, key, value, options): """Adds a distance penalty that corresponds to the position at which `value` appears in `options`. A distance penalty of 0.0 for the first option, or 1.0 if there is no matching option. If an option is a compiled regular expression, it will be considered equal if it matches against `value`. """ if not isinstance(options, (list, tuple)): options = [options] unit = 1.0 / (len(options) or 1) for i, opt in enumerate(options): if self._eq(opt, value): dist = i * unit break else: dist = 1.0 self.add(key, dist) def add_ratio(self, key, number1, number2): """Adds a distance penalty for `number1` as a ratio of `number2`. `number1` is bound at 0 and `number2`. """ number = float(max(min(number1, number2), 0)) if number2: dist = number / number2 else: dist = 0.0 self.add(key, dist) def add_string(self, key, str1, str2): """Adds a distance penalty based on the edit distance between `str1` and `str2`. """ dist = string_dist(str1, str2) self.add(key, dist) # Structures that compose all the information for a candidate match. AlbumMatch = namedtuple('AlbumMatch', ['distance', 'info', 'mapping', 'extra_items', 'extra_tracks']) TrackMatch = namedtuple('TrackMatch', ['distance', 'info']) # Aggregation of sources. def album_for_mbid(release_id): """Get an AlbumInfo object for a MusicBrainz release ID. Return None if the ID is not found. """ try: return mb.album_for_id(release_id) except mb.MusicBrainzAPIError as exc: exc.log(log) def track_for_mbid(recording_id): """Get a TrackInfo object for a MusicBrainz recording ID. Return None if the ID is not found. """ try: return mb.track_for_id(recording_id) except mb.MusicBrainzAPIError as exc: exc.log(log) def albums_for_id(album_id): """Get a list of albums for an ID.""" candidates = [album_for_mbid(album_id)] candidates.extend(plugins.album_for_id(album_id)) return filter(None, candidates) def tracks_for_id(track_id): """Get a list of tracks for an ID.""" candidates = [track_for_mbid(track_id)] candidates.extend(plugins.track_for_id(track_id)) return filter(None, candidates) def album_candidates(items, artist, album, va_likely): """Search for album matches. ``items`` is a list of Item objects that make up the album. ``artist`` and ``album`` are the respective names (strings), which may be derived from the item list or may be entered by the user. ``va_likely`` is a boolean indicating whether the album is likely to be a "various artists" release. """ out = [] # Base candidates if we have album and artist to match. if artist and album: try: out.extend(mb.match_album(artist, album, len(items))) except mb.MusicBrainzAPIError as exc: exc.log(log) # Also add VA matches from MusicBrainz where appropriate. if va_likely and album: try: out.extend(mb.match_album(None, album, len(items))) except mb.MusicBrainzAPIError as exc: exc.log(log) # Candidates from plugins. out.extend(plugins.candidates(items, artist, album, va_likely)) return out def item_candidates(item, artist, title): """Search for item matches. ``item`` is the Item to be matched. ``artist`` and ``title`` are strings and either reflect the item or are specified by the user. """ out = [] # MusicBrainz candidates. if artist and title: try: out.extend(mb.match_track(artist, title)) except mb.MusicBrainzAPIError as exc: exc.log(log) # Plugin candidates. out.extend(plugins.item_candidates(item, artist, title)) return out beets-1.3.1/beets/autotag/match.py0000644000076500000240000004246112204764033020006 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Matches existing metadata with canonical information to identify releases and tracks. """ from __future__ import division import datetime import logging import re from munkres import Munkres from beets import plugins from beets import config from beets.util import plurality from beets.util.enumeration import enum from beets.autotag import hooks # Recommendation enumeration. recommendation = enum('none', 'low', 'medium', 'strong', name='recommendation') # Artist signals that indicate "various artists". These are used at the # album level to determine whether a given release is likely a VA # release and also on the track level to to remove the penalty for # differing artists. VA_ARTISTS = (u'', u'various artists', u'various', u'va', u'unknown') # Global logger. log = logging.getLogger('beets') # Primary matching functionality. def current_metadata(items): """Extract the likely current metadata for an album given a list of its items. Return two dictionaries: - The most common value for each field. - Whether each field's value was unanimous (values are booleans). """ assert items # Must be nonempty. likelies = {} consensus = {} fields = ['artist', 'album', 'albumartist', 'year', 'disctotal', 'mb_albumid', 'label', 'catalognum', 'country', 'media', 'albumdisambig'] for key in fields: values = [getattr(item, key) for item in items if item] likelies[key], freq = plurality(values) consensus[key] = (freq == len(values)) # If there's an album artist consensus, use this for the artist. if consensus['albumartist'] and likelies['albumartist']: likelies['artist'] = likelies['albumartist'] return likelies, consensus def assign_items(items, tracks): """Given a list of Items and a list of TrackInfo objects, find the best mapping between them. Returns a mapping from Items to TrackInfo objects, a set of extra Items, and a set of extra TrackInfo objects. These "extra" objects occur when there is an unequal number of objects of the two types. """ # Construct the cost matrix. costs = [] for item in items: row = [] for i, track in enumerate(tracks): row.append(track_distance(item, track)) costs.append(row) # Find a minimum-cost bipartite matching. matching = Munkres().compute(costs) # Produce the output matching. mapping = dict((items[i], tracks[j]) for (i, j) in matching) extra_items = list(set(items) - set(mapping.keys())) extra_items.sort(key=lambda i: (i.disc, i.track, i.title)) extra_tracks = list(set(tracks) - set(mapping.values())) extra_tracks.sort(key=lambda t: (t.index, t.title)) return mapping, extra_items, extra_tracks def track_index_changed(item, track_info): """Returns True if the item and track info index is different. Tolerates per disc and per release numbering. """ return item.track not in (track_info.medium_index, track_info.index) def track_distance(item, track_info, incl_artist=False): """Determines the significance of a track metadata change. Returns a Distance object. `incl_artist` indicates that a distance component should be included for the track artist (i.e., for various-artist releases). """ dist = hooks.Distance() # Length. if track_info.length: diff = abs(item.length - track_info.length) - \ config['match']['track_length_grace'].as_number() dist.add_ratio('track_length', diff, config['match']['track_length_max'].as_number()) # Title. dist.add_string('track_title', item.title, track_info.title) # Artist. Only check if there is actually an artist in the track data. if incl_artist and track_info.artist and \ item.artist.lower() not in VA_ARTISTS: dist.add_string('track_artist', item.artist, track_info.artist) # Track index. if track_info.index and item.track: dist.add_expr('track_index', track_index_changed(item, track_info)) # Track ID. if item.mb_trackid: dist.add_expr('track_id', item.mb_trackid != track_info.track_id) # Plugins. dist.update(plugins.track_distance(item, track_info)) return dist def distance(items, album_info, mapping): """Determines how "significant" an album metadata change would be. Returns a Distance object. `album_info` is an AlbumInfo object reflecting the album to be compared. `items` is a sequence of all Item objects that will be matched (order is not important). `mapping` is a dictionary mapping Items to TrackInfo objects; the keys are a subset of `items` and the values are a subset of `album_info.tracks`. """ likelies, _ = current_metadata(items) dist = hooks.Distance() # Artist, if not various. if not album_info.va: dist.add_string('artist', likelies['artist'], album_info.artist) # Album. dist.add_string('album', likelies['album'], album_info.album) # Current or preferred media. if album_info.media: # Preferred media options. patterns = config['match']['preferred']['media'].as_str_seq() options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns] if options: dist.add_priority('media', album_info.media, options) # Current media. elif likelies['media']: dist.add_equality('media', album_info.media, likelies['media']) # Mediums. if likelies['disctotal'] and album_info.mediums: dist.add_number('mediums', likelies['disctotal'], album_info.mediums) # Prefer earliest release. if album_info.year and config['match']['preferred']['original_year']: # Assume 1889 (earliest first gramophone discs) if we don't know the # original year. original = album_info.original_year or 1889 diff = abs(album_info.year - original) diff_max = abs(datetime.date.today().year - original) dist.add_ratio('year', diff, diff_max) # Year. elif likelies['year'] and album_info.year: if likelies['year'] in (album_info.year, album_info.original_year): # No penalty for matching release or original year. dist.add('year', 0.0) elif album_info.original_year: # Prefer matchest closest to the release year. diff = abs(likelies['year'] - album_info.year) diff_max = abs(datetime.date.today().year - album_info.original_year) dist.add_ratio('year', diff, diff_max) else: # Full penalty when there is no original year. dist.add('year', 1.0) # Preferred countries. patterns = config['match']['preferred']['countries'].as_str_seq() options = [re.compile(pat, re.I) for pat in patterns] if album_info.country and options: dist.add_priority('country', album_info.country, options) # Country. elif likelies['country'] and album_info.country: dist.add_string('country', likelies['country'], album_info.country) # Label. if likelies['label'] and album_info.label: dist.add_string('label', likelies['label'], album_info.label) # Catalog number. if likelies['catalognum'] and album_info.catalognum: dist.add_string('catalognum', likelies['catalognum'], album_info.catalognum) # Disambiguation. if likelies['albumdisambig'] and album_info.albumdisambig: dist.add_string('albumdisambig', likelies['albumdisambig'], album_info.albumdisambig) # Album ID. if likelies['mb_albumid']: dist.add_equality('album_id', likelies['mb_albumid'], album_info.album_id) # Tracks. dist.tracks = {} for item, track in mapping.iteritems(): dist.tracks[track] = track_distance(item, track, album_info.va) dist.add('tracks', dist.tracks[track].distance) # Missing tracks. for i in range(len(album_info.tracks) - len(mapping)): dist.add('missing_tracks', 1.0) # Unmatched tracks. for i in range(len(items) - len(mapping)): dist.add('unmatched_tracks', 1.0) # Plugins. dist.update(plugins.album_distance(items, album_info, mapping)) return dist def match_by_id(items): """If the items are tagged with a MusicBrainz album ID, returns an AlbumInfo object for the corresponding album. Otherwise, returns None. """ # Is there a consensus on the MB album ID? albumids = [item.mb_albumid for item in items if item.mb_albumid] if not albumids: log.debug('No album IDs found.') return None # If all album IDs are equal, look up the album. if bool(reduce(lambda x,y: x if x==y else (), albumids)): albumid = albumids[0] log.debug('Searching for discovered album ID: ' + albumid) return hooks.album_for_mbid(albumid) else: log.debug('No album ID consensus.') def _recommendation(results): """Given a sorted list of AlbumMatch or TrackMatch objects, return a recommendation based on the results' distances. If the recommendation is higher than the configured maximum for an applied penalty, the recommendation will be downgraded to the configured maximum for that penalty. """ if not results: # No candidates: no recommendation. return recommendation.none # Basic distance thresholding. min_dist = results[0].distance if min_dist < config['match']['strong_rec_thresh'].as_number(): # Strong recommendation level. rec = recommendation.strong elif min_dist <= config['match']['medium_rec_thresh'].as_number(): # Medium recommendation level. rec = recommendation.medium elif len(results) == 1: # Only a single candidate. rec = recommendation.low elif results[1].distance - min_dist >= \ config['match']['rec_gap_thresh'].as_number(): # Gap between first two candidates is large. rec = recommendation.low else: # No conclusion. Return immediately. Can't be downgraded any further. return recommendation.none # Downgrade to the max rec if it is lower than the current rec for an # applied penalty. keys = set(min_dist.keys()) if isinstance(results[0], hooks.AlbumMatch): for track_dist in min_dist.tracks.values(): keys.update(track_dist.keys()) max_rec_view = config['match']['max_rec'] for key in keys: if key in max_rec_view.keys(): max_rec = max_rec_view[key].as_choice({ 'strong': recommendation.strong, 'medium': recommendation.medium, 'low': recommendation.low, 'none': recommendation.none, }) rec = min(rec, max_rec) return rec def _add_candidate(items, results, info): """Given a candidate AlbumInfo object, attempt to add the candidate to the output dictionary of AlbumMatch objects. This involves checking the track count, ordering the items, checking for duplicates, and calculating the distance. """ log.debug('Candidate: %s - %s' % (info.artist, info.album)) # Don't duplicate. if info.album_id in results: log.debug('Duplicate.') return # Find mapping between the items and the track info. mapping, extra_items, extra_tracks = assign_items(items, info.tracks) # Get the change distance. dist = distance(items, info, mapping) # Skip matches with ignored penalties. penalties = [key for _, key in dist] for penalty in config['match']['ignored'].as_str_seq(): if penalty in penalties: log.debug('Ignored. Penalty: %s' % penalty) return log.debug('Success. Distance: %f' % dist) results[info.album_id] = hooks.AlbumMatch(dist, info, mapping, extra_items, extra_tracks) def tag_album(items, search_artist=None, search_album=None, search_id=None): """Bundles together the functionality used to infer tags for a set of items comprised by an album. Returns everything relevant: - The current artist. - The current album. - A list of AlbumMatch objects. The candidates are sorted by distance (i.e., best match first). - A recommendation. If search_artist and search_album or search_id are provided, then they are used as search terms in place of the current metadata. """ # Get current metadata. likelies, consensus = current_metadata(items) cur_artist = likelies['artist'] cur_album = likelies['album'] log.debug('Tagging %s - %s' % (cur_artist, cur_album)) # The output result (distance, AlbumInfo) tuples (keyed by MB album # ID). candidates = {} # Search by explicit ID. if search_id is not None: log.debug('Searching for album ID: ' + search_id) search_cands = hooks.albums_for_id(search_id) # Use existing metadata or text search. else: # Try search based on current ID. id_info = match_by_id(items) if id_info: _add_candidate(items, candidates, id_info) rec = _recommendation(candidates.values()) log.debug('Album ID match recommendation is ' + str(rec)) if candidates and not config['import']['timid']: # If we have a very good MBID match, return immediately. # Otherwise, this match will compete against metadata-based # matches. if rec == recommendation.strong: log.debug('ID match.') return cur_artist, cur_album, candidates.values(), rec # Search terms. if not (search_artist and search_album): # No explicit search terms -- use current metadata. search_artist, search_album = cur_artist, cur_album log.debug(u'Search terms: %s - %s' % (search_artist, search_album)) # Is this album likely to be a "various artist" release? va_likely = ((not consensus['artist']) or (search_artist.lower() in VA_ARTISTS) or any(item.comp for item in items)) log.debug(u'Album might be VA: %s' % str(va_likely)) # Get the results from the data sources. search_cands = hooks.album_candidates(items, search_artist, search_album, va_likely) log.debug(u'Evaluating %i candidates.' % len(search_cands)) for info in search_cands: _add_candidate(items, candidates, info) # Sort and get the recommendation. candidates = sorted(candidates.itervalues()) rec = _recommendation(candidates) return cur_artist, cur_album, candidates, rec def tag_item(item, search_artist=None, search_title=None, search_id=None): """Attempts to find metadata for a single track. Returns a `(candidates, recommendation)` pair where `candidates` is a list of TrackMatch objects. `search_artist` and `search_title` may be used to override the current metadata for the purposes of the MusicBrainz title; likewise `search_id`. """ # Holds candidates found so far: keys are MBIDs; values are # (distance, TrackInfo) pairs. candidates = {} # First, try matching by MusicBrainz ID. trackid = search_id or item.mb_trackid if trackid: log.debug('Searching for track ID: ' + trackid) for track_info in hooks.tracks_for_id(trackid): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.track_id] = \ hooks.TrackMatch(dist, track_info) # If this is a good match, then don't keep searching. rec = _recommendation(candidates.values()) if rec == recommendation.strong and not config['import']['timid']: log.debug('Track ID match.') return candidates.values(), rec # If we're searching by ID, don't proceed. if search_id is not None: if candidates: return candidates.values(), rec else: return [], recommendation.none # Search terms. if not (search_artist and search_title): search_artist, search_title = item.artist, item.title log.debug(u'Item search terms: %s - %s' % (search_artist, search_title)) # Get and evaluate candidate metadata. for track_info in hooks.item_candidates(item, search_artist, search_title): dist = track_distance(item, track_info, incl_artist=True) candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) # Sort by distance and return with recommendation. log.debug('Found %i candidates.' % len(candidates)) candidates = sorted(candidates.itervalues()) rec = _recommendation(candidates) return candidates, rec beets-1.3.1/beets/autotag/mb.py0000644000076500000240000003273412222336661017314 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Searches for albums in the MusicBrainz database. """ import logging import musicbrainzngs import re import traceback import beets.autotag.hooks import beets from beets import util from beets import config SEARCH_LIMIT = 5 VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377' musicbrainzngs.set_useragent('beets', beets.__version__, 'http://beets.radbox.org/') class MusicBrainzAPIError(util.HumanReadableException): """An error while talking to MusicBrainz. The `query` field is the parameter to the action and may have any type. """ def __init__(self, reason, verb, query, tb=None): self.query = query super(MusicBrainzAPIError, self).__init__(reason, verb, tb) def get_message(self): return u'"{0}" in {1} with query {2}'.format( self._reasonstr(), self.verb, repr(self.query) ) log = logging.getLogger('beets') RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', 'labels', 'artist-credits', 'aliases'] TRACK_INCLUDES = ['artists', 'aliases'] def configure(): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. """ musicbrainzngs.set_hostname(config['musicbrainz']['host'].get(unicode)) musicbrainzngs.set_rate_limit( config['musicbrainz']['ratelimit_interval'].as_number(), config['musicbrainz']['ratelimit'].get(int), ) def _preferred_alias(aliases): """Given an list of alias structures for an artist credit, select and return the user's preferred alias alias or None if no matching alias is found. """ if not aliases: return # Only consider aliases that have locales set. aliases = [a for a in aliases if 'locale' in a] # Search configured locales in order. for locale in config['import']['languages'].as_str_seq(): # Find matching aliases for this locale. matches = [a for a in aliases if a['locale'] == locale] # Skip to the next locale if we have no matches if not matches: continue # Find the aliases that have the primary flag set. primaries = [a for a in matches if 'primary' in a] # Take the primary if we have it, otherwise take the first # match with the correct locale. if primaries: return primaries[0] else: return matches[0] def _flatten_artist_credit(credit): """Given a list representing an ``artist-credit`` block, flatten the data into a triple of joined artist name strings: canonical, sort, and credit. """ artist_parts = [] artist_sort_parts = [] artist_credit_parts = [] for el in credit: if isinstance(el, basestring): # Join phrase. artist_parts.append(el) artist_credit_parts.append(el) artist_sort_parts.append(el) else: alias = _preferred_alias(el['artist'].get('alias-list', ())) # An artist. if alias: cur_artist_name = alias['alias'] else: cur_artist_name = el['artist']['name'] artist_parts.append(cur_artist_name) # Artist sort name. if alias: artist_sort_parts.append(alias['sort-name']) elif 'sort-name' in el['artist']: artist_sort_parts.append(el['artist']['sort-name']) else: artist_sort_parts.append(cur_artist_name) # Artist credit. if 'name' in el: artist_credit_parts.append(el['name']) else: artist_credit_parts.append(cur_artist_name) return ( ''.join(artist_parts), ''.join(artist_sort_parts), ''.join(artist_credit_parts), ) def track_info(recording, index=None, medium=None, medium_index=None, medium_total=None): """Translates a MusicBrainz recording result dictionary into a beets ``TrackInfo`` object. Three parameters are optional and are used only for tracks that appear on releases (non-singletons): ``index``, the overall track number; ``medium``, the disc number; ``medium_index``, the track's index on its medium; ``medium_total``, the number of tracks on the medium. Each number is a 1-based index. """ info = beets.autotag.hooks.TrackInfo( recording['title'], recording['id'], index=index, medium=medium, medium_index=medium_index, medium_total=medium_total, ) if recording.get('artist-credit'): # Get the artist names. info.artist, info.artist_sort, info.artist_credit = \ _flatten_artist_credit(recording['artist-credit']) # Get the ID and sort name of the first artist. artist = recording['artist-credit'][0]['artist'] info.artist_id = artist['id'] if recording.get('length'): info.length = int(recording['length']) / (1000.0) info.decode() return info def _set_date_str(info, date_str, original=False): """Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo object, set the object's release date fields appropriately. If `original`, then set the original_year, etc., fields. """ if date_str: date_parts = date_str.split('-') for key in ('year', 'month', 'day'): if date_parts: date_part = date_parts.pop(0) try: date_num = int(date_part) except ValueError: continue if original: key = 'original_' + key setattr(info, key, date_num) def album_info(release): """Takes a MusicBrainz release result dictionary and returns a beets AlbumInfo object containing the interesting data about that release. """ # Get artist name using join phrases. artist_name, artist_sort_name, artist_credit_name = \ _flatten_artist_credit(release['artist-credit']) # Basic info. track_infos = [] index = 0 for medium in release['medium-list']: disctitle = medium.get('title') for track in medium['track-list']: # Basic information from the recording. index += 1 ti = track_info( track['recording'], index, int(medium['position']), int(track['position']), len(medium['track-list']), ) ti.disctitle = disctitle # Prefer track data, where present, over recording data. if track.get('title'): ti.title = track['title'] if track.get('artist-credit'): # Get the artist names. ti.artist, ti.artist_sort, ti.artist_credit = \ _flatten_artist_credit(track['artist-credit']) ti.artist_id = track['artist-credit'][0]['artist']['id'] if track.get('length'): ti.length = int(track['length']) / (1000.0) track_infos.append(ti) info = beets.autotag.hooks.AlbumInfo( release['title'], release['id'], artist_name, release['artist-credit'][0]['artist']['id'], track_infos, mediums=len(release['medium-list']), artist_sort=artist_sort_name, artist_credit=artist_credit_name, data_source='MusicBrainz', ) info.va = info.artist_id == VARIOUS_ARTISTS_ID info.asin = release.get('asin') info.releasegroup_id = release['release-group']['id'] info.country = release.get('country') info.albumstatus = release.get('status') # Build up the disambiguation string from the release group and release. disambig = [] if release['release-group'].get('disambiguation'): disambig.append(release['release-group'].get('disambiguation')) if release.get('disambiguation'): disambig.append(release.get('disambiguation')) info.albumdisambig = u', '.join(disambig) # Release type not always populated. if 'type' in release['release-group']: reltype = release['release-group']['type'] if reltype: info.albumtype = reltype.lower() # Release dates. release_date = release.get('date') release_group_date = release['release-group'].get('first-release-date') if not release_date: # Fall back if release-specific date is not available. release_date = release_group_date _set_date_str(info, release_date, False) _set_date_str(info, release_group_date, True) # Label name. if release.get('label-info-list'): label_info = release['label-info-list'][0] if label_info.get('label'): label = label_info['label']['name'] if label != '[no label]': info.label = label info.catalognum = label_info.get('catalog-number') # Text representation data. if release.get('text-representation'): rep = release['text-representation'] info.script = rep.get('script') info.language = rep.get('language') # Media (format). if release['medium-list']: first_medium = release['medium-list'][0] info.media = first_medium.get('format') info.decode() return info def match_album(artist, album, tracks=None, limit=SEARCH_LIMIT): """Searches for a single album ("release" in MusicBrainz parlance) and returns an iterator over AlbumInfo objects. May raise a MusicBrainzAPIError. The query consists of an artist name, an album name, and, optionally, a number of tracks on the album. """ # Build search criteria. criteria = {'release': album.lower()} if artist is not None: criteria['artist'] = artist.lower() else: # Various Artists search. criteria['arid'] = VARIOUS_ARTISTS_ID if tracks is not None: criteria['tracks'] = str(tracks) # Abort if we have no search terms. if not any(criteria.itervalues()): return try: res = musicbrainzngs.search_releases(limit=limit, **criteria) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError(exc, 'release search', criteria, traceback.format_exc()) for release in res['release-list']: # The search result is missing some data (namely, the tracks), # so we just use the ID and fetch the rest of the information. albuminfo = album_for_id(release['id']) if albuminfo is not None: yield albuminfo def match_track(artist, title, limit=SEARCH_LIMIT): """Searches for a single track and returns an iterable of TrackInfo objects. May raise a MusicBrainzAPIError. """ criteria = { 'artist': artist.lower(), 'recording': title.lower(), } if not any(criteria.itervalues()): return try: res = musicbrainzngs.search_recordings(limit=limit, **criteria) except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError(exc, 'recording search', criteria, traceback.format_exc()) for recording in res['recording-list']: yield track_info(recording) def _parse_id(s): """Search for a MusicBrainz ID in the given string and return it. If no ID can be found, return None. """ # Find the first thing that looks like a UUID/MBID. match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', s) if match: return match.group() def album_for_id(albumid): """Fetches an album by its MusicBrainz ID and returns an AlbumInfo object or None if the album is not found. May raise a MusicBrainzAPIError. """ albumid = _parse_id(albumid) if not albumid: log.error('Invalid MBID.') return try: res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES) except musicbrainzngs.ResponseError: log.debug('Album ID match failed.') return None except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError(exc, 'get release by ID', albumid, traceback.format_exc()) return album_info(res['release']) def track_for_id(trackid): """Fetches a track by its MusicBrainz ID. Returns a TrackInfo object or None if no track is found. May raise a MusicBrainzAPIError. """ trackid = _parse_id(trackid) if not trackid: log.error('Invalid MBID.') return try: res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES) except musicbrainzngs.ResponseError: log.debug('Track ID match failed.') return None except musicbrainzngs.MusicBrainzError as exc: raise MusicBrainzAPIError(exc, 'get recording by ID', trackid, traceback.format_exc()) return track_info(res['recording']) beets-1.3.1/beets/config_default.yaml0000644000076500000240000000365212224423344020527 0ustar asampsonstaff00000000000000library: library.db directory: ~/Music import: write: yes copy: yes move: no delete: no resume: ask incremental: no quiet_fallback: skip none_rec_action: ask timid: no log: autotag: yes quiet: no singletons: no default_action: apply languages: [] detail: no flat: no clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information"] replace: '[\\/]': _ '^\.': _ '[\x00-\x1f]': _ '[<>:"\?\*\|]': _ '\.$': _ '\s+$': '' path_sep_replace: _ art_filename: cover max_filename_length: 0 plugins: [] pluginpath: [] threaded: yes color: yes timeout: 5.0 per_disc_numbering: no verbose: no terminal_encoding: utf8 original_date: no id3v23: no ui: terminal_width: 80 length_diff_thresh: 10.0 list_format_item: $artist - $album - $title list_format_album: $albumartist - $album time_format: '%Y-%m-%d %H:%M:%S' paths: default: $albumartist/$album%aunique{}/$track $title singleton: Non-Album/$artist/$title comp: Compilations/$album%aunique{}/$track $title statefile: state.pickle musicbrainz: host: musicbrainz.org ratelimit: 1 ratelimit_interval: 1.0 match: strong_rec_thresh: 0.04 medium_rec_thresh: 0.25 rec_gap_thresh: 0.25 max_rec: missing_tracks: medium unmatched_tracks: medium distance_weights: source: 2.0 artist: 3.0 album: 3.0 media: 1.0 mediums: 1.0 year: 1.0 country: 0.5 label: 0.5 catalognum: 0.5 albumdisambig: 0.5 album_id: 5.0 tracks: 2.0 missing_tracks: 0.9 unmatched_tracks: 0.6 track_title: 3.0 track_artist: 2.0 track_index: 1.0 track_length: 2.0 track_id: 5.0 preferred: countries: [] media: [] original_year: no ignored: [] track_length_grace: 10 track_length_max: 30 beets-1.3.1/beets/importer.py0000644000076500000240000010352212222151126017074 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Provides the basic, interface-agnostic workflow for importing and autotagging music files. """ from __future__ import print_function import os import logging import pickle from collections import defaultdict from beets import autotag from beets import library from beets import plugins from beets import util from beets import config from beets.util import pipeline from beets.util import syspath, normpath, displayable_path from beets.util.enumeration import enum from beets.mediafile import UnreadableFileError action = enum( 'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID', name='action' ) QUEUE_SIZE = 128 SINGLE_ARTIST_THRESH = 0.25 VARIOUS_ARTISTS = u'Various Artists' # Global logger. log = logging.getLogger('beets') class ImportAbort(Exception): """Raised when the user aborts the tagging operation. """ pass # Utilities. def _duplicate_check(lib, task): """Check whether an album already exists in the library. Returns a list of Album objects (empty if no duplicates are found). """ assert task.choice_flag in (action.ASIS, action.APPLY) artist, album = task.chosen_ident() if artist is None: # As-is import with no artist. Skip check. return [] found_albums = [] cur_paths = set(i.path for i in task.items if i) for album_cand in lib.albums(library.MatchQuery('albumartist', artist)): if album_cand.album == album: # Check whether the album is identical in contents, in which # case it is not a duplicate (will be replaced). other_paths = set(i.path for i in album_cand.items()) if other_paths == cur_paths: continue found_albums.append(album_cand) return found_albums def _item_duplicate_check(lib, task): """Check whether an item already exists in the library. Returns a list of Item objects. """ assert task.choice_flag in (action.ASIS, action.APPLY) artist, title = task.chosen_ident() found_items = [] query = library.AndQuery(( library.MatchQuery('artist', artist), library.MatchQuery('title', title), )) for other_item in lib.items(query): # Existing items not considered duplicates. if other_item.path == task.item.path: continue found_items.append(other_item) return found_items def _infer_album_fields(task): """Given an album and an associated import task, massage the album-level metadata. This ensures that the album artist is set and that the "compilation" flag is set automatically. """ assert task.is_album assert task.items changes = {} if task.choice_flag == action.ASIS: # Taking metadata "as-is". Guess whether this album is VA. plur_artist, freq = util.plurality([i.artist for i in task.items]) if freq == len(task.items) or (freq > 1 and float(freq) / len(task.items) >= SINGLE_ARTIST_THRESH): # Single-artist album. changes['albumartist'] = plur_artist changes['comp'] = False else: # VA. changes['albumartist'] = VARIOUS_ARTISTS changes['comp'] = True elif task.choice_flag == action.APPLY: # Applying autotagged metadata. Just get AA from the first # item. for item in task.items: if item is not None: first_item = item break else: assert False, "all items are None" if not first_item.albumartist: changes['albumartist'] = first_item.artist if not first_item.mb_albumartistid: changes['mb_albumartistid'] = first_item.mb_artistid else: assert False # Apply new metadata. for item in task.items: if item is not None: for k, v in changes.iteritems(): setattr(item, k, v) def _resume(): """Check whether an import should resume and return a boolean or the string 'ask' indicating that the user should be queried. """ return config['import']['resume'].as_choice([True, False, 'ask']) def _open_state(): """Reads the state file, returning a dictionary.""" try: with open(config['statefile'].as_filename()) as f: return pickle.load(f) except (IOError, EOFError): return {} def _save_state(state): """Writes the state dictionary out to disk.""" try: with open(config['statefile'].as_filename(), 'w') as f: pickle.dump(state, f) except IOError as exc: log.error(u'state file could not be written: %s' % unicode(exc)) # Utilities for reading and writing the beets progress file, which # allows long tagging tasks to be resumed when they pause (or crash). PROGRESS_KEY = 'tagprogress' def progress_set(toppath, paths): """Record that tagging for the given `toppath` was successful up to `paths`. If paths is None, then clear the progress value (indicating that the tagging completed). """ state = _open_state() if PROGRESS_KEY not in state: state[PROGRESS_KEY] = {} if paths is None: # Remove progress from file. if toppath in state[PROGRESS_KEY]: del state[PROGRESS_KEY][toppath] else: state[PROGRESS_KEY][toppath] = paths _save_state(state) def progress_get(toppath): """Get the last successfully tagged subpath of toppath. If toppath has no progress information, returns None. """ state = _open_state() if PROGRESS_KEY not in state: return None return state[PROGRESS_KEY].get(toppath) # Similarly, utilities for manipulating the "incremental" import log. # This keeps track of all directories that were ever imported, which # allows the importer to only import new stuff. HISTORY_KEY = 'taghistory' def history_add(paths): """Indicate that the import of the album in `paths` is completed and should not be repeated in incremental imports. """ state = _open_state() if HISTORY_KEY not in state: state[HISTORY_KEY] = set() state[HISTORY_KEY].add(tuple(paths)) _save_state(state) def history_get(): """Get the set of completed path tuples in incremental imports. """ state = _open_state() if HISTORY_KEY not in state: return set() return state[HISTORY_KEY] # Abstract session class. class ImportSession(object): """Controls an import action. Subclasses should implement methods to communicate with the user or otherwise make decisions. """ def __init__(self, lib, logfile, paths, query): """Create a session. `lib` is a Library object. `logfile` is a file-like object open for writing or None if no logging is to be performed. Either `paths` or `query` is non-null and indicates the source of files to be imported. """ self.lib = lib self.logfile = logfile self.paths = paths self.query = query # Normalize the paths. if self.paths: self.paths = map(normpath, self.paths) def _amend_config(self): """Make implied changes the importer configuration. """ # FIXME: Maybe this function should not exist and should instead # provide "decision wrappers" like "should_resume()", etc. iconfig = config['import'] # Incremental and progress are mutually exclusive. if iconfig['incremental']: iconfig['resume'] = False # When based on a query instead of directories, never # save progress or try to resume. if self.query is not None: iconfig['resume'] = False iconfig['incremental'] = False # Copy and move are mutually exclusive. if iconfig['move']: iconfig['copy'] = False # Only delete when copying. if not iconfig['copy']: iconfig['delete'] = False def tag_log(self, status, paths): """Log a message about a given album to logfile. The status should reflect the reason the album couldn't be tagged. """ if self.logfile: print(u'{0} {1}'.format(status, displayable_path(paths)), file=self.logfile) self.logfile.flush() def log_choice(self, task, duplicate=False): """Logs the task's current choice if it should be logged. If ``duplicate``, then this is a secondary choice after a duplicate was detected and a decision was made. """ paths = task.paths if task.is_album else [task.item.path] if duplicate: # Duplicate: log all three choices (skip, keep both, and trump). if task.remove_duplicates: self.tag_log('duplicate-replace', paths) elif task.choice_flag in (action.ASIS, action.APPLY): self.tag_log('duplicate-keep', paths) elif task.choice_flag is (action.SKIP): self.tag_log('duplicate-skip', paths) else: # Non-duplicate: log "skip" and "asis" choices. if task.choice_flag is action.ASIS: self.tag_log('asis', paths) elif task.choice_flag is action.SKIP: self.tag_log('skip', paths) def should_resume(self, path): raise NotImplementedError def choose_match(self, task): raise NotImplementedError def resolve_duplicate(self, task): raise NotImplementedError def choose_item(self, task): raise NotImplementedError def run(self): """Run the import task. """ self._amend_config() # Set up the pipeline. if self.query is None: stages = [read_tasks(self)] else: stages = [query_tasks(self)] if config['import']['singletons']: # Singleton importer. if config['import']['autotag']: stages += [item_lookup(self), item_query(self)] else: stages += [item_progress(self)] else: # Whole-album importer. if config['import']['autotag']: # Only look up and query the user when autotagging. stages += [initial_lookup(self), user_query(self)] else: # When not autotagging, just display progress. stages += [show_progress(self)] stages += [apply_choices(self)] for stage_func in plugins.import_stages(): stages.append(plugin_stage(self, stage_func)) stages += [manipulate_files(self)] stages += [finalize(self)] pl = pipeline.Pipeline(stages) # Run the pipeline. try: if config['threaded']: pl.run_parallel(QUEUE_SIZE) else: pl.run_sequential() except ImportAbort: # User aborted operation. Silently stop. pass # The importer task class. class ImportTask(object): """Represents a single set of items to be imported along with its intermediate state. May represent an album or a single item. """ def __init__(self, toppath=None, paths=None, items=None): self.toppath = toppath self.paths = paths self.items = items self.sentinel = False self.remove_duplicates = False self.is_album = True @classmethod def done_sentinel(cls, toppath): """Create an ImportTask that indicates the end of a top-level directory import. """ obj = cls(toppath) obj.sentinel = True return obj @classmethod def progress_sentinel(cls, toppath, paths): """Create a task indicating that a single directory in a larger import has finished. This is only required for singleton imports; progress is implied for album imports. """ obj = cls(toppath, paths) obj.sentinel = True return obj @classmethod def item_task(cls, item): """Creates an ImportTask for a single item.""" obj = cls() obj.item = item obj.is_album = False return obj def set_candidates(self, cur_artist, cur_album, candidates, rec): """Sets the candidates for this album matched by the `autotag.tag_album` method. """ assert self.is_album assert not self.sentinel self.cur_artist = cur_artist self.cur_album = cur_album self.candidates = candidates self.rec = rec def set_null_candidates(self): """Set the candidates to indicate no album match was found. """ self.cur_artist = None self.cur_album = None self.candidates = None self.rec = None def set_item_candidates(self, candidates, rec): """Set the match for a single-item task.""" assert not self.is_album assert self.item is not None self.candidates = candidates self.rec = rec def set_choice(self, choice): """Given an AlbumMatch or TrackMatch object or an action constant, indicates that an action has been selected for this task. """ assert not self.sentinel # Not part of the task structure: assert choice not in (action.MANUAL, action.MANUAL_ID) assert choice != action.APPLY # Only used internally. if choice in (action.SKIP, action.ASIS, action.TRACKS): self.choice_flag = choice self.match = None else: if self.is_album: assert isinstance(choice, autotag.AlbumMatch) else: assert isinstance(choice, autotag.TrackMatch) self.choice_flag = action.APPLY # Implicit choice. self.match = choice def save_progress(self): """Updates the progress state to indicate that this album has finished. """ if self.sentinel and self.paths is None: # "Done" sentinel. progress_set(self.toppath, None) elif self.sentinel or self.is_album: # "Directory progress" sentinel for singletons or a real # album task, which implies the same. progress_set(self.toppath, self.paths) def save_history(self): """Save the directory in the history for incremental imports. """ if self.is_album and not self.sentinel: history_add(self.paths) # Logical decisions. def should_write_tags(self): """Should new info be written to the files' metadata?""" if self.choice_flag == action.APPLY: return True elif self.choice_flag in (action.ASIS, action.TRACKS, action.SKIP): return False else: assert False def should_skip(self): """After a choice has been made, returns True if this is a sentinel or it has been marked for skipping. """ return self.sentinel or self.choice_flag == action.SKIP # Convenient data. def chosen_ident(self): """Returns identifying metadata about the current choice. For albums, this is an (artist, album) pair. For items, this is (artist, title). May only be called when the choice flag is ASIS (in which case the data comes from the files' current metadata) or APPLY (data comes from the choice). """ assert self.choice_flag in (action.ASIS, action.APPLY) if self.is_album: if self.choice_flag is action.ASIS: return (self.cur_artist, self.cur_album) elif self.choice_flag is action.APPLY: return (self.match.info.artist, self.match.info.album) else: if self.choice_flag is action.ASIS: return (self.item.artist, self.item.title) elif self.choice_flag is action.APPLY: return (self.match.info.artist, self.match.info.title) def imported_items(self): """Return a list of Items that should be added to the library. If this is an album task, return the list of items in the selected match or everything if the choice is ASIS. If this is a singleton task, return a list containing the item. """ if self.is_album: if self.choice_flag == action.ASIS: return list(self.items) elif self.choice_flag == action.APPLY: return self.match.mapping.keys() else: assert False else: return [self.item] # Utilities. def prune(self, filename): """Prune any empty directories above the given file. If this task has no `toppath` or the file path provided is not within the `toppath`, then this function has no effect. Similarly, if the file still exists, no pruning is performed, so it's safe to call when the file in question may not have been removed. """ if self.toppath and not os.path.exists(filename): util.prune_dirs(os.path.dirname(filename), self.toppath, clutter=config['clutter'].get(list)) # Full-album pipeline stages. def read_tasks(session): """A generator yielding all the albums (as ImportTask objects) found in the user-specified list of paths. In the case of a singleton import, yields single-item tasks instead. """ # Look for saved progress. if _resume(): resume_dirs = {} for path in session.paths: resume_dir = progress_get(path) if resume_dir: # Either accept immediately or prompt for input to decide. if _resume() is True: do_resume = True log.warn('Resuming interrupted import of %s' % path) else: do_resume = session.should_resume(path) if do_resume: resume_dirs[path] = resume_dir else: # Clear progress; we're starting from the top. progress_set(path, None) # Look for saved incremental directories. if config['import']['incremental']: incremental_skipped = 0 history_dirs = history_get() for toppath in session.paths: # Check whether the path is to a file. if config['import']['singletons'] and \ not os.path.isdir(syspath(toppath)): try: item = library.Item.from_path(toppath) except UnreadableFileError: log.warn(u'unreadable file: {0}'.format( util.displayable_path(toppath) )) continue yield ImportTask.item_task(item) continue # A flat album import merges all items into one album. if config['import']['flat'] and not config['import']['singletons']: all_items = [] for _, items in autotag.albums_in_dir(toppath): all_items += items yield ImportTask(toppath, toppath, all_items) yield ImportTask.done_sentinel(toppath) continue # Produce paths under this directory. if _resume(): resume_dir = resume_dirs.get(toppath) for path, items in autotag.albums_in_dir(toppath): # Skip according to progress. if _resume() and resume_dir: # We're fast-forwarding to resume a previous tagging. if path == resume_dir: # We've hit the last good path! Turn off the # fast-forwarding. resume_dir = None continue # When incremental, skip paths in the history. if config['import']['incremental'] and tuple(path) in history_dirs: log.debug(u'Skipping previously-imported path: %s' % displayable_path(path)) incremental_skipped += 1 continue # Yield all the necessary tasks. if config['import']['singletons']: for item in items: yield ImportTask.item_task(item) yield ImportTask.progress_sentinel(toppath, path) else: yield ImportTask(toppath, path, items) # Indicate the directory is finished. yield ImportTask.done_sentinel(toppath) # Show skipped directories. if config['import']['incremental'] and incremental_skipped: log.info(u'Incremental import: skipped %i directories.' % incremental_skipped) def query_tasks(session): """A generator that works as a drop-in-replacement for read_tasks. Instead of finding files from the filesystem, a query is used to match items from the library. """ if config['import']['singletons']: # Search for items. for item in session.lib.items(session.query): yield ImportTask.item_task(item) else: # Search for albums. for album in session.lib.albums(session.query): log.debug('yielding album %i: %s - %s' % (album.id, album.albumartist, album.album)) items = list(album.items()) yield ImportTask(None, [album.item_dir()], items) def initial_lookup(session): """A coroutine for performing the initial MusicBrainz lookup for an album. It accepts lists of Items and yields (items, cur_artist, cur_album, candidates, rec) tuples. If no match is found, all of the yielded parameters (except items) are None. """ task = None while True: task = yield task if task.sentinel: continue plugins.send('import_task_start', session=session, task=task) log.debug('Looking up: %s' % displayable_path(task.paths)) task.set_candidates( *autotag.tag_album(task.items) ) def user_query(session): """A coroutine for interfacing with the user about the tagging process. lib is the Library to import into and logfile may be a file-like object for logging the import process. The coroutine accepts and yields ImportTask objects. """ recent = set() task = None while True: task = yield task if task.sentinel: continue # Ask the user for a choice. choice = session.choose_match(task) task.set_choice(choice) session.log_choice(task) plugins.send('import_task_choice', session=session, task=task) # As-tracks: transition to singleton workflow. if choice is action.TRACKS: # Set up a little pipeline for dealing with the singletons. item_tasks = [] def emitter(): for item in task.items: yield ImportTask.item_task(item) yield ImportTask.progress_sentinel(task.toppath, task.paths) def collector(): while True: item_task = yield item_tasks.append(item_task) ipl = pipeline.Pipeline((emitter(), item_lookup(session), item_query(session), collector())) ipl.run_sequential() task = pipeline.multiple(item_tasks) continue # Check for duplicates if we have a match (or ASIS). if task.choice_flag in (action.ASIS, action.APPLY): ident = task.chosen_ident() # The "recent" set keeps track of identifiers for recently # imported albums -- those that haven't reached the database # yet. if ident in recent or _duplicate_check(session.lib, task): session.resolve_duplicate(task) session.log_choice(task, True) recent.add(ident) def show_progress(session): """This stage replaces the initial_lookup and user_query stages when the importer is run without autotagging. It displays the album name and artist as the files are added. """ task = None while True: task = yield task if task.sentinel: continue log.info(displayable_path(task.paths)) # Behave as if ASIS were selected. task.set_null_candidates() task.set_choice(action.ASIS) def apply_choices(session): """A coroutine for applying changes to albums and singletons during the autotag process. """ task = None while True: task = yield task if task.should_skip(): continue items = task.imported_items() # Clear IDs in case the items are being re-tagged. for item in items: item.id = None item.album_id = None # Change metadata. if task.should_write_tags(): if task.is_album: autotag.apply_metadata( task.match.info, task.match.mapping ) else: autotag.apply_item_metadata(task.item, task.match.info) plugins.send('import_task_apply', session=session, task=task) # Infer album-level fields. if task.is_album: _infer_album_fields(task) # Find existing item entries that these are replacing (for # re-imports). Old album structures are automatically cleaned up # when the last item is removed. task.replaced_items = defaultdict(list) for item in items: dup_items = session.lib.items( library.MatchQuery('path', item.path) ) for dup_item in dup_items: task.replaced_items[item].append(dup_item) log.debug('replacing item %i: %s' % (dup_item.id, displayable_path(item.path))) log.debug('%i of %i items replaced' % (len(task.replaced_items), len(items))) # Find old items that should be replaced as part of a duplicate # resolution. duplicate_items = [] if task.remove_duplicates: if task.is_album: for album in _duplicate_check(session.lib, task): duplicate_items += album.items() else: duplicate_items = _item_duplicate_check(session.lib, task) log.debug('removing %i old duplicated items' % len(duplicate_items)) # Delete duplicate files that are located inside the library # directory. task.duplicate_paths = [] for duplicate_path in [i.path for i in duplicate_items]: if session.lib.directory in util.ancestry(duplicate_path): # Mark the path for deletion in the manipulate_files # stage. task.duplicate_paths.append(duplicate_path) # Add items -- before path changes -- to the library. We add the # items now (rather than at the end) so that album structures # are in place before calls to destination(). with session.lib.transaction(): # Remove old items. for replaced in task.replaced_items.itervalues(): for item in replaced: item.remove() for item in duplicate_items: item.remove() # Add new ones. if task.is_album: # Add an album. album = session.lib.add_album(items) task.album_id = album.id else: # Add tracks. for item in items: session.lib.add(item) def plugin_stage(session, func): """A coroutine (pipeline stage) that calls the given function with each non-skipped import task. These stages occur between applying metadata changes and moving/copying/writing files. """ task = None while True: task = yield task if task.should_skip(): continue func(session, task) # Stage may modify DB, so re-load cached item data. for item in task.imported_items(): item.load() def manipulate_files(session): """A coroutine (pipeline stage) that performs necessary file manipulations *after* items have been added to the library. """ task = None while True: task = yield task if task.should_skip(): continue # Remove duplicate files marked for deletion. if task.remove_duplicates: for duplicate_path in task.duplicate_paths: log.debug(u'deleting replaced duplicate %s' % util.displayable_path(duplicate_path)) util.remove(duplicate_path) util.prune_dirs(os.path.dirname(duplicate_path), session.lib.directory) # Move/copy/write files. items = task.imported_items() # Save the original paths of all items for deletion and pruning # in the next step (finalization). task.old_paths = [item.path for item in items] for item in items: if config['import']['move']: # Just move the file. item.move(False) elif config['import']['copy']: # If it's a reimport, move in-library files and copy # out-of-library files. Otherwise, copy and keep track # of the old path. old_path = item.path if task.replaced_items[item]: # This is a reimport. Move in-library files and copy # out-of-library files. if session.lib.directory in util.ancestry(old_path): item.move(False) # We moved the item, so remove the # now-nonexistent file from old_paths. task.old_paths.remove(old_path) else: item.move(True) else: # A normal import. Just copy files and keep track of # old paths. item.move(True) if config['import']['write'] and task.should_write_tags(): item.write() # Save new paths. with session.lib.transaction(): for item in items: item.store() # Plugin event. plugins.send('import_task_files', session=session, task=task) def finalize(session): """A coroutine that finishes up importer tasks. In particular, the coroutine sends plugin events, deletes old files, and saves progress. This is a "terminal" coroutine (it yields None). """ while True: task = yield if task.should_skip(): if _resume(): task.save_progress() if config['import']['incremental']: task.save_history() continue items = task.imported_items() # Announce that we've added an album. if task.is_album: album = session.lib.get_album(task.album_id) plugins.send('album_imported', lib=session.lib, album=album) else: for item in items: plugins.send('item_imported', lib=session.lib, item=item) # When copying and deleting originals, delete old files. if config['import']['copy'] and config['import']['delete']: new_paths = [os.path.realpath(item.path) for item in items] for old_path in task.old_paths: # Only delete files that were actually copied. if old_path not in new_paths: util.remove(syspath(old_path), False) task.prune(old_path) # When moving, prune empty directories containing the original # files. elif config['import']['move']: for old_path in task.old_paths: task.prune(old_path) # Update progress. if _resume(): task.save_progress() if config['import']['incremental']: task.save_history() # Singleton pipeline stages. def item_lookup(session): """A coroutine used to perform the initial MusicBrainz lookup for an item task. """ task = None while True: task = yield task if task.sentinel: continue plugins.send('import_task_start', session=session, task=task) task.set_item_candidates(*autotag.tag_item(task.item)) def item_query(session): """A coroutine that queries the user for input on single-item lookups. """ task = None recent = set() while True: task = yield task if task.sentinel: continue choice = session.choose_item(task) task.set_choice(choice) session.log_choice(task) plugins.send('import_task_choice', session=session, task=task) # Duplicate check. if task.choice_flag in (action.ASIS, action.APPLY): ident = task.chosen_ident() if ident in recent or _item_duplicate_check(session.lib, task): session.resolve_duplicate(task) session.log_choice(task, True) recent.add(ident) def item_progress(session): """Skips the lookup and query stages in a non-autotagged singleton import. Just shows progress. """ task = None log.info('Importing items:') while True: task = yield task if task.sentinel: continue log.info(displayable_path(task.item.path)) task.set_null_candidates() task.set_choice(action.ASIS) beets-1.3.1/beets/library.py0000644000076500000240000020705212225206252016706 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """The core data store and collection logic for beets. """ import sqlite3 import os import re import sys import logging import shlex import unicodedata import threading import contextlib import traceback import time from collections import defaultdict from unidecode import unidecode from beets.mediafile import MediaFile from beets import plugins from beets import util from beets.util import bytestring_path, syspath, normpath, samefile,\ displayable_path from beets.util.functemplate import Template import beets from datetime import datetime # Fields in the "items" database table; all the metadata available for # items in the library. These are used directly in SQL; they are # vulnerable to injection if accessible to the user. # Each tuple has the following values: # - The name of the field. # - The (Python) type of the field. # - Is the field writable? # - Does the field reflect an attribute of a MediaFile? ITEM_FIELDS = [ ('id', int, False, False), ('path', bytes, False, False), ('album_id', int, False, False), ('title', unicode, True, True), ('artist', unicode, True, True), ('artist_sort', unicode, True, True), ('artist_credit', unicode, True, True), ('album', unicode, True, True), ('albumartist', unicode, True, True), ('albumartist_sort', unicode, True, True), ('albumartist_credit', unicode, True, True), ('genre', unicode, True, True), ('composer', unicode, True, True), ('grouping', unicode, True, True), ('year', int, True, True), ('month', int, True, True), ('day', int, True, True), ('track', int, True, True), ('tracktotal', int, True, True), ('disc', int, True, True), ('disctotal', int, True, True), ('lyrics', unicode, True, True), ('comments', unicode, True, True), ('bpm', int, True, True), ('comp', bool, True, True), ('mb_trackid', unicode, True, True), ('mb_albumid', unicode, True, True), ('mb_artistid', unicode, True, True), ('mb_albumartistid', unicode, True, True), ('albumtype', unicode, True, True), ('label', unicode, True, True), ('acoustid_fingerprint', unicode, True, True), ('acoustid_id', unicode, True, True), ('mb_releasegroupid', unicode, True, True), ('asin', unicode, True, True), ('catalognum', unicode, True, True), ('script', unicode, True, True), ('language', unicode, True, True), ('country', unicode, True, True), ('albumstatus', unicode, True, True), ('media', unicode, True, True), ('albumdisambig', unicode, True, True), ('disctitle', unicode, True, True), ('encoder', unicode, True, True), ('rg_track_gain', float, True, True), ('rg_track_peak', float, True, True), ('rg_album_gain', float, True, True), ('rg_album_peak', float, True, True), ('original_year', int, True, True), ('original_month', int, True, True), ('original_day', int, True, True), ('length', float, False, True), ('bitrate', int, False, True), ('format', unicode, False, True), ('samplerate', int, False, True), ('bitdepth', int, False, True), ('channels', int, False, True), ('mtime', int, False, False), ('added', datetime, False, False), ] ITEM_KEYS_WRITABLE = [f[0] for f in ITEM_FIELDS if f[3] and f[2]] ITEM_KEYS_META = [f[0] for f in ITEM_FIELDS if f[3]] ITEM_KEYS = [f[0] for f in ITEM_FIELDS] # Database fields for the "albums" table. # The third entry in each tuple indicates whether the field reflects an # identically-named field in the items table. ALBUM_FIELDS = [ ('id', int, False), ('artpath', bytes, False), ('added', datetime, True), ('albumartist', unicode, True), ('albumartist_sort', unicode, True), ('albumartist_credit', unicode, True), ('album', unicode, True), ('genre', unicode, True), ('year', int, True), ('month', int, True), ('day', int, True), ('tracktotal', int, True), ('disctotal', int, True), ('comp', bool, True), ('mb_albumid', unicode, True), ('mb_albumartistid', unicode, True), ('albumtype', unicode, True), ('label', unicode, True), ('mb_releasegroupid', unicode, True), ('asin', unicode, True), ('catalognum', unicode, True), ('script', unicode, True), ('language', unicode, True), ('country', unicode, True), ('albumstatus', unicode, True), ('media', unicode, True), ('albumdisambig', unicode, True), ('rg_album_gain', float, True), ('rg_album_peak', float, True), ('original_year', int, True), ('original_month', int, True), ('original_day', int, True), ] ALBUM_KEYS = [f[0] for f in ALBUM_FIELDS] ALBUM_KEYS_ITEM = [f[0] for f in ALBUM_FIELDS if f[2]] # SQLite type names. SQLITE_TYPES = { int: 'INT', float: 'REAL', datetime: 'FLOAT', bytes: 'BLOB', unicode: 'TEXT', bool: 'INT', } SQLITE_KEY_TYPE = 'INTEGER PRIMARY KEY' # Default search fields for each model. ALBUM_DEFAULT_FIELDS = ('album', 'albumartist', 'genre') ITEM_DEFAULT_FIELDS = ALBUM_DEFAULT_FIELDS + ('artist', 'title', 'comments') # Special path format key. PF_KEY_DEFAULT = 'default' # Logger. log = logging.getLogger('beets') if not log.handlers: log.addHandler(logging.StreamHandler()) log.propagate = False # Don't propagate to root handler. # A little SQL utility. def _orelse(exp1, exp2): """Generates an SQLite expression that evaluates to exp1 if exp1 is non-null and non-empty or exp2 otherwise. """ return ('(CASE {0} WHEN NULL THEN {1} ' 'WHEN "" THEN {1} ' 'ELSE {0} END)').format(exp1, exp2) # Path element formatting for templating. def format_for_path(value, key=None, pathmod=None): """Sanitize the value for inclusion in a path: replace separators with _, etc. Doesn't guarantee that the whole path will be valid; you should still call `util.sanitize_path` on the complete path. """ pathmod = pathmod or os.path if isinstance(value, basestring): if isinstance(value, str): value = value.decode('utf8', 'ignore') sep_repl = beets.config['path_sep_replace'].get(unicode) for sep in (pathmod.sep, pathmod.altsep): if sep: value = value.replace(sep, sep_repl) elif key in ('track', 'tracktotal', 'disc', 'disctotal'): # Pad indices with zeros. value = u'%02i' % (value or 0) elif key == 'year': value = u'%04i' % (value or 0) elif key in ('month', 'day'): value = u'%02i' % (value or 0) elif key == 'bitrate': # Bitrate gets formatted as kbps. value = u'%ikbps' % ((value or 0) // 1000) elif key == 'samplerate': # Sample rate formatted as kHz. value = u'%ikHz' % ((value or 0) // 1000) elif key in ('added', 'mtime'): # Times are formatted to be human-readable. value = time.strftime(beets.config['time_format'].get(unicode), time.localtime(value)) value = unicode(value) elif value is None: value = u'' else: value = unicode(value) return value # Items (songs), albums, and their common bases. class FlexModel(object): """An abstract object that consists of a set of "fast" (fixed) fields and an arbitrary number of flexible fields. """ _fields = () """The available "fixed" fields on this type. """ def __init__(self, **values): """Create a new object with the given field values (which may be fixed or flex fields). """ self._dirty = set() self._values_fixed = {} self._values_flex = {} self.update(values) self.clear_dirty() def __repr__(self): return '{0}({1})'.format( type(self).__name__, ', '.join('{0}={1!r}'.format(k, v) for k, v in dict(self).items()), ) def clear_dirty(self): self._dirty = set() # Act like a dictionary. def __getitem__(self, key): """Get the value for a field. Fixed fields always return a value (which may be None); flex fields may raise a KeyError. """ if key in self._fields: return self._values_fixed.get(key) elif key in self._values_flex: return self._values_flex[key] else: raise KeyError(key) def __setitem__(self, key, value): """Assign the value for a field. """ source = self._values_fixed if key in self._fields \ else self._values_flex old_value = source.get(key) source[key] = value if old_value != value: self._dirty.add(key) def update(self, values): """Assign all values in the given dict. """ for key, value in values.items(): self[key] = value def keys(self): """Get all the keys (both fixed and flex) on this object. """ return list(self._fields) + self._values_flex.keys() def get(self, key, default=None): """Get the value for a given key or `default` if it does not exist. """ if key in self: return self[key] else: return default def __contains__(self, key): """Determine whether `key` is a fixed or flex attribute on this object. """ return key in self._fields or key in self._values_flex # Convenient attribute access. def __getattr__(self, key): if key.startswith('_'): raise AttributeError('model has no attribute {0!r}'.format(key)) else: try: return self[key] except KeyError: raise AttributeError('no such field {0!r}'.format(key)) def __setattr__(self, key, value): if key.startswith('_'): super(FlexModel, self).__setattr__(key, value) else: self[key] = value class LibModel(FlexModel): """A model base class that includes a reference to a Library object. It knows how to load and store itself from the database. """ _table = None """The main SQLite table name. """ _flex_table = None """The flex field SQLite table name. """ _bytes_keys = ('path', 'artpath') """Keys whose values should be stored as raw bytes blobs rather than strings. """ _search_fields = () """The fields that should be queried by default by unqualified query terms. """ def __init__(self, lib=None, **values): self._lib = lib super(LibModel, self).__init__(**values) def _check_db(self): """Ensure that this object is associated with a database row: it has a reference to a library (`_lib`) and an id. A ValueError exception is raised otherwise. """ if not self._lib: raise ValueError('{0} has no library'.format(type(self).__name__)) if not self.id: raise ValueError('{0} has no id'.format(type(self).__name__)) def store(self): """Save the object's metadata into the library database. """ self._check_db() # Build assignments for query. assignments = '' subvars = [] for key in self._fields: if key != 'id' and key in self._dirty: assignments += key + '=?,' value = self[key] # Wrap path strings in buffers so they get stored # "in the raw". if key in self._bytes_keys and isinstance(value, str): value = buffer(value) subvars.append(value) assignments = assignments[:-1] # Knock off last , with self._lib.transaction() as tx: # Main table update. if assignments: query = 'UPDATE {0} SET {1} WHERE id=?'.format( self._table, assignments ) subvars.append(self.id) tx.mutate(query, subvars) # Flexible attributes. for key, value in self._values_flex.items(): tx.mutate( 'INSERT INTO {0} ' '(entity_id, key, value) ' 'VALUES (?, ?, ?);'.format(self._flex_table), (self.id, key, value), ) self.clear_dirty() def load(self): """Refresh the object's metadata from the library database. """ self._check_db() stored_obj = self._lib._get(type(self), self.id) self.update(dict(stored_obj)) self.clear_dirty() def remove(self): """Remove the object's associated rows from the database. """ self._check_db() with self._lib.transaction() as tx: tx.mutate( 'DELETE FROM {0} WHERE id=?'.format(self._table), (self.id,) ) tx.mutate( 'DELETE FROM {0} WHERE entity_id=?'.format(self._flex_table), (self.id,) ) class Item(LibModel): _fields = ITEM_KEYS _table = 'items' _flex_table = 'item_attributes' _search_fields = ITEM_DEFAULT_FIELDS @classmethod def from_path(cls, path): """Creates a new item from the media file at the specified path. """ # Initiate with values that aren't read from files. i = cls(album_id=None) i.read(path) i.mtime = i.current_mtime() # Initial mtime. return i def __setitem__(self, key, value): """Set the item's value for a standard field or a flexattr. """ # Encode unicode paths and read buffers. if key == 'path': if isinstance(value, unicode): value = bytestring_path(value) elif isinstance(value, buffer): value = str(value) if key in ITEM_KEYS_WRITABLE: self.mtime = 0 # Reset mtime on dirty. super(Item, self).__setitem__(key, value) def update(self, values): """Sett all key/value pairs in the mapping. If mtime is specified, it is not reset (as it might otherwise be). """ super(Item, self).update(values) if self.mtime == 0 and 'mtime' in values: self.mtime = values['mtime'] def get_album(self): """Get the Album object that this item belongs to, if any, or None if the item is a singleton or is not associated with a library. """ if not self._lib: return None return self._lib.get_album(self) # Interaction with file metadata. def read(self, read_path=None): """Read the metadata from the associated file. If read_path is specified, read metadata from that file instead. """ if read_path is None: read_path = self.path else: read_path = normpath(read_path) try: f = MediaFile(syspath(read_path)) except (OSError, IOError) as exc: raise util.FilesystemError(exc, 'read', (read_path,), traceback.format_exc()) for key in ITEM_KEYS_META: value = getattr(f, key) if isinstance(value, (int, long)): # Filter values wider than 64 bits (in signed # representation). SQLite cannot store them. # py26: Post transition, we can use: # value.bit_length() > 63 if abs(value) >= 2 ** 63: value = 0 setattr(self, key, value) # Database's mtime should now reflect the on-disk value. if read_path == self.path: self.mtime = self.current_mtime() self.path = read_path def write(self): """Writes the item's metadata to the associated file. """ plugins.send('write', item=self) try: f = MediaFile(syspath(self.path)) except (OSError, IOError) as exc: raise util.FilesystemError(exc, 'read', (self.path,), traceback.format_exc()) for key in ITEM_KEYS_WRITABLE: setattr(f, key, getattr(self, key)) try: f.save(id3v23=beets.config['id3v23'].get(bool)) except (OSError, IOError) as exc: raise util.FilesystemError(exc, 'write', (self.path,), traceback.format_exc()) # The file has a new mtime. self.mtime = self.current_mtime() # Files themselves. def move_file(self, dest, copy=False): """Moves or copies the item's file, updating the path value if the move succeeds. If a file exists at ``dest``, then it is slightly modified to be unique. """ if not util.samefile(self.path, dest): dest = util.unique_path(dest) if copy: util.copy(self.path, dest) else: util.move(self.path, dest) plugins.send("item_moved", item=self, source=self.path, destination=dest) # Either copying or moving succeeded, so update the stored path. self.path = dest def current_mtime(self): """Returns the current mtime of the file, rounded to the nearest integer. """ return int(os.path.getmtime(syspath(self.path))) # Model methods. def remove(self, delete=False, with_album=True): """Removes the item. If `delete`, then the associated file is removed from disk. If `with_album`, then the item's album (if any) is removed if it the item was the last in the album. """ super(Item, self).remove() # Remove the album if it is empty. if with_album: album = self.get_album() if album and not album.items(): album.remove(delete, False) # Delete the associated file. if delete: util.remove(self.path) util.prune_dirs(os.path.dirname(self.path), self._lib.directory) self._lib._memotable = {} def move(self, copy=False, basedir=None, with_album=True): """Move the item to its designated location within the library directory (provided by destination()). Subdirectories are created as needed. If the operation succeeds, the item's path field is updated to reflect the new location. If copy is True, moving the file is copied rather than moved. basedir overrides the library base directory for the destination. If the item is in an album, the album is given an opportunity to move its art. (This can be disabled by passing with_album=False.) The item is stored to the database if it is in the database, so any dirty fields prior to the move() call will be written as a side effect. You probably want to call save() to commit the DB transaction. """ self._check_db() dest = self.destination(basedir=basedir) # Create necessary ancestry for the move. util.mkdirall(dest) # Perform the move and store the change. old_path = self.path self.move_file(dest, copy) self.store() # If this item is in an album, move its art. if with_album: album = self.get_album() if album: album.move_art(copy) album.store() # Prune vacated directory. if not copy: util.prune_dirs(os.path.dirname(old_path), self._lib.directory) # Templating. def evaluate_template(self, template, sanitize=False, pathmod=None): """Evaluates a Template object using the item's fields. If `sanitize`, then each value will be sanitized for inclusion in a file path. """ pathmod = pathmod or os.path # Get the item's Album if it has one. album = self.get_album() # Build the mapping for substitution in the template, # beginning with the values from the database. mapping = {} for key in ITEM_KEYS: # Get the values from either the item or its album. if key in ALBUM_KEYS_ITEM and album is not None: # From album. value = getattr(album, key) else: # From Item. value = getattr(self, key) if sanitize: value = format_for_path(value, key, pathmod) mapping[key] = value # Include the path if we're not sanitizing to construct a path. if sanitize: del mapping['path'] else: mapping['path'] = displayable_path(self.path) # Use the album artist if the track artist is not set and # vice-versa. if not mapping['artist']: mapping['artist'] = mapping['albumartist'] if not mapping['albumartist']: mapping['albumartist'] = mapping['artist'] # Flexible attributes. for key, value in self._values_flex.items(): if sanitize: value = format_for_path(value, None, pathmod) mapping[key] = value # Get values from plugins. for key, value in plugins.template_values(self).items(): if sanitize: value = format_for_path(value, key, pathmod) mapping[key] = value if album: for key, value in plugins.album_template_values(album).items(): if sanitize: value = format_for_path(value, key, pathmod) mapping[key] = value # Get template functions. funcs = DefaultTemplateFunctions(self, self._lib, pathmod).functions() funcs.update(plugins.template_funcs()) # Perform substitution. return template.substitute(mapping, funcs) def destination(self, pathmod=None, fragment=False, basedir=None, platform=None, path_formats=None): """Returns the path in the library directory designated for the item (i.e., where the file ought to be). fragment makes this method return just the path fragment underneath the root library directory; the path is also returned as Unicode instead of encoded as a bytestring. basedir can override the library's base directory for the destination. """ self._check_db() pathmod = pathmod or os.path platform = platform or sys.platform basedir = basedir or self._lib.directory path_formats = path_formats or self._lib.path_formats # Use a path format based on a query, falling back on the # default. for query, path_format in path_formats: if query == PF_KEY_DEFAULT: continue query = AndQuery.from_string(query) if query.match(self): # The query matches the item! Use the corresponding path # format. break else: # No query matched; fall back to default. for query, path_format in path_formats: if query == PF_KEY_DEFAULT: break else: assert False, "no default path format" if isinstance(path_format, Template): subpath_tmpl = path_format else: subpath_tmpl = Template(path_format) # Evaluate the selected template. subpath = self.evaluate_template(subpath_tmpl, True, pathmod) # Prepare path for output: normalize Unicode characters. if platform == 'darwin': subpath = unicodedata.normalize('NFD', subpath) else: subpath = unicodedata.normalize('NFC', subpath) # Truncate components and remove forbidden characters. subpath = util.sanitize_path(subpath, pathmod, self._lib.replacements) # Encode for the filesystem. if not fragment: subpath = bytestring_path(subpath) # Preserve extension. _, extension = pathmod.splitext(self.path) if fragment: # Outputting Unicode. extension = extension.decode('utf8', 'ignore') subpath += extension.lower() # Truncate too-long components. maxlen = beets.config['max_filename_length'].get(int) if not maxlen: # When zero, try to determine from filesystem. maxlen = util.max_filename_length(self._lib.directory) subpath = util.truncate_path(subpath, pathmod, maxlen) if fragment: return subpath else: return normpath(os.path.join(basedir, subpath)) class Album(LibModel): """Provides access to information about albums stored in a library. Reflects the library's "albums" table, including album art. """ _fields = ALBUM_KEYS _table = 'albums' _flex_table = 'album_attributes' _search_fields = ALBUM_DEFAULT_FIELDS def __setitem__(self, key, value): """Set the value of an album attribute.""" if key == 'artpath': if isinstance(value, unicode): value = bytestring_path(value) elif isinstance(value, buffer): value = bytes(value) super(Album, self).__setitem__(key, value) def items(self): """Returns an iterable over the items associated with this album. """ return self._lib.items(MatchQuery('album_id', self.id)) def remove(self, delete=False, with_items=True): """Removes this album and all its associated items from the library. If delete, then the items' files are also deleted from disk, along with any album art. The directories containing the album are also removed (recursively) if empty. Set with_items to False to avoid removing the album's items. """ super(Album, self).remove() # Delete art file. if delete: artpath = self.artpath if artpath: util.remove(artpath) # Remove (and possibly delete) the constituent items. if with_items: for item in self.items(): item.remove(delete, False) def move_art(self, copy=False): """Move or copy any existing album art so that it remains in the same directory as the items. """ old_art = self.artpath if not old_art: return new_art = self.art_destination(old_art) if new_art == old_art: return new_art = util.unique_path(new_art) log.debug('moving album art %s to %s' % (old_art, new_art)) if copy: util.copy(old_art, new_art) else: util.move(old_art, new_art) self.artpath = new_art # Prune old path when moving. if not copy: util.prune_dirs(os.path.dirname(old_art), self._lib.directory) def move(self, copy=False, basedir=None): """Moves (or copies) all items to their destination. Any album art moves along with them. basedir overrides the library base directory for the destination. The album is stored to the database, persisting any modifications to its metadata. """ basedir = basedir or self._lib.directory # Ensure new metadata is available to items for destination # computation. self.store() # Move items. items = list(self.items()) for item in items: item.move(copy, basedir=basedir, with_album=False) # Move art. self.move_art(copy) self.store() def item_dir(self): """Returns the directory containing the album's first item, provided that such an item exists. """ item = self.items().get() if not item: raise ValueError('empty album') return os.path.dirname(item.path) def art_destination(self, image, item_dir=None): """Returns a path to the destination for the album art image for the album. `image` is the path of the image that will be moved there (used for its extension). The path construction uses the existing path of the album's items, so the album must contain at least one item or item_dir must be provided. """ image = bytestring_path(image) item_dir = item_dir or self.item_dir() filename_tmpl = Template(beets.config['art_filename'].get(unicode)) subpath = format_for_path(self.evaluate_template(filename_tmpl)) subpath = util.sanitize_path(subpath, replacements=self._lib.replacements) subpath = bytestring_path(subpath) _, ext = os.path.splitext(image) dest = os.path.join(item_dir, subpath + ext) return bytestring_path(dest) def set_art(self, path, copy=True): """Sets the album's cover art to the image at the given path. The image is copied (or moved) into place, replacing any existing art. """ path = bytestring_path(path) oldart = self.artpath artdest = self.art_destination(path) if oldart and samefile(path, oldart): # Art already set. return elif samefile(path, artdest): # Art already in place. self.artpath = path return # Normal operation. if oldart == artdest: util.remove(oldart) artdest = util.unique_path(artdest) if copy: util.copy(path, artdest) else: util.move(path, artdest) self.artpath = artdest def evaluate_template(self, template): """Evaluates a Template object using the album's fields. """ # Get template field values. mapping = {} for key, value in dict(self).items(): mapping[key] = format_for_path(value, key) mapping['artpath'] = displayable_path(mapping['artpath']) mapping['path'] = displayable_path(self.item_dir()) # Get values from plugins. for key, value in plugins.album_template_values(self).iteritems(): mapping[key] = value # Get template functions. funcs = DefaultTemplateFunctions().functions() funcs.update(plugins.template_funcs()) # Perform substitution. return template.substitute(mapping, funcs) def store(self): """Update the database with the album information. The album's tracks are also updated. """ # Get modified track fields. track_updates = {} for key in ALBUM_KEYS_ITEM: if key in self._dirty: track_updates[key] = self[key] with self._lib.transaction(): super(Album, self).store() if track_updates: for item in self.items(): for key, value in track_updates.items(): item[key] = value item.store() # Query abstraction hierarchy. class Query(object): """An abstract class representing a query into the item database. """ def clause(self): """Generate an SQLite expression implementing the query. Return a clause string, a sequence of substitution values for the clause, and a Query object representing the "remainder" Returns (clause, subvals) where clause is a valid sqlite WHERE clause implementing the query and subvals is a list of items to be substituted for ?s in the clause. """ return None, () def match(self, item): """Check whether this query matches a given Item. Can be used to perform queries on arbitrary sets of Items. """ raise NotImplementedError class FieldQuery(Query): """An abstract query that searches in a specific field for a pattern. Subclasses must provide a `value_match` class method, which determines whether a certain pattern string matches a certain value string. Subclasses may also provide `col_clause` to implement the same matching functionality in SQLite. """ def __init__(self, field, pattern, fast=True): self.field = field self.pattern = pattern self.fast = fast def col_clause(self): return None, () def clause(self): if self.fast: return self.col_clause() else: # Matching a flexattr. This is a slow query. return None, () @classmethod def value_match(cls, pattern, value): """Determine whether the value matches the pattern. Both arguments are strings. """ raise NotImplementedError() @classmethod def _raw_value_match(cls, pattern, value): """Determine whether the value matches the pattern. The value may have any type. """ return cls.value_match(pattern, util.as_string(value)) def match(self, item): return self._raw_value_match(self.pattern, item.get(self.field)) class MatchQuery(FieldQuery): """A query that looks for exact matches in an item field.""" def col_clause(self): pattern = self.pattern if self.field == 'path' and isinstance(pattern, str): pattern = buffer(pattern) return self.field + " = ?", [pattern] # We override the "raw" version here as a special case because we # want to compare objects before conversion. @classmethod def _raw_value_match(cls, pattern, value): return pattern == value class SubstringQuery(FieldQuery): """A query that matches a substring in a specific item field.""" def col_clause(self): search = '%' + (self.pattern.replace('\\','\\\\').replace('%','\\%') .replace('_','\\_')) + '%' clause = self.field + " like ? escape '\\'" subvals = [search] return clause, subvals @classmethod def value_match(cls, pattern, value): return pattern.lower() in value.lower() class RegexpQuery(FieldQuery): """A query that matches a regular expression in a specific item field. """ @classmethod def value_match(cls, pattern, value): try: res = re.search(pattern, value) except re.error: # Invalid regular expression. return False return res is not None class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a string reflecting a boolean. """ def __init__(self, field, pattern): super(BooleanQuery, self).__init__(field, pattern) if isinstance(pattern, basestring): self.pattern = util.str2bool(pattern) self.pattern = int(self.pattern) class NumericQuery(FieldQuery): """Matches numeric fields. A syntax using Ruby-style range ellipses (``..``) lets users specify one- or two-sided ranges. For example, ``year:2001..`` finds music released since the turn of the century. """ kinds = dict((r[0], r[1]) for r in ITEM_FIELDS) @classmethod def applies_to(cls, field): """Determine whether a field has numeric type. NumericQuery should only be used with such fields. """ return cls.kinds.get(field) in (int, float) def _convert(self, s): """Convert a string to the appropriate numeric type. If the string cannot be converted, return None. """ try: return self.numtype(s) except ValueError: return None def __init__(self, field, pattern, fast=True): super(NumericQuery, self).__init__(field, pattern, fast) self.numtype = self.kinds[field] parts = pattern.split('..', 1) if len(parts) == 1: # No range. self.point = self._convert(parts[0]) self.rangemin = None self.rangemax = None else: # One- or two-sided range. self.point = None self.rangemin = self._convert(parts[0]) self.rangemax = self._convert(parts[1]) def match(self, item): value = getattr(item, self.field) if isinstance(value, basestring): value = self._convert(value) if self.point is not None: return value == self.point else: if self.rangemin is not None and value < self.rangemin: return False if self.rangemax is not None and value > self.rangemax: return False return True def col_clause(self): if self.point is not None: return self.field + '=?', (self.point,) else: if self.rangemin is not None and self.rangemax is not None: return (u'{0} >= ? AND {0} <= ?'.format(self.field), (self.rangemin, self.rangemax)) elif self.rangemin is not None: return u'{0} >= ?'.format(self.field), (self.rangemin,) elif self.rangemax is not None: return u'{0} <= ?'.format(self.field), (self.rangemax,) else: return '1' class SingletonQuery(Query): """Matches either singleton or non-singleton items.""" def __init__(self, sense): self.sense = sense def clause(self): if self.sense: return "album_id ISNULL", () else: return "NOT album_id ISNULL", () def match(self, item): return (not item.album_id) == self.sense class CollectionQuery(Query): """An abstract query class that aggregates other queries. Can be indexed like a list to access the sub-queries. """ def __init__(self, subqueries=()): self.subqueries = subqueries # is there a better way to do this? def __len__(self): return len(self.subqueries) def __getitem__(self, key): return self.subqueries[key] def __iter__(self): return iter(self.subqueries) def __contains__(self, item): return item in self.subqueries def clause_with_joiner(self, joiner): """Returns a clause created by joining together the clauses of all subqueries with the string joiner (padded by spaces). """ clause_parts = [] subvals = [] for subq in self.subqueries: subq_clause, subq_subvals = subq.clause() if not subq_clause: # Fall back to slow query. return None, () clause_parts.append('(' + subq_clause + ')') subvals += subq_subvals clause = (' ' + joiner + ' ').join(clause_parts) return clause, subvals @classmethod def from_strings(cls, query_parts, default_fields, all_keys): """Creates a query from a list of strings in the format used by parse_query_part. If default_fields are specified, they are the fields to be searched by unqualified search terms. Otherwise, all fields are searched for those terms. """ subqueries = [] for part in query_parts: subq = construct_query_part(part, default_fields, all_keys) if subq: subqueries.append(subq) if not subqueries: # No terms in query. subqueries = [TrueQuery()] return cls(subqueries) @classmethod def from_string(cls, query, default_fields=ITEM_DEFAULT_FIELDS, all_keys=ITEM_KEYS): """Creates a query based on a single string. The string is split into query parts using shell-style syntax. """ # A bug in Python < 2.7.3 prevents correct shlex splitting of # Unicode strings. # http://bugs.python.org/issue6988 if isinstance(query, unicode): query = query.encode('utf8') parts = [s.decode('utf8') for s in shlex.split(query)] return cls.from_strings(parts, default_fields, all_keys) class AnyFieldQuery(CollectionQuery): """A query that matches if a given FieldQuery subclass matches in any field. The individual field query class is provided to the constructor. """ def __init__(self, pattern, fields, cls): self.pattern = pattern self.fields = fields self.query_class = cls subqueries = [] for field in self.fields: subqueries.append(cls(field, pattern, True)) super(AnyFieldQuery, self).__init__(subqueries) def clause(self): return self.clause_with_joiner('or') def match(self, item): for subq in self.subqueries: if subq.match(item): return True return False class MutableCollectionQuery(CollectionQuery): """A collection query whose subqueries may be modified after the query is initialized. """ def __setitem__(self, key, value): self.subqueries[key] = value def __delitem__(self, key): del self.subqueries[key] class AndQuery(MutableCollectionQuery): """A conjunction of a list of other queries.""" def clause(self): return self.clause_with_joiner('and') def match(self, item): return all([q.match(item) for q in self.subqueries]) class TrueQuery(Query): """A query that always matches.""" def clause(self): return '1', () def match(self, item): return True class FalseQuery(Query): """A query that never matches.""" def clause(self): return '0', () def match(self, item): return False class PathQuery(Query): """A query that matches all items under a given path.""" def __init__(self, path): # Match the path as a single file. self.file_path = bytestring_path(normpath(path)) # As a directory (prefix). self.dir_path = bytestring_path(os.path.join(self.file_path, '')) def match(self, item): return (item.path == self.file_path) or \ item.path.startswith(self.dir_path) def clause(self): dir_pat = buffer(self.dir_path + '%') file_blob = buffer(self.file_path) return '(path = ?) || (path LIKE ?)', (file_blob, dir_pat) # Query construction and parsing helpers. PARSE_QUERY_PART_REGEX = re.compile( # Non-capturing optional segment for the keyword. r'(?:' r'(\S+?)' # The field key. r'(?>> f = MediaFile('Lucy.mp3') >>> f.title u'Lucy in the Sky with Diamonds' >>> f.artist = 'The Beatles' >>> f.save() A field will always return a reasonable value of the correct type, even if no tag is present. If no value is available, the value will be false (e.g., zero or the empty string). """ import mutagen import mutagen.mp3 import mutagen.oggopus import mutagen.oggvorbis import mutagen.mp4 import mutagen.flac import mutagen.monkeysaudio import mutagen.asf import datetime import re import base64 import math import struct import imghdr import os import logging import traceback from beets.util.enumeration import enum __all__ = ['UnreadableFileError', 'FileTypeError', 'MediaFile'] # Logger. log = logging.getLogger('beets') # Exceptions. # Raised for any file MediaFile can't read. class UnreadableFileError(Exception): pass # Raised for files that don't seem to have a type MediaFile supports. class FileTypeError(UnreadableFileError): pass # Constants. # Human-readable type names. TYPES = { 'mp3': 'MP3', 'aac': 'AAC', 'alac': 'ALAC', 'ogg': 'OGG', 'opus': 'Opus', 'flac': 'FLAC', 'ape': 'APE', 'wv': 'WavPack', 'mpc': 'Musepack', 'asf': 'Windows Media', } MP4_TYPES = ('aac', 'alac') # Utility. def _safe_cast(out_type, val): """Tries to covert val to out_type but will never raise an exception. If the value can't be converted, then a sensible default value is returned. out_type should be bool, int, or unicode; otherwise, the value is just passed through. """ if out_type == int: if val is None: return 0 elif isinstance(val, int) or isinstance(val, float): # Just a number. return int(val) else: # Process any other type as a string. if not isinstance(val, basestring): val = unicode(val) # Get a number from the front of the string. val = re.match(r'[0-9]*', val.strip()).group(0) if not val: return 0 else: return int(val) elif out_type == bool: if val is None: return False else: try: if isinstance(val, mutagen.asf.ASFBoolAttribute): return val.value else: # Should work for strings, bools, ints: return bool(int(val)) except ValueError: return False elif out_type == unicode: if val is None: return u'' else: if isinstance(val, str): return val.decode('utf8', 'ignore') elif isinstance(val, unicode): return val else: return unicode(val) elif out_type == float: if val is None: return 0.0 elif isinstance(val, int) or isinstance(val, float): return float(val) else: if not isinstance(val, basestring): val = unicode(val) val = re.match(r'[\+-]?[0-9\.]*', val.strip()).group(0) if not val: return 0.0 else: return float(val) else: return val # Image coding for ASF/WMA. def _unpack_asf_image(data): """Unpack image data from a WM/Picture tag. Return a tuple containing the MIME type, the raw image data, a type indicator, and the image's description. This function is treated as "untrusted" and could throw all manner of exceptions (out-of-bounds, etc.). We should clean this up sometime so that the failure modes are well-defined. """ type, size = struct.unpack_from(" 0: gain = math.log10(maxgain / 1000.0) * -10 else: # Invalid gain value found. gain = 0.0 # SoundCheck stores peak values as the actual value of the sample, # and again separately for the left and right channels. We need to # convert this to a percentage of full scale, which is 32768 for a # 16 bit sample. Once again, we play it safe by using the larger of # the two values. peak = max(soundcheck[6:8]) / 32768.0 return round(gain, 2), round(peak, 6) def _sc_encode(gain, peak): """Encode ReplayGain gain/peak values as a Sound Check string. """ # SoundCheck stores the peak value as the actual value of the # sample, rather than the percentage of full scale that RG uses, so # we do a simple conversion assuming 16 bit samples. peak *= 32768.0 # SoundCheck stores absolute RMS values in some unknown units rather # than the dB values RG uses. We can calculate these absolute values # from the gain ratio using a reference value of 1000 units. We also # enforce the maximum value here, which is equivalent to about # -18.2dB. g1 = min(round((10 ** (gain / -10)) * 1000), 65534) # Same as above, except our reference level is 2500 units. g2 = min(round((10 ** (gain / -10)) * 2500), 65534) # The purpose of these values are unknown, but they also seem to be # unused so we just use zero. uk = 0 values = (g1, g1, g2, g2, uk, uk, peak, peak, uk, uk) return (u' %08X' * 10) % values # Flags for encoding field behavior. # Determine style of packing, if any. packing = enum('SLASHED', # pair delimited by / 'TUPLE', # a python tuple of 2 items 'DATE', # YYYY-MM-DD 'SC', # Sound Check gain/peak encoding name='packing') class StorageStyle(object): """Parameterizes the storage behavior of a single field for a certain tag format. - key: The Mutagen key used to access the field's data. - list_elem: Store item as a single object or as first element of a list. - as_type: Which type the value is stored as (unicode, int, bool, or str). - packing: If this value is packed in a multiple-value storage unit, which type of packing (in the packing enum). Otherwise, None. (Makes as_type irrelevant). - pack_pos: If the value is packed, in which position it is stored. - suffix: When `as_type` is a string type, append this before storing the value. - float_places: When the value is a floating-point number and encoded as a string, the number of digits to store after the point. For MP3 only: - id3_desc: match against this 'desc' field as well as the key. - id3_frame_field: store the data in this field of the frame object. - id3_lang: set the language field of the frame object. """ def __init__(self, key, list_elem=True, as_type=unicode, packing=None, pack_pos=0, pack_type=int, id3_desc=None, id3_frame_field='text', id3_lang=None, suffix=None, float_places=2): self.key = key self.list_elem = list_elem self.as_type = as_type self.packing = packing self.pack_pos = pack_pos self.pack_type = pack_type self.id3_desc = id3_desc self.id3_frame_field = id3_frame_field self.id3_lang = id3_lang self.suffix = suffix self.float_places = float_places # Convert suffix to correct string type. if self.suffix and self.as_type in (str, unicode): self.suffix = self.as_type(self.suffix) # Dealing with packings. class Packed(object): """Makes a packed list of values subscriptable. To access the packed output after making changes, use packed_thing.items. """ def __init__(self, items, packstyle, out_type=int): """Create a Packed object for subscripting the packed values in items. The items are packed using packstyle, which is a value from the packing enum. Values are converted to out_type before they are returned. """ self.items = items self.packstyle = packstyle self.out_type = out_type if out_type is int: self.none_val = 0 elif out_type is float: self.none_val = 0.0 else: self.none_val = None def __getitem__(self, index): if not isinstance(index, int): raise TypeError('index must be an integer') if self.items is None: return self.none_val items = self.items if self.packstyle == packing.DATE: # Remove time information from dates. Usually delimited by # a "T" or a space. items = re.sub(r'[Tt ].*$', '', unicode(items)) # transform from a string packing into a list we can index into if self.packstyle == packing.SLASHED: seq = unicode(items).split('/') elif self.packstyle == packing.DATE: seq = unicode(items).split('-') elif self.packstyle == packing.TUPLE: seq = items # tuple: items is already indexable elif self.packstyle == packing.SC: seq = _sc_decode(items) try: out = seq[index] except: out = None if out is None or out == self.none_val or out == '': return _safe_cast(self.out_type, self.none_val) else: return _safe_cast(self.out_type, out) def __setitem__(self, index, value): # Interpret null values. if value is None: value = self.none_val if self.packstyle in (packing.SLASHED, packing.TUPLE, packing.SC): # SLASHED, TUPLE and SC are always two-item packings length = 2 else: # DATE can have up to three fields length = 3 # make a list of the items we'll pack new_items = [] for i in range(length): if i == index: next_item = value else: next_item = self[i] new_items.append(next_item) if self.packstyle == packing.DATE: # Truncate the items wherever we reach an invalid (none) # entry. This prevents dates like 2008-00-05. for i, item in enumerate(new_items): if item == self.none_val or item is None: del(new_items[i:]) # truncate break if self.packstyle == packing.SLASHED: self.items = '/'.join(map(unicode, new_items)) elif self.packstyle == packing.DATE: field_lengths = [4, 2, 2] # YYYY-MM-DD elems = [] for i, item in enumerate(new_items): elems.append('{0:0{1}}'.format(int(item), field_lengths[i])) self.items = '-'.join(elems) elif self.packstyle == packing.TUPLE: self.items = new_items elif self.packstyle == packing.SC: self.items = _sc_encode(*new_items) # The field itself. class MediaField(object): """A descriptor providing access to a particular (abstract) metadata field. out_type is the type that users of MediaFile should see and can be unicode, int, or bool. id3, mp4, and flac are StorageStyle instances parameterizing the field's storage for each type. """ def __init__(self, out_type=unicode, **kwargs): """Creates a new MediaField. - out_type: The field's semantic (exterior) type. - kwargs: A hash whose keys are 'mp3', 'mp4', 'asf', and 'etc' and whose values are StorageStyle instances parameterizing the field's storage for each type. """ self.out_type = out_type if not set(['mp3', 'mp4', 'etc', 'asf']) == set(kwargs): raise TypeError('MediaField constructor must have keyword ' 'arguments mp3, mp4, asf, and etc') self.styles = kwargs def _fetchdata(self, obj, style): """Get the value associated with this descriptor's field stored with the given StorageStyle. Unwraps from a list if necessary. """ # fetch the value, which may be a scalar or a list if obj.type == 'mp3': if style.id3_desc is not None: # also match on 'desc' field frames = obj.mgfile.tags.getall(style.key) entry = None for frame in frames: if frame.desc.lower() == style.id3_desc.lower(): entry = getattr(frame, style.id3_frame_field) break if entry is None: # no desc match return None else: # Get the metadata frame object. try: frame = obj.mgfile[style.key] except KeyError: return None entry = getattr(frame, style.id3_frame_field) else: # Not MP3. try: entry = obj.mgfile[style.key] except KeyError: return None # Possibly index the list. if style.list_elem: if entry: # List must have at least one value. # Handle Mutagen bugs when reading values (#356). try: return entry[0] except: log.error('Mutagen exception when reading field: %s' % traceback.format_exc) return None else: return None else: return entry def _storedata(self, obj, val, style): """Store val for this descriptor's field in the tag dictionary according to the provided StorageStyle. Store it as a single-item list if necessary. """ # Wrap as a list if necessary. if style.list_elem: out = [val] else: out = val if obj.type == 'mp3': # Try to match on "desc" field. if style.id3_desc is not None: frames = obj.mgfile.tags.getall(style.key) # try modifying in place found = False for frame in frames: if frame.desc.lower() == style.id3_desc.lower(): setattr(frame, style.id3_frame_field, out) found = True break # need to make a new frame? if not found: assert isinstance(style.id3_frame_field, str) # Keyword. args = { 'encoding': 3, 'desc': style.id3_desc, style.id3_frame_field: val, } if style.id3_lang: args['lang'] = style.id3_lang obj.mgfile.tags.add(mutagen.id3.Frames[style.key](**args)) # Try to match on "owner" field. elif style.key.startswith('UFID:'): owner = style.key.split(':', 1)[1] frames = obj.mgfile.tags.getall(style.key) for frame in frames: # Replace existing frame data. if frame.owner == owner: setattr(frame, style.id3_frame_field, val) else: # New frame. assert isinstance(style.id3_frame_field, str) # Keyword. frame = mutagen.id3.UFID(owner=owner, **{style.id3_frame_field: val}) obj.mgfile.tags.setall('UFID', [frame]) # Just replace based on key. else: assert isinstance(style.id3_frame_field, str) # Keyword. frame = mutagen.id3.Frames[style.key](encoding=3, **{style.id3_frame_field: val}) obj.mgfile.tags.setall(style.key, [frame]) else: # Not MP3. obj.mgfile[style.key] = out def _styles(self, obj): if obj.type in ('mp3', 'asf'): styles = self.styles[obj.type] elif obj.type in MP4_TYPES: styles = self.styles['mp4'] else: styles = self.styles['etc'] # Sane styles. # Make sure we always return a list of styles, even when given # a single style for convenience. if isinstance(styles, StorageStyle): return [styles] else: return styles def __get__(self, obj, owner): """Retrieve the value of this metadata field. """ # Fetch the data using the various StorageStyles. styles = self._styles(obj) if styles is None: out = None else: for style in styles: # Use the first style that returns a reasonable value. out = self._fetchdata(obj, style) if out: break if style.packing: p = Packed(out, style.packing, out_type=style.pack_type) out = p[style.pack_pos] # Remove suffix. if style.suffix and isinstance(out, (str, unicode)): if out.endswith(style.suffix): out = out[:-len(style.suffix)] # MPEG-4 freeform frames are (should be?) encoded as UTF-8. if obj.type in MP4_TYPES and style.key.startswith('----:') and \ isinstance(out, str): out = out.decode('utf8') return _safe_cast(self.out_type, out) def __set__(self, obj, val): """Set the value of this metadata field. """ # Store using every StorageStyle available. styles = self._styles(obj) if styles is None: return for style in styles: if style.packing: p = Packed(self._fetchdata(obj, style), style.packing, out_type=style.pack_type) p[style.pack_pos] = val out = p.items else: # Unicode, integer, boolean, or float scalar. out = val # deal with Nones according to abstract type if present if out is None: if self.out_type == int: out = 0 elif self.out_type == float: out = 0.0 elif self.out_type == bool: out = False elif self.out_type == unicode: out = u'' # We trust that packed values are handled above. # Convert to correct storage type (irrelevant for # packed values). if self.out_type == float and style.as_type in (str, unicode): # Special case for float-valued data. out = u'{0:.{1}f}'.format(out, style.float_places) out = style.as_type(out) elif style.as_type == unicode: if out is None: out = u'' else: if self.out_type == bool: # Store bools as 1/0 instead of True/False. out = unicode(int(bool(out))) elif isinstance(out, str): out = out.decode('utf8', 'ignore') else: out = unicode(out) elif style.as_type == int: if out is None: out = 0 else: out = int(out) elif style.as_type in (bool, str): out = style.as_type(out) # Add a suffix to string storage. if style.as_type in (str, unicode) and style.suffix: out += style.suffix # MPEG-4 "freeform" (----) frames must be encoded as UTF-8 # byte strings. if obj.type in MP4_TYPES and style.key.startswith('----:') and \ isinstance(out, unicode): out = out.encode('utf8') # Store the data. self._storedata(obj, out, style) class CompositeDateField(object): """A MediaFile field for conveniently accessing the year, month, and day fields as a datetime.date object. Allows both getting and setting of the component fields. """ def __init__(self, year_field, month_field, day_field): """Create a new date field from the indicated MediaFields for the component values. """ self.year_field = year_field self.month_field = month_field self.day_field = day_field def __get__(self, obj, owner): """Return a datetime.date object whose components indicating the smallest valid date whose components are at least as large as the three component fields (that is, if year == 1999, month == 0, and day == 0, then date == datetime.date(1999, 1, 1)). If the components indicate an invalid date (e.g., if month == 47), datetime.date.min is returned. """ try: return datetime.date( max(self.year_field.__get__(obj, owner), datetime.MINYEAR), max(self.month_field.__get__(obj, owner), 1), max(self.day_field.__get__(obj, owner), 1) ) except ValueError: # Out of range values. return datetime.date.min def __set__(self, obj, val): """Set the year, month, and day fields to match the components of the provided datetime.date object. """ self.year_field.__set__(obj, val.year) self.month_field.__set__(obj, val.month) self.day_field.__set__(obj, val.day) class ImageField(object): """A descriptor providing access to a file's embedded album art. Holds a bytestring reflecting the image data. The image should either be a JPEG or a PNG for cross-format compatibility. It's probably a bad idea to use anything but these two formats. """ @classmethod def _mime(cls, data): """Return the MIME type (either image/png or image/jpeg) of the image data (a bytestring). """ kind = imghdr.what(None, h=data) if kind == 'png': return 'image/png' else: # Currently just fall back to JPEG. return 'image/jpeg' @classmethod def _mp4kind(cls, data): """Return the MPEG-4 image type code of the data. If the image is not a PNG or JPEG, JPEG is assumed. """ kind = imghdr.what(None, h=data) if kind == 'png': return mutagen.mp4.MP4Cover.FORMAT_PNG else: return mutagen.mp4.MP4Cover.FORMAT_JPEG def __get__(self, obj, owner): if obj.type == 'mp3': # Look for APIC frames. for frame in obj.mgfile.tags.values(): if frame.FrameID == 'APIC': picframe = frame break else: # No APIC frame. return None return picframe.data elif obj.type in MP4_TYPES: if 'covr' in obj.mgfile: covers = obj.mgfile['covr'] if covers: cover = covers[0] # cover is an MP4Cover, which is a subclass of str. return cover # No cover found. return None elif obj.type == 'flac': pictures = obj.mgfile.pictures if pictures: return pictures[0].data or None else: return None elif obj.type == 'asf': if 'WM/Picture' in obj.mgfile: pictures = obj.mgfile['WM/Picture'] if pictures: data = pictures[0].value try: return _unpack_asf_image(data)[1] except: return None return None else: # Here we're assuming everything but MP3, MPEG-4, FLAC, and # ASF/WMA use the Xiph/Vorbis Comments standard. This may # not be valid. http://wiki.xiph.org/VorbisComment#Cover_art if 'metadata_block_picture' not in obj.mgfile: # Try legacy COVERART tags. if 'coverart' in obj.mgfile and obj.mgfile['coverart']: return base64.b64decode(obj.mgfile['coverart'][0]) return None for data in obj.mgfile["metadata_block_picture"]: try: pic = mutagen.flac.Picture(base64.b64decode(data)) break except TypeError: pass else: return None if not pic.data: return None return pic.data def __set__(self, obj, val): if val is not None: if not isinstance(val, str): raise ValueError('value must be a byte string or None') if obj.type == 'mp3': # Clear all APIC frames. obj.mgfile.tags.delall('APIC') if val is None: # If we're clearing the image, we're done. return picframe = mutagen.id3.APIC( encoding=3, mime=self._mime(val), type=3, # Front cover. desc=u'', data=val, ) obj.mgfile['APIC'] = picframe elif obj.type in MP4_TYPES: if val is None: if 'covr' in obj.mgfile: del obj.mgfile['covr'] else: cover = mutagen.mp4.MP4Cover(val, self._mp4kind(val)) obj.mgfile['covr'] = [cover] elif obj.type == 'flac': obj.mgfile.clear_pictures() if val is not None: pic = mutagen.flac.Picture() pic.data = val pic.mime = self._mime(val) obj.mgfile.add_picture(pic) elif obj.type == 'asf': if 'WM/Picture' in obj.mgfile: del obj.mgfile['WM/Picture'] if val is not None: pic = mutagen.asf.ASFByteArrayAttribute() pic.value = _pack_asf_image(self._mime(val), val) obj.mgfile['WM/Picture'] = [pic] else: # Again, assuming Vorbis Comments standard. # Strip all art, including legacy COVERART. if 'metadata_block_picture' in obj.mgfile: if 'metadata_block_picture' in obj.mgfile: del obj.mgfile['metadata_block_picture'] if 'coverart' in obj.mgfile: del obj.mgfile['coverart'] if 'coverartmime' in obj.mgfile: del obj.mgfile['coverartmime'] # Add new art if provided. if val is not None: pic = mutagen.flac.Picture() pic.data = val pic.mime = self._mime(val) obj.mgfile['metadata_block_picture'] = [ base64.b64encode(pic.write()) ] # The file (a collection of fields). class MediaFile(object): """Represents a multimedia file on disk and provides access to its metadata. """ def __init__(self, path): """Constructs a new MediaFile reflecting the file at path. May throw UnreadableFileError. """ self.path = path unreadable_exc = ( mutagen.mp3.error, mutagen.id3.error, mutagen.flac.error, mutagen.monkeysaudio.MonkeysAudioHeaderError, mutagen.mp4.error, mutagen.oggopus.error, mutagen.oggvorbis.error, mutagen.ogg.error, mutagen.asf.error, mutagen.apev2.error, ) try: self.mgfile = mutagen.File(path) except unreadable_exc as exc: log.debug(u'header parsing failed: {0}'.format(unicode(exc))) raise UnreadableFileError('Mutagen could not read file') except IOError as exc: if type(exc) == IOError: # This is a base IOError, not a subclass from Mutagen or # anywhere else. raise else: log.debug(traceback.format_exc()) raise UnreadableFileError('Mutagen raised an exception') except Exception as exc: # Hide bugs in Mutagen. log.debug(traceback.format_exc()) log.error('uncaught Mutagen exception: {0}'.format(exc)) raise UnreadableFileError('Mutagen raised an exception') if self.mgfile is None: # Mutagen couldn't guess the type raise FileTypeError('file type unsupported by Mutagen') elif type(self.mgfile).__name__ == 'M4A' or \ type(self.mgfile).__name__ == 'MP4': # This hack differentiates AAC and ALAC until we find a more # deterministic approach. Mutagen only sets the sample rate # for AAC files. See: # https://github.com/sampsyo/beets/pull/295 if hasattr(self.mgfile.info, 'sample_rate') and \ self.mgfile.info.sample_rate > 0: self.type = 'aac' else: self.type = 'alac' elif type(self.mgfile).__name__ == 'ID3' or \ type(self.mgfile).__name__ == 'MP3': self.type = 'mp3' elif type(self.mgfile).__name__ == 'FLAC': self.type = 'flac' elif type(self.mgfile).__name__ == 'OggOpus': self.type = 'opus' elif type(self.mgfile).__name__ == 'OggVorbis': self.type = 'ogg' elif type(self.mgfile).__name__ == 'MonkeysAudio': self.type = 'ape' elif type(self.mgfile).__name__ == 'WavPack': self.type = 'wv' elif type(self.mgfile).__name__ == 'Musepack': self.type = 'mpc' elif type(self.mgfile).__name__ == 'ASF': self.type = 'asf' else: raise FileTypeError('file type %s unsupported by MediaFile' % type(self.mgfile).__name__) # add a set of tags if it's missing if self.mgfile.tags is None: self.mgfile.add_tags() def save(self, id3v23=False): """Write the object's tags back to the file. By default, MP3 files are saved with ID3v2.4 tags. You can use the older ID3v2.3 standard by specifying the `id3v23` option. """ if id3v23 and self.type == 'mp3': id3 = self.mgfile if hasattr(id3, 'tags'): # In case this is an MP3 object, not an ID3 object. id3 = id3.tags id3.update_to_v23() self.mgfile.save() def delete(self): """Remove the current metadata tag from the file. """ try: self.mgfile.delete() except NotImplementedError: # For Mutagen types that don't support deletion (notably, # ASF), just delete each tag individually. for tag in self.mgfile.keys(): del self.mgfile[tag] # Field definitions. title = MediaField( mp3=StorageStyle('TIT2'), mp4=StorageStyle("\xa9nam"), etc=StorageStyle('TITLE'), asf=StorageStyle('Title'), ) artist = MediaField( mp3=StorageStyle('TPE1'), mp4=StorageStyle("\xa9ART"), etc=StorageStyle('ARTIST'), asf=StorageStyle('Author'), ) album = MediaField( mp3=StorageStyle('TALB'), mp4=StorageStyle("\xa9alb"), etc=StorageStyle('ALBUM'), asf=StorageStyle('WM/AlbumTitle'), ) genre = MediaField( mp3=StorageStyle('TCON'), mp4=StorageStyle("\xa9gen"), etc=StorageStyle('GENRE'), asf=StorageStyle('WM/Genre'), ) composer = MediaField( mp3=StorageStyle('TCOM'), mp4=StorageStyle("\xa9wrt"), etc=StorageStyle('COMPOSER'), asf=StorageStyle('WM/Composer'), ) grouping = MediaField( mp3=StorageStyle('TIT1'), mp4=StorageStyle("\xa9grp"), etc=StorageStyle('GROUPING'), asf=StorageStyle('WM/ContentGroupDescription'), ) track = MediaField(out_type=int, mp3=StorageStyle('TRCK', packing=packing.SLASHED, pack_pos=0), mp4=StorageStyle('trkn', packing=packing.TUPLE, pack_pos=0), etc=[StorageStyle('TRACK'), StorageStyle('TRACKNUMBER')], asf=StorageStyle('WM/TrackNumber'), ) tracktotal = MediaField(out_type=int, mp3=StorageStyle('TRCK', packing=packing.SLASHED, pack_pos=1), mp4=StorageStyle('trkn', packing=packing.TUPLE, pack_pos=1), etc=[StorageStyle('TRACKTOTAL'), StorageStyle('TRACKC'), StorageStyle('TOTALTRACKS')], asf=StorageStyle('TotalTracks'), ) disc = MediaField(out_type=int, mp3=StorageStyle('TPOS', packing=packing.SLASHED, pack_pos=0), mp4=StorageStyle('disk', packing=packing.TUPLE, pack_pos=0), etc=[StorageStyle('DISC'), StorageStyle('DISCNUMBER')], asf=StorageStyle('WM/PartOfSet'), ) disctotal = MediaField(out_type=int, mp3=StorageStyle('TPOS', packing=packing.SLASHED, pack_pos=1), mp4=StorageStyle('disk', packing=packing.TUPLE, pack_pos=1), etc=[StorageStyle('DISCTOTAL'), StorageStyle('DISCC'), StorageStyle('TOTALDISCS')], asf=StorageStyle('TotalDiscs'), ) lyrics = MediaField( mp3=StorageStyle('USLT', list_elem=False, id3_desc=u''), mp4=StorageStyle("\xa9lyr"), etc=StorageStyle('LYRICS'), asf=StorageStyle('WM/Lyrics'), ) comments = MediaField( mp3=StorageStyle('COMM', id3_desc=u''), mp4=StorageStyle("\xa9cmt"), etc=[StorageStyle('DESCRIPTION'), StorageStyle('COMMENT')], asf=StorageStyle('WM/Comments'), ) bpm = MediaField( out_type=int, mp3=StorageStyle('TBPM'), mp4=StorageStyle('tmpo', as_type=int), etc=StorageStyle('BPM'), asf=StorageStyle('WM/BeatsPerMinute'), ) comp = MediaField( out_type=bool, mp3=StorageStyle('TCMP'), mp4=StorageStyle('cpil', list_elem=False, as_type=bool), etc=StorageStyle('COMPILATION'), asf=StorageStyle('WM/IsCompilation', as_type=bool), ) albumartist = MediaField( mp3=StorageStyle('TPE2'), mp4=StorageStyle('aART'), etc=[StorageStyle('ALBUM ARTIST'), StorageStyle('ALBUMARTIST')], asf=StorageStyle('WM/AlbumArtist'), ) albumtype = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Type'), mp4=StorageStyle('----:com.apple.iTunes:MusicBrainz Album Type'), etc=StorageStyle('MUSICBRAINZ_ALBUMTYPE'), asf=StorageStyle('MusicBrainz/Album Type'), ) label = MediaField( mp3=StorageStyle('TPUB'), mp4=[StorageStyle('----:com.apple.iTunes:Label'), StorageStyle('----:com.apple.iTunes:publisher')], etc=[StorageStyle('LABEL'), StorageStyle('PUBLISHER')], # Traktor asf=StorageStyle('WM/Publisher'), ) artist_sort = MediaField( mp3=StorageStyle('TSOP'), mp4=StorageStyle("soar"), etc=StorageStyle('ARTISTSORT'), asf=StorageStyle('WM/ArtistSortOrder'), ) albumartist_sort = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'ALBUMARTISTSORT'), mp4=StorageStyle("soaa"), etc=StorageStyle('ALBUMARTISTSORT'), asf=StorageStyle('WM/AlbumArtistSortOrder'), ) asin = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'ASIN'), mp4=StorageStyle("----:com.apple.iTunes:ASIN"), etc=StorageStyle('ASIN'), asf=StorageStyle('MusicBrainz/ASIN'), ) catalognum = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'CATALOGNUMBER'), mp4=StorageStyle("----:com.apple.iTunes:CATALOGNUMBER"), etc=StorageStyle('CATALOGNUMBER'), asf=StorageStyle('WM/CatalogNo'), ) disctitle = MediaField( mp3=StorageStyle('TSST'), mp4=StorageStyle("----:com.apple.iTunes:DISCSUBTITLE"), etc=StorageStyle('DISCSUBTITLE'), asf=StorageStyle('WM/SetSubTitle'), ) encoder = MediaField( mp3=StorageStyle('TENC'), mp4=StorageStyle("\xa9too"), etc=[StorageStyle('ENCODEDBY'), StorageStyle('ENCODER')], asf=StorageStyle('WM/EncodedBy'), ) script = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'Script'), mp4=StorageStyle("----:com.apple.iTunes:SCRIPT"), etc=StorageStyle('SCRIPT'), asf=StorageStyle('WM/Script'), ) language = MediaField( mp3=StorageStyle('TLAN'), mp4=StorageStyle("----:com.apple.iTunes:LANGUAGE"), etc=StorageStyle('LANGUAGE'), asf=StorageStyle('WM/Language'), ) country = MediaField( mp3=StorageStyle('TXXX', id3_desc='MusicBrainz Album Release Country'), mp4=StorageStyle("----:com.apple.iTunes:MusicBrainz Album " "Release Country"), etc=StorageStyle('RELEASECOUNTRY'), asf=StorageStyle('MusicBrainz/Album Release Country'), ) albumstatus = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Status'), mp4=StorageStyle("----:com.apple.iTunes:MusicBrainz Album Status"), etc=StorageStyle('MUSICBRAINZ_ALBUMSTATUS'), asf=StorageStyle('MusicBrainz/Album Status'), ) media = MediaField( mp3=StorageStyle('TMED'), mp4=StorageStyle("----:com.apple.iTunes:MEDIA"), etc=StorageStyle('MEDIA'), asf=StorageStyle('WM/Media'), ) albumdisambig = MediaField( # This tag mapping was invented for beets (not used by Picard, etc). mp3=StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Comment'), mp4=StorageStyle("----:com.apple.iTunes:MusicBrainz Album Comment"), etc=StorageStyle('MUSICBRAINZ_ALBUMCOMMENT'), asf=StorageStyle('MusicBrainz/Album Comment'), ) # Release date. year = MediaField( out_type=int, mp3=StorageStyle('TDRC', packing=packing.DATE, pack_pos=0), mp4=StorageStyle("\xa9day", packing=packing.DATE, pack_pos=0), etc=[StorageStyle('DATE', packing=packing.DATE, pack_pos=0), StorageStyle('YEAR')], asf=StorageStyle('WM/Year', packing=packing.DATE, pack_pos=0), ) month = MediaField( out_type=int, mp3=StorageStyle('TDRC', packing=packing.DATE, pack_pos=1), mp4=StorageStyle("\xa9day", packing=packing.DATE, pack_pos=1), etc=StorageStyle('DATE', packing=packing.DATE, pack_pos=1), asf=StorageStyle('WM/Year', packing=packing.DATE, pack_pos=1), ) day = MediaField( out_type=int, mp3=StorageStyle('TDRC', packing=packing.DATE, pack_pos=2), mp4=StorageStyle("\xa9day", packing=packing.DATE, pack_pos=2), etc=StorageStyle('DATE', packing=packing.DATE, pack_pos=2), asf=StorageStyle('WM/Year', packing=packing.DATE, pack_pos=2), ) date = CompositeDateField(year, month, day) # *Original* release date. original_year = MediaField(out_type=int, mp3=StorageStyle('TDOR', packing=packing.DATE, pack_pos=0), mp4=StorageStyle('----:com.apple.iTunes:ORIGINAL YEAR', packing=packing.DATE, pack_pos=0), etc=StorageStyle('ORIGINALDATE', packing=packing.DATE, pack_pos=0), asf=StorageStyle('WM/OriginalReleaseYear', packing=packing.DATE, pack_pos=0), ) original_month = MediaField(out_type=int, mp3=StorageStyle('TDOR', packing=packing.DATE, pack_pos=1), mp4=StorageStyle('----:com.apple.iTunes:ORIGINAL YEAR', packing=packing.DATE, pack_pos=1), etc=StorageStyle('ORIGINALDATE', packing=packing.DATE, pack_pos=1), asf=StorageStyle('WM/OriginalReleaseYear', packing=packing.DATE, pack_pos=1), ) original_day = MediaField(out_type=int, mp3=StorageStyle('TDOR', packing=packing.DATE, pack_pos=2), mp4=StorageStyle('----:com.apple.iTunes:ORIGINAL YEAR', packing=packing.DATE, pack_pos=2), etc=StorageStyle('ORIGINALDATE', packing=packing.DATE, pack_pos=2), asf=StorageStyle('WM/OriginalReleaseYear', packing=packing.DATE, pack_pos=2), ) original_date = CompositeDateField(original_year, original_month, original_day) # Nonstandard metadata. artist_credit = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'Artist Credit'), mp4=StorageStyle("----:com.apple.iTunes:Artist Credit"), etc=StorageStyle('ARTIST_CREDIT'), asf=StorageStyle('beets/Artist Credit'), ) albumartist_credit = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'Album Artist Credit'), mp4=StorageStyle("----:com.apple.iTunes:Album Artist Credit"), etc=StorageStyle('ALBUMARTIST_CREDIT'), asf=StorageStyle('beets/Album Artist Credit'), ) # Album art. art = ImageField() # MusicBrainz IDs. mb_trackid = MediaField( mp3=StorageStyle('UFID:http://musicbrainz.org', list_elem=False, id3_frame_field='data'), mp4=StorageStyle('----:com.apple.iTunes:MusicBrainz Track Id', as_type=str), etc=StorageStyle('MUSICBRAINZ_TRACKID'), asf=StorageStyle('MusicBrainz/Track Id'), ) mb_albumid = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Id'), mp4=StorageStyle('----:com.apple.iTunes:MusicBrainz Album Id', as_type=str), etc=StorageStyle('MUSICBRAINZ_ALBUMID'), asf=StorageStyle('MusicBrainz/Album Id'), ) mb_artistid = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'MusicBrainz Artist Id'), mp4=StorageStyle('----:com.apple.iTunes:MusicBrainz Artist Id', as_type=str), etc=StorageStyle('MUSICBRAINZ_ARTISTID'), asf=StorageStyle('MusicBrainz/Artist Id'), ) mb_albumartistid = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Artist Id'), mp4=StorageStyle('----:com.apple.iTunes:MusicBrainz Album Artist Id', as_type=str), etc=StorageStyle('MUSICBRAINZ_ALBUMARTISTID'), asf=StorageStyle('MusicBrainz/Album Artist Id'), ) mb_releasegroupid = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'MusicBrainz Release Group Id'), mp4=StorageStyle('----:com.apple.iTunes:MusicBrainz Release Group Id', as_type=str), etc=StorageStyle('MUSICBRAINZ_RELEASEGROUPID'), asf=StorageStyle('MusicBrainz/Release Group Id'), ) # Acoustid fields. acoustid_fingerprint = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'Acoustid Fingerprint'), mp4=StorageStyle('----:com.apple.iTunes:Acoustid Fingerprint', as_type=str), etc=StorageStyle('ACOUSTID_FINGERPRINT'), asf=StorageStyle('Acoustid/Fingerprint'), ) acoustid_id = MediaField( mp3=StorageStyle('TXXX', id3_desc=u'Acoustid Id'), mp4=StorageStyle('----:com.apple.iTunes:Acoustid Id', as_type=str), etc=StorageStyle('ACOUSTID_ID'), asf=StorageStyle('Acoustid/Id'), ) # ReplayGain fields. rg_track_gain = MediaField(out_type=float, mp3=[StorageStyle('TXXX', id3_desc=u'REPLAYGAIN_TRACK_GAIN', float_places=2, suffix=u' dB'), StorageStyle('COMM', id3_desc=u'iTunNORM', id3_lang='eng', packing=packing.SC, pack_pos=0, pack_type=float)], mp4=[StorageStyle('----:com.apple.iTunes:replaygain_track_gain', as_type=str, float_places=2, suffix=b' dB'), StorageStyle('----:com.apple.iTunes:iTunNORM', packing=packing.SC, pack_pos=0, pack_type=float)], etc=StorageStyle(u'REPLAYGAIN_TRACK_GAIN', float_places=2, suffix=u' dB'), asf=StorageStyle(u'replaygain_track_gain', float_places=2, suffix=u' dB'), ) rg_album_gain = MediaField(out_type=float, mp3=StorageStyle('TXXX', id3_desc=u'REPLAYGAIN_ALBUM_GAIN', float_places=2, suffix=u' dB'), mp4=StorageStyle('----:com.apple.iTunes:replaygain_album_gain', as_type=str, float_places=2, suffix=b' dB'), etc=StorageStyle(u'REPLAYGAIN_ALBUM_GAIN', float_places=2, suffix=u' dB'), asf=StorageStyle(u'replaygain_album_gain', float_places=2, suffix=u' dB'), ) rg_track_peak = MediaField(out_type=float, mp3=[StorageStyle('TXXX', id3_desc=u'REPLAYGAIN_TRACK_PEAK', float_places=6), StorageStyle('COMM', id3_desc=u'iTunNORM', id3_lang='eng', packing=packing.SC, pack_pos=1, pack_type=float)], mp4=[StorageStyle('----:com.apple.iTunes:replaygain_track_peak', as_type=str, float_places=6), StorageStyle('----:com.apple.iTunes:iTunNORM', packing=packing.SC, pack_pos=1, pack_type=float)], etc=StorageStyle(u'REPLAYGAIN_TRACK_PEAK', float_places=6), asf=StorageStyle(u'replaygain_track_peak', float_places=6), ) rg_album_peak = MediaField(out_type=float, mp3=StorageStyle('TXXX', id3_desc=u'REPLAYGAIN_ALBUM_PEAK', float_places=6), mp4=StorageStyle('----:com.apple.iTunes:replaygain_album_peak', as_type=str, float_places=6), etc=StorageStyle(u'REPLAYGAIN_ALBUM_PEAK', float_places=6), asf=StorageStyle(u'replaygain_album_peak', float_places=6), ) @property def length(self): """The duration of the audio in seconds (a float).""" return self.mgfile.info.length @property def samplerate(self): """The audio's sample rate (an int).""" if hasattr(self.mgfile.info, 'sample_rate'): return self.mgfile.info.sample_rate elif self.type == 'opus': # Opus is always 48kHz internally. return 48000 return 0 @property def bitdepth(self): """The number of bits per sample in the audio encoding (an int). Only available for certain file formats (zero where unavailable). """ if hasattr(self.mgfile.info, 'bits_per_sample'): return self.mgfile.info.bits_per_sample return 0 @property def channels(self): """The number of channels in the audio (an int).""" if isinstance(self.mgfile.info, mutagen.mp3.MPEGInfo): return { mutagen.mp3.STEREO: 2, mutagen.mp3.JOINTSTEREO: 2, mutagen.mp3.DUALCHANNEL: 2, mutagen.mp3.MONO: 1, }[self.mgfile.info.mode] if hasattr(self.mgfile.info, 'channels'): return self.mgfile.info.channels return 0 @property def bitrate(self): """The number of bits per seconds used in the audio coding (an int). If this is provided explicitly by the compressed file format, this is a precise reflection of the encoding. Otherwise, it is estimated from the on-disk file size. In this case, some imprecision is possible because the file header is incorporated in the file size. """ if hasattr(self.mgfile.info, 'bitrate') and self.mgfile.info.bitrate: # Many formats provide it explicitly. return self.mgfile.info.bitrate else: # Otherwise, we calculate bitrate from the file size. (This # is the case for all of the lossless formats.) if not self.length: # Avoid division by zero if length is not available. return 0 size = os.path.getsize(self.path) return int(size * 8 / self.length) @property def format(self): """A string describing the file format/codec.""" return TYPES[self.type] beets-1.3.1/beets/plugins.py0000755000076500000240000002664012207240712016727 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Support for beets plugins.""" import logging import traceback from collections import defaultdict import beets from beets import mediafile PLUGIN_NAMESPACE = 'beetsplug' # Plugins using the Last.fm API can share the same API key. LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43' # Global logger. log = logging.getLogger('beets') # Managing the plugins themselves. class BeetsPlugin(object): """The base class for all beets plugins. Plugins provide functionality by defining a subclass of BeetsPlugin and overriding the abstract methods defined here. """ def __init__(self, name=None): """Perform one-time plugin setup. """ _add_media_fields(self.item_fields()) self.import_stages = [] self.name = name or self.__module__.split('.')[-1] self.config = beets.config[self.name] if not self.template_funcs: self.template_funcs = {} if not self.template_fields: self.template_fields = {} if not self.album_template_fields: self.album_template_fields = {} def commands(self): """Should return a list of beets.ui.Subcommand objects for commands that should be added to beets' CLI. """ return () def queries(self): """Should return a dict mapping prefixes to PluginQuery subclasses. """ return {} def track_distance(self, item, info): """Should return a Distance object to be added to the distance for every track comparison. """ return beets.autotag.hooks.Distance() def album_distance(self, items, album_info, mapping): """Should return a Distance object to be added to the distance for every album-level comparison. """ return beets.autotag.hooks.Distance() def candidates(self, items, artist, album, va_likely): """Should return a sequence of AlbumInfo objects that match the album whose items are provided. """ return () def item_candidates(self, item, artist, title): """Should return a sequence of TrackInfo objects that match the item provided. """ return () def configure(self, config): """This method is called with the ConfigParser object after the CLI starts up. """ pass def item_fields(self): """Returns field descriptors to be added to the MediaFile class, in the form of a dictionary whose keys are field names and whose values are descriptor (e.g., MediaField) instances. The Library database schema is not (currently) extended. """ return {} def album_for_id(self, album_id): """Return an AlbumInfo object or None if no matching release was found. """ return None def track_for_id(self, track_id): """Return a TrackInfo object or None if no matching release was found. """ return None listeners = None @classmethod def register_listener(cls, event, func): """Add a function as a listener for the specified event. (An imperative alternative to the @listen decorator.) """ if cls.listeners is None: cls.listeners = defaultdict(list) cls.listeners[event].append(func) @classmethod def listen(cls, event): """Decorator that adds a function as an event handler for the specified event (as a string). The parameters passed to function will vary depending on what event occurred. The function should respond to named parameters. function(**kwargs) will trap all arguments in a dictionary. Example: >>> @MyPlugin.listen("imported") >>> def importListener(**kwargs): >>> pass """ def helper(func): if cls.listeners is None: cls.listeners = defaultdict(list) cls.listeners[event].append(func) return func return helper template_funcs = None template_fields = None album_template_fields = None @classmethod def template_func(cls, name): """Decorator that registers a path template function. The function will be invoked as ``%name{}`` from path format strings. """ def helper(func): if cls.template_funcs is None: cls.template_funcs = {} cls.template_funcs[name] = func return func return helper @classmethod def template_field(cls, name): """Decorator that registers a path template field computation. The value will be referenced as ``$name`` from path format strings. The function must accept a single parameter, the Item being formatted. """ def helper(func): if cls.template_fields is None: cls.template_fields = {} cls.template_fields[name] = func return func return helper _classes = [] def load_plugins(names=()): """Imports the modules for a sequence of plugin names. Each name must be the name of a Python module under the "beetsplug" namespace package in sys.path; the module indicated should contain the BeetsPlugin subclasses desired. """ for name in names: modname = '%s.%s' % (PLUGIN_NAMESPACE, name) try: try: namespace = __import__(modname, None, None) except ImportError as exc: # Again, this is hacky: if exc.args[0].endswith(' ' + name): log.warn('** plugin %s not found' % name) else: raise else: for obj in getattr(namespace, name).__dict__.values(): if isinstance(obj, type) and issubclass(obj, BeetsPlugin) \ and obj != BeetsPlugin: _classes.append(obj) except: log.warn('** error loading plugin %s' % name) log.warn(traceback.format_exc()) _instances = {} def find_plugins(): """Returns a list of BeetsPlugin subclass instances from all currently loaded beets plugins. Loads the default plugin set first. """ load_plugins() plugins = [] for cls in _classes: # Only instantiate each plugin class once. if cls not in _instances: _instances[cls] = cls() plugins.append(_instances[cls]) return plugins # Communication with plugins. def commands(): """Returns a list of Subcommand objects from all loaded plugins. """ out = [] for plugin in find_plugins(): out += plugin.commands() return out def queries(): """Returns a dict mapping prefix strings to beet.library.PluginQuery subclasses all loaded plugins. """ out = {} for plugin in find_plugins(): out.update(plugin.queries()) return out def track_distance(item, info): """Gets the track distance calculated by all loaded plugins. Returns a Distance object. """ from beets.autotag.hooks import Distance dist = Distance() for plugin in find_plugins(): dist.update(plugin.track_distance(item, info)) return dist def album_distance(items, album_info, mapping): """Returns the album distance calculated by plugins.""" from beets.autotag.hooks import Distance dist = Distance() for plugin in find_plugins(): dist.update(plugin.album_distance(items, album_info, mapping)) return dist def candidates(items, artist, album, va_likely): """Gets MusicBrainz candidates for an album from each plugin. """ out = [] for plugin in find_plugins(): out.extend(plugin.candidates(items, artist, album, va_likely)) return out def item_candidates(item, artist, title): """Gets MusicBrainz candidates for an item from the plugins. """ out = [] for plugin in find_plugins(): out.extend(plugin.item_candidates(item, artist, title)) return out def album_for_id(album_id): """Get AlbumInfo objects for a given ID string. """ out = [] for plugin in find_plugins(): res = plugin.album_for_id(album_id) if res: out.append(res) return out def track_for_id(track_id): """Get TrackInfo objects for a given ID string. """ out = [] for plugin in find_plugins(): res = plugin.track_for_id(track_id) if res: out.append(res) return out def configure(config): """Sends the configuration object to each plugin.""" for plugin in find_plugins(): plugin.configure(config) def template_funcs(): """Get all the template functions declared by plugins as a dictionary. """ funcs = {} for plugin in find_plugins(): if plugin.template_funcs: funcs.update(plugin.template_funcs) return funcs def template_values(item): """Get all the template values computed for a given Item by registered field computations. """ values = {} for plugin in find_plugins(): if plugin.template_fields: for name, func in plugin.template_fields.iteritems(): values[name] = unicode(func(item)) return values def album_template_values(album): """Get the plugin-defined template values for an Album. """ values = {} for plugin in find_plugins(): if plugin.album_template_fields: for name, func in plugin.album_template_fields.iteritems(): values[name] = unicode(func(album)) return values def _add_media_fields(fields): """Adds a {name: descriptor} dictionary of fields to the MediaFile class. Called during the plugin initialization. """ for key, value in fields.iteritems(): setattr(mediafile.MediaFile, key, value) def import_stages(): """Get a list of import stage functions defined by plugins.""" stages = [] for plugin in find_plugins(): if hasattr(plugin, 'import_stages'): stages += plugin.import_stages return stages # Event dispatch. def event_handlers(): """Find all event handlers from plugins as a dictionary mapping event names to sequences of callables. """ all_handlers = defaultdict(list) for plugin in find_plugins(): if plugin.listeners: for event, handlers in plugin.listeners.items(): all_handlers[event] += handlers return all_handlers def send(event, **arguments): """Sends an event to all assigned event listeners. Event is the name of the event to send, all other named arguments go to the event handler(s). Returns the number of handlers called. """ log.debug('Sending event: %s' % event) handlers = event_handlers()[event] for handler in handlers: handler(**arguments) return len(handlers) beets-1.3.1/beets/ui/0000755000076500000240000000000012226377756015322 5ustar asampsonstaff00000000000000beets-1.3.1/beets/ui/__init__.py0000644000076500000240000006432412216073474017431 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """This module contains all of the core logic for beets' command-line interface. To invoke the CLI, just call beets.ui.main(). The actual CLI commands are implemented in the ui.commands module. """ from __future__ import print_function import locale import optparse import textwrap import sys from difflib import SequenceMatcher import logging import sqlite3 import errno import re import struct import traceback from beets import library from beets import plugins from beets import util from beets.util.functemplate import Template from beets import config from beets.util import confit from beets.autotag import mb # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == 'win32': try: import colorama except ImportError: pass else: colorama.init() # Constants. PF_KEY_QUERIES = { 'comp': 'comp:true', 'singleton': 'singleton:true', } # UI exception. Commands should throw this in order to display # nonrecoverable errors to the user. class UserError(Exception): pass # Main logger. log = logging.getLogger('beets') # Utilities. def _encoding(): """Tries to guess the encoding used by the terminal.""" # Configured override? encoding = config['terminal_encoding'].get() if encoding: return encoding # Determine from locale settings. try: return locale.getdefaultlocale()[1] or 'utf8' except ValueError: # Invalid locale environment variable setting. To avoid # failing entirely for no good reason, assume UTF-8. return 'utf8' def decargs(arglist): """Given a list of command-line argument bytestrings, attempts to decode them to Unicode strings. """ return [s.decode(_encoding()) for s in arglist] def print_(*strings): """Like print, but rather than raising an error when a character is not in the terminal's encoding's character set, just silently replaces it. """ if strings: if isinstance(strings[0], unicode): txt = u' '.join(strings) else: txt = ' '.join(strings) else: txt = u'' if isinstance(txt, unicode): txt = txt.encode(_encoding(), 'replace') print(txt) def input_(prompt=None): """Like `raw_input`, but decodes the result to a Unicode string. Raises a UserError if stdin is not available. The prompt is sent to stdout rather than stderr. A printed between the prompt and the input cursor. """ # raw_input incorrectly sends prompts to stderr, not stdout, so we # use print() explicitly to display prompts. # http://bugs.python.org/issue1927 if prompt: if isinstance(prompt, unicode): prompt = prompt.encode(_encoding(), 'replace') print(prompt, end=' ') try: resp = raw_input() except EOFError: raise UserError('stdin stream ended while input required') return resp.decode(sys.stdin.encoding or 'utf8', 'ignore') def input_options(options, require=False, prompt=None, fallback_prompt=None, numrange=None, default=None, max_width=72): """Prompts a user for input. The sequence of `options` defines the choices the user has. A single-letter shortcut is inferred for each option; the user's choice is returned as that single, lower-case letter. The options should be provided as lower-case strings unless a particular shortcut is desired; in that case, only that letter should be capitalized. By default, the first option is the default. `default` can be provided to override this. If `require` is provided, then there is no default. The prompt and fallback prompt are also inferred but can be overridden. If numrange is provided, it is a pair of `(high, low)` (both ints) indicating that, in addition to `options`, the user may enter an integer in that inclusive range. `max_width` specifies the maximum number of columns in the automatically generated prompt string. """ # Assign single letters to each option. Also capitalize the options # to indicate the letter. letters = {} display_letters = [] capitalized = [] first = True for option in options: # Is a letter already capitalized? for letter in option: if letter.isalpha() and letter.upper() == letter: found_letter = letter break else: # Infer a letter. for letter in option: if not letter.isalpha(): continue # Don't use punctuation. if letter not in letters: found_letter = letter break else: raise ValueError('no unambiguous lettering found') letters[found_letter.lower()] = option index = option.index(found_letter) # Mark the option's shortcut letter for display. if not require and ((default is None and not numrange and first) or (isinstance(default, basestring) and found_letter.lower() == default.lower())): # The first option is the default; mark it. show_letter = '[%s]' % found_letter.upper() is_default = True else: show_letter = found_letter.upper() is_default = False # Colorize the letter shortcut. show_letter = colorize('turquoise' if is_default else 'blue', show_letter) # Insert the highlighted letter back into the word. capitalized.append( option[:index] + show_letter + option[index+1:] ) display_letters.append(found_letter.upper()) first = False # The default is just the first option if unspecified. if require: default = None elif default is None: if numrange: default = numrange[0] else: default = display_letters[0].lower() # Make a prompt if one is not provided. if not prompt: prompt_parts = [] prompt_part_lengths = [] if numrange: if isinstance(default, int): default_name = str(default) default_name = colorize('turquoise', default_name) tmpl = '# selection (default %s)' prompt_parts.append(tmpl % default_name) prompt_part_lengths.append(len(tmpl % str(default))) else: prompt_parts.append('# selection') prompt_part_lengths.append(len(prompt_parts[-1])) prompt_parts += capitalized prompt_part_lengths += [len(s) for s in options] # Wrap the query text. prompt = '' line_length = 0 for i, (part, length) in enumerate(zip(prompt_parts, prompt_part_lengths)): # Add punctuation. if i == len(prompt_parts) - 1: part += '?' else: part += ',' length += 1 # Choose either the current line or the beginning of the next. if line_length + length + 1 > max_width: prompt += '\n' line_length = 0 if line_length != 0: # Not the beginning of the line; need a space. part = ' ' + part length += 1 prompt += part line_length += length # Make a fallback prompt too. This is displayed if the user enters # something that is not recognized. if not fallback_prompt: fallback_prompt = 'Enter one of ' if numrange: fallback_prompt += '%i-%i, ' % numrange fallback_prompt += ', '.join(display_letters) + ':' resp = input_(prompt) while True: resp = resp.strip().lower() # Try default option. if default is not None and not resp: resp = default # Try an integer input if available. if numrange: try: resp = int(resp) except ValueError: pass else: low, high = numrange if low <= resp <= high: return resp else: resp = None # Try a normal letter input. if resp: resp = resp[0] if resp in letters: return resp # Prompt for new input. resp = input_(fallback_prompt) def input_yn(prompt, require=False): """Prompts the user for a "yes" or "no" response. The default is "yes" unless `require` is `True`, in which case there is no default. """ sel = input_options( ('y', 'n'), require, prompt, 'Enter Y or N:' ) return sel == 'y' def human_bytes(size): """Formats size, a number of bytes, in a human-readable way.""" suffices = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'HB'] for suffix in suffices: if size < 1024: return "%3.1f %s" % (size, suffix) size /= 1024.0 return "big" def human_seconds(interval): """Formats interval, a number of seconds, as a human-readable time interval using English words. """ units = [ (1, 'second'), (60, 'minute'), (60, 'hour'), (24, 'day'), (7, 'week'), (52, 'year'), (10, 'decade'), ] for i in range(len(units)-1): increment, suffix = units[i] next_increment, _ = units[i+1] interval /= float(increment) if interval < next_increment: break else: # Last unit. increment, suffix = units[-1] interval /= float(increment) return "%3.1f %ss" % (interval, suffix) def human_seconds_short(interval): """Formats a number of seconds as a short human-readable M:SS string. """ interval = int(interval) return u'%i:%02i' % (interval // 60, interval % 60) # ANSI terminal colorization code heavily inspired by pygments: # http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py # (pygments is by Tim Hatch, Armin Ronacher, et al.) COLOR_ESCAPE = "\x1b[" DARK_COLORS = ["black", "darkred", "darkgreen", "brown", "darkblue", "purple", "teal", "lightgray"] LIGHT_COLORS = ["darkgray", "red", "green", "yellow", "blue", "fuchsia", "turquoise", "white"] RESET_COLOR = COLOR_ESCAPE + "39;49;00m" def _colorize(color, text): """Returns a string that prints the given text in the given color in a terminal that is ANSI color-aware. The color must be something in DARK_COLORS or LIGHT_COLORS. """ if color in DARK_COLORS: escape = COLOR_ESCAPE + "%im" % (DARK_COLORS.index(color) + 30) elif color in LIGHT_COLORS: escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS.index(color) + 30) else: raise ValueError('no such color %s', color) return escape + text + RESET_COLOR def colorize(color, text): """Colorize text if colored output is enabled. (Like _colorize but conditional.) """ if config['color']: return _colorize(color, text) else: return text def _colordiff(a, b, highlight='red', minor_highlight='lightgray'): """Given two values, return the same pair of strings except with their differences highlighted in the specified color. Strings are highlighted intelligently to show differences; other values are stringified and highlighted in their entirety. """ if not isinstance(a, basestring) or not isinstance(b, basestring): # Non-strings: use ordinary equality. a = unicode(a) b = unicode(b) if a == b: return a, b else: return colorize(highlight, a), colorize(highlight, b) if isinstance(a, bytes) or isinstance(b, bytes): # A path field. a = util.displayable_path(a) b = util.displayable_path(b) a_out = [] b_out = [] matcher = SequenceMatcher(lambda x: False, a, b) for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): if op == 'equal': # In both strings. a_out.append(a[a_start:a_end]) b_out.append(b[b_start:b_end]) elif op == 'insert': # Right only. b_out.append(colorize(highlight, b[b_start:b_end])) elif op == 'delete': # Left only. a_out.append(colorize(highlight, a[a_start:a_end])) elif op == 'replace': # Right and left differ. Colorise with second highlight if # it's just a case change. if a[a_start:a_end].lower() != b[b_start:b_end].lower(): color = highlight else: color = minor_highlight a_out.append(colorize(color, a[a_start:a_end])) b_out.append(colorize(color, b[b_start:b_end])) else: assert(False) return u''.join(a_out), u''.join(b_out) def colordiff(a, b, highlight='red'): """Colorize differences between two values if color is enabled. (Like _colordiff but conditional.) """ if config['color']: return _colordiff(a, b, highlight) else: return unicode(a), unicode(b) def color_diff_suffix(a, b, highlight='red'): """Colorize the differing suffix between two strings.""" a, b = unicode(a), unicode(b) if not config['color']: return a, b # Fast path. if a == b: return a, b # Find the longest common prefix. first_diff = None for i in range(min(len(a), len(b))): if a[i] != b[i]: first_diff = i break else: first_diff = min(len(a), len(b)) # Colorize from the first difference on. return a[:first_diff] + colorize(highlight, a[first_diff:]), \ b[:first_diff] + colorize(highlight, b[first_diff:]) def get_path_formats(subview=None): """Get the configuration's path formats as a list of query/template pairs. """ path_formats = [] subview = subview or config['paths'] for query, view in subview.items(): query = PF_KEY_QUERIES.get(query, query) # Expand common queries. path_formats.append((query, Template(view.get(unicode)))) return path_formats def get_replacements(): """Confit validation function that reads regex/string pairs. """ replacements = [] for pattern, repl in config['replace'].get(dict).items(): try: replacements.append((re.compile(pattern), repl)) except re.error: raise UserError( u'malformed regular expression in replace: {0}'.format( pattern )) return replacements def get_plugin_paths(): """Get the list of search paths for plugins from the config file. The value for "pluginpath" may be a single string or a list of strings. """ pluginpaths = config['pluginpath'].get() if isinstance(pluginpaths, basestring): pluginpaths = [pluginpaths] if not isinstance(pluginpaths, list): raise confit.ConfigTypeError( u'pluginpath must be string or a list of strings' ) return map(util.normpath, pluginpaths) def _pick_format(album, fmt=None): """Pick a format string for printing Album or Item objects, falling back to config options and defaults. """ if fmt: return fmt if album: return config['list_format_album'].get(unicode) else: return config['list_format_item'].get(unicode) def print_obj(obj, lib, fmt=None): """Print an Album or Item object. If `fmt` is specified, use that format string. Otherwise, use the configured template. """ album = isinstance(obj, library.Album) fmt = _pick_format(album, fmt) if isinstance(fmt, Template): template = fmt else: template = Template(fmt) print_(obj.evaluate_template(template)) def term_width(): """Get the width (columns) of the terminal.""" fallback = config['ui']['terminal_width'].get(int) # The fcntl and termios modules are not available on non-Unix # platforms, so we fall back to a constant. try: import fcntl import termios except ImportError: return fallback try: buf = fcntl.ioctl(0, termios.TIOCGWINSZ, ' '*4) except IOError: return fallback try: height, width = struct.unpack('hh', buf) except struct.error: return fallback return width # Subcommand parsing infrastructure. # This is a fairly generic subcommand parser for optparse. It is # maintained externally here: # http://gist.github.com/462717 # There you will also find a better description of the code and a more # succinct example program. class Subcommand(object): """A subcommand of a root command-line application that may be invoked by a SubcommandOptionParser. """ def __init__(self, name, parser=None, help='', aliases=()): """Creates a new subcommand. name is the primary way to invoke the subcommand; aliases are alternate names. parser is an OptionParser responsible for parsing the subcommand's options. help is a short description of the command. If no parser is given, it defaults to a new, empty OptionParser. """ self.name = name self.parser = parser or optparse.OptionParser() self.aliases = aliases self.help = help class SubcommandsOptionParser(optparse.OptionParser): """A variant of OptionParser that parses subcommands and their arguments. """ # A singleton command used to give help on other subcommands. _HelpSubcommand = Subcommand('help', optparse.OptionParser(), help='give detailed help on a specific sub-command', aliases=('?',)) def __init__(self, *args, **kwargs): """Create a new subcommand-aware option parser. All of the options to OptionParser.__init__ are supported in addition to subcommands, a sequence of Subcommand objects. """ # The subcommand array, with the help command included. self.subcommands = list(kwargs.pop('subcommands', [])) self.subcommands.append(self._HelpSubcommand) # A more helpful default usage. if 'usage' not in kwargs: kwargs['usage'] = """ %prog COMMAND [ARGS...] %prog help COMMAND""" # Super constructor. optparse.OptionParser.__init__(self, *args, **kwargs) # Adjust the help-visible name of each subcommand. for subcommand in self.subcommands: subcommand.parser.prog = '%s %s' % \ (self.get_prog_name(), subcommand.name) # Our root parser needs to stop on the first unrecognized argument. self.disable_interspersed_args() def add_subcommand(self, cmd): """Adds a Subcommand object to the parser's list of commands. """ self.subcommands.append(cmd) # Add the list of subcommands to the help message. def format_help(self, formatter=None): # Get the original help message, to which we will append. out = optparse.OptionParser.format_help(self, formatter) if formatter is None: formatter = self.formatter # Subcommands header. result = ["\n"] result.append(formatter.format_heading('Commands')) formatter.indent() # Generate the display names (including aliases). # Also determine the help position. disp_names = [] help_position = 0 for subcommand in self.subcommands: name = subcommand.name if subcommand.aliases: name += ' (%s)' % ', '.join(subcommand.aliases) disp_names.append(name) # Set the help position based on the max width. proposed_help_position = len(name) + formatter.current_indent + 2 if proposed_help_position <= formatter.max_help_position: help_position = max(help_position, proposed_help_position) # Add each subcommand to the output. for subcommand, name in zip(self.subcommands, disp_names): # Lifted directly from optparse.py. name_width = help_position - formatter.current_indent - 2 if len(name) > name_width: name = "%*s%s\n" % (formatter.current_indent, "", name) indent_first = help_position else: name = "%*s%-*s " % (formatter.current_indent, "", name_width, name) indent_first = 0 result.append(name) help_width = formatter.width - help_position help_lines = textwrap.wrap(subcommand.help, help_width) result.append("%*s%s\n" % (indent_first, "", help_lines[0])) result.extend(["%*s%s\n" % (help_position, "", line) for line in help_lines[1:]]) formatter.dedent() # Concatenate the original help message with the subcommand # list. return out + "".join(result) def _subcommand_for_name(self, name): """Return the subcommand in self.subcommands matching the given name. The name may either be the name of a subcommand or an alias. If no subcommand matches, returns None. """ for subcommand in self.subcommands: if name == subcommand.name or \ name in subcommand.aliases: return subcommand return None def parse_args(self, a=None, v=None): """Like OptionParser.parse_args, but returns these four items: - options: the options passed to the root parser - subcommand: the Subcommand object that was invoked - suboptions: the options passed to the subcommand parser - subargs: the positional arguments passed to the subcommand """ options, args = optparse.OptionParser.parse_args(self, a, v) if not args: # No command given. self.print_help() self.exit() else: cmdname = args.pop(0) subcommand = self._subcommand_for_name(cmdname) if not subcommand: self.error('unknown command ' + cmdname) suboptions, subargs = subcommand.parser.parse_args(args) if subcommand is self._HelpSubcommand: if subargs: # particular cmdname = subargs[0] helpcommand = self._subcommand_for_name(cmdname) if not helpcommand: self.error('no command named {0}'.format(cmdname)) helpcommand.parser.print_help() self.exit() else: # general self.print_help() self.exit() return options, subcommand, suboptions, subargs # The root parser and its main function. def _raw_main(args): """A helper function for `main` without top-level exception handling. """ # Temporary: Migrate from 1.0-style configuration. from beets.ui import migrate migrate.automigrate() # Get the default subcommands. from beets.ui.commands import default_commands # Add plugin paths. sys.path += get_plugin_paths() # Load requested plugins. plugins.load_plugins(config['plugins'].as_str_seq()) plugins.send("pluginload") # Construct the root parser. commands = list(default_commands) commands += plugins.commands() commands.append(migrate.migrate_cmd) # Temporary. parser = SubcommandsOptionParser(subcommands=commands) parser.add_option('-l', '--library', dest='library', help='library database file to use') parser.add_option('-d', '--directory', dest='directory', help="destination music directory") parser.add_option('-v', '--verbose', dest='verbose', action='store_true', help='print debugging information') # Parse the command-line! options, subcommand, suboptions, subargs = parser.parse_args(args) config.set_args(options) # Open library file. dbpath = config['library'].as_filename() try: lib = library.Library( dbpath, config['directory'].as_filename(), get_path_formats(), get_replacements(), ) except sqlite3.OperationalError: raise UserError(u"database file {0} could not be opened".format( util.displayable_path(dbpath) )) plugins.send("library_opened", lib=lib) # Configure the logger. if config['verbose'].get(bool): log.setLevel(logging.DEBUG) else: log.setLevel(logging.INFO) log.debug(u'data directory: {0}\n' u'library database: {1}\n' u'library directory: {2}'.format( util.displayable_path(config.config_dir()), util.displayable_path(lib.path), util.displayable_path(lib.directory), )) # Configure the MusicBrainz API. mb.configure() # Invoke the subcommand. subcommand.func(lib, suboptions, subargs) plugins.send('cli_exit', lib=lib) def main(args=None): """Run the main command-line interface for beets. Includes top-level exception handlers that print friendly error messages. """ try: _raw_main(args) except UserError as exc: message = exc.args[0] if exc.args else None log.error(u'error: {0}'.format(message)) sys.exit(1) except util.HumanReadableException as exc: exc.log(log) sys.exit(1) except confit.ConfigError as exc: log.error(u'configuration error: {0}'.format(exc)) except IOError as exc: if exc.errno == errno.EPIPE: # "Broken pipe". End silently. pass else: raise except KeyboardInterrupt: # Silently ignore ^C except in verbose mode. log.debug(traceback.format_exc()) beets-1.3.1/beets/ui/commands.py0000644000076500000240000013005112224314225017451 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """This module provides the default commands for beets' command-line interface. """ from __future__ import print_function import logging import os import time import itertools import codecs from datetime import datetime import beets from beets import ui from beets.ui import print_, input_, decargs from beets import autotag from beets.autotag import recommendation from beets.autotag import hooks from beets import plugins from beets import importer from beets import util from beets.util import syspath, normpath, ancestry, displayable_path from beets.util.functemplate import Template from beets import library from beets import config # Global logger. log = logging.getLogger('beets') # The list of default subcommands. This is populated with Subcommand # objects that can be fed to a SubcommandsOptionParser. default_commands = [] # Utilities. def _do_query(lib, query, album, also_items=True): """For commands that operate on matched items, performs a query and returns a list of matching items and a list of matching albums. (The latter is only nonempty when album is True.) Raises a UserError if no items match. also_items controls whether, when fetching albums, the associated items should be fetched also. """ if album: albums = list(lib.albums(query)) items = [] if also_items: for al in albums: items += al.items() else: albums = [] items = list(lib.items(query)) if album and not albums: raise ui.UserError('No matching albums found.') elif not album and not items: raise ui.UserError('No matching items found.') return items, albums FLOAT_EPSILON = 0.01 def _showdiff(field, oldval, newval): """Prints out a human-readable field difference line.""" # Considering floats incomparable for perfect equality, introduce # an epsilon tolerance. if isinstance(oldval, float) and isinstance(newval, float) and \ abs(oldval - newval) < FLOAT_EPSILON: return if newval != oldval: oldval, newval = ui.colordiff(oldval, newval) print_(u' %s: %s -> %s' % (field, oldval, newval)) # fields: Shows a list of available fields for queries and format strings. fields_cmd = ui.Subcommand('fields', help='show fields available for queries and format strings') def fields_func(lib, opts, args): def _print_rows(names): print(" " + "\n ".join(names)) def _show_plugin_fields(album): plugin_fields = [] for plugin in plugins.find_plugins(): if album: fdict = plugin.album_template_fields else: fdict = plugin.template_fields plugin_fields += fdict.keys() if plugin_fields: print("Template fields from plugins:") _print_rows(plugin_fields) print("Item fields:") _print_rows(library.ITEM_KEYS) _show_plugin_fields(False) print("\nAlbum fields:") _print_rows(library.ALBUM_KEYS) _show_plugin_fields(True) fields_cmd.func = fields_func default_commands.append(fields_cmd) # import: Autotagger and importer. VARIOUS_ARTISTS = u'Various Artists' # Importer utilities and support. def disambig_string(info): """Generate a string for an AlbumInfo or TrackInfo object that provides context that helps disambiguate similar-looking albums and tracks. """ disambig = [] if info.data_source and info.data_source != 'MusicBrainz': disambig.append(info.data_source) if isinstance(info, hooks.AlbumInfo): if info.media: if info.mediums > 1: disambig.append(u'{0}x{1}'.format( info.mediums, info.media )) else: disambig.append(info.media) if info.year: disambig.append(unicode(info.year)) if info.country: disambig.append(info.country) if info.label: disambig.append(info.label) if info.albumdisambig: disambig.append(info.albumdisambig) if disambig: return u', '.join(disambig) def dist_string(dist): """Formats a distance (a float) as a colorized similarity percentage string. """ out = '%.1f%%' % ((1 - dist) * 100) if dist <= config['match']['strong_rec_thresh'].as_number(): out = ui.colorize('green', out) elif dist <= config['match']['medium_rec_thresh'].as_number(): out = ui.colorize('yellow', out) else: out = ui.colorize('red', out) return out def penalty_string(distance, limit=None): """Returns a colorized string that indicates all the penalties applied to a distance object. """ penalties = [] for key in distance.keys(): key = key.replace('album_', '') key = key.replace('track_', '') key = key.replace('_', ' ') penalties.append(key) if penalties: if limit and len(penalties) > limit: penalties = penalties[:limit] + ['...'] return ui.colorize('yellow', '(%s)' % ', '.join(penalties)) def show_change(cur_artist, cur_album, match): """Print out a representation of the changes that will be made if an album's tags are changed according to `match`, which must be an AlbumMatch object. """ def show_album(artist, album): if artist: album_description = u' %s - %s' % (artist, album) elif album: album_description = u' %s' % album else: album_description = u' (unknown album)' print_(album_description) def format_index(track_info): """Return a string representing the track index of the given TrackInfo or Item object. """ if isinstance(track_info, hooks.TrackInfo): index = track_info.index medium_index = track_info.medium_index medium = track_info.medium mediums = match.info.mediums else: index = medium_index = track_info.track medium = track_info.disc mediums = track_info.disctotal if config['per_disc_numbering']: if mediums > 1: return u'{0}-{1}'.format(medium, medium_index) else: return unicode(medium_index) else: return unicode(index) # Identify the album in question. if cur_artist != match.info.artist or \ (cur_album != match.info.album and match.info.album != VARIOUS_ARTISTS): artist_l, artist_r = cur_artist or '', match.info.artist album_l, album_r = cur_album or '', match.info.album if artist_r == VARIOUS_ARTISTS: # Hide artists for VA releases. artist_l, artist_r = u'', u'' artist_l, artist_r = ui.colordiff(artist_l, artist_r) album_l, album_r = ui.colordiff(album_l, album_r) print_("Correcting tags from:") show_album(artist_l, album_l) print_("To:") show_album(artist_r, album_r) else: print_(u"Tagging:\n {0.artist} - {0.album}".format(match.info)) # Data URL. if match.info.data_url: print_('URL:\n %s' % match.info.data_url) # Info line. info = [] # Similarity. info.append('(Similarity: %s)' % dist_string(match.distance)) # Penalties. penalties = penalty_string(match.distance) if penalties: info.append(penalties) # Disambiguation. disambig = disambig_string(match.info) if disambig: info.append(ui.colorize('lightgray', '(%s)' % disambig)) print_(' '.join(info)) # Tracks. pairs = match.mapping.items() pairs.sort(key=lambda (_, track_info): track_info.index) # Build up LHS and RHS for track difference display. The `lines` list # contains ``(lhs, rhs, width)`` tuples where `width` is the length (in # characters) of the uncolorized LHS. lines = [] medium = disctitle = None for item, track_info in pairs: # Medium number and title. if medium != track_info.medium or disctitle != track_info.disctitle: media = match.info.media or 'Media' if match.info.mediums > 1 and track_info.disctitle: lhs = '%s %s: %s' % (media, track_info.medium, track_info.disctitle) elif match.info.mediums > 1: lhs = '%s %s' % (media, track_info.medium) elif track_info.disctitle: lhs = '%s: %s' % (media, track_info.disctitle) else: lhs = None if lhs: lines.append((lhs, '', 0)) medium, disctitle = track_info.medium, track_info.disctitle # Titles. new_title = track_info.title if not item.title.strip(): # If there's no title, we use the filename. cur_title = displayable_path(os.path.basename(item.path)) lhs, rhs = cur_title, new_title else: cur_title = item.title.strip() lhs, rhs = ui.colordiff(cur_title, new_title) lhs_width = len(cur_title) # Track number change. cur_track, new_track = format_index(item), format_index(track_info) if cur_track != new_track: if item.track in (track_info.index, track_info.medium_index): color = 'lightgray' else: color = 'red' if (cur_track + new_track).count('-') == 1: lhs_track, rhs_track = ui.colorize(color, cur_track), \ ui.colorize(color, new_track) else: color = 'red' lhs_track, rhs_track = ui.color_diff_suffix(cur_track, new_track) templ = ui.colorize(color, u' (#') + u'{0}' + \ ui.colorize(color, u')') lhs += templ.format(lhs_track) rhs += templ.format(rhs_track) lhs_width += len(cur_track) + 4 # Length change. if item.length and track_info.length and \ abs(item.length - track_info.length) > \ config['ui']['length_diff_thresh'].as_number(): cur_length = ui.human_seconds_short(item.length) new_length = ui.human_seconds_short(track_info.length) lhs_length, rhs_length = ui.color_diff_suffix(cur_length, new_length) templ = ui.colorize('red', u' (') + u'{0}' + \ ui.colorize('red', u')') lhs += templ.format(lhs_length) rhs += templ.format(rhs_length) lhs_width += len(cur_length) + 3 # Penalties. penalties = penalty_string(match.distance.tracks[track_info]) if penalties: rhs += ' %s' % penalties if lhs != rhs: lines.append((' * %s' % lhs, rhs, lhs_width)) elif config['import']['detail']: lines.append((' * %s' % lhs, '', lhs_width)) # Print each track in two columns, or across two lines. col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2 if lines: max_width = max(w for _, _, w in lines) for lhs, rhs, lhs_width in lines: if not rhs: print_(lhs) elif max_width > col_width: print_(u'%s ->\n %s' % (lhs, rhs)) else: pad = max_width - lhs_width print_(u'%s%s -> %s' % (lhs, ' ' * pad, rhs)) # Missing and unmatched tracks. if match.extra_tracks: print_('Missing tracks:') for track_info in match.extra_tracks: line = ' ! %s (#%s)' % (track_info.title, format_index(track_info)) if track_info.length: line += ' (%s)' % ui.human_seconds_short(track_info.length) print_(ui.colorize('yellow', line)) if match.extra_items: print_('Unmatched tracks:') for item in match.extra_items: line = ' ! %s (#%s)' % (item.title, format_index(item)) if item.length: line += ' (%s)' % ui.human_seconds_short(item.length) print_(ui.colorize('yellow', line)) def show_item_change(item, match): """Print out the change that would occur by tagging `item` with the metadata from `match`, a TrackMatch object. """ cur_artist, new_artist = item.artist, match.info.artist cur_title, new_title = item.title, match.info.title if cur_artist != new_artist or cur_title != new_title: cur_artist, new_artist = ui.colordiff(cur_artist, new_artist) cur_title, new_title = ui.colordiff(cur_title, new_title) print_("Correcting track tags from:") print_(" %s - %s" % (cur_artist, cur_title)) print_("To:") print_(" %s - %s" % (new_artist, new_title)) else: print_("Tagging track: %s - %s" % (cur_artist, cur_title)) # Data URL. if match.info.data_url: print_('URL:\n %s' % match.info.data_url) # Info line. info = [] # Similarity. info.append('(Similarity: %s)' % dist_string(match.distance)) # Penalties. penalties = penalty_string(match.distance) if penalties: info.append(penalties) # Disambiguation. disambig = disambig_string(match.info) if disambig: info.append(ui.colorize('lightgray', '(%s)' % disambig)) print_(' '.join(info)) def _summary_judment(rec): """Determines whether a decision should be made without even asking the user. This occurs in quiet mode and when an action is chosen for NONE recommendations. Return an action or None if the user should be queried. May also print to the console if a summary judgment is made. """ if config['import']['quiet']: if rec == recommendation.strong: return importer.action.APPLY else: action = config['import']['quiet_fallback'].as_choice({ 'skip': importer.action.SKIP, 'asis': importer.action.ASIS, }) elif rec == recommendation.none: action = config['import']['none_rec_action'].as_choice({ 'skip': importer.action.SKIP, 'asis': importer.action.ASIS, 'ask': None, }) else: return None if action == importer.action.SKIP: print_('Skipping.') elif action == importer.action.ASIS: print_('Importing as-is.') return action def choose_candidate(candidates, singleton, rec, cur_artist=None, cur_album=None, item=None, itemcount=None): """Given a sorted list of candidates, ask the user for a selection of which candidate to use. Applies to both full albums and singletons (tracks). Candidates are either AlbumMatch or TrackMatch objects depending on `singleton`. for albums, `cur_artist`, `cur_album`, and `itemcount` must be provided. For singletons, `item` must be provided. Returns the result of the choice, which may SKIP, ASIS, TRACKS, or MANUAL or a candidate (an AlbumMatch/TrackMatch object). """ # Sanity check. if singleton: assert item is not None else: assert cur_artist is not None assert cur_album is not None # Zero candidates. if not candidates: if singleton: print_("No matching recordings found.") opts = ('Use as-is', 'Skip', 'Enter search', 'enter Id', 'aBort') else: print_("No matching release found for {0} tracks." .format(itemcount)) print_('For help, see: ' 'http://beets.readthedocs.org/en/latest/faq.html#nomatch') opts = ('Use as-is', 'as Tracks', 'Skip', 'Enter search', 'enter Id', 'aBort') sel = ui.input_options(opts) if sel == 'u': return importer.action.ASIS elif sel == 't': assert not singleton return importer.action.TRACKS elif sel == 'e': return importer.action.MANUAL elif sel == 's': return importer.action.SKIP elif sel == 'b': raise importer.ImportAbort() elif sel == 'i': return importer.action.MANUAL_ID else: assert False # Is the change good enough? bypass_candidates = False if rec != recommendation.none: match = candidates[0] bypass_candidates = True while True: # Display and choose from candidates. require = rec <= recommendation.low if not bypass_candidates: # Display list of candidates. print_(u'Finding tags for {0} "{1} - {2}".'.format( u'track' if singleton else u'album', item.artist if singleton else cur_artist, item.title if singleton else cur_album, )) print_(u'Candidates:') for i, match in enumerate(candidates): # Index, metadata, and distance. line = [ u'{0}.'.format(i + 1), u'{0} - {1}'.format( match.info.artist, match.info.title if singleton else match.info.album, ), u'({0})'.format(dist_string(match.distance)), ] # Penalties. penalties = penalty_string(match.distance, 3) if penalties: line.append(penalties) # Disambiguation disambig = disambig_string(match.info) if disambig: line.append(ui.colorize('lightgray', '(%s)' % disambig)) print_(' '.join(line)) # Ask the user for a choice. if singleton: opts = ('Skip', 'Use as-is', 'Enter search', 'enter Id', 'aBort') else: opts = ('Skip', 'Use as-is', 'as Tracks', 'Enter search', 'enter Id', 'aBort') sel = ui.input_options(opts, numrange=(1, len(candidates))) if sel == 's': return importer.action.SKIP elif sel == 'u': return importer.action.ASIS elif sel == 'e': return importer.action.MANUAL elif sel == 't': assert not singleton return importer.action.TRACKS elif sel == 'b': raise importer.ImportAbort() elif sel == 'i': return importer.action.MANUAL_ID else: # Numerical selection. match = candidates[sel - 1] if sel != 1: # When choosing anything but the first match, # disable the default action. require = True bypass_candidates = False # Show what we're about to do. if singleton: show_item_change(item, match) else: show_change(cur_artist, cur_album, match) # Exact match => tag automatically if we're not in timid mode. if rec == recommendation.strong and not config['import']['timid']: return match # Ask for confirmation. if singleton: opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', 'Enter search', 'enter Id', 'aBort') else: opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', 'as Tracks', 'Enter search', 'enter Id', 'aBort') default = config['import']['default_action'].as_choice({ 'apply': 'a', 'skip': 's', 'asis': 'u', 'none': None, }) if default is None: require = True sel = ui.input_options(opts, require=require, default=default) if sel == 'a': return match elif sel == 'm': pass elif sel == 's': return importer.action.SKIP elif sel == 'u': return importer.action.ASIS elif sel == 't': assert not singleton return importer.action.TRACKS elif sel == 'e': return importer.action.MANUAL elif sel == 'b': raise importer.ImportAbort() elif sel == 'i': return importer.action.MANUAL_ID def manual_search(singleton): """Input either an artist and album (for full albums) or artist and track name (for singletons) for manual search. """ artist = input_('Artist:') name = input_('Track:' if singleton else 'Album:') return artist.strip(), name.strip() def manual_id(singleton): """Input an ID, either for an album ("release") or a track ("recording"). """ prompt = u'Enter {0} ID:'.format('recording' if singleton else 'release') return input_(prompt).strip() class TerminalImportSession(importer.ImportSession): """An import session that runs in a terminal. """ def choose_match(self, task): """Given an initial autotagging of items, go through an interactive dance with the user to ask for a choice of metadata. Returns an AlbumMatch object, ASIS, or SKIP. """ # Show what we're tagging. print_() print_(displayable_path(task.paths, u'\n') + u' ({0} items)'.format(len(task.items))) # Take immediate action if appropriate. action = _summary_judment(task.rec) if action == importer.action.APPLY: match = task.candidates[0] show_change(task.cur_artist, task.cur_album, match) return match elif action is not None: return action # Loop until we have a choice. candidates, rec = task.candidates, task.rec while True: # Ask for a choice from the user. choice = choose_candidate(candidates, False, rec, task.cur_artist, task.cur_album, itemcount=len(task.items)) # Choose which tags to use. if choice in (importer.action.SKIP, importer.action.ASIS, importer.action.TRACKS): # Pass selection to main control flow. return choice elif choice is importer.action.MANUAL: # Try again with manual search terms. search_artist, search_album = manual_search(False) _, _, candidates, rec = autotag.tag_album( task.items, search_artist, search_album ) elif choice is importer.action.MANUAL_ID: # Try a manually-entered ID. search_id = manual_id(False) if search_id: _, _, candidates, rec = autotag.tag_album( task.items, search_id=search_id ) else: # We have a candidate! Finish tagging. Here, choice is an # AlbumMatch object. assert isinstance(choice, autotag.AlbumMatch) return choice def choose_item(self, task): """Ask the user for a choice about tagging a single item. Returns either an action constant or a TrackMatch object. """ print_() print_(task.item.path) candidates, rec = task.candidates, task.rec # Take immediate action if appropriate. action = _summary_judment(task.rec) if action == importer.action.APPLY: match = candidates[0] show_item_change(task.item, match) return match elif action is not None: return action while True: # Ask for a choice. choice = choose_candidate(candidates, True, rec, item=task.item) if choice in (importer.action.SKIP, importer.action.ASIS): return choice elif choice == importer.action.TRACKS: assert False # TRACKS is only legal for albums. elif choice == importer.action.MANUAL: # Continue in the loop with a new set of candidates. search_artist, search_title = manual_search(True) candidates, rec = autotag.tag_item(task.item, search_artist, search_title) elif choice == importer.action.MANUAL_ID: # Ask for a track ID. search_id = manual_id(True) if search_id: candidates, rec = autotag.tag_item(task.item, search_id=search_id) else: # Chose a candidate. assert isinstance(choice, autotag.TrackMatch) return choice def resolve_duplicate(self, task): """Decide what to do when a new album or item seems similar to one that's already in the library. """ log.warn("This %s is already in the library!" % ("album" if task.is_album else "item")) if config['import']['quiet']: # In quiet mode, don't prompt -- just skip. log.info('Skipping.') sel = 's' else: sel = ui.input_options( ('Skip new', 'Keep both', 'Remove old') ) if sel == 's': # Skip new. task.set_choice(importer.action.SKIP) elif sel == 'k': # Keep both. Do nothing; leave the choice intact. pass elif sel == 'r': # Remove old. task.remove_duplicates = True else: assert False def should_resume(self, path): return ui.input_yn(u"Import of the directory:\n{0}\n" "was interrupted. Resume (Y/n)?" .format(displayable_path(path))) # The import command. def import_files(lib, paths, query): """Import the files in the given list of paths or matching the query. """ # Check the user-specified directories. for path in paths: fullpath = syspath(normpath(path)) if not config['import']['singletons'] and not os.path.isdir(fullpath): raise ui.UserError(u'not a directory: {0}'.format( displayable_path(path))) elif config['import']['singletons'] and not os.path.exists(fullpath): raise ui.UserError(u'no such file: {0}'.format( displayable_path(path))) # Check parameter consistency. if config['import']['quiet'] and config['import']['timid']: raise ui.UserError("can't be both quiet and timid") # Open the log. if config['import']['log'].get() is not None: logpath = config['import']['log'].as_filename() try: logfile = codecs.open(syspath(logpath), 'a', 'utf8') except IOError: raise ui.UserError(u"could not open log file for writing: %s" % displayable_path(logpath)) print(u'import started', time.asctime(), file=logfile) else: logfile = None # Never ask for input in quiet mode. if config['import']['resume'].get() == 'ask' and \ config['import']['quiet']: config['import']['resume'] = False session = TerminalImportSession(lib, logfile, paths, query) try: session.run() finally: # If we were logging, close the file. if logfile: print(u'', file=logfile) logfile.close() # Emit event. plugins.send('import', lib=lib, paths=paths) import_cmd = ui.Subcommand('import', help='import new music', aliases=('imp', 'im')) import_cmd.parser.add_option('-c', '--copy', action='store_true', default=None, help="copy tracks into library directory (default)") import_cmd.parser.add_option('-C', '--nocopy', action='store_false', dest='copy', help="don't copy tracks (opposite of -c)") import_cmd.parser.add_option('-w', '--write', action='store_true', default=None, help="write new metadata to files' tags (default)") import_cmd.parser.add_option('-W', '--nowrite', action='store_false', dest='write', help="don't write metadata (opposite of -w)") import_cmd.parser.add_option('-a', '--autotag', action='store_true', dest='autotag', help="infer tags for imported files (default)") import_cmd.parser.add_option('-A', '--noautotag', action='store_false', dest='autotag', help="don't infer tags for imported files (opposite of -a)") import_cmd.parser.add_option('-p', '--resume', action='store_true', default=None, help="resume importing if interrupted") import_cmd.parser.add_option('-P', '--noresume', action='store_false', dest='resume', help="do not try to resume importing") import_cmd.parser.add_option('-q', '--quiet', action='store_true', dest='quiet', help="never prompt for input: skip albums instead") import_cmd.parser.add_option('-l', '--log', dest='log', help='file to log untaggable albums for later review') import_cmd.parser.add_option('-s', '--singletons', action='store_true', help='import individual tracks instead of full albums') import_cmd.parser.add_option('-t', '--timid', dest='timid', action='store_true', help='always confirm all actions') import_cmd.parser.add_option('-L', '--library', dest='library', action='store_true', help='retag items matching a query') import_cmd.parser.add_option('-i', '--incremental', dest='incremental', action='store_true', help='skip already-imported directories') import_cmd.parser.add_option('-I', '--noincremental', dest='incremental', action='store_false', help='do not skip already-imported directories') import_cmd.parser.add_option('--flat', dest='flat', action='store_true', help='import an entire tree as a single album') def import_func(lib, opts, args): config['import'].set_args(opts) # Special case: --copy flag suppresses import_move (which would # otherwise take precedence). if opts.copy: config['import']['move'] = False if opts.library: query = decargs(args) paths = [] else: query = None paths = args if not paths: raise ui.UserError('no path specified') import_files(lib, paths, query) import_cmd.func = import_func default_commands.append(import_cmd) # list: Query and show library contents. def list_items(lib, query, album, fmt): """Print out items in lib matching query. If album, then search for albums instead of single items. """ tmpl = Template(ui._pick_format(album, fmt)) if album: for album in lib.albums(query): ui.print_obj(album, lib, tmpl) else: for item in lib.items(query): ui.print_obj(item, lib, tmpl) list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',)) list_cmd.parser.add_option('-a', '--album', action='store_true', help='show matching albums instead of tracks') list_cmd.parser.add_option('-p', '--path', action='store_true', help='print paths for matched items or albums') list_cmd.parser.add_option('-f', '--format', action='store', help='print with custom format', default=None) def list_func(lib, opts, args): if opts.path: fmt = '$path' else: fmt = opts.format list_items(lib, decargs(args), opts.album, fmt) list_cmd.func = list_func default_commands.append(list_cmd) # update: Update library contents according to on-disk tags. def update_items(lib, query, album, move, pretend): """For all the items matched by the query, update the library to reflect the item's embedded tags. """ with lib.transaction(): items, _ = _do_query(lib, query, album) # Walk through the items and pick up their changes. affected_albums = set() for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): ui.print_obj(item, lib) if not pretend: item.remove(True) affected_albums.add(item.album_id) continue # Did the item change since last checked? if item.current_mtime() <= item.mtime: log.debug(u'skipping %s because mtime is up to date (%i)' % (displayable_path(item.path), item.mtime)) continue # Read new data. old_data = dict(item) try: item.read() except Exception as exc: log.error(u'error reading {0}: {1}'.format( displayable_path(item.path), exc)) continue # Special-case album artist when it matches track artist. (Hacky # but necessary for preserving album-level metadata for non- # autotagged imports.) if not item.albumartist and \ old_data['albumartist'] == old_data['artist'] == \ item.artist: item.albumartist = old_data['albumartist'] item._dirty.discard('albumartist') # Get and save metadata changes. changes = {} for key in library.ITEM_KEYS_META: if key in item._dirty: changes[key] = old_data[key], getattr(item, key) if changes: # Something changed. ui.print_obj(item, lib) for key, (oldval, newval) in changes.iteritems(): _showdiff(key, oldval, newval) # If we're just pretending, then don't move or save. if pretend: continue # Move the item if it's in the library. if move and lib.directory in ancestry(item.path): item.move() item.store() affected_albums.add(item.album_id) elif not pretend: # The file's mtime was different, but there were no changes # to the metadata. Store the new mtime, which is set in the # call to read(), so we don't check this again in the # future. item.store() # Skip album changes while pretending. if pretend: return # Modify affected albums to reflect changes in their items. for album_id in affected_albums: if album_id is None: # Singletons. continue album = lib.get_album(album_id) if not album: # Empty albums have already been removed. log.debug('emptied album %i' % album_id) continue first_item = album.items().get() # Update album structure to reflect an item in it. for key in library.ALBUM_KEYS_ITEM: album[key] = first_item[key] album.store() # Move album art (and any inconsistent items). if move and lib.directory in ancestry(first_item.path): log.debug('moving album %i' % album_id) album.move() update_cmd = ui.Subcommand('update', help='update the library', aliases=('upd','up',)) update_cmd.parser.add_option('-a', '--album', action='store_true', help='match albums instead of tracks') update_cmd.parser.add_option('-M', '--nomove', action='store_false', default=True, dest='move', help="don't move files in library") update_cmd.parser.add_option('-p', '--pretend', action='store_true', help="show all changes but do nothing") update_cmd.parser.add_option('-f', '--format', action='store', help='print with custom format', default=None) def update_func(lib, opts, args): update_items(lib, decargs(args), opts.album, opts.move, opts.pretend) update_cmd.func = update_func default_commands.append(update_cmd) # remove: Remove items from library, delete files. def remove_items(lib, query, album, delete): """Remove items matching query from lib. If album, then match and remove whole albums. If delete, also remove files from disk. """ # Get the matching items. items, albums = _do_query(lib, query, album) # Show all the items. for item in items: ui.print_obj(item, lib) # Confirm with user. print_() if delete: prompt = 'Really DELETE %i files (y/n)?' % len(items) else: prompt = 'Really remove %i items from the library (y/n)?' % \ len(items) if not ui.input_yn(prompt, True): return # Remove (and possibly delete) items. with lib.transaction(): for obj in (albums if album else items): obj.remove(delete) remove_cmd = ui.Subcommand('remove', help='remove matching items from the library', aliases=('rm',)) remove_cmd.parser.add_option("-d", "--delete", action="store_true", help="also remove files from disk") remove_cmd.parser.add_option('-a', '--album', action='store_true', help='match albums instead of tracks') def remove_func(lib, opts, args): remove_items(lib, decargs(args), opts.album, opts.delete) remove_cmd.func = remove_func default_commands.append(remove_cmd) # stats: Show library/query statistics. def show_stats(lib, query, exact): """Shows some statistics about the matched items.""" items = lib.items(query) total_size = 0 total_time = 0.0 total_items = 0 artists = set() albums = set() for item in items: if exact: total_size += os.path.getsize(item.path) else: total_size += int(item.length * item.bitrate / 8) total_time += item.length total_items += 1 artists.add(item.artist) albums.add(item.album) size_str = '' + ui.human_bytes(total_size) if exact: size_str += ' ({0} bytes)'.format(total_size) print_("""Tracks: {0} Total time: {1} ({2:.2f} seconds) Total size: {3} Artists: {4} Albums: {5}""".format(total_items, ui.human_seconds(total_time), total_time, size_str, len(artists), len(albums))) stats_cmd = ui.Subcommand('stats', help='show statistics about the library or a query') stats_cmd.parser.add_option('-e', '--exact', action='store_true', help='get exact file sizes') def stats_func(lib, opts, args): show_stats(lib, decargs(args), opts.exact) stats_cmd.func = stats_func default_commands.append(stats_cmd) # version: Show current beets version. def show_version(lib, opts, args): print_('beets version %s' % beets.__version__) # Show plugins. names = [p.name for p in plugins.find_plugins()] if names: print_('plugins:', ', '.join(names)) else: print_('no plugins loaded') version_cmd = ui.Subcommand('version', help='output version information') version_cmd.func = show_version default_commands.append(version_cmd) # modify: Declaratively change metadata. def _convert_type(key, value, album=False): """Convert a string to the appropriate type for the given field. `album` indicates whether to use album or item field definitions. """ fields = library.ALBUM_FIELDS if album else library.ITEM_FIELDS if key not in fields: return value typ = [f[1] for f in fields if f[0] == key][0] if typ is bool: return util.str2bool(value) elif typ is datetime: fmt = config['time_format'].get(unicode) try: return time.mktime(time.strptime(value, fmt)) except ValueError: raise ui.UserError(u'{0} must have format {1}'.format(key, fmt)) elif typ is bytes: # A path. return util.bytestring_path(value) else: try: return typ(value) except ValueError: raise ui.UserError(u'{0} must be a {1}'.format(key, typ)) def modify_items(lib, mods, query, write, move, album, confirm): """Modifies matching items according to key=value assignments.""" # Parse key=value specifications into a dictionary. fsets = {} for mod in mods: key, value = mod.split('=', 1) fsets[key] = _convert_type(key, value, album) # Get the items to modify. items, albums = _do_query(lib, query, album, False) objs = albums if album else items # Preview change. print_('Modifying %i %ss.' % (len(objs), 'album' if album else 'item')) for obj in objs: # Identify the changed object. ui.print_obj(obj, lib) # Show each change. for field, value in fsets.iteritems(): _showdiff(field, obj.get(field), value) # Confirm. if confirm: extra = ' and write tags' if write else '' if not ui.input_yn('Really modify%s (Y/n)?' % extra): return # Apply changes to database. with lib.transaction(): for obj in objs: for field, value in fsets.iteritems(): obj[field] = value if move: cur_path = obj.item_dir() if album else obj.path if lib.directory in ancestry(cur_path): # In library? log.debug('moving object %s' % cur_path) obj.move() obj.store() # Apply tags if requested. if write: if album: items = itertools.chain(*(a.items() for a in albums)) for item in items: item.write() modify_cmd = ui.Subcommand('modify', help='change metadata fields', aliases=('mod',)) modify_cmd.parser.add_option('-M', '--nomove', action='store_false', default=True, dest='move', help="don't move files in library") modify_cmd.parser.add_option('-w', '--write', action='store_true', default=None, help="write new metadata to files' tags (default)") modify_cmd.parser.add_option('-W', '--nowrite', action='store_false', dest='write', help="don't write metadata (opposite of -w)") modify_cmd.parser.add_option('-a', '--album', action='store_true', help='modify whole albums instead of tracks') modify_cmd.parser.add_option('-y', '--yes', action='store_true', help='skip confirmation') modify_cmd.parser.add_option('-f', '--format', action='store', help='print with custom format', default=None) def modify_func(lib, opts, args): args = decargs(args) mods = [a for a in args if '=' in a] query = [a for a in args if '=' not in a] if not mods: raise ui.UserError('no modifications specified') write = opts.write if opts.write is not None else \ config['import']['write'].get(bool) modify_items(lib, mods, query, write, opts.move, opts.album, not opts.yes) modify_cmd.func = modify_func default_commands.append(modify_cmd) # move: Move/copy files to the library or a new base directory. def move_items(lib, dest, query, copy, album): """Moves or copies items to a new base directory, given by dest. If dest is None, then the library's base directory is used, making the command "consolidate" files. """ items, albums = _do_query(lib, query, album, False) objs = albums if album else items action = 'Copying' if copy else 'Moving' entity = 'album' if album else 'item' logging.info('%s %i %ss.' % (action, len(objs), entity)) for obj in objs: old_path = obj.item_dir() if album else obj.path logging.debug('moving: %s' % old_path) obj.move(copy, basedir=dest) obj.store() move_cmd = ui.Subcommand('move', help='move or copy items', aliases=('mv',)) move_cmd.parser.add_option('-d', '--dest', metavar='DIR', dest='dest', help='destination directory') move_cmd.parser.add_option('-c', '--copy', default=False, action='store_true', help='copy instead of moving') move_cmd.parser.add_option('-a', '--album', default=False, action='store_true', help='match whole albums instead of tracks') def move_func(lib, opts, args): dest = opts.dest if dest is not None: dest = normpath(dest) if not os.path.isdir(dest): raise ui.UserError('no such directory: %s' % dest) move_items(lib, dest, decargs(args), opts.copy, opts.album) move_cmd.func = move_func default_commands.append(move_cmd) beets-1.3.1/beets/ui/migrate.py0000644000076500000240000003224512203275653017316 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Conversion from legacy (pre-1.1) configuration to Confit/YAML configuration. """ import os import ConfigParser import codecs import yaml import logging import time import itertools import re import beets from beets import util from beets import ui from beets.util import confit CONFIG_PATH_VAR = 'BEETSCONFIG' DEFAULT_CONFIG_FILENAME_UNIX = '.beetsconfig' DEFAULT_CONFIG_FILENAME_WINDOWS = 'beetsconfig.ini' DEFAULT_LIBRARY_FILENAME_UNIX = '.beetsmusic.blb' DEFAULT_LIBRARY_FILENAME_WINDOWS = 'beetsmusic.blb' WINDOWS_BASEDIR = os.environ.get('APPDATA') or '~' OLD_CONFIG_SUFFIX = '.old' PLUGIN_NAMES = { 'rdm': 'random', 'fuzzy_search': 'fuzzy', } AUTO_KEYS = ('automatic', 'autofetch', 'autoembed', 'autoscrub') IMPORTFEEDS_PREFIX = 'feeds_' CONFIG_MIGRATED_MESSAGE = u""" You appear to be upgrading from beets 1.0 (or earlier) to 1.1. Your configuration file has been migrated automatically to: {newconfig} Edit this file to configure beets. You might want to remove your old-style ".beetsconfig" file now. See the documentation for more details on the new configuration system: http://beets.readthedocs.org/page/reference/config.html """.strip() DB_MIGRATED_MESSAGE = u'Your database file has also been copied to:\n{newdb}' YAML_COMMENT = '# Automatically migrated from legacy .beetsconfig.\n\n' log = logging.getLogger('beets') # An itertools recipe. def grouper(n, iterable): args = [iter(iterable)] * n return itertools.izip_longest(*args) def _displace(fn): """Move a file aside using a timestamp suffix so a new file can be put in its place. """ util.move( fn, u'{0}.old.{1}'.format(fn, int(time.time())), True ) def default_paths(): """Produces the appropriate default config and library database paths for the current system. On Unix, this is always in ~. On Windows, tries ~ first and then $APPDATA for the config and library files (for backwards compatibility). """ windows = os.path.__name__ == 'ntpath' if windows: windata = os.environ.get('APPDATA') or '~' # Shorthand for joining paths. def exp(*vals): return os.path.expanduser(os.path.join(*vals)) config = exp('~', DEFAULT_CONFIG_FILENAME_UNIX) if windows and not os.path.exists(config): config = exp(windata, DEFAULT_CONFIG_FILENAME_WINDOWS) libpath = exp('~', DEFAULT_LIBRARY_FILENAME_UNIX) if windows and not os.path.exists(libpath): libpath = exp(windata, DEFAULT_LIBRARY_FILENAME_WINDOWS) return config, libpath def get_config(): """Using the same logic as beets 1.0, locate and read the .beetsconfig file. Return a ConfigParser instance or None if no config is found. """ default_config, default_libpath = default_paths() if CONFIG_PATH_VAR in os.environ: configpath = os.path.expanduser(os.environ[CONFIG_PATH_VAR]) else: configpath = default_config config = ConfigParser.SafeConfigParser() if os.path.exists(util.syspath(configpath)): with codecs.open(configpath, 'r', encoding='utf-8') as f: config.readfp(f) return config, configpath else: return None, configpath def flatten_config(config): """Given a ConfigParser, flatten the values into a dict-of-dicts representation where each section gets its own dictionary of values. """ out = confit.OrderedDict() for section in config.sections(): sec_dict = out[section] = confit.OrderedDict() for option in config.options(section): sec_dict[option] = config.get(section, option, True) return out def transform_value(value): """Given a string read as the value of a config option, return a massaged version of that value (possibly with a different type). """ # Booleans. if value.lower() in ('false', 'no', 'off'): return False elif value.lower() in ('true', 'yes', 'on'): return True # Integers. try: return int(value) except ValueError: pass # Floats. try: return float(value) except ValueError: pass return value def transform_data(data): """Given a dict-of-dicts representation of legacy config data, tweak the data into a new form. This new form is suitable for dumping as YAML. """ out = confit.OrderedDict() for section, pairs in data.items(): if section == 'beets': # The "main" section. In the new config system, these values # are in the "root": no section at all. for key, value in pairs.items(): value = transform_value(value) if key.startswith('import_'): # Importer config is now under an "import:" key. if 'import' not in out: out['import'] = confit.OrderedDict() out['import'][key[7:]] = value elif key == 'plugins': # Renamed plugins. plugins = value.split() new_plugins = [PLUGIN_NAMES.get(p, p) for p in plugins] out['plugins'] = ' '.join(new_plugins) elif key == 'replace': # YAMLy representation for character replacements. replacements = confit.OrderedDict() for pat, repl in grouper(2, value.split()): if repl == '': repl = '' replacements[pat] = repl out['replace'] = replacements elif key == 'pluginpath': # Used to be a colon-separated string. Now a list. out['pluginpath'] = value.split(':') else: out[key] = value elif pairs: # Other sections (plugins, etc). sec_out = out[section] = confit.OrderedDict() for key, value in pairs.items(): # Standardized "auto" option. if key in AUTO_KEYS: key = 'auto' # Unnecessary : hack in queries. if section == 'paths': key = key.replace('_', ':') # Changed option names for importfeeds plugin. if section == 'importfeeds': if key.startswith(IMPORTFEEDS_PREFIX): key = key[len(IMPORTFEEDS_PREFIX):] sec_out[key] = transform_value(value) return out class Dumper(yaml.SafeDumper): """A PyYAML Dumper that represents OrderedDicts as ordinary mappings (in order, of course). """ # From http://pyyaml.org/attachment/ticket/161/use_ordered_dict.py def represent_mapping(self, tag, mapping, flow_style=None): value = [] node = yaml.MappingNode(tag, value, flow_style=flow_style) if self.alias_key is not None: self.represented_objects[self.alias_key] = node best_style = True if hasattr(mapping, 'items'): mapping = list(mapping.items()) for item_key, item_value in mapping: node_key = self.represent_data(item_key) node_value = self.represent_data(item_value) if not (isinstance(node_key, yaml.ScalarNode) and \ not node_key.style): best_style = False if not (isinstance(node_value, yaml.ScalarNode) and \ not node_value.style): best_style = False value.append((node_key, node_value)) if flow_style is None: if self.default_flow_style is not None: node.flow_style = self.default_flow_style else: node.flow_style = best_style return node Dumper.add_representer(confit.OrderedDict, Dumper.represent_dict) def migrate_config(replace=False): """Migrate a legacy beetsconfig file to a new-style config.yaml file in an appropriate place. If `replace` is enabled, then any existing config.yaml will be moved aside. Otherwise, the process is aborted when the file exists. """ # Load legacy configuration data, if any. config, configpath = get_config() if not config: log.debug(u'no config file found at {0}'.format( util.displayable_path(configpath) )) return # Get the new configuration file path and possibly move it out of # the way. destfn = os.path.join(beets.config.config_dir(), confit.CONFIG_FILENAME) if os.path.exists(destfn): if replace: log.debug(u'moving old config aside: {0}'.format( util.displayable_path(destfn) )) _displace(destfn) else: # File exists and we won't replace it. We're done. return log.debug(u'migrating config file {0}'.format( util.displayable_path(configpath) )) # Convert the configuration to a data structure ready to be dumped # as the new Confit file. data = transform_data(flatten_config(config)) # Encode result as YAML. yaml_out = yaml.dump( data, Dumper=Dumper, default_flow_style=False, indent=4, width=1000, ) # A ridiculous little hack to add some whitespace between "sections" # in the YAML output. I hope this doesn't break any YAML syntax. yaml_out = re.sub(r'(\n\w+:\n [^-\s])', '\n\\1', yaml_out) yaml_out = YAML_COMMENT + yaml_out # Write the data to the new config destination. log.debug(u'writing migrated config to {0}'.format( util.displayable_path(destfn) )) with open(destfn, 'w') as f: f.write(yaml_out) return destfn def migrate_db(replace=False): """Copy the beets library database file to the new location (e.g., from ~/.beetsmusic.blb to ~/.config/beets/library.db). """ _, srcfn = default_paths() destfn = beets.config['library'].as_filename() if not os.path.exists(srcfn) or srcfn == destfn: # Old DB does not exist or we're configured to point to the same # database. Do nothing. return if os.path.exists(destfn): if replace: log.debug(u'moving old database aside: {0}'.format( util.displayable_path(destfn) )) _displace(destfn) else: return log.debug(u'copying database from {0} to {1}'.format( util.displayable_path(srcfn), util.displayable_path(destfn) )) util.copy(srcfn, destfn) return destfn def migrate_state(replace=False): """Copy the beets runtime state file from the old path (i.e., ~/.beetsstate) to the new path (i.e., ~/.config/beets/state.pickle). """ srcfn = os.path.expanduser(os.path.join('~', '.beetsstate')) if not os.path.exists(srcfn): return destfn = beets.config['statefile'].as_filename() if os.path.exists(destfn): if replace: _displace(destfn) else: return log.debug(u'copying state file from {0} to {1}'.format( util.displayable_path(srcfn), util.displayable_path(destfn) )) util.copy(srcfn, destfn) return destfn # Automatic migration when beets starts. def automigrate(): """Migrate the configuration, database, and state files. If any migration occurs, print out a notice with some helpful next steps. """ config_fn = migrate_config() db_fn = migrate_db() migrate_state() if config_fn: ui.print_(ui.colorize('fuchsia', u'MIGRATED CONFIGURATION')) ui.print_(CONFIG_MIGRATED_MESSAGE.format( newconfig=util.displayable_path(config_fn)) ) if db_fn: ui.print_(DB_MIGRATED_MESSAGE.format( newdb=util.displayable_path(db_fn) )) ui.input_(ui.colorize('fuchsia', u'Press ENTER to continue:')) ui.print_() # CLI command for explicit migration. migrate_cmd = ui.Subcommand('migrate', help='convert legacy config') def migrate_func(lib, opts, args): """Explicit command for migrating files. Existing files in each destination are moved aside. """ config_fn = migrate_config(replace=True) if config_fn: log.info(u'Migrated configuration to: {0}'.format( util.displayable_path(config_fn) )) db_fn = migrate_db(replace=True) if db_fn: log.info(u'Migrated library database to: {0}'.format( util.displayable_path(db_fn) )) state_fn = migrate_state(replace=True) if state_fn: log.info(u'Migrated state file to: {0}'.format( util.displayable_path(state_fn) )) migrate_cmd.func = migrate_func beets-1.3.1/beets/util/0000755000076500000240000000000012226377756015662 5ustar asampsonstaff00000000000000beets-1.3.1/beets/util/__init__.py0000644000076500000240000005177212203275653017773 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Miscellaneous utility functions.""" from __future__ import division import os import sys import re import shutil import fnmatch from collections import defaultdict import traceback import subprocess MAX_FILENAME_LENGTH = 200 WINDOWS_MAGIC_PREFIX = u'\\\\?\\' class HumanReadableException(Exception): """An Exception that can include a human-readable error message to be logged without a traceback. Can preserve a traceback for debugging purposes as well. Has at least two fields: `reason`, the underlying exception or a string describing the problem; and `verb`, the action being performed during the error. If `tb` is provided, it is a string containing a traceback for the associated exception. (Note that this is not necessary in Python 3.x and should be removed when we make the transition.) """ error_kind = 'Error' # Human-readable description of error type. def __init__(self, reason, verb, tb=None): self.reason = reason self.verb = verb self.tb = tb super(HumanReadableException, self).__init__(self.get_message()) def _gerund(self): """Generate a (likely) gerund form of the English verb. """ if ' ' in self.verb: return self.verb gerund = self.verb[:-1] if self.verb.endswith('e') else self.verb gerund += 'ing' return gerund def _reasonstr(self): """Get the reason as a string.""" if isinstance(self.reason, unicode): return self.reason elif isinstance(self.reason, basestring): # Byte string. return self.reason.decode('utf8', 'ignore') elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError return self.reason.strerror else: return u'"{0}"'.format(unicode(self.reason)) def get_message(self): """Create the human-readable description of the error, sans introduction. """ raise NotImplementedError def log(self, logger): """Log to the provided `logger` a human-readable message as an error and a verbose traceback as a debug message. """ if self.tb: logger.debug(self.tb) logger.error(u'{0}: {1}'.format(self.error_kind, self.args[0])) class FilesystemError(HumanReadableException): """An error that occurred while performing a filesystem manipulation via a function in this module. The `paths` field is a sequence of pathnames involved in the operation. """ def __init__(self, reason, verb, paths, tb=None): self.paths = paths super(FilesystemError, self).__init__(reason, verb, tb) def get_message(self): # Use a nicer English phrasing for some specific verbs. if self.verb in ('move', 'copy', 'rename'): clause = 'while {0} {1} to {2}'.format( self._gerund(), repr(self.paths[0]), repr(self.paths[1]) ) elif self.verb in ('delete', 'write', 'create', 'read'): clause = 'while {0} {1}'.format( self._gerund(), repr(self.paths[0]) ) else: clause = 'during {0} of paths {1}'.format( self.verb, u', '.join(repr(p) for p in self.paths) ) return u'{0} {1}'.format(self._reasonstr(), clause) def normpath(path): """Provide the canonical form of the path suitable for storing in the database. """ path = syspath(path, prefix=False) path = os.path.normpath(os.path.abspath(os.path.expanduser(path))) return bytestring_path(path) def ancestry(path, pathmod=None): """Return a list consisting of path's parent directory, its grandparent, and so on. For instance: >>> ancestry('/a/b/c') ['/', '/a', '/a/b'] The argument should *not* be the result of a call to `syspath`. """ pathmod = pathmod or os.path out = [] last_path = None while path: path = pathmod.dirname(path) if path == last_path: break last_path = path if path: # don't yield '' out.insert(0, path) return out def sorted_walk(path, ignore=(), logger=None): """Like `os.walk`, but yields things in case-insensitive sorted, breadth-first order. Directory and file names matching any glob pattern in `ignore` are skipped. If `logger` is provided, then warning messages are logged there when a directory cannot be listed. """ # Make sure the path isn't a Unicode string. path = bytestring_path(path) # Get all the directories and files at this level. try: contents = os.listdir(syspath(path)) except OSError as exc: if logger: logger.warn(u'could not list directory {0}: {1}'.format( displayable_path(path), exc.strerror )) return dirs = [] files = [] for base in contents: base = bytestring_path(base) # Skip ignored filenames. skip = False for pat in ignore: if fnmatch.fnmatch(base, pat): skip = True break if skip: continue # Add to output as either a file or a directory. cur = os.path.join(path, base) if os.path.isdir(syspath(cur)): dirs.append(base) else: files.append(base) # Sort lists (case-insensitive) and yield the current level. dirs.sort(key=bytes.lower) files.sort(key=bytes.lower) yield (path, dirs, files) # Recurse into directories. for base in dirs: cur = os.path.join(path, base) # yield from sorted_walk(...) for res in sorted_walk(cur, ignore, logger): yield res def mkdirall(path): """Make all the enclosing directories of path (like mkdir -p on the parent). """ for ancestor in ancestry(path): if not os.path.isdir(syspath(ancestor)): try: os.mkdir(syspath(ancestor)) except (OSError, IOError) as exc: raise FilesystemError(exc, 'create', (ancestor,), traceback.format_exc()) def fnmatch_all(names, patterns): """Determine whether all strings in `names` match at least one of the `patterns`, which should be shell glob expressions. """ for name in names: matches = False for pattern in patterns: matches = fnmatch.fnmatch(name, pattern) if matches: break if not matches: return False return True def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')): """If path is an empty directory, then remove it. Recursively remove path's ancestry up to root (which is never removed) where there are empty directories. If path is not contained in root, then nothing is removed. Glob patterns in clutter are ignored when determining emptiness. If root is not provided, then only path may be removed (i.e., no recursive removal). """ path = normpath(path) if root is not None: root = normpath(root) ancestors = ancestry(path) if root is None: # Only remove the top directory. ancestors = [] elif root in ancestors: # Only remove directories below the root. ancestors = ancestors[ancestors.index(root)+1:] else: # Remove nothing. return # Traverse upward from path. ancestors.append(path) ancestors.reverse() for directory in ancestors: directory = syspath(directory) if not os.path.exists(directory): # Directory gone already. continue if fnmatch_all(os.listdir(directory), clutter): # Directory contains only clutter (or nothing). try: shutil.rmtree(directory) except OSError: break else: break def components(path, pathmod=None): """Return a list of the path components in path. For instance: >>> components('/a/b/c') ['a', 'b', 'c'] The argument should *not* be the result of a call to `syspath`. """ pathmod = pathmod or os.path comps = [] ances = ancestry(path, pathmod) for anc in ances: comp = pathmod.basename(anc) if comp: comps.append(comp) else: # root comps.append(anc) last = pathmod.basename(path) if last: comps.append(last) return comps def _fsencoding(): """Get the system's filesystem encoding. On Windows, this is always UTF-8 (not MBCS). """ encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() if encoding == 'mbcs': # On Windows, a broken encoding known to Python as "MBCS" is # used for the filesystem. However, we only use the Unicode API # for Windows paths, so the encoding is actually immaterial so # we can avoid dealing with this nastiness. We arbitrarily # choose UTF-8. encoding = 'utf8' return encoding def bytestring_path(path, pathmod=None): """Given a path, which is either a str or a unicode, returns a str path (ensuring that we never deal with Unicode pathnames). """ pathmod = pathmod or os.path windows = pathmod.__name__ == 'ntpath' # Pass through bytestrings. if isinstance(path, str): return path # On Windows, remove the magic prefix added by `syspath`. This makes # ``bytestring_path(syspath(X)) == X``, i.e., we can safely # round-trip through `syspath`. if windows and path.startswith(WINDOWS_MAGIC_PREFIX): path = path[len(WINDOWS_MAGIC_PREFIX):] # Try to encode with default encodings, but fall back to UTF8. try: return path.encode(_fsencoding()) except (UnicodeError, LookupError): return path.encode('utf8') def displayable_path(path, separator=u'; '): """Attempts to decode a bytestring path to a unicode object for the purpose of displaying it to the user. If the `path` argument is a list or a tuple, the elements are joined with `separator`. """ if isinstance(path, (list, tuple)): return separator.join(displayable_path(p) for p in path) elif isinstance(path, unicode): return path elif not isinstance(path, str): # A non-string object: just get its unicode representation. return unicode(path) try: return path.decode(_fsencoding(), 'ignore') except (UnicodeError, LookupError): return path.decode('utf8', 'ignore') def syspath(path, prefix=True, pathmod=None): """Convert a path for use by the operating system. In particular, paths on Windows must receive a magic prefix and must be converted to Unicode before they are sent to the OS. To disable the magic prefix on Windows, set `prefix` to False---but only do this if you *really* know what you're doing. """ pathmod = pathmod or os.path windows = pathmod.__name__ == 'ntpath' # Don't do anything if we're not on windows if not windows: return path if not isinstance(path, unicode): # Beets currently represents Windows paths internally with UTF-8 # arbitrarily. But earlier versions used MBCS because it is # reported as the FS encoding by Windows. Try both. try: path = path.decode('utf8') except UnicodeError: # The encoding should always be MBCS, Windows' broken # Unicode representation. encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() path = path.decode(encoding, 'replace') # Add the magic prefix if it isn't already there if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX): path = WINDOWS_MAGIC_PREFIX + path return path def samefile(p1, p2): """Safer equality for paths.""" return shutil._samefile(syspath(p1), syspath(p2)) def remove(path, soft=True): """Remove the file. If `soft`, then no error will be raised if the file does not exist. """ path = syspath(path) if soft and not os.path.exists(path): return try: os.remove(path) except (OSError, IOError) as exc: raise FilesystemError(exc, 'delete', (path,), traceback.format_exc()) def copy(path, dest, replace=False, pathmod=os.path): """Copy a plain file. Permissions are not copied. If `dest` already exists, raises a FilesystemError unless `replace` is True. Has no effect if `path` is the same as `dest`. Paths are translated to system paths before the syscall. """ if samefile(path, dest): return path = syspath(path) dest = syspath(dest) if not replace and pathmod.exists(dest): raise FilesystemError('file exists', 'copy', (path, dest)) try: shutil.copyfile(path, dest) except (OSError, IOError) as exc: raise FilesystemError(exc, 'copy', (path, dest), traceback.format_exc()) def move(path, dest, replace=False, pathmod=os.path): """Rename a file. `dest` may not be a directory. If `dest` already exists, raises an OSError unless `replace` is True. Has no effect if `path` is the same as `dest`. If the paths are on different filesystems (or the rename otherwise fails), a copy is attempted instead, in which case metadata will *not* be preserved. Paths are translated to system paths. """ if samefile(path, dest): return path = syspath(path) dest = syspath(dest) if pathmod.exists(dest) and not replace: raise FilesystemError('file exists', 'rename', (path, dest), traceback.format_exc()) # First, try renaming the file. try: os.rename(path, dest) except OSError: # Otherwise, copy and delete the original. try: shutil.copyfile(path, dest) os.remove(path) except (OSError, IOError) as exc: raise FilesystemError(exc, 'move', (path, dest), traceback.format_exc()) def unique_path(path): """Returns a version of ``path`` that does not exist on the filesystem. Specifically, if ``path` itself already exists, then something unique is appended to the path. """ if not os.path.exists(syspath(path)): return path base, ext = os.path.splitext(path) match = re.search(r'\.(\d)+$', base) if match: num = int(match.group(1)) base = base[:match.start()] else: num = 0 while True: num += 1 new_path = '%s.%i%s' % (base, num, ext) if not os.path.exists(new_path): return new_path # Note: The Windows "reserved characters" are, of course, allowed on # Unix. They are forbidden here because they cause problems on Samba # shares, which are sufficiently common as to cause frequent problems. # http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx CHAR_REPLACE = [ (re.compile(ur'[\\/]'), u'_'), # / and \ -- forbidden everywhere. (re.compile(ur'^\.'), u'_'), # Leading dot (hidden files on Unix). (re.compile(ur'[\x00-\x1f]'), u''), # Control characters. (re.compile(ur'[<>:"\?\*\|]'), u'_'), # Windows "reserved characters". (re.compile(ur'\.$'), u'_'), # Trailing dots. (re.compile(ur'\s+$'), u''), # Trailing whitespace. ] def sanitize_path(path, pathmod=None, replacements=None): """Takes a path (as a Unicode string) and makes sure that it is legal. Returns a new path. Only works with fragments; won't work reliably on Windows when a path begins with a drive letter. Path separators (including altsep!) should already be cleaned from the path components. If replacements is specified, it is used *instead* of the default set of replacements; it must be a list of (compiled regex, replacement string) pairs. """ pathmod = pathmod or os.path replacements = replacements or CHAR_REPLACE comps = components(path, pathmod) if not comps: return '' for i, comp in enumerate(comps): for regex, repl in replacements: comp = regex.sub(repl, comp) comps[i] = comp return pathmod.join(*comps) def truncate_path(path, pathmod=None, length=MAX_FILENAME_LENGTH): """Given a bytestring path or a Unicode path fragment, truncate the components to a legal length. In the last component, the extension is preserved. """ pathmod = pathmod or os.path comps = components(path, pathmod) out = [c[:length] for c in comps] base, ext = pathmod.splitext(comps[-1]) if ext: # Last component has an extension. base = base[:length - len(ext)] out[-1] = base + ext return pathmod.join(*out) def str2bool(value): """Returns a boolean reflecting a human-entered string.""" if value.lower() in ('yes', '1', 'true', 't', 'y'): return True else: return False def as_string(value): """Convert a value to a Unicode object for matching with a query. None becomes the empty string. Bytestrings are silently decoded. """ if value is None: return u'' elif isinstance(value, buffer): return str(value).decode('utf8', 'ignore') elif isinstance(value, str): return value.decode('utf8', 'ignore') else: return unicode(value) def levenshtein(s1, s2): """A nice DP edit distance implementation from Wikibooks: http://en.wikibooks.org/wiki/Algorithm_implementation/Strings/ Levenshtein_distance#Python """ if len(s1) < len(s2): return levenshtein(s2, s1) if not s1: return len(s2) previous_row = xrange(len(s2) + 1) for i, c1 in enumerate(s1): current_row = [i + 1] for j, c2 in enumerate(s2): insertions = previous_row[j + 1] + 1 deletions = current_row[j] + 1 substitutions = previous_row[j] + (c1 != c2) current_row.append(min(insertions, deletions, substitutions)) previous_row = current_row return previous_row[-1] def plurality(objs): """Given a sequence of comparable objects, returns the object that is most common in the set and the frequency of that object. The sequence must contain at least one object. """ # Calculate frequencies. freqs = defaultdict(int) for obj in objs: freqs[obj] += 1 if not freqs: raise ValueError('sequence must be non-empty') # Find object with maximum frequency. max_freq = 0 res = None for obj, freq in freqs.items(): if freq > max_freq: max_freq = freq res = obj return res, max_freq def cpu_count(): """Return the number of hardware thread contexts (cores or SMT threads) in the system. """ # Adapted from the soundconverter project: # https://github.com/kassoulet/soundconverter if sys.platform == 'win32': try: num = int(os.environ['NUMBER_OF_PROCESSORS']) except (ValueError, KeyError): num = 0 elif sys.platform == 'darwin': try: num = int(os.popen('sysctl -n hw.ncpu').read()) except ValueError: num = 0 else: try: num = os.sysconf('SC_NPROCESSORS_ONLN') except (ValueError, OSError, AttributeError): num = 0 if num >= 1: return num else: return 1 def command_output(cmd): """Wraps the `subprocess` module to invoke a command (given as a list of arguments starting with the command name) and collect stdout. The stderr stream is ignored. May raise `subprocess.CalledProcessError` or an `OSError`. This replaces `subprocess.check_output`, which isn't available in Python 2.6 and which can have problems if lots of output is sent to stderr. """ with open(os.devnull, 'w') as devnull: proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=devnull) stdout, _ = proc.communicate() if proc.returncode: raise subprocess.CalledProcessError(proc.returncode, cmd) return stdout def max_filename_length(path, limit=MAX_FILENAME_LENGTH): """Attempt to determine the maximum filename length for the filesystem containing `path`. If the value is greater than `limit`, then `limit` is used instead (to prevent errors when a filesystem misreports its capacity). If it cannot be determined (e.g., on Windows), return `limit`. """ if hasattr(os, 'statvfs'): try: res = os.statvfs(path) except OSError: return limit return min(res[9], limit) else: return limit beets-1.3.1/beets/util/artresizer.py0000644000076500000240000001420412203275653020413 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, Fabrice Laporte # # 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. """Abstraction layer to resize images using PIL, ImageMagick, or a public resizing proxy if neither is available. """ import urllib import subprocess import os from tempfile import NamedTemporaryFile import logging from beets import util # Resizing methods PIL = 1 IMAGEMAGICK = 2 WEBPROXY = 3 PROXY_URL = 'http://images.weserv.nl/' log = logging.getLogger('beets') class ArtResizerError(Exception): """Raised when an error occurs during image resizing. """ def call(args): """Execute the command indicated by `args` (a list of strings) and return the command's output. The stderr stream is ignored. If the command exits abnormally, a ArtResizerError is raised. """ try: return util.command_output(args) except subprocess.CalledProcessError as e: raise ArtResizerError( "{0} exited with status {1}".format(args[0], e.returncode) ) def resize_url(url, maxwidth): """Return a proxied image URL that resizes the original image to maxwidth (preserving aspect ratio). """ return '{0}?{1}'.format(PROXY_URL, urllib.urlencode({ 'url': url.replace('http://',''), 'w': str(maxwidth), })) def temp_file_for(path): """Return an unused filename with the same extension as the specified path. """ ext = os.path.splitext(path)[1] with NamedTemporaryFile(suffix=ext, delete=False) as f: return f.name def pil_resize(maxwidth, path_in, path_out=None): """Resize using Python Imaging Library (PIL). Return the output path of resized image. """ path_out = path_out or temp_file_for(path_in) from PIL import Image log.debug(u'artresizer: PIL resizing {0} to {1}'.format( util.displayable_path(path_in), util.displayable_path(path_out) )) try: im = Image.open(util.syspath(path_in)) size = maxwidth, maxwidth im.thumbnail(size, Image.ANTIALIAS) im.save(path_out) return path_out except IOError: log.error(u"PIL cannot create thumbnail for '{0}'".format( util.displayable_path(path_in) )) return path_in def im_resize(maxwidth, path_in, path_out=None): """Resize using ImageMagick's ``convert`` tool. tool. Return the output path of resized image. """ path_out = path_out or temp_file_for(path_in) log.debug(u'artresizer: ImageMagick resizing {0} to {1}'.format( util.displayable_path(path_in), util.displayable_path(path_out) )) # "-resize widthxheight>" shrinks images with dimension(s) larger # than the corresponding width and/or height dimension(s). The > # "only shrink" flag is prefixed by ^ escape char for Windows # compatibility. call([ 'convert', util.syspath(path_in), '-resize', '{0}x^>'.format(maxwidth), path_out ]) return path_out BACKEND_FUNCS = { PIL: pil_resize, IMAGEMAGICK: im_resize, } class Shareable(type): """A pseudo-singleton metaclass that allows both shared and non-shared instances. The ``MyClass.shared`` property holds a lazily-created shared instance of ``MyClass`` while calling ``MyClass()`` to construct a new object works as usual. """ def __init__(cls, name, bases, dict): super(Shareable, cls).__init__(name, bases, dict) cls._instance = None @property def shared(cls): if cls._instance is None: cls._instance = cls() return cls._instance class ArtResizer(object): """A singleton class that performs image resizes. """ __metaclass__ = Shareable def __init__(self, method=None): """Create a resizer object for the given method or, if none is specified, with an inferred method. """ self.method = method or self._guess_method() log.debug(u"artresizer: method is {0}".format(self.method)) def resize(self, maxwidth, path_in, path_out=None): """Manipulate an image file according to the method, returning a new path. For PIL or IMAGEMAGIC methods, resizes the image to a temporary file. For WEBPROXY, returns `path_in` unmodified. """ if self.local: func = BACKEND_FUNCS[self.method] return func(maxwidth, path_in, path_out) else: return path_in def proxy_url(self, maxwidth, url): """Modifies an image URL according the method, returning a new URL. For WEBPROXY, a URL on the proxy server is returned. Otherwise, the URL is returned unmodified. """ if self.local: return url else: return resize_url(url, maxwidth) @property def local(self): """A boolean indicating whether the resizing method is performed locally (i.e., PIL or IMAGEMAGICK). """ return self.method in BACKEND_FUNCS @staticmethod def _guess_method(): """Determine which resizing method to use. Returns PIL, IMAGEMAGICK, or WEBPROXY depending on available dependencies. """ # Try importing PIL. try: __import__('PIL', fromlist=['Image']) return PIL except ImportError: pass # Try invoking ImageMagick's "convert". try: out = subprocess.check_output(['convert', '--version']).lower() if 'imagemagick' in out: return IMAGEMAGICK except subprocess.CalledProcessError: pass # system32/convert.exe may be interfering # Fall back to Web proxy method. return WEBPROXY beets-1.3.1/beets/util/bluelet.py0000644000076500000240000004675712222442167017674 0ustar asampsonstaff00000000000000"""Extremely simple pure-Python implementation of coroutine-style asynchronous socket I/O. Inspired by, but inferior to, Eventlet. Bluelet can also be thought of as a less-terrible replacement for asyncore. Bluelet: easy concurrency without all the messy parallelism. """ import socket import select import sys import types import errno import traceback import time import collections # A little bit of "six" (Python 2/3 compatibility): cope with PEP 3109 syntax # changes. PY3 = sys.version_info[0] == 3 if PY3: def _reraise(typ, exc, tb): raise exc.with_traceback(tb) else: exec(""" def _reraise(typ, exc, tb): raise typ, exc, tb """) # Basic events used for thread scheduling. class Event(object): """Just a base class identifying Bluelet events. An event is an object yielded from a Bluelet thread coroutine to suspend operation and communicate with the scheduler. """ pass class WaitableEvent(Event): """A waitable event is one encapsulating an action that can be waited for using a select() call. That is, it's an event with an associated file descriptor. """ def waitables(self): """Return "waitable" objects to pass to select(). Should return three iterables for input readiness, output readiness, and exceptional conditions (i.e., the three lists passed to select()). """ return (), (), () def fire(self): """Called when an associated file descriptor becomes ready (i.e., is returned from a select() call). """ pass class ValueEvent(Event): """An event that does nothing but return a fixed value.""" def __init__(self, value): self.value = value class ExceptionEvent(Event): """Raise an exception at the yield point. Used internally.""" def __init__(self, exc_info): self.exc_info = exc_info class SpawnEvent(Event): """Add a new coroutine thread to the scheduler.""" def __init__(self, coro): self.spawned = coro class JoinEvent(Event): """Suspend the thread until the specified child thread has completed. """ def __init__(self, child): self.child = child class KillEvent(Event): """Unschedule a child thread.""" def __init__(self, child): self.child = child class DelegationEvent(Event): """Suspend execution of the current thread, start a new thread and, once the child thread finished, return control to the parent thread. """ def __init__(self, coro): self.spawned = coro class ReturnEvent(Event): """Return a value the current thread's delegator at the point of delegation. Ends the current (delegate) thread. """ def __init__(self, value): self.value = value class SleepEvent(WaitableEvent): """Suspend the thread for a given duration. """ def __init__(self, duration): self.wakeup_time = time.time() + duration def time_left(self): return max(self.wakeup_time - time.time(), 0.0) class ReadEvent(WaitableEvent): """Reads from a file-like object.""" def __init__(self, fd, bufsize): self.fd = fd self.bufsize = bufsize def waitables(self): return (self.fd,), (), () def fire(self): return self.fd.read(self.bufsize) class WriteEvent(WaitableEvent): """Writes to a file-like object.""" def __init__(self, fd, data): self.fd = fd self.data = data def waitable(self): return (), (self.fd,), () def fire(self): self.fd.write(self.data) # Core logic for executing and scheduling threads. def _event_select(events): """Perform a select() over all the Events provided, returning the ones ready to be fired. Only WaitableEvents (including SleepEvents) matter here; all other events are ignored (and thus postponed). """ # Gather waitables and wakeup times. waitable_to_event = {} rlist, wlist, xlist = [], [], [] earliest_wakeup = None for event in events: if isinstance(event, SleepEvent): if not earliest_wakeup: earliest_wakeup = event.wakeup_time else: earliest_wakeup = min(earliest_wakeup, event.wakeup_time) elif isinstance(event, WaitableEvent): r, w, x = event.waitables() rlist += r wlist += w xlist += x for waitable in r: waitable_to_event[('r', waitable)] = event for waitable in w: waitable_to_event[('w', waitable)] = event for waitable in x: waitable_to_event[('x', waitable)] = event # If we have a any sleeping threads, determine how long to sleep. if earliest_wakeup: timeout = max(earliest_wakeup - time.time(), 0.0) else: timeout = None # Perform select() if we have any waitables. if rlist or wlist or xlist: rready, wready, xready = select.select(rlist, wlist, xlist, timeout) else: rready, wready, xready = (), (), () if timeout: time.sleep(timeout) # Gather ready events corresponding to the ready waitables. ready_events = set() for ready in rready: ready_events.add(waitable_to_event[('r', ready)]) for ready in wready: ready_events.add(waitable_to_event[('w', ready)]) for ready in xready: ready_events.add(waitable_to_event[('x', ready)]) # Gather any finished sleeps. for event in events: if isinstance(event, SleepEvent) and event.time_left() == 0.0: ready_events.add(event) return ready_events class ThreadException(Exception): def __init__(self, coro, exc_info): self.coro = coro self.exc_info = exc_info def reraise(self): _reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2]) SUSPENDED = Event() # Special sentinel placeholder for suspended threads. class Delegated(Event): """Placeholder indicating that a thread has delegated execution to a different thread. """ def __init__(self, child): self.child = child def run(root_coro): """Schedules a coroutine, running it to completion. This encapsulates the Bluelet scheduler, which the root coroutine can add to by spawning new coroutines. """ # The "threads" dictionary keeps track of all the currently- # executing and suspended coroutines. It maps coroutines to their # currently "blocking" event. The event value may be SUSPENDED if # the coroutine is waiting on some other condition: namely, a # delegated coroutine or a joined coroutine. In this case, the # coroutine should *also* appear as a value in one of the below # dictionaries `delegators` or `joiners`. threads = {root_coro: ValueEvent(None)} # Maps child coroutines to delegating parents. delegators = {} # Maps child coroutines to joining (exit-waiting) parents. joiners = collections.defaultdict(list) def complete_thread(coro, return_value): """Remove a coroutine from the scheduling pool, awaking delegators and joiners as necessary and returning the specified value to any delegating parent. """ del threads[coro] # Resume delegator. if coro in delegators: threads[delegators[coro]] = ValueEvent(return_value) del delegators[coro] # Resume joiners. if coro in joiners: for parent in joiners[coro]: threads[parent] = ValueEvent(None) del joiners[coro] def advance_thread(coro, value, is_exc=False): """After an event is fired, run a given coroutine associated with it in the threads dict until it yields again. If the coroutine exits, then the thread is removed from the pool. If the coroutine raises an exception, it is reraised in a ThreadException. If is_exc is True, then the value must be an exc_info tuple and the exception is thrown into the coroutine. """ try: if is_exc: next_event = coro.throw(*value) else: next_event = coro.send(value) except StopIteration: # Thread is done. complete_thread(coro, None) except: # Thread raised some other exception. del threads[coro] raise ThreadException(coro, sys.exc_info()) else: if isinstance(next_event, types.GeneratorType): # Automatically invoke sub-coroutines. (Shorthand for # explicit bluelet.call().) next_event = DelegationEvent(next_event) threads[coro] = next_event def kill_thread(coro): """Unschedule this thread and its (recursive) delegates. """ # Collect all coroutines in the delegation stack. coros = [coro] while isinstance(threads[coro], Delegated): coro = threads[coro].child coros.append(coro) # Complete each coroutine from the top to the bottom of the # stack. for coro in reversed(coros): complete_thread(coro, None) # Continue advancing threads until root thread exits. exit_te = None while threads: try: # Look for events that can be run immediately. Continue # running immediate events until nothing is ready. while True: have_ready = False for coro, event in list(threads.items()): if isinstance(event, SpawnEvent): threads[event.spawned] = ValueEvent(None) # Spawn. advance_thread(coro, None) have_ready = True elif isinstance(event, ValueEvent): advance_thread(coro, event.value) have_ready = True elif isinstance(event, ExceptionEvent): advance_thread(coro, event.exc_info, True) have_ready = True elif isinstance(event, DelegationEvent): threads[coro] = Delegated(event.spawned) # Suspend. threads[event.spawned] = ValueEvent(None) # Spawn. delegators[event.spawned] = coro have_ready = True elif isinstance(event, ReturnEvent): # Thread is done. complete_thread(coro, event.value) have_ready = True elif isinstance(event, JoinEvent): threads[coro] = SUSPENDED # Suspend. joiners[event.child].append(coro) have_ready = True elif isinstance(event, KillEvent): threads[coro] = ValueEvent(None) kill_thread(event.child) have_ready = True # Only start the select when nothing else is ready. if not have_ready: break # Wait and fire. event2coro = dict((v,k) for k,v in threads.items()) for event in _event_select(threads.values()): # Run the IO operation, but catch socket errors. try: value = event.fire() except socket.error as exc: if isinstance(exc.args, tuple) and \ exc.args[0] == errno.EPIPE: # Broken pipe. Remote host disconnected. pass else: traceback.print_exc() # Abort the coroutine. threads[event2coro[event]] = ReturnEvent(None) else: advance_thread(event2coro[event], value) except ThreadException as te: # Exception raised from inside a thread. event = ExceptionEvent(te.exc_info) if te.coro in delegators: # The thread is a delegate. Raise exception in its # delegator. threads[delegators[te.coro]] = event del delegators[te.coro] else: # The thread is root-level. Raise in client code. exit_te = te break except: # For instance, KeyboardInterrupt during select(). Raise # into root thread and terminate others. threads = {root_coro: ExceptionEvent(sys.exc_info())} # If any threads still remain, kill them. for coro in threads: coro.close() # If we're exiting with an exception, raise it in the client. if exit_te: exit_te.reraise() # Sockets and their associated events. class SocketClosedError(Exception): pass class Listener(object): """A socket wrapper object for listening sockets. """ def __init__(self, host, port): """Create a listening socket on the given hostname and port. """ self._closed = False self.host = host self.port = port self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind((host, port)) self.sock.listen(5) def accept(self): """An event that waits for a connection on the listening socket. When a connection is made, the event returns a Connection object. """ if self._closed: raise SocketClosedError() return AcceptEvent(self) def close(self): """Immediately close the listening socket. (Not an event.) """ self._closed = True self.sock.close() class Connection(object): """A socket wrapper object for connected sockets. """ def __init__(self, sock, addr): self.sock = sock self.addr = addr self._buf = b'' self._closed = False def close(self): """Close the connection.""" self._closed = True self.sock.close() def recv(self, size): """Read at most size bytes of data from the socket.""" if self._closed: raise SocketClosedError() if self._buf: # We already have data read previously. out = self._buf[:size] self._buf = self._buf[size:] return ValueEvent(out) else: return ReceiveEvent(self, size) def send(self, data): """Sends data on the socket, returning the number of bytes successfully sent. """ if self._closed: raise SocketClosedError() return SendEvent(self, data) def sendall(self, data): """Send all of data on the socket.""" if self._closed: raise SocketClosedError() return SendEvent(self, data, True) def readline(self, terminator=b"\n", bufsize=1024): """Reads a line (delimited by terminator) from the socket.""" if self._closed: raise SocketClosedError() while True: if terminator in self._buf: line, self._buf = self._buf.split(terminator, 1) line += terminator yield ReturnEvent(line) break data = yield ReceiveEvent(self, bufsize) if data: self._buf += data else: line = self._buf self._buf = b'' yield ReturnEvent(line) break class AcceptEvent(WaitableEvent): """An event for Listener objects (listening sockets) that suspends execution until the socket gets a connection. """ def __init__(self, listener): self.listener = listener def waitables(self): return (self.listener.sock,), (), () def fire(self): sock, addr = self.listener.sock.accept() return Connection(sock, addr) class ReceiveEvent(WaitableEvent): """An event for Connection objects (connected sockets) for asynchronously reading data. """ def __init__(self, conn, bufsize): self.conn = conn self.bufsize = bufsize def waitables(self): return (self.conn.sock,), (), () def fire(self): return self.conn.sock.recv(self.bufsize) class SendEvent(WaitableEvent): """An event for Connection objects (connected sockets) for asynchronously writing data. """ def __init__(self, conn, data, sendall=False): self.conn = conn self.data = data self.sendall = sendall def waitables(self): return (), (self.conn.sock,), () def fire(self): if self.sendall: return self.conn.sock.sendall(self.data) else: return self.conn.sock.send(self.data) # Public interface for threads; each returns an event object that # can immediately be "yield"ed. def null(): """Event: yield to the scheduler without doing anything special. """ return ValueEvent(None) def spawn(coro): """Event: add another coroutine to the scheduler. Both the parent and child coroutines run concurrently. """ if not isinstance(coro, types.GeneratorType): raise ValueError('%s is not a coroutine' % str(coro)) return SpawnEvent(coro) def call(coro): """Event: delegate to another coroutine. The current coroutine is resumed once the sub-coroutine finishes. If the sub-coroutine returns a value using end(), then this event returns that value. """ if not isinstance(coro, types.GeneratorType): raise ValueError('%s is not a coroutine' % str(coro)) return DelegationEvent(coro) def end(value=None): """Event: ends the coroutine and returns a value to its delegator. """ return ReturnEvent(value) def read(fd, bufsize=None): """Event: read from a file descriptor asynchronously.""" if bufsize is None: # Read all. def reader(): buf = [] while True: data = yield read(fd, 1024) if not data: break buf.append(data) yield ReturnEvent(''.join(buf)) return DelegationEvent(reader()) else: return ReadEvent(fd, bufsize) def write(fd, data): """Event: write to a file descriptor asynchronously.""" return WriteEvent(fd, data) def connect(host, port): """Event: connect to a network address and return a Connection object for communicating on the socket. """ addr = (host, port) sock = socket.create_connection(addr) return ValueEvent(Connection(sock, addr)) def sleep(duration): """Event: suspend the thread for ``duration`` seconds. """ return SleepEvent(duration) def join(coro): """Suspend the thread until another, previously `spawn`ed thread completes. """ return JoinEvent(coro) def kill(coro): """Halt the execution of a different `spawn`ed thread. """ return KillEvent(coro) # Convenience function for running socket servers. def server(host, port, func): """A coroutine that runs a network server. Host and port specify the listening address. func should be a coroutine that takes a single parameter, a Connection object. The coroutine is invoked for every incoming connection on the listening socket. """ def handler(conn): try: yield func(conn) finally: conn.close() listener = Listener(host, port) try: while True: conn = yield listener.accept() yield spawn(handler(conn)) except KeyboardInterrupt: pass finally: listener.close() beets-1.3.1/beets/util/confit.py0000644000076500000240000006001412203275653017503 0ustar asampsonstaff00000000000000# This file is part of Confit. # Copyright 2013, 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. """Worry-free YAML configuration files. """ from __future__ import unicode_literals import platform import os import pkgutil import sys import yaml import types try: from collections import OrderedDict except ImportError: from ordereddict import OrderedDict UNIX_DIR_VAR = 'XDG_CONFIG_HOME' UNIX_DIR_FALLBACK = '~/.config' WINDOWS_DIR_VAR = 'APPDATA' WINDOWS_DIR_FALLBACK = '~\\AppData\\Roaming' MAC_DIR = '~/Library/Application Support' CONFIG_FILENAME = 'config.yaml' DEFAULT_FILENAME = 'config_default.yaml' ROOT_NAME = 'root' YAML_TAB_PROBLEM = "found character '\\t' that cannot start any token" # Utilities. PY3 = sys.version_info[0] == 3 STRING = str if PY3 else unicode BASESTRING = str if PY3 else basestring NUMERIC_TYPES = (int, float) if PY3 else (int, float, long) TYPE_TYPES = (type,) if PY3 else (type, types.ClassType) def iter_first(sequence): """Get the first element from an iterable or raise a ValueError if the iterator generates no values. """ it = iter(sequence) try: if PY3: return next(it) else: return it.next() except StopIteration: raise ValueError() # Exceptions. class ConfigError(Exception): """Base class for exceptions raised when querying a configuration. """ class NotFoundError(ConfigError): """A requested value could not be found in the configuration trees. """ class ConfigTypeError(ConfigError, TypeError): """The value in the configuration did not match the expected type. """ class ConfigValueError(ConfigError, ValueError): """The value in the configuration is illegal.""" class ConfigReadError(ConfigError): """A configuration file could not be read.""" def __init__(self, filename, reason=None): self.filename = filename self.reason = reason message = 'file {0} could not be read'.format(filename) if isinstance(reason, yaml.scanner.ScannerError) and \ reason.problem == YAML_TAB_PROBLEM: # Special-case error message for tab indentation in YAML markup. message += ': found tab character at line {0}, column {1}'.format( reason.problem_mark.line + 1, reason.problem_mark.column + 1, ) elif reason: # Generic error message uses exception's message. message += ': {0}'.format(reason) super(ConfigReadError, self).__init__(message) # Views and sources. class ConfigSource(dict): """A dictionary augmented with metadata about the source of the configuration. """ def __init__(self, value, filename=None, default=False): super(ConfigSource, self).__init__(value) if filename is not None and not isinstance(filename, BASESTRING): raise TypeError('filename must be a string or None') self.filename = filename self.default = default def __repr__(self): return 'ConfigSource({0}, {1}, {2})'.format( super(ConfigSource, self).__repr__(), repr(self.filename), repr(self.default) ) @classmethod def of(self, value): """Given either a dictionary or a `ConfigSource` object, return a `ConfigSource` object. This lets a function accept either type of object as an argument. """ if isinstance(value, ConfigSource): return value elif isinstance(value, dict): return ConfigSource(value) else: raise TypeError('source value must be a dict') class ConfigView(object): """A configuration "view" is a query into a program's configuration data. A view represents a hypothetical location in the configuration tree; to extract the data from the location, a client typically calls the ``view.get()`` method. The client can access children in the tree (subviews) by subscripting the parent view (i.e., ``view[key]``). """ name = None """The name of the view, depicting the path taken through the configuration in Python-like syntax (e.g., ``foo['bar'][42]``). """ def resolve(self): """The core (internal) data retrieval method. Generates (value, source) pairs for each source that contains a value for this view. May raise ConfigTypeError if a type error occurs while traversing a source. """ raise NotImplementedError def first(self): """Returns a (value, source) pair for the first object found for this view. This amounts to the first element returned by `resolve`. If no values are available, a NotFoundError is raised. """ pairs = self.resolve() try: return iter_first(pairs) except ValueError: raise NotFoundError("{0} not found".format(self.name)) def add(self, value): """Set the *default* value for this configuration view. The specified value is added as the lowest-priority configuration data source. """ raise NotImplementedError def set(self, value): """*Override* the value for this configuration view. The specified value is added as the highest-priority configuration data source. """ raise NotImplementedError def root(self): """The RootView object from which this view is descended. """ raise NotImplementedError def __repr__(self): return '' % self.name def __getitem__(self, key): """Get a subview of this view.""" return Subview(self, key) def __setitem__(self, key, value): """Create an overlay source to assign a given key under this view. """ self.set({key: value}) def set_args(self, namespace): """Overlay parsed command-line arguments, generated by a library like argparse or optparse, onto this view's value. """ args = {} for key, value in namespace.__dict__.items(): if value is not None: # Avoid unset options. args[key] = value self.set(args) # Magical conversions. These special methods make it possible to use # View objects somewhat transparently in certain circumstances. For # example, rather than using ``view.get(bool)``, it's possible to # just say ``bool(view)`` or use ``view`` in a conditional. def __str__(self): """Gets the value for this view as a byte string.""" return str(self.get()) def __unicode__(self): """Gets the value for this view as a unicode string. (Python 2 only.) """ return unicode(self.get()) def __nonzero__(self): """Gets the value for this view as a boolean. (Python 2 only.) """ return self.__bool__() def __bool__(self): """Gets the value for this view as a boolean. (Python 3 only.) """ return bool(self.get()) # Dictionary emulation methods. def keys(self): """Returns a list containing all the keys available as subviews of the current views. This enumerates all the keys in *all* dictionaries matching the current view, in contrast to ``view.get(dict).keys()``, which gets all the keys for the *first* dict matching the view. If the object for this view in any source is not a dict, then a ConfigTypeError is raised. The keys are ordered according to how they appear in each source. """ keys = [] for dic, _ in self.resolve(): try: cur_keys = dic.keys() except AttributeError: raise ConfigTypeError( '{0} must be a dict, not {1}'.format( self.name, type(dic).__name__ ) ) for key in cur_keys: if key not in keys: keys.append(key) return keys def items(self): """Iterates over (key, subview) pairs contained in dictionaries from *all* sources at this view. If the object for this view in any source is not a dict, then a ConfigTypeError is raised. """ for key in self.keys(): yield key, self[key] def values(self): """Iterates over all the subviews contained in dictionaries from *all* sources at this view. If the object for this view in any source is not a dict, then a ConfigTypeError is raised. """ for key in self.keys(): yield self[key] # List/sequence emulation. def all_contents(self): """Iterates over all subviews from collections at this view from *all* sources. If the object for this view in any source is not iterable, then a ConfigTypeError is raised. This method is intended to be used when the view indicates a list; this method will concatenate the contents of the list from all sources. """ for collection, _ in self.resolve(): try: it = iter(collection) except TypeError: raise ConfigTypeError( '{0} must be an iterable, not {1}'.format( self.name, type(collection).__name__ ) ) for value in it: yield value # Validation and conversion. def get(self, typ=None): """Returns the canonical value for the view, checked against the passed-in type. If the value is not an instance of the given type, a ConfigTypeError is raised. May also raise a NotFoundError. """ value, _ = self.first() if typ is not None: if not isinstance(typ, TYPE_TYPES): raise TypeError('argument to get() must be a type') if not isinstance(value, typ): raise ConfigTypeError( "{0} must be of type {1}, not {2}".format( self.name, typ.__name__, type(value).__name__ ) ) return value def as_filename(self): """Get a string as a normalized filename, made absolute and with tilde expanded. If the value comes from a default source, the path is considered relative to the application's config directory. If it comes from another file source, the filename is expanded as if it were relative to that directory. Otherwise, it is relative to the current working directory. """ path, source = self.first() if not isinstance(path, BASESTRING): raise ConfigTypeError('{0} must be a filename, not {1}'.format( self.name, type(path).__name__ )) path = os.path.expanduser(STRING(path)) if source.default: # From defaults: relative to the app's directory. path = os.path.join(self.root().config_dir(), path) elif source.filename is not None: # Relative to source filename's directory. path = os.path.join(os.path.dirname(source.filename), path) return os.path.abspath(path) def as_choice(self, choices): """Ensure that the value is among a collection of choices and return it. If `choices` is a dictionary, then return the corresponding value rather than the value itself (the key). """ value = self.get() if value not in choices: raise ConfigValueError( '{0} must be one of {1}, not {2}'.format( self.name, repr(list(choices)), repr(value) ) ) if isinstance(choices, dict): return choices[value] else: return value def as_number(self): """Ensure that a value is of numeric type.""" value = self.get() if isinstance(value, NUMERIC_TYPES): return value raise ConfigTypeError( '{0} must be numeric, not {1}'.format( self.name, type(value).__name__ ) ) def as_str_seq(self): """Get the value as a list of strings. The underlying configured value can be a sequence or a single string. In the latter case, the string is treated as a white-space separated list of words. """ value = self.get() if isinstance(value, bytes): value = value.decode('utf8', 'ignore') if isinstance(value, STRING): return value.split() else: try: return list(value) except TypeError: raise ConfigTypeError( '{0} must be a whitespace-separated string or ' 'a list'.format(self.name) ) class RootView(ConfigView): """The base of a view hierarchy. This view keeps track of the sources that may be accessed by subviews. """ def __init__(self, sources): """Create a configuration hierarchy for a list of sources. At least one source must be provided. The first source in the list has the highest priority. """ self.sources = list(sources) self.name = ROOT_NAME def add(self, obj): self.sources.append(ConfigSource.of(obj)) def set(self, value): self.sources.insert(0, ConfigSource.of(value)) def resolve(self): return ((dict(s), s) for s in self.sources) def clear(self): """Remove all sources from this configuration.""" del self.sources[:] def root(self): return self class Subview(ConfigView): """A subview accessed via a subscript of a parent view.""" def __init__(self, parent, key): """Make a subview of a parent view for a given subscript key. """ self.parent = parent self.key = key # Choose a human-readable name for this view. if isinstance(self.parent, RootView): self.name = '' else: self.name = self.parent.name if not isinstance(self.key, int): self.name += '.' if isinstance(self.key, int): self.name += '#{0}'.format(self.key) elif isinstance(self.key, BASESTRING): self.name += '{0}'.format(self.key) else: self.name += '{0}'.format(repr(self.key)) def resolve(self): for collection, source in self.parent.resolve(): try: value = collection[self.key] except IndexError: # List index out of bounds. continue except KeyError: # Dict key does not exist. continue except TypeError: # Not subscriptable. raise ConfigTypeError( "{0} must be a collection, not {1}".format( self.parent.name, type(collection).__name__ ) ) yield value, source def set(self, value): self.parent.set({self.key: value}) def add(self, value): self.parent.add({self.key: value}) def root(self): return self.parent.root() # Config file paths, including platform-specific paths and in-package # defaults. # Based on get_root_path from Flask by Armin Ronacher. def _package_path(name): """Returns the path to the package containing the named module or None if the path could not be identified (e.g., if ``name == "__main__"``). """ loader = pkgutil.get_loader(name) if loader is None or name == '__main__': return None if hasattr(loader, 'get_filename'): filepath = loader.get_filename(name) else: # Fall back to importing the specified module. __import__(name) filepath = sys.modules[name].__file__ return os.path.dirname(os.path.abspath(filepath)) def config_dirs(): """Returns a list of user configuration directories to be searched. """ if platform.system() == 'Darwin': paths = [UNIX_DIR_FALLBACK, MAC_DIR] elif platform.system() == 'Windows': if WINDOWS_DIR_VAR in os.environ: paths = [os.environ[WINDOWS_DIR_VAR]] else: paths = [WINDOWS_DIR_FALLBACK] else: # Assume Unix. paths = [UNIX_DIR_FALLBACK] if UNIX_DIR_VAR in os.environ: paths.insert(0, os.environ[UNIX_DIR_VAR]) # Expand and deduplicate paths. out = [] for path in paths: path = os.path.abspath(os.path.expanduser(path)) if path not in out: out.append(path) return out # YAML. class Loader(yaml.SafeLoader): """A customized YAML loader. This loader deviates from the official YAML spec in a few convenient ways: - All strings as are Unicode objects. - All maps are OrderedDicts. - Strings can begin with % without quotation. """ # All strings should be Unicode objects, regardless of contents. def _construct_unicode(self, node): return self.construct_scalar(node) # Use ordered dictionaries for every YAML map. # From https://gist.github.com/844388 def construct_yaml_map(self, node): data = OrderedDict() yield data value = self.construct_mapping(node) data.update(value) def construct_mapping(self, node, deep=False): if isinstance(node, yaml.MappingNode): self.flatten_mapping(node) else: raise yaml.constructor.ConstructorError( None, None, 'expected a mapping node, but found %s' % node.id, node.start_mark ) mapping = OrderedDict() for key_node, value_node in node.value: key = self.construct_object(key_node, deep=deep) try: hash(key) except TypeError as exc: raise yaml.constructor.ConstructorError( 'while constructing a mapping', node.start_mark, 'found unacceptable key (%s)' % exc, key_node.start_mark ) value = self.construct_object(value_node, deep=deep) mapping[key] = value return mapping # Allow bare strings to begin with %. Directives are still detected. def check_plain(self): plain = super(Loader, self).check_plain() return plain or self.peek() == '%' Loader.add_constructor('tag:yaml.org,2002:str', Loader._construct_unicode) Loader.add_constructor('tag:yaml.org,2002:map', Loader.construct_yaml_map) Loader.add_constructor('tag:yaml.org,2002:omap', Loader.construct_yaml_map) def load_yaml(filename): """Read a YAML document from a file. If the file cannot be read or parsed, a ConfigReadError is raised. """ try: with open(filename, 'r') as f: return yaml.load(f, Loader=Loader) except (IOError, yaml.error.YAMLError) as exc: raise ConfigReadError(filename, exc) # Main interface. class Configuration(RootView): def __init__(self, appname, modname=None, read=True): """Create a configuration object by reading the automatically-discovered config files for the application for a given name. If `modname` is specified, it should be the import name of a module whose package will be searched for a default config file. (Otherwise, no defaults are used.) Pass `False` for `read` to disable automatic reading of all discovered configuration files. Use this when creating a configuration object at module load time and then call the `read` method later. """ super(Configuration, self).__init__([]) self.appname = appname self.modname = modname self._env_var = '{0}DIR'.format(self.appname.upper()) if read: self.read() def _search_dirs(self): """Yield directories that will be searched for configuration files for this application. """ # Application's environment variable. if self._env_var in os.environ: path = os.environ[self._env_var] yield os.path.abspath(os.path.expanduser(path)) # Standard configuration directories. for confdir in config_dirs(): yield os.path.join(confdir, self.appname) def _user_sources(self): """Generate `ConfigSource` objects for each user configuration file in the program's search directories. """ for appdir in self._search_dirs(): filename = os.path.join(appdir, CONFIG_FILENAME) if os.path.isfile(filename): yield ConfigSource(load_yaml(filename) or {}, filename) def _default_source(self): """Return the default-value source for this program or `None` if it does not exist. """ if self.modname: pkg_path = _package_path(self.modname) if pkg_path: filename = os.path.join(pkg_path, DEFAULT_FILENAME) if os.path.isfile(filename): return ConfigSource(load_yaml(filename), filename, True) def read(self, user=True, defaults=True): """Find and read the files for this configuration and set them as the sources for this configuration. To disable either discovered user configuration files or the in-package defaults, set `user` or `defaults` to `False`. """ if user: for source in self._user_sources(): self.add(source) if defaults: source = self._default_source() if source: self.add(source) def config_dir(self): """Get the path to the directory containing the highest-priority user configuration. If no user configuration is present, create a suitable directory before returning it. """ dirs = list(self._search_dirs()) # First, look for an existent configuration file. for appdir in dirs: if os.path.isfile(os.path.join(appdir, CONFIG_FILENAME)): return appdir # As a fallback, create the first-listed directory name. appdir = dirs[0] if not os.path.isdir(appdir): os.makedirs(appdir) return appdir class LazyConfig(Configuration): """A Configuration at reads files on demand when it is first accessed. This is appropriate for using as a global config object at the module level. """ def __init__(self, appname, modname=None): super(LazyConfig, self).__init__(appname, modname, False) self._materialized = False # Have we read the files yet? self._lazy_prefix = [] # Pre-materialization calls to set(). self._lazy_suffix = [] # Calls to add(). def read(self, user=True, defaults=True): self._materialized = True super(LazyConfig, self).read(user, defaults) def resolve(self): if not self._materialized: # Read files and unspool buffers. self.read() self.sources += self._lazy_suffix self.sources[:0] = self._lazy_prefix return super(LazyConfig, self).resolve() def add(self, value): super(LazyConfig, self).add(value) if not self._materialized: # Buffer additions to end. self._lazy_suffix += self.sources del self.sources[:] def set(self, value): super(LazyConfig, self).set(value) if not self._materialized: # Buffer additions to beginning. self._lazy_prefix[:0] = self.sources del self.sources[:] beets-1.3.1/beets/util/enumeration.py0000644000076500000240000001371412102026773020547 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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 metaclass for enumerated types that really are types. You can create enumerations with `enum(values, [name])` and they work how you would expect them to. >>> from enumeration import enum >>> Direction = enum('north east south west', name='Direction') >>> Direction.west Direction.west >>> Direction.west == Direction.west True >>> Direction.west == Direction.east False >>> isinstance(Direction.west, Direction) True >>> Direction[3] Direction.west >>> Direction['west'] Direction.west >>> Direction.west.name 'west' >>> Direction.north < Direction.west True Enumerations are classes; their instances represent the possible values of the enumeration. Because Python classes must have names, you may provide a `name` parameter to `enum`; if you don't, a meaningless one will be chosen for you. """ import random class Enumeration(type): """A metaclass whose classes are enumerations. The `values` attribute of the class is used to populate the enumeration. Values may either be a list of enumerated names or a string containing a space-separated list of names. When the class is created, it is instantiated for each name value in `values`. Each such instance is the name of the enumerated item as the sole argument. The `Enumerated` class is a good choice for a superclass. """ def __init__(cls, name, bases, dic): super(Enumeration, cls).__init__(name, bases, dic) if 'values' not in dic: # Do nothing if no values are provided (i.e., with # Enumerated itself). return # May be called with a single string, in which case we split on # whitespace for convenience. values = dic['values'] if isinstance(values, basestring): values = values.split() # Create the Enumerated instances for each value. We have to use # super's __setattr__ here because we disallow setattr below. super(Enumeration, cls).__setattr__('_items_dict', {}) super(Enumeration, cls).__setattr__('_items_list', []) for value in values: item = cls(value, len(cls._items_list)) cls._items_dict[value] = item cls._items_list.append(item) def __getattr__(cls, key): try: return cls._items_dict[key] except KeyError: raise AttributeError("enumeration '" + cls.__name__ + "' has no item '" + key + "'") def __setattr__(cls, key, val): raise TypeError("enumerations do not support attribute assignment") def __getitem__(cls, key): if isinstance(key, int): return cls._items_list[key] else: return getattr(cls, key) def __len__(cls): return len(cls._items_list) def __iter__(cls): return iter(cls._items_list) def __nonzero__(cls): # Ensures that __len__ doesn't get called before __init__ by # pydoc. return True class Enumerated(object): """An item in an enumeration. Contains instance methods inherited by enumerated objects. The metaclass is preset to `Enumeration` for your convenience. Instance attributes: name -- The name of the item. index -- The index of the item in its enumeration. >>> from enumeration import Enumerated >>> class Garment(Enumerated): ... values = 'hat glove belt poncho lederhosen suspenders' ... def wear(self): ... print('now wearing a ' + self.name) ... >>> Garment.poncho.wear() now wearing a poncho """ __metaclass__ = Enumeration def __init__(self, name, index): self.name = name self.index = index def __str__(self): return type(self).__name__ + '.' + self.name def __repr__(self): return str(self) def __cmp__(self, other): if type(self) is type(other): # Note that we're assuming that the items are direct # instances of the same Enumeration (i.e., no fancy # subclassing), which is probably okay. return cmp(self.index, other.index) else: return NotImplemented def enum(*values, **kwargs): """Shorthand for creating a new Enumeration class. Call with enumeration values as a list, a space-delimited string, or just an argument list. To give the class a name, pass it as the `name` keyword argument. Otherwise, a name will be chosen for you. The following are all equivalent: enum('pinkie ring middle index thumb') enum('pinkie', 'ring', 'middle', 'index', 'thumb') enum(['pinkie', 'ring', 'middle', 'index', 'thumb']) """ if ('name' not in kwargs) or kwargs['name'] is None: # Create a probably-unique name. It doesn't really have to be # unique, but getting distinct names each time helps with # identification in debugging. name = 'Enumeration' + hex(random.randint(0,0xfffffff))[2:].upper() else: name = kwargs['name'] if len(values) == 1: # If there's only one value, we have a couple of alternate calling # styles. if isinstance(values[0], basestring) or hasattr(values[0], '__iter__'): values = values[0] return type(name, (Enumerated,), {'values': values}) beets-1.3.1/beets/util/functemplate.py0000644000076500000240000004543612102026773020716 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """This module implements a string formatter based on the standard PEP 292 string.Template class extended with function calls. Variables, as with string.Template, are indicated with $ and functions are delimited with %. This module assumes that everything is Unicode: the template and the substitution values. Bytestrings are not supported. Also, the templates always behave like the ``safe_substitute`` method in the standard library: unknown symbols are left intact. This is sort of like a tiny, horrible degeneration of a real templating engine like Jinja2 or Mustache. """ from __future__ import print_function import re import ast import dis import types SYMBOL_DELIM = u'$' FUNC_DELIM = u'%' GROUP_OPEN = u'{' GROUP_CLOSE = u'}' ARG_SEP = u',' ESCAPE_CHAR = u'$' VARIABLE_PREFIX = '__var_' FUNCTION_PREFIX = '__func_' class Environment(object): """Contains the values and functions to be substituted into a template. """ def __init__(self, values, functions): self.values = values self.functions = functions # Code generation helpers. def ex_lvalue(name): """A variable load expression.""" return ast.Name(name, ast.Store()) def ex_rvalue(name): """A variable store expression.""" return ast.Name(name, ast.Load()) def ex_literal(val): """An int, float, long, bool, string, or None literal with the given value. """ if val is None: return ast.Name('None', ast.Load()) elif isinstance(val, (int, float, long)): return ast.Num(val) elif isinstance(val, bool): return ast.Name(str(val), ast.Load()) elif isinstance(val, basestring): return ast.Str(val) raise TypeError('no literal for {0}'.format(type(val))) def ex_varassign(name, expr): """Assign an expression into a single variable. The expression may either be an `ast.expr` object or a value to be used as a literal. """ if not isinstance(expr, ast.expr): expr = ex_literal(expr) return ast.Assign([ex_lvalue(name)], expr) def ex_call(func, args): """A function-call expression with only positional parameters. The function may be an expression or the name of a function. Each argument may be an expression or a value to be used as a literal. """ if isinstance(func, basestring): func = ex_rvalue(func) args = list(args) for i in range(len(args)): if not isinstance(args[i], ast.expr): args[i] = ex_literal(args[i]) return ast.Call(func, args, [], None, None) def compile_func(arg_names, statements, name='_the_func', debug=False): """Compile a list of statements as the body of a function and return the resulting Python function. If `debug`, then print out the bytecode of the compiled function. """ func_def = ast.FunctionDef( name, ast.arguments( [ast.Name(n, ast.Param()) for n in arg_names], None, None, [ex_literal(None) for _ in arg_names], ), statements, [], ) mod = ast.Module([func_def]) ast.fix_missing_locations(mod) prog = compile(mod, '', 'exec') # Debug: show bytecode. if debug: dis.dis(prog) for const in prog.co_consts: if isinstance(const, types.CodeType): dis.dis(const) the_locals = {} exec prog in {}, the_locals return the_locals[name] # AST nodes for the template language. class Symbol(object): """A variable-substitution symbol in a template.""" def __init__(self, ident, original): self.ident = ident self.original = original def __repr__(self): return u'Symbol(%s)' % repr(self.ident) def evaluate(self, env): """Evaluate the symbol in the environment, returning a Unicode string. """ if self.ident in env.values: # Substitute for a value. return env.values[self.ident] else: # Keep original text. return self.original def translate(self): """Compile the variable lookup.""" expr = ex_rvalue(VARIABLE_PREFIX + self.ident.encode('utf8')) return [expr], set([self.ident.encode('utf8')]), set() class Call(object): """A function call in a template.""" def __init__(self, ident, args, original): self.ident = ident self.args = args self.original = original def __repr__(self): return u'Call(%s, %s, %s)' % (repr(self.ident), repr(self.args), repr(self.original)) def evaluate(self, env): """Evaluate the function call in the environment, returning a Unicode string. """ if self.ident in env.functions: arg_vals = [expr.evaluate(env) for expr in self.args] try: out = env.functions[self.ident](*arg_vals) except Exception as exc: # Function raised exception! Maybe inlining the name of # the exception will help debug. return u'<%s>' % unicode(exc) return unicode(out) else: return self.original def translate(self): """Compile the function call.""" varnames = set() funcnames = set([self.ident.encode('utf8')]) arg_exprs = [] for arg in self.args: subexprs, subvars, subfuncs = arg.translate() varnames.update(subvars) funcnames.update(subfuncs) # Create a subexpression that joins the result components of # the arguments. arg_exprs.append(ex_call( ast.Attribute(ex_literal(u''), 'join', ast.Load()), [ex_call( 'map', [ ex_rvalue('unicode'), ast.List(subexprs, ast.Load()), ] )], )) subexpr_call = ex_call( FUNCTION_PREFIX + self.ident.encode('utf8'), arg_exprs ) return [subexpr_call], varnames, funcnames class Expression(object): """Top-level template construct: contains a list of text blobs, Symbols, and Calls. """ def __init__(self, parts): self.parts = parts def __repr__(self): return u'Expression(%s)' % (repr(self.parts)) def evaluate(self, env): """Evaluate the entire expression in the environment, returning a Unicode string. """ out = [] for part in self.parts: if isinstance(part, basestring): out.append(part) else: out.append(part.evaluate(env)) return u''.join(map(unicode, out)) def translate(self): """Compile the expression to a list of Python AST expressions, a set of variable names used, and a set of function names. """ expressions = [] varnames = set() funcnames = set() for part in self.parts: if isinstance(part, basestring): expressions.append(ex_literal(part)) else: e, v, f = part.translate() expressions.extend(e) varnames.update(v) funcnames.update(f) return expressions, varnames, funcnames # Parser. class ParseError(Exception): pass class Parser(object): """Parses a template expression string. Instantiate the class with the template source and call ``parse_expression``. The ``pos`` field will indicate the character after the expression finished and ``parts`` will contain a list of Unicode strings, Symbols, and Calls reflecting the concatenated portions of the expression. This is a terrible, ad-hoc parser implementation based on a left-to-right scan with no lexing step to speak of; it's probably both inefficient and incorrect. Maybe this should eventually be replaced with a real, accepted parsing technique (PEG, parser generator, etc.). """ def __init__(self, string): self.string = string self.pos = 0 self.parts = [] # Common parsing resources. special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE, ARG_SEP, ESCAPE_CHAR) special_char_re = re.compile(ur'[%s]|$' % u''.join(re.escape(c) for c in special_chars)) def parse_expression(self): """Parse a template expression starting at ``pos``. Resulting components (Unicode strings, Symbols, and Calls) are added to the ``parts`` field, a list. The ``pos`` field is updated to be the next character after the expression. """ text_parts = [] while self.pos < len(self.string): char = self.string[self.pos] if char not in self.special_chars: # A non-special character. Skip to the next special # character, treating the interstice as literal text. next_pos = ( self.special_char_re.search(self.string[self.pos:]).start() + self.pos ) text_parts.append(self.string[self.pos:next_pos]) self.pos = next_pos continue if self.pos == len(self.string) - 1: # The last character can never begin a structure, so we # just interpret it as a literal character (unless it # terminates the expression, as with , and }). if char not in (GROUP_CLOSE, ARG_SEP): text_parts.append(char) self.pos += 1 break next_char = self.string[self.pos + 1] if char == ESCAPE_CHAR and next_char in \ (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP): # An escaped special character ($$, $}, etc.). Note that # ${ is not an escape sequence: this is ambiguous with # the start of a symbol and it's not necessary (just # using { suffices in all cases). text_parts.append(next_char) self.pos += 2 # Skip the next character. continue # Shift all characters collected so far into a single string. if text_parts: self.parts.append(u''.join(text_parts)) text_parts = [] if char == SYMBOL_DELIM: # Parse a symbol. self.parse_symbol() elif char == FUNC_DELIM: # Parse a function call. self.parse_call() elif char in (GROUP_CLOSE, ARG_SEP): # Template terminated. break elif char == GROUP_OPEN: # Start of a group has no meaning hear; just pass # through the character. text_parts.append(char) self.pos += 1 else: assert False # If any parsed characters remain, shift them into a string. if text_parts: self.parts.append(u''.join(text_parts)) def parse_symbol(self): """Parse a variable reference (like ``$foo`` or ``${foo}``) starting at ``pos``. Possibly appends a Symbol object (or, failing that, text) to the ``parts`` field and updates ``pos``. The character at ``pos`` must, as a precondition, be ``$``. """ assert self.pos < len(self.string) assert self.string[self.pos] == SYMBOL_DELIM if self.pos == len(self.string) - 1: # Last character. self.parts.append(SYMBOL_DELIM) self.pos += 1 return next_char = self.string[self.pos + 1] start_pos = self.pos self.pos += 1 if next_char == GROUP_OPEN: # A symbol like ${this}. self.pos += 1 # Skip opening. closer = self.string.find(GROUP_CLOSE, self.pos) if closer == -1 or closer == self.pos: # No closing brace found or identifier is empty. self.parts.append(self.string[start_pos:self.pos]) else: # Closer found. ident = self.string[self.pos:closer] self.pos = closer + 1 self.parts.append(Symbol(ident, self.string[start_pos:self.pos])) else: # A bare-word symbol. ident = self._parse_ident() if ident: # Found a real symbol. self.parts.append(Symbol(ident, self.string[start_pos:self.pos])) else: # A standalone $. self.parts.append(SYMBOL_DELIM) def parse_call(self): """Parse a function call (like ``%foo{bar,baz}``) starting at ``pos``. Possibly appends a Call object to ``parts`` and update ``pos``. The character at ``pos`` must be ``%``. """ assert self.pos < len(self.string) assert self.string[self.pos] == FUNC_DELIM start_pos = self.pos self.pos += 1 ident = self._parse_ident() if not ident: # No function name. self.parts.append(FUNC_DELIM) return if self.pos >= len(self.string): # Identifier terminates string. self.parts.append(self.string[start_pos:self.pos]) return if self.string[self.pos] != GROUP_OPEN: # Argument list not opened. self.parts.append(self.string[start_pos:self.pos]) return # Skip past opening brace and try to parse an argument list. self.pos += 1 args = self.parse_argument_list() if self.pos >= len(self.string) or \ self.string[self.pos] != GROUP_CLOSE: # Arguments unclosed. self.parts.append(self.string[start_pos:self.pos]) return self.pos += 1 # Move past closing brace. self.parts.append(Call(ident, args, self.string[start_pos:self.pos])) def parse_argument_list(self): """Parse a list of arguments starting at ``pos``, returning a list of Expression objects. Does not modify ``parts``. Should leave ``pos`` pointing to a } character or the end of the string. """ # Try to parse a subexpression in a subparser. expressions = [] while self.pos < len(self.string): subparser = Parser(self.string[self.pos:]) subparser.parse_expression() # Extract and advance past the parsed expression. expressions.append(Expression(subparser.parts)) self.pos += subparser.pos if self.pos >= len(self.string) or \ self.string[self.pos] == GROUP_CLOSE: # Argument list terminated by EOF or closing brace. break # Only other way to terminate an expression is with ,. # Continue to the next argument. assert self.string[self.pos] == ARG_SEP self.pos += 1 return expressions def _parse_ident(self): """Parse an identifier and return it (possibly an empty string). Updates ``pos``. """ remainder = self.string[self.pos:] ident = re.match(ur'\w*', remainder).group(0) self.pos += len(ident) return ident def _parse(template): """Parse a top-level template string Expression. Any extraneous text is considered literal text. """ parser = Parser(template) parser.parse_expression() parts = parser.parts remainder = parser.string[parser.pos:] if remainder: parts.append(remainder) return Expression(parts) # External interface. class Template(object): """A string template, including text, Symbols, and Calls. """ def __init__(self, template): self.expr = _parse(template) self.original = template self.compiled = self.translate() def __eq__(self, other): return self.original == other.original def interpret(self, values={}, functions={}): """Like `substitute`, but forces the interpreter (rather than the compiled version) to be used. The interpreter includes exception-handling code for missing variables and buggy template functions but is much slower. """ return self.expr.evaluate(Environment(values, functions)) def substitute(self, values={}, functions={}): """Evaluate the template given the values and functions. """ try: res = self.compiled(values, functions) except: # Handle any exceptions thrown by compiled version. res = self.interpret(values, functions) return res def translate(self): """Compile the template to a Python function.""" expressions, varnames, funcnames = self.expr.translate() argnames = [] for varname in varnames: argnames.append(VARIABLE_PREFIX.encode('utf8') + varname) for funcname in funcnames: argnames.append(FUNCTION_PREFIX.encode('utf8') + funcname) func = compile_func( argnames, [ast.Return(ast.List(expressions, ast.Load()))], ) def wrapper_func(values={}, functions={}): args = {} for varname in varnames: args[VARIABLE_PREFIX + varname] = values[varname] for funcname in funcnames: args[FUNCTION_PREFIX + funcname] = functions[funcname] parts = func(**args) return u''.join(parts) return wrapper_func # Performance tests. if __name__ == '__main__': import timeit _tmpl = Template(u'foo $bar %baz{foozle $bar barzle} $bar') _vars = {'bar': 'qux'} _funcs = {'baz': unicode.upper} interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)', 'from __main__ import _tmpl, _vars, _funcs', number=10000) print(interp_time) comp_time = timeit.timeit('_tmpl.substitute(_vars, _funcs)', 'from __main__ import _tmpl, _vars, _funcs', number=10000) print(comp_time) print('Speedup:', interp_time / comp_time) beets-1.3.1/beets/util/pipeline.py0000644000076500000240000003400312102026773020020 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Simple but robust implementation of generator/coroutine-based pipelines in Python. The pipelines may be run either sequentially (single-threaded) or in parallel (one thread per pipeline stage). This implementation supports pipeline bubbles (indications that the processing for a certain item should abort). To use them, yield the BUBBLE constant from any stage coroutine except the last. In the parallel case, the implementation transparently handles thread shutdown when the processing is complete and when a stage raises an exception. KeyboardInterrupts (^C) are also handled. When running a parallel pipeline, it is also possible to use multiple coroutines for the same pipeline stage; this lets you speed up a bottleneck stage by dividing its work among multiple threads. To do so, pass an iterable of coroutines to the Pipeline constructor in place of any single coroutine. """ from __future__ import print_function import Queue from threading import Thread, Lock import sys import types BUBBLE = '__PIPELINE_BUBBLE__' POISON = '__PIPELINE_POISON__' DEFAULT_QUEUE_SIZE = 16 def _invalidate_queue(q, val=None, sync=True): """Breaks a Queue such that it never blocks, always has size 1, and has no maximum size. get()ing from the queue returns `val`, which defaults to None. `sync` controls whether a lock is required (because it's not reentrant!). """ def _qsize(len=len): return 1 def _put(item): pass def _get(): return val if sync: q.mutex.acquire() try: q.maxsize = 0 q._qsize = _qsize q._put = _put q._get = _get q.not_empty.notifyAll() q.not_full.notifyAll() finally: if sync: q.mutex.release() class CountedQueue(Queue.Queue): """A queue that keeps track of the number of threads that are still feeding into it. The queue is poisoned when all threads are finished with the queue. """ def __init__(self, maxsize=0): Queue.Queue.__init__(self, maxsize) self.nthreads = 0 self.poisoned = False def acquire(self): """Indicate that a thread will start putting into this queue. Should not be called after the queue is already poisoned. """ with self.mutex: assert not self.poisoned assert self.nthreads >= 0 self.nthreads += 1 def release(self): """Indicate that a thread that was putting into this queue has exited. If this is the last thread using the queue, the queue is poisoned. """ with self.mutex: self.nthreads -= 1 assert self.nthreads >= 0 if self.nthreads == 0: # All threads are done adding to this queue. Poison it # when it becomes empty. self.poisoned = True # Replacement _get invalidates when no items remain. _old_get = self._get def _get(): out = _old_get() if not self.queue: _invalidate_queue(self, POISON, False) return out if self.queue: # Items remain. self._get = _get else: # No items. Invalidate immediately. _invalidate_queue(self, POISON, False) class MultiMessage(object): """A message yielded by a pipeline stage encapsulating multiple values to be sent to the next stage. """ def __init__(self, messages): self.messages = messages def multiple(messages): """Yield multiple([message, ..]) from a pipeline stage to send multiple values to the next pipeline stage. """ return MultiMessage(messages) def _allmsgs(obj): """Returns a list of all the messages encapsulated in obj. If obj is a MultiMessage, returns its enclosed messages. If obj is BUBBLE, returns an empty list. Otherwise, returns a list containing obj. """ if isinstance(obj, MultiMessage): return obj.messages elif obj == BUBBLE: return [] else: return [obj] class PipelineThread(Thread): """Abstract base class for pipeline-stage threads.""" def __init__(self, all_threads): super(PipelineThread, self).__init__() self.abort_lock = Lock() self.abort_flag = False self.all_threads = all_threads self.exc_info = None def abort(self): """Shut down the thread at the next chance possible. """ with self.abort_lock: self.abort_flag = True # Ensure that we are not blocking on a queue read or write. if hasattr(self, 'in_queue'): _invalidate_queue(self.in_queue, POISON) if hasattr(self, 'out_queue'): _invalidate_queue(self.out_queue, POISON) def abort_all(self, exc_info): """Abort all other threads in the system for an exception. """ self.exc_info = exc_info for thread in self.all_threads: thread.abort() class FirstPipelineThread(PipelineThread): """The thread running the first stage in a parallel pipeline setup. The coroutine should just be a generator. """ def __init__(self, coro, out_queue, all_threads): super(FirstPipelineThread, self).__init__(all_threads) self.coro = coro self.out_queue = out_queue self.out_queue.acquire() self.abort_lock = Lock() self.abort_flag = False def run(self): try: while True: with self.abort_lock: if self.abort_flag: return # Get the value from the generator. try: msg = self.coro.next() except StopIteration: break # Send messages to the next stage. for msg in _allmsgs(msg): with self.abort_lock: if self.abort_flag: return self.out_queue.put(msg) except: self.abort_all(sys.exc_info()) return # Generator finished; shut down the pipeline. self.out_queue.release() class MiddlePipelineThread(PipelineThread): """A thread running any stage in the pipeline except the first or last. """ def __init__(self, coro, in_queue, out_queue, all_threads): super(MiddlePipelineThread, self).__init__(all_threads) self.coro = coro self.in_queue = in_queue self.out_queue = out_queue self.out_queue.acquire() def run(self): try: # Prime the coroutine. self.coro.next() while True: with self.abort_lock: if self.abort_flag: return # Get the message from the previous stage. msg = self.in_queue.get() if msg is POISON: break with self.abort_lock: if self.abort_flag: return # Invoke the current stage. out = self.coro.send(msg) # Send messages to next stage. for msg in _allmsgs(out): with self.abort_lock: if self.abort_flag: return self.out_queue.put(msg) except: self.abort_all(sys.exc_info()) return # Pipeline is shutting down normally. self.out_queue.release() class LastPipelineThread(PipelineThread): """A thread running the last stage in a pipeline. The coroutine should yield nothing. """ def __init__(self, coro, in_queue, all_threads): super(LastPipelineThread, self).__init__(all_threads) self.coro = coro self.in_queue = in_queue def run(self): # Prime the coroutine. self.coro.next() try: while True: with self.abort_lock: if self.abort_flag: return # Get the message from the previous stage. msg = self.in_queue.get() if msg is POISON: break with self.abort_lock: if self.abort_flag: return # Send to consumer. self.coro.send(msg) except: self.abort_all(sys.exc_info()) return class Pipeline(object): """Represents a staged pattern of work. Each stage in the pipeline is a coroutine that receives messages from the previous stage and yields messages to be sent to the next stage. """ def __init__(self, stages): """Makes a new pipeline from a list of coroutines. There must be at least two stages. """ if len(stages) < 2: raise ValueError('pipeline must have at least two stages') self.stages = [] for stage in stages: if isinstance(stage, (list, tuple)): self.stages.append(stage) else: # Default to one thread per stage. self.stages.append((stage,)) def run_sequential(self): """Run the pipeline sequentially in the current thread. The stages are run one after the other. Only the first coroutine in each stage is used. """ coros = [stage[0] for stage in self.stages] # "Prime" the coroutines. for coro in coros[1:]: coro.next() # Begin the pipeline. for out in coros[0]: msgs = _allmsgs(out) for coro in coros[1:]: next_msgs = [] for msg in msgs: out = coro.send(msg) next_msgs.extend(_allmsgs(out)) msgs = next_msgs def run_parallel(self, queue_size=DEFAULT_QUEUE_SIZE): """Run the pipeline in parallel using one thread per stage. The messages between the stages are stored in queues of the given size. """ queues = [CountedQueue(queue_size) for i in range(len(self.stages)-1)] threads = [] # Set up first stage. for coro in self.stages[0]: threads.append(FirstPipelineThread(coro, queues[0], threads)) # Middle stages. for i in range(1, len(self.stages)-1): for coro in self.stages[i]: threads.append(MiddlePipelineThread( coro, queues[i-1], queues[i], threads )) # Last stage. for coro in self.stages[-1]: threads.append( LastPipelineThread(coro, queues[-1], threads) ) # Start threads. for thread in threads: thread.start() # Wait for termination. The final thread lasts the longest. try: # Using a timeout allows us to receive KeyboardInterrupt # exceptions during the join(). while threads[-1].isAlive(): threads[-1].join(1) except: # Stop all the threads immediately. for thread in threads: thread.abort() raise finally: # Make completely sure that all the threads have finished # before we return. They should already be either finished, # in normal operation, or aborted, in case of an exception. for thread in threads[:-1]: thread.join() for thread in threads: exc_info = thread.exc_info if exc_info: # Make the exception appear as it was raised originally. raise exc_info[0], exc_info[1], exc_info[2] # Smoke test. if __name__ == '__main__': import time # Test a normally-terminating pipeline both in sequence and # in parallel. def produce(): for i in range(5): print('generating %i' % i) time.sleep(1) yield i def work(): num = yield while True: print('processing %i' % num) time.sleep(2) num = yield num*2 def consume(): while True: num = yield time.sleep(1) print('received %i' % num) ts_start = time.time() Pipeline([produce(), work(), consume()]).run_sequential() ts_seq = time.time() Pipeline([produce(), work(), consume()]).run_parallel() ts_par = time.time() Pipeline([produce(), (work(), work()), consume()]).run_parallel() ts_end = time.time() print('Sequential time:', ts_seq - ts_start) print('Parallel time:', ts_par - ts_seq) print('Multiply-parallel time:', ts_end - ts_par) print() # Test a pipeline that raises an exception. def exc_produce(): for i in range(10): print('generating %i' % i) time.sleep(1) yield i def exc_work(): num = yield while True: print('processing %i' % num) time.sleep(3) if num == 3: raise Exception() num = yield num * 2 def exc_consume(): while True: num = yield #if num == 4: # raise Exception() print('received %i' % num) Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1) beets-1.3.1/beets/vfs.py0000644000076500000240000000332312216075012016031 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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 simple utility for constructing filesystem-like trees from beets libraries. """ from collections import namedtuple from beets import util Node = namedtuple('Node', ['files', 'dirs']) def _insert(node, path, itemid): """Insert an item into a virtual filesystem node.""" if len(path) == 1: # Last component. Insert file. node.files[path[0]] = itemid else: # In a directory. dirname = path[0] rest = path[1:] if dirname not in node.dirs: node.dirs[dirname] = Node({}, {}) _insert(node.dirs[dirname], rest, itemid) def libtree(lib): """Generates a filesystem-like directory tree for the files contained in `lib`. Filesystem nodes are (files, dirs) named tuples in which both components are dictionaries. The first maps filenames to Item ids. The second maps directory names to child node tuples. """ root = Node({}, {}) for item in lib.items(): dest = item.destination(fragment=True) parts = util.components(dest) _insert(root, parts, item.id) return root beets-1.3.1/beets.egg-info/0000755000076500000240000000000012226377756016377 5ustar asampsonstaff00000000000000beets-1.3.1/beets.egg-info/dependency_links.txt0000644000076500000240000000000112226377754022443 0ustar asampsonstaff00000000000000 beets-1.3.1/beets.egg-info/entry_points.txt0000644000076500000240000000005012226377754021666 0ustar asampsonstaff00000000000000[console_scripts] beet = beets.ui:main beets-1.3.1/beets.egg-info/namespace_packages.txt0000644000076500000240000000001212226377754022721 0ustar asampsonstaff00000000000000beetsplug beets-1.3.1/beets.egg-info/PKG-INFO0000644000076500000240000000645112226377754017500 0ustar asampsonstaff00000000000000Metadata-Version: 1.1 Name: beets Version: 1.3.1 Summary: music tagger and library organizer Home-page: http://beets.radbox.org/ Author: Adrian Sampson Author-email: adrian@radbox.org License: MIT Description: Beets is the media library management system for obsessive-compulsive music geeks. The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a bouquet of tools for manipulating and accessing your music. Here's an example of beets' brainy tag corrector doing its thing:: $ beet import ~/music/ladytron Tagging: Ladytron - Witching Hour (Similarity: 98.4%) * Last One Standing -> The Last One Standing * Beauty -> Beauty*2 * White Light Generation -> Whitelightgenerator * All the Way -> All the Way... Because beets is designed as a library, it can do almost anything you can imagine for your music collection. Via `plugins`_, beets becomes a panacea: - Embed and extract album art from files' metadata. - Listen to your library with a music player that speaks the `MPD`_ protocol and works with a staggering variety of interfaces. - Fetch lyrics for all your songs from databases on the Web. - Manage your `MusicBrainz music collection`_. - Analyze music files' metadata from the command line. - Clean up crufty tags left behind by other, less-awesome tools. - Browse your music library graphically through a Web browser and play it in any browser that supports `HTML5 Audio`_. If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. .. _plugins: http://beets.readthedocs.org/page/plugins/ .. _MPD: http://mpd.wikia.com/ .. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/ .. _writing your own plugin: http://beets.readthedocs.org/page/plugins/#writing-plugins .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html Read More --------- Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for news and updates. You can install beets by typing ``pip install beets``. Then check out the `Getting Started`_ guide. .. _its Web site: http://beets.radbox.org/ .. _Getting Started: http://beets.readthedocs.org/page/guides/main.html .. _@b33ts: http://twitter.com/b33ts/ Authors ------- Beets is by `Adrian Sampson`_. .. _Adrian Sampson: mailto:adrian@radbox.org Platform: ALL Classifier: Topic :: Multimedia :: Sound/Audio Classifier: Topic :: Multimedia :: Sound/Audio :: Players :: MP3 Classifier: License :: OSI Approved :: MIT License Classifier: Environment :: Console Classifier: Environment :: Web Environment Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.6 Classifier: Programming Language :: Python :: 2.7 beets-1.3.1/beets.egg-info/requires.txt0000644000076500000240000000007212226377754020774 0ustar asampsonstaff00000000000000mutagen>=1.21 munkres unidecode musicbrainzngs>=0.4 pyyamlbeets-1.3.1/beets.egg-info/SOURCES.txt0000644000076500000240000000731512226377756020271 0ustar asampsonstaff00000000000000LICENSE MANIFEST.in README.rst setup.py beets/__init__.py beets/config_default.yaml beets/importer.py beets/library.py beets/mediafile.py beets/plugins.py beets/vfs.py beets.egg-info/PKG-INFO beets.egg-info/SOURCES.txt beets.egg-info/dependency_links.txt beets.egg-info/entry_points.txt beets.egg-info/namespace_packages.txt beets.egg-info/requires.txt beets.egg-info/top_level.txt beets/autotag/__init__.py beets/autotag/hooks.py beets/autotag/match.py beets/autotag/mb.py beets/ui/__init__.py beets/ui/commands.py beets/ui/migrate.py beets/util/__init__.py beets/util/artresizer.py beets/util/bluelet.py beets/util/confit.py beets/util/enumeration.py beets/util/functemplate.py beets/util/pipeline.py beetsplug/__init__.py beetsplug/beatport.py beetsplug/bench.py beetsplug/chroma.py beetsplug/convert.py beetsplug/discogs.py beetsplug/duplicates.py beetsplug/echonest_tempo.py beetsplug/embedart.py beetsplug/fetchart.py beetsplug/fromfilename.py beetsplug/ftintitle.py beetsplug/fuzzy.py beetsplug/ihate.py beetsplug/importfeeds.py beetsplug/info.py beetsplug/inline.py beetsplug/lyrics.py beetsplug/mbcollection.py beetsplug/mbsync.py beetsplug/missing.py beetsplug/mpdupdate.py beetsplug/random.py beetsplug/replaygain.py beetsplug/rewrite.py beetsplug/scrub.py beetsplug/smartplaylist.py beetsplug/the.py beetsplug/zero.py beetsplug/bpd/__init__.py beetsplug/bpd/gstplayer.py beetsplug/lastgenre/__init__.py beetsplug/lastgenre/genres-tree.yaml beetsplug/lastgenre/genres.txt beetsplug/web/__init__.py beetsplug/web/static/backbone.js beetsplug/web/static/beets.css beetsplug/web/static/beets.js beetsplug/web/static/jquery.js beetsplug/web/static/underscore.js beetsplug/web/templates/index.html docs/Makefile docs/changelog.rst docs/conf.py docs/faq.rst docs/index.rst docs/dev/api.rst docs/dev/index.rst docs/dev/plugins.rst docs/guides/advanced.rst docs/guides/index.rst docs/guides/main.rst docs/guides/migration.rst docs/guides/tagger.rst docs/plugins/beatport.rst docs/plugins/beetsweb.png docs/plugins/bpd.rst docs/plugins/chroma.rst docs/plugins/convert.rst docs/plugins/discogs.rst docs/plugins/duplicates.rst docs/plugins/echonest_tempo.rst docs/plugins/embedart.rst docs/plugins/fetchart.rst docs/plugins/fromfilename.rst docs/plugins/ftintitle.rst docs/plugins/fuzzy.rst docs/plugins/ihate.rst docs/plugins/importfeeds.rst docs/plugins/index.rst docs/plugins/info.rst docs/plugins/inline.rst docs/plugins/lastgenre.rst docs/plugins/lyrics.rst docs/plugins/mbcollection.rst docs/plugins/mbsync.rst docs/plugins/missing.rst docs/plugins/mpdupdate.rst docs/plugins/random.rst docs/plugins/replaygain.rst docs/plugins/rewrite.rst docs/plugins/scrub.rst docs/plugins/smartplaylist.rst docs/plugins/the.rst docs/plugins/web.rst docs/plugins/zero.rst docs/reference/cli.rst docs/reference/config.rst docs/reference/index.rst docs/reference/pathformat.rst docs/reference/query.rst man/beet.1 man/beetsconfig.5 test/__init__.py test/_common.py test/test_art.py test/test_autotag.py test/test_db.py test/test_files.py test/test_ihate.py test/test_importer.py test/test_mb.py test/test_mediafile.py test/test_mediafile_basic.py test/test_pipeline.py test/test_player.py test/test_query.py test/test_template.py test/test_the.py test/test_ui.py test/test_vfs.py test/test_zero.py test/testall.py test/rsrc/bpm.mp3 test/rsrc/date.mp3 test/rsrc/discc.ogg test/rsrc/empty.mp3 test/rsrc/emptylist.mp3 test/rsrc/full.alac.m4a test/rsrc/full.ape test/rsrc/full.flac test/rsrc/full.m4a test/rsrc/full.mp3 test/rsrc/full.mpc test/rsrc/full.ogg test/rsrc/full.opus test/rsrc/full.wma test/rsrc/full.wv test/rsrc/min.flac test/rsrc/min.m4a test/rsrc/min.mp3 test/rsrc/oldape.ape test/rsrc/partial.flac test/rsrc/partial.m4a test/rsrc/partial.mp3 test/rsrc/space_time.mp3 test/rsrc/t_time.m4abeets-1.3.1/beets.egg-info/top_level.txt0000644000076500000240000000002012226377754021117 0ustar asampsonstaff00000000000000beetsplug beets beets-1.3.1/beetsplug/0000755000076500000240000000000012226377756015575 5ustar asampsonstaff00000000000000beets-1.3.1/beetsplug/__init__.py0000644000076500000240000000144112102026773017665 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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 namespace package for beets plugins.""" # Make this a namespace package. from pkgutil import extend_path __path__ = extend_path(__path__, __name__) beets-1.3.1/beetsplug/beatport.py0000644000076500000240000002703612203275653017763 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Adds Beatport release and track search support to the autotagger """ import logging import re from datetime import datetime, timedelta import requests from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.plugins import BeetsPlugin log = logging.getLogger('beets') class BeatportAPIError(Exception): pass class BeatportObject(object): def __init__(self, data): self.beatport_id = data['id'] self.name = unicode(data['name']) if 'releaseDate' in data: self.release_date = datetime.strptime(data['releaseDate'], '%Y-%m-%d') if 'artists' in data: self.artists = [(x['id'], unicode(x['name'])) for x in data['artists']] if 'genres' in data: self.genres = [unicode(x['name']) for x in data['genres']] class BeatportAPI(object): API_BASE = 'http://api.beatport.com/' @classmethod def get(cls, endpoint, **kwargs): try: response = requests.get(cls.API_BASE + endpoint, params=kwargs) except Exception as e: raise BeatportAPIError("Error connection to Beatport API: {}" .format(e.message)) if not response: raise BeatportAPIError( "Error {0.status_code} for '{0.request.path_url}" .format(response)) return response.json()['results'] class BeatportSearch(object): query = None release_type = None def __unicode__(self): return u''.format( self.release_type, self.query, len(self.results)) def __init__(self, query, release_type='release', details=True): self.results = [] self.query = query self.release_type = release_type response = BeatportAPI.get('catalog/3/search', query=query, facets=['fieldType:{0}' .format(release_type)], perPage=5) for item in response: if release_type == 'release': release = BeatportRelease(item) if details: release.get_tracks() self.results.append(release) elif release_type == 'track': self.results.append(BeatportTrack(item)) class BeatportRelease(BeatportObject): API_ENDPOINT = 'catalog/3/beatport/release' def __unicode__(self): if len(self.artists) < 4: artist_str = ", ".join(x[1] for x in self.artists) else: artist_str = "Various Artists" return u"".format(artist_str, self.name, self.catalog_number) def __init__(self, data): BeatportObject.__init__(self, data) if 'catalogNumber' in data: self.catalog_number = data['catalogNumber'] if 'label' in data: self.label_name = data['label']['name'] if 'category' in data: self.category = data['category'] if 'slug' in data: self.url = "http://beatport.com/release/{0}/{1}".format( data['slug'], data['id']) @classmethod def from_id(cls, beatport_id): response = BeatportAPI.get(cls.API_ENDPOINT, id=beatport_id) release = BeatportRelease(response['release']) release.tracks = [BeatportTrack(x) for x in response['tracks']] return release def get_tracks(self): response = BeatportAPI.get(self.API_ENDPOINT, id=self.beatport_id) self.tracks = [BeatportTrack(x) for x in response['tracks']] class BeatportTrack(BeatportObject): API_ENDPOINT = 'catalog/3/beatport/track' def __unicode__(self): artist_str = ", ".join(x[1] for x in self.artists) return u"".format(artist_str, self.name, self.mix_name) def __init__(self, data): BeatportObject.__init__(self, data) if 'title' in data: self.title = unicode(data['title']) if 'mixName' in data: self.mix_name = unicode(data['mixName']) self.length = timedelta(milliseconds=data.get('lengthMs', 0) or 0) if not self.length: try: min, sec = data.get('length', '0:0').split(':') self.length = timedelta(minutes=int(min), seconds=int(sec)) except ValueError: pass if 'slug' in data: self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'], data['id']) @classmethod def from_id(cls, beatport_id): response = BeatportAPI.get(cls.API_ENDPOINT, id=beatport_id) return BeatportTrack(response['track']) class BeatportPlugin(BeetsPlugin): def __init__(self): super(BeatportPlugin, self).__init__() self.config.add({ 'source_weight': 0.5, }) def album_distance(self, items, album_info, mapping): """Returns the beatport source weight and the maximum source weight for albums. """ dist = Distance() if album_info.data_source == 'Beatport': dist.add('source', self.config['source_weight'].as_number()) return dist def track_distance(self, item, track_info): """Returns the beatport source weight and the maximum source weight for individual tracks. """ dist = Distance() if track_info.data_source == 'Beatport': dist.add('source', self.config['source_weight'].as_number()) return dist def candidates(self, items, artist, release, va_likely): """Returns a list of AlbumInfo objects for beatport search results matching release and artist (if not various). """ if va_likely: query = release else: query = '%s %s' % (artist, release) try: return self._get_releases(query) except BeatportAPIError as e: log.debug('Beatport API Error: %s (query: %s)' % (e, query)) return [] def item_candidates(self, item, artist, title): """Returns a list of TrackInfo objects for beatport search results matching title and artist. """ query = '%s %s' % (artist, title) try: return self._get_tracks(query) except BeatportAPIError as e: log.debug('Beatport API Error: %s (query: %s)' % (e, query)) return [] def album_for_id(self, release_id): """Fetches a release by its Beatport ID and returns an AlbumInfo object or None if the release is not found. """ log.debug('Searching Beatport for release %s' % str(release_id)) match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id) if not match: return None release = BeatportRelease.from_id(match.group(2)) album = self._get_album_info(release) return album def track_for_id(self, track_id): """Fetches a track by its Beatport ID and returns a TrackInfo object or None if the track is not found. """ log.debug('Searching Beatport for track %s' % str(track_id)) match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id) if not match: return None bp_track = BeatportTrack.from_id(match.group(2)) track = self._get_track_info(bp_track) return track def _get_releases(self, query): """Returns a list of AlbumInfo objects for a beatport search query. """ # Strip non-word characters from query. Things like "!" and "-" can # cause a query to return no results, even if they match the artist or # album title. Use `re.UNICODE` flag to avoid stripping non-english # word characters. query = re.sub(r'\W+', ' ', query, re.UNICODE) # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. query = re.sub(r'\b(CD|disc)\s*\d+', '', query, re.I) albums = [self._get_album_info(x) for x in BeatportSearch(query).results] return albums def _get_album_info(self, release): """Returns an AlbumInfo object for a Beatport Release object. """ va = len(release.artists) > 3 artist, artist_id = self._get_artist(release.artists) if va: artist = u"Various Artists" tracks = [self._get_track_info(x, index=idx) for idx, x in enumerate(release.tracks, 1)] return AlbumInfo(album=release.name, album_id=release.beatport_id, artist=artist, artist_id=artist_id, tracks=tracks, albumtype=release.category, va=va, year=release.release_date.year, month=release.release_date.month, day=release.release_date.day, label=release.label_name, catalognum=release.catalog_number, media=u'Digital', data_source=u'Beatport', data_url=release.url) def _get_track_info(self, track, index=None): """Returns a TrackInfo object for a Beatport Track object. """ title = track.name if track.mix_name != u"Original Mix": title += u" ({0})".format(track.mix_name) artist, artist_id = self._get_artist(track.artists) length = track.length.total_seconds() return TrackInfo(title=title, track_id=track.beatport_id, artist=artist, artist_id=artist_id, length=length, index=index, data_source=u'Beatport', data_url=track.url) def _get_artist(self, artists): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of Beatport release or track artists. """ artist_id = None bits = [] for artist in artists: if not artist_id: artist_id = artist[0] name = artist[1] # Strip disambiguation number. name = re.sub(r' \(\d+\)$', '', name) # Move articles to the front. name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) bits.append(name) artist = ', '.join(bits).replace(' ,', ',') or None return artist, artist_id def _get_tracks(self, query): """Returns a list of TrackInfo objects for a Beatport query. """ bp_tracks = BeatportSearch(query, release_type='track').results tracks = [self._get_track_info(x) for x in bp_tracks] return tracks beets-1.3.1/beetsplug/bench.py0000644000076500000240000000457012102026773017213 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Some simple performance benchmarks for beets. """ from __future__ import print_function from beets.plugins import BeetsPlugin from beets import ui from beets import vfs from beets import library from beets.util.functemplate import Template import cProfile import timeit def benchmark(lib, prof): def _build_tree(): vfs.libtree(lib) # Measure path generation performance with %aunique{} included. lib.path_formats = [ (library.PF_KEY_DEFAULT, Template('$albumartist/$album%aunique{}/$track $title')), ] if prof: cProfile.runctx('_build_tree()', {}, {'_build_tree': _build_tree}, 'paths.withaunique.prof') else: interval = timeit.timeit(_build_tree, number=1) print('With %aunique:', interval) # And with %aunique replaceed with a "cheap" no-op function. lib.path_formats = [ (library.PF_KEY_DEFAULT, Template('$albumartist/$album%lower{}/$track $title')), ] if prof: cProfile.runctx('_build_tree()', {}, {'_build_tree': _build_tree}, 'paths.withoutaunique.prof') else: interval = timeit.timeit(_build_tree, number=1) print('Without %aunique:', interval) class BenchmarkPlugin(BeetsPlugin): """A plugin for performing some simple performance benchmarks. """ def commands(self): def bench_func(lib, opts, args): benchmark(lib, opts.profile) bench_cmd = ui.Subcommand('bench', help='benchmark') bench_cmd.parser.add_option('-p', '--profile', action='store_true', default=False, help='performance profiling') bench_cmd.func = bench_func return [bench_cmd] beets-1.3.1/beetsplug/bpd/0000755000076500000240000000000012226377756016342 5ustar asampsonstaff00000000000000beets-1.3.1/beetsplug/bpd/__init__.py0000644000076500000240000011551212216075034020437 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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 clone of the Music Player Daemon (MPD) that plays music from a Beets library. Attempts to implement a compatible protocol to allow use of the wide range of MPD clients. """ from __future__ import print_function import re from string import Template import traceback import logging import random import time import beets from beets.plugins import BeetsPlugin import beets.ui from beets import vfs from beets.util import bluelet from beets.library import ITEM_KEYS_WRITABLE PROTOCOL_VERSION = '0.13.0' BUFSIZE = 1024 HELLO = 'OK MPD %s' % PROTOCOL_VERSION CLIST_BEGIN = 'command_list_begin' CLIST_VERBOSE_BEGIN = 'command_list_ok_begin' CLIST_END = 'command_list_end' RESP_OK = 'OK' RESP_CLIST_VERBOSE = 'list_OK' RESP_ERR = 'ACK' NEWLINE = u"\n" ERROR_NOT_LIST = 1 ERROR_ARG = 2 ERROR_PASSWORD = 3 ERROR_PERMISSION = 4 ERROR_UNKNOWN = 5 ERROR_NO_EXIST = 50 ERROR_PLAYLIST_MAX = 51 ERROR_SYSTEM = 52 ERROR_PLAYLIST_LOAD = 53 ERROR_UPDATE_ALREADY = 54 ERROR_PLAYER_SYNC = 55 ERROR_EXIST = 56 VOLUME_MIN = 0 VOLUME_MAX = 100 SAFE_COMMANDS = ( # Commands that are available when unauthenticated. u'close', u'commands', u'notcommands', u'password', u'ping', ) # Loggers. log = logging.getLogger('beets.bpd') global_log = logging.getLogger('beets') # Gstreamer import error. class NoGstreamerError(Exception): pass # Error-handling, exceptions, parameter parsing. class BPDError(Exception): """An error that should be exposed to the client to the BPD server. """ def __init__(self, code, message, cmd_name='', index=0): self.code = code self.message = message self.cmd_name = cmd_name self.index = index template = Template(u'$resp [$code@$index] {$cmd_name} $message') def response(self): """Returns a string to be used as the response code for the erring command. """ return self.template.substitute({'resp': RESP_ERR, 'code': self.code, 'index': self.index, 'cmd_name': self.cmd_name, 'message': self.message }) def make_bpd_error(s_code, s_message): """Create a BPDError subclass for a static code and message. """ class NewBPDError(BPDError): code = s_code message = s_message cmd_name = '' index = 0 def __init__(self): pass return NewBPDError ArgumentTypeError = make_bpd_error(ERROR_ARG, 'invalid type for argument') ArgumentIndexError = make_bpd_error(ERROR_ARG, 'argument out of range') ArgumentNotFoundError = make_bpd_error(ERROR_NO_EXIST, 'argument not found') def cast_arg(t, val): """Attempts to call t on val, raising a ArgumentTypeError on ValueError. If 't' is the special string 'intbool', attempts to cast first to an int and then to a bool (i.e., 1=True, 0=False). """ if t == 'intbool': return cast_arg(bool, cast_arg(int, val)) else: try: return t(val) except ValueError: raise ArgumentTypeError() class BPDClose(Exception): """Raised by a command invocation to indicate that the connection should be closed. """ # Generic server infrastructure, implementing the basic protocol. class BaseServer(object): """A MPD-compatible music player server. The functions with the `cmd_` prefix are invoked in response to client commands. For instance, if the client says `status`, `cmd_status` will be invoked. The arguments to the client's commands are used as function arguments following the connection issuing the command. The functions may send data on the connection. They may also raise BPDError exceptions to report errors. This is a generic superclass and doesn't support many commands. """ def __init__(self, host, port, password): """Create a new server bound to address `host` and listening on port `port`. If `password` is given, it is required to do anything significant on the server. """ self.host, self.port, self.password = host, port, password # Default server values. self.random = False self.repeat = False self.volume = VOLUME_MAX self.crossfade = 0 self.playlist = [] self.playlist_version = 0 self.current_index = -1 self.paused = False self.error = None # Object for random numbers generation self.random_obj = random.Random() def run(self): """Block and start listening for connections from clients. An interrupt (^C) closes the server. """ self.startup_time = time.time() bluelet.run(bluelet.server(self.host, self.port, Connection.handler(self))) def _item_info(self, item): """An abstract method that should response lines containing a single song's metadata. """ raise NotImplementedError def _item_id(self, item): """An abstract method returning the integer id for an item. """ raise NotImplementedError def _id_to_index(self, track_id): """Searches the playlist for a song with the given id and returns its index in the playlist. """ track_id = cast_arg(int, track_id) for index, track in enumerate(self.playlist): if self._item_id(track) == track_id: return index # Loop finished with no track found. raise ArgumentNotFoundError() def _random_idx(self): """Returns a random index different from the current one. If there are no songs in the playlist it returns -1. If there is only one song in the playlist it returns 0. """ if len(self.playlist) < 2: return len(self.playlist)-1 new_index = self.random_obj.randint(0, len(self.playlist)-1) while new_index == self.current_index: new_index = self.random_obj.randint(0, len(self.playlist)-1) return new_index def _succ_idx(self): """Returns the index for the next song to play. It also considers random and repeat flags. No boundaries are checked. """ if self.repeat: return self.current_index if self.random: return self._random_idx() return self.current_index+1 def _prev_idx(self): """Returns the index for the previous song to play. It also considers random and repeat flags. No boundaries are checked. """ if self.repeat: return self.current_index if self.random: return self._random_idx() return self.current_index-1 def cmd_ping(self, conn): """Succeeds.""" pass def cmd_kill(self, conn): """Exits the server process.""" exit(0) def cmd_close(self, conn): """Closes the connection.""" raise BPDClose() def cmd_password(self, conn, password): """Attempts password authentication.""" if password == self.password: conn.authenticated = True else: conn.authenticated = False raise BPDError(ERROR_PASSWORD, 'incorrect password') def cmd_commands(self, conn): """Lists the commands available to the user.""" if self.password and not conn.authenticated: # Not authenticated. Show limited list of commands. for cmd in SAFE_COMMANDS: yield u'command: ' + cmd else: # Authenticated. Show all commands. for func in dir(self): if func.startswith('cmd_'): yield u'command: ' + func[4:] def cmd_notcommands(self, conn): """Lists all unavailable commands.""" if self.password and not conn.authenticated: # Not authenticated. Show privileged commands. for func in dir(self): if func.startswith('cmd_'): cmd = func[4:] if cmd not in SAFE_COMMANDS: yield u'command: ' + cmd else: # Authenticated. No commands are unavailable. pass def cmd_status(self, conn): """Returns some status information for use with an implementation of cmd_status. Gives a list of response-lines for: volume, repeat, random, playlist, playlistlength, and xfade. """ yield (u'volume: ' + unicode(self.volume), u'repeat: ' + unicode(int(self.repeat)), u'random: ' + unicode(int(self.random)), u'playlist: ' + unicode(self.playlist_version), u'playlistlength: ' + unicode(len(self.playlist)), u'xfade: ' + unicode(self.crossfade), ) if self.current_index == -1: state = u'stop' elif self.paused: state = u'pause' else: state = u'play' yield u'state: ' + state if self.current_index != -1: # i.e., paused or playing current_id = self._item_id(self.playlist[self.current_index]) yield u'song: ' + unicode(self.current_index) yield u'songid: ' + unicode(current_id) if self.error: yield u'error: ' + self.error def cmd_clearerror(self, conn): """Removes the persistent error state of the server. This error is set when a problem arises not in response to a command (for instance, when playing a file). """ self.error = None def cmd_random(self, conn, state): """Set or unset random (shuffle) mode.""" self.random = cast_arg('intbool', state) def cmd_repeat(self, conn, state): """Set or unset repeat mode.""" self.repeat = cast_arg('intbool', state) def cmd_setvol(self, conn, vol): """Set the player's volume level (0-100).""" vol = cast_arg(int, vol) if vol < VOLUME_MIN or vol > VOLUME_MAX: raise BPDError(ERROR_ARG, u'volume out of range') self.volume = vol def cmd_crossfade(self, conn, crossfade): """Set the number of seconds of crossfading.""" crossfade = cast_arg(int, crossfade) if crossfade < 0: raise BPDError(ERROR_ARG, u'crossfade time must be nonnegative') def cmd_clear(self, conn): """Clear the playlist.""" self.playlist = [] self.playlist_version += 1 self.cmd_stop(conn) def cmd_delete(self, conn, index): """Remove the song at index from the playlist.""" index = cast_arg(int, index) try: del(self.playlist[index]) except IndexError: raise ArgumentIndexError() self.playlist_version += 1 if self.current_index == index: # Deleted playing song. self.cmd_stop(conn) elif index < self.current_index: # Deleted before playing. # Shift playing index down. self.current_index -= 1 def cmd_deleteid(self, conn, track_id): self.cmd_delete(conn, self._id_to_index(track_id)) def cmd_move(self, conn, idx_from, idx_to): """Move a track in the playlist.""" idx_from = cast_arg(int, idx_from) idx_to = cast_arg(int, idx_to) try: track = self.playlist.pop(idx_from) self.playlist.insert(idx_to, track) except IndexError: raise ArgumentIndexError() # Update currently-playing song. if idx_from == self.current_index: self.current_index = idx_to elif idx_from < self.current_index <= idx_to: self.current_index -= 1 elif idx_from > self.current_index >= idx_to: self.current_index += 1 self.playlist_version += 1 def cmd_moveid(self, conn, idx_from, idx_to): idx_from = self._id_to_index(idx_from) return self.cmd_move(conn, idx_from, idx_to) def cmd_swap(self, conn, i, j): """Swaps two tracks in the playlist.""" i = cast_arg(int, i) j = cast_arg(int, j) try: track_i = self.playlist[i] track_j = self.playlist[j] except IndexError: raise ArgumentIndexError() self.playlist[j] = track_i self.playlist[i] = track_j # Update currently-playing song. if self.current_index == i: self.current_index = j elif self.current_index == j: self.current_index = i self.playlist_version += 1 def cmd_swapid(self, conn, i_id, j_id): i = self._id_to_index(i_id) j = self._id_to_index(j_id) return self.cmd_swap(conn, i, j) def cmd_urlhandlers(self, conn): """Indicates supported URL schemes. None by default.""" pass def cmd_playlistinfo(self, conn, index=-1): """Gives metadata information about the entire playlist or a single track, given by its index. """ index = cast_arg(int, index) if index == -1: for track in self.playlist: yield self._item_info(track) else: try: track = self.playlist[index] except IndexError: raise ArgumentIndexError() yield self._item_info(track) def cmd_playlistid(self, conn, track_id=-1): return self.cmd_playlistinfo(conn, self._id_to_index(track_id)) def cmd_plchanges(self, conn, version): """Sends playlist changes since the given version. This is a "fake" implementation that ignores the version and just returns the entire playlist (rather like version=0). This seems to satisfy many clients. """ return self.cmd_playlistinfo(conn) def cmd_plchangesposid(self, conn, version): """Like plchanges, but only sends position and id. Also a dummy implementation. """ for idx, track in enumerate(self.playlist): yield u'cpos: ' + unicode(idx) yield u'Id: ' + unicode(track.id) def cmd_currentsong(self, conn): """Sends information about the currently-playing song. """ if self.current_index != -1: # -1 means stopped. track = self.playlist[self.current_index] yield self._item_info(track) def cmd_next(self, conn): """Advance to the next song in the playlist.""" self.current_index = self._succ_idx() if self.current_index >= len(self.playlist): # Fallen off the end. Just move to stopped state. return self.cmd_stop(conn) else: return self.cmd_play(conn) def cmd_previous(self, conn): """Step back to the last song.""" self.current_index = self._prev_idx() if self.current_index < 0: return self.cmd_stop(conn) else: return self.cmd_play(conn) def cmd_pause(self, conn, state=None): """Set the pause state playback.""" if state is None: self.paused = not self.paused # Toggle. else: self.paused = cast_arg('intbool', state) def cmd_play(self, conn, index=-1): """Begin playback, possibly at a specified playlist index.""" index = cast_arg(int, index) if index < -1 or index > len(self.playlist): raise ArgumentIndexError() if index == -1: # No index specified: start where we are. if not self.playlist: # Empty playlist: stop immediately. return self.cmd_stop(conn) if self.current_index == -1: # No current song. self.current_index = 0 # Start at the beginning. # If we have a current song, just stay there. else: # Start with the specified index. self.current_index = index self.paused = False def cmd_playid(self, conn, track_id=0): track_id = cast_arg(int, track_id) if track_id == -1: index = -1 else: index = self._id_to_index(track_id) return self.cmd_play(conn, index) def cmd_stop(self, conn): """Stop playback.""" self.current_index = -1 self.paused = False def cmd_seek(self, conn, index, pos): """Seek to a specified point in a specified song.""" index = cast_arg(int, index) if index < 0 or index >= len(self.playlist): raise ArgumentIndexError() self.current_index = index def cmd_seekid(self, conn, track_id, pos): index = self._id_to_index(track_id) return self.cmd_seek(conn, index, pos) def cmd_profile(self, conn): """Memory profiling for debugging.""" from guppy import hpy heap = hpy().heap() print(heap) class Connection(object): """A connection between a client and the server. Handles input and output from and to the client. """ def __init__(self, server, sock): """Create a new connection for the accepted socket `client`. """ self.server = server self.sock = sock self.authenticated = False def send(self, lines): """Send lines, which which is either a single string or an iterable consisting of strings, to the client. A newline is added after every string. Returns a Bluelet event that sends the data. """ if isinstance(lines, basestring): lines = [lines] out = NEWLINE.join(lines) + NEWLINE log.debug(out[:-1]) # Don't log trailing newline. if isinstance(out, unicode): out = out.encode('utf8') return self.sock.sendall(out) def do_command(self, command): """A coroutine that runs the given command and sends an appropriate response.""" try: yield bluelet.call(command.run(self)) except BPDError as e: # Send the error. yield self.send(e.response()) else: # Send success code. yield self.send(RESP_OK) def run(self): """Send a greeting to the client and begin processing commands as they arrive. """ yield self.send(HELLO) clist = None # Initially, no command list is being constructed. while True: line = yield self.sock.readline() if not line: break line = line.strip() if not line: break log.debug(line) if clist is not None: # Command list already opened. if line == CLIST_END: yield bluelet.call(self.do_command(clist)) clist = None # Clear the command list. else: clist.append(Command(line)) elif line == CLIST_BEGIN or line == CLIST_VERBOSE_BEGIN: # Begin a command list. clist = CommandList([], line == CLIST_VERBOSE_BEGIN) else: # Ordinary command. try: yield bluelet.call(self.do_command(Command(line))) except BPDClose: # Command indicates that the conn should close. self.sock.close() return @classmethod def handler(cls, server): def _handle(sock): """Creates a new `Connection` and runs it. """ return cls(server, sock).run() return _handle class Command(object): """A command issued by the client for processing by the server. """ command_re = re.compile(r'^([^ \t]+)[ \t]*') arg_re = re.compile(r'"((?:\\"|[^"])+)"|([^ \t"]+)') def __init__(self, s): """Creates a new `Command` from the given string, `s`, parsing the string for command name and arguments. """ command_match = self.command_re.match(s) self.name = command_match.group(1) self.args = [] arg_matches = self.arg_re.findall(s[command_match.end():]) for match in arg_matches: if match[0]: # Quoted argument. arg = match[0] arg = arg.replace('\\"', '"').replace('\\\\', '\\') else: # Unquoted argument. arg = match[1] arg = arg.decode('utf8') self.args.append(arg) def run(self, conn): """A coroutine that executes the command on the given connection. """ # Attempt to get correct command function. func_name = 'cmd_' + self.name if not hasattr(conn.server, func_name): raise BPDError(ERROR_UNKNOWN, u'unknown command', self.name) func = getattr(conn.server, func_name) # Ensure we have permission for this command. if conn.server.password and \ not conn.authenticated and \ self.name not in SAFE_COMMANDS: raise BPDError(ERROR_PERMISSION, u'insufficient privileges') try: args = [conn] + self.args results = func(*args) if results: for data in results: yield conn.send(data) except BPDError as e: # An exposed error. Set the command name and then let # the Connection handle it. e.cmd_name = self.name raise e except BPDClose: # An indication that the connection should close. Send # it on the Connection. raise except Exception as e: # An "unintentional" error. Hide it from the client. log.error(traceback.format_exc(e)) raise BPDError(ERROR_SYSTEM, u'server error', self.name) class CommandList(list): """A list of commands issued by the client for processing by the server. May be verbose, in which case the response is delimited, or not. Should be a list of `Command` objects. """ def __init__(self, sequence=None, verbose=False): """Create a new `CommandList` from the given sequence of `Command`s. If `verbose`, this is a verbose command list. """ if sequence: for item in sequence: self.append(item) self.verbose = verbose def run(self, conn): """Coroutine executing all the commands in this list. """ for i, command in enumerate(self): try: yield bluelet.call(command.run(conn)) except BPDError as e: # If the command failed, stop executing. e.index = i # Give the error the correct index. raise e # Otherwise, possibly send the output delimeter if we're in a # verbose ("OK") command list. if self.verbose: yield conn.send(RESP_CLIST_VERBOSE) # A subclass of the basic, protocol-handling server that actually plays # music. class Server(BaseServer): """An MPD-compatible server using GStreamer to play audio and beets to store its library. """ def __init__(self, library, host, port, password): try: from beetsplug.bpd import gstplayer except ImportError as e: # This is a little hacky, but it's the best I know for now. if e.args[0].endswith(' gst'): raise NoGstreamerError() else: raise super(Server, self).__init__(host, port, password) self.lib = library self.player = gstplayer.GstPlayer(self.play_finished) self.cmd_update(None) def run(self): self.player.run() super(Server, self).run() def play_finished(self): """A callback invoked every time our player finishes a track. """ self.cmd_next(None) # Metadata helper functions. def _item_info(self, item): info_lines = [u'file: ' + item.destination(fragment=True), u'Time: ' + unicode(int(item.length)), u'Title: ' + item.title, u'Artist: ' + item.artist, u'Album: ' + item.album, u'Genre: ' + item.genre, ] track = unicode(item.track) if item.tracktotal: track += u'/' + unicode(item.tracktotal) info_lines.append(u'Track: ' + track) info_lines.append(u'Date: ' + unicode(item.year)) try: pos = self._id_to_index(item.id) info_lines.append(u'Pos: ' + unicode(pos)) except ArgumentNotFoundError: # Don't include position if not in playlist. pass info_lines.append(u'Id: ' + unicode(item.id)) return info_lines def _item_id(self, item): return item.id # Database updating. def cmd_update(self, conn, path=u'/'): """Updates the catalog to reflect the current database state. """ # Path is ignored. Also, the real MPD does this asynchronously; # this is done inline. print('Building directory tree...') self.tree = vfs.libtree(self.lib) print('... done.') self.updated_time = time.time() # Path (directory tree) browsing. def _resolve_path(self, path): """Returns a VFS node or an item ID located at the path given. If the path does not exist, raises a """ components = path.split(u'/') node = self.tree for component in components: if not component: continue if isinstance(node, int): # We're trying to descend into a file node. raise ArgumentNotFoundError() if component in node.files: node = node.files[component] elif component in node.dirs: node = node.dirs[component] else: raise ArgumentNotFoundError() return node def _path_join(self, p1, p2): """Smashes together two BPD paths.""" out = p1 + u'/' + p2 return out.replace(u'//', u'/').replace(u'//', u'/') def cmd_lsinfo(self, conn, path=u"/"): """Sends info on all the items in the path.""" node = self._resolve_path(path) if isinstance(node, int): # Trying to list a track. raise BPDError(ERROR_ARG, 'this is not a directory') else: for name, itemid in iter(sorted(node.files.items())): item = self.lib.get_item(itemid) yield self._item_info(item) for name, _ in iter(sorted(node.dirs.iteritems())): dirpath = self._path_join(path, name) if dirpath.startswith(u"/"): # Strip leading slash (libmpc rejects this). dirpath = dirpath[1:] yield u'directory: %s' % dirpath def _listall(self, basepath, node, info=False): """Helper function for recursive listing. If info, show tracks' complete info; otherwise, just show items' paths. """ if isinstance(node, int): # List a single file. if info: item = self.lib.get_item(node) yield self._item_info(item) else: yield u'file: ' + basepath else: # List a directory. Recurse into both directories and files. for name, itemid in sorted(node.files.iteritems()): newpath = self._path_join(basepath, name) # "yield from" for v in self._listall(newpath, itemid, info): yield v for name, subdir in sorted(node.dirs.iteritems()): newpath = self._path_join(basepath, name) yield u'directory: ' + newpath for v in self._listall(newpath, subdir, info): yield v def cmd_listall(self, conn, path=u"/"): """Send the paths all items in the directory, recursively.""" return self._listall(path, self._resolve_path(path), False) def cmd_listallinfo(self, conn, path=u"/"): """Send info on all the items in the directory, recursively.""" return self._listall(path, self._resolve_path(path), True) # Playlist manipulation. def _all_items(self, node): """Generator yielding all items under a VFS node. """ if isinstance(node, int): # Could be more efficient if we built up all the IDs and # then issued a single SELECT. yield self.lib.get_item(node) else: # Recurse into a directory. for name, itemid in sorted(node.files.iteritems()): # "yield from" for v in self._all_items(itemid): yield v for name, subdir in sorted(node.dirs.iteritems()): for v in self._all_items(subdir): yield v def _add(self, path, send_id=False): """Adds a track or directory to the playlist, specified by the path. If `send_id`, write each item's id to the client. """ for item in self._all_items(self._resolve_path(path)): self.playlist.append(item) if send_id: yield u'Id: ' + unicode(item.id) self.playlist_version += 1 def cmd_add(self, conn, path): """Adds a track or directory to the playlist, specified by a path. """ return self._add(path, False) def cmd_addid(self, conn, path): """Same as `cmd_add` but sends an id back to the client.""" return self._add(path, True) # Server info. def cmd_status(self, conn): for line in super(Server, self).cmd_status(conn): yield line if self.current_index > -1: item = self.playlist[self.current_index] yield u'bitrate: ' + unicode(item.bitrate/1000) # Missing 'audio'. (pos, total) = self.player.time() yield u'time: ' + unicode(pos) + u':' + unicode(total) # Also missing 'updating_db'. def cmd_stats(self, conn): """Sends some statistics about the library.""" with self.lib.transaction() as tx: statement = 'SELECT COUNT(DISTINCT artist), ' \ 'COUNT(DISTINCT album), ' \ 'COUNT(id), ' \ 'SUM(length) ' \ 'FROM items' artists, albums, songs, totaltime = tx.query(statement)[0] yield (u'artists: ' + unicode(artists), u'albums: ' + unicode(albums), u'songs: ' + unicode(songs), u'uptime: ' + unicode(int(time.time() - self.startup_time)), u'playtime: ' + u'0', # Missing. u'db_playtime: ' + unicode(int(totaltime)), u'db_update: ' + unicode(int(self.updated_time)), ) # Searching. tagtype_map = { u'Artist': u'artist', u'Album': u'album', u'Title': u'title', u'Track': u'track', u'AlbumArtist': u'albumartist', u'AlbumArtistSort': u'albumartist_sort', # Name? u'Genre': u'genre', u'Date': u'year', u'Composer': u'composer', # Performer? u'Disc': u'disc', u'filename': u'path', # Suspect. } def cmd_tagtypes(self, conn): """Returns a list of the metadata (tag) fields available for searching. """ for tag in self.tagtype_map: yield u'tagtype: ' + tag def _tagtype_lookup(self, tag): """Uses `tagtype_map` to look up the beets column name for an MPD tagtype (or throw an appropriate exception). Returns both the canonical name of the MPD tagtype and the beets column name. """ for test_tag, key in self.tagtype_map.items(): # Match case-insensitively. if test_tag.lower() == tag.lower(): return test_tag, key raise BPDError(ERROR_UNKNOWN, u'no such tagtype') def _metadata_query(self, query_type, any_query_type, kv): """Helper function returns a query object that will find items according to the library query type provided and the key-value pairs specified. The any_query_type is used for queries of type "any"; if None, then an error is thrown. """ if kv: # At least one key-value pair. queries = [] # Iterate pairwise over the arguments. it = iter(kv) for tag, value in zip(it, it): if tag.lower() == u'any': if any_query_type: queries.append(any_query_type(value, ITEM_KEYS_WRITABLE, query_type)) else: raise BPDError(ERROR_UNKNOWN, u'no such tagtype') else: _, key = self._tagtype_lookup(tag) queries.append(query_type(key, value)) return beets.library.AndQuery(queries) else: # No key-value pairs. return beets.library.TrueQuery() def cmd_search(self, conn, *kv): """Perform a substring match for items.""" query = self._metadata_query(beets.library.SubstringQuery, beets.library.AnyFieldQuery, kv) for item in self.lib.items(query): yield self._item_info(item) def cmd_find(self, conn, *kv): """Perform an exact match for items.""" query = self._metadata_query(beets.library.MatchQuery, None, kv) for item in self.lib.items(query): yield self._item_info(item) def cmd_list(self, conn, show_tag, *kv): """List distinct metadata values for show_tag, possibly filtered by matching match_tag to match_term. """ show_tag_canon, show_key = self._tagtype_lookup(show_tag) query = self._metadata_query(beets.library.MatchQuery, None, kv) clause, subvals = query.clause() statement = 'SELECT DISTINCT ' + show_key + \ ' FROM items WHERE ' + clause + \ ' ORDER BY ' + show_key with self.lib.transaction() as tx: rows = tx.query(statement, subvals) for row in rows: yield show_tag_canon + u': ' + unicode(row[0]) def cmd_count(self, conn, tag, value): """Returns the number and total time of songs matching the tag/value query. """ _, key = self._tagtype_lookup(tag) songs = 0 playtime = 0.0 for item in self.lib.items(beets.library.MatchQuery(key, value)): songs += 1 playtime += item.length yield u'songs: ' + unicode(songs) yield u'playtime: ' + unicode(int(playtime)) # "Outputs." Just a dummy implementation because we don't control # any outputs. def cmd_outputs(self, conn): """List the available outputs.""" yield (u'outputid: 0', u'outputname: gstreamer', u'outputenabled: 1', ) def cmd_enableoutput(self, conn, output_id): output_id = cast_arg(int, output_id) if output_id != 0: raise ArgumentIndexError() def cmd_disableoutput(self, conn, output_id): output_id = cast_arg(int, output_id) if output_id == 0: raise BPDError(ERROR_ARG, u'cannot disable this output') else: raise ArgumentIndexError() # Playback control. The functions below hook into the # half-implementations provided by the base class. Together, they're # enough to implement all normal playback functionality. def cmd_play(self, conn, index=-1): new_index = index != -1 and index != self.current_index was_paused = self.paused super(Server, self).cmd_play(conn, index) if self.current_index > -1: # Not stopped. if was_paused and not new_index: # Just unpause. self.player.play() else: self.player.play_file(self.playlist[self.current_index].path) def cmd_pause(self, conn, state=None): super(Server, self).cmd_pause(conn, state) if self.paused: self.player.pause() elif self.player.playing: self.player.play() def cmd_stop(self, conn): super(Server, self).cmd_stop(conn) self.player.stop() def cmd_seek(self, conn, index, pos): """Seeks to the specified position in the specified song.""" index = cast_arg(int, index) pos = cast_arg(int, pos) super(Server, self).cmd_seek(conn, index, pos) self.player.seek(pos) # Volume control. def cmd_setvol(self, conn, vol): vol = cast_arg(int, vol) super(Server, self).cmd_setvol(conn, vol) self.player.volume = float(vol)/100 # Beets plugin hooks. class BPDPlugin(BeetsPlugin): """Provides the "beet bpd" command for running a music player server. """ def __init__(self): super(BPDPlugin, self).__init__() self.config.add({ 'host': u'', 'port': 6600, 'password': u'', }) def start_bpd(self, lib, host, port, password, debug): """Starts a BPD server.""" if debug: log.setLevel(logging.DEBUG) else: log.setLevel(logging.WARNING) try: Server(lib, host, port, password).run() except NoGstreamerError: global_log.error('Gstreamer Python bindings not found.') global_log.error('Install "python-gst0.10", "py27-gst-python", ' 'or similar package to use BPD.') def commands(self): cmd = beets.ui.Subcommand('bpd', help='run an MPD-compatible music player server') cmd.parser.add_option('-d', '--debug', action='store_true', help='dump all MPD traffic to stdout') def func(lib, opts, args): host = args.pop(0) if args else self.config['host'].get(unicode) port = args.pop(0) if args else self.config['port'].get(int) if args: raise beets.ui.UserError('too many arguments') password = self.config['password'].get(unicode) debug = opts.debug or False self.start_bpd(lib, host, int(port), password, debug) cmd.func = func return [cmd] beets-1.3.1/beetsplug/bpd/gstplayer.py0000644000076500000240000001603512102026773020712 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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 wrapper for the GStreamer Python bindings that exposes a simple music player. """ from __future__ import print_function import sys import time import gobject import thread import os import copy import urllib import pygst pygst.require('0.10') import gst class GstPlayer(object): """A music player abstracting GStreamer's Playbin element. Create a player object, then call run() to start a thread with a runloop. Then call play_file to play music. Use player.playing to check whether music is currently playing. A basic play queue is also implemented (just a Python list, player.queue, whose last element is next to play). To use it, just call enqueue() and then play(). When a track finishes and another is available on the queue, it is played automatically. """ def __init__(self, finished_callback=None): """Initialize a player. If a finished_callback is provided, it is called every time a track started with play_file finishes. Once the player has been created, call run() to begin the main runloop in a separate thread. """ # Set up the Gstreamer player. From the pygst tutorial: # http://pygstdocs.berlios.de/pygst-tutorial/playbin.html self.player = gst.element_factory_make("playbin2", "player") fakesink = gst.element_factory_make("fakesink", "fakesink") self.player.set_property("video-sink", fakesink) bus = self.player.get_bus() bus.add_signal_watch() bus.connect("message", self._handle_message) # Set up our own stuff. self.playing = False self.finished_callback = finished_callback self.cached_time = None self._volume = 1.0 def _get_state(self): """Returns the current state flag of the playbin.""" # gst's get_state function returns a 3-tuple; we just want the # status flag in position 1. return self.player.get_state()[1] def _handle_message(self, bus, message): """Callback for status updates from GStreamer.""" if message.type == gst.MESSAGE_EOS: # file finished playing self.player.set_state(gst.STATE_NULL) self.playing = False self.cached_time = None if self.finished_callback: self.finished_callback() elif message.type == gst.MESSAGE_ERROR: # error self.player.set_state(gst.STATE_NULL) err, debug = message.parse_error() print("Error: " + str(err)) self.playing = False def _set_volume(self, volume): """Set the volume level to a value in the range [0, 1.5].""" # And the volume for the playbin. self._volume = volume self.player.set_property("volume", volume) def _get_volume(self): """Get the volume as a float in the range [0, 1.5].""" return self._volume volume = property(_get_volume, _set_volume) def play_file(self, path): """Immediately begin playing the audio file at the given path. """ self.player.set_state(gst.STATE_NULL) if isinstance(path, unicode): path = path.encode('utf8') uri = 'file://' + urllib.quote(path) self.player.set_property("uri", uri) self.player.set_state(gst.STATE_PLAYING) self.playing = True def play(self): """If paused, resume playback.""" if self._get_state() == gst.STATE_PAUSED: self.player.set_state(gst.STATE_PLAYING) self.playing = True def pause(self): """Pause playback.""" self.player.set_state(gst.STATE_PAUSED) def stop(self): """Halt playback.""" self.player.set_state(gst.STATE_NULL) self.playing = False self.cached_time = None def run(self): """Start a new thread for the player. Call this function before trying to play any music with play_file() or play(). """ # If we don't use the MainLoop, messages are never sent. gobject.threads_init() def start(): loop = gobject.MainLoop() loop.run() thread.start_new_thread(start, ()) def time(self): """Returns a tuple containing (position, length) where both values are integers in seconds. If no stream is available, returns (0, 0). """ fmt = gst.Format(gst.FORMAT_TIME) try: pos = self.player.query_position(fmt, None)[0]/(10**9) length = self.player.query_duration(fmt, None)[0]/(10**9) self.cached_time = (pos, length) return (pos, length) except gst.QueryError: # Stream not ready. For small gaps of time, for instance # after seeking, the time values are unavailable. For this # reason, we cache recent. if self.playing and self.cached_time: return self.cached_time else: return (0, 0) def seek(self, position): """Seeks to position (in seconds).""" cur_pos, cur_len = self.time() if position > cur_len: self.stop() return fmt = gst.Format(gst.FORMAT_TIME) ns = position * 10**9 # convert to nanoseconds self.player.seek_simple(fmt, gst.SEEK_FLAG_FLUSH, ns) # save new cached time self.cached_time = (position, cur_len) def block(self): """Block until playing finishes.""" while self.playing: time.sleep(1) def play_simple(paths): """Play the files in paths in a straightforward way, without using the player's callback function. """ p = GstPlayer() p.run() for path in paths: p.play_file(path) p.block() def play_complicated(paths): """Play the files in the path one after the other by using the callback function to advance to the next song. """ my_paths = copy.copy(paths) def next_song(): my_paths.pop(0) p.play_file(my_paths[0]) p = GstPlayer(next_song) p.run() p.play_file(my_paths[0]) while my_paths: time.sleep(1) if __name__ == '__main__': # A very simple command-line player. Just give it names of audio # files on the command line; these are all played in sequence. paths = [os.path.abspath(os.path.expanduser(p)) for p in sys.argv[1:]] # play_simple(paths) play_complicated(paths) beets-1.3.1/beetsplug/chroma.py0000644000076500000240000002322412207240712017377 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Adds Chromaprint/Acoustid acoustic fingerprinting support to the autotagger. Requires the pyacoustid library. """ from beets import plugins from beets import ui from beets import util from beets import config from beets.util import confit from beets.autotag import hooks import acoustid import logging from collections import defaultdict API_KEY = '1vOwZtEn' SCORE_THRESH = 0.5 TRACK_ID_WEIGHT = 10.0 COMMON_REL_THRESH = 0.6 # How many tracks must have an album in common? log = logging.getLogger('beets') # Stores the Acoustid match information for each track. This is # populated when an import task begins and then used when searching for # candidates. It maps audio file paths to (recording_ids, release_ids) # pairs. If a given path is not present in the mapping, then no match # was found. _matches = {} # Stores the fingerprint and Acoustid ID for each track. This is stored # as metadata for each track for later use but is not relevant for # autotagging. _fingerprints = {} _acoustids = {} def acoustid_match(path): """Gets metadata for a file from Acoustid and populates the _matches, _fingerprints, and _acoustids dictionaries accordingly. """ try: duration, fp = acoustid.fingerprint_file(util.syspath(path)) except acoustid.FingerprintGenerationError as exc: log.error('fingerprinting of %s failed: %s' % (repr(path), str(exc))) return None _fingerprints[path] = fp try: res = acoustid.lookup(API_KEY, fp, duration, meta='recordings releases') except acoustid.AcoustidError as exc: log.debug('fingerprint matching %s failed: %s' % (repr(path), str(exc))) return None log.debug('chroma: fingerprinted %s' % repr(path)) # Ensure the response is usable and parse it. if res['status'] != 'ok' or not res.get('results'): log.debug('chroma: no match found') return None result = res['results'][0] # Best match. if result['score'] < SCORE_THRESH: log.debug('chroma: no results above threshold') return None _acoustids[path] = result['id'] # Get recording and releases from the result. if not result.get('recordings'): log.debug('chroma: no recordings found') return None recording_ids = [] release_ids = [] for recording in result['recordings']: recording_ids.append(recording['id']) if 'releases' in recording: release_ids += [rel['id'] for rel in recording['releases']] log.debug('chroma: matched recordings {0}'.format(recording_ids)) _matches[path] = recording_ids, release_ids # Plugin structure and autotagging logic. def _all_releases(items): """Given an iterable of Items, determines (according to Acoustid) which releases the items have in common. Generates release IDs. """ # Count the number of "hits" for each release. relcounts = defaultdict(int) for item in items: if item.path not in _matches: continue _, release_ids = _matches[item.path] for release_id in release_ids: relcounts[release_id] += 1 for release_id, count in relcounts.iteritems(): if float(count) / len(items) > COMMON_REL_THRESH: yield release_id class AcoustidPlugin(plugins.BeetsPlugin): def track_distance(self, item, info): dist = hooks.Distance() if item.path not in _matches or not info.track_id: # Match failed or no track ID. return dist recording_ids, _ = _matches[item.path] dist.add_expr('track_id', info.track_id not in recording_ids) return dist def candidates(self, items, artist, album, va_likely): albums = [] for relid in _all_releases(items): album = hooks.album_for_mbid(relid) if album: albums.append(album) log.debug('acoustid album candidates: %i' % len(albums)) return albums def item_candidates(self, item, artist, title): if item.path not in _matches: return [] recording_ids, _ = _matches[item.path] tracks = [] for recording_id in recording_ids: track = hooks.track_for_mbid(recording_id) if track: tracks.append(track) log.debug('acoustid item candidates: {0}'.format(len(tracks))) return tracks def commands(self): submit_cmd = ui.Subcommand('submit', help='submit Acoustid fingerprints') def submit_cmd_func(lib, opts, args): try: apikey = config['acoustid']['apikey'].get(unicode) except confit.NotFoundError: raise ui.UserError('no Acoustid user API key provided') submit_items(apikey, lib.items(ui.decargs(args))) submit_cmd.func = submit_cmd_func fingerprint_cmd = ui.Subcommand('fingerprint', help='generate fingerprints for items without them') def fingerprint_cmd_func(lib, opts, args): for item in lib.items(ui.decargs(args)): fingerprint_item(item, write=config['import']['write'].get(bool)) fingerprint_cmd.func = fingerprint_cmd_func return [submit_cmd, fingerprint_cmd] # Hooks into import process. @AcoustidPlugin.listen('import_task_start') def fingerprint_task(task, session): """Fingerprint each item in the task for later use during the autotagging candidate search. """ items = task.items if task.is_album else [task.item] for item in items: acoustid_match(item.path) @AcoustidPlugin.listen('import_task_apply') def apply_acoustid_metadata(task, session): """Apply Acoustid metadata (fingerprint and ID) to the task's items. """ for item in task.imported_items(): if item.path in _fingerprints: item.acoustid_fingerprint = _fingerprints[item.path] if item.path in _acoustids: item.acoustid_id = _acoustids[item.path] # UI commands. def submit_items(userkey, items, chunksize=64): """Submit fingerprints for the items to the Acoustid server. """ data = [] # The running list of dictionaries to submit. def submit_chunk(): """Submit the current accumulated fingerprint data.""" log.info('submitting {0} fingerprints'.format(len(data))) try: acoustid.submit(API_KEY, userkey, data) except acoustid.AcoustidError as exc: log.warn(u'acoustid submission error: {}'.format(exc)) del data[:] for item in items: fp = fingerprint_item(item) # Construct a submission dictionary for this item. item_data = { 'duration': int(item.length), 'fingerprint': fp, } if item.mb_trackid: item_data['mbid'] = item.mb_trackid log.debug('submitting MBID') else: item_data.update({ 'track': item.title, 'artist': item.artist, 'album': item.album, 'albumartist': item.albumartist, 'year': item.year, 'trackno': item.track, 'discno': item.disc, }) log.debug('submitting textual metadata') data.append(item_data) # If we have enough data, submit a chunk. if len(data) >= chunksize: submit_chunk() # Submit remaining data in a final chunk. if data: submit_chunk() def fingerprint_item(item, write=False): """Get the fingerprint for an Item. If the item already has a fingerprint, it is not regenerated. If fingerprint generation fails, return None. If the items are associated with a library, they are saved to the database. If `write` is set, then the new fingerprints are also written to files' metadata. """ # Get a fingerprint and length for this track. if not item.length: log.info(u'{0}: no duration available'.format( util.displayable_path(item.path) )) elif item.acoustid_fingerprint: if write: log.info(u'{0}: fingerprint exists, skipping'.format( util.displayable_path(item.path) )) else: log.info(u'{0}: using existing fingerprint'.format( util.displayable_path(item.path) )) return item.acoustid_fingerprint else: log.info(u'{0}: fingerprinting'.format( util.displayable_path(item.path) )) try: _, fp = acoustid.fingerprint_file(item.path) item.acoustid_fingerprint = fp if write: log.info(u'{0}: writing fingerprint'.format( util.displayable_path(item.path) )) item.write() if item._lib: item.store() return item.acoustid_fingerprint except acoustid.FingerprintGenerationError as exc: log.info( 'fingerprint generation failed: {0}'.format(exc) ) beets-1.3.1/beetsplug/convert.py0000644000076500000240000002534312224332665017622 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Converts tracks or albums to external directory """ import logging import os import threading from subprocess import Popen import tempfile from string import Template import pipes from beets.plugins import BeetsPlugin from beets import ui, util from beetsplug.embedart import _embed from beets import config log = logging.getLogger('beets') DEVNULL = open(os.devnull, 'wb') _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. # Some convenient alternate names for formats. ALIASES = { u'wma': u'windows media', u'vorbis': u'ogg', } def _destination(dest_dir, item, keep_new, path_formats): """Return the path under `dest_dir` where the file should be placed (possibly after conversion). """ dest = item.destination(basedir=dest_dir, path_formats=path_formats) if keep_new: # When we're keeping the converted file, no extension munging # occurs. return dest else: # Otherwise, replace the extension. _, ext = get_format() return os.path.splitext(dest)[0] + ext def get_format(): """Get the currently configured format command and extension. """ format = config['convert']['format'].get(unicode).lower() format = ALIASES.get(format, format) format_info = config['convert']['formats'][format].get(dict) # Convenience and backwards-compatibility shortcuts. keys = config['convert'].keys() if 'command' in keys: format_info['command'] = config['convert']['command'].get(unicode) elif 'opts' in keys: # Undocumented option for backwards compatibility with < 1.3.1. format_info['command'] = u'ffmpeg -i $source -y {0} $dest'.format( config['convert']['opts'].get(unicode) ) if 'extension' in keys: format_info['extension'] = config['convert']['extension'].get(unicode) try: return [a.encode('utf8') for a in format_info['command'].split()], \ (u'.' + format_info['extension']).encode('utf8') except KeyError: raise ui.UserError( u'convert: format {0} needs "command" and "extension" fields' .format(format) ) def encode(source, dest): quiet = config['convert']['quiet'].get() if not quiet: log.info(u'Started encoding {0}'.format(util.displayable_path(source))) command, _ = get_format() opts = [] for arg in command: opts.append(Template(arg).safe_substitute({ 'source': source, 'dest': dest, })) log.debug(u'convert: executing: {0}'.format( u' '.join(pipes.quote(o.decode('utf8', 'ignore')) for o in opts) )) encode = Popen(opts, close_fds=True, stderr=DEVNULL) encode.wait() if encode.returncode != 0: # Something went wrong (probably Ctrl+C), remove temporary files log.info(u'Encoding {0} failed. Cleaning up...' .format(util.displayable_path(source))) util.remove(dest) util.prune_dirs(os.path.dirname(dest)) return if not quiet: log.info(u'Finished encoding {0}'.format( util.displayable_path(source)) ) def should_transcode(item): """Determine whether the item should be transcoded as part of conversion (i.e., its bitrate is high or it has the wrong format). """ maxbr = config['convert']['max_bitrate'].get(int) format_name = config['convert']['format'].get(unicode) return format_name.lower() == item.format.lower() or \ item.bitrate >= 1000 * maxbr def convert_item(dest_dir, keep_new, path_formats): while True: item = yield dest = _destination(dest_dir, item, keep_new, path_formats) if os.path.exists(util.syspath(dest)): log.info(u'Skipping {0} (target file exists)'.format( util.displayable_path(item.path) )) continue # Ensure that only one thread tries to create directories at a # time. (The existence check is not atomic with the directory # creation inside this function.) with _fs_lock: util.mkdirall(dest) # When keeping the new file in the library, we first move the # current (pristine) file to the destination. We'll then copy it # back to its old path or transcode it to a new path. if keep_new: log.info(u'Moving to {0}'. format(util.displayable_path(dest))) util.move(item.path, dest) if not should_transcode(item): # No transcoding necessary. log.info(u'Copying {0}'.format(util.displayable_path(item.path))) if keep_new: util.copy(dest, item.path) else: util.copy(item.path, dest) else: if keep_new: _, ext = get_format() item.path = os.path.splitext(item.path)[0] + ext encode(dest, item.path) else: encode(item.path, dest) # Write tags from the database to the converted file. if not keep_new: item.path = dest item.write() # If we're keeping the transcoded file, read it again (after # writing) to get new bitrate, duration, etc. if keep_new: item.read() item.store() # Store new path and audio data. if config['convert']['embed']: album = item.get_album() if album: artpath = album.artpath if artpath: _embed(artpath, [item]) def convert_on_import(lib, item): """Transcode a file automatically after it is imported into the library. """ if should_transcode(item): _, ext = get_format() fd, dest = tempfile.mkstemp(ext) os.close(fd) _temp_files.append(dest) # Delete the transcode later. encode(item.path, dest) item.path = dest item.write() item.read() # Load new audio information data. item.store() def convert_func(lib, opts, args): dest = opts.dest if opts.dest is not None else \ config['convert']['dest'].get() if not dest: raise ui.UserError('no convert destination set') dest = util.bytestring_path(dest) threads = opts.threads if opts.threads is not None else \ config['convert']['threads'].get(int) keep_new = opts.keep_new if not config['convert']['paths']: path_formats = ui.get_path_formats() else: path_formats = ui.get_path_formats(config['convert']['paths']) ui.commands.list_items(lib, ui.decargs(args), opts.album, None) if not ui.input_yn("Convert? (Y/n)"): return if opts.album: items = (i for a in lib.albums(ui.decargs(args)) for i in a.items()) else: items = iter(lib.items(ui.decargs(args))) convert = [convert_item(dest, keep_new, path_formats) for i in range(threads)] pipe = util.pipeline.Pipeline([items, convert]) pipe.run_parallel() class ConvertPlugin(BeetsPlugin): def __init__(self): super(ConvertPlugin, self).__init__() self.config.add({ u'dest': None, u'threads': util.cpu_count(), u'format': u'mp3', u'formats': { u'aac': { u'command': u'ffmpeg -i $source -y -acodec libfaac ' u'-aq 100 $dest', u'extension': u'm4a', }, u'alac': { u'command': u'ffmpeg -i $source -y -acodec alac $dest', u'extension': u'm4a', }, u'flac': { u'command': u'ffmpeg -i $source -y -acodec flac $dest', u'extension': u'flac', }, u'mp3': { u'command': u'ffmpeg -i $source -y -aq 2 $dest', u'extension': u'mp3', }, u'opus': { u'command': u'ffmpeg -i $source -y -acodec libopus -vn ' u'-ab 96k $dest', u'extension': u'opus', }, u'ogg': { u'command': u'ffmpeg -i $source -y -acodec libvorbis -vn ' u'-aq 2 $dest', u'extension': u'ogg', }, u'windows media': { u'command': u'ffmpeg -i $source -y -acodec wmav2 ' u'-vn $dest', u'extension': u'wma', }, }, u'max_bitrate': 500, u'auto': False, u'quiet': False, u'embed': True, u'paths': {}, }) self.import_stages = [self.auto_convert] def commands(self): cmd = ui.Subcommand('convert', help='convert to external location') cmd.parser.add_option('-a', '--album', action='store_true', help='choose albums instead of tracks') cmd.parser.add_option('-t', '--threads', action='store', type='int', help='change the number of threads, \ defaults to maximum available processors') cmd.parser.add_option('-k', '--keep-new', action='store_true', dest='keep_new', help='keep only the converted \ and move the old files') cmd.parser.add_option('-d', '--dest', action='store', help='set the destination directory') cmd.func = convert_func return [cmd] def auto_convert(self, config, task): if self.config['auto'].get(): if not task.is_album: convert_on_import(config.lib, task.item) else: for item in task.items: convert_on_import(config.lib, item) @ConvertPlugin.listen('import_task_files') def _cleanup(task, session): for path in task.old_paths: if path in _temp_files: if os.path.isfile(path): util.remove(path) _temp_files.remove(path) beets-1.3.1/beetsplug/discogs.py0000644000076500000240000002404312203275653017571 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Adds Discogs album search support to the autotagger. Requires the discogs-client library. """ from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.plugins import BeetsPlugin from discogs_client import DiscogsAPIError, Release, Search import beets import discogs_client import logging import re import time log = logging.getLogger('beets') # Silence spurious INFO log lines generated by urllib3. urllib3_logger = logging.getLogger('requests.packages.urllib3') urllib3_logger.setLevel(logging.CRITICAL) # Set user-agent for discogs client. discogs_client.user_agent = 'beets/%s +http://beets.radbox.org/' % \ beets.__version__ class DiscogsPlugin(BeetsPlugin): def __init__(self): super(DiscogsPlugin, self).__init__() self.config.add({ 'source_weight': 0.5, }) def album_distance(self, items, album_info, mapping): """Returns the album distance. """ dist = Distance() if album_info.data_source == 'Discogs': dist.add('source', self.config['source_weight'].as_number()) return dist def candidates(self, items, artist, album, va_likely): """Returns a list of AlbumInfo objects for discogs search results matching an album and artist (if not various). """ if va_likely: query = album else: query = '%s %s' % (artist, album) try: return self.get_albums(query) except DiscogsAPIError as e: log.debug('Discogs API Error: %s (query: %s' % (e, query)) return [] def album_for_id(self, album_id): """Fetches an album by its Discogs ID and returns an AlbumInfo object or None if the album is not found. """ log.debug('Searching discogs for release %s' % str(album_id)) # Discogs-IDs are simple integers. We only look for those at the end # of an input string as to avoid confusion with other metadata plugins. # An optional bracket can follow the integer, as this is how discogs # displays the release ID on its webpage. match = re.search(r'(^|\[*r|discogs\.com/.+/release/)(\d+)($|\])', album_id) if not match: return None result = Release(match.group(2)) # Try to obtain title to verify that we indeed have a valid Release try: getattr(result, 'title') except DiscogsAPIError as e: if e.message != '404 Not Found': log.debug('Discogs API Error: %s (query: %s)' % (e, result._uri)) return None return self.get_album_info(result) def get_albums(self, query): """Returns a list of AlbumInfo objects for a discogs search query. """ # Strip non-word characters from query. Things like "!" and "-" can # cause a query to return no results, even if they match the artist or # album title. Use `re.UNICODE` flag to avoid stripping non-english # word characters. query = re.sub(r'(?u)\W+', ' ', query) # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. query = re.sub(r'(?i)\b(CD|disc)\s*\d+', '', query) albums = [] for result in Search(query).results(): if isinstance(result, Release): albums.append(self.get_album_info(result)) if len(albums) >= 5: break return albums def get_album_info(self, result): """Returns an AlbumInfo object for a discogs Release object. """ album = re.sub(r' +', ' ', result.title) album_id = result.data['id'] artist, artist_id = self.get_artist(result.data['artists']) # Use `.data` to access the tracklist directly instead of the convenient # `.tracklist` property, which will strip out useful artist information # and leave us with skeleton `Artist` objects that will each make an API # call just to get the same data back. tracks = self.get_tracks(result.data['tracklist']) albumtype = ', '.join( result.data['formats'][0].get('descriptions', [])) or None va = result.data['artists'][0]['name'].lower() == 'various' year = result.data['year'] label = result.data['labels'][0]['name'] mediums = len(set(t.medium for t in tracks)) catalogno = result.data['labels'][0]['catno'] if catalogno == 'none': catalogno = None country = result.data.get('country') media = result.data['formats'][0]['name'] data_url = result.data['uri'] return AlbumInfo(album, album_id, artist, artist_id, tracks, asin=None, albumtype=albumtype, va=va, year=year, month=None, day=None, label=label, mediums=mediums, artist_sort=None, releasegroup_id=None, catalognum=catalogno, script=None, language=None, country=country, albumstatus=None, media=media, albumdisambig=None, artist_credit=None, original_year=None, original_month=None, original_day=None, data_source='Discogs', data_url=data_url) def get_artist(self, artists): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of discogs album or track artists. """ artist_id = None bits = [] for artist in artists: if not artist_id: artist_id = artist['id'] name = artist['name'] # Strip disambiguation number. name = re.sub(r' \(\d+\)$', '', name) # Move articles to the front. name = re.sub(r'(?i)^(.*?), (a|an|the)$', r'\2 \1', name) bits.append(name) if artist['join']: bits.append(artist['join']) artist = ' '.join(bits).replace(' ,', ',') or None return artist, artist_id def get_tracks(self, tracklist): """Returns a list of TrackInfo objects for a discogs tracklist. """ tracks = [] index_tracks = {} index = 0 for track in tracklist: # Only real tracks have `position`. Otherwise, it's an index track. if track['position']: index += 1 tracks.append(self.get_track_info(track, index)) else: index_tracks[index+1] = track['title'] # Fix up medium and medium_index for each track. Discogs position is # unreliable, but tracks are in order. medium = None medium_count, index_count = 0, 0 for track in tracks: # Handle special case where a different medium does not indicate a # new disc, when there is no medium_index and the ordinal of medium # is not sequential. For example, I, II, III, IV, V. Assume these # are the track index, not the medium. medium_is_index = track.medium and not track.medium_index and ( len(track.medium) != 1 or ord(track.medium) - 64 != medium_count + 1) if not medium_is_index and medium != track.medium: # Increment medium_count and reset index_count when medium # changes. medium = track.medium medium_count += 1 index_count = 0 index_count += 1 track.medium, track.medium_index = medium_count, index_count # Get `disctitle` from Discogs index tracks. Assume that an index track # before the first track of each medium is a disc title. for track in tracks: if track.medium_index == 1: if track.index in index_tracks: disctitle = index_tracks[track.index] else: disctitle = None track.disctitle = disctitle return tracks def get_track_info(self, track, index): """Returns a TrackInfo object for a discogs track. """ title = track['title'] track_id = None medium, medium_index = self.get_track_index(track['position']) artist, artist_id = self.get_artist(track.get('artists', [])) length = self.get_track_length(track['duration']) return TrackInfo(title, track_id, artist, artist_id, length, index, medium, medium_index, artist_sort=None, disctitle=None, artist_credit=None) def get_track_index(self, position): """Returns the medium and medium index for a discogs track position. """ # medium_index is a number at the end of position. medium is everything # else. E.g. (A)(1), (Side A, Track )(1), (A)(), ()(1), etc. match = re.match(r'^(.*?)(\d*)$', position.upper()) if match: medium, index = match.groups() else: log.debug('Invalid discogs position: %s' % position) medium = index = None return medium or None, index or None def get_track_length(self, duration): """Returns the track length in seconds for a discogs duration. """ try: length = time.strptime(duration, '%M:%S') except ValueError: return None return length.tm_min * 60 + length.tm_sec beets-1.3.1/beetsplug/duplicates.py0000644000076500000240000000765212203275653020302 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, Pedro Silva. # # 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 duplicate tracks or albums. """ import logging from beets.plugins import BeetsPlugin from beets.ui import decargs, print_obj, Subcommand PLUGIN = 'duplicates' log = logging.getLogger('beets') def _group_by_id(objs): """Return a dictionary whose keys are MBIDs and whose values are lists of objects (Albums or Items) with that ID. """ import collections counts = collections.defaultdict(list) for obj in objs: mbid = getattr(obj, 'mb_trackid', obj.mb_albumid) counts[mbid].append(obj) return counts def _duplicates(objs, full): """Generate triples of MBIDs, duplicate counts, and constituent objects. """ offset = 0 if full else 1 for mbid, objs in _group_by_id(objs).iteritems(): if len(objs) > 1: yield (mbid, len(objs) - offset, objs[offset:]) class DuplicatesPlugin(BeetsPlugin): """List duplicate tracks or albums """ def __init__(self): super(DuplicatesPlugin, self).__init__() self.config.add({'format': ''}) self.config.add({'count': False}) self.config.add({'album': False}) self.config.add({'full': False}) self._command = Subcommand('duplicates', help=__doc__, aliases=['dup']) self._command.parser.add_option('-f', '--format', dest='format', action='store', type='string', help='print with custom FORMAT', metavar='FORMAT') self._command.parser.add_option('-c', '--count', dest='count', action='store_true', help='count duplicate tracks or\ albums') self._command.parser.add_option('-a', '--album', dest='album', action='store_true', help='show duplicate albums instead\ of tracks') self._command.parser.add_option('-F', '--full', dest='full', action='store_true', help='show all versions of duplicate\ tracks or albums') def commands(self): def _dup(lib, opts, args): self.config.set_args(opts) fmt = self.config['format'].get() count = self.config['count'].get() album = self.config['album'].get() full = self.config['full'].get() if album: items = lib.albums(decargs(args)) else: items = lib.items(decargs(args)) # Default format string for count mode. if count and not fmt: if album: fmt = '$albumartist - $album' else: fmt = '$albumartist - $album - $title' fmt += ': {0}' for obj_id, obj_count, objs in _duplicates(items, full): if obj_id: # Skip empty IDs. for o in objs: print_obj(o, lib, fmt=fmt.format(obj_count)) self._command.func = _dup return [self._command] beets-1.3.1/beetsplug/echonest_tempo.py0000644000076500000240000001200512215715441021142 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, David Brenner # # 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 tempo (bpm) for imported music from the EchoNest API. Requires the pyechonest library (https://github.com/echonest/pyechonest). """ import time import logging from beets.plugins import BeetsPlugin from beets import ui from beets import config import pyechonest.config import pyechonest.song import socket # Global logger. log = logging.getLogger('beets') RETRY_INTERVAL = 10 # Seconds. RETRIES = 10 def fetch_item_tempo(lib, loglevel, item, write): """Fetch and store tempo for a single item. If ``write``, then the tempo will also be written to the file itself in the bpm field. The ``loglevel`` parameter controls the visibility of the function's status log messages. """ # Skip if the item already has the tempo field. if item.bpm: log.log(loglevel, u'bpm already present: %s - %s' % (item.artist, item.title)) return # Fetch tempo. tempo = get_tempo(item.artist, item.title) if not tempo: log.log(loglevel, u'tempo not found: %s - %s' % (item.artist, item.title)) return log.log(loglevel, u'fetched tempo: %s - %s' % (item.artist, item.title)) item.bpm = tempo if write: item.write() item.store() def get_tempo(artist, title): """Get the tempo for a song.""" # We must have sufficient metadata for the lookup. Otherwise the API # will just complain. artist = artist.replace(u'\n', u' ').strip() title = title.replace(u'\n', u' ').strip() if not artist or not title: return None for i in range(RETRIES): try: # Unfortunately, all we can do is search by artist and title. # EchoNest supports foreign ids from MusicBrainz, but currently # only for artists, not individual tracks/recordings. results = pyechonest.song.search( artist=artist, title=title, results=1, buckets=['audio_summary'] ) except pyechonest.util.EchoNestAPIError as e: if e.code == 3: # Wait and try again. time.sleep(RETRY_INTERVAL) else: log.warn(u'echonest_tempo: {0}'.format(e.args[0][0])) return None except (pyechonest.util.EchoNestIOError, socket.error) as e: log.debug(u'echonest_tempo: IO error: {0}'.format(e)) time.sleep(RETRY_INTERVAL) else: break else: # If we exited the loop without breaking, then we used up all # our allotted retries. log.debug(u'echonest_tempo: exceeded retries') return None # The Echo Nest API can return songs that are not perfect matches. # So we look through the results for songs that have the right # artist and title. The API also doesn't have MusicBrainz track IDs; # otherwise we could use those for a more robust match. for result in results: if result.artist_name == artist and result.title == title: return results[0].audio_summary['tempo'] class EchoNestTempoPlugin(BeetsPlugin): def __init__(self): super(EchoNestTempoPlugin, self).__init__() self.import_stages = [self.imported] self.config.add({ 'apikey': u'NY2KTZHQ0QDSHBAP6', 'auto': True, }) pyechonest.config.ECHO_NEST_API_KEY = \ self.config['apikey'].get(unicode) def commands(self): cmd = ui.Subcommand('tempo', help='fetch song tempo (bpm)') cmd.parser.add_option('-p', '--print', dest='printbpm', action='store_true', default=False, help='print tempo (bpm) to console') 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)): fetch_item_tempo(lib, logging.INFO, item, write) if opts.printbpm and item.bpm: ui.print_('{0} BPM'.format(item.bpm)) cmd.func = func return [cmd] # Auto-fetch tempo on import. def imported(self, config, task): if self.config['auto']: for item in task.imported_items(): fetch_item_tempo(config.lib, logging.DEBUG, item, False) beets-1.3.1/beetsplug/embedart.py0000644000076500000240000001472612214741277017732 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Allows beets to embed album art into file metadata.""" import logging import imghdr from beets.plugins import BeetsPlugin from beets import mediafile from beets import ui from beets.ui import decargs from beets.util import syspath, normpath, displayable_path from beets.util.artresizer import ArtResizer from beets import config log = logging.getLogger('beets') def _embed(path, items, maxwidth=0): """Embed an image file, located at `path`, into each item. """ if maxwidth: path = ArtResizer.shared.resize(maxwidth, syspath(path)) data = open(syspath(path), 'rb').read() kindstr = imghdr.what(None, data) if kindstr is None: log.error(u'Could not embed art of unkown type: {0}'.format( displayable_path(path) )) return elif kindstr not in ('jpeg', 'png'): log.error(u'Image type {0} is not allowed as cover art: {1}'.format( kindstr, displayable_path(path) )) return # Add art to each file. log.debug('Embedding album art.') for item in items: try: f = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: log.warn('Could not embed art in {0}: {1}'.format( displayable_path(item.path), exc )) continue f.art = data f.save() class EmbedCoverArtPlugin(BeetsPlugin): """Allows albumart to be embedded into the actual files. """ def __init__(self): super(EmbedCoverArtPlugin, self).__init__() self.config.add({ 'maxwidth': 0, 'auto': True, }) if self.config['maxwidth'].get(int) and \ not ArtResizer.shared.local: self.config['maxwidth'] = 0 log.warn("embedart: ImageMagick or PIL not found; " "'maxwidth' option ignored") def commands(self): # Embed command. embed_cmd = ui.Subcommand('embedart', help='embed image files into file metadata') embed_cmd.parser.add_option('-f', '--file', metavar='PATH', help='the image file to embed') def embed_func(lib, opts, args): if opts.file: imagepath = normpath(opts.file) embed(lib, imagepath, decargs(args)) else: embed_current(lib, decargs(args)) embed_cmd.func = embed_func # Extract command. extract_cmd = ui.Subcommand('extractart', help='extract an image from file metadata') extract_cmd.parser.add_option('-o', dest='outpath', help='image output file') def extract_func(lib, opts, args): outpath = normpath(opts.outpath or 'cover') extract(lib, outpath, decargs(args)) extract_cmd.func = extract_func # Clear command. clear_cmd = ui.Subcommand('clearart', help='remove images from file metadata') def clear_func(lib, opts, args): clear(lib, decargs(args)) clear_cmd.func = clear_func return [embed_cmd, extract_cmd, clear_cmd] # "embedart" command with --file argument. def embed(lib, imagepath, query): albums = lib.albums(query) for i_album in albums: album = i_album break else: log.error('No album matches query.') return log.info(u'Embedding album art into {0.albumartist} - {0.album}.'.format( album )) _embed(imagepath, album.items(), config['embedart']['maxwidth'].get(int)) # "embedart" command without explicit file. def embed_current(lib, query): albums = lib.albums(query) for album in albums: if not album.artpath: log.info(u'No album art present: {0} - {1}'. format(album.albumartist, album.album)) continue log.info(u'Embedding album art into {0} - {1}'. format(album.albumartist, album.album)) _embed(album.artpath, album.items(), config['embedart']['maxwidth'].get(int)) # "extractart" command. def extract(lib, outpath, query): items = lib.items(query) for i_item in items: item = i_item break else: log.error('No item matches query.') return # Extract the art. try: mf = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: log.error(u'Could not extract art from {0}: {1}'.format( displayable_path(item.path), exc )) return art = mf.art if not art: log.error('No album art present in %s - %s.' % (item.artist, item.title)) return # Add an extension to the filename. ext = imghdr.what(None, h=art) if not ext: log.error('Unknown image type.') return outpath += '.' + ext log.info(u'Extracting album art from: {0.artist} - {0.title}\n' u'To: {1}'.format(item, displayable_path(outpath))) with open(syspath(outpath), 'wb') as f: f.write(art) # "clearart" command. def clear(lib, query): log.info('Clearing album art from items:') for item in lib.items(query): log.info(u'%s - %s' % (item.artist, item.title)) try: mf = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: log.error(u'Could not clear art from {0}: {1}'.format( displayable_path(item.path), exc )) continue mf.art = None mf.save() # Automatically embed art into imported albums. @EmbedCoverArtPlugin.listen('album_imported') def album_imported(lib, album): if album.artpath and config['embedart']['auto']: _embed(album.artpath, album.items(), config['embedart']['maxwidth'].get(int)) beets-1.3.1/beetsplug/fetchart.py0000644000076500000240000002301712207240712017726 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Fetches album art. """ import urllib import re import logging import os import tempfile from beets.plugins import BeetsPlugin from beets.util.artresizer import ArtResizer from beets import importer from beets import ui from beets import util from beets import config IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg'] CONTENT_TYPES = ('image/jpeg',) DOWNLOAD_EXTENSION = '.jpg' log = logging.getLogger('beets') def _fetch_image(url): """Downloads an image from a URL and checks whether it seems to actually be an image. If so, returns a path to the downloaded image. Otherwise, returns None. """ # Generate a temporary filename with the correct extension. fd, fn = tempfile.mkstemp(suffix=DOWNLOAD_EXTENSION) os.close(fd) log.debug(u'fetchart: downloading art: {0}'.format(url)) try: _, headers = urllib.urlretrieve(url, filename=fn) except IOError: log.debug(u'error fetching art') return # Make sure it's actually an image. if headers.gettype() in CONTENT_TYPES: log.debug(u'fetchart: downloaded art to: {0}'.format( util.displayable_path(fn) )) return fn else: log.debug(u'fetchart: not an image') # ART SOURCES ################################################################ # Cover Art Archive. CAA_URL = 'http://coverartarchive.org/release/{mbid}/front-500.jpg' CAA_GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front-500.jpg' def caa_art(release_id): """Return the Cover Art Archive URL given a MusicBrainz release ID. """ return CAA_URL.format(mbid=release_id) def caa_group_art(release_group_id): """Return the Cover Art Archive release group URL given a MusicBrainz release group ID. """ return CAA_GROUP_URL.format(mbid=release_group_id) # Art from Amazon. AMAZON_URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' AMAZON_INDICES = (1, 2) def art_for_asin(asin): """Generate URLs for an Amazon ID (ASIN) string.""" for index in AMAZON_INDICES: yield AMAZON_URL % (asin, index) # AlbumArt.org scraper. AAO_URL = 'http://www.albumart.org/index_detail.php' AAO_PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' def aao_art(asin): """Return art URL from AlbumArt.org given an ASIN.""" # Get the page from albumart.org. url = '%s?%s' % (AAO_URL, urllib.urlencode({'asin': asin})) try: log.debug(u'fetchart: scraping art URL: {0}'.format(url)) page = urllib.urlopen(url).read() except IOError: log.debug(u'fetchart: error scraping art page') return # Search the page for the image URL. m = re.search(AAO_PAT, page) if m: image_url = m.group(1) return image_url else: log.debug(u'fetchart: no image found on page') # Art from the filesystem. def art_in_path(path, cover_names, cautious): """Look for album art files in a specified directory.""" if not os.path.isdir(path): return # Find all files that look like images in the directory. images = [] for fn in os.listdir(path): for ext in IMAGE_EXTENSIONS: if fn.lower().endswith('.' + ext): images.append(fn) # Look for "preferred" filenames. cover_pat = r"(\b|_)({0})(\b|_)".format('|'.join(cover_names)) for fn in images: if re.search(cover_pat, os.path.splitext(fn)[0], re.I): log.debug(u'fetchart: using well-named art file {0}'.format( util.displayable_path(fn) )) return os.path.join(path, fn) # Fall back to any image in the folder. if images and not cautious: log.debug(u'fetchart: using fallback art file {0}'.format( util.displayable_path(images[0]) )) return os.path.join(path, images[0]) # Try each source in turn. def _source_urls(album): """Generate possible source URLs for an album's art. The URLs are not guaranteed to work so they each need to be attempted in turn. This allows the main `art_for_album` function to abort iteration through this sequence early to avoid the cost of scraping when not necessary. """ # Cover Art Archive. if album.mb_albumid: yield caa_art(album.mb_albumid) if album.mb_releasegroupid: yield caa_group_art(album.mb_releasegroupid) # Amazon and AlbumArt.org. if album.asin: for url in art_for_asin(album.asin): yield url url = aao_art(album.asin) if url: yield url def art_for_album(album, paths, maxwidth=None, local_only=False): """Given an Album object, returns a path to downloaded art for the album (or None if no art is found). If `maxwidth`, then images are resized to this maximum pixel size. If `local_only`, then only local image files from the filesystem are returned; no network requests are made. """ out = None # Local art. cover_names = config['fetchart']['cover_names'].as_str_seq() cautious = config['fetchart']['cautious'].get(bool) if paths: for path in paths: out = art_in_path(path, cover_names, cautious) if out: break # Web art sources. remote_priority = config['fetchart']['remote_priority'].get(bool) if not local_only and (remote_priority or not out): for url in _source_urls(album): if maxwidth: url = ArtResizer.shared.proxy_url(maxwidth, url) out = _fetch_image(url) if out: break if maxwidth and out: out = ArtResizer.shared.resize(maxwidth, out) return out # PLUGIN LOGIC ############################################################### def batch_fetch_art(lib, albums, force, maxwidth=None): """Fetch album art for each of the albums. This implements the manual fetchart CLI command. """ for album in albums: if album.artpath and not force: message = 'has album art' else: path = art_for_album(album, None, maxwidth) if path: album.set_art(path, False) album.store() message = 'found album art' else: message = 'no art found' log.info(u'{0} - {1}: {2}'.format(album.albumartist, album.album, message)) class FetchArtPlugin(BeetsPlugin): def __init__(self): super(FetchArtPlugin, self).__init__() self.config.add({ 'auto': True, 'maxwidth': 0, 'remote_priority': False, 'cautious': False, 'cover_names': ['cover', 'front', 'art', 'album', 'folder'], }) # Holds paths to downloaded images between fetching them and # placing them in the filesystem. self.art_paths = {} self.maxwidth = self.config['maxwidth'].get(int) if self.config['auto']: # Enable two import hooks when fetching is enabled. self.import_stages = [self.fetch_art] self.register_listener('import_task_files', self.assign_art) # Asynchronous; after music is added to the library. def fetch_art(self, session, task): """Find art for the album being imported.""" if task.is_album: # Only fetch art for full albums. if task.choice_flag == importer.action.ASIS: # For as-is imports, don't search Web sources for art. local = True elif task.choice_flag == importer.action.APPLY: # Search everywhere for art. local = False else: # For any other choices (e.g., TRACKS), do nothing. return album = session.lib.get_album(task.album_id) path = art_for_album(album, task.paths, self.maxwidth, local) if path: self.art_paths[task] = path # Synchronous; after music files are put in place. def assign_art(self, session, task): """Place the discovered art in the filesystem.""" if task in self.art_paths: path = self.art_paths.pop(task) album = session.lib.get_album(task.album_id) src_removed = config['import']['delete'].get(bool) or \ config['import']['move'].get(bool) album.set_art(path, not src_removed) album.store() if src_removed: task.prune(path) # Manual album art fetching. def commands(self): cmd = ui.Subcommand('fetchart', help='download album art') cmd.parser.add_option('-f', '--force', dest='force', action='store_true', default=False, help='re-download art when already present') def func(lib, opts, args): batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force, self.maxwidth) cmd.func = func return [cmd] beets-1.3.1/beetsplug/fromfilename.py0000644000076500000240000001236412222152643020577 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, Jan-Erik Dahlin # # 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. """If the title is empty, try to extract track and title from the filename. """ from beets import plugins from beets.util import displayable_path import os import re # Filename field extraction patterns. PATTERNS = [ # "01 - Track 01" and "01": do nothing ur'^(\d+)\s*-\s*track\s*\d$', ur'^\d+$', # Useful patterns. ur'^(?P.+)-(?P.+)-(?P<tag>.*)$', ur'^(?P<track>\d+)\s*-(?P<artist>.+)-(?P<title>.+)-(?P<tag>.*)$', ur'^(?P<track>\d+)\s(?P<artist>.+)-(?P<title>.+)-(?P<tag>.*)$', ur'^(?P<artist>.+)-(?P<title>.+)$', ur'^(?P<track>\d+)\.\s*(?P<artist>.+)-(?P<title>.+)$', ur'^(?P<track>\d+)\s*-\s*(?P<artist>.+)-(?P<title>.+)$', ur'^(?P<track>\d+)\s*-(?P<artist>.+)-(?P<title>.+)$', ur'^(?P<track>\d+)\s(?P<artist>.+)-(?P<title>.+)$', ur'^(?P<title>.+)$', ur'^(?P<track>\d+)\.\s*(?P<title>.+)$', ur'^(?P<track>\d+)\s*-\s*(?P<title>.+)$', ur'^(?P<track>\d+)\s(?P<title>.+)$', ur'^(?P<title>.+) by (?P<artist>.+)$', ] # Titles considered "empty" and in need of replacement. BAD_TITLE_PATTERNS = [ ur'^$', ur'\d+?\s?-?\s*track\s*\d+', ] def equal(seq): """Determine whether a sequence holds identical elements. """ return len(set(seq)) <= 1 def equal_fields(matchdict, field): """Do all items in `matchdict`, whose values are dictionaries, have the same value for `field`? (If they do, the field is probably not the title.) """ return equal(m[field] for m in matchdict.values()) def all_matches(names, pattern): """If all the filenames in the item/filename mapping match the pattern, return a dictionary mapping the items to dictionaries giving the value for each named subpattern in the match. Otherwise, return None. """ matches = {} for item, name in names.items(): m = re.match(pattern, name, re.IGNORECASE) if m: matches[item] = m.groupdict() else: return None return matches def bad_title(title): """Determine whether a given title is "bad" (empty or otherwise meaningless) and in need of replacement. """ for pat in BAD_TITLE_PATTERNS: if re.match(pat, title, re.IGNORECASE): return True return False def apply_matches(d): """Given a mapping from items to field dicts, apply the fields to the objects. """ some_map = d.values()[0] keys = some_map.keys() # Only proceed if the "tag" field is equal across all filenames. if 'tag' in keys and not equal_fields(d, 'tag'): return # Given both an "artist" and "title" field, assume that one is # *actually* the artist, which must be uniform, and use the other # for the title. This, of course, won't work for VA albums. if 'artist' in keys: if equal_fields(d, 'artist'): artist = some_map['artist'] title_field = 'title' elif equal_fields(d, 'title'): artist = some_map['title'] title_field = 'artist' else: # Both vary. Abort. return for item in d: if not item.artist: item.artist = artist # No artist field: remaining field is the title. else: title_field = 'title' # Apply the title and track. for item in d: if bad_title(item.title): item.title = unicode(d[item][title_field]) if 'track' in d[item] and item.track == 0: item.track = int(d[item]['track']) # Plugin structure and hook into import process. class FromFilenamePlugin(plugins.BeetsPlugin): pass @FromFilenamePlugin.listen('import_task_start') def filename_task(task, session): """Examine each item in the task to see if we can extract a title from the filename. Try to match all filenames to a number of regexps, starting with the most complex patterns and successively trying less complex patterns. As soon as all filenames match the same regex we can make an educated guess of which part of the regex that contains the title. """ items = task.items if task.is_album else [task.item] # Look for suspicious (empty or meaningless) titles. missing_titles = sum(bad_title(i.title) for i in items) if missing_titles: # Get the base filenames (no path or extension). names = {} for item in items: path = displayable_path(item.path) name, _ = os.path.splitext(os.path.basename(path)) names[item] = name # Look for useful information in the filenames. for pattern in PATTERNS: d = all_matches(names, pattern) if d: apply_matches(d) ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/ftintitle.py������������������������������������������������������������������0000644�0000765�0000024�00000010622�12216144755�020140� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, Verrus, <github.com/Verrus/beets-plugin-featInTitle> # # 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 "featured" artists to the title from the artist field. """ from beets.plugins import BeetsPlugin from beets import ui from beets.util import displayable_path from beets import config import re def split_on_feat(artist): """Given an artist string, split the "main" artist from any artist on the right-hand side of a string like "feat". Return the main artist, which is always a string, and the featuring artist, which may be a string or None if none is present. """ parts = re.split( r'[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.|and', artist, 1, # Only split on the first "feat". ) parts = [s.strip() for s in parts] if len(parts) == 1: return parts[0], None else: return parts def contains_feat(title): """Determine whether the title contains a "featured" marker. """ return bool(re.search( r'[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&', title, )) def update_metadata(item, feat_part): """Choose how to add new artists to the title and write the new metadata. Also, print out messages about any changes that are made. """ # In all cases, update the artist fields. ui.print_(u'artist: {0} -> {1}'.format(item.artist, item.albumartist)) item.artist = item.albumartist item.artist_sort, _ = split_on_feat(item.artist_sort) # Strip featured. # Only update the title if it does not already contain a featured # artist. if not contains_feat(item.title): new_title = u"{0} feat. {1}".format(item.title, feat_part) ui.print_(u'title: {0} -> {1}'.format(item.title, new_title)) item.title = new_title def ft_in_title(item): """Look for featured artists in the item's artist fields and move them to the title. """ artist = item.artist.strip() albumartist = item.albumartist.strip() # Check whether there is a featured artist on this track and the # artist field does not exactly match the album artist field. In # that case, we attempt to move the featured artist to the title. _, featured = split_on_feat(artist) if featured and albumartist != artist: ui.print_(displayable_path(item.path)) feat_part = None # Look for the album artist in the artist field. If it's not # present, give up. albumartist_split = artist.split(albumartist) if len(albumartist_split) <= 1: ui.print_('album artist not present in artist') # If the last element of the split (the right-hand side of the # album artist) is nonempty, then it probably contains the # featured artist. elif albumartist_split[-1] != '': # Extract the featured artist from the right-hand side. _, feat_part = split_on_feat(albumartist_split[-1]) # Otherwise, if there's nothing on the right-hand side, look for a # featuring artist on the left-hand side. else: lhs, rhs = split_on_feat(albumartist_split[0]) if rhs: feat_part = lhs # If we have a featuring artist, move it to the title. if feat_part: update_metadata(item, feat_part) else: ui.print_(u'no featuring artists found') ui.print_() class FtInTitlePlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('ftintitle', help='move featured artists to the title field') def func(lib, opts, args): write = config['import']['write'].get(bool) for item in lib.items(ui.decargs(args)): ft_in_title(item) item.store() if write: item.write() cmd.func = func return [cmd] ��������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/fuzzy.py����������������������������������������������������������������������0000644�0000765�0000024�00000002701�12207240712�017312� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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. """Provides a fuzzy matching query. """ from beets.plugins import BeetsPlugin from beets.library import FieldQuery import beets import difflib class FuzzyQuery(FieldQuery): @classmethod def value_match(self, pattern, val): # smartcase if pattern.islower(): val = val.lower() queryMatcher = difflib.SequenceMatcher(None, pattern, val) threshold = beets.config['fuzzy']['threshold'].as_number() return queryMatcher.quick_ratio() >= threshold class FuzzyPlugin(BeetsPlugin): def __init__(self): super(FuzzyPlugin, self).__init__() self.config.add({ 'prefix': '~', 'threshold': 0.7, }) def queries(self): prefix = beets.config['fuzzy']['prefix'].get(basestring) return {prefix: FuzzyQuery} ���������������������������������������������������������������beets-1.3.1/beetsplug/ihate.py����������������������������������������������������������������������0000644�0000765�0000024�00000010666�12102547525�017234� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, Blemjhoo Tezoulbr <baobab@heresiarch.info>. # # 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. """Warns you about things you hate (or even blocks import).""" import re import logging from beets.plugins import BeetsPlugin from beets.importer import action __author__ = 'baobab@heresiarch.info' __version__ = '1.0' class IHatePlugin(BeetsPlugin): _instance = None _log = logging.getLogger('beets') warn_genre = [] warn_artist = [] warn_album = [] warn_whitelist = [] skip_genre = [] skip_artist = [] skip_album = [] skip_whitelist = [] def __init__(self): super(IHatePlugin, self).__init__() self.register_listener('import_task_choice', self.import_task_choice_event) self.config.add({ 'warn_genre': [], 'warn_artist': [], 'warn_album': [], 'warn_whitelist': [], 'skip_genre': [], 'skip_artist': [], 'skip_album': [], 'skip_whitelist': [], }) @classmethod def match_patterns(cls, s, patterns): """Check if string is matching any of the patterns in the list.""" for p in patterns: if re.findall(p, s, flags=re.IGNORECASE): return True return False @classmethod def do_i_hate_this(cls, task, genre_patterns, artist_patterns, album_patterns, whitelist_patterns): """Process group of patterns (warn or skip) and returns True if task is hated and not whitelisted. """ hate = False try: genre = task.items[0].genre except: genre = u'' if genre and genre_patterns: if cls.match_patterns(genre, genre_patterns): hate = True if not hate and task.cur_album and album_patterns: if cls.match_patterns(task.cur_album, album_patterns): hate = True if not hate and task.cur_artist and artist_patterns: if cls.match_patterns(task.cur_artist, artist_patterns): hate = True if hate and whitelist_patterns: if cls.match_patterns(task.cur_artist, whitelist_patterns): hate = False return hate def job_to_do(self): """Return True if at least one pattern is defined.""" return any(self.config[l].as_str_seq() for l in ('warn_genre', 'warn_artist', 'warn_album', 'skip_genre', 'skip_artist', 'skip_album')) def import_task_choice_event(self, session, task): if task.choice_flag == action.APPLY: if self.job_to_do(): self._log.debug('[ihate] processing your hate') if self.do_i_hate_this(task, self.config['skip_genre'].as_str_seq(), self.config['skip_artist'].as_str_seq(), self.config['skip_album'].as_str_seq(), self.config['skip_whitelist'].as_str_seq()): task.choice_flag = action.SKIP self._log.info(u'[ihate] skipped: {0} - {1}' .format(task.cur_artist, task.cur_album)) return if self.do_i_hate_this(task, self.config['warn_genre'].as_str_seq(), self.config['warn_artist'].as_str_seq(), self.config['warn_album'].as_str_seq(), self.config['warn_whitelist'].as_str_seq()): self._log.info(u'[ihate] you maybe hate this: {0} - {1}' .format(task.cur_artist, task.cur_album)) else: self._log.debug('[ihate] nothing to do') else: self._log.debug('[ihate] user made a decision, nothing to do') ��������������������������������������������������������������������������beets-1.3.1/beetsplug/importfeeds.py����������������������������������������������������������������0000644�0000765�0000024�00000010462�12121146403�020444� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, Fabrice Laporte. # # 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. """Write paths of imported files in various formats to ease later import in a music player. """ import datetime import os import re from beets.plugins import BeetsPlugin from beets.util import normpath, syspath, bytestring_path from beets import config M3U_DEFAULT_NAME = 'imported.m3u' class ImportFeedsPlugin(BeetsPlugin): def __init__(self): super(ImportFeedsPlugin, self).__init__() self.config.add({ 'formats': [], 'm3u_name': u'imported.m3u', 'dir': None, 'relative_to': None, 'absolute_path': False, }) feeds_dir = self.config['dir'].get() if feeds_dir: feeds_dir = os.path.expanduser(bytestring_path(feeds_dir)) self.config['dir'] = feeds_dir if not os.path.exists(syspath(feeds_dir)): os.makedirs(syspath(feeds_dir)) relative_to = self.config['relative_to'].get() if relative_to: self.config['relative_to'] = normpath(relative_to) else: self.config['relative_to'] = feeds_dir def _get_feeds_dir(lib): """Given a Library object, return the path to the feeds directory to be used (either in the library directory or an explicitly configured path). Ensures that the directory exists. """ # Inside library directory. dirpath = lib.directory # Ensure directory exists. if not os.path.exists(syspath(dirpath)): os.makedirs(syspath(dirpath)) return dirpath def _build_m3u_filename(basename): """Builds unique m3u filename by appending given basename to current date.""" basename = re.sub(r"[\s,'\"]", '_', basename) date = datetime.datetime.now().strftime("%Y%m%d_%Hh%M") path = normpath(os.path.join( config['importfeeds']['dir'].as_filename(), date + '_' + basename + '.m3u' )) return path def _write_m3u(m3u_path, items_paths): """Append relative paths to items into m3u file. """ with open(syspath(m3u_path), 'a') as f: for path in items_paths: f.write(path + '\n') def _record_items(lib, basename, items): """Records relative paths to the given items for each feed format """ feedsdir = bytestring_path(config['importfeeds']['dir'].as_filename()) formats = config['importfeeds']['formats'].as_str_seq() relative_to = config['importfeeds']['relative_to'].get() \ or config['importfeeds']['dir'].as_filename() relative_to = bytestring_path(relative_to) paths = [] for item in items: if config['importfeeds']['absolute_path']: paths.append(item.path) else: paths.append(os.path.relpath( item.path, relative_to )) if 'm3u' in formats: basename = bytestring_path( config['importfeeds']['m3u_name'].get(unicode) ) m3u_path = os.path.join(feedsdir, basename) _write_m3u(m3u_path, paths) if 'm3u_multi' in formats: m3u_path = _build_m3u_filename(basename) _write_m3u(m3u_path, paths) if 'link' in formats: for path in paths: dest = os.path.join(feedsdir, os.path.basename(path)) if not os.path.exists(syspath(dest)): os.symlink(syspath(path), syspath(dest)) @ImportFeedsPlugin.listen('library_opened') def library_opened(lib): if config['importfeeds']['dir'].get() is None: config['importfeeds']['dir'] = _get_feeds_dir(lib) @ImportFeedsPlugin.listen('album_imported') def album_imported(lib, album): _record_items(lib, album.album, album.items()) @ImportFeedsPlugin.listen('item_imported') def item_imported(lib, item): _record_items(lib, item.title, [item]) ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/info.py�����������������������������������������������������������������������0000644�0000765�0000024�00000004315�12215712755�017073� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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. """Shows file metadata. """ import os from beets.plugins import BeetsPlugin from beets import library from beets import ui from beets import mediafile from beets import util def info(paths): # Set up fields to output. fields = [] for name, _, _, mffield in library.ITEM_FIELDS: if mffield: fields.append(name) # Line format. other_fields = ['album art'] maxwidth = max(len(name) for name in fields + other_fields) lineformat = u'{{0:>{0}}}: {{1}}'.format(maxwidth) first = True for path in paths: if not first: ui.print_() path = util.normpath(path) if not os.path.isfile(path): ui.print_(u'not a file: {0}'.format( util.displayable_path(path) )) continue ui.print_(path) try: mf = mediafile.MediaFile(path) except mediafile.UnreadableFileError: ui.print_('cannot read file: {0}'.format( util.displayable_path(path) )) continue # Basic fields. for name in fields: ui.print_(lineformat.format(name, getattr(mf, name))) # Extra stuff. ui.print_(lineformat.format('album art', mf.art is not None)) first = False class InfoPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('info', help='show file metadata') def func(lib, opts, args): if not args: raise ui.UserError('no file specified') info(args) cmd.func = func return [cmd] �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/inline.py���������������������������������������������������������������������0000644�0000765�0000024�00000007765�12207240712�017420� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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. """Allows inline path template customization code in the config file. """ import logging import traceback import itertools from beets.plugins import BeetsPlugin from beets import config log = logging.getLogger('beets') FUNC_NAME = u'__INLINE_FUNC__' class InlineError(Exception): """Raised when a runtime error occurs in an inline expression. """ def __init__(self, code, exc): super(InlineError, self).__init__( (u"error in inline path field code:\n" \ u"%s\n%s: %s") % (code, type(exc).__name__, unicode(exc)) ) def _compile_func(body): """Given Python code for a function body, return a compiled callable that invokes that code. """ body = u'def {0}():\n {1}'.format( FUNC_NAME, body.replace('\n', '\n ') ) code = compile(body, 'inline', 'exec') env = {} eval(code, env) return env[FUNC_NAME] def compile_inline(python_code, album): """Given a Python expression or function body, compile it as a path field function. The returned function takes a single argument, an Item, and returns a Unicode string. If the expression cannot be compiled, then an error is logged and this function returns None. """ # First, try compiling as a single function. try: code = compile(u'({0})'.format(python_code), 'inline', 'eval') except SyntaxError: # Fall back to a function body. try: func = _compile_func(python_code) except SyntaxError: log.error(u'syntax error in inline field definition:\n%s' % traceback.format_exc()) return else: is_expr = False else: is_expr = True def _dict_for(obj): out = dict(obj) if album: out['items'] = list(obj.items()) return out if is_expr: # For expressions, just evaluate and return the result. def _expr_func(obj): values = _dict_for(obj) try: return eval(code, values) except Exception as exc: raise InlineError(python_code, exc) return _expr_func else: # For function bodies, invoke the function with values as global # variables. def _func_func(obj): func.__globals__.update(_dict_for(obj)) try: return func() except Exception as exc: raise InlineError(python_code, exc) return _func_func class InlinePlugin(BeetsPlugin): def __init__(self): super(InlinePlugin, self).__init__() config.add({ 'pathfields': {}, # Legacy name. 'item_fields': {}, 'album_fields': {}, }) # Item fields. for key, view in itertools.chain(config['item_fields'].items(), config['pathfields'].items()): log.debug(u'inline: adding item field %s' % key) func = compile_inline(view.get(unicode), False) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config['album_fields'].items(): log.debug(u'inline: adding album field %s' % key) func = compile_inline(view.get(unicode), True) if func is not None: self.album_template_fields[key] = func �����������beets-1.3.1/beetsplug/lastgenre/��������������������������������������������������������������������0000755�0000765�0000024�00000000000�12226377756�017561� 5����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/lastgenre/__init__.py���������������������������������������������������������0000644�0000765�0000024�00000030241�12224317033�021646� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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. """Gets genres for imported music based on Last.fm tags. Uses a provided whitelist file to determine which tags are valid genres. The genre whitelist can be specified like so in .beetsconfig: [lastgenre] whitelist=/path/to/genres.txt The included (default) genre list was produced by scraping Wikipedia. The scraper script used is available here: https://gist.github.com/1241307 """ import logging import pylast import os import yaml from beets import plugins from beets import ui from beets.util import normpath, plurality from beets import config from beets import library log = logging.getLogger('beets') LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) C14N_TREE = os.path.join(os.path.dirname(__file__), 'genres-tree.yaml') PYLAST_EXCEPTIONS = ( pylast.WSError, pylast.MalformedResponseError, pylast.NetworkError, ) # Core genre identification routine. def _tags_for(obj): """Given a pylast entity (album or track), returns a list of tag names for that entity. Returns an empty list if the entity is not found or another error occurs. """ try: res = obj.get_top_tags() except PYLAST_EXCEPTIONS as exc: log.debug(u'last.fm error: %s' % unicode(exc)) return [] tags = [] for el in res: if isinstance(el, pylast.TopItem): el = el.item tags.append(el.get_name()) log.debug(u'last.fm tags: %s' % unicode(tags)) return tags def _is_allowed(genre): """Determine whether the genre is present in the whitelist, returning a boolean. """ if genre is None: return False if genre.lower() in options['whitelist']: return True return False def _find_allowed(genres): """Given a list of candidate genres (strings), return an allowed genre string. If `multiple` is on, then this may be a comma-separated list; otherwise, it is one of the elements of `genres`. """ allowed_genres = [g.title() for g in genres if _is_allowed(g)] if not allowed_genres: return if config['lastgenre']['multiple']: return u', '.join(allowed_genres) else: return allowed_genres[0] def _strings_to_genre(tags): """Given a list of strings, return a genre. Returns the first string that is present in the genre whitelist (or the canonicalization tree) or None if no tag is suitable. """ if not tags: return None elif not options['whitelist']: return tags[0].title() if options.get('c14n'): # Use the canonicalization tree. for tag in tags: return _find_allowed(find_parents(tag, options['branches'])) else: # Just use the flat whitelist. return _find_allowed(tags) def fetch_genre(lastfm_obj): """Return the genre for a pylast entity or None if no suitable genre can be found. Ex. 'Electronic, House, Dance' """ return _strings_to_genre(_tags_for(lastfm_obj)) # Canonicalization tree processing. def flatten_tree(elem, path, branches): """Flatten nested lists/dictionaries into lists of strings (branches). """ if not path: path = [] if isinstance(elem, dict): for (k, v) in elem.items(): flatten_tree(v, path + [k], branches) elif isinstance(elem, list): for sub in elem: flatten_tree(sub, path, branches) else: branches.append(path + [unicode(elem)]) def find_parents(candidate, branches): """Find parents genre of a given genre, ordered from the closest to the further parent. """ for branch in branches: try: idx = branch.index(candidate.lower()) return list(reversed(branch[:idx + 1])) except ValueError: continue return [candidate] # Cached entity lookups. _genre_cache = {} def _cached_lookup(entity, method, *args): """Get a genre based on the named entity using the callable `method` whose arguments are given in the sequence `args`. The genre lookup is cached based on the entity name and the arguments. """ # Shortcut if we're missing metadata. if any(not s for s in args): return None key = u'{0}.{1}'.format(entity, u'-'.join(unicode(a) for a in args)) if key in _genre_cache: return _genre_cache[key] else: genre = fetch_genre(method(*args)) _genre_cache[key] = genre return genre def fetch_album_genre(obj): """Return the album genre for this Item or Album. """ return _cached_lookup(u'album', LASTFM.get_album, obj.albumartist, obj.album) def fetch_album_artist_genre(obj): """Return the album artist genre for this Item or Album. """ return _cached_lookup(u'artist', LASTFM.get_artist, obj.albumartist) def fetch_artist_genre(item): """Returns the track artist genre for this Item. """ return _cached_lookup(u'artist', LASTFM.get_artist, item.artist) def fetch_track_genre(obj): """Returns the track genre for this Item. """ return _cached_lookup(u'track', LASTFM.get_track, obj.artist, obj.title) # Main plugin logic. options = { 'whitelist': None, 'branches': None, 'c14n': False, } class LastGenrePlugin(plugins.BeetsPlugin): def __init__(self): super(LastGenrePlugin, self).__init__() self.config.add({ 'whitelist': os.path.join(os.path.dirname(__file__), 'genres.txt'), 'multiple': False, 'fallback': None, 'canonical': None, 'source': 'album', 'force': True, 'auto': True, }) if self.config['auto']: self.import_stages = [self.imported] # Read the whitelist file. wl_filename = self.config['whitelist'].as_filename() whitelist = set() with open(wl_filename) as f: for line in f: line = line.decode('utf8').strip().lower() if line: whitelist.add(line) options['whitelist'] = whitelist # Read the genres tree for canonicalization if enabled. c14n_filename = self.config['canonical'].get() if c14n_filename is not None: c14n_filename = c14n_filename.strip() if not c14n_filename: c14n_filename = C14N_TREE c14n_filename = normpath(c14n_filename) genres_tree = yaml.load(open(c14n_filename, 'r')) branches = [] flatten_tree(genres_tree, [], branches) options['branches'] = branches options['c14n'] = True @property def sources(self): """A tuple of allowed genre sources. May contain 'track', 'album', or 'artist.' """ source = self.config['source'].as_choice(('track', 'album', 'artist')) if source == 'track': return 'track', 'album', 'artist' elif source == 'album': return 'album', 'artist' elif source == 'artist': return 'artist', def _get_genre(self, obj): """Get the genre string for an Album or Item object based on self.sources. Return a `(genre, source)` pair. The prioritization order is: - track (for Items only) - album - artist - original - fallback - None """ # Shortcut to existing genre if not forcing. if not self.config['force'] and _is_allowed(obj.genre): return obj.genre, 'keep' # Track genre (for Items only). if isinstance(obj, library.Item): if 'track' in self.sources: result = fetch_track_genre(obj) if result: return result, 'track' # Album genre. if 'album' in self.sources: result = fetch_album_genre(obj) if result: return result, 'album' # Artist (or album artist) genre. if 'artist' in self.sources: result = None if isinstance(obj, library.Item): result = fetch_artist_genre(obj) elif obj.albumartist != 'Various Artists': result = fetch_album_artist_genre(obj) else: # For "Various Artists", pick the most popular track genre. item_genres = [] for item in obj.items(): item_genre = None if 'track' in self.sources: item_genre = fetch_track_genre(item) if not item_genre: item_genre = fetch_artist_genre(item) if item_genre: item_genres.append(item_genre) if item_genres: result, _ = plurality(item_genres) if result: return result, 'artist' # Filter the existing genre. if obj.genre: result = _strings_to_genre([obj.genre]) if result: return result, 'original' # Fallback string. fallback = self.config['fallback'].get() if fallback: return fallback, 'fallback' return None, None def commands(self): lastgenre_cmd = ui.Subcommand('lastgenre', help='fetch genres') lastgenre_cmd.parser.add_option('-f', '--force', dest='force', action='store_true', default=False, help='re-download genre when already present') lastgenre_cmd.parser.add_option('-s', '--source', dest='source', type='string', help='genre source: artist, album, or track') def lastgenre_func(lib, opts, args): write = config['import']['write'].get(bool) self.config.set_args(opts) for album in lib.albums(ui.decargs(args)): album.genre, src = self._get_genre(album) log.info(u'genre for album {0} - {1} ({2}): {3}'.format( album.albumartist, album.album, src, album.genre )) album.store() for item in album.items(): # If we're using track-level sources, also look up each # track on the album. if 'track' in self.sources: item.genre, src = self._get_genre(item) item.store() log.info(u'genre for track {0} - {1} ({2}): {3}'.format( item.artist, item.title, src, item.genre )) if write: item.write() lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] def imported(self, session, task): """Event hook called when an import task finishes.""" if task.is_album: album = session.lib.get_album(task.album_id) album.genre, src = self._get_genre(album) log.debug(u'added last.fm album genre ({0}): {1}'.format( src, album.genre)) album.store() if 'track' in self.sources: for item in album.items(): item.genre, src = self._get_genre(item) log.debug(u'added last.fm item genre ({0}): {1}'.format( src, item.genre)) item.store() else: item = task.item item.genre, src = self._get_genre(item) log.debug(u'added last.fm item genre ({0}): {1}'.format( src, item.genre)) item.store() ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/lastgenre/genres-tree.yaml����������������������������������������������������0000644�0000765�0000024�00000023147�12013011113�022632� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������- blues: - african blues - blues rock: - punk blues - country blues - british blues - classic female blues - country blues: - delta blues: - electric blues: - blues rock - east coast blues - hill country blues - jump blues - swamp blues - new orleans blues - dirty blues - fife and drum blues - gospel blues - harmonica blues - indian blues - piano blues: - boogie-woogie: - boogie rock - soul blues - west coast blues - country: - alternative country: - americana - blues country - cowpunk - country rock - country folk - country rap: - country crunk: - country psycrunk - deathcountry - gothic americana - hellbilly - nashville sound: - country pop - outlaw country - psychobilly - punkabilly - psydeco - red dirt - rockabilly - rock country - soul country - techno-country - texas country: - progressive country - bakersfield sound - bluegrass: - heavy metal bluegrass - nu-grass - old-time bluegrass - progressive bluegrass - reactionary bluegrass - christian country music - neotraditional country - western music: - honky tonk - easy listening: - background music - beautiful music - elevator music - furniture music - lounge music - electronic: - ambient: - ambient house - ambient techno - dark ambient - drone music - illbient - isolationism - lowercase - breakbeat: - acid breaks - baltimore club - big beat - breakbeat hardcore - broken beat - florida breaks - nu skool breaks - 4-beat - dance: - eurodance - hi-nrg - house: - acid house - autumn house - chicago house - deep house - diva house - fidget house - french house - freestyle house - funky house - ghetto house - hardbag - hip house - italo house - latin house - minimal house - microhouse - rave music - swing house - tech house - tribal house - uk hard house - us garage - vocal house - electronica: - berlin school - bitpop - chip - chillwave - downtempo: - acid jazz - balearic beat - chill out - dubtronica - ethnic electronica - moombahton - new age music - nu jazz - trip hop - folktronica - glitch - idm - plinkerpop - hardcore techno: - bouncy house - bouncy techno - breakcore - darkcore - digital hardcore - doomcore - gabba - happy hardcore - hardstyle - jumpstyle - makina - speedcore - terrorcore - uk hardcore - jungle: - drum and bass: - clownstep - darkcore - darkstep - drumfunk - drumstep - hardstep - intelligent drum and bass - jump-up - liquid funk - neurofunk - oldschool jungle: - darkside jungle - ragga-jungle - raggacore - sambass - trancestep - progressive: - progressive breaks - progressive drum & bass - progressive house: - disco house - dream house - space house - progressive techno - techno: - acid techno - detroit techno - free tekno - ghettotech - minimal - nortec - rotterdam techno - schranz / hardtechno - symphonic techno - tecno brega - techno-dnb - techstep - toytown techno - trance: - acid trance - classic trance - dream trance - euro-trance - goa trance: - dark psytrance - full on - psyprog - psybient - psybreaks - hard trance - neo-trance - tech trance - uplifting trance: - orchestral uplifting - vocal trance: - nightcore - uk garage: - 2-step - 4x4 - bassline - breakstep - dubstep - funky - grime - speed garage - folk: - folk punk - indie folk - neofolk - progressive folk - anti-folk - hip hop & rap: - hip hop: - alternative hip hop: - jazz rap - abstract hip hop - british hip hop: - grime - political hip hop - turntablism - us hip hop: - east coast hip hop: - conscious rap - midwest hip hop: - ghetto house - ghettotech - horrorcore - southern hip hop: - snap music - bounce music - crunk - chopped and screwed - west coast hip hop: - gangsta rap: - g-funk - latin rap - g-funk - hyphy - jerkin' - rap: - bass rap - dirty rap - hardcore rap - mafioso rap - party rap - porn rap - rap metal - rap rock - rapcore - latin: - bachata - brazilian music: - samba: - bossa nova: - tropicalismo - calypso - chutney: - chutney soca - cuban music: - salsa - son cubano - cumbia - kompa - mambo: - cha-cha-cha - pachanga - merengue - salsa - soca - tejano - zouk - pop: - electro pop - new romantic - operatic pop - pop rap - psychedelic pop - sunshine pop - surf pop - synthpop - teen pop - traditional pop music - turkish pop - world: - europop - indian pop - latin pop - r&b: - contemporary r&b - doo wop - funkd: - deep funk - disco: - eurodisco - disco polo - post disco - space disco - boogie - go-go - nu-funk - p-funk - new jack swing - soul: - blue-eyed soul - brown-eyed soul - hip hop soul - motown sound - neo soul - northern soul - psychedelic soul - smooth soul - quiet storm - rock: - alternative rock: - britpop: - post-britpop - dream pop - grunge: - post-grunge - indie pop - indie rock - industrial rock - noise pop - post-rock - shoegazer - slowcore - blues-rock - chinese rock - dark cabaret - desert rock - electronic rock - folk rock - garage rock - glam rock - hard rock - heavy metal: - black metal - christian metal - death metal: - brutal death metal - melodic death metal - technical death metal - progressive death metal - doom metal - drone metal - folk metal - funk metal - glam metal - gothic metal - grindcore - groove metal - industrial metal - metalcore: - deathcore - mathcore - melodic metalcore - djent - nu metal - power metal - progressive metal - rap metal - sludge metal - speed metal - stoner rock - symphonic metal - thrash metal: - crossover thrash metal - thrashcore - weld - unblack metal - jazz-rock - j-rock - math rock - new wave: - world fusion - paisley underground - pop rock - power pop - progressive rock: - new prog - space rock - psychedelic rock: - acid rock - punk rock: - anarcho punk: - crust punk - deathrock - hardcore punk: - post-hardcore - emo: - screamo - pop punk - post-punk: - gothic rock - psychobilly - rap rock: - rapcore - rock and roll - soft rock - southern rock - surf rock - ska: - 2 tone - reggae: - early reggae - dub: - dub poetry - afro-dub - rockers - lovers rock - dancehall: - ragga - reggaeton - raggamuffin - roots reggae - rocksteady �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/lastgenre/genres.txt����������������������������������������������������������0000644�0000765�0000024�00000041275�12013011113�021554� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������2 tone 2-step garage 4-beat 4x4 garage 8-bit acapella acid acid breaks acid house acid jazz acid rock acoustic music acousticana adult contemporary music african popular music african rumba afrobeat aleatoric music alternative country alternative dance alternative hip hop alternative metal alternative rock ambient ambient house ambient music americana anarcho punk anime music anti-folk apala ape haters arab pop arabesque arabic pop argentine rock ars antiqua ars nova art punk art rock ashiq asian american jazz australian country music australian hip hop australian pub rock austropop avant-garde avant-garde jazz avant-garde metal avant-garde music axé bac-bal bachata background music baggy baila baile funk baisha xiyue baithak gana baião bajourou bakersfield sound bakou bakshy bal-musette balakadri balinese gamelan balkan pop ballad ballata ballet bamboo band bambuco banda bangsawan bantowbol barbershop music barndance baroque music baroque pop bass music batcave batucada batuco batá-rumba beach music beat beatboxing beautiful music bebop beiguan bel canto bend-skin benga berlin school of electronic music bhajan bhangra bhangra-wine bhangragga bhangramuffin big band big band music big beat biguine bihu bikutsi biomusic bitcore bitpop black metal blackened death metal blue-eyed soul bluegrass blues blues ballad blues-rock boogie boogie woogie boogie-woogie bossa nova brass band brazilian funk brazilian jazz breakbeat breakbeat hardcore breakcore breton music brill building pop britfunk british blues british invasion britpop broken beat brown-eyed soul brukdown brutal death metal bubblegum dance bubblegum pop bulerias bumba-meu-boi bunraku burger-highlife burgundian school byzantine chant ca din tulnic ca pe lunca ca trù cabaret cadence cadence rampa cadence-lypso café-aman cai luong cajun music cakewalk calenda calentanos calgia calypso calypso jazz calypso-style baila campursari canatronic candombe canon canrock cantata cante chico cante jondo canterbury scene cantiga cantique cantiñas canto livre canto nuevo canto popular cantopop canzone napoletana cape jazz capoeira music caracoles carceleras cardas cardiowave carimbó cariso carnatic music carol cartageneras cassette culture casséy-co cavacha caveman caña celempungan cello rock celtic celtic fusion celtic metal celtic punk celtic reggae celtic rock cha-cha-cha chakacha chalga chamamé chamber jazz chamber music chamber pop champeta changuí chanson chant charanga charanga-vallenata charikawi chastushki chau van chemical breaks chicago blues chicago house chicago soul chicano rap chicha chicken scratch children's music chillout chillwave chimurenga chinese music chinese pop chinese rock chip music cho-kantrum chongak chopera chorinho choro chouval bwa chowtal christian alternative christian black metal christian electronic music christian hardcore christian hip hop christian industrial christian metal christian music christian punk christian r&b christian rock christian ska christmas carol christmas music chumba chut-kai-pang chutney chutney soca chutney-bhangra chutney-hip hop chutney-soca chylandyk chzalni chèo cigányzene classic classic country classic female blues classic rock classical music classical music era clicks n cuts close harmony club music cocobale coimbra fado coladeira colombianas combined rhythm comedy rap comedy rock comic opera comparsa compas direct compas meringue concert overture concerto concerto grosso congo conjunto contemporary christian contemporary christian music contemporary r&b contonbley contradanza cool jazz corrido corsican polyphonic song cothoza mfana country country blues country gospel country music country pop country r&b country rock country-rap countrypolitan couple de sonneurs coupé-décalé cowpunk cretan music crossover jazz crossover music crossover thrash crossover thrash metal crunk crunk&b crunkcore crust punk csárdás cuarteto cuban rumba cuddlecore cueca cumbia cumbia villera cybergrind dabka dadra daina dalauna dance dance music dance-pop dance-punk dance-rock dancehall dangdut danger music dansband danza danzón dark ambient dark cabaret dark pop darkcore darkstep darkwave de ascultat la servici de codru de dragoste de jale de pahar death industrial death metal death rock death/doom deathcore deathgrind deathrock deep funk deep house deep soul degung delta blues dementia desert rock desi detroit blues detroit techno dhamar dhimotiká dhrupad dhun digital hardcore dirge dirty dutch dirty rap dirty rap/pornocore dirty south disco disco house disco polo disney disney hardcore disney pop diva house divine rock dixieland dixieland jazz djambadon djent dodompa doina dombola dondang sayang donegal fiddle tradition dongjing doo wop doom metal doomcore downtempo drag dream pop drone doom drone metal drone music dronology drum and bass dub dub house dubanguthu dubstep dubtronica dunedin sound dunun dutch jazz décima early music east coast blues east coast hip hop easy listening electric blues electric folk electro electro backbeat electro hop electro house electro punk electro-industrial electro-swing electroclash electrofunk electronic electronic art music electronic body music electronic dance electronic luk thung electronic music electronic rock electronica electropop elevator music emo emo pop emo rap emocore emotronic enka eremwu eu essential rock ethereal pop ethereal wave euro euro disco eurobeat eurodance europop eurotrance eurourban exotica experimental music experimental noise experimental pop experimental rock extreme metal ezengileer fado falak fandango farruca fife and drum blues filk film score filmi filmi-ghazal finger-style fjatpangarri flamenco flamenco rumba flower power foaie verde fofa folk hop folk metal folk music folk pop folk punk folk rock folktronica forró franco-country freak-folk freakbeat free improvisation free jazz free music freestyle freestyle house freetekno french pop frenchcore frevo fricote fuji fuji music fulia full on funaná funeral doom funk funk metal funk rock funkcore funky house furniture music fusion jazz g-funk gaana gabba gabber gagaku gaikyoku gaita galant gamad gambang kromong gamelan gamelan angklung gamelan bang gamelan bebonangan gamelan buh gamelan degung gamelan gede gamelan kebyar gamelan salendro gamelan selunding gamelan semar pegulingan gamewave gammeldans gandrung gangsta rap gar garage rock garrotin gavotte gelugpa chanting gender wayang gending german folk music gharbi gharnati ghazal ghazal-song ghetto house ghettotech girl group glam metal glam punk glam rock glitch gnawa go-go goa goa trance gong-chime music goombay goregrind goshu ondo gospel music gothic metal gothic rock granadinas grebo gregorian chant grime grindcore groove metal group sounds grunge grupera guaguanbo guajira guasca guitarra baiana guitarradas gumbe gunchei gunka guoyue gwo ka gwo ka moderne gypsy jazz gypsy punk gypsybilly gyu ke habanera hajnali hakka halling hambo hands up hapa haole happy hardcore haqibah hard hard bop hard house hard rock hard trance hardcore hip hop hardcore metal hardcore punk hardcore techno hardstyle harepa harmonica blues hasaposérviko heart attack heartland rock heavy beat heavy metal hesher hi-nrg highlands highlife highlife fusion hillybilly music hindustani classical music hip hop hip hop & rap hip hop soul hip house hiplife hiragasy hiva usu hong kong and cantonese pop hong kong english pop honky tonk honkyoku hora lunga hornpipe horror punk horrorcore horrorcore rap house house music hua'er huasteco huayno hula humppa hunguhungu hyangak hymn hyphy hát chau van hát chèo hát cãi luong hát tuồng ibiza music icaro idm igbo music ijexá ilahije illbient impressionist music improvisational incidental music indian pop indie folk indie music indie pop indie rock indietronica indo jazz indo rock indonesian pop indoyíftika industrial death metal industrial hip-hop industrial metal industrial music industrial musical industrial rock instrumental rock intelligent dance music international latin inuit music iranian pop irish folk irish rebel music iscathamiya isicathamiya isikhwela jo island isolationist italo dance italo disco italo house itsmeños izvorna bosanska muzika j'ouvert j-fusion j-pop j-rock jaipongan jaliscienses jam band jam rock jamana kura jamrieng samai jangle pop japanese pop jarana jariang jarochos jawaiian jazz jazz blues jazz fusion jazz metal jazz rap jazz-funk jazz-rock jegog jenkka jesus music jibaro jig jig punk jing ping jingle jit jitterbug jive joged joged bumbung joik jonnycore joropo jota jtek jug band jujitsu juju juke joint blues jump blues jumpstyle jungle junkanoo juré jùjú k-pop kaba kabuki kachÄshÄ« kadans kagok kagyupa chanting kaiso kalamatianó kalattuut kalinda kamba pop kan ha diskan kansas city blues kantrum kantádhes kargyraa karma kaseko katajjaq kawachi ondo kayÅkyoku ke-kwe kebyar kecak kecapi suling kertok khaleeji khap khelimaski djili khene khoomei khorovodi khplam wai khrung sai khyal kilapanda kinko kirtan kiwi rock kizomba klape klasik klezmer kliningan kléftiko kochare kolomyjka komagaku kompa konpa korean pop koumpaneia kpanlogo krakowiak krautrock kriti kroncong krump krzesany kuduro kulintang kulning kumina kun-borrk kundere kundiman kussundé kutumba wake kveding kvæði kwaito kwassa kwassa kwela käng kélé kÄ©kÅ©yÅ© pop la la latin american latin jazz latin pop latin rap lavway laïko laïkó le leagan legényes lelio letkajenkka levenslied lhamo lieder light music light rock likanos liquid drum&bass liquid funk liquindi llanera llanto lo-fi lo-fi music loki djili long-song louisiana blues louisiana swamp pop lounge music lovers rock lowercase lubbock sound lucknavi thumri luhya omutibo luk grung lullaby lundu lundum m-base madchester madrigal mafioso rap maglaal magnificat mahori mainstream jazz makossa makossa-soukous malagueñas malawian jazz malhun maloya maluf maluka mambo manaschi mandarin pop manding swing mango mangue bit mangulina manikay manila sound manouche manzuma mapouka mapouka-serré marabi maracatu marga mariachi marimba marinera marrabenta martial industrial martinetes maskanda mass matamuerte math rock mathcore matt bello maxixe mazurka mbalax mbaqanga mbube mbumba medh medieval folk rock medieval metal medieval music meditation mejorana melhoun melhûn melodic black metal melodic death metal melodic hardcore melodic metalcore melodic music melodic trance memphis blues memphis rap memphis soul mento merengue merengue típico moderno merengue-bomba meringue merseybeat metal metalcore metallic hardcore mexican pop mexican rock mexican son meykhana mezwed miami bass microhouse middle of the road midwest hip hop milonga min'yo mineras mini compas mini-jazz minimal techno minimalist music minimalist trance minneapolis sound minstrel show minuet mirolóyia modal jazz modern classical music modern laika modern rock modinha mohabelo montuno monumental dance mor lam mor lam sing morna motorpop motown mozambique mpb mugam multicultural murga musette museve mushroom jazz music drama music hall musiqi-e assil musique concrète mutuashi muwashshah muzak méringue música campesina música criolla música de la interior música llanera música nordestina música popular brasileira música tropical nagauta nakasi nangma nanguan narcocorrido nardcore narodna muzika nasheed nashville sound nashville sound/countrypolitan national socialist black metal naturalismo nederpop neo soul neo-classical metal neo-medieval neo-prog neo-psychedelia neoclassical neoclassical music neofolk neotraditional country nerdcore neue deutsche härte neue deutsche welle new age music new beat new instrumental new jack swing new orleans blues new orleans jazz new pop new prog new rave new romantic new school hip hop new taiwanese song new wave new wave of british heavy metal new wave of new wave new weird america new york blues new york house newgrass nganja niche nightcore nintendocore nisiótika no wave noh noise music noise pop noise rock nongak norae undong nordic folk dance music nordic folk music nortec norteño northern soul nota nu breaks nu jazz nu metal nu soul nueva canción nyatiti néo kýma obscuro oi! old school hip hop old-time oldies olonkho oltului ondo opera operatic pop oratorio orchestra organ trio organic ambient organum orgel oriental metal ottava rima outlaw country outsider music p-funk pagan metal pagan rock pagode paisley underground palm wine palm-wine pambiche panambih panchai baja panchavadyam pansori paranda parang parody parranda partido alto pasillo patriotic peace punk pelimanni music petenera peyote song philadelphia soul piano blues piano rock piedmont blues pimba pinoy pop pinoy rock pinpeat orchestra piphat piyyutim plainchant plena pleng phua cheewit pleng thai sakorn political hip hop polka polo polonaise pols polska pong lang pop pop folk pop music pop punk pop rap pop rock pop sunda pornocore porro post disco post-britpop post-disco post-grunge post-hardcore post-industrial post-metal post-minimalism post-punk post-rock post-romanticism pow-wow power electronics power metal power noise power pop powerviolence ppongtchak praise song program symphony progressive bluegrass progressive country progressive death metal progressive electronic progressive electronic music progressive folk progressive folk music progressive house progressive metal progressive rock progressive trance protopunk psych folk psychedelic music psychedelic pop psychedelic rock psychedelic trance psychobilly punk blues punk cabaret punk jazz punk rock punta punta rock qasidah qasidah modern qawwali quadrille quan ho queercore quiet storm rada raga raga rock ragga ragga jungle raggamuffin ragtime rai rake-and-scrape ramkbach ramvong ranchera rap rap metal rap rock rapcore rara rare groove rasiya rave raw rock raï rebetiko red dirt reel reggae reggae fusion reggae highlife reggaefusion reggaeton rekilaulu relax music religious rembetiko renaissance music requiem rhapsody rhyming spiritual rhythm & blues rhythm and blues ricercar riot grrrl rock rock and roll rock en español rock opera rockabilly rocksteady rococo romantic period in music rondeaux ronggeng roots reggae roots rock roots rock reggae rumba russian pop rímur sabar sacred harp sadcore saibara sakara salegy salsa salsa erotica salsa romantica saltarello samba samba-canção samba-reggae samba-rock sambai sanjo sato kagura sawt saya scat schlager schottisch schranz scottish baroque music screamo scrumpy and western sea shanty sean nós second viennese school sega music seggae seis semba sephardic music serialism set dance sevdalinka sevillana shabab shabad shalako shan'ge shango shape note shibuya-kei shidaiqu shima uta shock rock shoegaze shoegazer shoka shomyo show tune sica siguiriyas silat sinawi singer-songwriter situational ska ska punk skacore skald skate punk skiffle slack-key guitar slide slowcore sludge metal slängpolska smooth jazz soca soft rock son son montuno son-batá sonata songo songo-salsa sophisti-pop soukous soul soul blues soul jazz soul music soundtrack southern gospel southern harmony southern hip hop southern metal southern rock southern soul space age pop space music space rock spectralism speed garage speed metal speedcore spirituals spouge sprechgesang square dance squee st. louis blues steelband stoner metal stoner rock straight edge strathspeys stride string string quartet sufi music suite sunshine pop suomirock super eurobeat surf ballad surf instrumental surf music surf pop surf rock swamp blues swamp pop swamp rock swing swing music swingbeat sygyt symphonic black metal symphonic metal symphonic poem symphonic rock symphony synthpop synthpunk t'ong guitar taarab tai tu taiwanese pop tala talempong tambu tamburitza tamil christian keerthanai tango tanguk tappa tarana tarantella taranto tech tech house tech trance technical death metal technical metal techno technoid technopop techstep techtonik teen pop tejano tejano music tekno tembang sunda texas blues thai pop thillana thrash metal thrashcore thumri tibetan pop tiento timbila tin pan alley tinga tinku toeshey togaku trad jazz traditional bluegrass traditional pop music trallalero trance tribal house trikitixa trip hop trip rock trip-hop tropicalia tropicalismo tropipop truck-driving country tumba turbo-folk turkish music turkish pop turntablism tuvan throat-singing twee pop twist two tone táncház uk garage uk pub rock unblack metal underground music uplifting uplifting trance urban cowboy urban folk urban jazz vallenato vaudeville venezuela verbunkos verismo video game music viking metal villanella virelai vispop visual kei visual music vocal vocal house vocal jazz vocal music volksmusik waila waltz wangga warabe uta wassoulou weld were music west coast hip hop west coast jazz western western blues western swing witch house wizard rock women's music wong shadow wonky pop wood work song world fusion world fusion music world music worldbeat xhosa music xoomii yo-pop yodeling yukar yé-yé zajal zapin zarzuela zeibekiko zeuhl ziglibithy zouglou zouk zouk chouv zouklove zulu music zydeco �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/lyrics.py���������������������������������������������������������������������0000644�0000765�0000024�00000034732�12207240712�017441� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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. """Fetches, embeds, and displays lyrics. """ from __future__ import print_function import re import logging import urllib import json import unicodedata import difflib from beets.plugins import BeetsPlugin from beets import ui from beets import config # Global logger. log = logging.getLogger('beets') DIV_RE = re.compile(r'<(/?)div>?') COMMENT_RE = re.compile(r'<!--.*-->', re.S) TAG_RE = re.compile(r'<[^>]*>') BREAK_RE = re.compile(r'<br\s*/?>') URL_CHARACTERS = { u'\u2018': u"'", u'\u2019': u"'", u'\u201c': u'"', u'\u201d': u'"', u'\u2010': u'-', u'\u2011': u'-', u'\u2012': u'-', u'\u2013': u'-', u'\u2014': u'-', u'\u2015': u'-', u'\u2016': u'-', u'\u2026': u'...', } # Utilities. def fetch_url(url): """Retrieve the content at a given URL, or return None if the source is unreachable. """ try: return urllib.urlopen(url).read() except IOError as exc: log.debug(u'failed to fetch: {0} ({1})'.format(url, unicode(exc))) return None def unescape(text): """Resolves &#xxx; HTML entities (and some others).""" if isinstance(text, str): text = text.decode('utf8', 'ignore') out = text.replace(u' ', u' ') def replchar(m): num = m.group(1) return unichr(int(num)) out = re.sub(u"&#(\d+);", replchar, out) return out def extract_text(html, starttag): """Extract the text from a <DIV> 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 lyrics = ''.join(parts) return strip_cruft(lyrics) def strip_cruft(lyrics, wscollapse=True): """Clean up HTML from an extracted lyrics string. For example, <BR> tags are replaced with newlines. """ lyrics = COMMENT_RE.sub('', lyrics) lyrics = unescape(lyrics) if wscollapse: lyrics = re.sub(r'\s+', ' ', lyrics) # Whitespace collapse. lyrics = BREAK_RE.sub('\n', lyrics) # <BR> newlines. lyrics = re.sub(r'\n +', '\n', lyrics) lyrics = re.sub(r' +\n', '\n', lyrics) lyrics = TAG_RE.sub('', lyrics) # Strip remaining HTML tags. lyrics = lyrics.replace('\r','\n') lyrics = lyrics.strip() return lyrics def _encode(s): """Encode the string for inclusion in a URL (common to both LyricsWiki and Lyrics.com). """ 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) # LyricsWiki. LYRICSWIKI_URL_PATTERN = 'http://lyrics.wikia.com/%s:%s' 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) 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 lyrics = extract_text(html, "<div class='lyricbox'>") if lyrics and 'Unfortunately, we are not licensed' not in lyrics: return lyrics # Lyrics.com. LYRICSCOM_URL_PATTERN = 'http://www.lyrics.com/%s-lyrics-%s.html' LYRICSCOM_NOT_FOUND = ( 'Sorry, we do not have the lyric', 'Submit Lyrics', ) def _lc_encode(s): s = re.sub(r'[^\w\s-]', '', s) s = re.sub(r'\s+', '-', s) return _encode(s).lower() def fetch_lyricscom(artist, title): """Fetch lyrics from Lyrics.com.""" url = LYRICSCOM_URL_PATTERN % (_lc_encode(title), _lc_encode(artist)) html = fetch_url(url) if not html: return lyrics = extract_text(html, '<div id="lyric_space">') if not lyrics: return for not_found_str in LYRICSCOM_NOT_FOUND: if not_found_str in lyrics: return parts = lyrics.split('\n---\nLyrics powered by', 1) if parts: return parts[0] # Optional Google custom search API backend. def slugify(text): """Normalize a string and remove non-alphanumeric characters. """ # http://stackoverflow.com/questions/295135/turn-a-string-into-a-valid- # filename-in-python # Remove content within parentheses pat = "([^,\(]*)\((.*?)\)" 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("Failing to normalize '%s'" % (text)) return urllib.quote(text) BY_TRANS = ['by', 'par'] LYRICS_TRANS = ['lyrics', 'paroles'] 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+'%20'+artist for by in BY_TRANS] + \ [artist, sitename, sitename.replace('www.','')] + LYRICS_TRANS songTitle = re.sub(u'(%s)' % u'|'.join(tokens) ,u'', urlTitle).strip('%20') typoRatio = .8 return difflib.SequenceMatcher(None, songTitle, title).ratio() >= typoRatio def insert_line_feeds(text): """Insert newlines before upper-case characters. """ tokensStr = re.split("([a-z][A-Z])", text) for idx in range(1, len(tokensStr), 2): ltoken = list(tokensStr[idx]) tokensStr[idx] = ltoken[0] + '\n' + ltoken[1] return ''.join(tokensStr) def sanitize_lyrics(text): """Clean text, returning raw lyrics as output or None if it happens that input text is actually not lyrics content. Clean (x)html tags in text, correct layout and syntax... """ text = strip_cruft(text, False) # Restore \n in input text if '\n' not in text: text = insert_line_feeds(text) while text.count('\n\n') > text.count('\n')/4: # Remove first occurrence of \n for each sequence of \n text = re.sub(r'\n(\n+)', '\g<1>', text) text = re.sub(r'\n\n+', '\n\n', text) # keep at most two \n in a row return text def is_lyrics(text, artist): """Determine whether the text seems to be valid lyrics. """ badTriggers = [] nbLines = text.count('\n') if nbLines <= 1: log.debug("Ignoring too short lyrics '%s'" % text) return 0 elif nbLines < 5: badTriggers.append('too_short') else: # Don't penalize long text because of lyrics keyword in credits textlines = text.split('\n') popped = False for i in [len(textlines)-1, 0]: if 'lyrics' in textlines[i].lower(): popped = textlines.pop(i) if popped: text = '\n'.join(textlines) for item in artist, 'lyrics', 'copyright', 'property': badTriggers += [item] * len(re.findall(r'\W%s\W' % item, text, re.I)) if badTriggers: log.debug('Bad triggers detected: %s' % badTriggers) return len(badTriggers) < 2 def scrape_lyrics_from_url(url): """Scrape lyrics from a URL. If no lyrics can be found, return None instead. """ from bs4 import BeautifulSoup, Tag, Comment html = fetch_url(url) soup = BeautifulSoup(html) for tag in soup.findAll('br'): tag.replaceWith('\n') # Remove non relevant html parts [s.extract() for s in soup(['head', 'script'])] comments = soup.findAll(text=lambda text:isinstance(text, Comment)) [s.extract() for s in comments] try: for tag in soup.findAll(True): tag.name = 'p' # keep tag contents except Exception, e: log.debug('Error %s when replacing containing marker by p marker' % e, exc_info=True) # Make better soup from current soup! The previous unclosed <p> sections # are now closed. Use str() rather than prettify() as it's more # conservative concerning EOL soup = BeautifulSoup(str(soup)) # In case lyrics are nested in no markup but <body> # Insert the whole body in a <p> bodyTag = soup.find('body') if bodyTag: pTag = soup.new_tag("p") bodyTag.parent.insert(0, pTag) pTag.insert(0, bodyTag) tagTokens = [] for tag in soup.findAll('p'): soup2 = BeautifulSoup(str(tag)) # Extract all text of <p> section. tagTokens += soup2.findAll(text=True) if tagTokens: # Lyrics are expected to be the longest paragraph tagTokens = sorted(tagTokens, key=len, reverse=True) soup = BeautifulSoup(tagTokens[0]) return unescape(tagTokens[0].strip("\n\r: ")) 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'))) 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: %s' % reason) return None if 'items' in data.keys(): for item in data['items']: urlLink = item['link'] urlTitle = item['title'] if not is_page_candidate(urlLink, urlTitle, title, artist): continue lyrics = scrape_lyrics_from_url(urlLink) if not lyrics: continue lyrics = sanitize_lyrics(lyrics) if is_lyrics(lyrics, artist): log.debug(u'got lyrics from %s' % item['displayLink']) return lyrics # Plugin logic. class LyricsPlugin(BeetsPlugin): def __init__(self): super(LyricsPlugin, self).__init__() self.import_stages = [self.imported] self.config.add({ 'auto': True, 'google_API_key': None, 'google_engine_ID': u'009217259823014548361:lndtuqkycfu', 'fallback': None, }) self.backends = [fetch_lyricswiki, fetch_lyricscom] if self.config['google_API_key'].get(): self.backends.insert(0, fetch_google) 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') 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) if opts.printlyr and item.lyrics: ui.print_(item.lyrics) cmd.func = func return [cmd] # Auto-fetch lyrics on import. def imported(self, session, task): if self.config['auto']: for item in task.imported_items(): self.fetch_item_lyrics(session.lib, logging.DEBUG, item, False) def fetch_item_lyrics(self, lib, loglevel, item, write): """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. """ fallback = self.config['fallback'].get() # Skip if the item already has lyrics. if item.lyrics: log.log(loglevel, u'lyrics already present: %s - %s' % (item.artist, item.title)) return # Fetch lyrics. lyrics = self.get_lyrics(item.artist, item.title) if not lyrics: log.log(loglevel, u'lyrics not found: %s - %s' % (item.artist, item.title)) if fallback: lyrics = fallback else: return else: log.log(loglevel, u'fetched lyrics: %s - %s' % (item.artist, item.title)) item.lyrics = lyrics if write: item.write() item.store() def get_lyrics(self, artist, title): """Fetch lyrics, trying each source in turn. Return a string or None if no lyrics were found. """ # Remove featuring artists from search. pattern = u"(.*) feat(uring|\.)?\s\S+" match = re.search(pattern, artist, re.IGNORECASE) if match: artist = match.group(0) for backend in self.backends: lyrics = backend(artist, title) if lyrics: if isinstance(lyrics, str): lyrics = lyrics.decode('utf8', 'ignore') log.debug(u'got lyrics from backend: {0}'.format( backend.__name__ )) return lyrics ��������������������������������������beets-1.3.1/beetsplug/mbcollection.py���������������������������������������������������������������0000644�0000765�0000024�00000006360�12214742511�020604� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������#Copyright (c) 2011, Jeffrey Aylesworth <jeffrey@aylesworth.ca> # #Permission to use, copy, modify, and/or distribute this software for any #purpose with or without fee is hereby granted, provided that the above #copyright notice and this permission notice appear in all copies. # #THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES #WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF #MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR #ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES #WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN #ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF #OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import print_function from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets import ui from beets import config import musicbrainzngs import re import logging SUBMISSION_CHUNK_SIZE = 200 UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$' log = logging.getLogger('beets.bpd') 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 as exc: raise ui.UserError('MusicBrainz API error: {0}'.format(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 ) def update_collection(lib, opts, args): # Get the collection to modify. collections = mb_call(musicbrainzngs.get_collections) if not collections['collection-list']: raise ui.UserError('no collections exist for user') collection_id = collections['collection-list'][0]['id'] # Get a list of all the album IDs. album_ids = [] for album in lib.albums(): aid = album.mb_albumid if aid: if re.match(UUID_REGEX, aid): album_ids.append(aid) else: log.info(u'skipping invalid MBID: {0}'.format(aid)) # Submit to MusicBrainz. print('Updating MusicBrainz collection {0}...'.format(collection_id)) submit_albums(collection_id, album_ids) print('...MusicBrainz collection updated.') update_mb_collection_cmd = Subcommand('mbupdate', help='Update MusicBrainz collection') update_mb_collection_cmd.func = update_collection class MusicBrainzCollectionPlugin(BeetsPlugin): def __init__(self): super(MusicBrainzCollectionPlugin, self).__init__() musicbrainzngs.auth( config['musicbrainz']['user'].get(unicode), config['musicbrainz']['pass'].get(unicode), ) def commands(self): return [update_mb_collection_cmd] ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/mbsync.py���������������������������������������������������������������������0000644�0000765�0000024�00000013257�12215757203�017435� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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. """ import logging from beets.plugins import BeetsPlugin from beets import autotag, library, ui, util from beets.autotag import hooks from beets import config log = logging.getLogger('beets') def _print_and_apply_changes(lib, item, old_data, move, pretend, write): """Apply changes to an Item and preview them in the console. Return a boolean indicating whether any changes were made. """ changes = {} for key in library.ITEM_KEYS_META: if key in item._dirty: changes[key] = old_data[key], getattr(item, key) if not changes: return False # Something changed. ui.print_obj(item, lib) for key, (oldval, newval) in changes.iteritems(): ui.commands._showdiff(key, oldval, newval) # If we're just pretending, then don't move or save. if not pretend: # Move the item if it's in the library. if move and lib.directory in util.ancestry(item.path): item.move(with_album=False) if write: try: item.write() except Exception as exc: log.error(u'could not sync {0}: {1}'.format( util.displayable_path(item.path), exc)) return False item.store() return True def mbsync_singletons(lib, query, move, pretend, write): """Synchronize matching singleton items. """ singletons_query = library.get_query(query, library.Item) singletons_query.subqueries.append(library.SingletonQuery(True)) for s in lib.items(singletons_query): if not s.mb_trackid: log.info(u'Skipping singleton {0}: has no mb_trackid' .format(s.title)) continue old_data = dict(s) # Get the MusicBrainz recording info. track_info = hooks.track_for_mbid(s.mb_trackid) if not track_info: log.info(u'Recording ID not found: {0}'.format(s.mb_trackid)) continue # Apply. with lib.transaction(): autotag.apply_item_metadata(s, track_info) _print_and_apply_changes(lib, s, old_data, move, pretend, write) def mbsync_albums(lib, query, move, pretend, write): """Synchronize matching albums. """ # Process matching albums. for a in lib.albums(query): if not a.mb_albumid: log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id)) continue items = list(a.items()) old_data = dict((item, dict(item)) for item in items) # Get the MusicBrainz album information. album_info = hooks.album_for_mbid(a.mb_albumid) if not album_info: log.info(u'Release ID not found: {0}'.format(a.mb_albumid)) continue # Construct a track mapping according to MBIDs. This should work # for albums that have missing or extra tracks. mapping = {} for item in items: for track_info in album_info.tracks: if item.mb_trackid == track_info.track_id: mapping[item] = track_info break # Apply. with lib.transaction(): autotag.apply_metadata(album_info, mapping) changed = False for item in items: changed |= _print_and_apply_changes(lib, item, old_data[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_KEYS_ITEM: setattr(a, key, getattr(items[0], key)) # Move album art (and any inconsistent items). if move and lib.directory in util.ancestry(items[0].path): log.debug(u'moving album {0}'.format(a.id)) a.move() def mbsync_func(lib, opts, args): """Command handler for the mbsync function. """ move = opts.move pretend = opts.pretend write = opts.write query = ui.decargs(args) mbsync_singletons(lib, query, move, pretend, write) mbsync_albums(lib, query, move, pretend, write) class MBSyncPlugin(BeetsPlugin): def __init__(self): super(MBSyncPlugin, self).__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', '--nomove', action='store_false', default=True, dest='move', help="don't move files in library") cmd.parser.add_option('-W', '--nowrite', action='store_false', default=config['import']['write'], dest='write', help="don't write updated metadata to files") cmd.func = mbsync_func return [cmd] �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/missing.py��������������������������������������������������������������������0000644�0000765�0000024�00000013167�12224051451�017603� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, Pedro Silva. # # 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 logging from beets.autotag import hooks from beets.library import Item, Album from beets.plugins import BeetsPlugin from beets.ui import decargs, print_obj, Subcommand PLUGIN = 'missing' log = logging.getLogger('beets') def _missing_count(album): """Return number of missing items in `album`. """ return album.tracktotal - len([i for i in album.items()]) def _missing(album): """Query MusicBrainz to determine items missing from `album`. """ item_mbids = map(lambda x: x.mb_trackid, album.items()) if len([i for i in album.items()]) < album.tracktotal: # 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) log.debug('{0}: track {1} in album {2}' .format(PLUGIN, track_info.track_id, album_info.album_id)) yield item 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 = a.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 """ def __init__(self): super(MissingPlugin, self).__init__() self.config.add({ 'format': None, 'count': False, 'total': False, }) self.album_template_fields['missing'] = _missing_count self._command = Subcommand('missing', help=__doc__, aliases=['miss']) self._command.parser.add_option('-f', '--format', dest='format', action='store', type='string', help='print with custom FORMAT', metavar='FORMAT') 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') def commands(self): def _miss(lib, opts, args): self.config.set_args(opts) fmt = self.config['format'].get() count = self.config['count'].get() total = self.config['total'].get() albums = lib.albums(decargs(args)) if total: print(sum([_missing_count(a) for a in albums])) return # Default format string for count mode. if count and not fmt: fmt = '$albumartist - $album: $missing' for album in albums: if count: missing = _missing_count(album) if missing: print_obj(album, lib, fmt=fmt) else: for item in _missing(album): print_obj(item, lib, fmt=fmt) self._command.func = _miss return [self._command] ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/mpdupdate.py������������������������������������������������������������������0000644�0000765�0000024�00000007057�12220136315�020115� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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: mpdupdate: host: localhost port: 6600 password: seekrit """ from __future__ import print_function from beets.plugins import BeetsPlugin import os import socket from beets import config # Global variable so that mpdupdate can detect database changes and run only # once before beets exits. database_changed = False # 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(object): """Socket abstraction that allows reading by line.""" def __init__(self, host, port, sep='\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 = '' 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 '\n' in self.buf: res, self.buf = self.buf.split(self.sep, 1) return res + self.sep else: return '' def send(self, data): self.sock.send(data) def close(self): self.sock.close() def update_mpd(host='localhost', port=6600, password=None): """Sends the "update" command to the MPD server indicated, possibly authenticating with a password first. """ print('Updating MPD database...') s = BufferedSocket(host, port) resp = s.readline() if 'OK MPD' not in resp: print('MPD connection failed:', repr(resp)) return if password: s.send('password "%s"\n' % password) resp = s.readline() if 'OK' not in resp: print('Authentication failed:', repr(resp)) s.send('close\n') s.close() return s.send('update\n') resp = s.readline() if 'updating_db' not in resp: print('Update failed:', repr(resp)) s.send('close\n') s.close() print('... updated.') class MPDUpdatePlugin(BeetsPlugin): def __init__(self): super(MPDUpdatePlugin, self).__init__() self.config.add({ 'host': u'localhost', 'port': 6600, 'password': u'', }) @MPDUpdatePlugin.listen('database_change') def handle_change(lib=None): global database_changed database_changed = True @MPDUpdatePlugin.listen('cli_exit') def update(lib=None): if database_changed: update_mpd( config['mpdupdate']['host'].get(unicode), config['mpdupdate']['port'].get(int), config['mpdupdate']['password'].get(unicode), ) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/random.py���������������������������������������������������������������������0000644�0000765�0000024�00000006152�12213263605�017412� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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 __future__ import absolute_import from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs, print_obj from beets.util.functemplate import Template import random from operator import attrgetter from itertools import groupby import collections def random_item(lib, opts, args): query = decargs(args) if opts.path: fmt = '$path' else: fmt = opts.format template = Template(fmt) if fmt else None if opts.album: objs = list(lib.albums(query)) else: objs = list(lib.items(query)) if opts.equal_chance: # Group the objects by artist so we can sample from them. key = attrgetter('albumartist') objs.sort(key=key) objs_by_artists = {} for artist, v in groupby(objs, key): objs_by_artists[artist] = list(v) objs = [] for _ in range(opts.number): # Terminate early if we're out of objects to select. if not objs_by_artists: break # Choose an artist and an object for that artist, removing # this choice from the pool. artist = random.choice(objs_by_artists.keys()) objs_from_artist = objs_by_artists[artist] i = random.randint(0, len(objs_from_artist) - 1) objs.append(objs_from_artist.pop(i)) # Remove the artist if we've used up all of its objects. if not objs_from_artist: del objs_by_artists[artist] else: number = min(len(objs), opts.number) objs = random.sample(objs, number) for item in objs: print_obj(item, lib, template) random_cmd = Subcommand('random', help='chose a random track or album') random_cmd.parser.add_option('-a', '--album', action='store_true', help='choose an album instead of track') random_cmd.parser.add_option('-p', '--path', action='store_true', help='print the path of the matched item') random_cmd.parser.add_option('-f', '--format', action='store', help='print with custom format', default=None) 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.func = random_item class Random(BeetsPlugin): def commands(self): return [random_cmd] ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/replaygain.py�����������������������������������������������������������������0000644�0000765�0000024�00000021566�12207240712�020270� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, Fabrice Laporte. # # 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 logging import subprocess import os from beets import ui from beets.plugins import BeetsPlugin from beets.util import syspath, command_output from beets import config log = logging.getLogger('beets') SAMPLE_MAX = 1 << 15 class ReplayGainError(Exception): """Raised when an error occurs during mp3gain/aacgain execution. """ def call(args): """Execute the command and return its output or raise a ReplayGainError on failure. """ try: return command_output(args) except subprocess.CalledProcessError as e: raise ReplayGainError( "{0} exited with status {1}".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: # http://code.google.com/p/beets/issues/detail?id=499 raise ReplayGainError("argument encoding failed") def parse_tool_output(text): """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('\n'): parts = line.split('\t') if len(parts) != 6 or parts[0] == 'File': continue out.append({ 'file': parts[0], 'mp3gain': int(parts[1]), 'gain': float(parts[2]), 'peak': float(parts[3]) / SAMPLE_MAX, 'maxgain': int(parts[4]), 'mingain': int(parts[5]), }) return out class ReplayGainPlugin(BeetsPlugin): """Provides ReplayGain analysis. """ def __init__(self): super(ReplayGainPlugin, self).__init__() self.import_stages = [self.imported] self.config.add({ 'overwrite': False, 'albumgain': False, 'noclip': True, 'apply_gain': False, 'targetlevel': 89, 'auto': True, 'command': u'', }) self.overwrite = self.config['overwrite'].get(bool) self.albumgain = self.config['albumgain'].get(bool) self.noclip = self.config['noclip'].get(bool) self.apply_gain = self.config['apply_gain'].get(bool) target_level = self.config['targetlevel'].as_number() self.gain_offset = int(target_level - 89) self.automatic = self.config['auto'].get(bool) self.command = self.config['command'].get(unicode) if self.command: # Explicit executable path. if not os.path.isfile(self.command): raise ui.UserError( 'replaygain command does not exist: {0}'.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 ui.UserError( 'no replaygain command found: install mp3gain or aacgain' ) def imported(self, session, task): """Our import stage function.""" if not self.automatic: return if task.is_album: album = session.lib.get_album(task.album_id) items = list(album.items()) else: items = [task.item] results = self.compute_rgain(items, task.is_album) if results: self.store_gain(session.lib, items, results, album if task.is_album else None) def commands(self): """Provide a ReplayGain command.""" def func(lib, opts, args): write = config['import']['write'].get(bool) if opts.album: # Analyze albums. for album in lib.albums(ui.decargs(args)): log.info(u'analyzing {0} - {1}'.format(album.albumartist, album.album)) items = list(album.items()) results = self.compute_rgain(items, True) if results: self.store_gain(lib, items, results, album) if write: for item in items: item.write() else: # Analyze individual tracks. for item in lib.items(ui.decargs(args)): log.info(u'analyzing {0} - {1}'.format(item.artist, item.title)) results = self.compute_rgain([item], False) if results: self.store_gain(lib, [item], results, None) if write: item.write() cmd = ui.Subcommand('replaygain', help='analyze for ReplayGain') cmd.parser.add_option('-a', '--album', action='store_true', help='analyze albums instead of tracks') cmd.func = func return [cmd] def requires_gain(self, item, album=False): """Does the gain need to be computed?""" 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 self.overwrite or \ (not item.rg_track_gain or not item.rg_track_peak) or \ ((not item.rg_album_gain or not item.rg_album_peak) and \ album) def compute_rgain(self, items, album=False): """Compute ReplayGain values and return a list of results dictionaries as given by `parse_tool_output`. """ # 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. if all([not self.requires_gain(i, album) for i in items]): log.debug(u'replaygain: no gain to compute') return # 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'] if self.apply_gain: # Lossless audio adjustment. cmd = cmd + ['-a' if album and self.albumgain else '-r'] cmd = cmd + ['-d', str(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] log.debug(u'replaygain: analyzing {0} files'.format(len(items))) try: output = call(cmd) except ReplayGainError as exc: log.warn(u'replaygain: analysis failed ({0})'.format(exc)) return log.debug(u'replaygain: analysis finished') results = parse_tool_output(output) return results def store_gain(self, lib, items, rgain_infos, album=None): """Store computed ReplayGain values to the Items and the Album (if it is provided). """ for item, info in zip(items, rgain_infos): item.rg_track_gain = info['gain'] item.rg_track_peak = info['peak'] item.store() log.debug(u'replaygain: applied track gain {0}, peak {1}'.format( item.rg_track_gain, item.rg_track_peak )) if album and self.albumgain: assert len(rgain_infos) == len(items) + 1 album_info = rgain_infos[-1] album.rg_album_gain = album_info['gain'] album.rg_album_peak = album_info['peak'] log.debug(u'replaygain: applied album gain {0}, peak {1}'.format( album.rg_album_gain, album.rg_album_peak )) album.store() ������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/rewrite.py��������������������������������������������������������������������0000644�0000765�0000024�00000005137�12103237171�017612� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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 import logging from collections import defaultdict from beets.plugins import BeetsPlugin from beets import ui from beets import library from beets import config log = logging.getLogger('beets') 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 = getattr(item, 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(RewritePlugin, self).__init__() self.config.add({}) # Gather all the rewrite rules for each field. rules = defaultdict(list) for key, value in self.config.items(): try: fieldname, pattern = key.split(None, 1) except ValueError: raise ui.UserError("invalid rewrite specification") if fieldname not in library.ITEM_KEYS: raise ui.UserError("invalid field name (%s) in rewriter" % fieldname) log.debug(u'adding template field %s' % 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.iteritems(): self.template_fields[fieldname] = rewriter(fieldname, fieldrules) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/scrub.py����������������������������������������������������������������������0000644�0000765�0000024�00000010667�12225076467�017271� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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. """ import logging from beets.plugins import BeetsPlugin from beets import ui from beets import util from beets import config from beets import mediafile log = logging.getLogger('beets') _MUTAGEN_FORMATS = { 'asf': 'ASF', 'apev2': 'APEv2File', 'flac': 'FLAC', 'id3': 'ID3FileType', 'mp3': 'MP3', 'oggflac': 'OggFLAC', 'oggspeex': 'OggSpeex', 'oggtheora': 'OggTheora', 'oggvorbis': 'OggVorbis', 'oggopus': 'OggOpus', 'trueaudio': 'TrueAudio', 'wavpack': 'WavPack', 'monkeysaudio': 'MonkeysAudio', 'optimfrog': 'OptimFROG', } scrubbing = False class ScrubPlugin(BeetsPlugin): """Removes extraneous metadata from files' tags.""" def __init__(self): super(ScrubPlugin, self).__init__() self.config.add({ 'auto': True, }) def commands(self): def scrub_func(lib, opts, args): # This is a little bit hacky, but we set a global flag to # avoid autoscrubbing when we're also explicitly scrubbing. global scrubbing scrubbing = True # Walk through matching files and remove tags. for item in lib.items(ui.decargs(args)): log.info(u'scrubbing: %s' % util.displayable_path(item.path)) # Get album art if we need to restore it. if opts.write: mf = mediafile.MediaFile(item.path) art = mf.art # Remove all tags. _scrub(item.path) # Restore tags, if enabled. if opts.write: log.debug(u'writing new tags after scrub') item.write() if art: print('restoring art') mf = mediafile.MediaFile(item.path) mf.art = art mf.save() scrubbing = False 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] def _mutagen_classes(): """Get a list of file type classes from the Mutagen module. """ classes = [] for modname, clsname in _MUTAGEN_FORMATS.items(): mod = __import__('mutagen.{0}'.format(modname), fromlist=[clsname]) classes.append(getattr(mod, clsname)) return classes def _scrub(path): """Remove all tags from a file. """ for cls in _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 IOError as exc: log.error(u'could not scrub {0}: {1}'.format( util.displayable_path(path), exc, )) # Automatically embed art into imported albums. @ScrubPlugin.listen('write') def write_item(item): if not scrubbing and config['scrub']['auto']: log.debug(u'auto-scrubbing %s' % util.displayable_path(item.path)) _scrub(item.path) �������������������������������������������������������������������������beets-1.3.1/beetsplug/smartplaylist.py��������������������������������������������������������������0000644�0000765�0000024�00000006110�12216073530�021033� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, Dang Mai <contact@dangmai.net>. # # 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 __future__ import print_function from beets.plugins import BeetsPlugin from beets import config, ui, library from beets.util import normpath, syspath from beets.util.functemplate import Template import os # Global variable so that smartplaylist can detect database changes and run # only once before beets exits. database_changed = False def update_playlists(lib): ui.print_("Updating smart playlists...") playlists = config['smartplaylist']['playlists'].get(list) playlist_dir = config['smartplaylist']['playlist_dir'].as_filename() relative_to = config['smartplaylist']['relative_to'].get() if relative_to: relative_to = normpath(relative_to) for playlist in playlists: items = lib.items(library.AndQuery.from_string(playlist['query'])) m3us = {} basename = playlist['name'].encode('utf8') # 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(Template(basename), sanitize=True) if not (m3u_name in m3us): m3us[m3u_name] = [] if relative_to: m3us[m3u_name].append(os.path.relpath(item.path, relative_to)) else: m3us[m3u_name].append(item.path) # Now iterate through the m3us that we need to generate for m3u in m3us: m3u_path = normpath(os.path.join(playlist_dir, m3u)) with open(syspath(m3u_path), 'w') as f: for path in m3us[m3u]: f.write(path + '\n') ui.print_("... Done") class SmartPlaylistPlugin(BeetsPlugin): def __init__(self): super(SmartPlaylistPlugin, self).__init__() self.config.add({ 'relative_to': None, 'playlist_dir': u'.', 'playlists': [] }) def commands(self): def update(lib, opts, args): update_playlists(lib) spl_update = ui.Subcommand('splupdate', help='update the smart playlists') spl_update.func = update return [spl_update] @SmartPlaylistPlugin.listen('database_change') def handle_change(lib): global database_changed database_changed = True @SmartPlaylistPlugin.listen('cli_exit') def update(lib): if database_changed: update_playlists(lib) ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/the.py������������������������������������������������������������������������0000644�0000765�0000024�00000006412�12102550004�016676� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, Blemjhoo Tezoulbr <baobab@heresiarch.info>. # # 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 import logging from beets.plugins import BeetsPlugin __author__ = 'baobab@heresiarch.info' __version__ = '1.1' PATTERN_THE = u'^[the]{3}\s' PATTERN_A = u'^[a][n]?\s' FORMAT = u'{0}, {1}' class ThePlugin(BeetsPlugin): _instance = None _log = logging.getLogger('beets') the = True a = True format = u'' strip = False patterns = [] def __init__(self): super(ThePlugin, self).__init__() self.template_funcs['the'] = self.the_template_func self.config.add({ 'the': True, 'a': True, 'format': u'{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(u'[the] invalid pattern: {0}'.format(p)) else: if not (p.startswith('^') or p.endswith('$')): self._log.warn(u'[the] warning: \"{0}\" will not ' 'match string start/end'.format(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.warn(u'[the] 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'].get(unicode) return fmt.format(r, t.strip()).strip() else: return u'' 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: break self._log.debug(u'[the] \"{0}\" -> \"{1}\"'.format(text, r)) return r else: return u'' ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/��������������������������������������������������������������������������0000755�0000765�0000024�00000000000�12226377756�016352� 5����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/__init__.py���������������������������������������������������������������0000644�0000765�0000024�00000011275�12213514105�020442� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2013, 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 import os # 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): del out['path'] # 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): del out['artpath'] if expand: out['items'] = [_rep(item) for item in obj.items()] return out # Flask setup. app = flask.Flask(__name__) @app.before_request def before_request(): g.lib = app.config['lib'] # Items. @app.route('/item/<int:item_id>') def single_item(item_id): item = g.lib.get_item(item_id) return flask.jsonify(_rep(item)) @app.route('/item/') def all_items(): with g.lib.transaction() as tx: rows = tx.query("SELECT id FROM items") all_ids = [row[0] for row in rows] return flask.jsonify(item_ids=all_ids) @app.route('/item/<int:item_id>/file') def item_file(item_id): item = g.lib.get_item(item_id) response = flask.send_file(item.path, as_attachment=True, attachment_filename=os.path.basename(item.path)) response.headers['Content-Length'] = os.path.getsize(item.path) return response @app.route('/item/query/<path:query>') def item_query(query): parts = query.split('/') items = g.lib.items(parts) return flask.jsonify(results=[_rep(item) for item in items]) # Albums. @app.route('/album/<int:album_id>') def single_album(album_id): album = g.lib.get_album(album_id) return flask.jsonify(_rep(album)) @app.route('/album/') def all_albums(): with g.lib.transaction() as tx: rows = tx.query("SELECT id FROM albums") all_ids = [row[0] for row in rows] return flask.jsonify(album_ids=all_ids) @app.route('/album/query/<path:query>') def album_query(query): parts = query.split('/') albums = g.lib.albums(parts) return flask.jsonify(results=[_rep(album) for album in albums]) @app.route('/album/<int:album_id>/art') def album_art(album_id): album = g.lib.get_album(album_id) return flask.send_file(album.artpath) # 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(WebPlugin, self).__init__() self.config.add({ 'host': u'', 'port': 8337, }) 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 app.run(host=self.config['host'].get(unicode), port=self.config['port'].get(int), debug=opts.debug, threaded=True) cmd.func = func return [cmd] �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/static/�������������������������������������������������������������������0000755�0000765�0000024�00000000000�12226377756�017641� 5����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/static/backbone.js��������������������������������������������������������0000644�0000765�0000024�00000123137�12013011113�021710� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������// 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 = $('<iframe src="javascript:0" tabindex="-1" />').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, ''').replace(/\//g,'/'); }; }).call(this); ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/static/beets.css����������������������������������������������������������0000644�0000765�0000024�00000005123�12013011113�021414� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������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; } #detail { position: fixed; top: 36px; bottom: 0; left: 17em; margin: 1.0em 0 0 1.5em; } #detail .artist, #detail .album, #detail .title { display: block; } #detail .title { font-size: 1.3em; font-weight: bold; } #detail .albumtitle { font-style: italic; } #detail dl dt, #detail dl dd { list-style: none; margin: 0; padding: 0; } #detail dl dt { width: 10em; float: left; text-align: right; font-weight: bold; clear: both; } #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; } ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/static/beets.js�����������������������������������������������������������0000644�0000765�0000024�00000020041�12013011113�021234� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������// 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.round(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: // http://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) { $.getJSON('/item/query/' + query, 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(); } }); var ItemDetailView = Backbone.View.extend({ tagName: "div", template: _.template($('#item-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); } }); // Main app view. var AppView = Backbone.View.extend({ el: $('body'), events: { 'submit #queryForm': 'querySubmit', }, querySubmit: function(ev) { ev.preventDefault(); router.navigate('item/query/' + escape($('#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 detail. var detailView = new ItemDetailView({model: view.model}); $('#detail').empty().append(detailView.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(); }); �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/static/jquery.js����������������������������������������������������������0000644�0000765�0000024�00000744653�12102026773�021520� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������/*! * jQuery JavaScript Library v1.7.1 * http://jquery.com/ * * Copyright 2013, John Resig * Dual licensed under the MIT or GPL Version 2 licenses. * http://jquery.org/license * * Includes Sizzle.js * http://sizzlejs.com/ * Copyright 2013, 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 <tag> 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 = " <link/><table></table><a href='/a' style='top:1px;float:left;opacity:.55;'>a</a><input type='checkbox'/>"; 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></: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 = "<div " + style + "><div></div></div>" + "<table " + style + " cellpadding='0' cellspacing='0'>" + "<tr><td></td></tr></table>"; 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 = "<table><tr><td style='padding:0;border:0;display:none'></td><td>t</td></tr></table>"; 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 = "<div style='width:4px;'></div>"; 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 2013, 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 = "<a name='" + id + "'/>"; // 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 = "<a href='#'></a>"; 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 = "<p class='TEST'></p>"; // 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 = "<div class='test e'></div><div class='test'></div>"; // 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 = /<tbody/i, rhtml = /<|&#?\w+;/, rnoInnerhtml = /<(?:script|style)/i, rnocache = /<(?:script|object|embed|option|style)/i, rnoshimcache = new RegExp("<(?:" + nodeNames + ")", "i"), // checked="checked" or checked rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i, rscriptType = /\/(java|ecma)script/i, rcleanScript = /^\s*<!(?:\[CDATA\[|\-\-)/, wrapMap = { option: [ 1, "<select multiple='multiple'>", "</select>" ], legend: [ 1, "<fieldset>", "</fieldset>" ], thead: [ 1, "<table>", "</table>" ], tr: [ 2, "<table><tbody>", "</tbody></table>" ], td: [ 3, "<table><tbody><tr>", "</tr></tbody></table>" ], col: [ 2, "<table><tbody></tbody><colgroup>", "</colgroup></table>" ], area: [ 1, "<map>", "</map>" ], _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 <link> and <script> tags normally if ( !jQuery.support.htmlSerialize ) { wrapMap._default = [ 1, "div<div>", "</div>" ]; } jQuery.fn.extend({ text: function( text ) { if ( jQuery.isFunction(text) ) { return this.each(function(i) { var self = jQuery( this ); self.text( text.call(this, i, self.text()) ); }); } if ( typeof text !== "object" && text !== undefined ) { return this.empty().append( (this[0] && this[0].ownerDocument || document).createTextNode( text ) ); } return jQuery.text( this ); }, wrapAll: function( html ) { if ( jQuery.isFunction( html ) ) { return this.each(function(i) { jQuery(this).wrapAll( html.call(this, i) ); }); } if ( this[0] ) { // The elements to wrap the target around var wrap = jQuery( html, this[0].ownerDocument ).eq(0).clone(true); if ( this[0].parentNode ) { wrap.insertBefore( this[0] ); } wrap.map(function() { var elem = this; while ( elem.firstChild && elem.firstChild.nodeType === 1 ) { elem = elem.firstChild; } return elem; }).append( this ); } return this; }, wrapInner: function( html ) { if ( jQuery.isFunction( html ) ) { return this.each(function(i) { jQuery(this).wrapInner( html.call(this, i) ); }); } return this.each(function() { var self = jQuery( this ), contents = self.contents(); if ( contents.length ) { contents.wrapAll( html ); } else { self.append( html ); } }); }, wrap: function( html ) { var isFunction = jQuery.isFunction( html ); return this.each(function(i) { jQuery( this ).wrapAll( isFunction ? html.call(this, i) : html ); }); }, unwrap: function() { return this.parent().each(function() { if ( !jQuery.nodeName( this, "body" ) ) { jQuery( this ).replaceWith( this.childNodes ); } }).end(); }, append: function() { return this.domManip(arguments, true, function( elem ) { if ( this.nodeType === 1 ) { this.appendChild( elem ); } }); }, prepend: function() { return this.domManip(arguments, true, function( elem ) { if ( this.nodeType === 1 ) { this.insertBefore( elem, this.firstChild ); } }); }, before: function() { if ( this[0] && this[0].parentNode ) { return this.domManip(arguments, false, function( elem ) { this.parentNode.insertBefore( elem, this ); }); } else if ( arguments.length ) { var set = jQuery.clean( arguments ); set.push.apply( set, this.toArray() ); return this.pushStack( set, "before", arguments ); } }, after: function() { if ( this[0] && this[0].parentNode ) { return this.domManip(arguments, false, function( elem ) { this.parentNode.insertBefore( elem, this.nextSibling ); }); } else if ( arguments.length ) { var set = this.pushStack( this, "after", arguments ); set.push.apply( set, jQuery.clean(arguments) ); return set; } }, // keepData is for internal use only--do not document remove: function( selector, keepData ) { for ( var i = 0, elem; (elem = this[i]) != null; i++ ) { if ( !selector || jQuery.filter( selector, [ elem ] ).length ) { if ( !keepData && elem.nodeType === 1 ) { jQuery.cleanData( elem.getElementsByTagName("*") ); jQuery.cleanData( [ elem ] ); } if ( elem.parentNode ) { elem.parentNode.removeChild( elem ); } } } return this; }, empty: function() { for ( var i = 0, elem; (elem = this[i]) != null; i++ ) { // Remove element nodes and prevent memory leaks if ( elem.nodeType === 1 ) { jQuery.cleanData( elem.getElementsByTagName("*") ); } // Remove any remaining nodes while ( elem.firstChild ) { elem.removeChild( elem.firstChild ); } } return this; }, clone: function( dataAndEvents, deepDataAndEvents ) { dataAndEvents = dataAndEvents == null ? false : dataAndEvents; deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; return this.map( function () { return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); }); }, html: function( value ) { if ( value === undefined ) { return this[0] && this[0].nodeType === 1 ? this[0].innerHTML.replace(rinlinejQuery, "") : null; // See if we can take a shortcut and just use innerHTML } else if ( typeof value === "string" && !rnoInnerhtml.test( value ) && (jQuery.support.leadingWhitespace || !rleadingWhitespace.test( value )) && !wrapMap[ (rtagName.exec( value ) || ["", ""])[1].toLowerCase() ] ) { value = value.replace(rxhtmlTag, "<$1></$2>"); try { for ( var i = 0, l = this.length; i < l; i++ ) { // Remove element nodes and prevent memory leaks if ( this[i].nodeType === 1 ) { jQuery.cleanData( this[i].getElementsByTagName("*") ); this[i].innerHTML = value; } } // If using innerHTML throws an exception, use the fallback method } catch(e) { this.empty().append( value ); } } else if ( jQuery.isFunction( value ) ) { this.each(function(i){ var self = jQuery( this ); self.html( value.call(this, i, self.html()) ); }); } else { this.empty().append( value ); } return this; }, replaceWith: function( value ) { if ( this[0] && this[0].parentNode ) { // Make sure that the elements are removed from the DOM before they are inserted // this can help fix replacing a parent with child elements if ( jQuery.isFunction( value ) ) { return this.each(function(i) { var self = jQuery(this), old = self.html(); self.replaceWith( value.call( this, i, old ) ); }); } if ( typeof value !== "string" ) { value = jQuery( value ).detach(); } return this.each(function() { var next = this.nextSibling, parent = this.parentNode; jQuery( this ).remove(); if ( next ) { jQuery(next).before( value ); } else { jQuery(parent).append( value ); } }); } else { return this.length ? this.pushStack( jQuery(jQuery.isFunction(value) ? value() : value), "replaceWith", value ) : this; } }, detach: function( selector ) { return this.remove( selector, true ); }, domManip: function( args, table, callback ) { var results, first, fragment, parent, value = args[0], scripts = []; // We can't cloneNode fragments that contain checked, in WebKit if ( !jQuery.support.checkClone && arguments.length === 3 && typeof value === "string" && rchecked.test( value ) ) { return this.each(function() { jQuery(this).domManip( args, table, callback, true ); }); } if ( jQuery.isFunction(value) ) { return this.each(function(i) { var self = jQuery(this); args[0] = value.call(this, i, table ? self.html() : undefined); self.domManip( args, table, callback ); }); } if ( this[0] ) { parent = value && value.parentNode; // If we're in a fragment, just use that instead of building a new one if ( jQuery.support.parentNode && parent && parent.nodeType === 11 && parent.childNodes.length === this.length ) { results = { fragment: parent }; } else { results = jQuery.buildFragment( args, this, scripts ); } fragment = results.fragment; if ( fragment.childNodes.length === 1 ) { first = fragment = fragment.firstChild; } else { first = fragment.firstChild; } if ( first ) { table = table && jQuery.nodeName( first, "tr" ); for ( var i = 0, l = this.length, lastIndex = l - 1; i < l; i++ ) { callback.call( table ? root(this[i], first) : this[i], // Make sure that we do not leak memory by inadvertently discarding // the original fragment (which might have attached data) instead of // using it; in addition, use the original fragment object for the last // item instead of first because it can end up being emptied incorrectly // in certain situations (Bug #8070). // Fragments from the fragment cache must always be cloned and never used // in place. results.cacheable || ( l > 1 && i < lastIndex ) ? jQuery.clone( fragment, true, true ) : fragment ); } } if ( scripts.length ) { jQuery.each( scripts, evalScript ); } } return this; } }); function root( elem, cur ) { return jQuery.nodeName(elem, "table") ? (elem.getElementsByTagName("tbody")[0] || elem.appendChild(elem.ownerDocument.createElement("tbody"))) : elem; } function cloneCopyEvent( src, dest ) { if ( dest.nodeType !== 1 || !jQuery.hasData( src ) ) { return; } var type, i, l, oldData = jQuery._data( src ), curData = jQuery._data( dest, oldData ), events = oldData.events; if ( events ) { delete curData.handle; curData.events = {}; for ( type in events ) { for ( i = 0, l = events[ type ].length; i < l; i++ ) { jQuery.event.add( dest, type + ( events[ type ][ i ].namespace ? "." : "" ) + events[ type ][ i ].namespace, events[ type ][ i ], events[ type ][ i ].data ); } } } // make the cloned public data object a copy from the original if ( curData.data ) { curData.data = jQuery.extend( {}, curData.data ); } } function cloneFixAttributes( src, dest ) { var nodeName; // We do not need to do anything for non-Elements if ( dest.nodeType !== 1 ) { return; } // clearAttributes removes the attributes, which we don't want, // but also removes the attachEvent events, which we *do* want if ( dest.clearAttributes ) { dest.clearAttributes(); } // mergeAttributes, in contrast, only merges back on the // original attributes, not the events if ( dest.mergeAttributes ) { dest.mergeAttributes( src ); } nodeName = dest.nodeName.toLowerCase(); // IE6-8 fail to clone children inside object elements that use // the proprietary classid attribute value (rather than the type // attribute) to identify the type of content to display if ( nodeName === "object" ) { dest.outerHTML = src.outerHTML; } else if ( nodeName === "input" && (src.type === "checkbox" || src.type === "radio") ) { // IE6-8 fails to persist the checked state of a cloned checkbox // or radio button. Worse, IE6-7 fail to give the cloned element // a checked appearance if the defaultChecked value isn't also set if ( src.checked ) { dest.defaultChecked = dest.checked = src.checked; } // IE6-7 get confused and end up setting the value of a cloned // checkbox/radio button to an empty string instead of "on" if ( dest.value !== src.value ) { dest.value = src.value; } // IE6-8 fails to return the selected option to the default selected // state when cloning options } else if ( nodeName === "option" ) { dest.selected = src.defaultSelected; // IE6-8 fails to set the defaultValue to the correct value when // cloning other types of input fields } else if ( nodeName === "input" || nodeName === "textarea" ) { dest.defaultValue = src.defaultValue; } // Event data gets referenced instead of copied if the expando // gets copied too dest.removeAttribute( jQuery.expando ); } jQuery.buildFragment = function( args, nodes, scripts ) { var fragment, cacheable, cacheresults, doc, first = args[ 0 ]; // nodes may contain either an explicit document object, // a jQuery collection or context object. // If nodes[0] contains a valid object to assign to doc if ( nodes && nodes[0] ) { doc = nodes[0].ownerDocument || nodes[0]; } // Ensure that an attr object doesn't incorrectly stand in as a document object // Chrome and Firefox seem to allow this to occur and will throw exception // Fixes #8950 if ( !doc.createDocumentFragment ) { doc = document; } // Only cache "small" (1/2 KB) HTML strings that are associated with the main document // Cloning options loses the selected state, so don't cache them // IE 6 doesn't like it when you put <object> or <embed> elements in a fragment // Also, WebKit does not clone 'checked' attributes on cloneNode, so don't cache // Lastly, IE6,7,8 will not correctly reuse cached fragments that were created from unknown elems #10501 if ( args.length === 1 && typeof first === "string" && first.length < 512 && doc === document && first.charAt(0) === "<" && !rnocache.test( first ) && (jQuery.support.checkClone || !rchecked.test( first )) && (jQuery.support.html5Clone || !rnoshimcache.test( first )) ) { cacheable = true; cacheresults = jQuery.fragments[ first ]; if ( cacheresults && cacheresults !== 1 ) { fragment = cacheresults; } } if ( !fragment ) { fragment = doc.createDocumentFragment(); jQuery.clean( args, doc, fragment, scripts ); } if ( cacheable ) { jQuery.fragments[ first ] = cacheresults ? fragment : 1; } return { fragment: fragment, cacheable: cacheable }; }; jQuery.fragments = {}; jQuery.each({ appendTo: "append", prependTo: "prepend", insertBefore: "before", insertAfter: "after", replaceAll: "replaceWith" }, function( name, original ) { jQuery.fn[ name ] = function( selector ) { var ret = [], insert = jQuery( selector ), parent = this.length === 1 && this[0].parentNode; if ( parent && parent.nodeType === 11 && parent.childNodes.length === 1 && insert.length === 1 ) { insert[ original ]( this[0] ); return this; } else { for ( var i = 0, l = insert.length; i < l; i++ ) { var elems = ( i > 0 ? this.clone(true) : this ).get(); jQuery( insert[i] )[ original ]( elems ); ret = ret.concat( elems ); } return this.pushStack( ret, name, insert.selector ); } }; }); function getAll( elem ) { if ( typeof elem.getElementsByTagName !== "undefined" ) { return elem.getElementsByTagName( "*" ); } else if ( typeof elem.querySelectorAll !== "undefined" ) { return elem.querySelectorAll( "*" ); } else { return []; } } // Used in clean, fixes the defaultChecked property function fixDefaultChecked( elem ) { if ( elem.type === "checkbox" || elem.type === "radio" ) { elem.defaultChecked = elem.checked; } } // Finds all inputs and passes them to fixDefaultChecked function findInputs( elem ) { var nodeName = ( elem.nodeName || "" ).toLowerCase(); if ( nodeName === "input" ) { fixDefaultChecked( elem ); // Skip scripts, get other children } else if ( nodeName !== "script" && typeof elem.getElementsByTagName !== "undefined" ) { jQuery.grep( elem.getElementsByTagName("input"), fixDefaultChecked ); } } // Derived From: http://www.iecss.com/shimprove/javascript/shimprove.1-0-1.js function shimCloneNode( elem ) { var div = document.createElement( "div" ); safeFragment.appendChild( div ); div.innerHTML = elem.outerHTML; return div.firstChild; } jQuery.extend({ clone: function( elem, dataAndEvents, deepDataAndEvents ) { var srcElements, destElements, i, // IE<=8 does not properly clone detached, unknown element nodes clone = jQuery.support.html5Clone || !rnoshimcache.test( "<" + elem.nodeName ) ? elem.cloneNode( true ) : shimCloneNode( elem ); if ( (!jQuery.support.noCloneEvent || !jQuery.support.noCloneChecked) && (elem.nodeType === 1 || elem.nodeType === 11) && !jQuery.isXMLDoc(elem) ) { // IE copies events bound via attachEvent when using cloneNode. // Calling detachEvent on the clone will also remove the events // from the original. In order to get around this, we use some // proprietary methods to clear the events. Thanks to MooTools // guys for this hotness. cloneFixAttributes( elem, clone ); // Using Sizzle here is crazy slow, so we use getElementsByTagName instead srcElements = getAll( elem ); destElements = getAll( clone ); // Weird iteration because IE will replace the length property // with an element if you are cloning the body and one of the // elements on the page has a name or id of "length" for ( i = 0; srcElements[i]; ++i ) { // Ensure that the destination node is not null; Fixes #9587 if ( destElements[i] ) { cloneFixAttributes( srcElements[i], destElements[i] ); } } } // Copy the events from the original to the clone if ( dataAndEvents ) { cloneCopyEvent( elem, clone ); if ( deepDataAndEvents ) { srcElements = getAll( elem ); destElements = getAll( clone ); for ( i = 0; srcElements[i]; ++i ) { cloneCopyEvent( srcElements[i], destElements[i] ); } } } srcElements = destElements = null; // Return the cloned set return clone; }, clean: function( elems, context, fragment, scripts ) { var checkScriptType; context = context || document; // !context.createElement fails in IE with an error but returns typeof 'object' if ( typeof context.createElement === "undefined" ) { context = context.ownerDocument || context[0] && context[0].ownerDocument || document; } var ret = [], j; for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { if ( typeof elem === "number" ) { elem += ""; } if ( !elem ) { continue; } // Convert html string into DOM nodes if ( typeof elem === "string" ) { if ( !rhtml.test( elem ) ) { elem = context.createTextNode( elem ); } else { // Fix "XHTML"-style tags in all browsers elem = elem.replace(rxhtmlTag, "<$1></$2>"); // Trim whitespace, otherwise indexOf won't work as expected var tag = ( rtagName.exec( elem ) || ["", ""] )[1].toLowerCase(), wrap = wrapMap[ tag ] || wrapMap._default, depth = wrap[0], div = context.createElement("div"); // Append wrapper element to unknown element safe doc fragment if ( context === document ) { // Use the fragment we've already created for this document safeFragment.appendChild( div ); } else { // Use a fragment created with the owner document createSafeFragment( context ).appendChild( div ); } // Go to html and back, then peel off extra wrappers div.innerHTML = wrap[1] + elem + wrap[2]; // Move to the right depth while ( depth-- ) { div = div.lastChild; } // Remove IE's autoinserted <tbody> from table fragments if ( !jQuery.support.tbody ) { // String was a <table>, *may* have spurious <tbody> var hasBody = rtbody.test(elem), tbody = tag === "table" && !hasBody ? div.firstChild && div.firstChild.childNodes : // String was a bare <thead> or <tfoot> wrap[1] === "<table>" && !hasBody ? div.childNodes : []; for ( j = tbody.length - 1; j >= 0 ; --j ) { if ( jQuery.nodeName( tbody[ j ], "tbody" ) && !tbody[ j ].childNodes.length ) { tbody[ j ].parentNode.removeChild( tbody[ j ] ); } } } // IE completely kills leading whitespace when innerHTML is used if ( !jQuery.support.leadingWhitespace && rleadingWhitespace.test( elem ) ) { div.insertBefore( context.createTextNode( rleadingWhitespace.exec(elem)[0] ), div.firstChild ); } elem = div.childNodes; } } // Resets defaultChecked for any radios and checkboxes // about to be appended to the DOM in IE 6/7 (#8060) var len; if ( !jQuery.support.appendChecked ) { if ( elem[0] && typeof (len = elem.length) === "number" ) { for ( j = 0; j < len; j++ ) { findInputs( elem[j] ); } } else { findInputs( elem ); } } if ( elem.nodeType ) { ret.push( elem ); } else { ret = jQuery.merge( ret, elem ); } } if ( fragment ) { checkScriptType = function( elem ) { return !elem.type || rscriptType.test( elem.type ); }; for ( i = 0; ret[i]; i++ ) { if ( scripts && jQuery.nodeName( ret[i], "script" ) && (!ret[i].type || ret[i].type.toLowerCase() === "text/javascript") ) { scripts.push( ret[i].parentNode ? ret[i].parentNode.removeChild( ret[i] ) : ret[i] ); } else { if ( ret[i].nodeType === 1 ) { var jsTags = jQuery.grep( ret[i].getElementsByTagName( "script" ), checkScriptType ); ret.splice.apply( ret, [i + 1, 0].concat( jsTags ) ); } fragment.appendChild( ret[i] ); } } } return ret; }, cleanData: function( elems ) { var data, id, cache = jQuery.cache, special = jQuery.event.special, deleteExpando = jQuery.support.deleteExpando; for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { if ( elem.nodeName && jQuery.noData[elem.nodeName.toLowerCase()] ) { continue; } id = elem[ jQuery.expando ]; if ( id ) { data = cache[ id ]; if ( data && data.events ) { for ( var type in data.events ) { if ( special[ type ] ) { jQuery.event.remove( elem, type ); // This is a shortcut to avoid jQuery.event.remove's overhead } else { jQuery.removeEvent( elem, type, data.handle ); } } // Null the DOM reference to avoid IE6/7/8 leak (#7054) if ( data.handle ) { data.handle.elem = null; } } if ( deleteExpando ) { delete elem[ jQuery.expando ]; } else if ( elem.removeAttribute ) { elem.removeAttribute( jQuery.expando ); } delete cache[ id ]; } } } }); function evalScript( i, elem ) { if ( elem.src ) { jQuery.ajax({ url: elem.src, async: false, dataType: "script" }); } else { jQuery.globalEval( ( elem.text || elem.textContent || elem.innerHTML || "" ).replace( rcleanScript, "/*$0*/" ) ); } if ( elem.parentNode ) { elem.parentNode.removeChild( elem ); } } var ralpha = /alpha\([^)]*\)/i, ropacity = /opacity=([^)]*)/, // fixed for IE9, see #8346 rupper = /([A-Z]|^ms)/g, rnumpx = /^-?\d+(?:px)?$/i, rnum = /^-?\d/, rrelNum = /^([\-+])=([\-+.\de]+)/, cssShow = { position: "absolute", visibility: "hidden", display: "block" }, cssWidth = [ "Left", "Right" ], cssHeight = [ "Top", "Bottom" ], curCSS, getComputedStyle, currentStyle; jQuery.fn.css = function( name, value ) { // Setting 'undefined' is a no-op if ( arguments.length === 2 && value === undefined ) { return this; } return jQuery.access( this, name, value, true, function( elem, name, value ) { return value !== undefined ? jQuery.style( elem, name, value ) : jQuery.css( elem, name ); }); }; jQuery.extend({ // Add in style property hooks for overriding the default // behavior of getting and setting a style property cssHooks: { opacity: { get: function( elem, computed ) { if ( computed ) { // We should always get a number back from opacity var ret = curCSS( elem, "opacity", "opacity" ); return ret === "" ? "1" : ret; } else { return elem.style.opacity; } } } }, // Exclude the following css properties to add px cssNumber: { "fillOpacity": true, "fontWeight": true, "lineHeight": true, "opacity": true, "orphans": true, "widows": true, "zIndex": true, "zoom": true }, // Add in properties whose names you wish to fix before // setting or getting the value cssProps: { // normalize float css property "float": jQuery.support.cssFloat ? "cssFloat" : "styleFloat" }, // Get and set the style property on a DOM Node style: function( elem, name, value, extra ) { // Don't set styles on text and comment nodes if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { return; } // Make sure that we're working with the right name var ret, type, origName = jQuery.camelCase( name ), style = elem.style, hooks = jQuery.cssHooks[ origName ]; name = jQuery.cssProps[ origName ] || origName; // Check if we're setting a value if ( value !== undefined ) { type = typeof value; // convert relative number strings (+= or -=) to relative numbers. #7345 if ( type === "string" && (ret = rrelNum.exec( value )) ) { value = ( +( ret[1] + 1) * +ret[2] ) + parseFloat( jQuery.css( elem, name ) ); // Fixes bug #9237 type = "number"; } // Make sure that NaN and null values aren't set. See: #7116 if ( value == null || type === "number" && isNaN( value ) ) { return; } // If a number was passed in, add 'px' to the (except for certain CSS properties) if ( type === "number" && !jQuery.cssNumber[ origName ] ) { value += "px"; } // If a hook was provided, use that value, otherwise just set the specified value if ( !hooks || !("set" in hooks) || (value = hooks.set( elem, value )) !== undefined ) { // Wrapped to prevent IE from throwing errors when 'invalid' values are provided // Fixes bug #5509 try { style[ name ] = value; } catch(e) {} } } else { // If a hook was provided get the non-computed value from there if ( hooks && "get" in hooks && (ret = hooks.get( elem, false, extra )) !== undefined ) { return ret; } // Otherwise just get the value from the style object return style[ name ]; } }, css: function( elem, name, extra ) { var ret, hooks; // Make sure that we're working with the right name name = jQuery.camelCase( name ); hooks = jQuery.cssHooks[ name ]; name = jQuery.cssProps[ name ] || name; // cssFloat needs a special treatment if ( name === "cssFloat" ) { name = "float"; } // If a hook was provided get the computed value from there if ( hooks && "get" in hooks && (ret = hooks.get( elem, true, extra )) !== undefined ) { return ret; // Otherwise, if a way to get the computed value exists, use that } else if ( curCSS ) { return curCSS( elem, name ); } }, // A method for quickly swapping in/out CSS properties to get correct calculations swap: function( elem, options, callback ) { var old = {}; // Remember the old values, and insert the new ones for ( var name in options ) { old[ name ] = elem.style[ name ]; elem.style[ name ] = options[ name ]; } callback.call( elem ); // Revert the old values for ( name in options ) { elem.style[ name ] = old[ name ]; } } }); // DEPRECATED, Use jQuery.css() instead jQuery.curCSS = jQuery.css; jQuery.each(["height", "width"], function( i, name ) { jQuery.cssHooks[ name ] = { get: function( elem, computed, extra ) { var val; if ( computed ) { if ( elem.offsetWidth !== 0 ) { return getWH( elem, name, extra ); } else { jQuery.swap( elem, cssShow, function() { val = getWH( elem, name, extra ); }); } return val; } }, set: function( elem, value ) { if ( rnumpx.test( value ) ) { // ignore negative width and height values #1599 value = parseFloat( value ); if ( value >= 0 ) { return value + "px"; } } else { return value; } } }; }); if ( !jQuery.support.opacity ) { jQuery.cssHooks.opacity = { get: function( elem, computed ) { // IE uses filters for opacity return ropacity.test( (computed && elem.currentStyle ? elem.currentStyle.filter : elem.style.filter) || "" ) ? ( parseFloat( RegExp.$1 ) / 100 ) + "" : computed ? "1" : ""; }, set: function( elem, value ) { var style = elem.style, currentStyle = elem.currentStyle, opacity = jQuery.isNumeric( value ) ? "alpha(opacity=" + value * 100 + ")" : "", filter = currentStyle && currentStyle.filter || style.filter || ""; // IE has trouble with opacity if it does not have layout // Force it by setting the zoom level style.zoom = 1; // if setting opacity to 1, and no other filters exist - attempt to remove filter attribute #6652 if ( value >= 1 && jQuery.trim( filter.replace( ralpha, "" ) ) === "" ) { // Setting style.filter to null, "" & " " still leave "filter:" in the cssText // if "filter:" is present at all, clearType is disabled, we want to avoid this // style.removeAttribute is IE Only, but so apparently is this code path... style.removeAttribute( "filter" ); // if there there is no filter style applied in a css rule, we are done if ( currentStyle && !currentStyle.filter ) { return; } } // otherwise, set new filter values style.filter = ralpha.test( filter ) ? filter.replace( ralpha, opacity ) : filter + " " + opacity; } }; } jQuery(function() { // This hook cannot be added until DOM ready because the support test // for it is not run until after DOM ready if ( !jQuery.support.reliableMarginRight ) { jQuery.cssHooks.marginRight = { get: function( elem, computed ) { // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right // Work around by temporarily setting element display to inline-block var ret; jQuery.swap( elem, { "display": "inline-block" }, function() { if ( computed ) { ret = curCSS( elem, "margin-right", "marginRight" ); } else { ret = elem.style.marginRight; } }); return ret; } }; } }); if ( document.defaultView && document.defaultView.getComputedStyle ) { getComputedStyle = function( elem, name ) { var ret, defaultView, computedStyle; name = name.replace( rupper, "-$1" ).toLowerCase(); if ( (defaultView = elem.ownerDocument.defaultView) && (computedStyle = defaultView.getComputedStyle( elem, null )) ) { ret = computedStyle.getPropertyValue( name ); if ( ret === "" && !jQuery.contains( elem.ownerDocument.documentElement, elem ) ) { ret = jQuery.style( elem, name ); } } return ret; }; } if ( document.documentElement.currentStyle ) { currentStyle = function( elem, name ) { var left, rsLeft, uncomputed, ret = elem.currentStyle && elem.currentStyle[ name ], style = elem.style; // Avoid setting ret to empty string here // so we don't default to auto if ( ret === null && style && (uncomputed = style[ name ]) ) { ret = uncomputed; } // From the awesome hack by Dean Edwards // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 // If we're not dealing with a regular pixel number // but a number that has a weird ending, we need to convert it to pixels if ( !rnumpx.test( ret ) && rnum.test( ret ) ) { // Remember the original values left = style.left; rsLeft = elem.runtimeStyle && elem.runtimeStyle.left; // Put in the new values to get a computed value out if ( rsLeft ) { elem.runtimeStyle.left = elem.currentStyle.left; } style.left = name === "fontSize" ? "1em" : ( ret || 0 ); ret = style.pixelLeft + "px"; // Revert the changed values style.left = left; if ( rsLeft ) { elem.runtimeStyle.left = rsLeft; } } return ret === "" ? "auto" : ret; }; } curCSS = getComputedStyle || currentStyle; function getWH( elem, name, extra ) { // Start with offset property var val = name === "width" ? elem.offsetWidth : elem.offsetHeight, which = name === "width" ? cssWidth : cssHeight, i = 0, len = which.length; if ( val > 0 ) { if ( extra !== "border" ) { for ( ; i < len; i++ ) { if ( !extra ) { val -= parseFloat( jQuery.css( elem, "padding" + which[ i ] ) ) || 0; } if ( extra === "margin" ) { val += parseFloat( jQuery.css( elem, extra + which[ i ] ) ) || 0; } else { val -= parseFloat( jQuery.css( elem, "border" + which[ i ] + "Width" ) ) || 0; } } } return val + "px"; } // Fall back to computed then uncomputed css if necessary val = curCSS( elem, name, name ); if ( val < 0 || val == null ) { val = elem.style[ name ] || 0; } // Normalize "", auto, and prepare for extra val = parseFloat( val ) || 0; // Add padding, border, margin if ( extra ) { for ( ; i < len; i++ ) { val += parseFloat( jQuery.css( elem, "padding" + which[ i ] ) ) || 0; if ( extra !== "padding" ) { val += parseFloat( jQuery.css( elem, "border" + which[ i ] + "Width" ) ) || 0; } if ( extra === "margin" ) { val += parseFloat( jQuery.css( elem, extra + which[ i ] ) ) || 0; } } } return val + "px"; } if ( jQuery.expr && jQuery.expr.filters ) { jQuery.expr.filters.hidden = function( elem ) { var width = elem.offsetWidth, height = elem.offsetHeight; return ( width === 0 && height === 0 ) || (!jQuery.support.reliableHiddenOffsets && ((elem.style && elem.style.display) || jQuery.css( elem, "display" )) === "none"); }; jQuery.expr.filters.visible = function( elem ) { return !jQuery.expr.filters.hidden( elem ); }; } var r20 = /%20/g, rbracket = /\[\]$/, rCRLF = /\r?\n/g, rhash = /#.*$/, rheaders = /^(.*?):[ \t]*([^\r\n]*)\r?$/mg, // IE leaves an \r character at EOL rinput = /^(?:color|date|datetime|datetime-local|email|hidden|month|number|password|range|search|tel|text|time|url|week)$/i, // #7653, #8125, #8152: local protocol detection rlocalProtocol = /^(?:about|app|app\-storage|.+\-extension|file|res|widget):$/, rnoContent = /^(?:GET|HEAD)$/, rprotocol = /^\/\//, rquery = /\?/, rscript = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, rselectTextarea = /^(?:select|textarea)/i, rspacesAjax = /\s+/, rts = /([?&])_=[^&]*/, rurl = /^([\w\+\.\-]+:)(?:\/\/([^\/?#:]*)(?::(\d+))?)?/, // Keep a copy of the old load method _load = jQuery.fn.load, /* Prefilters * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example) * 2) These are called: * - BEFORE asking for a transport * - AFTER param serialization (s.data is a string if s.processData is true) * 3) key is the dataType * 4) the catchall symbol "*" can be used * 5) execution will start with transport dataType and THEN continue down to "*" if needed */ prefilters = {}, /* Transports bindings * 1) key is the dataType * 2) the catchall symbol "*" can be used * 3) selection will start with transport dataType and THEN go to "*" if needed */ transports = {}, // Document location ajaxLocation, // Document location segments ajaxLocParts, // Avoid comment-prolog char sequence (#10098); must appease lint and evade compression allTypes = ["*/"] + ["*"]; // #8138, IE may throw an exception when accessing // a field from window.location if document.domain has been set try { ajaxLocation = location.href; } catch( e ) { // Use the href attribute of an A element // since IE will modify it given document.location ajaxLocation = document.createElement( "a" ); ajaxLocation.href = ""; ajaxLocation = ajaxLocation.href; } // Segment location into parts ajaxLocParts = rurl.exec( ajaxLocation.toLowerCase() ) || []; // Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport function addToPrefiltersOrTransports( structure ) { // dataTypeExpression is optional and defaults to "*" return function( dataTypeExpression, func ) { if ( typeof dataTypeExpression !== "string" ) { func = dataTypeExpression; dataTypeExpression = "*"; } if ( jQuery.isFunction( func ) ) { var dataTypes = dataTypeExpression.toLowerCase().split( rspacesAjax ), i = 0, length = dataTypes.length, dataType, list, placeBefore; // For each dataType in the dataTypeExpression for ( ; i < length; i++ ) { dataType = dataTypes[ i ]; // We control if we're asked to add before // any existing element placeBefore = /^\+/.test( dataType ); if ( placeBefore ) { dataType = dataType.substr( 1 ) || "*"; } list = structure[ dataType ] = structure[ dataType ] || []; // then we add to the structure accordingly list[ placeBefore ? "unshift" : "push" ]( func ); } } }; } // Base inspection function for prefilters and transports function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR, dataType /* internal */, inspected /* internal */ ) { dataType = dataType || options.dataTypes[ 0 ]; inspected = inspected || {}; inspected[ dataType ] = true; var list = structure[ dataType ], i = 0, length = list ? list.length : 0, executeOnly = ( structure === prefilters ), selection; for ( ; i < length && ( executeOnly || !selection ); i++ ) { selection = list[ i ]( options, originalOptions, jqXHR ); // If we got redirected to another dataType // we try there if executing only and not done already if ( typeof selection === "string" ) { if ( !executeOnly || inspected[ selection ] ) { selection = undefined; } else { options.dataTypes.unshift( selection ); selection = inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR, selection, inspected ); } } } // If we're only executing or nothing was selected // we try the catchall dataType if not done already if ( ( executeOnly || !selection ) && !inspected[ "*" ] ) { selection = inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR, "*", inspected ); } // unnecessary when only executing (prefilters) // but it'll be ignored by the caller in that case return selection; } // A special extend for ajax options // that takes "flat" options (not to be deep extended) // Fixes #9887 function ajaxExtend( target, src ) { var key, deep, flatOptions = jQuery.ajaxSettings.flatOptions || {}; for ( key in src ) { if ( src[ key ] !== undefined ) { ( flatOptions[ key ] ? target : ( deep || ( deep = {} ) ) )[ key ] = src[ key ]; } } if ( deep ) { jQuery.extend( true, target, deep ); } } jQuery.fn.extend({ load: function( url, params, callback ) { if ( typeof url !== "string" && _load ) { return _load.apply( this, arguments ); // Don't do a request if no elements are being requested } else if ( !this.length ) { return this; } var off = url.indexOf( " " ); if ( off >= 0 ) { var selector = url.slice( off, url.length ); url = url.slice( 0, off ); } // Default to a GET request var type = "GET"; // If the second parameter was provided if ( params ) { // If it's a function if ( jQuery.isFunction( params ) ) { // We assume that it's the callback callback = params; params = undefined; // Otherwise, build a param string } else if ( typeof params === "object" ) { params = jQuery.param( params, jQuery.ajaxSettings.traditional ); type = "POST"; } } var self = this; // Request the remote document jQuery.ajax({ url: url, type: type, dataType: "html", data: params, // Complete callback (responseText is used internally) complete: function( jqXHR, status, responseText ) { // Store the response as specified by the jqXHR object responseText = jqXHR.responseText; // If successful, inject the HTML into all the matched elements if ( jqXHR.isResolved() ) { // #4825: Get the actual response in case // a dataFilter is present in ajaxSettings jqXHR.done(function( r ) { responseText = r; }); // See if a selector was specified self.html( selector ? // Create a dummy div to hold the results jQuery("<div>") // inject the contents of the document in, removing the scripts // to avoid any 'Permission Denied' errors in IE .append(responseText.replace(rscript, "")) // Locate the specified elements .find(selector) : // If not, just inject the full result responseText ); } if ( callback ) { self.each( callback, [ responseText, status, jqXHR ] ); } } }); return this; }, serialize: function() { return jQuery.param( this.serializeArray() ); }, serializeArray: function() { return this.map(function(){ return this.elements ? jQuery.makeArray( this.elements ) : this; }) .filter(function(){ return this.name && !this.disabled && ( this.checked || rselectTextarea.test( this.nodeName ) || rinput.test( this.type ) ); }) .map(function( i, elem ){ var val = jQuery( this ).val(); return val == null ? null : jQuery.isArray( val ) ? jQuery.map( val, function( val, i ){ return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; }) : { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; }).get(); } }); // Attach a bunch of functions for handling common AJAX events jQuery.each( "ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split( " " ), function( i, o ){ jQuery.fn[ o ] = function( f ){ return this.on( o, f ); }; }); jQuery.each( [ "get", "post" ], function( i, method ) { jQuery[ method ] = function( url, data, callback, type ) { // shift arguments if data argument was omitted if ( jQuery.isFunction( data ) ) { type = type || callback; callback = data; data = undefined; } return jQuery.ajax({ type: method, url: url, data: data, success: callback, dataType: type }); }; }); jQuery.extend({ getScript: function( url, callback ) { return jQuery.get( url, undefined, callback, "script" ); }, getJSON: function( url, data, callback ) { return jQuery.get( url, data, callback, "json" ); }, // Creates a full fledged settings object into target // with both ajaxSettings and settings fields. // If target is omitted, writes into ajaxSettings. ajaxSetup: function( target, settings ) { if ( settings ) { // Building a settings object ajaxExtend( target, jQuery.ajaxSettings ); } else { // Extending ajaxSettings settings = target; target = jQuery.ajaxSettings; } ajaxExtend( target, settings ); return target; }, ajaxSettings: { url: ajaxLocation, isLocal: rlocalProtocol.test( ajaxLocParts[ 1 ] ), global: true, type: "GET", contentType: "application/x-www-form-urlencoded", processData: true, async: true, /* timeout: 0, data: null, dataType: null, username: null, password: null, cache: null, traditional: false, headers: {}, */ accepts: { xml: "application/xml, text/xml", html: "text/html", text: "text/plain", json: "application/json, text/javascript", "*": allTypes }, contents: { xml: /xml/, html: /html/, json: /json/ }, responseFields: { xml: "responseXML", text: "responseText" }, // List of data converters // 1) key format is "source_type destination_type" (a single space in-between) // 2) the catchall symbol "*" can be used for source_type converters: { // Convert anything to text "* text": window.String, // Text to html (true = no transformation) "text html": true, // Evaluate text as a json expression "text json": jQuery.parseJSON, // Parse text as xml "text xml": jQuery.parseXML }, // For options that shouldn't be deep extended: // you can add your own custom options here if // and when you create one that shouldn't be // deep extended (see ajaxExtend) flatOptions: { context: true, url: true } }, ajaxPrefilter: addToPrefiltersOrTransports( prefilters ), ajaxTransport: addToPrefiltersOrTransports( transports ), // Main method ajax: function( url, options ) { // If url is an object, simulate pre-1.5 signature if ( typeof url === "object" ) { options = url; url = undefined; } // Force options to be an object options = options || {}; var // Create the final options object s = jQuery.ajaxSetup( {}, options ), // Callbacks context callbackContext = s.context || s, // Context for global events // It's the callbackContext if one was provided in the options // and if it's a DOM node or a jQuery collection globalEventContext = callbackContext !== s && ( callbackContext.nodeType || callbackContext instanceof jQuery ) ? jQuery( callbackContext ) : jQuery.event, // Deferreds deferred = jQuery.Deferred(), completeDeferred = jQuery.Callbacks( "once memory" ), // Status-dependent callbacks statusCode = s.statusCode || {}, // ifModified key ifModifiedKey, // Headers (they are sent all at once) requestHeaders = {}, requestHeadersNames = {}, // Response headers responseHeadersString, responseHeaders, // transport transport, // timeout handle timeoutTimer, // Cross-domain detection vars parts, // The jqXHR state state = 0, // To know if global events are to be dispatched fireGlobals, // Loop variable i, // Fake xhr jqXHR = { readyState: 0, // Caches the header setRequestHeader: function( name, value ) { if ( !state ) { var lname = name.toLowerCase(); name = requestHeadersNames[ lname ] = requestHeadersNames[ lname ] || name; requestHeaders[ name ] = value; } return this; }, // Raw string getAllResponseHeaders: function() { return state === 2 ? responseHeadersString : null; }, // Builds headers hashtable if needed getResponseHeader: function( key ) { var match; if ( state === 2 ) { if ( !responseHeaders ) { responseHeaders = {}; while( ( match = rheaders.exec( responseHeadersString ) ) ) { responseHeaders[ match[1].toLowerCase() ] = match[ 2 ]; } } match = responseHeaders[ key.toLowerCase() ]; } return match === undefined ? null : match; }, // Overrides response content-type header overrideMimeType: function( type ) { if ( !state ) { s.mimeType = type; } return this; }, // Cancel the request abort: function( statusText ) { statusText = statusText || "abort"; if ( transport ) { transport.abort( statusText ); } done( 0, statusText ); return this; } }; // Callback for when everything is done // It is defined here because jslint complains if it is declared // at the end of the function (which would be more logical and readable) function done( status, nativeStatusText, responses, headers ) { // Called once if ( state === 2 ) { return; } // State is "done" now state = 2; // Clear timeout if it exists if ( timeoutTimer ) { clearTimeout( timeoutTimer ); } // Dereference transport for early garbage collection // (no matter how long the jqXHR object will be used) transport = undefined; // Cache response headers responseHeadersString = headers || ""; // Set readyState jqXHR.readyState = status > 0 ? 4 : 0; var isSuccess, success, error, statusText = nativeStatusText, response = responses ? ajaxHandleResponses( s, jqXHR, responses ) : undefined, lastModified, etag; // If successful, handle type chaining if ( status >= 200 && status < 300 || status === 304 ) { // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. if ( s.ifModified ) { if ( ( lastModified = jqXHR.getResponseHeader( "Last-Modified" ) ) ) { jQuery.lastModified[ ifModifiedKey ] = lastModified; } if ( ( etag = jqXHR.getResponseHeader( "Etag" ) ) ) { jQuery.etag[ ifModifiedKey ] = etag; } } // If not modified if ( status === 304 ) { statusText = "notmodified"; isSuccess = true; // If we have data } else { try { success = ajaxConvert( s, response ); statusText = "success"; isSuccess = true; } catch(e) { // We have a parsererror statusText = "parsererror"; error = e; } } } else { // We extract error from statusText // then normalize statusText and status for non-aborts error = statusText; if ( !statusText || status ) { statusText = "error"; if ( status < 0 ) { status = 0; } } } // Set data for the fake xhr object jqXHR.status = status; jqXHR.statusText = "" + ( nativeStatusText || statusText ); // Success/Error if ( isSuccess ) { deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); } else { deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); } // Status-dependent callbacks jqXHR.statusCode( statusCode ); statusCode = undefined; if ( fireGlobals ) { globalEventContext.trigger( "ajax" + ( isSuccess ? "Success" : "Error" ), [ jqXHR, s, isSuccess ? success : error ] ); } // Complete completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] ); if ( fireGlobals ) { globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] ); // Handle the global AJAX counter if ( !( --jQuery.active ) ) { jQuery.event.trigger( "ajaxStop" ); } } } // Attach deferreds deferred.promise( jqXHR ); jqXHR.success = jqXHR.done; jqXHR.error = jqXHR.fail; jqXHR.complete = completeDeferred.add; // Status-dependent callbacks jqXHR.statusCode = function( map ) { if ( map ) { var tmp; if ( state < 2 ) { for ( tmp in map ) { statusCode[ tmp ] = [ statusCode[tmp], map[tmp] ]; } } else { tmp = map[ jqXHR.status ]; jqXHR.then( tmp, tmp ); } } return this; }; // Remove hash character (#7531: and string promotion) // Add protocol if not provided (#5866: IE7 issue with protocol-less urls) // We also use the url parameter if available s.url = ( ( url || s.url ) + "" ).replace( rhash, "" ).replace( rprotocol, ajaxLocParts[ 1 ] + "//" ); // Extract dataTypes list s.dataTypes = jQuery.trim( s.dataType || "*" ).toLowerCase().split( rspacesAjax ); // Determine if a cross-domain request is in order if ( s.crossDomain == null ) { parts = rurl.exec( s.url.toLowerCase() ); s.crossDomain = !!( parts && ( parts[ 1 ] != ajaxLocParts[ 1 ] || parts[ 2 ] != ajaxLocParts[ 2 ] || ( parts[ 3 ] || ( parts[ 1 ] === "http:" ? 80 : 443 ) ) != ( ajaxLocParts[ 3 ] || ( ajaxLocParts[ 1 ] === "http:" ? 80 : 443 ) ) ) ); } // Convert data if not already a string if ( s.data && s.processData && typeof s.data !== "string" ) { s.data = jQuery.param( s.data, s.traditional ); } // Apply prefilters inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); // If request was aborted inside a prefiler, stop there if ( state === 2 ) { return false; } // We can fire global events as of now if asked to fireGlobals = s.global; // Uppercase the type s.type = s.type.toUpperCase(); // Determine if request has content s.hasContent = !rnoContent.test( s.type ); // Watch for a new set of requests if ( fireGlobals && jQuery.active++ === 0 ) { jQuery.event.trigger( "ajaxStart" ); } // More options handling for requests with no content if ( !s.hasContent ) { // If data is available, append data to url if ( s.data ) { s.url += ( rquery.test( s.url ) ? "&" : "?" ) + s.data; // #9682: remove data so that it's not used in an eventual retry delete s.data; } // Get ifModifiedKey before adding the anti-cache parameter ifModifiedKey = s.url; // Add anti-cache in url if needed if ( s.cache === false ) { var ts = jQuery.now(), // try replacing _= if it is there ret = s.url.replace( rts, "$1_=" + ts ); // if nothing was replaced, add timestamp to the end s.url = ret + ( ( ret === s.url ) ? ( rquery.test( s.url ) ? "&" : "?" ) + "_=" + ts : "" ); } } // Set the correct header, if data is being sent if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) { jqXHR.setRequestHeader( "Content-Type", s.contentType ); } // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. if ( s.ifModified ) { ifModifiedKey = ifModifiedKey || s.url; if ( jQuery.lastModified[ ifModifiedKey ] ) { jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ ifModifiedKey ] ); } if ( jQuery.etag[ ifModifiedKey ] ) { jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ ifModifiedKey ] ); } } // Set the Accepts header for the server, depending on the dataType jqXHR.setRequestHeader( "Accept", s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[0] ] ? s.accepts[ s.dataTypes[0] ] + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) : s.accepts[ "*" ] ); // Check for headers option for ( i in s.headers ) { jqXHR.setRequestHeader( i, s.headers[ i ] ); } // Allow custom headers/mimetypes and early abort if ( s.beforeSend && ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || state === 2 ) ) { // Abort if not done already jqXHR.abort(); return false; } // Install callbacks on deferreds for ( i in { success: 1, error: 1, complete: 1 } ) { jqXHR[ i ]( s[ i ] ); } // Get transport transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR ); // If no transport, we auto-abort if ( !transport ) { done( -1, "No Transport" ); } else { jqXHR.readyState = 1; // Send global event if ( fireGlobals ) { globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] ); } // Timeout if ( s.async && s.timeout > 0 ) { timeoutTimer = setTimeout( function(){ jqXHR.abort( "timeout" ); }, s.timeout ); } try { state = 1; transport.send( requestHeaders, done ); } catch (e) { // Propagate exception as error if not done if ( state < 2 ) { done( -1, e ); // Simply rethrow otherwise } else { throw e; } } } return jqXHR; }, // Serialize an array of form elements or a set of // key/values into a query string param: function( a, traditional ) { var s = [], add = function( key, value ) { // If value is a function, invoke it and return its value value = jQuery.isFunction( value ) ? value() : value; s[ s.length ] = encodeURIComponent( key ) + "=" + encodeURIComponent( value ); }; // Set traditional to true for jQuery <= 1.3.2 behavior. if ( traditional === undefined ) { traditional = jQuery.ajaxSettings.traditional; } // If an array was passed in, assume that it is an array of form elements. if ( jQuery.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { // Serialize the form elements jQuery.each( a, function() { add( this.name, this.value ); }); } else { // If traditional, encode the "old" way (the way 1.3.2 or older // did it), otherwise encode params recursively. for ( var prefix in a ) { buildParams( prefix, a[ prefix ], traditional, add ); } } // Return the resulting serialization return s.join( "&" ).replace( r20, "+" ); } }); function buildParams( prefix, obj, traditional, add ) { if ( jQuery.isArray( obj ) ) { // Serialize array item. jQuery.each( obj, function( i, v ) { if ( traditional || rbracket.test( prefix ) ) { // Treat each array item as a scalar. add( prefix, v ); } else { // If array item is non-scalar (array or object), encode its // numeric index to resolve deserialization ambiguity issues. // Note that rack (as of 1.0.0) can't currently deserialize // nested arrays properly, and attempting to do so may cause // a server error. Possible fixes are to modify rack's // deserialization algorithm or to provide an option or flag // to force array serialization to be shallow. buildParams( prefix + "[" + ( typeof v === "object" || jQuery.isArray(v) ? i : "" ) + "]", v, traditional, add ); } }); } else if ( !traditional && obj != null && typeof obj === "object" ) { // Serialize object item. for ( var name in obj ) { buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add ); } } else { // Serialize scalar item. add( prefix, obj ); } } // This is still on the jQuery object... for now // Want to move this to jQuery.ajax some day jQuery.extend({ // Counter for holding the number of active queries active: 0, // Last-Modified header cache for next request lastModified: {}, etag: {} }); /* Handles responses to an ajax request: * - sets all responseXXX fields accordingly * - finds the right dataType (mediates between content-type and expected dataType) * - returns the corresponding response */ function ajaxHandleResponses( s, jqXHR, responses ) { var contents = s.contents, dataTypes = s.dataTypes, responseFields = s.responseFields, ct, type, finalDataType, firstDataType; // Fill responseXXX fields for ( type in responseFields ) { if ( type in responses ) { jqXHR[ responseFields[type] ] = responses[ type ]; } } // Remove auto dataType and get content-type in the process while( dataTypes[ 0 ] === "*" ) { dataTypes.shift(); if ( ct === undefined ) { ct = s.mimeType || jqXHR.getResponseHeader( "content-type" ); } } // Check if we're dealing with a known content-type if ( ct ) { for ( type in contents ) { if ( contents[ type ] && contents[ type ].test( ct ) ) { dataTypes.unshift( type ); break; } } } // Check to see if we have a response for the expected dataType if ( dataTypes[ 0 ] in responses ) { finalDataType = dataTypes[ 0 ]; } else { // Try convertible dataTypes for ( type in responses ) { if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[0] ] ) { finalDataType = type; break; } if ( !firstDataType ) { firstDataType = type; } } // Or just use first one finalDataType = finalDataType || firstDataType; } // If we found a dataType // We add the dataType to the list if needed // and return the corresponding response if ( finalDataType ) { if ( finalDataType !== dataTypes[ 0 ] ) { dataTypes.unshift( finalDataType ); } return responses[ finalDataType ]; } } // Chain conversions given the request and the original response function ajaxConvert( s, response ) { // Apply the dataFilter if provided if ( s.dataFilter ) { response = s.dataFilter( response, s.dataType ); } var dataTypes = s.dataTypes, converters = {}, i, key, length = dataTypes.length, tmp, // Current and previous dataTypes current = dataTypes[ 0 ], prev, // Conversion expression conversion, // Conversion function conv, // Conversion functions (transitive conversion) conv1, conv2; // For each dataType in the chain for ( i = 1; i < length; i++ ) { // Create converters map // with lowercased keys if ( i === 1 ) { for ( key in s.converters ) { if ( typeof key === "string" ) { converters[ key.toLowerCase() ] = s.converters[ key ]; } } } // Get the dataTypes prev = current; current = dataTypes[ i ]; // If current is auto dataType, update it to prev if ( current === "*" ) { current = prev; // If no auto and dataTypes are actually different } else if ( prev !== "*" && prev !== current ) { // Get the converter conversion = prev + " " + current; conv = converters[ conversion ] || converters[ "* " + current ]; // If there is no direct converter, search transitively if ( !conv ) { conv2 = undefined; for ( conv1 in converters ) { tmp = conv1.split( " " ); if ( tmp[ 0 ] === prev || tmp[ 0 ] === "*" ) { conv2 = converters[ tmp[1] + " " + current ]; if ( conv2 ) { conv1 = converters[ conv1 ]; if ( conv1 === true ) { conv = conv2; } else if ( conv2 === true ) { conv = conv1; } break; } } } } // If we found no converter, dispatch an error if ( !( conv || conv2 ) ) { jQuery.error( "No conversion from " + conversion.replace(" "," to ") ); } // If found converter is not an equivalence if ( conv !== true ) { // Convert with 1 or 2 converters accordingly response = conv ? conv( response ) : conv2( conv1(response) ); } } } return response; } var jsc = jQuery.now(), jsre = /(\=)\?(&|$)|\?\?/i; // Default jsonp settings jQuery.ajaxSetup({ jsonp: "callback", jsonpCallback: function() { return jQuery.expando + "_" + ( jsc++ ); } }); // Detect, normalize options and install callbacks for jsonp requests jQuery.ajaxPrefilter( "json jsonp", function( s, originalSettings, jqXHR ) { var inspectData = s.contentType === "application/x-www-form-urlencoded" && ( typeof s.data === "string" ); if ( s.dataTypes[ 0 ] === "jsonp" || s.jsonp !== false && ( jsre.test( s.url ) || inspectData && jsre.test( s.data ) ) ) { var responseContainer, jsonpCallback = s.jsonpCallback = jQuery.isFunction( s.jsonpCallback ) ? s.jsonpCallback() : s.jsonpCallback, previous = window[ jsonpCallback ], url = s.url, data = s.data, replace = "$1" + jsonpCallback + "$2"; if ( s.jsonp !== false ) { url = url.replace( jsre, replace ); if ( s.url === url ) { if ( inspectData ) { data = data.replace( jsre, replace ); } if ( s.data === data ) { // Add callback manually url += (/\?/.test( url ) ? "&" : "?") + s.jsonp + "=" + jsonpCallback; } } } s.url = url; s.data = data; // Install callback window[ jsonpCallback ] = function( response ) { responseContainer = [ response ]; }; // Clean-up function jqXHR.always(function() { // Set callback back to previous value window[ jsonpCallback ] = previous; // Call if it was a function and we have a response if ( responseContainer && jQuery.isFunction( previous ) ) { window[ jsonpCallback ]( responseContainer[ 0 ] ); } }); // Use data converter to retrieve json after script execution s.converters["script json"] = function() { if ( !responseContainer ) { jQuery.error( jsonpCallback + " was not called" ); } return responseContainer[ 0 ]; }; // force json dataType s.dataTypes[ 0 ] = "json"; // Delegate to script return "script"; } }); // Install script dataType jQuery.ajaxSetup({ accepts: { script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript" }, contents: { script: /javascript|ecmascript/ }, converters: { "text script": function( text ) { jQuery.globalEval( text ); return text; } } }); // Handle cache's special case and global jQuery.ajaxPrefilter( "script", function( s ) { if ( s.cache === undefined ) { s.cache = false; } if ( s.crossDomain ) { s.type = "GET"; s.global = false; } }); // Bind script tag hack transport jQuery.ajaxTransport( "script", function(s) { // This transport only deals with cross domain requests if ( s.crossDomain ) { var script, head = document.head || document.getElementsByTagName( "head" )[0] || document.documentElement; return { send: function( _, callback ) { script = document.createElement( "script" ); script.async = "async"; if ( s.scriptCharset ) { script.charset = s.scriptCharset; } script.src = s.url; // Attach handlers for all browsers script.onload = script.onreadystatechange = function( _, isAbort ) { if ( isAbort || !script.readyState || /loaded|complete/.test( script.readyState ) ) { // Handle memory leak in IE script.onload = script.onreadystatechange = null; // Remove the script if ( head && script.parentNode ) { head.removeChild( script ); } // Dereference the script script = undefined; // Callback if not abort if ( !isAbort ) { callback( 200, "success" ); } } }; // Use insertBefore instead of appendChild to circumvent an IE6 bug. // This arises when a base node is used (#2709 and #4378). head.insertBefore( script, head.firstChild ); }, abort: function() { if ( script ) { script.onload( 0, 1 ); } } }; } }); var // #5280: Internet Explorer will keep connections alive if we don't abort on unload xhrOnUnloadAbort = window.ActiveXObject ? function() { // Abort all pending requests for ( var key in xhrCallbacks ) { xhrCallbacks[ key ]( 0, 1 ); } } : false, xhrId = 0, xhrCallbacks; // Functions to create xhrs function createStandardXHR() { try { return new window.XMLHttpRequest(); } catch( e ) {} } function createActiveXHR() { try { return new window.ActiveXObject( "Microsoft.XMLHTTP" ); } catch( e ) {} } // Create the request object // (This is still attached to ajaxSettings for backward compatibility) jQuery.ajaxSettings.xhr = window.ActiveXObject ? /* Microsoft failed to properly * implement the XMLHttpRequest in IE7 (can't request local files), * so we use the ActiveXObject when it is available * Additionally XMLHttpRequest can be disabled in IE7/IE8 so * we need a fallback. */ function() { return !this.isLocal && createStandardXHR() || createActiveXHR(); } : // For all other browsers, use the standard XMLHttpRequest object createStandardXHR; // Determine support properties (function( xhr ) { jQuery.extend( jQuery.support, { ajax: !!xhr, cors: !!xhr && ( "withCredentials" in xhr ) }); })( jQuery.ajaxSettings.xhr() ); // Create transport if the browser can provide an xhr if ( jQuery.support.ajax ) { jQuery.ajaxTransport(function( s ) { // Cross domain only allowed if supported through XMLHttpRequest if ( !s.crossDomain || jQuery.support.cors ) { var callback; return { send: function( headers, complete ) { // Get a new xhr var xhr = s.xhr(), handle, i; // Open the socket // Passing null username, generates a login popup on Opera (#2865) if ( s.username ) { xhr.open( s.type, s.url, s.async, s.username, s.password ); } else { xhr.open( s.type, s.url, s.async ); } // Apply custom fields if provided if ( s.xhrFields ) { for ( i in s.xhrFields ) { xhr[ i ] = s.xhrFields[ i ]; } } // Override mime type if needed if ( s.mimeType && xhr.overrideMimeType ) { xhr.overrideMimeType( s.mimeType ); } // X-Requested-With header // For cross-domain requests, seeing as conditions for a preflight are // akin to a jigsaw puzzle, we simply never set it to be sure. // (it can always be set on a per-request basis or even using ajaxSetup) // For same-domain requests, won't change header if already provided. if ( !s.crossDomain && !headers["X-Requested-With"] ) { headers[ "X-Requested-With" ] = "XMLHttpRequest"; } // Need an extra try/catch for cross domain requests in Firefox 3 try { for ( i in headers ) { xhr.setRequestHeader( i, headers[ i ] ); } } catch( _ ) {} // Do send the request // This may raise an exception which is actually // handled in jQuery.ajax (so no try/catch here) xhr.send( ( s.hasContent && s.data ) || null ); // Listener callback = function( _, isAbort ) { var status, statusText, responseHeaders, responses, xml; // Firefox throws exceptions when accessing properties // of an xhr when a network error occured // http://helpful.knobs-dials.com/index.php/Component_returned_failure_code:_0x80040111_(NS_ERROR_NOT_AVAILABLE) try { // Was never called and is aborted or complete if ( callback && ( isAbort || xhr.readyState === 4 ) ) { // Only called once callback = undefined; // Do not keep as active anymore if ( handle ) { xhr.onreadystatechange = jQuery.noop; if ( xhrOnUnloadAbort ) { delete xhrCallbacks[ handle ]; } } // If it's an abort if ( isAbort ) { // Abort it manually if needed if ( xhr.readyState !== 4 ) { xhr.abort(); } } else { status = xhr.status; responseHeaders = xhr.getAllResponseHeaders(); responses = {}; xml = xhr.responseXML; // Construct response list if ( xml && xml.documentElement /* #4958 */ ) { responses.xml = xml; } responses.text = xhr.responseText; // Firefox throws an exception when accessing // statusText for faulty cross-domain requests try { statusText = xhr.statusText; } catch( e ) { // We normalize with Webkit giving an empty statusText statusText = ""; } // Filter status for non standard behaviors // If the request is local and we have data: assume a success // (success with no data won't get notified, that's the best we // can do given current implementations) if ( !status && s.isLocal && !s.crossDomain ) { status = responses.text ? 200 : 404; // IE - #1450: sometimes returns 1223 when it should be 204 } else if ( status === 1223 ) { status = 204; } } } } catch( firefoxAccessException ) { if ( !isAbort ) { complete( -1, firefoxAccessException ); } } // Call complete if needed if ( responses ) { complete( status, statusText, responses, responseHeaders ); } }; // if we're in sync mode or it's in cache // and has been retrieved directly (IE6 & IE7) // we need to manually fire the callback if ( !s.async || xhr.readyState === 4 ) { callback(); } else { handle = ++xhrId; if ( xhrOnUnloadAbort ) { // Create the active xhrs callbacks list if needed // and attach the unload handler if ( !xhrCallbacks ) { xhrCallbacks = {}; jQuery( window ).unload( xhrOnUnloadAbort ); } // Add to list of active xhrs callbacks xhrCallbacks[ handle ] = callback; } xhr.onreadystatechange = callback; } }, abort: function() { if ( callback ) { callback(0,1); } } }; } }); } var elemdisplay = {}, iframe, iframeDoc, rfxtypes = /^(?:toggle|show|hide)$/, rfxnum = /^([+\-]=)?([\d+.\-]+)([a-z%]*)$/i, timerId, fxAttrs = [ // height animations [ "height", "marginTop", "marginBottom", "paddingTop", "paddingBottom" ], // width animations [ "width", "marginLeft", "marginRight", "paddingLeft", "paddingRight" ], // opacity animations [ "opacity" ] ], fxNow; jQuery.fn.extend({ show: function( speed, easing, callback ) { var elem, display; if ( speed || speed === 0 ) { return this.animate( genFx("show", 3), speed, easing, callback ); } else { for ( var i = 0, j = this.length; i < j; i++ ) { elem = this[ i ]; if ( elem.style ) { display = elem.style.display; // Reset the inline display of this element to learn if it is // being hidden by cascaded rules or not if ( !jQuery._data(elem, "olddisplay") && display === "none" ) { display = elem.style.display = ""; } // Set elements which have been overridden with display: none // in a stylesheet to whatever the default browser style is // for such an element if ( display === "" && jQuery.css(elem, "display") === "none" ) { jQuery._data( elem, "olddisplay", defaultDisplay(elem.nodeName) ); } } } // Set the display of most of the elements in a second loop // to avoid the constant reflow for ( i = 0; i < j; i++ ) { elem = this[ i ]; if ( elem.style ) { display = elem.style.display; if ( display === "" || display === "none" ) { elem.style.display = jQuery._data( elem, "olddisplay" ) || ""; } } } return this; } }, hide: function( speed, easing, callback ) { if ( speed || speed === 0 ) { return this.animate( genFx("hide", 3), speed, easing, callback); } else { var elem, display, i = 0, j = this.length; for ( ; i < j; i++ ) { elem = this[i]; if ( elem.style ) { display = jQuery.css( elem, "display" ); if ( display !== "none" && !jQuery._data( elem, "olddisplay" ) ) { jQuery._data( elem, "olddisplay", display ); } } } // Set the display of the elements in a second loop // to avoid the constant reflow for ( i = 0; i < j; i++ ) { if ( this[i].style ) { this[i].style.display = "none"; } } return this; } }, // Save the old toggle function _toggle: jQuery.fn.toggle, toggle: function( fn, fn2, callback ) { var bool = typeof fn === "boolean"; if ( jQuery.isFunction(fn) && jQuery.isFunction(fn2) ) { this._toggle.apply( this, arguments ); } else if ( fn == null || bool ) { this.each(function() { var state = bool ? fn : jQuery(this).is(":hidden"); jQuery(this)[ state ? "show" : "hide" ](); }); } else { this.animate(genFx("toggle", 3), fn, fn2, callback); } return this; }, fadeTo: function( speed, to, easing, callback ) { return this.filter(":hidden").css("opacity", 0).show().end() .animate({opacity: to}, speed, easing, callback); }, animate: function( prop, speed, easing, callback ) { var optall = jQuery.speed( speed, easing, callback ); if ( jQuery.isEmptyObject( prop ) ) { return this.each( optall.complete, [ false ] ); } // Do not change referenced properties as per-property easing will be lost prop = jQuery.extend( {}, prop ); function doAnimation() { // XXX 'this' does not always have a nodeName when running the // test suite if ( optall.queue === false ) { jQuery._mark( this ); } var opt = jQuery.extend( {}, optall ), isElement = this.nodeType === 1, hidden = isElement && jQuery(this).is(":hidden"), name, val, p, e, parts, start, end, unit, method; // will store per property easing and be used to determine when an animation is complete opt.animatedProperties = {}; for ( p in prop ) { // property name normalization name = jQuery.camelCase( p ); if ( p !== name ) { prop[ name ] = prop[ p ]; delete prop[ p ]; } val = prop[ name ]; // easing resolution: per property > opt.specialEasing > opt.easing > 'swing' (default) if ( jQuery.isArray( val ) ) { opt.animatedProperties[ name ] = val[ 1 ]; val = prop[ name ] = val[ 0 ]; } else { opt.animatedProperties[ name ] = opt.specialEasing && opt.specialEasing[ name ] || opt.easing || 'swing'; } if ( val === "hide" && hidden || val === "show" && !hidden ) { return opt.complete.call( this ); } if ( isElement && ( name === "height" || name === "width" ) ) { // Make sure that nothing sneaks out // Record all 3 overflow attributes because IE does not // change the overflow attribute when overflowX and // overflowY are set to the same value opt.overflow = [ this.style.overflow, this.style.overflowX, this.style.overflowY ]; // Set display property to inline-block for height/width // animations on inline elements that are having width/height animated if ( jQuery.css( this, "display" ) === "inline" && jQuery.css( this, "float" ) === "none" ) { // inline-level elements accept inline-block; // block-level elements need to be inline with layout if ( !jQuery.support.inlineBlockNeedsLayout || defaultDisplay( this.nodeName ) === "inline" ) { this.style.display = "inline-block"; } else { this.style.zoom = 1; } } } } if ( opt.overflow != null ) { this.style.overflow = "hidden"; } for ( p in prop ) { e = new jQuery.fx( this, opt, p ); val = prop[ p ]; if ( rfxtypes.test( val ) ) { // Tracks whether to show or hide based on private // data attached to the element method = jQuery._data( this, "toggle" + p ) || ( val === "toggle" ? hidden ? "show" : "hide" : 0 ); if ( method ) { jQuery._data( this, "toggle" + p, method === "show" ? "hide" : "show" ); e[ method ](); } else { e[ val ](); } } else { parts = rfxnum.exec( val ); start = e.cur(); if ( parts ) { end = parseFloat( parts[2] ); unit = parts[3] || ( jQuery.cssNumber[ p ] ? "" : "px" ); // We need to compute starting value if ( unit !== "px" ) { jQuery.style( this, p, (end || 1) + unit); start = ( (end || 1) / e.cur() ) * start; jQuery.style( this, p, start + unit); } // If a +=/-= token was provided, we're doing a relative animation if ( parts[1] ) { end = ( (parts[ 1 ] === "-=" ? -1 : 1) * end ) + start; } e.custom( start, end, unit ); } else { e.custom( start, val, "" ); } } } // For JS strict compliance return true; } return optall.queue === false ? this.each( doAnimation ) : this.queue( optall.queue, doAnimation ); }, stop: function( type, clearQueue, gotoEnd ) { if ( typeof type !== "string" ) { gotoEnd = clearQueue; clearQueue = type; type = undefined; } if ( clearQueue && type !== false ) { this.queue( type || "fx", [] ); } return this.each(function() { var index, hadTimers = false, timers = jQuery.timers, data = jQuery._data( this ); // clear marker counters if we know they won't be if ( !gotoEnd ) { jQuery._unmark( true, this ); } function stopQueue( elem, data, index ) { var hooks = data[ index ]; jQuery.removeData( elem, index, true ); hooks.stop( gotoEnd ); } if ( type == null ) { for ( index in data ) { if ( data[ index ] && data[ index ].stop && index.indexOf(".run") === index.length - 4 ) { stopQueue( this, data, index ); } } } else if ( data[ index = type + ".run" ] && data[ index ].stop ){ stopQueue( this, data, index ); } for ( index = timers.length; index--; ) { if ( timers[ index ].elem === this && (type == null || timers[ index ].queue === type) ) { if ( gotoEnd ) { // force the next step to be the last timers[ index ]( true ); } else { timers[ index ].saveState(); } hadTimers = true; timers.splice( index, 1 ); } } // start the next in the queue if the last step wasn't forced // timers currently will call their complete callbacks, which will dequeue // but only if they were gotoEnd if ( !( gotoEnd && hadTimers ) ) { jQuery.dequeue( this, type ); } }); } }); // Animations created synchronously will run synchronously function createFxNow() { setTimeout( clearFxNow, 0 ); return ( fxNow = jQuery.now() ); } function clearFxNow() { fxNow = undefined; } // Generate parameters to create a standard animation function genFx( type, num ) { var obj = {}; jQuery.each( fxAttrs.concat.apply([], fxAttrs.slice( 0, num )), function() { obj[ this ] = type; }); return obj; } // Generate shortcuts for custom animations jQuery.each({ slideDown: genFx( "show", 1 ), slideUp: genFx( "hide", 1 ), slideToggle: genFx( "toggle", 1 ), fadeIn: { opacity: "show" }, fadeOut: { opacity: "hide" }, fadeToggle: { opacity: "toggle" } }, function( name, props ) { jQuery.fn[ name ] = function( speed, easing, callback ) { return this.animate( props, speed, easing, callback ); }; }); jQuery.extend({ speed: function( speed, easing, fn ) { var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : { complete: fn || !fn && easing || jQuery.isFunction( speed ) && speed, duration: speed, easing: fn && easing || easing && !jQuery.isFunction( easing ) && easing }; opt.duration = jQuery.fx.off ? 0 : typeof opt.duration === "number" ? opt.duration : opt.duration in jQuery.fx.speeds ? jQuery.fx.speeds[ opt.duration ] : jQuery.fx.speeds._default; // normalize opt.queue - true/undefined/null -> "fx" if ( opt.queue == null || opt.queue === true ) { opt.queue = "fx"; } // Queueing opt.old = opt.complete; opt.complete = function( noUnmark ) { if ( jQuery.isFunction( opt.old ) ) { opt.old.call( this ); } if ( opt.queue ) { jQuery.dequeue( this, opt.queue ); } else if ( noUnmark !== false ) { jQuery._unmark( this ); } }; return opt; }, easing: { linear: function( p, n, firstNum, diff ) { return firstNum + diff * p; }, swing: function( p, n, firstNum, diff ) { return ( ( -Math.cos( p*Math.PI ) / 2 ) + 0.5 ) * diff + firstNum; } }, timers: [], fx: function( elem, options, prop ) { this.options = options; this.elem = elem; this.prop = prop; options.orig = options.orig || {}; } }); jQuery.fx.prototype = { // Simple function for setting a style value update: function() { if ( this.options.step ) { this.options.step.call( this.elem, this.now, this ); } ( jQuery.fx.step[ this.prop ] || jQuery.fx.step._default )( this ); }, // Get the current size cur: function() { if ( this.elem[ this.prop ] != null && (!this.elem.style || this.elem.style[ this.prop ] == null) ) { return this.elem[ this.prop ]; } var parsed, r = jQuery.css( this.elem, this.prop ); // Empty strings, null, undefined and "auto" are converted to 0, // complex values such as "rotate(1rad)" are returned as is, // simple values such as "10px" are parsed to Float. return isNaN( parsed = parseFloat( r ) ) ? !r || r === "auto" ? 0 : r : parsed; }, // Start an animation from one number to another custom: function( from, to, unit ) { var self = this, fx = jQuery.fx; this.startTime = fxNow || createFxNow(); this.end = to; this.now = this.start = from; this.pos = this.state = 0; this.unit = unit || this.unit || ( jQuery.cssNumber[ this.prop ] ? "" : "px" ); function t( gotoEnd ) { return self.step( gotoEnd ); } t.queue = this.options.queue; t.elem = this.elem; t.saveState = function() { if ( self.options.hide && jQuery._data( self.elem, "fxshow" + self.prop ) === undefined ) { jQuery._data( self.elem, "fxshow" + self.prop, self.start ); } }; if ( t() && jQuery.timers.push(t) && !timerId ) { timerId = setInterval( fx.tick, fx.interval ); } }, // Simple 'show' function show: function() { var dataShow = jQuery._data( this.elem, "fxshow" + this.prop ); // Remember where we started, so that we can go back to it later this.options.orig[ this.prop ] = dataShow || jQuery.style( this.elem, this.prop ); this.options.show = true; // Begin the animation // Make sure that we start at a small width/height to avoid any flash of content if ( dataShow !== undefined ) { // This show is picking up where a previous hide or show left off this.custom( this.cur(), dataShow ); } else { this.custom( this.prop === "width" || this.prop === "height" ? 1 : 0, this.cur() ); } // Start by showing the element jQuery( this.elem ).show(); }, // Simple 'hide' function hide: function() { // Remember where we started, so that we can go back to it later this.options.orig[ this.prop ] = jQuery._data( this.elem, "fxshow" + this.prop ) || jQuery.style( this.elem, this.prop ); this.options.hide = true; // Begin the animation this.custom( this.cur(), 0 ); }, // Each step of an animation step: function( gotoEnd ) { var p, n, complete, t = fxNow || createFxNow(), done = true, elem = this.elem, options = this.options; if ( gotoEnd || t >= options.duration + this.startTime ) { this.now = this.end; this.pos = this.state = 1; this.update(); options.animatedProperties[ this.prop ] = true; for ( p in options.animatedProperties ) { if ( options.animatedProperties[ p ] !== true ) { done = false; } } if ( done ) { // Reset the overflow if ( options.overflow != null && !jQuery.support.shrinkWrapBlocks ) { jQuery.each( [ "", "X", "Y" ], function( index, value ) { elem.style[ "overflow" + value ] = options.overflow[ index ]; }); } // Hide the element if the "hide" operation was done if ( options.hide ) { jQuery( elem ).hide(); } // Reset the properties, if the item has been hidden or shown if ( options.hide || options.show ) { for ( p in options.animatedProperties ) { jQuery.style( elem, p, options.orig[ p ] ); jQuery.removeData( elem, "fxshow" + p, true ); // Toggle data is no longer needed jQuery.removeData( elem, "toggle" + p, true ); } } // Execute the complete function // in the event that the complete function throws an exception // we must ensure it won't be called twice. #5684 complete = options.complete; if ( complete ) { options.complete = false; complete.call( elem ); } } return false; } else { // classical easing cannot be used with an Infinity duration if ( options.duration == Infinity ) { this.now = t; } else { n = t - this.startTime; this.state = n / options.duration; // Perform the easing function, defaults to swing this.pos = jQuery.easing[ options.animatedProperties[this.prop] ]( this.state, n, 0, 1, options.duration ); this.now = this.start + ( (this.end - this.start) * this.pos ); } // Perform the next step of the animation this.update(); } return true; } }; jQuery.extend( jQuery.fx, { tick: function() { var timer, timers = jQuery.timers, i = 0; for ( ; i < timers.length; i++ ) { timer = timers[ i ]; // Checks the timer has not already been removed if ( !timer() && timers[ i ] === timer ) { timers.splice( i--, 1 ); } } if ( !timers.length ) { jQuery.fx.stop(); } }, interval: 13, stop: function() { clearInterval( timerId ); timerId = null; }, speeds: { slow: 600, fast: 200, // Default speed _default: 400 }, step: { opacity: function( fx ) { jQuery.style( fx.elem, "opacity", fx.now ); }, _default: function( fx ) { if ( fx.elem.style && fx.elem.style[ fx.prop ] != null ) { fx.elem.style[ fx.prop ] = fx.now + fx.unit; } else { fx.elem[ fx.prop ] = fx.now; } } } }); // Adds width/height step functions // Do not set anything below 0 jQuery.each([ "width", "height" ], function( i, prop ) { jQuery.fx.step[ prop ] = function( fx ) { jQuery.style( fx.elem, prop, Math.max(0, fx.now) + fx.unit ); }; }); if ( jQuery.expr && jQuery.expr.filters ) { jQuery.expr.filters.animated = function( elem ) { return jQuery.grep(jQuery.timers, function( fn ) { return elem === fn.elem; }).length; }; } // Try to restore the default display value of an element function defaultDisplay( nodeName ) { if ( !elemdisplay[ nodeName ] ) { var body = document.body, elem = jQuery( "<" + nodeName + ">" ).appendTo( body ), display = elem.css( "display" ); elem.remove(); // If the simple way fails, // get element's real default display by attaching it to a temp iframe if ( display === "none" || display === "" ) { // No iframe to use yet, so create it if ( !iframe ) { iframe = document.createElement( "iframe" ); iframe.frameBorder = iframe.width = iframe.height = 0; } body.appendChild( iframe ); // Create a cacheable copy of the iframe document on first call. // IE and Opera will allow us to reuse the iframeDoc without re-writing the fake HTML // document to it; WebKit & Firefox won't allow reusing the iframe document. if ( !iframeDoc || !iframe.createElement ) { iframeDoc = ( iframe.contentWindow || iframe.contentDocument ).document; iframeDoc.write( ( document.compatMode === "CSS1Compat" ? "<!doctype html>" : "" ) + "<html><body>" ); iframeDoc.close(); } elem = iframeDoc.createElement( nodeName ); iframeDoc.body.appendChild( elem ); display = jQuery.css( elem, "display" ); body.removeChild( iframe ); } // Store the correct default display elemdisplay[ nodeName ] = display; } return elemdisplay[ nodeName ]; } var rtable = /^t(?:able|d|h)$/i, rroot = /^(?:body|html)$/i; if ( "getBoundingClientRect" in document.documentElement ) { jQuery.fn.offset = function( options ) { var elem = this[0], box; if ( options ) { return this.each(function( i ) { jQuery.offset.setOffset( this, options, i ); }); } if ( !elem || !elem.ownerDocument ) { return null; } if ( elem === elem.ownerDocument.body ) { return jQuery.offset.bodyOffset( elem ); } try { box = elem.getBoundingClientRect(); } catch(e) {} var doc = elem.ownerDocument, docElem = doc.documentElement; // Make sure we're not dealing with a disconnected DOM node if ( !box || !jQuery.contains( docElem, elem ) ) { return box ? { top: box.top, left: box.left } : { top: 0, left: 0 }; } var body = doc.body, win = getWindow(doc), clientTop = docElem.clientTop || body.clientTop || 0, clientLeft = docElem.clientLeft || body.clientLeft || 0, scrollTop = win.pageYOffset || jQuery.support.boxModel && docElem.scrollTop || body.scrollTop, scrollLeft = win.pageXOffset || jQuery.support.boxModel && docElem.scrollLeft || body.scrollLeft, top = box.top + scrollTop - clientTop, left = box.left + scrollLeft - clientLeft; return { top: top, left: left }; }; } else { jQuery.fn.offset = function( options ) { var elem = this[0]; if ( options ) { return this.each(function( i ) { jQuery.offset.setOffset( this, options, i ); }); } if ( !elem || !elem.ownerDocument ) { return null; } if ( elem === elem.ownerDocument.body ) { return jQuery.offset.bodyOffset( elem ); } var computedStyle, offsetParent = elem.offsetParent, prevOffsetParent = elem, doc = elem.ownerDocument, docElem = doc.documentElement, body = doc.body, defaultView = doc.defaultView, prevComputedStyle = defaultView ? defaultView.getComputedStyle( elem, null ) : elem.currentStyle, top = elem.offsetTop, left = elem.offsetLeft; while ( (elem = elem.parentNode) && elem !== body && elem !== docElem ) { if ( jQuery.support.fixedPosition && prevComputedStyle.position === "fixed" ) { break; } computedStyle = defaultView ? defaultView.getComputedStyle(elem, null) : elem.currentStyle; top -= elem.scrollTop; left -= elem.scrollLeft; if ( elem === offsetParent ) { top += elem.offsetTop; left += elem.offsetLeft; if ( jQuery.support.doesNotAddBorder && !(jQuery.support.doesAddBorderForTableAndCells && rtable.test(elem.nodeName)) ) { top += parseFloat( computedStyle.borderTopWidth ) || 0; left += parseFloat( computedStyle.borderLeftWidth ) || 0; } prevOffsetParent = offsetParent; offsetParent = elem.offsetParent; } if ( jQuery.support.subtractsBorderForOverflowNotVisible && computedStyle.overflow !== "visible" ) { top += parseFloat( computedStyle.borderTopWidth ) || 0; left += parseFloat( computedStyle.borderLeftWidth ) || 0; } prevComputedStyle = computedStyle; } if ( prevComputedStyle.position === "relative" || prevComputedStyle.position === "static" ) { top += body.offsetTop; left += body.offsetLeft; } if ( jQuery.support.fixedPosition && prevComputedStyle.position === "fixed" ) { top += Math.max( docElem.scrollTop, body.scrollTop ); left += Math.max( docElem.scrollLeft, body.scrollLeft ); } return { top: top, left: left }; }; } jQuery.offset = { bodyOffset: function( body ) { var top = body.offsetTop, left = body.offsetLeft; if ( jQuery.support.doesNotIncludeMarginInBodyOffset ) { top += parseFloat( jQuery.css(body, "marginTop") ) || 0; left += parseFloat( jQuery.css(body, "marginLeft") ) || 0; } return { top: top, left: left }; }, setOffset: function( elem, options, i ) { var position = jQuery.css( elem, "position" ); // set position first, in-case top/left are set even on static elem if ( position === "static" ) { elem.style.position = "relative"; } var curElem = jQuery( elem ), curOffset = curElem.offset(), curCSSTop = jQuery.css( elem, "top" ), curCSSLeft = jQuery.css( elem, "left" ), calculatePosition = ( position === "absolute" || position === "fixed" ) && jQuery.inArray("auto", [curCSSTop, curCSSLeft]) > -1, props = {}, curPosition = {}, curTop, curLeft; // need to be able to calculate position if either top or left is auto and position is either absolute or fixed if ( calculatePosition ) { curPosition = curElem.position(); curTop = curPosition.top; curLeft = curPosition.left; } else { curTop = parseFloat( curCSSTop ) || 0; curLeft = parseFloat( curCSSLeft ) || 0; } if ( jQuery.isFunction( options ) ) { options = options.call( elem, i, curOffset ); } if ( options.top != null ) { props.top = ( options.top - curOffset.top ) + curTop; } if ( options.left != null ) { props.left = ( options.left - curOffset.left ) + curLeft; } if ( "using" in options ) { options.using.call( elem, props ); } else { curElem.css( props ); } } }; jQuery.fn.extend({ position: function() { if ( !this[0] ) { return null; } var elem = this[0], // Get *real* offsetParent offsetParent = this.offsetParent(), // Get correct offsets offset = this.offset(), parentOffset = rroot.test(offsetParent[0].nodeName) ? { top: 0, left: 0 } : offsetParent.offset(); // Subtract element margins // note: when an element has margin: auto the offsetLeft and marginLeft // are the same in Safari causing offset.left to incorrectly be 0 offset.top -= parseFloat( jQuery.css(elem, "marginTop") ) || 0; offset.left -= parseFloat( jQuery.css(elem, "marginLeft") ) || 0; // Add offsetParent borders parentOffset.top += parseFloat( jQuery.css(offsetParent[0], "borderTopWidth") ) || 0; parentOffset.left += parseFloat( jQuery.css(offsetParent[0], "borderLeftWidth") ) || 0; // Subtract the two offsets return { top: offset.top - parentOffset.top, left: offset.left - parentOffset.left }; }, offsetParent: function() { return this.map(function() { var offsetParent = this.offsetParent || document.body; while ( offsetParent && (!rroot.test(offsetParent.nodeName) && jQuery.css(offsetParent, "position") === "static") ) { offsetParent = offsetParent.offsetParent; } return offsetParent; }); } }); // Create scrollLeft and scrollTop methods jQuery.each( ["Left", "Top"], function( i, name ) { var method = "scroll" + name; jQuery.fn[ method ] = function( val ) { var elem, win; if ( val === undefined ) { elem = this[ 0 ]; if ( !elem ) { return null; } win = getWindow( elem ); // Return the scroll offset return win ? ("pageXOffset" in win) ? win[ i ? "pageYOffset" : "pageXOffset" ] : jQuery.support.boxModel && win.document.documentElement[ method ] || win.document.body[ method ] : elem[ method ]; } // Set the scroll offset return this.each(function() { win = getWindow( this ); if ( win ) { win.scrollTo( !i ? val : jQuery( win ).scrollLeft(), i ? val : jQuery( win ).scrollTop() ); } else { this[ method ] = val; } }); }; }); function getWindow( elem ) { return jQuery.isWindow( elem ) ? elem : elem.nodeType === 9 ? elem.defaultView || elem.parentWindow : false; } // Create width, height, innerHeight, innerWidth, outerHeight and outerWidth methods jQuery.each([ "Height", "Width" ], function( i, name ) { var type = name.toLowerCase(); // innerHeight and innerWidth jQuery.fn[ "inner" + name ] = function() { var elem = this[0]; return elem ? elem.style ? parseFloat( jQuery.css( elem, type, "padding" ) ) : this[ type ]() : null; }; // outerHeight and outerWidth jQuery.fn[ "outer" + name ] = function( margin ) { var elem = this[0]; return elem ? elem.style ? parseFloat( jQuery.css( elem, type, margin ? "margin" : "border" ) ) : this[ type ]() : null; }; jQuery.fn[ type ] = function( size ) { // Get window width or height var elem = this[0]; if ( !elem ) { return size == null ? null : this; } if ( jQuery.isFunction( size ) ) { return this.each(function( i ) { var self = jQuery( this ); self[ type ]( size.call( this, i, self[ type ]() ) ); }); } if ( jQuery.isWindow( elem ) ) { // Everyone else use document.documentElement or document.body depending on Quirks vs Standards mode // 3rd condition allows Nokia support, as it supports the docElem prop but not CSS1Compat var docElemProp = elem.document.documentElement[ "client" + name ], body = elem.document.body; return elem.document.compatMode === "CSS1Compat" && docElemProp || body && body[ "client" + name ] || docElemProp; // Get document width or height } else if ( elem.nodeType === 9 ) { // Either scroll[Width/Height] or offset[Width/Height], whichever is greater return Math.max( elem.documentElement["client" + name], elem.body["scroll" + name], elem.documentElement["scroll" + name], elem.body["offset" + name], elem.documentElement["offset" + name] ); // Get or set width or height on the element } else if ( size === undefined ) { var orig = jQuery.css( elem, type ), ret = parseFloat( orig ); return jQuery.isNumeric( ret ) ? ret : orig; // Set the width or height on the element (default to pixels if value is unitless) } else { return this.css( type, typeof size === "string" ? size : size + "px" ); } }; }); // Expose jQuery to the global object window.jQuery = window.$ = jQuery; // Expose jQuery as an AMD module, but only for AMD loaders that // understand the issues with loading multiple versions of jQuery // in a page that all might call define(). The loader will indicate // they have special allowances for multiple jQuery versions by // specifying define.amd.jQuery = true. Register as a named module, // since jQuery can be concatenated with other files that may use define, // but not use a proper concatenation script that understands anonymous // AMD modules. A named AMD is safest and most robust way to register. // Lowercase jquery is used because AMD module names are derived from // file names, and jQuery is normally delivered in a lowercase file name. // Do this after creating the global so that if an AMD module wants to call // noConflict to hide this version of jQuery, it will work. if ( typeof define === "function" && define.amd && define.amd.jQuery ) { define( "jquery", [], function () { return jQuery; } ); } })( window ); �������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/static/underscore.js������������������������������������������������������0000644�0000765�0000024�00000103302�12013011113�022305� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������// Underscore.js 1.2.2 // (c) 2011 Jeremy Ashkenas, DocumentCloud Inc. // Underscore is freely distributable under the MIT license. // Portions of Underscore are inspired or borrowed from Prototype, // Oliver Steele's Functional, and John Resig's Micro-Templating. // For all details and documentation: // http://documentcloud.github.com/underscore (function() { // Baseline setup // -------------- // Establish the root object, `window` in the browser, or `global` on the server. var root = this; // Save the previous value of the `_` variable. var previousUnderscore = root._; // Establish the object that gets returned to break out of a loop iteration. var breaker = {}; // Save bytes in the minified (but not gzipped) version: var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype; // Create quick reference variables for speed access to core prototypes. var slice = ArrayProto.slice, unshift = ArrayProto.unshift, toString = ObjProto.toString, hasOwnProperty = ObjProto.hasOwnProperty; // All **ECMAScript 5** native function implementations that we hope to use // are declared here. var nativeForEach = ArrayProto.forEach, nativeMap = ArrayProto.map, nativeReduce = ArrayProto.reduce, nativeReduceRight = ArrayProto.reduceRight, nativeFilter = ArrayProto.filter, nativeEvery = ArrayProto.every, nativeSome = ArrayProto.some, nativeIndexOf = ArrayProto.indexOf, nativeLastIndexOf = ArrayProto.lastIndexOf, nativeIsArray = Array.isArray, nativeKeys = Object.keys, nativeBind = FuncProto.bind; // Create a safe reference to the Underscore object for use below. var _ = function(obj) { return new wrapper(obj); }; // Export the Underscore object for **Node.js** and **"CommonJS"**, with // backwards-compatibility for the old `require()` API. If we're not in // CommonJS, add `_` to the global object. if (typeof exports !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { exports = module.exports = _; } exports._ = _; } else if (typeof define === 'function' && define.amd) { // Register as a named module with AMD. define('underscore', function() { return _; }); } else { // Exported as a string, for Closure Compiler "advanced" mode. root['_'] = _; } // Current version. _.VERSION = '1.2.2'; // Collection Functions // -------------------- // The cornerstone, an `each` implementation, aka `forEach`. // Handles objects with the built-in `forEach`, arrays, and raw objects. // Delegates to **ECMAScript 5**'s native `forEach` if available. var each = _.each = _.forEach = function(obj, iterator, context) { if (obj == null) return; if (nativeForEach && obj.forEach === nativeForEach) { obj.forEach(iterator, context); } else if (obj.length === +obj.length) { for (var i = 0, l = obj.length; i < l; i++) { if (i in obj && iterator.call(context, obj[i], i, obj) === breaker) return; } } else { for (var key in obj) { if (hasOwnProperty.call(obj, key)) { if (iterator.call(context, obj[key], key, obj) === breaker) return; } } } }; // Return the results of applying the iterator to each element. // Delegates to **ECMAScript 5**'s native `map` if available. _.map = function(obj, iterator, context) { var results = []; if (obj == null) return results; if (nativeMap && obj.map === nativeMap) return obj.map(iterator, context); each(obj, function(value, index, list) { results[results.length] = iterator.call(context, value, index, list); }); return results; }; // **Reduce** builds up a single result from a list of values, aka `inject`, // or `foldl`. Delegates to **ECMAScript 5**'s native `reduce` if available. _.reduce = _.foldl = _.inject = function(obj, iterator, memo, context) { var initial = memo !== void 0; if (obj == null) obj = []; if (nativeReduce && obj.reduce === nativeReduce) { if (context) iterator = _.bind(iterator, context); return initial ? obj.reduce(iterator, memo) : obj.reduce(iterator); } each(obj, function(value, index, list) { if (!initial) { memo = value; initial = true; } else { memo = iterator.call(context, memo, value, index, list); } }); if (!initial) throw new TypeError("Reduce of empty array with no initial value"); return memo; }; // The right-associative version of reduce, also known as `foldr`. // Delegates to **ECMAScript 5**'s native `reduceRight` if available. _.reduceRight = _.foldr = function(obj, iterator, memo, context) { if (obj == null) obj = []; if (nativeReduceRight && obj.reduceRight === nativeReduceRight) { if (context) iterator = _.bind(iterator, context); return memo !== void 0 ? obj.reduceRight(iterator, memo) : obj.reduceRight(iterator); } var reversed = (_.isArray(obj) ? obj.slice() : _.toArray(obj)).reverse(); return _.reduce(reversed, iterator, memo, context); }; // Return the first value which passes a truth test. Aliased as `detect`. _.find = _.detect = function(obj, iterator, context) { var result; any(obj, function(value, index, list) { if (iterator.call(context, value, index, list)) { result = value; return true; } }); return result; }; // Return all the elements that pass a truth test. // Delegates to **ECMAScript 5**'s native `filter` if available. // Aliased as `select`. _.filter = _.select = function(obj, iterator, context) { var results = []; if (obj == null) return results; if (nativeFilter && obj.filter === nativeFilter) return obj.filter(iterator, context); each(obj, function(value, index, list) { if (iterator.call(context, value, index, list)) results[results.length] = value; }); return results; }; // Return all the elements for which a truth test fails. _.reject = function(obj, iterator, context) { var results = []; if (obj == null) return results; each(obj, function(value, index, list) { if (!iterator.call(context, value, index, list)) results[results.length] = value; }); return results; }; // Determine whether all of the elements match a truth test. // Delegates to **ECMAScript 5**'s native `every` if available. // Aliased as `all`. _.every = _.all = function(obj, iterator, context) { var result = true; if (obj == null) return result; if (nativeEvery && obj.every === nativeEvery) return obj.every(iterator, context); each(obj, function(value, index, list) { if (!(result = result && iterator.call(context, value, index, list))) return breaker; }); return result; }; // Determine if at least one element in the object matches a truth test. // Delegates to **ECMAScript 5**'s native `some` if available. // Aliased as `any`. var any = _.some = _.any = function(obj, iterator, context) { iterator = iterator || _.identity; var result = false; if (obj == null) return result; if (nativeSome && obj.some === nativeSome) return obj.some(iterator, context); each(obj, function(value, index, list) { if (result || (result = iterator.call(context, value, index, list))) return breaker; }); return !!result; }; // Determine if a given value is included in the array or object using `===`. // Aliased as `contains`. _.include = _.contains = function(obj, target) { var found = false; if (obj == null) return found; if (nativeIndexOf && obj.indexOf === nativeIndexOf) return obj.indexOf(target) != -1; found = any(obj, function(value) { return value === target; }); return found; }; // Invoke a method (with arguments) on every item in a collection. _.invoke = function(obj, method) { var args = slice.call(arguments, 2); return _.map(obj, function(value) { return (method.call ? method || value : value[method]).apply(value, args); }); }; // Convenience version of a common use case of `map`: fetching a property. _.pluck = function(obj, key) { return _.map(obj, function(value){ return value[key]; }); }; // Return the maximum element or (element-based computation). _.max = function(obj, iterator, context) { if (!iterator && _.isArray(obj)) return Math.max.apply(Math, obj); if (!iterator && _.isEmpty(obj)) return -Infinity; var result = {computed : -Infinity}; each(obj, function(value, index, list) { var computed = iterator ? iterator.call(context, value, index, list) : value; computed >= result.computed && (result = {value : value, computed : computed}); }); return result.value; }; // Return the minimum element (or element-based computation). _.min = function(obj, iterator, context) { if (!iterator && _.isArray(obj)) return Math.min.apply(Math, obj); if (!iterator && _.isEmpty(obj)) return Infinity; var result = {computed : Infinity}; each(obj, function(value, index, list) { var computed = iterator ? iterator.call(context, value, index, list) : value; computed < result.computed && (result = {value : value, computed : computed}); }); return result.value; }; // Shuffle an array. _.shuffle = function(obj) { var shuffled = [], rand; each(obj, function(value, index, list) { if (index == 0) { shuffled[0] = value; } else { rand = Math.floor(Math.random() * (index + 1)); shuffled[index] = shuffled[rand]; shuffled[rand] = value; } }); return shuffled; }; // Sort the object's values by a criterion produced by an iterator. _.sortBy = function(obj, iterator, context) { return _.pluck(_.map(obj, function(value, index, list) { return { value : value, criteria : iterator.call(context, value, index, list) }; }).sort(function(left, right) { var a = left.criteria, b = right.criteria; return a < b ? -1 : a > b ? 1 : 0; }), 'value'); }; // Groups the object's values by a criterion. Pass either a string attribute // to group by, or a function that returns the criterion. _.groupBy = function(obj, val) { var result = {}; var iterator = _.isFunction(val) ? val : function(obj) { return obj[val]; }; each(obj, function(value, index) { var key = iterator(value, index); (result[key] || (result[key] = [])).push(value); }); return result; }; // Use a comparator function to figure out at what index an object should // be inserted so as to maintain order. Uses binary search. _.sortedIndex = function(array, obj, iterator) { iterator || (iterator = _.identity); var low = 0, high = array.length; while (low < high) { var mid = (low + high) >> 1; iterator(array[mid]) < iterator(obj) ? low = mid + 1 : high = mid; } return low; }; // Safely convert anything iterable into a real, live array. _.toArray = function(iterable) { if (!iterable) return []; if (iterable.toArray) return iterable.toArray(); if (_.isArray(iterable)) return slice.call(iterable); if (_.isArguments(iterable)) return slice.call(iterable); return _.values(iterable); }; // Return the number of elements in an object. _.size = function(obj) { return _.toArray(obj).length; }; // Array Functions // --------------- // Get the first element of an array. Passing **n** will return the first N // values in the array. Aliased as `head`. The **guard** check allows it to work // with `_.map`. _.first = _.head = function(array, n, guard) { return (n != null) && !guard ? slice.call(array, 0, n) : array[0]; }; // Returns everything but the last entry of the array. Especcialy useful on // the arguments object. Passing **n** will return all the values in // the array, excluding the last N. The **guard** check allows it to work with // `_.map`. _.initial = function(array, n, guard) { return slice.call(array, 0, array.length - ((n == null) || guard ? 1 : n)); }; // Get the last element of an array. Passing **n** will return the last N // values in the array. The **guard** check allows it to work with `_.map`. _.last = function(array, n, guard) { if ((n != null) && !guard) { return slice.call(array, Math.max(array.length - n, 0)); } else { return array[array.length - 1]; } }; // Returns everything but the first entry of the array. Aliased as `tail`. // Especially useful on the arguments object. Passing an **index** will return // the rest of the values in the array from that index onward. The **guard** // check allows it to work with `_.map`. _.rest = _.tail = function(array, index, guard) { return slice.call(array, (index == null) || guard ? 1 : index); }; // Trim out all falsy values from an array. _.compact = function(array) { return _.filter(array, function(value){ return !!value; }); }; // Return a completely flattened version of an array. _.flatten = function(array, shallow) { return _.reduce(array, function(memo, value) { if (_.isArray(value)) return memo.concat(shallow ? value : _.flatten(value)); memo[memo.length] = value; return memo; }, []); }; // Return a version of the array that does not contain the specified value(s). _.without = function(array) { return _.difference(array, slice.call(arguments, 1)); }; // Produce a duplicate-free version of the array. If the array has already // been sorted, you have the option of using a faster algorithm. // Aliased as `unique`. _.uniq = _.unique = function(array, isSorted, iterator) { var initial = iterator ? _.map(array, iterator) : array; var result = []; _.reduce(initial, function(memo, el, i) { if (0 == i || (isSorted === true ? _.last(memo) != el : !_.include(memo, el))) { memo[memo.length] = el; result[result.length] = array[i]; } return memo; }, []); return result; }; // Produce an array that contains the union: each distinct element from all of // the passed-in arrays. _.union = function() { return _.uniq(_.flatten(arguments, true)); }; // Produce an array that contains every item shared between all the // passed-in arrays. (Aliased as "intersect" for back-compat.) _.intersection = _.intersect = function(array) { var rest = slice.call(arguments, 1); return _.filter(_.uniq(array), function(item) { return _.every(rest, function(other) { return _.indexOf(other, item) >= 0; }); }); }; // Take the difference between one array and another. // Only the elements present in just the first array will remain. _.difference = function(array, other) { return _.filter(array, function(value){ return !_.include(other, value); }); }; // Zip together multiple lists into a single array -- elements that share // an index go together. _.zip = function() { var args = slice.call(arguments); var length = _.max(_.pluck(args, 'length')); var results = new Array(length); for (var i = 0; i < length; i++) results[i] = _.pluck(args, "" + i); return results; }; // If the browser doesn't supply us with indexOf (I'm looking at you, **MSIE**), // we need this function. Return the position of the first occurrence of an // item in an array, or -1 if the item is not included in the array. // Delegates to **ECMAScript 5**'s native `indexOf` if available. // If the array is large and already in sort order, pass `true` // for **isSorted** to use binary search. _.indexOf = function(array, item, isSorted) { if (array == null) return -1; var i, l; if (isSorted) { i = _.sortedIndex(array, item); return array[i] === item ? i : -1; } if (nativeIndexOf && array.indexOf === nativeIndexOf) return array.indexOf(item); for (i = 0, l = array.length; i < l; i++) if (array[i] === item) return i; return -1; }; // Delegates to **ECMAScript 5**'s native `lastIndexOf` if available. _.lastIndexOf = function(array, item) { if (array == null) return -1; if (nativeLastIndexOf && array.lastIndexOf === nativeLastIndexOf) return array.lastIndexOf(item); var i = array.length; while (i--) if (array[i] === item) return i; return -1; }; // Generate an integer Array containing an arithmetic progression. A port of // the native Python `range()` function. See // [the Python documentation](http://docs.python.org/library/functions.html#range). _.range = function(start, stop, step) { if (arguments.length <= 1) { stop = start || 0; start = 0; } step = arguments[2] || 1; var len = Math.max(Math.ceil((stop - start) / step), 0); var idx = 0; var range = new Array(len); while(idx < len) { range[idx++] = start; start += step; } return range; }; // Function (ahem) Functions // ------------------ // Reusable constructor function for prototype setting. var ctor = function(){}; // Create a function bound to a given object (assigning `this`, and arguments, // optionally). Binding with arguments is also known as `curry`. // Delegates to **ECMAScript 5**'s native `Function.bind` if available. // We check for `func.bind` first, to fail fast when `func` is undefined. _.bind = function bind(func, context) { var bound, args; if (func.bind === nativeBind && nativeBind) return nativeBind.apply(func, slice.call(arguments, 1)); if (!_.isFunction(func)) throw new TypeError; args = slice.call(arguments, 2); return bound = function() { if (!(this instanceof bound)) return func.apply(context, args.concat(slice.call(arguments))); ctor.prototype = func.prototype; var self = new ctor; var result = func.apply(self, args.concat(slice.call(arguments))); if (Object(result) === result) return result; return self; }; }; // Bind all of an object's methods to that object. Useful for ensuring that // all callbacks defined on an object belong to it. _.bindAll = function(obj) { var funcs = slice.call(arguments, 1); if (funcs.length == 0) funcs = _.functions(obj); each(funcs, function(f) { obj[f] = _.bind(obj[f], obj); }); return obj; }; // Memoize an expensive function by storing its results. _.memoize = function(func, hasher) { var memo = {}; hasher || (hasher = _.identity); return function() { var key = hasher.apply(this, arguments); return hasOwnProperty.call(memo, key) ? memo[key] : (memo[key] = func.apply(this, arguments)); }; }; // Delays a function for the given number of milliseconds, and then calls // it with the arguments supplied. _.delay = function(func, wait) { var args = slice.call(arguments, 2); return setTimeout(function(){ return func.apply(func, args); }, wait); }; // Defers a function, scheduling it to run after the current call stack has // cleared. _.defer = function(func) { return _.delay.apply(_, [func, 1].concat(slice.call(arguments, 1))); }; // Returns a function, that, when invoked, will only be triggered at most once // during a given window of time. _.throttle = function(func, wait) { var context, args, timeout, throttling, more; var whenDone = _.debounce(function(){ more = throttling = false; }, wait); return function() { context = this; args = arguments; var later = function() { timeout = null; if (more) func.apply(context, args); whenDone(); }; if (!timeout) timeout = setTimeout(later, wait); if (throttling) { more = true; } else { func.apply(context, args); } whenDone(); throttling = true; }; }; // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. _.debounce = function(func, wait) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; func.apply(context, args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; // Returns a function that will be executed at most one time, no matter how // often you call it. Useful for lazy initialization. _.once = function(func) { var ran = false, memo; return function() { if (ran) return memo; ran = true; return memo = func.apply(this, arguments); }; }; // Returns the first function passed as an argument to the second, // allowing you to adjust arguments, run code before and after, and // conditionally execute the original function. _.wrap = function(func, wrapper) { return function() { var args = [func].concat(slice.call(arguments)); return wrapper.apply(this, args); }; }; // Returns a function that is the composition of a list of functions, each // consuming the return value of the function that follows. _.compose = function() { var funcs = slice.call(arguments); return function() { var args = slice.call(arguments); for (var i = funcs.length - 1; i >= 0; i--) { args = [funcs[i].apply(this, args)]; } return args[0]; }; }; // Returns a function that will only be executed after being called N times. _.after = function(times, func) { if (times <= 0) return func(); return function() { if (--times < 1) { return func.apply(this, arguments); } }; }; // Object Functions // ---------------- // Retrieve the names of an object's properties. // Delegates to **ECMAScript 5**'s native `Object.keys` _.keys = nativeKeys || function(obj) { if (obj !== Object(obj)) throw new TypeError('Invalid object'); var keys = []; for (var key in obj) if (hasOwnProperty.call(obj, key)) keys[keys.length] = key; return keys; }; // Retrieve the values of an object's properties. _.values = function(obj) { return _.map(obj, _.identity); }; // Return a sorted list of the function names available on the object. // Aliased as `methods` _.functions = _.methods = function(obj) { var names = []; for (var key in obj) { if (_.isFunction(obj[key])) names.push(key); } return names.sort(); }; // Extend a given object with all the properties in passed-in object(s). _.extend = function(obj) { each(slice.call(arguments, 1), function(source) { for (var prop in source) { if (source[prop] !== void 0) obj[prop] = source[prop]; } }); return obj; }; // Fill in a given object with default properties. _.defaults = function(obj) { each(slice.call(arguments, 1), function(source) { for (var prop in source) { if (obj[prop] == null) obj[prop] = source[prop]; } }); return obj; }; // Create a (shallow-cloned) duplicate of an object. _.clone = function(obj) { if (!_.isObject(obj)) return obj; return _.isArray(obj) ? obj.slice() : _.extend({}, obj); }; // Invokes interceptor with the obj, and then returns obj. // The primary purpose of this method is to "tap into" a method chain, in // order to perform operations on intermediate results within the chain. _.tap = function(obj, interceptor) { interceptor(obj); return obj; }; // Internal recursive comparison function. function eq(a, b, stack) { // Identical objects are equal. `0 === -0`, but they aren't identical. // See the Harmony `egal` proposal: http://wiki.ecmascript.org/doku.php?id=harmony:egal. if (a === b) return a !== 0 || 1 / a == 1 / b; // A strict comparison is necessary because `null == undefined`. if (a == null || b == null) return a === b; // Unwrap any wrapped objects. if (a._chain) a = a._wrapped; if (b._chain) b = b._wrapped; // Invoke a custom `isEqual` method if one is provided. if (_.isFunction(a.isEqual)) return a.isEqual(b); if (_.isFunction(b.isEqual)) return b.isEqual(a); // Compare `[[Class]]` names. var className = toString.call(a); if (className != toString.call(b)) return false; switch (className) { // Strings, numbers, dates, and booleans are compared by value. case '[object String]': // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is // equivalent to `new String("5")`. return String(a) == String(b); case '[object Number]': a = +a; b = +b; // `NaN`s are equivalent, but non-reflexive. An `egal` comparison is performed for // other numeric values. return a != a ? b != b : (a == 0 ? 1 / a == 1 / b : a == b); case '[object Date]': case '[object Boolean]': // Coerce dates and booleans to numeric primitive values. Dates are compared by their // millisecond representations. Note that invalid dates with millisecond representations // of `NaN` are not equivalent. return +a == +b; // RegExps are compared by their source patterns and flags. case '[object RegExp]': return a.source == b.source && a.global == b.global && a.multiline == b.multiline && a.ignoreCase == b.ignoreCase; } if (typeof a != 'object' || typeof b != 'object') return false; // Assume equality for cyclic structures. The algorithm for detecting cyclic // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. var length = stack.length; while (length--) { // Linear search. Performance is inversely proportional to the number of // unique nested structures. if (stack[length] == a) return true; } // Add the first object to the stack of traversed objects. stack.push(a); var size = 0, result = true; // Recursively compare objects and arrays. if (className == '[object Array]') { // Compare array lengths to determine if a deep comparison is necessary. size = a.length; result = size == b.length; if (result) { // Deep compare the contents, ignoring non-numeric properties. while (size--) { // Ensure commutative equality for sparse arrays. if (!(result = size in a == size in b && eq(a[size], b[size], stack))) break; } } } else { // Objects with different constructors are not equivalent. if ("constructor" in a != "constructor" in b || a.constructor != b.constructor) return false; // Deep compare objects. for (var key in a) { if (hasOwnProperty.call(a, key)) { // Count the expected number of properties. size++; // Deep compare each member. if (!(result = hasOwnProperty.call(b, key) && eq(a[key], b[key], stack))) break; } } // Ensure that both objects contain the same number of properties. if (result) { for (key in b) { if (hasOwnProperty.call(b, key) && !(size--)) break; } result = !size; } } // Remove the first object from the stack of traversed objects. stack.pop(); return result; } // Perform a deep comparison to check if two objects are equal. _.isEqual = function(a, b) { return eq(a, b, []); }; // Is a given array, string, or object empty? // An "empty" object has no enumerable own-properties. _.isEmpty = function(obj) { if (_.isArray(obj) || _.isString(obj)) return obj.length === 0; for (var key in obj) if (hasOwnProperty.call(obj, key)) return false; return true; }; // Is a given value a DOM element? _.isElement = function(obj) { return !!(obj && obj.nodeType == 1); }; // Is a given value an array? // Delegates to ECMA5's native Array.isArray _.isArray = nativeIsArray || function(obj) { return toString.call(obj) == '[object Array]'; }; // Is a given variable an object? _.isObject = function(obj) { return obj === Object(obj); }; // Is a given variable an arguments object? if (toString.call(arguments) == '[object Arguments]') { _.isArguments = function(obj) { return toString.call(obj) == '[object Arguments]'; }; } else { _.isArguments = function(obj) { return !!(obj && hasOwnProperty.call(obj, 'callee')); }; } // Is a given value a function? _.isFunction = function(obj) { return toString.call(obj) == '[object Function]'; }; // Is a given value a string? _.isString = function(obj) { return toString.call(obj) == '[object String]'; }; // Is a given value a number? _.isNumber = function(obj) { return toString.call(obj) == '[object Number]'; }; // Is the given value `NaN`? _.isNaN = function(obj) { // `NaN` is the only value for which `===` is not reflexive. return obj !== obj; }; // Is a given value a boolean? _.isBoolean = function(obj) { return obj === true || obj === false || toString.call(obj) == '[object Boolean]'; }; // Is a given value a date? _.isDate = function(obj) { return toString.call(obj) == '[object Date]'; }; // Is the given value a regular expression? _.isRegExp = function(obj) { return toString.call(obj) == '[object RegExp]'; }; // Is a given value equal to null? _.isNull = function(obj) { return obj === null; }; // Is a given variable undefined? _.isUndefined = function(obj) { return obj === void 0; }; // Utility Functions // ----------------- // Run Underscore.js in *noConflict* mode, returning the `_` variable to its // previous owner. Returns a reference to the Underscore object. _.noConflict = function() { root._ = previousUnderscore; return this; }; // Keep the identity function around for default iterators. _.identity = function(value) { return value; }; // Run a function **n** times. _.times = function (n, iterator, context) { for (var i = 0; i < n; i++) iterator.call(context, i); }; // Escape a string for HTML interpolation. _.escape = function(string) { return (''+string).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g,'/'); }; // Add your own custom functions to the Underscore object, ensuring that // they're correctly added to the OOP wrapper as well. _.mixin = function(obj) { each(_.functions(obj), function(name){ addToWrapper(name, _[name] = obj[name]); }); }; // Generate a unique integer id (unique within the entire client session). // Useful for temporary DOM ids. var idCounter = 0; _.uniqueId = function(prefix) { var id = idCounter++; return prefix ? prefix + id : id; }; // By default, Underscore uses ERB-style template delimiters, change the // following template settings to use alternative delimiters. _.templateSettings = { evaluate : /<%([\s\S]+?)%>/g, interpolate : /<%=([\s\S]+?)%>/g, escape : /<%-([\s\S]+?)%>/g }; // JavaScript micro-templating, similar to John Resig's implementation. // Underscore templating handles arbitrary delimiters, preserves whitespace, // and correctly escapes quotes within interpolated code. _.template = function(str, data) { var c = _.templateSettings; var tmpl = 'var __p=[],print=function(){__p.push.apply(__p,arguments);};' + 'with(obj||{}){__p.push(\'' + str.replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(c.escape, function(match, code) { return "',_.escape(" + code.replace(/\\'/g, "'") + "),'"; }) .replace(c.interpolate, function(match, code) { return "'," + code.replace(/\\'/g, "'") + ",'"; }) .replace(c.evaluate || null, function(match, code) { return "');" + code.replace(/\\'/g, "'") .replace(/[\r\n\t]/g, ' ') + ";__p.push('"; }) .replace(/\r/g, '\\r') .replace(/\n/g, '\\n') .replace(/\t/g, '\\t') + "');}return __p.join('');"; var func = new Function('obj', '_', tmpl); return data ? func(data, _) : function(data) { return func(data, _) }; }; // The OOP Wrapper // --------------- // If Underscore is called as a function, it returns a wrapped object that // can be used OO-style. This wrapper holds altered versions of all the // underscore functions. Wrapped objects may be chained. var wrapper = function(obj) { this._wrapped = obj; }; // Expose `wrapper.prototype` as `_.prototype` _.prototype = wrapper.prototype; // Helper function to continue chaining intermediate results. var result = function(obj, chain) { return chain ? _(obj).chain() : obj; }; // A method to easily add functions to the OOP wrapper. var addToWrapper = function(name, func) { wrapper.prototype[name] = function() { var args = slice.call(arguments); unshift.call(args, this._wrapped); return result(func.apply(_, args), this._chain); }; }; // Add all of the Underscore functions to the wrapper object. _.mixin(_); // Add all mutator Array functions to the wrapper. each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) { var method = ArrayProto[name]; wrapper.prototype[name] = function() { method.apply(this._wrapped, arguments); return result(this._wrapped, this._chain); }; }); // Add all accessor Array functions to the wrapper. each(['concat', 'join', 'slice'], function(name) { var method = ArrayProto[name]; wrapper.prototype[name] = function() { return result(method.apply(this._wrapped, arguments), this._chain); }; }); // Start chaining a wrapped Underscore object. wrapper.prototype.chain = function() { this._chain = true; return this; }; // Extracts the result from a wrapped and chained object. wrapper.prototype.value = function() { return this._wrapped; }; }).call(this); ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/templates/����������������������������������������������������������������0000755�0000765�0000024�00000000000�12226377756�020350� 5����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.3.1/beetsplug/web/templates/index.html������������������������������������������������������0000644�0000765�0000024�00000006075�12013011113�022313� 0����������������������������������������������������������������������������������������������������ustar �asampson������������������������staff���������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������<!DOCTYPE html> <html> <head> <title>beets
beets-1.3.1/beetsplug/zero.py0000644000076500000240000000660512203275653017121 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """ Clears tag fields in media files.""" import re import logging from beets.plugins import BeetsPlugin from beets.library import ITEM_KEYS from beets.importer import action from beets.util import confit __author__ = 'baobab@heresiarch.info' __version__ = '0.10' class ZeroPlugin(BeetsPlugin): _instance = None _log = logging.getLogger('beets') def __init__(self): super(ZeroPlugin, self).__init__() # Listeners. self.register_listener('write', self.write_event) self.register_listener('import_task_choice', self.import_task_choice_event) self.config.add({ 'fields': [], }) self.patterns = {} self.warned = False for f in self.config['fields'].as_str_seq(): if f not in ITEM_KEYS: self._log.error(u'[zero] invalid field: {0}'.format(f)) else: try: self.patterns[f] = self.config[f].as_str_seq() except confit.NotFoundError: self.patterns[f] = [u''] def import_task_choice_event(self, session, task): """Listen for import_task_choice event.""" if task.choice_flag == action.ASIS and not self.warned: self._log.warn(u'[zero] cannot zero in \"as-is\" mode') self.warned = True # TODO request write in as-is mode @classmethod def match_patterns(cls, field, patterns): """Check if field (as string) is matching any of the patterns in the list. """ for p in patterns: if re.search(p, unicode(field), flags=re.IGNORECASE): return True return False def write_event(self, item): """Listen for write event.""" if not self.patterns: self._log.warn(u'[zero] no fields, nothing to do') return for fn, patterns in self.patterns.items(): try: fval = getattr(item, fn) except AttributeError: self._log.error(u'[zero] no such field: {0}'.format(fn)) else: if not self.match_patterns(fval, patterns): self._log.debug(u'[zero] \"{0}\" ({1}) not match: {2}' .format(fval, fn, ' '.join(patterns))) continue self._log.debug(u'[zero] \"{0}\" ({1}) match: {2}' .format(fval, fn, ' '.join(patterns))) new_val = None if fval is None else type(fval)() setattr(item, fn, new_val) self._log.debug(u'[zero] {0}={1}' .format(fn, getattr(item, fn))) beets-1.3.1/docs/0000755000076500000240000000000012226377756014533 5ustar asampsonstaff00000000000000beets-1.3.1/docs/changelog.rst0000644000076500000240000025526412226377507017224 0ustar asampsonstaff00000000000000Changelog ========= 1.3.1 (October 12, 2013) ------------------------ This release boasts a host new little features, many of them contributed by beets' amazing and prolific community. It adds support for `Opus`_ files, transcoding to any format, and two new plugins: one that guesses metadata for "blank" files based on their filenames and one that moves featured artists into the title field. Here's the new stuff: * Add `Opus`_ audio support. Thanks to Rowan Lewis. * :doc:`/plugins/convert`: You can now transcode files to any audio format, rather than just MP3. Thanks again to Rowan Lewis. * The new :doc:`/plugins/fromfilename` guesses tags from the filenames during import when metadata tags themselves are missing. Thanks to Jan-Erik Dahlin. * The :doc:`/plugins/ftintitle`, by `@Verrus`_, is now distributed with beets. It helps you rewrite tags to move "featured" artists from the artist field to the title field. * The MusicBrainz data source now uses track artists over recording artists. This leads to better metadata when tagging classical music. Thanks to Henrique Ferreiro. * :doc:`/plugins/lastgenre`: You can now get multiple genres per album or track using the ``multiple`` config option. Thanks to rashley60 on GitHub. * A new :ref:`id3v23` config option makes beets write MP3 files' tags using the older ID3v2.3 metadata standard. Use this if you want your tags to be visible to Windows and some older players. And some fixes: * :doc:`/plugins/fetchart`: Better error message when the image file has an unrecognized type. * :doc:`/plugins/mbcollection`: Detect, log, and skip invalid MusicBrainz IDs (instead of failing with an API error). * :doc:`/plugins/info`: Fail gracefully when used erroneously with a directory. * :doc:`/plugins/echonest_tempo`: Fix an issue where the plugin could use the tempo from the wrong song when the API did not contain the requested song. * Fix a crash when a file's metadata included a very large number (one wider than 64 bits). These huge numbers are now replaced with zeroes in the database. * When a track on a MusicBrainz release has a different length from the underlying recording's length, the track length is now used instead. * With :ref:`per_disc_numbering` enabled, the ``tracktotal`` field is now set correctly (i.e., to the number of tracks on the disc). * :doc:`/plugins/scrub`: The ``scrub`` command now restores album art in addition to other (database-backed) tags. * :doc:`/plugins/mpdupdate`: Domain sockets can now begin with a tilde (which is correctly expanded to ``$HOME``) as well as a slash. Thanks to Johann Klähn. * :doc:`/plugins/lastgenre`: Fix a regression that could cause new genres found during import not to be persisted. * Fixed a crash when imported album art was also marked as "clutter" where the art would be deleted before it could be moved into place. This led to a "image.jpg not found during copy" error. Now clutter is removed (and directories pruned) much later in the process, after the ``import_task_files`` hook. * :doc:`/plugins/missing`: Fix an error when printing missing track names. Thanks to Pedro Silva. * Fix an occasional KeyError in the :ref:`update-cmd` command introduced in 1.3.0. * :doc:`/plugins/scrub`: Avoid preserving certain non-standard ID3 tags such as NCON. .. _Opus: http://www.opus-codec.org/ .. _@Verrus: https://github.com/Verrus 1.3.0 (September 11, 2013) -------------------------- Albums and items now have **flexible attributes**. This means that, when you want to store information about your music in the beets database, you're no longer constrained to the set of fields it supports out of the box (title, artist, track, etc.). Instead, you can use any field name you can think of and treat it just like the built-in fields. For example, you can use the :ref:`modify-cmd` command to set a new field on a track:: $ beet modify mood=sexy artist:miguel and then query your music based on that field:: $ beet ls mood:sunny or use templates to see the value of the field:: $ beet ls -f '$title: $mood' While this feature is nifty when used directly with the usual command-line suspects, it's especially useful for plugin authors and for future beets features. Stay tuned for great things built on this flexible attribute infrastructure. One side effect of this change: queries that include unknown fields will now match *nothing* instead of *everything*. So if you type ``beet ls fieldThatDoesNotExist:foo``, beets will now return no results, whereas previous versions would spit out a warning and then list your entire library. There's more detail than you could ever need `on the beets blog`_. .. _on the beets blog: http://beets.radbox.org/blog/flexattr.html 1.2.2 (August 27, 2013) ----------------------- This is a bugfix release. We're in the midst of preparing for a large change in beets 1.3, so 1.2.2 resolves some issues that came up over the last few weeks. Stay tuned! The improvements in this release are: * A new plugin event, ``item_moved``, is sent when files are moved on disk. Thanks to dsedivec. * :doc:`/plugins/lyrics`: More improvements to the Google backend by Fabrice Laporte. * :doc:`/plugins/bpd`: Fix for a crash when searching, thanks to Simon Chopin. * Regular expression queries (and other query types) over paths now work. (Previously, special query types were ignored for the ``path`` field.) * :doc:`/plugins/fetchart`: Look for images in the Cover Art Archive for the release group in addition to the specific release. Thanks to Filipe Fortes. * Fix a race in the importer that could cause files to be deleted before they were imported. This happened when importing one album, importing a duplicate album, and then asking for the first album to be replaced with the second. The situation could only arise when importing music from the library directory and when the two albums are imported close in time. 1.2.1 (June 22, 2013) --------------------- This release introduces a major internal change in the way that similarity scores are handled. It means that the importer interface can now show you exactly why a match is assigned its score and that the autotagger gained a few new options that let you customize how matches are prioritized and recommended. The refactoring work is due to the continued efforts of Tai Lee. The changes you'll notice while using the autotagger are: * The top 3 distance penalties are now displayed on the release listing, and all album and track penalties are now displayed on the track changes list. This should make it clear exactly which metadata is contributing to a low similarity score. * When displaying differences, the colorization has been made more consistent and helpful: red for an actual difference, yellow to indicate that a distance penalty is being applied, and light gray for no penalty (e.g., case changes) or disambiguation data. There are also three new (or overhauled) configuration options that let you customize the way that matches are selected: * The :ref:`ignored` setting lets you instruct the importer not to show you matches that have a certain penalty applied. * The :ref:`preferred` collection of settings specifies a sorted list of preferred countries and media types, or prioritizes releases closest to the original year for an album. * The :ref:`max_rec` settings can now be used for any distance penalty component. The recommendation will be downgraded if a non-zero penalty is being applied to the specified field. And some little enhancements and bug fixes: * Multi-disc directory names can now contain "disk" (in addition to "disc"). Thanks to John Hawthorn. * :doc:`/plugins/web`: Item and album counts are now exposed through the API for use with the Tomahawk resolver. Thanks to Uwe L. Korn. * Python 2.6 compatibility for :doc:`/plugins/beatport`, :doc:`/plugins/missing`, and :doc:`/plugins/duplicates`. Thanks to Wesley Bitter and Pedro Silva. * Don't move the config file during a null migration. Thanks to Theofilos Intzoglou. * Fix an occasional crash in the :doc:`/plugins/beatport` when a length field was missing from the API response. Thanks to Timothy Appnel. * :doc:`/plugins/scrub`: Handle and log I/O errors. * :doc:`/plugins/lyrics`: The Google backend should now turn up more results. Thanks to Fabrice Laporte. * :doc:`/plugins/random`: Fix compatibility with Python 2.6. Thanks to Matthias Drochner. 1.2.0 (June 5, 2013) -------------------- There's a *lot* of new stuff in this release: new data sources for the autotagger, new plugins to look for problems in your library, tracking the date that you acquired new music, an awesome new syntax for doing queries over numeric fields, support for ALAC files, and major enhancements to the importer's UI and distance calculations. A special thanks goes out to all the contributors who helped make this release awesome. For the first time, beets can now tag your music using additional **data sources** to augment the matches from MusicBrainz. When you enable either of these plugins, the importer will start showing you new kinds of matches: * New :doc:`/plugins/discogs`: Get matches from the `Discogs`_ database. Thanks to Artem Ponomarenko and Tai Lee. * New :doc:`/plugins/beatport`: Get matches from the `Beatport`_ database. Thanks to Johannes Baiter. We also have two other new plugins that can scan your library to check for common problems, both by Pedro Silva: * New :doc:`/plugins/duplicates`: Find tracks or albums in your library that are **duplicated**. * New :doc:`/plugins/missing`: Find albums in your library that are **missing tracks**. There are also three more big features added to beets core: * Your library now keeps track of **when music was added** to it. The new ``added`` field is a timestamp reflecting when each item and album was imported and the new ``%time{}`` template function lets you format this timestamp for humans. Thanks to Lucas Duailibe. * When using queries to match on quantitative fields, you can now use **numeric ranges**. For example, you can get a list of albums from the '90s by typing ``beet ls year:1990..1999`` or find high-bitrate music with ``bitrate:128000..``. See :ref:`numericquery`. Thanks to Michael Schuerig. * **ALAC files** are now marked as ALAC instead of being conflated with AAC audio. Thanks to Simon Luijk. In addition, the importer saw various UI enhancements, thanks to Tai Lee: * More consistent format and colorization of album and track metadata. * Display data source URL for matches from the new data source plugins. This should make it easier to migrate data from Discogs or Beatport into MusicBrainz. * Display album disambiguation and disc titles in the track listing, when available. * Track changes are highlighted in yellow when they indicate a change in format to or from the style of :ref:`per_disc_numbering`. (As before, no penalty is applied because the track number is still "correct", just in a different format.) * Sort missing and unmatched tracks by index and title and group them together for better readability. * Indicate MusicBrainz ID mismatches. The calculation of the similarity score for autotagger matches was also improved, again thanks to Tai Lee. These changes, in general, help deal with the new metadata sources and help disambiguate between similar releases in the same MusicBrainz release group: * Strongly prefer releases with a matching MusicBrainz album ID. This helps beets re-identify the same release when re-importing existing files. * Prefer releases that are closest to the tagged ``year``. Tolerate files tagged with release or original year. * The new ``preferred_media`` config option lets you prefer a certain media type when the ``media`` field is unset on an album. * Apply minor penalties across a range of fields to differentiate between nearly identical releases: ``disctotal``, ``label``, ``catalognum``, ``country`` and ``albumdisambig``. As usual, there were also lots of other great littler enhancements: * :doc:`/plugins/random`: A new ``-e`` option gives an equal chance to each artist in your collection to avoid biasing random samples to prolific artists. Thanks to Georges Dubus. * The :ref:`modify-cmd` now correctly converts types when modifying non-string fields. You can now safely modify the "comp" flag and the "year" field, for example. Thanks to Lucas Duailibe. * :doc:`/plugins/convert`: You can now configure the path formats for converted files separately from your main library. Thanks again to Lucas Duailibe. * The importer output now shows the number of audio files in each album. Thanks to jayme on GitHub. * Plugins can now provide fields for both Album and Item templates, thanks to Pedro Silva. Accordingly, the :doc:`/plugins/inline` can also now define album fields. For consistency, the ``pathfields`` configuration section has been renamed ``item_fields`` (although the old name will still work for compatibility). * Plugins can also provide metadata matches for ID searches. For example, the new Discogs plugin lets you search for an album by its Discogs ID from the same prompt that previously just accepted MusicBrainz IDs. Thanks to Johannes Baiter. * The :ref:`fields-cmd` command shows template fields provided by plugins. Thanks again to Pedro Silva. * :doc:`/plugins/mpdupdate`: You can now communicate with MPD over a Unix domain socket. Thanks to John Hawthorn. And a batch of fixes: * Album art filenames now respect the :ref:`replace` configuration. * Friendly error messages are now printed when trying to read or write files that go missing. * The :ref:`modify-cmd` command can now change albums' album art paths (i.e., ``beet modify artpath=...`` works). Thanks to Lucas Duailibe. * :doc:`/plugins/zero`: Fix a crash when nulling out a field that contains None. * Templates can now refer to non-tag item fields (e.g., ``$id`` and ``$album_id``). * :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due to some fixes in dealing with special characters. .. _Discogs: http://discogs.com/ .. _Beatport: http://www.beatport.com/ 1.1.0 (April 29, 2013) ---------------------- This final release of 1.1 brings a little polish to the betas that introduced the new configuration system. The album art and lyrics plugins also got a little love. If you're upgrading from 1.0.0 or earlier, this release (like the 1.1 betas) will automatically migrate your configuration to the new system. See :doc:`/guides/migration`. * :doc:`/plugins/embedart`: The ``embedart`` command now embeds each album's associated art by default. The ``--file`` option invokes the old behavior, in which a specific image file is used. * :doc:`/plugins/lyrics`: A new (optional) Google Custom Search backend was added for finding lyrics on a wide array of sites. Thanks to Fabrice Laporte. * When automatically detecting the filesystem's maximum filename length, never guess more than 200 characters. This prevents errors on systems where the maximum length was misreported. You can, of course, override this default with the :ref:`max_filename_length` option. * :doc:`/plugins/fetchart`: Two new configuration options were added: ``cover_names``, the list of keywords used to identify preferred images, and ``cautious``, which lets you avoid falling back to images that don't contain those keywords. Thanks to Fabrice Laporte. * Avoid some error cases in the ``update`` command and the ``embedart`` and ``mbsync`` plugins. Invalid or missing files now cause error logs instead of crashing beets. Thanks to Lucas Duailibe. * :doc:`/plugins/lyrics`: Searches now strip "featuring" artists when searching for lyrics, which should increase the hit rate for these tracks. Thanks to Fabrice Laporte. * When listing the items in an album, the items are now always in track-number order. This should lead to more predictable listings from the :doc:`/plugins/importfeeds`. * :doc:`/plugins/smartplaylist`: Queries are now split using shell-like syntax instead of just whitespace, so you can now construct terms that contain spaces. * :doc:`/plugins/lastgenre`: The ``force`` config option now defaults to true and controls the behavior of the import hook. (Previously, new genres were always forced during import.) * :doc:`/plugins/web`: Fix an error when specifying the hostname on the command line. * :doc:`/plugins/web`: The underlying API was expanded slightly to support `Tomahawk`_ collections. And file transfers now have a "Content-Length" header. Thanks to Uwe L. Korn. * :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization. .. _Tomahawk: http://www.tomahawk-player.org/ 1.1b3 (March 16, 2013) ---------------------- This third beta of beets 1.1 brings a hodgepodge of little new features (and internal overhauls that will make improvements easier in the future). There are new options for getting metadata in a particular language and seeing more detail during the import process. There's also a new plugin for synchronizing your metadata with MusicBrainz. Under the hood, plugins can now extend the query syntax. New configuration options: * :ref:`languages` controls the preferred languages when selecting an alias from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 0.3 or later. Thanks to Sam Doshi. * :ref:`detail` enables a mode where all tracks are listed in the importer UI, as opposed to only changed tracks. * The ``--flat`` option to the ``beet import`` command treats an entire directory tree of music files as a single album. This can help in situations where a multi-disc album is split across multiple directories. * :doc:`/plugins/importfeeds`: An option was added to use absolute, rather than relative, paths. Thanks to Lucas Duailibe. Other stuff: * A new :doc:`/plugins/mbsync` provides a command that looks up each item and track in MusicBrainz and updates your library to reflect it. This can help you easily correct errors that have been fixed in the MB database. Thanks to Jakob Schnitzer. * :doc:`/plugins/fuzzy`: The ``fuzzy`` command was removed and replaced with a new query type. To perform fuzzy searches, use the ``~`` prefix with :ref:`list-cmd` or other commands. Thanks to Philippe Mongeau. * As part of the above, plugins can now extend the query syntax and new kinds of matching capabilities to beets. See :ref:`extend-query`. Thanks again to Philippe Mongeau. * :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store transcoded files in your library while backing up the originals (instead of vice-versa). Thanks to Lucas Duailibe. * :doc:`/plugins/convert`: Also, a new ``auto`` config option will transcode audio files automatically during import. Thanks again to Lucas Duailibe. * :doc:`/plugins/chroma`: A new ``fingerprint`` command lets you generate and store fingerprints for items that don't yet have them. One more round of applause for Lucas Duailibe. * :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. * When the importer encounters an error (insufficient permissions, for example) when walking a directory tree, it now logs an error instead of crashing. * In path formats, null database values now expand to the empty string instead of the string "None". * Add "System Volume Information" (an internal directory found on some Windows filesystems) to the default ignore list. * Fix a crash when ReplayGain values were set to null. * Fix a crash when iTunes Sound Check tags contained invalid data. * Fix an error when the configuration file (``config.yaml``) is completely empty. * Fix an error introduced in 1.1b1 when importing using timid mode. Thanks to Sam Doshi. * :doc:`/plugins/convert`: Fix a bug when creating files with Unicode pathnames. * Fix a spurious warning from the Unidecode module when matching albums that are missing all metadata. * Fix Unicode errors when a directory or file doesn't exist when invoking the import command. Thanks to Lucas Duailibe. * :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when MusicBrainz exceptions occur. * :doc:`/plugins/echonest_tempo`: Catch socket errors that are not handled by the Echo Nest library. * :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting fingerprints. 1.1b2 (February 16, 2013) ------------------------- The second beta of beets 1.1 uses the fancy new configuration infrastructure to add many, many new config options. The import process is more flexible; filenames can be customized in more detail; and more. This release also supports Windows Media (ASF) files and iTunes Sound Check volume normalization. This version introduces one **change to the default behavior** that you should be aware of. Previously, when importing new albums matched in MusicBrainz, the date fields (``year``, ``month``, and ``day``) would be set to the release date of the *original* version of the album, as opposed to the specific date of the release selected. Now, these fields reflect the specific release and ``original_year``, etc., reflect the earlier release date. If you want the old behavior, just set :ref:`original_date` to true in your config file. New configuration options: * :ref:`default_action` lets you determine the default (just-hit-return) option is when considering a candidate. * :ref:`none_rec_action` lets you skip the prompt, and automatically choose an action, when there is no good candidate. Thanks to Tai Lee. * :ref:`max_rec` lets you define a maximum recommendation for albums with missing/extra tracks or differing track lengths/numbers. Thanks again to Tai Lee. * :ref:`original_date` determines whether, when importing new albums, the ``year``, ``month``, and ``day`` fields should reflect the specific (e.g., reissue) release date or the original release date. Note that the original release date is always available as ``original_year``, etc. * :ref:`clutter` controls which files should be ignored when cleaning up empty directories. Thanks to Steinþór Pálsson. * :doc:`/plugins/lastgenre`: A new configuration option lets you choose to retrieve artist-level tags as genres instead of album- or track-level tags. Thanks to Peter Fern and Peter Schnebel. * :ref:`max_filename_length` controls truncation of long filenames. Also, beets now tries to determine the filesystem's maximum length automatically if you leave this option unset. * :doc:`/plugins/fetchart`: The ``remote_priority`` option searches remote (Web) art sources even when local art is present. * You can now customize the character substituted for path separators (e.g., /) in filenames via ``path_sep_replace``. The default is an underscore. Use this setting with caution. Other new stuff: * Support for Windows Media/ASF audio files. Thanks to Dave Hayes. * New :doc:`/plugins/smartplaylist`: generate and maintain m3u playlist files based on beets queries. Thanks to Dang Mai Hai. * ReplayGain tags on MPEG-4/AAC files are now supported. And, even more astonishingly, ReplayGain values in MP3 and AAC files are now compatible with `iTunes Sound Check`_. Thanks to Dave Hayes. * Track titles in the importer UI's difference display are now either aligned vertically or broken across two lines for readability. Thanks to Tai Lee. * Albums and items have new fields reflecting the *original* release date (``original_year``, ``original_month``, and ``original_day``). Previously, when tagging from MusicBrainz, *only* the original date was stored; now, the old fields refer to the *specific* release date (e.g., when the album was reissued). * Some changes to the way candidates are recommended for selection, thanks to Tai Lee: * According to the new :ref:`max_rec` configuration option, partial album matches are downgraded to a "low" recommendation by default. * When a match isn't great but is either better than all the others or the only match, it is given a "low" (rather than "medium") recommendation. * There is no prompt default (i.e., input is required) when matches are bad: "low" or "none" recommendations or when choosing a candidate other than the first. * The importer's heuristic for coalescing the directories in a multi-disc album has been improved. It can now detect when two directories alongside each other share a similar prefix but a different number (e.g., "Album Disc 1" and "Album Disc 2") even when they are not alone in a common parent directory. Thanks once again to Tai Lee. * Album listings in the importer UI now show the release medium (CD, Vinyl, 3xCD, etc.) as well as the disambiguation string. Thanks to Peter Schnebel. * :doc:`/plugins/lastgenre`: The plugin can now get different genres for individual tracks on an album. Thanks to Peter Schnebel. * When getting data from MusicBrainz, the album disambiguation string (``albumdisambig``) now reflects both the release and the release group. * :doc:`/plugins/mpdupdate`: Sends an update message whenever *anything* in the database changes---not just when importing. Thanks to Dang Mai Hai. * When the importer UI shows a difference in track numbers or durations, they are now colorized based on the *suffixes* that differ. For example, when showing the difference between 2:01 and 2:09, only the last digit will be highlighted. * The importer UI no longer shows a change when the track length difference is less than 10 seconds. (This threshold was previously 2 seconds.) * Two new plugin events were added: *database_change* and *cli_exit*. Thanks again to Dang Mai Hai. * Plugins are now loaded in the order they appear in the config file. Thanks to Dang Mai Hai. * :doc:`/plugins/bpd`: Browse by album artist and album artist sort name. Thanks to Steinþór Pálsson. * :doc:`/plugins/echonest_tempo`: Don't attempt a lookup when the artist or track title is missing. * Fix an error when migrating the ``.beetsstate`` file on Windows. * A nicer error message is now given when the configuration file contains tabs. (YAML doesn't like tabs.) * Fix the ``-l`` (log path) command-line option for the ``import`` command. .. _iTunes Sound Check: http://support.apple.com/kb/HT2425 1.1b1 (January 29, 2013) ------------------------ This release entirely revamps beets' configuration system. The configuration file is now a `YAML`_ document and is located, along with other support files, in a common directory (e.g., ``~/.config/beets`` on Unix-like systems). If you're upgrading from an earlier version, please see :doc:`/guides/migration`. .. _YAML: http://en.wikipedia.org/wiki/YAML * Renamed plugins: The ``rdm`` plugin has been renamed to ``random`` and ``fuzzy_search`` has been renamed to ``fuzzy``. * Renamed config options: Many plugins have a flag dictating whether their action runs at import time. This option had many names (``autofetch``, ``autoembed``, etc.) but is now consistently called ``auto``. * Reorganized import config options: The various ``import_*`` options are now organized under an ``import:`` heading and their prefixes have been removed. * New default file locations: The default filename of the library database is now ``library.db`` in the same directory as the config file, as opposed to ``~/.beetsmusic.blb`` previously. Similarly, the runtime state file is now called ``state.pickle`` in the same directory instead of ``~/.beetsstate``. It also adds some new features: * :doc:`/plugins/inline`: Inline definitions can now contain statements or blocks in addition to just expressions. Thanks to Florent Thoumie. * Add a configuration option, :ref:`terminal_encoding`, controlling the text encoding used to print messages to standard output. * The MusicBrainz hostname (and rate limiting) are now configurable. See :ref:`musicbrainz-config`. * You can now configure the similarity thresholds used to determine when the autotagger automatically accepts a metadata match. See :ref:`match-config`. * :doc:`/plugins/importfeeds`: Added a new configuration option that controls the base for relative paths used in m3u files. Thanks to Philippe Mongeau. 1.0.0 (January 29, 2013) ------------------------ After fifteen betas and two release candidates, beets has finally hit one-point-oh. Congratulations to everybody involved. This version of beets will remain stable and receive only bug fixes from here on out. New development is ongoing in the betas of version 1.1. * :doc:`/plugins/scrub`: Fix an incompatibility with Python 2.6. * :doc:`/plugins/lyrics`: Fix an issue that failed to find lyrics when metadata contained "real" apostrophes. * :doc:`/plugins/replaygain`: On Windows, emit a warning instead of crashing when analyzing non-ASCII filenames. * Silence a spurious warning from version 0.04.12 of the Unidecode module. 1.0rc2 (December 31, 2012) -------------------------- This second release candidate follows quickly after rc1 and fixes a few small bugs found since that release. There were a couple of regressions and some bugs in a newly added plugin. * :doc:`/plugins/echonest_tempo`: If the Echo Nest API limit is exceeded or a communication error occurs, the plugin now waits and tries again instead of crashing. Thanks to Zach Denton. * :doc:`/plugins/fetchart`: Fix a regression that caused crashes when art was not available from some sources. * Fix a regression on Windows that caused all relative paths to be "not found". 1.0rc1 (December 17, 2012) -------------------------- The first release candidate for beets 1.0 includes a deluge of new features contributed by beets users. The vast majority of the credit for this release goes to the growing and vibrant beets community. A million thanks to everybody who contributed to this release. There are new plugins for transcoding music, fuzzy searches, tempo collection, and fiddling with metadata. The ReplayGain plugin has been rebuilt from scratch. Album art images can now be resized automatically. Many other smaller refinements make things "just work" as smoothly as possible. With this release candidate, beets 1.0 is feature-complete. We'll be fixing bugs on the road to 1.0 but no new features will be added. Concurrently, work begins today on features for version 1.1. * New plugin: :doc:`/plugins/convert` **transcodes** music and embeds album art while copying to a separate directory. Thanks to Jakob Schnitzer and Andrew G. Dunn. * New plugin: :doc:`/plugins/fuzzy` lets you find albums and tracks using **fuzzy string matching** so you don't have to type (or even remember) their exact names. Thanks to Philippe Mongeau. * New plugin: :doc:`/plugins/echonest_tempo` fetches **tempo** (BPM) information from `The Echo Nest`_. Thanks to David Brenner. * New plugin: :doc:`/plugins/the` adds a template function that helps format text for nicely-sorted directory listings. Thanks to Blemjhoo Tezoulbr. * New plugin: :doc:`/plugins/zero` **filters out undesirable fields** before they are written to your tags. Thanks again to Blemjhoo Tezoulbr. * New plugin: :doc:`/plugins/ihate` automatically skips (or warns you about) importing albums that match certain criteria. Thanks once again to Blemjhoo Tezoulbr. * :doc:`/plugins/replaygain`: This plugin has been completely overhauled to use the `mp3gain`_ or `aacgain`_ command-line tools instead of the failure-prone Gstreamer ReplayGain implementation. Thanks to Fabrice Laporte. * :doc:`/plugins/fetchart` and :doc:`/plugins/embedart`: Both plugins can now **resize album art** to avoid excessively large images. Use the ``maxwidth`` config option with either plugin. Thanks to Fabrice Laporte. * :doc:`/plugins/scrub`: Scrubbing now removes *all* types of tags from a file rather than just one. For example, if your FLAC file has both ordinary FLAC tags and ID3 tags, the ID3 tags are now also removed. * :ref:`stats-cmd` command: New ``--exact`` switch to make the file size calculation more accurate (thanks to Jakob Schnitzer). * :ref:`list-cmd` command: Templates given with ``-f`` can now show items' and albums' paths (using ``$path``). * The output of the :ref:`update-cmd`, :ref:`remove-cmd`, and :ref:`modify-cmd` commands now respects the :ref:`list_format_album` and :ref:`list_format_item` config options. Thanks to Mike Kazantsev. * The :ref:`art-filename` option can now be a template rather than a simple string. Thanks to Jarrod Beardwood. * Fix album queries for ``artpath`` and other non-item fields. * Null values in the database can now be matched with the empty-string regular expression, ``^$``. * Queries now correctly match non-string values in path format predicates. * When autotagging a various-artists album, the album artist field is now used instead of the majority track artist. * :doc:`/plugins/lastgenre`: Use the albums' existing genre tags if they pass the whitelist (thanks to Fabrice Laporte). * :doc:`/plugins/lastgenre`: Add a ``lastgenre`` command for fetching genres post facto (thanks to Jakob Schnitzer). * :doc:`/plugins/fetchart`: Local image filenames are now used in alphabetical order. * :doc:`/plugins/fetchart`: Fix a bug where cover art filenames could lack a ``.jpg`` extension. * :doc:`/plugins/lyrics`: Fix an exception with non-ASCII lyrics. * :doc:`/plugins/web`: The API now reports file sizes (for use with the `Tomahawk resolver`_). * :doc:`/plugins/web`: Files now download with a reasonable filename rather than just being called "file" (thanks to Zach Denton). * :doc:`/plugins/importfeeds`: Fix error in symlink mode with non-ASCII filenames. * :doc:`/plugins/mbcollection`: Fix an error when submitting a large number of releases (we now submit only 200 releases at a time instead of 350). Thanks to Jonathan Towne. * :doc:`/plugins/embedart`: Made the method for embedding art into FLAC files `standard `_-compliant. Thanks to Daniele Sluijters. * Add the track mapping dictionary to the ``album_distance`` plugin function. * When an exception is raised while reading a file, the path of the file in question is now logged (thanks to Mike Kazantsev). * Truncate long filenames based on their *bytes* rather than their Unicode *characters*, fixing situations where encoded names could be too long. * Filename truncation now incorporates the length of the extension. * Fix an assertion failure when the MusicBrainz main database and search server disagree. * Fix a bug that caused the :doc:`/plugins/lastgenre` and other plugins not to modify files' tags even when they successfully change the database. * Fix a VFS bug leading to a crash in the :doc:`/plugins/bpd` when files had non-ASCII extensions. * Fix for changing date fields (like "year") with the :ref:`modify-cmd` command. * Fix a crash when input is read from a pipe without a specified encoding. * Fix some problem with identifying files on Windows with Unicode directory names in their path. * Fix a crash when Unicode queries were used with ``import -L`` re-imports. * Fix an error when fingerprinting files with Unicode filenames on Windows. * Warn instead of crashing when importing a specific file in singleton mode. * Add human-readable error messages when writing files' tags fails or when a directory can't be created. * Changed plugin loading so that modules can be imported without unintentionally loading the plugins they contain. .. _The Echo Nest: http://the.echonest.com/ .. _Tomahawk resolver: http://beets.radbox.org/blog/tomahawk-resolver.html .. _mp3gain: http://mp3gain.sourceforge.net/download.php .. _aacgain: http://aacgain.altosdesign.com 1.0b15 (July 26, 2012) ---------------------- The fifteenth (!) beta of beets is compendium of small fixes and features, most of which represent long-standing requests. The improvements include matching albums with extra tracks, per-disc track numbering in multi-disc albums, an overhaul of the album art downloader, and robustness enhancements that should keep beets running even when things go wrong. All these smaller changes should help us focus on some larger changes coming before 1.0. Please note that this release contains one backwards-incompatible change: album art fetching, which was previously baked into the import workflow, is now encapsulated in a plugin (the :doc:`/plugins/fetchart`). If you want to continue fetching cover art for your music, enable this plugin after upgrading to beets 1.0b15. * The autotagger can now find matches for albums when you have **extra tracks** on your filesystem that aren't present in the MusicBrainz catalog. Previously, if you tried to match album with 15 audio files but the MusicBrainz entry had only 14 tracks, beets would ignore this match. Now, beets will show you matches even when they are "too short" and indicate which tracks from your disk are unmatched. * Tracks on multi-disc albums can now be **numbered per-disc** instead of per-album via the :ref:`per_disc_numbering` config option. * The default output format for the ``beet list`` command is now configurable via the :ref:`list_format_item` and :ref:`list_format_album` config options. Thanks to Fabrice Laporte. * Album **cover art fetching** is now encapsulated in the :doc:`/plugins/fetchart`. Be sure to enable this plugin if you're using this functionality. As a result of this new organization, the new plugin has gained a few new features: * "As-is" and non-autotagged imports can now have album art imported from the local filesystem (although Web repositories are still not searched in these cases). * A new command, ``beet fetchart``, allows you to download album art post-import. If you only want to fetch art manually, not automatically during import, set the new plugin's ``autofetch`` option to ``no``. * New album art sources have been added. * Errors when communicating with MusicBrainz now log an error message instead of halting the importer. * Similarly, filesystem manipulation errors now print helpful error messages instead of a messy traceback. They still interrupt beets, but they should now be easier for users to understand. Tracebacks are still available in verbose mode. * New metadata fields for `artist credits`_: ``artist_credit`` and ``albumartist_credit`` can now contain release- and recording-specific variations of the artist's name. See :ref:`itemfields`. * Revamped the way beets handles concurrent database access to avoid nondeterministic SQLite-related crashes when using the multithreaded importer. On systems where SQLite was compiled without ``usleep(3)`` support, multithreaded database access could cause an internal error (with the message "database is locked"). This release synchronizes access to the database to avoid internal SQLite contention, which should avoid this error. * Plugins can now add parallel stages to the import pipeline. See :ref:`writing-plugins`. * Beets now prints out an error when you use an unrecognized field name in a query: for example, when running ``beet ls -a artist:foo`` (because ``artist`` is an item-level field). * New plugin events: * ``import_task_choice`` is called after an import task has an action assigned. * ``import_task_files`` is called after a task's file manipulation has finished (copying or moving files, writing metadata tags). * ``library_opened`` is called when beets starts up and opens the library database. * :doc:`/plugins/lastgenre`: Fixed a problem where path formats containing ``$genre`` would use the old genre instead of the newly discovered one. * Fix a crash when moving files to a Samba share. * :doc:`/plugins/mpdupdate`: Fix TypeError crash (thanks to Philippe Mongeau). * When re-importing files with ``import_copy`` enabled, only files inside the library directory are moved. Files outside the library directory are still copied. This solves a problem (introduced in 1.0b14) where beets could crash after adding files to the library but before finishing copying them; during the next import, the (external) files would be moved instead of copied. * Artist sort names are now populated correctly for multi-artist tracks and releases. (Previously, they only reflected the first artist.) * When previewing changes during import, differences in track duration are now shown as "2:50 vs. 3:10" rather than separated with ``->`` like track numbers. This should clarify that beets isn't doing anything to modify lengths. * Fix a problem with query-based path format matching where a field-qualified pattern, like ``albumtype_soundtrack``, would match everything. * :doc:`/plugins/chroma`: Fix matching with ambiguous Acoustids. Some Acoustids are identified with multiple recordings; beets now considers any associated recording a valid match. This should reduce some cases of errant track reordering when using chroma. * Fix the ID3 tag name for the catalog number field. * :doc:`/plugins/chroma`: Fix occasional crash at end of fingerprint submission and give more context to "failed fingerprint generation" errors. * Interactive prompts are sent to stdout instead of stderr. * :doc:`/plugins/embedart`: Fix crash when audio files are unreadable. * :doc:`/plugins/bpd`: Fix crash when sockets disconnect (thanks to Matteo Mecucci). * Fix an assertion failure while importing with moving enabled when the file was already at its destination. * Fix Unicode values in the ``replace`` config option (thanks to Jakob Borg). * Use a nicer error message when input is requested but stdin is closed. * Fix errors on Windows for certain Unicode characters that can't be represented in the MBCS encoding. This required a change to the way that paths are represented in the database on Windows; if you find that beets' paths are out of sync with your filesystem with this release, delete and recreate your database with ``beet import -AWC /path/to/music``. * Fix ``import`` with relative path arguments on Windows. .. _artist credits: http://wiki.musicbrainz.org/Artist_Credit 1.0b14 (May 12, 2012) --------------------- The centerpiece of this beets release is the graceful handling of similarly-named albums. It's now possible to import two albums with the same artist and title and to keep them from conflicting in the filesystem. Many other awesome new features were contributed by the beets community, including regular expression queries, artist sort names, moving files on import. There are three new plugins: random song/album selection; MusicBrainz "collection" integration; and a plugin for interoperability with other music library systems. A million thanks to the (growing) beets community for making this a huge release. * The importer now gives you **choices when duplicates are detected**. Previously, when beets found an existing album or item in your library matching the metadata on a newly-imported one, it would just skip the new music to avoid introducing duplicates into your library. Now, you have three choices: skip the new music (the previous behavior), keep both, or remove the old music. See the :ref:`guide-duplicates` section in the autotagging guide for details. * Beets can now avoid storing identically-named albums in the same directory. The new ``%aunique{}`` template function, which is included in the default path formats, ensures that Crystal Castles' albums will be placed into different directories. See :ref:`aunique` for details. * Beets queries can now use **regular expressions**. Use an additional ``:`` in your query to enable regex matching. See :ref:`regex` for the full details. Thanks to Matteo Mecucci. * Artist **sort names** are now fetched from MusicBrainz. There are two new data fields, ``artist_sort`` and ``albumartist_sort``, that contain sortable artist names like "Beatles, The". These fields are also used to sort albums and items when using the ``list`` command. Thanks to Paul Provost. * Many other **new metadata fields** were added, including ASIN, label catalog number, disc title, encoder, and MusicBrainz release group ID. For a full list of fields, see :ref:`itemfields`. * :doc:`/plugins/chroma`: A new command, ``beet submit``, will **submit fingerprints** to the Acoustid database. Submitting your library helps increase the coverage and accuracy of Acoustid fingerprinting. The Chromaprint fingerprint and Acoustid ID are also now stored for all fingerprinted tracks. This version of beets *requires* at least version 0.6 of `pyacoustid`_ for fingerprinting to work. * The importer can now **move files**. Previously, beets could only copy files and delete the originals, which is inefficient if the source and destination are on the same filesystem. Use the ``import_move`` configuration option and see :doc:`/reference/config` for more details. Thanks to Domen Kožar. * New :doc:`/plugins/random`: Randomly select albums and tracks from your library. Thanks to Philippe Mongeau. * The :doc:`/plugins/mbcollection` by Jeffrey Aylesworth was added to the core beets distribution. * New :doc:`/plugins/importfeeds`: Catalog imported files in ``m3u`` playlist files or as symlinks for easy importing to other systems. Thanks to Fabrice Laporte. * The ``-f`` (output format) option to the ``beet list`` command can now contain template functions as well as field references. Thanks to Steve Dougherty. * A new command ``beet fields`` displays the available metadata fields (thanks to Matteo Mecucci). * The ``import`` command now has a ``--noincremental`` or ``-I`` flag to disable incremental imports (thanks to Matteo Mecucci). * When the autotagger fails to find a match, it now displays the number of tracks on the album (to help you guess what might be going wrong) and a link to the FAQ. * The default filename character substitutions were changed to be more conservative. The Windows "reserved characters" are substituted by default even on Unix platforms (this causes less surprise when using Samba shares to store music). To customize your character substitutions, see :ref:`the replace config option `. * :doc:`/plugins/lastgenre`: Added a "fallback" option when no suitable genre can be found (thanks to Fabrice Laporte). * :doc:`/plugins/rewrite`: Unicode rewriting rules are now allowed (thanks to Nicolas Dietrich). * Filename collisions are now avoided when moving album art. * :doc:`/plugins/bpd`: Print messages to show when directory tree is being constructed. * :doc:`/plugins/bpd`: Use Gstreamer's ``playbin2`` element instead of the deprecated ``playbin``. * :doc:`/plugins/bpd`: Random and repeat modes are now supported (thanks to Matteo Mecucci). * :doc:`/plugins/bpd`: Listings are now sorted (thanks once again to Matteo Mecucci). * Filenames are normalized with Unicode Normal Form D (NFD) on Mac OS X and NFC on all other platforms. * Significant internal restructuring to avoid SQLite locking errors. As part of these changes, the not-very-useful "save" plugin event has been removed. .. _pyacoustid: https://github.com/sampsyo/pyacoustid 1.0b13 (March 16, 2012) ----------------------- Beets 1.0b13 consists of a plethora of small but important fixes and refinements. A lyrics plugin is now included with beets; new audio properties are catalogged; the ``list`` command has been made more powerful; the autotagger is more tolerant of different tagging styles; and importing with original file deletion now cleans up after itself more thoroughly. Many, many bugs—including several crashers—were fixed. This release lays the foundation for more features to come in the next couple of releases. * The :doc:`/plugins/lyrics`, originally by `Peter Brunner`_, is revamped and included with beets, making it easy to fetch **song lyrics**. * Items now expose their audio **sample rate**, number of **channels**, and **bits per sample** (bitdepth). See :doc:`/reference/pathformat` for a list of all available audio properties. Thanks to Andrew Dunn. * The ``beet list`` command now accepts a "format" argument that lets you **show specific information about each album or track**. For example, run ``beet ls -af '$album: $tracktotal' beatles`` to see how long each Beatles album is. Thanks to Philippe Mongeau. * The autotagger now tolerates tracks on multi-disc albums that are numbered per-disc. For example, if track 24 on a release is the first track on the second disc, then it is not penalized for having its track number set to 1 instead of 24. * The autotagger sets the disc number and disc total fields on autotagged albums. * The autotagger now also tolerates tracks whose track artists tags are set to "Various Artists". * Terminal colors are now supported on Windows via `Colorama`_ (thanks to Karl). * When previewing metadata differences, the importer now shows discrepancies in track length. * Importing with ``import_delete`` enabled now cleans up empty directories that contained deleting imported music files. * Similarly, ``import_delete`` now causes original album art imported from the disk to be deleted. * Plugin-supplied template values, such as those created by ``rewrite``, are now properly sanitized (for example, ``AC/DC`` properly becomes ``AC_DC``). * Filename extensions are now always lower-cased when copying and moving files. * The ``inline`` plugin now prints a more comprehensible error when exceptions occur in Python snippets. * The ``replace`` configuration option can now remove characters entirely (in addition to replacing them) if the special string ```` is specified as the replacement. * New plugin API: plugins can now add fields to the MediaFile tag abstraction layer. See :ref:`writing-plugins`. * A reasonable error message is now shown when the import log file cannot be opened. * The import log file is now flushed and closed properly so that it can be used to monitor import progress, even when the import crashes. * Duplicate track matches are no longer shown when autotagging singletons. * The ``chroma`` plugin now logs errors when fingerprinting fails. * The ``lastgenre`` plugin suppresses more errors when dealing with the Last.fm API. * Fix a bug in the ``rewrite`` plugin that broke the use of multiple rules for a single field. * Fix a crash with non-ASCII characters in bytestring metadata fields (e.g., MusicBrainz IDs). * Fix another crash with non-ASCII characters in the configuration paths. * Fix a divide-by-zero crash on zero-length audio files. * Fix a crash in the ``chroma`` plugin when the Acoustid database had no recording associated with a fingerprint. * Fix a crash when an autotagging with an artist or album containing "AND" or "OR" (upper case). * Fix an error in the ``rewrite`` and ``inline`` plugins when the corresponding config sections did not exist. * Fix bitrate estimation for AAC files whose headers are missing the relevant data. * Fix the ``list`` command in BPD (thanks to Simon Chopin). .. _Colorama: http://pypi.python.org/pypi/colorama 1.0b12 (January 16, 2012) ------------------------- This release focuses on making beets' path formatting vastly more powerful. It adds a function syntax for transforming text. Via a new plugin, arbitrary Python code can also be used to define new path format fields. Each path format template can now be activated conditionally based on a query. Character set substitutions are also now configurable. In addition, beets avoids problematic filename conflicts by appending numbers to filenames that would otherwise conflict. Three new plugins (``inline``, ``scrub``, and ``rewrite``) are included in this release. * **Functions in path formats** provide a simple way to write complex file naming rules: for example, ``%upper{%left{$artist,1}}`` will insert the capitalized first letter of the track's artist. For more details, see :doc:`/reference/pathformat`. If you're interested in adding your own template functions via a plugin, see :ref:`writing-plugins`. * Plugins can also now define new path *fields* in addition to functions. * The new :doc:`/plugins/inline` lets you **use Python expressions to customize path formats** by defining new fields in the config file. * The configuration can **condition path formats based on queries**. That is, you can write a path format that is only used if an item matches a given query. (This supersedes the earlier functionality that only allowed conditioning on album type; if you used this feature in a previous version, you will need to replace, for example, ``soundtrack:`` with ``albumtype_soundtrack:``.) See :ref:`path-format-config`. * **Filename substitutions are now configurable** via the ``replace`` config value. You can choose which characters you think should be allowed in your directory and music file names. See :doc:`/reference/config`. * Beets now ensures that files have **unique filenames** by appending a number to any filename that would otherwise conflict with an existing file. * The new :doc:`/plugins/scrub` can remove extraneous metadata either manually or automatically. * The new :doc:`/plugins/rewrite` can canonicalize names for path formats. * The autotagging heuristics have been tweaked in situations where the MusicBrainz database did not contain track lengths. Previously, beets penalized matches where this was the case, leading to situations where seemingly good matches would have poor similarity. This penalty has been removed. * Fix an incompatibility in BPD with libmpc (the library that powers mpc and ncmpc). * Fix a crash when importing a partial match whose first track was missing. * The ``lastgenre`` plugin now correctly writes discovered genres to imported files (when tag-writing is enabled). * Add a message when skipping directories during an incremental import. * The default ignore settings now ignore all files beginning with a dot. * Date values in path formats (``$year``, ``$month``, and ``$day``) are now appropriately zero-padded. * Removed the ``--path-format`` global flag for ``beet``. * Removed the ``lastid`` plugin, which was deprecated in the previous version. 1.0b11 (December 12, 2011) -------------------------- This version of beets focuses on transitioning the autotagger to the new version of the MusicBrainz database (called NGS). This transition brings with it a number of long-overdue improvements: most notably, predictable behavior when tagging multi-disc albums and integration with the new `Acoustid`_ acoustic fingerprinting technology. The importer can also now tag *incomplete* albums when you're missing a few tracks from a given release. Two other new plugins are also included with this release: one for assigning genres and another for ReplayGain analysis. * Beets now communicates with MusicBrainz via the new `Next Generation Schema`_ (NGS) service via `python-musicbrainz-ngs`_. The bindings are included with this version of beets, but a future version will make them an external dependency. * The importer now detects **multi-disc albums** and tags them together. Using a heuristic based on the names of directories, certain structures are classified as multi-disc albums: for example, if a directory contains subdirectories labeled "disc 1" and "disc 2", these subdirectories will be coalesced into a single album for tagging. * The new :doc:`/plugins/chroma` uses the `Acoustid`_ **open-source acoustic fingerprinting** service. This replaces the old ``lastid`` plugin, which used Last.fm fingerprinting and is now deprecated. Fingerprinting with this library should be faster and more reliable. * The importer can now perform **partial matches**. This means that, if you're missing a few tracks from an album, beets can still tag the remaining tracks as a single album. (Thanks to `Simon Chopin`_.) * The new :doc:`/plugins/lastgenre` automatically **assigns genres to imported albums** and items based on Last.fm tags and an internal whitelist. (Thanks to `KraYmer`_.) * The :doc:`/plugins/replaygain`, written by `Peter Brunner`_, has been merged into the core beets distribution. Use it to analyze audio and **adjust playback levels** in ReplayGain-aware music players. * Albums are now tagged with their *original* release date rather than the date of any reissue, remaster, "special edition", or the like. * The config file and library databases are now given better names and locations on Windows. Namely, both files now reside in ``%APPDATA%``; the config file is named ``beetsconfig.ini`` and the database is called ``beetslibrary.blb`` (neither has a leading dot as on Unix). For backwards compatibility, beets will check the old locations first. * When entering an ID manually during tagging, beets now searches for anything that looks like an MBID in the entered string. This means that full MusicBrainz URLs now work as IDs at the prompt. (Thanks to derwin.) * The importer now ignores certain "clutter" files like ``.AppleDouble`` directories and ``._*`` files. The list of ignored patterns is configurable via the ``ignore`` setting; see :doc:`/reference/config`. * The database now keeps track of files' modification times so that, during an ``update``, unmodified files can be skipped. (Thanks to Jos van der Til.) * The album art fetcher now uses `albumart.org`_ as a fallback when the Amazon art downloader fails. * A new ``timeout`` config value avoids database locking errors on slow systems. * Fix a crash after using the "as Tracks" option during import. * Fix a Unicode error when tagging items with missing titles. * Fix a crash when the state file (``~/.beetsstate``) became emptied or corrupted. .. _KraYmer: https://github.com/KraYmer .. _Next Generation Schema: http://musicbrainz.org/doc/XML_Web_Service/Version_2 .. _python-musicbrainz-ngs: https://github.com/alastair/python-musicbrainz-ngs .. _acoustid: http://acoustid.org/ .. _Peter Brunner: https://github.com/Lugoues .. _Simon Chopin: https://github.com/laarmen .. _albumart.org: http://www.albumart.org/ 1.0b10 (September 22, 2011) --------------------------- This version of beets focuses on making it easier to manage your metadata *after* you've imported it. A bumper crop of new commands has been added: a manual tag editor (``modify``), a tool to pick up out-of-band deletions and modifications (``update``), and functionality for moving and copying files around (``move``). Furthermore, the concept of "re-importing" is new: you can choose to re-run beets' advanced autotagger on any files you already have in your library if you change your mind after you finish the initial import. As a couple of added bonuses, imports can now automatically skip previously-imported directories (with the ``-i`` flag) and there's an :doc:`experimental Web interface ` to beets in a new standard plugin. * A new ``beet modify`` command enables **manual, command-line-based modification** of music metadata. Pass it a query along with ``field=value`` pairs that specify the changes you want to make. * A new ``beet update`` command updates the database to reflect **changes in the on-disk metadata**. You can now use an external program to edit tags on files, remove files and directories, etc., and then run ``beet update`` to make sure your beets library is in sync. This will also rename files to reflect their new metadata. * A new ``beet move`` command can **copy or move files** into your library directory or to another specified directory. * When importing files that are already in the library database, the items are no longer duplicated---instead, the library is updated to reflect the new metadata. This way, the import command can be transparently used as a **re-import**. * Relatedly, the ``-L`` flag to the "import" command makes it take a query as its argument instead of a list of directories. The matched albums (or items, depending on the ``-s`` flag) are then re-imported. * A new flag ``-i`` to the import command runs **incremental imports**, keeping track of and skipping previously-imported directories. This has the effect of making repeated import commands pick up only newly-added directories. The ``import_incremental`` config option makes this the default. * When pruning directories, "clutter" files such as ``.DS_Store`` and ``Thumbs.db`` are ignored (and removed with otherwise-empty directories). * The :doc:`/plugins/web` encapsulates a simple **Web-based GUI for beets**. The current iteration can browse the library and play music in browsers that support `HTML5 Audio`_. * When moving items that are part of an album, the album art implicitly moves too. * Files are no longer silently overwritten when moving and copying files. * Handle exceptions thrown when running Mutagen. * Fix a missing ``__future__`` import in ``embed art`` on Python 2.5. * Fix ID3 and MPEG-4 tag names for the album-artist field. * Fix Unicode encoding of album artist, album type, and label. * Fix crash when "copying" an art file that's already in place. .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html 1.0b9 (July 9, 2011) -------------------- This release focuses on a large number of small fixes and improvements that turn beets into a well-oiled, music-devouring machine. See the full release notes, below, for a plethora of new features. * **Queries can now contain whitespace.** Spaces passed as shell arguments are now preserved, so you can use your shell's escaping syntax (quotes or backslashes, for instance) to include spaces in queries. For example, typing``beet ls "the knife"`` or ``beet ls the\ knife``. Read more in :doc:`/reference/query`. * Queries can **match items from the library by directory**. A ``path:`` prefix is optional; any query containing a path separator (/ on POSIX systems) is assumed to be a path query. Running ``beet ls path/to/music`` will show all the music in your library under the specified directory. The :doc:`/reference/query` reference again has more details. * **Local album art** is now automatically discovered and copied from the imported directories when available. * When choosing the "as-is" import album (or doing a non-autotagged import), **every album either has an "album artist" set or is marked as a compilation (Various Artists)**. The choice is made based on the homogeneity of the tracks' artists. This prevents compilations that are imported as-is from being scattered across many directories after they are imported. * The release **label** for albums and tracks is now fetched from !MusicBrainz, written to files, and stored in the database. * The "list" command now accepts a ``-p`` switch that causes it to **show paths** instead of titles. This makes the output of ``beet ls -p`` suitable for piping into another command such as `xargs`_. * Release year and label are now shown in the candidate selection list to help disambiguate different releases of the same album. * Prompts in the importer interface are now colorized for easy reading. The default option is always highlighted. * The importer now provides the option to specify a MusicBrainz ID manually if the built-in searching isn't working for a particular album or track. * ``$bitrate`` in path formats is now formatted as a human-readable kbps value instead of as a raw integer. * The import logger has been improved for "always-on" use. First, it is now possible to specify a log file in .beetsconfig. Also, logs are now appended rather than overwritten and contain timestamps. * Album art fetching and plugin events are each now run in separate pipeline stages during imports. This should bring additional performance when using album art plugins like embedart or beets-lyrics. * Accents and other Unicode decorators on characters are now treated more fairly by the autotagger. For example, if you're missing the acute accent on the "e" in "café", that change won't be penalized. This introduces a new dependency on the `unidecode`_ Python module. * When tagging a track with no title set, the track's filename is now shown (instead of nothing at all). * The bitrate of lossless files is now calculated from their file size (rather than being fixed at 0 or reflecting the uncompressed audio bitrate). * Fixed a problem where duplicate albums or items imported at the same time would fail to be detected. * BPD now uses a persistent "virtual filesystem" in order to fake a directory structure. This means that your path format settings are respected in BPD's browsing hierarchy. This may come at a performance cost, however. The virtual filesystem used by BPD is available for reuse by plugins (e.g., the FUSE plugin). * Singleton imports (``beet import -s``) can now take individual files as arguments as well as directories. * Fix Unicode queries given on the command line. * Fix crasher in quiet singleton imports (``import -qs``). * Fix crash when autotagging files with no metadata. * Fix a rare deadlock when finishing the import pipeline. * Fix an issue that was causing mpdupdate to run twice for every album. * Fix a bug that caused release dates/years not to be fetched. * Fix a crasher when setting MBIDs on MP3s file metadata. * Fix a "broken pipe" error when piping beets' standard output. * A better error message is given when the database file is unopenable. * Suppress errors due to timeouts and bad responses from MusicBrainz. * Fix a crash on album queries with item-only field names. .. _xargs: http://en.wikipedia.org/wiki/xargs .. _unidecode: http://pypi.python.org/pypi/Unidecode/0.04.1 1.0b8 (April 28, 2011) ---------------------- This release of beets brings two significant new features. First, beets now has first-class support for "singleton" tracks. Previously, it was only really meant to manage whole albums, but many of us have lots of non-album tracks to keep track of alongside our collections of albums. So now beets makes it easy to tag, catalog, and manipulate your individual tracks. Second, beets can now (optionally) embed album art directly into file metadata rather than only storing it in a "file on the side." Check out the :doc:`/plugins/embedart` for that functionality. * Better support for **singleton (non-album) tracks**. Whereas beets previously only really supported full albums, now it can also keep track of individual, off-album songs. The "singleton" path format can be used to customize where these tracks are stored. To import singleton tracks, provide the -s switch to the import command or, while doing a normal full-album import, choose the "as Tracks" (T) option to add singletons to your library. To list only singleton or only album tracks, use the new ``singleton:`` query term: the query ``singleton:true`` matches only singleton tracks; ``singleton:false`` matches only album tracks. The ``lastid`` plugin has been extended to support matching individual items as well. * The importer/autotagger system has been heavily refactored in this release. If anything breaks as a result, please get in touch or just file a bug. * Support for **album art embedded in files**. A new :doc:`/plugins/embedart` implements this functionality. Enable the plugin to automatically embed downloaded album art into your music files' metadata. The plugin also provides the "embedart" and "extractart" commands for moving image files in and out of metadata. See the wiki for more details. (Thanks, daenney!) * The "distance" number, which quantifies how different an album's current and proposed metadata are, is now displayed as "similarity" instead. This should be less noisy and confusing; you'll now see 99.5% instead of 0.00489323. * A new "timid mode" in the importer asks the user every time, even when it makes a match with very high confidence. The ``-t`` flag on the command line and the ``import_timid`` config option control this mode. (Thanks to mdecker on GitHub!) * The multithreaded importer should now abort (either by selecting aBort or by typing ^C) much more quickly. Previously, it would try to get a lot of work done before quitting; now it gives up as soon as it can. * Added a new plugin event, ``album_imported``, which is called every time an album is added to the library. (Thanks, Lugoues!) * A new plugin method, ``register_listener``, is an imperative alternative to the ``@listen`` decorator (Thanks again, Lugoues!) * In path formats, ``$albumartist`` now falls back to ``$artist`` (as well as the other way around). * The importer now prints "(unknown album)" when no tags are present. * When autotagging, "and" is considered equal to "&". * Fix some crashes when deleting files that don't exist. * Fix adding individual tracks in BPD. * Fix crash when ``~/.beetsconfig`` does not exist. 1.0b7 (April 5, 2011) --------------------- Beta 7's focus is on better support for "various artists" releases. These albums can be treated differently via the new ``[paths]`` config section and the autotagger is better at handling them. It also includes a number of oft-requested improvements to the ``beet`` command-line tool, including several new configuration options and the ability to clean up empty directory subtrees. * **"Various artists" releases** are handled much more gracefully. The autotagger now sets the ``comp`` flag on albums whenever the album is identified as a "various artists" release by !MusicBrainz. Also, there is now a distinction between the "album artist" and the "track artist", the latter of which is never "Various Artists" or other such bogus stand-in. *(Thanks to Jonathan for the bulk of the implementation work on this feature!)* * The directory hierarchy can now be **customized based on release type**. In particular, the ``path_format`` setting in .beetsconfig has been replaced with a new ``[paths]`` section, which allows you to specify different path formats for normal and "compilation" (various artists) releases as well as for each album type (see below). The default path formats have been changed to use ``$albumartist`` instead of ``$artist``. * A **new ``albumtype`` field** reflects the release type `as specified by MusicBrainz`_. * When deleting files, beets now appropriately "prunes" the directory tree---empty directories are automatically cleaned up. *(Thanks to wlof on GitHub for this!)* * The tagger's output now always shows the album directory that is currently being tagged. This should help in situations where files' current tags are missing or useless. * The logging option (``-l``) to the ``import`` command now logs duplicate albums. * A new ``import_resume`` configuration option can be used to disable the importer's resuming feature or force it to resume without asking. This option may be either ``yes``, ``no``, or ``ask``, with the obvious meanings. The ``-p`` and ``-P`` command-line flags override this setting and correspond to the "yes" and "no" settings. * Resuming is automatically disabled when the importer is in quiet (``-q``) mode. Progress is still saved, however, and the ``-p`` flag (above) can be used to force resuming. * The ``BEETSCONFIG`` environment variable can now be used to specify the location of the config file that is at ~/.beetsconfig by default. * A new ``import_quiet_fallback`` config option specifies what should happen in quiet mode when there is no strong recommendation. The options are ``skip`` (the default) and "asis". * When importing with the "delete" option and importing files that are already at their destination, files could be deleted (leaving zero copies afterward). This is fixed. * The ``version`` command now lists all the loaded plugins. * A new plugin, called ``info``, just prints out audio file metadata. * Fix a bug where some files would be erroneously interpreted as MPEG-4 audio. * Fix permission bits applied to album art files. * Fix malformed !MusicBrainz queries caused by null characters. * Fix a bug with old versions of the Monkey's Audio format. * Fix a crash on broken symbolic links. * Retry in more cases when !MusicBrainz servers are slow/overloaded. * The old "albumify" plugin for upgrading databases was removed. .. _as specified by MusicBrainz: http://wiki.musicbrainz.org/ReleaseType 1.0b6 (January 20, 2011) ------------------------ This version consists primarily of bug fixes and other small improvements. It's in preparation for a more feature-ful release in beta 7. The most important issue involves correct ordering of autotagged albums. * **Quiet import:** a new "-q" command line switch for the import command suppresses all prompts for input; it pessimistically skips all albums that the importer is not completely confident about. * Added support for the **WavPack** and **Musepack** formats. Unfortunately, due to a limitation in the Mutagen library (used by beets for metadata manipulation), Musepack SV8 is not yet supported. Here's the `upstream bug`_ in question. * BPD now uses a pure-Python socket library and no longer requires eventlet/greenlet (the latter of which is a C extension). For the curious, the socket library in question is called `Bluelet`_. * Non-autotagged imports are now resumable (just like autotagged imports). * Fix a terrible and long-standing bug where track orderings were never applied. This manifested when the tagger appeared to be applying a reasonable ordering to the tracks but, later, the database reflects a completely wrong association of track names to files. The order applied was always just alphabetical by filename, which is frequently but not always what you want. * We now use Windows' "long filename" support. This API is fairly tricky, though, so some instability may still be present---please file a bug if you run into pathname weirdness on Windows. Also, filenames on Windows now never end in spaces. * Fix crash in lastid when the artist name is not available. * Fixed a spurious crash when ``LANG`` or a related environment variable is set to an invalid value (such as ``'UTF-8'`` on some installations of Mac OS X). * Fixed an error when trying to copy a file that is already at its destination. * When copying read-only files, the importer now tries to make the copy writable. (Previously, this would just crash the import.) * Fixed an ``UnboundLocalError`` when no matches are found during autotag. * Fixed a Unicode encoding error when entering special characters into the "manual search" prompt. * Added `` beet version`` command that just shows the current release version. .. _upstream bug: http://code.google.com/p/mutagen/issues/detail?id=7 .. _Bluelet: https://github.com/sampsyo/bluelet 1.0b5 (September 28, 2010) -------------------------- This version of beets focuses on increasing the accuracy of the autotagger. The main addition is an included plugin that uses acoustic fingerprinting to match based on the audio content (rather than existing metadata). Additional heuristics were also added to the metadata-based tagger as well that should make it more reliable. This release also greatly expands the capabilities of beets' :doc:`plugin API `. A host of other little features and fixes are also rolled into this release. * The ``lastid`` plugin adds Last.fm **acoustic fingerprinting support** to the autotagger. Similar to the PUIDs used by !MusicBrainz Picard, this system allows beets to recognize files that don't have any metadata at all. You'll need to install some dependencies for this plugin to work. * To support the above, there's also a new system for **extending the autotagger via plugins**. Plugins can currently add components to the track and album distance functions as well as augment the MusicBrainz search. The new API is documented at :doc:`/plugins/index`. * **String comparisons** in the autotagger have been augmented to act more intuitively. Previously, if your album had the title "Something (EP)" and it was officially called "Something", then beets would think this was a fairly significant change. It now checks for and appropriately reweights certain parts of each string. As another example, the title "The Great Album" is considered equal to "Great Album, The". * New **event system for plugins** (thanks, Jeff!). Plugins can now get callbacks from beets when certain events occur in the core. Again, the API is documented in :doc:`/plugins/index`. * The BPD plugin is now disabled by default. This greatly simplifies installation of the beets core, which is now 100% pure Python. To use BPD, though, you'll need to set ``plugins: bpd`` in your .beetsconfig. * The ``import`` command can now remove original files when it copies items into your library. (This might be useful if you're low on disk space.) Set the ``import_delete`` option in your .beetsconfig to ``yes``. * Importing without autotagging (``beet import -A``) now prints out album names as it imports them to indicate progress. * The new :doc:`/plugins/mpdupdate` will automatically update your MPD server's index whenever your beets library changes. * Efficiency tweak should reduce the number of !MusicBrainz queries per autotagged album. * A new ``-v`` command line switch enables debugging output. * Fixed bug that completely broke non-autotagged imports (``import -A``). * Fixed bug that logged the wrong paths when using ``import -l``. * Fixed autotagging for the creatively-named band `!!!`_. * Fixed normalization of relative paths. * Fixed escaping of ``/`` characters in paths on Windows. .. _!!!: http://musicbrainz.org/artist/f26c72d3-e52c-467b-b651-679c73d8e1a7.html 1.0b4 (August 9, 2010) ---------------------- This thrilling new release of beets focuses on making the tagger more usable in a variety of ways. First and foremost, it should now be much faster: the tagger now uses a multithreaded algorithm by default (although, because the new tagger is experimental, a single-threaded version is still available via a config option). Second, the tagger output now uses a little bit of ANSI terminal coloring to make changes stand out. This way, it should be faster to decide what to do with a proposed match: the more red you see, the worse the match is. Finally, the tagger can be safely interrupted (paused) and restarted later at the same point. Just enter ``b`` for aBort at any prompt to stop the tagging process and save its progress. (The progress-saving also works in the unthinkable event that beets crashes while tagging.) Among the under-the-hood changes in 1.0b4 is a major change to the way beets handles paths (filenames). This should make the whole system more tolerant to special characters in filenames, but it may break things (especially databases created with older versions of beets). As always, let me know if you run into weird problems with this release. Finally, this release's ``setup.py`` should install a ``beet.exe`` startup stub for Windows users. This should make running beets much easier: just type ``beet`` if you have your ``PATH`` environment variable set up correctly. The :doc:`/guides/main` guide has some tips on installing beets on Windows. Here's the detailed list of changes: * **Parallel tagger.** The autotagger has been reimplemented to use multiple threads. This means that it can concurrently read files from disk, talk to the user, communicate with MusicBrainz, and write data back to disk. Not only does this make the tagger much faster because independent work may be performed in parallel, but it makes the tagging process much more pleasant for large imports. The user can let albums queue up in the background while making a decision rather than waiting for beets between each question it asks. The parallel tagger is on by default but a sequential (single- threaded) version is still available by setting the ``threaded`` config value to ``no`` (because the parallel version is still quite experimental). * **Colorized tagger output.** The autotagger interface now makes it a little easier to see what's going on at a glance by highlighting changes with terminal colors. This feature is on by default, but you can turn it off by setting ``color`` to ``no`` in your ``.beetsconfig`` (if, for example, your terminal doesn't understand colors and garbles the output). * **Pause and resume imports.** The ``import`` command now keeps track of its progress, so if you're interrupted (beets crashes, you abort the process, an alien devours your motherboard, etc.), beets will try to resume from the point where you left off. The next time you run ``import`` on the same directory, it will ask if you want to resume. It accomplishes this by "fast-forwarding" through the albums in the directory until it encounters the last one it saw. (This means it might fail if that album can't be found.) Also, you can now abort the tagging process by entering ``b`` (for aBort) at any of the prompts. * Overhauled methods for handling fileystem paths to allow filenames that have badly encoded special characters. These changes are pretty fragile, so please report any bugs involving ``UnicodeError`` or SQLite ``ProgrammingError`` messages in this version. * The destination paths (the library directory structure) now respect album-level metadata. This means that if you have an album in which two tracks have different album-level attributes (like year, for instance), they will still wind up in the same directory together. (There's currently not a very smart method for picking the "correct" album-level metadata, but we'll fix that later.) * Fixed a bug where the CLI would fail completely if the ``LANG`` environment variable was not set. * Fixed removal of albums (``beet remove -a``): previously, the album record would stay around although the items were deleted. * The setup script now makes a ``beet.exe`` startup stub on Windows; Windows users can now just type ``beet`` at the prompt to run beets. * Fixed an occasional bug where Mutagen would complain that a tag was already present. * Fixed a bug with reading invalid integers from ID3 tags. * The tagger should now be a little more reluctant to reorder tracks that already have indices. 1.0b3 (July 22, 2010) --------------------- This release features two major additions to the autotagger's functionality: album art fetching and MusicBrainz ID tags. It also contains some important under-the-hood improvements: a new plugin architecture is introduced and the database schema is extended with explicit support for albums. This release has one major backwards-incompatibility. Because of the new way beets handles albums in the library, databases created with an old version of beets might have trouble with operations that deal with albums (like the ``-a`` switch to ``beet list`` and ``beet remove``, as well as the file browser for BPD). To "upgrade" an old database, you can use the included ``albumify`` plugin (see the fourth bullet point below). * **Album art.** The tagger now, by default, downloads album art from Amazon that is referenced in the MusicBrainz database. It places the album art alongside the audio files in a file called (for example) ``cover.jpg``. The ``import_art`` config option controls this behavior, as do the ``-r`` and ``-R`` options to the import command. You can set the name (minus extension) of the album art file with the ``art_filename`` config option. (See :doc:`/reference/config` for more information about how to configure the album art downloader.) * **Support for MusicBrainz ID tags.** The autotagger now keeps track of the MusicBrainz track, album, and artist IDs it matched for each file. It also looks for album IDs in new files it's importing and uses those to look up data in MusicBrainz. Furthermore, track IDs are used as a component of the tagger's distance metric now. (This obviously lays the groundwork for a utility that can update tags if the MB database changes, but that's `for the future`_.) Tangentially, this change required the database code to support a lightweight form of migrations so that new columns could be added to old databases--this is a delicate feature, so it would be very wise to make a backup of your database before upgrading to this version. * **Plugin architecture.** Add-on modules can now add new commands to the beets command-line interface. The ``bpd`` and ``dadd`` commands were removed from the beets core and turned into plugins; BPD is loaded by default. To load the non-default plugins, use the config options ``plugins`` (a space-separated list of plugin names) and ``pluginpath`` (a colon-separated list of directories to search beyond ``sys.path``). Plugins are just Python modules under the ``beetsplug`` namespace package containing subclasses of ``beets.plugins.BeetsPlugin``. See `the beetsplug directory`_ for examples or :doc:`/plugins/index` for instructions. * As a consequence of adding album art, the database was significantly refactored to keep track of some information at an album (rather than item) granularity. Databases created with earlier versions of beets should work fine, but they won't have any "albums" in them--they'll just be a bag of items. This means that commands like ``beet ls -a`` and ``beet rm -a`` won't match anything. To "upgrade" your database, you can use the included ``albumify`` plugin. Running ``beets albumify`` with the plugin activated (set ``plugins=albumify`` in your config file) will group all your items into albums, making beets behave more or less as it did before. * Fixed some bugs with encoding paths on Windows. Also, ``:`` is now replaced with ``-`` in path names (instead of ``_``) for readability. * ``MediaFile``s now have a ``format`` attribute, so you can use ``$format`` in your library path format strings like ``$artist - $album ($format)`` to get directories with names like ``Paul Simon - Graceland (FLAC)``. .. _for the future: http://code.google.com/p/beets/issues/detail?id=69 .. _the beetsplug directory: http://code.google.com/p/beets/source/browse/#hg/beetsplug Beets also now has its first third-party plugin: `beetfs`_, by Martin Eve! It exposes your music in a FUSE filesystem using a custom directory structure. Even cooler: it lets you keep your files intact on-disk while correcting their tags when accessed through FUSE. Check it out! .. _beetfs: http://code.google.com/p/beetfs/ 1.0b2 (July 7, 2010) -------------------- This release focuses on high-priority fixes and conspicuously missing features. Highlights include support for two new audio formats (Monkey's Audio and Ogg Vorbis) and an option to log untaggable albums during import. * **Support for Ogg Vorbis and Monkey's Audio** files and their tags. (This support should be considered preliminary: I haven't tested it heavily because I don't use either of these formats regularly.) * An option to the ``beet import`` command for **logging albums that are untaggable** (i.e., are skipped or taken "as-is"). Use ``beet import -l LOGFILE PATHS``. The log format is very simple: it's just a status (either "skip" or "asis") followed by the path to the album in question. The idea is that you can tag a large collection and automatically keep track of the albums that weren't found in MusicBrainz so you can come back and look at them later. * Fixed a ``UnicodeEncodeError`` on terminals that don't (or don't claim to) support UTF-8. * Importing without autotagging (``beet import -A``) is now faster and doesn't print out a bunch of whitespace. It also lets you specify single files on the command line (rather than just directories). * Fixed importer crash when attempting to read a corrupt file. * Reorganized code for CLI in preparation for adding pluggable subcommands. Also removed dependency on the aging ``cmdln`` module in favor of `a hand-rolled solution`_. .. _a hand-rolled solution: http://gist.github.com/462717 1.0b1 (June 17, 2010) --------------------- Initial release. beets-1.3.1/docs/conf.py0000644000076500000240000000174012220455104016006 0ustar asampsonstaff00000000000000AUTHOR = u'Adrian Sampson' # -- General configuration ----------------------------------------------------- extensions = ['sphinx.ext.autodoc'] #templates_path = ['_templates'] exclude_patterns = ['_build'] source_suffix = '.rst' master_doc = 'index' project = u'beets' copyright = u'2012, Adrian Sampson' version = '1.3' release = '1.3.1' pygments_style = 'sphinx' # -- Options for HTML output --------------------------------------------------- html_theme = 'default' #html_static_path = ['_static'] htmlhelp_basename = 'beetsdoc' # -- Options for LaTeX output -------------------------------------------------- latex_documents = [ ('index', 'beets.tex', u'beets Documentation', AUTHOR, 'manual'), ] # -- Options for manual page output -------------------------------------------- man_pages = [ ('reference/cli', 'beet', u'music tagger and library organizer', [AUTHOR], 1), ('reference/config', 'beetsconfig', u'beets configuration file', [AUTHOR], 5), ] beets-1.3.1/docs/dev/0000755000076500000240000000000012226377756015311 5ustar asampsonstaff00000000000000beets-1.3.1/docs/dev/api.rst0000644000076500000240000000370112225207406016574 0ustar asampsonstaff00000000000000API Documentation ================= .. currentmodule:: beets.library This page describes the internal API of beets' core. It's a work in progress---since beets is an application first and a library second, its API has been mainly undocumented until recently. Please file bugs if you run across incomplete or incorrect docs here. The :class:`Library` object is the central repository for data in beets. It represents a database containing songs, which are :class:`Item` instances, and groups of items, which are :class:`Album` instances. The Library Class ----------------- .. autoclass:: Library(path, directory[, path_formats[, replacements]]) .. automethod:: items .. automethod:: albums .. automethod:: get_item .. automethod:: get_album .. automethod:: add .. automethod:: add_album .. automethod:: transaction Transactions '''''''''''' The :class:`Library` class provides the basic methods necessary to access and manipulate its contents. To perform more complicated operations atomically, or to interact directly with the underlying SQLite database, you must use a *transaction*. For example:: lib = Library() with lib.transaction() as tx: items = lib.items(query) lib.add_album(list(items)) .. autoclass:: Transaction :members: Model Classes ------------- The two model entities in beets libraries, :class:`Item` and :class:`Album`, share base classes that provide generic data storage. The :class:`LibModel` class inherits from :class:`FlexModel`, and both :class:`Item` and :class:`Album` inherit from it. The fields model classes can be accessed using attributes (dots, as in ``item.artist``) or items (brackets, as in ``item['artist']``). The :class:`FlexModel` base class provides some methods that resemble `dict` objects. .. autoclass:: FlexModel :members: .. autoclass:: LibModel :members: Item '''' .. autoclass:: Item :members: Album ''''' .. autoclass:: Album :members: beets-1.3.1/docs/dev/index.rst0000644000076500000240000000030512220454430017123 0ustar asampsonstaff00000000000000For Developers ============== This section contains information for developers. Read on if you're interested in hacking beets itself or creating plugins for it. .. toctree:: plugins api beets-1.3.1/docs/dev/plugins.rst0000644000076500000240000003626112220451674017516 0ustar asampsonstaff00000000000000.. _writing-plugins: Writing Plugins --------------- A beets plugin is just a Python module inside the ``beetsplug`` namespace package. (Check out this `Stack Overflow question about namespace packages`_ if you haven't heard of them.) So, to make one, create a directory called ``beetsplug`` and put two files in it: one called ``__init__.py`` and one called ``myawesomeplugin.py`` (but don't actually call it that). Your directory structure should look like this:: beetsplug/ __init__.py myawesomeplugin.py .. _Stack Overflow question about namespace packages: http://stackoverflow.com/questions/1675734/how-do-i-create-a-namespace-package-in-python/1676069#1676069 Then, you'll need to put this stuff in ``__init__.py`` to make ``beetsplug`` a namespace package:: from pkgutil import extend_path __path__ = extend_path(__path__, __name__) That's all for ``__init__.py``; you can can leave it alone. The meat of your plugin goes in ``myawesomeplugin.py``. There, you'll have to import the ``beets.plugins`` module and define a subclass of the ``BeetsPlugin`` class found therein. Here's a skeleton of a plugin file:: from beets.plugins import BeetsPlugin class MyPlugin(BeetsPlugin): pass Once you have your ``BeetsPlugin`` subclass, there's a variety of things your plugin can do. (Read on!) To use your new plugin, make sure your ``beetsplug`` directory is in the Python path (using ``PYTHONPATH`` or by installing in a `virtualenv`_, for example). Then, as described above, edit your ``config.yaml`` to include ``plugins: myawesomeplugin`` (substituting the name of the Python module containing your plugin). .. _virtualenv: http://pypi.python.org/pypi/virtualenv Add Commands to the CLI ^^^^^^^^^^^^^^^^^^^^^^^ Plugins can add new subcommands to the ``beet`` command-line interface. Define the plugin class' ``commands()`` method to return a list of ``Subcommand`` objects. (The ``Subcommand`` class is defined in the ``beets.ui`` module.) Here's an example plugin that adds a simple command:: from beets.plugins import BeetsPlugin from beets.ui import Subcommand my_super_command = Subcommand('super', help='do something super') def say_hi(lib, opts, args): print "Hello everybody! I'm a plugin!" my_super_command.func = say_hi class SuperPlug(BeetsPlugin): def commands(self): return [my_super_command] To make a subcommand, invoke the constructor like so: ``Subcommand(name, parser, help, aliases)``. The ``name`` parameter is the only required one and should just be the name of your command. ``parser`` can be an `OptionParser instance`_, but it defaults to an empty parser (you can extend it later). ``help`` is a description of your command, and ``aliases`` is a list of shorthand versions of your command name. .. _OptionParser instance: http://docs.python.org/library/optparse.html You'll need to add a function to your command by saying ``mycommand.func = myfunction``. This function should take the following parameters: ``lib`` (a beets ``Library`` object) and ``opts`` and ``args`` (command-line options and arguments as returned by `OptionParser.parse_args`_). .. _OptionParser.parse_args: http://docs.python.org/library/optparse.html#parsing-arguments The function should use any of the utility functions defined in ``beets.ui``. Try running ``pydoc beets.ui`` to see what's available. You can add command-line options to your new command using the ``parser`` member of the ``Subcommand`` class, which is an ``OptionParser`` instance. Just use it like you would a normal ``OptionParser`` in an independent script. Listen for Events ^^^^^^^^^^^^^^^^^ Event handlers allow plugins to run code whenever something happens in beets' operation. For instance, a plugin could write a log message every time an album is successfully autotagged or update MPD's index whenever the database is changed. You can "listen" for events using the ``BeetsPlugin.listen`` decorator. Here's an example:: from beets.plugins import BeetsPlugin class SomePlugin(BeetsPlugin): pass @SomePlugin.listen('pluginload') def loaded(): print 'Plugin loaded!' Pass the name of the event in question to the ``listen`` decorator. The events currently available are: * *pluginload*: called after all the plugins have been loaded after the ``beet`` command starts * *import*: called after a ``beet import`` command finishes (the ``lib`` keyword argument is a Library object; ``paths`` is a list of paths (strings) that were imported) * *album_imported*: called with an ``Album`` object every time the ``import`` command finishes adding an album to the library. Parameters: ``lib``, ``album`` * *item_imported*: called with an ``Item`` object every time the importer adds a singleton to the library (not called for full-album imports). Parameters: ``lib``, ``item`` * *item_moved*: called with an ``Item`` object whenever its file is moved. Parameters: ``item``, ``source`` path, ``destination`` path * *write*: called with an ``Item`` object just before a file's metadata is written to disk (i.e., just before the file on disk is opened). * *import_task_start*: called when before an import task begins processing. Parameters: ``task`` (an `ImportTask`) and ``session`` (an `ImportSession`). * *import_task_apply*: called after metadata changes have been applied in an import task. Parameters: ``task`` and ``session``. * *import_task_choice*: called after a decision has been made about an import task. This event can be used to initiate further interaction with the user. Use ``task.choice_flag`` to determine the action to be taken. Parameters: ``task`` and ``session``. * *import_task_files*: called after an import task finishes manipulating the filesystem (copying and moving files, writing metadata tags). Parameters: ``task`` and ``session``. * *library_opened*: called after beets starts up and initializes the main Library object. Parameter: ``lib``. * *database_change*: a modification has been made to the library database. The change might not be committed yet. Parameter: ``lib``. * *cli_exit*: called just before the ``beet`` command-line program exits. Parameter: ``lib``. The included ``mpdupdate`` plugin provides an example use case for event listeners. Extend the Autotagger ^^^^^^^^^^^^^^^^^^^^^ Plugins in can also enhance the functionality of the autotagger. For a comprehensive example, try looking at the ``chroma`` plugin, which is included with beets. A plugin can extend three parts of the autotagger's process: the track distance function, the album distance function, and the initial MusicBrainz search. The distance functions determine how "good" a match is at the track and album levels; the initial search controls which candidates are presented to the matching algorithm. Plugins implement these extensions by implementing four methods on the plugin class: * ``track_distance(self, item, info)``: adds a component to the distance function (i.e., the similarity metric) for individual tracks. ``item`` is the track to be matched (an Item object) and ``info`` is the TrackInfo object that is proposed as a match. Should return a ``(dist, dist_max)`` pair of floats indicating the distance. * ``album_distance(self, items, album_info, mapping)``: like the above, but compares a list of items (representing an album) to an album-level MusicBrainz entry. ``items`` is a list of Item objects; ``album_info`` is an AlbumInfo object; and ``mapping`` is a dictionary that maps Items to their corresponding TrackInfo objects. * ``candidates(self, items, artist, album, va_likely)``: given a list of items comprised by an album to be matched, return a list of ``AlbumInfo`` objects for candidate albums to be compared and matched. * ``item_candidates(self, item, artist, album)``: given a *singleton* item, return a list of ``TrackInfo`` objects for candidate tracks to be compared and matched. * ``album_for_id(self, album_id)``: given an ID from user input or an album's tags, return a candidate AlbumInfo object (or None). * ``track_for_id(self, track_id)``: given an ID from user input or a file's tags, return a candidate TrackInfo object (or None). When implementing these functions, you may want to use the functions from the ``beets.autotag`` and ``beets.autotag.mb`` modules, both of which have somewhat helpful docstrings. Read Configuration Options ^^^^^^^^^^^^^^^^^^^^^^^^^^ Plugins can configure themselves using the ``config.yaml`` file. You can read configuration values in two ways. The first is to use `self.config` within your plugin class. This gives you a view onto the configuration values in a section with the same name as your plugin's module. For example, if your plugin is in ``greatplugin.py``, then `self.config` will refer to options under the ``greatplugin:`` section of the config file. For example, if you have a configuration value called "foo", then users can put this in their ``config.yaml``:: greatplugin: foo: bar To access this value, say ``self.config['foo'].get()`` at any point in your plugin's code. The `self.config` object is a *view* as defined by the `Confit`_ library. .. _Confit: http://confit.readthedocs.org/ If you want to access configuration values *outside* of your plugin's section, import the `config` object from the `beets` module. That is, just put ``from beets import config`` at the top of your plugin and access values from there. Add Path Format Functions and Fields ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Beets supports *function calls* in its path format syntax (see :doc:`/reference/pathformat`). Beets includes a few built-in functions, but plugins can register new functions by adding them to the ``template_funcs`` dictionary. Here's an example:: class MyPlugin(BeetsPlugin): def __init__(self): super(MyPlugin, self).__init__() self.template_funcs['initial'] = _tmpl_initial def _tmpl_initial(text): if text: return text[0].upper() else: return u'' This plugin provides a function ``%initial`` to path templates where ``%initial{$artist}`` expands to the artist's initial (its capitalized first character). Plugins can also add template *fields*, which are computed values referenced as ``$name`` in templates. To add a new field, add a function that takes an ``Item`` object to the ``template_fields`` dictionary on the plugin object. Here's an example that adds a ``$disc_and_track`` field:: class MyPlugin(BeetsPlugin): def __init__(self): super(MyPlugin, self).__init__() self.template_fields['disc_and_track'] = _tmpl_disc_and_track def _tmpl_disc_and_track(item): """Expand to the disc number and track number if this is a multi-disc release. Otherwise, just exapnds to the track number. """ if item.disctotal > 1: return u'%02i.%02i' % (item.disc, item.track) else: return u'%02i' % (item.track) With this plugin enabled, templates can reference ``$disc_and_track`` as they can any standard metadata field. This field works for *item* templates. Similarly, you can register *album* template fields by adding a function accepting an ``Album`` argument to the ``album_template_fields`` dict. Extend MediaFile ^^^^^^^^^^^^^^^^ `MediaFile`_ is the file tag abstraction layer that beets uses to make cross-format metadata manipulation simple. Plugins can add fields to MediaFile to extend the kinds of metadata that they can easily manage. The ``item_fields`` method on plugins should be overridden to return a dictionary whose keys are field names and whose values are descriptor objects that provide the field in question. The descriptors should probably be ``MediaField`` instances (defined in ``beets.mediafile``). Here's an example plugin that provides a meaningless new field "foo":: from beets import mediafile, plugins, ui class FooPlugin(plugins.BeetsPlugin): def item_fields(self): return { 'foo': mediafile.MediaField( mp3 = mediafile.StorageStyle( 'TXXX', id3_desc=u'Foo Field'), mp4 = mediafile.StorageStyle( '----:com.apple.iTunes:Foo Field'), etc = mediafile.StorageStyle('FOO FIELD') ), } Later, the plugin can manipulate this new field by saying something like ``mf.foo = 'bar'`` where ``mf`` is a ``MediaFile`` instance. Note that, currently, these additional fields are *only* applied to ``MediaFile`` itself. The beets library database schema and the ``Item`` class are not extended, so the fields are second-class citizens. This may change eventually. .. _MediaFile: https://github.com/sampsyo/beets/wiki/MediaFile Add Import Pipeline Stages ^^^^^^^^^^^^^^^^^^^^^^^^^^ Many plugins need to add high-latency operations to the import workflow. For example, a plugin that fetches lyrics from the Web would, ideally, not block the progress of the rest of the importer. Beets allows plugins to add stages to the parallel import pipeline. Each stage is run in its own thread. Plugin stages run after metadata changes have been applied to a unit of music (album or track) and before file manipulation has occurred (copying and moving files, writing tags to disk). Multiple stages run in parallel but each stage processes only one task at a time and each task is processed by only one stage at a time. Plugins provide stages as functions that take two arguments: ``config`` and ``task``, which are ``ImportConfig`` and ``ImportTask`` objects (both defined in ``beets.importer``). Add such a function to the plugin's ``import_stages`` field to register it:: from beets.plugins import BeetsPlugin class ExamplePlugin(BeetsPlugin): def __init__(self): super(ExamplePlugin, self).__init__() self.import_stages = [self.stage] def stage(self, config, task): print('Importing something!') .. _extend-query: Extend the Query Syntax ^^^^^^^^^^^^^^^^^^^^^^^ You can add new kinds of queries to beets' :doc:`query syntax ` indicated by a prefix. As an example, beets already supports regular expression queries, which are indicated by a colon prefix---plugins can do the same. To do so, define a subclass of the ``Query`` type from the ``beets.library`` module. Then, in the ``queries`` method of your plugin class, return a dictionary mapping prefix strings to query classes. One simple kind of query you can extend is the ``FieldQuery``, which implements string comparisons on fields. To use it, create a subclass inheriting from that class and override the ``value_match`` class method. (Remember the ``@classmethod`` decorator!) The following example plugin declares a query using the ``@`` prefix to delimit exact string matches. The plugin will be used if we issue a command like ``beet ls @something`` or ``beet ls artist:@something``:: from beets.plugins import BeetsPlugin from beets.library import FieldQuery class ExactMatchQuery(FieldQuery): @classmethod def value_match(self, pattern, val): return pattern == val class ExactMatchPlugin(BeetsPlugin): def queries(): return { '@': ExactMatchQuery } beets-1.3.1/docs/faq.rst0000644000076500000240000002767012224423646016034 0ustar asampsonstaff00000000000000FAQ ### Here are some answers to frequently-asked questions from IRC and elsewhere. Got a question that isn't answered here? Try `IRC`_, the `mailing list`_, or :ref:`filing an issue ` in the bug tracker. .. _IRC: irc://irc.freenode.net/beets .. _mailing list: http://groups.google.com/group/beets-users .. contents:: :local: :depth: 2 How do I… ========= .. _move: …rename my files according to a new path format configuration? -------------------------------------------------------------- Just run the :ref:`move-cmd` command. Use a :doc:`query ` to rename a subset of your music or leave the query off to rename everything. .. _asispostfacto: …find all the albums I imported "as-is"? ---------------------------------------- Enable the :ref:`import log ` to automatically record whenever you skip an album or accept one "as-is". Alternatively, you can find all the albums in your library that are missing MBIDs using a command like this:: beet ls -a mb_albumid::^$ Assuming your files didn't have MBIDs already, then this will roughly correspond to those albums that didn't get autotagged. .. _discdir: …create "Disc N" directories for multi-disc albums? --------------------------------------------------- Use the :doc:`/plugins/inline` along with the ``%if{}`` function to accomplish this:: plugins: inline paths: default: $albumartist/$album%aunique{}/%if{$multidisc,Disc $disc/}$track $title item_fields: multidisc: 1 if disctotal > 1 else 0 .. _multidisc: …import a multi-disc album? --------------------------- As of 1.0b11, beets tags multi-disc albums as a *single unit*. To get a good match, it needs to treat all of the album's parts together as a single release. To help with this, the importer uses a simple heuristic to guess when a directory represents a multi-disc album that's been divided into multiple subdirectories. When it finds a situation like this, it collapses all of the items in the subdirectories into a single release for tagging. The heuristic works by looking at the names of directories. If multiple subdirectories of a common parent directory follow the pattern "(title) disc (number) (...)" and the *prefix* (everything up to the number) is the same, the directories are collapsed together. One of the key words "disc" or "CD" must be present to make this work. If you have trouble tagging a multi-disc album, consider the ``--flat`` flag (which treats a whole tree as a single album) or just putting all the tracks into a single directory to force them to be tagged together. .. _mbid: …enter a MusicBrainz ID? ------------------------ An MBID looks like one of these: - ``http://musicbrainz.org/release/ded77dcf-7279-457e-955d-625bd3801b87`` - ``d569deba-8c6b-4d08-8c43-d0e5a1b8c7f3`` Beets can recognize either the hex-with-dashes UUID-style string or the full URL that contains it (as of 1.0b11). You can get these IDs by `searching on the MusicBrainz web site `__ and going to a *release* page (when tagging full albums) or a *recording* page (when tagging singletons). Then, copy the URL of the page and paste it into beets. Note that MusicBrainz has both "releases" and "release groups," which link together different versions of the same album. Use *release* IDs here. .. _upgrade: …upgrade to the latest version of beets? ---------------------------------------- Run a command like this:: pip install -U beets The ``-U`` flag tells `pip `__ to upgrade beets to the latest version. If you want a specific version, you can specify with using ``==`` like so:: pip install beets==1.0rc2 .. _src: …run the latest source version of beets? ---------------------------------------- Beets sees regular releases (about every six weeks or so), but sometimes it's helpful to run on the "bleeding edge". To run the latest source: 1. Uninstall beets. If you installed using ``pip``, you can just run ``pip uninstall beets``. 2. Install from source. There are a few easy ways to do this: - Use ``pip`` to install the latest snapshot tarball: just type ``pip install https://github.com/sampsyo/beets/tarball/master``. - Grab the source using Mercurial (``hg clone https://bitbucket.org/adrian/beets``) or git (``git clone https://github.com/sampsyo/beets.git``). Then ``cd beets`` and type ``python setup.py install``. - Use ``pip`` to install an "editable" version of beets based on an automatic source checkout. For example, run ``pip install -e hg+https://bitbucket.org/adrian/beets#egg=beets`` to clone beets from BitBucket using Mercurial and install it, allowing you to modify the source in-place to try out changes. More details about the beets source are available on the [[Hacking]] page. .. _bugs: …report a bug in beets? ----------------------- We use the `issue tracker `__ on GitHub. `Enter a new issue `__ there to report a bug. Please follow these guidelines when reporting an issue: - Most importantly: if beets is crashing, please `include the traceback `__. Tracebacks can be more readable if you put them in a pastebin (e.g., `Gist `__ or `Hastebin `__), especially when communicating over IRC or email. - Turn on beets' debug output (using the -v option: for example, ``beet -v import ...``) and include that with your bug report. Look through this verbose output for any red flags that might point to the problem. - If you can, try installing the latest beets source code to see if the bug is fixed in an unreleased version. You can also look at the :doc:`latest changelog entries ` for descriptions of the problem you're seeing. - Try to narrow your problem down to something specific. Is a particular plugin causing the problem? (You can disable plugins to see whether the problem goes away.) Is a some music file or a single album leading to the crash? (Try importing individual albums to determine which one is causing the problem.) Is some entry in your configuration file causing it? Et cetera. - If you do narrow the problem down to a particular audio file or album, include it with your bug report so the developers can run tests. If you've never reported a bug before, Mozilla has some well-written `general guidelines for good bug reports `__. Why does beets… =============== .. _nomatch: …complain that it can't find a match? ------------------------------------- There are a number of possibilities: - First, make sure the album is in `the MusicBrainz database `__ the MusicBrainz database. You can search on their site to make sure it's cataloged there. (If not, anyone can edit MusicBrainz---so consider adding the data yourself.) - If the album in question is a multi-disc release, see the relevant FAQ answer above. - The music files' metadata might be insufficient. Try using the "enter search" or "enter ID" options to help the matching process find the right MusicBrainz entry. - If you have a lot of files that are missing metadata, consider using :doc:`acoustic fingerprinting ` or :doc:`filename-based guesses ` for that music. If none of these situations apply and you're still having trouble tagging something, please :ref:`file a bug report `. .. _plugins: …appear to be missing some plugins? ----------------------------------- Please make sure you're using the latest version of beets---you might be using a version earlier than the one that introduced the plugin. In many cases, the plugin may be introduced in beets "trunk" (the latest source version) and might not be released yet. Take a look at :doc:`the changelog ` to see which version added the plugin. (You can type ``beet version`` to check which version of beets you have installed.) If you want to live on the bleeding edge and use the latest source version of beets, you can check out the source (see the next question). To see the beets documentation for your version (and avoid confusion with new features in trunk), select your version from the left-hand sidebar (or the buttons at the bottom of the window). .. _kill: …ignore control-C during an import? ----------------------------------- Typing a ^C (control-C) control sequence will not halt beets' multithreaded importer while it is waiting at a prompt for user input. Instead, hit "return" (dismissing the prompt) after typing ^C. Alternatively, just type a "b" for "aBort" at most prompts. Typing ^C *will* work if the importer interface is between prompts. Also note that beets may take some time to quit after ^C is typed; it tries to clean up after itself briefly even when canceled. (For developers: this is because the UI thread is blocking on ``raw_input`` and cannot be interrupted by the main thread, which is trying to close all pipeline stages in the exception handler by setting a flag. There is no simple way to remedy this.) .. _id3v24: …not change my ID3 tags? ------------------------ Beets writes `ID3v2.4 `__ tags by default. Some software, including Windows (i.e., Windows Explorer and Windows Media Player) and `id3lib/id3v2 `__, don't support v2.4 tags. When using 2.4-unaware software, it might look like the tags are unmodified or missing completely. To enable ID3v2.3 tags, enable the :ref:`id3v23` config option. .. _invalid: …complain that a file is "unreadable"? -------------------------------------- Beets will log a message like "unreadable file: /path/to/music.mp3" when it encounters files that *look* like music files (according to their extension) but seem to be broken. Most of the time, this is because the file is corrupted. To check whether the file is intact, try opening it in another media player (e.g., `VLC `__) to see whether it can read the file. You can also use specialized programs for checking file integrity---for example, type ``metaflac --list music.flac`` to check FLAC files. If beets still complains about a file that seems to be valid, `file a bug `__ and we'll look into it. There's always a possibility that there's a bug "upstream" in the `Mutagen `__ library used by beets, in which case we'll forward the bug to that project's tracker. .. _importhang: …seem to "hang" after an import finishes? ----------------------------------------- Probably not. Beets uses a *multithreaded importer* that overlaps many different activities: it can prompt you for decisions while, in the background, it talks to MusicBrainz and copies files. This means that, even after you make your last decision, there may be a backlog of files to be copied into place and tags to be written. (Plugin tasks, like looking up lyrics and genres, also run at this time.) If beets pauses after you see all the albums go by, have patience. .. _replaceq: …put a bunch of underscores in my filenames? -------------------------------------------- When naming files, beets replaces certain characters to avoid causing problems on the filesystem. For example, leading dots can confusingly hide files on Unix and several non-alphanumeric characters are forbidden on Windows. The :ref:`replace` config option controls which replacements are made. By default, beets makes filenames safe for all known platforms by replacing several patterns with underscores. This means that, even on Unix, filenames are made Windows-safe so that network filesystems (such as SMB) can be used safely. Most notably, Windows forbids trailing dots, so a folder called "M.I.A." will be rewritten to "M.I.A\_" by default. Change the ``replace`` config if you don't want this behavior and don't need Windows-safe names. beets-1.3.1/docs/guides/0000755000076500000240000000000012226377756016013 5ustar asampsonstaff00000000000000beets-1.3.1/docs/guides/advanced.rst0000644000076500000240000001072512220453745020302 0ustar asampsonstaff00000000000000Advanced Awesomeness ==================== So you have beets up and running and you've started :doc:`importing your music `. There's a lot more that beets can do now that it has cataloged your collection. Here's a few features to get you started. Most of these tips involve :doc:`plugins ` and fiddling with beets' :doc:`configuration `. So use your favorite text editor create a config file before you continue. Fetch album art, genres, and lyrics ----------------------------------- Beets can help you fill in more than just the basic taxonomy metadata that comes from MusicBrainz. Plugins can provide :doc:`album art `, :doc:`lyrics `, and :doc:`genres ` from databases around the Web. If you want beets to get any of this data automatically during the import process, just enable any of the three relevant plugins (see :doc:`/plugins/index`). For example, put this line in your :doc:`config file ` to enable all three:: plugins: fetchart lyrics lastgenre Each plugin also has a command you can run to fetch data manually. For example, if you want to get lyrics for all the Beatles tracks in your collection, just type ``beet lyrics beatles`` after enabling the plugin. Read more about using each of these plugins: * :doc:`/plugins/fetchart` (and its accompanying :doc:`/plugins/embedart`) * :doc:`/plugins/lyrics` * :doc:`/plugins/lastgenre` Customize your file and folder names ------------------------------------ Beets uses an extremely flexible template system to name the folders and files that organize your music in your filesystem. Take a look at :ref:`path-format-config` for the basics: use fields like ``$year`` and ``$title`` to build up a naming scheme. But if you need more flexibility, there are two features you need to know about: * :ref:`Template functions ` are simple expressions you can use in your path formats to add logic to your names. For example, you can get an artist's first initial using ``%upper{%left{$albumartist,1}}``. * If you need more flexibility, the :doc:`/plugins/inline` lets you write snippets of Python code that generate parts of your filenames. The equivalent code for getting an artist initial with the *inline* plugin looks like ``initial: albumartist[0].upper()``. If you already have music in your library and want to update their names according to a new scheme, just run the :ref:`move-cmd` command to rename everything. Stream your music to another computer ------------------------------------- Sometimes it can be really convenient to store your music on one machine and play it on another. For example, I like to keep my music on a server at home but play it at work (without copying my whole library locally). The :doc:`/plugins/web` makes streaming your music easy---it's sort of like having your own personal Spotify. First, enable the ``web`` plugin (see :doc:`/plugins/index`). Run the server by typing ``beet web`` and head to http://localhost:8337 in a browser. You can browse your collection with queries and, if your browser supports it, play music using HTML5 audio. But for a great listening experience, pair beets with the `Tomahawk`_ music player. Tomahawk lets you listen to music from many different sources, including a beets server. Just download Tomahawk and open its settings to connect it to beets. `A post on the beets blog`_ has a more detailed guide. .. _A post on the beets blog: http://beets.radbox.org/blog/tomahawk-resolver.html .. _Tomahawk: http://www.tomahawk-player.org Transcode music files for media players --------------------------------------- Do you ever find yourself transcoding high-quality rips to a lower-bitrate, lossy format for your phone or music player? Beets can help with that. You'll first need to install `ffmpeg`_. Then, enable beets' :doc:`/plugins/convert`. Set a destination directory in your :doc:`config file ` like so:: convert: dest: ~/converted_music Then, use the command ``beet convert QUERY`` to transcode everything matching the query and drop the resulting files in that directory, named according to your path formats. For example, ``beet convert long winters`` will move over everything by the Long Winters for listening on the go. The plugin has many more dials you can fiddle with to get your conversions how you like them. Check out :doc:`its documentation `. .. _ffmpeg: http://www.ffmpeg.org beets-1.3.1/docs/guides/index.rst0000644000076500000240000000037512203275653017645 0ustar asampsonstaff00000000000000Guides ====== This section contains a couple of walkthroughs that will help you get familiar with beets. If you're new to beets, you'll want to begin with the :doc:`main` guide. .. toctree:: :maxdepth: 1 main tagger advanced migration beets-1.3.1/docs/guides/main.rst0000644000076500000240000002402012220454052017442 0ustar asampsonstaff00000000000000Getting Started =============== Welcome to `beets`_! This guide will help you begin using it to make your music collection better. .. _beets: http://beets.radbox.org/ Installing ---------- You will need Python. (Beets is written for `Python 2.7`_, but it works with 2.6 as well. Python 3.x is not yet supported.) .. _Python 2.7: http://www.python.org/download/releases/2.7.2/ * **Mac OS X** v10.7 (Lion) and 10.8 (Mountain Lion) include Python 2.7 out of the box; Snow Leopard ships with Python 2.6. * On **Debian or Ubuntu**, depending on the version, beets is available as an official package (`Debian details`_, `Ubuntu details`_), so try typing: ``apt-get install beets``. To build from source, you can get everything you need by running: ``apt-get install python-dev python-setuptools python-pip`` * On **Arch Linux**, `beets is in [community]`_, so just run ``pacman -S beets``. (There's also a bleeding-edge `dev package`_ in the AUR, which will probably set your computer on fire.) * For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run ``emerge beets`` to install. There are several USE flags available for optional plugin dependencies. * On **FreeBSD**, there's a `beets port`_ at ``audio/beets``. .. _beets port: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets .. _beets from AUR: http://aur.archlinux.org/packages.php?ID=39577 .. _dev package: http://aur.archlinux.org/packages.php?ID=48617 .. _Debian details: http://packages.qa.debian.org/b/beets.html .. _Ubuntu details: https://launchpad.net/ubuntu/+source/beets .. _beets is in [community]: https://www.archlinux.org/packages/community/any/beets/ If you have `pip`_, just say ``pip install beets`` (you might need ``sudo`` in front of that). On Arch, you'll need to use ``pip2`` instead of ``pip``. To install without pip, download beets from `its PyPI page`_ and run ``python setup.py install`` in the directory therein. .. _its PyPI page: http://pypi.python.org/pypi/beets#downloads .. _pip: http://pip.openplans.org/ The best way to upgrade beets to a new version is by running ``pip install -U beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on new versions. .. _@b33ts: http://twitter.com/b33ts Installing on Windows ^^^^^^^^^^^^^^^^^^^^^ Installing beets on Windows can be tricky. Following these steps might help you get it right: 1. If you don't have it, `install Python`_ (you want Python 2.7). 2. Install `Setuptools`_ from PyPI. To do this, scroll to the bottom of that page and download the Windows installer (``.exe``, not ``.egg``) for your Python version (for example: ``setuptools-0.6c11.win32-py2.7.exe``). 3. If you haven't done so already, set your ``PATH`` environment variable to include Python and its scripts. To do so, you have to get the "Properties" window for "My Computer", then choose the "Advanced" tab, then hit the "Environment Variables" button, and then look for the ``PATH`` variable in the table. Add the following to the end of the variable's value: ``;C:\Python27;C:\Python27\Scripts``. 4. Open a command prompt and install pip by running: ``easy_install pip`` 5. Now install beets by running: ``pip install beets`` 6. You're all set! Type ``beet`` at the command prompt to make sure everything's in order. Windows users may also want to install a context menu item for importing files into beets. Just download and open `beets.reg`_ to add the necessary keys to the registry. You can then right-click a directory and choose "Import with beets". If Python is in a nonstandard location on your system, you may have to edit the command path manually. Because I don't use Windows myself, I may have missed something. If you have trouble or you have more detail to contribute here, please `let me know`_. .. _let me know: mailto:adrian@radbox.org .. _install Python: http://python.org/download/ .. _Setuptools: http://pypi.python.org/pypi/setuptools .. _beets.reg: https://github.com/sampsyo/beets/blob/master/extra/beets.reg Configuring ----------- You'll want to set a few basic options before you start using beets. The configuration is stored in a text file: on Unix-like OSes, the config file is at ``~/.config/beets/config.yaml``; on Windows, it's at ``%APPDATA%\beets\config.yaml``. Create and edit the appropriate file with your favorite text editor. (You may need to create the enclosing directories also.) The file will start out empty, but here's good place to start:: directory: ~/music library: ~/data/musiclibrary.blb Change that first path to a directory where you'd like to keep your music. Then, for ``library``, choose a good place to keep a database file that keeps an index of your music. The default configuration assumes you want to start a new organized music folder (that ``directory`` above) and that you'll *copy* cleaned-up music into that empty folder using beets' ``import`` command (see below). But you can configure beets to behave many other ways: * Start with a new empty directory, but *move* new music in instead of copying it (saving disk space). Put this in your config file:: import: move: yes * Keep your current directory structure; importing should never move or copy files but instead just correct the tags on music. Put the line ``copy: no`` under the ``import:`` heading in your config file to disable any copying or renaming. Make sure to point ``directory`` at the place where your music is currently stored. * Keep your current directory structure and *do not* correct files' tags: leave files completely unmodified on your disk. (Corrected tags will still be stored in beets' database, and you can use them to do renaming or tag changes later.) Put this in your config file:: import: copy: no write: no to disable renaming and tag-writing. There are approximately six million other configuration options you can set here, including the directory and file naming scheme. See :doc:`/reference/config` for a full reference. Importing Your Library ---------------------- There are two good ways to bring your existing library into beets. You can either: (a) quickly bring all your files with all their current metadata into beets' database, or (b) use beets' highly-refined autotagger to find canonical metadata for every album you import. Option (a) is really fast, but option (b) makes sure all your songs' tags are exactly right from the get-go. The point about speed bears repeating: using the autotagger on a large library can take a very long time, and it's an interactive process. So set aside a good chunk of time if you're going to go that route. For more on the interactive tagging process, see :doc:`tagger`. If you've got time and want to tag all your music right once and for all, do this:: $ beet import /path/to/my/music (Note that by default, this command will *copy music into the directory you specified above*. If you want to use your current directory structure, set the ``import.copy`` config option.) To take the fast, un-autotagged path, just say:: $ beet import -A /my/huge/mp3/library Note that you just need to add ``-A`` for "don't autotag". Adding More Music ----------------- If you've ripped or... otherwise obtained some new music, you can add it with the ``beet import`` command, the same way you imported your library. Like so:: $ beet import ~/some_great_album This will attempt to autotag the new album (interactively) and add it to your library. There are, of course, more options for this command---just type ``beet help import`` to see what's available. Seeing Your Music ----------------- If you want to query your music library, the ``beet list`` (shortened to ``beet ls``) command is for you. You give it a :doc:`query string `, which is formatted something like a Google search, and it gives you a list of songs. Thus:: $ beet ls the magnetic fields The Magnetic Fields - Distortion - Three-Way The Magnetic Fields - Distortion - California Girls The Magnetic Fields - Distortion - Old Fools $ beet ls hissing gronlandic of Montreal - Hissing Fauna, Are You the Destroyer? - Gronlandic Edit $ beet ls bird The Knife - The Knife - Bird The Mae Shi - Terrorbird - Revelation Six $ beet ls album:bird The Mae Shi - Terrorbird - Revelation Six As you can see, search terms by default search all attributes of songs. (They're also implicitly joined by ANDs: a track must match *all* criteria in order to match the query.) To narrow a search term to a particular metadata field, just put the field before the term, separated by a : character. So ``album:bird`` only looks for ``bird`` in the "album" field of your songs. (Need to know more? :doc:`/reference/query/` will answer all your questions.) The ``beet list`` command has another useful option worth mentioning, ``-a``, which searches for albums instead of songs:: $ beet ls -a forever Bon Iver - For Emma, Forever Ago Freezepop - Freezepop Forever So handy! Beets also has a ``stats`` command, just in case you want to see how much music you have:: $ beet stats Tracks: 13019 Total time: 4.9 weeks Total size: 71.1 GB Artists: 548 Albums: 1094 Keep Playing ------------ This is only the beginning of your long and prosperous journey with beets. To keep learning, take a look at :doc:`advanced` for a sampling of what else is possible. You'll also want to glance over the :doc:`/reference/cli` page for a more detailed description of all of beets' functionality. (Like deleting music! That's important.) Also, check out :doc:`beets' plugins `. The real power of beets is in its extensibility---with plugins, beets can do almost anything for your music collection. You can always get help using the ``beet help`` command. The plain ``beet help`` command lists all the available commands; then, for example, ``beet help import`` gives more specific help about the ``import`` command. Please let me know what you think of beets via `email`_ or `Twitter`_. .. _email: mailto:adrian@radbox.org .. _twitter: http://twitter.com/b33ts beets-1.3.1/docs/guides/migration.rst0000644000076500000240000000454212102026773020522 0ustar asampsonstaff00000000000000Upgrading from 1.0 ================== Prior to version 1.1, beets used a completely different system for configuration. The config file was in "INI" syntax instead of `YAML`_ and the various files used by beets were (messily) stored in ``$HOME`` instead of a centralized beets directory. If you're upgrading from version 1.0 or earlier, your configuration syntax (and paths) need to be updated to work with the latest version. Fortunately, this should require very little effort on your part. When you first run beets 1.1, it will look for an old-style ``.beetsconfig`` to migrate. If it finds one (and there is no new-style ``config.yaml`` yet), beets will warn you and then transparently convert one to the other. At this point, you'll likely want to: * Look at your new configuration file (find out where in :doc:`/reference/config`) to make sure everything was migrated correctly. * Remove your old configuration file (``~/.beetsconfig`` on Unix; ``%APPDATA%\beetsconfig.ini`` on Windows) to avoid confusion in the future. You might be interested in the :doc:`/changelog` to see which configuration option names have changed. What's Migrated --------------- Automatic migration is most important for the configuration file, since its syntax is completely different, but two other files are also moved. This is to consolidate everything beets needs in a single directory instead of leaving it messily strewn about in your home directory. First, the library database file was at ``~/.beetsmusic.blb`` on Unix and ``%APPDATA%\beetsmusic.blb`` on Windows. This file will be copied to ``library.db`` in the same directory as your new configuration file. Finally, the runtime state file, which keeps track of interrupted and incremental imports, was previously known as ``~/.beetsstate``; it is copied to a file called ``state.pickle``. Feel free to remove the old files once they've been copied to their new homes. Manual Migration ---------------- If you find you need to re-run the migration process, just type ``beet migrate`` in your shell. This will migrate the configuration file, the database, and the runtime state file all over again. Unlike automatic migration, no step is suppressed if the file already exists. If you already have a ``config.yaml``, for example, it will be renamed to make room for the newly migrated configuration. .. _YAML: http://en.wikipedia.org/wiki/YAML beets-1.3.1/docs/guides/tagger.rst0000644000076500000240000003027412214763030020001 0ustar asampsonstaff00000000000000Using the Auto-Tagger ===================== Beets' automatic metadata correcter is sophisticated but complicated and cryptic. This is a guide to help you through its myriad inputs and options. An Apology and a Brief Interlude -------------------------------- I would like to sincerely apologize that the autotagger in beets is so fussy. It asks you a *lot* of complicated questions, insecurely asking that you verify nearly every assumption it makes. This means importing and correcting the tags for a large library can be an endless, tedious process. I'm sorry for this. Maybe it will help to think of it as a tradeoff. By carefully examining every album you own, you get to become more familiar with your library, its extent, its variation, and its quirks. People used to spend hours lovingly sorting and resorting their shelves of LPs. In the iTunes age, many of us toss our music into a heap and forget about it. This is great for some people. But there's value in intimate, complete familiarity with your collection. So instead of a chore, try thinking of correcting tags as quality time with your music collection. That's what I do. One practical piece of advice: because beets' importer runs in multiple threads, it queues up work in the background while it's waiting for you to respond. So if you find yourself waiting for beets for a few seconds between every question it asks you, try walking away from the computer for a while, making some tea, and coming back. Beets will have a chance to catch up with you and will ask you questions much more quickly. Back to the guide. Overview -------- Beets' tagger is invoked using the ``beet import`` command. Point it at a directory and it imports the files into your library, tagging them as it goes (unless you pass ``--noautotag``, of course). There are several assumptions beets currently makes about the music you import. In time, we'd like to remove all of these limitations. * Your music should be organized by album into directories. That is, the tagger assumes that each album is in a single directory. These directories can be arbitrarily deep (like ``music/2010/hiphop/seattle/freshespresso/glamour``), but any directory with music files in it is interpreted as a separate album. This means that your flat directory of six thousand uncategorized MP3s won't currently be autotaggable. (This will change eventually.) There is one exception to this rule: directories that look like separate parts of a *multi-disc album* are tagged together as a single release. If two adjacent albums have a common prefix, followed by "disc," "disk," or "CD" and then a number, they are tagged together. * The music may have bad tags, but it's not completely untagged. This is because beets by default infers tags based on existing metadata. But this is not a hard and fast rule---there are a few ways to tag metadata-poor music: * You can use the *E* option described below to search in MusicBrainz for a specific album or song. * The :doc:`Acoustid plugin ` extends the autotagger to use acoustic fingerprinting to find information for arbitrary audio. Install that plugin if you're willing to spend a little more CPU power to get tags for unidentified albums. (But be aware that it does slow down the process.) * The :doc:`FromFilename plugin ` adds the ability to guess tags from the filenames. Use this plugin if your tracks have useful names (like "03 Call Me Maybe.mp3") but their tags don't reflect that. * Currently, MP3, AAC, FLAC, ALAC, Ogg Vorbis, Monkey's Audio, WavPack, Musepack, Windows Media, and Opus files are supported. (Do you use some other format? `Let me know!`_) .. _Let me know!: mailto:adrian@radbox.org Now that that's out of the way, let's tag some music. Options ------- To import music, just say ``beet import MUSICDIR``. There are, of course, a few command-line options you should know: * ``beet import -A``: don't try to autotag anything; just import files (this goes much faster than with autotagging enabled) * ``beet import -W``: when autotagging, don't write new tags to the files themselves (just keep the new metadata in beets' database) * ``beet import -C``: don't copy imported files to your music directory; leave them where they are * ``beet import -l LOGFILE``: write a message to ``LOGFILE`` every time you skip an album or choose to take its tags "as-is" (see below) or the album is skipped as a duplicate; this lets you come back later and reexamine albums that weren't tagged successfully * ``beet import -q``: quiet mode. Never prompt for input and, instead, conservatively skip any albums that need your opinion. The ``-ql`` combination is recommended. * ``beet import -t``: timid mode, which is sort of the opposite of "quiet." The importer will ask your permission for everything it does, confirming even very good matches with a prompt. * ``beet import -p``: automatically resume an interrupted import. The importer keeps track of imports that don't finish completely (either due to a crash or because you stop them halfway through) and, by default, prompts you to decide whether to resume them. The ``-p`` flag automatically says "yes" to this question. Relatedly, ``-P`` flag automatically says "no." * ``beet import -s``: run in *singleton* mode, tagging individual tracks instead of whole albums at a time. See the "as Tracks" choice below. This means you can use ``beet import -AC`` to quickly add a bunch of files to your library without doing anything to them. Similarity ---------- So you import an album into your beets library. It goes like this:: $ beet imp witchinghour Tagging: Ladytron - Witching Hour (Similarity: 98.4%) * Last One Standing -> The Last One Standing * Beauty -> Beauty*2 * White Light Generation -> Whitelightgenerator * All the Way -> All the Way... Here, beets gives you a preview of the album match it has found. It shows you which track titles will be changed if the match is applied. In this case, beets has found a match and thinks it's a good enough match to proceed without asking your permission. It has reported the *similarity* for the match it's found. Similarity is a measure of how well-matched beets thinks a tagging option is. 100% similarity means a perfect match 0% indicates a truly horrible match. In this case, beets has proceeded automatically because it found an option with very high similarity (98.4%). But, as you'll notice, if the similarity isn't quite so high, beets will ask you to confirm changes. This is because beets can't be very confident about more dissimilar matches, and you (as a human) are better at making the call than a computer. So it occasionally asks for help. Choices ------- When beets needs your input about a match, it says something like this:: Tagging: Beirut - Lon Gisland (Similarity: 94.4%) * Scenic World (Second Version) -> Scenic World [A]pply, More candidates, Skip, Use as-is, as Tracks, Enter search, or aBort? When beets asks you this question, it wants you to enter one of the capital letters: A, M, S, U, T, E, or B. That is, you can choose one of the following: * *A*: Apply the suggested changes shown and move on. * *M*: Show more options. (See the Candidates section, below.) * *S*: Skip this album entirely and move on to the next one. * *U*: Import the album without changing any tags. This is a good option for albums that aren't in the MusicBrainz database, like your friend's operatic faux-goth solo record that's only on two CD-Rs in the universe. * *T*: Import the directory as *singleton* tracks, not as an album. Choose this if the tracks don't form a real release---you just have one or more loner tracks that aren't a full album. This will temporarily flip the tagger into *singleton* mode, which attempts to match each track individually. * *E*: Enter an artist and album to use as a search in the database. Use this option if beets hasn't found any good options because the album is mistagged or untagged. * *B*: Cancel this import task altogether. No further albums will be tagged; beets shuts down immediately. The next time you attempt to import the same directory, though, beets will ask you if you want to resume tagging where you left off. Note that the option with ``[B]rackets`` is the default---so if you want to apply the changes, you can just hit return without entering anything. Candidates ---------- If you choose the M option, or if beets isn't very confident about any of the choices it found, it will present you with a list of choices (called candidates), like so:: Finding tags for "Panther - Panther". Candidates: 1. Panther - Yourself (66.8%) 2. Tav Falco's Panther Burns - Return of the Blue Panther (30.4%) # selection (default 1), Skip, Use as-is, or Enter search, or aBort? Here, you have many of the same options as before, but you can also enter a number to choose one of the options that beets has found. Don't worry about guessing---beets will show you the proposed changes and ask you to confirm them, just like the earlier example. As the prompt suggests, you can just hit return to select the first candidate. .. _guide-duplicates: Duplicates ---------- If beets finds an album or item in your library that seems to be the same as the one you're importing, you may see a prompt like this:: This album is already in the library! [S]kip new, Keep both, Remove old? Beets wants to keep you safe from duplicates, which can be a real pain, so you have three choices in this situation. You can skip importing the new music, choosing to keep the stuff you already have in your library; you can keep both the old and the new music; or you can remove the existing music and choose the new stuff. If you choose that last "trump" option, any duplicates will be removed from your library database---and, if the corresponding files are located inside of your beets library directory, the files themselves will be deleted as well. If you choose to keep two identically-named albums, beets can avoid storing both in the same directory. See :ref:`aunique` for details. Fingerprinting -------------- You may have noticed by now that beets' autotagger works pretty well for most files, but can get confused when files don't have any metadata (or have wildly incorrect metadata). In this case, you need *acoustic fingerprinting*, a technology that identifies songs from the audio itself. With fingerprinting, beets can autotag files that have very bad or missing tags. The :doc:`"chroma" plugin `, distributed with beets, uses the `Chromaprint`_ open-source fingerprinting technology, but it's disabled by default. That's because it's sort of tricky to install. See the :doc:`/plugins/chroma` page for a guide to getting it set up. Before you jump into acoustic fingerprinting with both feet, though, give beets a try without it. You may be surprised at how well metadata-based matching works. .. _Chromaprint: http://acoustid.org/chromaprint Album Art, Lyrics, Genres and Such ---------------------------------- Aside from the basic stuff, beets can optionally fetch more specialized metadata. As a rule, plugins are responsible for getting information that doesn't come directly from the MusicBrainz database. This includes :doc:`album cover art `, :doc:`song lyrics `, and :doc:`musical genres `. Check out the :doc:`list of plugins ` to pick and choose the data you want. Missing Albums? --------------- If you're having trouble tagging a particular album with beets, check to make sure the album is present in `the MusicBrainz database`_. You can search on their site to make sure it's cataloged there. If not, anyone can edit MusicBrainz---so consider adding the data yourself. .. _the MusicBrainz database: http://musicbrainz.org/ If you think beets is ignoring an album that's listed in MusicBrainz, please `file a bug report`_. .. _file a bug report: https://github.com/sampsyo/beets/issues I Hope That Makes Sense ----------------------- If I haven't made the process clear, please `drop me an email`_ and I'll try to improve this guide. .. _drop me an email: mailto:adrian@radbox.org beets-1.3.1/docs/index.rst0000644000076500000240000000212712220454077016360 0ustar asampsonstaff00000000000000beets: the music geek's media organizer ======================================= Welcome to the documentation for `beets`_, the media library management system for obsessive-compulsive music geeks. If you're new to beets, begin with the :doc:`guides/main` guide. That guide walks you through installing beets, setting it up how you like it, and starting to build your music library. Then you can get a more detailed look at beets' features in the :doc:`/reference/cli/` and :doc:`/reference/config` references. You might also be interested in exploring the :doc:`plugins `. If you still need help, your can drop by the ``#beets`` IRC channel on Freenode, `email the author`_, or `file a bug`_ in the issue tracker. Please let me know where you think this documentation can be improved. .. _beets: http://beets.radbox.org/ .. _email the author: mailto:adrian@radbox.org .. _file a bug: https://github.com/sampsyo/beets/issues Contents -------- .. toctree:: :maxdepth: 2 guides/index reference/index plugins/index faq dev/index .. toctree:: :maxdepth: 1 changelog beets-1.3.1/docs/Makefile0000644000076500000240000001075212013011113016136 0ustar asampsonstaff00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/beets.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/beets.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/beets" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/beets" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." make -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." beets-1.3.1/docs/plugins/0000755000076500000240000000000012226377756016214 5ustar asampsonstaff00000000000000beets-1.3.1/docs/plugins/beatport.rst0000644000076500000240000000170212203275653020552 0ustar asampsonstaff00000000000000Beatport Plugin =============== The ``beatport`` plugin adds support for querying the `Beatport`_ catalogue during the autotagging process. This can potentially be helpful for users whose collection includes a lot of diverse electronic music releases, for which both MusicBrainz and (to a lesser degree) Discogs show no matches. .. _Beatport: http://beatport.com Installation ------------ To see matches from the ``beatport`` plugin, you first have to enable it in your configuration (see :doc:`/plugins/index`). Then, install the `requests`_ library (which we need for querying the Beatport API) by typing:: pip install requests And you're done. Matches from Beatport should now show up alongside matches from MusicBrainz and other sources. If you have a Beatport ID or a URL for a release or track you want to tag, you can just enter one of the two at the "enter Id" prompt in the importer. .. _requests: http://docs.python-requests.org/en/latest/ beets-1.3.1/docs/plugins/beetsweb.png0000644000076500000240000010737712121146403020512 0ustar asampsonstaff00000000000000‰PNG  IHDR&röçágAMA± üaPLTEYZYEEE777­­®º¼½ÕÕÕNKHrrrRRR¹¾Á (((ÆÆÅ:::J‹ÍŸ Jd}’„qh–¯µ»ËËÌúúú¹¾Ä___dz\lºBwâ¤K`“;ÖíºAŒ×ºÞ•±s óuqøÝöŸœnJï£ OkóBñw›õ= í|£¼ó¿e­™òïVP€ò•Ëq!4N^°üanzvfR…]+<& ]enT ‡²ñS‚7c†*šôÓËø¼·öœ¬»G[l]G,ZyO Jq’‰qT˜{RrX9Y¡1E]Šye~Š–c&‰g@™£§Ìúú±u“±.UsDp¼´¬Â¬˜$Iº¥ŒË·¡> öS3ÌßûÖðiB𢬢‘x˜¼ø2`|–«¹°£Œ¢º_uŒ“¬Ãm‹¤„›³¯š~D¢‰eæÔ½¬£uvt÷ïÞ®·ÀîÑǾ³«š¬«ªÊÀ¸¨½Ñ€€ØÎÃìà˵Êßêû³ÈÙ>*µÀËûõí/­­õ‰ˆˆÝÖÌ÷ïçþùòÓÚâÌÜêØæóïæØÓàëe®ùÿý÷¿ÐÞí÷þÇÇÇöýÿ¡  Gšï§ÂíÆÖãH›ñ®®®ïyºýÜáæìììa­ùÔÖÙÿÿý]ª÷¿Å̯¯¯ÅÍÔËË˦«±àæìS¢ô¶Ò÷òòòi±ú“““NŸó§§§ÍÍÍkµþ°°°FFFW¦öIIIKKKKœðäëñNNNcccóóô"""YYYeee[[[äèëåíôVVV±±±ðððjjjããã&&&ßç—CCCÏÏÏÁÁÁéñø+++»»¼<<<éèèp¶üààà¶¶¶×××¾¾¾³³³¹¸¸ØØØ÷÷÷ÔÔÔÄÄÄ???áéðRRR888333ûûü///øøùîîîÜãê111õõõêêêßßßüüýÉÉÉÝÝÝÒÒÒåååâââÖÖÖççç___úúúÚÚÚÜãëýþþþþÿÝäëÿÿÿç”®tRNSóãêðî¿ÄE`ÖÓÁÖlÁÅuþþþÖÖü¿vE|Z^‹„IDATxÚì½{tG¶7:ÿœûoÝ»Îw>à®3÷ÌãËdæ¬YkÖœuÖÍ$!!á[@€ BÏð žÁxÍˮߌeÍ8Nã?ÆÈ㉉óÇFØHŠp$Ë’- Ù±­—e$Ëí¾{Wu·ZòÉ`°·­îzìªÝ]õë]»ª«º~ñOÿÜSÐÛØØÕ8õ6Þ‡†®‘¢» Ã’ß{†1!¿ëg“ßøèòï›IûÿñOÿô‹vV÷öÂý"‰7ßË'ï•ú{{?Ÿ5IÑ,­—¦Ñ½¡±½¡×E³¦òż‚2$_¬”ß"¿WÌ£—J¹¯|ñjËo$¿ñ~ò‡“ßt!#Éï}PùCɧYfçsëüå?ýâ—un·»—ÿ!õöò'Hå–P¯ðOH… b¨›Ö2uQž^Ê@½4¤±7 Iä'ò„œøAò{…€€ü^ž¹7D~ãÐò{G”ïA~¯;øN†“ß"¿÷廇ï–ß;X~¯ ­1À=¢ü^i… '_€ŒÛ]`˜T»Åšï*((肳”º ÚCÂT|uD½îŸšT÷ñ-ùîÇ _õS]–¾[ßÝÝ­×`ôÿü‹_vªÜ*ÌVÕë–åEäYܽ*£Â0Dzˆ"™»‘ +RæYT½*â2 Gr™*ÊM£Uüe Ñb8e£¡b„J%‘/rˆÉ„\EfUˆ|ÕðòUÂO*_$_õ`òÝCÉwN¾˜hdùª”ï~Pùª ù*·/%,!***pÒ 0©î¡q½®DZàj¤!===½žÉ³Ÿ}öÙÙb„öz¢~ýëßþú×aêFé…?0õÜ/eOϨòyDù½‰ßîoì2ßžûeðóÞÿã•ßÓè“#5èUn„IˆëÝâš÷ÇÿøpχÿñÇyj7 jIïn}ëÙ?ì‰Ûó‡g_T»õXovï·¿ƒ°ßýöbòõP^‘híŠ.òS >‚÷Ózä×KRCŸ”Fâ1§êòà¡*ŸPIä÷ /_ß»wǽ*½žÈ’d×íD©z«—ÞÜè嫆‘/¹y>Iðý«ô<«žÞ@~T¾àDâÒ)_%”h|Õ`ù=ùú.—ž òO}zš%ñ~±GŒ×Ó½^È*à¤ù÷ôHӊ↔¯F¾xû½Gvu«Üð À“ ¢'·¾kŤÌ^t`L@~˜ÁÐò{Bä÷W|~þ(Þ½¿ž{¥|¡ÌCä÷Éï „ä‹E¡‚Q§È#&N•ŠÀÌ}·ÛòÇÿè‘n&›ý›ÿ¤ô›Ù!ì×ÿ¯@¿Ö‰EøØ©ûaÓLÖ•_ZX¬ê‰^pqaaʼþâó§z#gÝ [”Ÿ˜pëÞ Õh¤vÿƒ®÷'¿;¥ÂDÐ&ÏîSaÓ]û[&¿U"†:…s·à -#g·è˜Å‚¦‰‹çxÄœ¤¡ÝÔñðòñ¿gï‘mWÚßeO­Ø™Û¾rgñ{ìþöè#3ÚW,(èž;3µ®7D¾T°>H¾$ûÑËïã(>œ!Î!ïXùÎäŠ •Ï£óô¹z c³Ê"ðõúä> “’Œh“ß#L6‘ê÷©–= ùÃþÇg7©ŸÊ€0ùÂDå–º£¥îGˆ}t˜ìÑGîˆ]íÒgw¯üêÖg—>Ñ»}f®S¿ljüB—Þ÷ó^A÷? ãîық\nuB˜Á †ŠOe†„I)}ä%hÁþþ÷Œ$(õé-Ï” NžÔû|$ `‚6,ÂĨF”oŸÏ7 ÖaÒJ3M¥ ™Ç }z€‰^»ý¹X‡Eæ\_Zb±¸TÛg¦:}î膘-©!÷1Z±£‹õ>“Pù¾ásó=¼|§/!E“Òeîæ£»›&fZþëÿ‹ïéü×.ð‘Àí¿ùÏ?8pàøÍz _Êo÷»ÿ•ñ¿@L&@f!„“Ïéââà$÷9Å>= Óçø|B†BÝÉÒ;…ÌòÃÈïÞ»zsjûòâ•ñá]z=¶A*§~ûÒT•Ïå®Û¾±¸[¼éÙ¸¾QË÷ }ÿN_ðý;Cï°|çðò}N_ —$ß)J“ʧqfyyTƒR. ËéA˜Ð’vª_ÿý@ç÷?~ÿz…ÓÌ_z׿˜üû.†×ãš Úäh“%f¦¼ä$V§Ï8¥·êÄ(³$Ø,6‡¤1K~!ò¬¼|³4Ô<‚|r‚DæîÈ#w¢Ømù¾ÉlTÊw±î}ìóåßÌpoÚñ|iâ ϧ¬ž’JŠ'pMNß0´|óòÅ¢2›ßÿP>ô;‡”o~XùƒÊ9½§;8òÖÀ#Ø 0ém2rº¶ïZñîŠ]ÛÕN³@ÎÖÿŽD¾ÖÝ“woÚ½¾ÂHAûh8žšø3 ¥¥`% ‘û$ßP¼¡ÁC¥¨Iˆ÷™–ï ÊéVjä)á 1œËÊJ[}úȆe±Ï·,¥Ô·D^^ZèY¾Or¡òÍ#È÷ ¾W_hß%#¹'IÉûF-ß,?¤üánZ& %=M¢|„‰»© óoò8õÑ‘K"£õ¾&^`“ÙヰÈÈèŸÇÜ|MH>½Zc4– ÑëÃ<š^³ýáÒéYŒ6‹lM¼,êj â–DûÌ‚pš'¹*¶IÈjtòyŸÓøTz•»îßGÞd4™õprzÌ=*½YïV©|I(à>òÍ£“oæå“ìšÄGL xéý/¿‰¯©ŸL~ÀÇèS>¡:LT`õ4µzšÌÝî^w7dÞäñxèE´¶:!Ìé!a¤–€š0¬ÛÓÚä!¼x!‰‡Ö&òó5k¦)ùjFáb¦§>!9(Êçyù‰øÑMüÃË÷ÜO~“§µµïS€ dâ }P(.,ßCëƒÇKÄê ò›‚ä7 ò=£»ÿV3‰¾O«à ‘ò<Áò[Ë7!ß#””T¾ÇL`"OHP›yIf … –˜ ÍC/–U+!,CêÇ äj%!$RÇlh!óH|­hvM‚[ˆå“°ùM!‘D~+­RO°|šœ—8‚|»5pæå“ò[›xùMÒ"ä·zF)Ÿr ÷ßDZsk5f×ÿÅ/{ZÔÄ'áÉ#ZCƒ¼1<8Îã*„¦p‰ÞA)<É ðÓ`ÎPù.A~«Çå|Ïýå.'ÏhäCx†–ßú°ò=ÃËçƒ\­ÁògÔ$ ŠBr7©&.–)7<¹„,Å“+pŸ¼[ áÙéAÂÄæ’r…dME%ß%ž¤ò[]CÊ—\Eˆ|רä»D¾küÖù®Ð«V¾Kš¡§õ~ò]&ß5´ü@-’˜è K€ÔC:CoMä{î#ß$Ÿ°Ž$¿µ‰—OŠ¡ä{„¾(Ÿ·J„`*_¬˜¦à!ɹ)¨ËÄwgH?ÆÓ ø ¨n•0ø„ªk2Ÿ €‰Suy˜"””„³à•ú#ð?ÂP5¯æ¢ N(‘‰æŒ?cseemme-ÔLþ1œP‘^Y[ÉQ%&©”$ª•²UJNxšÝHò+Gß*ŸçA~­$S©ü ŽÊAò+Cä‹ î¢@áF S?¡ÕAë ¨ÈÒª¦@A¬Xòx¾ˆ@òe‘ŒTªšÔ |ÀKLÔê u…Ë’"7jŒ£$e‚sBr9Räºa˜4¢8™¼!Ï0A@¦ˆ¨Ñ×Ï`jNÈãŸX8*S CeÕœÁ3HŽ “ ð¤h:mCÝ>Th§,¡¹ ¥+EWm’ZJA$¹ÒìæW~áôýÆÐe`]¶Î©+„?4ù°Ùu{º„áå?à•=à}Ü_ŠSà¬ôÚžZT)RïPc• -bxKK€Ç]Þì¢h¨ G\.ÇÿÅ¿úJ'.ƒ¼º½`0Õµºê†n¯VÖb†P×P'åhooïœÊ"ù»4 ¾®.ˆƒ°•¾§±¬'è¾$SA>¢àQ¨³¢\Mªº¢"¥©3P‘ÕbuºÊ]ê ÊU«P"LJ\JCu×™—É,eC„w•å®’’HQ`誫Ü×ñb»ª5ÀrJÔÍy]ä‚lÕ§>ˆ->u0¶À Þ º/ÙÄb²5ÕÀƒÂ¤]Ÿ5u&Kiäóh¯ë6ÖÖUe|ví*à))!K˜ù³ÃQâpLê‚5ÑÈíêŠv‰î•}ŽÀÄáP+kÛ{…•Ÿíæ”»ì ¹¾€ò×!LHî͆:h™Z¬íïÿ93;';sÿÛùvAÙ £J½ÃG -_K ´“-ôD|-èEj¡ú”Zxç°ú¸etj»%Ô/äßÂkî––Qf5Ú†ÂÖ®ò{©-Ðâíªì’4A]@ÒV3Г!”V Ч8H•ÈRz»H¸å׿þ⋯/({ùêíI!R°âè#0iB·L­l.P‰Ë ÝîÆ.qv9d]tý\W£*°|TÕ®“«1À¤¨@EVB Uñý]¥©½ln$IÚ@eTùíÝ–T¨ œËá´ÙôŽB›5kî6Œò•T[m\—C ulï‘V3‘,Ø¿W¼Ìë[¹¬yÛ¸;—¼v‡­Î{Gë¡êôØqœiñg@ÕÙ’M‰§¬Þöv{wCj™ÍÞål·{;Ûm=¶,UÔ©Í O¾½ÎÙUÖîí,h±Â½W¬^o{Kª½š•öNP¥Z»­]ïn±¢6I ïÊÓî)müÙ:í\’<•ËgâÙn-s¶£6)ëÑWÛË  Yªn÷>¸n±»Ã.7˜@›xZHb{Až0uÊ6âTOcd©Ói6(k[NGQD^…Ó©wDD#Nu;]•Jp鯳0çÕ…0ÁÊÖ$Ð9’Q÷ ¶\WÃÂ.§tCÕu{²æ;ì…fu-K,B0QC„aÒMGy]µ_^»vO©ÇÑa§Q‡“Ù<ݺ¥€b¢Ã¼MÝ•rÌÁbq(•Ðךð© ôõu^ý²–$jn b,Ž<X\ñ+Ï|~r͆i@'ŸùÕ~vÐ&5Y+ØKìÖlîÀ,öúFíJPKÛrˆ6ù:l;¥šÛóYÌÒL ñiqóâc¦äìڒìˆ?ýU.ãõz?Üq˜ƒ'>nלl¨\{ò‹[¡aöÆúã"›•†ˆˆØÎÈSÉË”ÊX[¢2BÙÇ5F˜”rc¤Ròf-9î·g(#òä±9몠¶á§Ì“ûã–5+MͱVˆm³#šüäÈE`RZ°©>#\ p]áGˆ$,AmAâÒf?Ö0ÂDû!´²³^Ü™é-ðv×pÛ_Ó2“DvRÍ{lúž#[s’Þ|Ãÿ✜äj.šÍõÚ©Öþ¤µ,»™³1+/°ïû½þŒ;Žfr¨ŸVîlƒf)yY*çíLŒm¬UÔy#¡öäýò>.jP^ØÛ kOŠhãâ6Åz“6…û Dä5Éûйë´ÉËJöÄu5 $ärÖö:.Â7Û$1Ïd¬TTƒeëO^žÍD—f[³"c³6…Ûâ–…çt^Ý’™µ)¶NŒíAQboŠ*àêÜ6¸4 ÏCß2á<ˆ¨ c·Ù"Ouûä§šT]½‰Ê sD8ô."4Îe¥ùîh¹Ë³)üŠ[˜/`®@hàϘàÂ7D¨Dä&Œ24€Z¡SASb¼zëB‘ËxõÒ…f¨Aã¿L4 ©m3}EÓjL¸uUéÃaöV“Áƒ£í“¯L)£o‡ÔæˆySE“én³^ò®ÈÜñU³GmÎk°h'²]'…¿ú|چŋÏd笙vìW±œ×jõ"LÞݘÍe½øvÒ‘ÍœÕîg¢óæ¾C`ͦTÒ°i÷îkÙï² SíÆ ãeV̯œµó8gí–íŽå¬qKæM‚vÛólRD ÓÙ¹·8àµ1‘§ Îê¹D¹Æ±©`h(Õ"ìÜðl®à``’$mÝЖpð'És㔩 !UŒ—it-)ÕfQ˜dÕ5.Ó™ŠŠ­ö¸e¥Z¿`b˜”bá ÑaêZàŒ^—p«D^ÁÕr«×î·V[Ôv ±·GÞ^«# y±ÏµD./—+5ž\ÇYáRžj­PGh<Íò†y„ý%BŠV@€ÀD—@ò©mPѤV›aà$/iKŒQf »÷Y^‹, ÖvkEͪ›<†„°„0ek ¾ê1Ôº0¸ÕzXilm¥ãx%­Ê™˜ÐáQ~CÆwñ@N®Ö«å­ŽV€ Š5Z"t-6+S÷Æ3ÓþvæÌ™{Ρi¿JåÐ $0y'ÛʼõvÜ‘ƒœÍžµþhéd&;Û(L2ýÜÊÚ¬ÈÕ v&`Èz ‡¹=k_ͧy N~¢|ÖoÔ2V+X9Lb¥²9"¯6•`² ÈéORR˜@3ág"‹9Çì˜FG›$?Õê*ìD­áô`5šrìL´²x/À„öt é©ÈܤÐ.á6Æ ¹eû³–œŠ“wp´PòX5±V —ã>YýuQQíhq5Ý.÷“€öˆ ÔÊS½KÊ-®%ÊB03Z y>µÜQ¡ÔÁ µÔ\ŠF¦º$ÂD‡ÝI ?O ‹ºwÉèr™®VNFé&K34HͲZ8,„‰"ŒF¨m?°â(iu%\•»dàVשx6Èáp° ‡]J.% SZ0¡+Ⲭ‚Œ‘‘£:¬Á%sEHab·rû5mñ™5kÒ­þCÏlÃá¨îwfCÃÂ%ùsÖÚW³9îÔõŠwr0a?¤0Ép0¯¿’íå˜çôCæ.—0áv½5Èe!L¬4\Ç9.c(»L8%Ê"3í^Ô&ò6€@=ŽªH`ÂÅåõ1Ð,ärÐPLj˜½ë´q»!®m/¤‘÷[Mþ¸}€uÙWg²¬u—–ó{Å-Lÿ¿%Êœn§?Ù#S¸eZT0œý¡p¢J¹wó³«Êjša¨x–5jµQÓÔª3YdFãSë4®V£Æ}™SmÒw‰CRgº(:ßCcŠ¢5-“NR¢¢RÂRpàBc‘‘G#¿ýe”N&¿ñeh£ÎhB˜˜ŒÕÐ £ÍR0Y”JËÁÐH5htœ)å·/èV9ÜØ-åFÉ=ÂH`b·.œóÆÛoÏ™rª“–…M ;`'nŽóž„({?‰&± Ú I™§þ@€?IpMç'g’Üïç³f„¼³–e ‚T6Ëò“T¤š)š¨,Ž!—æïçPa£××OÓÒ+ñ÷óÌ{K¯x£ö‡Ò¼¾’ ¹) IV@mµ6iŒjŸ¯If¬079\f øšdžˆoõùZáX¨S !Š’•È£)KøJ¨0¹Ê¤ã¹„ 5DéHLtB˜ÌRQ FƒQP4˜˜—ÐF%2#ÉN‡0±P‚Q™`äs!1Ä…T[ËûL$ÎÄÇjä:¦4FD‘‹3肈0“&]­²]P¥¼²°ÿÌÄ1£bóâJü£Ïn¸äKÓ—«Sb‹Õ#­)Z9|•™ÕgÒ‰5fÒ(h€Î¡¡ L£Œ#©#øGH`0 M}“)¡EèŒÍZõƒÑ4O`†¶d²6ÂR7ãZƒNCí]ìðjÈOCÏdðNc‘Ä› Hȱ¹ÙH÷Äyu?D‘¬ÉIGs êF¡êizQþàNV Ø‚žwˆ¼‡è·Yç6»%pǃ3N~HúÁò-CÉ’}hù‡&Hš…Þ6)lEðR—epù™h5Ój3I‹C¨f‹ÑÔLiòd‹¸ÁTK`ÒÜ,¬!«XŠ*É‚º†ÿUV6‹«VÈ¢€­ýf’\\RÄ/;Áç ʃ'’Žàj®Åǰ²YX S‰/‰Ê¯$ò…¬Iª U1Íü:–æAËmšù+ÁË$™Ö ·',…¡òé²`ùµ!ò+tÀ,¿9h O@~å°ò›EùÀST$ȧëƒDùE æ B˜ªR,,Ž¢ÐÅB|>ͼƒ”p‘Pzpä×Zˆ©,¢—YIª Ô¹­Z›yvrá,4z=aùýñ¹ÂD>[H2¬YšÅ3/HHÊçX+26S…Ê'TŽ,_¸HÃýå7KGåWJå“ûoæK£ù† ù•ƒä׎Z¾P‚üæÑȯ –/”¢a8ù´©üæ€üJ©|Œ‘OƒDH6.TBÀÄ„'§õrÑ1×oJ£®ø7 nÏ ô<0¹Á‡Bí¯ž¿‰44ñ0¹Ž0Yšÿž…Õþ em„R)_Tm§®ˆysªûW_¿6³T]àmñï™u'&w—ÖØú÷>Wãí˜Çý8_›1ëÆmh^fݹpSôܫɘuó~õsÿÉO¢cœ”ÀKã:ËX£§²x­^ÒZ3Ö²;+M›ž¯á]ËVL>d—¦ºg¡Ö–ôÂ÷!exãîÒª?À17oKy.3yîò˜99ÑGn²ìrðχñ¼#ñÈc¤4&h˜àé&»ß^íMï¸óøÐ0$ýÈŸ€ðxgÜÿýˆ0Ár¹¿PWîRošõõOŸ+U››–Ì‹¿p}õ¼NcœË>[è3[&ïTÀ·ïÎ/5˜Ö?‡ò¯BcoÄÌÔù|Ëf]»}w]ó ìÌpô| ¡á4ô1>&DoÐ<Ý ‘~D˜ÜÅdžEºö)ûýíoße¿ºq‡½F]·ï°_ƒkD}c M8ËU±w1’Í ØoÅ~ ²¾ñ×Ï·¿æUŸqgHp|DãîLhÑ ÕxƒÛ €Nˆ -<¨ïÈzûÇ!ú>7oÿHƒ “l ç|WôˆŽÇOw™@Æ`˜@©ˆÊåGü‘JTp=Þ¹36êõ'DȰúãÚñDaò})<^ÆG“{wP3á`h‚Šðé§PŒÜ EÈ××¾"‡qNL°Eׯ¼ØÖðá髯¿úŠüÆ7!L®‰êä{ªŠÇ×_Pƒ#ÁÈW “kŒŒ—gPÃÃk%B} ÿŸÂÿø¦LxÛäÇ»’>ÍÓL¡X B‰€‘ ú‚Àä¯Mjd½¬à»þRœ(¡E4AHô}{GgŒ?âçð/y~ êíŒçŽöôÄñÿÉNÐ݇þç/þµ¹t‚$$ŠJ ”"|{ü‘ð‘>9!¥ü_~ñË쪚 ¨ª&ß Óá'Æq›ø²Í¥Ú5®ˆlìÁ‹}N§^¯r÷ôþ?¿ø¥6¿cÊŠÿ†»å¼fƒ ‘B "“l]Q1^ˆ4,€g·^Õ0é˜ :êS+ ¸¯•†ªªOÆ—6! ÅúÕI·¾GÕØ3&ãÄ´:€’’ ŠVž“'‹”ܸ  •Š`¤·±««« ®K“EG¬Rãl,(OÔÞ¥w”†§Öw`B”II DhŸi±=íäÆ?ü'ÔÛÛФ«  ½½L“E~©¡§ºÓæõZEò‚Gêö<ñ·g³µT7ZJ yœð0‘9*Ô%Ý‚îu÷bÉ=ݸ ÇF‚Ä€¤®®º@ªMJí-^ûxÚ²•ln·Úª+”©´Ý¡0eM  }}®»HÁ* ÔÞŽ©++ëlÀDkª³ ‰=°¹»àš@‚›™s~¿·³¤V¢M4GEIŠ6Ï´äÚ žjThðT‡TV ÔR'ÂD‘ZÚëõ‡–câKéÜÊ)9¢ÏÕ\ÎÿÔ Äê”5®vpZëšO‘f'™ ¤‘W½B¹» !RÝÙÙi+aR.“ê?yÔ¸÷Ø4nûkÙ\?‡Z…á2ØÃœÝOÄþEóÄ¡¤ÓRk0™š zĉ¯Rht : ¶9M¾n=¢„6ÏXj´Øžn ºÉN¤––o&ù•n»?¸Ñæ)Llqöì8Œ€é§\Ì­MìŽfÉd0êš»à‰(3+abD˜´šÝ=îÞ.$b±=Íz‹µš‡IG}¡¡ZŠ’ÆÝ÷nÍÈ–À„á˜%·&ím’˜ðÝK±pL縤ò´'µ òsîfS]o¥Ã«®uØAXN…• AIÙx€E…„l"ÐñµÙ;˜´k¬~‰¡úÞK§vã§E˜ø¹•ìº%³&Û§È&³é{Ø?sÜ»lÛ“kª¸ ¦oo‹ÛdÒUC#ëŠab‘iâsö`“ÓŽ Á"$¥ÖÂ?_|Â…‚%4*àûƒÉë%#"˜œ’ù¥ÍJ\ÌæL.ùÍ­±M êœvmÌN^ûöÜðT4ëò\\u³Ag¨°ûÂÛ‚aÒM” E b¼Ù¡D­œ•”!8ìø¼yO-[>ÊÎó=$Å%[²[­~‰6±H X.yÉ·÷Ø0N(—þ=€¤}÷¾ƒS4›{€M{‚û=ÁÚ„ók“&hsˆ2”PáõrIƈJÍÎk/siíPˆàPWÙ¡8;›Z=|…ÜZe§^{œºŠKRkíO쀤ÝÿH\‹`Â*Ru6I2ëÙØî#›Ca‡ÃY³7æFLâÞܺ땜'V™„Ú&œC°Mš LÐ4A¶ u E ³w*Î ?˜Ã}è°^=в}Àác ššD½‡|OÖе'Ñ!…É@~x{$\ÖËæöÄðÚdc6:q/¿‘Ã¼Ëæ¢Bù=ކ ~^ø)éép^caý€ MhÁö6t y}a·r{Ùå±u‘hŸíQÖC?6¿ûñÜ2KÉ>vŠC¦ #Œ•À ÊŠ²üö'…ü„.psÿ7‰m•Ô9ó:v]žŽ0ÙµMØ~€ÅÌ‹;ØÃÉo û‘TÃeÙXÃõ?%ã&\o¸8n¢A˜øœ LÀ2A”‹‹›}4|š%Zfç²zý…‰G%4 ¡¤ YªaÂ%« žðqÈ“ŒÂ:Eœø¹¤M …ÎòÜ$ƒ–KŒÍ!†‰l(uë¹ ùºüè†>°iß~¢GN¤£°œ_S,ŒÂ´ I§ÍF41Tù6ÎŽH{Où@õ`SŽì4î 0a þ»K?³Ûž-5"LöÌ‹?þD]Ka2Pª„“àDÚç K£Þ{¢ Ø w:~Ω'H`R …ÉJ¨r?†?Ñ(va±¨Mü&SûJGÝ&Ð,&¹qk—yZ´ P¸šÒÓÁzl”à¿Çì)N …?>ªŸy}NÎÓð†˜Á[Ö‡ç+ä1&`—AËòòµïo<×–A`ò¶ŸØ&Á0ÙÓЦ~Ì6&µs>é¯ÁB`’¯«° ¹?Ù[¬þ§ÐÀô…óóî “$¹\ù›Na²Ÿl¯—4\Ýn hr˜þ§ &§tæê§åð¨É¦×Äæ+:î“÷ Ú‰b]ÁÖ 8½ÌÞZ¤Ü!h“™Qpò?]0©O=¥³¨ ǹdºØÂÀäz&B‡¸:“Œ˜WÉRÒì£ÃäŸÛN”ÎŽ?ó0žáÎô'݆û·Á3ëS‹Ç¥æ+A3뇆‰¿ŸYϾ¯õû°;G‚ÉúøL.y»Øè-´ÕÏõ?U0!H_ët¡Ë¹†‰KZÀ²÷î±ËËÙôdŒñ aò66:ÑìѨ©ñ;ØãäeÇa.k×Þì “ñ¼èo˜N²ö&\ +­J6iãšÛ¸¤æ>h] }L’ ©Øñe¢£ÂJë¢Òâš3¹=ÀŒ§ž²Fgb ñð0!Á3·É@à ž§åƒš€É8€‰Ÿ!@S?c÷ã8G⸒8jG¹¡{lÆð&`òTÑH0áÆ'MÀd°56p˜øá— YÓW«#¼Œ&s‹ý÷éîŒUNÀ„.w,¥”ÁÁ9"L$59è=Ͱë ÈÄB³CúǪ3òŽ¢·¥…"aüVoÏH0þŒKA áÌïÇ)ÇÆVüæÃ•᪹Ÿ‹Î…™ Ù,^/ŘŸ³[)d0Ä\þ ˜Œ]˜àü4/±5­^›~˜082%ÔFÒËo௟¡a„fó/ý‚ö`¨žÈ`÷s{ÖOeŸ›‘Ãqq»§²ÏçœdÍÝŒ,|·2^;&q2&Vo»»±Å˜à‹vR6g'Ãjth ªyíÒS%%Kæ±Û„v‡‘4K¤]¯ä$¿Å.lFÄYÄ2#ë=öm͇$ÏÞ:&gÁDbÅu œ¡‡á¨3(üɃ‰­¥If±”tùG„ "bæ ¯µ¸ä’ŸŸ(‹·±43^ÞxPáv8 YjqWC•'»«9:ÙàCœÚ÷âÎCØ­9²ÕÏì´ CvLÁ%.™cQOKªâG¯«õUCôUÁãÚ<;ù—$ªOôt<€!0ñi‡ÌQf &8Ñ3wÔ¥Û¾³ü&ï‘ù°~f×Îz.n26?3,üÜ ¤í;kiœÛÎ֯Ġ¬·6j™½¥&ï’×_;Äí9ò籨N$0éPjN:‹¿Luxf蛎…¢06UªèÖæâ޶Ekl¦ˆ…KH]_ÓÖñdÀÄVçÀ/{:,ú‘`b½ñÔêa®?ù­Wr²Þz…N^°£‰²óó:^}`íÆl¢a€ÝÏpq³çä€åÛÉqI³_Ónßy˜·M‡ò;ò6×ϬC0×±ÝètÔì×ßÍæ*:M ¤U~ªf»¿J| ÒQ}„rÏ?¬¨ÚÍWt Ðöªjz0‘G~XÑñ$À¤ÚV`¡01“~Ї迤—ßæö¼L, Ž(‡w²“÷s\ò‹¯æ$½¼%‡ÙŸŽë® ¤)Éxý€y}#Ú©+V. b 9 yxÄLäÞUZkXÀ¾o¨,ÍÜŸ[UEž¶ªª¶üšHvsv${X1á ÊŸ³%¼9<<}'­Ê²(MÑÑ׬À0©®üš½ì¶š§&8iz’θ›}ŸÎ s°Ó3'‡áßãÔµ°»Ð–$¿øJÎ,`Ðt“K[$<'+{LVLÈaLÃÛ•6í>ö°€±ûhš£26SÑQ¯(i/®Wä/cßO˜T9u¥±™tÙSâŽ÷·æÊ\öp[~*]©/HM˜ä–ÔKØ÷Sûž˜€ÊþHfÍçSc%é® <¶‘±Ùídn ûÒ¢õdöÀFíë¯iéê‰GØ÷ëù&ÆG&ÒF´±l2®ìf÷_Éï¨ÚK#­¾›d[Û–±×Ø7JØ\×,ð.% ÂMª]ìah°rëó îù˜Í€µ0pMÊ\Â~ÍÎIj`‚Së÷{«m{f¿£evA;³ Q@–àãÇ<¸hvNΞ—_Õbeg‚bØÁk› \”¿çÍ©¤ÌÉŒÚäÙ`„ø LЄ=:åÅw²9nÌ7:(`c ù§åá ؃Pã[sÁq¸{7»0·iQæëñ±©¤î±ÑÙ:ŸŸ¿,fiigö±[NUNe&Ëg¨'³äïf§?5ÚlŒ—_E3ëõ‡²À*M~ÛÐÛÙÜÎv÷&vc=(–8foÌÒlÀƒ,ýdXå 0=ØXk{{];°¼‘ͬÄ~6W´C¼¿²™ëÏzýì18Ïmx˜ì¯As¤Æ´0½¦fàe/ûAMG•jöÆãUZÓŒ>bÂNå-ÞÃmØÎ¨ÖnL«Ò.!09Xƒv‰6ÊlÒi¹¬È¨«¥54é’\ŽaHȺÎN°ÛŸ ˜ÄS˜`'yæÂÐ&&õUƨ;`«ôILXÏê×ÒÐ6IŒy0L ÑSc›ƒïý‰iþ ÷ÄAFEÖ®54Ð0ûà m’wÁ Í„0¯ƒÅÒÿÄÁd›bíÆÜhH&@ëóŽWå;æÅQ•z‚‹Ž>Å< “î©`² ÖyZa"ÎAóK&¯‘É$’Ù&tæ·oA@1øq‰(Ç¢ûÆÂÌ> †_ ŠÓðÇâ<·aara²c[þ›¯dV¹æ±ûQ›´uÔûbÀeÄö˜`“¢˜»œÀ¤w.»¿Þü ¤N<ò”5:0{ ŒŽµËcE=ÖǾƒ÷·9ú¹•›ûÇäZãA0ÙÎD˜l§¶ÉÛ5ëÑÜúžÅïÞ°[ÓÑ6™Ÿr‹ÝZŸWІŽ2 (›Ýìñ*ÇTö:ûÀd7Ñ&쇢cØ)éã&øýЭð4-5ïSÈÖA{‚§LE½OÛÖ-¿úüŒTùÂÜ+Í :ò‹®~÷ÒŒL0aê}Jò*…fQl•c]Ú@[¡2e†&f[l]š¢Þ ± CÂŒ¾ñ“Áqþ‡ Ã= ¸~¶9’¡óMúÚ°CNôÅ¢m ¾ª—<á«` Á¯Rкç_ìµÁ¬_˜ž ç¶zLMbùDOË(ìOYòf©ÚýcH›àHl_`Æ?ñ„¾í&–H¦^bº+ëÙ-¥ ì;iŠÀ”'c&ÁhaB6á?éçˆ9*<õ^›m¨Šd ÁH,Íc¨h*ÐêÕ,Y¯ÍfÿÁäæ×릲ì–ãõOÜ\¸QÁÄÊ%ͽuïò7@—/ß»q8yÁ·/õ‰=ÞÉß?x+ÃÅ-¸uýðP5l‡<1Ù·/ ?ÉÏ-¹}„E…gß' —µ}ùW‡ž‘þŸr’cG}[~j~}Û“7cr´0yS²Ë÷þ¸7ÙøLŽ4EPßo±ìqa¬_Úáaéòá pœ5ðÅ÷›9.n6©cW@Þ$‚“`ŽŸëO¯'äîÿyúÓ?é\ØŽ…B1ðΫ-Læ=wóæ5ö‹/nܸÎŽ[Ëî̤…ˆjáömÐ&ÂDXaõà aâ'³ÚØm8ç‘=š°wýÁ†(ÂäÚW×?»þËÞÁi¶! t¢-5r (kûs?þyzUS¦ä quY™ð¥m-ee~PGû8}Ó|¹Çt–•Y±®’<._XáÌ­8úÍsǹ¬õÏ]šùÓMI˜€Éƒ ÖãÜžk?1;¾F£áS„ µM²¶ó†Ä«t6€FNV„ídñ,ûAÖl!È“[ÉsMáßëP˜pœ;–jE†-9\ò‹Ä™|'\µMö¬19²ñ?å'U&`ò`Ãk~&ý&;Ø…–¹ì÷`…&ÏE˜dİÏŪ6áÔ ^mP˜ÐðeB8t€"AlÑõ6ùòXÀÞÅÔG޲[4ë!?ñóK lR~w·3rV<û>0ijÏk¶ÃA.î-6æè¢Ý1ñ_±3 ô(L¸hð”BöSü?Ýw'`ò0ªÅðf˜81Óáˆ.£=P]×XvÇòŸr²äLE›`g–ê “ì×;Øù‹Œ .Ø69À%áÛV4añ“öY¯£žZÞb1iv“ö¥_„ i[®±3p³ ʰcÇüC“\ü€3iÞ’À/ìÓOÚ®ñ0y´qS² °oœÀäh&¯˜$óK‰Ù9A¶Iòz>|³hu:Ä©œ?a¼ŽZ€ÐòC¼2a3›› 󨝨ã†&Ç)LüA0!Û3^cçü¤;“ ñ%Ç•}:ÄVÃ/Ðèx‚VˆþT0Ã`ò]xöwðæ£ØÓá'C(>†, é“&ìó „Öe‹Ú„š°в}Àìe ;ôrØk;v¦ý¬ÚDáÓå>ü ¬¾¶ÖùºðÔ¶Ž'eÁßO §LŸìÚö&µ.˜tC¸-‘†sÃÁ„[O¬ Žéìl‘Ž›àgß‘ámn;±g(EÆ0±BØ×ÐÏyõ§œz “޶Hv[[¾ä%Ÿäµ_GŠ™B;Ú–Å N|n›‚.ø ¬Ä׃ÒdO#L¶“1u¢‚`S€ÉÛýÁ0YÉ~µ< ¨ø©"˜Þ%?AÀÛXÿË¡Sµ=~ꤜaµ é…MýBP\?#LÈÛÿ6E_}ý@[h…ªªúŽ…=’)D_(…Î,è¨wYžZØv=¿à,$ܸP<$¯'&+Ù˜Iü§¦ãw˜`O'ùõxÒÓ¹»c¦<áÎ÷|áú¯£¤§ó= ¿F¾iÏàîï'¿¶ VðaÄ]L|ØwÐÍ–vˆy˜ÄLâf#Ãe2o?ù­øx LÐCaÇ©µÛÙÛGÂfgH˜TC@~qª¢°xÀ^ܦ7†¯W¤¦X*OÕSD ´9JKR‹*šcÉÒòŽœ4 @ØÅîW,cßOPT%ƒÀ­&ë{…†ðÜzÅ­MÞ¡0y™eÓ&³±ŠãÈ1k…0þ•- ·¾Œ- N&ÇâvoÉobŪá0ǘÇÀüLG¢s^õ3 ~Þš}­†Ëà62Ö Úd)ôtfmò:¾fDQûA¥@\û³Â˜Ôì:š«ÇÚÝGqùÞB¼ºƒUë—ãJÀ­ôcùåx½ÛúªöÅ“~i “÷ûÚÚªÔëè‚¿|báOd=àÔÍm Ò*½ß÷äÂ$ÃdÂe¸ìSpY{M¦6r¬Â7„}{5%Ö&N0ˆÔ™2Á‘Ž”¨”ËÓ˜½&] ןm2Õs¸Â#êË«‹ …¹û~Îg0“Áz§Áhhã ;žAWSìç¯\˜HN×—HüC`‚“Z—‡W³Kc›c^XÏÎ-Y€3§ð£Sp5àÚ}ìÑŠÓî;êÕGØ™‹@[h©È° ~êÉì~²°v*»M yÉ€{l™·0ÉÑ:ËY2Ÿ‘(»Õºƒ"¼ˆ÷zö`üÚZç±3´¸øÀd`îÆŽõì¶P¤yqÏe÷·D¶i÷Å‹0!†mÚÀ€o2Ø%“µï¤Uy^`ÌeâzÀm5ëÙêÛ–±oÔLh“'µÑqÎco]b—¦UE²ì-èÐnÖââ¿+»Xht^Ü™ ½Ûù÷Ø8–ßѶ÷õ-ðÖ~tý_G}÷6þ¥oñû9mÑ1ìÖv~û=»ü¸k5{ûÏ5žyìóWÙwrOjO§ÓWQâ¡—Û?n`‚_Ôkøö¥EP… cBÔºbåá*˺Ì…qX¦ŠõuQÝFU:á›—Ö¥)a…ùÎV¾!ìÖå…á}`Äæ+qÉ 2êx™®l«÷cìuϨaÂUË|Õ-ƒ¨Àaö˜à@zǾÞíkk«¯Öò CUÕ•õ¯¥_ék¶amk#¼t} °þA6}䣱’Á+매gg/ÃåÉõmø`ŒÖ&®î¡‹QÖõTÛ'ÁÃkô-ß¿ÜOøØ4¸² :ÄÙ(oÈG¦ÅÂË>2³}ž®”$y§LÛ^aú#Þ°s=æq“ç\Ù=)óáÔ@‡"_>¦×Ž&Õ¾ÔöÆú9é²­.×LyZ[‡bl¯=LdäK6Vî]vJ*'|ØÆnçzÕ0ù hl¯|˜àÚoYµ¹¨³ÛÈÂsç0éÏô 0ñÚZl¶û»ìµ/Øå3´L xÁ?.`Rí¾2®I5j˜Xl@ÕÖwÙOÙ¯v°óc[¨_5`ò¯93Ωq”0é´”µU¯@˜°ìÌSuÄß=`ò?>ùû¸¦¿lxP˜”­`¯. ¯ê,O0ßô÷‡ {tQGu{Á¸‚Éã™&Ðè,,¬®ëBwû¸ƒÉ'âß"rAhL 3>«*hlàä`"«n*¨‹Œ-+„ðTP­w˜œý‹@gÏþåïBØßƒËôÄ_óÙ¿œªôOмþ>lõ “îN£·Ml–vj³Ö•¨½ Óì09 ôÃÙO ôɆ3Ÿ@ #Ž-^,õþpöü_ÎòÌ!ôÍjÃ'GiPÞ‡F¿¿¤Õ[]V†{Ÿ—I¨³ÌØ8`Bëêü1xñnÖî§×€ƒÀ§¿çX?9ðûdÍâU<3-ó@ ¿·¡õÌ Q"´Èá·øäAQ‡F ;W`(i,è %½®ÄÎqO?LŽaiøäÌ™3‡¸CpüjþôùóÇΞ\¼fÚ‰³ÇΟ>OëúØÙÀDôŸÿ„;ô±ÀŒyœ?’‡É9Ù8$±†ë‡„ˆc'Ïž<Òïä†5Nž³áÄ Èû‡ N;ùɆON{lxmý$G;‡ &/à ˜œ úà4ÀäôٳЎh!6{6(ç>ÁOÒY7œ?†<9Ö³¢ÿØéÅ\?¨žùäù3Ø@§Ÿ=q êþïþC'á¼j ·øüùÅqó=„ä_³j14VÙÜ™U?d˱‹Ž˜ŒKâarR(1¨Ç3«Žá©ÿ̆CÜlPPüp†ëD†“'r¬'Ö¿ šŠ“p>4M`ž¾˜³-FÇ9¨s„ ¶C‹s¬ÓVmà#¦Ÿá˜3²9û™Å9ý ‰þ†IήáÖœ>qâ1ÂdÚLFž$093}Õ'»ÿСCé~ûu¬˜5@Ù`¼œÏfHs Èy’ùé5Ì“s'Oh9;œ'›€Éè`rž§“&'O:¦;ýíÅœ›¾øh†C' GŽíØÇ¢ÿ$¯M6œ;Öèt6£:´;2§ùM_µjú±lfÚªl&#ÒÏ2N{g…ɪ“çq¶içNž|trÕL&Ó±µ † ˜±¦Û e6`Ç`r–ú9ô‹09 09—m?¶êôôÅg0«ÓÜÀ£ºêÂÄ~–D ÕzN€ÉYÞúæôùÇI0%LNŸ§ç Lðu{úl:‰ßö§éƒ-09ŸmƒÎŽèÿH°_ˆòXÃýïs€„šs“9‡ð x˜ö1©¡ŽÈ˜ÎÃäOàùøÎöÃ!”yú±ýMÀdÔ0á)@ǹ³´ÑŸ5y‘c?ðŸÿè?—Ž09w‚«ùëÙlN«å gQð9æˆ+F‚>4f÷Âä#0]&ÿßùîoÐT§MÀäAarzÕ†5XÉpúÓ¹s'×üm:8>:·X›­=sòVþé3g΄þ5'?Â-N?C˜ÏŸY|î£ck´Z’pbÍ"NЈ¦­™veœûhÚ“Ÿ¬ùÓ'kÎËâ5&§'`2:˜œ?'ÐéU¯:MNïãéÔ1ý£sŸOÿœrLÿ˜÷Äû§O˜ÏþhúGMÇ ÎÑš£$â#`¥y®ú=!Óéé YÏþüÿ;7“ÑÂäs&ŸC]}ΟàpšúÑCÂÑ+úÏ}ÎûÏ!3Ä"HjþL² YJäŸäuù>:÷~§§OÀd”0 tîóÇ”s0LNÿõ£ÇOŸÿ•À䯕Ã$š*ŽÅhÄMk L’<ž|ŠÄ믂ö°ú9ÜET‹{¡ƒ6áà¶Éɯ³i®NB¾ ëœLÔÛÑõü7?GØÙ¯#Ÿÿ¸4~]:¦Û<(~¡|~l}Û˜ÞGt¬›°“LÐи„±³·“¢ÆýËI{„{ÄíÌL~AØJ7{šýgÎkÅÚe0ÑÊ¢#oÄçlÜJöø#Âd g;36µP¾ƒ=Ø6@öðí ~Ó¾ÀÎ~ÐÌÔð; ¸'³3Ã!] ûŸ.°JGÕ&üÖgÛXnyòÇv‡˜ÂÄpøše§D²ìBÓ ìÒœ=GØù ³Øøôìk©ëqë6aï·¤7_šÅ·ÖeO À‰Ëz$—47>óÑ`ÒQµŒ”V¥¨¯Yƾ“é+V”ð{ø¡yQB6íwöëP(áÇ… –ðé–ìØ˜æ ìý'Âäÿ~jÕ“ÿ%ÇÇ£Mü˜°[üÑ)ë´ÌvvfÎJöèqîéñǰ˯²ñVË–]´l»­4H:n‡0!מ±ZžG´M ØýUù`^YÀ¦{ømI#õÝM£­™Utó¿Ii ²ßK;ˆ6¹2Óu@:plöþë ÀäÓOw°ÏÍè«ê˜€É#·ÄNl€´É vf6ǵt"*ØüNè„OD*Ç%½x4ÛõJvqˆ6ó>qÃõG€I÷Ú¸áÉÁMìòŽø™yâߊ;ûíoßÍïì§ÅÝû–Me LzæíÌET "f, ìý€ ‹ß³›[?“GƒI|.Ù,ôèÂ&“²™~h“°ñ,nkÌH†ÔìV°BÒ’|€ãi¼6ÉXËNÉäq“zçÚwèF±ÐúLÖÔD²›&mº…é5ÐÔò;ûmv“½þ–˜Ôëç½–Fa²aò¸÷Ÿ&l<¶«}0y4˜Ç­í·dCÈ$€ÉÆt.ãò…\Ð&3ÖãNµv%{e“ÝiÓ?dÁaxm7{çñGz§Ãä[„É>v?Ùu+šª‹6‡<êÕÚÝ$tÇ63¿-‰ÎG˜ðˆx‘½ÿ¶†ÂÔÉó±Úä!a²‘À$æh.Ž~LØ»`mДŠÉhÂîØ™¾'†Ã7'ƒÐM>òNÎöwÈ^¸ìé´¶·×ÕYq¶gÞÑãWpPj›ˆ0éhÛÇoÚGvu«OD˜¼01¡{·Íe“Ž1Ø&‡7ÑMÝB` ™^_5“‡‚É 6>M´MVòƒ²iÌ.Vè³iÜvVØîz:/²3£@½$Çü™ì[wd·ó s±§Ó¶Pj¡fïŽwÒw`2 _»1w@»DÐ&îõWµ—önÛJÒEÙ˜¹omrí öèæ1<È6Öaâ4q‡ò$º%öÞ„„Sn!ŸKŽL‰*/c÷͆ÈS¢ ›´,*l]*ÇïXYÌõ3‰’©æaRïYÍnKíèÍB@¥Í ÎÕ¯dV¹æ ¡ÐÖT½Åîoë™LaRï[˾éJæÛ9¨šÇMØ…¹UcxàdŒ¯õÓòÆ×OŒ_ðŽ]~ÄžNG[äj6þÖu–Ý98:z9nÚ'_˜[BB•Pí _.<Kßé´bº…Ðã Úûæ©›Ñ7¶Çê'v}Ð7Äõdû>Ô d¿zº‡_‡°iŸ‚„*jÚ öëÚÚø-þhºŽ¶*!°÷µkãoþÆ"Lü°u½(1äµ>êìµ  $Ò ûÄ-ü›ÿÑ ûú$3L†K'îÿ7“Ÿe²š€¦þP8Hæ&1‚21{íiƒ‰×SÑ1ÚIÉM…ÕÁ&,$´{½œÐ?æ¼Þ~2XÿÓÍ^›€É€ ôh_fß_åÙyÓÕï÷¦­ùQ‘dE*s¹Œö8g•4R ÇDÎb¿&C±pÉÉ›f±ÏÇQ¶ŸjöÚLÆ LÞd·ñ“Œú¹à¯_ÒÄdÍfÿÌeL%o9ÉF¦ï±Ku“y 1ÛÙ-«å'›½6“1¾’9®Ûd:¥…¶ÅUXh2Ö`Ó¤ëhtäûç±ï×d‰/N4«íóÿ·÷í¿Q\y¾ûÓ^ÝŒf4 žÕ JÂ(BϵE0æaóò›d0^ƒÁOüÆlwÛݱ1íî–¶©'rÉc¨e´£[f&ãN)b:¾Ìc+»§7¡Á@ÆîêÓu¿ßsªª«ýâ™à&]‚v=N• õñ÷|Ï9Ÿï烓ó™>¥k{I“2y.ä(Ns)S’ü¬Økq˜,<˜(š”’7:±~{Y ‡óõY_“CŸšÈg$óöjë×x©“cfå†|æî¸²ƒ41®ÔÒtäY±×â0Yh0‘•BŽ®#=fBÒ#­õX1yãD6!‡nÔd“U•\È%z2#*#{ YÝoQfÒ[¤³cø™°×â0Yp0áB]¹$ÙoÛâ˜V:¶“CðÊ­ÝʱlrHîÚç&ùx¡@…IHÙMHòQÌB”ÀxW)ô=$Ÿ<+öZ& &!_ÿß>þú„4W˜¥‚œt‘Kár&*LØ—¢ÃÒ±É&ÁF`òlØkq˜,,˜ÐI‘íä?þþñªúóî “¥[ƒÉ2Àè0aä?Å­LÒ,dl ÙI“Ý“gÃ^S§Sç,Þ‹>O¡a&O “±ÀX^‚K9~ö¬O&ðʳè;Ÿ&{0k…O7âAˆÛ”ÒóŒØkl¡nFõžI2Tàè÷xüRXÚAõßZN=edk7f²Ml¸A‚ßdéVòÐ0£¥w#m˜0@£Iv¢»†$tÇ£ÉÓÑ_ç\ƒsãã“óñ–4¶Уˆ<©íñ`âÙ^â »Èñ·E¶í)>ß”) ò‘þHnB÷È'lø4ÜX ï®ÞÐÔ 6Äܤäûâ¹É³ÊMf=ÇÉó²"¹ü79ÂSzj˜ðâ­¶'L™ÙY'ÚÚÚH¦½–Õ#Lì¬X”…±®Ì›X—µµ­íD1¦­ùR$…Í—â0yöÚÄMÜ&|³¿UNžÙ5uùœÒú ¯‡»Ý7uýúWI&'OÃFÁ‹ö¿(I߯G˜|ôÑG«ê gIp¨”ÔцC©ë*l˜ŽÐ 0â0y*öÛê0Meµ˜ÃâzB/¸P¹m”Ǧ­·n£ë€8kéìZÁž”àSÇb3ŒeŸŽ½†…WX½càâŒ>/}ÐÒd„I5ÝÇRôV›3ûÐðZ¥‡É3…ýâóÏ?ÿwK³Z?®Ì>tIÊcÓa’CòPÇ¢k±vrÛ³úOݹ³Óºžl=šM»ß‰®ú›Ê!ùN¿ÿR6Ép.&¸ñÕª} ¤°v¶ƒ™u´<ðV6ÙìÌ%‡EiIkªÕ;ÄSa¼ÓyÊ5Éoa8F®]p) “^;3Ñw¦a´ßÕÑ;:€Ã^b“”ÀÝTr`HŽÀ„ ¾¦b€É ¬7‡sy®}X‚îX­O×>Y§ã—à½[ÿú9I^^â¼™M–}„ó(Մб‚D1îc™ß:hø{hhu~•C–'™n¡ÁÌÞ5åÙa?!> ~J.,Ûö™i—Ñä×2ˆõ/Xm‹sø…r·4·šÈŸIVÍ:°ÓY”“‚>ÇRO&yÈ’†/®}V;tO9‰Oxý×O–ÿ}Ù~çðZ§ôàý—¥£nɵ¶F¼xùheW÷ýÒÔ 6ìœ8í”ú.BC·䯴­ÅA°çèZ}?“'†É¢޾38€ë¿›O/H¨Òkð h=±ÎB¬á7µéˆ*Äæ£<6—MVï,³6)]+6ì¢0ñq““ûÈ7Î}s¡Œ4>ö„Ë̪¿°Ÿxü"Jaž.KÓñô ‘}Zæiž³a&Oš›Ð¼s.ܹ•®Å$ÏwŒJ¯Á´v~ì!%öö6&ÄÖCyl\&íÐë8Hã>Ò W>û %†»Èr>Un¢SEüAZÖç×ëøüú5 ðÈ ã0yÂh’bk¡BlÇw Vjó!¨µ¦8®Ùåö5hÒâÒÑWS~>ý q˜,ÜbNÿŒˆ¨êÎåù’eE/SŸNVŠ+¼@ì5ý{!ÝL–#Òk”'©"›.ĆK‚úíŒf"3!%£&›<Çâr&/{í9lq˜,`˜¨´3yZty´{u¥Ï™?4gä¶=Ñdýc:ý=R}`ô qvvGŸ8Ëk•sÒÛ¢äÖ89*Ѹm¡' /Ñí+lü¢W˜;¢ÀÏzã9 þø@Z˜0™¼I=CûÎ! ­÷–WQf§¿r³¡„²Ô"rkœ&ÀF‡Q7±TzöQKv¢)ÓSWûap>UÄ/ö­œ¹ ,z%~¢ÒÉÏvÃPïÉôôʇ<þ9 .D˜Dlo´(´äÛ1親k÷P•´&G‘«e¥‘[‹°Ñ»F¬4«šm²bTu{-IÚÁ~®Íu¢º’G¿„ Rž#?q*•d:y£ `˜ÕþyQ·^_Ô 5GÀän½TP}<ûaV@GÀ°ÔG+ñéB-:˜“ÃBP:÷Qó@4ä°Á÷ SÁæ‚»`pÁ DGŠ‘:RlÙF># ÷PJZû ŽhéyV§¾B¼§„±Ôt³ÐNU€ÍjÇ|;Ö-¤ÙAÎcßáTŽ™ëM^ºÒµ[¥i?&…¢ÇÚHšjæG 3ù‚g9©nç$Ieš‘¥Mÿ‰$zhÉyaP¨±Ô“<'žý3I8J¿?4ãšéå0+PÆRA¿8q²_äé}œÔ Aj(.ô$øèŒÎZxl’3Æ9ik«_‰‹À“µÿàJ²ª¥¡øO÷PJZki У¬#™:$ ±Ô4³PI`c0©ëÚ`8f&k¿ÛA2]ÇŠéN¢ë)<ãXmå‡À„úã”Qçœ÷©å¤sÚ³É&èM|ªÕ_£Y„ «üjÈ!4D²Z<ë®!Yk‡!høhE \fæL–nÖyyé 9Ô=¼…ä{‹(LÈ€‰eí©Å$߈Gƒ ²P8‹[Á,– ´–K’½Ê¾bK·ÆÂ§æ$ËNPnM)µjÊZ&ûcZrŒä3úIŠ(»q%º£ãŸˆ“ƒäð½ìLç·Ý´!¼½Ä¹ƒüß$ñ iþT³úÃVðõð»Ôòï+S’T¯œæ& $¿GmV(l,qû|—³±´8^žâæÕTø~jŠ[ðbI‡&‡½Þ†â_{qa<7Q£IBï¥[‹‰ª¯Öµv¢ÜCÍ„ÛD¾øøˆê,¸»wÊHR§Ê­Ø&Ŷ{Ðæ¼[™„'vWÀ­“Jì˜Èæô×Ã;Õm«±½¸@ô=©™vÈG‹2OXÝ¥)‚jõÇœÝjÉá»hù §æA:Ò<ägª…>Õ¡kç}VhÎÃ4…_^¢ÂÄœÊN³{‚üÆgY&Íh×”ÐTMHq˜h¹ eד#L î¡ &Qþ•gÓ_¹{™ªK3¹5¿°Q˜lnCrÂdŒÁ¤™î4+Çüò!šxü~´úk§ˆA˜T“µÛÓjIcNš]P­þL šLQOHƒ‰4 LÔŠ@HzѤ¸Y…Wþ]SèÀïaV’“†8L"#ø%?¶:“Æi´FE[ϳÉ2èq6¥ K ¥`sT¹5]€M Ñ:07Me)™­BÝq;úîÌ[3ë½fÀÄË‹Þjs†½Ýœ&úÅ)S’ýòº¯ÉáË+?&’¤Zýé0¹–(A `’¯Ã„Ïž +ƒa½TP¸4^ÎN²S§Ò v:&aR\(ÄaÂr“ä¶zHa¡Kp¬´ LlÓÜC;VC6”g»¹˜hÜÅK­G•[ûFÖØ´ñ’ä:fΠ¦°^ØIqíFU¿c‡É[_ßh6ä¨÷ÑÌÆ#ùŒP¬Í`²Ü¼P£Zý©0Éw#´ˆ`‰Š&Ôï¯À«U¢Å—x;al”á„ÇèB_98µ\‡I6t:Åñh¢D鯺Ø,ì&‚Õ\Ñî¡HZó1 ѵ! [¡±Ô"rkT€-ER§×±KÊG7ôr´Ï€!v«É‰6Ôi“aÞ¼n3©ÿ$u¢ÒIf%Iv¿ Yý©G¡¯ÚDam ·LƒIƒ™`Ñ„¤5=XÇ*éG:µŽXþ¸’dÀų«É!Z*xÖD¾&¿%ˆ3 Tšà‘ñ±Aͦ¨Âk(´ö݈æj ­uU¿_bÀðR¿TYjªÜÚˆW`ÜE9~ÅOí÷êíƒå[m?*wߊ³þŽ÷ÿþQº4 +N ž<Ý-áK=Ûö—eû;yèy+ƒâÍ“-º 5òc’|ÒpÛ_VA+þÎ~7›t㯴Uj~aqêâGX¨Âðî`ùùek’ß/MœøËªt€ëD}úÀÑáܾ2M£?zÁà_!æ¦Ô¦¹‡j7n:KÍ@^3 °Ñõ?Ãza€æ²ô²*¿5ïÒŸ_ôÒùû¡ã[Íõ ªìgP³úó«ktz+ÕâN¼Ã¦úý*Ùóqå˜MâkOJ’H=EížçP0¸@abLâ4‚šº4MZÓäÙf°Ô"rkš›Î3á˜å£àâŽL ‡fTšVCì7NPBšÉHGªø¢ŒüüÆ’¿ˆâ´þ„¨ŠÀEÀøL¶3ÝEÐÿã¯;þé“Ë?9öš¬8þâVqöÚ#ÀäÛñ©K€’Þ;g&œú+/Ï!“ÈE6pÒæm÷,¶Ð#>ïaß7&7¾ê½uëÔwòä“Fnzâ`<š‹“6ýÅ>òG ŒÃD…ɱÉoî}yC{\˜pʉ‰aŽ/ÎN Ÿ›ñ¢e¥µ·7"¡fä¤M{Nkï-ßcUG<¬Þ&°Æ‡É3†Éø$Ëù&2N‘b=8‡³ÊX@ÿUW{—c8qÙA…:òF3dTGM¥¢ÑvÝʘ¬RÑä(ÅÏö¡”·Æ¸ljV‰.¬¯ )çªþQ¢Ç†8Lž&““c†'Éj«9Ó«´n·¬´Ú¢ ±è Ä±<Ù©¦ˆ¥b5rÒþƒ{tL íl3»$N™Õ>´+Âe›3ºŒ½½WKOBñhò¬`Â~}Ÿ&–b‹¿üÖêì8;qNéš:ÅÃ[k¸2zÉ«(­wî ŠâòÍ”Šm¤14>¹9iÇÏ"MF§‚[¶ëõ¡?hõ½@;§aÆnžahg\¶‰oF°ËSºnê×&Ú×ø|¡ž1ÀG`Í»¬„|pr¬©?Æã0y.0±þž®Êm^i !ĆžZ…J%veáôETBÍZGa‚œ4V1^RN2{h;k3mW¡úƒ:63³Ø‡:©ØZ&å²4ŒÄ›ÙRLHùU@ù%¯}[õ¿-/WUU½â àÉ5U¯ÉÊ›U{ã28Ï&%ï“e7y?aRbCúi¾²dUV¯Cr˜Ùêt’|q)i:¾Zå¤Qѽ?²©h»–“$—Ãlé¬(fþ ;•]çÛêÍ_ LÐ1vš}èα2ä­µ›ÿ7×0‰Š<»¶ô÷zÕ{  á½1ÈM&_®z³éͪWàä^øûj·ôÕ¸ZÒsŠ&•9‰ç6•ŒR˜XU˜¼09×~­Åu¬Øê§jûVZÝ­«#œ4|û| úÄéªEa‚þ ­ÛI‚Ì¡ú/ÙУÈ!Nµ%hJ]BUÓPÊe @ ÝÁ'AÖ†äBÕkJhiUÏ8Àd FÀGÓšª÷8,{¹%¯=enbPV3gÖIÑÙ›†§ÍÔÆˆÛSÄ l*^Ÿ4a²`‚Ú#–Uk¡?0[ï–!'„»ñm¢ÉVÆI£ŒE7¦0ÿŠÔa€ t:f ³j“‘ O2|XŠ5‹}(³ùc\¶®M$áL޼†Ì9€ ŽtÞ®ZúúÒ×_®Ú;VõÚ¹ª·—ü«BMè©`" Ò´v0Ëÿ¬(Iü̦aZ9HWvháŸ(ˆ? ˜¸w“ä€Ãle„ƒ|Ei(Ç|$£‰g7™Crs“:\òÍ¡¦}82R©h˜t L”v3òÈÆ.Ê>T³Tab馄¢ŒQS–®Âôc ô1 &¯,] 𔥝¬©jZúÚ›UMO?ßw•WðgG"ï}¸eFH04é7®çòw¯Ò[{±–‚ïˆ/·g“}ä·¤L€‰2Ím“†‘OÈK&™Mʱÿèl] adRiG9$€ ‹ =ØLƒ >u³]'@' ?Ú‡vO&¨&mýR ÛPã„ÉkK_u)ßLXÂÚôfì½ò  æåWž^³- ÂáË©$ÉÉé’ú¸ymåO[õóî"ȯ‡ï/'‰€}¹Oj0':Ãâu¤6!?%_ðë£w ¤òüia²¸ÅÿE:æ’ äª‡z·€É&’Õÿînbµ³ C¶žÝBSX˪¶úò•8†¡‘_:_½’,sÁÛî¤þ “‚ŽÄ|hôèÑ\ê}Kå§EìC™i(b à‰ÏH³aõŽ[ZQn,ù7ܸ”s/Wõ(Â’ªC6åö”0)ý”’”ÄŽ:”àP®`"J¼?R‡ŒE’ "YÍ„ˆ2÷ÝO…æÒŽb|¿‰Ôô1´¾U ª;þ°úb&Ť»c)A_Të¥ÛN$±Òb$:ÐEš­”r˜5î—KÍM4*šÝAÄ0G}Wm—†0é˜nšÄLC)—-€YLqž¡+ š6 £Í{Š<ö2|ž[Ñ%ðÔ0ÉëäýBÁwÏŸºV9 †)Lø¾¯ºÿi0Éô@¼Àâ+Õî/̪¶ê¤¡"²2Ã)MegvŠüY| Ö÷±²>}ÇÏóÃp)ÌÇ:LZw\¸†ký®ã£#^åø®‹§ï6 ð™c°¾üD¿K¡§;vœhKP|Ë8i—ߌ>ÂÃÐSÂdmvšˆ©È•/À‚¾æws­µ$¯Û»ƒ,jé]8òS˜@S;Ò¦—gÚ5»?j¢ÒnJ²7Â;”aØw$¸‡“:8°Ò²>8£îì"‡Ü‹ém± F-S­BŽL iriº¨¾¤'«Ëy* o×üAe%Êìo¦}¨þ}` ´BºaÊ a2ïöÔ0©+-ér-ÝëÅ[í^ïAÒøMiÉ 9Ã-x¨ûß]SšE“ºÜ72¥W$ú5»?jra{‰tõ±”t W·:½€°ÃjYŸ^ßGx/©—„‡ÉóÝ&›ÞXæT~ÈŸm:Lšwº{Û’<9Iv¡÷dý×0žù»†BpйÿgâÀaRK¿ÏIô¬OœÒìþ°û@›ÚæÒ÷Ц”¾õpF:u²þcB«Œi!NV‘S`—ΞlÃKq˜<Þ;Ó®4xáß±ñPJBtƒ+sm'·E_šMîš zÉᡜD±†ä­õ&ËRI³p=;‹–ða BarÙ”vŠä­O¼®÷"L09I_·Á Ûo*¥ƒø˜¶Ù`‚ƒjz)“§¤2*ŠòÃþd3`òî–¼zK÷½(yî0¤uw-I²ßf¨)lã7‹³êI7ÀĦÚýµÐ§ˆíÙÿEò%±aå¢REjJwØW;L¤Ôv)“ÇÙ&¿ÒLE;zoùº&†çb¼5­‘FnkšxMk\sÂ@/‘l°ßÏI²e':…‰T„‰Õýdœ^H618ÐZ.hÚ‚Áð…ÔİZÜWÀ¦NîmÄ2AœxËê–.¯Ëì@â«ÃÄ«Á„OÍë¾Z‡Écj¦¢Í8ÇRÁfN¦[‡B2·´ªGѺ˜(êšÖóŒW½¦öP‘®J½Yy½Ê¸Œ/ N<0A†y{u¦˜K6Ÿ'_y~ÙZÛÉ­î[ûaþRz‹÷3õ£R6Ãû»y›ú?X”®ëWhñàÉÚ„ zñ1ƒõîaµP¢õ}°#Þ?z~Ù~¸ÔÍÇaòX0)GSQ¹ãT/[äQFí5º¨³fÍÂD_óæÞiÔ5®É5¾'Þ&!lL›®Ùë¢ðÁ›ç„ Õ1 †ú!„©f.c ŸŸæEJøX‘žÖtzqÕ‹ÅBAºV¬>F+ëók5þ° ^ Ç;Ç‚ÉN¶ï0¶d­½¯wÍ’ªªW)]mÉLêÚØ’×Ðöžr£‰HÉm¤Ð¦¯î…3ÚÍ¡9ù&AýƒªpFêñ´(šI¤é´â>#Ù$ê1áèú¾²øk0IþÝÑwû©è PŽEk¯…páæ½¦¥U¯ôœ[òêÞžiÔ5×ø’ª×ÏÂh‚k<ØÂ5†;€$½y˜ÄÙk1ÍTT…Ißîhí5¤›¼ÝË˯ºèÎtêÚ%¯È ÃËkŠÖb/÷:îåWÝ-ðr&±MÐT´E…‰åT)ù›Q{-Äab€wïb;kªö Ô5d¡°Í$À D[¼ÉëK—ÐÀÓDoŽÃäEÉM(L¬ý¥Óµ×0y•)LhL@¨k7^ 6…c/W½ºôí%¯¨gÞŽÃä€ uU£ÉDQ”öeÖ7)2v+ZdØk¤®GÃ[¼‰ÝÒ¿±N[ËÊÒ8Lb&ë·DVÙ„‰mßtí5ÊX[y)Ýñ.y?4ꌃ)L^¥0¡-|KXÃa¶«ÞÏMb&&d´Q˜¨³°][¢´× …QÎRÞÂÀ†îèÔµ*$ÆžÀŒq´>ãÿhMßSšªª–âø_õæ¦8Lb&SQNi½zEè•”iÚkÈX[ºtoÓ¶3ƒºF—rð*ÝÑZø^_úvÇ;x³w‡I¬¯sQ…å·Í@K¢ŒµÙ©kF›±»…žf{xÕ_,ÃD_åããm†öÚCÙk¶Í“G2ã›Ñ"Jníï‰Ã$†¶é–¢W5ã›-ÓKùô»‚s˜ü…ç(ü©ÃD?„4RׯP­eNúš<Ï5¸W†?¡¹a"M žÜOÍøæqÜ»=½¤”n…»l’“?Øø‰g&Ó y6"ýc#Mžý׿£™ùÍœ‰Ï<Ñ$ˆŒDôkj–ø›'±¬Ó?£<ÏïÈNÉTWõ°Ðo­o-9ì¥e~,p¨åyhëm/>॥[a¤øÅaS^%Þ•çöÖ·jòT½ü¨e è¢0Ÿ?g`ÁÀ„S¸Ú•ô—’αÓþGaâjj-G`úÑ\ lÓCaÛGÞJšé \ƒ¤«º_Vd¥$#6¡2°Õ”}¤iµ'ZÌÚÈ^;Hê|~qhiæ’Bgó¡V4+Ä:½;•ÝíæþxÐÐïÝAá.¡”Öâè&è凎ÆhØ-R˜ˆX:‡‰ö?¿KùrB¬N%0½“àŒ½’6þ]í73ÄÊñÑY-çÎTg3ÿ­i^H/«0ýF{ü4ñqø&E‰Ê¦´¨`32]ˆRà§ö;w‘ÏH’ë ɇlD-ä©à*RØ‹¼WôzüÈž>”$aø4FÕä/̼ü‚aÏEÜ9ŒŽ·Ö”×½  žLdåx±v+]{I“9Å1ríÊúÖá¾3 £ý®Ž[£rä¨fQ`cΡ\û•Ñ«z/‚³ÖNÜ;Bçk[‡{û\ŠüåŠ ) œ¡J…ù7XÕzç*^ )­}ã“gÔ­c[c‡9Ú2Ú£ÚL’÷WºEo+#[ݻȢþ[ŒíL ë¼eäPÿ®•¤ðBN^§x?5¥“úų&’œŽw…ÝäOóô¢sß­‹»ˆ¸«‘BíGõ?þªpJWчé.e_1­“E-ëǰ·u#þ‚âÑ_ðgãóe£ÛyM C©ßgV³+Ž *ˆvÕ¸ª©Èнc).i:B‚&J{6ØNòòE. ±-瑽üû­®9SXþêyüqWÕ…½Õ¤ñ›T,ó›2¥Ij`Ý=jíÑÃ[˜©fTú ‰Þ•\‰0a&CëS áõÕIžÔ7ÞÑ\f4mîüñÜDû ÅMèÜX銛O/fÀ(¶žXg!V*Ôv,êèc©ØTçPëÚ†T’¢¦Øç4ëÁà¸)ÏvfÙ©´¯ßl;nJ±©…„IÅê,÷™=dƒ«Â”50…:<ÔEåê–’‘íyWúå9Ä‚ï9ùWbÁ*ñ×Y™Ét wh`5DÛ¥&€„–:$zt²uÔÎáXBê òÛ3‡³7Øa€ãáQZ·@òÄG:˜PM=†Ý¨ÌÖ±@¡³–:e)±3¥xŠæP`Û1ü\ûµ«> &{T¶=½YëE'te«¡÷Y‘Ø©¤Q˜ì¡¦æEÄŽ¨R:r2ÔøQ𦬸Í|Ók×ÖalyE¡Áä#ZÌÇ Û&$ø•é€t?5϶"“é%®u∨†zmQ÷®S¦h&g³Ó&ar“UËUXÅa¢Â¤Ä©0…#ôîsq€€†Õ†ò8NüÀ#·v”b+[ º€µ¾ˆ]µ¦‰39 HíÉò•¤@éX‘èRLŠJ|Ê|S÷nkàJ3&Ü—í¦üv²öÝÉ9aro=qù=¾jr  `ro5-óû …î uSfˆ \!}M7ï¤oßNÜ‚ßãm [Ìä=ÃâÝì¤>4õjWu—‘îj’áäƒq˜D:ÍÊdˆ;ض–C˜T˜X)L,ÝÚQŠmÓ lJûZgú4Œ#p2_;R¹*rÈæ­G!št­H”õ„InVý•ô^&X§~A>ÿùïCsÏ›He¤ 3žÂ´–ä hÈ#ãžmjà»î/&…¢Ø`‚®‰Ú¶AB’ ÝUCEMÍ>ã. `"ä’B!žÂjÿñÉð*J "aÄÁgÓ‘é0‰DfΦÀÖ¨´ôÚž[¬ÕuÁpwuÕ™†ß\ZУt1˜@˜ia4š¨¦³v´<ÇˈŒ@×î”oK{&ç®!ïn$ÖU¢2T ÒMVæ×9¤6˜Iù:L2ü<¤ik”¼ÿá²ôN>¬v §Ú>ÜÚ´Eô ó¹¤¢Ës­üë¿â]¼fò'JmÌËOºùþùúÊàЭýa©7½EŒÃDûŸo¥³QÄÒŸiI¦¤‰”[(tìê¡Gή9ئrèù—:qÊQô?þÎQø­>›¬„,xɸeRŽÃhp–|c¤FY+Éo&8?Ûì0J/ÍVÎ…¥{~‰.æJ-óãqê]/ôK|˜9ó m)q«‰» —€ƒ“?ÍËŸ—‚zé`¼Ó‰L¡V_¬¯ßïÇéP®áb[Ûi胎£ö|øP˜Í9¢³+°UºŽ­/ok‰xÖãóÞ//ß/áœj{[ù~¡½­Yqœ<Ý£twí°A?³£ü£tm=xÚFSX´í©홟H6úÊüôò<î í*7zëwÍbòõ|¤½Š+tâ™.Ó¦ÈÓ¦Þ•ÙôÛ M9õœ½@¢_Cs­jÞ¢¥½œ™en®uã'c¯¡"RBçµÄßuÙ´5mWµ¥nª›~4—{F´__äÑTN¦­dý€™‡²#è—J*GR™É,þP\àÙÀ„ò ^”üÄÙkÐ/-'$y`BÃsaùð‹ƒ’&r`.Χý ÿS7Æåùh/qÊô‹“Ð ›bÀ†î[>Kú‡ÉO&!åË›ãˆÇ„61Ë&&ÇÇPàŸkÀÓCþeq˜¼0ÙM:aÓ±‚jö”¥˜±¶–­$Yûa¸ëXl&É-ò£šÄaòÃä]ç á襚”p~ÿÚrR ·®'[f«æ€q˜ü”a"s¹Öz\*QÑ-ˆ&0ò= £s˜ý Dq¬Nr)q˜üÄa¢T˜ hÆRgÂh²t+ãÊÒ¹Ïb‡³>ñ‰`â×7urĈž™¨þùJõ ~&Ï&ûHs×úfíÖµ ar|ÂÇ)#7¸O/”Q§ÛLJ‰(Hl4?Ã匂>žµƒ- s݇ɓ®My=9û ¹ë¶4u’ß±z.Ä•RJÿãç&~¾ïR þ¥üÍH¥=ˆzßüÔUhÚ2àœu5?Uég÷õµ8ù8LžLŽ'\-#\œüZCÈZÊR»}§ ]®C o Ѷæ!/¢&lP(#ÈBŠÔòIµÅ¬D°Q4ÚªkBnI'½W¨¡ÿ‚¶š}!a²›|¯ç·VʤT£‰#‡$xÉxÛEi°ne,ð¸0'NrBÒ•NwJbXò^Jwò<ý‡äÇ× 01¸ýLU^»v±˜Ú&iE~¢ªÍ 0“&öc脌Ã$—Úé ïµqì›o>ýFÈu¥ä”4q W¥ã™>Ò*#uCèb`ÈXfq—›užüÑN‡7¸ýIÈœöøƒC[HÀjû CE7ÎnÞ\+:u'tÒN‡?hf5€q˜ü80)"¶Rœa“CÜ&JEk5m€w]¤æ­9$¹ë::¶û#ä±ÓõM u‚¿X¶ö—€ ÙÜrj É÷L¢Üþ&K> E~¹¤›Á¤ˆ¼Q g áR·½·h¬È8L~xf@¥PÝâ¢ï÷òï<Ôyt@ qíWFpõ*_Ö”ŸO·A÷óÑ$ &‡½>Mà-{¯¯F‡Èæ!£ÛŸ“]$ÿ{Z xÝ”(b֫¤ÙëÊδCnòÝÆ·Ïw9›yÆL®L†U˜|z&æWˆgþÔ•¼¦*ÉåßM¹)+“‡Ã¤§MLÎÄ$L2á-Ë´.ç)p)9%&ôàx޵gaK¹Q´¼Î×Ç×[zh“†âx ûp˜\‚ÔÇÃ÷¾£09˜l/QaRâv“Ím¨°V¾´ì Y9V¬Š°U˜È‰6’')xÅ WºrIr=!Ö8L“;g' 5¡\Ò‰¹5†*·w|´ß¥pÕõ”¨Ö1Šl5vÀÙÞÿûGé‚áJ×®òòþë#ó­éh3«´âï÷/첊'…É 8UÝ0ÐѧMbk…˜3,³eÝR%dXC¦z'º{†üðb>ºêoþmþª¿X†É=Úç2ؘƒ‰®–¤ŠµÉÌ«‰ÑeY1*²i"mÚN“u› &üÔÕ~ºµ<‚;ÒüU± :鬥&1M~öš_Ú¡Uýå Ö¯„õ|ú:_xΪ¿HƒX† ÎÔk}NT+¹I 4Gˆ‘å‡+#ɃI-9Ty·nI3óÓ öxËÿ"ÑUÌ ÐÏGÄ2LèÜ2±Ï‰A˜(ÊÜEÜÃYlÜ0éñh0É÷Áû†7nÓÌüx­Ðoj |«’žµQ‹¿èª?þ,5´E<c&ßO|…Áä{$Âê}NìÀ„SZÏúf¤¢T la±u}5»§è¯ÿá×}>z#1˜`+í"…’GB3?æ÷Â:@B DzVòxÄZ¬úóø±êOºKå×ìµ´A i=þÚ<÷Ün|{o⺠“o(NnŒãù±EËÂ߯”#hÁ2ý,·›t"ƒ ÿÎúÁ_ƒ@¨Â¼Sf¿‘ z«üÅ?üâ×}=>ØzªIBåµÊÊk-¾{9™vßwë3› rO,&®’µ¿?5žµû|ÞžRØã…¯áârÀ9µ˜4ŸIMqúÞÅV^_ mÞ3Q¿8çîSžÉñG+×_x›ƒtÎrö-ÒÔdÓoæ»·cÖËÿøO¿ø‡ÿù‹_ÿ/ºýòglÛü«_ýŸýò¥_þìÐKøv–½´~¾…ž]ô+Øðªú5.þ [m}ÉÐ ¦¶Ÿ«Û?ÿ3|¼DO½ÛϹCÛ¯~öËYÎÂKúùÏþù%ø;Ïöó¨Ëÿ¨nÿô‹_ü d"ñ£*·ŽIEND®B`‚beets-1.3.1/docs/plugins/bpd.rst0000644000076500000240000001031012203275653017472 0ustar asampsonstaff00000000000000BPD Plugin ========== BPD is a music player using music from a beets library. It runs as a daemon and implements the MPD protocol, so it's compatible with all the great MPD clients out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully. .. _Theremin: https://theremin.sigterm.eu/ .. _gmpc: http://gmpc.wikia.com/wiki/Gnome_Music_Player_Client .. _Sonata: http://sonata.berlios.de/ .. _Ario: http://ario-player.sourceforge.net/ Dependencies ------------ Before you can use BPD, you'll need the media library called GStreamer (along with its Python bindings) on your system. * On Mac OS X, you can use `MacPorts`_ or `Homebrew`_. For MacPorts, just run ``port install py27-gst-python``. For Homebrew, the appropriate formulae are in `homebrew-versions`_, so run ``brew tap homebrew/versions`` and then ``brew install gst-python010``. (Note that you'll need the Mac OS X Developer Tools in either case.) .. _homebrew-versions: https://github.com/Homebrew/homebrew-versions * On Linux, it's likely that you already have gst-python. (If not, your distribution almost certainly has a package for it.) * On Windows, you may want to try `GStreamer WinBuilds`_ (cavet emptor: I haven't tried this). You will also need the various GStreamer plugin packages to make everything work. See the :doc:`/plugins/chroma` documentation for more information on installing GStreamer plugins. .. _MacPorts: http://www.macports.org/ .. _GStreamer WinBuilds: http://www.gstreamer-winbuild.ylatuya.es/ .. _Homebrew: http://mxcl.github.com/homebrew/ Using and Configuring --------------------- BPD is a plugin for beets. It comes with beets, but it's disabled by default. To enable it, you'll need to edit your :doc:`configuration file ` and add ``bpd`` to your ``plugins:`` line. Then, you can run BPD by invoking:: $ beet bpd Fire up your favorite MPD client to start playing music. The MPD site has `a long list of available clients`_. Here are my favorites: .. _a long list of available clients: http://mpd.wikia.com/wiki/Clients * Linux: `gmpc`_, `Sonata`_ * Mac: `Theremin`_ * Windows: I don't know. Get in touch if you have a recommendation. * iPhone/iPod touch: `MPoD`_ .. _MPoD: http://www.katoemba.net/makesnosenseatall/mpod/ One nice thing about MPD's (and thus BPD's) client-server architecture is that the client can just as easily on a different computer from the server as it can be run locally. Control your music from your laptop (or phone!) while it plays on your headless server box. Rad! To configure the BPD server, add a ``bpd:`` section to your ``config.yaml`` file. The configuration values, which are pretty self-explanatory, are ``host``, ``port``, and ``password``. Here's an example:: bpd: host: 127.0.0.1 port: 6600 password: seekrit Implementation Notes -------------------- In the real MPD, the user can browse a music directory as it appears on disk. In beets, we like to abstract away from the directory structure. Therefore, BPD creates a "virtual" directory structure (artist/album/track) to present to clients. This is static for now and cannot be reconfigured like the real on-disk directory structure can. (Note that an obvious solution to this is just string matching on items' destination, but this requires examining the entire library Python-side for every query.) We don't currently support versioned playlists. Many clients, however, use plchanges instead of playlistinfo to get the current playlist, so plchanges contains a dummy implementation that just calls playlistinfo. The ``stats`` command always send zero for ``playtime``, which is supposed to indicate the amount of time the server has spent playing music. BPD doesn't currently keep track of this. The ``update`` command regenerates the directory tree from the beets database. Unimplemented Commands ---------------------- These are the commands from `the MPD protocol`_ that have not yet been implemented in BPD. .. _the MPD protocol: http://mpd.wikia.com/wiki/MusicPlayerDaemonCommands Saved playlists: * playlistclear * playlistdelete * playlistmove * playlistadd * playlistsearch * listplaylist * listplaylistinfo * playlistfind * rm * save * load * rename Deprecated: * playlist * volume beets-1.3.1/docs/plugins/chroma.rst0000644000076500000240000001203112212737261020176 0ustar asampsonstaff00000000000000Chromaprint/Acoustid Plugin =========================== Acoustic fingerprinting is a technique for identifying songs from the way they "sound" rather from their existing metadata. That means that beets' autotagger can theoretically use fingerprinting to tag files that don't have any ID3 information at all (or have completely incorrect data). This plugin uses an open-source fingerprinting technology called `Chromaprint`_ and its associated Web service, called `Acoustid`_. .. _Chromaprint: http://acoustid.org/chromaprint .. _acoustid: http://acoustid.org/ Turning on fingerprinting can increase the accuracy of the autotagger---especially on files with very poor metadata---but it comes at a cost. First, it can be trickier to set up than beets itself (you need to set up the native fingerprinting library, whereas all of the beets core is written in pure Python). Also, fingerprinting takes significantly more CPU and memory than ordinary tagging---which means that imports will go substantially slower. If you're willing to pay the performance cost for fingerprinting, read on! Installing Dependencies ----------------------- To get fingerprinting working, you'll need to install three things: the `Chromaprint`_ library or command-line tool, an audio decoder, and the `pyacoustid`_ Python library (version 0.6 or later). First, install pyacoustid itself. You can do this using `pip`_, like so:: $ pip install pyacoustid .. _pip: http://pip.openplans.org/ Then, you will need to install `Chromaprint`_, either as a dynamic library or in the form of a command-line tool (``fpcalc``). Installing the Binary Command-Line Tool ''''''''''''''''''''''''''''''''''''''' The simplest way to get up and running, especially on Windows, is to `download`_ the appropriate Chromaprint binary package and place the ``fpcalc`` (or ``fpcalc.exe``) on your shell search path. On Windows, this means something like ``C:\\Program Files``. On OS X or Linux, put the executable somewhere like ``/usr/local/bin``. .. _download: http://acoustid.org/chromaprint Installing the Library '''''''''''''''''''''' On OS X and Linux, you can also use a library installed by your package manager, which has some advantages (automatic upgrades, etc.). The Chromaprint site has links to packages for major Linux distributions. If you use `Homebrew`_ on Mac OS X, you can install the library with ``brew install chromaprint``. .. _Homebrew: http://mxcl.github.com/homebrew/ You will also need a mechanism for decoding audio files supported by the `audioread`_ library: * OS X has a number of decoders already built into Core Audio, so there's no need to install anything. * On Linux, you can install `GStreamer for Python`_, `FFmpeg`_, or `MAD`_ and `pymad`_. How you install these will depend on your distribution. For example, on Ubuntu, run ``apt-get install python-gst0.10-dev``. On Arch Linux, you want ``pacman -S gstreamer0.10-python``. If you use GStreamer, be sure to install its codec plugins also. * On Windows, try the Gstreamer "WinBuilds" from the `OSSBuild`_ project. .. _audioread: https://github.com/sampsyo/audioread .. _pyacoustid: http://github.com/sampsyo/pyacoustid .. _GStreamer for Python: http://gstreamer.freedesktop.org/modules/gst-python.html .. _FFmpeg: http://ffmpeg.org/ .. _MAD: http://spacepants.org/src/pymad/ .. _pymad: http://www.underbit.com/products/mad/ .. _Core Audio: http://developer.apple.com/technologies/mac/audio-and-video.html .. _OSSBuild: http://code.google.com/p/ossbuild/ To decode audio formats (MP3, FLAC, etc.) with GStreamer, you'll need the standard set of Gstreamer plugins. For example, on Ubuntu, install the packages ``gstreamer0.10-plugins-good``, ``gstreamer0.10-plugins-bad``, and ``gstreamer0.10-plugins-ugly``. Using ----- Once you have all the dependencies sorted out, you can enable fingerprinting by editing your :doc:`configuration file `. Put ``chroma`` on your ``plugins:`` line. With that, beets will use fingerprinting the next time you run ``beet import``. You can also use the ``beet fingerprint`` command to generate fingerprints for items already in your library. (Provide a query to fingerprint a subset of your library.) The generated fingerprints will be stored in the library database. If you have the ``import.write`` config option enabled, they will also be written to files' metadata. .. _submitfp: Submitting Fingerprints ''''''''''''''''''''''' You can help expand the `Acoustid`_ database by submitting fingerprints for the music in your collection. To do this, first `get an API key`_ from the Acoustid service. Just use an OpenID or MusicBrainz account to log in and you'll get a short token string. Then, add the key to your ``config.yaml`` as the value ``apikey`` in a section called ``acoustid`` like so:: acoustid: apikey: AbCd1234 Then, run ``beet submit``. (You can also provide a query to submit a subset of your library.) The command will use stored fingerprints if they're available; otherwise it will fingerprint each file before submitting it. .. _get an API key: http://acoustid.org/api-key beets-1.3.1/docs/plugins/convert.rst0000644000076500000240000001123712224331543020410 0ustar asampsonstaff00000000000000Convert Plugin ============== The ``convert`` plugin lets you convert parts of your collection to a directory of your choice, transcoding audio and embedding album art along the way. It can transcode to and from any format using a configurable command line. It will skip files that are already present in the target directory. Converted files follow the same path formats as your library. .. _FFmpeg: http://ffmpeg.org Installation ------------ First, enable the ``convert`` plugin (see :doc:`/plugins/index`). To transcode music, this plugin requires the ``ffmpeg`` command-line tool. If its executable is in your path, it will be found automatically by the plugin. Otherwise, configure the plugin to locate the executable:: convert: ffmpeg: /usr/bin/ffmpeg Usage ----- To convert a part of your collection, run ``beet convert QUERY``. This will display all items matching ``QUERY`` and ask you for confirmation before starting the conversion. The ``-a`` (or ``--album``) option causes the command to match albums instead of tracks. The ``-t`` (``--threads``) and ``-d`` (``--dest``) options allow you to specify or overwrite the respective configuration options. By default, the command places converted files into the destination directory and leaves your library pristine. To instead back up your original files into the destination directory and keep converted files in your library, use the ``-k`` (or ``--keep-new``) option. Configuration ------------- The plugin offers several configuration options, all of which live under the ``convert:`` section: * ``dest`` sets the directory the files will be converted (or copied) to. A destination is required---you either have to provide it in the config file or on the command line using the ``-d`` flag. * ``embed`` indicates whether or not to embed album art in converted items. Default: true. * If you set ``max_bitrate``, all lossy files with a higher bitrate will be transcoded and those with a lower bitrate will simply be copied. Note that this does not guarantee that all converted files will have a lower bitrate---that depends on the encoder and its configuration. * ``auto`` gives you the option to import transcoded versions of your files automatically during the ``import`` command. With this option enabled, the importer will transcode all non-MP3 files over the maximum bitrate before adding them to your library. * ``quiet`` mode prevents the plugin from announcing every file it processes. Default: false. * ``paths`` lets you specify the directory structure and naming scheme for the converted files. Use the same format as the top-level ``paths`` section (see :ref:`path-format-config`). By default, the plugin reuses your top-level path format settings. * Finally, ``threads`` determines the number of threads to use for parallel encoding. By default, the plugin will detect the number of processors available and use them all. These config options control the transcoding process: * ``format`` is the name of the audio file format to transcode to. Files that are already in the format (and are below the maximum bitrate) will not be transcoded. The plugin includes default commands for the formats MP3, AAC, ALAC, FLAC, Opus, Vorbis, and Windows Media; the default is MP3. If you want to use a different format (or customize the transcoding options), use the options below. * ``extension`` is the filename extension to be used for newly transcoded files. This is implied by the ``format`` option, but you can set it yourself if you're using a different format. * ``command`` is the command line to use to transcode audio. A default command, usually using an FFmpeg invocation, is implied by the ``format`` option. The tokens ``$source`` and ``$dest`` in the command are replaced with the paths to the existing and new file. For example, the command ``ffmpeg -i $source -y -aq 4 $dest`` transcodes to MP3 using FFmpeg at the V4 quality level. Here's an example configuration:: convert: embed: false format: aac max_bitrate: 200 dest: /home/user/MusicForPhone threads: 4 paths: default: $albumartist/$title If you have several formats you want to switch between, you can list them under the ``formats`` key and refer to them using the ``format`` option. Each key under ``formats`` should contain values for ``command`` and ``extension`` as described above:: convert: format: speex formats: speex: command: ffmpeg -i $source -y -acodec speex $dest extension: spx wav: command: ffmpeg -i $source -y -acodec pcm_s16le $dest extension: wav beets-1.3.1/docs/plugins/discogs.rst0000644000076500000240000000120612203275653020364 0ustar asampsonstaff00000000000000Discogs Plugin ============== The ``discogs`` plugin extends the autotagger's search capabilities to include matches from the `Discogs`_ database. .. _Discogs: http://discogs.com Installation ------------ First, enable the ``discogs`` plugin (see :doc:`/plugins/index`). Then, install the `discogs-client`_ library by typing:: pip install discogs-client That's it! Matches from Discogs will now show up during import alongside matches from MusicBrainz. If you have a Discogs ID for an album you want to tag, you can also enter it at the "enter Id" prompt in the importer. .. _discogs-client: https://github.com/discogs/discogs_client beets-1.3.1/docs/plugins/duplicates.rst0000644000076500000240000000547712203275653021104 0ustar asampsonstaff00000000000000Duplicates Plugin ================= This plugin adds a new command, ``duplicates`` or ``dup``, which finds and lists duplicate tracks or albums in your collection. Installation ------------ Enable the plugin by putting ``duplicates`` on your ``plugins`` line in your :doc:`config file `:: plugins: duplicates Configuration ------------- By default, the ``beet duplicates`` command lists the names of tracks in your library that are duplicates. It assumes that Musicbrainz track and album ids are unique to each track or album. That is, it lists every track or album with an ID that has been seen before in the library. You can customize the output format, count the number of duplicate tracks or albums, and list all tracks that have duplicates or just the duplicates themselves. These options can either be specified in the config file:: duplicates: format: $albumartist - $album - $title count: no album: no full: no or on the command-line:: -f FORMAT, --format=FORMAT print with custom FORMAT -c, --count count duplicate tracks or albums -a, --album show duplicate albums instead of tracks -F, --full show all versions of duplicate tracks or albums format ~~~~~~ The ``format`` option (default: :ref:`list_format_item`) lets you specify a specific format with which to print every track or album. This uses the same template syntax as beets’ :doc:`path formats `. The usage is inspired by, and therefore similar to, the :ref:`list ` command. count ~~~~~ The ``count`` option (default: false) prints a count of duplicate tracks or albums, with ``format`` hard-coded to ``$albumartist - $album - $title: $count`` or ``$albumartist - $album: $count`` (for the ``-a`` option). album ~~~~~ The ``album`` option (default: false) lists duplicate albums instead of tracks. full ~~~~ The ``full`` option (default: false) lists every track or album that has duplicates, not just the duplicates themselves. Examples -------- List all duplicate tracks in your collection:: beet duplicates List all duplicate tracks from 2008:: beet duplicates year:2008 Print out a unicode histogram of duplicate track years using `spark`_:: beet duplicates -f '$year' | spark â–†â–▆█▄▇▇▄▇▇â–█▇▆▇▂▄█â–██▂█â–â–██â–█▂▇▆▂▇█▇▇█▆▆▇█▇█▇▆██▂▇ Print out a listing of all albums with duplicate tracks, and respective counts:: beet duplicates -ac The same as the above but include the original album, and show the path:: beet duplicates -acf '$path' TODO ---- - Allow deleting duplicates. .. _spark: https://github.com/holman/spark beets-1.3.1/docs/plugins/echonest_tempo.rst0000644000076500000240000000456312203275653021756 0ustar asampsonstaff00000000000000EchoNest Tempo Plugin ===================== The ``echonest_tempo`` plugin fetches and stores a track's tempo (the "bpm" field) from the `EchoNest API`_ .. _EchoNest API: http://developer.echonest.com/ Installing Dependencies ----------------------- This plugin requires the pyechonest library in order to talk to the EchoNest API. There are packages for most major linux distributions, you can download the library from the Echo Nest, or you can install the library from `pip`_, like so:: $ pip install pyechonest .. _pip: http://pip.openplans.org/ Configuring ----------- Beets includes its own Echo Nest API key, but you can `apply for your own`_ for free from the EchoNest. To specify your own API key, add the key to your :doc:`configuration file ` as the value for ``apikey`` under the key ``echonest_tempo`` like so:: echonest_tempo: apikey: YOUR_API_KEY In addition, the ``autofetch`` config option lets you disable automatic tempo fetching during import. To do so, add this to your ``config.yaml``:: echonest_tempo: auto: no .. _apply for your own: http://developer.echonest.com/account/register Fetch Tempo During Import ------------------------- To automatically fetch the tempo for songs you import, just enable the plugin by putting ``echonest_tempo`` on your config file's ``plugins`` line (see :doc:`/plugins/index`). When importing new files, beets will now fetch the tempo for files that don't already have them. The bpm field will be stored in the beets database. If the ``import.write`` config option is on, then the tempo will also be written to the files' tags. This behavior can be disabled with the ``autofetch`` config option (see below). Fetching Tempo Manually ----------------------- The ``tempo`` command provided by this plugin fetches tempos for items that match a query (see :doc:`/reference/query`). For example, ``beet tempo magnetic fields absolutely cuckoo`` will get the tempo for the appropriate Magnetic Fields song, ``beet tempo magnetic fields`` will get tempos for all my tracks by that band, and ``beet tempo`` will get tempos for my entire library. The tempos will be added to the beets database and, if ``import.write`` is on, embedded into files' metadata. The ``-p`` option to the ``tempo`` command makes it print tempos out to the console so you can view the fetched (or previously-stored) tempos. beets-1.3.1/docs/plugins/embedart.rst0000644000076500000240000000457412203275653020527 0ustar asampsonstaff00000000000000EmbedArt Plugin =============== Typically, beets stores album art in a "file on the side": along with each album, there is a file (named "cover.jpg" by default) that stores the album art. You might want to embed the album art directly into each file's metadata. While this will take more space than the external-file approach, it is necessary for displaying album art in some media players (iPods, for example). This plugin was added in beets 1.0b8. Embedding Art Automatically --------------------------- To automatically embed discovered album art into imported files, just enable the plugin (see :doc:`/plugins/index`). You'll also want to enable the :doc:`/plugins/fetchart` to obtain the images to be embedded. Art will be embedded after each album is added to the library. This behavior can be disabled with the ``auto`` config option (see below). Manually Embedding and Extracting Art ------------------------------------- The ``embedart`` plugin provides a couple of commands for manually managing embedded album art: * ``beet embedart [-f IMAGE] QUERY``: embed images into the every track on the albums matching the query. If the ``-f`` (``--file``) option is given, then use a specific image file from the filesystem; otherwise, each album embeds its own currently associated album art. * ``beet extractart [-o FILE] QUERY``: extracts the image from an item matching the query and stores it in a file. You can specify the destination file using the ``-o`` option, but leave off the extension: it will be chosen automatically. The destination filename defaults to ``cover`` if it's not specified. * ``beet clearart QUERY``: removes all embedded images from all items matching the query. (Use with caution!) Configuring ----------- The ``auto`` option lets you disable automatic album art embedding. To do so, add this to your ``config.yaml``:: embedart: auto: no A maximum image width can be configured as ``maxwidth`` to downscale images before embedding them (the original image file is not altered). The resize operation reduces image width to ``maxwidth`` pixels. The height is recomputed so that the aspect ratio is preserved. `PIL`_ or `ImageMagick`_ is required to use the ``maxwidth`` config option. See also :ref:`image-resizing` for further caveats about image resizing. .. _PIL: http://www.pythonware.com/products/pil/ .. _ImageMagick: http://www.imagemagick.org/ beets-1.3.1/docs/plugins/fetchart.rst0000644000076500000240000000735612203275653020545 0ustar asampsonstaff00000000000000FetchArt Plugin =============== The ``fetchart`` plugin retrieves album art images from various sources on the Web and stores them as image files. Fetching Album Art During Import -------------------------------- To automatically get album art for every album you import, just enable the plugin by putting ``fetchart`` on your config file's ``plugins`` line (see :doc:`/plugins/index`). By default, beets stores album art image files alongside the music files for an album in a file called ``cover.jpg``. To customize the name of this file, use the :ref:`art-filename` config option. To disable automatic art downloading, just put this in your configuration file:: fetchart: auto: no Manually Fetching Album Art --------------------------- Use the ``fetchart`` command to download album art after albums have already been imported:: $ beet fetchart [-f] [query] By default, the command will only look for album art when the album doesn't already have it; the ``-f`` or ``--force`` switch makes it search for art regardless. If you specify a query, only matching albums will be processed; otherwise, the command processes every album in your library. .. _image-resizing: Image Resizing -------------- A maximum image width can be configured as ``maxwidth`` to downscale fetched images if they are too big. The resize operation reduces image width to ``maxwidth`` pixels. The height is recomputed so that the aspect ratio is preserved. Beets can resize images using `PIL`_, `ImageMagick`_, or a server-side resizing proxy. If either PIL or ImageMagick is installed, beets will use those; otherwise, it falls back to the resizing proxy. If the resizing proxy is used, no resizing is performed for album art found on the filesystem---only downloaded art is resized. Server-side resizing can also be slower than local resizing, so consider installing one of the two backends for better performance. When using ImageMagic, beets looks for the ``convert`` executable in your path. On some versions of Windows, the program can be shadowed by a system-provided ``convert.exe``. On these systems, you may need to modify your ``%PATH%`` environment variable so that ImageMagick comes first or use PIL instead. .. _PIL: http://www.pythonware.com/products/pil/ .. _ImageMagick: http://www.imagemagick.org/ Album Art Sources ----------------- Currently, this plugin searches for art in the local filesystem as well as on the Cover Art Archive, Amazon, and AlbumArt.org (in that order). When looking for local album art, beets checks for image files located in the same folder as the music files you're importing. Beets prefers to use an image file whose name contains "cover", "front", "art", "album" or "folder", but in the absence of well-known names, it will use any image file in the same folder as your music files. You can change the list of filename keywords using the ``cover_names`` config option. Or, to use *only* filenames containing the keywords and not fall back to any image, set ``cautious`` to true. For example:: fetchart: cautious: true cover_names: front back By default, remote (Web) art sources are only queried if no local art is found in the filesystem. To query remote sources every time, set the ``remote_priority`` configuration option to true, which will cause beets to prefer remote cover art over any local image files. When you choose to apply changes during an import, beets will search for art as described above. For "as-is" imports (and non-autotagged imports using the ``-A`` flag), beets only looks for art on the local filesystem. Embedding Album Art ------------------- This plugin fetches album art but does not embed images into files' tags. To do that, use the :doc:`/plugins/embedart`. (You'll want to have both plugins enabled.) beets-1.3.1/docs/plugins/fromfilename.rst0000644000076500000240000000110212214762361021367 0ustar asampsonstaff00000000000000FromFilename Plugin =================== The ``fromfilename`` plugin helps to tag albums that are missing tags altogether but where the filenames contain useful information like the artist and title. When you attempt to import a track that's missing a title, this plugin will look at the track's filename and guess its track number, title, and artist. These will be used to search in MusicBrainz and match track ordering. To use the plugin, just enable it by putting ``fromfilename`` on the ``plugins:`` line in your config file. There are currently no configuration options. beets-1.3.1/docs/plugins/ftintitle.rst0000644000076500000240000000133712216145443020735 0ustar asampsonstaff00000000000000FtInTitle Plugin ================ The ``ftintitle`` plugin automatically moved "featured" artists from the ``artist`` field to the ``title`` field. According to `MusicBrainz style`_, featured artists are part of the artist field. That means that, if you tag your music using MusicBrainz, you'll have tracks in your library like "Tellin' Me Things" by the artist "Blakroc feat. RZA". If you prefer to tag this as "Tellin' Me Things feat. RZA" by "Blakroc", then this plugin is for you. To use the plugin, just enable it and run the command:: $ beet ftintitle [QUERY] The query is optional; if it's left off, the transformation will be applied to your entire collection. .. _MusicBrainz style: http://musicbrainz.org/doc/Style beets-1.3.1/docs/plugins/fuzzy.rst0000644000076500000240000000146212121146403020111 0ustar asampsonstaff00000000000000Fuzzy Search Plugin =================== The ``fuzzy`` plugin provides a prefixed query that search you library using fuzzy pattern matching. This can be useful if you want to find a track with complicated characters in the title. First, enable the plugin named ``fuzzy`` (see :doc:`/plugins/index`). You'll then be able to use the ``~`` prefix to use fuzzy matching:: $ beet ls '~Vareoldur' Sigur Rós - Valtari - Varðeldur The plugin provides config options that let you choose the prefix and the threshold.:: fuzzy: threshold: 0.8 prefix: '@' A threshold value of 1.0 will show only perfect matches and a value of 0.0 will match everything. The default prefix ``~`` needs to be escaped or quoted in most shells. If this bothers you, you can change the prefix in your config file. beets-1.3.1/docs/plugins/ihate.rst0000644000076500000240000000215312224333414020016 0ustar asampsonstaff00000000000000IHate Plugin ============ The ``ihate`` plugin allows you to automatically skip things you hate during import or warn you about them. It supports album, artist and genre patterns. There also is a whitelist to avoid skipping bands you still like. There are two groups: warn and skip. The skip group is checked first. Whitelist overrides any other patterns. To use the plugin, enable it by including ``ihate`` in the ``plugins`` line of your beets config. Then, add an ``ihate:`` section to your configuration file:: ihate: # you will be warned about these suspicious genres/artists (regexps): warn_genre: rnb soul power\smetal warn_artist: bad\band another\sbad\sband warn_album: tribute\sto # if you don't like a genre in general, but accept some band playing it, # add exceptions here: warn_whitelist: hate\sexception # never import any of this: skip_genre: russian\srock polka skip_artist: manowar skip_album: christmas # but import this: skip_whitelist: '' Note: The plugin will trust your decision in 'as-is' mode. beets-1.3.1/docs/plugins/importfeeds.rst0000644000076500000240000000321212121146403021236 0ustar asampsonstaff00000000000000ImportFeeds Plugin ================== The ``importfeeds`` plugin helps you keep track of newly imported music in your library. To use the plugin, just put ``importfeeds`` on the ``plugins`` line in your :doc:`configuration file `. Then set a few options under the ``importfeeds:`` section in the config file. The ``dir`` configuration option can be set to specify another folder than the default library directory. The ``relative_to`` configuration option can be set to make the m3u paths relative to another folder than where the playlist is being written. If you're using importfeeds to generate a playlist for MPD, you should set this to the root of your music library. The ``absolute_path`` configuration option can be set to use absolute paths instead of relative paths. Some applications may need this to work properly. Three different types of outputs coexist, specify the ones you want to use by setting the ``formats`` parameter: - ``m3u``: catalog the imports in a centralized playlist. By default, the playlist is named ``imported.m3u``. To use a different file, just set the ``m3u_name`` parameter inside the ``importfeeds`` config section. - ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name). - ``link``: create a symlink for each imported item. This is the recommended setting to propagate beets imports to your iTunes library: just drag and drop the ``dir`` folder on the iTunes dock icon. Here's an example configuration for this plugin:: importfeeds: formats: m3u link dir: ~/imports/ relative_to: ~/Music/ m3u_name: newfiles.m3u beets-1.3.1/docs/plugins/index.rst0000644000076500000240000001121612220454554020040 0ustar asampsonstaff00000000000000Plugins ======= Plugins extend beets' core functionality. They add new commands, fetch additional data during import, provide new metadata sources, and much more. If beets by itself doesn't do what you want it to, you may just need to enable a plugin---or, if you want to do something new, :doc:`writing a plugin ` is easy if you know a little Python. To use one of the plugins included with beets (see below for a list), just use the `plugins` option in your `config.yaml` file, like so:: plugins: mygreatplugin someotherplugin The value for `plugins` can be a space-separated list of plugin names or a YAML list like ``[foo, bar]``. You can see which plugins are currently enabled by typing ``beet version``. .. toctree:: :hidden: chroma lyrics echonest_tempo bpd mpdupdate fetchart embedart web lastgenre replaygain inline scrub rewrite random mbcollection importfeeds the fuzzy zero ihate convert info smartplaylist mbsync missing duplicates discogs beatport fromfilename ftintitle Autotagger Extensions --------------------- * :doc:`chroma`: Use acoustic fingerprinting to identify audio files with missing or incorrect metadata. * :doc:`discogs`: Search for releases in the `Discogs`_ database. * :doc:`beatport`: Search for tracks and releases in the `Beatport`_ database. * :doc:`fromfilename`: Guess metadata for untagged tracks from their filenames. .. _Beatport: http://www.beatport.com/ .. _Discogs: http://www.discogs.com/ Metadata -------- * :doc:`lyrics`: Automatically fetch song lyrics. * :doc:`echonest_tempo`: Automatically fetch song tempos (bpm). * :doc:`lastgenre`: Fetch genres based on Last.fm tags. * :doc:`mbsync`: Fetch updated metadata from MusicBrainz * :doc:`fetchart`: Fetch album cover art from various sources. * :doc:`embedart`: Embed album art images into files' metadata. * :doc:`replaygain`: Calculate volume normalization for players that support it. * :doc:`scrub`: Clean extraneous metadata from music files. * :doc:`zero`: Nullify fields by pattern or unconditionally. * :doc:`ftintitle`: Move "featured" artists from the artist field to the title field. Path Formats ------------ * :doc:`inline`: Use Python snippets to customize path format strings. * :doc:`rewrite`: Substitute values in path formats. * :doc:`the`: Move patterns in path formats (i.e., move "a" and "the" to the end). Interoperability ---------------- * :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library changes. * :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks. * :doc:`smartplaylist`: Generate smart playlists based on beets queries. Miscellaneous ------------- * :doc:`web`: An experimental Web-based GUI for beets. * :doc:`random`: Randomly choose albums and tracks from your library. * :doc:`fuzzy`: Search albums and tracks with fuzzy string matching. * :doc:`mbcollection`: Maintain your MusicBrainz collection list. * :doc:`ihate`: Automatically skip albums and tracks during the import process. * :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is compatible with `MPD clients`_. * :doc:`convert`: Transcode music and embed album art while exporting to a different directory. * :doc:`info`: Print music files' tags to the console. * :doc:`missing`: List missing tracks. * :doc:`duplicates`: List duplicate tracks or albums. .. _MPD: http://mpd.wikia.com/ .. _MPD clients: http://mpd.wikia.com/wiki/Clients .. _other-plugins: Other Plugins ------------- In addition to the plugins that come with beets, there are several plugins that are maintained by the beets community. To use an external plugin, there are two options for installation: * Make sure it's in the Python path (known as `sys.path` to developers). This just means the plugin has to be installed on your system (e.g., with a `setup.py` script or a command like `pip` or `easy_install`). * Set the `pluginpath` config variable to point to the directory containing the plugin. (See :doc:`/reference/config`.) Once the plugin is installed, enable it by placing its name on the `plugins` line in your config file. Here are a few of the plugins written by the beets community: * `beetFs`_ is a FUSE filesystem for browsing the music in your beets library. (Might be out of date.) * `A cmus plugin`_ integrates with the `cmus`_ console music player. .. _beetFs: http://code.google.com/p/beetfs/ .. _Beet-MusicBrainz-Collection: https://github.com/jeffayle/Beet-MusicBrainz-Collection/ .. _A cmus plugin: https://github.com/coolkehon/beets/blob/master/beetsplug/cmus.py .. _cmus: http://cmus.sourceforge.net/ beets-1.3.1/docs/plugins/info.rst0000644000076500000240000000075012062243562017664 0ustar asampsonstaff00000000000000Info Plugin =========== The ``info`` plugin provides a command that dumps the current tag values for any file format supported by beets. It works like a supercharged version of `mp3info`_ or `id3v2`_. Enable the plugin and then type:: $ beet info /path/to/music.flac and the plugin will enumerate all the tags in the specified file. It also accepts multiple filenames in a single command-line. .. _id3v2: http://id3v2.sourceforge.net .. _mp3info: http://www.ibiblio.org/mp3info/ beets-1.3.1/docs/plugins/inline.rst0000644000076500000240000000465712203275653020224 0ustar asampsonstaff00000000000000Inline Plugin ============= The ``inline`` plugin lets you use Python to customize your path formats. Using it, you can define template fields in your beets configuration file and refer to them from your template strings in the ``paths:`` section (see :doc:`/reference/config/`). To use inline field definitions, first enable the plugin by putting ``inline`` on your ``plugins`` line in your configuration file. Then, make a ``item_fields:`` block in your config file. Under this key, every line defines a new template field; the key is the name of the field (you'll use the name to refer to the field in your templates) and the value is a Python expression or function body. The Python code has all of a track's fields in scope, so you can refer to any normal attributes (such as ``artist`` or ``title``) as Python variables. Here are a couple of examples of expressions:: item_fields: initial: albumartist[0].upper() + u'.' disc_and_track: u'%02i.%02i' % (disc, track) if disctotal > 1 else u'%02i' % (track) Note that YAML syntax allows newlines in values if the subsequent lines are indented. These examples define ``$initial`` and ``$disc_and_track`` fields that can be referenced in path templates like so:: paths: default: $initial/$artist/$album%aunique{}/$disc_and_track $title Block Definitions ----------------- If you need to use statements like ``import``, you can write a Python function body instead of a single expression. In this case, you'll need to ``return`` a result for the value of the path field, like so:: item_fields: filename: | import os from beets.util import bytestring_path return bytestring_path(os.path.basename(path)) You might want to use the YAML syntax for "block literals," in which a leading ``|`` character indicates a multi-line block of text. Album Fields ------------ The above examples define fields for *item* templates, but you can also define fields for *album* templates. Use the ``album_fields`` configuration section. In this context, all existing album fields are available as variables along with ``items``, which is a list of items in the album. This example defines a ``$bitrate`` field for albums as the average of the tracks' fields:: album_fields: bitrate: | total = 0 for item in items: total += item.bitrate return total / len(items) beets-1.3.1/docs/plugins/lastgenre.rst0000644000076500000240000000722612224317242020717 0ustar asampsonstaff00000000000000LastGenre Plugin ================ The MusicBrainz database `does not contain genre information`_. Therefore, when importing and autotagging music, beets does not assign a genre. The ``lastgenre`` plugin fetches *tags* from `Last.fm`_ and assigns them as genres to your albums and items. The plugin is included with beets as of version 1.0b11. .. _does not contain genre information: http://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F .. _Last.fm: http://last.fm/ The plugin requires `pylast`_, which you can install using `pip`_ by typing:: pip install pylast After you have pylast installed, enable the plugin by putting ``lastgenre`` on your ``plugins`` line in :doc:`config file `. The plugin chooses genres based on a *whitelist*, meaning that only certain tags can be considered genres. This way, tags like "my favorite music" or "seen live" won't be considered genres. The plugin ships with a fairly extensive internal whitelist, but you can set your own in the config file using the ``whitelist`` configuration value:: lastgenre: whitelist: /path/to/genres.txt The genre list file should contain one genre per line. Blank lines are ignored. For the curious, the default genre list is generated by a `script that scrapes Wikipedia`_. .. _pip: http://www.pip-installer.org/ .. _pylast: http://code.google.com/p/pylast/ .. _script that scrapes Wikipedia: https://gist.github.com/1241307 By default, beets will always fetch new genres, even if the files already have once. To instead leave genres in place in when they pass the whitelist, set the ``force`` option to "no". If no genre is found, the file will be left unchanged. To instead specify a fallback genre, use the ``fallback`` configuration option. You can, of course, use the empty string as a fallback, like so:: lastgenre: fallback: '' Canonicalization ---------------- The plugin can also *canonicalize* genres, meaning that more obscure genres can be turned into coarser-grained ones that are present in the whitelist. This works using a tree of nested genre names, represented using `YAML`_, where the leaves of the tree represent the most specific genres. To enable canonicalization, set the ``canonical`` configuration value:: lastgenre: canonical: '' Setting this value to the empty string will use a built-in canonicalization tree. You can also set it to a path, just like the ``whitelist`` config value, to use your own tree. .. _YAML: http://www.yaml.org/ Genre Source ------------ When looking up genres for albums or individual tracks, you can choose whether to use Last.fm tags on the album, the artist, or the track. For example, you might want all the albums for a certain artist to carry the same genre. Set the ``source`` configuration value to "album", "track", or "artist", like so:: lastgenre: source: artist The default is "album". When set to "track", the plugin will fetch *both* album-level and track-level genres for your music when importing albums. Multiple Genres --------------- By default, the plugin chooses the most popular tag on Last.fm as a genre. If you prefer to use a list of *all available* genre tags, turn on the ``multiple`` config option:: lastgenre: multiple: true Comma-separated lists of genres will then be used instead of single genres. Running Manually ---------------- In addition to running automatically on import, the plugin can also run manually from the command line. Use the command ``beet lastgenre [QUERY]`` to fetch genres for albums matching a certain query. To disable automatic genre fetching on import, set the ``auto`` config option to false. beets-1.3.1/docs/plugins/lyrics.rst0000644000076500000240000000602712203275653020244 0ustar asampsonstaff00000000000000Lyrics Plugin ============= The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web. Namely, the current version of the plugin uses `Lyric Wiki`_, `Lyrics.com`_, and, optionally, the Google custom search API. .. _Lyric Wiki: http://lyrics.wikia.com/ .. _Lyrics.com: http://www.lyrics.com/ Fetch Lyrics During Import -------------------------- To automatically fetch lyrics for songs you import, just enable the plugin by putting ``lyrics`` on your config file's ``plugins`` line (see :doc:`/plugins/index`). When importing new files, beets will now fetch lyrics for files that don't already have them. The lyrics will be stored in the beets database. If the ``import.write`` config option is on, then the lyrics will also be written to the files' tags. This behavior can be disabled with the ``auto`` config option (see below). Fetching Lyrics Manually ------------------------ The ``lyrics`` command provided by this plugin fetches lyrics for items that match a query (see :doc:`/reference/query`). For example, ``beet lyrics magnetic fields absolutely cuckoo`` will get the lyrics for the appropriate Magnetic Fields song, ``beet lyrics magnetic fields`` will get lyrics for all my tracks by that band, and ``beet lyrics`` will get lyrics for my entire library. The lyrics will be added to the beets database and, if ``import.write`` is on, embedded into files' metadata. The ``-p`` option to the ``lyrics`` command makes it print lyrics out to the console so you can view the fetched (or previously-stored) lyrics. Configuring ----------- To disable automatic lyric fetching during import, set the ``auto`` option to false, like so:: lyrics: auto: no By default, if no lyrics are found, the file will be left unchanged. To specify a placeholder for the lyrics tag when none are found, use the ``fallback`` configuration option:: lyrics: fallback: 'No lyrics found' .. _activate-google-custom-search: Activate Google custom search ------------------------------ Using the Google backend requires `BeautifulSoup`_, which you can install using `pip`_ by typing:: pip install beautifulsoup4 You also need to `register for a Google API key`_. Set the ``google_API_key`` configuration option to your key. This enables the Google backend. .. _register for a Google API key: https://code.google.com/apis/console. Optionally, you can `define a custom search engine`_. Get your search engine's token and use it for your ``google_engine_ID`` configuration option. By default, beets use a list of sources known to be scrapeable. .. _define a custom search engine: http://www.google.com/cse/all Here's an example of ``config.yaml``:: lyrics: google_API_key: AZERTYUIOPQSDFGHJKLMWXCVBN1234567890_ab google_engine_ID: 009217259823014548361:lndtuqkycfu Note that the Google custom search API is limited to 100 queries per day. After that, the lyrics plugin will fall back on its other data sources. .. _pip: http://www.pip-installer.org/ .. _BeautifulSoup: http://www.crummy.com/software/BeautifulSoup/bs4/doc/ beets-1.3.1/docs/plugins/mbcollection.rst0000644000076500000240000000140712121146403021373 0ustar asampsonstaff00000000000000MusicBrainz Collection Plugin ============================= The ``mbcollection`` plugin lets you submit your catalog to MusicBrainz to maintain your `music collection`_ list there. .. _music collection: http://musicbrainz.org/doc/Collections To begin, just enable the ``mbcollection`` plugin (see :doc:`/plugins/index`). Then, add your MusicBrainz username and password to your :doc:`configuration file ` under a ``musicbrainz`` section:: musicbrainz: user: you pass: seekrit Then, use the ``beet mbupdate`` command to send your albums to MusicBrainz. The command automatically adds all of your albums to the first collection it finds. If you don't have a MusicBrainz collection yet, you may need to add one to your profile first. beets-1.3.1/docs/plugins/mbsync.rst0000644000076500000240000000303212121146403020210 0ustar asampsonstaff00000000000000MBSync Plugin ============= This plugin provides the ``mbsync`` command, which lets you fetch metadata from MusicBrainz for albums and tracks that already have MusicBrainz IDs. This is useful for updating tags as they are fixed in the MusicBrainz database, or when you change your mind about some config options that change how tags are written to files. If you have a music library that is already nicely tagged by a program that also uses MusicBrainz like Picard, this can speed up the initial import if you just import "as-is" and then use ``mbsync`` to get up-to-date tags that are written to the files according to your beets configuration. Usage ----- Enable the plugin and then run ``beet mbsync QUERY`` to fetch updated metadata for a part of your collection (or omit the query to run over your whole library). This plugin treats albums and singletons (non-album tracks) separately. It first processes all matching singletons and then proceeds on to full albums. The same query is used to search for both kinds of entities. The command has a few command-line options: * To preview the changes that would be made without applying them, use the ``-p`` (``--pretend``) flag. * By default, files will be moved (renamed) according to their metadata if they are inside your beets library directory. To disable this, use the ``-M`` (``--nomove``) command-line option. * If you have the `import.write` configuration option enabled, then this plugin will write new metadata to files' tags. To disable this, use the ``-W`` (``--nowrite``) option. beets-1.3.1/docs/plugins/missing.rst0000644000076500000240000000514012203275653020403 0ustar asampsonstaff00000000000000Missing Plugin ============== This plugin adds a new command, ``missing`` or ``miss``, which finds and lists, for every album in your collection, which or how many tracks are missing. Listing missing files requires one network call to MusicBrainz. Merely counting missing files avoids any network calls. Installation ------------ Enable the plugin by putting ``missing`` on your ``plugins`` line in :doc:`config file `:: plugins: missing ... Configuration ------------- By default, the ``beet missing`` command lists the names of tracks that your library is missing from each album. You can customize the output format, count the number of missing tracks per album, or total up the number of missing tracks over your whole library. These options can either be specified in the config file:: missing: format: $albumartist - $album - $title count: no total: no or on the command-line:: -f FORMAT, --format=FORMAT print with custom FORMAT -c, --count count missing tracks per album -t, --total count total of missing tracks format ~~~~~~ The ``format`` option (default: :ref:`list_format_item`) lets you specify a specific format with which to print every track. This uses the same template syntax as beets’ :doc:`path formats `. The usage is inspired by, and therefore similar to, the :ref:`list ` command. count ~~~~~ The ``count`` option (default: false) prints a count of missing tracks per album, with ``format`` defaulting to ``$albumartist - $album: $missing``. total ~~~~~ The ``total`` option (default: false) prints a single count of missing tracks in all albums Template Fields --------------- With this plugin enabled, the ``$missing`` template field expands to the number of tracks missing from each album. Examples -------- List all missing tracks in your collection:: beet missing List all missing tracks from 2008:: beet missing year:2008 Print out a unicode histogram of the missing track years using `spark`_:: beet missing -f '$year' | spark â–†â–▆█▄▇▇▄▇▇â–█▇▆▇▂▄█â–██▂█â–â–██â–█▂▇▆▂▇█▇▇█▆▆▇█▇█▇▆██▂▇ Print out a listing of all albums with missing tracks, and respective counts:: beet missing -c Print out a count of the total number of missing tracks:: beet missing -t Call this plugin from other beet commands:: beet ls -a -f '$albumartist - $album: $missing' TODO ---- - Add caching. .. _spark: https://github.com/holman/spark beets-1.3.1/docs/plugins/mpdupdate.rst0000644000076500000240000000170312220136315020704 0ustar asampsonstaff00000000000000MPDUpdate Plugin ================ ``mpdupdate`` is a very simple plugin for beets that lets you automatically update `MPD`_'s index whenever you change your beets library. .. _MPD: http://mpd.wikia.com/wiki/Music_Player_Daemon_Wiki To use it, enable it in your ``config.yaml`` by putting ``mpdupdate`` on your ``plugins`` line. Then, you'll probably want to configure the specifics of your MPD server. You can do that using an ``mpdupdate:`` section in your ``config.yaml``, which looks like this:: mpdupdate: host: localhost port: 6600 password: seekrit With that all in place, you'll see beets send the "update" command to your MPD server every time you change your beets library. If you want to communicate with MPD over a Unix domain socket instead over TCP, just give the path to the socket in the filesystem for the ``host`` setting. (Any ``host`` value starting with a slash or a tilde is interpreted as a domain socket.) beets-1.3.1/docs/plugins/random.rst0000644000076500000240000000204012203275653020206 0ustar asampsonstaff00000000000000Random Plugin ============= The ``random`` plugin provides a command that randomly selects tracks or albums from your library. This can be helpful if you need some help deciding what to listen to. First, enable the plugin named ``random`` (see :doc:`/plugins/index`). You'll then be able to use the ``beet random`` command:: $ beet random Aesop Rock - None Shall Pass - The Harbor Is Yours The command has several options that resemble those for the ``beet list`` command (see :doc:`/reference/cli`). To choose an album instead of a single track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and to use a custom format for printing, use ``-f FORMAT``. If the ``-e`` option is passed, the random choice will be even among artists (the albumartist field). This makes sure that your anthology of Bob Dylan won't make you listen to Bob Dylan 50% of the time. The ``-n NUMBER`` option controls the number of objects that are selected and printed (default 1). To select 5 tracks from your library, type ``beet random -n5``. beets-1.3.1/docs/plugins/replaygain.rst0000644000076500000240000000635012102026773021064 0ustar asampsonstaff00000000000000ReplayGain Plugin ================= This plugin adds support for `ReplayGain`_, a technique for normalizing audio playback levels. .. _ReplayGain: http://wiki.hydrogenaudio.org/index.php?title=ReplayGain Installation ------------ This plugin uses the `mp3gain`_ command-line tool or the `aacgain`_ fork thereof. To get started, install this tool: * On Mac OS X, you can use `Homebrew`_. Type ``brew install aacgain``. * On Linux, `mp3gain`_ is probably in your repositories. On Debian or Ubuntu, for example, you can run ``apt-get install mp3gain``. * On Windows, download and install the original `mp3gain`_. .. _mp3gain: http://mp3gain.sourceforge.net/download.php .. _aacgain: http://aacgain.altosdesign.com .. _Homebrew: http://mxcl.github.com/homebrew/ Then enable the ``replaygain`` plugin (see :doc:`/reference/config`). If beets doesn't automatically find the ``mp3gain`` or ``aacgain`` executable, you can configure the path explicitly like so:: replaygain: command: /Applications/MacMP3Gain.app/Contents/Resources/aacgain Usage & Configuration --------------------- The plugin will automatically analyze albums and individual tracks as you import them. It writes tags to each file according to the `ReplayGain`_ specification; if your player supports these tags, it can use them to do level adjustment. By default, files that already have ReplayGain tags will not be re-analyzed. If you want to analyze *every* file on import, you can set the ``overwrite`` option for the plugin in your :doc:`configuration file `, like so:: replaygain: overwrite: yes The target level can be modified to any target dB with the ``targetlevel`` option (default: 89 dB). When analyzing albums, this plugin can calculates an "album gain" alongside individual track gains. Album gain normalizes an entire album's loudness while allowing the dynamics from song to song on the album to remain intact. This is especially important for classical music albums with large loudness ranges. Players can choose which gain (track or album) to honor. By default, only per-track gains are used; to calculate album gain also, set the ``albumgain`` option to ``yes``. If you use a player that does not support ReplayGain specifications, you can force the volume normalization by applying the gain to the file via the ``apply`` option. This is a lossless and reversible operation with no transcoding involved. The use of ReplayGain can cause clipping if the average volume of a song is below the target level. By default, a "prevent clipping" option named ``noclip`` is enabled to reduce the amount of ReplayGain adjustment to whatever amount would keep clipping from occurring. Manual Analysis --------------- By default, the plugin will analyze all items an albums as they are implemented. However, you can also manually analyze files that are already in your library. Use the ``beet replaygain`` command:: $ beet replaygain [-a] [QUERY] The ``-a`` flag analyzes whole albums instead of individual tracks. Provide a query (see :doc:`/reference/query`) to indicate which items or albums to analyze. ReplayGain analysis is not fast, so you may want to disable it during import. Use the ``auto`` config option to control this:: replaygain: auto: no beets-1.3.1/docs/plugins/rewrite.rst0000644000076500000240000000272612102026773020415 0ustar asampsonstaff00000000000000Rewrite Plugin ============== The ``rewrite`` plugin lets you easily substitute values in your path formats. Specifically, it is intended to let you *canonicalize* names such as artists: for example, perhaps you want albums from The Jimi Hendrix Experience to be sorted into the same folder as solo Hendrix albums. To use field rewriting, first enable the plugin by putting ``rewrite`` on your ``plugins`` line. Then, make a ``rewrite:`` section in your config file to contain your rewrite rules. Each rule consists of a field name, a regular expression pattern, and a replacement value. Rules are written ``fieldname regex: replacement``. For example, this line implements the Jimi Hendrix example above:: rewrite: artist The Jimi Hendrix Experience: Jimi Hendrix This will make ``$artist`` in your path formats expand to "Jimi Henrix" where it would otherwise be "The Jimi Hendrix Experience". The pattern is a case-insensitive regular expression. This means you can use ordinary regular expression syntax to match multiple artists. For example, you might use:: rewrite: artist .*jimi hendrix.*: Jimi Hendrix As a convenience, the plugin applies patterns for the ``artist`` field to the ``albumartist`` field as well. (Otherwise, you would probably want to duplicate every rule for ``artist`` and ``albumartist``.) Note that this plugin only applies to path templating; it does not modify files' metadata tags or the values tracked by beets' library database. beets-1.3.1/docs/plugins/scrub.rst0000644000076500000240000000312112203275653020045 0ustar asampsonstaff00000000000000Scrub Plugin ============= The ``scrub`` plugin lets you remove extraneous metadata from files' tags. If you'd prefer never to see crufty tags that come from other tools, the plugin can automatically remove all non-beets-tracked tags whenever a file's metadata is written to disk by removing the tag entirely before writing new data. The plugin also provides a command that lets you manually remove files' tags. Automatic Scrubbing ------------------- To automatically remove files' tags before writing new ones, just enable the plugin (see :doc:`/plugins/index`). When importing new files (with ``import.write`` turned on) or modifying files' tags with the ``beet modify`` command, beets will first strip all types of tags entirely and then write the database-tracked metadata to the file. This behavior can be disabled with the ``auto`` config option (see below). Manual Scrubbing ---------------- The ``scrub`` command provided by this plugin removes tags from files and then rewrites their database-tracked metadata. To run it, just type ``beet scrub QUERY`` where ``QUERY`` matches the tracks to be scrubbed. Use this command with caution, however, because any information in the tags that is out of sync with the database will be lost. The ``-W`` (or ``--nowrite``) option causes the command to just remove tags but not restore any information. This will leave the files with no metadata whatsoever. Configuring ----------- The plugin has one configuration option, ``auto``, which lets you disable automatic metadata stripping. To do so, add this to your ``config.yaml``:: scrub: auto: no beets-1.3.1/docs/plugins/smartplaylist.rst0000644000076500000240000000442712203275653021651 0ustar asampsonstaff00000000000000Smart Playlist Plugin ===================== ``smartplaylist`` is a plugin to generate smart playlists in m3u format based on beets queries every time your library changes. This plugin is specifically created to work well with `MPD's`_ playlist functionality. .. _MPD's: http://mpd.wikia.com/wiki/Music_Player_Daemon_Wiki To use it, enable the plugin by putting ``smartplaylist`` in the ``plugins`` section in your ``config.yaml``. Then configure your smart playlists like the following example:: smartplaylist: relative_to: ~/Music playlist_dir: ~/.mpd/playlists playlists: - query: '' name: all.m3u - query: 'artist:Beatles' name: beatles.m3u If you intend to use this plugin to generate playlists for MPD, you should set ``relative_to`` to your MPD music directory (by default, ``relative_to`` is ``None``, and the absolute paths to your music files will be generated). ``playlist_dir`` is where the generated playlist files will be put. You can generate as many playlists as you want by adding them to the ``playlists`` section, using beets query syntax (see :doc:`/reference/query`) for ``query`` and the file name to be generated for ``name``. The query will be split using shell-like syntax, so if you need to use spaces in the query, be sure to quote them (e.g., ``artist:"The Beatles"``). If you have existing files with the same names, you should back them up---they will be overwritten when the plugin runs. For more advanced usage, you can use template syntax (see :doc:`/reference/pathformat/`) in the ``name`` field. For example:: - query: 'year::201(0|1)' name: 'ReleasedIn$year.m3u' This will query all the songs in 2010 and 2011 and generate the two playlist files `ReleasedIn2010.m3u` and `ReleasedIn2011.m3u` using those songs. By default, all playlists are regenerated after every beets command that changes the library database. To force regeneration, you can invoke it manually from the command line:: $ beet splupdate which will generate your new smart playlists. You can also use this plugin together with the :doc:`mpdupdate`, in order to automatically notify MPD of the playlist change, by adding ``mpdupdate`` to the ``plugins`` line in your config file *after* the ``smartplaylist`` plugin. beets-1.3.1/docs/plugins/the.rst0000644000076500000240000000275612121146403017511 0ustar asampsonstaff00000000000000The Plugin ========== The ``the`` plugin allows you to move patterns in path formats. It's suitable, for example, for moving articles from string start to the end. This is useful for quick search on filesystems and generally looks good. Plugin DOES NOT change tags. By default plugin supports English "the, a, an", but custom regexp patterns can be added by user. How it works:: The Something -> Something, The A Band -> Band, A An Orchestra -> Orchestra, An To use plugin, enable it by including ``the`` into ``plugins`` line of your beets config. The plugin provides a template function called ``%the`` for use in path format expressions:: paths: default: %the{$albumartist}/($year) $album/$track $title The default configuration moves all English articles to the end of the string, but you can override these defaults to make more complex changes:: the: # handle "The" (on by default) the: yes # handle "A/An" (on by default) a: yes # format string, {0} - part w/o article, {1} - article # spaces already trimmed from ends of both parts # default is '{0}, {1}' format: '{0}, {1}' # strip instead of moving to the end, default is off strip: no # custom regexp patterns, space-separated patterns: ... Custom patterns are case-insensitive regular expressions. Patterns can be matched anywhere in the string (not just the beginning), so use ``^`` if you intend to match leading words. beets-1.3.1/docs/plugins/web.rst0000644000076500000240000000436712102026773017514 0ustar asampsonstaff00000000000000Web Plugin ========== The ``web`` plugin is a very basic alternative interface to beets that supplements the CLI. It can't do much right now, and the interface is a little clunky, but you can use it to query and browse your music and---in browsers that support HTML5 Audio---you can even play music. While it's not meant to replace the CLI, a graphical interface has a number of advantages in certain situations. For example, when editing a tag, a natural CLI makes you retype the whole thing---common GUI conventions can be used to just edit the part of the tag you want to change. A graphical interface could also drastically increase the number of people who can use beets. Install ------- The Web interface depends on `Flask`_. To get it, just run ``pip install flask``. .. _Flask: http://flask.pocoo.org/ Put ``web`` on your ``plugins`` line in your configuration file to enable the plugin. Run the Server -------------- Then just type ``beet web`` to start the server and go to http://localhost:8337/. This is what it looks like: .. image:: beetsweb.png You can also specify the hostname and port number used by the Web server. These can be specified on the command line or in the ``[web]`` section of your :doc:`configuration file `. On the command line, use ``beet web [HOSTNAME] [PORT]``. In the config file, use something like this:: web: host: 127.0.0.1 port: 8888 Usage ----- Type queries into the little search box. Double-click a track to play it with `HTML5 Audio`_. .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html Implementation -------------- The Web backend is built using a simple REST+JSON API with the excellent `Flask`_ library. The frontend is a single-page application written with `Backbone.js`_. This allows future non-Web clients to use the same backend API. .. _Flask: http://flask.pocoo.org/ .. _Backbone.js: http://documentcloud.github.com/backbone/ Eventually, to make the Web player really viable, we should use a Flash fallback for unsupported formats/browsers. There are a number of options for this: * `audio.js`_ * `html5media`_ * `MediaElement.js`_ .. _audio.js: http://kolber.github.com/audiojs/ .. _html5media: http://html5media.info/ .. _MediaElement.js: http://mediaelementjs.com/ beets-1.3.1/docs/plugins/zero.rst0000644000076500000240000000206712203275653017716 0ustar asampsonstaff00000000000000Zero Plugin =========== The ``zero`` plugin allows you to null fields in files' metadata tags. Fields can be nulled unconditionally or conditioned on a pattern match. For example, the plugin can strip useless comments like "ripped by MyGreatRipper." This plugin only affects files' tags; the beets database is unchanged. To use the plugin, enable it by including ``zero`` in the ``plugins`` line of your configuration file. To configure the plugin, use a ``zero:`` section in your configuration file. Set ``fields`` to the (whitespace-separated) list of fields to change. You can get the list of available fields by running ``beet fields``. To conditionally filter a field, use ``field: [regexp, regexp]`` to specify regular expressions. For example:: zero: fields: month day genre comments comments: [EAC, LAME, from.+collection, 'ripped by'] genre: [rnb, 'power metal'] If a custom pattern is not defined for a given field, the field will be nulled unconditionally. Note that the plugin currently does not zero fields when importing "as-is". beets-1.3.1/docs/reference/0000755000076500000240000000000012226377756016471 5ustar asampsonstaff00000000000000beets-1.3.1/docs/reference/cli.rst0000644000076500000240000002567312203275653017773 0ustar asampsonstaff00000000000000Command-Line Interface ====================== .. only:: man SYNOPSIS -------- | **beet** [*args*...] *command* [*args*...] | **beet help** *command* .. only:: html **beet** is the command-line interface to beets. You invoke beets by specifying a *command*, like so:: beet COMMAND [ARGS...] The rest of this document describes the available commands. If you ever need a quick list of what's available, just type ``beet help`` or ``beet help COMMAND`` or help with a specific command. Commands -------- .. _import-cmd: import `````` :: beet import [-CWAPRqst] [-l LOGPATH] DIR... beet import [options] -L QUERY Add music to your library, attempting to get correct tags for it from MusicBrainz. Point the command at a directory full of music. The directory can be a single album or a directory whose leaf subdirectories are albums (the latter case is true of typical Artist/Album organizations and many people's "downloads" folders). The music will be copied to a configurable directory structure (see below) and added to a library database (see below). The command is interactive and will try to get you to verify MusicBrainz tags that it thinks are suspect. (This means that importing a large amount of music is therefore very tedious right now; this is something we need to work on. Read the :doc:`autotagging guide ` if you need help.) * By default, the command copies files your the library directory and updates the ID3 tags on your music. If you'd like to leave your music files untouched, try the ``-C`` (don't copy) and ``-W`` (don't write tags) options. You can also disable this behavior by default in the configuration file (below). * Also, you can disable the autotagging behavior entirely using ``-A`` (don't autotag)---then your music will be imported with its existing metadata. * During a long tagging import, it can be useful to keep track of albums that weren't tagged successfully---either because they're not in the MusicBrainz database or because something's wrong with the files. Use the ``-l`` option to specify a filename to log every time you skip an album or import it "as-is" or an album gets skipped as a duplicate. * Relatedly, the ``-q`` (quiet) option can help with large imports by autotagging without ever bothering to ask for user input. Whenever the normal autotagger mode would ask for confirmation, the quiet mode pessimistically skips the album. The quiet mode also disables the tagger's ability to resume interrupted imports. * Speaking of resuming interrupted imports, the tagger will prompt you if it seems like the last import of the directory was interrupted (by you or by a crash). If you want to skip this prompt, you can say "yes" automatically by providing ``-p`` or "no" using ``-P``. The resuming feature can be disabled by default using a configuration option (see below). * If you want to import only the *new* stuff from a directory, use the ``-i`` option to run an *incremental* import. With this flag, beets will keep track of every directory it ever imports and avoid importing them again. This is useful if you have an "incoming" directory that you periodically add things to. To get this to work correctly, you'll need to use an incremental import *every time* you run an import on the directory in question---including the first time, when no subdirectories will be skipped. So consider enabling the ``incremental`` configuration option. * By default, beets will proceed without asking if it finds a very close metadata match. To disable this and have the importer ask you every time, use the ``-t`` (for *timid*) option. * The importer typically works in a whole-album-at-a-time mode. If you instead want to import individual, non-album tracks, use the *singleton* mode by supplying the ``-s`` option. * If you have an album that's split across several directories under a common top directory, use the ``--flat`` option. This takes all the music files under the directory (recursively) and treats them as a single large album instead of as one album per directory. This can help with your more stubborn multi-disc albums. .. only:: html Reimporting ^^^^^^^^^^^ The ``import`` command can also be used to "reimport" music that you've already added to your library. This is useful when you change your mind about some selections you made during the initial import, or if you prefer to import everything "as-is" and then correct tags later. Just point the ``beet import`` command at a directory of files that are already catalogged in your library. Beets will automatically detect this situation and avoid duplicating any items. In this situation, the "copy files" option (``-c``/``-C`` on the command line or ``copy`` in the config file) has slightly different behavior: it causes files to be *moved*, rather than duplicated, if they're already in your library. (The same is true, of course, if ``move`` is enabled.) That is, your directory structure will be updated to reflect the new tags if copying is enabled; you never end up with two copies of the file. The ``-L`` (``--library``) flag is also useful for retagging. Instead of listing paths you want to import on the command line, specify a :doc:`query string ` that matches items from your library. In this case, the ``-s`` (singleton) flag controls whether the query matches individual items or full albums. If you want to retag your whole library, just supply a null query, which matches everything: ``beet import -L`` Note that, if you just want to update your files' tags according to changes in the MusicBrainz database, the :doc:`/plugins/mbsync` is a better choice. Reimporting uses the full matching machinery to guess metadata matches; ``mbsync`` just relies on MusicBrainz IDs. .. _list-cmd: list ```` :: beet list [-apf] QUERY :doc:`Queries ` the database for music. Want to search for "Gronlandic Edit" by of Montreal? Try ``beet list gronlandic``. Maybe you want to see everything released in 2009 with "vegetables" in the title? Try ``beet list year:2009 title:vegetables``. (Read more in :doc:`query`.) You can use the ``-a`` switch to search for albums instead of individual items. In this case, the queries you use are restricted to album-level fields: for example, you can search for ``year:1969`` but query parts for item-level fields like ``title:foo`` will be ignored. Remember that ``artist`` is an item-level field; ``albumartist`` is the corresponding album field. The ``-p`` option makes beets print out filenames of matched items, which might be useful for piping into other Unix commands (such as `xargs`_). Similarly, the ``-f`` option lets you specify a specific format with which to print every album or track. This uses the same template syntax as beets' :doc:`path formats `. For example, the command ``beet ls -af '$album: $tracktotal' beatles`` prints out the number of tracks on each Beatles album. In Unix shells, remember to enclose the template argument in single quotes to avoid environment variable expansion. .. _xargs: http://en.wikipedia.org/wiki/Xargs .. _remove-cmd: remove `````` :: beet remove [-ad] QUERY Remove music from your library. This command uses the same :doc:`query ` syntax as the ``list`` command. You'll be shown a list of the files that will be removed and asked to confirm. By default, this just removes entries from the library database; it doesn't touch the files on disk. To actually delete the files, use ``beet remove -d``. .. _modify-cmd: modify `````` :: beet modify [-MWay] QUERY FIELD=VALUE... Change the metadata for items or albums in the database. Supply a :doc:`query ` matching the things you want to change and a series of ``field=value`` pairs. For example, ``beet modify genius of love artist="Tom Tom Club"`` will change the artist for the track "Genius of Love." The ``-a`` switch operates on albums instead of individual tracks. Items will automatically be moved around when necessary if they're in your library directory, but you can disable that with ``-M``. Tags will be written to the files according to the settings you have for imports, but these can be overridden with ``-w`` (write tags, the default) and ``-W`` (don't write tags). Finally, this command politely asks for your permission before making any changes, but you can skip that prompt with the ``-y`` switch. .. _move-cmd: move ```` :: beet move [-ca] [-d DIR] QUERY Move or copy items in your library. This command, by default, acts as a library consolidator: items matching the query are renamed into your library directory structure. By specifying a destination directory with ``-d`` manually, you can move items matching a query anywhere in your filesystem. The ``-c`` option copies files instead of moving them. As with other commands, the ``-a`` option matches albums instead of items. .. _update-cmd: update `````` :: beet update [-aM] QUERY Update the library (and, optionally, move files) to reflect out-of-band metadata changes and file deletions. This will scan all the matched files and read their tags, populating the database with the new values. By default, files will be renamed according to their new metadata; disable this with ``-M``. To perform a "dry run" of an update, just use the ``-p`` (for "pretend") flag. This will show you all the proposed changes but won't actually change anything on disk. When an updated track is part of an album, the album-level fields of *all* tracks from the album are also updated. (Specifically, the command copies album-level data from the first track on the album and applies it to the rest of the tracks.) This means that, if album-level fields aren't identical within an album, some changes shown by the ``update`` command may be overridden by data from other tracks on the same album. This means that running the ``update`` command multiple times may show the same changes being applied. .. _stats-cmd: stats ````` :: beet stats [-e] [QUERY] Show some statistics on your entire library (if you don't provide a :doc:`query `) or the matched items (if you do). The ``-e`` (``--exact``) option makes the calculation of total file size more accurate but slower. .. _fields-cmd: fields `````` :: beet fields Show the item and album metadata fields available for use in :doc:`query` and :doc:`pathformat`. Includes any template fields provided by plugins. Global Flags ------------ Beets has a few "global" flags that affect all commands. These must appear between the executable name (``beet``) and the command: for example, ``beet -v import ...``. * ``-l LIBPATH``: specify the library database file to use. * ``-d DIRECTORY``: specify the library root directory. * ``-v``: verbose mode; prints out a deluge of debugging information. Please use this flag when reporting bugs. .. only:: man See Also -------- ``http://beets.readthedocs.org/`` :manpage:`beetsconfig(5)` beets-1.3.1/docs/reference/config.rst0000644000076500000240000004440212224423561020454 0ustar asampsonstaff00000000000000Configuration ============= Beets has an extensive configuration system that lets you customize nearly every aspect of its operation. To configure beets, you'll edit a file called ``config.yaml``. The location of this file depends on your OS: * On Unix-like OSes (including OS X), you want ``~/.config/beets/config.yaml``. * On Windows, use ``%APPDATA%\beets\config.yaml``. This is usually in a directory like ``C:\Users\You\AppData\Roaming``. * On OS X, you can also use ``~/Library/Application Support/beets/config.yaml`` if you prefer that over the Unix-like ``~/.config``. * If you prefer a different location, set the ``BEETSDIR`` environment variable to a path; beets will then look for a ``config.yaml`` in that directory. The config file uses `YAML`_ syntax. You can use the full power of YAML, but most configuration options are simple key/value pairs. This means your config file will look like this:: option: value another_option: foo bigger_option: key: value foo: bar In YAML, you will need to use spaces (not tabs!) to indent some lines. If you have questions about more sophisticated syntax, take a look at the `YAML`_ documentation. .. _YAML: http://yaml.org/ Global Options -------------- These options control beets' global operation. library ~~~~~~~ Path to the beets library file. By default, beets will use a file called ``library.db`` alongside your configuration file. directory ~~~~~~~~~ The directory to which files will be copied/moved when adding them to the library. Defaults to a folder called ``Music`` in your home directory. plugins ~~~~~~~ A space-separated list of plugin module names to load. For instance, beets includes the BPD plugin for playing music. pluginpath ~~~~~~~~~~ Directories to search for plugins. These paths are just added to ``sys.path`` before the plugins are loaded. (The plugins still have to be contained in a ``beetsplug`` namespace package.) This can either be a single string or a list of strings---so, if you have multiple paths, format them as a YAML list like so:: pluginpath: - /path/one - /path/two ignore ~~~~~~ A list of glob patterns specifying file and directory names to be ignored when importing. By default, this consists of ``.*``, ``*~``, and ``System Volume Information`` (i.e., beets ignores Unix-style hidden files, backup files, and a directory that appears at the root of some Windows filesystems). .. _replace: replace ~~~~~~~ A set of regular expression/replacement pairs to be applied to all filenames created by beets. Typically, these replacements are used to avoid confusing problems or errors with the filesystem (for example, leading dots, which hide files on Unix, and trailing whitespace, which is illegal on Windows). To override these substitutions, specify a mapping from regular expression to replacement strings. For example, ``[xy]: z`` will make beets replace all instances of the characters ``x`` or ``y`` with the character ``z``. If you do change this value, be certain that you include at least enough substitutions to avoid causing errors on your operating system. Here are the default substitutions used by beets, which are sufficient to avoid unexpected behavior on all popular platforms:: replace: '[\\/]': _ '^\.': _ '[\x00-\x1f]': _ '[<>:"\?\*\|]': _ '\.$': _ '\s+$': '' These substitutions remove forward and back slashes, leading dots, and control characters—all of which is a good idea on any OS. The fourth line removes the Windows "reserved characters" (useful even on Unix for for compatibility with Windows-influenced network filesystems like Samba). Trailing dots and trailing whitespace, which can cause problems on Windows clients, are also removed. .. _art-filename: art_filename ~~~~~~~~~~~~ When importing album art, the name of the file (without extension) where the cover art image should be placed. This is a template string, so you can use any of the syntax available to :doc:`/reference/pathformat`. Defaults to ``cover`` (i.e., images will be named ``cover.jpg`` or ``cover.png`` and placed in the album's directory). threaded ~~~~~~~~ Either ``yes`` or ``no``, indicating whether the autotagger should use multiple threads. This makes things faster but may behave strangely. Defaults to ``yes``. color ~~~~~ Either ``yes`` or ``no``; whether to use color in console output (currently only in the ``import`` command). Turn this off if your terminal doesn't support ANSI colors. .. _list_format_item: list_format_item ~~~~~~~~~~~~~~~~ Format to use when listing *individual items* with the :ref:`list-cmd` command and other commands that need to print out items. Defaults to ``$artist - $album - $title``. The ``-f`` command-line option overrides this setting. .. _list_format_album: list_format_album ~~~~~~~~~~~~~~~~~ Format to use when listing *albums* with :ref:`list-cmd` and other commands. Defaults to ``$albumartist - $album``. The ``-f`` command-line option overrides this setting. .. _original_date: original_date ~~~~~~~~~~~~~ Either ``yes`` or ``no``, indicating whether matched albums should have their ``year``, ``month``, and ``day`` fields set to the release date of the *original* version of an album rather than the selected version of the release. That is, if this option is turned on, then ``year`` will always equal ``original_year`` and so on. Default: ``no``. .. _per_disc_numbering: per_disc_numbering ~~~~~~~~~~~~~~~~~~ A boolean controlling the track numbering style on multi-disc releases. By default (``per_disc_numbering: no``), tracks are numbered per-release, so the first track on the second disc has track number N+1 where N is the number of tracks on the first disc. If this ``per_disc_numbering`` is enabled, then the first track on each disc always has track number 1. If you enable ``per_disc_numbering``, you will likely want to change your :ref:`path-format-config` also to include ``$disc`` before ``$track`` to make filenames sort correctly in album directories. For example, you might want to use a path format like this:: paths: default: $albumartist/$album%aunique{}/$disc-$track $title .. _terminal_encoding: terminal_encoding ~~~~~~~~~~~~~~~~~ The text encoding, as `known to Python`_, to use for messages printed to the standard output. By default, this is determined automatically from the locale environment variables. .. _known to python: http://docs.python.org/2/library/codecs.html#standard-encodings .. _clutter: clutter ~~~~~~~ When beets imports all the files in a directory, it tries to remove the directory if it's empty. A directory is considered empty if it only contains files whose names match the glob patterns in `clutter`, which should be a list of strings. The default list consists of "Thumbs.DB" and ".DS_Store". .. _max_filename_length: max_filename_length ~~~~~~~~~~~~~~~~~~~ Set the maximum number of characters in a filename, after which names will be truncated. By default, beets tries to ask the filesystem for the correct maximum. .. _id3v23: id3v23 ~~~~~~ By default, beets writes MP3 tags using the ID3v2.4 standard, the latest version of ID3. Enable this option to instead use the older ID3v2.3 standard, which is preferred by certain older software such as Windows Media Player. Importer Options ---------------- The options that control the :ref:`import-cmd` command are indented under the ``import:`` key. For example, you might have a section in your configuration file that looks like this:: import: write: yes copy: yes resume: no These options are available in this section: write ~~~~~ Either ``yes`` or ``no``, controlling whether metadata (e.g., ID3) tags are written to files when using ``beet import``. Defaults to ``yes``. The ``-w`` and ``-W`` command-line options override this setting. copy ~~~~ Either ``yes`` or ``no``, indicating whether to **copy** files into the library directory when using ``beet import``. Defaults to ``yes``. Can be overridden with the ``-c`` and ``-C`` command-line options. The option is ignored if ``move`` is enabled (i.e., beets can move or copy files but it doesn't make sense to do both). move ~~~~ Either ``yes`` or ``no``, indicating whether to **move** files into the library directory when using ``beet import``. Defaults to ``no``. The effect is similar to the ``copy`` option but you end up with only one copy of the imported file. ("Moving" works even across filesystems; if necessary, beets will copy and then delete when a simple rename is impossible.) Moving files can be risky—it's a good idea to keep a backup in case beets doesn't do what you expect with your files. This option *overrides* ``copy``, so enabling it will always move (and not copy) files. The ``-c`` switch to the ``beet import`` command, however, still takes precedence. resume ~~~~~~ Either ``yes``, ``no``, or ``ask``. Controls whether interrupted imports should be resumed. "Yes" means that imports are always resumed when possible; "no" means resuming is disabled entirely; "ask" (the default) means that the user should be prompted when resuming is possible. The ``-p`` and ``-P`` flags correspond to the "yes" and "no" settings and override this option. incremental ~~~~~~~~~~~ Either ``yes`` or ``no``, controlling whether imported directories are recorded and whether these recorded directories are skipped. This corresponds to the ``-i`` flag to ``beet import``. quiet_fallback ~~~~~~~~~~~~~~ Either ``skip`` (default) or ``asis``, specifying what should happen in quiet mode (see the ``-q`` flag to ``import``, above) when there is no strong recommendation. .. _none_rec_action: none_rec_action ~~~~~~~~~~~~~~~ Either ``ask`` (default), ``asis`` or ``skip``. Specifies what should happen during an interactive import session when there is no recommendation. Useful when you are only interested in processing medium and strong recommendations interactively. timid ~~~~~ Either ``yes`` or ``no``, controlling whether the importer runs in *timid* mode, in which it asks for confirmation on every autotagging match, even the ones that seem very close. Defaults to ``no``. The ``-t`` command-line flag controls the same setting. .. _import_log: log ~~~ Specifies a filename where the importer's log should be kept. By default, no log is written. This can be overridden with the ``-l`` flag to ``import``. .. _default_action: default_action ~~~~~~~~~~~~~~ One of ``apply``, ``skip``, ``asis``, or ``none``, indicating which option should be the *default* when selecting an action for a given match. This is the action that will be taken when you type return without an option letter. The default is ``apply``. .. _languages: languages ~~~~~~~~~ A list of locale names to search for preferred aliases. For example, setting this to "en" uses the transliterated artist name "Pyotr Ilyich Tchaikovsky" instead of the Cyrillic script for the composer's name when tagging from MusicBrainz. Defaults to an empty list, meaning that no language is preferred. .. _detail: detail ~~~~~~ Whether the importer UI should show detailed information about each match it finds. When enabled, this mode prints out the title of every track, regardless of whether it matches the original metadata. (The default behavior only shows changes.) Default: ``no``. .. _musicbrainz-config: MusicBrainz Options ------------------- If you run your own `MusicBrainz`_ server, you can instruct beets to use it instead of the main server. Use the ``host`` and ``ratelimit`` options under a ``musicbrainz:`` header, like so:: musicbrainz: host: localhost:5000 ratelimit: 100 The ``host`` key, of course, controls the Web server hostname (and port, optionally) that will be contacted by beets (default: musicbrainz.org). The ``ratelimit`` option, an integer, controls the number of Web service requests per second (default: 1). **Do not change the rate limit setting** if you're using the main MusicBrainz server---on this public server, you're `limited`_ to one request per second. .. _limited: http://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting .. _MusicBrainz: http://musicbrainz.org/ .. _match-config: Autotagger Matching Options --------------------------- You can configure some aspects of the logic beets uses when automatically matching MusicBrainz results under the ``match:`` section. To control how *tolerant* the autotagger is of differences, use the ``strong_rec_thresh`` option, which reflects the distance threshold below which beets will make a "strong recommendation" that the metadata be used. Strong recommendations are accepted automatically (except in "timid" mode), so you can use this to make beets ask your opinion more or less often. The threshold is a *distance* value between 0.0 and 1.0, so you can think of it as the opposite of a *similarity* value. For example, if you want to automatically accept any matches above 90% similarity, use:: match: strong_rec_thresh: 0.10 The default strong recommendation threshold is 0.04. The ``medium_rec_thresh`` and ``rec_gap_thresh`` options work similarly. When a match is above the *medium* recommendation threshold or the distance between it and the next-best match is above the *gap* threshold, the importer will suggest that match but not automatically confirm it. Otherwise, you'll see a list of options to choose from. .. _max_rec: max_rec ~~~~~~~ As mentioned above, autotagger matches have *recommendations* that control how the UI behaves for a certain quality of match. The recommendation for a certain match is based on the overall distance calculation. But you can also control the recommendation when a specific distance penalty is applied by defining *maximum* recommendations for each field: To define maxima, use keys under ``max_rec:`` in the ``match`` section. The defaults are "medium" for missing and unmatched tracks and "strong" (i.e., no maximum) for everything else:: match: max_rec: missing_tracks: medium unmatched_tracks: medium If a recommendation is higher than the configured maximum and the indicated penalty is applied, the recommendation is downgraded. The setting for each field can be one of ``none``, ``low``, ``medium`` or ``strong``. When the maximum recommendation is ``strong``, no "downgrading" occurs. The available penalty names here are: * source * artist * album * media * mediums * year * country * label * catalognum * albumdisambig * album_id * tracks * missing_tracks * unmatched_tracks * track_title * track_artist * track_index * track_length * track_id .. _preferred: preferred ~~~~~~~~~ In addition to comparing the tagged metadata with the match metadata for similarity, you can also specify an ordered list of preferred countries and media types. A distance penalty will be applied if the country or media type from the match metadata doesn't match. The specified values are preferred in descending order (i.e., the first item will be most preferred). Each item may be a regular expression, and will be matched case insensitively. The number of media will be stripped when matching preferred media (e.g. "2x" in "2xCD"). You can also tell the autotagger to prefer matches that have a release year closest to the original year for an album. Here's an example:: match: preferred: countries: ['US', 'GB|UK'] media: ['CD', 'Digital Media|File'] original_year: yes By default, none of these options are enabled. .. _ignored: ignored ~~~~~~~ You can completely avoid matches that have certain penalties applied by adding the penalty name to the ``ignored`` setting:: match: ignored: missing_tracks unmatched_tracks The available penalties are the same as those for the :ref:`max_rec` setting. .. _path-format-config: Path Format Configuration ------------------------- You can also configure the directory hierarchy beets uses to store music. These settings appear under the ``paths:`` key. Each string is a template string that can refer to metadata fields like ``$artist`` or ``$title``. The filename extension is added automatically. At the moment, you can specify three special paths: ``default`` for most releases, ``comp`` for "various artist" releases with no dominant artist, and ``singleton`` for non-album tracks. The defaults look like this:: paths: default: $albumartist/$album%aunique{}/$track $title singleton: Non-Album/$artist/$title comp: Compilations/$album%aunique{}/$track $title Note the use of ``$albumartist`` instead of ``$artist``; this ensure that albums will be well-organized. For more about these format strings, see :doc:`pathformat`. The ``aunique{}`` function ensures that identically-named albums are placed in different directories; see :ref:`aunique` for details. In addition to ``default``, ``comp``, and ``singleton``, you can condition path queries based on beets queries (see :doc:`/reference/query`). This means that a config file like this:: paths: albumtype:soundtrack: Soundtracks/$album/$track $title will place soundtrack albums in a separate directory. The queries are tested in the order they appear in the configuration file, meaning that if an item matches multiple queries, beets will use the path format for the *first* matching query. Note that the special ``singleton`` and ``comp`` path format conditions are, in fact, just shorthand for the explicit queries ``singleton:true`` and ``comp:true``. In contrast, ``default`` is special and has no query equivalent: the ``default`` format is only used if no queries match. Example ------- Here's an example file:: library: /var/music.blb directory: /var/mp3 path_format: $genre/$artist/$album/$track $title import: copy: yes write: yes resume: ask quiet_fallback: skip timid: no log: beetslog.txt ignore: .AppleDouble ._* *~ .DS_Store art_filename: albumart plugins: bpd pluginpath: ~/beets/myplugins threaded: yes color: yes paths: default: $genre/$albumartist/$album/$track $title singleton: Singletons/$artist - $title comp: $genre/$album/$track $title albumtype:soundtrack: Soundtracks/$album/$track $title bpd: host: 127.0.0.1 port: 6600 password: seekrit (That ``[bpd]`` section configures the optional :doc:`BPD ` plugin.) .. only:: man See Also -------- ``http://beets.readthedocs.org/`` :manpage:`beet(1)` beets-1.3.1/docs/reference/index.rst0000644000076500000240000000041512013011113020270 0ustar asampsonstaff00000000000000Reference ========= This section contains reference materials for various parts of beets. To get started with beets as a new user, though, you may want to read the :doc:`/guides/main` guide first. .. toctree:: :maxdepth: 2 cli config pathformat query beets-1.3.1/docs/reference/pathformat.rst0000644000076500000240000002204012203275653021352 0ustar asampsonstaff00000000000000Path Formats ============ The ``[paths]`` section of the config file (see :doc:`config`) lets you specify the directory and file naming scheme for your music library. Templates substitute symbols like ``$title`` (any field value prefixed by ``$``) with the appropriate value from the track's metadata. Beets adds the filename extension automatically. For example, consider this path format string: ``$albumartist/$album/$track $title`` Here are some paths this format will generate: * ``Yeah Yeah Yeahs/It's Blitz!/01 Zero.mp3`` * ``Spank Rock/YoYoYoYoYo/11 Competition.mp3`` * ``The Magnetic Fields/Realism/01 You Must Be Out of Your Mind.mp3`` Because ``$`` is used to delineate a field reference, you can use ``$$`` to emit a dollars sign. As with `Python template strings`_, ``${title}`` is equivalent to ``$title``; you can use this if you need to separate a field name from the text that follows it. .. _Python template strings: http://docs.python.org/library/string.html#template-strings A Note About Artists -------------------- Note that in path formats, you almost certainly want to use ``$albumartist`` and not ``$artist``. The latter refers to the "track artist" when it is present, which means that albums that have tracks from different artists on them (like `Stop Making Sense`_, for example) will be placed into different folders! Continuing with the Stop Making Sense example, you'll end up with most of the tracks in a "Talking Heads" directory and one in a "Tom Tom Club" directory. You probably don't want that! So use ``$albumartist``. .. _Stop Making Sense: http://musicbrainz.org/release/798dcaab-0f1a-4f02-a9cb-61d5b0ddfd36.html As a convenience, however, beets allows ``$albumartist`` to fall back to the value for ``$artist`` and vice-versa if one tag is present but the other is not. .. _template-functions: Functions --------- Beets path formats also support *function calls*, which can be used to transform text and perform logical manipulations. The syntax for function calls is like this: ``%func{arg,arg}``. For example, the ``upper`` function makes its argument upper-case, so ``%upper{beets rocks}`` will be replaced with ``BEETS ROCKS``. You can, of course, nest function calls and place variable references in function arguments, so ``%upper{$artist}`` becomes the upper-case version of the track's artists. These functions are built in to beets: * ``%lower{text}``: Convert ``text`` to lowercase. * ``%upper{text}``: Convert ``text`` to UPPERCASE. * ``%title{text}``: Convert ``text`` to Title Case. * ``%left{text,n}``: Return the first ``n`` characters of ``text``. * ``%right{text,n}``: Return the last ``n`` characters of ``text``. * ``%if{condition,text}`` or ``%if{condition,truetext,falsetext}``: If ``condition`` is nonempty (or nonzero, if it's a number), then returns the second argument. Otherwise, returns the third argument if specified (or nothing if ``falsetext`` is left off). * ``%asciify{text}``: Convert non-ASCII characters to their ASCII equivalents. For example, "café" becomes "cafe". Uses the mapping provided by the `unidecode module`_. * ``%aunique{identifiers,disambiguators}``: Provides a unique string to disambiguate similar albums in the database. See :ref:`aunique`, below. * ``%time{date_time,format}``: Return the date and time in any format accepted by `strftime`_. For example, to get the year some music was added to your library, use ``%time{$added,%Y}``. .. _unidecode module: http://pypi.python.org/pypi/Unidecode .. _strftime: http://docs.python.org/2/library/time.html#time.strftime Plugins can extend beets with more template functions (see :ref:`writing-plugins`). .. _aunique: Album Disambiguation -------------------- Occasionally, bands release two albums with the same name (c.f. Crystal Castles, Weezer, and any situation where a single has the same name as an album or EP). Beets ships with special support, in the form of the ``%aunique{}`` template function, to avoid placing two identically-named albums in the same directory on disk. The ``aunique`` function detects situations where two albums have some identical fields and emits text from additional fields to disambiguate the albums. For example, if you have both Crystal Castles albums in your library, ``%aunique{}`` will expand to "[2008]" for one album and "[2010]" for the other. The function detects that you have two albums with the same artist and title but that they have different release years. For full flexibility, the ``%aunique`` function takes two arguments, each of which are whitespace-separated lists of album field names: a set of *identifiers* and a set of *disambiguators*. Any group of albums with identical values for all the identifiers will be considered "duplicates". Then, the function tries each disambiguator field, looking for one that distinguishes each of the duplicate albums from each other. The first such field is used as the result for ``%aunique``. If no field suffices, an arbitrary number is used to distinguish the two albums. The default identifiers are ``albumartist album`` and the default disambiguators are ``albumtype year label catalognum albumdisambig``. So you can get reasonable disambiguation behavior if you just use ``%aunique{}`` with no parameters in your path forms (as in the default path formats), but you can customize the disambiguation if, for example, you include the year by default in path formats. One caveat: When you import an album that is named identically to one already in your library, the *first* album—the one already in your library— will not consider itself a duplicate at import time. This means that ``%aunique{}`` will expand to nothing for this album and no disambiguation string will be used at its import time. Only the second album will receive a disambiguation string. If you want to add the disambiguation string to both albums, just run ``beet move`` (possibly restricted by a query) to update the paths for the albums. Syntax Details -------------- The characters ``$``, ``%``, ``{``, ``}``, and ``,`` are "special" in the path template syntax. This means that, for example, if you want a ``%`` character to appear in your paths, you'll need to be careful that you don't accidentally write a function call. To escape any of these characters (except ``{``), prefix it with a ``$``. For example, ``$$`` becomes ``$``; ``$%`` becomes ``%``, etc. The only exception is ``${``, which is ambiguous with the variable reference syntax (like ``${title}``). To insert a ``{`` alone, it's always sufficient to just type ``{``. If a value or function is undefined, the syntax is simply left unreplaced. For example, if you write ``$foo`` in a path template, this will yield ``$foo`` in the resulting paths because "foo" is not a valid field name. The same is true of syntax errors like unclosed ``{}`` pairs; if you ever see template syntax constructs leaking into your paths, check your template for errors. If an error occurs in the Python code that implements a function, the function call will be expanded to a string that describes the exception so you can debug your template. For example, the second parameter to ``%left`` must be an integer; if you write ``%left{foo,bar}``, this will be expanded to something like ````. .. _itemfields: Available Values ---------------- Here's a list of the different values available to path formats. The current list can be found definitively by running the command ``beet fields``. Note that plugins can add new (or replace existing) template values (see :ref:`writing-plugins`). Ordinary metadata: * title * artist * artist_sort: The "sort name" of the track artist (e.g., "Beatles, The" or "White, Jack"). * artist_credit: The track-specific `artist credit`_ name, which may be a variation of the artist's "canonical" name. * album * albumartist: The artist for the entire album, which may be different from the artists for the individual tracks. * albumartist_sort * albumartist_credit * genre * composer * grouping * year, month, day: The release date of the specific release. * original_year, original_month, original_day: The release date of the original version of the album. * track * tracktotal * disc * disctotal * lyrics * comments * bpm * comp: Compilation flag. * albumtype: The MusicBrainz album type; the MusicBrainz wiki has a `list of type names`_. * label * asin * catalognum * script * language * country * albumstatus * media * albumdisambig * disctitle * encoder .. _artist credit: http://wiki.musicbrainz.org/Artist_Credit .. _list of type names: http://musicbrainz.org/doc/Release_Group/Type Audio information: * length (in seconds) * bitrate (in kilobits per second, with units: e.g., "192kbps") * format (e.g., "MP3" or "FLAC") * channels * bitdepth (only available for some formats) * samplerate (in kilohertz, with units: e.g., "48kHz") MusicBrainz and fingerprint information: * mb_trackid * mb_albumid * mb_artistid * mb_albumartistid * mb_releasegroupid * acoustid_fingerprint * acoustid_id Library metadata: * mtime: The modification time of the audio file. * added: The date and time that the music was added to your library. beets-1.3.1/docs/reference/query.rst0000644000076500000240000001152312203275653020356 0ustar asampsonstaff00000000000000Queries ======= Many of beets' :doc:`commands ` are built around **query strings:** searches that select tracks and albums from your library. This page explains the query string syntax, which is meant to vaguely resemble the syntax used by Web search engines. Keyword ------- This command:: $ beet list love will show all tracks matching the query string ``love``. Any unadorned word like this matches *anywhere* in a track's metadata, so you'll see all the tracks with "love" in their title, in their album name, in the artist, and so on. For example, this is what I might see when I run the command above:: Against Me! - Reinventing Axl Rose - I Still Love You Julie Air - Love 2 - Do the Joy Bag Raiders - Turbo Love - Shooting Stars Bat for Lashes - Two Suns - Good Love ... Combining Keywords ------------------ Multiple keywords are implicitly joined with a Boolean "and." That is, if a query has two keywords, it only matches tracks that contain *both* keywords. For example, this command:: $ beet ls magnetic tomorrow matches songs from the album "The House of Tomorrow" by The Magnetic Fields in my library. It *doesn't* match other songs by the Magnetic Fields, nor does it match "Tomorrowland" by Walter Meego---those songs only have *one* of the two keywords I specified. Specific Fields --------------- Sometimes, a broad keyword match isn't enough. Beets supports a syntax that lets you query a specific field---only the artist, only the track title, and so on. Just say ``field:value``, where ``field`` is the name of the thing you're trying to match (such as ``artist``, ``album``, or ``title``) and ``value`` is the keyword you're searching for. For example, while this query:: $ beet list dream matches a lot of songs in my library, this more-specific query:: $ beet list artist:dream only matches songs by the artist The-Dream. One query I especially appreciate is one that matches albums by year:: $ beet list -a year:2012 Recall that ``-a`` makes the ``list`` command show albums instead of individual tracks, so this command shows me all the releases I have from this year. Phrases ------- You can query for strings with spaces in them by quoting or escaping them using your shell's argument syntax. For example, this command:: $ beet list the rebel shows several tracks in my library, but these (equivalent) commands:: $ beet list "the rebel" $ beet list the\ rebel only match the track "The Rebel" by Buck 65. Note that the quotes and backslashes are not part of beets' syntax; I'm just using the escaping functionality of my shell (bash or zsh, for instance) to pass ``the rebel`` as a single argument instead of two. .. _regex: Regular Expressions ------------------- While ordinary keywords perform simple substring matches, beets also supports regular expression matching for more advanced queries. To run a regex query, use an additional ``:`` between the field name and the expression:: $ beet list 'artist::Ann(a|ie)' That query finds songs by Anna Calvi and Annie but not Annuals. Similarly, this query prints the path to any file in my library that's missing a track title:: $ beet list -p title::^$ To search *all* fields using a regular expression, just prefix the expression with a single ``:``, like so:: $ beet list :Ho[pm]eless Regular expressions are case-sensitive and build on `Python's built-in implementation`_. See Python's documentation for specifics on regex syntax. .. _Python's built-in implementation: http://docs.python.org/library/re.html .. _numericquery: Numeric Range Queries --------------------- For numeric fields, such as year, bitrate, and track, you can query using one- or two-sided intervals. That is, you can find music that falls within a *range* of values. To use ranges, write a query that has two dots (``..``) at the beginning, middle, or end of a string of numbers. Dots in the beginning let you specify a maximum (e.g., ``..7``); dots at the end mean a minimum (``4..``); dots in the middle mean a range (``4..7``). For example, this command finds all your albums that were released in the '90s:: $ beet list -a year:1990..1999 and this command finds MP3 files with bitrates of 128k or lower:: $ beet list format:MP3 bitrate:..128000 Path Queries ------------ Sometimes it's useful to find all the items in your library that are (recursively) inside a certain directory. Use the ``path:`` field to do this:: $ beet list path:/my/music/directory In fact, beets automatically recognizes any query term containing a path separator (``/`` on POSIX systems) as a path query, so this command is equivalent:: $ beet list /my/music/directory Note that this only matches items that are *already in your library*, so a path query won't necessarily find *all* the audio files in a directory---just the ones you've already added to your beets library. beets-1.3.1/LICENSE0000644000076500000240000000206212013011112014545 0ustar asampsonstaff00000000000000The MIT License Copyright (c) 2010 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. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.beets-1.3.1/man/0000755000076500000240000000000012226377756014356 5ustar asampsonstaff00000000000000beets-1.3.1/man/beet.10000644000076500000240000002344512226377754015365 0ustar asampsonstaff00000000000000.TH "BEET" "1" "October 12, 2013" "1.3" "beets" .SH NAME beet \- music tagger and library organizer . .nr rst2man-indent-level 0 . .de1 rstReportMargin \\$1 \\n[an-margin] level \\n[rst2man-indent-level] level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] - \\n[rst2man-indent0] \\n[rst2man-indent1] \\n[rst2man-indent2] .. .de1 INDENT .\" .rstReportMargin pre: . RS \\$1 . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] . nr rst2man-indent-level +1 .\" .rstReportMargin post: .. .de UNINDENT . RE .\" indent \\n[an-margin] .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] .nr rst2man-indent-level -1 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. .\" Man page generated from reStructuredText. . .SH SYNOPSIS .nf \fBbeet\fP [\fIargs\fP...] \fIcommand\fP [\fIargs\fP...] \fBbeet help\fP \fIcommand\fP .fi .sp .SH COMMANDS .SS import .sp .nf .ft C beet import [\-CWAPRqst] [\-l LOGPATH] DIR... beet import [options] \-L QUERY .ft P .fi .sp Add music to your library, attempting to get correct tags for it from MusicBrainz. .sp Point the command at a directory full of music. The directory can be a single album or a directory whose leaf subdirectories are albums (the latter case is true of typical Artist/Album organizations and many people\(aqs "downloads" folders). The music will be copied to a configurable directory structure (see below) and added to a library database (see below). The command is interactive and will try to get you to verify MusicBrainz tags that it thinks are suspect. (This means that importing a large amount of music is therefore very tedious right now; this is something we need to work on. Read the \fBautotagging guide\fP if you need help.) .INDENT 0.0 .IP \(bu 2 By default, the command copies files your the library directory and updates the ID3 tags on your music. If you\(aqd like to leave your music files untouched, try the \fB\-C\fP (don\(aqt copy) and \fB\-W\fP (don\(aqt write tags) options. You can also disable this behavior by default in the configuration file (below). .IP \(bu 2 Also, you can disable the autotagging behavior entirely using \fB\-A\fP (don\(aqt autotag)\-\-\-then your music will be imported with its existing metadata. .IP \(bu 2 During a long tagging import, it can be useful to keep track of albums that weren\(aqt tagged successfully\-\-\-either because they\(aqre not in the MusicBrainz database or because something\(aqs wrong with the files. Use the \fB\-l\fP option to specify a filename to log every time you skip an album or import it "as\-is" or an album gets skipped as a duplicate. .IP \(bu 2 Relatedly, the \fB\-q\fP (quiet) option can help with large imports by autotagging without ever bothering to ask for user input. Whenever the normal autotagger mode would ask for confirmation, the quiet mode pessimistically skips the album. The quiet mode also disables the tagger\(aqs ability to resume interrupted imports. .IP \(bu 2 Speaking of resuming interrupted imports, the tagger will prompt you if it seems like the last import of the directory was interrupted (by you or by a crash). If you want to skip this prompt, you can say "yes" automatically by providing \fB\-p\fP or "no" using \fB\-P\fP. The resuming feature can be disabled by default using a configuration option (see below). .IP \(bu 2 If you want to import only the \fInew\fP stuff from a directory, use the \fB\-i\fP option to run an \fIincremental\fP import. With this flag, beets will keep track of every directory it ever imports and avoid importing them again. This is useful if you have an "incoming" directory that you periodically add things to. To get this to work correctly, you\(aqll need to use an incremental import \fIevery time\fP you run an import on the directory in question\-\-\-including the first time, when no subdirectories will be skipped. So consider enabling the \fBincremental\fP configuration option. .IP \(bu 2 By default, beets will proceed without asking if it finds a very close metadata match. To disable this and have the importer ask you every time, use the \fB\-t\fP (for \fItimid\fP) option. .IP \(bu 2 The importer typically works in a whole\-album\-at\-a\-time mode. If you instead want to import individual, non\-album tracks, use the \fIsingleton\fP mode by supplying the \fB\-s\fP option. .IP \(bu 2 If you have an album that\(aqs split across several directories under a common top directory, use the \fB\-\-flat\fP option. This takes all the music files under the directory (recursively) and treats them as a single large album instead of as one album per directory. This can help with your more stubborn multi\-disc albums. .UNINDENT .SS list .sp .nf .ft C beet list [\-apf] QUERY .ft P .fi .sp \fBQueries\fP the database for music. .sp Want to search for "Gronlandic Edit" by of Montreal? Try \fBbeet list gronlandic\fP. Maybe you want to see everything released in 2009 with "vegetables" in the title? Try \fBbeet list year:2009 title:vegetables\fP. (Read more in \fBquery\fP.) .sp You can use the \fB\-a\fP switch to search for albums instead of individual items. In this case, the queries you use are restricted to album\-level fields: for example, you can search for \fByear:1969\fP but query parts for item\-level fields like \fBtitle:foo\fP will be ignored. Remember that \fBartist\fP is an item\-level field; \fBalbumartist\fP is the corresponding album field. .sp The \fB\-p\fP option makes beets print out filenames of matched items, which might be useful for piping into other Unix commands (such as \fI\%xargs\fP). Similarly, the \fB\-f\fP option lets you specify a specific format with which to print every album or track. This uses the same template syntax as beets\(aq \fBpath formats\fP. For example, the command \fBbeet ls \-af \(aq$album: $tracktotal\(aq beatles\fP prints out the number of tracks on each Beatles album. In Unix shells, remember to enclose the template argument in single quotes to avoid environment variable expansion. .SS remove .sp .nf .ft C beet remove [\-ad] QUERY .ft P .fi .sp Remove music from your library. .sp This command uses the same \fBquery\fP syntax as the \fBlist\fP command. You\(aqll be shown a list of the files that will be removed and asked to confirm. By default, this just removes entries from the library database; it doesn\(aqt touch the files on disk. To actually delete the files, use \fBbeet remove \-d\fP. .SS modify .sp .nf .ft C beet modify [\-MWay] QUERY FIELD=VALUE... .ft P .fi .sp Change the metadata for items or albums in the database. .sp Supply a \fBquery\fP matching the things you want to change and a series of \fBfield=value\fP pairs. For example, \fBbeet modify genius of love artist="Tom Tom Club"\fP will change the artist for the track "Genius of Love." The \fB\-a\fP switch operates on albums instead of individual tracks. Items will automatically be moved around when necessary if they\(aqre in your library directory, but you can disable that with \fB\-M\fP. Tags will be written to the files according to the settings you have for imports, but these can be overridden with \fB\-w\fP (write tags, the default) and \fB\-W\fP (don\(aqt write tags). Finally, this command politely asks for your permission before making any changes, but you can skip that prompt with the \fB\-y\fP switch. .SS move .sp .nf .ft C beet move [\-ca] [\-d DIR] QUERY .ft P .fi .sp Move or copy items in your library. .sp This command, by default, acts as a library consolidator: items matching the query are renamed into your library directory structure. By specifying a destination directory with \fB\-d\fP manually, you can move items matching a query anywhere in your filesystem. The \fB\-c\fP option copies files instead of moving them. As with other commands, the \fB\-a\fP option matches albums instead of items. .SS update .sp .nf .ft C beet update [\-aM] QUERY .ft P .fi .sp Update the library (and, optionally, move files) to reflect out\-of\-band metadata changes and file deletions. .sp This will scan all the matched files and read their tags, populating the database with the new values. By default, files will be renamed according to their new metadata; disable this with \fB\-M\fP. .sp To perform a "dry run" of an update, just use the \fB\-p\fP (for "pretend") flag. This will show you all the proposed changes but won\(aqt actually change anything on disk. .sp When an updated track is part of an album, the album\-level fields of \fIall\fP tracks from the album are also updated. (Specifically, the command copies album\-level data from the first track on the album and applies it to the rest of the tracks.) This means that, if album\-level fields aren\(aqt identical within an album, some changes shown by the \fBupdate\fP command may be overridden by data from other tracks on the same album. This means that running the \fBupdate\fP command multiple times may show the same changes being applied. .SS stats .sp .nf .ft C beet stats [\-e] [QUERY] .ft P .fi .sp Show some statistics on your entire library (if you don\(aqt provide a \fBquery\fP) or the matched items (if you do). .sp The \fB\-e\fP (\fB\-\-exact\fP) option makes the calculation of total file size more accurate but slower. .SS fields .sp .nf .ft C beet fields .ft P .fi .sp Show the item and album metadata fields available for use in \fBquery\fP and \fBpathformat\fP. Includes any template fields provided by plugins. .SH GLOBAL FLAGS .sp Beets has a few "global" flags that affect all commands. These must appear between the executable name (\fBbeet\fP) and the command: for example, \fBbeet \-v import ...\fP. .INDENT 0.0 .IP \(bu 2 \fB\-l LIBPATH\fP: specify the library database file to use. .IP \(bu 2 \fB\-d DIRECTORY\fP: specify the library root directory. .IP \(bu 2 \fB\-v\fP: verbose mode; prints out a deluge of debugging information. Please use this flag when reporting bugs. .UNINDENT .SH AUTHOR Adrian Sampson .SH COPYRIGHT 2012, Adrian Sampson .\" Generated by docutils manpage writer. . beets-1.3.1/man/beetsconfig.50000644000076500000240000004576412226377754016752 0ustar asampsonstaff00000000000000.TH "BEETSCONFIG" "5" "October 12, 2013" "1.3" "beets" .SH NAME beetsconfig \- beets configuration file . .nr rst2man-indent-level 0 . .de1 rstReportMargin \\$1 \\n[an-margin] level \\n[rst2man-indent-level] level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] - \\n[rst2man-indent0] \\n[rst2man-indent1] \\n[rst2man-indent2] .. .de1 INDENT .\" .rstReportMargin pre: . RS \\$1 . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] . nr rst2man-indent-level +1 .\" .rstReportMargin post: .. .de UNINDENT . RE .\" indent \\n[an-margin] .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] .nr rst2man-indent-level -1 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. .\" Man page generated from reStructuredText. . .sp Beets has an extensive configuration system that lets you customize nearly every aspect of its operation. To configure beets, you\(aqll edit a file called \fBconfig.yaml\fP. The location of this file depends on your OS: .INDENT 0.0 .IP \(bu 2 On Unix\-like OSes (including OS X), you want \fB~/.config/beets/config.yaml\fP. .IP \(bu 2 On Windows, use \fB%APPDATA%\ebeets\econfig.yaml\fP. This is usually in a directory like \fBC:\eUsers\eYou\eAppData\eRoaming\fP. .IP \(bu 2 On OS X, you can also use \fB~/Library/Application Support/beets/config.yaml\fP if you prefer that over the Unix\-like \fB~/.config\fP. .IP \(bu 2 If you prefer a different location, set the \fBBEETSDIR\fP environment variable to a path; beets will then look for a \fBconfig.yaml\fP in that directory. .UNINDENT .sp The config file uses \fI\%YAML\fP syntax. You can use the full power of YAML, but most configuration options are simple key/value pairs. This means your config file will look like this: .sp .nf .ft C option: value another_option: foo bigger_option: key: value foo: bar .ft P .fi .sp In YAML, you will need to use spaces (not tabs!) to indent some lines. If you have questions about more sophisticated syntax, take a look at the \fI\%YAML\fP documentation. .SH GLOBAL OPTIONS .sp These options control beets\(aq global operation. .SS library .sp Path to the beets library file. By default, beets will use a file called \fBlibrary.db\fP alongside your configuration file. .SS directory .sp The directory to which files will be copied/moved when adding them to the library. Defaults to a folder called \fBMusic\fP in your home directory. .SS plugins .sp A space\-separated list of plugin module names to load. For instance, beets includes the BPD plugin for playing music. .SS pluginpath .sp Directories to search for plugins. These paths are just added to \fBsys.path\fP before the plugins are loaded. (The plugins still have to be contained in a \fBbeetsplug\fP namespace package.) This can either be a single string or a list of strings\-\-\-so, if you have multiple paths, format them as a YAML list like so: .sp .nf .ft C pluginpath: \- /path/one \- /path/two .ft P .fi .SS ignore .sp A list of glob patterns specifying file and directory names to be ignored when importing. By default, this consists of \fB.*\fP, \fB*~\fP, and \fBSystem Volume Information\fP (i.e., beets ignores Unix\-style hidden files, backup files, and a directory that appears at the root of some Windows filesystems). .SS replace .sp A set of regular expression/replacement pairs to be applied to all filenames created by beets. Typically, these replacements are used to avoid confusing problems or errors with the filesystem (for example, leading dots, which hide files on Unix, and trailing whitespace, which is illegal on Windows). To override these substitutions, specify a mapping from regular expression to replacement strings. For example, \fB[xy]: z\fP will make beets replace all instances of the characters \fBx\fP or \fBy\fP with the character \fBz\fP. .sp If you do change this value, be certain that you include at least enough substitutions to avoid causing errors on your operating system. Here are the default substitutions used by beets, which are sufficient to avoid unexpected behavior on all popular platforms: .sp .nf .ft C replace: \(aq[\e\e/]\(aq: _ \(aq^\e.\(aq: _ \(aq[\ex00\-\ex1f]\(aq: _ \(aq[<>:"\e?\e*\e|]\(aq: _ \(aq\e.$\(aq: _ \(aq\es+$\(aq: \(aq\(aq .ft P .fi .sp These substitutions remove forward and back slashes, leading dots, and control characters—all of which is a good idea on any OS. The fourth line removes the Windows "reserved characters" (useful even on Unix for for compatibility with Windows\-influenced network filesystems like Samba). Trailing dots and trailing whitespace, which can cause problems on Windows clients, are also removed. .SS art_filename .sp When importing album art, the name of the file (without extension) where the cover art image should be placed. This is a template string, so you can use any of the syntax available to \fB/reference/pathformat\fP. Defaults to \fBcover\fP (i.e., images will be named \fBcover.jpg\fP or \fBcover.png\fP and placed in the album\(aqs directory). .SS threaded .sp Either \fByes\fP or \fBno\fP, indicating whether the autotagger should use multiple threads. This makes things faster but may behave strangely. Defaults to \fByes\fP. .SS color .sp Either \fByes\fP or \fBno\fP; whether to use color in console output (currently only in the \fBimport\fP command). Turn this off if your terminal doesn\(aqt support ANSI colors. .SS list_format_item .sp Format to use when listing \fIindividual items\fP with the \fIlist\-cmd\fP command and other commands that need to print out items. Defaults to \fB$artist \- $album \- $title\fP. The \fB\-f\fP command\-line option overrides this setting. .SS list_format_album .sp Format to use when listing \fIalbums\fP with \fIlist\-cmd\fP and other commands. Defaults to \fB$albumartist \- $album\fP. The \fB\-f\fP command\-line option overrides this setting. .SS original_date .sp Either \fByes\fP or \fBno\fP, indicating whether matched albums should have their \fByear\fP, \fBmonth\fP, and \fBday\fP fields set to the release date of the \fIoriginal\fP version of an album rather than the selected version of the release. That is, if this option is turned on, then \fByear\fP will always equal \fBoriginal_year\fP and so on. Default: \fBno\fP. .SS per_disc_numbering .sp A boolean controlling the track numbering style on multi\-disc releases. By default (\fBper_disc_numbering: no\fP), tracks are numbered per\-release, so the first track on the second disc has track number N+1 where N is the number of tracks on the first disc. If this \fBper_disc_numbering\fP is enabled, then the first track on each disc always has track number 1. .sp If you enable \fBper_disc_numbering\fP, you will likely want to change your \fI\%Path Format Configuration\fP also to include \fB$disc\fP before \fB$track\fP to make filenames sort correctly in album directories. For example, you might want to use a path format like this: .sp .nf .ft C paths: default: $albumartist/$album%aunique{}/$disc\-$track $title .ft P .fi .SS terminal_encoding .sp The text encoding, as \fI\%known to Python\fP, to use for messages printed to the standard output. By default, this is determined automatically from the locale environment variables. .SS clutter .sp When beets imports all the files in a directory, it tries to remove the directory if it\(aqs empty. A directory is considered empty if it only contains files whose names match the glob patterns in \fIclutter\fP, which should be a list of strings. The default list consists of "Thumbs.DB" and ".DS_Store". .SS max_filename_length .sp Set the maximum number of characters in a filename, after which names will be truncated. By default, beets tries to ask the filesystem for the correct maximum. .SS id3v23 .sp By default, beets writes MP3 tags using the ID3v2.4 standard, the latest version of ID3. Enable this option to instead use the older ID3v2.3 standard, which is preferred by certain older software such as Windows Media Player. .SH IMPORTER OPTIONS .sp The options that control the \fIimport\-cmd\fP command are indented under the \fBimport:\fP key. For example, you might have a section in your configuration file that looks like this: .sp .nf .ft C import: write: yes copy: yes resume: no .ft P .fi .sp These options are available in this section: .SS write .sp Either \fByes\fP or \fBno\fP, controlling whether metadata (e.g., ID3) tags are written to files when using \fBbeet import\fP. Defaults to \fByes\fP. The \fB\-w\fP and \fB\-W\fP command\-line options override this setting. .SS copy .sp Either \fByes\fP or \fBno\fP, indicating whether to \fBcopy\fP files into the library directory when using \fBbeet import\fP. Defaults to \fByes\fP. Can be overridden with the \fB\-c\fP and \fB\-C\fP command\-line options. .sp The option is ignored if \fBmove\fP is enabled (i.e., beets can move or copy files but it doesn\(aqt make sense to do both). .SS move .sp Either \fByes\fP or \fBno\fP, indicating whether to \fBmove\fP files into the library directory when using \fBbeet import\fP. Defaults to \fBno\fP. .sp The effect is similar to the \fBcopy\fP option but you end up with only one copy of the imported file. ("Moving" works even across filesystems; if necessary, beets will copy and then delete when a simple rename is impossible.) Moving files can be risky—it\(aqs a good idea to keep a backup in case beets doesn\(aqt do what you expect with your files. .sp This option \fIoverrides\fP \fBcopy\fP, so enabling it will always move (and not copy) files. The \fB\-c\fP switch to the \fBbeet import\fP command, however, still takes precedence. .SS resume .sp Either \fByes\fP, \fBno\fP, or \fBask\fP. Controls whether interrupted imports should be resumed. "Yes" means that imports are always resumed when possible; "no" means resuming is disabled entirely; "ask" (the default) means that the user should be prompted when resuming is possible. The \fB\-p\fP and \fB\-P\fP flags correspond to the "yes" and "no" settings and override this option. .SS incremental .sp Either \fByes\fP or \fBno\fP, controlling whether imported directories are recorded and whether these recorded directories are skipped. This corresponds to the \fB\-i\fP flag to \fBbeet import\fP. .SS quiet_fallback .sp Either \fBskip\fP (default) or \fBasis\fP, specifying what should happen in quiet mode (see the \fB\-q\fP flag to \fBimport\fP, above) when there is no strong recommendation. .SS none_rec_action .sp Either \fBask\fP (default), \fBasis\fP or \fBskip\fP. Specifies what should happen during an interactive import session when there is no recommendation. Useful when you are only interested in processing medium and strong recommendations interactively. .SS timid .sp Either \fByes\fP or \fBno\fP, controlling whether the importer runs in \fItimid\fP mode, in which it asks for confirmation on every autotagging match, even the ones that seem very close. Defaults to \fBno\fP. The \fB\-t\fP command\-line flag controls the same setting. .SS log .sp Specifies a filename where the importer\(aqs log should be kept. By default, no log is written. This can be overridden with the \fB\-l\fP flag to \fBimport\fP. .SS default_action .sp One of \fBapply\fP, \fBskip\fP, \fBasis\fP, or \fBnone\fP, indicating which option should be the \fIdefault\fP when selecting an action for a given match. This is the action that will be taken when you type return without an option letter. The default is \fBapply\fP. .SS languages .sp A list of locale names to search for preferred aliases. For example, setting this to "en" uses the transliterated artist name "Pyotr Ilyich Tchaikovsky" instead of the Cyrillic script for the composer\(aqs name when tagging from MusicBrainz. Defaults to an empty list, meaning that no language is preferred. .SS detail .sp Whether the importer UI should show detailed information about each match it finds. When enabled, this mode prints out the title of every track, regardless of whether it matches the original metadata. (The default behavior only shows changes.) Default: \fBno\fP. .SH MUSICBRAINZ OPTIONS .sp If you run your own \fI\%MusicBrainz\fP server, you can instruct beets to use it instead of the main server. Use the \fBhost\fP and \fBratelimit\fP options under a \fBmusicbrainz:\fP header, like so: .sp .nf .ft C musicbrainz: host: localhost:5000 ratelimit: 100 .ft P .fi .sp The \fBhost\fP key, of course, controls the Web server hostname (and port, optionally) that will be contacted by beets (default: musicbrainz.org). The \fBratelimit\fP option, an integer, controls the number of Web service requests per second (default: 1). \fBDo not change the rate limit setting\fP if you\(aqre using the main MusicBrainz server\-\-\-on this public server, you\(aqre \fI\%limited\fP to one request per second. .SH AUTOTAGGER MATCHING OPTIONS .sp You can configure some aspects of the logic beets uses when automatically matching MusicBrainz results under the \fBmatch:\fP section. To control how \fItolerant\fP the autotagger is of differences, use the \fBstrong_rec_thresh\fP option, which reflects the distance threshold below which beets will make a "strong recommendation" that the metadata be used. Strong recommendations are accepted automatically (except in "timid" mode), so you can use this to make beets ask your opinion more or less often. .sp The threshold is a \fIdistance\fP value between 0.0 and 1.0, so you can think of it as the opposite of a \fIsimilarity\fP value. For example, if you want to automatically accept any matches above 90% similarity, use: .sp .nf .ft C match: strong_rec_thresh: 0.10 .ft P .fi .sp The default strong recommendation threshold is 0.04. .sp The \fBmedium_rec_thresh\fP and \fBrec_gap_thresh\fP options work similarly. When a match is above the \fImedium\fP recommendation threshold or the distance between it and the next\-best match is above the \fIgap\fP threshold, the importer will suggest that match but not automatically confirm it. Otherwise, you\(aqll see a list of options to choose from. .SS max_rec .sp As mentioned above, autotagger matches have \fIrecommendations\fP that control how the UI behaves for a certain quality of match. The recommendation for a certain match is based on the overall distance calculation. But you can also control the recommendation when a specific distance penalty is applied by defining \fImaximum\fP recommendations for each field: .sp To define maxima, use keys under \fBmax_rec:\fP in the \fBmatch\fP section. The defaults are "medium" for missing and unmatched tracks and "strong" (i.e., no maximum) for everything else: .sp .nf .ft C match: max_rec: missing_tracks: medium unmatched_tracks: medium .ft P .fi .sp If a recommendation is higher than the configured maximum and the indicated penalty is applied, the recommendation is downgraded. The setting for each field can be one of \fBnone\fP, \fBlow\fP, \fBmedium\fP or \fBstrong\fP. When the maximum recommendation is \fBstrong\fP, no "downgrading" occurs. The available penalty names here are: .INDENT 0.0 .IP \(bu 2 source .IP \(bu 2 artist .IP \(bu 2 album .IP \(bu 2 media .IP \(bu 2 mediums .IP \(bu 2 year .IP \(bu 2 country .IP \(bu 2 label .IP \(bu 2 catalognum .IP \(bu 2 albumdisambig .IP \(bu 2 album_id .IP \(bu 2 tracks .IP \(bu 2 missing_tracks .IP \(bu 2 unmatched_tracks .IP \(bu 2 track_title .IP \(bu 2 track_artist .IP \(bu 2 track_index .IP \(bu 2 track_length .IP \(bu 2 track_id .UNINDENT .SS preferred .sp In addition to comparing the tagged metadata with the match metadata for similarity, you can also specify an ordered list of preferred countries and media types. .sp A distance penalty will be applied if the country or media type from the match metadata doesn\(aqt match. The specified values are preferred in descending order (i.e., the first item will be most preferred). Each item may be a regular expression, and will be matched case insensitively. The number of media will be stripped when matching preferred media (e.g. "2x" in "2xCD"). .sp You can also tell the autotagger to prefer matches that have a release year closest to the original year for an album. .sp Here\(aqs an example: .sp .nf .ft C match: preferred: countries: [\(aqUS\(aq, \(aqGB|UK\(aq] media: [\(aqCD\(aq, \(aqDigital Media|File\(aq] original_year: yes .ft P .fi .sp By default, none of these options are enabled. .SS ignored .sp You can completely avoid matches that have certain penalties applied by adding the penalty name to the \fBignored\fP setting: .sp .nf .ft C match: ignored: missing_tracks unmatched_tracks .ft P .fi .sp The available penalties are the same as those for the \fI\%max_rec\fP setting. .SH PATH FORMAT CONFIGURATION .sp You can also configure the directory hierarchy beets uses to store music. These settings appear under the \fBpaths:\fP key. Each string is a template string that can refer to metadata fields like \fB$artist\fP or \fB$title\fP. The filename extension is added automatically. At the moment, you can specify three special paths: \fBdefault\fP for most releases, \fBcomp\fP for "various artist" releases with no dominant artist, and \fBsingleton\fP for non\-album tracks. The defaults look like this: .sp .nf .ft C paths: default: $albumartist/$album%aunique{}/$track $title singleton: Non\-Album/$artist/$title comp: Compilations/$album%aunique{}/$track $title .ft P .fi .sp Note the use of \fB$albumartist\fP instead of \fB$artist\fP; this ensure that albums will be well\-organized. For more about these format strings, see \fBpathformat\fP. The \fBaunique{}\fP function ensures that identically\-named albums are placed in different directories; see \fIaunique\fP for details. .sp In addition to \fBdefault\fP, \fBcomp\fP, and \fBsingleton\fP, you can condition path queries based on beets queries (see \fB/reference/query\fP). This means that a config file like this: .sp .nf .ft C paths: albumtype:soundtrack: Soundtracks/$album/$track $title .ft P .fi .sp will place soundtrack albums in a separate directory. The queries are tested in the order they appear in the configuration file, meaning that if an item matches multiple queries, beets will use the path format for the \fIfirst\fP matching query. .sp Note that the special \fBsingleton\fP and \fBcomp\fP path format conditions are, in fact, just shorthand for the explicit queries \fBsingleton:true\fP and \fBcomp:true\fP. In contrast, \fBdefault\fP is special and has no query equivalent: the \fBdefault\fP format is only used if no queries match. .SH EXAMPLE .sp Here\(aqs an example file: .sp .nf .ft C library: /var/music.blb directory: /var/mp3 path_format: $genre/$artist/$album/$track $title import: copy: yes write: yes resume: ask quiet_fallback: skip timid: no log: beetslog.txt ignore: .AppleDouble ._* *~ .DS_Store art_filename: albumart plugins: bpd pluginpath: ~/beets/myplugins threaded: yes color: yes paths: default: $genre/$albumartist/$album/$track $title singleton: Singletons/$artist \- $title comp: $genre/$album/$track $title albumtype:soundtrack: Soundtracks/$album/$track $title bpd: host: 127.0.0.1 port: 6600 password: seekrit .ft P .fi .sp (That \fB[bpd]\fP section configures the optional \fBBPD\fP plugin.) .SH AUTHOR Adrian Sampson .SH COPYRIGHT 2012, Adrian Sampson .\" Generated by docutils manpage writer. . beets-1.3.1/MANIFEST.in0000644000076500000240000000124412102026773015321 0ustar asampsonstaff00000000000000# Include tests (but avoid including *.pyc, etc.) prune test recursive-include test/rsrc * include test/*.py # Include relevant text files. include LICENSE README.rst # And generated manpages. include man/beet.1 include man/beetsconfig.5 # Include the Sphinx documentation. recursive-include docs *.rst *.py Makefile *.png prune docs/_build # Resources for web plugin. recursive-include beetsplug/web/templates * recursive-include beetsplug/web/static * # And for the lastgenre plugin. include beetsplug/lastgenre/genres.txt include beetsplug/lastgenre/genres-tree.yaml # Exclude junk. global-exclude .DS_Store # Include default config include beets/config_default.yaml beets-1.3.1/PKG-INFO0000644000076500000240000000645112226377756014706 0ustar asampsonstaff00000000000000Metadata-Version: 1.1 Name: beets Version: 1.3.1 Summary: music tagger and library organizer Home-page: http://beets.radbox.org/ Author: Adrian Sampson Author-email: adrian@radbox.org License: MIT Description: Beets is the media library management system for obsessive-compulsive music geeks. The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a bouquet of tools for manipulating and accessing your music. Here's an example of beets' brainy tag corrector doing its thing:: $ beet import ~/music/ladytron Tagging: Ladytron - Witching Hour (Similarity: 98.4%) * Last One Standing -> The Last One Standing * Beauty -> Beauty*2 * White Light Generation -> Whitelightgenerator * All the Way -> All the Way... Because beets is designed as a library, it can do almost anything you can imagine for your music collection. Via `plugins`_, beets becomes a panacea: - Embed and extract album art from files' metadata. - Listen to your library with a music player that speaks the `MPD`_ protocol and works with a staggering variety of interfaces. - Fetch lyrics for all your songs from databases on the Web. - Manage your `MusicBrainz music collection`_. - Analyze music files' metadata from the command line. - Clean up crufty tags left behind by other, less-awesome tools. - Browse your music library graphically through a Web browser and play it in any browser that supports `HTML5 Audio`_. If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. .. _plugins: http://beets.readthedocs.org/page/plugins/ .. _MPD: http://mpd.wikia.com/ .. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/ .. _writing your own plugin: http://beets.readthedocs.org/page/plugins/#writing-plugins .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html Read More --------- Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for news and updates. You can install beets by typing ``pip install beets``. Then check out the `Getting Started`_ guide. .. _its Web site: http://beets.radbox.org/ .. _Getting Started: http://beets.readthedocs.org/page/guides/main.html .. _@b33ts: http://twitter.com/b33ts/ Authors ------- Beets is by `Adrian Sampson`_. .. _Adrian Sampson: mailto:adrian@radbox.org Platform: ALL Classifier: Topic :: Multimedia :: Sound/Audio Classifier: Topic :: Multimedia :: Sound/Audio :: Players :: MP3 Classifier: License :: OSI Approved :: MIT License Classifier: Environment :: Console Classifier: Environment :: Web Environment Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.6 Classifier: Programming Language :: Python :: 2.7 beets-1.3.1/README.rst0000644000076500000240000000432312203275653015260 0ustar asampsonstaff00000000000000Beets is the media library management system for obsessive-compulsive music geeks. The purpose of beets is to get your music collection right once and for all. It catalogs your collection, automatically improving its metadata as it goes. It then provides a bouquet of tools for manipulating and accessing your music. Here's an example of beets' brainy tag corrector doing its thing:: $ beet import ~/music/ladytron Tagging: Ladytron - Witching Hour (Similarity: 98.4%) * Last One Standing -> The Last One Standing * Beauty -> Beauty*2 * White Light Generation -> Whitelightgenerator * All the Way -> All the Way... Because beets is designed as a library, it can do almost anything you can imagine for your music collection. Via `plugins`_, beets becomes a panacea: - Embed and extract album art from files' metadata. - Listen to your library with a music player that speaks the `MPD`_ protocol and works with a staggering variety of interfaces. - Fetch lyrics for all your songs from databases on the Web. - Manage your `MusicBrainz music collection`_. - Analyze music files' metadata from the command line. - Clean up crufty tags left behind by other, less-awesome tools. - Browse your music library graphically through a Web browser and play it in any browser that supports `HTML5 Audio`_. If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. .. _plugins: http://beets.readthedocs.org/page/plugins/ .. _MPD: http://mpd.wikia.com/ .. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/ .. _writing your own plugin: http://beets.readthedocs.org/page/plugins/#writing-plugins .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html Read More --------- Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for news and updates. You can install beets by typing ``pip install beets``. Then check out the `Getting Started`_ guide. .. _its Web site: http://beets.radbox.org/ .. _Getting Started: http://beets.readthedocs.org/page/guides/main.html .. _@b33ts: http://twitter.com/b33ts/ Authors ------- Beets is by `Adrian Sampson`_. .. _Adrian Sampson: mailto:adrian@radbox.org beets-1.3.1/setup.cfg0000644000076500000240000000007312226377756015424 0ustar asampsonstaff00000000000000[egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 beets-1.3.1/setup.py0000755000076500000240000000553612214741277015317 0ustar asampsonstaff00000000000000#!/usr/bin/env python # This file is part of beets. # Copyright 2013, 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 os import sys import subprocess import shutil from setuptools import setup def _read(fn): path = os.path.join(os.path.dirname(__file__), fn) return open(path).read() # Build manpages if we're making a source distribution tarball. if 'sdist' in sys.argv: # Go into the docs directory and build the manpage. docdir = os.path.join(os.path.dirname(__file__), 'docs') curdir = os.getcwd() os.chdir(docdir) try: subprocess.check_call(['make', 'man']) finally: os.chdir(curdir) # Copy resulting manpages. mandir = os.path.join(os.path.dirname(__file__), 'man') if os.path.exists(mandir): shutil.rmtree(mandir) shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir) setup(name='beets', version='1.3.1', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', url='http://beets.radbox.org/', license='MIT', platforms='ALL', long_description=_read('README.rst'), test_suite='test.testall.suite', include_package_data=True, # Install plugin resources. packages=[ 'beets', 'beets.ui', 'beets.autotag', 'beets.util', 'beetsplug', 'beetsplug.bpd', 'beetsplug.web', 'beetsplug.lastgenre', ], namespace_packages=['beetsplug'], entry_points={ 'console_scripts': [ 'beet = beets.ui:main', ], }, install_requires=[ 'mutagen>=1.21', 'munkres', 'unidecode', 'musicbrainzngs>=0.4', 'pyyaml', ] + (['colorama'] if (sys.platform == 'win32') else []) + (['ordereddict'] if sys.version_info < (2, 7, 0) else []), classifiers=[ 'Topic :: Multimedia :: Sound/Audio', 'Topic :: Multimedia :: Sound/Audio :: Players :: MP3', 'License :: OSI Approved :: MIT License', 'Environment :: Console', 'Environment :: Web Environment', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', ], ) beets-1.3.1/test/0000755000076500000240000000000012226377756014562 5ustar asampsonstaff00000000000000beets-1.3.1/test/__init__.py0000644000076500000240000000004312013011113016626 0ustar asampsonstaff00000000000000# Make python -m testall.py work. beets-1.3.1/test/_common.py0000644000076500000240000001607312220072410016537 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Some common functionality for beets' test cases.""" import time import sys import os import logging import tempfile import shutil # Use unittest2 on Python < 2.7. try: import unittest2 as unittest except ImportError: import unittest # Mangle the search path to include the beets sources. sys.path.insert(0, '..') import beets.library from beets import importer from beets.ui import commands import beets # Suppress logging output. log = logging.getLogger('beets') log.setLevel(logging.CRITICAL) # Test resources/sandbox path. RSRC = os.path.join(os.path.dirname(__file__), 'rsrc') # Dummy item creation. _item_ident = 0 def item(lib=None): global _item_ident _item_ident += 1 i = beets.library.Item( title = u'the title', artist = u'the artist', albumartist = u'the album artist', album = u'the album', genre = u'the genre', composer = u'the composer', grouping = u'the grouping', year = 1, month = 2, day = 3, track = 4, tracktotal = 5, disc = 6, disctotal = 7, lyrics = u'the lyrics', comments = u'the comments', bpm = 8, comp = True, path = 'somepath' + str(_item_ident), length = 60.0, bitrate = 128000, format = 'FLAC', mb_trackid = 'someID-1', mb_albumid = 'someID-2', mb_artistid = 'someID-3', mb_albumartistid = 'someID-4', album_id = None, ) if lib: lib.add(i) return i # Dummy import session. def import_session(lib=None, logfile=None, paths=[], query=[], cli=False): cls = commands.TerminalImportSession if cli else importer.ImportSession return cls(lib, logfile, paths, query) # A test harness for all beets tests. # Provides temporary, isolated configuration. class TestCase(unittest.TestCase): """A unittest.TestCase subclass that saves and restores beets' global configuration. This allows tests to make temporary modifications that will then be automatically removed when the test completes. Also provides some additional assertion methods, a temporary directory, and a DummyIO. """ def setUp(self): # A "clean" source list including only the defaults. beets.config.sources = [] beets.config.read(user=False, defaults=True) # Direct paths to a temporary directory. Tests can also use this # temporary directory. self.temp_dir = tempfile.mkdtemp() beets.config['statefile'] = os.path.join(self.temp_dir, 'state.pickle') beets.config['library'] = os.path.join(self.temp_dir, 'library.db') beets.config['directory'] = os.path.join(self.temp_dir, 'libdir') # Set $HOME, which is used by confit's `config_dir()` to create # directories. self._old_home = os.environ.get('HOME') os.environ['HOME'] = self.temp_dir # Initialize, but don't install, a DummyIO. self.io = DummyIO() def tearDown(self): if os.path.isdir(self.temp_dir): shutil.rmtree(self.temp_dir) os.environ['HOME'] = self._old_home self.io.restore() def assertExists(self, path): self.assertTrue(os.path.exists(path), 'file does not exist: %s' % path) def assertNotExists(self, path): self.assertFalse(os.path.exists(path), 'file exists: %s' % path) class LibTestCase(TestCase): """A test case that includes an in-memory library object (`lib`) and an item added to the library (`i`). """ def setUp(self): super(LibTestCase, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item(self.lib) # Mock timing. class Timecop(object): """Mocks the timing system (namely time() and sleep()) for testing. Inspired by the Ruby timecop library. """ def __init__(self): self.now = time.time() def time(self): return self.now def sleep(self, amount): self.now += amount def install(self): self.orig = { 'time': time.time, 'sleep': time.sleep, } time.time = self.time time.sleep = self.sleep def restore(self): time.time = self.orig['time'] time.sleep = self.orig['sleep'] # Mock I/O. class InputException(Exception): def __init__(self, output=None): self.output = output def __str__(self): msg = "Attempt to read with no input provided." if self.output is not None: msg += " Output: %s" % repr(self.output) return msg class DummyOut(object): encoding = 'utf8' def __init__(self): self.buf = [] def write(self, s): self.buf.append(s) def get(self): return ''.join(self.buf) def clear(self): self.buf = [] class DummyIn(object): encoding = 'utf8' def __init__(self, out=None): self.buf = [] self.reads = 0 self.out = out def add(self, s): self.buf.append(s + '\n') def readline(self): if not self.buf: if self.out: raise InputException(self.out.get()) else: raise InputException() self.reads += 1 return self.buf.pop(0) class DummyIO(object): """Mocks input and output streams for testing UI code.""" def __init__(self): self.stdout = DummyOut() self.stdin = DummyIn(self.stdout) def addinput(self, s): self.stdin.add(s) def getoutput(self): res = self.stdout.get() self.stdout.clear() return res def readcount(self): return self.stdin.reads def install(self): sys.stdin = self.stdin sys.stdout = self.stdout def restore(self): sys.stdin = sys.__stdin__ sys.stdout = sys.__stdout__ # Utility. def touch(path): open(path, 'a').close() class Bag(object): """An object that exposes a set of fields given as keyword arguments. Any field not found in the dictionary appears to be None. Used for mocking Album objects and the like. """ def __init__(self, **fields): self.fields = fields def __getattr__(self, key): return self.fields.get(key) beets-1.3.1/test/rsrc/0000755000076500000240000000000012226377756015533 5ustar asampsonstaff00000000000000beets-1.3.1/test/rsrc/bpm.mp30000644000076500000240000003102412013011113016670 0ustar asampsonstaff00000000000000ID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC2001TCON the genreCOMMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000TCMP1TENCiTunes v7.6.2COMMengthe commentsTBPM 128 BPMTIT1the groupingCOMMengiTunPGAP0COMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000USLTengthe lyricsTCOMthe composerTPE2the album artistÿû`À F=íÉ‘A#ô‰¹ÿÿÿÿF0ÃßÿC ÿ_ùïÿÿÿÿóÏ£)çÚa”2ùŠz<‚žyõ=xù=O `ÐÊÇ  ¥àÆyé>`øÜá ÐÀXè" z)ãBœ"À$)‚ A„ ÿÿÿÿÿøÿ×?ÿûÿÿÿÿþ¾Ývb£×B¬¦ ³4Àã°²+¸µÂ¤Ír¹ÁÔR°Ä%;îR£Q[ÿÿÿïÖÜîÿ³¾Ú7UW6dTUT!ub8´Çø çèx?ñ.Ì€ˆˆˆˆÿÚÚ¢³<ŒaeŸDY/ÿÿÿ½ÿPœ¼·$D¬(躡ïPŒi§ÿû`À€Š9à… éâH#4•¸‹RÒAbÉuÏ&Ž ü:=ÄÝL´2!Ô¶#9îŽÔ#"5¿ÿÿÿú­ÿfE$ìȉ,ª1HËez²1’ª!ĨR ˜‰Ì§)¢((â¢,Êc°š)ÄÜX¢¬,qE„T9N@øˆtÿÿÒþ÷ÿ?ÿû`À ò= mÁ¸È#t¸¿ÿÿóËäܯðÉ÷IN¡æiÞÿ{ÞžYg§S—ÜëdE/'ÿÿÿÿÿÿÿçþ§Ì¸g Wëï&%ÍirÄDuñ6.ÝítÜ‘mtP&ê,Ž«¢’Duhâb¨D›ÀN±ÿÿÿÿÿý`Zÿý{ÿÿÿÿÿçÌímÞó¥/Þj¾GH„<&ñÎC¥ÿü„žl÷ÿÿÿÿÿÿÿýþÿçJÎÉP1•¥I’òÔä»ò9£3³ZHFÊ;ŽÔr/viƒÃ1  Ü‰…£2‡ ìÁÿÿÿÿÿÖõÿþyÿ?ÿÿùz\¨Û+dDΪ1ˆÆckTIw˜ÿûbÀ æ% „MɬH#t‰¸™¨…¶R²¶Í´öÿÿÿÿdÝꓳÐ÷1êr© ÊϳîÒ»³lÖ• lΆmC‘Uê÷c,ì$(tÈ4BKÿÿÿÿÿëåý²ëZSÿÿÿÿü¹QRÅ­ÝŒ‡>³Ìl®®è§1Œ¨ˆËf:V¨m&¾åDGB;?ÿÿÿÿÿù­RÌé+ƒ9‡}•+ϳ+±ÍUÐèÊbPSú;1Œ%Ü; Š…R•Ìwt”€Î9Bÿÿÿÿþ¶ƒÿëÿ•ÿÿÿÿäìúRwó5lª„s>ʸõ#ÌäºuFlëÐêɕћÿÿÿÚÿ³«ÔªFJȪyŒEus–ÿû`À" ? „­Á¬Æ#t•¹a2ft*3άVwVl̸â•êc@@Ç‚o2ÇåQÇ;> ¢£€?ÿÿõsÿÿÿÿÿü»µ®å:=¨îïv:ºº•VB/Îlêèö-ÊZ$Û™5OÿÿÿþÍ'*MD£'» È.–z¹›S«”ÌêW!„Ça溡Z!3#NǞ#!ƒ¢âcH5VA¸ò¨`†`±óõùþ¿ÿÿÿÿ®•g;RWTÙUÕ½ÔÝQeÑÑTľÎB%OÿÿÿõùÑgêÕ9F F‘T‡j9"Î(cJb#!ÇxˆÕ3³ ªEÿû`À, Ú= „­É¯È#4‰¸vcœqL8áaàÊPq¢Ñ2$48ÿÿÿÿþÖ¯uÿºÿÿÿÿÿ?£‘Ý”„5›Rl›ogGÐŒªöi7ºÕŽ·ìKwÿÿÿÿÿÚÒ"1Ý_™Ü§8¤T„Dgä;YƒœFä%PìÆ \ÈäRŠF €‚äS3: ÁÈQBªà•Ü(ÿÿÿÿþÍ—ÿ/ü¡ßŸõÿÿÿo‘œíbnYØz(‰‘ëu×J:ä·Id£ïZÐÔÙÿÿÿÿÿþŸJ'»Jƒ Ff®ï©Ne+ È 8ä*&*†q2 0Ì AŠAD…0’Q!2ª”¥A§aQEÿû`À6€ ¶A „­Á¬E£t¹0ÿÿÿý°/ó׿ü¿ÿÿÿÿò]s¹œÐô… 仪Dþ9FzÓ¦[7‘t¥+M¹2ä³ÿÿÿÿÿÿÿéÿ9æÓ*FE‘D‰»ë2³×r™9“Ò·I­|¼UèÊã±Áz°spÂ+† JÛØ8ÿÿÿÿþ©ü¼ògRfÒÏåÿÿýþ•$íc1üý§º!Yz:d÷#ê×*º%Pú?{¯ÿÿÿÿÿèɦ·5\ÎR£¾ì‚ŠTv{˜Ä$A™ RkÑi†ˆ!£±ÅÊÖ(|¨rˆ˜pðé¢î<ÂÁÀ+)h0À5"ü¿ÿÿÿÿÿdîÿûbÀBVA „­ÁÄÇ£4•¹F±:¼Û>yê­)Ýì¬åºQò²+7cfTÿÿÿëÙ[óÉW£•hr¿ÿÿÿÿiO‰&׊aÒ¶§ö’ѺkL“ÔW0÷?ÜpµpÝÿÿÿÿÿÿÿ1ÿ×ýúÚuz÷¤sò8ë›~9çˆ^;Ž’˜…ìar:¦hZ—Ro|†AwÚÖìXòo®Ü**>@Ö0ÿÿÿÿÿëËWuûJFÑYìîåˆòYÙræ\ˆîjí»*"±Hßÿÿÿÿ~î‰m¨—g*:˜…+"ofi•ÞÂ#E ¨²)JçWACê,$aqÊÇ)„€p1Œ"Pè‘ÅH‡(Ñ¡`8ÿûbÀo€5 … ɽH"Ì•¸3èòÿ¯ÿÿÿÖ×Ýzè¼ì×»Èr/È¢ Ê®”d£3Ñ—yN¤qÿÿÿ÷ä{"Zåu#ÌeR ¸ñŽÄsî®e-ESi‘pÕ( ›0“„bŽ3‡Àãs†yNEr”ÓŠˆ¨€ ŒÀÿÿÿÿÿíyïÿéßÿÿÿýz.¥1,o5ä+9·Ežuvf±Îõ¹v}ÈÈ–NÿÿÿÿDû}l³µ+3UTŠCo¼¥>%èOyS!XŒ‡‘Æi;\ê8#)Ń:‰cÅ•Êt(P‘À?ÿÿÿÒÿùÿóÿÿÿÿù´š÷fÿû`Àx 9 ­Ù¤G£t‰¹b$„vºftgdû©kU-™œèƤÆVäfÛÿÿÿÿìŸêR>Ó­ÕK5(i5; Îñ“•ÑŠwiÇ*¹ˆ¤"âŠ*.*1â¦c´x´çRH@“ˆ#•bÀÿÿÿö@¿ÿ-»<ŽÄjµ=]ÖÓTª¤®Æ96Kä<îäsn³¼ó—ÿÿÿýv"Ñõ{Ñ]LëgRg©ªF5ŽFuõZÇQ3QÓ ‡Ç°›”a‡¨Ô ˜µÌ.tP‰GQCå<ÊÿÿÿÿÖþ]ÿþ«¯ÿÿÿþÞˆy‘žr3ÚÛg§Ÿ¤m4U++!d“Ö¶_J´Í{ÿû`Àƒ nA „­ÁªG£4¹ÿÿÿÿÿÿÿÿöù7ÈqyiHsC0¤Er_?* –gÐNP‰îF¸v#UCªˆ9ŽB!* jèA‡1Y(0ÿÿÿÿý`_óòþ_ÿÿÿÿüßíÑžïzJ_¹K&´išôE3Šeu!&>ˆˆþFÿÿÿÿéÌý<ˆs±Q#•ÞcÚï39’Àœ“Ùœ[%ÜÑÝÄÇ8s¸Îd†; áXä …C0­ƒÿùZõºVýi ­b¹*ªOf3U§,ÍD=®E%^ÝËÿÿÿÿóHêí³=Uή¯­]™žØâ®ª9ÕŒfd#9ÿû`À òA „mÁžÈ#t‰¸ÑÔ¤C»Tˆpø…Å”HƒbÈ s‘ E,*Qÿÿÿÿÿý`9oËêkÿýýÿÿÿÎë"åE§KQîÖÝJî·<†aG TGȪW›F}]iÿÿÿÿêÞªú#f«¢Ì¨ÅUTH̵Eç̪·;f¤€ÜâÀÙÑÕN]Dœr¬¨qNÈaÝ(ÿÿÿþ²ûçþ}~_]ÿÿëïôìÌÇûèw.šQÞrØÌÌäb-lzèÒÒšÿÿÿÿßé­ÝíSUÐæg«DL†îBÓœxÝ ¶QE§™ˆc±îD €Ñ3‡PƒÃ¡ÿûbÀœ 2A €­Á²H#4‰¸$)Ÿ1Qd‚ £ýÿçÿÿÿÿýÖþ—tÍT!˜¦ÈwS5îêëfºÐÅ*”lý؈ŠßÿÿÿÿôºµYÊÊKžÓÊÄB²¢Ïvj±•QXÌB *”a”£¤q3”Á7 áº¡b±Îƒ!¬k **6Hÿÿÿÿ¶ü¿¿Ëå_ÿòùß%é,«¿uÜöeæBÊìöJ¹Mwº®í­]Õr«7ÿÿÿû¢Ú®d+Tµ#!•ˆDeœ‡æ)Øì…F•®q`³QËÊ·K ‡!:ÁÎÂÃur‹C˜®àÿÿÿÿú@¾_âüÿû`À©€ ž; „­ÉÁG#4•¹¯ÿÿÿÿçïÿ%2ó¥xzžõËa©—ÝßÈͶMŸšÃ™);þe­Ûÿÿÿÿÿÿÿÿù~~KrS¬[uvzŸzYq¢ñ òlQwÑß#}¨\‰jZ |‹T ¸Ä!ÌéÀÀàÿÿÿÿÿŸž Øç —Ne9Ÿÿÿç eR!áçÖVê¢õœ}!wÂOí3UUÚZWU>ÑÿÜWÿÿÿÿÿÿÿÿ?íñó÷+kLÖÃùŽ*Øëk‘s*—gJ4`ž¹EÆÇviF"‡$˜z©†9‚XõÂaË”9pèP·XT]Çÿÿÿÿÿc`qîyÿû`À²€ .A „MÁ­G£t¹‰#±’¿%¾§ÿþY¸tûîú㙬ꙉy¥˜†ýwŠõÚ©¦÷ÞÒ¥©S»éºæÿÿÿÿÿÿÿþÿ¿ÿû¿Ž¹šk¯«YO›­²<6<’±…&TÉ눢Æ!wB±E2ˆ ¸ö³J‡4\V…K1`°’ކ6Ëq1Š@ÿÿÿÿý±ƒÍ#˜Wå¿Ì«ÿÿÿý:*\Ȫuo™H(έb+JB{1¨¦î·±•jjÿÿÿþ7¥®·tT2+9#ÝÖ<–ºœÈ©*­ ayŒQ1§3•Ú&=¥B:``ë‘‚¢§Š ‰Zpð[JQÿû`ÀÀŠA … ÂÈ"´¡¸ €k?ýþ½ÿÿÿÿÿþÕdR•¨É¿WDJ2oR›±ÎÓêUd©Šf3¦Óo5ŸÿÿÿÿòKk­Ê„y™bêìêt3–S’È5‹Ž «,† 1 áòŽrJâŠ0¢„#(@Xêè$,âŠ8Š4xD*…„ÿÿÿÿÿ’Ëäû×ÿÿÿÿûνey5v}úº*±K÷U2‘ÖgR™–tK"µoEÿÿÿÿÿéîíLÊŒE:2+Ù’}Hî8†fc ‹³)†ÅÌîìBˆaÇds¸“!…„XPr˜xñÊ,Š($0<*5 †4lüÿû`À³€²A „­ÁÛH"ô•¸Ö|ÿÿÿÿÿÿôM‘Û[VÛ²NI”¬ÚÖçbk3›ß¢ªÕ‘Z~¿ÿÿÿõ¡j‡»µ¶g*•Ø”ssB«¥r”¬QGª‰ ”„)å1˜0‚ädÆw<)]Ì($aE8pj‡ÐhÀ³˜À¢¦‰€ÿÿÿÿþßüÿF—=ÆÚþ¿ÿùå®›±÷õÌk*"§f5§¢*YÕC^bºê§»LÜÕ²ÿÿÿÿÿëî‰wcª¦UÎ<Ê;0Ô=QÖ«žŽ¤:³ó¿ÿÿÿÏé¹&q…+T©µ'ÙTU•ŒgeR á‡B32)—ýž·D|ïÿÿÿÿÓ]«Mɺ+YŒ[Ö¦0Ò)ÊbŽ¥Ž5EQ„ÄEa!˜\h˜AÂ!ذ ³BÃîg À(ñá‚ÂC &P 8|DV&Qÿÿÿÿÿû`À´€ÂA „­Á×Ç£4•¹ÿéä^¤–ú,‹ÿŸÿÿžÒ÷f»‘×d[Òº½ V¹Lës ws)P§z^¬t252]koÿÿÿï§£÷]»ÍÊS2•gsƙٞDkaœÔ9Ç#˜Â"%F âƒDL*£¢ (B" ‡XÂŽAìb¼>ÿÿÿÿÿ±…åþY//ÿÿÿüæ©J$î”Y?RºÑôVT}ÞŠrQKjßZmu}vÿÿÿþ«ëzttz%¦TS•1ä#ÌÆ½j=‘Òª”ªQ!0c ÓH,¢å £ÄÄÊ,¡Ñw‡È 8ç1Ðå†0¸ÐÛ ÃjÐöÿûbÀ¶€A ­ÑÙÈ"ô•¸}s—ÿþ¿ÿÿÄ@÷:XÈ®M*¨Cc¹§r”ü­#çDQz­ªý§µ)ÿÿÿÿýþ–ÑNbç›-LQl„0׳ )Ük ;˜Â,5ܣŃ‹;"Š 8ЦA¨$ÁÑqÆ\M…EAB@Áç( *ÿÿÿþÿ-ã4Ñ—pþRÿÿÿ³¢îVlìU·K&öu+<Ï™‡tLììv3¡ŠÔ³I{×ÿÿÿÿúûû•YÑ ¥*«“êçªîR†Ð<Ž=ÇÔLU…aÅqáL (sˆ”ñ¨§D4P€È‚Ä !˜€8ÿÿÿÿÿû`À·€’A „­ÁñÈ"´•¸ÿìŒ w"šçÏ’–«ÿÿþMÿÇýwóó¯÷Üu]fFÌÕH÷SR‰õô±×K¯×ÿÿÿÿÿÿÿÿÿÏw§qóßLò°»O¥O3r²1GÔ­mÍ»±å;=šSÊ8y£2lÏ#•0ÑÄÃHˆpò ¢¥% « €ÿÿÿÿÿ²ŸÏ_åËzÿÿÿþþŠŒ÷Z2>ô±• ŒÌ]ªC\È„VGGuyÕ(Êr¢J´ÿÿÿÿû[BUÝŸaj«ö£[‹Õ™HŠèãLRˆ˜«yŒ‡œ¬C%Üq]Qbì->A!Yâ†`£1\Xâ@`Ò y?ÿû`À·6A „­ÁâÈ"ô¡¸—#ÿÿÿÿÊ­ä&ʪËÕèêÊ}V¤1Ö³Fnn¹:û[¯ÿÿÿëmjÌës¡ÒÕF!ŠbŠê®Æ)˜†‹‚*‘LŒcˆ°j¡¬*‰¢ï0p¥>$av!œXè4DHÁÁ5‡ÅĨU(p@1@ÿÿÿþ­ƒüŒÿ¥ÔËïëÿÿÿ7êˆìµR!Ÿ#£µŠè¨~ܦf5æfb1ÞŽÝS"ÛÿÿÿþßèÍiš£P®vJÖ®d1qèq”2ÚÈ0:Å+‘Tƒ6aÂbEl?2‡DD…‡‡\M"B £¢† ”Ácˆ4\6à ÿû`Àµ€.A „­ÁçÈ"´•¸´ý¹~öÿÿÿþT«Û"£—ÜÇž—ÑsÙÑhê®DÎmYd½îdSnÛÿÿÿÿéí¾­:ª3š§š•c%®5HQU<§!ÅEHcDH@8â(‡E`=‡(¹Ã§qR¸²:$&<(j¹‹ÆŠ¸  ˆhˆØ6­ƒóæ¿ÿÿÿÿ#í%)sMªØí1ærÚì·IUkwG‘rîGtTe÷Ñ—ÿÿÿÿÞÕêÛ++Jζ%ƱÞÈÄÌ<‹;‘XÇ8qˆ‚:‹ Aa6qe- @ÊAA¦`‘Dˆa"ÄÆĘ„@ð™”:?ÿÿÿÿý$ÿû`À·€A „­ÁïÈ"´•¸/þN_¬™ôëÿÿä ÷Gõ>¾J¥Ó:²—G4¸±Q(„tC\î{+©•®gOÿÿÿÿ쮕½Ôˆ³!O<®ë!¨Žd,ŠæR1>‚%f0°¬@ê>qâ¡ô (¨˜åœ4ÖQ!0ø€ºX‚eQC€ÿÿÿÿÿÒäþgì?ÿÿÿÿÿ-‘œ¦ŽI"*ÌD£™•̧Y§In–jH~­’Vkg»T®Š¬‹ÿÿÿÿ•úÑÌŽäRËÕ®¬–K¦í[ÝF"Ψpá®ås ƒ”<îƒáC‡A r$ò 0ˆ×R‹ŽÌWˆ€ÿÿÿÿÛÿÿûbÀµ^A ­ÁéÇâô•¸½ÿÿß›ÿÿü¿->ùŞó/ß&@ºÒzšºË)¢Ô-^Ÿð¼Ž½Ïmûÿÿÿÿÿÿÿÿå‘ßæe ·îV›E‚˜6Þõ$4C¹»¼­dPÀŽŠ$B¬ŽAÅ£E "£,†8SW©9 € €`5‘çÿËækúëÿÿòÿZšçTC£ú^5Ýõ‘œ¨bT©‘Üz-®–™=¤½ŸÿÿÿýiCž”èËB)©au”‚o,ÑÈ8‡c‡ ""@”AÐD¥*<:*‡ °x:@ùDEEH8:.Œ…Ї†ÿÿÿÿ¶ü¿ùëïÿÿÿÿ©ÿÿ–ŽÍ­îGH¢’°»NÞ±™ÄW%²zÕóØøDo“eÞÿÿÿÿÿÿÿþ_ü?)¹leyX†¿ç)ΜzR̈Ÿ"]Ë•L´'dxÈÐtS 3j±Û é àÿÿÿÿÿÀ~˜dsÿû`À½€>A … ÂH"´•¸æ½ ÿ#ÿÿÿ˲µÐÕc0‰&V‰ZâBÅrÌdpëFEyFˆm ¥*³¬Î¾ë×ÿÿÿÿÿ¿¿mYÊŠVÊÆ}LêÆ+>j=‚¨,å-*UcÅB²= QSÎPèÑÎ"ÊY˜H  ÿÿÿÿÿÿÿÿÿÿÿÿ¯ÿÿÿÿÿ¯ÿý~Æ(`JEª*!ÙÙÊbª”Vs ` #³±Š©ÿ³”ÁB(ÿû`À³€ n? „mÁÑF¢ô•¹ÿûbÀ»€&@ÞMÀ[Àÿû`Àÿ€ÞÀbeets-1.3.1/test/rsrc/date.mp30000644000076500000240000003102412013011113017027 0ustar asampsonstaff00000000000000ID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC 1987-03-31TCON the genreUSLTengthe lyricsCOMMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000TCMP1TENCiTunes v7.6.2COMMengthe commentsTBPM6COMMengiTunPGAP0TIT1the groupingCOMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000TPE2the album artistTCOMthe composerÿû`À F=íÉ‘A#ô‰¹ÿÿÿÿF0ÃßÿC ÿ_ùïÿÿÿÿóÏ£)çÚa”2ùŠz<‚žyõ=xù=O `ÐÊÇ  ¥àÆyé>`øÜá ÐÀXè" z)ãBœ"À$)‚ A„ ÿÿÿÿÿøÿ×?ÿûÿÿÿÿþ¾Ývb£×B¬¦ ³4Àã°²+¸µÂ¤Ír¹ÁÔR°Ä%;îR£Q[ÿÿÿïÖÜîÿ³¾Ú7UW6dTUT!ub8´Çø çèx?ñ.Ì€ˆˆˆˆÿÚÚ¢³<ŒaeŸDY/ÿÿÿ½ÿPœ¼·$D¬(躡ïPŒi§ÿû`À€Š9à… éâH#4•¸‹RÒAbÉuÏ&Ž ü:=ÄÝL´2!Ô¶#9îŽÔ#"5¿ÿÿÿú­ÿfE$ìȉ,ª1HËez²1’ª!ĨR ˜‰Ì§)¢((â¢,Êc°š)ÄÜX¢¬,qE„T9N@øˆtÿÿÒþ÷ÿ?ÿû`À ò= mÁ¸È#t¸¿ÿÿóËäܯðÉ÷IN¡æiÞÿ{ÞžYg§S—ÜëdE/'ÿÿÿÿÿÿÿçþ§Ì¸g Wëï&%ÍirÄDuñ6.ÝítÜ‘mtP&ê,Ž«¢’Duhâb¨D›ÀN±ÿÿÿÿÿý`Zÿý{ÿÿÿÿÿçÌímÞó¥/Þj¾GH„<&ñÎC¥ÿü„žl÷ÿÿÿÿÿÿÿýþÿçJÎÉP1•¥I’òÔä»ò9£3³ZHFÊ;ŽÔr/viƒÃ1  Ü‰…£2‡ ìÁÿÿÿÿÿÖõÿþyÿ?ÿÿùz\¨Û+dDΪ1ˆÆckTIw˜ÿûbÀ æ% „MɬH#t‰¸™¨…¶R²¶Í´öÿÿÿÿdÝꓳÐ÷1êr© ÊϳîÒ»³lÖ• lΆmC‘Uê÷c,ì$(tÈ4BKÿÿÿÿÿëåý²ëZSÿÿÿÿü¹QRÅ­ÝŒ‡>³Ìl®®è§1Œ¨ˆËf:V¨m&¾åDGB;?ÿÿÿÿÿù­RÌé+ƒ9‡}•+ϳ+±ÍUÐèÊbPSú;1Œ%Ü; Š…R•Ìwt”€Î9Bÿÿÿÿþ¶ƒÿëÿ•ÿÿÿÿäìúRwó5lª„s>ʸõ#ÌäºuFlëÐêɕћÿÿÿÚÿ³«ÔªFJȪyŒEus–ÿû`À" ? „­Á¬Æ#t•¹a2ft*3άVwVl̸â•êc@@Ç‚o2ÇåQÇ;> ¢£€?ÿÿõsÿÿÿÿÿü»µ®å:=¨îïv:ºº•VB/Îlêèö-ÊZ$Û™5OÿÿÿþÍ'*MD£'» È.–z¹›S«”ÌêW!„Ça溡Z!3#NǞ#!ƒ¢âcH5VA¸ò¨`†`±óõùþ¿ÿÿÿÿ®•g;RWTÙUÕ½ÔÝQeÑÑTľÎB%OÿÿÿõùÑgêÕ9F F‘T‡j9"Î(cJb#!ÇxˆÕ3³ ªEÿû`À, Ú= „­É¯È#4‰¸vcœqL8áaàÊPq¢Ñ2$48ÿÿÿÿþÖ¯uÿºÿÿÿÿÿ?£‘Ý”„5›Rl›ogGÐŒªöi7ºÕŽ·ìKwÿÿÿÿÿÚÒ"1Ý_™Ü§8¤T„Dgä;YƒœFä%PìÆ \ÈäRŠF €‚äS3: ÁÈQBªà•Ü(ÿÿÿÿþÍ—ÿ/ü¡ßŸõÿÿÿo‘œíbnYØz(‰‘ëu×J:ä·Id£ïZÐÔÙÿÿÿÿÿþŸJ'»Jƒ Ff®ï©Ne+ È 8ä*&*†q2 0Ì AŠAD…0’Q!2ª”¥A§aQEÿû`À6€ ¶A „­Á¬E£t¹0ÿÿÿý°/ó׿ü¿ÿÿÿÿò]s¹œÐô… 仪Dþ9FzÓ¦[7‘t¥+M¹2ä³ÿÿÿÿÿÿÿéÿ9æÓ*FE‘D‰»ë2³×r™9“Ò·I­|¼UèÊã±Áz°spÂ+† JÛØ8ÿÿÿÿþ©ü¼ògRfÒÏåÿÿýþ•$íc1üý§º!Yz:d÷#ê×*º%Pú?{¯ÿÿÿÿÿèɦ·5\ÎR£¾ì‚ŠTv{˜Ä$A™ RkÑi†ˆ!£±ÅÊÖ(|¨rˆ˜pðé¢î<ÂÁÀ+)h0À5"ü¿ÿÿÿÿÿdîÿûbÀBVA „­ÁÄÇ£4•¹F±:¼Û>yê­)Ýì¬åºQò²+7cfTÿÿÿëÙ[óÉW£•hr¿ÿÿÿÿiO‰&׊aÒ¶§ö’ѺkL“ÔW0÷?ÜpµpÝÿÿÿÿÿÿÿ1ÿ×ýúÚuz÷¤sò8ë›~9çˆ^;Ž’˜…ìar:¦hZ—Ro|†AwÚÖìXòo®Ü**>@Ö0ÿÿÿÿÿëËWuûJFÑYìîåˆòYÙræ\ˆîjí»*"±Hßÿÿÿÿ~î‰m¨—g*:˜…+"ofi•ÞÂ#E ¨²)JçWACê,$aqÊÇ)„€p1Œ"Pè‘ÅH‡(Ñ¡`8ÿûbÀo€5 … ɽH"Ì•¸3èòÿ¯ÿÿÿÖ×Ýzè¼ì×»Èr/È¢ Ê®”d£3Ñ—yN¤qÿÿÿ÷ä{"Zåu#ÌeR ¸ñŽÄsî®e-ESi‘pÕ( ›0“„bŽ3‡Àãs†yNEr”ÓŠˆ¨€ ŒÀÿÿÿÿÿíyïÿéßÿÿÿýz.¥1,o5ä+9·Ežuvf±Îõ¹v}ÈÈ–NÿÿÿÿDû}l³µ+3UTŠCo¼¥>%èOyS!XŒ‡‘Æi;\ê8#)Ń:‰cÅ•Êt(P‘À?ÿÿÿÒÿùÿóÿÿÿÿù´š÷fÿû`Àx 9 ­Ù¤G£t‰¹b$„vºftgdû©kU-™œèƤÆVäfÛÿÿÿÿìŸêR>Ó­ÕK5(i5; Îñ“•ÑŠwiÇ*¹ˆ¤"âŠ*.*1â¦c´x´çRH@“ˆ#•bÀÿÿÿö@¿ÿ-»<ŽÄjµ=]ÖÓTª¤®Æ96Kä<îäsn³¼ó—ÿÿÿýv"Ñõ{Ñ]LëgRg©ªF5ŽFuõZÇQ3QÓ ‡Ç°›”a‡¨Ô ˜µÌ.tP‰GQCå<ÊÿÿÿÿÖþ]ÿþ«¯ÿÿÿþÞˆy‘žr3ÚÛg§Ÿ¤m4U++!d“Ö¶_J´Í{ÿû`Àƒ nA „­ÁªG£4¹ÿÿÿÿÿÿÿÿöù7ÈqyiHsC0¤Er_?* –gÐNP‰îF¸v#UCªˆ9ŽB!* jèA‡1Y(0ÿÿÿÿý`_óòþ_ÿÿÿÿüßíÑžïzJ_¹K&´išôE3Šeu!&>ˆˆþFÿÿÿÿéÌý<ˆs±Q#•ÞcÚï39’Àœ“Ùœ[%ÜÑÝÄÇ8s¸Îd†; áXä …C0­ƒÿùZõºVýi ­b¹*ªOf3U§,ÍD=®E%^ÝËÿÿÿÿóHêí³=Uή¯­]™žØâ®ª9ÕŒfd#9ÿû`À òA „mÁžÈ#t‰¸ÑÔ¤C»Tˆpø…Å”HƒbÈ s‘ E,*Qÿÿÿÿÿý`9oËêkÿýýÿÿÿÎë"åE§KQîÖÝJî·<†aG TGȪW›F}]iÿÿÿÿêÞªú#f«¢Ì¨ÅUTH̵Eç̪·;f¤€ÜâÀÙÑÕN]Dœr¬¨qNÈaÝ(ÿÿÿþ²ûçþ}~_]ÿÿëïôìÌÇûèw.šQÞrØÌÌäb-lzèÒÒšÿÿÿÿßé­ÝíSUÐæg«DL†îBÓœxÝ ¶QE§™ˆc±îD €Ñ3‡PƒÃ¡ÿûbÀœ 2A €­Á²H#4‰¸$)Ÿ1Qd‚ £ýÿçÿÿÿÿýÖþ—tÍT!˜¦ÈwS5îêëfºÐÅ*”lý؈ŠßÿÿÿÿôºµYÊÊKžÓÊÄB²¢Ïvj±•QXÌB *”a”£¤q3”Á7 áº¡b±Îƒ!¬k **6Hÿÿÿÿ¶ü¿¿Ëå_ÿòùß%é,«¿uÜöeæBÊìöJ¹Mwº®í­]Õr«7ÿÿÿû¢Ú®d+Tµ#!•ˆDeœ‡æ)Øì…F•®q`³QËÊ·K ‡!:ÁÎÂÃur‹C˜®àÿÿÿÿú@¾_âüÿû`À©€ ž; „­ÉÁG#4•¹¯ÿÿÿÿçïÿ%2ó¥xzžõËa©—ÝßÈͶMŸšÃ™);þe­Ûÿÿÿÿÿÿÿÿù~~KrS¬[uvzŸzYq¢ñ òlQwÑß#}¨\‰jZ |‹T ¸Ä!ÌéÀÀàÿÿÿÿÿŸž Øç —Ne9Ÿÿÿç eR!áçÖVê¢õœ}!wÂOí3UUÚZWU>ÑÿÜWÿÿÿÿÿÿÿÿ?íñó÷+kLÖÃùŽ*Øëk‘s*—gJ4`ž¹EÆÇviF"‡$˜z©†9‚XõÂaË”9pèP·XT]Çÿÿÿÿÿc`qîyÿû`À²€ .A „MÁ­G£t¹‰#±’¿%¾§ÿþY¸tûîú㙬ꙉy¥˜†ýwŠõÚ©¦÷ÞÒ¥©S»éºæÿÿÿÿÿÿÿþÿ¿ÿû¿Ž¹šk¯«YO›­²<6<’±…&TÉ눢Æ!wB±E2ˆ ¸ö³J‡4\V…K1`°’ކ6Ëq1Š@ÿÿÿÿý±ƒÍ#˜Wå¿Ì«ÿÿÿý:*\Ȫuo™H(έb+JB{1¨¦î·±•jjÿÿÿþ7¥®·tT2+9#ÝÖ<–ºœÈ©*­ ayŒQ1§3•Ú&=¥B:``ë‘‚¢§Š ‰Zpð[JQÿû`ÀÀŠA … ÂÈ"´¡¸ €k?ýþ½ÿÿÿÿÿþÕdR•¨É¿WDJ2oR›±ÎÓêUd©Šf3¦Óo5ŸÿÿÿÿòKk­Ê„y™bêìêt3–S’È5‹Ž «,† 1 áòŽrJâŠ0¢„#(@Xêè$,âŠ8Š4xD*…„ÿÿÿÿÿ’Ëäû×ÿÿÿÿûνey5v}úº*±K÷U2‘ÖgR™–tK"µoEÿÿÿÿÿéîíLÊŒE:2+Ù’}Hî8†fc ‹³)†ÅÌîìBˆaÇds¸“!…„XPr˜xñÊ,Š($0<*5 †4lüÿû`À³€²A „­ÁÛH"ô•¸Ö|ÿÿÿÿÿÿôM‘Û[VÛ²NI”¬ÚÖçbk3›ß¢ªÕ‘Z~¿ÿÿÿõ¡j‡»µ¶g*•Ø”ssB«¥r”¬QGª‰ ”„)å1˜0‚ädÆw<)]Ì($aE8pj‡ÐhÀ³˜À¢¦‰€ÿÿÿÿþßüÿF—=ÆÚþ¿ÿùå®›±÷õÌk*"§f5§¢*YÕC^bºê§»LÜÕ²ÿÿÿÿÿëî‰wcª¦UÎ<Ê;0Ô=QÖ«žŽ¤:³ó¿ÿÿÿÏé¹&q…+T©µ'ÙTU•ŒgeR á‡B32)—ýž·D|ïÿÿÿÿÓ]«Mɺ+YŒ[Ö¦0Ò)ÊbŽ¥Ž5EQ„ÄEa!˜\h˜AÂ!ذ ³BÃîg À(ñá‚ÂC &P 8|DV&Qÿÿÿÿÿû`À´€ÂA „­Á×Ç£4•¹ÿéä^¤–ú,‹ÿŸÿÿžÒ÷f»‘×d[Òº½ V¹Lës ws)P§z^¬t252]koÿÿÿï§£÷]»ÍÊS2•gsƙٞDkaœÔ9Ç#˜Â"%F âƒDL*£¢ (B" ‡XÂŽAìb¼>ÿÿÿÿÿ±…åþY//ÿÿÿüæ©J$î”Y?RºÑôVT}ÞŠrQKjßZmu}vÿÿÿþ«ëzttz%¦TS•1ä#ÌÆ½j=‘Òª”ªQ!0c ÓH,¢å £ÄÄÊ,¡Ñw‡È 8ç1Ðå†0¸ÐÛ ÃjÐöÿûbÀ¶€A ­ÑÙÈ"ô•¸}s—ÿþ¿ÿÿÄ@÷:XÈ®M*¨Cc¹§r”ü­#çDQz­ªý§µ)ÿÿÿÿýþ–ÑNbç›-LQl„0׳ )Ük ;˜Â,5ܣŃ‹;"Š 8ЦA¨$ÁÑqÆ\M…EAB@Áç( *ÿÿÿþÿ-ã4Ñ—pþRÿÿÿ³¢îVlìU·K&öu+<Ï™‡tLììv3¡ŠÔ³I{×ÿÿÿÿúûû•YÑ ¥*«“êçªîR†Ð<Ž=ÇÔLU…aÅqáL (sˆ”ñ¨§D4P€È‚Ä !˜€8ÿÿÿÿÿû`À·€’A „­ÁñÈ"´•¸ÿìŒ w"šçÏ’–«ÿÿþMÿÇýwóó¯÷Üu]fFÌÕH÷SR‰õô±×K¯×ÿÿÿÿÿÿÿÿÿÏw§qóßLò°»O¥O3r²1GÔ­mÍ»±å;=šSÊ8y£2lÏ#•0ÑÄÃHˆpò ¢¥% « €ÿÿÿÿÿ²ŸÏ_åËzÿÿÿþþŠŒ÷Z2>ô±• ŒÌ]ªC\È„VGGuyÕ(Êr¢J´ÿÿÿÿû[BUÝŸaj«ö£[‹Õ™HŠèãLRˆ˜«yŒ‡œ¬C%Üq]Qbì->A!Yâ†`£1\Xâ@`Ò y?ÿû`À·6A „­ÁâÈ"ô¡¸—#ÿÿÿÿÊ­ä&ʪËÕèêÊ}V¤1Ö³Fnn¹:û[¯ÿÿÿëmjÌës¡ÒÕF!ŠbŠê®Æ)˜†‹‚*‘LŒcˆ°j¡¬*‰¢ï0p¥>$av!œXè4DHÁÁ5‡ÅĨU(p@1@ÿÿÿþ­ƒüŒÿ¥ÔËïëÿÿÿ7êˆìµR!Ÿ#£µŠè¨~ܦf5æfb1ÞŽÝS"ÛÿÿÿþßèÍiš£P®vJÖ®d1qèq”2ÚÈ0:Å+‘Tƒ6aÂbEl?2‡DD…‡‡\M"B £¢† ”Ácˆ4\6à ÿû`Àµ€.A „­ÁçÈ"´•¸´ý¹~öÿÿÿþT«Û"£—ÜÇž—ÑsÙÑhê®DÎmYd½îdSnÛÿÿÿÿéí¾­:ª3š§š•c%®5HQU<§!ÅEHcDH@8â(‡E`=‡(¹Ã§qR¸²:$&<(j¹‹ÆŠ¸  ˆhˆØ6­ƒóæ¿ÿÿÿÿ#í%)sMªØí1ærÚì·IUkwG‘rîGtTe÷Ñ—ÿÿÿÿÞÕêÛ++Jζ%ƱÞÈÄÌ<‹;‘XÇ8qˆ‚:‹ Aa6qe- @ÊAA¦`‘Dˆa"ÄÆĘ„@ð™”:?ÿÿÿÿý$ÿû`À·€A „­ÁïÈ"´•¸/þN_¬™ôëÿÿä ÷Gõ>¾J¥Ó:²—G4¸±Q(„tC\î{+©•®gOÿÿÿÿ쮕½Ôˆ³!O<®ë!¨Žd,ŠæR1>‚%f0°¬@ê>qâ¡ô (¨˜åœ4ÖQ!0ø€ºX‚eQC€ÿÿÿÿÿÒäþgì?ÿÿÿÿÿ-‘œ¦ŽI"*ÌD£™•̧Y§In–jH~­’Vkg»T®Š¬‹ÿÿÿÿ•úÑÌŽäRËÕ®¬–K¦í[ÝF"Ψpá®ås ƒ”<îƒáC‡A r$ò 0ˆ×R‹ŽÌWˆ€ÿÿÿÿÛÿÿûbÀµ^A ­ÁéÇâô•¸½ÿÿß›ÿÿü¿->ùŞó/ß&@ºÒzšºË)¢Ô-^Ÿð¼Ž½Ïmûÿÿÿÿÿÿÿÿå‘ßæe ·îV›E‚˜6Þõ$4C¹»¼­dPÀŽŠ$B¬ŽAÅ£E "£,†8SW©9 € €`5‘çÿËækúëÿÿòÿZšçTC£ú^5Ýõ‘œ¨bT©‘Üz-®–™=¤½ŸÿÿÿýiCž”èËB)©au”‚o,ÑÈ8‡c‡ ""@”AÐD¥*<:*‡ °x:@ùDEEH8:.Œ…Ї†ÿÿÿÿ¶ü¿ùëïÿÿÿÿ©ÿÿ–ŽÍ­îGH¢’°»NÞ±™ÄW%²zÕóØøDo“eÞÿÿÿÿÿÿÿþ_ü?)¹leyX†¿ç)ΜzR̈Ÿ"]Ë•L´'dxÈÐtS 3j±Û é àÿÿÿÿÿÀ~˜dsÿû`À½€>A … ÂH"´•¸æ½ ÿ#ÿÿÿ˲µÐÕc0‰&V‰ZâBÅrÌdpëFEyFˆm ¥*³¬Î¾ë×ÿÿÿÿÿ¿¿mYÊŠVÊÆ}LêÆ+>j=‚¨,å-*UcÅB²= QSÎPèÑÎ"ÊY˜H  ÿÿÿÿÿÿÿÿÿÿÿÿ¯ÿÿÿÿÿ¯ÿý~Æ(`JEª*!ÙÙÊbª”Vs ` #³±Š©ÿ³”ÁB(ÿû`À³€ n? „mÁÑF¢ô•¹ÿûbÀ»€&@ÞMÀ[Àÿû`Àÿ€ÞÀbeets-1.3.1/test/rsrc/discc.ogg0000644000076500000240000002116212013011113017256 0ustar asampsonstaff00000000000000OggSþ–ç=݃[vorbisD¬€»€»€»¸OggSþ–ç=^¸µÿ@ÿÿÿÿÿÿÿÿÿÿÿÿÿ2vorbisXiph.Org libVorbis I 20050304ALBUM=the albumARTIST=the artistBPM=6COMMENT=the comments COMPILATION=1COMPOSER=the composer DATE=2001DISC=4DISCC=5GENRE=the genreGROUPING=the groupingLYRICS=the lyrics TITLE=full TRACKNUMBER=2 TRACKTOTAL=3 YEAR=2001vorbisBCVcT)F™RÒJ‰s”1F™b’J‰¥„BHsS©9לk¬¹µ „SP)™RŽRic)™RKI%t:'c[IÁÖ˜k‹A¶„ šRL)Ä”RŠBSŒ)Å”RJB%t:æSŽJ(A¸œs«µ––c‹©t’Jç$dLBH)…’J¥SNBH5–ÖR)sRRjAè „B¶ „ ‚ÐUÀ@² PСЄ†¬2 (Žâ(Ž#9’cI² ÀpI‘ɱ$KÒ,KÓDQU}Õ6UUöu]×u]×u 4d@H§™¥  d Y F(ÂBCVb(9ˆ&´æ|sŽƒf9h*Åætp"ÕæIn*ææœsÎ9'›sÆ8çœsŠrf1h&´æœsƒf)h&´æœsžÄæAkª´æœsÆ9§ƒqFçœsš´æAj6Öæœs´¦9j.Åæœs"åæIm.ÕæœsÎ9çœsÎ9çœsª§spN8çœs¢öæZnBçœs>§{sB8çœsÎ9çœsÎ9çœs‚ÐUA6†q§ HŸ£EˆiȤÝ£Ã$h r ©G££‘Rê ”TÆI) 4d!„RH!…RH!…Rˆ!†bÈ)§œ‚ *©¤¢Š2Ê,³Ì2Ë,³Ì2ë°³Î:ì0ÄC ­´KMµÕXc­¹çœkÒZi­µÖJ)¥”RJ) Y€dAF!…Rˆ!¦œrÊ)¨ BCV€<ÉsDGtDGtDGtDGtDÇsUõ}Sv…áteß×…ßYn]8–Ñu}a•máXeY9~áX–Ý÷•et]_XmÙVY†_øåö}ãxu]nÝç̺ï Çï¤ûÊÓÕmc™}ÝYf_wŽá:¿ð㩪¯›®+ §, ¿íëÆ³û¾²Œ®ëûª, ¿*Û±ë¾óü¾°,£ìúÂj˰ڶ1ܾn,¿pËkëÊ1ë¾Q¶u|_x Ãótu]yf]ÇöutãG8~Ê€€Ê@¡!+€8$‰¢dY¢(Y–(Š¦èº¢hº®¤i¦©ižiZšgš¦iª²)š®,išiZžfšš§™¦hš®kš¦¬Š¦)˦jʲiš²ìº²m»®lÛ¢iʲiš²lš¦,»²«Û®ì꺤Y¦©yžijžgš¦jʲiš®«yžjzžhªž(ªªjªª­ªª,[žgššè©¦'Šªjª¦­šª*˦ªÚ²iª¶lªªm»ªìú²mëºiª²mª¦-›ªjÛ®ìê²,Ûº/išijžgššç™¦iš²lšª+[ž§šž(ªªæ‰¦jªª,›¦ªÊ–癪'Šªê‰žkšª*˦jÚªiš¶lªª-›¦*Ë®mû¾ëʲnªªl›ªjë¦jʲl˾ïʪ)˦ªÚ²iª²-Û²ï˲¬û¢iʲiª²mªª.˲m³lûºhš²mª¦-›ª*Û²-ûº,ÛºïÊ®o«ª¬ë²-ûºîú®pëº0¼²lûª¬úº+Ûºoë2Ûö}DÓ”eS5mÛTUYveÙöeÛö}Ñ4m[UU[6MÕ¶eYö}Y¶ma4MÙ6UUÖMÕ´mY–ma¶eáveÙ·e[öuוu_×}ã×eÝæº²í˲­ûª«ú¶îûÂpë®ð p0¡ ²ˆŒaŒ1RÎ9¡QÊ9ç dÎA!•Ì9!”’9¡””2ç ”’R¡””Z !””Rk8Ø )±8@¡!+€TƒãX–癢jÚ²cIž'Šª©ª¶íH–牢iªªm[ž'Ц©ª®ëëšç‰¢iªªëêºhš¦©ª®ëºº.š¢©ªªëº²®›¦ªª®+»²ì릪ªªëÊ®,ûªº®+˲më°ª®ëʲlÛ¶oܺ®ë¾ïû‘­ëº.üÂ1 Gà @6¬ŽpR4XhÈJ €0!ƒB!„RJ!¥”0à`B(4dE'C)¤”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RH)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ©¤”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)•RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”R Špz0¡ ²HŒQJ)Æœƒ1æcÐI()bÌ9Æ”’Rå„Ri-·Ê9!¤ÔRm™sRZ‹1æ3礤[Í9‡RR‹±æškVk®5çZZ«5לs͹´k®9לsË1לsÎ9çsÎ9çœsÎà48€ذ:ÂIÑX`¡!+€T¥sÎ9èRŒ9ç„"…sÎ9!TŒ9çtB¨sÌ9!„9ç„B!s:è „B„B¡”ÎA!„J(!„B!„:!„B!„B!„RJ!„B ¡”P`@€ «#œ²€– R΄AŽA AÊQ3 BL9Ñ™bNj3S9tjAÙ^2 € À (øBˆ1AˆÌ …U°À  æÀD„D˜ H»¸€.\ÐÅ]BB‚X@ 88á†'Þð„œ STê àà ""š«°¸ÀÈÐØàèðø8>€ˆˆæ*,.02468:<€€€@€€OggS@–þ–ç=ý‚å'02310276;:?CCBl…‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹¬Ø%ô B8FkF#ëœzÊýýžåJI`Ÿ76BU5À: š†îK.Þ[/IÀ´˜˜€Ž€ ~ÊýýžåJ¼çÛJÙ@!TÕ€ €z wPˆ{@Û‚š_,ß5ðè@ 4>ªýÿüå žík¥°@!TUè40ÑMtô™àÉÆ+ÚÙÁ…WÇуàÐ>ªýÿüšäâvy¨…PU¡ô…|G×OӱЂ††ÊpBÀ&€>ªýÿüšäîô4ª…PU'Ð@$¸O}Ç%åMÒ;†:€ žä>ªýÿüšäÒ´ð&”ªÊ* @t𨴯ùçaï¥)t (4 ƒ˜šýÿOre1éM0BU 1>ÞY(¥ÚsYÀ&-RJgè¡ÕMkòØÀ‚VšýÿOr¥2ØåIØUUÅ@EO5š‚nE~IôÆÓ´JÊqØ)d¢U)°$ @šýÿOr¥28åMèªÊj ÌY¬"æeç…d×Ôdþ‘òcw¢ ±“¦‘€d :šýÿOr¥`°Û›¸TU:Ì]¹¤¿\Ïô³\ÏÒ{QFH4¬T05Þyýök”‹2¸ÛÛXaE…P + &EÒæ^ ô#©”¶&Ú!ir}Ï””Ωaku¨“)lY ‚&˜ HÞyýök”‹2˜íMP*jT¦Þ1{Ã1^_Y•¢~vçt×ZŸ}¹.ó­¡£ª9“5\1á)èàÁ_#ÞyýÿþåR Ny“B1ê,˜r(¶Øò³4ptmp sR›¦£L/g9‚èÞ?®Í0—M)°”,ZýÎu¹ƒ‘Þ8 4´X‰ h©u À÷ßÒ‹ö~>S£Èü†ý$=cr¦Èß7²ü¼öŽ¥ÏÃŒóù0ƒÊ«®b¥*¯ß·þQÿŠá¨Y4®#öO¤Å± Š«=¡ P{údX]ÎÔýÎ59 £¼ „f)6ðÖ”³A`fœ“€½^Ä!ƒ<©¬˜i0/d¥·?/Ôh³=é«m¤ò¶Þ­™žwÛÕZ:Ý–ÿ‰^®¦×Ö‘&uVwû’CÅ_Åjð-˜mŒýÎu9 %½ Ð"++IÀ@âÊAGÕrý5]óo”X€¯/sãÎ\ú çHÇ‘ÙqoÅóÈÐ3ú.+¼ð¬qÖ\E¬SF’¿1R-à"ç ÜI¦ÏlÂjÂɘÌìæˆýÎwÙ4-= Ĥ¼ÌdÀ¢ÐIà,1æ8‘qü]âj­_è÷⥧Թéô''ïsçõu|‰ìwûæ¹ÅÇë³Îå‹/ó¡<:0ÑÛå$¡Í½'—3Œˆí®ý ýÎu9q­< B¡¡ËÂÉgPà€~57kg§P¿d´]ÙÛü¹œÎo¿’_Ú¹Js)êã5÷Òä¤Åp–ô•õÚc\^;Ç¥QåtFôbˆ[D®áì §­“ýÎ5¹ƒVÞh’ʦ¡°@vv¿´ÄN˜¾d ¦²²Ki*¯u67[˜`&· 'nV‡§¯‹“ËÈcºt“Ÿq’C>-VþFŸÄ¼VÙ ]êïtGNÊy49ªéaœÙMãýÎ5¹ƒä¡Xd‰‘\%`8'\àÁÁû5ô7‰M?/'x‘,ׯ×}Y¿Ž½nmcfFU)¸¸ð“‡0ÙëÞ¾ÕÑøÚ·•êP[©+T²ûë¼ý2š\ÍüÓSœ ÂG.W¹èX©;·åUFÀhkôo†FØ/÷àÂu cÊ:ýÎ7Ùƒ–Þ8€˜š• ›0Ød7•\´×vÎ}ií½E«MÝ M3‹Š"äðºÏ~µ½b³¶#Ah©'òR‚ÑÖ&y,]~MG“-9¶#8…þùäë¼#s/yýÎ7Ù8ƒQ¾1ˆ•Ö%ˆ8€iup;š¤_I5…ÀÖÿ?»ØÄ&òÒµädu¡n±{½ï…é!‰Ÿ»:•¬Ï…úzÅU.25žBÉ–+Êèq0ïîQ®*/CUë&^ܱé-ýÎ59q#} „&å68ZÀ€ôлt3ü´WÜ™ohm™äP»-¨Y?Ç{ž°êŽLXø8&MöØ f%ªO©›÷NÉ”‰Íͯ®b¢ì½›ìÎÈPw¡ëWI;–ýÎwÙ(ƒ–ž„*hRdÁPLKºá?7œI—»BÁ¦Ñ|_O&±arúäºvQà$¹k×°d­Áå²l­Lc/["°R#lcú,èVÀÝŠ…«äàHïbz¬Û{8˜TýÎ7Ù8ƒQÞ1ˆÉJQ3=HÐi— ‚©æÊì•ä¸óM6¿ÝRmöµ|fê «ö’ôÑZ*Ìü(NbÎÑÙ&ç²ýÎu¹ƒ–ž@-jIÁ…&&èh‹o\ˆœ‰[¢ÆüJx2ÝÏÚ ^Cí7^:«YGcF¦òO}žÝºž‡ƒž™‰°±<É~¾¬2Pâü"SÔÞ‰íõªÀö™!Gæ&ÛóS’‰ÎI…¨ýÎu¹8ƒQÞ(BÌR¬H¦Ð‡YB,ƒ’²-oÅx3CT§XÈèòßÑ| éÈÓùÆûѳ^^ª„^‰)5=‚%G½pïb€{fy](€>(Ö÷:;ubjÙ:kGG:OggSD¬þ–ç=‡¿²¬‹‹‹‹‹‹ýÎ59 %½ ‚”. :>þè]µ¤?÷TãÌxL€çsÔ˜žM›PÏXšÁ„—+q!^#§ÃaâèÑ|S«ã.„™ãµ•«Q®%(Îsnå¶©lõ]”N-{~ýÎwÙ8ƒ™ž0B“b…qÄñ–X9vñ)l}¬Å¼b‘Id=!û…t,W¢7Åæÿ9±¸õñ­Ûÿâ® Rñˆçƒfƒ©ÄÀoé<ïËÓ—·§¾UÚe±”׫üpõ0Úé[(wÉÎ"…ýÎu¹8ƒ–Þ„2h–¢"hrðÈ)=|éP›Û"ýr:Iò"qö°Ì˜è¹¨š¯à޾Rß"à€"«•uÞ½‰ þ¹Ú‡¿š.øAsñ©Ïˆnµïn7…£'òèeŽ~ ýÎu¹ƒÞ0B Í c@2 °8èä>h»ï_6ƒzyu—YÆü|$Ô•»¹Žëö¬ÊÀÅ…÷ñSìÙ n'E²0!¸¶²ö>~v3üayè™zŸ›9óMÔk ¦1×5„éW7ñ#ÞežÈiýÎwÙ8ƒ‘ž1h–bÞFh‚¦zmœòp4<¯å²é*é´ ´ÚäœÉ"N¨„ªñ|oâSðjl’ÌôøœxŽMqIß–Š'S´Î—WøKw®²:&U0TÌn;‘bbž ÆPÈeýί² 8P‹<Üà0P8Ó¹­j xz}]Übeets-1.3.1/test/rsrc/empty.mp30000644000076500000240000002056712013011113017262 0ustar asampsonstaff00000000000000ÿûPÄInfo(!w  &&,,,33999@@FFFLLSSSYY```fflllssyyy€€†††ŒŒ“““™™   ¦¦¬¬¬³³¹¹¹ÀÀÆÆÆÌÌÓÓÓÙÙàààææìììóóùùùÿÿ9LAME3.97 ¥-þ@$|B@!w_»õ^ÿûPÄ1ªü@TØ5"L‹‰€TÇÿÿÿÿÿBhNs‡y„}@3žÇ;çBú2¿úŸï!?úþ}C¡„'B‰¨ÀbÊ€,=°² 7›âýcE`M.¦2 :ºJw f}w§[?ÿÿÿÿ®‘ÒW:®ª…F Œø·BÒ5¥Ê纡Ö^³­*+R:kB·ß¿Û®Ÿÿßÿå™ü¾[&8—Ub0'QX`€h¡eÿÿŸÿÙ2yÕYäÿûRÄ!ƒ)± @} 86`€³¡µàv"6‡¬ƒF” ³äÄÓ”š£ŸÃ=ØŠÿÃÿíçÞvó?Va˜VÓ¯S˜ä¤ËŒIùóläe÷M[#@~¸¨H'ŠÝz£U0¤fÿÿÿúZc{²™Î¤d îî‰tÖÇiƒ1ާ+Ó>b—Wn¯›¾·ÿÿÿüÿújÝkv}I*shìCÚ #9§™„Ä! ×u»kÑ{µjk¦¨™É+Q@·ãÚ·]šÙ+¶ŽºmÿöéFÿ¿nïâçÃWõÈó¯ÿûRÄ;ƒ­µ @×À÷¶aˆ N#œ4ìœHšW®JU?¦^gÍç=¿¥v„y*g^M)\ŒèÑZ¿½W$4>ÚdLN[Íûåú>¥S3õê*Ööß G_ÊxV#³B)ë´eT¶H Œ–šN@Ñk%©§GI,™eU]¦zQ.û«,¶-¯¬ª£"Ùõó·ÿõ¤~ù}ßJWò}ÞÖT˜E¤>é ÈMŠB#–¤D™?Èÿï–\dáO=!›Ÿbtph½'r†PŒ·¨ˆX.vlÈKÿûRÄWƒ ­µ =¶!'¡½…Ö.™|Ÿÿ÷ºùãnµXNkíÝbß aWIvȇ¢'HT'@if³³"rÙ•µz:ö²í£<êàŠZZVˆl¥VC¦žÌ´dO¯÷G+>F{)M¨¦³k-«¡‘§!M.hñqõ•Ò'ÆfŒz0@úÿÿúïºYïmV¶ígÔÖwµT†‘ x…!G!'GtÕ ÌgùÖ2éK.ªüÖµ«mÞ“ºŠkœR1U¦cë’ë&ÈÊ ÄhÓF…ÿûRÄnƒ‰Y± = ¶ Ä'¡’#¤´UdVißr˜Œ —N^’1·|ÓÜQêåZ=ÞÎëO7Z¶ÆÒ}ùãû³«×ÆwáñC¼[fB¢¯> ’†R*ˆŠ(£ ?ÎŒŽÿk;L¨f*nS¡SyÌsfiÝT…*2ÙŠ&®ã;+È•ïÿ¦µ ¿î‚YSõcY4FÚ™s­Î(M 4q°È¬4EXuà(ÐfJ8 vhã dznE"p z³3¬Ó¢zÿþr|í…ÿûRÄ„ Áµ@ 7Á#¶`È' ÷oò°×«¬Übß—w.Lû7eŠ’@K”¥)um.W§[MF¢‹vR6"î‘mÚvbÖf±QY˜ªGuJd³ý¾º~ü2[q޵öŸšŠÜfÊBýIŒ“Ù+q Ñ áY¤û¾èàl;4·pÌ2!˜äê£fŒKËCŽ˜…I¤«»Y%>®#Þ¾Ûøþ?Þ¯‰y—³,¸b¡AY‡ž ,‘+…” %*Œ?ÿÿÿËÿÿÿÿéíçX‘©ÿûRÄ–ƒ ³ =ý¶aH› Ò+Œ§º…-zÎv»c±¸F”ø1ÞV”Ôß?Bøü'‘ßÈ‹ûßþoë$–R[ Ådt¯# 4± KY$íq:4s²#M’ÀÊÀÕ= „gìdLt2•f;^«écµ]Lô[;£ÕS–ÕßÿúßD÷¾½ú~"‚i]1N^ w/jɃO)ª MFW+MÈ6_+i¼oÌŠ¬ó%Èìéô™Á® )Ë $'¬ž\W'5³’ŸŸ6?'ÿþpüÏ&Ý™ÞãqÿûR݃ɯ =9¶ È£Xô¡Ùì”<ö =}aÔw¥“ꫜL»©ä¤NýÝgf¡V X‚Š÷uQNkØJ$&¨gÈ©s©ÐªèÈ„\ôôµ?M?Ýÿ’ÛXßéCe+0Ylö¦Ñi4,b`ø=@DˆWÿÿÿ-oU¦—ƒIT;fÊ·j‘ ÇÕ’:ìtÑ… ¹ès5KÝ[$Ú?Ó#D ˆ§øþ?þxç“ëˆÌô¬-5ñ d_e°ÙSä9Ç$, ÿûRÄÄJͳ = 6!'¡Øaþÿ?éö”`ÂI‰‚”]ÏKi»eóf;éi#R ¶/;:FSœ}LÄ×s ÓÔÏW¶ó£–=h¸c ¤4\jŒ" Eî A 8. `&¡ r ðäà‡å¥?ôõÿñò‡¬¿-ŽB¼}åøåVAT÷QÞ>™Z­v_ÿWS›QW3µPÍÇ ›;ÙóÙN~U~íÿëåxJ§nÑ,¹!h,è–Ôš Òdj-i² ¿DH\”ÿûRÄÕ ¡¯ 5ᡌ˜ËA€ J ŠÆ@ßÿÿÿEV"£¸ÌkÌeR‘÷w)ЬÇtz¯.æE!êK©nèÆvz¹W[ºûþúµu—ö¶ëÆ7”…NJ(ô˜"5±›Öó@ÁaL=#ŽÅ @ÿü¿ü R‡ÿÿÿ䉕àt½wiÒ«UGQlwE²‰ì¬#GðPà‘ƒ°ºâNÜ#›±Î_ùSÌžž‘œ¬?Wý¢“c‚ÃNB¥%©ëÎn_XèƒaåÑ0²Áej±ÿûRÄèƒ 5µ`‡¶à!¸I¶ÇïÿÿÿÿiòÌ—31˜[šÏ-¾y?ï œ””󤊮nK6‘â–ªÔÕ{±†yÕN~;sùU/µ¾ö¼½Í¯Š¸»k¡œÐMl0>Pø`ùÑP 8XLÆT,aLq€ÿÿÿþ¥Yÿÿu­>yñ²ïr‡•vá†Ïù¸JK×u|â•1ÚEf®´0’ rl즽þÕ¥éö6\on-dfY”š·®ßi ÕìÑ*¢'m9Å!D##à98ùÿûRÄèƒ e³ -ÉA¶¡ &øÿÿÿ¯ÿÿÿÞü"1µ“Y˜2 ÊiR+E½‹'Ç  NàÍOJä(aØ™dÚ%‡y©µþû3;%fr®g,«ŸXP˜¹üKÔ1¬´¼ÕîS&!§“I…ó—–—ª°Ðÿÿÿüçÿÿÿb]ˆÍaI¤ÝÑÂàšW„=Ë»¨K=\ˆi“ÅñlÁIus?W;ŸñÉeûåß8g—UYñCcÂIêË®)M–VX`‰L3b3EØ„$TVÿûRÄìƒLeµ@žÁw¶ ¦¨Œ<Â/ÿÿÿÿÿÿþwËMr:jdZ‘—uƒÊ@œÍåW‚ÉÉWŠÃ‰G’=A ‚Á.yXžÎ<²,·ÎçîWQ`®f[:º+gdr´’—2vÕ02+#`˜PH"(ø•ÐÿîM©4D6±Ï‹2•ÕJùݲn¬0^ŒgZ"‘ClV¤±ÔªÒîEy~Ÿóï±¾²ü ‰ú…Èa5TŠ…W¦djj¨a#ÉÉTDL@Ðl”\Ò¶Ä€ÿÿÿëðÿÿûRÄêKq³@= ‚¶ ³Ùó‘v¾Y™©¶q‰'þj»%#»+Ñž„l2ƒŘ$hœ^´ Ñ‘PIÄå/é^S4à?Ì›Ë3ѱ1WO\+uîŒý”† —¤:!H¸€N /Š„sµ?@ù>ÿÿÿÿÿÿŸ;Ô)*_ª¹jdJŒå$Ç™Ya´ LŒê ʃˆ „Ï8Ù«c¾ï¹ç}» Q©ûÝðm™¡Q†çXËïTQÙØ7(ÏB¢£Ääm2• ÿùKÿÿÿûRÄéƒ ™±@= q6 H¦ùúÿÿÿçlA¯û÷l|õ¿Têmš(þ™ýˆê-r ðm±Ôe¶ïȧ9Y™å¦â›þþcwßo¬÷OÏ̆awV­“¨)“dK‹ž°àðÆå“&Θxÿÿÿù”޲ÿÿëáÚ¾Ë œú]¹²ñ¾ê­\)„›q„]»LBåÂU—RÐó†eNÞ~þ~W™Ïßž¾R»±;'––ZÆ×À\µx©,·ÙKÂßM·ŽÿÿûRÄë }µ@ ;A”6à³Øÿÿþ¦/—ÿÿǘ9sIBé¡ EËu;#XŠÊV¢‚…¹Î—D8TuGtF[Š‘.Ôïò+»0–õÍêl²ÑÇQêAŽj$¦?~ê•(@MNV'ž -Œ–©6pÿÿÿïúúÿÿ‘œ™P\Т/?ôéB%¯ZÖ*Jt¢a.fæýÁšÖÔ’p‰µ-sÿ.ßÿý9¿ô¿î?K=¶ÓK^gϬÁ•Ö3‹ :--€Üý:ö„› Û`@ÿûRÄìƒ %µ` 7Á’6 3ØÑ Ë×ÿúµ6Úò²ä4"ª(‡)芢êCÕ°¹$3ÝXî-,t9XÆ»4çeSUÿú©Õf¨š?Y“jM¸ñ ðß%\ȸm ‰`dºND ˆÈVÅꀽWÿÿõÝï5’©î›k3·SH ~œëÍX);“ZÔŸQ‘ˆ{ù­- JIxeÞçù·Wýüùó+Ö{¿°òõLäo&³I1ŠŸÔÄI¢ÈìD`5ÃÀh6 •A1@•²ÿûRÄëƒK™µÀ…6 H³ØÊ$Dëª~Çÿÿ‹<÷cb4Ås\\ÏB·ÐérZå^ï™n†åMÄÚS¼™Rg̺ìÏÎßzÿü÷×z¬uW¾ØÂ¥µJB‰VsŠÁjá`j¥ ¡‰ÈFŠœ½.ábD °òà0»/ÿõ)_ Í ¼hÞecöÔ»éäcE3³ÔÑdÙïhÃ(·h÷ÚÎòƒî>¯ÿù‰^?ÿÿ¿ûþ»ˆíüuEŒeÆ ©ÔQ …””Â:   ÐáÿûRÄêƒ yµÀžÁj6`Œ&ùÿÿþ_š@;v¿ÿ©­D üC0ðu",g¼C„¦ #6†þœ|¯“Å5„ùnÍÒ7ˆ¹ÏΟÞgržnßù¼´4ö7õÛ]å¬+Þ‚æaCÔÈO]:HOˆX×0>heÕ,­úìÝÕ(½Tšn‰5ªâ4uƒ½é–ýŸ¾Z<˜öÕë³ûã°gZ£¤ãñsŸ¹.îMózffvŸóütïbí³[Õ6TzäjY:ÿ Dâ*qÕ8†ÿûRÄí =µ@ 5Ak6a(¢at$;^‚Z,–HÚº­ ÿÿÿ÷H(©¸Dzœ†3EЮe§3¦;&QÂä9ou%Ån C”‚d=+_ÿÿþÝèT»X™%8á< bHÃG‹ ‚01C‚ Qÿÿþ53{ÿú¢°ÚsÉ–¥~ß|…™AÑ4Í˱Sá:Ì#rm%di?2³Èïô½´ü¿çc_ût×™¶Ne [|óLL,%é8&Y7™lõ:1¹£±ìÿûRÄì‚ ±µ @A}¶ ¨³ ‚?ÿÿþͨVÝÁ¿ÿøŸln4 ‘ò8õTÅû,EY¯ «ÉN57C“5TlÉ‚n{ Ó€¬¥=guÿŸéÈêYò¼eP–•‚~ÙU (i–q &®pH@)H„©ŠpBÿÿÿý×%—7ÿµü)ýM>©-;c®k9r/©Ä…ä‡ áb]C‚†AˆÉëŠ$0äLYpÛõÿÿœ§oª½dV’{8ªtú‘Q 1#moHt´YerRDL(ÿûRÄì ‘±À “=¶¡Œ–  ÅÓ˜]ÿ° 4zb£ÈfËþ|¤0 ¥  ˆ‚ªC•¤>ÁH›IV¾{¦×°å2l“)M1 R3ÿÿÿT·e£±ŽèŠŠ ÊU,1ÑŽ–$Á Õ`Ò µ¬Õ\u"˜ÿô¿™æ¡…?«m8ëT),ùK_û¾^UQó3õ­H†©ò”>‰ò2%~#¯øùþ~:ŽÛt(s8×¢¤¨†« ñ2F ݱ`ôÐwk¹ÆÉ(ˆ€ÿûRÄìKõ³@w6`Œ§iÿÿþi3m EMÿÿ¼È_nLDWcIÅ3“Ú ¨G!)\ÊyûE:ŽÇpÖÃ&CmMZÙ7‡¤ÿ–þ|º™ÿ¨ Èì&ÚÙžL™‰*(¡"bLÕ$"]!*©•Qÿÿÿ S¬eÿùFg/’³4&g§õÑ Ì蔿J1¬ TÃ1ëK;„nå\Äšc(0£N´,òÛsüçsŸ)%“‰ S*DsŸü‡óüóuûb¤ÅF䉜¤àüÆ\Â(ćVZa”BŠ ,`v²JŠ• ´@Ò·ÿîÙgs{ÿù•©<Æ&Nr=îyÎÆ¾¼vì8¿™æ’ö«XÛ;G们ªçÿÓ·íëÊ®£QJìŠÊ ÎìcÄ1ÅPˆ&4ÿûRÄïƒ }±@ ‰}¶ Œ§i85a«d?ÿÿòm?À2ÓZŸýÊfyÝÃá•QŸÏ)%[+lg®V£#‘¡^¬à*ݺ©×êìÞ÷ûÛ¿/!rÈT®}÷'.¢U惒¦Òç¢mÀ›Q\V`}f€QÿÿþN×Lù¿ÿºñ–¤^CžêÞÎwÓ `ÖVq%Kà yÝ¢C=Zó$Ô‹:Êt¢Òòú^\ÙçŪ*鋃 É,m' œ2F ¤š¤ÿûRÄë‚ u³@>É‚¶ ¨§hL›ÀR)\ÚúášÿÿýSÙÎÇ&‹7ÿMœ"ŒÞ‡–kŠ: ”a!Á%-ÐÿûRÄé‚ I¯ ¡ ;IX6a´è› ÿÿÿ±ÌÅp†@£¡,Ìßÿœ%èw*LS³3ÁËìÝ209TÄžs)ʦEtº¹T΋S©èVf{nÏFûÿÿÿµ:ŽÞBÛ¶'¨bZÔ(–HË €™í“Ì”¨ ‹j¾u[kiþî—ÿŽŒ£e aö¿ùezà’<Zõ2UÎE3-P¢p¢²;™Ó1•Tò¡*­IÊÃv:5ÕÕ¦–WbÿÿÿÿKF½mK^áÆØ#.•ÿÿýÿûRÄëJñ±¡ UÑ?µâtŠ»<Èæ(²äb+‘=í5˜‰—Ù¡jÿýjB5Œt{·ˆJ¼b"ìðÒÅ $/7“Û­Ê_ÿ‘fÜëæìˉ•›{¾{9ï|Ú8>0 ë `…eKkI?‹²žrØÃ³çmŒ«Üó±)’Ò2þÎÔÆ<¥ØÏ•s9‰º!YÔyÌbM™† **:Ì×mÑU[ÿÿÿÿÝ™¨ö»:µdaö•C (¹Š9»î ?ÿÕÿûRÄõNµà™ƒ¶ ô!&ø£':¸—8µc…3¶ôéïsAЫõª'ßû¯4÷{2ÓKcÕ>È…X™‹eÄ43B3Ýn…ùŸ—åÿoJ§¹Sœ™Œc$‰4šFÌWU®¡"M3¥Ãž*j(Cp ¢rÝ« ÿÿØd<ͱ¦ŒD8L».ÿ Y“€¬={¹?]T.sž]…u¦m©9ÚÊÝ *»æ°›ß_ü¿ïçñõ^_Ýõ:­Vá[4®a÷’» Ð/*B„Ô ÿûRÄê m§  ­Ù”¶ ´!³¡£ËcDœ§Çó‘”¡Ð@ß*´¿â²ŸKÿ4#õp”[¡kUI„G$7)¡gÖÚ$ÍlÖˡγ?ÿÿûw§g»3vA„©Âܪ@3¡îF$Êrï³@ÿÿÙ²ODd9ƒJ7"5Ïü¥þטWBMsé[Ae[íCPÐ )ÓËË´ÏrSR6*O3z›1ùÄËŽ^ÇYÿÿó¿;Ÿg<„÷!MÕ]Ïfžá¦QCêi$zR²Ó`ÿûRÄë‚ é³  ±Ñ“6 ¨!§ÙJá€6µÿÿÿ½Њ1~„¦ÿù&GIÐäæZ Î+×·ÒzZ®U$·s…)iw*•·78YäQÖ‘U«ñ!žóÿþþrGÜsn[Ò„çßä‚^eÚiœÌU‚¯ÉÐ.K*߬Eÿúô*å“3Yħÿì’"§íöš)œ³ÈŒó¶  ªAj¥hÜP £ ê*9`¢ jÿŸŸçÿò¿ý·{³ç­2eÎ@ÎÇNh¢ÿûRÄë‚ ± >ÉN6aèŠz´†Õ³‚‰‘$ ·mcŒttäÕ3ÞÞ^ó?¿þw§G/>}Š«ŸÃ6õÚWMH(€ˆçB1#ª‹F‰Æ#ÖÎŽ£¢À6ÿû?L‚V@˜‚ïóH€ä¾nˆR…•2»d‹·gš÷‹[z¾àšÝˆ=Æ)ú,Ľ>el×î‹h›ê©š¯Ÿùë¾úÿþ8žõi¿AšI̳¹Æ F<É$ñ p†â„(¿r4IÿþÿûRÄï -¯¡ 7Ƀ¶`ô!§h( L ”ã·t?ÓþûèºV¶áÈs5B¶U)8&°õH?pÄ  ÚÛ«Â uÒ/5amë>åùÿÿÿÿþß;3¶únôY#šKC²þ-Rz¥ºÌÀ±i½¾HÑ$ e¹îÖ8fBÞ-vtïÿyÑfœ…Dµ³… óë=B“ÐszI›ba’ª\‰T’“]–_??ïÿÿÿ·÷;™{~uÚ²cÓIUP%€ŒC+‡0ç8W-Œ€ÿÿÊ-ÿûRÄç‚ ]± ¡ Ý ¡ã´Ò{™ Ó¤rìF‡,ÈÒÏ'=A¼C—'émåP™ ª›ÊÃ[T¶³Û‡&”cÛòn•5-~Égù_ÿ‡3úiZ¼­÷ŒbŠàÖ4ÉÅ g¬tÉH,Œ2†¦!ipM4 ‘ 4ÌŒÀ×—ÿäÖË™ÉHÈÕ–¡‘“YZËPÉ•”'#Y&²Ë!”¹¬œ²öJGü¿ÿö‘¬¿#“,²Ô5k,ŽF³Ï²æ_ùË–( NŽF¡¬˜‚šŠf\ÿûRÄñ Y³  Á‡¶!hšúrnªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªLAME3.97ªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªÿûRÄì‚ õ± @oÙ¶`h¦øªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªÿûRÄìƒË]°Ú €t4€ªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªbeets-1.3.1/test/rsrc/emptylist.mp30000644000076500000240000002000012013011113020134 0ustar asampsonstaff00000000000000ID3gTSSE4LAME 32bits version 3.98.2 (http://www.mp3dev.org/)TIT2#þÿJump In The PoolTPE1þÿFriendly FiresTALBþÿFriendly FiresTCONþÿTRCK1TLEN217040ÿûdXing w]è¿ "$'*,/1469<>ACFIKOQTWY\^adfiknpsvx{~€ƒ…ˆŠŽ‘“—™Ÿ¡¤¥¨ª­¯±´¶¹»½ÀÂÅÈÉÌÎÒÔ×ÚÜàâåèëîïòõ÷úüþdLAME3.98rÃ4 $¸Mô]è¿üå»:ÿûdði ¤ 4€LAME3.98.2UUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûd"ði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdDði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdfði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdˆði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdªði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÌði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdîði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUULAME3.98.2UUUUUUUUUUUUUUUUUUUUUUUUUÿûdÿði ¤ 4€UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUÿÿÿÿÄø>Gÿÿÿ9ÿÿÿÿÿàÿ(& ¦¢™—œÿûdÿði ¤ 4€*ªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªvŇXf˜N¡0ÁðÄ!`8þM™:H˜úö×1Ï·?ó÷rJý¶ñŽ^ÿûdÿði ¤ 4€ÊY éC!ŒCñ‘‘b8°‚æb 0`0a€$YËŸ¯Ïû9ž[wŠ÷îXú{ô˜W¿/¨ 7åã ƒÁaÿûdÿði ¤ 4€„À 4 ×AÀA`8ÐIÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿúÃÿÿçÿÿÿÿÿÿüã/†ß¹û0útE8°ë.êDVÿûdÿði ¤ 4€tSEjfVtùî÷;-޲¯"WL$ `yýô¯8"ýض !äϾ”P,¯áÚm8”Ïæ{«¬o^¿z>åÅóþÚïç/ûÿûDÿÀi < t E#Ž€¿/†÷sÿñƧfé·#`ù`îLÛÇóî;µ®cܳn÷l9nüÿÔæ{Ï *ØÃ;—íß±~1,ΕѬ"ÄiŸ†¯¾~]ÿû°dÿ€ °‰Lþ{¤‚ÇLÉÌà*r%UùÌ€ Œ+?4€Õ>U'e|Ïyå¶^ÖÚ{L½…&6åùëñ§wÜ? ÿ–óÏ»ü¿œÏ¼ÖµoyX« ÅêX쾞݌ÿÿÿü¦]à‚q%‘ŒíÜÙtD„H“#2¤D΀çxF]!™üBb±Ù„L#$“ ¦‰Jrƒ‚€l¶¢¡F¨B.* €»8×I¡,hÐi5…LT)6ÅŠ…f)FàëD—ÐÉ|£˜;oä¤xƒà,l³‡¥Ó”/ts]Kå&ÜyÅUÑV-+·½w­‡@šKi/ÃФØÕ7rTQ‰©»Ý1oY^zSår-æá—÷´,9?ä?K†1gI`h“slJÉ¥ñŒRœ4y³8ìýVFeÎʶ7'aôp×¢ûK7/‰¦a^EYÖM‚ž»~î08¤ýèXý¤”–èÊ`õ2j¬²WE7MmŠ,x*öã´Þ œ{ïÌÝÃêgZÿyß¿Ígyÿÿÿÿÿÿö¥z½Ì·—s¡¿ù÷ÿÿÿÿÿþƧ? êýZöv’‡ÈH ÁÙÉŠ@D€7jîÔ“±‘ÎòSs/4_Ê"ôÄ/Ü@ŠQsmOSQÝÕW_üHʉŽ{kªZül~Š£‘І+7,ø¼y³­-9iZŠˆôì "ØFC 2 ÍÂP!³¡Pð(’%y\@Á`kÈâ1†E•Y§™1E°}e„ÈCñäU€0«ö{,*?³ÌMÏw%Ìm³·i ©êkÔ• œÖ61wñÂÖÿû°d,‚myS÷kS©Š~íáUÍqï½²ª^`‚•RdÓCeŠùg ¶(©d\ çjÒÏᓵ·nÿþY]?8È/ô´JG,N#â0L¾Ãú’ŧä¯üxò¡A~¯ÿÿ8ÄïÈUØWS4@-¡ÿû°d,xUóOer1zÞa"ÍåUÍ=žˆƒ‚køð¤HLÃJ)"@fG€O²Î0,ô ø`P ‡‚EÃHŠ¢<}—Ý€lB+|&“™|¸±Â¬Ìj5Ǧéo¸Yï†CñúÓ÷‰³¤ý9\εSSõi ÀàÔéá7O!ôŠ•"–ïˆÒFž”Älâ°Ÿzª9,§‘C < ¨ìâä S¹Ür!Å͇ Ø’›ÖœZ¨ä'¦LXA0…bÆqk‹/5q©‚îѵŒ‘‡kÞ?^¼±DQgÎÕÇv7Z«ïlÄÍ+\ÙîœéìÊ|ôîOkkVÁs`$BÀÃ^å^ˆr+J¡ŸA=ÕöþŸúöÝnY?túüdôÿ†÷û$™ö8_wþî³CÐ ƒ8pôojXhÈšp@å‹‚$=q4Ȅ؆šiZAáë¥9*JÌØ‘wc¸\ÓJˆ>ÏdÅFMI$ŽÎ¹p|^OËoÿsoïÓÛ”Xtd–êCÒéÙ×)B8 …Jbw¦\d{‹ã‹æ“¡J%Uqc8Ö>2åñ#Ù1¸‘ª¡W ÍVôÁœ«Ä9‡h{ö¼UªšŠøÌ)'÷“rkn2X8€^ ðÈÔ` „Ié9­ök.ÓÓÂ^“Q“‡µøYpt`BJ·+Io“Åôˆîý\Wñomsëÿ“6¦ß~ß3ÿÓ,MÙcNCîr„ àÆI‚àJx¦ø½•ßÿîùÚ2ÿÿëÿÿ¡ÙUBO¥6Ž0´ÎAã €1‡4L Z¬‚#ñIä]Gö=&@Œ”˜f¿ÿû°djÕóOerEçªÏeg)éYÍ=7ÈÊký”ˆñÍSRù;[SõvðÄš<8FÉû–øñ/çΜ—!¯Õˆ±F{IW1”W<«™y—'*Ž8\¤N³+bµC[½.þ6æèÛC£àñ ¥N#|í\¹pXgŽ¢8D ‘óØ+šÅt¬ÓÅr)XÕ(î~l;–\\t†Ä0Òöj÷a¿•B“uœJ]dC›Õ0^×Íac­¬9 Õý]ò˼*ý[½ËÕçŒß;ðH¢@@.9Êi4Ɉ¡5JãEH38¨A_Ö¼Ê'ð°¸Äg»uûÿu<«{ô™×zzÿÔô*ƒPC O¦“ÿIlçþCéXAÀÕòÅÄœ]FX†(©;Š£Â EGJ ÒRÓ z_d^W×2‘|»qMeqºKõE–ãö–ª´ž¹©¦—­u}aƒ/âÚTÛ y ~õóãaeìhŠs)¨E*Ȭ¯ÕjW’âÙô´}ĕƕ,‹£t¬R/«ÉlvØŠÔ,çCS˜ÇùIÏ3Ú¤”ð¡»Zbz~´éôS¥ßBYÌ*Ö™Öí˜:Ù,l.6…ñ–!8d~9 oR¦¬´Y6ZÝR¼¯e[Ÿ?¬ù÷>ü¾JÚù£úg@V Uv-QbíäNÏG_þíÿOûÑÝ‘}zõû¿äe»òßíaþµíXC4€S%< eŒPÓNQoÀúë R^ep}wZ„>­ÍŒ˜Žv©ùý-ó„JpÞˆ–E(®Àí̾ÿû°d"€ jVsOerIJ ÿa……åS͕ݧSýá9!Iå·QôŠ¢âu¨v/…{Ku5tûcl¨9¥ó2XP¹Z;ßkýMbz6³MMŠ¥ö pz.qÿó¸}.8‘ê)Û2Ü»û÷ÐóWÅÔßÕø+#™P È*ÿà AJE‹ŸÄ;úî$.²€†6±dþ¿éÿÊîÈÎy?/ïý¶·ÿÿZo/ûzÿìõ0á‚Áˆ5Xï/ÈúöÀLD |P¨TÌšl(p&H±ÕÞ°$Å!× ›•o%èÚ:ñ…ÿ· xnÞ@ÿAÿGŒ]ÎÀç Ìsž€~ï\RqÑ¥úÆü€s°yFüŒs°À$‡;üäs°ÀAã þ<9Ø?AÖí³©T…€Ý€Œ]nÞ@™ú¾òû‡;Ö0ÎÆìÿ€9Fü1t€Ò0ÎÀé¹O@þc­Ï@^vTFhR5•Oø LÉÜÉÿ('ü4¥»øR›KeußãÉÊ ÿU¿ø|ˆæR´R¨.²ØŠ„! DRJ…B¡R•)½É!ê‘×'&J(‚ZÄ ‰@¤,¨T3”¤¸™DV*ð×ù3’AĨŠS'l‘*SÀÕ…["(+‚9¨BPP%1"!A›þNw’#)‰"I R™.P!ÁS"*”r I²±(BSÀ ‡À $ÇüòêD«…)ºÀ JSÀ S]r#¿àr'·ü¶xäBSÀïøˆõþ þ ýr!C¿àÓcþÐT;“#À¦Oø ÌÒoø}û’Mÿá‘Ô&wðÒŠ?à'üxåv§ü¾ÿ‚‘pbeets-1.3.1/test/rsrc/full.ape0000644000076500000240000003326512013011113017133 0ustar asampsonstaff00000000000000MAC –4Ü3Ù0' 0éoÅ|ÍþqôèîˆD¬D¬P('¸è+6¹D€»…›gz¿ãÿnè±`Èé¯CÔC-]áXUÚ½ÛΈý–̹€ËRôa‹% –ýÒñ0ó©'lOéØ[&ï(ïïHõ)œ"i%6aMJO1FÅ@…D8Ùqlpù¤ŒE ]“¹$ê åýwëvy0äÏ\þv7®Œ±I˜®Æ ̇m7¥.uMÒÉè$Åý #­×x|‰SŸúgb;<·|:}|{H?k¬ò"ÁÓ{äìæwß áÝéÝ¡"v˜útŽ`OM".ÿÄ+.Ë÷"q=f“; åF&8e*’<û´íH4ŠÈiçÞ-w»´7rý:¤Û˜œJ \ó!¶Ü$W'8[4“ì«„ËAvìMXFÊUÄÍHU®qta!;{;ªë¹ ÊÁ­àG^ôad¶Gê™gŒ¾P¥­)«Œ·È úîØ™ÜzИšSŽøg_êˆQ¤Ü=ÏS ªù¶¸âèM¿‡næ”– ­u%ã÷ú¢ÓÁ8½ Ì úx«=j]m6“aú@“I›L]ö~çên0.*§ãÏæcžânQjŠ0‚ôêí‰6j®ñ1ÚC%g§4ß\-GhÙ3Ý•[èàH"’—ó@ù>¯3°+©ÙÎÿäÜG½ù§W3r˜ÚKn>œÒÚ+{ç²òô·Š¬´ &…ñ6u¿_zêmeS)K+2òkò± éØ8ôÝ‘c¼3L®ÝÚ펪v²WܸÚßèÑ‘¶Yຓ@§1"kâr}õqIÆþu ¸ ¡©[#ØÈôð¦‘w“f•—C?s·”b Œ[XAÅÊ Ã=‰‹\ ú„yäüvJe¹•ÙׂfÁ$0²¤y£ž}:ŽÎ Ø­ô[èr‰zØ>Q†Áx „“(® ~ 9Å5QþK¸ÓÓÈÓ&)±¶‘9jÜa¥«`¾ê‹A8¤Ì×– ”©¿ƒÕ"“à†;bR3¸ “D©W væÇ)ãϳS›Î£ŽÚÊ̲A}ÏÐꂇTÖ‹¾DcXHwüWE>Èà‘'•:ЕȜÄqqoie4>#÷ -&}ì#}Fí5pÅã±ã³*"•(taà¡ÂàÅ“"Ñ)®BÜ -·,AEexj¥ŸòÚ†›ûu®RH×n_¶n¶(ö¶Îâg'v9HÃ^%Ü!?¾"XÎ@µÛÚ6×›R÷…­‰¯ÌÎyÔÆá=ÀÏ‚ö¯–8/Ö3VõMæÂ»8¿±ÿu!ð™rP@ÄÔ¯ ›“øqSzá~åjwñ¤xTèv¨TÚ.'ÿî†>ÐäÒš?Î`XºùÊT÷:™—ýÌ Aã?”1²\@ðd±Fâ|ɶÆD½‹°6õ Xõœ“4„,ˆðÁMÝR¿#÷°Œ¥&ìeõ¾­E„›ŸväÊýS²š+vWÈãsJÿËžýxñ¢Ìœã{ö[·>¦pQ… tÿ³òŽ)®Ì ¤Ç`ãmnX±^xõÚ-Ç®“©=®æ´|t¢Àr…βþ‰¹!ɰvÈ ^T¿ ßN‘k1½9¥#œÞiÔ[@ÝöSÉ‘’è&ÀÏvš¶¦_Ö.¯oþUœæ÷ÙÕt÷xLÚ½êø‰H/ ‚Ïm…Òfè¿ÃÉ‹.y Æ8Ô÷KüQE¶p:•7'§Nn¾„ì 8lh˯îo=›}¯¾•9P}Ví{sÔèÿŽïnsÀXiVÓ”íEI€íH=ÒƒõsºK—Î{ÍÁ»ØÒcyO€g›¡P˜û‘–õ‰¿ÉÅþµ\3yy8#;çtº2‡ÿê[mš ©í‡ÿ‰y0˜ÚäQv{DŸ×®ú3@ä ã­h½lXÂqKž!®!õÐÖ¹ú§çÚ‡ŸîÀø–%5Pú]×|¹õ´††#i2‰öíÍý Eäùì›êLÿî­ 7X¥¢)E»[Ýá[Z$ѾƺÅ–½¿îKšW¤¥ÒÜíešY#ñÙ,‰íÞ„ì.eÀÒ<Š[èÍ؆,I#ŒôÒ·5´BÚ ¢6Ìå)¥ÙùMP™12]ž Bäã'‘i"’-,¹XQ3Á™€Ø_@øå©ÆY5ÎŒDdm P Uëe*zï =ý…,òc‡-¿gCó½6a‘¢1ucô°,a$õÖ×zܤ æV‚òÀRå “”$}ôÇÅ~ï¸9EZç1°H.1.–j¸¢î¿Ÿ{—N]aL·.7 ¦¯€DQ‰l¨°Ùñ‘±89Ågg˜­vDm˜ê^żäžïðDUV‰.’‹ „OX“EV½ñúůſ>qáz¯™Q}(¯#¸MoNªþ‚,–-ç~bûc–97þƒÛ W_ýÚ 'sžNYO j]feÈu´Ê6ó݃3¬¥Nâ‘+1g¸`äjÄ;Vn6ý²pf_OBdkàžpìuÕqž\HM…kûÇbµY2}¶PÏe'¼ÒqCG¡[Jì7‰P~Oëê ’k¬™‘Õ3“/—A ]%†°h@ ozWÇlýÒ0—u¹rî[ÿ˜KŸ}–ún²gþÌ@)E¢ …/ï׿•A ã욦›ŸÝéFz ‹ìӓ͸B‰@[Ë %Û×á!Ê~nÂ8o¦®H#7±=ªðG Ÿö¿qÍÐóв`Kaÿ„íHÃPXÙóú{ Œ¯Þz“ð|\+Èç|+Nn{W´*ã›Òý2†usá1\$«J÷‰µ®E3Õÿ°Û (ÓÁò‰¥Ql µÓâû¦ˆ\õ€V 6°ì¶†RÐ-]ú–SÑM¬´«}.ÒZ+í)BÏw{”û]ò×¼BôŽ £[Ú~š–’‹©–uÖêk‹§*ë[“Réù½¼ÈƒKñ^ÿWñ.ýyx^èS¥`¹ÇXñ T9 Á‰}­ ,f%cò¶z¤Cdð°‰/oûXfÍÁgî/òŽæÖLb ãz¹IÑ9z,çik߸m2ÃHßè:ÚÈ™U3±6ê›ÐÔVà-­!S)­Tµþòt —Ãߊq‚üãÎ4zM’î=03mcí(f4E×Ù9¨˜Ji#çØ$Ö€²‡.¼¶½î—f¹OÛša—¿ÇFaöæoËFV¹68qc•ò:u[P¯_«eÒžfàëf›ÛÖ»$;R¾íŸÄ=§Œ‘!MÆ—íS±(ÙbéLIçoWÙ>òŸ“î铳ÒÓ¶—S{Iî=C.…ŠÛÍ'ÃÎ?^“Hut†q§¹›Yn—¦´ˆ®1o2²òz.½ :L?ÑÁmt‘¦×x) Lìå\èíöó¬©K•a¿´¬p\!)ó §÷¯P#û…?#™ðÆÎêX58æ»ù޵¸*l¦&#àp(müL‡3–B±AóéV$®ò÷\Ö^›G4–Øï”y³Þ°"Q’©s¬¯aò—R%VªwPRõ±ûoÆuÒÍ‘?ºËèðÔø| ÑjÅÆ ˆßFZ© Uíçíw¦³ÊåÝLeòt—Þ¡£±ß\®]nfÈ yø*e6úŒ.ü¦”PèZ Öâ‚ º!ýi…ï«{ï¤Ý†S¶hÚëêØgeyäÎy&úñ —Ê6#ˆýF(;`;S÷íþX·à‡«¶~Nþ>^„Æjß½#䬭VÏ%Þ†„Ød8tLí¥ƒÚF:änœ_ îõÈxrr«åô± „àŽV¡ÿ—;œ!ÑÆüHÙ¥©½IÄAº_\d™êÞYšštˆI˾§ãzÒ!¦%,¼"{–( –ƒÆiðÅůÓïI«»öe²¢mnøq¥ŒÉ…w³ËX!qõüm­~"œ.Ã5Ì08¡8’ëA&›k÷y»pÌÝg¸þä¡!O úÆ5Ò/#Fv²Ñ¾»AßaI$¶öùIM$éV]ÆŽÎtõº˜ ¹ˆ\€^ö*­B§X/e`{Æh ÎLóê¥5ÎV4híE@òEÁhŒ2²H1C|ggÍʳÏß® :O­ —B¿ ¸9’Œ0²ïXwTl}Š$4¨é]¸ô‘“l“ÎôFÝÞ¬½0ÆeSbŽ+¿’í‰<ÑL¸J‘õ^‹!hòï¡SiûA fa‚B$?é×ZAt¸# – =š ü¿ÎGú—çTûÔûõU¾‘‘+FèX §L7ÿ»-áãx»µ¿˜Nƒ`¯Á„6$cöÓ ÁÂBÚõ:—þN’mkCØ=ŽãF/}8âÖã^ÚDðAs¹´®#x_™•C«KT]˜ÖƲÔ¾½møa×á}üo† Vú´âä‚€ +Z*³N÷·G°.™ IÊ›êáS \†Ä?ΤRý}-Jzã¡ÖbÍ­wêµØµ„ …È™_’ä7¥Æó¥sRáÁÅtÍ­n—’ëT;×ñŒl CŽS÷Q’ë¥Õ>T¡ã‹#A陡¯:^¯hõQ”ŠåÚÆÐÌU fB\ÿœm˜\ÕMrYó^ù`—A‡ô–œEëQˆïZ‹’ÿ̪™¾Ð8#”Ú:V:ÞgJo’C‚ß0~×}”ætž4$¥ºóÔò°Ái“@b8epÚµÁµíÆE+˜b÷y–ßb´è³ò„«.ÓÐî˜v0ù›™H9û('%™ÎqÓÁ‰‡N«)†{œ‰b²§?›,-nþ x½k Î?UT…áʰ4vl”¥P{%ªÝ‹vÄuݦBnðC¿jI÷4CLJ§ì³Xk?fC¯åjpõß¿h×q”«ì50RHU*‡ÒØÌ-m‹:Á|ã5´;Q•›µß&¼ùì¶PérçÕnŽŒL‘Uçò×5ƒ÷©yéšI,OY¡*„áùŽ“)XLÐN5 8ð‡L¨Vá6«³W 9Q R#+ކ K¢ìýäXÜg.náá2Šá»í2KÒØ—ïTõÂÞËÇÝ„¥F0XC‡,S_ç-ÏÔ,ÞÇñäÄÜà–¸‚ ö3"¤m²wr•‰kBÇR‰žÎrêK¦Éå.yú°V:±Ü;â * 0èk½—V¼!¾“ŽBçýê ÔŠ¬±ˆì¿´Šú祽ò´D…¶ïbhtÄ·+ǵ!dƯ¢t'UNa¤…‘øÃÈÆ³RV»3J†™¢ñqôòï(Öô<xm%cw†8Òž gVlz/˜ZÀφ“é{ꇕõGï@îv•“pذU×ML-wmÕ<õ[ü ‰ŽêiAå8\0"k87Êž??—… Zî"¶B'ìry/ Œ¬G’ñ)Ê•Hœj¾d/¦EÞLÑÆÿ4×8éG©Vä^LÁÊr1ÿ›¤äP =£¿gŒ˜(F݇VÅÙY´ü4Åd•SÐL×ZÄÈÿ@,Ú†qñ­Û÷sUwOéB©Ø‹ðáiñŽ$B¿ü(Ü»/ÞœSÀÑùdñëŽ3{Y¨¸¶ÛAò¥ ÙS'¦êçX*=eB3;näŒ1 îr§ír™‚ÊGÛ¶šãÍ®TóÄÍ <¼Ìžá—‰šy³sWûCáÜ=ªÝÃIjÐyg!=°Š¤m¯\2£ö}bD!bë¯}ÂWœš¢åÐÓÝ=XØj´:×…*hýøA81 #$ÄcØWfš'çF]iJo·âY…ÿd çmdÃß B~!=9- •ÔµþÞ yMMfª¿lp‹ÙÞuÁßá°æ•¹6¹•]«Ùøk²¨6´%{둞: µ·Å³ë₍ïû`眺!T̺p=ygxæ(q–˜®ÈZn¼@ŠE¾ûö½aœe¯c6ho†UèÅ[óuø"Ðï¤}†aÌä“ÐÉ>:óˆŽ)ß»^]Q¬˜#õy«‰Câ«9åÜÅÎÞÑÅ…ÿG¹DZjœ³í&⥎Aà=ä²½um©q>©jJF¨’!ÙÍÀZ »{Ê^Š©óf!— o:Kç\tÚJ8™X@ —úÎj Ü4«º;07:MóÑ!©çñ3Vï‚€dܱ MÝppËTýµ]÷?lórˆÔÔV/b „Ö†68¨º.'“[±$ƒ<בiﮂ¶æŽˆ¿4 Í®§ðú€1Üñ.†úôÂ~pç”d>?ØÕ¾æw‰8­±P¹Lçbð\8Q·Ì²mïf\ ƒe+‰õ½Í® ù?Êìªd)dœ“ÉóŽ¥µ„…®W™®´ÑIr$YB8v@Ø 06Šf%¸.ý·Ê+1_Ê£€Ìî^Ѥà <•ºîp[GépÏŒwÌ ‚ìÙ´EâÔq6ÛªƒñË´×›ý_ƒ-/¼‰kç qNúãKŽñLDu÷ïá’ c_DM!ÌâçwsV·¨¹ -ðUÓ÷ãØŸA¯Š´¼q®g@Ukwíìp~SE9½tݲduÍtÿ}*Á&ïYezÂîÛSÙñEóÝâ=|çÁ Ʀ ;ïÐ<_oâLÜòÔ|’'Y÷ûÈá=‡2¶y¢nï²Ì}VœúRìtOæ¼ þ•A0gÎ:KÉ{‡ìµÂ]è­I‰}/àÀjóÒah•_t ®I$5‘uÌ&.µÒq¤2ÍÎ^¿w…dMMOè1-)zàuñÃX<Û]ÖIâ ŽÈ" Ý,ˆ/g® €ã|õ¯˜>éŸu¦¶ËÀ9ÃVÿûžÉÒ/ò5k€ì-!“[Äø&þñŒ FÖ Ö{ëñq±ØõwhŽ¥ æéyõ3²]—æäéˆÛÛP\5NDùìÙžožcAÈSéâVm>[þâ}kE:Ž"üºÍTÆ ?WŸ î'aï3QíjŠuØxgÇúnrj–èMQyµäçŸÏhøV´W†–&ÞÿÛEÙ)ó–È«<ªýLÓmÿѵJEöíÄDâ|ødæCj¹žßA§+äˆ׉Ôwˆ,Ý rcÆýMÜ­«M“ÏפkFKg˜ÓÇ ü¡ÇZcxé ìÊ)lQYŽ*Ru¹tO’2¢¼^½òµW@ýäÞ‚½ƒÄÐ`þÜÂT¯šÜ† fCpK:©Þl)Ü9Øc––Mž¸¦ß®&)n,•ì‹Nñ%ù»ß4ç{Ä?3Ð^@>èÈJ„.òE|ƒ 0¡×\ì§=e÷7üØsÉZ$ 5Þ«w? \."<$Ó)´ßpäRwèƒÌß6Î;nì\+@°ÃiÍõ”ë–Rxã7Öî.V€~傦Bmk4ñ@áé‚%?-f$1¿²ÍFbñçšã•²ßPïeCS r:ýJ(üqÀf‹µ9âç˜äˆ¶L iáØÕÁNS(†î)»%w–yûyðS(Ô$("­¤-­£H2Á‹²f>šËÙVïà$xbƒ‹žsÆhÃ%uÎIíòm+'ðNÝçd럈?¿põË—8}ÖÖÊàpÝbèî§‘™ßè¬à̈ŠPÄ«¯(KL.Íg ¼kH¼a+Œxé‚3ü1˜›+éOéçÌ…ar±7& ëÁvõdVóùOÉËê6!ï3ÞB4ÿø,F|sÄ·¡ž¦ë¿õw@*B`Ð_]æq>ù/ èÃñ½ø×Ô"êÎÇNiK&”…=.ÿh¾À-JÓìæÎ;RvÓõ¨„À;YD¯ŸðÂpâšôT>JmÑØ„§3a¿ÕS ¬J+`Õ=*ãŒ1o05ÖY¼ò1¨k¯2eîÉÒÚÃ:ÅŽò5IrÝ å¬WÚ1Ë1(£ X=~É`ä5ø;B,ƒd—´5ŠB KZOù>üS.TÝ Š é%*¸ŒźþíoOú4yJ˜Ô8"B‘¶`G YSôâ#Ÿ‰ U¨I• 1ú÷nùäîy)~Nj7÷•ZÜg¹\|¢¢EÐ «ÿ«0‚ŽínSîÅÀ¼“UúP Rf=²ÎÒòUE¹!¿_’šïÛ?~ åŽ+æΰÔ[£!W;þ#„µ‘ú%‚tqšeŠ*ÚÅö»˜ÊŽLð û‹òY9WTd Ê+¿vsÓ~Vò!@œº’šÝ ;éWÉïlÑÚUéf€æ6€ÒG¾´y ²ü–¥%rŽŠLb@çå-,ßÚy Á¥Ý MxR ä¼o^‹ÉüdçN¬Ÿôf¤ÏóÄåmà% Û1÷[òKÇ;‹.žúBºÅ,ý¢á:þ£þ^§ÁÌ&µ> ê/!†3|Ö«SÞ—yTßF?øË0E22¢>¤jQu?ìžx€©’ûî~$[Ó«™8ô¯ù{Üw!?I‰’a ×)!ö@à°½È@¶ jTz\žù+þU¼•øÐêêÆüß¼–T½Œ‘£ºBŒ+.ú€Ý%Û'±Zú oMO™œ8b(ºÔßg.ÙØE·ácGZÒËêŸB—æ¾Wx‚»5õÔ0¤'œç##9]!@&˜ÌóÝ¢FýL†FŠÐ*ß•Âa¤6˜ï`­£ 1\j¶Œ©ûܦ#ÂôÊÁ«ÌÀ5~µZ œ¬;s–í¹ØŸ¯o9­5 :!Ï$½—¡`môŽJºÒ¨ÑC+œþH;q !LO‚ñªá’b5­O~ 6D„o§õÉòÑkCZå¹@ȶD›©t†xÅø Ûõ˜¿4‡ï¬Ž<œŒU§9ë—žægù²Ÿ•ë–zÛ"rU¯ BœŒ‡,¿|¼?;Éùñî¾²ñ0sê@aŠ€Ë³éYòó:‰’@5¦1ñq±0ÆYDãc:âPoBê.Õ­²WZ²ÍDL÷·ÞÜ ÅìZ­°ezž> \_¼7tm–îÓë9&¢DütÉð—Ü•hI >kæ…F€‡þÒLöÑqWPBX`á#¦´«ÚæAßùþƒ#ÎÓXn³$u1Y ÀuÃ|þE)èx†H¿õ.®-¿\t¿<7¶dÆ®(ìTü¾%ž(Í‚%Çê–¨«¸™Ç¨-’7ï±=ªaß’Ö±ü@†?À‘ÈT¡}ý‰öó·p ÷§àp(¶RW‚»\ŸT@² ž½îâ²;)BhôR9ïþ÷ï߮㫴ßì?}_™‰l¿ &°Y–ˆèê½odŠ‘ô*ƒïðÊ¿dD«´âí€Ì ³ûª]›õëha >ôyTMp´2‹¸e q¬±8/ߟjÈ ÚǫɿÀÏ k³ Y® ¾ m’èº].Ê}ûß`æ4Ê1Sy˜;€à,ɯt r!Mƒõ1Á:°/i)¸X£ŽŸçÌa‘Xö–ø 4ðˆRš(Œ”ŽÕÕxºäIfQ3så/ŒÀ†Ñöž5ÒÕÃ',ö÷,½Ç²ªu[rwÝþ ÖÏn i á‰Tç†Ó9XÏs§³Šè \·Ø =ºœôî˜1 "¸coäÅ/ê¡ã……¶“ ƒéžk?'-ÍHWªk …ukÈ1 0f›-ÝД˜¬5²gñéãªøÖc˜» q;ˆZÀî5\ÂÓY5*÷[àØx–Àñ¯ß R»Y b¢úÄ_(%…×L™ëa h²§5(·yÓn>‘›éDŠ-¤Á»ˆ/HòÒ{uäef/TCIÇ·R+FlÁ±øÄ» ÷UãÚ8ïúfBûa¦Þ<š’9 ¡ŒUvnɆŒ`g K³1–ŠNDß·¬ ÇL§? :ÚYS3oÜ 2b~ôg ó(š/¨y'æ”´¸ç@I%JšZÐö3 lülŠY±=± ^!ua¯¼eAöè(†Ÿ34þܵÿÏSºaþ^ÀrŠÑrmgG`+n]8Ôī¿‹ù¨ˆ_Ã`ñ øHpn]á¼.2Dìò{µ¨ðDáÁí¶|’>– ÇÓö~­mWÌÚxI#¯g—zžÈ;‡*Õ~¶ïÅ ¦ëÞzi9§6@û<û²ÂÔÆºI&o /Þ…t¯Ô,ˆ=?«v•s»Ü¥ÒÎ @™R)e~3ÛÑëûcêhiI—·Ë¸¥9«¾9Ü|F{ÿC2V1°÷ÞSYêôše@µ 9_,˜ í ñ9%¶ó£ùÂúÃbŸ…#æ2ä`O\úÈf+e^ìϦê«_~úÆaÕgÛÖ;¸i † ï‘:§3mmØÁÚþ„ Ÿ{w©!dP-ÆæSêg£0ºÂÁ[Ü{efOŠ ˆ–s@¶ôÏÑ_!”um²hñM}õ!å‹ë+Ò&‚6YH C‹DµÕ{_ÅÅЧ0T7m`)C/4 Õ/a‡ •uâ©}XÛãcF ‰›Çš“SéºÄGÔÀbýzðó¹EísÚ‚X¥çs ÚÿÙ‘žÿ:°÷dþ%ÎN—fÉóá ôVÍó ç掶¤CXÏIýóê +{A:3Î×\4ñ&0]-k•ÈM$8£Äúâþ…éã§jë]at ¸ÿ±/æ‰h& Ì(ÞŒj MΜûÊ+˜«ó½ãϹ¡ÇÉ8,'ÂC²Y0‚ùnù­eCÄ«®"¡Å«B¨û1øêåŒùN>R.pñ1ü§)L­©Ó…WRW‡ÛZû°Ålÿç„£ª–ÝŒµé¤ &Ì‘ÔÜ+UáÌ&Ú«u´ýÝÖ~XªÏ"cÁ"l@u¸Ñ0¾ùòÈý×Ó'Ø ‚S¢XŠÀãZ¨\%téÔrŠ‹n¤¥5Ìç*¨\QzáÆ=@%â|¿øÑû.TÂO"µØ¸dGúE"sXYà!¾äx­}M§ÖÎAdL¹Öï„\•«üóͽlö·¦²ñeQMY¼’ãÆÓÑêìÎâÐò^§ŸºŠÚð­)è›òê)—*ñæö} q ç‡ 0‹ãe¨ÇpwvšÓ æ¥I¯¼ØW‡ô,2_FüZlà:Ô%,,Ü!º9¾…yÓ”)[~CZD,ÚëM“ß|NÀù_¨ÃJ[‘1hëäÔn¥Å-ª^÷rsÌ«§ymLBÁ÷Py‡…ÿk#”ÑQ{óxÅôgü[ ØH¬­a¼ ù¯¤w¢wGÕŸù^;Œ<ó?ó¨<¡Ý!ÆbNmÖð<^Ë´™É ýPÖËÒ6»y^‹ù¡Ð†¤¿•;Ñ„@Š¢¤vf+]û²2{æ¡ÜVFªÚó¨e¦&™k1ÕVîj6¼_£™f½×L2bJ¯SÌÙmý«3QIÜ]¹»ÜÖæ_Ÿ£‡ð8øŸe$ÊIC„I?(RaLÃɦÉÌÌ¡IžS…9È“4Ì)Éš Nr!(RyB’xJDPÊ99”9ùe‡=0ÊLå…%™3™ÌÊ™¡œ¡Â!(S&RæaJIáœ(S'0§' 3ÉL”)̧ dó‘ žPæg0‰2PÐÊL°³B¡’PÉ”šg>i'Bee I…8RP¤ÈNd‘ œáNLæIL(Rg æS% ”ˆ&JLˆIC¡BPˆJhPäÌÊB”“œ¤ÏŸÐå%%% ¤ÊJaÊB„ó–r$™He$ÉB!†™?"Éʘ~s”3™LçÐ))ú(I¡ Rg(g0‰3Í’ž†s¡ÈBÌ4“œáœü¦ffL¤œ¡ÉLšOIô9CÃ2|ˆ%PÎ ”Ã)%2PˆJ ”(p¡C9333š…0ÎhsC œå Ê,ü.)=!å ¦NRg” &rPÎIfg0Ó'aš…“3æRfe B!3™)˜Pá@²J8PÉ |ôÃ(PáN†PÎJB…&PáÎae%32’~dBag2’|¦s@‰0ù™’˜S33' ” ”&D'0²M ”(P§9Cœˆ¡Ìç3œ)")’!2!ÉÐ(RPô9èd¡Â!†g(Re'ü¦JB!HS)“)’L”)8XDe0ó ™”)2“þS'”Ÿ)2“”ÉùB“2“úfs9™C93% ™ÉÐÂÃ)2†rP¡L”?)2… æ'ÊL¡Ìå'¡ÊsÊdò˜s†JIL”Ì)™2!†“3) RLòaILšaIɲR‡&sœæH… LÌÌ”ÉLÌÌ¡œÌÐ)(D39%'¤"‘ œŸ”2S%% 礡I”ÉL”3’™™?(S0РRgŸ”Ì”2P¡)ü¡É”„@Œ€D…„ÊM0”Ã33&“¤¡Ê™'ÊJ”™ç)™’†Éœ(2e„Ê¡ÏÊNP¤Ê)’g%„ÊJœ¡2!“™’†IÉÈ„ÊY&’t&S$³,¤é†…'% IC% ¦™”8“ÿøÉœ@ÿÿµÆP!9BR‡™“ÎPðå”$ò…'3% ”šdˆp§&†NfaI”ÃÌág2’eB„¡’†O”)™ÌСÊ”¡œ"9’…&i)Ï"(fg?C'ÌÌ™9œü°ˆ@¤ÌÌ¡œ™C8D2p¡Îfs9œÏ$Iœ°ù”…2†s rP<“9))"˜xL¤Ì¡Ì.I¡‰˜RR†r„ô œôÌÊ’” JH|¤ùgÓ0¤¦e áLÊI4Ÿ”Îç–ÉL,"8r™)’™œ)3I¥ ¦Â!™…“3„@°å$ÊL"ÉB’ŸB„ÒI¡BR‡‰™“Bae2S’…2O …(M ”š™aNfe…% r“)%$¦)(ú¤)(d”™ó"y2†…'¡’†L¡“˜\žRe'”(p¡fS „¡¤ÌèfP)™(hd¤äC‡¡ÊfRdBe ”ÉòÂ’‡)4(g0ˆd§)<Â!„I<3’…&yB“œÐ¤Ê“ÉèfPç%2RÌòÊáBÉ™”ŸÐÂÃý&…&t0òtÉNL¡Ì¦r‡˜RS aä:Ï¡’… Ìœ3C9C9$C…3$C”„ˆ™Ìó dÍ'L<”)2™œå$¦IBæfrD)ffg% !JCL9”3% LÄP¦NM0òP¡IœÉfL¡Ìç9C™™C9œÉÃäÌÌ”ÌÌ”) "™™’”š™¦˜g(sò’P¤,Ê™43ŸC'™Îg3™I”™L„LŸ)™:ž, ™I¥œ3C')’s$@¤¡I)(S%2XD$Cœ"B”9œ¡œœÏ9C 9ž~Y¡C˜S… Ð3§$¹œ)“™™œÎPÎgå2J“)(r„¡C“2e%œ2™†…%™")œ¡ÌÌÉB’g fg2’rS2áaœårD)3‘„B„ˆPÊ™C”„Cœ2““4ÉLÌÌæyÊÉOÐááC38R†rd¡’…'8D9ÌæS!°"dèr’…&2e%PÊB‡“™2 P¤ÂžS…9ÊNPСə)˜Rf’…&Rr!C”(Rri…2s2RP¡ÌÓRtšaä¡I(p RfP"9Ì‘ ”)(RO% 2D9™ÌÌÌÌ¡L™@ç)œË3”3“™á,(hRC (žJP”¡=ÉäÓ åS3œå æe2hPç (d¡’“"r!3ÎRg(L¤ô̦B”“ÿ)(ICý’’‡…&|¡C“™ÊÂä/”™C“(s …|ÐÉü°¤äB| (Iò…&r†)™9™™)?”ÌÎ}“‡3”<<’…!BÉ”3Ây™”9”ÎdI'”™d¡¤¥ Ê9BrPˆ„BO’ F,ý ”ÃÌ9’Ê“2rS3%%%% IB¥fP,:R†B%…JI”3%‡3(g0³3™")’™:JfP"\¡IŸ2’fs9™‡(̙ʔ"IæD) ™>P¤(RrR¥!BÂ’…$”(JPç¡IBÌÌÌ)9™™™™)(r“Òçô'ü¦aðô%Ô̔32P)3ÏŸ(y…2p¡Ãœ)I<9”9™™™)™Âœ(S3™™™)’˜S&D&D3„I”Ÿ)9I”ÈD˜ÄÇÿøÉ‰@µÆè!339œÊað§ dóÎfaÊ!s LÐÉB‡ N“L<”)8g?Ò…ÎP¦L¦JP”¡4(JBa&P¦Lä@Í °¦fM çÿ¡Í L¤¡”38Pæ…&RS‡˜g=De É”„I”3…0¦NJJHD)9‡’Y@¤¡™å2P¡áäœÿ)(L¤”“å9ÒzM0å Ì,Ϥ(X&xD™”4 J)9’™’„@ááe'I”” JÉå2|¤¡(p¦NP¤Â!Â’’…P¤ÊM Jfd¤äC'30§!a”Ù”8D8D)(Dœ¡)™†R NÏ)>èr„¡’L™@ÎL¡Éæs0¦aIB‡“ˆL¤¡™™‡’áB˜hD $ð¡I”Ïœ¡™)™’ P¤ÂŸË 9ÎRg33338S3'333$C'%'IÒP¤ðä@òdC338D4(Y3”% “2D(JJBP²…&fffp³ p¦e J“2Pä@å%(OPÌÌ“ÉJ™)’™Ê̤”™IÊd‘…3 fg3™ÌÊ¡œÌÉL””ÉLɦ“å2t °”ÈDÉ32S&’†„C™ò™9L(D% ™I9„C%'(rPÎe&S'¡BRˆs:œ)’Xe&r’e!¦JaL™IL–g9C„C'2S dä¦yùL<…2hfp‰3IÒt(d¥ èÉÌ”'C &RM% s)2™(”<¡Î¤Ÿ 4ÉLÌ¡š<ÉÐÊLˆL¤¤¦g dÊ™“IB“†p¦N‡"8e Ì™B‡3ÿèp‰(æJB‰“™™’!Â!Iœ¡ÌùLÌå!9ærS%2S9’’†xs¤ô32|¦NPæRz9Êdé;&ɦ¤¡LÌÏ’’ÌÊdèr†fL¤ÊfS3™ùNÌÌÌÌç¡ÎD’RhP”ò™:Ô93™Â™9™ÊNS$ГBe$¡H_üó9CÌæ’…&)“òœé(Re0§$ D”)ÌÊÉ”332S<ò…d¡É™4ŸèäŸ)“þ’…&RzJ¡C… ‘R…PÌÉ”™Ê(SPˆÉI„”))˜RP¤¥ ä”ÉLæRIILÂ…!O¡= ý&”'˜S$òS”™Ê™(S0¤Â‡ (s,¡Ê2fr’†fs¡Iš™Â!Îd¦M0òP¡ÌФ"!JIpÊNfs<¡aš,"Ê¡“æRe‡(Nd¦fJd§ 3™C%% B‡ =%RP¦g„¡äÌÉ”‘ æOš?ô(JÄ@°ÊM0ËHD  R š™ÏI‘ Ð)HR’˜Rf“C„I…)$¡ÏI¡N¤™I”” Na¡4(9Bˆž¡43™žyC@¤Í äˆd§Ð¡Ìÿ)“ y'3”(s39™…2s L¡”™IB…!BÎYô2r‡3”3“3%9Ÿ””2zÎI’ˆd¦ˆgÿB’S!IùL9œÌÊ̤”,>`A“)%8r!)ô2„Ê|ùBÀˆrdˆd¥$å B– J™’™™æyÊ y‡(dÐÉBLæK32P¤ç)”%èf‡)’™))(RLçÐÉBe&†y™œÌ˜e&)2’’†‡))(sÒzaòî’ÿøÉŽ@ÿÿµÆÈ!å” 0¥ B’IBÊÐ" XAP¡2˜y…™L””33 Re BœÉL)’”)8PáLÌÌÎdˆað§2P¡’”% Có”3™Îd¤¤¡Í'I¤–P<ÃB†…32‡(PÎå&S3<9 0ærS%0¡IB™…… Ÿ”Ì” NdÐ)ˆd"LΔ)3Cœ¤ f†r„¡IIBÉœáá‘ 43…2JL¦OC”š2S”ÌÊdæfg8Y’!™…2Pˆg„ÐÉB“‡‡%gþ~ÿ”ÈDÌ)Ì"pÞ Y…È\’Ê… Ð)(“ÃÉ40Јp¤¡IBœ…™™Ã”2džáe ù¡&„…òi(Rp¤¡aNH†fNaæg”9…(pˆ…™Ìæyù¡BxPæg2…!9Ìó$C%(‘’‡&p¡I™Êœ4¡”ÉÉ32P°ˆJs3%2i…2hs>RPÏ0¤ÊJžg(y2Â!<ôš2†p¥$B‡2fJfP¤ÌÌ”,)"&fsCB“Ì‘0øP²N„¡Iša)†Re3ÿ¡…˜Sž… Ðô”šd¡Ê’‡˜y(RIr8S39ÎRr…ä̇É&PÉáË JfPç†áLš¤"ÃB’œÏ™”&D˜D8e&’†…% H)I”Ìæ‡ p¦g ‡PæfPÉÔ9IL”ÉIB“)’…&zB„¦OÍ!))(Y2„È„Êfr‡(L‰2’’ Rg ¤È„ó2“’M ”<2P¡Ì–P,2‡ fPÎsÐÎPÎe™JË…8RP¡Ì¤ˆP§(faÌ,™C˜S… 3™™(s)…(JfRfK0§„„³%)‡Â™2†s<¡ÉÌæJ9”ÌÉL“†pˆR¤ü¦N‡"(r‡‡)˜Y0ˆdÒIfrS3<å&y™…&S32†gNICŸÈ¡BPÊaòg2†rPÏP)’”<”)”™LÜÍ…&fPç rap’Èr‡)(hP”å2PÉåLÌÌ"L‘ @‚œ¦L¤Ë J™(™(g&e ˜s%% ÊaðÍ ÏC”ž‡(S33'% J,8RP))ž†sBf¦ÊL°¦OžP¤”)39CC”„B“9C„C™LžS8P‰ ç(g&PæRJa̤Í™”“™˜S2P°¤³330ˆdæO…8hR9C)&p°") PÎÌÎRe32S2“"…&g 3Êp§'(g0ˆLÊ… C$¡C…9C”2Pç)9œÌÌÌÌÊÊaÏ)™ÊHPáNdI„IC)2’’!ÉB’gLÉœ3’œôÂ’…3' (J3CB‡&ffe$ðÊB……”<3… È)èdˆ%% ”$ˆdˆR) d¦HHYœÊaÎP¦aC33…8P°Ë f!Jdˆœ“Ã9ùC8D9ÎdˆL"d”Τ",,"ÎP¤ D™IC’!BSœ¤¡=™“% ”ÌÎr“œ¤"Lç3ÌüÐÉCš™C””ÌÌÌÌÂÂ’!(S…9™Ìå ”’IžP¤¥ ðΤ%0”) RSúÐááC”2“(S$¡H|3IB™’™"Í æD’|¤že'ý0øhD!0‰ s™™)(Re39”ÃL§aBÂÊ’á¡NÌÌæp¤ÊN™6‡CÿøÉ‡@µÆØ! (e&J2„"d¤¤¡L)Ð,Ê)ˆXRR~RNdˆNaÏþ‡9Éœ¤že$¡IÌ”) 2RD JæRO3” dÌè™Cœ²‡™)“IÐÉóç”) PÌÎL¤’˜Rs 0³ JI„C%9O&™)48YCÉB‡'’„HP²fH‡9Ió PÒO@³4(rO”œ¤Â&O P“LùI”(RPô&˜0¦g$¥J…)%2hd Rd¡I”$¤’!2 s‡˜y‡˜xffN†J†hNRP4åŸÓ…933&˜Re!äËɤô9C”9é†hd¡…’~S'9C8D%8DÙ32RRD NL¡”™¦J B$)ÊaN‡4ÉrD) ÉB†… Jœ(s4ÉfáNs̳)HP¡CB‡(sB‡'(På d§(Sd”áfP¤)HS3%3%%P¦frJd¤¡IB’…“9C)3ÿI)BJd¥0¥ ”Ìå ÌùLžPæÉÌÌÊaNLÉþ…&S39""ÎffPùB™™2 s4'BJaIš"(dÒzJœ”¤(fPæP°¡aB“=” ̡̜Ê% NRO2‡Â™™?@§ 9”9é(PСI”<šd¦r…“)(RS3”)3”32rS&’™,ÉCIÓ ¤™Ê¡I”æy”™IÿùC™œå¦K‡ÉIL”ÌÎPСä,¤ y™:)“)˜S&e$ùȆJ|¡aÿúaL“2z8s)&PáÌ¡œ)™’!’™)™™Be'"I432 FáL™Iç"Ì¡œ¡Êd§ $ˆR"LŸ)…ÈS3%™”8\¡IL¦™)…% 8Y(pˆRNIL%RbÏœÎd°¡¡ÿ”” Nd¡C“8e2P!BÉœ)9CÉù)<2ÎS'ô(g&sÒtš¡áœèRp§'ÊÌç(ry…’S3Ÿ?(Y3”ÉJ“˜y’!žC% ‡<¦f™Ïþ’†„Bg%2Y’™†…%39’!“™)(dùLÌŸ”ÌÎPæ‡4)0ˆp Y áB!3'3œá¤þN™™˜S< ”ÉК¡äÐå%2„æJfp§ fd¦J@°¡NtÌÊL¤”ÈD™Â™œü¤Ì””)(S“(pˆsú¡”%3"òs(d¡LÉðÒ„ÐÉL”ÌÂ!HSÿþY”ÌÉBP,™ÏÐÍ0°ˆÉB™@³'33 dÈÊd¥!BÂ’!(D%&IˆdÊd§0°¡ÊJf™¡ÏB|ˆL¡3%… °¤Ë%9ÎR ÊÌ)™žS'Êp¥$çü¦fLˆLÐ,(pˆrfO 3œ¤Êa̤”É(Rs)8S“9†… OCž“"a”…48S2J"™4Ìå')“”2s3ÌÌ8S” (ÊfffLÊI”˜D8s%&…&S$Bd@¤¡IL”É)(S9š¥$å&RLÎPÎd‰0²ff|Êa™œ"™3(™Éé4<„He˜S2e%2S&’… LΓ¤¡LÂÂ……39IBP, s'ò™%!æa¡’aLÌÂÂ!ÉÌ””Ìχ fg p¡IÌ)Éže&R~hD(L¡œ))“LÌ,Â!¡šy2k»ÿøÉ€@ÿþµÆh aœœÌÉB¡HP¡Ê¡™™œôž…9IÏ æg332R†re ”¡É"Hy™ÊOèg?¡œò“(PÐ"LÎfaL™L2Àˆh&rÂ’„ÿþe!¤-”'"9L”ÃÌÌ)‡3¡™Â’„ÊfO”“ÉNäùó9œ§)™™…8S3%2tÃ)“Ì生)‡˜e&39C3%É9”3’Y‡)(hRP¦NaB¡I(s)œ¡C”(|4ÉI¡)ÿèaf} 礗'IICšaò†hg™ð³3P”(y9™”’…&J˜Y„L2“9fSfe ”(J™ÏIèdèN†r™œ”ÉIB!'%™2†P¡ÉÂ…$¦M’áBPçL”Ê…0¤¡O'BP"B„C% )‰2…P¡¡C”0"J”3ŸB‡3C9”¡<¡Cœ(y™Ê9<šaÏùæs3(g2‡9)Ü)™œé= @¤æe$æD2P¦IC˜hD% JJ2“ ™™(P”"% ”’™)™™Â!™™(RO&’… dòg9C% N9'Ês‘&i’™)’™)(XS0°¡L”"̦Oÿþ†eaL’‡%&He$¦ffJ%&r“ç(s9CÌÎPÎS$@ˆJ,’˜PàA B™’†Oʦdä@¤¡B“"BœÊL*"B™„C<…(S!|¤ÿèPÎLÌ¡œ”2S)“úç)ÔÂy„BP „¡B“ÉL‘„ˆPòS™™"3¡HPСäåš¡‚™áLžI2„¡aI@°ô2’„Bg ÌÌ(Ráae á™HYBS0Ò„)“IC@) dô™fRÌ”2R†fdð²ä’!„²hP²äÌÎe2†Jr“Ê™)% B dáfe%™ÃЙI„C…(sÊœ"œ’äˆP”Â…‡3”9™œ”)2™)™’!Â$"&LÉùL“”’P)(PæffJf¤(D†R¤ž<ÃB†˜y…s„šd³8R„Ê“B‡2…¨‘Ì”¤žP¤ÊO9À°¡Î†sC”„C… Ê44,,"2“¡I˜„ÊJfså3 J2S39ÂÉ”9œÎg(Rs”,Ï(rs„C’^SŸ”̡ aN!@°ç(O…)33™”8Pð ç)0¤ä¡I”<”Ìßèr’’…&hRrS&†fg(hr„¡™C)³$C2“ ̦”&†xe!Ì)’œ"LÿȆJPÎC”””(s4ž’‡"ˆa¦JfM0”2P)ÂIO”ÃOC”)9…3$¤™“32S&!“œ„I„C38Y”"(r’…“9ÎdˆL¤é‡’™"ÊC:B‡&dœÉá<“Ñ…”2PÉC'Ê™1 ”ÌÌ)(Rs332S’!“ÃÌ)2……8S%3(s?)™Í Na¤é4(ry)’Ì<ÉLÎsœ,™™™…&Ðæ…&S0¤¡CI…“9™Â“˜y(RRˆX B!(D%(rÊ% L¡)Î&%Ì“ä‰À‰(R|¤¡0‰2e!Ð"BœÌÌÌšN†só8P§ p°ˆJH„¡aNÉ”æPáœÎPó'þ†p¦OÊJ(PáB“,Îe$üüå$¡IžP¡I…3™”8D32rS$C0êàÿøÉ­@µÆà!™”’S NaLÉ”””2P¤ú愞ffJffR¡“™)’” °“C9¦M% L¤¡Êô8D4(y:”3˜D9)(… dä¡IÌ”ç,"†PÃáèffd”™ÌæÌ̤Â!Â!ÉB“?¡œÊL¤œ¤(D¤òyI@¤"%2ä,¡Ïò“4r††S <§2”‡C9IB˜s>‡‡&PÍIÓ L™ÌÌÉC@ˆJaN¡œ<¦OÊI"%†NJJfd¦aœÊdô J„I2„Ð)ÙÌÌÌÌ¡IÌŸ?”Ì"ÉB„ÎI RPÐÏ 2D2S$C%2D&D'2†rs9…9?”ÌŸ)…8PÌÉžg9þ‡(frP‰ áLÌšL‚H¥&S2„¡”(i’™œÊ™C™å L̤’&INJRBÊáÏC”)2“ÐÌ¡ÉBR²’…'0§ s“?þe$.Oò™:%<°¤¡= ó@¡"gÊdùNt8D‡¤)Ê8r’¥%% Ì)œ(S&SR B$2!“™”9‡3LÎd¦B$ÂÊfp¡¡B™)œ¡¡9“9žP§ 38P,¡LÌœÃL”ÌÎPæg(s™ÌÿÐÉC9B…% Jaʇ †™†RIL)“™…8S…3’DÉò™3”% JJáB$2„óúJfp§&r‡% B”’˜S‡å9Ó%$@ЈJPÎ L”šLˆp¡ÊJ™C2e39IB™)™’™4ÃÉLÊÊ2f} ”3(s4ž“èd¡’„ÊO)™™œý æ… ˆO0òNg”)0ˆP”Ï„Lœ™™™™˜P¤Ï!32Ri(Rs% !Iœ3ž„ÐÉC””)2†Ra&RRD 2S'C 3B’‡"¡9@ˆs(p‚!N¤¡L„@°§”ɔÙ̡œœ¦g†rNIå0Í …'(På ó”%™")™œå&s„@°¤þ†Re&…!C”$ˆd¡Êœ)™™œ¦eæH’aá‘&s™”9ùLœË æffJp¡I™¡œ‘ žP²e2S9@²…332På ¡BD9L))’™‡“B‡3B“):)<§†rs"„B™É)“B‡2˜S…9™™)˜RgC”)“œ"(D%&PåÌ™gÒP¤šœ"…&†J(p¤¡2$…Ã90¦(S I¦f!JND HCœÊaædÐÉC”)“)=0¦Iæe$󔜈J”3(p RgúJÎaL™IBÂ…’R†fJœ)&dÊ9’‡3œ¡ær‡(g Ê™“L<”ÌÉL”‘ ”„BP‰'>IB‡’|¡¤¡aÿС–IÊS!L‘„ˆD2R†p¦aI”ÃɦI<”,(sȆJ}’…†Y2’JP2D)2™4)(S' ¡œ=Ì"Í3™Ì‘“™Â D…„Ê“"(d¡œé‡ÉФ2Â…&e Iá9(p‰ “„C˜Y@²e9Iˆdä¦J”3Ÿ9I@#0ÿ‘LÌÌÌÌœ¤ô 3èg(Rf“"aäæs”™C”P,94fNRP)3(O3ÊÊÊ”2R†JaäÒ„¥% Òzd¦På™ÉL–fs9)™™BdBNz"= ¡Â!'(Pžt(XpˆJ@‚¿ÿøÉ ª@µÆ g™Âœ(Y%„BP‰% LÊ“(r’…2J™””9C”9Ièg(RP¤áI@³?ä@æNg)'œ)’hd¤þ†gM!) RPˆ¤äBe'L‘É4𙡙@¤¡Bz¡L9šaLœÌ¡’‡†…2†pé%33„BP°¡I™B’”)™4ž“¦¡C™¤¡aO9C… Nd§™™™”<>dæffJ‡ô3’™™ä¡LÉNaЙ9††M!šäHICɦH†Jd¦fg$B“:ò™“æRg)2“9By)CÉ)’YРD™™IÓ†x !NC…$C9œÏ= IèJ%(r‡(e&P¦Ng9I”ÉÎs–9™œ¡C3d¡Ìú2’…%’…… dæ™IIINáfK33339B’‡2XrÂ…2rd@¡¡Ã””ÉL”š4(S2s s3339’!œÏ(Re ”9ILÌÌ)3ü§:9 =ÉÊœŸ>e$ó)3”œ²„¡C38P°§å8XD $ÊNPС̤¡C9ž… É”Ÿò™?СÌú"s&På ÎáNÉ”) ’t3…”8D2RfS%32D2S9Ê™I)’„ÊB!C)3’Pæe$¡IOB„æL¦r’!ÉC“9™˜y4Îg30¦L¤¡Î˜y… ”)œÎg3) hÌ4ÃÌ”"C"I”“)&g3”)(RP9™Îá¦HÌô3Ô42PœÊaÊ&JIÊœ,"ÌáC…C–çùʆ’fJÊaIœ¤ÊdŸ2’IÉý ffa†LæH†p‡<ÊI"9B™œ"„Be å IB’…$C'(d¥B!·(sB‡“"(Pæò“9Ìó™ÌÎPæPäÎffM0òS$B“ÏIé= ™C’‡3”’„@å 8yIš33&@Éažç)3”™B“%2t3”9ò˜e%! D9Êg‡' J32†Jd¦Jpˆs')'BdC'2e&$Î)’™˜D8YùÊIò™I<”Ì”ÈD˜S2”ÂY„BP¡äô…9,"…2e ¦¡BS…%3(JfdˆP”"(g&†L¦Id¡f'Bú%?èfP”“2†…g(g&d¤¡ÊÉäÙ…9œ¤ô dÌÊI¡†” RaCœ§=30ˆd¡LÂÂ’“L>JR¡áÊåB”³3”)œÂ$Â!œ(fRe339…2e$@¤¤ÂÂ…'% J™¡™Â!HR‡:aNe Lü¡I…3 ¡š” :4 P™@æS8SœˆPæpòP¤ÊJœÉI2RP¤æfxÐЧ'(PÍ ó”8D’†aI8P¤ÊJÿB~faÍ !BS”Ìå% ')ÀŒ’YÊp¡¦S0¤Ì¤Êfe áùLÎhS9™”&D…)…!Jdÿ)’‡‡@³))“L)ÉLÎt(rfJ%œÌΡ’…%% L¤¡I”ÉÉáNPáær“”9™˜RaáC9”Ì áI”)†Xf“‘ œ¦fsÿÐòPˆ)¡Cœ(J|ù”8RD%(y<¦dç30ÒP²adšaL%Ã<ŸC'å%Â…†C%))™)"™ÎPæK˜D˜YC˜D8P§B@­ýÿøy CÔ@µÇ(©i*”´¥¥-*ZUd«(´¥-IKDÊJYeDÂÒÊRË*YJ”¨µ¢©KQT¢ÒÉ•)ZXš,µ%,©(¹)QjJZ”©IjRL¨šRÒªT²¥)K(­%rWJV*X©bÊ‹R–T¢ä¢å)R‹JVT¬´¥©J–RÒKQj‹R•µ*KRZÉU%©-EI‹E­*¬©U’T¤˜š-,©Jª\²Ub¥‹KJZ-eKZJÔ––¤©¤‰ªER‹‹I”–´L´ZÊR¥©–Tµ,–RRÔ’Ò•-*²Ò–•YJ‰”¥%“%JK%T©eIeJR¥%–”\´²©,´²Ô¥%©.-,¸²Ô•”´«J\YZ¤ÑeJª¬¥JªURÕ*,¤²•,´¨µ%I2’Ô¥)--IjR’Ô–YQKR#)-U•*©2•Ee¤Ée¥+RZ¨µIJ¢eJK*Y*²ÒU%eJªªª¬¨š,–+,©)J‹TZ¥)e%”¬¨šYRËRT¤´«,¬µ*&I‰”¨¥ÉZ¢eeKRU*É+)JZJ¢Ê–TªÑk*R´²µ)R¢å*ZX¨šQ5"êT²Ê”¤ÉdÑKJYQ4¥R©eJT²¤¥¥-RZÒU(¸­JRL¤©EKK*’­,©ieRZ’Ô¥)R•-%R•*Yqbhµ”´•IZ’Õ)R¥–”¥©)2’–)&II’ɢʕ&QYKR”©U•,¥J«)j*Yh²¥V•¨µ%IeDÊRR¥JªUTµ)QiUJ¬’Ô”¥¤“IjJL¤µ¥¥¥•+)Q2”¨˜ª¢É•Y*¥e)iEÉe¢©JR¥)J‰•**R–’ªUU–\¤©eIJQieÊRÒR”¢Ô\YrTL©JRÔT¥”¨˜´²²Ò¥(™(µ\Z”¥KR©+K-%K%”–”™iIeIIeJÊ¢•)j•V¢–”µJ-J²U”©UK.)T²¥UU”–T\²´µEÄÅ¥R•,ªJÒ¬¥J´«J´¬´±kE,¥%-)UK-*IU)U*LZYUKJ”©R•JT¥)&J–*TT«QK)--QrÊ–R’–R¥,¸¥ÉU,©JZJ©eÅ‹R¤\¢âbÑ2¢eE©E¥*²’Ê•ZYRÒ¬¥”¬«,²T”¥)R‹’Z”R¨¥RZZZ”©e))UeK*Š.J-JK&’”¥¬Z¢¥¥*T\L©KR‹”Z¢eEe%KJ––-QKIU+KTªË*RËK,©JZ’Z’«)*T¥JZKRVJLZQ4YyU¥VYIjRÑe©)j,´¥JR–’–”\”¥)IjKR”¥*R¥)JR”¤µ(µEdÉdÉT¢Õ%ZJ¢–’¥–”µ%©--Dµ%©e*Qr•”¬¥K)JŠ–”¥”––\&T’«*-IR”¥JU*Yh¥¨€q¦beets-1.3.1/test/rsrc/full.m4a0000644000076500000240000001334612013011113017045 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom ŠmoovlmvhdÄLé_ÄLï¬D¸@trak\tkhdÄLé_ÄLï¸@mdia mdhdÄLé_ÄLï¬D¸UÄ"hdlrsounÓminfsmhd$dinfdref url —stblgstsdWmp4a¬D3esds€€€"€€€@xú€€€€€€stts.(stscÌstsz.22""'-+1/+&0''&%)(-,*).)(*%)-4&6.*,$,3/'& stcoÆé •udta meta"hdlrmdirappl°ilst©namdatafull"©ARTdatathe artist$©wrtdatathe composer!©albdatathe album!©gendatathe genre trkndatadiskdata©daydata2001cpildatapgapdatatmpodata6©too.dataiTunes v7.6.2, QuickTime 7.4.5¢----meancom.apple.iTunesnameiTunNORMjdata 00000000 00000000 00000000 00000000 00000228 00000000 00000008 00000000 00000187 00000000¼----meancom.apple.iTunesnameiTunSMPB„data 00000000 00000840 0000037C 000000000000AC44 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000y----meancom.apple.iTunes!nameMusicBrainz Artist Id4data7cf0ea9d-86b9-4dad-ba9e-2355a64899eax----meancom.apple.iTunes nameMusicBrainz Track Id4data8b882575-08a5-4452-a7a7-cbb8a1531f9ex----meancom.apple.iTunes nameMusicBrainz Album Id4data9e873859-8aa4-4790-b985-5a953e8ef628N----meancom.apple.iTunesnameLabeldatathe labelR----meancom.apple.iTunesnamepublisherdatathe label"©lyrdatathe lyrics(aART datathe album artist$©cmtdatathe comments$©grpdatathe groupingÞfreefree(mdatЃì\«11;Ä<Àøƒ¤4„ `Ifàe—^àïP0#Ü·›Áb˜Ä»VOØÙ(*¾J€q8òƒ„q©-@>Ťè[-3þ!‹rtg¼¼(ι¶q]b¥¸úƒÄPEÙ€€ ª°DãÙ‘>JUl3ËA$Ö)i)ƒÀúƒÄA#€+:3ï`Xa¶™˜ÉùQ;«À±˜ˆèàöƒ¢" „Î Ž× óf{q wZ“Ä 3z¿f#)NÙq'øƒ¢" „kËr½ÙÖ£ g§ý'”ê )y*¦˜Úö»,°Îµx¡úƒ¢" „r †BXVFìì7nhϦNž|z%ôe0Ue®«Œ œöƒ‚B„_`9åo¢ðJ­Dz0¯)òøáÛ1F8‘ú·åÉ>7t·#‹Uàøƒ¤4#ŒXÛõÞ™¾áàÒ`¸Ž‚Þ×xT†aGˆ~fäHv<ý¶ÁÀúƒÄ`? &ˆ×63Дl:äGàë’ 莵š„ÇÓëåN‚àúƒ¢R#Aש9Ï<³ÔiÇ%Öøê¦—µŠÍÎê®yfžÎôƒ‚T„ `®!I}uhnV$?‹+ä¡(Z«„Á¡Üta¥Vi±+±l‰8úƒÄ3#P0¼ñ•T`’3¹èžÓ#?}¥ÕõÚ»ìÔ‹€úƒ¤Q#Hl`µˆÁ½¥ËK§)Q)E‚ñõ>O²Âô¯SÔ¦úƒÆ"#P ÛdpËŸ¿Û2é­~sÛÈÓï'Pîì=Í&ü!úƒÄPG<Ž0NÝÍEø™_ƒõ'1…:õ˜‹å\øƒ¢ ˆ,l ƒq¼Ôº<¬>èðÍ&ïÞ¦J3}•]dQ€€8úƒ¢`F  8I+–¶:aá–;0¥Ç>m>;ßM¾íÛŸ× ¥/gøƒ¤p>€ÞbSci›”¤L z:~H5ƒM3©'°A+è&„f ‡Ö(pöƒ‚0!dÀ¢’¯:%¢C°9B³î+]þ3Z†¹ècJr†Â¶öƒ¤`E€a,]µ)\BiwÈ,yç@úD¸ùü'ħ¥¨Üøƒ¢ ˆÐÀ€0qóà=bžmBÅAwùH°×! šDSª 8öƒ‚5 !`Â4†§å^ñíª^:$9µÏ…gs`M%…ÊZZtª^U£Y(o€úƒ¢"ˆ¤09œ’#NÑ·#̬zÚW›;t A-1dyß”3¤^ àúƒ¢" "ìFâwì¼ú„,µ¸rŠþ¬js%”ŸÒísÙfžep=¼øƒ¤`G €@ÀãÒ5?à¼`•ØÉÍM²ÈöÆýYB^×;C€øƒ¢`BÈ ÆãiRø—‡iéŽ6ˆ0 9ü¾åÓvë…(ÜÿÿGYJ´…=˜¢oçlÇWøƒ¤3 BÐ3ÜQhLÖÆ$/ê‡b<÷~³µ8ëûÛΚS­n*jõXeÅ×7’#àôƒ‚"(„@Ø Ad¨*Â`óÙ'ሬÁ®co·>Vûça &µ% øƒ¤`F‚ ¢±äßxã}¸<ëÊüfP[2ÚqXä.Š'Iµpúƒ¢" „¤*ÓØšÖ'êŠÇÉR™z[oÝÓ¬ía‚„¾”XÇS2p'ÀøƒÆ2! éZðÞx²µ¾ùìú$©¿Vn´%j(óVÀöƒ¤`F‚€öŽF†@›c}}ðCÂ2>Û<ÆçO5£!¹QÞ/grDðúƒ¤aBàô‡2‰´S(^®Zdð{¦P¤Ÿ~‰ÙÛ˜¦‘6 Sºœöƒ¢ „,`¼-1wNÞAÀÍ”XPKh¡wKÛ#0R¬Y±X-Àøƒ¢`E "ÅImq¤>w¢È1s5{!ÁXº[¯/æÛ?ÓG¥xøƒ¢2"%€Xä  aÔž®ŽæÏû©ÆÏç¿÷ºr¯˜LaÀƒì\ªRi"?pƒì\¬ „ô@À\beets-1.3.1/test/rsrc/full.mp30000644000076500000240000003102412013011113017054 0ustar asampsonstaff00000000000000ID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC2001TCON the genreTXXX<MusicBrainz Artist Id7cf0ea9d-86b9-4dad-ba9e-2355a64899eaCOMMengthe commentsTPE2the album artistCOMMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000TCMP1UFID;http://musicbrainz.org8b882575-08a5-4452-a7a7-cbb8a1531f9eTBPM6COMMengiTunPGAP0TCOMthe composerTENCiTunes v7.6.2TXXX;MusicBrainz Album Id9e873859-8aa4-4790-b985-5a953e8ef628TPUB the labelCOMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000TIT1the groupingUSLTengthe lyricsÿû`À F=íÉ‘A#ô‰¹ÿÿÿÿF0ÃßÿC ÿ_ùïÿÿÿÿóÏ£)çÚa”2ùŠz<‚žyõ=xù=O `ÐÊÇ  ¥àÆyé>`øÜá ÐÀXè" z)ãBœ"À$)‚ A„ ÿÿÿÿÿøÿ×?ÿûÿÿÿÿþ¾Ývb£×B¬¦ ³4Àã°²+¸µÂ¤Ír¹ÁÔR°Ä%;îR£Q[ÿÿÿïÖÜîÿ³¾Ú7UW6dTUT!ub8´Çø çèx?ñ.Ì€ˆˆˆˆÿÚÚ¢³<ŒaeŸDY/ÿÿÿ½ÿPœ¼·$D¬(躡ïPŒi§ÿû`À€Š9à… éâH#4•¸‹RÒAbÉuÏ&Ž ü:=ÄÝL´2!Ô¶#9îŽÔ#"5¿ÿÿÿú­ÿfE$ìȉ,ª1HËez²1’ª!ĨR ˜‰Ì§)¢((â¢,Êc°š)ÄÜX¢¬,qE„T9N@øˆtÿÿÒþ÷ÿ?ÿû`À ò= mÁ¸È#t¸¿ÿÿóËäܯðÉ÷IN¡æiÞÿ{ÞžYg§S—ÜëdE/'ÿÿÿÿÿÿÿçþ§Ì¸g Wëï&%ÍirÄDuñ6.ÝítÜ‘mtP&ê,Ž«¢’Duhâb¨D›ÀN±ÿÿÿÿÿý`Zÿý{ÿÿÿÿÿçÌímÞó¥/Þj¾GH„<&ñÎC¥ÿü„žl÷ÿÿÿÿÿÿÿýþÿçJÎÉP1•¥I’òÔä»ò9£3³ZHFÊ;ŽÔr/viƒÃ1  Ü‰…£2‡ ìÁÿÿÿÿÿÖõÿþyÿ?ÿÿùz\¨Û+dDΪ1ˆÆckTIw˜ÿûbÀ æ% „MɬH#t‰¸™¨…¶R²¶Í´öÿÿÿÿdÝꓳÐ÷1êr© ÊϳîÒ»³lÖ• lΆmC‘Uê÷c,ì$(tÈ4BKÿÿÿÿÿëåý²ëZSÿÿÿÿü¹QRÅ­ÝŒ‡>³Ìl®®è§1Œ¨ˆËf:V¨m&¾åDGB;?ÿÿÿÿÿù­RÌé+ƒ9‡}•+ϳ+±ÍUÐèÊbPSú;1Œ%Ü; Š…R•Ìwt”€Î9Bÿÿÿÿþ¶ƒÿëÿ•ÿÿÿÿäìúRwó5lª„s>ʸõ#ÌäºuFlëÐêɕћÿÿÿÚÿ³«ÔªFJȪyŒEus–ÿû`À" ? „­Á¬Æ#t•¹a2ft*3άVwVl̸â•êc@@Ç‚o2ÇåQÇ;> ¢£€?ÿÿõsÿÿÿÿÿü»µ®å:=¨îïv:ºº•VB/Îlêèö-ÊZ$Û™5OÿÿÿþÍ'*MD£'» È.–z¹›S«”ÌêW!„Ça溡Z!3#NǞ#!ƒ¢âcH5VA¸ò¨`†`±óõùþ¿ÿÿÿÿ®•g;RWTÙUÕ½ÔÝQeÑÑTľÎB%OÿÿÿõùÑgêÕ9F F‘T‡j9"Î(cJb#!ÇxˆÕ3³ ªEÿû`À, Ú= „­É¯È#4‰¸vcœqL8áaàÊPq¢Ñ2$48ÿÿÿÿþÖ¯uÿºÿÿÿÿÿ?£‘Ý”„5›Rl›ogGÐŒªöi7ºÕŽ·ìKwÿÿÿÿÿÚÒ"1Ý_™Ü§8¤T„Dgä;YƒœFä%PìÆ \ÈäRŠF €‚äS3: ÁÈQBªà•Ü(ÿÿÿÿþÍ—ÿ/ü¡ßŸõÿÿÿo‘œíbnYØz(‰‘ëu×J:ä·Id£ïZÐÔÙÿÿÿÿÿþŸJ'»Jƒ Ff®ï©Ne+ È 8ä*&*†q2 0Ì AŠAD…0’Q!2ª”¥A§aQEÿû`À6€ ¶A „­Á¬E£t¹0ÿÿÿý°/ó׿ü¿ÿÿÿÿò]s¹œÐô… 仪Dþ9FzÓ¦[7‘t¥+M¹2ä³ÿÿÿÿÿÿÿéÿ9æÓ*FE‘D‰»ë2³×r™9“Ò·I­|¼UèÊã±Áz°spÂ+† JÛØ8ÿÿÿÿþ©ü¼ògRfÒÏåÿÿýþ•$íc1üý§º!Yz:d÷#ê×*º%Pú?{¯ÿÿÿÿÿèɦ·5\ÎR£¾ì‚ŠTv{˜Ä$A™ RkÑi†ˆ!£±ÅÊÖ(|¨rˆ˜pðé¢î<ÂÁÀ+)h0À5"ü¿ÿÿÿÿÿdîÿûbÀBVA „­ÁÄÇ£4•¹F±:¼Û>yê­)Ýì¬åºQò²+7cfTÿÿÿëÙ[óÉW£•hr¿ÿÿÿÿiO‰&׊aÒ¶§ö’ѺkL“ÔW0÷?ÜpµpÝÿÿÿÿÿÿÿ1ÿ×ýúÚuz÷¤sò8ë›~9çˆ^;Ž’˜…ìar:¦hZ—Ro|†AwÚÖìXòo®Ü**>@Ö0ÿÿÿÿÿëËWuûJFÑYìîåˆòYÙræ\ˆîjí»*"±Hßÿÿÿÿ~î‰m¨—g*:˜…+"ofi•ÞÂ#E ¨²)JçWACê,$aqÊÇ)„€p1Œ"Pè‘ÅH‡(Ñ¡`8ÿûbÀo€5 … ɽH"Ì•¸3èòÿ¯ÿÿÿÖ×Ýzè¼ì×»Èr/È¢ Ê®”d£3Ñ—yN¤qÿÿÿ÷ä{"Zåu#ÌeR ¸ñŽÄsî®e-ESi‘pÕ( ›0“„bŽ3‡Àãs†yNEr”ÓŠˆ¨€ ŒÀÿÿÿÿÿíyïÿéßÿÿÿýz.¥1,o5ä+9·Ežuvf±Îõ¹v}ÈÈ–NÿÿÿÿDû}l³µ+3UTŠCo¼¥>%èOyS!XŒ‡‘Æi;\ê8#)Ń:‰cÅ•Êt(P‘À?ÿÿÿÒÿùÿóÿÿÿÿù´š÷fÿû`Àx 9 ­Ù¤G£t‰¹b$„vºftgdû©kU-™œèƤÆVäfÛÿÿÿÿìŸêR>Ó­ÕK5(i5; Îñ“•ÑŠwiÇ*¹ˆ¤"âŠ*.*1â¦c´x´çRH@“ˆ#•bÀÿÿÿö@¿ÿ-»<ŽÄjµ=]ÖÓTª¤®Æ96Kä<îäsn³¼ó—ÿÿÿýv"Ñõ{Ñ]LëgRg©ªF5ŽFuõZÇQ3QÓ ‡Ç°›”a‡¨Ô ˜µÌ.tP‰GQCå<ÊÿÿÿÿÖþ]ÿþ«¯ÿÿÿþÞˆy‘žr3ÚÛg§Ÿ¤m4U++!d“Ö¶_J´Í{ÿû`Àƒ nA „­ÁªG£4¹ÿÿÿÿÿÿÿÿöù7ÈqyiHsC0¤Er_?* –gÐNP‰îF¸v#UCªˆ9ŽB!* jèA‡1Y(0ÿÿÿÿý`_óòþ_ÿÿÿÿüßíÑžïzJ_¹K&´išôE3Šeu!&>ˆˆþFÿÿÿÿéÌý<ˆs±Q#•ÞcÚï39’Àœ“Ùœ[%ÜÑÝÄÇ8s¸Îd†; áXä …C0­ƒÿùZõºVýi ­b¹*ªOf3U§,ÍD=®E%^ÝËÿÿÿÿóHêí³=Uή¯­]™žØâ®ª9ÕŒfd#9ÿû`À òA „mÁžÈ#t‰¸ÑÔ¤C»Tˆpø…Å”HƒbÈ s‘ E,*Qÿÿÿÿÿý`9oËêkÿýýÿÿÿÎë"åE§KQîÖÝJî·<†aG TGȪW›F}]iÿÿÿÿêÞªú#f«¢Ì¨ÅUTH̵Eç̪·;f¤€ÜâÀÙÑÕN]Dœr¬¨qNÈaÝ(ÿÿÿþ²ûçþ}~_]ÿÿëïôìÌÇûèw.šQÞrØÌÌäb-lzèÒÒšÿÿÿÿßé­ÝíSUÐæg«DL†îBÓœxÝ ¶QE§™ˆc±îD €Ñ3‡PƒÃ¡ÿûbÀœ 2A €­Á²H#4‰¸$)Ÿ1Qd‚ £ýÿçÿÿÿÿýÖþ—tÍT!˜¦ÈwS5îêëfºÐÅ*”lý؈ŠßÿÿÿÿôºµYÊÊKžÓÊÄB²¢Ïvj±•QXÌB *”a”£¤q3”Á7 áº¡b±Îƒ!¬k **6Hÿÿÿÿ¶ü¿¿Ëå_ÿòùß%é,«¿uÜöeæBÊìöJ¹Mwº®í­]Õr«7ÿÿÿû¢Ú®d+Tµ#!•ˆDeœ‡æ)Øì…F•®q`³QËÊ·K ‡!:ÁÎÂÃur‹C˜®àÿÿÿÿú@¾_âüÿû`À©€ ž; „­ÉÁG#4•¹¯ÿÿÿÿçïÿ%2ó¥xzžõËa©—ÝßÈͶMŸšÃ™);þe­Ûÿÿÿÿÿÿÿÿù~~KrS¬[uvzŸzYq¢ñ òlQwÑß#}¨\‰jZ |‹T ¸Ä!ÌéÀÀàÿÿÿÿÿŸž Øç —Ne9Ÿÿÿç eR!áçÖVê¢õœ}!wÂOí3UUÚZWU>ÑÿÜWÿÿÿÿÿÿÿÿ?íñó÷+kLÖÃùŽ*Øëk‘s*—gJ4`ž¹EÆÇviF"‡$˜z©†9‚XõÂaË”9pèP·XT]Çÿÿÿÿÿc`qîyÿû`À²€ .A „MÁ­G£t¹‰#±’¿%¾§ÿþY¸tûîú㙬ꙉy¥˜†ýwŠõÚ©¦÷ÞÒ¥©S»éºæÿÿÿÿÿÿÿþÿ¿ÿû¿Ž¹šk¯«YO›­²<6<’±…&TÉ눢Æ!wB±E2ˆ ¸ö³J‡4\V…K1`°’ކ6Ëq1Š@ÿÿÿÿý±ƒÍ#˜Wå¿Ì«ÿÿÿý:*\Ȫuo™H(έb+JB{1¨¦î·±•jjÿÿÿþ7¥®·tT2+9#ÝÖ<–ºœÈ©*­ ayŒQ1§3•Ú&=¥B:``ë‘‚¢§Š ‰Zpð[JQÿû`ÀÀŠA … ÂÈ"´¡¸ €k?ýþ½ÿÿÿÿÿþÕdR•¨É¿WDJ2oR›±ÎÓêUd©Šf3¦Óo5ŸÿÿÿÿòKk­Ê„y™bêìêt3–S’È5‹Ž «,† 1 áòŽrJâŠ0¢„#(@Xêè$,âŠ8Š4xD*…„ÿÿÿÿÿ’Ëäû×ÿÿÿÿûνey5v}úº*±K÷U2‘ÖgR™–tK"µoEÿÿÿÿÿéîíLÊŒE:2+Ù’}Hî8†fc ‹³)†ÅÌîìBˆaÇds¸“!…„XPr˜xñÊ,Š($0<*5 †4lüÿû`À³€²A „­ÁÛH"ô•¸Ö|ÿÿÿÿÿÿôM‘Û[VÛ²NI”¬ÚÖçbk3›ß¢ªÕ‘Z~¿ÿÿÿõ¡j‡»µ¶g*•Ø”ssB«¥r”¬QGª‰ ”„)å1˜0‚ädÆw<)]Ì($aE8pj‡ÐhÀ³˜À¢¦‰€ÿÿÿÿþßüÿF—=ÆÚþ¿ÿùå®›±÷õÌk*"§f5§¢*YÕC^bºê§»LÜÕ²ÿÿÿÿÿëî‰wcª¦UÎ<Ê;0Ô=QÖ«žŽ¤:³ó¿ÿÿÿÏé¹&q…+T©µ'ÙTU•ŒgeR á‡B32)—ýž·D|ïÿÿÿÿÓ]«Mɺ+YŒ[Ö¦0Ò)ÊbŽ¥Ž5EQ„ÄEa!˜\h˜AÂ!ذ ³BÃîg À(ñá‚ÂC &P 8|DV&Qÿÿÿÿÿû`À´€ÂA „­Á×Ç£4•¹ÿéä^¤–ú,‹ÿŸÿÿžÒ÷f»‘×d[Òº½ V¹Lës ws)P§z^¬t252]koÿÿÿï§£÷]»ÍÊS2•gsƙٞDkaœÔ9Ç#˜Â"%F âƒDL*£¢ (B" ‡XÂŽAìb¼>ÿÿÿÿÿ±…åþY//ÿÿÿüæ©J$î”Y?RºÑôVT}ÞŠrQKjßZmu}vÿÿÿþ«ëzttz%¦TS•1ä#ÌÆ½j=‘Òª”ªQ!0c ÓH,¢å £ÄÄÊ,¡Ñw‡È 8ç1Ðå†0¸ÐÛ ÃjÐöÿûbÀ¶€A ­ÑÙÈ"ô•¸}s—ÿþ¿ÿÿÄ@÷:XÈ®M*¨Cc¹§r”ü­#çDQz­ªý§µ)ÿÿÿÿýþ–ÑNbç›-LQl„0׳ )Ük ;˜Â,5ܣŃ‹;"Š 8ЦA¨$ÁÑqÆ\M…EAB@Áç( *ÿÿÿþÿ-ã4Ñ—pþRÿÿÿ³¢îVlìU·K&öu+<Ï™‡tLììv3¡ŠÔ³I{×ÿÿÿÿúûû•YÑ ¥*«“êçªîR†Ð<Ž=ÇÔLU…aÅqáL (sˆ”ñ¨§D4P€È‚Ä !˜€8ÿÿÿÿÿû`À·€’A „­ÁñÈ"´•¸ÿìŒ w"šçÏ’–«ÿÿþMÿÇýwóó¯÷Üu]fFÌÕH÷SR‰õô±×K¯×ÿÿÿÿÿÿÿÿÿÏw§qóßLò°»O¥O3r²1GÔ­mÍ»±å;=šSÊ8y£2lÏ#•0ÑÄÃHˆpò ¢¥% « €ÿÿÿÿÿ²ŸÏ_åËzÿÿÿþþŠŒ÷Z2>ô±• ŒÌ]ªC\È„VGGuyÕ(Êr¢J´ÿÿÿÿû[BUÝŸaj«ö£[‹Õ™HŠèãLRˆ˜«yŒ‡œ¬C%Üq]Qbì->A!Yâ†`£1\Xâ@`Ò y?ÿû`À·6A „­ÁâÈ"ô¡¸—#ÿÿÿÿÊ­ä&ʪËÕèêÊ}V¤1Ö³Fnn¹:û[¯ÿÿÿëmjÌës¡ÒÕF!ŠbŠê®Æ)˜†‹‚*‘LŒcˆ°j¡¬*‰¢ï0p¥>$av!œXè4DHÁÁ5‡ÅĨU(p@1@ÿÿÿþ­ƒüŒÿ¥ÔËïëÿÿÿ7êˆìµR!Ÿ#£µŠè¨~ܦf5æfb1ÞŽÝS"ÛÿÿÿþßèÍiš£P®vJÖ®d1qèq”2ÚÈ0:Å+‘Tƒ6aÂbEl?2‡DD…‡‡\M"B £¢† ”Ácˆ4\6à ÿû`Àµ€.A „­ÁçÈ"´•¸´ý¹~öÿÿÿþT«Û"£—ÜÇž—ÑsÙÑhê®DÎmYd½îdSnÛÿÿÿÿéí¾­:ª3š§š•c%®5HQU<§!ÅEHcDH@8â(‡E`=‡(¹Ã§qR¸²:$&<(j¹‹ÆŠ¸  ˆhˆØ6­ƒóæ¿ÿÿÿÿ#í%)sMªØí1ærÚì·IUkwG‘rîGtTe÷Ñ—ÿÿÿÿÞÕêÛ++Jζ%ƱÞÈÄÌ<‹;‘XÇ8qˆ‚:‹ Aa6qe- @ÊAA¦`‘Dˆa"ÄÆĘ„@ð™”:?ÿÿÿÿý$ÿû`À·€A „­ÁïÈ"´•¸/þN_¬™ôëÿÿä ÷Gõ>¾J¥Ó:²—G4¸±Q(„tC\î{+©•®gOÿÿÿÿ쮕½Ôˆ³!O<®ë!¨Žd,ŠæR1>‚%f0°¬@ê>qâ¡ô (¨˜åœ4ÖQ!0ø€ºX‚eQC€ÿÿÿÿÿÒäþgì?ÿÿÿÿÿ-‘œ¦ŽI"*ÌD£™•̧Y§In–jH~­’Vkg»T®Š¬‹ÿÿÿÿ•úÑÌŽäRËÕ®¬–K¦í[ÝF"Ψpá®ås ƒ”<îƒáC‡A r$ò 0ˆ×R‹ŽÌWˆ€ÿÿÿÿÛÿÿûbÀµ^A ­ÁéÇâô•¸½ÿÿß›ÿÿü¿->ùŞó/ß&@ºÒzšºË)¢Ô-^Ÿð¼Ž½Ïmûÿÿÿÿÿÿÿÿå‘ßæe ·îV›E‚˜6Þõ$4C¹»¼­dPÀŽŠ$B¬ŽAÅ£E "£,†8SW©9 € €`5‘çÿËækúëÿÿòÿZšçTC£ú^5Ýõ‘œ¨bT©‘Üz-®–™=¤½ŸÿÿÿýiCž”èËB)©au”‚o,ÑÈ8‡c‡ ""@”AÐD¥*<:*‡ °x:@ùDEEH8:.Œ…Ї†ÿÿÿÿ¶ü¿ùëïÿÿÿÿ©ÿÿ–ŽÍ­îGH¢’°»NÞ±™ÄW%²zÕóØøDo“eÞÿÿÿÿÿÿÿþ_ü?)¹leyX†¿ç)ΜzR̈Ÿ"]Ë•L´'dxÈÐtS 3j±Û é àÿÿÿÿÿÀ~˜dsÿû`À½€>A … ÂH"´•¸æ½ ÿ#ÿÿÿ˲µÐÕc0‰&V‰ZâBÅrÌdpëFEyFˆm ¥*³¬Î¾ë×ÿÿÿÿÿ¿¿mYÊŠVÊÆ}LêÆ+>j=‚¨,å-*UcÅB²= QSÎPèÑÎ"ÊY˜H  ÿÿÿÿÿÿÿÿÿÿÿÿ¯ÿÿÿÿÿ¯ÿý~Æ(`JEª*!ÙÙÊbª”Vs ` #³±Š©ÿ³”ÁB(ÿû`À³€ n? „mÁÑF¢ô•¹ÿûbÀ»€&@ÞMÀ[Àÿû`Àÿ€ÞÀbeets-1.3.1/test/rsrc/full.mpc0000644000076500000240000000560512013011113017142 0ustar asampsonstaff00000000000000MP+' \@”7–sÌÿþ ÿÒÅDfÑçüÿÿÜ3Ï?óÏ<óÏ>÷Ì>÷üóÿüsÏüóÏ?EDDþñð>@Èñ_éÒá @ Ñ @;8ºo´è€€Þ ‘ÿE¾|‘_Dä¤È—DDDDù""_ˆˆ|¾ˆˆ""ED""""‘""ˆÈà‹ˆˆP 8ºÑh ½Ñ¶mÛ¶Óµ]×KÛ¶m^UÕ6ª^õ÷jå/ú{¯ª‹U¯¶m»ò—mkšg{¦KÛ[Ú,H’³mÑ¥çç&ŸÛliÛ¶³smÙ"Ò”(ªº­lu~傽ÿÝgUÝÓ·õ—U÷U±o Úó5„4ÆiŒß@N¥jT=’¦SÒ©NÿÿÐÿÿÿÿùD&¼|/]à½|_þÿ8yèÿÿAWÿÿÿÿ˜üúÿDÀÿüCx/þ‹Ÿàÿ]ÿÿÿÿ@bòÿçyŠÄáï1øëýÁ!ôóé€?A_OØÿÿ?ÿÿÿÿ>Àÿÿºÿÿÿÿýv½ä`oôôyCÀWì€Ážpø öú‚@ŸÀ§ÿÿAWÿÿÿÿЗüûù‚½À /觇~¿žÏè úùv>ßô¿ÿÿÿðÿÿÿ®pñÿÿÿƒùÿÿÿð2."âåß?€/¿‹ÿÿÿÿøÿÿAW¸uÿÿÿÿüÿÿø EDäÿD@àwðÿÿ@ÿÿÿÿçÿÿ?è ÿÿÿÿtù“ÿ ò¿€ñ "ƒˆ|ùàEी|ÿºÂÿÿÿÿÈ—ãÿ ˆ€ÿ䀈ˆ|¾ˆnÿÐÿÿÿÿâ?,ÿ" |ù ‘ÿ;€ÀÜÀ€mSº(›ÿ þÿ9öŸçìÓgõŒµõ¼ž³|ΟßKŸ÷ñ‹ÀDDÄ"m›vù´kÛ¶Û¦mÛ´ÛvmÛ¶mÛmÛ¦]¶kÛ6Ðà 8ÀxÀРhÐh Ð£oÀ¸Àápи£poÀ"Àá" """""‘/""ˆˆHˆ€ˆˆˆˆˆDDä‹PDD€7€íРð@ènt£ކÀèÝp´Ý@ÃÑ׸mÛ¶k¦mÛuÛ.]Û¶]»nÛ®kÛéÚ6mÿÛvÿ¿ÿÿÿ¿ÿßÿ¿ÿÿßÿÿªq*ùN%+ªcEU5Jª ’ªÂX=W£ýÐsÞ!§Ðçr ýW8yÿÿÿAüÿÿÿ?ˆô^þþø]/àä{€@ÿÿÿÿþÿÿÿÿÿÿÿ´àÿè]Ìÿÿÿ?ÿÿÿÝ tŸ£ 8€î: 8p4tt ÿÿÿÊÿÿÿ0ôíƒ?ß 0ô?Á¯øçû…? ° ×ë ]Ÿÿÿÿòÿÿÿ}Ã__¯ô…»¼ï×èú|‚½q0þü‚¾+àSÿÿÿ þÿÿÿaзK}>@PÈóñ|{<0°Çãú‚Á_PÇ…“€ÿÿtÿÿÿÿò»üÉùÀ‡ß/_ä‹‹€üΠÿÐÿÿÿÿùò'ÿDäË/âÁ Èù "cÿt…ÿÿÿÿ‘Íÿ€|/_à äË/ _D{ÿtÿÿÿÿ€öÎÿwhÀ@ÃÐO¸ÿƒ®€ÿÿÿÿ /ùÿ@¿ /8Øûýx¼//,ùz@ß_±ô*|=.ÐÅLÿÿÿƒðÿÿÿàÞs€8€àáð ÞÀp à AWPËÿÿÿÿ°üÿÿóÔg¡ÁƒÜwðÀ|þîÛ§Ï{íõÐ_>âûoŸ?úÁëGŽÿÿÐÿÿÿÿñß@>Hþù|ù_ÿï¿àÿÿÿàÿÿÿÀÌd¶mÿ`€ÿÿŸÓg“çú§{sB8çœsÎ9çœsÎ9çœs‚ÐUA6†q§ HŸ£EˆiȤÝ£Ã$h r ©G££‘Rê ”TÆI) 4d!„RH!…RH!…Rˆ!†bÈ)§œ‚ *©¤¢Š2Ê,³Ì2Ë,³Ì2ë°³Î:ì0ÄC ­´KMµÕXc­¹çœkÒZi­µÖJ)¥”RJ) Y€dAF!…Rˆ!¦œrÊ)¨ BCV€<ÉsDGtDGtDGtDGtDÇsUõ}Sv…áteß×…ßYn]8–Ñu}a•máXeY9~áX–Ý÷•et]_XmÙVY†_øåö}ãxu]nÝç̺ï Çï¤ûÊÓÕmc™}ÝYf_wŽá:¿ð㩪¯›®+ §, ¿íëÆ³û¾²Œ®ëûª, ¿*Û±ë¾óü¾°,£ìúÂj˰ڶ1ܾn,¿pËkëÊ1ë¾Q¶u|_x Ãótu]yf]ÇöutãG8~Ê€€Ê@¡!+€8$‰¢dY¢(Y–(Š¦èº¢hº®¤i¦©ižiZšgš¦iª²)š®,išiZžfšš§™¦hš®kš¦¬Š¦)˦jʲiš²ìº²m»®lÛ¢iʲiš²lš¦,»²«Û®ì꺤Y¦©yžijžgš¦jʲiš®«yžjzžhªž(ªªjªª­ªª,[žgššè©¦'Šªjª¦­šª*˦ªÚ²iª¶lªªm»ªìú²mëºiª²mª¦-›ªjÛ®ìê²,Ûº/išijžgššç™¦iš²lšª+[ž§šž(ªªæ‰¦jªª,›¦ªÊ–癪'Šªê‰žkšª*˦jÚªiš¶lªª-›¦*Ë®mû¾ëʲnªªl›ªjë¦jʲl˾ïʪ)˦ªÚ²iª²-Û²ï˲¬û¢iʲiª²mªª.˲m³lûºhš²mª¦-›ª*Û²-ûº,ÛºïÊ®o«ª¬ë²-ûºîú®pëº0¼²lûª¬úº+Ûºoë2Ûö}DÓ”eS5mÛTUYveÙöeÛö}Ñ4m[UU[6MÕ¶eYö}Y¶ma4MÙ6UUÖMÕ´mY–ma¶eáveÙ·e[öuוu_×}ã×eÝæº²í˲­ûª«ú¶îûÂpë®ð p0¡ ²ˆŒaŒ1RÎ9¡QÊ9ç dÎA!•Ì9!”’9¡””2ç ”’R¡””Z !””Rk8Ø )±8@¡!+€TƒãX–癢jÚ²cIž'Šª©ª¶íH–牢iªªm[ž'Ц©ª®ëëšç‰¢iªªëêºhš¦©ª®ëºº.š¢©ªªëº²®›¦ªª®+»²ì릪ªªëÊ®,ûªº®+˲më°ª®ëʲlÛ¶oܺ®ë¾ïû‘­ëº.üÂ1 Gà @6¬ŽpR4XhÈJ €0!ƒB!„RJ!¥”0à`B(4dE'C)¤”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RH)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ©¤”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)•RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”RJ)¥”R Špz0¡ ²HŒQJ)Æœƒ1æcÐI()bÌ9Æ”’Rå„Ri-·Ê9!¤ÔRm™sRZ‹1æ3礤[Í9‡RR‹±æškVk®5çZZ«5לs͹´k®9לsË1לsÎ9çsÎ9çœsÎà48€ذ:ÂIÑX`¡!+€T¥sÎ9èRŒ9ç„"…sÎ9!TŒ9çtB¨sÌ9!„9ç„B!s:è „B„B¡”ÎA!„J(!„B!„:!„B!„B!„RJ!„B ¡”P`@€ «#œ²€– R΄AŽA AÊQ3 BL9Ñ™bNj3S9tjAÙ^2 € À (øBˆ1AˆÌ …U°À  æÀD„D˜ H»¸€.\ÐÅ]BB‚X@ 88á†'Þð„œ STê àà ""š«°¸ÀÈÐØàèðø8>€ˆˆæ*,.02468:<€€€@€€OggS@–þ–ç=ý‚å'02310276;:?CCBl…‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹‹¬Ø%ô B8FkF#ëœzÊýýžåJI`Ÿ76BU5À: š†îK.Þ[/IÀ´˜˜€Ž€ ~ÊýýžåJ¼çÛJÙ@!TÕ€ €z wPˆ{@Û‚š_,ß5ðè@ 4>ªýÿüå žík¥°@!TUè40ÑMtô™àÉÆ+ÚÙÁ…WÇуàÐ>ªýÿüšäâvy¨…PU¡ô…|G×OӱЂ††ÊpBÀ&€>ªýÿüšäîô4ª…PU'Ð@$¸O}Ç%åMÒ;†:€ žä>ªýÿüšäÒ´ð&”ªÊ* @t𨴯ùçaï¥)t (4 ƒ˜šýÿOre1éM0BU 1>ÞY(¥ÚsYÀ&-RJgè¡ÕMkòØÀ‚VšýÿOr¥2ØåIØUUÅ@EO5š‚nE~IôÆÓ´JÊqØ)d¢U)°$ @šýÿOr¥28åMèªÊj ÌY¬"æeç…d×Ôdþ‘òcw¢ ±“¦‘€d :šýÿOr¥`°Û›¸TU:Ì]¹¤¿\Ïô³\ÏÒ{QFH4¬T05Þyýök”‹2¸ÛÛXaE…P + &EÒæ^ ô#©”¶&Ú!ir}Ï””Ωaku¨“)lY ‚&˜ HÞyýök”‹2˜íMP*jT¦Þ1{Ã1^_Y•¢~vçt×ZŸ}¹.ó­¡£ª9“5\1á)èàÁ_#ÞyýÿþåR Ny“B1ê,˜r(¶Øò³4ptmp sR›¦£L/g9‚èÞ?®Í0—M)°”,ZýÎu¹ƒ‘Þ8 4´X‰ h©u À÷ßÒ‹ö~>S£Èü†ý$=cr¦Èß7²ü¼öŽ¥ÏÃŒóù0ƒÊ«®b¥*¯ß·þQÿŠá¨Y4®#öO¤Å± Š«=¡ P{údX]ÎÔýÎ59 £¼ „f)6ðÖ”³A`fœ“€½^Ä!ƒ<©¬˜i0/d¥·?/Ôh³=é«m¤ò¶Þ­™žwÛÕZ:Ý–ÿ‰^®¦×Ö‘&uVwû’CÅ_Åjð-˜mŒýÎu9 %½ Ð"++IÀ@âÊAGÕrý5]óo”X€¯/sãÎ\ú çHÇ‘ÙqoÅóÈÐ3ú.+¼ð¬qÖ\E¬SF’¿1R-à"ç ÜI¦ÏlÂjÂɘÌìæˆýÎwÙ4-= Ĥ¼ÌdÀ¢ÐIà,1æ8‘qü]âj­_è÷⥧Թéô''ïsçõu|‰ìwûæ¹ÅÇë³Îå‹/ó¡<:0ÑÛå$¡Í½'—3Œˆí®ý ýÎu9q­< B¡¡ËÂÉgPà€~57kg§P¿d´]ÙÛü¹œÎo¿’_Ú¹Js)êã5÷Òä¤Åp–ô•õÚc\^;Ç¥QåtFôbˆ[D®áì §­“ýÎ5¹ƒVÞh’ʦ¡°@vv¿´ÄN˜¾d ¦²²Ki*¯u67[˜`&· 'nV‡§¯‹“ËÈcºt“Ÿq’C>-VþFŸÄ¼VÙ ]êïtGNÊy49ªéaœÙMãýÎ5¹ƒä¡Xd‰‘\%`8'\àÁÁû5ô7‰M?/'x‘,ׯ×}Y¿Ž½nmcfFU)¸¸ð“‡0ÙëÞ¾ÕÑøÚ·•êP[©+T²ûë¼ý2š\ÍüÓSœ ÂG.W¹èX©;·åUFÀhkôo†FØ/÷àÂu cÊ:ýÎ7Ùƒ–Þ8€˜š• ›0Ød7•\´×vÎ}ií½E«MÝ M3‹Š"äðºÏ~µ½b³¶#Ah©'òR‚ÑÖ&y,]~MG“-9¶#8…þùäë¼#s/yýÎ7Ù8ƒQ¾1ˆ•Ö%ˆ8€iup;š¤_I5…ÀÖÿ?»ØÄ&òÒµädu¡n±{½ï…é!‰Ÿ»:•¬Ï…úzÅU.25žBÉ–+Êèq0ïîQ®*/CUë&^ܱé-ýÎ59q#} „&å68ZÀ€ôлt3ü´WÜ™ohm™äP»-¨Y?Ç{ž°êŽLXø8&MöØ f%ªO©›÷NÉ”‰Íͯ®b¢ì½›ìÎÈPw¡ëWI;–ýÎwÙ(ƒ–ž„*hRdÁPLKºá?7œI—»BÁ¦Ñ|_O&±arúäºvQà$¹k×°d­Áå²l­Lc/["°R#lcú,èVÀÝŠ…«äàHïbz¬Û{8˜TýÎ7Ù8ƒQÞ1ˆÉJQ3=HÐi— ‚©æÊì•ä¸óM6¿ÝRmöµ|fê «ö’ôÑZ*Ìü(NbÎÑÙ&ç²ýÎu¹ƒ–ž@-jIÁ…&&èh‹o\ˆœ‰[¢ÆüJx2ÝÏÚ ^Cí7^:«YGcF¦òO}žÝºž‡ƒž™‰°±<É~¾¬2Pâü"SÔÞ‰íõªÀö™!Gæ&ÛóS’‰ÎI…¨ýÎu¹8ƒQÞ(BÌR¬H¦Ð‡YB,ƒ’²-oÅx3CT§XÈèòßÑ| éÈÓùÆûѳ^^ª„^‰)5=‚%G½pïb€{fy](€>(Ö÷:;ubjÙ:kGG:OggSD¬þ–ç=‡¿²¬‹‹‹‹‹‹ýÎ59 %½ ‚”. :>þè]µ¤?÷TãÌxL€çsÔ˜žM›PÏXšÁ„—+q!^#§ÃaâèÑ|S«ã.„™ãµ•«Q®%(Îsnå¶©lõ]”N-{~ýÎwÙ8ƒ™ž0B“b…qÄñ–X9vñ)l}¬Å¼b‘Id=!û…t,W¢7Åæÿ9±¸õñ­Ûÿâ® Rñˆçƒfƒ©ÄÀoé<ïËÓ—·§¾UÚe±”׫üpõ0Úé[(wÉÎ"…ýÎu¹8ƒ–Þ„2h–¢"hrðÈ)=|éP›Û"ýr:Iò"qö°Ì˜è¹¨š¯à޾Rß"à€"«•uÞ½‰ þ¹Ú‡¿š.øAsñ©Ïˆnµïn7…£'òèeŽ~ ýÎu¹ƒÞ0B Í c@2 °8èä>h»ï_6ƒzyu—YÆü|$Ô•»¹Žëö¬ÊÀÅ…÷ñSìÙ n'E²0!¸¶²ö>~v3üayè™zŸ›9óMÔk ¦1×5„éW7ñ#ÞežÈiýÎwÙ8ƒ‘ž1h–bÞFh‚¦zmœòp4<¯å²é*é´ ´ÚäœÉ"N¨„ªñ|oâSðjl’ÌôøœxŽMqIß–Š'S´Î—WøKw®²:&U0TÌn;‘bbž ÆPÈeýί² 8P‹<Üà0P8Ó¹­j xz}]Übeets-1.3.1/test/rsrc/full.opus0000644000076500000240000001612012214741277017373 0ustar asampsonstaff00000000000000OggS'·oIkéOpusHeaddD¬OggS'·oÒì¨ZÿÿpOpusTagslibopus 1.1-beta%ENCODER=opusenc from opus-tools 0.1.7 TITLE=full TRACKNUMBER=2ARTIST=the artist DISCNUMBER=4ALBUM=the album TRACKTOTAL=3 DISCTOTAL=5 COMPILATION=1 DATE=2001GENRE=the genreCOMPOSER=the composerDESCRIPTION=the comments TOTALTRACKS=3DISC=4DISCC=5 YEAR=2001BPM=6lyrics=the lyricsgrouping=the grouping8musicbrainz_trackid=8b882575-08a5-4452-a7a7-cbb8a1531f9e9musicbrainz_artistid=7cf0ea9d-86b9-4dad-ba9e-2355a64899ea8musicbrainz_albumid=9e873859-8aa4-4790-b985-5a953e8ef628label=the labelpublisher=the labelOggS€»'·o%Q‹32Ú€}y{zvzz}}~}wzx|{‚uu{{z{|{syz|€qv}„}t†}}{øû» é]Fÿù_2prtÌÆhæ­NÊàšgÔ—Ô)¼²X$ÃÞ}”d³,.tœUë!vq|‡% ФŽøØGmÒ¼ID^?Q3A› ±iÀ•ó`£¿dx;å3nåOæ{0ÙÆk½XQʪhég8r¾¦ô·œ¬§5¿“©¢,¢½•@›½ŽwžèÕí^†ý%Ay÷×xá$ Cš˜éßä<®lÐ/ *„ ïf' aX;‡]Ÿò¨ôþÃ…kÛ…³ó\ÖÜ[ìÝ¡$0®ßݲt} =2øaå‚›Ë2`7 ÒÓÁöâÉ|„ä°d±áä(Ï4àR°©T³¤ …'¡â Ù Ác.•†êz.’Ú2óŠ,¬¯Åf…9LômØP*•î ‡ÒΡXÔ¼g¹Î¥†’#—ö—«ƒ  =¹+¢í–—Ëo’Ò '>/øaäL’–8›N­u@€•¡BÚô]…èH>pEƈYKƒ©Ì&—g‹‚ȘxÚ'øaà+¤ôšª½P§0¾Ïþ›l2nNÖE±d‘ÁªËLŠ5Ÿ”.ÒúšÂ‘Ç <´¢À “ É}`n´ñDc½býºA{•òï@ê9`FSô© ÄÖHgDJøù×£·£]燿–«<Ò& 6°?ç:ó1‚õ±MG¬“ЯøaäLÉÿs¯!jåN,¼ÉH_{H¨šs±Š3.!0Æ\}7ü_j—ª·"h E¶pÇ-Êïc¬¢ÓYǽfÌèÂý\†Û3 Ú"êUk¡»»º%×̰æm}¸¾Á†e×:4鑜}Ë„%V¨ŸdZ f Føaà+¥#)}È쾘×`°Ï<‘=óüYó ³–ª¥¥;µüÒ‘ŽŽm†øomÿ6ôs ]FÍŸdÄoSxç‚l¥.šÌ»¯«T¤…˜‚qwäŽkß²O׊œ_¯X8±<u•/d7JÎ8/)”Ñå¾Ë¯'ð•mdøaà%f–媣uÇûé€7”¥\uøåQ*n:„‰søŒ¤kÀ…‹»”ghÀ^—A{˜Ùî_ú%IãŠðbÈmÑ.Ô%?&Ùñ9išË†ž>_I¦×­ŒãÁ/„§80\rs’U †`Fœ*²ÇµŠ#ì ߈“Uá‰uk¦bSøaà$ü õ|“wÓÈ6V2$ ¨ëgÂê»ÛKB5·’¬_˜h )ýõìòF³™WÊ¥Uš ]VE‰f¹5Ïå°£*6s\²·vâÛÜ ‹E“ È6úϽ£qpâ †Ž'àï ‡Yøaä[JÀ5úï Å(zŽŸ-/§©ì®)ãD•ᣙo\bŠ—2zÑ]7ÏÕg¥šC6r¿¾'#"×VÑ fÞe‘åë×ÝuXbƒ>Ñß>. £ºŸÚ› Kq?ø° FæåHx8áÊTpáþ*ߟGý~Á¡²6‡`Í`y…øaà%zõ£A>‹¢Îâ$ÙІҨ¹Œn+¶½Å5<õz6¶G`“Ò–¤xÐérhE¡'ðE:µÊsy™­—ÂÜç§²ãûM©ûts[¤˜6GÙyËàŠDÍ¢c{ú‰´Ç~·I7XEŒþdÊOœo Á¶ÿêôñ"IøaàCÔ–ÊnŒ(>·È4vaà·Gœ¶ ¯(5|óÿ1cË€?®H˜ãööøaà„;³D§KCB QÂÞcFKÛ<^ýú|j»3@01—ž£üyšôqJ·t ¾s‚ι”Be͆ODÒ(núò2GÂÓ3~ \ÔÝ9±MêÑÈû›°ë³ÉºKw=»x¤T$ѨÑ[Eo@G|ƒÙIA¯düÅŸøaàÔÇíè$â´Üãy©]u¡;ÿßÊð ’\ªßçÈ!êIt@æØ+ ÷NÆ"øaà„1ÞçN£Î‚e%[¦Lƒ>Œ¯~F ‡n]:nEþ ãc~qs’ÝÄeÓÏ­CßçÎØçOƒ8·Yãl*~úÝÉ4׋ð[< GîWOïϲØc®¶«“lé5º‡Xð¯7"fø–¾F¥l J}’søaà:ø#5ÍM7Ý‚§8:5Y ¿òNï«w)@X½¶ŠÑûÆ7Jàƒ³Ë@õçYŽÜÍLÛÿzhcX AÆz©™Œ©W6ߊ ô>Þ5:÷?W þäÝL¿/‡ÀâpuGžŒ½ŽãºìNËS¬)Xréøaà+§_²Bh+ä9€:ø1ñÜ~M¬Ý\I·ÍÞÓ–iª<óÎÐÇÒ(kµ ·Uuzc´_툥õi…üPîI|.ÎŒ¼Ké8@œ#v¦[;m=ÝŠP—ÏÙ5ˆ«€9UþžÜÔ©o–¯¡þ&Ù‹îšØPD®(`Iøaà…6Ø1ši\Eì(,ÙŠ³ª•‡èE] R¹ÀÛ)FV%ê÷_ûŒ‚,2+Lªï;U­A$?µ75b‘ÓÜ_³Ô‰+L¬”í?{yoÖ°Pœ· &³Gëé|ˆjC•á‡ï@$Üy$.À³Ï3ì”q øaà!kœ¶‹ËpÞýãTïÅjô‡UGwCqâ†aVöÆÍKŸ<ð}ƒ„P'T7G‚¿¤Ä(/ÃT}‹E¾D ÄkœŸMôº•åÝEÏëy-æe%ÛFŽý1ÑÀÔ;+\ÏyÀã'ö¦|µuÈÃøÖ«?ÊUJ ²m1QGÓøaäyðä¶ê™»š*ºQK—í–›gr¤ƒÒÖɽò&“y‰[ÿd3y¾ë5ÓÜÅr˜Ëôzf§BBeGý î`ñV-…Q»J÷âßÝ([=´ ¯Ô¶¼Ëa¿™Z)ýžn·c½ ²eelç#ß3ø7ÛøaäY1ÕÙJ¢xô>RÖ3Þ&Xï%ayÀæ`Ÿ÷Ȩ°ãi9xäÏXu’„‰;˪[c_Q|ÇTÔ—ŒÎozeuS=n1hú¤ƒ’ðž²g„+Kô^ÕÓe_'^9uÆxÌ´ÕI„±1¾nå6ãò‹H: “£bøaà$àÎ1Ú~ÒíÃüZïz$Ü}Zw ŒÈ5Ø[F5™æEH3H†ÞÝ·xÚkžÍákŠ×<§TU¥‘Ø“¨ú¬V=Ïÿt߸µõ½+ FtÂsÀ…=³©½1l¡½Î¯mùYÞ1w[ÚÅ«çXÇhJꡟAøaà9øbÜYXÝðäÕ€Pj\õùA#12j‹ì—Eä€Vðû¾»øjö£@ d²|/ö¯TâöAïÅ=9¥ÍÉ…Î0ÉÃÿãìÊq£Ê9æM>wP‘€­Ûý¦Uémϲí—ùƒŠ~’G ¦„Š%[>ŸiZ oâøaà$*k°Púò©Kç‚°º|óødn:ãZµ ä;7QR}e«á øaaŽ‹®mò?lÂI(q¬èŸk6ìUËïïöílžÆÂÚYuOˆtôn¾6ù6ûaÜFÜLî¶3.Lla…K ªi sz¥SõòF⬩‘[åøaà„6×êÒ¡€7œËÐk¶Ö6þ ìð–ÂGòÖDù*‡4l¼Ÿµ›¡%OÀLo¯#9™c[oHï¹S²x©Þ'¯#‡¦SUîu¨@%‘aàTÙÜáiMù·k;iùÔ©µˆf¯yÄ1z #„øaå´`á£rwqEÈM’™cC˜DP糞þV¤´ŽøÑÓ`\ùÔ‰2†ò5•C§RSñÜíßÝÌ mf›÷œ%u6g( ½+4pÞΗpùoQ%Èð;XÀžÇbÄ› VI×¶_ñ¥ÿÂTŸ§OìU€Ï'ÅÔ¯„&øaåV¹Éœq ˆ‚)²‡EÁy8E§±¼DnÛ pPÓõ¡Òf_!æÇÛ+‰vÖôsžÔÕ%Da€'gbiÃY>ÉSEÿ“HFjf«ÿá±%Ð0áÿç“:„NÙ> SÝ$.7S’·äM)â‰/DPî°'øaäN>=)Ô"µâ…G¡Ó±&ƒpÃדá%=Ôè@Jß²4i)V÷¶[ÇÀÉV¤Ohix¯´³<\"¹•e€}Âá*L¨ëoå­Å·U,ÿªOú/Ó)C0}”"_ býò§Vc@9Xì·([+d—_øaà,™~‰“î:@HÏ”xÞC¿°ÞÄåNPVÏc„5iL¦îª9ô¹uOàª,™#IO®C˜hôÓu+ }®=Cü7\®+õ*ëòŒìöé„^“Æ„ûÖê¼Â¾–n9{Ëu.Cûm×g1féŸÐ‰a¬{iž"I`‡rS Aøaà+¥#dÆ·Ä‚¶¹ä]qaVÿ8×ròÅ™É>%ÛÇu’¬ÞTÃMv$³¾^ð½(Or' ]ÿ¶M¥A¥ƒ˜7Ps«N²ÇØ[‘Tí‡"âU¤ÀÌéè•Ê¢Y¹õ¾ÓÁ¦B?bî°TeTlö ΀e¤k0sT?Hà¯ßôøaà+¯ÿUp)„@êÌoÜÇ>i+¥Un{(BkOO$H<ïßA»y™ / SîÞ 1L“§tè1¾ö™qúÒ]l÷=§iªÂ™mh0€!”áßåJÌ€Àüê<«q° ZØ·r ŽŠc`vÊYp¨£øaà~Óc¿^ÇsBtB 8ô»Ñ¦ÝùŽ×J ¦j%Xg}pÛ i 4êRNÙ¾/}Q‘hj@¬À¶- ÁÃzä;_A bs¼´¹H™Ð„Liµ=ŸàCÎò*‡Wݶ‹ê‚Nl¬˜€á+/í^†Îù<:qf\«UЇΛ?ÚøaàsWÚNͻҿŠ}ÁYì’)hàd P0H©F¨5rû}§Ñ”Í—fd1Zo¬õ~©ÚÈ{µ%qLå×É1| jà üs®¥LÚœH¨ö˜dé§ñ×­¾€µ~φÜl§'‡‘˜»~ æÌ‹èì±ñãV4o1qHøaàƒ§Êt0¾lÒ‡óÒþFÊ„1žž‘ºÊ_ÁYñÔíaøi'Ÿ,V0âÃ¿Ž¹ÉTkûôb‘€ve/ŸuÝx"Ôâ,b1Ðf£=ÛŸ¿v›‹U½à™fá}†;ÊÏÛ‰2>u{c?,…¼ĵ,©Î¦QëwÛYÕ&þñ†¿m¥ºyøaäx» IUíG>ñ¿L‚õdGNÌÃîÀ$”%¤ /Ž˜ØÕM@Ù‡»ô•t?lsªìÖ›ÙÎ&×"aÄogÌ3G[à€½²ç¯W%a—PãøÎz¬á¾’F}E­‹CŠÞUeâo{iä–׆IJF¼âp£»—¶+±/ ¡%n¢øaà€ f¬i<Èp ,9òe(°Qæîh9ÝÎ|# ‚‡ª.U‰«u ¦¦)šÏ…]¹fý237p–±M\¬wE]ìÒ ·± Õ¨ÌÓV*¶MyãYcyŠÀ_yÃ``„w}´á:b<4a½Ë6?Ùå‘„‘DG–¼£}öä÷5q<ýøaåŠß~šâ0±réG)YådP›yyÁ+e…Á7k“#'q8 Ò¥ˆñìó™lÍæLh .~_9|±À‡d»7—ŒH¦ ä¤n&×/üÞj’F÷Ì=­b®ŽÓñ†Á»fŒ_<û™QÝV€øú¿,~YóãBøaäyDÌôsH‹5¿qG Ñ µP î$ö˜­ÃƯ³b÷òlæä¬ÐÍ…! £o†ÄëWÃò+è%õâƒ~ÔÄóMVÇê„dÕÒŠ¾}à\"âŠQSÕ!Ü>[}+D½ý,dËé+"©`ÄOŽŸ“E–Ågøaà,{tþ\ÛEpÒÒ€Ë>Sò,›ìCk{ ¼à}…}¨ô©wÙ_¬35µ²ç͵ÑNèð÷©P³tþ;8eû#eNX‚Çn]­©Û6÷)û”æ:úF z2è!Ž@kGåÛ’ŸR ©jfT¯\¼VWó"Ù‹ƒDP†×ëb…Cøaà…‰¿{á‹§…L±3ørñ±·%C‰uÂŒu…tA̰ڕŽHæþãopŸƒ×pÕèŒÿ„<³«àFfŰÀÁvÉaÓÒàÝÃø³7À1Ûpß ;5.ÜfˆY²kñx¾^rÂS—¯¿ñ%G‘©iå…]‡ª4Й Xùøaà……iæ#ˆá¡I Óí"TˆÀ ;Lù\^S µRÝC" lú†Vª¡ÌMdÕ@C­ÿ[Ó5­Y5r¾#T¦Ö%^°lílÓ’R0SÒz<äOÒ±c<õi÷o’pw3\øßuöEn8 n+D €Œùžé ˜qùòøaäFǹö¡è­imªîŠo¼~–y¬­(p|Èð‹g¡Ÿî*tÄ’Iû0ï0ê½XÑ€YXÖßä°8s˜úoÔÄÊK»tXL¢îKã(ºð?Åé…/81"Û1½‘ŒÛ5G]ÑÒçl=7ÍÑžØr=ÂN_×ãX5Ü®VÅÀªœ*¼R^ßÝÞ7¹·`TÐÍçç»ýùì“'ŽyQ¿XÀqù M Ô¯HPN¤@OggSä¼'·oJ¤@0¤ø}ú¡>Ú¤ªîpxZ„=BF>³-&ÿëAÌz"œ:>³˜Ö'óªKóqß\Fî:!N r€h «}ñ'M¤yz5雯G4®Ë³nœ‚QhgŠQ¡sŽ’ÁT*†¥´›NȃïÞ³¦¡X)v¬)ÊŽU,QN ¶´ø©¬}ŽH_ª«¬2ì2ô©ì,'\I¼e·½Áfk–³y5ùXbeets-1.3.1/test/rsrc/full.wma0000644000076500000240000006170412102145650017166 0ustar asampsonstaff000000000000000&²uŽfϦ٪bÎl† ¡Ü«ŒG©ÏŽäÀ SehÄQ€>ÕÞ±ÐtÐÊ› € € ôµ¿_.©ÏŽãÀ SebÒÓ«º©ÏŽæÀ Se4êËøÅ¯[wH„gªŒDúLÊ”#D˜”ÑI¡ANEpT3&²uŽfϦ٪bÎlB fullthe artist@¤ÐÒãÒ—ð É^¨P†"WM/AlbumTitlethe album TITLE full6WM/ContentGroupDescriptionthe groupingBPM6WM/Lyricsthe lyrics LABELthe labelTOTALTRACKS3WM/Year 2001 DATE 2001WM/Composerthe composer(MUSICBRAINZ_ALBUMIDJ9e873859-8aa4-4790-b985-5a953e8ef628WM/PartOfSet4*MusicBrainz/Track IdJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/TrackNumber2,MusicBrainz/Artist IdJ7cf0ea9d-86b9-4dad-ba9e-2355a64899eaWM/Commentsthe commentsTRACKTOTAL3*MusicBrainz/Album IdJ9e873859-8aa4-4790-b985-5a953e8ef628GROUPINGthe grouping DISCC5*MUSICBRAINZ_ARTISTIDJ7cf0ea9d-86b9-4dad-ba9e-2355a64899eaLYRICSthe lyrics"WM/IsCompilationTotalDiscs5 YEAR 2001WM/Publisherthe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104$WM/BeatsPerMinute6‘Ü··©ÏŽæÀ Ser@žiøM[Ϩý€_\D+PÍÿaÏ‹²ª´â aD¬€>ç çç@Rц1У¤ ÉHödARц1У¤ ÉHöWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104$WM/BeatsPerMinute6‘Ü··©ÏŽæÀ Ser@žiøM[Ϩý€_\D+PÍÿaÏ‹²ª´â aD¬€>ç çç@Rц1У¤ ÉHödARц1У¤ ÉHöWindows Media Audio V8ahe labelDESCRIPTIONthe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104‘Ü··©ÏŽæÀ Ser@žiøM[Ϩý€_\D+PÍÿaÏ‹²ª´â aD¬€>ç çç@Rц1У¤ ÉHödARц1У¤ ÉHöWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eTotalTracks3WM/Genrethe genre(WM/EncodingSettingsLavf54.29.104‘Ü··©ÏŽæÀ Ser@žiøM[Ϩý€_\D+PÍÿaÏ‹²ª´â aD¬€>ç çç@Rц1У¤ ÉHödARц1У¤ ÉHöWindows Media Audio V8athe commentsDISCTOTAL5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104‘Ü··©ÏŽæÀ Ser@žiøM[Ϩý€_\D+PÍÿaÏ‹²ª´â aD¬€>ç çç@Rц1У¤ ÉHödARц1У¤ ÉHöWindows Media Audio V8a5COMPILATION1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104‘Ü··©ÏŽæÀ Ser@žiøM[Ϩý€_\D+PÍÿaÏ‹²ª´â aD¬€>ç çç@Rц1У¤ ÉHödARц1У¤ ÉHöWindows Media Audio V8a1(MUSICBRAINZ_TRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104‘Ü··©ÏŽæÀ Ser@žiøM[Ϩý€_\D+PÍÿaÏ‹²ª´â aD¬€>ç çç@Rц1У¤ ÉHödARц1У¤ ÉHöWindows Media Audio V8aTRACKIDJ8b882575-08a5-4452-a7a7-cbb8a1531f9eWM/Genrethe genre(WM/EncodingSettingsLavf54.29.104‘Ü··©ÏŽæÀ Ser@žiøM[Ϩý€_\D+PÍÿaÏ‹²ª´â aD¬€>ç çç@Rц1У¤ ÉHödARц1У¤ ÉHöWindows Media Audio V8aWindows Media Audio V8a6&²uŽfϦ٪bÎl2K‚ ]“‹„ç ç“ÿ@ ÓÃÏ6Ó‚}mà ;€¦Jà$´æ“$Ü$›ì ?'qU1Ù1%¦4©€` -I$ÀIPc Ô I¨R„UI10U5R’I‰¸IjR` Ù$Ę@«JTBTÓI¥1,E4” ¦”Ò’ÒÒ€I:¢„bf¥1&C›°` ³QR@H@1Všˆ`šˆ †€H “:ÓP@¨þ¥KÌI^o'»@*@˜7C²@ •ÂL À$¨Ä %©--¦ši!T€H””¿HEPùjH@&B4ŠJJÁóä"„0Ò•…ý Sƶ°|µALU2”’k?ßí/¨/ß¿¥,B(âýqôŠ1BÒÐ~¶¶š-ÜKlø¿!+t„H¢„„å8 H¨‰¡h-Ûߥ)B Ûô-P_”PVéCäQný"—év0{eýIBÒÒª¸V‚i~·ÆüPè6òµA \@ÑÅA KT¿ÚÛëpã¥k‰¿, ñøRѤ ­¿vÙFPµB(âó^®oã§Íù»yZÀUÁ”P„ã(À\KYFP…»}¿÷o¤ÓM–Ê?_¥ Rýmøâ·> ·× p?,£ŠÝúð´à*à}Æý–­ËKx)~üþ°óyKïÒoéãýÒ±~ìù¼§(ðƒïΚÆó\yïû§ó¬sXß•?´­-ÿµ´[–¨}BÞSJÒ8øÇ‰9ïùå>Á·÷úýqº_ͧ‹õE¾±øÿ'~ú—è·ÑÄ·ÙH¢š_%úiÝM.Ê]ŸÙK°´V²ž+sô~_§`;t㎟ߛü°c[ŸOšã[t-·A¥ ·?ü¿|T>¡ûúBÒÓêr•¥¡”­ñññ-¿ý¦ÞýÙ·[–Çý>ót~¿'A·Ð„%lÓE(?º0çÔñ­ºi )·Ûé¥òßîÞ·C÷ÉnÀ@­å4qºR#)O½k(ZvÏ¿:< ¿ÊhÀT"±Ÿ-­­y¼¥6úr‘Å@[Á*XÈù­~뫈3Xöü§=ÿtþ¸¿\nÇÇ‚WÜi⦊Æ=©(YXùºÇ[[¨Œÿ)ýùº?+{ !öt½/¿Khó\võ³ÇO„Q•+yÊ-ÀU‚[ucåYò[Ð0òøqà?5€ÿtà”¥Ä\úÀN‚‡Á§`0þ¸2š2•¥†{Qàx6š>ÀT—ô%ù¤º[þðü_y»{ˆ7öáæí·ñe)Ê_y â × ºØì¦šàâºÆ|NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNç§ ç–ÿ@ˆð%,V,XU2¼ƒ¢ d†´µ°DôZb`°D€« ’ZÒÄ’XªpÍR/¸I… &`c †²X¬ÀH1…$Œ)1´ÌJI,¡•2"MK¢ ˜A2¢B`’*!&©JADÕ€ë$Ši–„¤‰ E&®‘(hØ+D¤„¥!¤Á$ˆ‰``:–h&FÄ꤈¸l¶¶ ¬ÜôF”a؈…E„–¹‚Cv[0¤á@™˜˜00Š&¬“‡)EdìEBMCQ 3P ¾4R EB( ±JKô¿BPPB0è@„„[‘M/Å¥‰ Û¨J€°¤¬Rü¡`ËTþêOT©QcM¼°–¿-ZZ¥hÒý nÊ-ôñ:\kaúüÖÓùSÇÅæŸ~¿×OóÔ~Íp; yHZÊ ÿÌ¡q!ÒÆ¸8风ßùÓG®/É ¥¯Én‡ïÿkHóHÀ_µ¼ýõpºZ…§ÂÝ”ºSÄŸÏóãüߥm÷xÂiÊ_;t8ˆµn®§òO8‹êÆ}”ñ~¸Ðit²ÞSŸ¿[¬`ÿ`ª¸pD†¸„såBÞP_× P”Ö=Ž·BÕc'(ãÕcy®'ÕÁ€ÿoè¡ÿp›cóåÁ^vkL£þ_“÷KWQÆ·‚WØ ­ÛŸ×ÁV}F §=ð_×ÞSBÕ4¾ð‚,¥(À~oóÁ*ÓúǬlù-ÜvǾqNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN‚ ]“¹Œ„çÕ ç–ÿ@“¹Æ_­mšVÿ b†­°&6¢ H7#@&RI($L™&ú»’@(Ó£" h&gQ‡0c "`éì “†2QyJB%,Dš€&`–ŠÈ¦S„ 2Ì@0’i jH”Ã*J ƒ˜1 (‚i¤”  $¢©Ê ÂJa0BJI%¥­$X €H–„ëD§ X©³B «-#@Á!¨JoV ]G@Ðc¼«-TËNÉéªÁ³ ±¥†®˜ªI1Q‘¹BJ… LÁ’…€À$˜„îši¦¥@“JÄ- .ËôŠQB@~„HDBÕ%4ST(@JIJØ¡…¥¡òX¿DS+eóä!/È~•ºBÊ”#ö´r¦„QNâ}Jj—ÁÛ¿·­ÛŸÒ‡Ï‘@vxŠ-£ö4>~mÅÛ-SÄùm%J-ëU_qþü)4&»)ZVô‡n´·H~·ùŽ?ÒVŠÚ?'ã(O©@}ú®È»gÏÒx·ùÛ­ô¬Vhúâ yíúâÍþ5¿É¡a€é·þNÚœ!mú+ŸÝ(óKÏ’HÊCëzVÈB?YHâCÿ4ûôûЏO›¥mbË•½Òïíß´Ž5¤¾OéÄ>SùÛÿ*P¶ÏÉõª°Ï|€©ókvî%¾/αè¡8Í­Sû§\täKùþ‹¥óÝëq|·B7K­WOçÅÄy¾#û}ùùº_£=Ê“üåÿ›Z¥o(·Óo¬zœCœ÷·þøøÖ¸ü#žÿ¾$ÛÖ–ðéóúÓOšÊy¼rŠÆ[Êx‡šûÊi·×QÇû§Í[¸ÿ!ûü¿^t¶{~ø––‡šÏl‚Kupeµù­QNP·ù¦ªÞ%ù¢ŸÎ¸ ¥€NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNç ç–ÿ@üð-TÀ×ß½Gé=õ¶4C $š DΠ&I:MOü`áÄ´€6 ‚FÈuaÌTAc áA@a@-2E@i0–,P I$¤ÔÀh“:I‰-/B(, áá´¤¥cRL Љ(H©JMDÉi@š‚ÑIQ¬6  Š†KL’™‰‚PL„ÂJRH4Ë@YP 5€" $†ÀÄ iJH`$°fb`liÌC`)Û¼À翎¶ënæ6€¬Ë ¤†5@Lµ hÃÉ$‚€Ä¦Š¥`+:H„¬QE dÁŠQA¦…€E-|ù4ýúh B‡ô‹ú(BA@Ú){¡kÍW 8–ò‹cÜCeµ¿ÝEx o)Á1”»/¸«õ€©tÿŸ%câG€€¸¿óvÿ5æ¿o©KêÇ(Ê^NNNNNNNNNNNNNNNNNNNNNNNNNNNNç2 ç–ÿ@b—•aEDØ%­iBLA2d 5Ôf¬vî‰ ”Õ Kd™ @ :„€&*’’!¤!bÔ"f@‘.%©¢¬‰¨ÀZ JR a,’CP†! %3U RDI%×Q‚˜ªÂ$HA@“0™(XVPÁ 0¦¬¦¬,Œ"€*‚°‰@0RK¨вІÔL†„TH‰U% ± (؈ ‚þLŠ¢ÃUkI ͬnTƒbe‚.ÔÈÂTs!¦éDư€ ¶Z 1`‚  Ja$¬°Ù k%Ôœ±~ý™5|¤¥m4;jΗéM)$iH ”>R"¢Â“Y ÒE @[¦€Ä)( %ý55–ÖÊ2ƒJ¨·Qý 4!(?·Ð‡ô¦ÞÑ&*>âvÜkOŸ,)BR’µBi ¡i`·JRùú©OÞèEDÒ(ðŠ?K||PýúpñXüD`>'ùN}oãý?Oäè+T-¤Ÿ ·&Þûˆe?¼¢ßúã‡ÅG¸ñRø¾â§óþ OpøUSoÊ-õ(+\h}O〟¿¬rrŽ7AFP¶ÿ">„e]¾[âü‘á,¦É¥inœ§)â¤V7[Ÿ~¸éE[ø©âGQo|C¥ø’VÇ¿a÷Q€¿iGëöâÿä]>U¾%·ëeoÀ@Të…o)[[|íèýå'Â%·>óVÇþÎ _`’¸JÛþ+{ˆ®ÁÏtñþíÔ„‹š@óx6’ÿòZ·¥mOïÍ­%kÂ.ŸŸà‘k‰ñZJ|׿+öSoý­¾£÷æœDqö·æò‹c­Ç÷”å ßy²·ûÊ2šàÛæ©®ÿ’£Â+Vúàʸֲ•ºàÇú[/¿YO|šióTÑOèÓ”S””ñÕ„ç·éin•¿ÊÝ”qÛ‡ínÝáÒrùƒûÊi¡6ô×ê±ß‡mlv{qññå/ß[ió\kYBÖù?4å±ÒµÄùiÿäùBp°IÇù?~µžÈÏn5 œ¥6êÆÊºü펥öQ€©/øŸÛèÀ^iÛÏê‡ùRZ`%ªÆÏ|‚[u¿‹=|#žß»~Rÿ>J©ý[ò‡ø”-q×óUŒµoÏ5”¦ÝžÎ—·`•–Ê+œš”[–­Ïß¾¬u§mæÿÜNŸ¨®ÚÕÄ;çáÓð}‚QoüÍ»ÍNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN‚ ]“s‹„ ç ç–ÿ@Äò²AP{õ¶ƒges@ ²ZÕ5¶ÁÉJ ÆØÄ‚Æ„È'´„•Ÿ H5 „&*N¿ä‚‚’¤T`b J †BRM ’„ÀD/HIª*%t. ˜"ЏA2iT"DH ª™€„’‘†[Q%• Hª-ˆRL€ @hD ¦pB6@À"óe KPI º,DÍB`D@… =ÞÜÛŒA˜UÕdʆb eÄ@D²HiÒBI’)nä,$JD¡¦—ÉX€ @BL³4‡È%“)&’”KúJ%lÓÆH¥ô¡!mÙ‰vÜa4ÑI/‘ ûðø¥n¡¢ªVЊÁ}ÇHCõ¥´-¡ú8Ÿ…¤P‡Í¥õ’ú‚’·Æ„ÐŒŒQEÉK°?°ù ·Ük°úˆ ñ:´-­§)X>Z[âÊKyO…­QÇKûzÛçÀR•¥äµEÐ8Ÿ?h·ø“úÊ2—éâA·¾+t¸‚ÊVŸþOÿTÖ„mߢž;z×îØãXÕŠRþ¸E->E»¼±—Ïé·ˆ·¾·›}»ó£ÂÿB„PÿÍ;~%¿7\=¸©£ø_í÷íkò[›ësဩ #=©ÊÚÞ¬ÏÂûu4¾·e."Pr…»}4­­3Ùýpå’›wâ/ÿ+{ünýq-ç½)•¯Øü¿\|x%ü–Ö¼Óçå/«ƒ;—ÇÍþÖ‹¥µ”e9C§Þ­?°µnóuÔ۰?´~¸ëLå/¸œC`.7ÏýkôégÔWÞ{~­ùGäšáÀb±øÝ/\JÌí½ýpþíÔ­ñV˜[EOËÍ×Mpe6ï[ŸæŸçÉnʳÝk=ò•ªRâ"?_·ø ÍÇ{->tûñe`/7û¬{pNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ç¾ ç•ÿ@˜O3  -ð9²ÄÉ×Xá«£Z¶A¡’Ö)ƒ:Õ’T««À%A€$ªd„!±LA’‰ŠÌD IJÐaIJÀ¤™lˆ¥)% BHLÕ ¢Q%0&ª%0‘I šHX¬RŠQ‡ I4¢RDÐCe IiŠP³‘&Š„S5$„˜–š‚²ŠP”‚* e! A;©Ù”š‡DTMPIh"PfÓ2 ‘†XVy“¶@ Íæ$À‘`4s0W–X[$á†HbgD“ƒ½$UlÀ„*$€‰«R’¶DBQBhA4”?&Œ© „¦¬a¿/šQAD´¤”š"„¡û°þ„-x\‚R*Ð샳IXR툤RA|š_ r‡Ä q¡jßJGJh‡Ô¿¦Š©(·daGHÈâmÄÕBBÕ/¤¬ƒ¶âM¹øZ}VÜ·ÅBhâÍ~IZ)§(ã¢ßM ¡òM [®Ï­¾ ®Aºÿ’Ý8ö(Z£Š—Ä[ÿTëNƒ€“ûý¿óX Ýnü£óº?/ݹPiM?´Ð) vå®*ë¸B/ݽóühX§õG愾 Iü‚h·×Ïwþz/7ÅûðºÇ ­Û¿vàŸÉ÷…q[ß[ë‚ÞùÀÿ7ù„q¾(â£öù´Œü[¸ð ¿ 0Th·~GÍ%ÿZÊ-þi¬ù&‡ÿ§Þl>[Àoÿ<ý87¿ËÍ¢—öêV¼×€€Ø?ýøGõù"œZeÒø$¥ý¹ý¸Ö7íõpq>q ”g±ã·`•?¬öýWç”çÊú¸2—ÿ´Ð³8·€³Û=ßà7õo·ÓÅÅ€­èEcù¬§ò®汣ͺ}¸ðqú ¶ü…¸ ¶êÇÃæŸV™q Xßž® ÷ZÀh‘'äø¸‰ƒjÚÅþ 2•¤á NNNNNNNNNNN çì ç–ÿ@õõ1à E.üÆÍÐwa(,± ­ÇB utL(fcDÁ`. ‚%ÉÚI¨€-”¡ H$„H’ØI’S&$…RàI,™)¦ZR¬6¢ÄŠ`BÔ”„‚)Am)HF¨(C&˜MH u(”ÌÊT$‚tH )A((Q5S'üɆÂAQ¥I”3q†`$QD a·S57$À³-Ð%¦ØÜFØ,ŽË&o­2¼hÌho`…„2I4Èa«’ÈI¥ a楇ŠS‘蔬IªìÕJj!4ˆŠ · $-¥ôBÚ Õ Jj¿ã(ŽFO•ŠPŠV…/¸‹çÉ·„„P_Ë'È¡ij‡îÇþ.$:)âG[H(Z·-:RÔÓÇBÁõ½l[ÐùnßA§ŠÝJÒx–ê%(+@|ÿ)+’]šhZKì%/¸¿e?ºZ vÿÈPµnãÊV+šÂÜé|¥—õŒh§ñ…º€[¸èX i¥õ‘-U¤~­þ$ñ~T¿~µû¯Õc?Ài¥öS”PþßCáNâýù¼2Ÿà­;`>ý¾ð‡R-Î"Û©tsïÐù&ܶÿ)Z[3”¿ÀX%ýq­å/©Mü£öÓùO„Vè9E4å/ßù¤g·›®ùRšá«ùÒÿ(¬l÷|°[.‚•·ÙFUÒùŠ)ÒÈÏj_×êÜ·ÇûÀhðµ«vPµæßWQùºíoýuÃáX õ»ŒÓ‘.P¶éd~·-׸ù¯6•«vPét~uŽâ'è[“ÅÅù-¥múp.—KìöOêßM¼xA÷ì')E6òâ/š)Ê?#nãvË_«r?\N–}€­Ëx*Ê?UÂø× Ýpº^±Ý-H}XÞn´ÇæéI”çÉžç(¢„eÃæŸþ`NNNNNNNNNNNNNNNNNNNNNN çç–ÿ@ç%ø,H¿A~”’Åð!½f%°ÖÅíz ÀXd€ ˆ$¸uÕ€PÈ- ’P ’ ™©1 ’J ¡!5!µ %‰L€I Œ3 ªDCú¥¨–ja†“¢ª‚œ$˜ `©…T’$–¤„ ´ ™0 ‰ˆ ‡Y(L ™aP2Á-;*j@ É–U2PvÖT@ˆØ€€È&bÉhh €I Ø´Á*†ZÀº[- ²¡Ä•6I6É]XD‚Š‚£¬ò’YC¤¬… S PxŠPP MRH¦TÚˆ•¤ˆ ¡hJĤPì¾ ¡l-?B—ô»)ÂÙ„I¦ŠÿoË ËúM (¡&š2…¤>-%+eb”ñ?ã§Šßú¢“JÃ(ÅÇ/ß­/Ú]—Ë|r)¡ FRý%`éz™Yž4')¢š-ç"Šx¿Iâ¡úÇÅnÀAñZZAl>}C÷èn[ý-£ô‚´°?§á÷Rùÿ.7õ_å?’+Íe  ¿Ê8‚ßhóUÄø>ÇGì>À_—ê±–gV’·GçûÀT£`'ß–SO·þÏðSæ²…¼öý?ýe9A£ò|!Çæê­äIn¬d»wëC‰§ôýòÞPµ@JŸåÿn!Ý,µ”-¾ãüÖg_ñÒ_  ”[Ñ\cÍq`*-öà2ŠmÁR8ðKæš[ãüÿ'ùí€ßÖ3ˆ©óO¨~´|"·ù­Q”q¬g_~O‘àT¤V6 (BÞ{ y·?¦ØàJŒ¤[ÿ+{¥_Ïß-V7êÜœ¥6ÿËŸ¡Æÿ>Jk+Oÿ/ÎÝÄýó¥¼ÀÝÅ€Ý/ù× ½ilà,Æé~,øF‹xüë(§)§)A£´þ––¼Ýce.—ZÁ"NNNNNNNNNNNNNNNNNNNNNNNNN‚ ]“-‹„ çIç–ÿ@˜_×I°Æ44¢{ˆ`ÙRAL—TĨÉ€RÆK@*L)H-`‚ Lfgb*T 1 DÄ)0™„IEC%0"0¶üeé@v%þv+"”TÀ$Œ9J*T‰dDÌJBh%aˆuÉ5)(5!"ê l"©’Pƒ„[´ ¡%ÖM!¤Í I(©8A•Ih5jBN  ™è‰Ñ æ[T4oa€ïLlÑÀ“ÛNκ0fÁ‰$3¶‰)‘TÅ‚H¨v4B*0DJ)(Ja"­C@˜(`)!4‚iJA|²‡ôÃó oÀ‚Š˜tÔ,_ÑJè¡I|„RâùÛSCäÒA)|ù)¢”ºRíÿB_ÒJ·n[¥in(¥n—Öþ:V“”>|ú©6íSúL­»)EDÓù V–ø±ð»z4?|µGå”Ón«s÷Ï©[GJ€RƒÇæÿ€þ±‘NR] _ªÆ·~ø€âJßêŽ4­>|‹u (·­#)¬oÍ÷çEo–ðV¶2Š*üÕ4º ý:RÐRø$~ŸÛ’þ¸ÿ^k‹‰÷q;ZvüKKOÿT¾[¡.à|h·~Ñ~‰âqö„QX߯ßçn£¸‚âýþU”q­,¿Í¡múÞSNSùQ”W (Ê?*Ç¡o=ߺ2Ÿ`‘nßNZã[Ïgë\HJßš}”V2ж_-Ûß­­" ¢´Å¾›v –¿+rÕÿF±íÃ=ò•§Þmý!5Àò‡ùC±ƒm¹Ä:?o¸“Ç€²—ôþNƒ‚JÇýþßÍÄU¯Í––ÿèüÂ×…-à;cÂÆ)â§õGï)·Û³ÚªÛúmÁ4x +vúÓ—çæÿ.*”¾?¿åV=EJà|û‹ŽØïÉbšàý~¨ãt°NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNçwç•ÿ@5øYà J•þ{l¯¿¢Í*ƒ'þX¾ 4ݵ2ˆ€ ƒ¦I €Öl(‚ˆØCœ8 A’tMY$Rœ3(IK“N’ɨÂA: ƒ,LLAiÔ(&BAÃ2€Ð0PU( “LÕaI@$B+2„’š€ª %™(š*U$Å&I5R€DЍ%Òì‘%!¨B‰‚P!'¥R„Š£Ä % 8S!AAAfXPµF†FPÒ"Û:Ëš¤9Ê¡—¶&H*–b ¨iB@HBH ÂH@4”¬àUv@j0@@ %‰h„PýúI¥òÚ(¥Ø«I¤…´Š$Ë÷áÓDЍGªÝ?[&•¾7d»4%nÞŠ*Ò”¢ Ïí`…‹þ*_¤Ð¶8Òýihq­¡inÝú lZ¥ÿð´­->©ÅEº¢(é㦈HJxÖèâ[åªSJñ$£ˆú„q->·ÓOï÷o}æŸäKK÷ÿ™E/ŠÒmëOÿ>4SMøéJ×ìeHN¥ú]ÝF –Š?5µ«sëz_Ñoói âBº c­›}pùºPÓñú¥Ð+Íe#)üŸŒ¢Ü´¶ü¾þ:ý McÛÖÐûŠ”[ÿiFRµGïõXùíOäŸ7žé Æ—áÿš[ã ãZBmâÝû·»o5žä?ñ'õùþo¿OòŠp?•6ñlzkR‘æÏ們Y[Â\DüËêà¬t&±Ÿñeå¿Ë¾ûÍçµ.–AÊk÷›t³ü"þÞÿôx°é÷›ýù¼÷/‘Åùà.?×å€ÿ\IÊ-î—[¢ÜÿàðŽPúšÆ}Io>L¦ƒžô×Êû®H×ÉÁ/šÀUÃ\5ù`<£xBŒ”~ÿvÇ‚éoÎÝ€­Ø%t¹ÀN!ÿ_µ¯ËÂÎ{eíÇZak=‹ìHNç¦ç–ÿ@bð#ÕØòsg¡uÍ"HÚ›]²ËÃ&;b&#`™¦‰,n€h$Õ«±I"%2C̘ Âpà5; ¤™I&jÁhh@I-5¥0¥ „HJ" ©‡EM”ÊHÈKeÕ)(4Õ‰TH"”dVDˆK ¤B@NA¦fU€d¤‚RN’dP–¥«B`È„¤ÄˆI %’Ì&\’K"Ú°7 T¨lžî…XK™¸HøcR%Q¡…›É1 !&bZLƒ@YÀ¤“2ÒHJ*! ™šP±Á¤¤¢“ XÒPL>J""¡ „±4ÐE¤Rš( „>4Õ4¦‚ùlñÐ’°©Ä”Rú i Kô? ‘ BÓõº2˜KñRšh[âÐJVç(¾¸Ÿ~ÎPP„~Ëêˆ}o¥ÙâD”¿(£ŠÜìRšÎx±|ì%Ðic\ XŠGR·FD¥ð[êÇvÔÛËô[ÊRì-ñþ’þßBQGíij_~O…º)Ïk}?°ù9M5QÇo¡Ø~‡é¬z´¶´ýñÅGä< mÏзÅot¤óÑñQáúMâv¼(Ûßþ¿IZZHÊ0(}‚»~®Ñ æ©Ê,Xëv÷ëyJGéiþ•ª?<hÊ_å6ì÷âãÀyFãü’kƒk~$;/¸°çù`ÙúÏ{w¹Û~UŽÿõ\[\Ö5¹þ -ô,Ê Â| _—Ζý-§(Zü–ÿŽÝ‘- ¸Ÿ~b¯?;u¸øEóðú‹aÂÒÖS€¿KKK3–÷HÚ;}pøCò[¢±¸ß罺ܵ斖ø°·>£òýà4>¬t?Æ¿*x°KûOïõ”:~ÏwÞoµÂ·ƒe¥¥«çKþÏ…­ø+_ÀãZ®ÈNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNçÔç–ÿ@ïð*|¨ßW¯!¢e‚Bª( +ÚlIƒ¶D5"ÎÀ2AVT˜ÑH10dACDÁKpŠhê[$NÑ Ú€I*ÔBI"Ä ¥¨‚F©ª  DÌ  [ (¡a ¡PH/é‚$È2PA‰I(A‚@! „§¬[Q`L‚° @¨·PDU ÁI Ñ”…I $A2CZLÈ2‘ I€2bR©Z4n¾Îúu†††BªÙ`›•l)"[«£¸Â@’&!bL8`¥¦PH1NØR”Ø:ŠKö @¥ Th%úLÕE, JP]‡eZ|è¡­¾vô"àq>?› TZãvôš8¨4~°î‡ô Š/ÖŸ¢ŸÛ·|“\/ÊÚÛê Ó÷ëX*(JÚ”ñ!úKo‰ BÝÆíé4¥úÕ?¥¸À\Aò\?¯È Ò”[‘A·Ûß­åOR+… T¡â§÷”бý~T[¿_§ütùºEGô­Bmÿ¤¾}n·~x [|ž$V5ºŠ–¼#ÅžÏݶ -Ö÷ÖêQ\ À´·æ­éZZ¢ßKþ5¿ÊßnÊ¿'ÕŽì­e)[üÐþŸÕ¹ØoŠÝGæ|ÞPµ\Û¨Ê?дýnÝžéýqдµùà”Qá×¼>üòšàÀ^jÏ"9jÜúž:áÊ~–“”ù£žØ ?—åùå6ÿÚÝ¿­$'ôŠÇ·­[¿h¥k=ÿ4W„<Óˆ«T£Ž‡ï³äâ[¢Ÿ×ë=ë¸Uݹõ¿ÍÖ1ý8‡Áµ$Zq _¾® zاͭҊkÞýÐV’ýn±«„Q”yº_× ÏpûÍþ°Là'Òú…¿~te/ÝŠàÁ¶¸2€NNNNNNNNNNNNNNN‚ ]“ç‹„çç–ÿ@tÇ€€ +WÕ7÷ Žø;!¥„@è˜$I¬Ó*ÆÎÚ–€Â„bálˆ.dD”P"ˆKa$Š¡!¤È0Xˆ„È€X„ I" RQ)&DÓ J ‚V “`RÑÂ(EILÒVx1TÔAL A& €°BeˆØÙbDP³„¬@T¤¦$ÀÙd´B!"¢V0:5Du¸"J$CH’@f¡›$LôÖImELG`ã™,h,fö¼I!®»€„f‚„j2‚ B Ã’P@ªtŒ*•Z)4&Zù&¬PV)JjP‡ácBÚ’ ”>&‡È ¢ªÔ—d-¡ù¤Ð•ŠêŠ)ã~VñÛÓÆ¥ùã¥ð BP?r‡ô5JØã~„SQlˆ|•µªVðTÿ#;4-”¨@ '‹ŒCê´¥õ!ÛÒù&šhã„~T}~ÓoÁRÆš8Ò‹zݸ¦€_,S”[Ÿ~Ö²š8¸íÖ쥨£Í¿K÷öÿÍÐ(·à'ïÿ+æëò)óO©Zt·›4­%#öù AÐVoy¥¤þgŽßXÉâ6즜ö§"T5ùù´qÛðKJ­?·‡ß¿Ì¿[üÂ+$£~k_)ýqe ¢Ørú‹tà/Û¥Åð¹oo‹õ¿ÏòtýnÁXÕÞþh×p>ótþht»ü£œ^n¢0šB+¸?øFœ¡(¥òßš¥.–áà8¸xèMc­y·ÉOš@NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNç1ç–ÿ@ð*ÿ—ÚÞÅѳò.ÔcÕ Ãgm ʳ53($”²2A!²0Á«!—6–ȆÀEI  Ë SwT$,D¤T€KÔŠ¤0 ¶€)‚”¡´@!² hM@‰¨Ó„ Âp’u Š’ "“%5@HHË‚BP†msI€Ù )HAЈÃu²RÂ@:%AJ˜p H%¦"MÚd¶a¬ÙDÉ`$À¹a[¡F64\á­¹cPeI$’Ј-iI „¥(¨„:BA€±BBP/Â#dЄÒþh ÑCåˆJh¥»*,@|ˆ—A)ãM)BÔ"”ºM.ÉYë\KA# ñ4%ÙÊúVCO…—ÆÞ•ˆ[E+ð( [–ß"ÞµE¹Ðq[Ðû(¤Û²”ºš xÜ•¤Ð_Úi}ûZÏ|?ñ%«zÚ?hM¯Ý N–~ýiõcÛÊÞ{RýjÝùû¦š“n®?ñ?5Œ+±mØ cæé·-¿À®$Ò¿t`‘ù.HZÊV¨ý¾ý-!."e%kB ïË/òŸÕ£Â+nƒ‚JÇâ¨)}nªú˜·qñ‡ët% £ò„‚;n7ÔåÉ Æ•«pM å°(C°ì¡4&…º <`R·\CJméK· Ò‡ø+·%ù·?|¶EKv “ÅÅRßùìŸÕ OÊÙ@Ê)"šr…¯7Jh|µM¾„۸lj¸ãñ-P•¥¥´¾·å!ý!h`*àý¤ŠršàGî…¾%®1úÊ2‘û|—ÞjŒƒh}oZ⦗Ͽvëy}æx°꜠g·P•µ‡¿òóYFXþIYœG€€xÒ¶¶·G÷ÙM¿ÿä ?iÊ0æK§þ$[©q”¦‡kúM±Ã/ø¨·à>5™Ûr-ÕŽßä‹zݾ‡ôñ—åm (ÀX6 Ö7cà0þØ÷Ëo–©ó_¯Î—Kñ­¿Ê2œ¢ß€,·‚ZõÆŠà·çµ([F{-ƒì¥Ø­1E¿=¸‹ˆ¸$tµ»ùòmôÿ>é{zE9­ŸÓÿ­çºÛ¥’þ¸ršátÿ\"Üxß¾üÝ,@â}lu¾Ý”qþéFQ‚L§šá[ZHNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN‚]‚ /‚ç¼ç–ÿ@ ®q~&<ñròX$é|N¶t 2æîò³²Y ÁÂ- ºfC«¶ˆIE@A &L$"©  &`j”Š‚ªJ ()J&¤CHJ*˜%W 2P’d B¬Ua P(!%"ªID%!ÔA0SQ2LH!-~Ij¤ƒDT‚$ƒ0t€€Â „à±*‚,HÐÁ!Öš¡°Êº©-“ÈÙBvƒ2u²DÁ ­ªÐÐ –ÁÖ¤‰jf*F¾ êR€ ™|ÊJHK* š„$Ê[Q(X„€™Z(€)% 4Š©AL Òü‘8TÓV~•‰¡@I \O€)…·š•M)JSJh¥|I¡l%ÿ²K÷Å)ÓÇE%nÍ©%õ%&ß–)+UiK²’x’±·ÓKÿÚßõ®4¦ÞOé+o„­H¥+gˆññCïÒü¿Ó”¥öSÄ´ý4ÓÆE4AZKôå?—Qo·%+iâ6ôÛÖÔ·å$ÒCÿ7€íëa>mkòã[¤ÅÇú¦Šxß`.?Úk…m+x (}”->Êù­þ_¡ú|‘*×¾œV=+’Þ¼è/²•¥¯Ù¥_¿Ï‰ÐV²•ºá[·'tÑ”?·à<§ÍþN–t¶Qù[üÕ¸¾ý!jP)G›@æíÜ·ÖúÆÊ|_’Û¥ÿ_µªhŽ*¿·e¼ß6ãæ–ü#ÇÆþÞ—ÜyM;86-SùþKtSCô¡ú_ÛŸ8†Á-›¥“oæ­Î”µÀü¦¸?Võ¾>3O瀛ýºYOº—Ô¿|’Œ«Š_¬kƒùçüž r„xAv_·H¤-,iª  R…¡ —Á/ÍeO¡úÀ¦š ô…ªiKòM4Û(ª´ÑA AâKñB-ï¥ñ|$#ö¶€V¨©üÊŠj?hãýÓJRVݲÑK²ì% k@P´¶û÷ò´ŠR—A+i·­ÑžÉ¥qŸÐÊ)ÀVü÷}…” å(~•·ô~c‹=íÈ¢œ‘Xè'ŠšÆýŠ_>¬jÓ+O°ñžé 8 Šÿ(§Íe/Êò” QCäÒµZf‡öûwÛî,û)}û£‹õúEõ¯5\4£ŠØî/ óyGçBOço·~Yïžÿ¯ÖÊ<ÞS”þ_“ˆ¼uXöÿÕc'‰."šÆº0ÖÊKˆ¦¸-é/³ä§=¿YJ8‘Xÿª+†ßXÔ8†¢±ø’ùmÄ7NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNbeets-1.3.1/test/rsrc/full.wv0000644000076500000240000003243112013011113017014 0ustar asampsonstaff00000000000000wvpk 2D¬D¬¼â¶é!RIFF¬XWAVEfmt D¬ˆXdataˆXBWWGVHCÏßïë –eŠÙÀt@¥¬ ‘ɦfÞ1/°¤% ˜k^‹™«\Á¥”Eãž7h… <–°ÍeÀD*ƒb=p™bE‘ÎÁ¨¸‰±I(€šxÀ‰À(N%ªT£´°TéæÂé6™r¬‰Sˆ(Ð`xtÔMI)gÙxL¨Up M ”¾¡hŒ˜`pH /Bš×`0â@ 7K6ePÉ=J8(p²Íü†Bd h sr`ÁMybáhÎn2÷T £ð6Á:ªûrÇ`…ŠÆŽ’Ñ%"39>JL9Æ uì)<ÚL;=&B8GAáøkÙFhF”›;n@+A¬P`ÃH›AŠFÖ$FlŠf¾÷À} f©S›áDœ4/°Q¬RÀ+žÓÒ"(â°¤ WéÄÜ÷´),²0±µÐMP„Þ·$Ë„ÇÄÁqŽÒFàd-À×?©µfsh"¤ aXjªŒ`²<½†Ã•°9Œy1xŽÁ‡êrVœu°<Õ.™î¤¢Ü>¢q±Ä-8np#á8ŒÜ¹âC#yT†1=Æ ÔÚ@ÈÅNòc$ŵ’Álrc U¢«‰ $¸O–JªFQ ó&³Qd™ˆŽ4/ z @jQ g(l ÆÖ4ƒ3ùJ~ñfИŒ1¦Š%€˜¤ÃhfTÔàôN¸àc“8HàLaD R+‡9QˆT~„ºDÃŒ2:Q›IPvjŽñÁë atÚXÖ!HªFÜ °†ÃË)Ì15ÕqK P |–MáØI=ŠÁJ`Õ±êíÃ+D™™X­ƒÐƒC=2™7TŽƒJ3)‹cðáDÍ,É"¤ÇñQ'#z¸PúDü—‰6ø RYÎ;ÐŽB¨¤4Ž']'T ¬À‚D3@ˆ…ÖœX J|påS ‰ š‹Db2Æñ|! xg¾pÈæš½=¢^jc'ɸeÜ`ä&À©Ð DP+ÔÈàˆÆŽñXÈÜ% ™jªY–ôjX†ìj‘l¡:dÙ’Û[µ>4& Àá>Ä ĉÒNЃøáhŒå¥€,d N¢³q<^@b…>Ög¾ÁÓŠQÏ:X-è`“Fø¡AØ\¸~¢µfs µI”Å’Å óˆNŠãÊñ¢'v‚nBà„ž¹ÃøP.”ãA9؇2Êic"Y èŒ4FXžªa!ŒP§"øøcb¢ã(ð’¾A6fȈùÍÈÏ@q*.¨`¯[kj®Ò ³€‘c¨YÊñ+ìx„j‚áX‰¾B$ìâ\R+ÈD× R‡€fÑD6ØT³,é"l,ÀRÂ’éŽW€PxS="=i t 7©åT3A˜—0`d„ÜÃÄQ0$Ž2îH;-îÁb5Ñ`´ ÈG˜N4ÁÓCO†P–°¬ì†M! ¤ YpJ…ƒ”ŒD  m€Ø¼,x ´1.Â0 MÆÈÃÊ•DÔœ€´Ñz")6÷Á± P,®hôÀFÕ\Òš–t?Š‹F¦‘6²ŽxAkF—X˜äjM P#Þå¨X`dI4Œ=ø(£ 9» 0óå·Xš€—tì`¶h AÀÌÙØ#G€¤ÖO†Ö 4šÃó% ÁNvº¨%„N ZD3”¡-97 .!í¢ÎäÄ÷iA”‘ŒÔ X‰à;#QÀ&܄쓭:LtnT‹ àhé¤mP¬„IÁ(ÍK Ђ¤Å-Z¶GO‚d‚7ŒP@gY=˜ž (‘¹‚p%l!HLJ|ÂʤƒfédÎÃA8>@k0'-?c¬ƒÍ‘ Ž‹, f Î¥“ „d³Ã’jØ)2q ¡‘…ej0§ÈhAPë©®=a–Œ2`©…Pò\wçñP0Uʼ¡P!à˜gÊòÀ± €§¶X~œ˜µ¨¹äl8ºH,f³ü:‹¬àL;ÆÔ¹$vÂCHª@x?V  Sí0û#"³:X3¼ ó’àˆVŸàÆCEÁÀ4:bqL$`<¦¦„?L5/•ieââ8{`hòbXs¤á`€©…+CÀ”T€Ú2¿ÆR(™º¦šj”ƒÇ*™x„yXòŽ­ #Mb‹p8‘‚E5Àcº¨ã@#+QRèHH>º@áÒ@4ƒ¼£ޤ“ÅÙQ%óRH®°ôpS*-¦ñ—h ØÀHä Ï5Ê6Ø9; LT>•$FŽ5hYdaÀ”â2CyìÇGÉ0‘A®A>A#•©õÁ:`@=c“.Tx™1 ;dX%‘ >âÆFEæN€–²@H‰ÈÌÁ™ØÁ­Á `æÊÜà@òX% ³ÃX€‹òŒ©b €Xê ÙÜ™š AfS(ë1ú…‘ÙŒ  £™€gfò…YDËx aP¤J4@"h„B¹ ó2Ã/ È‰ŠŒáæÊ| ­ ŽÓÈ Ç†m*ˆñ46Í—ªÇžðÍ åx‘¡ Ô´Gp#áx/Ôbã[€ 4¸@@ -æ$°TF9n€‰g²˜Ö xXöꌡš¹Z³ Ël a#©*èhêT¨yAÙ#ŠqÁÓYO°ŽpHdJÓNbÁZ‚zºñŒiʧe“Ê«ÇÐ¥2Ö™„£`Ò×|Ò•%‚I°ˆ‰™'(‡ ˜F†*’ÊTóÖ¬ŽòHƒ+ZÁA” &À˜ K^ n( I†ŒkŒ`ƒèÐ8°ÜÀÈ#‰tˆ2ÇÂ%ãùaÞit¹ˆA’AAU¨fýÆe"„±8Ôä²NT² ‹FÑc=•ÊÛpJCƒÊ°¢‘Âø .A<Æ&&ŽÆÌÀ£Y:ÇM‘‘€‰Àžõ1yfK,($£Ê á<àÕÀ˜ kʔ߄‰Zk$AÍ’?AÌK@A˜ÇÑ3%f6” HS£µ)ÇkÀ °ä g;Dµ{”‡ÄÁåÐ8Ò@$p ÉÑ$ £VÜMÀ<Õ-" S‰; G4M¤g=AsMzMæEe.¡ 0zˆbeÔi¬;óöá¡dî ÐD °Åå´L¸à0F X9• ýR£Âw±‡¨â—) Ù1& ØyˆØWr”ešmŒ6³MÔ,¤ç¾È´ΡW‰Å²§²rÀLd“zê¥ã²y1B6o' Ê‹—޳Q–À*KÐ"04EÄ´1qù¸¡ÛAš@õA›í0ˆ¦î©†ì®K`%(·5'‡›ž¢\ÓåPgý›˜±&´™’€R@3 N•Âu4Fáøø¨xA ‘kFð(;šã×ðĘKJÔÀ4£Ž“GÒ8 DHŒÓ ccͰ ±2ÁÂú¥Ábœ…=];p™PÓepj@ ˆRâÈóH)Lö Éu¦«sɼÄhB6°0fŒI)VæÞ2HÉL’`p| £%3„JÁXTƒ€òÕá×x´£Ä¼ Ú”3É Ù@ôÃ"GȤ j„6M ¡(^kFá1yfgà´L´d:*ã%¨Ý€=XÄòÖMAæÆ‡ó …l¨Š*E ™JìØÀ@qD¦»á†#PZ#ÊÆDGXœi €/Í7šKÉ–tAÑ bÚS@t\P9˜n ѡ°k1V(Tr18°DŽt”ƒ%€‘i2E&¬Çü†(œˆ†s±Ï°Ê­€>â–à…‘4¹F “DÍ/ÞûP ´Çä±â̵#EX2yÌ•Dd”%3L øOÍÑx… {Äp Xçø‹PAj¶æè"Ô©‚ŽKN4uª @X6Å'Rp ”T„é˜ Z€XŠàÜ  K`‡ŠF&­D†!¼CI‰ ÖŒGt„F6¡ÀuªÃ9À@2›ƒ„FرÅ@b¸ÆdA&¸Nu”$c1Ù˜S„,W ªsâÕ¡r7@²°ÂGÃp4ø€<€‰šÌMˆ5E`\Xd€Ø£$å1¸ÃD­5’ p¼ˆƒ,Oìá:6☡'†‡”*/Š"Ì3ó€P¡^´%Èe"‹­h"uC‰iÍ ež’ÖF=ÆÊ¸ÄH¤ã!„KÈ_²uH$Í¥ÔZ 3ø0HøqZœ6Š”Iü  \'V rePN¥†fã%$R‚ñ-s‡p±ž˜8„»`ÎÐ=¢©0_4¢h¤ˆcýETØ‹ÌAŒ¹´ÚP™_À€š/1£Á- D5¥–Ž€™2/¡ÙÐR@ç!àG'ÄxâbÍ<Ë%È1–Á8l8pzp€ Ì*ѳŒ1q‘PŽ• Å 9Ö¡A¤Ê•¹‚Dƒ(P¼¾ A H¢W„ã"Š%"GÄÙ‰xi‰ÇhûdN•õã¯?T@³ld A›Êšå\‘a 0i4ѱ.B>Ëbî3sx8ÃI¿ 8\ “wÀ;Š !åF «‰—:xSSJjñÕÜO…”!È2îPy0„WãÑX $Ó@†¯7RjèŸ;À|á#e®¢T¬²ú@ƒ‰j*/€‰aºaáD l²ÇºÐ8ZÆÚ5SYx„x4ŒˆRI—ÉlÁâ"kƒFhÍ×"ÙDC©R{4‘ìC±€‰Rdxf=R"ÆTÒ f 2†ãCmˆp›daa#¥£‹ˆ£qL6àå‹5z”AR 09Á­Às‚} d£©dbAÌWæÒãín¢¬–ŒÊ3±bGÂ1/tAq4ŽhŠä;#º§rÑk.9æ.(€´ÐpÐXØbëá7Õ¸³k¬9šñÑ:‚ŽX|r¸tÄ¡Âü@¬`Á¡K€Ñ®q–„ „+¦{Â%ðrŒC–%Çî\r e‚aF²Ã(K' K ‹=–D#§B÷è-Å“’𢂦À½¢ÊB.Êq¡Hd9äŒ.ßÈŽÊ…0pfa²!‡{XTt0¡k!BJînäW6¥• W€ÙHÔNâ °IAËT‚F&Ë¡Â4ó‹ò©¤©B´Ó T¬ì¤6]iq#l ²„›–HO„@¢Ñ‡QFËcwð¨2—ÌÙø’H@-Ñ:Šð a#B‹ì¤R¾#@ÕàÉ«C…Ögâbâhbä,æ¤`“aaC¹*b \B«(n@S a2Rh&—FÌ’C2eÞ€°P¨ðx*”%ýcXd`F“…– àoRzͤOt(GcPÛ7HDÈžÐèý„¼OLÖñÒʈŽb(D kIâ D…—Sb2ÉÀFdÎC/RæH<BÁD8`Œ›êÃ5@§ÅG`€ïi™jªãvëŒKL>Ô– Ãr°Á|Á’n06"¸“ ”ã5 Qþ%PêaØSO ”aYb‰ÍÕ!yžn„,\j¥Åäd«K°ƒ!AQJÓ37c-T)'–ƒÄS(Œ …ds¦L]³Äq4Ê€÷1t45²z€ŠÎ|àºp´Á5/ UBcŒ•ÁB;@雀U$…»àˆÒÌ/!TéŽü Sð—â!TâfÄ¢¦{ptN@Gb²eÂFaއ€ÇY‚ö0™ºN=©z°2Ê`Ù§0Iñ $£éø’H…£%žQÃ_ |˜+àPÖtM5å¸Aäè7âã—"e(àP"æ¦tìΕ¹ fh˜[€c-0+›)P,ãTÎV8\:2,U`}@¢™¦æN¼ ‘Jad"TÓžtC Ì×€€UëO\€€3‘‡Ð‰2ó°´ÆÅ'8Ó:Š ôa£!‡%¿,ƒ —^(±t<ÃPy8‡$•$° (©ÆÐPXRsêØ‘[D^¥‚1ƒ%FT6ÒF˜f¨.IA‹¹a`F<×\j´Ì[#ž&æ“<}0‚ b¢˜E²À( ±žõ°f)OuÄGž*¶îó¬añ ¢c. (¿(˜µä ñ‘¹Y&h‘‡R 3_=Çí§ð).>Ú@«hЛ/qŽ…9ÅÈà´‚úÆkw`¦qX`Á’á>ŒO y@T$Å¡I‰#…qfád(è¡xqɉ€LÄ€‘–L´6£‡¥€ S{„‚Æš°Bæ©Á¼8ÎÑŒ)`)0RÌ­AÒN5K5µCÍ5Õ©Ò© €™jÈœ¯%‚:õAÖ:âB8$Â%\’8„J*IM‘&Ч%C¼¥í¨ž¼˜ ühscQáò#\B)êƒ4Z|Iò–˜` Ðé† ­è`¨9’ËiPé0•bL$Á)RÙpBL‰º<ÌÃHá2zÄc‘€M&3úàuÐ… u¬1:.<ЂÐt zQ—åCj²>À7Yzæ$ ‘a€‰¤¨B;ǃ„8àì!™hª©)Ë ÈÆ7ÄŠ% Kf?È\ÃÅêAÙÂϵnÁÔ,7X È\MÙ =o“¥µº ÙIK€•ÁÚzE¥q ³9H…ŠEàH¡æ P^”Έ¦È ™©ž˜šh,¬’ÃÀžú Ù–ñ0ÚHyêb¢R!5 ïq ïÙhâ‚4ÐaÅ\9AƒÀÒV€MY‡C9¨H–Ø0nŠ!0YÈ z°´4ÈI3*˜V , b¬8þ…DÐ#¡”RH' ¦4µà†<9r<Æ©ÁM!‡ß&IX8""31S 7W'‡d²OGÐ ¯ÂgÇ﵈ª10ÖÄQ¡Ëô‰b„ÂN5¥c•‰ê¨ ŽnÂ"&逥”ŽIeæ-Ñ5_˜¦Ñ“˜Ï%jºgª<ŽYÂdl@gáç”IOkÁB±gÌPÁ aQvÚÍTr $ÁËKplFcÌ2¸4Ò™bs±½0ѨÒY “g @R©‘Þ”ì#Jsªƒ&f7=\Øè¤o¦S{ E¦@@sòœTaadP”;.×°Å`  ‘!ãÑ@‰ghE%"6š6@šG‚p@ö`–‘82y E•Ìœ'Ö–¡” Hf¶a â‘BcSdÞ#Å'[ª%%nP'€h¼cºX Õ½20–4À¨F#IÀQ¬$š z(ªØÅnˆ&p¸.PÃK aÑÃ],Æ¢As6ÿ(r2Zh§Â t*Ì¥ˆ™^Ã`Fž|)¥%1¸U™À=4 ;8dx¬¡ÜZ3‹EX<|RaáEÑRŽ/IÙeIá°ùå«a,¥”͈a(‰×HÚÙPÆ=Ô­³v8B¢ÖTŽO² ‰À\(/…/BÆfˆEp,1ðÈ W±È JÕl¡^ŠïŽBL% A£¥00üqÀÎÕp)€N}3‡sCkÂÊ@‚ ¼Æ°µ ¸60`q‰‡²E=ô|€ X&ì°Ò‘Š[Eø2…›ÓÁ#ÃFuÌIu$.¥^,]<5šK  Qbãð—uxM$Ý~)ÏFÃLÑ&ù…•Dùi_ib0Ó; –0¨Sti(ÚÙ€J\‹®²”É™¾uÀ¢‘)…#A¤PHdbÔ´{ 6lÂK¦³ã3RI…;G Xc¤E ¾K*§d°n¦èȨ7T*ã‚Æ%¬ÅkŒ>' åÅF¦£öA @¢ÁÚ(ùI· ìŠàyGùeD‰ ©ŽFIž®€¼7˜ÈoI\ƉͽŠÙ( Î;4Ð+BñEX0yàFsÅ1RPaéPjºYplžh,7‡æè$Ä#ˆ¶åSÂïP…| {\u†¶Pä6¥‰ÖPa40óµã ˆ(ËB$ˆ:½9¼ÎÁI4‰Pƒå°4„yahâ€ô`ZÀÔúð2ÒÁ¨âQÜø½Æ¸²‘Š (dOQ…tÍ•DeC>éH*5Ú0ž´Æ P±ÕBƒ;@S8l^–à „£€péêx²„ÖJ.±Ð"¸ÇGQŠÍ›"&ƒgdÒ¼›In0ÚE‘èE×Â/lÂ#,0â¥aZì‡A ™9 Xó¬Ii;ˆô—ˆÇ` ÄàÐ… a^àÕ „æ»1Îè3Ó@-˜2ÙQfy‘u(gÐ02l¡Ͱ|f´Ì,M¦i4®8;ŽH4ŠÃ`&ÆÔñ¤DÜÉå €X·`"¼ÁÍ©§`ì4¤è¬·"ê‡/» ¢èñ= ‚Ž8à ·˜m S+3k")KØseJWåÑ1AYd& :˜©v Jij*If)§:@ò„K5‰‚rœeÙˆ Zƒì!&J“SÍ'`6Ö+À „‚°6ØtùŠBïÓ÷ ñ\¨™`ÌZe‰tZŽGˆ/M*B•«Ã=¸ŠŽJ‚Ä`ÔÀRH š‰Æ¸‰9†rõ@Ááö„üXa‘ŽyÌ<¢’f~˜ß Ïh;A‰­V µh¨-Ÿ¡<¨Aêð…##Hœ(Ö03€§Ó’ZFŒý -Hiwè²óQö‰nZC»cÍSŠ,AŒíœ7ƒ¾ à¢è N!ò¸~ÔŠãh(s€Ä L²1ŽÑ‘ƒ8sÝi±pVå…@X2¤17/ž—˜Qˤ;e¥Cp ¸”%40Œ6”uAšÁ2gyäÅÄì1û, p ÌÑ'6ð6côŒ/#€ZŸdŠò’,® D£p$ɲ$"TЬ ô–Í%HÑ'©M˜]b Ç âùãDòA4`1×(âT24H9 ñ‘Ã:Q13(/’‚wú P3žcAp‡" ók™ì ÉÁJnÆ%Ø`\r´WµÖCZá’he¨ákR  Ä‹3Ób¾p>ÆHÖô-éŸÐ‚7¸2}IÊ=M™i\D²uX’ZàpÝ®¡N•±# ´Óœ(·€Kµp$4ûAF0‘£«ög„ ¼8®‚Ña[:*äF‘5V±Âw"dF$Rj€¥„Ȥp#ñób/<Û3BÄR`Âà€˜Ë&CÂÓ+μ4bÆêB¡Y q®3—ŒÐ2ci@‚MÑiç´ÍDgšÊšÔ~HS!)7—€E8õARJ‡ÑÄh€r¬˜:Ħè”#>ÇšBµW&}è0tàŽ‹>Å ,—–ȳûO€%莴€c(eS$(@­ŽF˜O²hì %tD41ëƒuÄ¡I‹ÀÎ0¦Fć%,@D&4fÊTæš!ÞC€¨†$âlîÌ´`Ž5%R¡bTs}æu ªP8ûAõ‰qL˜nžJ(‡$’y³Ù@˜C§;n˜’j³t±·€ÆÀ M4@ Δ›ô<Ý …*ÃqY ­h×À\jÂhxm= ¼¢'ÎjôÇȈ u±÷àìÒ É`8 Œ¸tlyÞqpÀ¼äXH1ó ‡ÕÑpG T–…©9Áx©Ø$€(ÀT”à $ÈFÇ”ãkD-Â2Ñ· P»s5Q‚Ó×DsGB4x óâÀxpU8 P¾½ÜCa¦ši‡çÍ!06Úð@ù +‰ÃÐÃ@¢Å ø0?ì× ”T#:'·BXÁ58KOëõô&lŽQ+`ެ¨‰ FàX ‰“¡%ê˜îÁûˆƒÉÔV`‰TÉì*R¾‘¬a޾(R$Ë@6¬À!5ÖæÎ\9Nk pª` ¸È6|FMyˆZ4Ë©Y²ð ‘©Qr¼§‰W3{¨¥µ&ÀŒ @ö膰M…ãdvØÔÌ;©bÝ óÔZL±lN+á6À •h£9@—-óˆ±õ£Ûƒ+ì·‚D¶Êh€Ü4™<:æjœ¡5 £\I\}T "€Ére.hŒÐh°`3.f#þY&ÅΤDáTkHL@ Ò0 hNm’¢¸@`gj2P!=XãLCÀrgÆÀ|»d’H =ÄÊ0Щ2«¡Ø€˜– Äcº'uÒa4 @ E£ÌAÄšÐ=½3à4.Ö‡;㊠O#20J´§íÇE„&hŽC÷‰;ÑñºÐ-!n Ý#z^u½ P^jfV€DsHD„ l4(4Õ©&ÄGĉ¨5ÎŽ=- & < § A㬄EgẌ‚EÃÇQÂ<#-…3Hlc0­O¦™;j0/Bi´ÌKzHJŒ&ãh26`'ãðZ™FHÄ3W<(Õce¨Í@ÊÐg^‰†GÜ<"ÜÄ¡%ÞZQꔇE ;hdà™ˆGƒ,!רŒ1· È9OmŽï„ýHDÔcExΡ]¬4àÀ£!ŸÙ$€”jªL ,­çY0AfM$…Š ¬§Äã4Z—$ ªÄ¥a#aÀNuŠ (¢‡ÜѦi:Ôy”Œî\ú´r„/E60®‹$4â‹ÀãÔ>¼>£D‚àF`z6%æù R¤ÙGs|¨Â]0h”u(”—Z.Íø0“dƤ3R9t¸ &´"y ‰ZƒH&• ¦®£ñÐá‘5§šJE=¸‚YÂK¡`^tt?%yà°8ˆð]Y4È9î «ç†œ>ȱaNð~$ûa¬h ”l”Œ˜ —ˆ€ D†°èYEpÜn"âDA6=X®€Ø9^š™ŽÁ±v*›²8Ù † í©€t'5Wg¶8Ńú‰šGÃ\û¢‚(%öÁÕB­oÌa<Œd‘ó† /‹ª,5W†â‚€B/Žè•=úТ 9ÖŒ¬±¾@Qš>Tâ óaPœ•° iƒ1Ò, ÉËsŠ„ɺ (X84ÆCiÚcpò‚•¨$<&…¦+¡\"@±.iÑ8ˆ5‘è5W§a2 ¢ƒ[B£ñ0ÐË­Sr ,Oh‘9@•h ',A@≃pN‘£ý´'YMö œ(¡,±^Äãð]Ð(@M…—ØÅ(@Z €ëXiƒ™Báx„‡e*W‚”¡š«3žÝOs"YÈ O¾hž‚E!e8…PÆÆOY0Ñi±`6I@¼#ò53dÐ]SÊ¡R.:“žì?b•fG¸ƒRÚ¹0W¬Ì•pC¡D8¢Ô„¤æJ¸aÐTGsLЂ8ª×\c切¬˜k”hB"),ÒÐ0Døkb( ©[>¼B¨¦}ðµÝ€ ™ÇØ–óÂq+ð „h€4ÈótK ÁE8\B•DKQx‚š(#€6e "¥KæƒcpæÔi –˜6ô”$¬8žÂÃöÇA†Ê 2 ˆSÅÞOÚƒ†Jt=D%Œ‡iMª‰pœF?„S#%‚B˜_~¢‰&Ù’` ï °Ãʉ²©tØP´CöPo€ÀO` ZóÅg‚dB,HТ{†Ês÷ñª8Gbá(ðºRD*kxŽŽyq°æã+€¨5ÈÔšc2U†\‘£1,òRd£,ìDaîŽ£Åæ aëP±‚¡UŒ^Á3d‰KÑÔ¸KJ(¨BäY'ö•8TØØ¬™»rÀÁ*G@‰F  M‡*¼ÇÀÌ•¡ð•¹Àq<0zò Ña¨l4æ ©²è:ºÄ©2)82NMIžŠ8i°ðsC0*µ`šó`©2nBÑø5 °ô‚f(c¨pÊÕ„œð"åÒ´e†×8 {ꃌ[„ÅDДr†Iä¬1j6@êl¡…[†gÂ:ÁÆŠP³¼0WG#‚h<Œ â©!qú‘!Pgé£cÈ*¤äغ¦æ´˜È0ëHG H$OŒ7ôd%˜‰Ä#2¥±!´Ø‚­ h᪠—„à5&Ì|6p àÀH5—‚‘’ (Nâ€$ü‚pìÜ ‚è*FBÃÃh%¦u°L80™LŽ,«Pì¹Rä67CDæ #{´}œ\­fŒUlM`=`A™â0C‘øÐÊARK#‰p ò €ìhޏ5ð‰tjñM‡*Øœy1¤YÆ?@‘’§X0ÕÏ‘¬¸)M–€\§($‘SM…q‚YE1µ º§Œ!£#-S­ƒE,½2„£i¦Áh¢PqEBR) ò²h â‚Ê%ƒ0‰ˆ7U‚²©<œƒÇ°§‘C2¤(¥P³ñ4*\:º`é—eRAšø˜o ’jÆ/¤²µ . ÖÌ=¸X˜—M€<ÈšÕHƒX Á±:–K eÆkÂd€ â5ÅGcl —æÐL tÀ†Jô:àYCÑ.¤ã  &iT£1(z¢Œ«C¨ñžê HËÄ­´$ %€ˆœ7„ðkc—Äj #îT™ x‰“ÝDpLŒ ¥@Ïtbäl^ /0Ç@Õç!h𨺣‹RÎi“èp ¼0ư*ÀÄúð22€³… ¨iM!¨Q ¯f•°Æ‘޲HèâHYÀãÉCe¢1 6!™>5ˆE0a“L ÀiA,`¹ ¨’³>0t 7W¦%è€l€*9…*aÁˆä{í2lÌ™qöB ¼È¸Ñ,<"Ãre\!°Û/pQØPÄV˜pØ’Ì!N)Væê”dˆT.“.AìÁKÂŒ£€dÁq±¥ÁIê8ã ¦ÐTOëðN§~j‰#Œ&ôØNc’Ê$RC–û¡ã@IÉA Òã7&5WFĉã’aØHcóÄé§êà !Æ+Pœ›J>P,AÏ„¦.U¢J"14¡q3R¬§”R1J‘ ¡9np …9 D l¤Œ<ÂÈO>œ’qVƒˆâÑ… ‡+Á-'´<Ѐv3DH Òt h²^€fOëjæµRšy,)UöQ?âg)À©£DP\L!Á28Q pe `Nêq€&Dc,b¨Â¡ÔÑ¡TQkèÀR ó"(H8¨2d:b¹b°,‹d h< \Re¦¢Z ºiÖ¾Ähq'°Œ)i^U°Ø˜¯š—"Ÿ æ‡öQ àÄî\ [‡YN%Ša [NXc–U f”S%Ô3ª©ÅC×ñì8 h‡¦šÖ¦jx À&Tƒ‘BÅÎc:\B>eX$‰õ¡kOax”ªXÁ‘a`¸ªÂ R”c}†½¦©m(³ Q@4g‘øØ(a=¢‡…ŒãP î°ÔT覚)ó¦ èƒe€lŒ%Oì$¤èÁÌÎAçctÖ@TcQA<=úN)I~*ƒ¿N:œŠ#(­2lP‹Œá ’›'´ÇX€LÇi G ·†Ä Y¸ ¯1µ3™@¥_$ŸJÀØÈàóh¦ZG@&Bd ¹š8=S"b€`Ø8;¢ÉÌ,ÕÔ5Õ¤ÃaeVG'Äš,th`Û ApaájÀŽUN…=zÇ5Ê7¸¥=¥¸“8ÔCC‰hÙ¦8DnS"Ú<ž(d}¸”©½Ôh?€`ƒ!š øa/„Ðä{'ˆI‚ñ)0°£}Æ$¬gö…÷XÐ!VPÀ“DžW?Eaþf°ÃĘ&vK,LìN…zP"·!2Rñ<Ç‹8 †`J±Ê%Må™Õ ò¼L`q޲Lµ ²B@gHÊ3d%ë>p¢l„/C Y^ ¥JB9pL$ÌeAƤÇ$± ™ƒÅ",zÉ‘M u$‰ Ïù ‚ßxL6ð‚ŽÆ òBj®Br2D­”ÔAúÑŠÒXJ˜Ì‹*F#/XpDhi¥™¯6©à¹t„È nZ4C<@J€C‡”¹š¸>Å*TJ×±>؃ÜdGsØV@¶™Öì 4Ecƒ 6û=Z A–Ø óÄ5åæMAT`”ZÀSå Ž 6ŸPó ý :w‹C!’Ù×lG^@Êñ~z$4¼^ÁЧtj2e"O¡kÂÇÍAʃnDÚM(…SX^ ÆÂ, -Æ ‰ Zk ø«cû;†e $‡GëT.èæ¡¨  Sh ¾Š‡†Áq‚FO€ÂHâ,/€hÃ3o ¶ ò:àK#@ki ¤b $3ÈÀÚ™óÒL5õ¤Dü<5Çþ k„'KNÆé ™ 3öàd†ÕT³”±Šœr–¸ÃÁJšÌŽqÚa (%«Ñ¡€-–ÆKò0™ÿAPETAGEXÐÑ bpm6disc4part4discc5trackc3year2001date2001titlefulldisctotal5track02/03totaldiscs5tracktotal3discnumber4compilation1totaltracks3 genrethe genre albumthe album labelthe label lyricsthe lyrics artistthe artist publisherthe label commentthe comments composerthe composer groupingthe grouping$musicbrainz_albumid9e873859-8aa4-4790-b985-5a953e8ef628$musicbrainz_trackid8b882575-08a5-4452-a7a7-cbb8a1531f9e$musicbrainz_artistid7cf0ea9d-86b9-4dad-ba9e-2355a64899eaAPETAGEXÐÑ€beets-1.3.1/test/rsrc/min.flac0000644000076500000240000005260212013011113017110 0ustar asampsonstaff00000000000000fLaC"÷ Ä@ð¬D!îÄvoWeƒ×l°÷5 reference libFLAC 1.2.1 20070917 TITLE=minóÿøÉ•@µÇ!Ì¡'†hPæhRs%… $¡aäÌ̔Ô9C9)™<¡C™”ÌÌÓ%8Y”Ÿ)…% aœ(r’‡49CL9C%RY’Ì<ž… dÓ ”)™™œÎd¦M 礧2…&yùgÐÉågÊaá94% L³9œ(Xp¤¤¡ÃB…3™e% B‡ ˜D™¡= ¡@³ ) !ý Ì’˜s(NN’…2P¤¡åe&faʤÐå3$C'3™ g$³ 3”%8RS%™"Bœ,’” IC„By— °ˆaNˆ”„Lä‘ ”Ê”¡”’yC“2i…9ÿÿ¤¡a))™B‡ f,0©@¦„¡IÉþ†Re ¡(XS …†Ng:OCž†s¡C™C<3Ì4ÉL(hÌ9å”)“"))(Rp¦Jg3™™˜S39òÊI’‡?)“)9C3'(e äžPÊI,)(S L”)'&p¦r†œ¡Ìáae ”2P)2HD%B†”†Je2yB„ˆP‘‡‡(~y@¤¡4)“L<™2“¦K(y(”2“‡3ýPç)3™™”)<ÊaC˜R¡g)“)3æS0¥ 䟔ÉÒ2d@°å$晡C…†s,ÎC“™ XzBœ)™’a@Ò¦ÌÊÊI伡I’‡%0æRe'ÊPÉB’PÌÃI”'&RP”2yNe2 ‘ 9ILÂ$2™)"% L¡Iš”þXRe&P¦pˆ(L¤þ†r…Pæ…$ò–,2’Pˆ”2’g JÉ43š¡CL)™Ìó”3' Ðå$C„ÉMRS%330%3 p¡I”2“†IBr’aÿùL’™2“ 3”É)'œ¡Ì§= ’˜x|)C9'èe$Δ dæsÒ$"I…9ô” 0§2̦g4) )(J¦g p¦fs)†œ¤¡…™¤ˆLÌ“ÂPçÐ)’„@ÐäBr D…„¡á¡’¤¤ô<'BP,(g(aaNÌŸ)ÊC930¤“ÃÌÌÌ)(RrPå0¦L¤ô9C””9œ¡Â!™HD fJ ç ÉB r“L‘$èg= 4Í ”9ÿ<¡É™™Ê<Ì™L%”’JL9ü¤ó9™C™L硜¡IB!(D9™ä§ L¤ÒS%2RRPСáI…†i4ÌÌÊ” fr’r„§9HD93ç@Í Ê3ÐÎPÌÉ™™™ÌÌÌ‘r™% 8d¡ÏBPÉÊLó9)C9”3’S32†JdˆL¡Ê¤¤¡Lá¡Ì")Ì,™Iè””90áÉL¦O”(g e&Rt3” D99I¡IL4)(y‡(g?@§ L™I”Ÿ)œ)ÎIB’‡))(r†PáC””9Ë sL2’O)%(O<æJJJJùÌÌ,™I$C!L9B„ʦMçB…‡(e$”¡2Â…8y”“çåŸÿ‘L¤™™%0ç”)'IáNL)3å I™’r‡™ œÊ)“L””ádÌ”š9'IL”””9II)3?ýœ(g…2R†fLÿô2“!(D ™C32Jaœ™L,ÊJ˜rfH„ȇ“<)™…&hfPáC…8r!9ÊJrPˆJ”Éèffd¡Iå9¡B§ÐfxÿøÉ’@µÇh!)œ“4)2™™œ¡I™™Âœœ¡ÌæfH¡™L3”3ŸC”ÌÊå&s9Cœ¡IB“ÊÊàS‡2D2RRP¤Êý ðÊL¤Òz:†e Ì™çÿ¡Â$Í B…Ð"LÄ2S„I„Iœå&|ÊLå&P¡Ìœç(fdša¦M™ÊL¤Ê”(JLÌ)(S'2PÓ g!d–JfP¦ffLå$§ fL¤ÏBP¤¤¦fK3Ì¡BP¦aIÌ”)2†hg4(hD L¡œô' S% r\3„@°Ïå&PäÊ"žILå p§8D9™ÊLÊœÎffaIB’…% NPžaL”)(y(S“)0²aIÂ’…' J ™ò…&P9@ær„§ fL¦¦M… LáÈN(RfP¤Ï(RP¤ÿ‘& Έd"rfs9B“™“òœô(Jr™(P Ì” J¡œ D”)™”(s'3œˆ¤§‡3ЙL)3ÌʘYœÎPæRe2JJ’På æ™4Ô)8P¤ÎfJåž’™""ÎS’’IIB˜p§33%8\9žff4e$ÊIC„I?(RaLÃɦÉÌÌ¡IžS…9È“4Ì)Éš Nr!(RyB’xJDPÊ99”9ùe‡=0ÊLå…%™3™ÌÊ™¡œ¡Â!(S&RæaJIáœ(S'0§' 3ÉL”)̧ dó‘ žPæg0‰2PÐÊL°³B¡’PÉ”šg>i'Bee I…8RP¤ÈNd‘ œáNLæIL(Rg æS% ”ˆ&JLˆIC¡BPˆJhPäÌÊB”“œ¤ÏŸÐå%%% ¤ÊJaÊB„ó–r$™He$ÉB!†™?"Éʘ~s”3™LçÐ))ú(I¡ Rg(g0‰3Í’ž†s¡ÈBÌ4“œáœü¦ffL¤œ¡ÉLšOIô9CÃ2|ˆ%PÎ ”Ã)%2PˆJ ”(p¡C9333š…0ÎhsC œå Ê,ü.)=!å ¦NRg” &rPÎIfg0Ó'aš…“3æRfe B!3™)˜Pá@²J8PÉ |ôÃ(PáN†PÎJB…&PáÎae%32’~dBag2’|¦s@‰0ù™’˜S33' ” ”&D'0²M ”(P§9Cœˆ¡Ìç3œ)")’!2!ÉÐ(RPô9èd¡Â!†g(Re'ü¦JB!HS)“)’L”)8XDe0ó ™”)2“þS'”Ÿ)2“”ÉùB“2“úfs9™C93% ™ÉÐÂÃ)2†rP¡L”?)2… æ'ÊL¡Ìå'¡ÊsÊdò˜s†JIL”Ì)™2!†“3) RLòaILšaIɲR‡&sœæH… LÌÌ”ÉLÌÌ¡œÌÐ)(D39%'¤"‘ œŸ”2S%% 礡I”ÉL”3’™™?(S0РRgŸ”Ì”2P¡)ü¡É”„@Œ€D…„ÊM0”Ã33&“¤¡Ê™'ÊJ”™ç)™’†Éœ(2e„Ê¡ÏÊNP¤Ê)’g%„ÊJœ¡2!“™’†IÉÈ„ÊY&’t&S$³,¤é†…'% IC% ¦™”8“ÿøÉœ@ÿÿµÆP!9BR‡™“ÎPðå”$ò…'3% ”šdˆp§&†NfaI”ÃÌág2’eB„¡’†O”)™ÌСÊ”¡œ"9’…&i)Ï"(fg?C'ÌÌ™9œü°ˆ@¤ÌÌ¡œ™C8D2p¡Îfs9œÏ$Iœ°ù”…2†s rP<“9))"˜xL¤Ì¡Ì.I¡‰˜RR†r„ô œôÌÊ’” JH|¤ùgÓ0¤¦e áLÊI4Ÿ”Îç–ÉL,"8r™)’™œ)3I¥ ¦Â!™…“3„@°å$ÊL"ÉB’ŸB„ÒI¡BR‡‰™“Bae2S’…2O …(M ”š™aNfe…% r“)%$¦)(ú¤)(d”™ó"y2†…'¡’†L¡“˜\žRe'”(p¡fS „¡¤ÌèfP)™(hd¤äC‡¡ÊfRdBe ”ÉòÂ’‡)4(g0ˆd§)<Â!„I<3’…&yB“œÐ¤Ê“ÉèfPç%2RÌòÊáBÉ™”ŸÐÂÃý&…&t0òtÉNL¡Ì¦r‡˜RS aä:Ï¡’… Ìœ3C9C9$C…3$C”„ˆ™Ìó dÍ'L<”)2™œå$¦IBæfrD)ffg% !JCL9”3% LÄP¦NM0òP¡IœÉfL¡Ìç9C™™C9œÉÃäÌÌ”ÌÌ”) "™™’”š™¦˜g(sò’P¤,Ê™43ŸC'™Îg3™I”™L„LŸ)™:ž, ™I¥œ3C')’s$@¤¡I)(S%2XD$Cœ"B”9œ¡œœÏ9C 9ž~Y¡C˜S… Ð3§$¹œ)“™™œÎPÎgå2J“)(r„¡C“2e%œ2™†…%™")œ¡ÌÌÉB’g fg2’rS2áaœårD)3‘„B„ˆPÊ™C”„Cœ2““4ÉLÌÌæyÊÉOÐááC38R†rd¡’…'8D9ÌæS!°"dèr’…&2e%PÊB‡“™2 P¤ÂžS…9ÊNPСə)˜Rf’…&Rr!C”(Rri…2s2RP¡ÌÓRtšaä¡I(p RfP"9Ì‘ ”)(RO% 2D9™ÌÌÌÌ¡L™@ç)œË3”3“™á,(hRC (žJP”¡=ÉäÓ åS3œå æe2hPç (d¡’“"r!3ÎRg(L¤ô̦B”“ÿ)(ICý’’‡…&|¡C“™ÊÂä/”™C“(s …|ÐÉü°¤äB| (Iò…&r†)™9™™)?”ÌÎ}“‡3”<<’…!BÉ”3Ây™”9”ÎdI'”™d¡¤¥ Ê9BrPˆ„BO’ F,ý ”ÃÌ9’Ê“2rS3%%%% IB¥fP,:R†B%…JI”3%‡3(g0³3™")’™:JfP"\¡IŸ2’fs9™‡(̙ʔ"IæD) ™>P¤(RrR¥!BÂ’…$”(JPç¡IBÌÌÌ)9™™™™)(r“Òçô'ü¦aðô%Ô̔32P)3ÏŸ(y…2p¡Ãœ)I<9”9™™™)™Âœ(S3™™™)’˜S&D&D3„I”Ÿ)9I”ÈD˜ÄÇÿøÉ‰@µÆè!339œÊað§ dóÎfaÊ!s LÐÉB‡ N“L<”)8g?Ò…ÎP¦L¦JP”¡4(JBa&P¦Lä@Í °¦fM çÿ¡Í L¤¡”38Pæ…&RS‡˜g=De É”„I”3…0¦NJJHD)9‡’Y@¤¡™å2P¡áäœÿ)(L¤”“å9ÒzM0å Ì,Ϥ(X&xD™”4 J)9’™’„@ááe'I”” JÉå2|¤¡(p¦NP¤Â!Â’’…P¤ÊM Jfd¤äC'30§!a”Ù”8D8D)(Dœ¡)™†R NÏ)>èr„¡’L™@ÎL¡Éæs0¦aIB‡“ˆL¤¡™™‡’áB˜hD $ð¡I”Ïœ¡™)™’ P¤ÂŸË 9ÎRg33338S3'333$C'%'IÒP¤ðä@òdC338D4(Y3”% “2D(JJBP²…&fffp³ p¦e J“2Pä@å%(OPÌÌ“ÉJ™)’™Ê̤”™IÊd‘…3 fg3™ÌÊ¡œÌÉL””ÉLɦ“å2t °”ÈDÉ32S&’†„C™ò™9L(D% ™I9„C%'(rPÎe&S'¡BRˆs:œ)’Xe&r’e!¦JaL™IL–g9C„C'2S dä¦yùL<…2hfp‰3IÒt(d¥ èÉÌ”'C &RM% s)2™(”<¡Î¤Ÿ 4ÉLÌ¡š<ÉÐÊLˆL¤¤¦g dÊ™“IB“†p¦N‡"8e Ì™B‡3ÿèp‰(æJB‰“™™’!Â!Iœ¡ÌùLÌå!9ærS%2S9’’†xs¤ô32|¦NPæRz9Êdé;&ɦ¤¡LÌÏ’’ÌÊdèr†fL¤ÊfS3™ùNÌÌÌÌç¡ÎD’RhP”ò™:Ô93™Â™9™ÊNS$ГBe$¡H_üó9CÌæ’…&)“òœé(Re0§$ D”)ÌÊÉ”332S<ò…d¡É™4ŸèäŸ)“þ’…&RzJ¡C… ‘R…PÌÉ”™Ê(SPˆÉI„”))˜RP¤¥ ä”ÉLæRIILÂ…!O¡= ý&”'˜S$òS”™Ê™(S0¤Â‡ (s,¡Ê2fr’†fs¡Iš™Â!Îd¦M0òP¡ÌФ"!JIpÊNfs<¡aš,"Ê¡“æRe‡(Nd¦fJd§ 3™C%% B‡ =%RP¦g„¡äÌÉ”‘ æOš?ô(JÄ@°ÊM0ËHD  R š™ÏI‘ Ð)HR’˜Rf“C„I…)$¡ÏI¡N¤™I”” Na¡4(9Bˆž¡43™žyC@¤Í äˆd§Ð¡Ìÿ)“ y'3”(s39™…2s L¡”™IB…!BÎYô2r‡3”3“3%9Ÿ””2zÎI’ˆd¦ˆgÿB’S!IùL9œÌÊ̤”,>`A“)%8r!)ô2„Ê|ùBÀˆrdˆd¥$å B– J™’™™æyÊ y‡(dÐÉBLæK32P¤ç)”%èf‡)’™))(RLçÐÉBe&†y™œÌ˜e&)2’’†‡))(sÒzaòî’ÿøÉŽ@ÿÿµÆÈ!å” 0¥ B’IBÊÐ" XAP¡2˜y…™L””33 Re BœÉL)’”)8PáLÌÌÎdˆað§2P¡’”% Có”3™Îd¤¤¡Í'I¤–P<ÃB†…32‡(PÎå&S3<9 0ærS%0¡IB™…… Ÿ”Ì” NdÐ)ˆd"LΔ)3Cœ¤ f†r„¡IIBÉœáá‘ 43…2JL¦OC”š2S”ÌÊdæfg8Y’!™…2Pˆg„ÐÉB“‡‡%gþ~ÿ”ÈDÌ)Ì"pÞ Y…È\’Ê… Ð)(“ÃÉ40Јp¤¡IBœ…™™Ã”2džáe ù¡&„…òi(Rp¤¡aNH†fNaæg”9…(pˆ…™Ìæyù¡BxPæg2…!9Ìó$C%(‘’‡&p¡I™Êœ4¡”ÉÉ32P°ˆJs3%2i…2hs>RPÏ0¤ÊJžg(y2Â!<ôš2†p¥$B‡2fJfP¤ÌÌ”,)"&fsCB“Ì‘0øP²N„¡Iša)†Re3ÿ¡…˜Sž… Ðô”šd¡Ê’‡˜y(RIr8S39ÎRr…ä̇É&PÉáË JfPç†áLš¤"ÃB’œÏ™”&D˜D8e&’†…% H)I”Ìæ‡ p¦g ‡PæfPÉÔ9IL”ÉIB“)’…&zB„¦OÍ!))(Y2„È„Êfr‡(L‰2’’ Rg ¤È„ó2“’M ”<2P¡Ì–P,2‡ fPÎsÐÎPÎe™JË…8RP¡Ì¤ˆP§(faÌ,™C˜S… 3™™(s)…(JfRfK0§„„³%)‡Â™2†s<¡ÉÌæJ9”ÌÉL“†pˆR¤ü¦N‡"(r‡‡)˜Y0ˆdÒIfrS3<å&y™…&S32†gNICŸÈ¡BPÊaòg2†rPÏP)’”<”)”™LÜÍ…&fPç rap’Èr‡)(hP”å2PÉåLÌÌ"L‘ @‚œ¦L¤Ë J™(™(g&e ˜s%% ÊaðÍ ÏC”ž‡(S33'% J,8RP))ž†sBf¦ÊL°¦OžP¤”)39CC”„B“9C„C™LžS8P‰ ç(g&PæRJa̤Í™”“™˜S2P°¤³330ˆdæO…8hR9C)&p°") PÎÌÎRe32S2“"…&g 3Êp§'(g0ˆLÊ… C$¡C…9C”2Pç)9œÌÌÌÌÊÊaÏ)™ÊHPáNdI„IC)2’’!ÉB’gLÉœ3’œôÂ’…3' (J3CB‡&ffe$ðÊB……”<3… È)èdˆ%% ”$ˆdˆR) d¦HHYœÊaÎP¦aC33…8P°Ë f!Jdˆœ“Ã9ùC8D9ÎdˆL"d”Τ",,"ÎP¤ D™IC’!BSœ¤¡=™“% ”ÌÎr“œ¤"Lç3ÌüÐÉCš™C””ÌÌÌÌÂÂ’!(S…9™Ìå ”’IžP¤¥ ðΤ%0”) RSúÐááC”2“(S$¡H|3IB™’™"Í æD’|¤že'ý0øhD!0‰ s™™)(Re39”ÃL§aBÂÊ’á¡NÌÌæp¤ÊN™6‡CÿøÉ‡@µÆØ! (e&J2„"d¤¤¡L)Ð,Ê)ˆXRR~RNdˆNaÏþ‡9Éœ¤že$¡IÌ”) 2RD JæRO3” dÌè™Cœ²‡™)“IÐÉóç”) PÌÎL¤’˜Rs 0³ JI„C%9O&™)48YCÉB‡'’„HP²fH‡9Ió PÒO@³4(rO”œ¤Â&O P“LùI”(RPô&˜0¦g$¥J…)%2hd Rd¡I”$¤’!2 s‡˜y‡˜xffN†J†hNRP4åŸÓ…933&˜Re!äËɤô9C”9é†hd¡…’~S'9C8D%8DÙ32RRD NL¡”™¦J B$)ÊaN‡4ÉrD) ÉB†… Jœ(s4ÉfáNs̳)HP¡CB‡(sB‡'(På d§(Sd”áfP¤)HS3%3%%P¦frJd¤¡IB’…“9C)3ÿI)BJd¥0¥ ”Ìå ÌùLžPæÉÌÌÊaNLÉþ…&S39""ÎffPùB™™2 s4'BJaIš"(dÒzJœ”¤(fPæP°¡aB“=” ̡̜Ê% NRO2‡Â™™?@§ 9”9é(PСI”<šd¦r…“)(RS3”)3”32rS&’™,ÉCIÓ ¤™Ê¡I”æy”™IÿùC™œå¦K‡ÉIL”ÌÎPСä,¤ y™:)“)˜S&e$ùȆJ|¡aÿúaL“2z8s)&PáÌ¡œ)™’!’™)™™Be'"I432 FáL™Iç"Ì¡œ¡Êd§ $ˆR"LŸ)…ÈS3%™”8\¡IL¦™)…% 8Y(pˆRNIL%RbÏœÎd°¡¡ÿ”” Nd¡C“8e2P!BÉœ)9CÉù)<2ÎS'ô(g&sÒtš¡áœèRp§'ÊÌç(ry…’S3Ÿ?(Y3”ÉJ“˜y’!žC% ‡<¦f™Ïþ’†„Bg%2Y’™†…%39’!“™)(dùLÌŸ”ÌÎPæ‡4)0ˆp Y áB!3'3œá¤þN™™˜S< ”ÉК¡äÐå%2„æJfp§ fd¦J@°¡NtÌÊL¤”ÈD™Â™œü¤Ì””)(S“(pˆsú¡”%3"òs(d¡LÉðÒ„ÐÉL”ÌÂ!HSÿþY”ÌÉBP,™ÏÐÍ0°ˆÉB™@³'33 dÈÊd¥!BÂ’!(D%&IˆdÊd§0°¡ÊJf™¡ÏB|ˆL¡3%… °¤Ë%9ÎR ÊÌ)™žS'Êp¥$çü¦fLˆLÐ,(pˆrfO 3œ¤Êa̤”É(Rs)8S“9†… OCž“"a”…48S2J"™4Ìå')“”2s3ÌÌ8S” (ÊfffLÊI”˜D8s%&…&S$Bd@¤¡IL”É)(S9š¥$å&RLÎPÎd‰0²ff|Êa™œ"™3(™Éé4<„He˜S2e%2S&’… LΓ¤¡LÂÂ……39IBP, s'ò™%!æa¡’aLÌÂÂ!ÉÌ””Ìχ fg p¡IÌ)Éže&R~hD(L¡œ))“LÌ,Â!¡šy2k»ÿøÉ€@ÿþµÆh aœœÌÉB¡HP¡Ê¡™™œôž…9IÏ æg332R†re ”¡É"Hy™ÊOèg?¡œò“(PÐ"LÎfaL™L2Àˆh&rÂ’„ÿþe!¤-”'"9L”ÃÌÌ)‡3¡™Â’„ÊfO”“ÉNäùó9œ§)™™…8S3%2tÃ)“Ì生)‡˜e&39C3%É9”3’Y‡)(hRP¦NaB¡I(s)œ¡C”(|4ÉI¡)ÿèaf} 礗'IICšaò†hg™ð³3P”(y9™”’…&J˜Y„L2“9fSfe ”(J™ÏIèdèN†r™œ”ÉIB!'%™2†P¡ÉÂ…$¦M’áBPçL”Ê…0¤¡O'BP"B„C% )‰2…P¡¡C”0"J”3ŸB‡3C9”¡<¡Cœ(y™Ê9<šaÏùæs3(g2‡9)Ü)™œé= @¤æe$æD2P¦IC˜hD% JJ2“ ™™(P”"% ”’™)™™Â!™™(RO&’… dòg9C% N9'Ês‘&i’™)’™)(XS0°¡L”"̦Oÿþ†eaL’‡%&He$¦ffJ%&r“ç(s9CÌÎPÎS$@ˆJ,’˜PàA B™’†Oʦdä@¤¡B“"BœÊL*"B™„C<…(S!|¤ÿèPÎLÌ¡œ”2S)“úç)ÔÂy„BP „¡B“ÉL‘„ˆPòS™™"3¡HPСäåš¡‚™áLžI2„¡aI@°ô2’„Bg ÌÌ(Ráae á™HYBS0Ò„)“IC@) dô™fRÌ”2R†fdð²ä’!„²hP²äÌÎe2†Jr“Ê™)% B dáfe%™ÃЙI„C…(sÊœ"œ’äˆP”Â…‡3”9™œ”)2™)™’!Â$"&LÉùL“”’P)(PæffJf¤(D†R¤ž<ÃB†˜y…s„šd³8R„Ê“B‡2…¨‘Ì”¤žP¤ÊO9À°¡Î†sC”„C… Ê44,,"2“¡I˜„ÊJfså3 J2S39ÂÉ”9œÎg(Rs”,Ï(rs„C’^SŸ”̡ aN!@°ç(O…)33™”8Pð ç)0¤ä¡I”<”Ìßèr’’…&hRrS&†fg(hr„¡™C)³$C2“ ̦”&†xe!Ì)’œ"LÿȆJPÎC”””(s4ž’‡"ˆa¦JfM0”2P)ÂIO”ÃOC”)9…3$¤™“32S&!“œ„I„C38Y”"(r’…“9ÎdˆL¤é‡’™"ÊC:B‡&dœÉá<“Ñ…”2PÉC'Ê™1 ”ÌÌ)(Rs332S’!“ÃÌ)2……8S%3(s?)™Í Na¤é4(ry)’Ì<ÉLÎsœ,™™™…&Ðæ…&S0¤¡CI…“9™Â“˜y(RRˆX B!(D%(rÊ% L¡)Î&%Ì“ä‰À‰(R|¤¡0‰2e!Ð"BœÌÌÌšN†só8P§ p°ˆJH„¡aNÉ”æPáœÎPó'þ†p¦OÊJ(PáB“,Îe$üüå$¡IžP¡I…3™”8D32rS$C0êàÿøÉ­@µÆà!™”’S NaLÉ”””2P¤ú愞ffJffR¡“™)’” °“C9¦M% L¤¡Êô8D4(y:”3˜D9)(… dä¡IÌ”ç,"†PÃáèffd”™ÌæÌ̤Â!Â!ÉB“?¡œÊL¤œ¤(D¤òyI@¤"%2ä,¡Ïò“4r††S <§2”‡C9IB˜s>‡‡&PÍIÓ L™ÌÌÉC@ˆJaN¡œ<¦OÊI"%†NJJfd¦aœÊdô J„I2„Ð)ÙÌÌÌÌ¡IÌŸ?”Ì"ÉB„ÎI RPÐÏ 2D2S$C%2D&D'2†rs9…9?”ÌŸ)…8PÌÉžg9þ‡(frP‰ áLÌšL‚H¥&S2„¡”(i’™œÊ™C™å L̤’&INJRBÊáÏC”)2“ÐÌ¡ÉBR²’…'0§ s“?þe$.Oò™:%<°¤¡= ó@¡"gÊdùNt8D‡¤)Ê8r’¥%% Ì)œ(S&SR B$2!“™”9‡3LÎd¦B$ÂÊfp¡¡B™)œ¡¡9“9žP§ 38P,¡LÌœÃL”ÌÎPæg(s™ÌÿÐÉC9B…% Jaʇ †™†RIL)“™…8S…3’DÉò™3”% JJáB$2„óúJfp§&r‡% B”’˜S‡å9Ó%$@ЈJPÎ L”šLˆp¡ÊJ™C2e39IB™)™’™4ÃÉLÊÊ2f} ”3(s4ž“èd¡’„ÊO)™™œý æ… ˆO0òNg”)0ˆP”Ï„Lœ™™™™˜P¤Ï!32Ri(Rs% !Iœ3ž„ÐÉC””)2†Ra&RRD 2S'C 3B’‡"¡9@ˆs(p‚!N¤¡L„@°§”ɔÙ̡œœ¦g†rNIå0Í …'(På ó”%™")™œå&s„@°¤þ†Re&…!C”$ˆd¡Êœ)™™œ¦eæH’aá‘&s™”9ùLœË æffJp¡I™¡œ‘ žP²e2S9@²…332På ¡BD9L))’™‡“B‡3B“):)<§†rs"„B™É)“B‡2˜S…9™™)˜RgC”)“œ"(D%&PåÌ™gÒP¤šœ"…&†J(p¤¡2$…Ã90¦(S I¦f!JND HCœÊaædÐÉC”)“)=0¦Iæe$󔜈J”3(p RgúJÎaL™IBÂ…’R†fJœ)&dÊ9’‡3œ¡ær‡(g Ê™“L<”ÌÉL”‘ ”„BP‰'>IB‡’|¡¤¡aÿС–IÊS!L‘„ˆD2R†p¦aI”ÃɦI<”,(sȆJ}’…†Y2’JP2D)2™4)(S' ¡œ=Ì"Í3™Ì‘“™Â D…„Ê“"(d¡œé‡ÉФ2Â…&e Iá9(p‰ “„C˜Y@²e9Iˆdä¦J”3Ÿ9I@#0ÿ‘LÌÌÌÌœ¤ô 3èg(Rf“"aäæs”™C”P,94fNRP)3(O3ÊÊÊ”2R†JaäÒ„¥% Òzd¦På™ÉL–fs9)™™BdBNz"= ¡Â!'(Pžt(XpˆJ@‚¿ÿøÉ ª@µÆ g™Âœ(Y%„BP‰% LÊ“(r’…2J™””9C”9Ièg(RP¤áI@³?ä@æNg)'œ)’hd¤þ†gM!) RPˆ¤äBe'L‘É4𙡙@¤¡Bz¡L9šaLœÌ¡’‡†…2†pé%33„BP°¡I™B’”)™4ž“¦¡C™¤¡aO9C… Nd§™™™”<>dæffJ‡ô3’™™ä¡LÉNaЙ9††M!šäHICɦH†Jd¦fg$B“:ò™“æRg)2“9By)CÉ)’YРD™™IÓ†x !NC…$C9œÏ= IèJ%(r‡(e&P¦Ng9I”ÉÎs–9™œ¡C3d¡Ìú2’…%’…… dæ™IIINáfK33339B’‡2XrÂ…2rd@¡¡Ã””ÉL”š4(S2s s3339’!œÏ(Re ”9ILÌÌ)3ü§:9 =ÉÊœŸ>e$ó)3”œ²„¡C38P°§å8XD $ÊNPС̤¡C9ž… É”Ÿò™?СÌú"s&På ÎáNÉ”) ’t3…”8D2RfS%32D2S9Ê™I)’„ÊB!C)3’Pæe$¡IOB„æL¦r’!ÉC“9™˜y4Îg30¦L¤¡Î˜y… ”)œÎg3) hÌ4ÃÌ”"C"I”“)&g3”)(RP9™Îá¦HÌô3Ô42PœÊaÊ&JIÊœ,"ÌáC…C–çùʆ’fJÊaIœ¤ÊdŸ2’IÉý ffa†LæH†p‡<ÊI"9B™œ"„Be å IB’…$C'(d¥B!·(sB‡“"(Pæò“9Ìó™ÌÎPæPäÎffM0òS$B“ÏIé= ™C’‡3”’„@å 8yIš33&@Éažç)3”™B“%2t3”9ò˜e%! D9Êg‡' J32†Jd¦Jpˆs')'BdC'2e&$Î)’™˜D8YùÊIò™I<”Ì”ÈD˜S2”ÂY„BP¡äô…9,"…2e ¦¡BS…%3(JfdˆP”"(g&†L¦Id¡f'Bú%?èfP”“2†…g(g&d¤¡ÊÉäÙ…9œ¤ô dÌÊI¡†” RaCœ§=30ˆd¡LÂÂ’“L>JR¡áÊåB”³3”)œÂ$Â!œ(fRe339…2e$@¤¤ÂÂ…'% J™¡™Â!HR‡:aNe Lü¡I…3 ¡š” :4 P™@æS8SœˆPæpòP¤ÊJœÉI2RP¤æfxÐЧ'(PÍ ó”8D’†aI8P¤ÊJÿB~faÍ !BS”Ìå% ')ÀŒ’YÊp¡¦S0¤Ì¤Êfe áùLÎhS9™”&D…)…!Jdÿ)’‡‡@³))“L)ÉLÎt(rfJ%œÌΡ’…%% L¤¡I”ÉÉáNPáær“”9™˜RaáC9”Ì áI”)†Xf“‘ œ¦fsÿÐòPˆ)¡Cœ(J|ù”8RD%(y<¦dç30ÒP²adšaL%Ã<ŸC'å%Â…†C%))™)"™ÎPæK˜D˜YC˜D8P§B@­ýÿøy CÔ@µÇ(©i*”´¥¥-*ZUd«(´¥-IKDÊJYeDÂÒÊRË*YJ”¨µ¢©KQT¢ÒÉ•)ZXš,µ%,©(¹)QjJZ”©IjRL¨šRÒªT²¥)K(­%rWJV*X©bÊ‹R–T¢ä¢å)R‹JVT¬´¥©J–RÒKQj‹R•µ*KRZÉU%©-EI‹E­*¬©U’T¤˜š-,©Jª\²Ub¥‹KJZ-eKZJÔ––¤©¤‰ªER‹‹I”–´L´ZÊR¥©–Tµ,–RRÔ’Ò•-*²Ò–•YJ‰”¥%“%JK%T©eIeJR¥%–”\´²©,´²Ô¥%©.-,¸²Ô•”´«J\YZ¤ÑeJª¬¥JªURÕ*,¤²•,´¨µ%I2’Ô¥)--IjR’Ô–YQKR#)-U•*©2•Ee¤Ée¥+RZ¨µIJ¢eJK*Y*²ÒU%eJªªª¬¨š,–+,©)J‹TZ¥)e%”¬¨šYRËRT¤´«,¬µ*&I‰”¨¥ÉZ¢eeKRU*É+)JZJ¢Ê–TªÑk*R´²µ)R¢å*ZX¨šQ5"êT²Ê”¤ÉdÑKJYQ4¥R©eJT²¤¥¥-RZÒU(¸­JRL¤©EKK*’­,©ieRZ’Ô¥)R•-%R•*Yqbhµ”´•IZ’Õ)R¥–”¥©)2’–)&II’ɢʕ&QYKR”©U•,¥J«)j*Yh²¥V•¨µ%IeDÊRR¥JªUTµ)QiUJ¬’Ô”¥¤“IjJL¤µ¥¥¥•+)Q2”¨˜ª¢É•Y*¥e)iEÉe¢©JR¥)J‰•**R–’ªUU–\¤©eIJQieÊRÒR”¢Ô\YrTL©JRÔT¥”¨˜´²²Ò¥(™(µ\Z”¥KR©+K-%K%”–”™iIeIIeJÊ¢•)j•V¢–”µJ-J²U”©UK.)T²¥UU”–T\²´µEÄÅ¥R•,ªJÒ¬¥J´«J´¬´±kE,¥%-)UK-*IU)U*LZYUKJ”©R•JT¥)&J–*TT«QK)--QrÊ–R’–R¥,¸¥ÉU,©JZJ©eÅ‹R¤\¢âbÑ2¢eE©E¥*²’Ê•ZYRÒ¬¥”¬«,²T”¥)R‹’Z”R¨¥RZZZ”©e))UeK*Š.J-JK&’”¥¬Z¢¥¥*T\L©KR‹”Z¢eEe%KJ––-QKIU+KTªË*RËK,©JZ’Z’«)*T¥JZKRVJLZQ4YyU¥VYIjRÑe©)j,´¥JR–’–”\”¥)IjKR”¥*R¥)JR”¤µ(µEdÉdÉT¢Õ%ZJ¢–’¥–”µ%©--Dµ%©e*Qr•”¬¥K)JŠ–”¥”––\&T’«*-IR”¥JU*Yh¥¨€q¦beets-1.3.1/test/rsrc/min.m4a0000644000076500000240000001334612013011113016666 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom ŠmoovlmvhdÄLé_ÄLï¬D¸@trak\tkhdÄLé_ÄLï¸@mdia mdhdÄLé_ÄLï¬D¸UÄ"hdlrsounÓminfsmhd$dinfdref url —stblgstsdWmp4a¬D3esds€€€"€€€@xú€€€€€€stts.(stscÌstsz.22""'-+1/+&0''&%)(-,*).)(*%)-4&6.*,$,3/'& stcoÆé •udta meta"hdlrmdirappl°ilst©namdatamincpildatapgapdatatmpodata6©too.dataiTunes v7.6.2, QuickTime 7.4.5¼----meancom.apple.iTunesnameiTunSMPB„data 00000000 00000840 0000037C 000000000000AC44 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000¢----meancom.apple.iTunesnameiTunNORMjdata 00000000 00000000 00000000 00000000 00000228 00000000 00000008 00000000 00000187 00000000\freefree(mdatЃì\«11;Ä<Àøƒ¤4„ `Ifàe—^àïP0#Ü·›Áb˜Ä»VOØÙ(*¾J€q8òƒ„q©-@>Ťè[-3þ!‹rtg¼¼(ι¶q]b¥¸úƒÄPEÙ€€ ª°DãÙ‘>JUl3ËA$Ö)i)ƒÀúƒÄA#€+:3ï`Xa¶™˜ÉùQ;«À±˜ˆèàöƒ¢" „Î Ž× óf{q wZ“Ä 3z¿f#)NÙq'øƒ¢" „kËr½ÙÖ£ g§ý'”ê )y*¦˜Úö»,°Îµx¡úƒ¢" „r †BXVFìì7nhϦNž|z%ôe0Ue®«Œ œöƒ‚B„_`9åo¢ðJ­Dz0¯)òøáÛ1F8‘ú·åÉ>7t·#‹Uàøƒ¤4#ŒXÛõÞ™¾áàÒ`¸Ž‚Þ×xT†aGˆ~fäHv<ý¶ÁÀúƒÄ`? &ˆ×63Дl:äGàë’ 莵š„ÇÓëåN‚àúƒ¢R#Aש9Ï<³ÔiÇ%Öøê¦—µŠÍÎê®yfžÎôƒ‚T„ `®!I}uhnV$?‹+ä¡(Z«„Á¡Üta¥Vi±+±l‰8úƒÄ3#P0¼ñ•T`’3¹èžÓ#?}¥ÕõÚ»ìÔ‹€úƒ¤Q#Hl`µˆÁ½¥ËK§)Q)E‚ñõ>O²Âô¯SÔ¦úƒÆ"#P ÛdpËŸ¿Û2é­~sÛÈÓï'Pîì=Í&ü!úƒÄPG<Ž0NÝÍEø™_ƒõ'1…:õ˜‹å\øƒ¢ ˆ,l ƒq¼Ôº<¬>èðÍ&ïÞ¦J3}•]dQ€€8úƒ¢`F  8I+–¶:aá–;0¥Ç>m>;ßM¾íÛŸ× ¥/gøƒ¤p>€ÞbSci›”¤L z:~H5ƒM3©'°A+è&„f ‡Ö(pöƒ‚0!dÀ¢’¯:%¢C°9B³î+]þ3Z†¹ècJr†Â¶öƒ¤`E€a,]µ)\BiwÈ,yç@úD¸ùü'ħ¥¨Üøƒ¢ ˆÐÀ€0qóà=bžmBÅAwùH°×! šDSª 8öƒ‚5 !`Â4†§å^ñíª^:$9µÏ…gs`M%…ÊZZtª^U£Y(o€úƒ¢"ˆ¤09œ’#NÑ·#̬zÚW›;t A-1dyß”3¤^ àúƒ¢" "ìFâwì¼ú„,µ¸rŠþ¬js%”ŸÒísÙfžep=¼øƒ¤`G €@ÀãÒ5?à¼`•ØÉÍM²ÈöÆýYB^×;C€øƒ¢`BÈ ÆãiRø—‡iéŽ6ˆ0 9ü¾åÓvë…(ÜÿÿGYJ´…=˜¢oçlÇWøƒ¤3 BÐ3ÜQhLÖÆ$/ê‡b<÷~³µ8ëûÛΚS­n*jõXeÅ×7’#àôƒ‚"(„@Ø Ad¨*Â`óÙ'ሬÁ®co·>Vûça &µ% øƒ¤`F‚ ¢±äßxã}¸<ëÊüfP[2ÚqXä.Š'Iµpúƒ¢" „¤*ÓØšÖ'êŠÇÉR™z[oÝÓ¬ía‚„¾”XÇS2p'ÀøƒÆ2! éZðÞx²µ¾ùìú$©¿Vn´%j(óVÀöƒ¤`F‚€öŽF†@›c}}ðCÂ2>Û<ÆçO5£!¹QÞ/grDðúƒ¤aBàô‡2‰´S(^®Zdð{¦P¤Ÿ~‰ÙÛ˜¦‘6 Sºœöƒ¢ „,`¼-1wNÞAÀÍ”XPKh¡wKÛ#0R¬Y±X-Àøƒ¢`E "ÅImq¤>w¢È1s5{!ÁXº[¯/æÛ?ÓG¥xøƒ¢2"%€Xä  aÔž®ŽæÏû©ÆÏç¿÷ºr¯˜LaÀƒì\ªRi"?pƒì\¬ „ô@À\beets-1.3.1/test/rsrc/min.mp30000644000076500000240000003102412013011113016675 0ustar asampsonstaff00000000000000ID34COMengiTunPGAP0TENiTunes v7.6.2COMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000COM‚engiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000TT2minÿû`À F=íÉ‘A#ô‰¹ÿÿÿÿF0ÃßÿC ÿ_ùïÿÿÿÿóÏ£)çÚa”2ùŠz<‚žyõ=xù=O `ÐÊÇ  ¥àÆyé>`øÜá ÐÀXè" z)ãBœ"À$)‚ A„ ÿÿÿÿÿøÿ×?ÿûÿÿÿÿþ¾Ývb£×B¬¦ ³4Àã°²+¸µÂ¤Ír¹ÁÔR°Ä%;îR£Q[ÿÿÿïÖÜîÿ³¾Ú7UW6dTUT!ub8´Çø çèx?ñ.Ì€ˆˆˆˆÿÚÚ¢³<ŒaeŸDY/ÿÿÿ½ÿPœ¼·$D¬(躡ïPŒi§ÿû`À€Š9à… éâH#4•¸‹RÒAbÉuÏ&Ž ü:=ÄÝL´2!Ô¶#9îŽÔ#"5¿ÿÿÿú­ÿfE$ìȉ,ª1HËez²1’ª!ĨR ˜‰Ì§)¢((â¢,Êc°š)ÄÜX¢¬,qE„T9N@øˆtÿÿÒþ÷ÿ?ÿû`À ò= mÁ¸È#t¸¿ÿÿóËäܯðÉ÷IN¡æiÞÿ{ÞžYg§S—ÜëdE/'ÿÿÿÿÿÿÿçþ§Ì¸g Wëï&%ÍirÄDuñ6.ÝítÜ‘mtP&ê,Ž«¢’Duhâb¨D›ÀN±ÿÿÿÿÿý`Zÿý{ÿÿÿÿÿçÌímÞó¥/Þj¾GH„<&ñÎC¥ÿü„žl÷ÿÿÿÿÿÿÿýþÿçJÎÉP1•¥I’òÔä»ò9£3³ZHFÊ;ŽÔr/viƒÃ1  Ü‰…£2‡ ìÁÿÿÿÿÿÖõÿþyÿ?ÿÿùz\¨Û+dDΪ1ˆÆckTIw˜ÿûbÀ æ% „MɬH#t‰¸™¨…¶R²¶Í´öÿÿÿÿdÝꓳÐ÷1êr© ÊϳîÒ»³lÖ• lΆmC‘Uê÷c,ì$(tÈ4BKÿÿÿÿÿëåý²ëZSÿÿÿÿü¹QRÅ­ÝŒ‡>³Ìl®®è§1Œ¨ˆËf:V¨m&¾åDGB;?ÿÿÿÿÿù­RÌé+ƒ9‡}•+ϳ+±ÍUÐèÊbPSú;1Œ%Ü; Š…R•Ìwt”€Î9Bÿÿÿÿþ¶ƒÿëÿ•ÿÿÿÿäìúRwó5lª„s>ʸõ#ÌäºuFlëÐêɕћÿÿÿÚÿ³«ÔªFJȪyŒEus–ÿû`À" ? „­Á¬Æ#t•¹a2ft*3άVwVl̸â•êc@@Ç‚o2ÇåQÇ;> ¢£€?ÿÿõsÿÿÿÿÿü»µ®å:=¨îïv:ºº•VB/Îlêèö-ÊZ$Û™5OÿÿÿþÍ'*MD£'» È.–z¹›S«”ÌêW!„Ça溡Z!3#NǞ#!ƒ¢âcH5VA¸ò¨`†`±óõùþ¿ÿÿÿÿ®•g;RWTÙUÕ½ÔÝQeÑÑTľÎB%OÿÿÿõùÑgêÕ9F F‘T‡j9"Î(cJb#!ÇxˆÕ3³ ªEÿû`À, Ú= „­É¯È#4‰¸vcœqL8áaàÊPq¢Ñ2$48ÿÿÿÿþÖ¯uÿºÿÿÿÿÿ?£‘Ý”„5›Rl›ogGÐŒªöi7ºÕŽ·ìKwÿÿÿÿÿÚÒ"1Ý_™Ü§8¤T„Dgä;YƒœFä%PìÆ \ÈäRŠF €‚äS3: ÁÈQBªà•Ü(ÿÿÿÿþÍ—ÿ/ü¡ßŸõÿÿÿo‘œíbnYØz(‰‘ëu×J:ä·Id£ïZÐÔÙÿÿÿÿÿþŸJ'»Jƒ Ff®ï©Ne+ È 8ä*&*†q2 0Ì AŠAD…0’Q!2ª”¥A§aQEÿû`À6€ ¶A „­Á¬E£t¹0ÿÿÿý°/ó׿ü¿ÿÿÿÿò]s¹œÐô… 仪Dþ9FzÓ¦[7‘t¥+M¹2ä³ÿÿÿÿÿÿÿéÿ9æÓ*FE‘D‰»ë2³×r™9“Ò·I­|¼UèÊã±Áz°spÂ+† JÛØ8ÿÿÿÿþ©ü¼ògRfÒÏåÿÿýþ•$íc1üý§º!Yz:d÷#ê×*º%Pú?{¯ÿÿÿÿÿèɦ·5\ÎR£¾ì‚ŠTv{˜Ä$A™ RkÑi†ˆ!£±ÅÊÖ(|¨rˆ˜pðé¢î<ÂÁÀ+)h0À5"ü¿ÿÿÿÿÿdîÿûbÀBVA „­ÁÄÇ£4•¹F±:¼Û>yê­)Ýì¬åºQò²+7cfTÿÿÿëÙ[óÉW£•hr¿ÿÿÿÿiO‰&׊aÒ¶§ö’ѺkL“ÔW0÷?ÜpµpÝÿÿÿÿÿÿÿ1ÿ×ýúÚuz÷¤sò8ë›~9çˆ^;Ž’˜…ìar:¦hZ—Ro|†AwÚÖìXòo®Ü**>@Ö0ÿÿÿÿÿëËWuûJFÑYìîåˆòYÙræ\ˆîjí»*"±Hßÿÿÿÿ~î‰m¨—g*:˜…+"ofi•ÞÂ#E ¨²)JçWACê,$aqÊÇ)„€p1Œ"Pè‘ÅH‡(Ñ¡`8ÿûbÀo€5 … ɽH"Ì•¸3èòÿ¯ÿÿÿÖ×Ýzè¼ì×»Èr/È¢ Ê®”d£3Ñ—yN¤qÿÿÿ÷ä{"Zåu#ÌeR ¸ñŽÄsî®e-ESi‘pÕ( ›0“„bŽ3‡Àãs†yNEr”ÓŠˆ¨€ ŒÀÿÿÿÿÿíyïÿéßÿÿÿýz.¥1,o5ä+9·Ežuvf±Îõ¹v}ÈÈ–NÿÿÿÿDû}l³µ+3UTŠCo¼¥>%èOyS!XŒ‡‘Æi;\ê8#)Ń:‰cÅ•Êt(P‘À?ÿÿÿÒÿùÿóÿÿÿÿù´š÷fÿû`Àx 9 ­Ù¤G£t‰¹b$„vºftgdû©kU-™œèƤÆVäfÛÿÿÿÿìŸêR>Ó­ÕK5(i5; Îñ“•ÑŠwiÇ*¹ˆ¤"âŠ*.*1â¦c´x´çRH@“ˆ#•bÀÿÿÿö@¿ÿ-»<ŽÄjµ=]ÖÓTª¤®Æ96Kä<îäsn³¼ó—ÿÿÿýv"Ñõ{Ñ]LëgRg©ªF5ŽFuõZÇQ3QÓ ‡Ç°›”a‡¨Ô ˜µÌ.tP‰GQCå<ÊÿÿÿÿÖþ]ÿþ«¯ÿÿÿþÞˆy‘žr3ÚÛg§Ÿ¤m4U++!d“Ö¶_J´Í{ÿû`Àƒ nA „­ÁªG£4¹ÿÿÿÿÿÿÿÿöù7ÈqyiHsC0¤Er_?* –gÐNP‰îF¸v#UCªˆ9ŽB!* jèA‡1Y(0ÿÿÿÿý`_óòþ_ÿÿÿÿüßíÑžïzJ_¹K&´išôE3Šeu!&>ˆˆþFÿÿÿÿéÌý<ˆs±Q#•ÞcÚï39’Àœ“Ùœ[%ÜÑÝÄÇ8s¸Îd†; áXä …C0­ƒÿùZõºVýi ­b¹*ªOf3U§,ÍD=®E%^ÝËÿÿÿÿóHêí³=Uή¯­]™žØâ®ª9ÕŒfd#9ÿû`À òA „mÁžÈ#t‰¸ÑÔ¤C»Tˆpø…Å”HƒbÈ s‘ E,*Qÿÿÿÿÿý`9oËêkÿýýÿÿÿÎë"åE§KQîÖÝJî·<†aG TGȪW›F}]iÿÿÿÿêÞªú#f«¢Ì¨ÅUTH̵Eç̪·;f¤€ÜâÀÙÑÕN]Dœr¬¨qNÈaÝ(ÿÿÿþ²ûçþ}~_]ÿÿëïôìÌÇûèw.šQÞrØÌÌäb-lzèÒÒšÿÿÿÿßé­ÝíSUÐæg«DL†îBÓœxÝ ¶QE§™ˆc±îD €Ñ3‡PƒÃ¡ÿûbÀœ 2A €­Á²H#4‰¸$)Ÿ1Qd‚ £ýÿçÿÿÿÿýÖþ—tÍT!˜¦ÈwS5îêëfºÐÅ*”lý؈ŠßÿÿÿÿôºµYÊÊKžÓÊÄB²¢Ïvj±•QXÌB *”a”£¤q3”Á7 áº¡b±Îƒ!¬k **6Hÿÿÿÿ¶ü¿¿Ëå_ÿòùß%é,«¿uÜöeæBÊìöJ¹Mwº®í­]Õr«7ÿÿÿû¢Ú®d+Tµ#!•ˆDeœ‡æ)Øì…F•®q`³QËÊ·K ‡!:ÁÎÂÃur‹C˜®àÿÿÿÿú@¾_âüÿû`À©€ ž; „­ÉÁG#4•¹¯ÿÿÿÿçïÿ%2ó¥xzžõËa©—ÝßÈͶMŸšÃ™);þe­Ûÿÿÿÿÿÿÿÿù~~KrS¬[uvzŸzYq¢ñ òlQwÑß#}¨\‰jZ |‹T ¸Ä!ÌéÀÀàÿÿÿÿÿŸž Øç —Ne9Ÿÿÿç eR!áçÖVê¢õœ}!wÂOí3UUÚZWU>ÑÿÜWÿÿÿÿÿÿÿÿ?íñó÷+kLÖÃùŽ*Øëk‘s*—gJ4`ž¹EÆÇviF"‡$˜z©†9‚XõÂaË”9pèP·XT]Çÿÿÿÿÿc`qîyÿû`À²€ .A „MÁ­G£t¹‰#±’¿%¾§ÿþY¸tûîú㙬ꙉy¥˜†ýwŠõÚ©¦÷ÞÒ¥©S»éºæÿÿÿÿÿÿÿþÿ¿ÿû¿Ž¹šk¯«YO›­²<6<’±…&TÉ눢Æ!wB±E2ˆ ¸ö³J‡4\V…K1`°’ކ6Ëq1Š@ÿÿÿÿý±ƒÍ#˜Wå¿Ì«ÿÿÿý:*\Ȫuo™H(έb+JB{1¨¦î·±•jjÿÿÿþ7¥®·tT2+9#ÝÖ<–ºœÈ©*­ ayŒQ1§3•Ú&=¥B:``ë‘‚¢§Š ‰Zpð[JQÿû`ÀÀŠA … ÂÈ"´¡¸ €k?ýþ½ÿÿÿÿÿþÕdR•¨É¿WDJ2oR›±ÎÓêUd©Šf3¦Óo5ŸÿÿÿÿòKk­Ê„y™bêìêt3–S’È5‹Ž «,† 1 áòŽrJâŠ0¢„#(@Xêè$,âŠ8Š4xD*…„ÿÿÿÿÿ’Ëäû×ÿÿÿÿûνey5v}úº*±K÷U2‘ÖgR™–tK"µoEÿÿÿÿÿéîíLÊŒE:2+Ù’}Hî8†fc ‹³)†ÅÌîìBˆaÇds¸“!…„XPr˜xñÊ,Š($0<*5 †4lüÿû`À³€²A „­ÁÛH"ô•¸Ö|ÿÿÿÿÿÿôM‘Û[VÛ²NI”¬ÚÖçbk3›ß¢ªÕ‘Z~¿ÿÿÿõ¡j‡»µ¶g*•Ø”ssB«¥r”¬QGª‰ ”„)å1˜0‚ädÆw<)]Ì($aE8pj‡ÐhÀ³˜À¢¦‰€ÿÿÿÿþßüÿF—=ÆÚþ¿ÿùå®›±÷õÌk*"§f5§¢*YÕC^bºê§»LÜÕ²ÿÿÿÿÿëî‰wcª¦UÎ<Ê;0Ô=QÖ«žŽ¤:³ó¿ÿÿÿÏé¹&q…+T©µ'ÙTU•ŒgeR á‡B32)—ýž·D|ïÿÿÿÿÓ]«Mɺ+YŒ[Ö¦0Ò)ÊbŽ¥Ž5EQ„ÄEa!˜\h˜AÂ!ذ ³BÃîg À(ñá‚ÂC &P 8|DV&Qÿÿÿÿÿû`À´€ÂA „­Á×Ç£4•¹ÿéä^¤–ú,‹ÿŸÿÿžÒ÷f»‘×d[Òº½ V¹Lës ws)P§z^¬t252]koÿÿÿï§£÷]»ÍÊS2•gsƙٞDkaœÔ9Ç#˜Â"%F âƒDL*£¢ (B" ‡XÂŽAìb¼>ÿÿÿÿÿ±…åþY//ÿÿÿüæ©J$î”Y?RºÑôVT}ÞŠrQKjßZmu}vÿÿÿþ«ëzttz%¦TS•1ä#ÌÆ½j=‘Òª”ªQ!0c ÓH,¢å £ÄÄÊ,¡Ñw‡È 8ç1Ðå†0¸ÐÛ ÃjÐöÿûbÀ¶€A ­ÑÙÈ"ô•¸}s—ÿþ¿ÿÿÄ@÷:XÈ®M*¨Cc¹§r”ü­#çDQz­ªý§µ)ÿÿÿÿýþ–ÑNbç›-LQl„0׳ )Ük ;˜Â,5ܣŃ‹;"Š 8ЦA¨$ÁÑqÆ\M…EAB@Áç( *ÿÿÿþÿ-ã4Ñ—pþRÿÿÿ³¢îVlìU·K&öu+<Ï™‡tLììv3¡ŠÔ³I{×ÿÿÿÿúûû•YÑ ¥*«“êçªîR†Ð<Ž=ÇÔLU…aÅqáL (sˆ”ñ¨§D4P€È‚Ä !˜€8ÿÿÿÿÿû`À·€’A „­ÁñÈ"´•¸ÿìŒ w"šçÏ’–«ÿÿþMÿÇýwóó¯÷Üu]fFÌÕH÷SR‰õô±×K¯×ÿÿÿÿÿÿÿÿÿÏw§qóßLò°»O¥O3r²1GÔ­mÍ»±å;=šSÊ8y£2lÏ#•0ÑÄÃHˆpò ¢¥% « €ÿÿÿÿÿ²ŸÏ_åËzÿÿÿþþŠŒ÷Z2>ô±• ŒÌ]ªC\È„VGGuyÕ(Êr¢J´ÿÿÿÿû[BUÝŸaj«ö£[‹Õ™HŠèãLRˆ˜«yŒ‡œ¬C%Üq]Qbì->A!Yâ†`£1\Xâ@`Ò y?ÿû`À·6A „­ÁâÈ"ô¡¸—#ÿÿÿÿÊ­ä&ʪËÕèêÊ}V¤1Ö³Fnn¹:û[¯ÿÿÿëmjÌës¡ÒÕF!ŠbŠê®Æ)˜†‹‚*‘LŒcˆ°j¡¬*‰¢ï0p¥>$av!œXè4DHÁÁ5‡ÅĨU(p@1@ÿÿÿþ­ƒüŒÿ¥ÔËïëÿÿÿ7êˆìµR!Ÿ#£µŠè¨~ܦf5æfb1ÞŽÝS"ÛÿÿÿþßèÍiš£P®vJÖ®d1qèq”2ÚÈ0:Å+‘Tƒ6aÂbEl?2‡DD…‡‡\M"B £¢† ”Ácˆ4\6à ÿû`Àµ€.A „­ÁçÈ"´•¸´ý¹~öÿÿÿþT«Û"£—ÜÇž—ÑsÙÑhê®DÎmYd½îdSnÛÿÿÿÿéí¾­:ª3š§š•c%®5HQU<§!ÅEHcDH@8â(‡E`=‡(¹Ã§qR¸²:$&<(j¹‹ÆŠ¸  ˆhˆØ6­ƒóæ¿ÿÿÿÿ#í%)sMªØí1ærÚì·IUkwG‘rîGtTe÷Ñ—ÿÿÿÿÞÕêÛ++Jζ%ƱÞÈÄÌ<‹;‘XÇ8qˆ‚:‹ Aa6qe- @ÊAA¦`‘Dˆa"ÄÆĘ„@ð™”:?ÿÿÿÿý$ÿû`À·€A „­ÁïÈ"´•¸/þN_¬™ôëÿÿä ÷Gõ>¾J¥Ó:²—G4¸±Q(„tC\î{+©•®gOÿÿÿÿ쮕½Ôˆ³!O<®ë!¨Žd,ŠæR1>‚%f0°¬@ê>qâ¡ô (¨˜åœ4ÖQ!0ø€ºX‚eQC€ÿÿÿÿÿÒäþgì?ÿÿÿÿÿ-‘œ¦ŽI"*ÌD£™•̧Y§In–jH~­’Vkg»T®Š¬‹ÿÿÿÿ•úÑÌŽäRËÕ®¬–K¦í[ÝF"Ψpá®ås ƒ”<îƒáC‡A r$ò 0ˆ×R‹ŽÌWˆ€ÿÿÿÿÛÿÿûbÀµ^A ­ÁéÇâô•¸½ÿÿß›ÿÿü¿->ùŞó/ß&@ºÒzšºË)¢Ô-^Ÿð¼Ž½Ïmûÿÿÿÿÿÿÿÿå‘ßæe ·îV›E‚˜6Þõ$4C¹»¼­dPÀŽŠ$B¬ŽAÅ£E "£,†8SW©9 € €`5‘çÿËækúëÿÿòÿZšçTC£ú^5Ýõ‘œ¨bT©‘Üz-®–™=¤½ŸÿÿÿýiCž”èËB)©au”‚o,ÑÈ8‡c‡ ""@”AÐD¥*<:*‡ °x:@ùDEEH8:.Œ…Ї†ÿÿÿÿ¶ü¿ùëïÿÿÿÿ©ÿÿ–ŽÍ­îGH¢’°»NÞ±™ÄW%²zÕóØøDo“eÞÿÿÿÿÿÿÿþ_ü?)¹leyX†¿ç)ΜzR̈Ÿ"]Ë•L´'dxÈÐtS 3j±Û é àÿÿÿÿÿÀ~˜dsÿû`À½€>A … ÂH"´•¸æ½ ÿ#ÿÿÿ˲µÐÕc0‰&V‰ZâBÅrÌdpëFEyFˆm ¥*³¬Î¾ë×ÿÿÿÿÿ¿¿mYÊŠVÊÆ}LêÆ+>j=‚¨,å-*UcÅB²= QSÎPèÑÎ"ÊY˜H  ÿÿÿÿÿÿÿÿÿÿÿÿ¯ÿÿÿÿÿ¯ÿý~Æ(`JEª*!ÙÙÊbª”Vs ` #³±Š©ÿ³”ÁB(ÿû`À³€ n? „mÁÑF¢ô•¹ÿûbÀ»€&@ÞMÀ[Àÿû`Àÿ€ÞÀbeets-1.3.1/test/rsrc/oldape.ape0000644000076500000240000003320312013011113017425 0ustar asampsonstaff00000000000000MAC P4Ü3Ù0' 0éoÅ|ÍþqôèîˆD¬D¬P('¸è+6¹D€»…›gz¿ãÿnè±`Èé¯CÔC-]áXUÚ½ÛΈý–̹€ËRôa‹% –ýÒñ0ó©'lOéØ[&ï(ïïHõ)œ"i%6aMJO1FÅ@…D8Ùqlpù¤ŒE ]“¹$ê åýwëvy0äÏ\þv7®Œ±I˜®Æ ̇m7¥.uMÒÉè$Åý #­×x|‰SŸúgb;<·|:}|{H?k¬ò"ÁÓ{äìæwß áÝéÝ¡"v˜útŽ`OM".ÿÄ+.Ë÷"q=f“; åF&8e*’<û´íH4ŠÈiçÞ-w»´7rý:¤Û˜œJ \ó!¶Ü$W'8[4“ì«„ËAvìMXFÊUÄÍHU®qta!;{;ªë¹ ÊÁ­àG^ôad¶Gê™gŒ¾P¥­)«Œ·È úîØ™ÜzИšSŽøg_êˆQ¤Ü=ÏS ªù¶¸âèM¿‡næ”– ­u%ã÷ú¢ÓÁ8½ Ì úx«=j]m6“aú@“I›L]ö~çên0.*§ãÏæcžânQjŠ0‚ôêí‰6j®ñ1ÚC%g§4ß\-GhÙ3Ý•[èàH"’—ó@ù>¯3°+©ÙÎÿäÜG½ù§W3r˜ÚKn>œÒÚ+{ç²òô·Š¬´ &…ñ6u¿_zêmeS)K+2òkò± éØ8ôÝ‘c¼3L®ÝÚ펪v²WܸÚßèÑ‘¶Yຓ@§1"kâr}õqIÆþu ¸ ¡©[#ØÈôð¦‘w“f•—C?s·”b Œ[XAÅÊ Ã=‰‹\ ú„yäüvJe¹•ÙׂfÁ$0²¤y£ž}:ŽÎ Ø­ô[èr‰zØ>Q†Áx „“(® ~ 9Å5QþK¸ÓÓÈÓ&)±¶‘9jÜa¥«`¾ê‹A8¤Ì×– ”©¿ƒÕ"“à†;bR3¸ “D©W væÇ)ãϳS›Î£ŽÚÊ̲A}ÏÐꂇTÖ‹¾DcXHwüWE>Èà‘'•:ЕȜÄqqoie4>#÷ -&}ì#}Fí5pÅã±ã³*"•(taà¡ÂàÅ“"Ñ)®BÜ -·,AEexj¥ŸòÚ†›ûu®RH×n_¶n¶(ö¶Îâg'v9HÃ^%Ü!?¾"XÎ@µÛÚ6×›R÷…­‰¯ÌÎyÔÆá=ÀÏ‚ö¯–8/Ö3VõMæÂ»8¿±ÿu!ð™rP@ÄÔ¯ ›“øqSzá~åjwñ¤xTèv¨TÚ.'ÿî†>ÐäÒš?Î`XºùÊT÷:™—ýÌ Aã?”1²\@ðd±Fâ|ɶÆD½‹°6õ Xõœ“4„,ˆðÁMÝR¿#÷°Œ¥&ìeõ¾­E„›ŸväÊýS²š+vWÈãsJÿËžýxñ¢Ìœã{ö[·>¦pQ… tÿ³òŽ)®Ì ¤Ç`ãmnX±^xõÚ-Ç®“©=®æ´|t¢Àr…βþ‰¹!ɰvÈ ^T¿ ßN‘k1½9¥#œÞiÔ[@ÝöSÉ‘’è&ÀÏvš¶¦_Ö.¯oþUœæ÷ÙÕt÷xLÚ½êø‰H/ ‚Ïm…Òfè¿ÃÉ‹.y Æ8Ô÷KüQE¶p:•7'§Nn¾„ì 8lh˯îo=›}¯¾•9P}Ví{sÔèÿŽïnsÀXiVÓ”íEI€íH=ÒƒõsºK—Î{ÍÁ»ØÒcyO€g›¡P˜û‘–õ‰¿ÉÅþµ\3yy8#;çtº2‡ÿê[mš ©í‡ÿ‰y0˜ÚäQv{DŸ×®ú3@ä ã­h½lXÂqKž!®!õÐÖ¹ú§çÚ‡ŸîÀø–%5Pú]×|¹õ´††#i2‰öíÍý Eäùì›êLÿî­ 7X¥¢)E»[Ýá[Z$ѾƺÅ–½¿îKšW¤¥ÒÜíešY#ñÙ,‰íÞ„ì.eÀÒ<Š[èÍ؆,I#ŒôÒ·5´BÚ ¢6Ìå)¥ÙùMP™12]ž Bäã'‘i"’-,¹XQ3Á™€Ø_@øå©ÆY5ÎŒDdm P Uëe*zï =ý…,òc‡-¿gCó½6a‘¢1ucô°,a$õÖ×zܤ æV‚òÀRå “”$}ôÇÅ~ï¸9EZç1°H.1.–j¸¢î¿Ÿ{—N]aL·.7 ¦¯€DQ‰l¨°Ùñ‘±89Ågg˜­vDm˜ê^żäžïðDUV‰.’‹ „OX“EV½ñúůſ>qáz¯™Q}(¯#¸MoNªþ‚,–-ç~bûc–97þƒÛ W_ýÚ 'sžNYO j]feÈu´Ê6ó݃3¬¥Nâ‘+1g¸`äjÄ;Vn6ý²pf_OBdkàžpìuÕqž\HM…kûÇbµY2}¶PÏe'¼ÒqCG¡[Jì7‰P~Oëê ’k¬™‘Õ3“/—A ]%†°h@ ozWÇlýÒ0—u¹rî[ÿ˜KŸ}–ún²gþÌ@)E¢ …/ï׿•A ã욦›ŸÝéFz ‹ìӓ͸B‰@[Ë %Û×á!Ê~nÂ8o¦®H#7±=ªðG Ÿö¿qÍÐóв`Kaÿ„íHÃPXÙóú{ Œ¯Þz“ð|\+Èç|+Nn{W´*ã›Òý2†usá1\$«J÷‰µ®E3Õÿ°Û (ÓÁò‰¥Ql µÓâû¦ˆ\õ€V 6°ì¶†RÐ-]ú–SÑM¬´«}.ÒZ+í)BÏw{”û]ò×¼BôŽ £[Ú~š–’‹©–uÖêk‹§*ë[“Réù½¼ÈƒKñ^ÿWñ.ýyx^èS¥`¹ÇXñ T9 Á‰}­ ,f%cò¶z¤Cdð°‰/oûXfÍÁgî/òŽæÖLb ãz¹IÑ9z,çik߸m2ÃHßè:ÚÈ™U3±6ê›ÐÔVà-­!S)­Tµþòt —Ãߊq‚üãÎ4zM’î=03mcí(f4E×Ù9¨˜Ji#çØ$Ö€²‡.¼¶½î—f¹OÛša—¿ÇFaöæoËFV¹68qc•ò:u[P¯_«eÒžfàëf›ÛÖ»$;R¾íŸÄ=§Œ‘!MÆ—íS±(ÙbéLIçoWÙ>òŸ“î铳ÒÓ¶—S{Iî=C.…ŠÛÍ'ÃÎ?^“Hut†q§¹›Yn—¦´ˆ®1o2²òz.½ :L?ÑÁmt‘¦×x) Lìå\èíöó¬©K•a¿´¬p\!)ó §÷¯P#û…?#™ðÆÎêX58æ»ù޵¸*l¦&#àp(müL‡3–B±AóéV$®ò÷\Ö^›G4–Øï”y³Þ°"Q’©s¬¯aò—R%VªwPRõ±ûoÆuÒÍ‘?ºËèðÔø| ÑjÅÆ ˆßFZ© Uíçíw¦³ÊåÝLeòt—Þ¡£±ß\®]nfÈ yø*e6úŒ.ü¦”PèZ Öâ‚ º!ýi…ï«{ï¤Ý†S¶hÚëêØgeyäÎy&úñ —Ê6#ˆýF(;`;S÷íþX·à‡«¶~Nþ>^„Æjß½#䬭VÏ%Þ†„Ød8tLí¥ƒÚF:änœ_ îõÈxrr«åô± „àŽV¡ÿ—;œ!ÑÆüHÙ¥©½IÄAº_\d™êÞYšštˆI˾§ãzÒ!¦%,¼"{–( –ƒÆiðÅůÓïI«»öe²¢mnøq¥ŒÉ…w³ËX!qõüm­~"œ.Ã5Ì08¡8’ëA&›k÷y»pÌÝg¸þä¡!O úÆ5Ò/#Fv²Ñ¾»AßaI$¶öùIM$éV]ÆŽÎtõº˜ ¹ˆ\€^ö*­B§X/e`{Æh ÎLóê¥5ÎV4híE@òEÁhŒ2²H1C|ggÍʳÏß® :O­ —B¿ ¸9’Œ0²ïXwTl}Š$4¨é]¸ô‘“l“ÎôFÝÞ¬½0ÆeSbŽ+¿’í‰<ÑL¸J‘õ^‹!hòï¡SiûA fa‚B$?é×ZAt¸# – =š ü¿ÎGú—çTûÔûõU¾‘‘+FèX §L7ÿ»-áãx»µ¿˜Nƒ`¯Á„6$cöÓ ÁÂBÚõ:—þN’mkCØ=ŽãF/}8âÖã^ÚDðAs¹´®#x_™•C«KT]˜ÖƲÔ¾½møa×á}üo† Vú´âä‚€ +Z*³N÷·G°.™ IÊ›êáS \†Ä?ΤRý}-Jzã¡ÖbÍ­wêµØµ„ …È™_’ä7¥Æó¥sRáÁÅtÍ­n—’ëT;×ñŒl CŽS÷Q’ë¥Õ>T¡ã‹#A陡¯:^¯hõQ”ŠåÚÆÐÌU fB\ÿœm˜\ÕMrYó^ù`—A‡ô–œEëQˆïZ‹’ÿ̪™¾Ð8#”Ú:V:ÞgJo’C‚ß0~×}”ætž4$¥ºóÔò°Ái“@b8epÚµÁµíÆE+˜b÷y–ßb´è³ò„«.ÓÐî˜v0ù›™H9û('%™ÎqÓÁ‰‡N«)†{œ‰b²§?›,-nþ x½k Î?UT…áʰ4vl”¥P{%ªÝ‹vÄuݦBnðC¿jI÷4CLJ§ì³Xk?fC¯åjpõß¿h×q”«ì50RHU*‡ÒØÌ-m‹:Á|ã5´;Q•›µß&¼ùì¶PérçÕnŽŒL‘Uçò×5ƒ÷©yéšI,OY¡*„áùŽ“)XLÐN5 8ð‡L¨Vá6«³W 9Q R#+ކ K¢ìýäXÜg.náá2Šá»í2KÒØ—ïTõÂÞËÇÝ„¥F0XC‡,S_ç-ÏÔ,ÞÇñäÄÜà–¸‚ ö3"¤m²wr•‰kBÇR‰žÎrêK¦Éå.yú°V:±Ü;â * 0èk½—V¼!¾“ŽBçýê ÔŠ¬±ˆì¿´Šú祽ò´D…¶ïbhtÄ·+ǵ!dƯ¢t'UNa¤…‘øÃÈÆ³RV»3J†™¢ñqôòï(Öô<xm%cw†8Òž gVlz/˜ZÀφ“é{ꇕõGï@îv•“pذU×ML-wmÕ<õ[ü ‰ŽêiAå8\0"k87Êž??—… Zî"¶B'ìry/ Œ¬G’ñ)Ê•Hœj¾d/¦EÞLÑÆÿ4×8éG©Vä^LÁÊr1ÿ›¤äP =£¿gŒ˜(F݇VÅÙY´ü4Åd•SÐL×ZÄÈÿ@,Ú†qñ­Û÷sUwOéB©Ø‹ðáiñŽ$B¿ü(Ü»/ÞœSÀÑùdñëŽ3{Y¨¸¶ÛAò¥ ÙS'¦êçX*=eB3;näŒ1 îr§ír™‚ÊGÛ¶šãÍ®TóÄÍ <¼Ìžá—‰šy³sWûCáÜ=ªÝÃIjÐyg!=°Š¤m¯\2£ö}bD!bë¯}ÂWœš¢åÐÓÝ=XØj´:×…*hýøA81 #$ÄcØWfš'çF]iJo·âY…ÿd çmdÃß B~!=9- •ÔµþÞ yMMfª¿lp‹ÙÞuÁßá°æ•¹6¹•]«Ùøk²¨6´%{둞: µ·Å³ë₍ïû`眺!T̺p=ygxæ(q–˜®ÈZn¼@ŠE¾ûö½aœe¯c6ho†UèÅ[óuø"Ðï¤}†aÌä“ÐÉ>:óˆŽ)ß»^]Q¬˜#õy«‰Câ«9åÜÅÎÞÑÅ…ÿG¹DZjœ³í&⥎Aà=ä²½um©q>©jJF¨’!ÙÍÀZ »{Ê^Š©óf!— o:Kç\tÚJ8™X@ —úÎj Ü4«º;07:MóÑ!©çñ3Vï‚€dܱ MÝppËTýµ]÷?lórˆÔÔV/b „Ö†68¨º.'“[±$ƒ<בiﮂ¶æŽˆ¿4 Í®§ðú€1Üñ.†úôÂ~pç”d>?ØÕ¾æw‰8­±P¹Lçbð\8Q·Ì²mïf\ ƒe+‰õ½Í® ù?Êìªd)dœ“ÉóŽ¥µ„…®W™®´ÑIr$YB8v@Ø 06Šf%¸.ý·Ê+1_Ê£€Ìî^Ѥà <•ºîp[GépÏŒwÌ ‚ìÙ´EâÔq6ÛªƒñË´×›ý_ƒ-/¼‰kç qNúãKŽñLDu÷ïá’ c_DM!ÌâçwsV·¨¹ -ðUÓ÷ãØŸA¯Š´¼q®g@Ukwíìp~SE9½tݲduÍtÿ}*Á&ïYezÂîÛSÙñEóÝâ=|çÁ Ʀ ;ïÐ<_oâLÜòÔ|’'Y÷ûÈá=‡2¶y¢nï²Ì}VœúRìtOæ¼ þ•A0gÎ:KÉ{‡ìµÂ]è­I‰}/àÀjóÒah•_t ®I$5‘uÌ&.µÒq¤2ÍÎ^¿w…dMMOè1-)zàuñÃX<Û]ÖIâ ŽÈ" Ý,ˆ/g® €ã|õ¯˜>éŸu¦¶ËÀ9ÃVÿûžÉÒ/ò5k€ì-!“[Äø&þñŒ FÖ Ö{ëñq±ØõwhŽ¥ æéyõ3²]—æäéˆÛÛP\5NDùìÙžožcAÈSéâVm>[þâ}kE:Ž"üºÍTÆ ?WŸ î'aï3QíjŠuØxgÇúnrj–èMQyµäçŸÏhøV´W†–&ÞÿÛEÙ)ó–È«<ªýLÓmÿѵJEöíÄDâ|ødæCj¹žßA§+äˆ׉Ôwˆ,Ý rcÆýMÜ­«M“ÏפkFKg˜ÓÇ ü¡ÇZcxé ìÊ)lQYŽ*Ru¹tO’2¢¼^½òµW@ýäÞ‚½ƒÄÐ`þÜÂT¯šÜ† fCpK:©Þl)Ü9Øc––Mž¸¦ß®&)n,•ì‹Nñ%ù»ß4ç{Ä?3Ð^@>èÈJ„.òE|ƒ 0¡×\ì§=e÷7üØsÉZ$ 5Þ«w? \."<$Ó)´ßpäRwèƒÌß6Î;nì\+@°ÃiÍõ”ë–Rxã7Öî.V€~傦Bmk4ñ@áé‚%?-f$1¿²ÍFbñçšã•²ßPïeCS r:ýJ(üqÀf‹µ9âç˜äˆ¶L iáØÕÁNS(†î)»%w–yûyðS(Ô$("­¤-­£H2Á‹²f>šËÙVïà$xbƒ‹žsÆhÃ%uÎIíòm+'ðNÝçd럈?¿põË—8}ÖÖÊàpÝbèî§‘™ßè¬à̈ŠPÄ«¯(KL.Íg ¼kH¼a+Œxé‚3ü1˜›+éOéçÌ…ar±7& ëÁvõdVóùOÉËê6!ï3ÞB4ÿø,F|sÄ·¡ž¦ë¿õw@*B`Ð_]æq>ù/ èÃñ½ø×Ô"êÎÇNiK&”…=.ÿh¾À-JÓìæÎ;RvÓõ¨„À;YD¯ŸðÂpâšôT>JmÑØ„§3a¿ÕS ¬J+`Õ=*ãŒ1o05ÖY¼ò1¨k¯2eîÉÒÚÃ:ÅŽò5IrÝ å¬WÚ1Ë1(£ X=~É`ä5ø;B,ƒd—´5ŠB KZOù>üS.TÝ Š é%*¸ŒźþíoOú4yJ˜Ô8"B‘¶`G YSôâ#Ÿ‰ U¨I• 1ú÷nùäîy)~Nj7÷•ZÜg¹\|¢¢EÐ «ÿ«0‚ŽínSîÅÀ¼“UúP Rf=²ÎÒòUE¹!¿_’šïÛ?~ åŽ+æΰÔ[£!W;þ#„µ‘ú%‚tqšeŠ*ÚÅö»˜ÊŽLð û‹òY9WTd Ê+¿vsÓ~Vò!@œº’šÝ ;éWÉïlÑÚUéf€æ6€ÒG¾´y ²ü–¥%rŽŠLb@çå-,ßÚy Á¥Ý MxR ä¼o^‹ÉüdçN¬Ÿôf¤ÏóÄåmà% Û1÷[òKÇ;‹.žúBºÅ,ý¢á:þ£þ^§ÁÌ&µ> ê/!†3|Ö«SÞ—yTßF?øË0E22¢>¤jQu?ìžx€©’ûî~$[Ó«™8ô¯ù{Üw!?I‰’a ×)!ö@à°½È@¶ jTz\žù+þU¼•øÐêêÆüß¼–T½Œ‘£ºBŒ+.ú€Ý%Û'±Zú oMO™œ8b(ºÔßg.ÙØE·ácGZÒËêŸB—æ¾Wx‚»5õÔ0¤'œç##9]!@&˜ÌóÝ¢FýL†FŠÐ*ß•Âa¤6˜ï`­£ 1\j¶Œ©ûܦ#ÂôÊÁ«ÌÀ5~µZ œ¬;s–í¹ØŸ¯o9­5 :!Ï$½—¡`môŽJºÒ¨ÑC+œþH;q !LO‚ñªá’b5­O~ 6D„o§õÉòÑkCZå¹@ȶD›©t†xÅø Ûõ˜¿4‡ï¬Ž<œŒU§9ë—žægù²Ÿ•ë–zÛ"rU¯ BœŒ‡,¿|¼?;Éùñî¾²ñ0sê@aŠ€Ë³éYòó:‰’@5¦1ñq±0ÆYDãc:âPoBê.Õ­²WZ²ÍDL÷·ÞÜ ÅìZ­°ezž> \_¼7tm–îÓë9&¢DütÉð—Ü•hI >kæ…F€‡þÒLöÑqWPBX`á#¦´«ÚæAßùþƒ#ÎÓXn³$u1Y ÀuÃ|þE)èx†H¿õ.®-¿\t¿<7¶dÆ®(ìTü¾%ž(Í‚%Çê–¨«¸™Ç¨-’7ï±=ªaß’Ö±ü@†?À‘ÈT¡}ý‰öó·p ÷§àp(¶RW‚»\ŸT@² ž½îâ²;)BhôR9ïþ÷ï߮㫴ßì?}_™‰l¿ &°Y–ˆèê½odŠ‘ô*ƒïðÊ¿dD«´âí€Ì ³ûª]›õëha >ôyTMp´2‹¸e q¬±8/ߟjÈ ÚǫɿÀÏ k³ Y® ¾ m’èº].Ê}ûß`æ4Ê1Sy˜;€à,ɯt r!Mƒõ1Á:°/i)¸X£ŽŸçÌa‘Xö–ø 4ðˆRš(Œ”ŽÕÕxºäIfQ3så/ŒÀ†Ñöž5ÒÕÃ',ö÷,½Ç²ªu[rwÝþ ÖÏn i á‰Tç†Ó9XÏs§³Šè \·Ø =ºœôî˜1 "¸coäÅ/ê¡ã……¶“ ƒéžk?'-ÍHWªk …ukÈ1 0f›-ÝД˜¬5²gñéãªøÖc˜» q;ˆZÀî5\ÂÓY5*÷[àØx–Àñ¯ß R»Y b¢úÄ_(%…×L™ëa h²§5(·yÓn>‘›éDŠ-¤Á»ˆ/HòÒ{uäef/TCIÇ·R+FlÁ±øÄ» ÷UãÚ8ïúfBûa¦Þ<š’9 ¡ŒUvnɆŒ`g K³1–ŠNDß·¬ ÇL§? :ÚYS3oÜ 2b~ôg ó(š/¨y'æ”´¸ç@I%JšZÐö3 lülŠY±=± ^!ua¯¼eAöè(†Ÿ34þܵÿÏSºaþ^ÀrŠÑrmgG`+n]8Ôī¿‹ù¨ˆ_Ã`ñ øHpn]á¼.2Dìò{µ¨ðDáÁí¶|’>– ÇÓö~­mWÌÚxI#¯g—zžÈ;‡*Õ~¶ïÅ ¦ëÞzi9§6@û<û²ÂÔÆºI&o /Þ…t¯Ô,ˆ=?«v•s»Ü¥ÒÎ @™R)e~3ÛÑëûcêhiI—·Ë¸¥9«¾9Ü|F{ÿC2V1°÷ÞSYêôše@µ 9_,˜ í ñ9%¶ó£ùÂúÃbŸ…#æ2ä`O\úÈf+e^ìϦê«_~úÆaÕgÛÖ;¸i † ï‘:§3mmØÁÚþ„ Ÿ{w©!dP-ÆæSêg£0ºÂÁ[Ü{efOŠ ˆ–s@¶ôÏÑ_!”um²hñM}õ!å‹ë+Ò&‚6YH C‹DµÕ{_ÅÅЧ0T7m`)C/4 Õ/a‡ •uâ©}XÛãcF ‰›Çš“SéºÄGÔÀbýzðó¹EísÚ‚X¥çs ÚÿÙ‘žÿ:°÷dþ%ÎN—fÉóá ôVÍó ç掶¤CXÏIýóê +{A:3Î×\4ñ&0]-k•ÈM$8£Äúâþ…éã§jë]at ¸ÿ±/æ‰h& Ì(ÞŒj MΜûÊ+˜«ó½ãϹ¡ÇÉ8,'ÂC²Y0‚ùnù­eCÄ«®"¡Å«B¨û1øêåŒùN>R.pñ1ü§)L­©Ó…WRW‡ÛZû°Ålÿç„£ª–ÝŒµé¤ &Ì‘ÔÜ+UáÌ&Ú«u´ýÝÖ~XªÏ"cÁ"l@u¸Ñ0¾ùòÈý×Ó'Ø ‚S¢XŠÀãZ¨\%téÔrŠ‹n¤¥5Ìç*¨\QzáÆ=@%â|¿øÑû.TÂO"µØ¸dGúE"sXYà!¾äx­}M§ÖÎAdL¹Öï„\•«üóͽlö·¦²ñeQMY¼’ãÆÓÑêìÎâÐò^§ŸºŠÚð­)è›òê)—*ñæö} q ç‡ 0‹ãe¨ÇpwvšÓ æ¥I¯¼ØW‡ô,2_FüZlà:Ô%,,Ü!º9¾…yÓ”)[~CZD,ÚëM“ß|NÀù_¨ÃJ[‘1hëäÔn¥Å-ª^÷rsÌ«§ymLBÁ÷Py‡…ÿk#”ÑQ{óxÅôgü[ ØH¬­a¼ ù¯¤w¢wGÕŸù^;Œ<ó?ó¨<¡Ý!ÆbNmÖð<^Ë´™É ýPÖËÒ6»y^‹ù¡Ð†¤¿•;Ñ„@Š¢¤vf+]û²2{æ¡ÜVFªÚó¨e¦&™k1ÕVîj6¼_£™f½×L2bJ¯SÌÙmý«3QIÜ]¹»ÜÖæ_Ÿ£‡ð8øŸe$ÊIC„I?(RaLÃɦÉÌÌ¡IžS…9È“4Ì)Éš Nr!(RyB’xJDPÊ99”9ùe‡=0ÊLå…%™3™ÌÊ™¡œ¡Â!(S&RæaJIáœ(S'0§' 3ÉL”)̧ dó‘ žPæg0‰2PÐÊL°³B¡’PÉ”šg>i'Bee I…8RP¤ÈNd‘ œáNLæIL(Rg æS% ”ˆ&JLˆIC¡BPˆJhPäÌÊB”“œ¤ÏŸÐå%%% ¤ÊJaÊB„ó–r$™He$ÉB!†™?"Éʘ~s”3™LçÐ))ú(I¡ Rg(g0‰3Í’ž†s¡ÈBÌ4“œáœü¦ffL¤œ¡ÉLšOIô9CÃ2|ˆ%PÎ ”Ã)%2PˆJ ”(p¡C9333š…0ÎhsC œå Ê,ü.)=!å ¦NRg” &rPÎIfg0Ó'aš…“3æRfe B!3™)˜Pá@²J8PÉ |ôÃ(PáN†PÎJB…&PáÎae%32’~dBag2’|¦s@‰0ù™’˜S33' ” ”&D'0²M ”(P§9Cœˆ¡Ìç3œ)")’!2!ÉÐ(RPô9èd¡Â!†g(Re'ü¦JB!HS)“)’L”)8XDe0ó ™”)2“þS'”Ÿ)2“”ÉùB“2“úfs9™C93% ™ÉÐÂÃ)2†rP¡L”?)2… æ'ÊL¡Ìå'¡ÊsÊdò˜s†JIL”Ì)™2!†“3) RLòaILšaIɲR‡&sœæH… LÌÌ”ÉLÌÌ¡œÌÐ)(D39%'¤"‘ œŸ”2S%% 礡I”ÉL”3’™™?(S0РRgŸ”Ì”2P¡)ü¡É”„@Œ€D…„ÊM0”Ã33&“¤¡Ê™'ÊJ”™ç)™’†Éœ(2e„Ê¡ÏÊNP¤Ê)’g%„ÊJœ¡2!“™’†IÉÈ„ÊY&’t&S$³,¤é†…'% IC% ¦™”8“ÿøÉœ@ÿÿµÆP!9BR‡™“ÎPðå”$ò…'3% ”šdˆp§&†NfaI”ÃÌág2’eB„¡’†O”)™ÌСÊ”¡œ"9’…&i)Ï"(fg?C'ÌÌ™9œü°ˆ@¤ÌÌ¡œ™C8D2p¡Îfs9œÏ$Iœ°ù”…2†s rP<“9))"˜xL¤Ì¡Ì.I¡‰˜RR†r„ô œôÌÊ’” JH|¤ùgÓ0¤¦e áLÊI4Ÿ”Îç–ÉL,"8r™)’™œ)3I¥ ¦Â!™…“3„@°å$ÊL"ÉB’ŸB„ÒI¡BR‡‰™“Bae2S’…2O …(M ”š™aNfe…% r“)%$¦)(ú¤)(d”™ó"y2†…'¡’†L¡“˜\žRe'”(p¡fS „¡¤ÌèfP)™(hd¤äC‡¡ÊfRdBe ”ÉòÂ’‡)4(g0ˆd§)<Â!„I<3’…&yB“œÐ¤Ê“ÉèfPç%2RÌòÊáBÉ™”ŸÐÂÃý&…&t0òtÉNL¡Ì¦r‡˜RS aä:Ï¡’… Ìœ3C9C9$C…3$C”„ˆ™Ìó dÍ'L<”)2™œå$¦IBæfrD)ffg% !JCL9”3% LÄP¦NM0òP¡IœÉfL¡Ìç9C™™C9œÉÃäÌÌ”ÌÌ”) "™™’”š™¦˜g(sò’P¤,Ê™43ŸC'™Îg3™I”™L„LŸ)™:ž, ™I¥œ3C')’s$@¤¡I)(S%2XD$Cœ"B”9œ¡œœÏ9C 9ž~Y¡C˜S… Ð3§$¹œ)“™™œÎPÎgå2J“)(r„¡C“2e%œ2™†…%™")œ¡ÌÌÉB’g fg2’rS2áaœårD)3‘„B„ˆPÊ™C”„Cœ2““4ÉLÌÌæyÊÉOÐááC38R†rd¡’…'8D9ÌæS!°"dèr’…&2e%PÊB‡“™2 P¤ÂžS…9ÊNPСə)˜Rf’…&Rr!C”(Rri…2s2RP¡ÌÓRtšaä¡I(p RfP"9Ì‘ ”)(RO% 2D9™ÌÌÌÌ¡L™@ç)œË3”3“™á,(hRC (žJP”¡=ÉäÓ åS3œå æe2hPç (d¡’“"r!3ÎRg(L¤ô̦B”“ÿ)(ICý’’‡…&|¡C“™ÊÂä/”™C“(s …|ÐÉü°¤äB| (Iò…&r†)™9™™)?”ÌÎ}“‡3”<<’…!BÉ”3Ây™”9”ÎdI'”™d¡¤¥ Ê9BrPˆ„BO’ F,ý ”ÃÌ9’Ê“2rS3%%%% IB¥fP,:R†B%…JI”3%‡3(g0³3™")’™:JfP"\¡IŸ2’fs9™‡(̙ʔ"IæD) ™>P¤(RrR¥!BÂ’…$”(JPç¡IBÌÌÌ)9™™™™)(r“Òçô'ü¦aðô%Ô̔32P)3ÏŸ(y…2p¡Ãœ)I<9”9™™™)™Âœ(S3™™™)’˜S&D&D3„I”Ÿ)9I”ÈD˜ÄÇÿøÉ‰@µÆè!339œÊað§ dóÎfaÊ!s LÐÉB‡ N“L<”)8g?Ò…ÎP¦L¦JP”¡4(JBa&P¦Lä@Í °¦fM çÿ¡Í L¤¡”38Pæ…&RS‡˜g=De É”„I”3…0¦NJJHD)9‡’Y@¤¡™å2P¡áäœÿ)(L¤”“å9ÒzM0å Ì,Ϥ(X&xD™”4 J)9’™’„@ááe'I”” JÉå2|¤¡(p¦NP¤Â!Â’’…P¤ÊM Jfd¤äC'30§!a”Ù”8D8D)(Dœ¡)™†R NÏ)>èr„¡’L™@ÎL¡Éæs0¦aIB‡“ˆL¤¡™™‡’áB˜hD $ð¡I”Ïœ¡™)™’ P¤ÂŸË 9ÎRg33338S3'333$C'%'IÒP¤ðä@òdC338D4(Y3”% “2D(JJBP²…&fffp³ p¦e J“2Pä@å%(OPÌÌ“ÉJ™)’™Ê̤”™IÊd‘…3 fg3™ÌÊ¡œÌÉL””ÉLɦ“å2t °”ÈDÉ32S&’†„C™ò™9L(D% ™I9„C%'(rPÎe&S'¡BRˆs:œ)’Xe&r’e!¦JaL™IL–g9C„C'2S dä¦yùL<…2hfp‰3IÒt(d¥ èÉÌ”'C &RM% s)2™(”<¡Î¤Ÿ 4ÉLÌ¡š<ÉÐÊLˆL¤¤¦g dÊ™“IB“†p¦N‡"8e Ì™B‡3ÿèp‰(æJB‰“™™’!Â!Iœ¡ÌùLÌå!9ærS%2S9’’†xs¤ô32|¦NPæRz9Êdé;&ɦ¤¡LÌÏ’’ÌÊdèr†fL¤ÊfS3™ùNÌÌÌÌç¡ÎD’RhP”ò™:Ô93™Â™9™ÊNS$ГBe$¡H_üó9CÌæ’…&)“òœé(Re0§$ D”)ÌÊÉ”332S<ò…d¡É™4ŸèäŸ)“þ’…&RzJ¡C… ‘R…PÌÉ”™Ê(SPˆÉI„”))˜RP¤¥ ä”ÉLæRIILÂ…!O¡= ý&”'˜S$òS”™Ê™(S0¤Â‡ (s,¡Ê2fr’†fs¡Iš™Â!Îd¦M0òP¡ÌФ"!JIpÊNfs<¡aš,"Ê¡“æRe‡(Nd¦fJd§ 3™C%% B‡ =%RP¦g„¡äÌÉ”‘ æOš?ô(JÄ@°ÊM0ËHD  R š™ÏI‘ Ð)HR’˜Rf“C„I…)$¡ÏI¡N¤™I”” Na¡4(9Bˆž¡43™žyC@¤Í äˆd§Ð¡Ìÿ)“ y'3”(s39™…2s L¡”™IB…!BÎYô2r‡3”3“3%9Ÿ””2zÎI’ˆd¦ˆgÿB’S!IùL9œÌÊ̤”,>`A“)%8r!)ô2„Ê|ùBÀˆrdˆd¥$å B– J™’™™æyÊ y‡(dÐÉBLæK32P¤ç)”%èf‡)’™))(RLçÐÉBe&†y™œÌ˜e&)2’’†‡))(sÒzaòî’ÿøÉŽ@ÿÿµÆÈ!å” 0¥ B’IBÊÐ" XAP¡2˜y…™L””33 Re BœÉL)’”)8PáLÌÌÎdˆað§2P¡’”% Có”3™Îd¤¤¡Í'I¤–P<ÃB†…32‡(PÎå&S3<9 0ærS%0¡IB™…… Ÿ”Ì” NdÐ)ˆd"LΔ)3Cœ¤ f†r„¡IIBÉœáá‘ 43…2JL¦OC”š2S”ÌÊdæfg8Y’!™…2Pˆg„ÐÉB“‡‡%gþ~ÿ”ÈDÌ)Ì"pÞ Y…È\’Ê… Ð)(“ÃÉ40Јp¤¡IBœ…™™Ã”2džáe ù¡&„…òi(Rp¤¡aNH†fNaæg”9…(pˆ…™Ìæyù¡BxPæg2…!9Ìó$C%(‘’‡&p¡I™Êœ4¡”ÉÉ32P°ˆJs3%2i…2hs>RPÏ0¤ÊJžg(y2Â!<ôš2†p¥$B‡2fJfP¤ÌÌ”,)"&fsCB“Ì‘0øP²N„¡Iša)†Re3ÿ¡…˜Sž… Ðô”šd¡Ê’‡˜y(RIr8S39ÎRr…ä̇É&PÉáË JfPç†áLš¤"ÃB’œÏ™”&D˜D8e&’†…% H)I”Ìæ‡ p¦g ‡PæfPÉÔ9IL”ÉIB“)’…&zB„¦OÍ!))(Y2„È„Êfr‡(L‰2’’ Rg ¤È„ó2“’M ”<2P¡Ì–P,2‡ fPÎsÐÎPÎe™JË…8RP¡Ì¤ˆP§(faÌ,™C˜S… 3™™(s)…(JfRfK0§„„³%)‡Â™2†s<¡ÉÌæJ9”ÌÉL“†pˆR¤ü¦N‡"(r‡‡)˜Y0ˆdÒIfrS3<å&y™…&S32†gNICŸÈ¡BPÊaòg2†rPÏP)’”<”)”™LÜÍ…&fPç rap’Èr‡)(hP”å2PÉåLÌÌ"L‘ @‚œ¦L¤Ë J™(™(g&e ˜s%% ÊaðÍ ÏC”ž‡(S33'% J,8RP))ž†sBf¦ÊL°¦OžP¤”)39CC”„B“9C„C™LžS8P‰ ç(g&PæRJa̤Í™”“™˜S2P°¤³330ˆdæO…8hR9C)&p°") PÎÌÎRe32S2“"…&g 3Êp§'(g0ˆLÊ… C$¡C…9C”2Pç)9œÌÌÌÌÊÊaÏ)™ÊHPáNdI„IC)2’’!ÉB’gLÉœ3’œôÂ’…3' (J3CB‡&ffe$ðÊB……”<3… È)èdˆ%% ”$ˆdˆR) d¦HHYœÊaÎP¦aC33…8P°Ë f!Jdˆœ“Ã9ùC8D9ÎdˆL"d”Τ",,"ÎP¤ D™IC’!BSœ¤¡=™“% ”ÌÎr“œ¤"Lç3ÌüÐÉCš™C””ÌÌÌÌÂÂ’!(S…9™Ìå ”’IžP¤¥ ðΤ%0”) RSúÐááC”2“(S$¡H|3IB™’™"Í æD’|¤že'ý0øhD!0‰ s™™)(Re39”ÃL§aBÂÊ’á¡NÌÌæp¤ÊN™6‡CÿøÉ‡@µÆØ! (e&J2„"d¤¤¡L)Ð,Ê)ˆXRR~RNdˆNaÏþ‡9Éœ¤že$¡IÌ”) 2RD JæRO3” dÌè™Cœ²‡™)“IÐÉóç”) PÌÎL¤’˜Rs 0³ JI„C%9O&™)48YCÉB‡'’„HP²fH‡9Ió PÒO@³4(rO”œ¤Â&O P“LùI”(RPô&˜0¦g$¥J…)%2hd Rd¡I”$¤’!2 s‡˜y‡˜xffN†J†hNRP4åŸÓ…933&˜Re!äËɤô9C”9é†hd¡…’~S'9C8D%8DÙ32RRD NL¡”™¦J B$)ÊaN‡4ÉrD) ÉB†… Jœ(s4ÉfáNs̳)HP¡CB‡(sB‡'(På d§(Sd”áfP¤)HS3%3%%P¦frJd¤¡IB’…“9C)3ÿI)BJd¥0¥ ”Ìå ÌùLžPæÉÌÌÊaNLÉþ…&S39""ÎffPùB™™2 s4'BJaIš"(dÒzJœ”¤(fPæP°¡aB“=” ̡̜Ê% NRO2‡Â™™?@§ 9”9é(PСI”<šd¦r…“)(RS3”)3”32rS&’™,ÉCIÓ ¤™Ê¡I”æy”™IÿùC™œå¦K‡ÉIL”ÌÎPСä,¤ y™:)“)˜S&e$ùȆJ|¡aÿúaL“2z8s)&PáÌ¡œ)™’!’™)™™Be'"I432 FáL™Iç"Ì¡œ¡Êd§ $ˆR"LŸ)…ÈS3%™”8\¡IL¦™)…% 8Y(pˆRNIL%RbÏœÎd°¡¡ÿ”” Nd¡C“8e2P!BÉœ)9CÉù)<2ÎS'ô(g&sÒtš¡áœèRp§'ÊÌç(ry…’S3Ÿ?(Y3”ÉJ“˜y’!žC% ‡<¦f™Ïþ’†„Bg%2Y’™†…%39’!“™)(dùLÌŸ”ÌÎPæ‡4)0ˆp Y áB!3'3œá¤þN™™˜S< ”ÉК¡äÐå%2„æJfp§ fd¦J@°¡NtÌÊL¤”ÈD™Â™œü¤Ì””)(S“(pˆsú¡”%3"òs(d¡LÉðÒ„ÐÉL”ÌÂ!HSÿþY”ÌÉBP,™ÏÐÍ0°ˆÉB™@³'33 dÈÊd¥!BÂ’!(D%&IˆdÊd§0°¡ÊJf™¡ÏB|ˆL¡3%… °¤Ë%9ÎR ÊÌ)™žS'Êp¥$çü¦fLˆLÐ,(pˆrfO 3œ¤Êa̤”É(Rs)8S“9†… OCž“"a”…48S2J"™4Ìå')“”2s3ÌÌ8S” (ÊfffLÊI”˜D8s%&…&S$Bd@¤¡IL”É)(S9š¥$å&RLÎPÎd‰0²ff|Êa™œ"™3(™Éé4<„He˜S2e%2S&’… LΓ¤¡LÂÂ……39IBP, s'ò™%!æa¡’aLÌÂÂ!ÉÌ””Ìχ fg p¡IÌ)Éže&R~hD(L¡œ))“LÌ,Â!¡šy2k»ÿøÉ€@ÿþµÆh aœœÌÉB¡HP¡Ê¡™™œôž…9IÏ æg332R†re ”¡É"Hy™ÊOèg?¡œò“(PÐ"LÎfaL™L2Àˆh&rÂ’„ÿþe!¤-”'"9L”ÃÌÌ)‡3¡™Â’„ÊfO”“ÉNäùó9œ§)™™…8S3%2tÃ)“Ì生)‡˜e&39C3%É9”3’Y‡)(hRP¦NaB¡I(s)œ¡C”(|4ÉI¡)ÿèaf} 礗'IICšaò†hg™ð³3P”(y9™”’…&J˜Y„L2“9fSfe ”(J™ÏIèdèN†r™œ”ÉIB!'%™2†P¡ÉÂ…$¦M’áBPçL”Ê…0¤¡O'BP"B„C% )‰2…P¡¡C”0"J”3ŸB‡3C9”¡<¡Cœ(y™Ê9<šaÏùæs3(g2‡9)Ü)™œé= @¤æe$æD2P¦IC˜hD% JJ2“ ™™(P”"% ”’™)™™Â!™™(RO&’… dòg9C% N9'Ês‘&i’™)’™)(XS0°¡L”"̦Oÿþ†eaL’‡%&He$¦ffJ%&r“ç(s9CÌÎPÎS$@ˆJ,’˜PàA B™’†Oʦdä@¤¡B“"BœÊL*"B™„C<…(S!|¤ÿèPÎLÌ¡œ”2S)“úç)ÔÂy„BP „¡B“ÉL‘„ˆPòS™™"3¡HPСäåš¡‚™áLžI2„¡aI@°ô2’„Bg ÌÌ(Ráae á™HYBS0Ò„)“IC@) dô™fRÌ”2R†fdð²ä’!„²hP²äÌÎe2†Jr“Ê™)% B dáfe%™ÃЙI„C…(sÊœ"œ’äˆP”Â…‡3”9™œ”)2™)™’!Â$"&LÉùL“”’P)(PæffJf¤(D†R¤ž<ÃB†˜y…s„šd³8R„Ê“B‡2…¨‘Ì”¤žP¤ÊO9À°¡Î†sC”„C… Ê44,,"2“¡I˜„ÊJfså3 J2S39ÂÉ”9œÎg(Rs”,Ï(rs„C’^SŸ”̡ aN!@°ç(O…)33™”8Pð ç)0¤ä¡I”<”Ìßèr’’…&hRrS&†fg(hr„¡™C)³$C2“ ̦”&†xe!Ì)’œ"LÿȆJPÎC”””(s4ž’‡"ˆa¦JfM0”2P)ÂIO”ÃOC”)9…3$¤™“32S&!“œ„I„C38Y”"(r’…“9ÎdˆL¤é‡’™"ÊC:B‡&dœÉá<“Ñ…”2PÉC'Ê™1 ”ÌÌ)(Rs332S’!“ÃÌ)2……8S%3(s?)™Í Na¤é4(ry)’Ì<ÉLÎsœ,™™™…&Ðæ…&S0¤¡CI…“9™Â“˜y(RRˆX B!(D%(rÊ% L¡)Î&%Ì“ä‰À‰(R|¤¡0‰2e!Ð"BœÌÌÌšN†só8P§ p°ˆJH„¡aNÉ”æPáœÎPó'þ†p¦OÊJ(PáB“,Îe$üüå$¡IžP¡I…3™”8D32rS$C0êàÿøÉ­@µÆà!™”’S NaLÉ”””2P¤ú愞ffJffR¡“™)’” °“C9¦M% L¤¡Êô8D4(y:”3˜D9)(… dä¡IÌ”ç,"†PÃáèffd”™ÌæÌ̤Â!Â!ÉB“?¡œÊL¤œ¤(D¤òyI@¤"%2ä,¡Ïò“4r††S <§2”‡C9IB˜s>‡‡&PÍIÓ L™ÌÌÉC@ˆJaN¡œ<¦OÊI"%†NJJfd¦aœÊdô J„I2„Ð)ÙÌÌÌÌ¡IÌŸ?”Ì"ÉB„ÎI RPÐÏ 2D2S$C%2D&D'2†rs9…9?”ÌŸ)…8PÌÉžg9þ‡(frP‰ áLÌšL‚H¥&S2„¡”(i’™œÊ™C™å L̤’&INJRBÊáÏC”)2“ÐÌ¡ÉBR²’…'0§ s“?þe$.Oò™:%<°¤¡= ó@¡"gÊdùNt8D‡¤)Ê8r’¥%% Ì)œ(S&SR B$2!“™”9‡3LÎd¦B$ÂÊfp¡¡B™)œ¡¡9“9žP§ 38P,¡LÌœÃL”ÌÎPæg(s™ÌÿÐÉC9B…% Jaʇ †™†RIL)“™…8S…3’DÉò™3”% JJáB$2„óúJfp§&r‡% B”’˜S‡å9Ó%$@ЈJPÎ L”šLˆp¡ÊJ™C2e39IB™)™’™4ÃÉLÊÊ2f} ”3(s4ž“èd¡’„ÊO)™™œý æ… ˆO0òNg”)0ˆP”Ï„Lœ™™™™˜P¤Ï!32Ri(Rs% !Iœ3ž„ÐÉC””)2†Ra&RRD 2S'C 3B’‡"¡9@ˆs(p‚!N¤¡L„@°§”ɔÙ̡œœ¦g†rNIå0Í …'(På ó”%™")™œå&s„@°¤þ†Re&…!C”$ˆd¡Êœ)™™œ¦eæH’aá‘&s™”9ùLœË æffJp¡I™¡œ‘ žP²e2S9@²…332På ¡BD9L))’™‡“B‡3B“):)<§†rs"„B™É)“B‡2˜S…9™™)˜RgC”)“œ"(D%&PåÌ™gÒP¤šœ"…&†J(p¤¡2$…Ã90¦(S I¦f!JND HCœÊaædÐÉC”)“)=0¦Iæe$󔜈J”3(p RgúJÎaL™IBÂ…’R†fJœ)&dÊ9’‡3œ¡ær‡(g Ê™“L<”ÌÉL”‘ ”„BP‰'>IB‡’|¡¤¡aÿС–IÊS!L‘„ˆD2R†p¦aI”ÃɦI<”,(sȆJ}’…†Y2’JP2D)2™4)(S' ¡œ=Ì"Í3™Ì‘“™Â D…„Ê“"(d¡œé‡ÉФ2Â…&e Iá9(p‰ “„C˜Y@²e9Iˆdä¦J”3Ÿ9I@#0ÿ‘LÌÌÌÌœ¤ô 3èg(Rf“"aäæs”™C”P,94fNRP)3(O3ÊÊÊ”2R†JaäÒ„¥% Òzd¦På™ÉL–fs9)™™BdBNz"= ¡Â!'(Pžt(XpˆJ@‚¿ÿøÉ ª@µÆ g™Âœ(Y%„BP‰% LÊ“(r’…2J™””9C”9Ièg(RP¤áI@³?ä@æNg)'œ)’hd¤þ†gM!) RPˆ¤äBe'L‘É4𙡙@¤¡Bz¡L9šaLœÌ¡’‡†…2†pé%33„BP°¡I™B’”)™4ž“¦¡C™¤¡aO9C… Nd§™™™”<>dæffJ‡ô3’™™ä¡LÉNaЙ9††M!šäHICɦH†Jd¦fg$B“:ò™“æRg)2“9By)CÉ)’YРD™™IÓ†x !NC…$C9œÏ= IèJ%(r‡(e&P¦Ng9I”ÉÎs–9™œ¡C3d¡Ìú2’…%’…… dæ™IIINáfK33339B’‡2XrÂ…2rd@¡¡Ã””ÉL”š4(S2s s3339’!œÏ(Re ”9ILÌÌ)3ü§:9 =ÉÊœŸ>e$ó)3”œ²„¡C38P°§å8XD $ÊNPС̤¡C9ž… É”Ÿò™?СÌú"s&På ÎáNÉ”) ’t3…”8D2RfS%32D2S9Ê™I)’„ÊB!C)3’Pæe$¡IOB„æL¦r’!ÉC“9™˜y4Îg30¦L¤¡Î˜y… ”)œÎg3) hÌ4ÃÌ”"C"I”“)&g3”)(RP9™Îá¦HÌô3Ô42PœÊaÊ&JIÊœ,"ÌáC…C–çùʆ’fJÊaIœ¤ÊdŸ2’IÉý ffa†LæH†p‡<ÊI"9B™œ"„Be å IB’…$C'(d¥B!·(sB‡“"(Pæò“9Ìó™ÌÎPæPäÎffM0òS$B“ÏIé= ™C’‡3”’„@å 8yIš33&@Éažç)3”™B“%2t3”9ò˜e%! D9Êg‡' J32†Jd¦Jpˆs')'BdC'2e&$Î)’™˜D8YùÊIò™I<”Ì”ÈD˜S2”ÂY„BP¡äô…9,"…2e ¦¡BS…%3(JfdˆP”"(g&†L¦Id¡f'Bú%?èfP”“2†…g(g&d¤¡ÊÉäÙ…9œ¤ô dÌÊI¡†” RaCœ§=30ˆd¡LÂÂ’“L>JR¡áÊåB”³3”)œÂ$Â!œ(fRe339…2e$@¤¤ÂÂ…'% J™¡™Â!HR‡:aNe Lü¡I…3 ¡š” :4 P™@æS8SœˆPæpòP¤ÊJœÉI2RP¤æfxÐЧ'(PÍ ó”8D’†aI8P¤ÊJÿB~faÍ !BS”Ìå% ')ÀŒ’YÊp¡¦S0¤Ì¤Êfe áùLÎhS9™”&D…)…!Jdÿ)’‡‡@³))“L)ÉLÎt(rfJ%œÌΡ’…%% L¤¡I”ÉÉáNPáær“”9™˜RaáC9”Ì áI”)†Xf“‘ œ¦fsÿÐòPˆ)¡Cœ(J|ù”8RD%(y<¦dç30ÒP²adšaL%Ã<ŸC'å%Â…†C%))™)"™ÎPæK˜D˜YC˜D8P§B@­ýÿøy CÔ@µÇ(©i*”´¥¥-*ZUd«(´¥-IKDÊJYeDÂÒÊRË*YJ”¨µ¢©KQT¢ÒÉ•)ZXš,µ%,©(¹)QjJZ”©IjRL¨šRÒªT²¥)K(­%rWJV*X©bÊ‹R–T¢ä¢å)R‹JVT¬´¥©J–RÒKQj‹R•µ*KRZÉU%©-EI‹E­*¬©U’T¤˜š-,©Jª\²Ub¥‹KJZ-eKZJÔ––¤©¤‰ªER‹‹I”–´L´ZÊR¥©–Tµ,–RRÔ’Ò•-*²Ò–•YJ‰”¥%“%JK%T©eIeJR¥%–”\´²©,´²Ô¥%©.-,¸²Ô•”´«J\YZ¤ÑeJª¬¥JªURÕ*,¤²•,´¨µ%I2’Ô¥)--IjR’Ô–YQKR#)-U•*©2•Ee¤Ée¥+RZ¨µIJ¢eJK*Y*²ÒU%eJªªª¬¨š,–+,©)J‹TZ¥)e%”¬¨šYRËRT¤´«,¬µ*&I‰”¨¥ÉZ¢eeKRU*É+)JZJ¢Ê–TªÑk*R´²µ)R¢å*ZX¨šQ5"êT²Ê”¤ÉdÑKJYQ4¥R©eJT²¤¥¥-RZÒU(¸­JRL¤©EKK*’­,©ieRZ’Ô¥)R•-%R•*Yqbhµ”´•IZ’Õ)R¥–”¥©)2’–)&II’ɢʕ&QYKR”©U•,¥J«)j*Yh²¥V•¨µ%IeDÊRR¥JªUTµ)QiUJ¬’Ô”¥¤“IjJL¤µ¥¥¥•+)Q2”¨˜ª¢É•Y*¥e)iEÉe¢©JR¥)J‰•**R–’ªUU–\¤©eIJQieÊRÒR”¢Ô\YrTL©JRÔT¥”¨˜´²²Ò¥(™(µ\Z”¥KR©+K-%K%”–”™iIeIIeJÊ¢•)j•V¢–”µJ-J²U”©UK.)T²¥UU”–T\²´µEÄÅ¥R•,ªJÒ¬¥J´«J´¬´±kE,¥%-)UK-*IU)U*LZYUKJ”©R•JT¥)&J–*TT«QK)--QrÊ–R’–R¥,¸¥ÉU,©JZJ©eÅ‹R¤\¢âbÑ2¢eE©E¥*²’Ê•ZYRÒ¬¥”¬«,²T”¥)R‹’Z”R¨¥RZZZ”©e))UeK*Š.J-JK&’”¥¬Z¢¥¥*T\L©KR‹”Z¢eEe%KJ––-QKIU+KTªË*RËK,©JZ’Z’«)*T¥JZKRVJLZQ4YyU¥VYIjRÑe©)j,´¥JR–’–”\”¥)IjKR”¥*R¥)JR”¤µ(µEdÉdÉT¢Õ%ZJ¢–’¥–”µ%©--Dµ%©e*Qr•”¬¥K)JŠ–”¥”––\&T’«*-IR”¥JU*Yh¥¨€q¦beets-1.3.1/test/rsrc/partial.m4a0000644000076500000240000001334612013011113017537 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom ŠmoovlmvhdÄLé_ÄLï ¬D¸@trak\tkhdÄLé_ÄLï ¸@mdia mdhdÄLé_ÄLï ¬D¸UÄ"hdlrsounÓminfsmhd$dinfdref url —stblgstsdWmp4a¬D3esds€€€"€€€@xú€€€€€€stts.(stscÌstsz.22""'-+1/+&0''&%)(-,*).)(*%)-4&6.*,$,3/'& stcoÆé •udta meta"hdlrmdirappl°ˆilst©namdatapartialcpildatapgapdatatmpodata6©too.dataiTunes v7.6.2, QuickTime 7.4.5¼----meancom.apple.iTunesnameiTunSMPB„data 00000000 00000840 0000037C 000000000000AC44 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000¢----meancom.apple.iTunesnameiTunNORMjdata 00000000 00000000 00000000 00000000 00000228 00000000 00000008 00000000 00000187 00000000"©ARTdatathe artist!©albdatathe album trkndatadiskdata×freefree(mdatЃì\«11;Ä<Àøƒ¤4„ `Ifàe—^àïP0#Ü·›Áb˜Ä»VOØÙ(*¾J€q8òƒ„q©-@>Ťè[-3þ!‹rtg¼¼(ι¶q]b¥¸úƒÄPEÙ€€ ª°DãÙ‘>JUl3ËA$Ö)i)ƒÀúƒÄA#€+:3ï`Xa¶™˜ÉùQ;«À±˜ˆèàöƒ¢" „Î Ž× óf{q wZ“Ä 3z¿f#)NÙq'øƒ¢" „kËr½ÙÖ£ g§ý'”ê )y*¦˜Úö»,°Îµx¡úƒ¢" „r †BXVFìì7nhϦNž|z%ôe0Ue®«Œ œöƒ‚B„_`9åo¢ðJ­Dz0¯)òøáÛ1F8‘ú·åÉ>7t·#‹Uàøƒ¤4#ŒXÛõÞ™¾áàÒ`¸Ž‚Þ×xT†aGˆ~fäHv<ý¶ÁÀúƒÄ`? &ˆ×63Дl:äGàë’ 莵š„ÇÓëåN‚àúƒ¢R#Aש9Ï<³ÔiÇ%Öøê¦—µŠÍÎê®yfžÎôƒ‚T„ `®!I}uhnV$?‹+ä¡(Z«„Á¡Üta¥Vi±+±l‰8úƒÄ3#P0¼ñ•T`’3¹èžÓ#?}¥ÕõÚ»ìÔ‹€úƒ¤Q#Hl`µˆÁ½¥ËK§)Q)E‚ñõ>O²Âô¯SÔ¦úƒÆ"#P ÛdpËŸ¿Û2é­~sÛÈÓï'Pîì=Í&ü!úƒÄPG<Ž0NÝÍEø™_ƒõ'1…:õ˜‹å\øƒ¢ ˆ,l ƒq¼Ôº<¬>èðÍ&ïÞ¦J3}•]dQ€€8úƒ¢`F  8I+–¶:aá–;0¥Ç>m>;ßM¾íÛŸ× ¥/gøƒ¤p>€ÞbSci›”¤L z:~H5ƒM3©'°A+è&„f ‡Ö(pöƒ‚0!dÀ¢’¯:%¢C°9B³î+]þ3Z†¹ècJr†Â¶öƒ¤`E€a,]µ)\BiwÈ,yç@úD¸ùü'ħ¥¨Üøƒ¢ ˆÐÀ€0qóà=bžmBÅAwùH°×! šDSª 8öƒ‚5 !`Â4†§å^ñíª^:$9µÏ…gs`M%…ÊZZtª^U£Y(o€úƒ¢"ˆ¤09œ’#NÑ·#̬zÚW›;t A-1dyß”3¤^ àúƒ¢" "ìFâwì¼ú„,µ¸rŠþ¬js%”ŸÒísÙfžep=¼øƒ¤`G €@ÀãÒ5?à¼`•ØÉÍM²ÈöÆýYB^×;C€øƒ¢`BÈ ÆãiRø—‡iéŽ6ˆ0 9ü¾åÓvë…(ÜÿÿGYJ´…=˜¢oçlÇWøƒ¤3 BÐ3ÜQhLÖÆ$/ê‡b<÷~³µ8ëûÛΚS­n*jõXeÅ×7’#àôƒ‚"(„@Ø Ad¨*Â`óÙ'ሬÁ®co·>Vûça &µ% øƒ¤`F‚ ¢±äßxã}¸<ëÊüfP[2ÚqXä.Š'Iµpúƒ¢" „¤*ÓØšÖ'êŠÇÉR™z[oÝÓ¬ía‚„¾”XÇS2p'ÀøƒÆ2! éZðÞx²µ¾ùìú$©¿Vn´%j(óVÀöƒ¤`F‚€öŽF†@›c}}ðCÂ2>Û<ÆçO5£!¹QÞ/grDðúƒ¤aBàô‡2‰´S(^®Zdð{¦P¤Ÿ~‰ÙÛ˜¦‘6 Sºœöƒ¢ „,`¼-1wNÞAÀÍ”XPKh¡wKÛ#0R¬Y±X-Àøƒ¢`E "ÅImq¤>w¢È1s5{!ÁXº[¯/æÛ?ÓG¥xøƒ¢2"%€Xä  aÔž®ŽæÏû©ÆÏç¿÷ºr¯˜LaÀƒì\ªRi"?pƒì\¬ „ô@À\beets-1.3.1/test/rsrc/partial.mp30000644000076500000240000003102412013011113017546 0ustar asampsonstaff00000000000000ID34COMengiTunPGAP0TENiTunes v7.6.2COMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000COM‚engiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000TP1 the artistTAL the albumTRK2TPA4TT2 partialÿû`À F=íÉ‘A#ô‰¹ÿÿÿÿF0ÃßÿC ÿ_ùïÿÿÿÿóÏ£)çÚa”2ùŠz<‚žyõ=xù=O `ÐÊÇ  ¥àÆyé>`øÜá ÐÀXè" z)ãBœ"À$)‚ A„ ÿÿÿÿÿøÿ×?ÿûÿÿÿÿþ¾Ývb£×B¬¦ ³4Àã°²+¸µÂ¤Ír¹ÁÔR°Ä%;îR£Q[ÿÿÿïÖÜîÿ³¾Ú7UW6dTUT!ub8´Çø çèx?ñ.Ì€ˆˆˆˆÿÚÚ¢³<ŒaeŸDY/ÿÿÿ½ÿPœ¼·$D¬(躡ïPŒi§ÿû`À€Š9à… éâH#4•¸‹RÒAbÉuÏ&Ž ü:=ÄÝL´2!Ô¶#9îŽÔ#"5¿ÿÿÿú­ÿfE$ìȉ,ª1HËez²1’ª!ĨR ˜‰Ì§)¢((â¢,Êc°š)ÄÜX¢¬,qE„T9N@øˆtÿÿÒþ÷ÿ?ÿû`À ò= mÁ¸È#t¸¿ÿÿóËäܯðÉ÷IN¡æiÞÿ{ÞžYg§S—ÜëdE/'ÿÿÿÿÿÿÿçþ§Ì¸g Wëï&%ÍirÄDuñ6.ÝítÜ‘mtP&ê,Ž«¢’Duhâb¨D›ÀN±ÿÿÿÿÿý`Zÿý{ÿÿÿÿÿçÌímÞó¥/Þj¾GH„<&ñÎC¥ÿü„žl÷ÿÿÿÿÿÿÿýþÿçJÎÉP1•¥I’òÔä»ò9£3³ZHFÊ;ŽÔr/viƒÃ1  Ü‰…£2‡ ìÁÿÿÿÿÿÖõÿþyÿ?ÿÿùz\¨Û+dDΪ1ˆÆckTIw˜ÿûbÀ æ% „MɬH#t‰¸™¨…¶R²¶Í´öÿÿÿÿdÝꓳÐ÷1êr© ÊϳîÒ»³lÖ• lΆmC‘Uê÷c,ì$(tÈ4BKÿÿÿÿÿëåý²ëZSÿÿÿÿü¹QRÅ­ÝŒ‡>³Ìl®®è§1Œ¨ˆËf:V¨m&¾åDGB;?ÿÿÿÿÿù­RÌé+ƒ9‡}•+ϳ+±ÍUÐèÊbPSú;1Œ%Ü; Š…R•Ìwt”€Î9Bÿÿÿÿþ¶ƒÿëÿ•ÿÿÿÿäìúRwó5lª„s>ʸõ#ÌäºuFlëÐêɕћÿÿÿÚÿ³«ÔªFJȪyŒEus–ÿû`À" ? „­Á¬Æ#t•¹a2ft*3άVwVl̸â•êc@@Ç‚o2ÇåQÇ;> ¢£€?ÿÿõsÿÿÿÿÿü»µ®å:=¨îïv:ºº•VB/Îlêèö-ÊZ$Û™5OÿÿÿþÍ'*MD£'» È.–z¹›S«”ÌêW!„Ça溡Z!3#NǞ#!ƒ¢âcH5VA¸ò¨`†`±óõùþ¿ÿÿÿÿ®•g;RWTÙUÕ½ÔÝQeÑÑTľÎB%OÿÿÿõùÑgêÕ9F F‘T‡j9"Î(cJb#!ÇxˆÕ3³ ªEÿû`À, Ú= „­É¯È#4‰¸vcœqL8áaàÊPq¢Ñ2$48ÿÿÿÿþÖ¯uÿºÿÿÿÿÿ?£‘Ý”„5›Rl›ogGÐŒªöi7ºÕŽ·ìKwÿÿÿÿÿÚÒ"1Ý_™Ü§8¤T„Dgä;YƒœFä%PìÆ \ÈäRŠF €‚äS3: ÁÈQBªà•Ü(ÿÿÿÿþÍ—ÿ/ü¡ßŸõÿÿÿo‘œíbnYØz(‰‘ëu×J:ä·Id£ïZÐÔÙÿÿÿÿÿþŸJ'»Jƒ Ff®ï©Ne+ È 8ä*&*†q2 0Ì AŠAD…0’Q!2ª”¥A§aQEÿû`À6€ ¶A „­Á¬E£t¹0ÿÿÿý°/ó׿ü¿ÿÿÿÿò]s¹œÐô… 仪Dþ9FzÓ¦[7‘t¥+M¹2ä³ÿÿÿÿÿÿÿéÿ9æÓ*FE‘D‰»ë2³×r™9“Ò·I­|¼UèÊã±Áz°spÂ+† JÛØ8ÿÿÿÿþ©ü¼ògRfÒÏåÿÿýþ•$íc1üý§º!Yz:d÷#ê×*º%Pú?{¯ÿÿÿÿÿèɦ·5\ÎR£¾ì‚ŠTv{˜Ä$A™ RkÑi†ˆ!£±ÅÊÖ(|¨rˆ˜pðé¢î<ÂÁÀ+)h0À5"ü¿ÿÿÿÿÿdîÿûbÀBVA „­ÁÄÇ£4•¹F±:¼Û>yê­)Ýì¬åºQò²+7cfTÿÿÿëÙ[óÉW£•hr¿ÿÿÿÿiO‰&׊aÒ¶§ö’ѺkL“ÔW0÷?ÜpµpÝÿÿÿÿÿÿÿ1ÿ×ýúÚuz÷¤sò8ë›~9çˆ^;Ž’˜…ìar:¦hZ—Ro|†AwÚÖìXòo®Ü**>@Ö0ÿÿÿÿÿëËWuûJFÑYìîåˆòYÙræ\ˆîjí»*"±Hßÿÿÿÿ~î‰m¨—g*:˜…+"ofi•ÞÂ#E ¨²)JçWACê,$aqÊÇ)„€p1Œ"Pè‘ÅH‡(Ñ¡`8ÿûbÀo€5 … ɽH"Ì•¸3èòÿ¯ÿÿÿÖ×Ýzè¼ì×»Èr/È¢ Ê®”d£3Ñ—yN¤qÿÿÿ÷ä{"Zåu#ÌeR ¸ñŽÄsî®e-ESi‘pÕ( ›0“„bŽ3‡Àãs†yNEr”ÓŠˆ¨€ ŒÀÿÿÿÿÿíyïÿéßÿÿÿýz.¥1,o5ä+9·Ežuvf±Îõ¹v}ÈÈ–NÿÿÿÿDû}l³µ+3UTŠCo¼¥>%èOyS!XŒ‡‘Æi;\ê8#)Ń:‰cÅ•Êt(P‘À?ÿÿÿÒÿùÿóÿÿÿÿù´š÷fÿû`Àx 9 ­Ù¤G£t‰¹b$„vºftgdû©kU-™œèƤÆVäfÛÿÿÿÿìŸêR>Ó­ÕK5(i5; Îñ“•ÑŠwiÇ*¹ˆ¤"âŠ*.*1â¦c´x´çRH@“ˆ#•bÀÿÿÿö@¿ÿ-»<ŽÄjµ=]ÖÓTª¤®Æ96Kä<îäsn³¼ó—ÿÿÿýv"Ñõ{Ñ]LëgRg©ªF5ŽFuõZÇQ3QÓ ‡Ç°›”a‡¨Ô ˜µÌ.tP‰GQCå<ÊÿÿÿÿÖþ]ÿþ«¯ÿÿÿþÞˆy‘žr3ÚÛg§Ÿ¤m4U++!d“Ö¶_J´Í{ÿû`Àƒ nA „­ÁªG£4¹ÿÿÿÿÿÿÿÿöù7ÈqyiHsC0¤Er_?* –gÐNP‰îF¸v#UCªˆ9ŽB!* jèA‡1Y(0ÿÿÿÿý`_óòþ_ÿÿÿÿüßíÑžïzJ_¹K&´išôE3Šeu!&>ˆˆþFÿÿÿÿéÌý<ˆs±Q#•ÞcÚï39’Àœ“Ùœ[%ÜÑÝÄÇ8s¸Îd†; áXä …C0­ƒÿùZõºVýi ­b¹*ªOf3U§,ÍD=®E%^ÝËÿÿÿÿóHêí³=Uή¯­]™žØâ®ª9ÕŒfd#9ÿû`À òA „mÁžÈ#t‰¸ÑÔ¤C»Tˆpø…Å”HƒbÈ s‘ E,*Qÿÿÿÿÿý`9oËêkÿýýÿÿÿÎë"åE§KQîÖÝJî·<†aG TGȪW›F}]iÿÿÿÿêÞªú#f«¢Ì¨ÅUTH̵Eç̪·;f¤€ÜâÀÙÑÕN]Dœr¬¨qNÈaÝ(ÿÿÿþ²ûçþ}~_]ÿÿëïôìÌÇûèw.šQÞrØÌÌäb-lzèÒÒšÿÿÿÿßé­ÝíSUÐæg«DL†îBÓœxÝ ¶QE§™ˆc±îD €Ñ3‡PƒÃ¡ÿûbÀœ 2A €­Á²H#4‰¸$)Ÿ1Qd‚ £ýÿçÿÿÿÿýÖþ—tÍT!˜¦ÈwS5îêëfºÐÅ*”lý؈ŠßÿÿÿÿôºµYÊÊKžÓÊÄB²¢Ïvj±•QXÌB *”a”£¤q3”Á7 áº¡b±Îƒ!¬k **6Hÿÿÿÿ¶ü¿¿Ëå_ÿòùß%é,«¿uÜöeæBÊìöJ¹Mwº®í­]Õr«7ÿÿÿû¢Ú®d+Tµ#!•ˆDeœ‡æ)Øì…F•®q`³QËÊ·K ‡!:ÁÎÂÃur‹C˜®àÿÿÿÿú@¾_âüÿû`À©€ ž; „­ÉÁG#4•¹¯ÿÿÿÿçïÿ%2ó¥xzžõËa©—ÝßÈͶMŸšÃ™);þe­Ûÿÿÿÿÿÿÿÿù~~KrS¬[uvzŸzYq¢ñ òlQwÑß#}¨\‰jZ |‹T ¸Ä!ÌéÀÀàÿÿÿÿÿŸž Øç —Ne9Ÿÿÿç eR!áçÖVê¢õœ}!wÂOí3UUÚZWU>ÑÿÜWÿÿÿÿÿÿÿÿ?íñó÷+kLÖÃùŽ*Øëk‘s*—gJ4`ž¹EÆÇviF"‡$˜z©†9‚XõÂaË”9pèP·XT]Çÿÿÿÿÿc`qîyÿû`À²€ .A „MÁ­G£t¹‰#±’¿%¾§ÿþY¸tûîú㙬ꙉy¥˜†ýwŠõÚ©¦÷ÞÒ¥©S»éºæÿÿÿÿÿÿÿþÿ¿ÿû¿Ž¹šk¯«YO›­²<6<’±…&TÉ눢Æ!wB±E2ˆ ¸ö³J‡4\V…K1`°’ކ6Ëq1Š@ÿÿÿÿý±ƒÍ#˜Wå¿Ì«ÿÿÿý:*\Ȫuo™H(έb+JB{1¨¦î·±•jjÿÿÿþ7¥®·tT2+9#ÝÖ<–ºœÈ©*­ ayŒQ1§3•Ú&=¥B:``ë‘‚¢§Š ‰Zpð[JQÿû`ÀÀŠA … ÂÈ"´¡¸ €k?ýþ½ÿÿÿÿÿþÕdR•¨É¿WDJ2oR›±ÎÓêUd©Šf3¦Óo5ŸÿÿÿÿòKk­Ê„y™bêìêt3–S’È5‹Ž «,† 1 áòŽrJâŠ0¢„#(@Xêè$,âŠ8Š4xD*…„ÿÿÿÿÿ’Ëäû×ÿÿÿÿûνey5v}úº*±K÷U2‘ÖgR™–tK"µoEÿÿÿÿÿéîíLÊŒE:2+Ù’}Hî8†fc ‹³)†ÅÌîìBˆaÇds¸“!…„XPr˜xñÊ,Š($0<*5 †4lüÿû`À³€²A „­ÁÛH"ô•¸Ö|ÿÿÿÿÿÿôM‘Û[VÛ²NI”¬ÚÖçbk3›ß¢ªÕ‘Z~¿ÿÿÿõ¡j‡»µ¶g*•Ø”ssB«¥r”¬QGª‰ ”„)å1˜0‚ädÆw<)]Ì($aE8pj‡ÐhÀ³˜À¢¦‰€ÿÿÿÿþßüÿF—=ÆÚþ¿ÿùå®›±÷õÌk*"§f5§¢*YÕC^bºê§»LÜÕ²ÿÿÿÿÿëî‰wcª¦UÎ<Ê;0Ô=QÖ«žŽ¤:³ó¿ÿÿÿÏé¹&q…+T©µ'ÙTU•ŒgeR á‡B32)—ýž·D|ïÿÿÿÿÓ]«Mɺ+YŒ[Ö¦0Ò)ÊbŽ¥Ž5EQ„ÄEa!˜\h˜AÂ!ذ ³BÃîg À(ñá‚ÂC &P 8|DV&Qÿÿÿÿÿû`À´€ÂA „­Á×Ç£4•¹ÿéä^¤–ú,‹ÿŸÿÿžÒ÷f»‘×d[Òº½ V¹Lës ws)P§z^¬t252]koÿÿÿï§£÷]»ÍÊS2•gsƙٞDkaœÔ9Ç#˜Â"%F âƒDL*£¢ (B" ‡XÂŽAìb¼>ÿÿÿÿÿ±…åþY//ÿÿÿüæ©J$î”Y?RºÑôVT}ÞŠrQKjßZmu}vÿÿÿþ«ëzttz%¦TS•1ä#ÌÆ½j=‘Òª”ªQ!0c ÓH,¢å £ÄÄÊ,¡Ñw‡È 8ç1Ðå†0¸ÐÛ ÃjÐöÿûbÀ¶€A ­ÑÙÈ"ô•¸}s—ÿþ¿ÿÿÄ@÷:XÈ®M*¨Cc¹§r”ü­#çDQz­ªý§µ)ÿÿÿÿýþ–ÑNbç›-LQl„0׳ )Ük ;˜Â,5ܣŃ‹;"Š 8ЦA¨$ÁÑqÆ\M…EAB@Áç( *ÿÿÿþÿ-ã4Ñ—pþRÿÿÿ³¢îVlìU·K&öu+<Ï™‡tLììv3¡ŠÔ³I{×ÿÿÿÿúûû•YÑ ¥*«“êçªîR†Ð<Ž=ÇÔLU…aÅqáL (sˆ”ñ¨§D4P€È‚Ä !˜€8ÿÿÿÿÿû`À·€’A „­ÁñÈ"´•¸ÿìŒ w"šçÏ’–«ÿÿþMÿÇýwóó¯÷Üu]fFÌÕH÷SR‰õô±×K¯×ÿÿÿÿÿÿÿÿÿÏw§qóßLò°»O¥O3r²1GÔ­mÍ»±å;=šSÊ8y£2lÏ#•0ÑÄÃHˆpò ¢¥% « €ÿÿÿÿÿ²ŸÏ_åËzÿÿÿþþŠŒ÷Z2>ô±• ŒÌ]ªC\È„VGGuyÕ(Êr¢J´ÿÿÿÿû[BUÝŸaj«ö£[‹Õ™HŠèãLRˆ˜«yŒ‡œ¬C%Üq]Qbì->A!Yâ†`£1\Xâ@`Ò y?ÿû`À·6A „­ÁâÈ"ô¡¸—#ÿÿÿÿÊ­ä&ʪËÕèêÊ}V¤1Ö³Fnn¹:û[¯ÿÿÿëmjÌës¡ÒÕF!ŠbŠê®Æ)˜†‹‚*‘LŒcˆ°j¡¬*‰¢ï0p¥>$av!œXè4DHÁÁ5‡ÅĨU(p@1@ÿÿÿþ­ƒüŒÿ¥ÔËïëÿÿÿ7êˆìµR!Ÿ#£µŠè¨~ܦf5æfb1ÞŽÝS"ÛÿÿÿþßèÍiš£P®vJÖ®d1qèq”2ÚÈ0:Å+‘Tƒ6aÂbEl?2‡DD…‡‡\M"B £¢† ”Ácˆ4\6à ÿû`Àµ€.A „­ÁçÈ"´•¸´ý¹~öÿÿÿþT«Û"£—ÜÇž—ÑsÙÑhê®DÎmYd½îdSnÛÿÿÿÿéí¾­:ª3š§š•c%®5HQU<§!ÅEHcDH@8â(‡E`=‡(¹Ã§qR¸²:$&<(j¹‹ÆŠ¸  ˆhˆØ6­ƒóæ¿ÿÿÿÿ#í%)sMªØí1ærÚì·IUkwG‘rîGtTe÷Ñ—ÿÿÿÿÞÕêÛ++Jζ%ƱÞÈÄÌ<‹;‘XÇ8qˆ‚:‹ Aa6qe- @ÊAA¦`‘Dˆa"ÄÆĘ„@ð™”:?ÿÿÿÿý$ÿû`À·€A „­ÁïÈ"´•¸/þN_¬™ôëÿÿä ÷Gõ>¾J¥Ó:²—G4¸±Q(„tC\î{+©•®gOÿÿÿÿ쮕½Ôˆ³!O<®ë!¨Žd,ŠæR1>‚%f0°¬@ê>qâ¡ô (¨˜åœ4ÖQ!0ø€ºX‚eQC€ÿÿÿÿÿÒäþgì?ÿÿÿÿÿ-‘œ¦ŽI"*ÌD£™•̧Y§In–jH~­’Vkg»T®Š¬‹ÿÿÿÿ•úÑÌŽäRËÕ®¬–K¦í[ÝF"Ψpá®ås ƒ”<îƒáC‡A r$ò 0ˆ×R‹ŽÌWˆ€ÿÿÿÿÛÿÿûbÀµ^A ­ÁéÇâô•¸½ÿÿß›ÿÿü¿->ùŞó/ß&@ºÒzšºË)¢Ô-^Ÿð¼Ž½Ïmûÿÿÿÿÿÿÿÿå‘ßæe ·îV›E‚˜6Þõ$4C¹»¼­dPÀŽŠ$B¬ŽAÅ£E "£,†8SW©9 € €`5‘çÿËækúëÿÿòÿZšçTC£ú^5Ýõ‘œ¨bT©‘Üz-®–™=¤½ŸÿÿÿýiCž”èËB)©au”‚o,ÑÈ8‡c‡ ""@”AÐD¥*<:*‡ °x:@ùDEEH8:.Œ…Ї†ÿÿÿÿ¶ü¿ùëïÿÿÿÿ©ÿÿ–ŽÍ­îGH¢’°»NÞ±™ÄW%²zÕóØøDo“eÞÿÿÿÿÿÿÿþ_ü?)¹leyX†¿ç)ΜzR̈Ÿ"]Ë•L´'dxÈÐtS 3j±Û é àÿÿÿÿÿÀ~˜dsÿû`À½€>A … ÂH"´•¸æ½ ÿ#ÿÿÿ˲µÐÕc0‰&V‰ZâBÅrÌdpëFEyFˆm ¥*³¬Î¾ë×ÿÿÿÿÿ¿¿mYÊŠVÊÆ}LêÆ+>j=‚¨,å-*UcÅB²= QSÎPèÑÎ"ÊY˜H  ÿÿÿÿÿÿÿÿÿÿÿÿ¯ÿÿÿÿÿ¯ÿý~Æ(`JEª*!ÙÙÊbª”Vs ` #³±Š©ÿ³”ÁB(ÿû`À³€ n? „mÁÑF¢ô•¹ÿûbÀ»€&@ÞMÀ[Àÿû`Àÿ€ÞÀbeets-1.3.1/test/rsrc/space_time.mp30000644000076500000240000003102412013011113020223 0ustar asampsonstaff00000000000000ID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC2009-09-04T14:20TCON the genreCOMMhengiTunNORM 80000000 00000000 00000000 00000000 00000224 00000000 00000008 00000000 000003AC 00000000TCMP1TENCiTunes v7.6.2COMMengthe commentsTBPM6TIT1the groupingCOMMengiTunPGAP0COMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000USLTengthe lyricsTCOMthe composerTPE2the album artistÿû`À F=íÉ‘A#ô‰¹ÿÿÿÿF0ÃßÿC ÿ_ùïÿÿÿÿóÏ£)çÚa”2ùŠz<‚žyõ=xù=O `ÐÊÇ  ¥àÆyé>`øÜá ÐÀXè" z)ãBœ"À$)‚ A„ ÿÿÿÿÿøÿ×?ÿûÿÿÿÿþ¾Ývb£×B¬¦ ³4Àã°²+¸µÂ¤Ír¹ÁÔR°Ä%;îR£Q[ÿÿÿïÖÜîÿ³¾Ú7UW6dTUT!ub8´Çø çèx?ñ.Ì€ˆˆˆˆÿÚÚ¢³<ŒaeŸDY/ÿÿÿ½ÿPœ¼·$D¬(躡ïPŒi§ÿû`À€Š9à… éâH#4•¸‹RÒAbÉuÏ&Ž ü:=ÄÝL´2!Ô¶#9îŽÔ#"5¿ÿÿÿú­ÿfE$ìȉ,ª1HËez²1’ª!ĨR ˜‰Ì§)¢((â¢,Êc°š)ÄÜX¢¬,qE„T9N@øˆtÿÿÒþ÷ÿ?ÿû`À ò= mÁ¸È#t¸¿ÿÿóËäܯðÉ÷IN¡æiÞÿ{ÞžYg§S—ÜëdE/'ÿÿÿÿÿÿÿçþ§Ì¸g Wëï&%ÍirÄDuñ6.ÝítÜ‘mtP&ê,Ž«¢’Duhâb¨D›ÀN±ÿÿÿÿÿý`Zÿý{ÿÿÿÿÿçÌímÞó¥/Þj¾GH„<&ñÎC¥ÿü„žl÷ÿÿÿÿÿÿÿýþÿçJÎÉP1•¥I’òÔä»ò9£3³ZHFÊ;ŽÔr/viƒÃ1  Ü‰…£2‡ ìÁÿÿÿÿÿÖõÿþyÿ?ÿÿùz\¨Û+dDΪ1ˆÆckTIw˜ÿûbÀ æ% „MɬH#t‰¸™¨…¶R²¶Í´öÿÿÿÿdÝꓳÐ÷1êr© ÊϳîÒ»³lÖ• lΆmC‘Uê÷c,ì$(tÈ4BKÿÿÿÿÿëåý²ëZSÿÿÿÿü¹QRÅ­ÝŒ‡>³Ìl®®è§1Œ¨ˆËf:V¨m&¾åDGB;?ÿÿÿÿÿù­RÌé+ƒ9‡}•+ϳ+±ÍUÐèÊbPSú;1Œ%Ü; Š…R•Ìwt”€Î9Bÿÿÿÿþ¶ƒÿëÿ•ÿÿÿÿäìúRwó5lª„s>ʸõ#ÌäºuFlëÐêɕћÿÿÿÚÿ³«ÔªFJȪyŒEus–ÿû`À" ? „­Á¬Æ#t•¹a2ft*3άVwVl̸â•êc@@Ç‚o2ÇåQÇ;> ¢£€?ÿÿõsÿÿÿÿÿü»µ®å:=¨îïv:ºº•VB/Îlêèö-ÊZ$Û™5OÿÿÿþÍ'*MD£'» È.–z¹›S«”ÌêW!„Ça溡Z!3#NǞ#!ƒ¢âcH5VA¸ò¨`†`±óõùþ¿ÿÿÿÿ®•g;RWTÙUÕ½ÔÝQeÑÑTľÎB%OÿÿÿõùÑgêÕ9F F‘T‡j9"Î(cJb#!ÇxˆÕ3³ ªEÿû`À, Ú= „­É¯È#4‰¸vcœqL8áaàÊPq¢Ñ2$48ÿÿÿÿþÖ¯uÿºÿÿÿÿÿ?£‘Ý”„5›Rl›ogGÐŒªöi7ºÕŽ·ìKwÿÿÿÿÿÚÒ"1Ý_™Ü§8¤T„Dgä;YƒœFä%PìÆ \ÈäRŠF €‚äS3: ÁÈQBªà•Ü(ÿÿÿÿþÍ—ÿ/ü¡ßŸõÿÿÿo‘œíbnYØz(‰‘ëu×J:ä·Id£ïZÐÔÙÿÿÿÿÿþŸJ'»Jƒ Ff®ï©Ne+ È 8ä*&*†q2 0Ì AŠAD…0’Q!2ª”¥A§aQEÿû`À6€ ¶A „­Á¬E£t¹0ÿÿÿý°/ó׿ü¿ÿÿÿÿò]s¹œÐô… 仪Dþ9FzÓ¦[7‘t¥+M¹2ä³ÿÿÿÿÿÿÿéÿ9æÓ*FE‘D‰»ë2³×r™9“Ò·I­|¼UèÊã±Áz°spÂ+† JÛØ8ÿÿÿÿþ©ü¼ògRfÒÏåÿÿýþ•$íc1üý§º!Yz:d÷#ê×*º%Pú?{¯ÿÿÿÿÿèɦ·5\ÎR£¾ì‚ŠTv{˜Ä$A™ RkÑi†ˆ!£±ÅÊÖ(|¨rˆ˜pðé¢î<ÂÁÀ+)h0À5"ü¿ÿÿÿÿÿdîÿûbÀBVA „­ÁÄÇ£4•¹F±:¼Û>yê­)Ýì¬åºQò²+7cfTÿÿÿëÙ[óÉW£•hr¿ÿÿÿÿiO‰&׊aÒ¶§ö’ѺkL“ÔW0÷?ÜpµpÝÿÿÿÿÿÿÿ1ÿ×ýúÚuz÷¤sò8ë›~9çˆ^;Ž’˜…ìar:¦hZ—Ro|†AwÚÖìXòo®Ü**>@Ö0ÿÿÿÿÿëËWuûJFÑYìîåˆòYÙræ\ˆîjí»*"±Hßÿÿÿÿ~î‰m¨—g*:˜…+"ofi•ÞÂ#E ¨²)JçWACê,$aqÊÇ)„€p1Œ"Pè‘ÅH‡(Ñ¡`8ÿûbÀo€5 … ɽH"Ì•¸3èòÿ¯ÿÿÿÖ×Ýzè¼ì×»Èr/È¢ Ê®”d£3Ñ—yN¤qÿÿÿ÷ä{"Zåu#ÌeR ¸ñŽÄsî®e-ESi‘pÕ( ›0“„bŽ3‡Àãs†yNEr”ÓŠˆ¨€ ŒÀÿÿÿÿÿíyïÿéßÿÿÿýz.¥1,o5ä+9·Ežuvf±Îõ¹v}ÈÈ–NÿÿÿÿDû}l³µ+3UTŠCo¼¥>%èOyS!XŒ‡‘Æi;\ê8#)Ń:‰cÅ•Êt(P‘À?ÿÿÿÒÿùÿóÿÿÿÿù´š÷fÿû`Àx 9 ­Ù¤G£t‰¹b$„vºftgdû©kU-™œèƤÆVäfÛÿÿÿÿìŸêR>Ó­ÕK5(i5; Îñ“•ÑŠwiÇ*¹ˆ¤"âŠ*.*1â¦c´x´çRH@“ˆ#•bÀÿÿÿö@¿ÿ-»<ŽÄjµ=]ÖÓTª¤®Æ96Kä<îäsn³¼ó—ÿÿÿýv"Ñõ{Ñ]LëgRg©ªF5ŽFuõZÇQ3QÓ ‡Ç°›”a‡¨Ô ˜µÌ.tP‰GQCå<ÊÿÿÿÿÖþ]ÿþ«¯ÿÿÿþÞˆy‘žr3ÚÛg§Ÿ¤m4U++!d“Ö¶_J´Í{ÿû`Àƒ nA „­ÁªG£4¹ÿÿÿÿÿÿÿÿöù7ÈqyiHsC0¤Er_?* –gÐNP‰îF¸v#UCªˆ9ŽB!* jèA‡1Y(0ÿÿÿÿý`_óòþ_ÿÿÿÿüßíÑžïzJ_¹K&´išôE3Šeu!&>ˆˆþFÿÿÿÿéÌý<ˆs±Q#•ÞcÚï39’Àœ“Ùœ[%ÜÑÝÄÇ8s¸Îd†; áXä …C0­ƒÿùZõºVýi ­b¹*ªOf3U§,ÍD=®E%^ÝËÿÿÿÿóHêí³=Uή¯­]™žØâ®ª9ÕŒfd#9ÿû`À òA „mÁžÈ#t‰¸ÑÔ¤C»Tˆpø…Å”HƒbÈ s‘ E,*Qÿÿÿÿÿý`9oËêkÿýýÿÿÿÎë"åE§KQîÖÝJî·<†aG TGȪW›F}]iÿÿÿÿêÞªú#f«¢Ì¨ÅUTH̵Eç̪·;f¤€ÜâÀÙÑÕN]Dœr¬¨qNÈaÝ(ÿÿÿþ²ûçþ}~_]ÿÿëïôìÌÇûèw.šQÞrØÌÌäb-lzèÒÒšÿÿÿÿßé­ÝíSUÐæg«DL†îBÓœxÝ ¶QE§™ˆc±îD €Ñ3‡PƒÃ¡ÿûbÀœ 2A €­Á²H#4‰¸$)Ÿ1Qd‚ £ýÿçÿÿÿÿýÖþ—tÍT!˜¦ÈwS5îêëfºÐÅ*”lý؈ŠßÿÿÿÿôºµYÊÊKžÓÊÄB²¢Ïvj±•QXÌB *”a”£¤q3”Á7 áº¡b±Îƒ!¬k **6Hÿÿÿÿ¶ü¿¿Ëå_ÿòùß%é,«¿uÜöeæBÊìöJ¹Mwº®í­]Õr«7ÿÿÿû¢Ú®d+Tµ#!•ˆDeœ‡æ)Øì…F•®q`³QËÊ·K ‡!:ÁÎÂÃur‹C˜®àÿÿÿÿú@¾_âüÿû`À©€ ž; „­ÉÁG#4•¹¯ÿÿÿÿçïÿ%2ó¥xzžõËa©—ÝßÈͶMŸšÃ™);þe­Ûÿÿÿÿÿÿÿÿù~~KrS¬[uvzŸzYq¢ñ òlQwÑß#}¨\‰jZ |‹T ¸Ä!ÌéÀÀàÿÿÿÿÿŸž Øç —Ne9Ÿÿÿç eR!áçÖVê¢õœ}!wÂOí3UUÚZWU>ÑÿÜWÿÿÿÿÿÿÿÿ?íñó÷+kLÖÃùŽ*Øëk‘s*—gJ4`ž¹EÆÇviF"‡$˜z©†9‚XõÂaË”9pèP·XT]Çÿÿÿÿÿc`qîyÿû`À²€ .A „MÁ­G£t¹‰#±’¿%¾§ÿþY¸tûîú㙬ꙉy¥˜†ýwŠõÚ©¦÷ÞÒ¥©S»éºæÿÿÿÿÿÿÿþÿ¿ÿû¿Ž¹šk¯«YO›­²<6<’±…&TÉ눢Æ!wB±E2ˆ ¸ö³J‡4\V…K1`°’ކ6Ëq1Š@ÿÿÿÿý±ƒÍ#˜Wå¿Ì«ÿÿÿý:*\Ȫuo™H(έb+JB{1¨¦î·±•jjÿÿÿþ7¥®·tT2+9#ÝÖ<–ºœÈ©*­ ayŒQ1§3•Ú&=¥B:``ë‘‚¢§Š ‰Zpð[JQÿû`ÀÀŠA … ÂÈ"´¡¸ €k?ýþ½ÿÿÿÿÿþÕdR•¨É¿WDJ2oR›±ÎÓêUd©Šf3¦Óo5ŸÿÿÿÿòKk­Ê„y™bêìêt3–S’È5‹Ž «,† 1 áòŽrJâŠ0¢„#(@Xêè$,âŠ8Š4xD*…„ÿÿÿÿÿ’Ëäû×ÿÿÿÿûνey5v}úº*±K÷U2‘ÖgR™–tK"µoEÿÿÿÿÿéîíLÊŒE:2+Ù’}Hî8†fc ‹³)†ÅÌîìBˆaÇds¸“!…„XPr˜xñÊ,Š($0<*5 †4lüÿû`À³€²A „­ÁÛH"ô•¸Ö|ÿÿÿÿÿÿôM‘Û[VÛ²NI”¬ÚÖçbk3›ß¢ªÕ‘Z~¿ÿÿÿõ¡j‡»µ¶g*•Ø”ssB«¥r”¬QGª‰ ”„)å1˜0‚ädÆw<)]Ì($aE8pj‡ÐhÀ³˜À¢¦‰€ÿÿÿÿþßüÿF—=ÆÚþ¿ÿùå®›±÷õÌk*"§f5§¢*YÕC^bºê§»LÜÕ²ÿÿÿÿÿëî‰wcª¦UÎ<Ê;0Ô=QÖ«žŽ¤:³ó¿ÿÿÿÏé¹&q…+T©µ'ÙTU•ŒgeR á‡B32)—ýž·D|ïÿÿÿÿÓ]«Mɺ+YŒ[Ö¦0Ò)ÊbŽ¥Ž5EQ„ÄEa!˜\h˜AÂ!ذ ³BÃîg À(ñá‚ÂC &P 8|DV&Qÿÿÿÿÿû`À´€ÂA „­Á×Ç£4•¹ÿéä^¤–ú,‹ÿŸÿÿžÒ÷f»‘×d[Òº½ V¹Lës ws)P§z^¬t252]koÿÿÿï§£÷]»ÍÊS2•gsƙٞDkaœÔ9Ç#˜Â"%F âƒDL*£¢ (B" ‡XÂŽAìb¼>ÿÿÿÿÿ±…åþY//ÿÿÿüæ©J$î”Y?RºÑôVT}ÞŠrQKjßZmu}vÿÿÿþ«ëzttz%¦TS•1ä#ÌÆ½j=‘Òª”ªQ!0c ÓH,¢å £ÄÄÊ,¡Ñw‡È 8ç1Ðå†0¸ÐÛ ÃjÐöÿûbÀ¶€A ­ÑÙÈ"ô•¸}s—ÿþ¿ÿÿÄ@÷:XÈ®M*¨Cc¹§r”ü­#çDQz­ªý§µ)ÿÿÿÿýþ–ÑNbç›-LQl„0׳ )Ük ;˜Â,5ܣŃ‹;"Š 8ЦA¨$ÁÑqÆ\M…EAB@Áç( *ÿÿÿþÿ-ã4Ñ—pþRÿÿÿ³¢îVlìU·K&öu+<Ï™‡tLììv3¡ŠÔ³I{×ÿÿÿÿúûû•YÑ ¥*«“êçªîR†Ð<Ž=ÇÔLU…aÅqáL (sˆ”ñ¨§D4P€È‚Ä !˜€8ÿÿÿÿÿû`À·€’A „­ÁñÈ"´•¸ÿìŒ w"šçÏ’–«ÿÿþMÿÇýwóó¯÷Üu]fFÌÕH÷SR‰õô±×K¯×ÿÿÿÿÿÿÿÿÿÏw§qóßLò°»O¥O3r²1GÔ­mÍ»±å;=šSÊ8y£2lÏ#•0ÑÄÃHˆpò ¢¥% « €ÿÿÿÿÿ²ŸÏ_åËzÿÿÿþþŠŒ÷Z2>ô±• ŒÌ]ªC\È„VGGuyÕ(Êr¢J´ÿÿÿÿû[BUÝŸaj«ö£[‹Õ™HŠèãLRˆ˜«yŒ‡œ¬C%Üq]Qbì->A!Yâ†`£1\Xâ@`Ò y?ÿû`À·6A „­ÁâÈ"ô¡¸—#ÿÿÿÿÊ­ä&ʪËÕèêÊ}V¤1Ö³Fnn¹:û[¯ÿÿÿëmjÌës¡ÒÕF!ŠbŠê®Æ)˜†‹‚*‘LŒcˆ°j¡¬*‰¢ï0p¥>$av!œXè4DHÁÁ5‡ÅĨU(p@1@ÿÿÿþ­ƒüŒÿ¥ÔËïëÿÿÿ7êˆìµR!Ÿ#£µŠè¨~ܦf5æfb1ÞŽÝS"ÛÿÿÿþßèÍiš£P®vJÖ®d1qèq”2ÚÈ0:Å+‘Tƒ6aÂbEl?2‡DD…‡‡\M"B £¢† ”Ácˆ4\6à ÿû`Àµ€.A „­ÁçÈ"´•¸´ý¹~öÿÿÿþT«Û"£—ÜÇž—ÑsÙÑhê®DÎmYd½îdSnÛÿÿÿÿéí¾­:ª3š§š•c%®5HQU<§!ÅEHcDH@8â(‡E`=‡(¹Ã§qR¸²:$&<(j¹‹ÆŠ¸  ˆhˆØ6­ƒóæ¿ÿÿÿÿ#í%)sMªØí1ærÚì·IUkwG‘rîGtTe÷Ñ—ÿÿÿÿÞÕêÛ++Jζ%ƱÞÈÄÌ<‹;‘XÇ8qˆ‚:‹ Aa6qe- @ÊAA¦`‘Dˆa"ÄÆĘ„@ð™”:?ÿÿÿÿý$ÿû`À·€A „­ÁïÈ"´•¸/þN_¬™ôëÿÿä ÷Gõ>¾J¥Ó:²—G4¸±Q(„tC\î{+©•®gOÿÿÿÿ쮕½Ôˆ³!O<®ë!¨Žd,ŠæR1>‚%f0°¬@ê>qâ¡ô (¨˜åœ4ÖQ!0ø€ºX‚eQC€ÿÿÿÿÿÒäþgì?ÿÿÿÿÿ-‘œ¦ŽI"*ÌD£™•̧Y§In–jH~­’Vkg»T®Š¬‹ÿÿÿÿ•úÑÌŽäRËÕ®¬–K¦í[ÝF"Ψpá®ås ƒ”<îƒáC‡A r$ò 0ˆ×R‹ŽÌWˆ€ÿÿÿÿÛÿÿûbÀµ^A ­ÁéÇâô•¸½ÿÿß›ÿÿü¿->ùŞó/ß&@ºÒzšºË)¢Ô-^Ÿð¼Ž½Ïmûÿÿÿÿÿÿÿÿå‘ßæe ·îV›E‚˜6Þõ$4C¹»¼­dPÀŽŠ$B¬ŽAÅ£E "£,†8SW©9 € €`5‘çÿËækúëÿÿòÿZšçTC£ú^5Ýõ‘œ¨bT©‘Üz-®–™=¤½ŸÿÿÿýiCž”èËB)©au”‚o,ÑÈ8‡c‡ ""@”AÐD¥*<:*‡ °x:@ùDEEH8:.Œ…Ї†ÿÿÿÿ¶ü¿ùëïÿÿÿÿ©ÿÿ–ŽÍ­îGH¢’°»NÞ±™ÄW%²zÕóØøDo“eÞÿÿÿÿÿÿÿþ_ü?)¹leyX†¿ç)ΜzR̈Ÿ"]Ë•L´'dxÈÐtS 3j±Û é àÿÿÿÿÿÀ~˜dsÿû`À½€>A … ÂH"´•¸æ½ ÿ#ÿÿÿ˲µÐÕc0‰&V‰ZâBÅrÌdpëFEyFˆm ¥*³¬Î¾ë×ÿÿÿÿÿ¿¿mYÊŠVÊÆ}LêÆ+>j=‚¨,å-*UcÅB²= QSÎPèÑÎ"ÊY˜H  ÿÿÿÿÿÿÿÿÿÿÿÿ¯ÿÿÿÿÿ¯ÿý~Æ(`JEª*!ÙÙÊbª”Vs ` #³±Š©ÿ³”ÁB(ÿû`À³€ n? „mÁÑF¢ô•¹ÿûbÀ»€&@ÞMÀ[Àÿû`Àÿ€ÞÀbeets-1.3.1/test/rsrc/t_time.m4a0000644000076500000240000001334612013011113017364 0ustar asampsonstaff00000000000000 ftypM4A M4A mp42isom ŠmoovlmvhdÄLé_ÄLï¬D¸@trak\tkhdÄLé_ÄLï¸@mdia mdhdÄLé_ÄLï¬D¸UÄ"hdlrsounÓminfsmhd$dinfdref url —stblgstsdWmp4a¬D3esds€€€"€€€@xú€€€€€€stts.(stscÌstsz.22""'-+1/+&0''&%)(-,*).)(*%)-4&6.*,$,3/'& stcoÆé •udta meta"hdlrmdirappl°ˆilst©namdatafull"©ARTdatathe artist$©wrtdatathe composer!©albdatathe album!©gendatathe genre trkndatadiskdata,©day$data1987-03-31T07:00:00Zcpildatapgapdatatmpodata6©too.dataiTunes v7.6.2, QuickTime 7.4.5¢----meancom.apple.iTunesnameiTunNORMjdata 00000000 00000000 00000000 00000000 00000228 00000000 00000008 00000000 00000187 00000000¼----meancom.apple.iTunesnameiTunSMPB„data 00000000 00000840 0000037C 000000000000AC44 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000"©lyrdatathe lyrics(aART datathe album artist$©cmtdatathe comments$©grpdatathe grouping×freefree(mdatЃì\«11;Ä<Àøƒ¤4„ `Ifàe—^àïP0#Ü·›Áb˜Ä»VOØÙ(*¾J€q8òƒ„q©-@>Ťè[-3þ!‹rtg¼¼(ι¶q]b¥¸úƒÄPEÙ€€ ª°DãÙ‘>JUl3ËA$Ö)i)ƒÀúƒÄA#€+:3ï`Xa¶™˜ÉùQ;«À±˜ˆèàöƒ¢" „Î Ž× óf{q wZ“Ä 3z¿f#)NÙq'øƒ¢" „kËr½ÙÖ£ g§ý'”ê )y*¦˜Úö»,°Îµx¡úƒ¢" „r †BXVFìì7nhϦNž|z%ôe0Ue®«Œ œöƒ‚B„_`9åo¢ðJ­Dz0¯)òøáÛ1F8‘ú·åÉ>7t·#‹Uàøƒ¤4#ŒXÛõÞ™¾áàÒ`¸Ž‚Þ×xT†aGˆ~fäHv<ý¶ÁÀúƒÄ`? &ˆ×63Дl:äGàë’ 莵š„ÇÓëåN‚àúƒ¢R#Aש9Ï<³ÔiÇ%Öøê¦—µŠÍÎê®yfžÎôƒ‚T„ `®!I}uhnV$?‹+ä¡(Z«„Á¡Üta¥Vi±+±l‰8úƒÄ3#P0¼ñ•T`’3¹èžÓ#?}¥ÕõÚ»ìÔ‹€úƒ¤Q#Hl`µˆÁ½¥ËK§)Q)E‚ñõ>O²Âô¯SÔ¦úƒÆ"#P ÛdpËŸ¿Û2é­~sÛÈÓï'Pîì=Í&ü!úƒÄPG<Ž0NÝÍEø™_ƒõ'1…:õ˜‹å\øƒ¢ ˆ,l ƒq¼Ôº<¬>èðÍ&ïÞ¦J3}•]dQ€€8úƒ¢`F  8I+–¶:aá–;0¥Ç>m>;ßM¾íÛŸ× ¥/gøƒ¤p>€ÞbSci›”¤L z:~H5ƒM3©'°A+è&„f ‡Ö(pöƒ‚0!dÀ¢’¯:%¢C°9B³î+]þ3Z†¹ècJr†Â¶öƒ¤`E€a,]µ)\BiwÈ,yç@úD¸ùü'ħ¥¨Üøƒ¢ ˆÐÀ€0qóà=bžmBÅAwùH°×! šDSª 8öƒ‚5 !`Â4†§å^ñíª^:$9µÏ…gs`M%…ÊZZtª^U£Y(o€úƒ¢"ˆ¤09œ’#NÑ·#̬zÚW›;t A-1dyß”3¤^ àúƒ¢" "ìFâwì¼ú„,µ¸rŠþ¬js%”ŸÒísÙfžep=¼øƒ¤`G €@ÀãÒ5?à¼`•ØÉÍM²ÈöÆýYB^×;C€øƒ¢`BÈ ÆãiRø—‡iéŽ6ˆ0 9ü¾åÓvë…(ÜÿÿGYJ´…=˜¢oçlÇWøƒ¤3 BÐ3ÜQhLÖÆ$/ê‡b<÷~³µ8ëûÛΚS­n*jõXeÅ×7’#àôƒ‚"(„@Ø Ad¨*Â`óÙ'ሬÁ®co·>Vûça &µ% øƒ¤`F‚ ¢±äßxã}¸<ëÊüfP[2ÚqXä.Š'Iµpúƒ¢" „¤*ÓØšÖ'êŠÇÉR™z[oÝÓ¬ía‚„¾”XÇS2p'ÀøƒÆ2! éZðÞx²µ¾ùìú$©¿Vn´%j(óVÀöƒ¤`F‚€öŽF†@›c}}ðCÂ2>Û<ÆçO5£!¹QÞ/grDðúƒ¤aBàô‡2‰´S(^®Zdð{¦P¤Ÿ~‰ÙÛ˜¦‘6 Sºœöƒ¢ „,`¼-1wNÞAÀÍ”XPKh¡wKÛ#0R¬Y±X-Àøƒ¢`E "ÅImq¤>w¢È1s5{!ÁXº[¯/æÛ?ÓG¥xøƒ¢2"%€Xä  aÔž®ŽæÏû©ÆÏç¿÷ºr¯˜LaÀƒì\ªRi"?pƒì\¬ „ô@À\beets-1.3.1/test/test_art.py0000644000076500000240000002637712203275653016764 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for the album art fetchers.""" import _common from _common import unittest from beetsplug import fetchart from beets.autotag import AlbumInfo, AlbumMatch from beets import library from beets import importer from beets import config import os import shutil import StringIO class MockHeaders(object): def __init__(self, typeval): self.typeval = typeval def gettype(self): return self.typeval class MockUrlRetrieve(object): def __init__(self, typeval, pathval='fetched_path'): self.pathval = pathval self.headers = MockHeaders(typeval) self.fetched = None def __call__(self, url, filename=None): self.fetched = url return filename or self.pathval, self.headers class FetchImageTest(unittest.TestCase): def test_invalid_type_returns_none(self): fetchart.urllib.urlretrieve = MockUrlRetrieve('') artpath = fetchart._fetch_image('http://example.com') self.assertEqual(artpath, None) def test_jpeg_type_returns_path(self): fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') artpath = fetchart._fetch_image('http://example.com') self.assertNotEqual(artpath, None) class FSArtTest(_common.TestCase): def setUp(self): super(FSArtTest, self).setUp() self.dpath = os.path.join(self.temp_dir, 'arttest') os.mkdir(self.dpath) def test_finds_jpg_in_directory(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) fn = fetchart.art_in_path(self.dpath, ('art',), False) self.assertEqual(fn, os.path.join(self.dpath, 'a.jpg')) def test_appropriately_named_file_takes_precedence(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) _common.touch(os.path.join(self.dpath, 'art.jpg')) fn = fetchart.art_in_path(self.dpath, ('art',), False) self.assertEqual(fn, os.path.join(self.dpath, 'art.jpg')) def test_non_image_file_not_identified(self): _common.touch(os.path.join(self.dpath, 'a.txt')) fn = fetchart.art_in_path(self.dpath, ('art',), False) self.assertEqual(fn, None) def test_cautious_skips_fallback(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) fn = fetchart.art_in_path(self.dpath, ('art',), True) self.assertEqual(fn, None) def test_empty_dir(self): fn = fetchart.art_in_path(self.dpath, ('art',), True) self.assertEqual(fn, None) class CombinedTest(_common.TestCase): def setUp(self): super(CombinedTest, self).setUp() self.dpath = os.path.join(self.temp_dir, 'arttest') os.mkdir(self.dpath) self.old_urlopen = fetchart.urllib.urlopen fetchart.urllib.urlopen = self._urlopen self.page_text = "" self.urlopen_called = False # Set up configuration. fetchart.FetchArtPlugin() def tearDown(self): super(CombinedTest, self).tearDown() fetchart.urllib.urlopen = self.old_urlopen def _urlopen(self, url): self.urlopen_called = True self.fetched_url = url return StringIO.StringIO(self.page_text) def test_main_interface_returns_amazon_art(self): fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') album = _common.Bag(asin='xxxx') artpath = fetchart.art_for_album(album, None) self.assertNotEqual(artpath, None) def test_main_interface_returns_none_for_missing_asin_and_path(self): album = _common.Bag() artpath = fetchart.art_for_album(album, None) self.assertEqual(artpath, None) def test_main_interface_gives_precedence_to_fs_art(self): _common.touch(os.path.join(self.dpath, 'art.jpg')) fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') album = _common.Bag(asin='xxxx') artpath = fetchart.art_for_album(album, [self.dpath]) self.assertEqual(artpath, os.path.join(self.dpath, 'art.jpg')) def test_main_interface_falls_back_to_amazon(self): fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') album = _common.Bag(asin='xxxx') artpath = fetchart.art_for_album(album, [self.dpath]) self.assertNotEqual(artpath, None) self.assertFalse(artpath.startswith(self.dpath)) def test_main_interface_tries_amazon_before_aao(self): fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') album = _common.Bag(asin='xxxx') fetchart.art_for_album(album, [self.dpath]) self.assertFalse(self.urlopen_called) def test_main_interface_falls_back_to_aao(self): fetchart.urllib.urlretrieve = MockUrlRetrieve('text/html') album = _common.Bag(asin='xxxx') fetchart.art_for_album(album, [self.dpath]) self.assertTrue(self.urlopen_called) def test_main_interface_uses_caa_when_mbid_available(self): mock_retrieve = MockUrlRetrieve('image/jpeg') fetchart.urllib.urlretrieve = mock_retrieve album = _common.Bag(mb_albumid='releaseid', asin='xxxx') artpath = fetchart.art_for_album(album, None) self.assertNotEqual(artpath, None) self.assertTrue('coverartarchive.org' in mock_retrieve.fetched) def test_local_only_does_not_access_network(self): mock_retrieve = MockUrlRetrieve('image/jpeg') fetchart.urllib.urlretrieve = mock_retrieve album = _common.Bag(mb_albumid='releaseid', asin='xxxx') artpath = fetchart.art_for_album(album, [self.dpath], local_only=True) self.assertEqual(artpath, None) self.assertFalse(self.urlopen_called) self.assertFalse(mock_retrieve.fetched) def test_local_only_gets_fs_image(self): _common.touch(os.path.join(self.dpath, 'art.jpg')) mock_retrieve = MockUrlRetrieve('image/jpeg') fetchart.urllib.urlretrieve = mock_retrieve album = _common.Bag(mb_albumid='releaseid', asin='xxxx') artpath = fetchart.art_for_album(album, [self.dpath], None, local_only=True) self.assertEqual(artpath, os.path.join(self.dpath, 'art.jpg')) self.assertFalse(self.urlopen_called) self.assertFalse(mock_retrieve.fetched) class AAOTest(unittest.TestCase): def setUp(self): self.old_urlopen = fetchart.urllib.urlopen fetchart.urllib.urlopen = self._urlopen self.page_text = '' def tearDown(self): fetchart.urllib.urlopen = self.old_urlopen def _urlopen(self, url): return StringIO.StringIO(self.page_text) def test_aao_scraper_finds_image(self): self.page_text = """
View larger image """ res = fetchart.aao_art('x') self.assertEqual(res, 'TARGET_URL') def test_aao_scraper_returns_none_when_no_image_present(self): self.page_text = "blah blah" res = fetchart.aao_art('x') self.assertEqual(res, None) class ArtImporterTest(_common.TestCase): def setUp(self): super(ArtImporterTest, self).setUp() # Mock the album art fetcher to always return our test file. self.art_file = os.path.join(self.temp_dir, 'tmpcover.jpg') _common.touch(self.art_file) self.old_afa = fetchart.art_for_album self.afa_response = self.art_file def art_for_album(i, p, maxwidth=None, local_only=False): return self.afa_response fetchart.art_for_album = art_for_album # Test library. self.libpath = os.path.join(self.temp_dir, 'tmplib.blb') self.libdir = os.path.join(self.temp_dir, 'tmplib') os.mkdir(self.libdir) os.mkdir(os.path.join(self.libdir, 'album')) itempath = os.path.join(self.libdir, 'album', 'test.mp3') shutil.copyfile(os.path.join(_common.RSRC, 'full.mp3'), itempath) self.lib = library.Library(self.libpath) self.i = _common.item() self.i.path = itempath self.album = self.lib.add_album([self.i]) self.lib._connection().commit() # The plugin and import configuration. self.plugin = fetchart.FetchArtPlugin() self.session = _common.import_session(self.lib) # Import task for the coroutine. self.task = importer.ImportTask(None, None, [self.i]) self.task.is_album = True self.task.album_id = self.album.id info = AlbumInfo( album = 'some album', album_id = 'albumid', artist = 'some artist', artist_id = 'artistid', tracks = [], ) self.task.set_choice(AlbumMatch(0, info, {}, set(), set())) def tearDown(self): super(ArtImporterTest, self).tearDown() fetchart.art_for_album = self.old_afa def _fetch_art(self, should_exist): """Execute the fetch_art coroutine for the task and return the album's resulting artpath. ``should_exist`` specifies whether to assert that art path was set (to the correct value) or or that the path was not set. """ # Execute the two relevant parts of the importer. self.plugin.fetch_art(self.session, self.task) self.plugin.assign_art(self.session, self.task) artpath = self.lib.albums()[0].artpath if should_exist: self.assertEqual(artpath, os.path.join(os.path.dirname(self.i.path), 'cover.jpg')) self.assertExists(artpath) else: self.assertEqual(artpath, None) return artpath def test_fetch_art(self): assert not self.lib.albums()[0].artpath self._fetch_art(True) def test_art_not_found(self): self.afa_response = None self._fetch_art(False) def test_no_art_for_singleton(self): self.task.is_album = False self._fetch_art(False) def test_leave_original_file_in_place(self): self._fetch_art(True) self.assertExists(self.art_file) def test_delete_original_file(self): config['import']['delete'] = True self._fetch_art(True) self.assertNotExists(self.art_file) def test_move_original_file(self): config['import']['move'] = True self._fetch_art(True) self.assertNotExists(self.art_file) def test_do_not_delete_original_if_already_in_place(self): artdest = os.path.join(os.path.dirname(self.i.path), 'cover.jpg') shutil.copyfile(self.art_file, artdest) self.afa_response = artdest self._fetch_art(True) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_autotag.py0000644000076500000240000010665312217447640017640 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for autotagging functionality. """ import os import shutil import re import copy import _common from _common import unittest from beets import autotag from beets.autotag import match from beets.autotag.hooks import Distance, string_dist from beets.library import Item from beets.util import plurality from beets.autotag import AlbumInfo, TrackInfo from beets import config class PluralityTest(_common.TestCase): def test_plurality_consensus(self): objs = [1, 1, 1, 1] obj, freq = plurality(objs) self.assertEqual(obj, 1) self.assertEqual(freq, 4) def test_plurality_near_consensus(self): objs = [1, 1, 2, 1] obj, freq = plurality(objs) self.assertEqual(obj, 1) self.assertEqual(freq, 3) def test_plurality_conflict(self): objs = [1, 1, 2, 2, 3] obj, freq = plurality(objs) self.assert_(obj in (1, 2)) self.assertEqual(freq, 2) def test_plurality_empty_sequence_raises_error(self): with self.assertRaises(ValueError): plurality([]) def test_current_metadata_finds_pluralities(self): items = [Item(artist='The Beetles', album='The White Album'), Item(artist='The Beatles', album='The White Album'), Item(artist='The Beatles', album='Teh White Album')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'The Beatles') self.assertEqual(likelies['album'], 'The White Album') self.assertFalse(consensus['artist']) def test_current_metadata_artist_consensus(self): items = [Item(artist='The Beatles', album='The White Album'), Item(artist='The Beatles', album='The White Album'), Item(artist='The Beatles', album='Teh White Album')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'The Beatles') self.assertEqual(likelies['album'], 'The White Album') self.assertTrue(consensus['artist']) def test_albumartist_consensus(self): items = [Item(artist='tartist1', album='album', albumartist='aartist'), Item(artist='tartist2', album='album', albumartist='aartist'), Item(artist='tartist3', album='album', albumartist='aartist')] likelies, consensus = match.current_metadata(items) self.assertEqual(likelies['artist'], 'aartist') self.assertFalse(consensus['artist']) def test_current_metadata_likelies(self): fields = ['artist', 'album', 'albumartist', 'year', 'disctotal', 'mb_albumid', 'label', 'catalognum', 'country', 'media', 'albumdisambig'] items = [Item(**dict((f, '%s_%s' % (f, i or 1)) for f in fields)) for i in range(5)] likelies, _ = match.current_metadata(items) for f in fields: self.assertEqual(likelies[f], '%s_1' % f) def _make_item(title, track, artist=u'some artist'): return Item(title=title, track=track, artist=artist, album=u'some album', length=1, mb_trackid='', mb_albumid='', mb_artistid='') def _make_trackinfo(): return [ TrackInfo(u'one', None, u'some artist', length=1, index=1), TrackInfo(u'two', None, u'some artist', length=1, index=2), TrackInfo(u'three', None, u'some artist', length=1, index=3), ] class DistanceTest(_common.TestCase): def test_add(self): dist = Distance() dist.add('add', 1.0) self.assertEqual(dist._penalties, {'add': [1.0]}) def test_add_equality(self): dist = Distance() dist.add_equality('equality', 'ghi', ['abc', 'def', 'ghi']) self.assertEqual(dist._penalties['equality'], [0.0]) dist.add_equality('equality', 'xyz', ['abc', 'def', 'ghi']) self.assertEqual(dist._penalties['equality'], [0.0, 1.0]) dist.add_equality('equality', 'abc', re.compile(r'ABC', re.I)) self.assertEqual(dist._penalties['equality'], [0.0, 1.0, 0.0]) def test_add_expr(self): dist = Distance() dist.add_expr('expr', True) self.assertEqual(dist._penalties['expr'], [1.0]) dist.add_expr('expr', False) self.assertEqual(dist._penalties['expr'], [1.0, 0.0]) def test_add_number(self): dist = Distance() # Add a full penalty for each number of difference between two numbers. dist.add_number('number', 1, 1) self.assertEqual(dist._penalties['number'], [0.0]) dist.add_number('number', 1, 2) self.assertEqual(dist._penalties['number'], [0.0, 1.0]) dist.add_number('number', 2, 1) self.assertEqual(dist._penalties['number'], [0.0, 1.0, 1.0]) dist.add_number('number', -1, 2) self.assertEqual(dist._penalties['number'], [0.0, 1.0, 1.0, 1.0, 1.0, 1.0]) def test_add_priority(self): dist = Distance() dist.add_priority('priority', 'abc', 'abc') self.assertEqual(dist._penalties['priority'], [0.0]) dist.add_priority('priority', 'def', ['abc', 'def']) self.assertEqual(dist._penalties['priority'], [0.0, 0.5]) dist.add_priority('priority', 'gh', ['ab', 'cd', 'ef', re.compile('GH', re.I)]) self.assertEqual(dist._penalties['priority'], [0.0, 0.5, 0.75]) dist.add_priority('priority', 'xyz', ['abc', 'def']) self.assertEqual(dist._penalties['priority'], [0.0, 0.5, 0.75, 1.0]) def test_add_ratio(self): dist = Distance() dist.add_ratio('ratio', 25, 100) self.assertEqual(dist._penalties['ratio'], [0.25]) dist.add_ratio('ratio', 10, 5) self.assertEqual(dist._penalties['ratio'], [0.25, 1.0]) dist.add_ratio('ratio', -5, 5) self.assertEqual(dist._penalties['ratio'], [0.25, 1.0, 0.0]) dist.add_ratio('ratio', 5, 0) self.assertEqual(dist._penalties['ratio'], [0.25, 1.0, 0.0, 0.0]) def test_add_string(self): dist = Distance() sdist = string_dist(u'abc', u'bcd') dist.add_string('string', u'abc', u'bcd') self.assertEqual(dist._penalties['string'], [sdist]) def test_distance(self): config['match']['distance_weights']['album'] = 2.0 config['match']['distance_weights']['medium'] = 1.0 dist = Distance() dist.add('album', 0.5) dist.add('media', 0.25) dist.add('media', 0.75) self.assertEqual(dist.distance, 0.5) # __getitem__() self.assertEqual(dist['album'], 0.25) self.assertEqual(dist['media'], 0.25) def test_max_distance(self): config['match']['distance_weights']['album'] = 3.0 config['match']['distance_weights']['medium'] = 1.0 dist = Distance() dist.add('album', 0.5) dist.add('medium', 0.0) dist.add('medium', 0.0) self.assertEqual(dist.max_distance, 5.0) def test_operators(self): config['match']['distance_weights']['source'] = 1.0 config['match']['distance_weights']['album'] = 2.0 config['match']['distance_weights']['medium'] = 1.0 dist = Distance() dist.add('source', 0.0) dist.add('album', 0.5) dist.add('medium', 0.25) dist.add('medium', 0.75) self.assertEqual(len(dist), 2) self.assertEqual(list(dist), [('album', 0.2), ('medium', 0.2)]) self.assertTrue(dist == 0.4) self.assertTrue(dist < 1.0) self.assertTrue(dist > 0.0) self.assertEqual(dist - 0.4, 0.0) self.assertEqual(0.4 - dist, 0.0) self.assertEqual(float(dist), 0.4) def test_raw_distance(self): config['match']['distance_weights']['album'] = 3.0 config['match']['distance_weights']['medium'] = 1.0 dist = Distance() dist.add('album', 0.5) dist.add('medium', 0.25) dist.add('medium', 0.5) self.assertEqual(dist.raw_distance, 2.25) def test_items(self): config['match']['distance_weights']['album'] = 4.0 config['match']['distance_weights']['medium'] = 2.0 dist = Distance() dist.add('album', 0.1875) dist.add('medium', 0.75) self.assertEqual(dist.items(), [('medium', 0.25), ('album', 0.125)]) # Sort by key if distance is equal. dist = Distance() dist.add('album', 0.375) dist.add('medium', 0.75) self.assertEqual(dist.items(), [('album', 0.25), ('medium', 0.25)]) def test_update(self): dist1 = Distance() dist1.add('album', 0.5) dist1.add('media', 1.0) dist2 = Distance() dist2.add('album', 0.75) dist2.add('album', 0.25) dist2.add('media', 0.05) dist1.update(dist2) self.assertEqual(dist1._penalties, {'album': [0.5, 0.75, 0.25], 'media': [1.0, 0.05]}) class TrackDistanceTest(_common.TestCase): def test_identical_tracks(self): item = _make_item(u'one', 1) info = _make_trackinfo()[0] dist = match.track_distance(item, info, incl_artist=True) self.assertEqual(dist, 0.0) def test_different_title(self): item = _make_item(u'foo', 1) info = _make_trackinfo()[0] dist = match.track_distance(item, info, incl_artist=True) self.assertNotEqual(dist, 0.0) def test_different_artist(self): item = _make_item(u'one', 1) item.artist = u'foo' info = _make_trackinfo()[0] dist = match.track_distance(item, info, incl_artist=True) self.assertNotEqual(dist, 0.0) def test_various_artists_tolerated(self): item = _make_item(u'one', 1) item.artist = u'Various Artists' info = _make_trackinfo()[0] dist = match.track_distance(item, info, incl_artist=True) self.assertEqual(dist, 0.0) class AlbumDistanceTest(_common.TestCase): def _mapping(self, items, info): out = {} for i, t in zip(items, info.tracks): out[i] = t return out def _dist(self, items, info): return match.distance(items, info, self._mapping(items, info)) def test_identical_albums(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist = u'some artist', album = u'some album', tracks = _make_trackinfo(), va = False, album_id = None, artist_id = None, ) self.assertEqual(self._dist(items, info), 0) def test_incomplete_album(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist = u'some artist', album = u'some album', tracks = _make_trackinfo(), va = False, album_id = None, artist_id = None, ) dist = self._dist(items, info) self.assertNotEqual(dist, 0) # Make sure the distance is not too great self.assertTrue(dist < 0.2) def test_global_artists_differ(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist = u'someone else', album = u'some album', tracks = _make_trackinfo(), va = False, album_id = None, artist_id = None, ) self.assertNotEqual(self._dist(items, info), 0) def test_comp_track_artists_match(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist = u'should be ignored', album = u'some album', tracks = _make_trackinfo(), va = True, album_id = None, artist_id = None, ) self.assertEqual(self._dist(items, info), 0) def test_comp_no_track_artists(self): # Some VA releases don't have track artists (incomplete metadata). items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist = u'should be ignored', album = u'some album', tracks = _make_trackinfo(), va = True, album_id = None, artist_id = None, ) info.tracks[0].artist = None info.tracks[1].artist = None info.tracks[2].artist = None self.assertEqual(self._dist(items, info), 0) def test_comp_track_artists_do_not_match(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2, u'someone else')) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist = u'some artist', album = u'some album', tracks = _make_trackinfo(), va = True, album_id = None, artist_id = None, ) self.assertNotEqual(self._dist(items, info), 0) def test_tracks_out_of_order(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'three', 2)) items.append(_make_item(u'two', 3)) info = AlbumInfo( artist = u'some artist', album = u'some album', tracks = _make_trackinfo(), va = False, album_id = None, artist_id = None, ) dist = self._dist(items, info) self.assertTrue(0 < dist < 0.2) def test_two_medium_release(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 3)) info = AlbumInfo( artist = u'some artist', album = u'some album', tracks = _make_trackinfo(), va = False, album_id = None, artist_id = None, ) info.tracks[0].medium_index = 1 info.tracks[1].medium_index = 2 info.tracks[2].medium_index = 1 dist = self._dist(items, info) self.assertEqual(dist, 0) def test_per_medium_track_numbers(self): items = [] items.append(_make_item(u'one', 1)) items.append(_make_item(u'two', 2)) items.append(_make_item(u'three', 1)) info = AlbumInfo( artist = u'some artist', album = u'some album', tracks = _make_trackinfo(), va = False, album_id = None, artist_id = None, ) info.tracks[0].medium_index = 1 info.tracks[1].medium_index = 2 info.tracks[2].medium_index = 1 dist = self._dist(items, info) self.assertEqual(dist, 0) def _mkmp3(path): shutil.copyfile(os.path.join(_common.RSRC, 'min.mp3'), path) class AlbumsInDirTest(_common.TestCase): def setUp(self): super(AlbumsInDirTest, self).setUp() # create a directory structure for testing self.base = os.path.abspath(os.path.join(self.temp_dir, 'tempdir')) os.mkdir(self.base) os.mkdir(os.path.join(self.base, 'album1')) os.mkdir(os.path.join(self.base, 'album2')) os.mkdir(os.path.join(self.base, 'more')) os.mkdir(os.path.join(self.base, 'more', 'album3')) os.mkdir(os.path.join(self.base, 'more', 'album4')) _mkmp3(os.path.join(self.base, 'album1', 'album1song1.mp3')) _mkmp3(os.path.join(self.base, 'album1', 'album1song2.mp3')) _mkmp3(os.path.join(self.base, 'album2', 'album2song.mp3')) _mkmp3(os.path.join(self.base, 'more', 'album3', 'album3song.mp3')) _mkmp3(os.path.join(self.base, 'more', 'album4', 'album4song.mp3')) def test_finds_all_albums(self): albums = list(autotag.albums_in_dir(self.base)) self.assertEqual(len(albums), 4) def test_separates_contents(self): found = [] for _, album in autotag.albums_in_dir(self.base): found.append(re.search(r'album(.)song', album[0].path).group(1)) self.assertTrue('1' in found) self.assertTrue('2' in found) self.assertTrue('3' in found) self.assertTrue('4' in found) def test_finds_multiple_songs(self): for _, album in autotag.albums_in_dir(self.base): n = re.search(r'album(.)song', album[0].path).group(1) if n == '1': self.assertEqual(len(album), 2) else: self.assertEqual(len(album), 1) class MultiDiscAlbumsInDirTest(_common.TestCase): def setUp(self): super(MultiDiscAlbumsInDirTest, self).setUp() self.base = os.path.abspath(os.path.join(self.temp_dir, 'tempdir')) os.mkdir(self.base) self.dirs = [ # Nested album, multiple subdirs. # Also, false positive marker in root dir, and subtitle for disc 3. os.path.join(self.base, 'ABCD1234'), os.path.join(self.base, 'ABCD1234', 'cd 1'), os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus'), # Nested album, single subdir. # Also, punctuation between marker and disc number. os.path.join(self.base, 'album'), os.path.join(self.base, 'album', 'cd _ 1'), # Flattened album, case typo. # Also, false positive marker in parent dir. os.path.join(self.base, 'artist [CD5]'), os.path.join(self.base, 'artist [CD5]', 'CAT disc 1'), os.path.join(self.base, 'artist [CD5]', 'CAt disc 2'), # Single disc album, sorted between CAT discs. os.path.join(self.base, 'artist [CD5]', 'CATS'), ] self.files = [ os.path.join(self.base, 'ABCD1234', 'cd 1', 'song1.mp3'), os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus', 'song2.mp3'), os.path.join(self.base, 'ABCD1234', 'cd 3 - bonus', 'song3.mp3'), os.path.join(self.base, 'album', 'cd _ 1', 'song4.mp3'), os.path.join(self.base, 'artist [CD5]', 'CAT disc 1', 'song5.mp3'), os.path.join(self.base, 'artist [CD5]', 'CAt disc 2', 'song6.mp3'), os.path.join(self.base, 'artist [CD5]', 'CATS', 'song7.mp3'), ] for path in self.dirs: os.mkdir(path) for path in self.files: _mkmp3(path) def test_coalesce_nested_album_multiple_subdirs(self): albums = list(autotag.albums_in_dir(self.base)) self.assertEquals(len(albums), 4) root, items = albums[0] self.assertEquals(root, self.dirs[0:3]) self.assertEquals(len(items), 3) def test_coalesce_nested_album_single_subdir(self): albums = list(autotag.albums_in_dir(self.base)) root, items = albums[1] self.assertEquals(root, self.dirs[3:5]) self.assertEquals(len(items), 1) def test_coalesce_flattened_album_case_typo(self): albums = list(autotag.albums_in_dir(self.base)) root, items = albums[2] self.assertEquals(root, self.dirs[6:8]) self.assertEquals(len(items), 2) def test_single_disc_album(self): albums = list(autotag.albums_in_dir(self.base)) root, items = albums[3] self.assertEquals(root, self.dirs[8:]) self.assertEquals(len(items), 1) def test_do_not_yield_empty_album(self): # Remove all the MP3s. for path in self.files: os.remove(path) albums = list(autotag.albums_in_dir(self.base)) self.assertEquals(len(albums), 0) class AssignmentTest(unittest.TestCase): def item(self, title, track): return Item( title=title, track=track, mb_trackid='', mb_albumid='', mb_artistid='', ) def test_reorder_when_track_numbers_incorrect(self): items = [] items.append(self.item(u'one', 1)) items.append(self.item(u'three', 2)) items.append(self.item(u'two', 3)) trackinfo = [] trackinfo.append(TrackInfo(u'one', None)) trackinfo.append(TrackInfo(u'two', None)) trackinfo.append(TrackInfo(u'three', None)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, []) self.assertEqual(mapping, { items[0]: trackinfo[0], items[1]: trackinfo[2], items[2]: trackinfo[1], }) def test_order_works_with_invalid_track_numbers(self): items = [] items.append(self.item(u'one', 1)) items.append(self.item(u'three', 1)) items.append(self.item(u'two', 1)) trackinfo = [] trackinfo.append(TrackInfo(u'one', None)) trackinfo.append(TrackInfo(u'two', None)) trackinfo.append(TrackInfo(u'three', None)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, []) self.assertEqual(mapping, { items[0]: trackinfo[0], items[1]: trackinfo[2], items[2]: trackinfo[1], }) def test_order_works_with_missing_tracks(self): items = [] items.append(self.item(u'one', 1)) items.append(self.item(u'three', 3)) trackinfo = [] trackinfo.append(TrackInfo(u'one', None)) trackinfo.append(TrackInfo(u'two', None)) trackinfo.append(TrackInfo(u'three', None)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, [trackinfo[1]]) self.assertEqual(mapping, { items[0]: trackinfo[0], items[1]: trackinfo[2], }) def test_order_works_with_extra_tracks(self): items = [] items.append(self.item(u'one', 1)) items.append(self.item(u'two', 2)) items.append(self.item(u'three', 3)) trackinfo = [] trackinfo.append(TrackInfo(u'one', None)) trackinfo.append(TrackInfo(u'three', None)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, [items[1]]) self.assertEqual(extra_tracks, []) self.assertEqual(mapping, { items[0]: trackinfo[0], items[2]: trackinfo[1], }) def test_order_works_when_track_names_are_entirely_wrong(self): # A real-world test case contributed by a user. def item(i, length): return Item( artist=u'ben harper', album=u'burn to shine', title=u'ben harper - Burn to Shine ' + str(i), track=i, length=length, mb_trackid='', mb_albumid='', mb_artistid='', ) items = [] items.append(item(1, 241.37243007106997)) items.append(item(2, 342.27781704375036)) items.append(item(3, 245.95070222338137)) items.append(item(4, 472.87662515485437)) items.append(item(5, 279.1759535763187)) items.append(item(6, 270.33333768012)) items.append(item(7, 247.83435613222923)) items.append(item(8, 216.54504531525072)) items.append(item(9, 225.72775379800484)) items.append(item(10, 317.7643606963552)) items.append(item(11, 243.57001238834192)) items.append(item(12, 186.45916150485752)) def info(index, title, length): return TrackInfo(title, None, length=length, index=index) trackinfo = [] trackinfo.append(info(1, u'Alone', 238.893)) trackinfo.append(info(2, u'The Woman in You', 341.44)) trackinfo.append(info(3, u'Less', 245.59999999999999)) trackinfo.append(info(4, u'Two Hands of a Prayer', 470.49299999999999)) trackinfo.append(info(5, u'Please Bleed', 277.86599999999999)) trackinfo.append(info(6, u'Suzie Blue', 269.30599999999998)) trackinfo.append(info(7, u'Steal My Kisses', 245.36000000000001)) trackinfo.append(info(8, u'Burn to Shine', 214.90600000000001)) trackinfo.append(info(9, u'Show Me a Little Shame', 224.09299999999999)) trackinfo.append(info(10, u'Forgiven', 317.19999999999999)) trackinfo.append(info(11, u'Beloved One', 243.733)) trackinfo.append(info(12, u'In the Lord\'s Arms', 186.13300000000001)) mapping, extra_items, extra_tracks = \ match.assign_items(items, trackinfo) self.assertEqual(extra_items, []) self.assertEqual(extra_tracks, []) for item, info in mapping.iteritems(): self.assertEqual(items.index(item), trackinfo.index(info)) class ApplyTestUtil(object): def _apply(self, info=None, per_disc_numbering=False): info = info or self.info mapping = {} for i, t in zip(self.items, info.tracks): mapping[i] = t config['per_disc_numbering'] = per_disc_numbering autotag.apply_metadata(info, mapping) class ApplyTest(_common.TestCase, ApplyTestUtil): def setUp(self): super(ApplyTest, self).setUp() self.items = [] self.items.append(Item({})) self.items.append(Item({})) trackinfo = [] trackinfo.append(TrackInfo( u'oneNew', 'dfa939ec-118c-4d0f-84a0-60f3d1e6522c', medium=1, medium_index=1, medium_total=1, index=1, artist_credit='trackArtistCredit', artist_sort='trackArtistSort', )) trackinfo.append(TrackInfo( u'twoNew', '40130ed1-a27c-42fd-a328-1ebefb6caef4', medium=2, medium_index=1, index=2, medium_total=1, )) self.info = AlbumInfo( tracks = trackinfo, artist = u'artistNew', album = u'albumNew', album_id = '7edb51cb-77d6-4416-a23c-3a8c2994a2c7', artist_id = 'a6623d39-2d8e-4f70-8242-0a9553b91e50', artist_credit = u'albumArtistCredit', artist_sort = u'albumArtistSort', albumtype = u'album', va = False, mediums = 2, ) def test_titles_applied(self): self._apply() self.assertEqual(self.items[0].title, 'oneNew') self.assertEqual(self.items[1].title, 'twoNew') def test_album_and_artist_applied_to_all(self): self._apply() self.assertEqual(self.items[0].album, 'albumNew') self.assertEqual(self.items[1].album, 'albumNew') self.assertEqual(self.items[0].artist, 'artistNew') self.assertEqual(self.items[1].artist, 'artistNew') def test_track_index_applied(self): self._apply() self.assertEqual(self.items[0].track, 1) self.assertEqual(self.items[1].track, 2) def test_track_total_applied(self): self._apply() self.assertEqual(self.items[0].tracktotal, 2) self.assertEqual(self.items[1].tracktotal, 2) def test_disc_index_applied(self): self._apply() self.assertEqual(self.items[0].disc, 1) self.assertEqual(self.items[1].disc, 2) def test_disc_total_applied(self): self._apply() self.assertEqual(self.items[0].disctotal, 2) self.assertEqual(self.items[1].disctotal, 2) def test_per_disc_numbering(self): self._apply(per_disc_numbering=True) self.assertEqual(self.items[0].track, 1) self.assertEqual(self.items[1].track, 1) def test_per_disc_numbering_track_total(self): self._apply(per_disc_numbering=True) self.assertEqual(self.items[0].tracktotal, 1) self.assertEqual(self.items[1].tracktotal, 1) def test_mb_trackid_applied(self): self._apply() self.assertEqual(self.items[0].mb_trackid, 'dfa939ec-118c-4d0f-84a0-60f3d1e6522c') self.assertEqual(self.items[1].mb_trackid, '40130ed1-a27c-42fd-a328-1ebefb6caef4') def test_mb_albumid_and_artistid_applied(self): self._apply() for item in self.items: self.assertEqual(item.mb_albumid, '7edb51cb-77d6-4416-a23c-3a8c2994a2c7') self.assertEqual(item.mb_artistid, 'a6623d39-2d8e-4f70-8242-0a9553b91e50') def test_albumtype_applied(self): self._apply() self.assertEqual(self.items[0].albumtype, 'album') self.assertEqual(self.items[1].albumtype, 'album') def test_album_artist_overrides_empty_track_artist(self): my_info = copy.deepcopy(self.info) self._apply(info=my_info) self.assertEqual(self.items[0].artist, 'artistNew') self.assertEqual(self.items[0].artist, 'artistNew') def test_album_artist_overriden_by_nonempty_track_artist(self): my_info = copy.deepcopy(self.info) my_info.tracks[0].artist = 'artist1!' my_info.tracks[1].artist = 'artist2!' self._apply(info=my_info) self.assertEqual(self.items[0].artist, 'artist1!') self.assertEqual(self.items[1].artist, 'artist2!') def test_artist_credit_applied(self): self._apply() self.assertEqual(self.items[0].albumartist_credit, 'albumArtistCredit') self.assertEqual(self.items[0].artist_credit, 'trackArtistCredit') self.assertEqual(self.items[1].albumartist_credit, 'albumArtistCredit') self.assertEqual(self.items[1].artist_credit, 'albumArtistCredit') def test_artist_sort_applied(self): self._apply() self.assertEqual(self.items[0].albumartist_sort, 'albumArtistSort') self.assertEqual(self.items[0].artist_sort, 'trackArtistSort') self.assertEqual(self.items[1].albumartist_sort, 'albumArtistSort') self.assertEqual(self.items[1].artist_sort, 'albumArtistSort') class ApplyCompilationTest(_common.TestCase, ApplyTestUtil): def setUp(self): super(ApplyCompilationTest, self).setUp() self.items = [] self.items.append(Item({})) self.items.append(Item({})) trackinfo = [] trackinfo.append(TrackInfo( u'oneNew', 'dfa939ec-118c-4d0f-84a0-60f3d1e6522c', u'artistOneNew', 'a05686fc-9db2-4c23-b99e-77f5db3e5282', index=1, )) trackinfo.append(TrackInfo( u'twoNew', '40130ed1-a27c-42fd-a328-1ebefb6caef4', u'artistTwoNew', '80b3cf5e-18fe-4c59-98c7-e5bb87210710', index=2, )) self.info = AlbumInfo( tracks = trackinfo, artist = u'variousNew', album = u'albumNew', album_id = '3b69ea40-39b8-487f-8818-04b6eff8c21a', artist_id = '89ad4ac3-39f7-470e-963a-56509c546377', albumtype = u'compilation', va = False, ) def test_album_and_track_artists_separate(self): self._apply() self.assertEqual(self.items[0].artist, 'artistOneNew') self.assertEqual(self.items[1].artist, 'artistTwoNew') self.assertEqual(self.items[0].albumartist, 'variousNew') self.assertEqual(self.items[1].albumartist, 'variousNew') def test_mb_albumartistid_applied(self): self._apply() self.assertEqual(self.items[0].mb_albumartistid, '89ad4ac3-39f7-470e-963a-56509c546377') self.assertEqual(self.items[1].mb_albumartistid, '89ad4ac3-39f7-470e-963a-56509c546377') self.assertEqual(self.items[0].mb_artistid, 'a05686fc-9db2-4c23-b99e-77f5db3e5282') self.assertEqual(self.items[1].mb_artistid, '80b3cf5e-18fe-4c59-98c7-e5bb87210710') def test_va_flag_cleared_does_not_set_comp(self): self._apply() self.assertFalse(self.items[0].comp) self.assertFalse(self.items[1].comp) def test_va_flag_sets_comp(self): va_info = copy.deepcopy(self.info) va_info.va = True self._apply(info=va_info) self.assertTrue(self.items[0].comp) self.assertTrue(self.items[1].comp) class StringDistanceTest(unittest.TestCase): def test_equal_strings(self): dist = string_dist(u'Some String', u'Some String') self.assertEqual(dist, 0.0) def test_different_strings(self): dist = string_dist(u'Some String', u'Totally Different') self.assertNotEqual(dist, 0.0) def test_punctuation_ignored(self): dist = string_dist(u'Some String', u'Some.String!') self.assertEqual(dist, 0.0) def test_case_ignored(self): dist = string_dist(u'Some String', u'sOME sTring') self.assertEqual(dist, 0.0) def test_leading_the_has_lower_weight(self): dist1 = string_dist(u'XXX Band Name', u'Band Name') dist2 = string_dist(u'The Band Name', u'Band Name') self.assert_(dist2 < dist1) def test_parens_have_lower_weight(self): dist1 = string_dist(u'One .Two.', u'One') dist2 = string_dist(u'One (Two)', u'One') self.assert_(dist2 < dist1) def test_brackets_have_lower_weight(self): dist1 = string_dist(u'One .Two.', u'One') dist2 = string_dist(u'One [Two]', u'One') self.assert_(dist2 < dist1) def test_ep_label_has_zero_weight(self): dist = string_dist(u'My Song (EP)', u'My Song') self.assertEqual(dist, 0.0) def test_featured_has_lower_weight(self): dist1 = string_dist(u'My Song blah Someone', u'My Song') dist2 = string_dist(u'My Song feat Someone', u'My Song') self.assert_(dist2 < dist1) def test_postfix_the(self): dist = string_dist(u'The Song Title', u'Song Title, The') self.assertEqual(dist, 0.0) def test_postfix_a(self): dist = string_dist(u'A Song Title', u'Song Title, A') self.assertEqual(dist, 0.0) def test_postfix_an(self): dist = string_dist(u'An Album Title', u'Album Title, An') self.assertEqual(dist, 0.0) def test_empty_strings(self): dist = string_dist(u'', u'') self.assertEqual(dist, 0.0) def test_solo_pattern(self): # Just make sure these don't crash. string_dist(u'The ', u'') string_dist(u'(EP)', u'(EP)') string_dist(u', An', u'') def test_heuristic_does_not_harm_distance(self): dist = string_dist(u'Untitled', u'[Untitled]') self.assertEqual(dist, 0.0) def test_ampersand_expansion(self): dist = string_dist(u'And', u'&') self.assertEqual(dist, 0.0) def test_accented_characters(self): dist = string_dist(u'\xe9\xe1\xf1', u'ean') self.assertEqual(dist, 0.0) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_db.py0000644000076500000240000010553412220072440016540 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for non-query database functions of Item. """ import os import sqlite3 import ntpath import posixpath import shutil import re import unicodedata import sys import _common from _common import unittest from _common import item import beets.library from beets import util from beets import plugins from beets import config TEMP_LIB = os.path.join(_common.RSRC, 'test_copy.blb') # Shortcut to path normalization. np = util.normpath class LoadTest(_common.LibTestCase): def test_load_restores_data_from_db(self): original_title = self.i.title self.i.title = 'something' self.i.load() self.assertEqual(original_title, self.i.title) def test_load_clears_dirty_flags(self): self.i.artist = 'something' self.assertTrue('artist' in self.i._dirty) self.i.load() self.assertTrue('artist' not in self.i._dirty) class StoreTest(_common.LibTestCase): def test_store_changes_database_value(self): self.i.year = 1987 self.i.store() new_year = self.lib._connection().execute( 'select year from items where ' 'title="the title"').fetchone()['year'] self.assertEqual(new_year, 1987) def test_store_only_writes_dirty_fields(self): original_genre = self.i.genre self.i._values_fixed['genre'] = 'beatboxing' # change w/o dirtying self.i.store() new_genre = self.lib._connection().execute( 'select genre from items where ' 'title="the title"').fetchone()['genre'] self.assertEqual(new_genre, original_genre) def test_store_clears_dirty_flags(self): self.i.composer = 'tvp' self.i.store() self.assertTrue('composer' not in self.i._dirty) class AddTest(_common.TestCase): def setUp(self): super(AddTest, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item() def test_item_add_inserts_row(self): self.lib.add(self.i) new_grouping = self.lib._connection().execute( 'select grouping from items ' 'where composer="the composer"').fetchone()['grouping'] self.assertEqual(new_grouping, self.i.grouping) def test_library_add_path_inserts_row(self): i = beets.library.Item.from_path(os.path.join(_common.RSRC, 'full.mp3')) self.lib.add(i) new_grouping = self.lib._connection().execute( 'select grouping from items ' 'where composer="the composer"').fetchone()['grouping'] self.assertEqual(new_grouping, self.i.grouping) class RemoveTest(_common.LibTestCase): def test_remove_deletes_from_db(self): self.i.remove() c = self.lib._connection().execute('select * from items') self.assertEqual(c.fetchone(), None) class GetSetTest(_common.TestCase): def setUp(self): super(GetSetTest, self).setUp() self.i = item() def test_set_changes_value(self): self.i.bpm = 4915 self.assertEqual(self.i.bpm, 4915) def test_set_sets_dirty_flag(self): self.i.comp = not self.i.comp self.assertTrue('comp' in self.i._dirty) def test_set_does_not_dirty_if_value_unchanged(self): self.i.title = self.i.title self.assertTrue('title' not in self.i._dirty) def test_invalid_field_raises_attributeerror(self): self.assertRaises(AttributeError, getattr, self.i, 'xyzzy') class DestinationTest(_common.TestCase): def setUp(self): super(DestinationTest, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item(self.lib) def tearDown(self): super(DestinationTest, self).tearDown() self.lib._connection().close() def test_directory_works_with_trailing_slash(self): self.lib.directory = 'one/' self.lib.path_formats = [('default', 'two')] self.assertEqual(self.i.destination(), np('one/two')) def test_directory_works_without_trailing_slash(self): self.lib.directory = 'one' self.lib.path_formats = [('default', 'two')] self.assertEqual(self.i.destination(), np('one/two')) def test_destination_substitues_metadata_values(self): self.lib.directory = 'base' self.lib.path_formats = [('default', '$album/$artist $title')] self.i.title = 'three' self.i.artist = 'two' self.i.album = 'one' self.assertEqual(self.i.destination(), np('base/one/two three')) def test_destination_preserves_extension(self): self.lib.directory = 'base' self.lib.path_formats = [('default', '$title')] self.i.path = 'hey.audioformat' self.assertEqual(self.i.destination(), np('base/the title.audioformat')) def test_lower_case_extension(self): self.lib.directory = 'base' self.lib.path_formats = [('default', '$title')] self.i.path = 'hey.MP3' self.assertEqual(self.i.destination(), np('base/the title.mp3')) def test_destination_pads_some_indices(self): self.lib.directory = 'base' self.lib.path_formats = [('default', '$track $tracktotal ' \ '$disc $disctotal $bpm')] self.i.track = 1 self.i.tracktotal = 2 self.i.disc = 3 self.i.disctotal = 4 self.i.bpm = 5 self.assertEqual(self.i.destination(), np('base/01 02 03 04 5')) def test_destination_pads_date_values(self): self.lib.directory = 'base' self.lib.path_formats = [('default', '$year-$month-$day')] self.i.year = 1 self.i.month = 2 self.i.day = 3 self.assertEqual(self.i.destination(), np('base/0001-02-03')) def test_destination_escapes_slashes(self): self.i.album = 'one/two' dest = self.i.destination() self.assertTrue('one' in dest) self.assertTrue('two' in dest) self.assertFalse('one/two' in dest) def test_destination_escapes_leading_dot(self): self.i.album = '.something' dest = self.i.destination() self.assertTrue('something' in dest) self.assertFalse('/.' in dest) def test_destination_preserves_legitimate_slashes(self): self.i.artist = 'one' self.i.album = 'two' dest = self.i.destination() self.assertTrue(os.path.join('one', 'two') in dest) def test_destination_long_names_truncated(self): self.i.title = 'X'*300 self.i.artist = 'Y'*300 for c in self.i.destination().split(os.path.sep): self.assertTrue(len(c) <= 255) def test_destination_long_names_keep_extension(self): self.i.title = 'X'*300 self.i.path = 'something.extn' dest = self.i.destination() self.assertEqual(dest[-5:], '.extn') def test_distination_windows_removes_both_separators(self): self.i.title = 'one \\ two / three.mp3' p = self.i.destination(pathmod=ntpath) self.assertFalse('one \\ two' in p) self.assertFalse('one / two' in p) self.assertFalse('two \\ three' in p) self.assertFalse('two / three' in p) def test_sanitize_unix_replaces_leading_dot(self): p = util.sanitize_path(u'one/.two/three', posixpath) self.assertFalse('.' in p) def test_sanitize_windows_replaces_trailing_dot(self): p = util.sanitize_path(u'one/two./three', ntpath) self.assertFalse('.' in p) def test_sanitize_windows_replaces_illegal_chars(self): p = util.sanitize_path(u':*?"<>|', ntpath) self.assertFalse(':' in p) self.assertFalse('*' in p) self.assertFalse('?' in p) self.assertFalse('"' in p) self.assertFalse('<' in p) self.assertFalse('>' in p) self.assertFalse('|' in p) def test_path_with_format(self): self.lib.path_formats = [('default', '$artist/$album ($format)')] p = self.i.destination() self.assert_('(FLAC)' in p) def test_heterogeneous_album_gets_single_directory(self): i1, i2 = item(), item() self.lib.add_album([i1, i2]) i1.year, i2.year = 2009, 2010 self.lib.path_formats = [('default', '$album ($year)/$track $title')] dest1, dest2 = i1.destination(), i2.destination() self.assertEqual(os.path.dirname(dest1), os.path.dirname(dest2)) def test_default_path_for_non_compilations(self): self.i.comp = False self.lib.add_album([self.i]) self.lib.directory = 'one' self.lib.path_formats = [('default', 'two'), ('comp:true', 'three')] self.assertEqual(self.i.destination(), np('one/two')) def test_singleton_path(self): i = item(self.lib) self.lib.directory = 'one' self.lib.path_formats = [ ('default', 'two'), ('singleton:true', 'four'), ('comp:true', 'three'), ] self.assertEqual(i.destination(), np('one/four')) def test_comp_before_singleton_path(self): i = item(self.lib) i.comp = True self.lib.directory = 'one' self.lib.path_formats = [ ('default', 'two'), ('comp:true', 'three'), ('singleton:true', 'four'), ] self.assertEqual(i.destination(), np('one/three')) def test_comp_path(self): self.i.comp = True self.lib.add_album([self.i]) self.lib.directory = 'one' self.lib.path_formats = [ ('default', 'two'), ('comp:true', 'three'), ] self.assertEqual(self.i.destination(), np('one/three')) def test_albumtype_query_path(self): self.i.comp = True self.lib.add_album([self.i]) self.i.albumtype = 'sometype' self.lib.directory = 'one' self.lib.path_formats = [ ('default', 'two'), ('albumtype:sometype', 'four'), ('comp:true', 'three'), ] self.assertEqual(self.i.destination(), np('one/four')) def test_albumtype_path_fallback_to_comp(self): self.i.comp = True self.lib.add_album([self.i]) self.i.albumtype = 'sometype' self.lib.directory = 'one' self.lib.path_formats = [ ('default', 'two'), ('albumtype:anothertype', 'four'), ('comp:true', 'three'), ] self.assertEqual(self.i.destination(), np('one/three')) def test_sanitize_windows_replaces_trailing_space(self): p = util.sanitize_path(u'one/two /three', ntpath) self.assertFalse(' ' in p) def test_component_sanitize_replaces_separators(self): name = posixpath.join('a', 'b') newname = beets.library.format_for_path(name, None, posixpath) self.assertNotEqual(name, newname) def test_component_sanitize_pads_with_zero(self): name = beets.library.format_for_path(1, 'track', posixpath) self.assertTrue(name.startswith('0')) def test_component_sanitize_uses_kbps_bitrate(self): val = beets.library.format_for_path(12345, 'bitrate', posixpath) self.assertEqual(val, u'12kbps') def test_component_sanitize_uses_khz_samplerate(self): val = beets.library.format_for_path(12345, 'samplerate', posixpath) self.assertEqual(val, u'12kHz') def test_component_sanitize_datetime(self): val = beets.library.format_for_path(1368302461.210265, 'added', posixpath) self.assertTrue(val.startswith('2013')) def test_component_sanitize_none(self): val = beets.library.format_for_path(None, 'foo', posixpath) self.assertEqual(val, u'') def test_artist_falls_back_to_albumartist(self): self.i.artist = '' self.i.albumartist = 'something' self.lib.path_formats = [('default', '$artist')] p = self.i.destination() self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'something') def test_albumartist_falls_back_to_artist(self): self.i.artist = 'trackartist' self.i.albumartist = '' self.lib.path_formats = [('default', '$albumartist')] p = self.i.destination() self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'trackartist') def test_artist_overrides_albumartist(self): self.i.artist = 'theartist' self.i.albumartist = 'something' self.lib.path_formats = [('default', '$artist')] p = self.i.destination() self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'theartist') def test_albumartist_overrides_artist(self): self.i.artist = 'theartist' self.i.albumartist = 'something' self.lib.path_formats = [('default', '$albumartist')] p = self.i.destination() self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'something') def test_sanitize_path_works_on_empty_string(self): p = util.sanitize_path(u'', posixpath) self.assertEqual(p, u'') def test_sanitize_with_custom_replace_overrides_built_in_sub(self): p = util.sanitize_path(u'a/.?/b', posixpath, [ (re.compile(ur'foo'), u'bar'), ]) self.assertEqual(p, u'a/.?/b') def test_sanitize_with_custom_replace_adds_replacements(self): p = util.sanitize_path(u'foo/bar', posixpath, [ (re.compile(ur'foo'), u'bar'), ]) self.assertEqual(p, u'bar/bar') def test_unicode_normalized_nfd_on_mac(self): instr = unicodedata.normalize('NFC', u'caf\xe9') self.lib.path_formats = [('default', instr)] dest = self.i.destination(platform='darwin', fragment=True) self.assertEqual(dest, unicodedata.normalize('NFD', instr)) def test_unicode_normalized_nfc_on_linux(self): instr = unicodedata.normalize('NFD', u'caf\xe9') self.lib.path_formats = [('default', instr)] dest = self.i.destination(platform='linux2', fragment=True) self.assertEqual(dest, unicodedata.normalize('NFC', instr)) def test_non_mbcs_characters_on_windows(self): oldfunc = sys.getfilesystemencoding sys.getfilesystemencoding = lambda: 'mbcs' try: self.i.title = u'h\u0259d' self.lib.path_formats = [('default', '$title')] p = self.i.destination() self.assertFalse('?' in p) # We use UTF-8 to encode Windows paths now. self.assertTrue(u'h\u0259d'.encode('utf8') in p) finally: sys.getfilesystemencoding = oldfunc def test_unicode_extension_in_fragment(self): self.lib.path_formats = [('default', u'foo')] self.i.path = util.bytestring_path(u'bar.caf\xe9') dest = self.i.destination(platform='linux2', fragment=True) self.assertEqual(dest, u'foo.caf\xe9') class PathFormattingMixin(object): """Utilities for testing path formatting.""" def _setf(self, fmt): self.lib.path_formats.insert(0, ('default', fmt)) def _assert_dest(self, dest, i=None): if i is None: i = self.i self.assertEqual(i.destination(pathmod=posixpath), dest) class DestinationFunctionTest(_common.TestCase, PathFormattingMixin): def setUp(self): super(DestinationFunctionTest, self).setUp() self.lib = beets.library.Library(':memory:') self.lib.directory = '/base' self.lib.path_formats = [('default', u'path')] self.i = item(self.lib) def tearDown(self): super(DestinationFunctionTest, self).tearDown() self.lib._connection().close() def test_upper_case_literal(self): self._setf(u'%upper{foo}') self._assert_dest('/base/FOO') def test_upper_case_variable(self): self._setf(u'%upper{$title}') self._assert_dest('/base/THE TITLE') def test_title_case_variable(self): self._setf(u'%title{$title}') self._assert_dest('/base/The Title') def test_left_variable(self): self._setf(u'%left{$title, 3}') self._assert_dest('/base/the') def test_right_variable(self): self._setf(u'%right{$title,3}') self._assert_dest('/base/tle') def test_if_false(self): self._setf(u'x%if{,foo}') self._assert_dest('/base/x') def test_if_true(self): self._setf(u'%if{bar,foo}') self._assert_dest('/base/foo') def test_if_else_false(self): self._setf(u'%if{,foo,baz}') self._assert_dest('/base/baz') def test_if_int_value(self): self._setf(u'%if{0,foo,baz}') self._assert_dest('/base/baz') def test_nonexistent_function(self): self._setf(u'%foo{bar}') self._assert_dest('/base/%foo{bar}') class DisambiguationTest(_common.TestCase, PathFormattingMixin): def setUp(self): super(DisambiguationTest, self).setUp() self.lib = beets.library.Library(':memory:') self.lib.directory = '/base' self.lib.path_formats = [('default', u'path')] self.i1 = item() self.i1.year = 2001 self.lib.add_album([self.i1]) self.i2 = item() self.i2.year = 2002 self.lib.add_album([self.i2]) self.lib._connection().commit() self._setf(u'foo%aunique{albumartist album,year}/$title') def tearDown(self): super(DisambiguationTest, self).tearDown() self.lib._connection().close() def test_unique_expands_to_disambiguating_year(self): self._assert_dest('/base/foo [2001]/the title', self.i1) def test_unique_with_default_arguments_uses_albumtype(self): album2 = self.lib.get_album(self.i1) album2.albumtype = 'bar' album2.store() self._setf(u'foo%aunique{}/$title') self._assert_dest('/base/foo [bar]/the title', self.i1) def test_unique_expands_to_nothing_for_distinct_albums(self): album2 = self.lib.get_album(self.i2) album2.album = 'different album' album2.store() self._assert_dest('/base/foo/the title', self.i1) def test_use_fallback_numbers_when_identical(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 album2.store() self._assert_dest('/base/foo 1/the title', self.i1) self._assert_dest('/base/foo 2/the title', self.i2) def test_unique_falls_back_to_second_distinguishing_field(self): self._setf(u'foo%aunique{albumartist album,month year}/$title') self._assert_dest('/base/foo [2001]/the title', self.i1) def test_unique_sanitized(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 album1 = self.lib.get_album(self.i1) album1.albumtype = 'foo/bar' album2.store() album1.store() self._setf(u'foo%aunique{albumartist album,albumtype}/$title') self._assert_dest('/base/foo [foo_bar]/the title', self.i1) class PathConversionTest(_common.TestCase): def test_syspath_windows_format(self): path = ntpath.join('a', 'b', 'c') outpath = util.syspath(path, pathmod=ntpath) self.assertTrue(isinstance(outpath, unicode)) self.assertTrue(outpath.startswith(u'\\\\?\\')) def test_syspath_posix_unchanged(self): path = posixpath.join('a', 'b', 'c') outpath = util.syspath(path, pathmod=posixpath) self.assertEqual(path, outpath) def _windows_bytestring_path(self, path): old_gfse = sys.getfilesystemencoding sys.getfilesystemencoding = lambda: 'mbcs' try: return util.bytestring_path(path, ntpath) finally: sys.getfilesystemencoding = old_gfse def test_bytestring_path_windows_encodes_utf8(self): path = u'caf\xe9' outpath = self._windows_bytestring_path(path) self.assertEqual(path, outpath.decode('utf8')) def test_bytesting_path_windows_removes_magic_prefix(self): path = u'\\\\?\\C:\\caf\xe9' outpath = self._windows_bytestring_path(path) self.assertEqual(outpath, u'C:\\caf\xe9'.encode('utf8')) class PluginDestinationTest(_common.TestCase): # Mock the plugins.template_values(item) function. def _template_values(self, item): return self._tv_map def setUp(self): super(PluginDestinationTest, self).setUp() self._tv_map = {} self.old_template_values = plugins.template_values plugins.template_values = self._template_values self.lib = beets.library.Library(':memory:') self.lib.directory = '/base' self.lib.path_formats = [('default', u'$artist $foo')] self.i = item(self.lib) def tearDown(self): super(PluginDestinationTest, self).tearDown() plugins.template_values = self.old_template_values def _assert_dest(self, dest): self.assertEqual(self.i.destination(pathmod=posixpath), '/base/' + dest) def test_undefined_value_not_substituted(self): self._assert_dest('the artist $foo') def test_plugin_value_not_substituted(self): self._tv_map = { 'foo': 'bar', } self._assert_dest('the artist bar') def test_plugin_value_overrides_attribute(self): self._tv_map = { 'artist': 'bar', } self._assert_dest('bar $foo') def test_plugin_value_sanitized(self): self._tv_map = { 'foo': 'bar/baz', } self._assert_dest('the artist bar_baz') class MigrationTest(_common.TestCase): """Tests the ability to change the database schema between versions. """ def setUp(self): super(MigrationTest, self).setUp() # Three different "schema versions". self.older_fields = [('field_one', int)] self.old_fields = self.older_fields + [('field_two', int)] self.new_fields = self.old_fields + [('field_three', int)] self.newer_fields = self.new_fields + [('field_four', int)] # Set up a library with old_fields. self.libfile = os.path.join(_common.RSRC, 'templib.blb') old_lib = beets.library.Library(self.libfile, item_fields=self.old_fields) # Add an item to the old library. old_lib._connection().execute( 'insert into items (field_one, field_two) values (4, 2)' ) old_lib._connection().commit() del old_lib def tearDown(self): super(MigrationTest, self).tearDown() os.unlink(self.libfile) def test_open_with_same_fields_leaves_untouched(self): new_lib = beets.library.Library(self.libfile, item_fields=self.old_fields) c = new_lib._connection().cursor() c.execute("select * from items") row = c.fetchone() self.assertEqual(len(row.keys()), len(self.old_fields)) def test_open_with_new_field_adds_column(self): new_lib = beets.library.Library(self.libfile, item_fields=self.new_fields) c = new_lib._connection().cursor() c.execute("select * from items") row = c.fetchone() self.assertEqual(len(row.keys()), len(self.new_fields)) def test_open_with_fewer_fields_leaves_untouched(self): new_lib = beets.library.Library(self.libfile, item_fields=self.older_fields) c = new_lib._connection().cursor() c.execute("select * from items") row = c.fetchone() self.assertEqual(len(row.keys()), len(self.old_fields)) def test_open_with_multiple_new_fields(self): new_lib = beets.library.Library(self.libfile, item_fields=self.newer_fields) c = new_lib._connection().cursor() c.execute("select * from items") row = c.fetchone() self.assertEqual(len(row.keys()), len(self.newer_fields)) def test_open_old_db_adds_album_table(self): conn = sqlite3.connect(self.libfile) conn.execute('drop table albums') conn.close() conn = sqlite3.connect(self.libfile) self.assertRaises(sqlite3.OperationalError, conn.execute, 'select * from albums') conn.close() new_lib = beets.library.Library(self.libfile, item_fields=self.newer_fields) try: new_lib._connection().execute("select * from albums") except sqlite3.OperationalError: self.fail("select failed") def test_album_data_preserved(self): conn = sqlite3.connect(self.libfile) conn.execute('drop table albums') conn.execute('create table albums (id primary key, album)') conn.execute("insert into albums values (1, 'blah')") conn.commit() conn.close() new_lib = beets.library.Library(self.libfile, item_fields=self.newer_fields) albums = new_lib._connection().execute( 'select * from albums' ).fetchall() self.assertEqual(len(albums), 1) self.assertEqual(albums[0][1], 'blah') def test_move_artist_to_albumartist(self): conn = sqlite3.connect(self.libfile) conn.execute('drop table albums') conn.execute('create table albums (id primary key, artist)') conn.execute("insert into albums values (1, 'theartist')") conn.commit() conn.close() new_lib = beets.library.Library(self.libfile, item_fields=self.newer_fields) c = new_lib._connection().execute("select * from albums") album = c.fetchone() self.assertEqual(album['albumartist'], 'theartist') class AlbumInfoTest(_common.TestCase): def setUp(self): super(AlbumInfoTest, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item() self.lib.add_album((self.i,)) def test_albuminfo_reflects_metadata(self): ai = self.lib.get_album(self.i) self.assertEqual(ai.mb_albumartistid, self.i.mb_albumartistid) self.assertEqual(ai.albumartist, self.i.albumartist) self.assertEqual(ai.album, self.i.album) self.assertEqual(ai.year, self.i.year) def test_albuminfo_stores_art(self): ai = self.lib.get_album(self.i) ai.artpath = '/my/great/art' ai.store() new_ai = self.lib.get_album(self.i) self.assertEqual(new_ai.artpath, '/my/great/art') def test_albuminfo_for_two_items_doesnt_duplicate_row(self): i2 = item(self.lib) self.lib.get_album(self.i) self.lib.get_album(i2) c = self.lib._connection().cursor() c.execute('select * from albums where album=?', (self.i.album,)) # Cursor should only return one row. self.assertNotEqual(c.fetchone(), None) self.assertEqual(c.fetchone(), None) def test_individual_tracks_have_no_albuminfo(self): i2 = item() i2.album = 'aTotallyDifferentAlbum' self.lib.add(i2) ai = self.lib.get_album(i2) self.assertEqual(ai, None) def test_get_album_by_id(self): ai = self.lib.get_album(self.i) ai = self.lib.get_album(self.i.id) self.assertNotEqual(ai, None) def test_album_items_consistent(self): ai = self.lib.get_album(self.i) for item in ai.items(): if item.id == self.i.id: break else: self.fail("item not found") def test_albuminfo_changes_affect_items(self): ai = self.lib.get_album(self.i) ai.album = 'myNewAlbum' ai.store() i = self.lib.items()[0] self.assertEqual(i.album, 'myNewAlbum') def test_albuminfo_change_albumartist_changes_items(self): ai = self.lib.get_album(self.i) ai.albumartist = 'myNewArtist' ai.store() i = self.lib.items()[0] self.assertEqual(i.albumartist, 'myNewArtist') self.assertNotEqual(i.artist, 'myNewArtist') def test_albuminfo_change_artist_does_not_change_items(self): ai = self.lib.get_album(self.i) ai.artist = 'myNewArtist' ai.store() i = self.lib.items()[0] self.assertNotEqual(i.artist, 'myNewArtist') def test_albuminfo_remove_removes_items(self): item_id = self.i.id self.lib.get_album(self.i).remove() c = self.lib._connection().execute( 'SELECT id FROM items WHERE id=?', (item_id,) ) self.assertEqual(c.fetchone(), None) def test_removing_last_item_removes_album(self): self.assertEqual(len(self.lib.albums()), 1) self.i.remove() self.assertEqual(len(self.lib.albums()), 0) class ArtDestinationTest(_common.TestCase): def setUp(self): super(ArtDestinationTest, self).setUp() config['art_filename'] = u'artimage' config['replace'] = {u'X': u'Y'} self.lib = beets.library.Library( ':memory:', replacements=[(re.compile(u'X'), u'Y')] ) self.i = item(self.lib) self.i.path = self.i.destination() self.ai = self.lib.add_album((self.i,)) def test_art_filename_respects_setting(self): art = self.ai.art_destination('something.jpg') self.assert_('%sartimage.jpg' % os.path.sep in art) def test_art_path_in_item_dir(self): art = self.ai.art_destination('something.jpg') track = self.i.destination() self.assertEqual(os.path.dirname(art), os.path.dirname(track)) def test_art_path_sanitized(self): config['art_filename'] = u'artXimage' art = self.ai.art_destination('something.jpg') self.assert_('artYimage' in art) class PathStringTest(_common.TestCase): def setUp(self): super(PathStringTest, self).setUp() self.lib = beets.library.Library(':memory:') self.i = item(self.lib) def test_item_path_is_bytestring(self): self.assert_(isinstance(self.i.path, str)) def test_fetched_item_path_is_bytestring(self): i = list(self.lib.items())[0] self.assert_(isinstance(i.path, str)) def test_unicode_path_becomes_bytestring(self): self.i.path = u'unicodepath' self.assert_(isinstance(self.i.path, str)) def test_unicode_in_database_becomes_bytestring(self): self.lib._connection().execute(""" update items set path=? where id=? """, (self.i.id, u'somepath')) i = list(self.lib.items())[0] self.assert_(isinstance(i.path, str)) def test_special_chars_preserved_in_database(self): path = 'b\xe1r' self.i.path = path self.i.store() i = list(self.lib.items())[0] self.assertEqual(i.path, path) def test_special_char_path_added_to_database(self): self.i.remove() path = 'b\xe1r' i = item() i.path = path self.lib.add(i) i = list(self.lib.items())[0] self.assertEqual(i.path, path) def test_destination_returns_bytestring(self): self.i.artist = u'b\xe1r' dest = self.i.destination() self.assert_(isinstance(dest, str)) def test_art_destination_returns_bytestring(self): self.i.artist = u'b\xe1r' alb = self.lib.add_album([self.i]) dest = alb.art_destination(u'image.jpg') self.assert_(isinstance(dest, str)) def test_artpath_stores_special_chars(self): path = b'b\xe1r' alb = self.lib.add_album([self.i]) alb.artpath = path alb.store() alb = self.lib.get_album(self.i) self.assertEqual(path, alb.artpath) def test_sanitize_path_with_special_chars(self): path = u'b\xe1r?' new_path = util.sanitize_path(path) self.assert_(new_path.startswith(u'b\xe1r')) def test_sanitize_path_returns_unicode(self): path = u'b\xe1r?' new_path = util.sanitize_path(path) self.assert_(isinstance(new_path, unicode)) def test_unicode_artpath_becomes_bytestring(self): alb = self.lib.add_album([self.i]) alb.artpath = u'somep\xe1th' self.assert_(isinstance(alb.artpath, str)) def test_unicode_artpath_in_database_decoded(self): alb = self.lib.add_album([self.i]) self.lib._connection().execute( "update albums set artpath=? where id=?", (u'somep\xe1th', alb.id) ) alb = self.lib.get_album(alb.id) self.assert_(isinstance(alb.artpath, str)) class PathTruncationTest(_common.TestCase): def test_truncate_bytestring(self): p = util.truncate_path('abcde/fgh', posixpath, 4) self.assertEqual(p, 'abcd/fgh') def test_truncate_unicode(self): p = util.truncate_path(u'abcde/fgh', posixpath, 4) self.assertEqual(p, u'abcd/fgh') def test_truncate_preserves_extension(self): p = util.truncate_path(u'abcde/fgh.ext', posixpath, 5) self.assertEqual(p, u'abcde/f.ext') class MtimeTest(_common.TestCase): def setUp(self): super(MtimeTest, self).setUp() self.ipath = os.path.join(_common.RSRC, 'testfile.mp3') shutil.copy(os.path.join(_common.RSRC, 'full.mp3'), self.ipath) self.i = beets.library.Item.from_path(self.ipath) self.lib = beets.library.Library(':memory:') self.lib.add(self.i) def tearDown(self): super(MtimeTest, self).tearDown() if os.path.exists(self.ipath): os.remove(self.ipath) def _mtime(self): return int(os.path.getmtime(self.ipath)) def test_mtime_initially_up_to_date(self): self.assertGreaterEqual(self.i.mtime, self._mtime()) def test_mtime_reset_on_db_modify(self): self.i.title = 'something else' self.assertLess(self.i.mtime, self._mtime()) def test_mtime_up_to_date_after_write(self): self.i.title = 'something else' self.i.write() self.assertGreaterEqual(self.i.mtime, self._mtime()) def test_mtime_up_to_date_after_read(self): self.i.title = 'something else' self.i.read() self.assertGreaterEqual(self.i.mtime, self._mtime()) class ImportTimeTest(_common.TestCase): def setUp(self): super(ImportTimeTest, self).setUp() self.lib = beets.library.Library(':memory:') def added(self): self.track = item() self.album = self.lib.add_album((self.track,)) self.assertGreater(self.album.added, 0) self.assertGreater(self.track.added, 0) def test_atime_for_singleton(self): self.singleton = item(self.lib) self.assertGreater(self.singleton.added, 0) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_files.py0000644000076500000240000004540512216076771017275 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Test file manipulation functionality of Item. """ import shutil import os import stat from os.path import join import _common from _common import unittest from _common import item, touch import beets.library from beets import util class MoveTest(_common.TestCase): def setUp(self): super(MoveTest, self).setUp() # make a temporary file self.path = join(self.temp_dir, 'temp.mp3') shutil.copy(join(_common.RSRC, 'full.mp3'), self.path) # add it to a temporary library self.lib = beets.library.Library(':memory:') self.i = beets.library.Item.from_path(self.path) self.lib.add(self.i) # set up the destination self.libdir = join(self.temp_dir, 'testlibdir') os.mkdir(self.libdir) self.lib.directory = self.libdir self.lib.path_formats = [('default', join('$artist', '$album', '$title'))] self.i.artist = 'one' self.i.album = 'two' self.i.title = 'three' self.dest = join(self.libdir, 'one', 'two', 'three.mp3') self.otherdir = join(self.temp_dir, 'testotherdir') def test_move_arrives(self): self.i.move() self.assertExists(self.dest) def test_move_to_custom_dir(self): self.i.move(basedir=self.otherdir) self.assertExists(join(self.otherdir, 'one', 'two', 'three.mp3')) def test_move_departs(self): self.i.move() self.assertNotExists(self.path) def test_move_in_lib_prunes_empty_dir(self): self.i.move() old_path = self.i.path self.assertExists(old_path) self.i.artist = 'newArtist' self.i.move() self.assertNotExists(old_path) self.assertNotExists(os.path.dirname(old_path)) def test_copy_arrives(self): self.i.move(copy=True) self.assertExists(self.dest) def test_copy_does_not_depart(self): self.i.move(copy=True) self.assertExists(self.path) def test_move_changes_path(self): self.i.move() self.assertEqual(self.i.path, util.normpath(self.dest)) def test_copy_already_at_destination(self): self.i.move() old_path = self.i.path self.i.move(copy=True) self.assertEqual(self.i.path, old_path) def test_move_already_at_destination(self): self.i.move() old_path = self.i.path self.i.move(copy=False) self.assertEqual(self.i.path, old_path) def test_read_only_file_copied_writable(self): # Make the source file read-only. os.chmod(self.path, 0444) try: self.i.move(copy=True) self.assertTrue(os.access(self.i.path, os.W_OK)) finally: # Make everything writable so it can be cleaned up. os.chmod(self.path, 0777) os.chmod(self.i.path, 0777) def test_move_avoids_collision_with_existing_file(self): # Make a conflicting file at the destination. dest = self.i.destination() os.makedirs(os.path.dirname(dest)) touch(dest) self.i.move() self.assertNotEqual(self.i.path, dest) self.assertEqual(os.path.dirname(self.i.path), os.path.dirname(dest)) class HelperTest(_common.TestCase): def test_ancestry_works_on_file(self): p = '/a/b/c' a = ['/','/a','/a/b'] self.assertEqual(util.ancestry(p), a) def test_ancestry_works_on_dir(self): p = '/a/b/c/' a = ['/', '/a', '/a/b', '/a/b/c'] self.assertEqual(util.ancestry(p), a) def test_ancestry_works_on_relative(self): p = 'a/b/c' a = ['a', 'a/b'] self.assertEqual(util.ancestry(p), a) def test_components_works_on_file(self): p = '/a/b/c' a = ['/', 'a', 'b', 'c'] self.assertEqual(util.components(p), a) def test_components_works_on_dir(self): p = '/a/b/c/' a = ['/', 'a', 'b', 'c'] self.assertEqual(util.components(p), a) def test_components_works_on_relative(self): p = 'a/b/c' a = ['a', 'b', 'c'] self.assertEqual(util.components(p), a) class AlbumFileTest(_common.TestCase): def setUp(self): super(AlbumFileTest, self).setUp() # Make library and item. self.lib = beets.library.Library(':memory:') self.lib.path_formats = \ [('default', join('$albumartist', '$album', '$title'))] self.libdir = os.path.join(self.temp_dir, 'testlibdir') self.lib.directory = self.libdir self.i = item(self.lib) # Make a file for the item. self.i.path = self.i.destination() util.mkdirall(self.i.path) touch(self.i.path) # Make an album. self.ai = self.lib.add_album((self.i,)) # Alternate destination dir. self.otherdir = os.path.join(self.temp_dir, 'testotherdir') def test_albuminfo_move_changes_paths(self): self.ai.album = 'newAlbumName' self.ai.move() self.ai.store() self.i.load() self.assert_('newAlbumName' in self.i.path) def test_albuminfo_move_moves_file(self): oldpath = self.i.path self.ai.album = 'newAlbumName' self.ai.move() self.ai.store() self.i.load() self.assertFalse(os.path.exists(oldpath)) self.assertTrue(os.path.exists(self.i.path)) def test_albuminfo_move_copies_file(self): oldpath = self.i.path self.ai.album = 'newAlbumName' self.ai.move(True) self.ai.store() self.i.load() self.assertTrue(os.path.exists(oldpath)) self.assertTrue(os.path.exists(self.i.path)) def test_albuminfo_move_to_custom_dir(self): self.ai.move(basedir=self.otherdir) self.i.load() self.ai.store() self.assertTrue('testotherdir' in self.i.path) class ArtFileTest(_common.TestCase): def setUp(self): super(ArtFileTest, self).setUp() # Make library and item. self.lib = beets.library.Library(':memory:') self.libdir = os.path.abspath(os.path.join(self.temp_dir, 'testlibdir')) self.lib.directory = self.libdir self.i = item(self.lib) self.i.path = self.i.destination() # Make a music file. util.mkdirall(self.i.path) touch(self.i.path) # Make an album. self.ai = self.lib.add_album((self.i,)) # Make an art file too. self.art = self.lib.get_album(self.i).art_destination('something.jpg') touch(self.art) self.ai.artpath = self.art self.ai.store() # Alternate destination dir. self.otherdir = os.path.join(self.temp_dir, 'testotherdir') def test_art_deleted_when_items_deleted(self): self.assertTrue(os.path.exists(self.art)) self.ai.remove(True) self.assertFalse(os.path.exists(self.art)) def test_art_moves_with_album(self): self.assertTrue(os.path.exists(self.art)) oldpath = self.i.path self.ai.album = 'newAlbum' self.ai.move() self.i.load() self.assertNotEqual(self.i.path, oldpath) self.assertFalse(os.path.exists(self.art)) newart = self.lib.get_album(self.i).art_destination(self.art) self.assertTrue(os.path.exists(newart)) def test_art_moves_with_album_to_custom_dir(self): # Move the album to another directory. self.ai.move(basedir=self.otherdir) self.ai.store() self.i.load() # Art should be in new directory. self.assertNotExists(self.art) newart = self.lib.get_album(self.i).artpath self.assertExists(newart) self.assertTrue('testotherdir' in newart) def test_setart_copies_image(self): os.remove(self.art) newart = os.path.join(self.libdir, 'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(True) self.assertEqual(ai.artpath, None) ai.set_art(newart) self.assertTrue(os.path.exists(ai.artpath)) def test_setart_to_existing_art_works(self): os.remove(self.art) # Original art. newart = os.path.join(self.libdir, 'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(True) ai.set_art(newart) # Set the art again. ai.set_art(ai.artpath) self.assertTrue(os.path.exists(ai.artpath)) def test_setart_to_existing_but_unset_art_works(self): newart = os.path.join(self.libdir, 'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(True) # Copy the art to the destination. artdest = ai.art_destination(newart) shutil.copy(newart, artdest) # Set the art again. ai.set_art(artdest) self.assertTrue(os.path.exists(ai.artpath)) def test_setart_to_conflicting_file_gets_new_path(self): newart = os.path.join(self.libdir, 'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(True) # Make a file at the destination. artdest = ai.art_destination(newart) touch(artdest) # Set the art. ai.set_art(newart) self.assertNotEqual(artdest, ai.artpath) self.assertEqual(os.path.dirname(artdest), os.path.dirname(ai.artpath)) def test_setart_sets_permissions(self): os.remove(self.art) newart = os.path.join(self.libdir, 'newart.jpg') touch(newart) os.chmod(newart, 0400) # read-only try: i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(True) ai.set_art(newart) mode = stat.S_IMODE(os.stat(ai.artpath).st_mode) self.assertTrue(mode & stat.S_IRGRP) self.assertTrue(os.access(ai.artpath, os.W_OK)) finally: # Make everything writable so it can be cleaned up. os.chmod(newart, 0777) os.chmod(ai.artpath, 0777) def test_move_last_file_moves_albumart(self): oldartpath = self.lib.albums()[0].artpath self.assertExists(oldartpath) self.ai.album = 'different_album' self.ai.store() self.ai.items()[0].move() artpath = self.lib.albums()[0].artpath self.assertTrue('different_album' in artpath) self.assertExists(artpath) self.assertNotExists(oldartpath) def test_move_not_last_file_does_not_move_albumart(self): i2 = item() i2.albumid = self.ai.id self.lib.add(i2) oldartpath = self.lib.albums()[0].artpath self.assertExists(oldartpath) self.i.album = 'different_album' self.i.album_id = None # detach from album self.i.move() artpath = self.lib.albums()[0].artpath self.assertFalse('different_album' in artpath) self.assertEqual(artpath, oldartpath) self.assertExists(oldartpath) class RemoveTest(_common.TestCase): def setUp(self): super(RemoveTest, self).setUp() # Make library and item. self.lib = beets.library.Library(':memory:') self.libdir = os.path.abspath(os.path.join(self.temp_dir, 'testlibdir')) self.lib.directory = self.libdir self.i = item(self.lib) self.i.path = self.i.destination() # Make a music file. util.mkdirall(self.i.path) touch(self.i.path) # Make an album with the item. self.ai = self.lib.add_album((self.i,)) def test_removing_last_item_prunes_empty_dir(self): parent = os.path.dirname(self.i.path) self.assertExists(parent) self.i.remove(True) self.assertNotExists(parent) def test_removing_last_item_preserves_nonempty_dir(self): parent = os.path.dirname(self.i.path) touch(os.path.join(parent, 'dummy.txt')) self.i.remove(True) self.assertExists(parent) def test_removing_last_item_prunes_dir_with_blacklisted_file(self): parent = os.path.dirname(self.i.path) touch(os.path.join(parent, '.DS_Store')) self.i.remove(True) self.assertNotExists(parent) def test_removing_without_delete_leaves_file(self): path = self.i.path self.i.remove(False) self.assertExists(path) def test_removing_last_item_preserves_library_dir(self): self.i.remove(True) self.assertExists(self.libdir) def test_removing_item_outside_of_library_deletes_nothing(self): self.lib.directory = os.path.abspath(os.path.join(self.temp_dir, 'xxx')) parent = os.path.dirname(self.i.path) self.i.remove(True) self.assertExists(parent) def test_removing_last_item_in_album_with_albumart_prunes_dir(self): artfile = os.path.join(self.temp_dir, 'testart.jpg') touch(artfile) self.ai.set_art(artfile) self.ai.store() parent = os.path.dirname(self.i.path) self.i.remove(True) self.assertNotExists(parent) # Tests that we can "delete" nonexistent files. class SoftRemoveTest(_common.TestCase): def setUp(self): super(SoftRemoveTest, self).setUp() self.path = os.path.join(self.temp_dir, 'testfile') touch(self.path) def test_soft_remove_deletes_file(self): util.remove(self.path, True) self.assertNotExists(self.path) def test_soft_remove_silent_on_no_file(self): try: util.remove(self.path + 'XXX', True) except OSError: self.fail('OSError when removing path') class SafeMoveCopyTest(_common.TestCase): def setUp(self): super(SafeMoveCopyTest, self).setUp() self.path = os.path.join(self.temp_dir, 'testfile') touch(self.path) self.otherpath = os.path.join(self.temp_dir, 'testfile2') touch(self.otherpath) self.dest = self.path + '.dest' def test_successful_move(self): util.move(self.path, self.dest) self.assertExists(self.dest) self.assertNotExists(self.path) def test_successful_copy(self): util.copy(self.path, self.dest) self.assertExists(self.dest) self.assertExists(self.path) def test_unsuccessful_move(self): with self.assertRaises(util.FilesystemError): util.move(self.path, self.otherpath) def test_unsuccessful_copy(self): with self.assertRaises(util.FilesystemError): util.copy(self.path, self.otherpath) def test_self_move(self): util.move(self.path, self.path) self.assertExists(self.path) def test_self_copy(self): util.copy(self.path, self.path) self.assertExists(self.path) class PruneTest(_common.TestCase): def setUp(self): super(PruneTest, self).setUp() self.base = os.path.join(self.temp_dir, 'testdir') os.mkdir(self.base) self.sub = os.path.join(self.base, 'subdir') os.mkdir(self.sub) def test_prune_existent_directory(self): util.prune_dirs(self.sub, self.base) self.assertExists(self.base) self.assertNotExists(self.sub) def test_prune_nonexistent_directory(self): util.prune_dirs(os.path.join(self.sub, 'another'), self.base) self.assertExists(self.base) self.assertNotExists(self.sub) class WalkTest(_common.TestCase): def setUp(self): super(WalkTest, self).setUp() self.base = os.path.join(self.temp_dir, 'testdir') os.mkdir(self.base) touch(os.path.join(self.base, 'y')) touch(os.path.join(self.base, 'x')) os.mkdir(os.path.join(self.base, 'd')) touch(os.path.join(self.base, 'd', 'z')) def test_sorted_files(self): res = list(util.sorted_walk(self.base)) self.assertEqual(len(res), 2) self.assertEqual(res[0], (self.base, ['d'], ['x', 'y'])) self.assertEqual(res[1], (os.path.join(self.base, 'd'), [], ['z'])) def test_ignore_file(self): res = list(util.sorted_walk(self.base, ('x',))) self.assertEqual(len(res), 2) self.assertEqual(res[0], (self.base, ['d'], ['y'])) self.assertEqual(res[1], (os.path.join(self.base, 'd'), [], ['z'])) def test_ignore_directory(self): res = list(util.sorted_walk(self.base, ('d',))) self.assertEqual(len(res), 1) self.assertEqual(res[0], (self.base, [], ['x', 'y'])) def test_ignore_everything(self): res = list(util.sorted_walk(self.base, ('*',))) self.assertEqual(len(res), 1) self.assertEqual(res[0], (self.base, [], [])) class UniquePathTest(_common.TestCase): def setUp(self): super(UniquePathTest, self).setUp() self.base = os.path.join(self.temp_dir, 'testdir') os.mkdir(self.base) touch(os.path.join(self.base, 'x.mp3')) touch(os.path.join(self.base, 'x.1.mp3')) touch(os.path.join(self.base, 'x.2.mp3')) touch(os.path.join(self.base, 'y.mp3')) def test_new_file_unchanged(self): path = util.unique_path(os.path.join(self.base, 'z.mp3')) self.assertEqual(path, os.path.join(self.base, 'z.mp3')) def test_conflicting_file_appends_1(self): path = util.unique_path(os.path.join(self.base, 'y.mp3')) self.assertEqual(path, os.path.join(self.base, 'y.1.mp3')) def test_conflicting_file_appends_higher_number(self): path = util.unique_path(os.path.join(self.base, 'x.mp3')) self.assertEqual(path, os.path.join(self.base, 'x.3.mp3')) def test_conflicting_file_with_number_increases_number(self): path = util.unique_path(os.path.join(self.base, 'x.1.mp3')) self.assertEqual(path, os.path.join(self.base, 'x.3.mp3')) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_ihate.py0000644000076500000240000000412012207240712017236 0ustar asampsonstaff00000000000000"""Tests for the 'ihate' plugin""" from _common import unittest from beets.importer import ImportTask from beets.library import Item from beetsplug.ihate import IHatePlugin class IHatePluginTest(unittest.TestCase): def test_hate(self): genre_p = [] artist_p = [] album_p = [] white_p = [] task = ImportTask() task.cur_artist = u'Test Artist' task.cur_album = u'Test Album' task.items = [Item(genre='Test Genre')] self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, album_p, white_p)) genre_p = 'some_genre test\sgenre'.split() self.assertTrue(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, album_p, white_p)) genre_p = [] artist_p = 'bad_artist test\sartist' self.assertTrue(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, album_p, white_p)) artist_p = [] album_p = 'tribute christmas test'.split() self.assertTrue(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, album_p, white_p)) album_p = [] white_p = 'goodband test\sartist another_band'.split() genre_p = 'some_genre test\sgenre'.split() self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, album_p, white_p)) genre_p = [] artist_p = 'bad_artist test\sartist' self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, album_p, white_p)) artist_p = [] album_p = 'tribute christmas test'.split() self.assertFalse(IHatePlugin.do_i_hate_this(task, genre_p, artist_p, album_p, white_p)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_importer.py0000644000076500000240000006660512215762107020033 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for the general importer functionality. """ import os import shutil import StringIO import _common from _common import unittest from beets import library from beets import importer from beets import mediafile from beets.autotag import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch from beets import config TEST_TITLES = ('The Opener', 'The Second Track', 'The Last Track') class NonAutotaggedImportTest(_common.TestCase): def setUp(self): super(NonAutotaggedImportTest, self).setUp() self.io.install() self.libdb = os.path.join(self.temp_dir, 'testlib.blb') self.lib = library.Library(self.libdb) self.libdir = os.path.join(self.temp_dir, 'testlibdir') self.lib.directory = self.libdir self.lib.path_formats = [( 'default', os.path.join('$artist', '$album', '$title') )] self.srcdir = os.path.join(self.temp_dir, 'testsrcdir') def _create_test_file(self, filepath, metadata): """Creates an mp3 file at the given path within self.srcdir. filepath is given as an array of folder names, ending with the file name. Sets the file's metadata from the provided dict. Returns the full, real path to the file. """ realpath = os.path.join(self.srcdir, *filepath) if not os.path.exists(os.path.dirname(realpath)): os.makedirs(os.path.dirname(realpath)) shutil.copy(os.path.join(_common.RSRC, 'full.mp3'), realpath) f = mediafile.MediaFile(realpath) for attr in metadata: setattr(f, attr, metadata[attr]) f.save() return realpath def _run_import(self, titles=TEST_TITLES, delete=False, threaded=False, singletons=False, move=False): # Make a bunch of tracks to import. paths = [] for i, title in enumerate(titles): paths.append(self._create_test_file( ['the_album', 'track_%s.mp3' % (i+1)], { 'track': (i+1), 'artist': 'The Artist', 'album': 'The Album', 'title': title, })) # Run the UI "beet import" command! config['import']['delete'] = delete config['import']['threaded'] = threaded config['import']['singletons'] = singletons config['import']['move'] = move config['import']['autotag'] = False session = importer.ImportSession(self.lib, logfile=None, paths=[os.path.dirname(paths[0])], query=None) session.run() return paths def test_album_created_with_track_artist(self): self._run_import() albums = self.lib.albums() self.assertEqual(len(albums), 1) self.assertEqual(albums[0].albumartist, 'The Artist') def _copy_arrives(self): artist_folder = os.path.join(self.libdir, 'The Artist') album_folder = os.path.join(artist_folder, 'The Album') self.assertEqual(len(os.listdir(artist_folder)), 1) self.assertEqual(len(os.listdir(album_folder)), 3) filenames = set(os.listdir(album_folder)) destinations = set('%s.mp3' % title for title in TEST_TITLES) self.assertEqual(filenames, destinations) def test_import_copy_arrives_but_leaves_originals(self): paths = self._run_import() self._copy_arrives() for path in paths: self.assertTrue(os.path.exists(path)) def test_threaded_import_copy_arrives(self): paths = self._run_import(threaded=True) self._copy_arrives() for path in paths: self.assertTrue(os.path.exists(path)) def test_import_move(self): paths = self._run_import(move=True) self._copy_arrives() for path in paths: self.assertFalse(os.path.exists(path)) def test_threaded_import_move(self): paths = self._run_import(threaded=True, move=True) self._copy_arrives() for path in paths: self.assertFalse(os.path.exists(path)) def test_import_no_delete(self): paths = self._run_import(['sometrack'], delete=False) self.assertTrue(os.path.exists(paths[0])) def test_import_with_delete(self): paths = self._run_import(['sometrack'], delete=True) self.assertFalse(os.path.exists(paths[0])) def test_import_singleton(self): paths = self._run_import(['sometrack'], singletons=True) self.assertTrue(os.path.exists(paths[0])) # Utilities for invoking the apply_choices, manipulate_files, and finalize # coroutines. def _call_stages(session, items, choice_or_info, stages=[importer.apply_choices, importer.manipulate_files, importer.finalize], album=True, toppath=None): # Set up the import task. task = importer.ImportTask(None, None, items) task.is_album = True task.toppath = toppath if not album: task.item = items[0] if isinstance(choice_or_info, importer.action): task.set_choice(choice_or_info) else: mapping = dict(zip(items, choice_or_info.tracks)) task.set_choice(AlbumMatch(0, choice_or_info, mapping, set(), set())) # Call the coroutines. for stage in stages: coro = stage(session) coro.next() coro.send(task) return task class ImportApplyTest(_common.TestCase): def setUp(self): super(ImportApplyTest, self).setUp() self.libdir = os.path.join(self.temp_dir, 'testlibdir') os.mkdir(self.libdir) self.libpath = os.path.join(self.temp_dir, 'testlib.blb') self.lib = library.Library(self.libpath, self.libdir) self.lib.path_formats = [ ('default', 'one'), ('singleton:true', 'three'), ('comp:true', 'two'), ] self.session = _common.import_session(self.lib) self.srcdir = os.path.join(self.temp_dir, 'testsrcdir') os.mkdir(self.srcdir) os.mkdir(os.path.join(self.srcdir, 'testalbum')) self.srcpath = os.path.join(self.srcdir, 'testalbum', 'srcfile.mp3') shutil.copy(os.path.join(_common.RSRC, 'full.mp3'), self.srcpath) self.i = library.Item.from_path(self.srcpath) self.i.comp = False self.lib.add(self.i) trackinfo = TrackInfo('one', 'trackid', 'some artist', 'artistid', 1) self.info = AlbumInfo( artist = 'some artist', album = 'some album', tracks = [trackinfo], va = False, album_id = 'albumid', artist_id = 'artistid', albumtype = 'soundtrack', ) def test_finalize_no_delete(self): config['import']['delete'] = False _call_stages(self.session, [self.i], self.info) self.assertExists(self.srcpath) def test_finalize_with_delete(self): config['import']['delete'] = True _call_stages(self.session, [self.i], self.info) self.assertNotExists(self.srcpath) def test_finalize_with_delete_prunes_directory_empty(self): config['import']['delete'] = True _call_stages(self.session, [self.i], self.info, toppath=self.srcdir) self.assertNotExists(os.path.dirname(self.srcpath)) def test_apply_asis_uses_album_path(self): _call_stages(self.session, [self.i], importer.action.ASIS) self.assertExists(os.path.join(self.libdir, 'one.mp3')) def test_apply_match_uses_album_path(self): _call_stages(self.session, [self.i], self.info) self.assertExists(os.path.join(self.libdir, 'one.mp3')) def test_apply_tracks_uses_singleton_path(self): apply_coro = importer.apply_choices(self.session) apply_coro.next() manip_coro = importer.manipulate_files(self.session) manip_coro.next() task = importer.ImportTask.item_task(self.i) task.set_choice(TrackMatch(0, self.info.tracks[0])) apply_coro.send(task) manip_coro.send(task) self.assertExists( os.path.join(self.libdir, 'three.mp3') ) def test_apply_sentinel(self): coro = importer.apply_choices(self.session) coro.next() coro.send(importer.ImportTask.done_sentinel('toppath')) # Just test no exception for now. def test_apply_populates_old_paths(self): task = _call_stages(self.session, [self.i], self.info) self.assertEqual(task.old_paths, [self.srcpath]) def test_reimport_inside_file_moves_and_does_not_add_to_old_paths(self): """Reimporting a file *inside* the library directory should *move* the file. """ # Add the item to the library while inside the library directory. internal_srcpath = os.path.join(self.libdir, 'source.mp3') shutil.move(self.srcpath, internal_srcpath) temp_item = library.Item.from_path(internal_srcpath) self.lib.add(temp_item) self.lib._connection().commit() self.i = library.Item.from_path(internal_srcpath) self.i.comp = False # Then, re-import the same file. task = _call_stages(self.session, [self.i], self.info) # Old file should be gone. self.assertNotExists(internal_srcpath) # New file should be present. self.assertExists(os.path.join(self.libdir, 'one.mp3')) # Also, the old file should not be in old_paths because it does # not exist. self.assertEqual(task.old_paths, []) def test_reimport_outside_file_copies(self): """Reimporting a file *outside* the library directory should *copy* the file (when copying is enabled). """ # First, add the item to the library. temp_item = library.Item.from_path(self.srcpath) self.lib.add(temp_item) self.lib._connection().commit() # Then, re-import the same file. task = _call_stages(self.session, [self.i], self.info) # Old file should still exist. self.assertExists(self.srcpath) # New file should also be present. self.assertExists(os.path.join(self.libdir, 'one.mp3')) # The old (copy-source) file should be marked for possible # deletion. self.assertEqual(task.old_paths, [self.srcpath]) def test_apply_with_move(self): config['import']['move'] = True _call_stages(self.session, [self.i], self.info) self.assertExists(list(self.lib.items())[0].path) self.assertNotExists(self.srcpath) def test_apply_with_move_prunes_empty_directory(self): config['import']['move'] = True _call_stages(self.session, [self.i], self.info, toppath=self.srcdir) self.assertNotExists(os.path.dirname(self.srcpath)) def test_apply_with_move_prunes_with_extra_clutter(self): f = open(os.path.join(self.srcdir, 'testalbum', 'alog.log'), 'w') f.close() config['clutter'] = ['*.log'] config['import']['move'] = True _call_stages(self.session, [self.i], self.info, toppath=self.srcdir) self.assertNotExists(os.path.dirname(self.srcpath)) def test_manipulate_files_with_null_move(self): """It should be possible to "move" a file even when the file is already at the destination. """ self.i.move() # Already at destination. config['import']['move'] = True _call_stages(self.session, [self.i], self.info, toppath=self.srcdir, stages=[importer.manipulate_files]) self.assertExists(self.i.path) class AsIsApplyTest(_common.TestCase): def setUp(self): super(AsIsApplyTest, self).setUp() self.dbpath = os.path.join(self.temp_dir, 'templib.blb') self.lib = library.Library(self.dbpath) self.session = _common.import_session(self.lib) # Make an "album" that has a homogenous artist. (Modified by # individual tests.) i1 = _common.item() i2 = _common.item() i3 = _common.item() i1.title = 'first item' i2.title = 'second item' i3.title = 'third item' i1.comp = i2.comp = i3.comp = False i1.albumartist = i2.albumartist = i3.albumartist = '' self.items = [i1, i2, i3] def _apply_result(self): """Run the "apply" coroutines and get the resulting Album.""" _call_stages(self.session, self.items, importer.action.ASIS, stages=[importer.apply_choices]) return self.lib.albums()[0] def test_asis_homogenous_va_not_set(self): alb = self._apply_result() self.assertFalse(alb.comp) self.assertEqual(alb.albumartist, self.items[2].artist) def test_asis_heterogenous_va_set(self): self.items[0].artist = 'another artist' self.items[1].artist = 'some other artist' alb = self._apply_result() self.assertTrue(alb.comp) self.assertEqual(alb.albumartist, 'Various Artists') def test_asis_majority_artist_va_not_set(self): self.items[0].artist = 'another artist' alb = self._apply_result() self.assertFalse(alb.comp) self.assertEqual(alb.albumartist, self.items[2].artist) class ApplyExistingItemsTest(_common.TestCase): def setUp(self): super(ApplyExistingItemsTest, self).setUp() self.libdir = os.path.join(self.temp_dir, 'testlibdir') os.mkdir(self.libdir) self.dbpath = os.path.join(self.temp_dir, 'templib.blb') self.lib = library.Library(self.dbpath, self.libdir) self.lib.path_formats = [ ('default', '$artist/$title'), ] self.session = _common.import_session(self.lib) config['import']['write'] = False config['import']['copy'] = False self.srcpath = os.path.join(self.libdir, 'srcfile.mp3') shutil.copy(os.path.join(_common.RSRC, 'full.mp3'), self.srcpath) self.i = library.Item.from_path(self.srcpath) self.i.comp = False def _apply_asis(self, items, album=True): """Run the "apply" coroutine.""" _call_stages(self.session, items, importer.action.ASIS, album=album, stages=[importer.apply_choices, importer.manipulate_files]) def test_apply_existing_album_does_not_duplicate_item(self): # First, import an item to add it to the library. self._apply_asis([self.i]) # Get the item's path and import it again. item = self.lib.items().get() new_item = library.Item.from_path(item.path) self._apply_asis([new_item]) # Should not be duplicated. self.assertEqual(len(list(self.lib.items())), 1) def test_apply_existing_album_does_not_duplicate_album(self): # As above. self._apply_asis([self.i]) item = self.lib.items().get() new_item = library.Item.from_path(item.path) self._apply_asis([new_item]) # Should not be duplicated. self.assertEqual(len(list(self.lib.albums())), 1) def test_apply_existing_singleton_does_not_duplicate_album(self): self._apply_asis([self.i]) item = self.lib.items().get() new_item = library.Item.from_path(item.path) self._apply_asis([new_item], False) # Should not be duplicated. self.assertEqual(len(list(self.lib.items())), 1) def test_apply_existing_item_new_metadata_does_not_duplicate(self): # We want to copy the item to a new location. config['import']['copy'] = True # Import with existing metadata. self._apply_asis([self.i]) # Import again with new metadata. item = self.lib.items().get() new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) # Should not be duplicated. self.assertEqual(len(list(self.lib.items())), 1) self.assertEqual(len(list(self.lib.albums())), 1) def test_apply_existing_item_new_metadata_moves_files(self): # As above, import with old metadata and then reimport with new. config['import']['copy'] = True self._apply_asis([self.i]) item = self.lib.items().get() new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) item = self.lib.items().get() self.assertTrue('differentTitle' in item.path) self.assertExists(item.path) def test_apply_existing_item_new_metadata_copy_disabled(self): # Import *without* copying to ensure that the path does *not* change. config['import']['copy'] = False self._apply_asis([self.i]) item = self.lib.items().get() new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) item = self.lib.items().get() self.assertFalse('differentTitle' in item.path) self.assertExists(item.path) def test_apply_existing_item_new_metadata_removes_old_files(self): config['import']['copy'] = True self._apply_asis([self.i]) item = self.lib.items().get() oldpath = item.path new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) item = self.lib.items().get() self.assertNotExists(oldpath) def test_apply_existing_item_new_metadata_delete_enabled(self): # The "delete" flag should be ignored -- only the "copy" flag # controls whether files move. config['import']['copy'] = True config['import']['delete'] = True # ! self._apply_asis([self.i]) item = self.lib.items().get() oldpath = item.path new_item = library.Item.from_path(item.path) new_item.title = 'differentTitle' self._apply_asis([new_item]) item = self.lib.items().get() self.assertNotExists(oldpath) self.assertTrue('differentTitle' in item.path) self.assertExists(item.path) def test_apply_existing_item_preserves_file(self): # With copying enabled, import the item twice with same metadata. config['import']['copy'] = True self._apply_asis([self.i]) item = self.lib.items().get() oldpath = item.path new_item = library.Item.from_path(item.path) self._apply_asis([new_item]) self.assertEqual(len(list(self.lib.items())), 1) item = self.lib.items().get() self.assertEqual(oldpath, item.path) self.assertExists(oldpath) def test_apply_existing_item_preserves_file_delete_enabled(self): config['import']['copy'] = True config['import']['delete'] = True # ! self._apply_asis([self.i]) item = self.lib.items().get() new_item = library.Item.from_path(item.path) self._apply_asis([new_item]) self.assertEqual(len(list(self.lib.items())), 1) item = self.lib.items().get() self.assertExists(item.path) def test_same_album_does_not_duplicate(self): # With the -L flag, exactly the same item (with the same ID) # is re-imported. This test simulates that situation. self._apply_asis([self.i]) item = self.lib.items().get() self._apply_asis([item]) # Should not be duplicated. self.assertEqual(len(list(self.lib.items())), 1) self.assertEqual(len(list(self.lib.albums())), 1) class InferAlbumDataTest(_common.TestCase): def setUp(self): super(InferAlbumDataTest, self).setUp() i1 = _common.item() i2 = _common.item() i3 = _common.item() i1.title = 'first item' i2.title = 'second item' i3.title = 'third item' i1.comp = i2.comp = i3.comp = False i1.albumartist = i2.albumartist = i3.albumartist = '' i1.mb_albumartistid = i2.mb_albumartistid = i3.mb_albumartistid = '' self.items = [i1, i2, i3] self.task = importer.ImportTask(paths=['a path'], toppath='top path', items=self.items) self.task.set_null_candidates() def _infer(self): importer._infer_album_fields(self.task) def test_asis_homogenous_single_artist(self): self.task.set_choice(importer.action.ASIS) self._infer() self.assertFalse(self.items[0].comp) self.assertEqual(self.items[0].albumartist, self.items[2].artist) def test_asis_heterogenous_va(self): self.items[0].artist = 'another artist' self.items[1].artist = 'some other artist' self.task.set_choice(importer.action.ASIS) self._infer() self.assertTrue(self.items[0].comp) self.assertEqual(self.items[0].albumartist, 'Various Artists') def test_asis_comp_applied_to_all_items(self): self.items[0].artist = 'another artist' self.items[1].artist = 'some other artist' self.task.set_choice(importer.action.ASIS) self._infer() for item in self.items: self.assertTrue(item.comp) self.assertEqual(item.albumartist, 'Various Artists') def test_asis_majority_artist_single_artist(self): self.items[0].artist = 'another artist' self.task.set_choice(importer.action.ASIS) self._infer() self.assertFalse(self.items[0].comp) self.assertEqual(self.items[0].albumartist, self.items[2].artist) def test_apply_gets_artist_and_id(self): self.task.set_choice(AlbumMatch(0, None, {}, set(), set())) # APPLY self._infer() self.assertEqual(self.items[0].albumartist, self.items[0].artist) self.assertEqual(self.items[0].mb_albumartistid, self.items[0].mb_artistid) def test_apply_lets_album_values_override(self): for item in self.items: item.albumartist = 'some album artist' item.mb_albumartistid = 'some album artist id' self.task.set_choice(AlbumMatch(0, None, {}, set(), set())) # APPLY self._infer() self.assertEqual(self.items[0].albumartist, 'some album artist') self.assertEqual(self.items[0].mb_albumartistid, 'some album artist id') def test_small_single_artist_album(self): self.items = [self.items[0]] self.task.items = self.items self.task.set_choice(importer.action.ASIS) self._infer() self.assertFalse(self.items[0].comp) def test_first_item_null_apply(self): self.items[0] = None self.task.set_choice(AlbumMatch(0, None, {}, set(), set())) # APPLY self._infer() self.assertFalse(self.items[1].comp) self.assertEqual(self.items[1].albumartist, self.items[2].artist) class DuplicateCheckTest(_common.TestCase): def setUp(self): super(DuplicateCheckTest, self).setUp() self.lib = library.Library(':memory:') self.i = _common.item() self.album = self.lib.add_album([self.i]) def _album_task(self, asis, artist=None, album=None, existing=False): if existing: item = self.i else: item = _common.item() artist = artist or item.albumartist album = album or item.album task = importer.ImportTask(paths=['a path'], toppath='top path', items=[item]) task.set_candidates(artist, album, None, None) if asis: task.set_choice(importer.action.ASIS) else: info = AlbumInfo(album, None, artist, None, None) task.set_choice(AlbumMatch(0, info, {}, set(), set())) return task def _item_task(self, asis, artist=None, title=None, existing=False): if existing: item = self.i else: item = _common.item() artist = artist or item.artist title = title or item.title task = importer.ImportTask.item_task(item) if asis: item.artist = artist item.title = title task.set_choice(importer.action.ASIS) else: task.set_choice(TrackMatch(0, TrackInfo(title, None, artist))) return task def test_duplicate_album_apply(self): res = importer._duplicate_check(self.lib, self._album_task(False)) self.assertTrue(res) def test_different_album_apply(self): res = importer._duplicate_check(self.lib, self._album_task(False, 'xxx', 'yyy')) self.assertFalse(res) def test_duplicate_album_asis(self): res = importer._duplicate_check(self.lib, self._album_task(True)) self.assertTrue(res) def test_different_album_asis(self): res = importer._duplicate_check(self.lib, self._album_task(True, 'xxx', 'yyy')) self.assertFalse(res) def test_duplicate_va_album(self): self.album.albumartist = 'an album artist' self.album.store() res = importer._duplicate_check(self.lib, self._album_task(False, 'an album artist')) self.assertTrue(res) def test_duplicate_item_apply(self): res = importer._item_duplicate_check(self.lib, self._item_task(False)) self.assertTrue(res) def test_different_item_apply(self): res = importer._item_duplicate_check(self.lib, self._item_task(False, 'xxx', 'yyy')) self.assertFalse(res) def test_duplicate_item_asis(self): res = importer._item_duplicate_check(self.lib, self._item_task(True)) self.assertTrue(res) def test_different_item_asis(self): res = importer._item_duplicate_check(self.lib, self._item_task(True, 'xxx', 'yyy')) self.assertFalse(res) def test_duplicate_album_existing(self): res = importer._duplicate_check(self.lib, self._album_task(False, existing=True)) self.assertFalse(res) def test_duplicate_item_existing(self): res = importer._item_duplicate_check(self.lib, self._item_task(False, existing=True)) self.assertFalse(res) class TagLogTest(_common.TestCase): def test_tag_log_line(self): sio = StringIO.StringIO() session = _common.import_session(logfile=sio) session.tag_log('status', 'path') assert 'status path' in sio.getvalue() def test_tag_log_unicode(self): sio = StringIO.StringIO() session = _common.import_session(logfile=sio) session.tag_log('status', 'caf\xc3\xa9') assert 'status caf' in sio.getvalue() def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_mb.py0000644000076500000240000003575712222337400016564 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for MusicBrainz API wrapper. """ import _common from _common import unittest from beets.autotag import mb from beets import config class MBAlbumInfoTest(_common.TestCase): def _make_release(self, date_str='2009', tracks=None, track_length=None, track_artist=False): release = { 'title': 'ALBUM TITLE', 'id': 'ALBUM ID', 'asin': 'ALBUM ASIN', 'disambiguation': 'R_DISAMBIGUATION', 'release-group': { 'type': 'Album', 'first-release-date': date_str, 'id': 'RELEASE GROUP ID', 'disambiguation': 'RG_DISAMBIGUATION', }, 'artist-credit': [ { 'artist': { 'name': 'ARTIST NAME', 'id': 'ARTIST ID', 'sort-name': 'ARTIST SORT NAME', }, 'name': 'ARTIST CREDIT', } ], 'date': '3001', 'medium-list': [], 'label-info-list': [{ 'catalog-number': 'CATALOG NUMBER', 'label': {'name': 'LABEL NAME'}, }], 'text-representation': { 'script': 'SCRIPT', 'language': 'LANGUAGE', }, 'country': 'COUNTRY', 'status': 'STATUS', } if tracks: track_list = [] for i, recording in enumerate(tracks): track = { 'recording': recording, 'position': str(i + 1), } if track_length: # Track lengths are distinct from recording lengths. track['length'] = track_length if track_artist: # Similarly, track artists can differ from recording # artists. track['artist-credit'] = [ { 'artist': { 'name': 'TRACK ARTIST NAME', 'id': 'TRACK ARTIST ID', 'sort-name': 'TRACK ARTIST SORT NAME', }, 'name': 'TRACK ARTIST CREDIT', } ] track_list.append(track) release['medium-list'].append({ 'position': '1', 'track-list': track_list, 'format': 'FORMAT', 'title': 'MEDIUM TITLE', }) return release def _make_track(self, title, tr_id, duration, artist=False): track = { 'title': title, 'id': tr_id, } if duration is not None: track['length'] = duration if artist: track['artist-credit'] = [ { 'artist': { 'name': 'RECORDING ARTIST NAME', 'id': 'RECORDING ARTIST ID', 'sort-name': 'RECORDING ARTIST SORT NAME', }, 'name': 'RECORDING ARTIST CREDIT', } ] return track def test_parse_release_with_year(self): release = self._make_release('1984') d = mb.album_info(release) self.assertEqual(d.album, 'ALBUM TITLE') self.assertEqual(d.album_id, 'ALBUM ID') self.assertEqual(d.artist, 'ARTIST NAME') self.assertEqual(d.artist_id, 'ARTIST ID') self.assertEqual(d.original_year, 1984) self.assertEqual(d.year, 3001) self.assertEqual(d.artist_credit, 'ARTIST CREDIT') def test_parse_release_type(self): release = self._make_release('1984') d = mb.album_info(release) self.assertEqual(d.albumtype, 'album') def test_parse_release_full_date(self): release = self._make_release('1987-03-31') d = mb.album_info(release) self.assertEqual(d.original_year, 1987) self.assertEqual(d.original_month, 3) self.assertEqual(d.original_day, 31) def test_parse_tracks(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=tracks) d = mb.album_info(release) t = d.tracks self.assertEqual(len(t), 2) self.assertEqual(t[0].title, 'TITLE ONE') self.assertEqual(t[0].track_id, 'ID ONE') self.assertEqual(t[0].length, 100.0) self.assertEqual(t[1].title, 'TITLE TWO') self.assertEqual(t[1].track_id, 'ID TWO') self.assertEqual(t[1].length, 200.0) def test_parse_track_indices(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=tracks) d = mb.album_info(release) t = d.tracks self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[0].index, 1) self.assertEqual(t[1].medium_index, 2) self.assertEqual(t[1].index, 2) def test_parse_medium_numbers_single_medium(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=tracks) d = mb.album_info(release) self.assertEqual(d.mediums, 1) t = d.tracks self.assertEqual(t[0].medium, 1) self.assertEqual(t[1].medium, 1) def test_parse_medium_numbers_two_mediums(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=[tracks[0]]) second_track_list = [{ 'recording': tracks[1], 'position': '1', }] release['medium-list'].append({ 'position': '2', 'track-list': second_track_list, }) d = mb.album_info(release) self.assertEqual(d.mediums, 2) t = d.tracks self.assertEqual(t[0].medium, 1) self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[0].index, 1) self.assertEqual(t[1].medium, 2) self.assertEqual(t[1].medium_index, 1) self.assertEqual(t[1].index, 2) def test_parse_release_year_month_only(self): release = self._make_release('1987-03') d = mb.album_info(release) self.assertEqual(d.original_year, 1987) self.assertEqual(d.original_month, 3) def test_no_durations(self): tracks = [self._make_track('TITLE', 'ID', None)] release = self._make_release(tracks=tracks) d = mb.album_info(release) self.assertEqual(d.tracks[0].length, None) def test_track_length_overrides_recording_length(self): tracks = [self._make_track('TITLE', 'ID', 1.0 * 1000.0)] release = self._make_release(tracks=tracks, track_length=2.0 * 1000.0) d = mb.album_info(release) self.assertEqual(d.tracks[0].length, 2.0) def test_no_release_date(self): release = self._make_release(None) d = mb.album_info(release) self.assertFalse(d.original_year) self.assertFalse(d.original_month) self.assertFalse(d.original_day) def test_various_artists_defaults_false(self): release = self._make_release(None) d = mb.album_info(release) self.assertFalse(d.va) def test_detect_various_artists(self): release = self._make_release(None) release['artist-credit'][0]['artist']['id'] = \ mb.VARIOUS_ARTISTS_ID d = mb.album_info(release) self.assertTrue(d.va) def test_parse_artist_sort_name(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.artist_sort, 'ARTIST SORT NAME') def test_parse_releasegroupid(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.releasegroup_id, 'RELEASE GROUP ID') def test_parse_asin(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.asin, 'ALBUM ASIN') def test_parse_catalognum(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.catalognum, 'CATALOG NUMBER') def test_parse_textrepr(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.script, 'SCRIPT') self.assertEqual(d.language, 'LANGUAGE') def test_parse_country(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.country, 'COUNTRY') def test_parse_status(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.albumstatus, 'STATUS') def test_parse_media(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(None, tracks=tracks) d = mb.album_info(release) self.assertEqual(d.media, 'FORMAT') def test_parse_disambig(self): release = self._make_release(None) d = mb.album_info(release) self.assertEqual(d.albumdisambig, 'RG_DISAMBIGUATION, R_DISAMBIGUATION') def test_parse_disctitle(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(None, tracks=tracks) d = mb.album_info(release) t = d.tracks self.assertEqual(t[0].disctitle, 'MEDIUM TITLE') self.assertEqual(t[1].disctitle, 'MEDIUM TITLE') def test_missing_language(self): release = self._make_release(None) del release['text-representation']['language'] d = mb.album_info(release) self.assertEqual(d.language, None) def test_parse_recording_artist(self): tracks = [self._make_track('a', 'b', 1, True)] release = self._make_release(None, tracks=tracks) track = mb.album_info(release).tracks[0] self.assertEqual(track.artist, 'RECORDING ARTIST NAME') self.assertEqual(track.artist_id, 'RECORDING ARTIST ID') self.assertEqual(track.artist_sort, 'RECORDING ARTIST SORT NAME') self.assertEqual(track.artist_credit, 'RECORDING ARTIST CREDIT') def test_track_artist_overrides_recording_artist(self): tracks = [self._make_track('a', 'b', 1, True)] release = self._make_release(None, tracks=tracks, track_artist=True) track = mb.album_info(release).tracks[0] self.assertEqual(track.artist, 'TRACK ARTIST NAME') self.assertEqual(track.artist_id, 'TRACK ARTIST ID') self.assertEqual(track.artist_sort, 'TRACK ARTIST SORT NAME') self.assertEqual(track.artist_credit, 'TRACK ARTIST CREDIT') class ParseIDTest(_common.TestCase): def test_parse_id_correct(self): id_string = "28e32c71-1450-463e-92bf-e0a46446fc11" out = mb._parse_id(id_string) self.assertEqual(out, id_string) def test_parse_id_non_id_returns_none(self): id_string = "blah blah" out = mb._parse_id(id_string) self.assertEqual(out, None) def test_parse_id_url_finds_id(self): id_string = "28e32c71-1450-463e-92bf-e0a46446fc11" id_url = "http://musicbrainz.org/entity/%s" % id_string out = mb._parse_id(id_url) self.assertEqual(out, id_string) class ArtistFlatteningTest(_common.TestCase): def _credit_dict(self, suffix=''): return { 'artist': { 'name': 'NAME' + suffix, 'sort-name': 'SORT' + suffix, }, 'name': 'CREDIT' + suffix, } def _add_alias(self, credit_dict, suffix='', locale='', primary=False): alias = { 'alias': 'ALIAS' + suffix, 'locale': locale, 'sort-name': 'ALIASSORT' + suffix } if primary: alias['primary'] = 'primary' if 'alias-list' not in credit_dict['artist']: credit_dict['artist']['alias-list'] = [] credit_dict['artist']['alias-list'].append(alias) def test_single_artist(self): a, s, c = mb._flatten_artist_credit([self._credit_dict()]) self.assertEqual(a, 'NAME') self.assertEqual(s, 'SORT') self.assertEqual(c, 'CREDIT') def test_two_artists(self): a, s, c = mb._flatten_artist_credit( [self._credit_dict('a'), ' AND ', self._credit_dict('b')] ) self.assertEqual(a, 'NAMEa AND NAMEb') self.assertEqual(s, 'SORTa AND SORTb') self.assertEqual(c, 'CREDITa AND CREDITb') def test_alias(self): credit_dict = self._credit_dict() self._add_alias(credit_dict, suffix='en', locale='en') self._add_alias(credit_dict, suffix='en_GB', locale='en_GB') self._add_alias(credit_dict, suffix='fr', locale='fr') self._add_alias(credit_dict, suffix='fr_P', locale='fr', primary=True) # test no alias config['import']['languages'] = [''] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('NAME', 'SORT', 'CREDIT')) # test en config['import']['languages'] = ['en'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) # test en_GB en config['import']['languages'] = ['en_GB', 'en'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASen_GB', 'ALIASSORTen_GB', 'CREDIT')) # test en en_GB config['import']['languages'] = ['en', 'en_GB'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) # test fr primary config['import']['languages'] = ['fr'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASfr_P', 'ALIASSORTfr_P', 'CREDIT')) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_mediafile.py0000644000076500000240000002452312224420436020076 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Specific, edge-case tests for the MediaFile metadata layer. """ import os import shutil import _common from _common import unittest import beets.mediafile class EdgeTest(unittest.TestCase): def test_emptylist(self): # Some files have an ID3 frame that has a list with no elements. # This is very hard to produce, so this is just the first 8192 # bytes of a file found "in the wild". emptylist = beets.mediafile.MediaFile( os.path.join(_common.RSRC, 'emptylist.mp3')) genre = emptylist.genre self.assertEqual(genre, '') def test_release_time_with_space(self): # Ensures that release times delimited by spaces are ignored. # Amie Street produces such files. space_time = beets.mediafile.MediaFile( os.path.join(_common.RSRC, 'space_time.mp3')) self.assertEqual(space_time.year, 2009) self.assertEqual(space_time.month, 9) self.assertEqual(space_time.day, 4) def test_release_time_with_t(self): # Ensures that release times delimited by Ts are ignored. # The iTunes Store produces such files. t_time = beets.mediafile.MediaFile( os.path.join(_common.RSRC, 't_time.m4a')) self.assertEqual(t_time.year, 1987) self.assertEqual(t_time.month, 3) self.assertEqual(t_time.day, 31) def test_tempo_with_bpm(self): # Some files have a string like "128 BPM" in the tempo field # rather than just a number. f = beets.mediafile.MediaFile(os.path.join(_common.RSRC, 'bpm.mp3')) self.assertEqual(f.bpm, 128) def test_discc_alternate_field(self): # Different taggers use different vorbis comments to reflect # the disc and disc count fields: ensure that the alternative # style works. f = beets.mediafile.MediaFile(os.path.join(_common.RSRC, 'discc.ogg')) self.assertEqual(f.disc, 4) self.assertEqual(f.disctotal, 5) def test_old_ape_version_bitrate(self): f = beets.mediafile.MediaFile(os.path.join(_common.RSRC, 'oldape.ape')) self.assertEqual(f.bitrate, 0) _sc = beets.mediafile._safe_cast class InvalidValueToleranceTest(unittest.TestCase): def test_packed_integer_with_extra_chars(self): pack = beets.mediafile.Packed("06a", beets.mediafile.packing.SLASHED) self.assertEqual(pack[0], 6) def test_packed_integer_invalid(self): pack = beets.mediafile.Packed("blah", beets.mediafile.packing.SLASHED) self.assertEqual(pack[0], 0) def test_packed_index_out_of_range(self): pack = beets.mediafile.Packed("06", beets.mediafile.packing.SLASHED) self.assertEqual(pack[1], 0) def test_safe_cast_string_to_int(self): self.assertEqual(_sc(int, 'something'), 0) def test_safe_cast_int_string_to_int(self): self.assertEqual(_sc(int, '20'), 20) def test_safe_cast_string_to_bool(self): self.assertEqual(_sc(bool, 'whatever'), False) def test_safe_cast_intstring_to_bool(self): self.assertEqual(_sc(bool, '5'), True) def test_safe_cast_string_to_float(self): self.assertAlmostEqual(_sc(float, '1.234'), 1.234) def test_safe_cast_int_to_float(self): self.assertAlmostEqual(_sc(float, 2), 2.0) def test_safe_cast_string_with_cruft_to_float(self): self.assertAlmostEqual(_sc(float, '1.234stuff'), 1.234) def test_safe_cast_negative_string_to_float(self): self.assertAlmostEqual(_sc(float, '-1.234'), -1.234) def test_safe_cast_special_chars_to_unicode(self): us = _sc(unicode, 'caf\xc3\xa9') self.assertTrue(isinstance(us, unicode)) self.assertTrue(us.startswith(u'caf')) class SafetyTest(unittest.TestCase): def _exccheck(self, fn, exc, data=''): fn = os.path.join(_common.RSRC, fn) with open(fn, 'w') as f: f.write(data) try: self.assertRaises(exc, beets.mediafile.MediaFile, fn) finally: os.unlink(fn) # delete the temporary file def test_corrupt_mp3_raises_unreadablefileerror(self): # Make sure we catch Mutagen reading errors appropriately. self._exccheck('corrupt.mp3', beets.mediafile.UnreadableFileError) def test_corrupt_mp4_raises_unreadablefileerror(self): self._exccheck('corrupt.m4a', beets.mediafile.UnreadableFileError) def test_corrupt_flac_raises_unreadablefileerror(self): self._exccheck('corrupt.flac', beets.mediafile.UnreadableFileError) def test_corrupt_ogg_raises_unreadablefileerror(self): self._exccheck('corrupt.ogg', beets.mediafile.UnreadableFileError) def test_invalid_ogg_header_raises_unreadablefileerror(self): self._exccheck('corrupt.ogg', beets.mediafile.UnreadableFileError, 'OggS\x01vorbis') def test_corrupt_monkeys_raises_unreadablefileerror(self): self._exccheck('corrupt.ape', beets.mediafile.UnreadableFileError) def test_invalid_extension_raises_filetypeerror(self): self._exccheck('something.unknown', beets.mediafile.FileTypeError) def test_magic_xml_raises_unreadablefileerror(self): self._exccheck('nothing.xml', beets.mediafile.UnreadableFileError, "ftyp") def test_broken_symlink(self): fn = os.path.join(_common.RSRC, 'brokenlink') os.symlink('does_not_exist', fn) try: self.assertRaises(IOError, beets.mediafile.MediaFile, fn) finally: os.unlink(fn) class SideEffectsTest(unittest.TestCase): def setUp(self): self.empty = os.path.join(_common.RSRC, 'empty.mp3') def test_opening_tagless_file_leaves_untouched(self): old_mtime = os.stat(self.empty).st_mtime beets.mediafile.MediaFile(self.empty) new_mtime = os.stat(self.empty).st_mtime self.assertEqual(old_mtime, new_mtime) class EncodingTest(unittest.TestCase): def setUp(self): src = os.path.join(_common.RSRC, 'full.m4a') self.path = os.path.join(_common.RSRC, 'test.m4a') shutil.copy(src, self.path) self.mf = beets.mediafile.MediaFile(self.path) def tearDown(self): os.remove(self.path) def test_unicode_label_in_m4a(self): self.mf.label = u'foo\xe8bar' self.mf.save() new_mf = beets.mediafile.MediaFile(self.path) self.assertEqual(new_mf.label, u'foo\xe8bar') class ZeroLengthMediaFile(beets.mediafile.MediaFile): @property def length(self): return 0.0 class MissingAudioDataTest(unittest.TestCase): def setUp(self): super(MissingAudioDataTest, self).setUp() path = os.path.join(_common.RSRC, 'full.mp3') self.mf = ZeroLengthMediaFile(path) def test_bitrate_with_zero_length(self): del self.mf.mgfile.info.bitrate # Not available directly. self.assertEqual(self.mf.bitrate, 0) class TypeTest(unittest.TestCase): def setUp(self): super(TypeTest, self).setUp() path = os.path.join(_common.RSRC, 'full.mp3') self.mf = beets.mediafile.MediaFile(path) def test_year_integer_in_string(self): self.mf.year = '2009' self.assertEqual(self.mf.year, 2009) def test_set_replaygain_gain_to_none(self): self.mf.rg_track_gain = None self.assertEqual(self.mf.rg_track_gain, 0.0) def test_set_replaygain_peak_to_none(self): self.mf.rg_track_peak = None self.assertEqual(self.mf.rg_track_peak, 0.0) def test_set_year_to_none(self): self.mf.year = None self.assertEqual(self.mf.year, 0) def test_set_track_to_none(self): self.mf.track = None self.assertEqual(self.mf.track, 0) class SoundCheckTest(unittest.TestCase): def test_round_trip(self): data = beets.mediafile._sc_encode(1.0, 1.0) gain, peak = beets.mediafile._sc_decode(data) self.assertEqual(gain, 1.0) self.assertEqual(peak, 1.0) def test_decode_zero(self): data = u' 80000000 80000000 00000000 00000000 00000000 00000000 ' \ u'00000000 00000000 00000000 00000000' gain, peak = beets.mediafile._sc_decode(data) self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) def test_malformatted(self): gain, peak = beets.mediafile._sc_decode(u'foo') self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) class ID3v23Test(unittest.TestCase): def _make_test(self, ext='mp3'): src = os.path.join(_common.RSRC, 'full.{0}'.format(ext)) self.path = os.path.join(_common.RSRC, 'test.{0}'.format(ext)) shutil.copy(src, self.path) return beets.mediafile.MediaFile(self.path) def _delete_test(self): os.remove(self.path) def test_v24_year_tag(self): mf = self._make_test() try: mf.year = 2013 mf.save(id3v23=False) frame = mf.mgfile['TDRC'] self.assertTrue('2013' in str(frame)) self.assertTrue('TYER' not in mf.mgfile) finally: self._delete_test() def test_v23_year_tag(self): mf = self._make_test() try: mf.year = 2013 mf.save(id3v23=True) frame = mf.mgfile['TYER'] self.assertTrue('2013' in str(frame)) self.assertTrue('TDRC' not in mf.mgfile) finally: self._delete_test() def test_v23_on_non_mp3_is_noop(self): mf = self._make_test('m4a') try: mf.year = 2013 mf.save(id3v23=True) finally: self._delete_test() def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_mediafile_basic.py0000644000076500000240000003271112214741277021245 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Automatically-generated blanket testing for the MediaFile metadata layer. """ import os import shutil import datetime import _common from _common import unittest import beets.mediafile CORRECT_DICTS = { # All of the fields iTunes supports that we do also. 'full': { 'title': u'full', 'artist': u'the artist', 'album': u'the album', 'genre': u'the genre', 'composer': u'the composer', 'grouping': u'the grouping', 'year': 2001, 'month': 0, 'day': 0, 'date': datetime.date(2001, 1, 1), 'track': 2, 'tracktotal': 3, 'disc': 4, 'disctotal': 5, 'lyrics': u'the lyrics', 'comments': u'the comments', 'bpm': 6, 'comp': True, 'mb_trackid': '8b882575-08a5-4452-a7a7-cbb8a1531f9e', 'mb_albumid': '9e873859-8aa4-4790-b985-5a953e8ef628', 'mb_artistid':'7cf0ea9d-86b9-4dad-ba9e-2355a64899ea', 'art': None, 'label': u'the label', }, # Additional coverage for common cases when "total" fields are unset. # Created with iTunes. (Also tests unset MusicBrainz fields.) 'partial': { 'track': 2, 'tracktotal': 0, 'disc': 4, 'disctotal': 0, 'mb_trackid': '', 'mb_albumid': '', 'mb_artistid':'', }, 'min': { 'track': 0, 'tracktotal': 0, 'disc': 0, 'disctotal': 0 }, # ID3 tag deleted with `mp3info -d`. Tests default values. 'empty': { 'title': u'', 'artist': u'', 'album': u'', 'genre': u'', 'composer': u'', 'grouping': u'', 'year': 0, 'month': 0, 'day': 0, 'date': datetime.date.min, 'track': 0, 'tracktotal': 0, 'disc': 0, 'disctotal': 0, 'lyrics': u'', 'comments': u'', 'bpm': 0, 'comp': False, 'mb_trackid': u'', 'mb_albumid': u'', 'mb_artistid':u'', 'art': None, 'label': u'', # Additional, non-iTunes fields. 'rg_track_peak': 0.0, 'rg_track_gain': 0.0, 'rg_album_peak': 0.0, 'rg_album_gain': 0.0, 'albumartist': u'', 'mb_albumartistid': u'', 'artist_sort': u'', 'albumartist_sort': u'', 'acoustid_fingerprint': u'', 'acoustid_id': u'', 'mb_releasegroupid': u'', 'asin': u'', 'catalognum': u'', 'disctitle': u'', 'encoder': u'', 'script': u'', 'language': u'', 'country': u'', 'albumstatus': u'', 'media': u'', 'albumdisambig': u'', 'artist_credit': u'', 'albumartist_credit': u'', 'original_year': 0, 'original_month': 0, 'original_day': 0, 'original_date': datetime.date.min, }, # Full release date. 'date': { 'year': 1987, 'month': 3, 'day': 31, 'date': datetime.date(1987, 3, 31) }, } READ_ONLY_CORRECT_DICTS = { 'full.mp3': { 'length': 1.0, 'bitrate': 80000, 'format': 'MP3', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, }, 'full.flac': { 'length': 1.0, 'bitrate': 175120, 'format': 'FLAC', 'samplerate': 44100, 'bitdepth': 16, 'channels': 1, }, 'full.m4a': { 'length': 1.0, 'bitrate': 64000, 'format': 'AAC', 'samplerate': 44100, 'bitdepth': 16, 'channels': 2, }, 'full.ogg': { 'length': 1.0, 'bitrate': 48000, 'format': 'OGG', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, }, 'full.opus': { 'length': 1.0, 'bitrate': 57984, 'format': 'Opus', 'samplerate': 48000, 'bitdepth': 0, 'channels': 1, }, 'full.ape': { 'length': 1.0, 'bitrate': 112040, 'format': 'APE', 'samplerate': 44100, 'bitdepth': 16, 'channels': 1, }, 'full.wv': { 'length': 1.0, 'bitrate': 108744, 'format': 'WavPack', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, }, 'full.mpc': { 'length': 1.0, 'bitrate': 23458, 'format': 'Musepack', 'samplerate': 44100, 'bitdepth': 0, 'channels': 2, }, 'full.wma': { 'length': 1.0, 'bitrate': 128000, 'format': 'Windows Media', 'samplerate': 44100, 'bitdepth': 0, 'channels': 1, }, 'full.alac.m4a': { 'length': 1.0, 'bitrate': 55072, 'format': 'ALAC', 'samplerate': 0, 'bitdepth': 0, 'channels': 0, }, } TEST_FILES = { 'm4a': ['full', 'partial', 'min'], 'mp3': ['full', 'partial', 'min'], 'flac': ['full', 'partial', 'min'], 'ogg': ['full'], 'opus': ['full'], 'ape': ['full'], 'wv': ['full'], 'mpc': ['full'], 'wma': ['full'], } class AllFilesMixin(object): """This is a dumb bit of copypasta but unittest has no supported method of generating tests at runtime. """ def test_m4a_full(self): self._run('full', 'm4a') def test_m4a_partial(self): self._run('partial', 'm4a') def test_m4a_min(self): self._run('min', 'm4a') def test_mp3_full(self): self._run('full', 'mp3') def test_mp3_partial(self): self._run('partial', 'mp3') def test_mp3_min(self): self._run('min', 'mp3') def test_flac_full(self): self._run('full', 'flac') def test_flac_partial(self): self._run('partial', 'flac') def test_flac_min(self): self._run('min', 'flac') def test_ogg(self): self._run('full', 'ogg') def test_opus(self): self._run('full', 'opus') def test_ape(self): self._run('full', 'ape') def test_wv(self): self._run('full', 'wv') def test_mpc(self): self._run('full', 'mpc') def test_wma(self): self._run('full', 'wma') def test_alac(self): self._run('full', 'alac.m4a') # Special test for advanced release date. def test_date_mp3(self): self._run('date', 'mp3') class ReadingTest(unittest.TestCase, AllFilesMixin): def _read_field(self, mf, correct_dict, field): got = getattr(mf, field) correct = correct_dict[field] message = field + ' incorrect (expected ' + repr(correct) + \ ', got ' + repr(got) + ')' if isinstance(correct, float): self.assertAlmostEqual(got, correct, msg=message) else: self.assertEqual(got, correct, message) def _run(self, tagset, kind): correct_dict = CORRECT_DICTS[tagset] path = os.path.join(_common.RSRC, tagset + '.' + kind) f = beets.mediafile.MediaFile(path) for field in correct_dict: if 'm4a' in path and field.startswith('rg_'): # MPEG-4 files: ReplayGain values not implemented. continue self._read_field(f, correct_dict, field) # Special test for missing ID3 tag. def test_empy_mp3(self): self._run('empty', 'mp3') class WritingTest(unittest.TestCase, AllFilesMixin): def _write_field(self, tpath, field, value, correct_dict): # Write new tag. a = beets.mediafile.MediaFile(tpath) setattr(a, field, value) a.save() # Verify ALL tags are correct with modification. b = beets.mediafile.MediaFile(tpath) for readfield in correct_dict.keys(): got = getattr(b, readfield) # Make sure the modified field was changed correctly... if readfield == field: message = field + ' modified incorrectly (changed to ' + \ repr(value) + ' but read ' + repr(got) + ')' if isinstance(value, float): self.assertAlmostEqual(got, value, msg=message) else: self.assertEqual(got, value, message) # ... and that no other field was changed. else: # MPEG-4: ReplayGain not implented. if 'm4a' in tpath and readfield.startswith('rg_'): continue # The value should be what it was originally most of the # time. correct = correct_dict[readfield] # The date field, however, is modified when its components # change. if readfield=='date' and field in ('year', 'month', 'day'): try: correct = datetime.date( value if field=='year' else correct.year, value if field=='month' else correct.month, value if field=='day' else correct.day ) except ValueError: correct = datetime.date.min # And vice-versa. if field=='date' and readfield in ('year', 'month', 'day'): correct = getattr(value, readfield) message = readfield + ' changed when it should not have' \ ' (expected ' + repr(correct) + ', got ' + \ repr(got) + ') when modifying ' + field if isinstance(correct, float): self.assertAlmostEqual(got, correct, msg=message) else: self.assertEqual(got, correct, message) def _run(self, tagset, kind): correct_dict = CORRECT_DICTS[tagset] path = os.path.join(_common.RSRC, tagset + '.' + kind) for field in correct_dict: if field == 'month' and correct_dict['year'] == 0 or \ field == 'day' and correct_dict['month'] == 0: continue # Generate the new value we'll try storing. if field == 'art': value = 'xxx' elif type(correct_dict[field]) is unicode: value = u'TestValue: ' + field elif type(correct_dict[field]) is int: value = correct_dict[field] + 42 elif type(correct_dict[field]) is bool: value = not correct_dict[field] elif type(correct_dict[field]) is datetime.date: value = correct_dict[field] + datetime.timedelta(42) elif type(correct_dict[field]) is str: value = 'TestValue-' + str(field) elif type(correct_dict[field]) is float: value = 9.87 else: raise ValueError('unknown field type ' + \ str(type(correct_dict[field]))) # Make a copy of the file we'll work on. root, ext = os.path.splitext(path) tpath = root + '_test' + ext shutil.copy(path, tpath) try: self._write_field(tpath, field, value, correct_dict) finally: os.remove(tpath) class ReadOnlyTest(unittest.TestCase): def _read_field(self, mf, field, value): got = getattr(mf, field) fail_msg = field + ' incorrect (expected ' + \ repr(value) + ', got ' + repr(got) + ')' if field == 'length': self.assertTrue(value-0.1 < got < value+0.1, fail_msg) else: self.assertEqual(got, value, fail_msg) def _run(self, filename): path = os.path.join(_common.RSRC, filename) f = beets.mediafile.MediaFile(path) correct_dict = READ_ONLY_CORRECT_DICTS[filename] for field, value in correct_dict.items(): self._read_field(f, field, value) def test_mp3(self): self._run('full.mp3') def test_m4a(self): self._run('full.m4a') def test_flac(self): self._run('full.flac') def test_ogg(self): self._run('full.ogg') def test_opus(self): self._run('full.opus') def test_ape(self): self._run('full.ape') def test_wv(self): self._run('full.wv') def test_mpc(self): self._run('full.mpc') def test_wma(self): self._run('full.wma') def test_alac(self): self._run('full.alac.m4a') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_pipeline.py0000644000076500000240000001155012102026773017761 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Test the "pipeline.py" restricted parallel programming library. """ from _common import unittest from beets.util import pipeline # Some simple pipeline stages for testing. def _produce(num=5): for i in range(num): yield i def _work(): i = None while True: i = yield i i *= 2 def _consume(l): while True: i = yield l.append(i) # A worker that raises an exception. class TestException(Exception): pass def _exc_work(num=3): i = None while True: i = yield i if i == num: raise TestException() i *= 2 # A worker that yields a bubble. def _bub_work(num=3): i = None while True: i = yield i if i == num: i = pipeline.BUBBLE else: i *= 2 # Yet another worker that yields multiple messages. def _multi_work(): i = None while True: i = yield i i = pipeline.multiple([i, -i]) class SimplePipelineTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline((_produce(), _work(), _consume(self.l))) def test_run_sequential(self): self.pl.run_sequential() self.assertEqual(self.l, [0,2,4,6,8]) def test_run_parallel(self): self.pl.run_parallel() self.assertEqual(self.l, [0,2,4,6,8]) class ParallelStageTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline(( _produce(), (_work(), _work()), _consume(self.l) )) def test_run_sequential(self): self.pl.run_sequential() self.assertEqual(self.l, [0,2,4,6,8]) def test_run_parallel(self): self.pl.run_parallel() # Order possibly not preserved; use set equality. self.assertEqual(set(self.l), set([0,2,4,6,8])) class ExceptionTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline((_produce(), _exc_work(), _consume(self.l))) def test_run_sequential(self): self.assertRaises(TestException, self.pl.run_sequential) def test_run_parallel(self): self.assertRaises(TestException, self.pl.run_parallel) class ParallelExceptionTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline(( _produce(), (_exc_work(), _exc_work()), _consume(self.l) )) def test_run_parallel(self): self.assertRaises(TestException, self.pl.run_parallel) class ConstrainedThreadedPipelineTest(unittest.TestCase): def test_constrained(self): l = [] # Do a "significant" amount of work... pl = pipeline.Pipeline((_produce(1000), _work(), _consume(l))) # ... with only a single queue slot. pl.run_parallel(1) self.assertEqual(l, [i*2 for i in range(1000)]) def test_constrained_exception(self): # Raise an exception in a constrained pipeline. l = [] pl = pipeline.Pipeline((_produce(1000), _exc_work(), _consume(l))) self.assertRaises(TestException, pl.run_parallel, 1) def test_constrained_parallel(self): l = [] pl = pipeline.Pipeline(( _produce(1000), (_work(), _work()), _consume(l) )) pl.run_parallel(1) self.assertEqual(set(l), set(i*2 for i in range(1000))) class BubbleTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline((_produce(), _bub_work(), _consume(self.l))) def test_run_sequential(self): self.pl.run_sequential() self.assertEqual(self.l, [0,2,4,8]) def test_run_parallel(self): self.pl.run_parallel() self.assertEqual(self.l, [0,2,4,8]) class MultiMessageTest(unittest.TestCase): def setUp(self): self.l = [] self.pl = pipeline.Pipeline(( _produce(), _multi_work(), _consume(self.l) )) def test_run_sequential(self): self.pl.run_sequential() self.assertEqual(self.l, [0,0,1,-1,2,-2,3,-3,4,-4]) def test_run_parallel(self): self.pl.run_parallel() self.assertEqual(self.l, [0,0,1,-1,2,-2,3,-3,4,-4]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_player.py0000644000076500000240000000423012102026773017445 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for BPD and music playing. """ from _common import unittest from beetsplug import bpd class CommandParseTest(unittest.TestCase): def test_no_args(self): s = ur'command' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, []) def test_one_unquoted_arg(self): s = ur'command hello' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, [u'hello']) def test_two_unquoted_args(self): s = ur'command hello there' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, [u'hello', u'there']) def test_one_quoted_arg(self): s = ur'command "hello there"' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, [u'hello there']) def test_heterogenous_args(self): s = ur'command "hello there" sir' c = bpd.Command(s) self.assertEqual(c.name, u'command') self.assertEqual(c.args, [u'hello there', u'sir']) def test_quote_in_arg(self): s = ur'command "hello \" there"' c = bpd.Command(s) self.assertEqual(c.args, [u'hello " there']) def test_backslash_in_arg(self): s = ur'command "hello \\ there"' c = bpd.Command(s) self.assertEqual(c.args, [u'hello \ there']) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_query.py0000644000076500000240000003367312220135742017331 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Various tests for querying the library database. """ import _common from _common import unittest import beets.library pqp = beets.library.parse_query_part class QueryParseTest(_common.TestCase): def test_one_basic_term(self): q = 'test' r = (None, 'test', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_one_keyed_term(self): q = 'test:val' r = ('test', 'val', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_colon_at_end(self): q = 'test:' r = (None, 'test:', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_one_basic_regexp(self): q = r':regexp' r = (None, 'regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) def test_keyed_regexp(self): q = r'test::regexp' r = ('test', 'regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) def test_escaped_colon(self): q = r'test\:val' r = (None, 'test:val', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_escaped_colon_in_regexp(self): q = r':test\:regexp' r = (None, 'test:regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) def test_single_year(self): q = 'year:1999' r = ('year', '1999', beets.library.NumericQuery) self.assertEqual(pqp(q), r) def test_multiple_years(self): q = 'year:1999..2010' r = ('year', '1999..2010', beets.library.NumericQuery) self.assertEqual(pqp(q), r) class AnyFieldQueryTest(_common.LibTestCase): def test_no_restriction(self): q = beets.library.AnyFieldQuery('title', beets.library.ITEM_KEYS, beets.library.SubstringQuery) self.assertEqual(self.lib.items(q).get().title, 'the title') def test_restriction_completeness(self): q = beets.library.AnyFieldQuery('title', ['title'], beets.library.SubstringQuery) self.assertEqual(self.lib.items(q).get().title, 'the title') def test_restriction_soundness(self): q = beets.library.AnyFieldQuery('title', ['artist'], beets.library.SubstringQuery) self.assertEqual(self.lib.items(q).get(), None) class AssertsMixin(object): def assert_matched(self, results, titles): self.assertEqual([i.title for i in results], titles) # A test case class providing a library with some dummy data and some # assertions involving that data. class DummyDataTestCase(_common.TestCase, AssertsMixin): def setUp(self): super(DummyDataTestCase, self).setUp() self.lib = beets.library.Library(':memory:') items = [_common.item() for _ in range(3)] items[0].title = 'foo bar' items[0].artist = 'one' items[0].album = 'baz' items[0].year = 2001 items[0].comp = True items[1].title = 'baz qux' items[1].artist = 'two' items[1].album = 'baz' items[1].year = 2002 items[1].comp = True items[2].title = 'beets 4 eva' items[2].artist = 'three' items[2].album = 'foo' items[2].year = 2003 items[2].comp = False for item in items: self.lib.add(item) self.lib.add_album(items[:2]) def assert_matched_all(self, results): self.assert_matched(results, [ 'foo bar', 'baz qux', 'beets 4 eva', ]) class GetTest(DummyDataTestCase): def test_get_empty(self): q = '' results = self.lib.items(q) self.assert_matched_all(results) def test_get_none(self): q = None results = self.lib.items(q) self.assert_matched_all(results) def test_get_one_keyed_term(self): q = 'title:qux' results = self.lib.items(q) self.assert_matched(results, ['baz qux']) def test_get_one_keyed_regexp(self): q = r'artist::t.+r' results = self.lib.items(q) self.assert_matched(results, ['beets 4 eva']) def test_get_one_unkeyed_term(self): q = 'three' results = self.lib.items(q) self.assert_matched(results, ['beets 4 eva']) def test_get_one_unkeyed_regexp(self): q = r':x$' results = self.lib.items(q) self.assert_matched(results, ['baz qux']) def test_get_no_matches(self): q = 'popebear' results = self.lib.items(q) self.assert_matched(results, []) def test_invalid_key(self): q = 'pope:bear' results = self.lib.items(q) # Matches nothing since the flexattr is not present on the # objects. self.assert_matched(results, []) def test_term_case_insensitive(self): q = 'oNE' results = self.lib.items(q) self.assert_matched(results, ['foo bar']) def test_regexp_case_sensitive(self): q = r':oNE' results = self.lib.items(q) self.assert_matched(results, []) q = r':one' results = self.lib.items(q) self.assert_matched(results, ['foo bar']) def test_term_case_insensitive_with_key(self): q = 'artist:thrEE' results = self.lib.items(q) self.assert_matched(results, ['beets 4 eva']) def test_key_case_insensitive(self): q = 'ArTiST:three' results = self.lib.items(q) self.assert_matched(results, ['beets 4 eva']) def test_unkeyed_term_matches_multiple_columns(self): q = 'baz' results = self.lib.items(q) self.assert_matched(results, [ 'foo bar', 'baz qux', ]) def test_unkeyed_regexp_matches_multiple_columns(self): q = r':z$' results = self.lib.items(q) self.assert_matched(results, [ 'foo bar', 'baz qux', ]) def test_keyed_term_matches_only_one_column(self): q = 'title:baz' results = self.lib.items(q) self.assert_matched(results, ['baz qux']) def test_keyed_regexp_matches_only_one_column(self): q = r'title::baz' results = self.lib.items(q) self.assert_matched(results, [ 'baz qux', ]) def test_multiple_terms_narrow_search(self): q = 'qux baz' results = self.lib.items(q) self.assert_matched(results, [ 'baz qux', ]) def test_multiple_regexps_narrow_search(self): q = r':baz :qux' results = self.lib.items(q) self.assert_matched(results, ['baz qux']) def test_mixed_terms_regexps_narrow_search(self): q = r':baz qux' results = self.lib.items(q) self.assert_matched(results, ['baz qux']) def test_single_year(self): q = 'year:2001' results = self.lib.items(q) self.assert_matched(results, ['foo bar']) def test_year_range(self): q = 'year:2000..2002' results = self.lib.items(q) self.assert_matched(results, [ 'foo bar', 'baz qux', ]) def test_bad_year(self): q = 'year:delete from items' self.assertRaises(ValueError, self.lib.items, q) def test_singleton_true(self): q = 'singleton:true' results = self.lib.items(q) self.assert_matched(results, ['beets 4 eva']) def test_singleton_false(self): q = 'singleton:false' results = self.lib.items(q) self.assert_matched(results, ['foo bar', 'baz qux']) def test_compilation_true(self): q = 'comp:true' results = self.lib.items(q) self.assert_matched(results, ['foo bar', 'baz qux']) def test_compilation_false(self): q = 'comp:false' results = self.lib.items(q) self.assert_matched(results, ['beets 4 eva']) def test_unknown_field_name_no_results(self): q = 'xyzzy:nonsense' results = self.lib.items(q) titles = [i.title for i in results] self.assertEqual(titles, []) def test_unknown_field_name_no_results_in_album_query(self): q = 'xyzzy:nonsense' results = self.lib.albums(q) names = [a.album for a in results] self.assertEqual(names, []) def test_item_field_name_matches_nothing_in_album_query(self): q = 'format:nonsense' results = self.lib.albums(q) names = [a.album for a in results] self.assertEqual(names, []) def test_unicode_query(self): item = self.lib.items().get() item.title = u'caf\xe9' item.store() q = u'title:caf\xe9' results = self.lib.items(q) self.assert_matched(results, [u'caf\xe9']) def test_numeric_search_positive(self): q = beets.library.NumericQuery('year', '2001') results = self.lib.items(q) self.assertTrue(results) def test_numeric_search_negative(self): q = beets.library.NumericQuery('year', '1999') results = self.lib.items(q) self.assertFalse(results) class MatchTest(_common.TestCase): def setUp(self): super(MatchTest, self).setUp() self.item = _common.item() def test_regex_match_positive(self): q = beets.library.RegexpQuery('album', '^the album$') self.assertTrue(q.match(self.item)) def test_regex_match_negative(self): q = beets.library.RegexpQuery('album', '^album$') self.assertFalse(q.match(self.item)) def test_regex_match_non_string_value(self): q = beets.library.RegexpQuery('disc', '^6$') self.assertTrue(q.match(self.item)) def test_substring_match_positive(self): q = beets.library.SubstringQuery('album', 'album') self.assertTrue(q.match(self.item)) def test_substring_match_negative(self): q = beets.library.SubstringQuery('album', 'ablum') self.assertFalse(q.match(self.item)) def test_substring_match_non_string_value(self): q = beets.library.SubstringQuery('disc', '6') self.assertTrue(q.match(self.item)) def test_year_match_positive(self): q = beets.library.NumericQuery('year', '1') self.assertTrue(q.match(self.item)) def test_year_match_negative(self): q = beets.library.NumericQuery('year', '10') self.assertFalse(q.match(self.item)) def test_bitrate_range_positive(self): q = beets.library.NumericQuery('bitrate', '100000..200000') self.assertTrue(q.match(self.item)) def test_bitrate_range_negative(self): q = beets.library.NumericQuery('bitrate', '200000..300000') self.assertFalse(q.match(self.item)) class PathQueryTest(_common.LibTestCase, AssertsMixin): def setUp(self): super(PathQueryTest, self).setUp() self.i.path = '/a/b/c.mp3' self.i.title = 'path item' self.i.store() def test_path_exact_match(self): q = 'path:/a/b/c.mp3' results = self.lib.items(q) self.assert_matched(results, ['path item']) def test_parent_directory_no_slash(self): q = 'path:/a' results = self.lib.items(q) self.assert_matched(results, ['path item']) def test_parent_directory_with_slash(self): q = 'path:/a/' results = self.lib.items(q) self.assert_matched(results, ['path item']) def test_no_match(self): q = 'path:/xyzzy/' results = self.lib.items(q) self.assert_matched(results, []) def test_fragment_no_match(self): q = 'path:/b/' results = self.lib.items(q) self.assert_matched(results, []) def test_nonnorm_path(self): q = 'path:/x/../a/b' results = self.lib.items(q) self.assert_matched(results, ['path item']) def test_slashed_query_matches_path(self): q = '/a/b' results = self.lib.items(q) self.assert_matched(results, ['path item']) def test_non_slashed_does_not_match_path(self): q = 'c.mp3' results = self.lib.items(q) self.assert_matched(results, []) def test_slashes_in_explicit_field_does_not_match_path(self): q = 'title:/a/b' results = self.lib.items(q) self.assert_matched(results, []) def test_path_regex(self): q = 'path::\\.mp3$' results = self.lib.items(q) self.assert_matched(results, ['path item']) class DefaultSearchFieldsTest(DummyDataTestCase): def test_albums_matches_album(self): albums = list(self.lib.albums('baz')) self.assertEqual(len(albums), 1) def test_albums_matches_albumartist(self): albums = list(self.lib.albums(['album artist'])) self.assertEqual(len(albums), 1) def test_items_matches_title(self): items = self.lib.items('beets') self.assert_matched(items, ['beets 4 eva']) def test_items_does_not_match_year(self): items = self.lib.items('2001') self.assert_matched(items, []) class StringParseTest(_common.TestCase): def test_single_field_query(self): q = beets.library.AndQuery.from_string(u'albumtype:soundtrack') self.assertEqual(len(q.subqueries), 1) subq = q.subqueries[0] self.assertTrue(isinstance(subq, beets.library.SubstringQuery)) self.assertEqual(subq.field, 'albumtype') self.assertEqual(subq.pattern, 'soundtrack') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_template.py0000644000076500000240000002325712102026773017776 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for template engine. """ from _common import unittest from beets.util import functemplate def _normexpr(expr): """Normalize an Expression object's parts, collapsing multiple adjacent text blocks and removing empty text blocks. Generates a sequence of parts. """ textbuf = [] for part in expr.parts: if isinstance(part, basestring): textbuf.append(part) else: if textbuf: text = u''.join(textbuf) if text: yield text textbuf = [] yield part if textbuf: text = u''.join(textbuf) if text: yield text def _normparse(text): """Parse a template and then normalize the resulting Expression.""" return _normexpr(functemplate._parse(text)) class ParseTest(unittest.TestCase): def test_empty_string(self): self.assertEqual(list(_normparse(u'')), []) def _assert_symbol(self, obj, ident): """Assert that an object is a Symbol with the given identifier. """ self.assertTrue(isinstance(obj, functemplate.Symbol), u"not a Symbol: %s" % repr(obj)) self.assertEqual(obj.ident, ident, u"wrong identifier: %s vs. %s" % (repr(obj.ident), repr(ident))) def _assert_call(self, obj, ident, numargs): """Assert that an object is a Call with the given identifier and argument count. """ self.assertTrue(isinstance(obj, functemplate.Call), u"not a Call: %s" % repr(obj)) self.assertEqual(obj.ident, ident, u"wrong identifier: %s vs. %s" % (repr(obj.ident), repr(ident))) self.assertEqual(len(obj.args), numargs, u"wrong argument count in %s: %i vs. %i" % (repr(obj.ident), len(obj.args), numargs)) def test_plain_text(self): self.assertEqual(list(_normparse(u'hello world')), [u'hello world']) def test_escaped_character_only(self): self.assertEqual(list(_normparse(u'$$')), [u'$']) def test_escaped_character_in_text(self): self.assertEqual(list(_normparse(u'a $$ b')), [u'a $ b']) def test_escaped_character_at_start(self): self.assertEqual(list(_normparse(u'$$ hello')), [u'$ hello']) def test_escaped_character_at_end(self): self.assertEqual(list(_normparse(u'hello $$')), [u'hello $']) def test_escaped_function_delim(self): self.assertEqual(list(_normparse(u'a $% b')), [u'a % b']) def test_escaped_sep(self): self.assertEqual(list(_normparse(u'a $, b')), [u'a , b']) def test_escaped_close_brace(self): self.assertEqual(list(_normparse(u'a $} b')), [u'a } b']) def test_bare_value_delim_kept_intact(self): self.assertEqual(list(_normparse(u'a $ b')), [u'a $ b']) def test_bare_function_delim_kept_intact(self): self.assertEqual(list(_normparse(u'a % b')), [u'a % b']) def test_bare_opener_kept_intact(self): self.assertEqual(list(_normparse(u'a { b')), [u'a { b']) def test_bare_closer_kept_intact(self): self.assertEqual(list(_normparse(u'a } b')), [u'a } b']) def test_bare_sep_kept_intact(self): self.assertEqual(list(_normparse(u'a , b')), [u'a , b']) def test_symbol_alone(self): parts = list(_normparse(u'$foo')) self.assertEqual(len(parts), 1) self._assert_symbol(parts[0], u"foo") def test_symbol_in_text(self): parts = list(_normparse(u'hello $foo world')) self.assertEqual(len(parts), 3) self.assertEqual(parts[0], u'hello ') self._assert_symbol(parts[1], u"foo") self.assertEqual(parts[2], u' world') def test_symbol_with_braces(self): parts = list(_normparse(u'hello${foo}world')) self.assertEqual(len(parts), 3) self.assertEqual(parts[0], u'hello') self._assert_symbol(parts[1], u"foo") self.assertEqual(parts[2], u'world') def test_unclosed_braces_symbol(self): self.assertEqual(list(_normparse(u'a ${ b')), [u'a ${ b']) def test_empty_braces_symbol(self): self.assertEqual(list(_normparse(u'a ${} b')), [u'a ${} b']) def test_call_without_args_at_end(self): self.assertEqual(list(_normparse(u'foo %bar')), [u'foo %bar']) def test_call_without_args(self): self.assertEqual(list(_normparse(u'foo %bar baz')), [u'foo %bar baz']) def test_call_with_unclosed_args(self): self.assertEqual(list(_normparse(u'foo %bar{ baz')), [u'foo %bar{ baz']) def test_call_with_unclosed_multiple_args(self): self.assertEqual(list(_normparse(u'foo %bar{bar,bar baz')), [u'foo %bar{bar,bar baz']) def test_call_empty_arg(self): parts = list(_normparse(u'%foo{}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), []) def test_call_single_arg(self): parts = list(_normparse(u'%foo{bar}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), [u'bar']) def test_call_two_args(self): parts = list(_normparse(u'%foo{bar,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 2) self.assertEqual(list(_normexpr(parts[0].args[0])), [u'bar']) self.assertEqual(list(_normexpr(parts[0].args[1])), [u'baz']) def test_call_with_escaped_sep(self): parts = list(_normparse(u'%foo{bar$,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), [u'bar,baz']) def test_call_with_escaped_close(self): parts = list(_normparse(u'%foo{bar$}baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), [u'bar}baz']) def test_call_with_symbol_argument(self): parts = list(_normparse(u'%foo{$bar,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 2) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_symbol(arg_parts[0], u"bar") self.assertEqual(list(_normexpr(parts[0].args[1])), [u"baz"]) def test_call_with_nested_call_argument(self): parts = list(_normparse(u'%foo{%bar{},baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 2) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_call(arg_parts[0], u"bar", 1) self.assertEqual(list(_normexpr(parts[0].args[1])), [u"baz"]) def test_nested_call_with_argument(self): parts = list(_normparse(u'%foo{%bar{baz}}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], u"foo", 1) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_call(arg_parts[0], u"bar", 1) self.assertEqual(list(_normexpr(arg_parts[0].args[0])), [u'baz']) class EvalTest(unittest.TestCase): def _eval(self, template): values = { u'foo': u'bar', u'baz': u'BaR', } functions = { u'lower': unicode.lower, u'len': len, } return functemplate.Template(template).substitute(values, functions) def test_plain_text(self): self.assertEqual(self._eval(u"foo"), u"foo") def test_subtitute_value(self): self.assertEqual(self._eval(u"$foo"), u"bar") def test_subtitute_value_in_text(self): self.assertEqual(self._eval(u"hello $foo world"), u"hello bar world") def test_not_subtitute_undefined_value(self): self.assertEqual(self._eval(u"$bar"), u"$bar") def test_function_call(self): self.assertEqual(self._eval(u"%lower{FOO}"), u"foo") def test_function_call_with_text(self): self.assertEqual(self._eval(u"A %lower{FOO} B"), u"A foo B") def test_nested_function_call(self): self.assertEqual(self._eval(u"%lower{%lower{FOO}}"), u"foo") def test_symbol_in_argument(self): self.assertEqual(self._eval(u"%lower{$baz}"), u"bar") def test_function_call_exception(self): res = self._eval(u"%lower{a,b,c,d,e}") self.assertTrue(isinstance(res, basestring)) def test_function_returning_integer(self): self.assertEqual(self._eval(u"%len{foo}"), u"3") def test_not_subtitute_undefined_func(self): self.assertEqual(self._eval(u"%bar{}"), u"%bar{}") def test_not_subtitute_func_with_no_args(self): self.assertEqual(self._eval(u"%lower"), u"%lower") def test_function_call_with_empty_arg(self): self.assertEqual(self._eval(u"%len{}"), u"0") def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_the.py0000644000076500000240000000517312102026773016740 0ustar asampsonstaff00000000000000"""Tests for the 'the' plugin""" from _common import unittest import _common from beets import config from beetsplug.the import ThePlugin, PATTERN_A, PATTERN_THE, FORMAT class ThePluginTest(_common.TestCase): def test_unthe_with_default_patterns(self): self.assertEqual(ThePlugin().unthe('', PATTERN_THE), '') self.assertEqual(ThePlugin().unthe('The Something', PATTERN_THE), 'Something, The') self.assertEqual(ThePlugin().unthe('The The', PATTERN_THE), 'The, The') self.assertEqual(ThePlugin().unthe('The The', PATTERN_THE), 'The, The') self.assertEqual(ThePlugin().unthe('The The X', PATTERN_THE), 'The X, The') self.assertEqual(ThePlugin().unthe('the The', PATTERN_THE), 'The, the') self.assertEqual(ThePlugin().unthe('Protected The', PATTERN_THE), 'Protected The') self.assertEqual(ThePlugin().unthe('A Boy', PATTERN_A), 'Boy, A') self.assertEqual(ThePlugin().unthe('a girl', PATTERN_A), 'girl, a') self.assertEqual(ThePlugin().unthe('An Apple', PATTERN_A), 'Apple, An') self.assertEqual(ThePlugin().unthe('An A Thing', PATTERN_A), 'A Thing, An') self.assertEqual(ThePlugin().unthe('the An Arse', PATTERN_A), 'the An Arse') def test_unthe_with_strip(self): config['the']['strip'] = True self.assertEqual(ThePlugin().unthe('The Something', PATTERN_THE), 'Something') self.assertEqual(ThePlugin().unthe('An A', PATTERN_A), 'A') def test_template_function_with_defaults(self): ThePlugin().patterns = [PATTERN_THE, PATTERN_A] ThePlugin().format = FORMAT self.assertEqual(ThePlugin().the_template_func('The The'), 'The, The') self.assertEqual(ThePlugin().the_template_func('An A'), 'A, An') def test_custom_pattern(self): config['the']['patterns'] = [u'^test\s'] config['the']['format'] = FORMAT self.assertEqual(ThePlugin().the_template_func('test passed'), 'passed, test') def test_custom_format(self): config['the']['patterns'] = [PATTERN_THE, PATTERN_A] config['the']['format'] = u'{1} ({0})' self.assertEqual(ThePlugin().the_template_func('The A'), 'The (A)') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_ui.py0000644000076500000240000005371712215762014016604 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for the command-line interface. """ import os import shutil import textwrap import re import yaml import _common from _common import unittest from beets import library from beets import ui from beets.ui import commands from beets import autotag from beets.autotag.match import distance from beets import importer from beets.mediafile import MediaFile from beets import config from beets.util import confit class ListTest(_common.TestCase): def setUp(self): super(ListTest, self).setUp() self.io.install() self.lib = library.Library(':memory:') i = _common.item() i.path = 'xxx/yyy' self.lib.add(i) self.lib.add_album([i]) self.item = i def _run_list(self, query='', album=False, path=False, fmt=None): commands.list_items(self.lib, query, album, fmt) def test_list_outputs_item(self): self._run_list() out = self.io.getoutput() self.assertTrue(u'the title' in out) def test_list_unicode_query(self): self.item.title = u'na\xefve' self.item.store() self.lib._connection().commit() self._run_list([u'na\xefve']) out = self.io.getoutput() self.assertTrue(u'na\xefve' in out.decode(self.io.stdout.encoding)) def test_list_item_path(self): self._run_list(fmt='$path') out = self.io.getoutput() self.assertEqual(out.strip(), u'xxx/yyy') def test_list_album_outputs_something(self): self._run_list(album=True) out = self.io.getoutput() self.assertGreater(len(out), 0) def test_list_album_path(self): self._run_list(album=True, fmt='$path') out = self.io.getoutput() self.assertEqual(out.strip(), u'xxx') def test_list_album_omits_title(self): self._run_list(album=True) out = self.io.getoutput() self.assertTrue(u'the title' not in out) def test_list_uses_track_artist(self): self._run_list() out = self.io.getoutput() self.assertTrue(u'the artist' in out) self.assertTrue(u'the album artist' not in out) def test_list_album_uses_album_artist(self): self._run_list(album=True) out = self.io.getoutput() self.assertTrue(u'the artist' not in out) self.assertTrue(u'the album artist' in out) def test_list_item_format_artist(self): self._run_list(fmt='$artist') out = self.io.getoutput() self.assertTrue(u'the artist' in out) def test_list_item_format_multiple(self): self._run_list(fmt='$artist - $album - $year') out = self.io.getoutput() self.assertTrue(u'1' in out) self.assertTrue(u'the album' in out) self.assertTrue(u'the artist' in out) self.assertEqual(u'the artist - the album - 1', out.strip()) def test_list_album_format(self): self._run_list(album=True, fmt='$genre') out = self.io.getoutput() self.assertTrue(u'the genre' in out) self.assertTrue(u'the album' not in out) class RemoveTest(_common.TestCase): def setUp(self): super(RemoveTest, self).setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, 'testlibdir') os.mkdir(self.libdir) # Copy a file into the library. self.lib = library.Library(':memory:', self.libdir) self.i = library.Item.from_path(os.path.join(_common.RSRC, 'full.mp3')) self.lib.add(self.i) self.i.move(True) def test_remove_items_no_delete(self): self.io.addinput('y') commands.remove_items(self.lib, '', False, False) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertTrue(os.path.exists(self.i.path)) def test_remove_items_with_delete(self): self.io.addinput('y') commands.remove_items(self.lib, '', False, True) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertFalse(os.path.exists(self.i.path)) class ModifyTest(_common.TestCase): def setUp(self): super(ModifyTest, self).setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, 'testlibdir') # Copy a file into the library. self.lib = library.Library(':memory:', self.libdir) self.i = library.Item.from_path(os.path.join(_common.RSRC, 'full.mp3')) self.lib.add(self.i) self.i.move(True) self.album = self.lib.add_album([self.i]) def _modify(self, mods, query=(), write=False, move=False, album=False): self.io.addinput('y') commands.modify_items(self.lib, mods, query, write, move, album, True) def test_modify_item_dbdata(self): self._modify(["title=newTitle"]) item = self.lib.items().get() self.assertEqual(item.title, 'newTitle') def test_modify_album_dbdata(self): self._modify(["album=newAlbum"], album=True) album = self.lib.albums()[0] self.assertEqual(album.album, 'newAlbum') def test_modify_item_tag_unmodified(self): self._modify(["title=newTitle"], write=False) item = self.lib.items().get() item.read() self.assertEqual(item.title, 'full') def test_modify_album_tag_unmodified(self): self._modify(["album=newAlbum"], write=False, album=True) item = self.lib.items().get() item.read() self.assertEqual(item.album, 'the album') def test_modify_item_tag(self): self._modify(["title=newTitle"], write=True) item = self.lib.items().get() item.read() self.assertEqual(item.title, 'newTitle') def test_modify_album_tag(self): self._modify(["album=newAlbum"], write=True, album=True) item = self.lib.items().get() item.read() self.assertEqual(item.album, 'newAlbum') def test_item_move(self): self._modify(["title=newTitle"], move=True) item = self.lib.items().get() self.assertTrue('newTitle' in item.path) def test_album_move(self): self._modify(["album=newAlbum"], move=True, album=True) item = self.lib.items().get() item.read() self.assertTrue('newAlbum' in item.path) def test_item_not_move(self): self._modify(["title=newTitle"], move=False) item = self.lib.items().get() self.assertFalse('newTitle' in item.path) def test_album_not_move(self): self._modify(["album=newAlbum"], move=False, album=True) item = self.lib.items().get() item.read() self.assertFalse('newAlbum' in item.path) class MoveTest(_common.TestCase): def setUp(self): super(MoveTest, self).setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, 'testlibdir') os.mkdir(self.libdir) self.itempath = os.path.join(self.libdir, 'srcfile') shutil.copy(os.path.join(_common.RSRC, 'full.mp3'), self.itempath) # Add a file to the library but don't copy it in yet. self.lib = library.Library(':memory:', self.libdir) self.i = library.Item.from_path(self.itempath) self.lib.add(self.i) self.album = self.lib.add_album([self.i]) # Alternate destination directory. self.otherdir = os.path.join(self.temp_dir, 'testotherdir') def _move(self, query=(), dest=None, copy=False, album=False): commands.move_items(self.lib, dest, query, copy, album) def test_move_item(self): self._move() self.i.load() self.assertTrue('testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_copy_item(self): self._move(copy=True) self.i.load() self.assertTrue('testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertExists(self.itempath) def test_move_album(self): self._move(album=True) self.i.load() self.assertTrue('testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_copy_album(self): self._move(copy=True, album=True) self.i.load() self.assertTrue('testlibdir' in self.i.path) self.assertExists(self.i.path) self.assertExists(self.itempath) def test_move_item_custom_dir(self): self._move(dest=self.otherdir) self.i.load() self.assertTrue('testotherdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_move_album_custom_dir(self): self._move(dest=self.otherdir, album=True) self.i.load() self.assertTrue('testotherdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) class UpdateTest(_common.TestCase): def setUp(self): super(UpdateTest, self).setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, 'testlibdir') # Copy a file into the library. self.lib = library.Library(':memory:', self.libdir) self.i = library.Item.from_path(os.path.join(_common.RSRC, 'full.mp3')) self.lib.add(self.i) self.i.move(True) self.album = self.lib.add_album([self.i]) # Album art. artfile = os.path.join(_common.RSRC, 'testart.jpg') _common.touch(artfile) self.album.set_art(artfile) self.album.store() os.remove(artfile) def _update(self, query=(), album=False, move=False, reset_mtime=True): self.io.addinput('y') if reset_mtime: self.i.mtime = 0 self.i.store() commands.update_items(self.lib, query, album, move, False) def test_delete_removes_item(self): self.assertTrue(list(self.lib.items())) os.remove(self.i.path) self._update() self.assertFalse(list(self.lib.items())) def test_delete_removes_album(self): self.assertTrue(self.lib.albums()) os.remove(self.i.path) self._update() self.assertFalse(self.lib.albums()) def test_delete_removes_album_art(self): artpath = self.album.artpath self.assertExists(artpath) os.remove(self.i.path) self._update() self.assertNotExists(artpath) def test_modified_metadata_detected(self): mf = MediaFile(self.i.path) mf.title = 'differentTitle' mf.save() self._update() item = self.lib.items().get() self.assertEqual(item.title, 'differentTitle') def test_modified_metadata_moved(self): mf = MediaFile(self.i.path) mf.title = 'differentTitle' mf.save() self._update(move=True) item = self.lib.items().get() self.assertTrue('differentTitle' in item.path) def test_modified_metadata_not_moved(self): mf = MediaFile(self.i.path) mf.title = 'differentTitle' mf.save() self._update(move=False) item = self.lib.items().get() self.assertTrue('differentTitle' not in item.path) def test_modified_album_metadata_moved(self): mf = MediaFile(self.i.path) mf.album = 'differentAlbum' mf.save() self._update(move=True) item = self.lib.items().get() self.assertTrue('differentAlbum' in item.path) def test_modified_album_metadata_art_moved(self): artpath = self.album.artpath mf = MediaFile(self.i.path) mf.album = 'differentAlbum' mf.save() self._update(move=True) album = self.lib.albums()[0] self.assertNotEqual(artpath, album.artpath) def test_mtime_match_skips_update(self): mf = MediaFile(self.i.path) mf.title = 'differentTitle' mf.save() # Make in-memory mtime match on-disk mtime. self.i.mtime = os.path.getmtime(self.i.path) self.i.store() self._update(reset_mtime=False) item = self.lib.items().get() self.assertEqual(item.title, 'full') class PrintTest(_common.TestCase): def setUp(self): super(PrintTest, self).setUp() self.io.install() def test_print_without_locale(self): lang = os.environ.get('LANG') if lang: del os.environ['LANG'] try: ui.print_(u'something') except TypeError: self.fail('TypeError during print') finally: if lang: os.environ['LANG'] = lang def test_print_with_invalid_locale(self): old_lang = os.environ.get('LANG') os.environ['LANG'] = '' old_ctype = os.environ.get('LC_CTYPE') os.environ['LC_CTYPE'] = 'UTF-8' try: ui.print_(u'something') except ValueError: self.fail('ValueError during print') finally: if old_lang: os.environ['LANG'] = old_lang else: del os.environ['LANG'] if old_ctype: os.environ['LC_CTYPE'] = old_ctype else: del os.environ['LC_CTYPE'] class AutotagTest(_common.TestCase): def setUp(self): super(AutotagTest, self).setUp() self.io.install() def _no_candidates_test(self, result): task = importer.ImportTask( 'toppath', 'path', [_common.item()], ) task.set_candidates('artist', 'album', [], autotag.recommendation.none) session = _common.import_session(cli=True) res = session.choose_match(task) self.assertEqual(res, result) self.assertTrue('No match' in self.io.getoutput()) def test_choose_match_with_no_candidates_skip(self): self.io.addinput('s') self._no_candidates_test(importer.action.SKIP) def test_choose_match_with_no_candidates_asis(self): self.io.addinput('u') self._no_candidates_test(importer.action.ASIS) class ImportTest(_common.TestCase): def test_quiet_timid_disallowed(self): config['import']['quiet'] = True config['import']['timid'] = True self.assertRaises(ui.UserError, commands.import_files, None, [], None) class InputTest(_common.TestCase): def setUp(self): super(InputTest, self).setUp() self.io.install() def test_manual_search_gets_unicode(self): self.io.addinput('\xc3\x82me') self.io.addinput('\xc3\x82me') artist, album = commands.manual_search(False) self.assertEqual(artist, u'\xc2me') self.assertEqual(album, u'\xc2me') class ConfigTest(_common.TestCase): def setUp(self): super(ConfigTest, self).setUp() self.io.install() self.test_cmd = ui.Subcommand('test', help='test') commands.default_commands.append(self.test_cmd) def tearDown(self): super(ConfigTest, self).tearDown() commands.default_commands.pop() def _run_main(self, args, config_yaml, func): self.test_cmd.func = func config_yaml = textwrap.dedent(config_yaml).strip() if config_yaml: config_data = yaml.load(config_yaml, Loader=confit.Loader) config.set(config_data) ui._raw_main(args + ['test']) def test_paths_section_respected(self): def func(lib, opts, args): key, template = lib.path_formats[0] self.assertEqual(key, 'x') self.assertEqual(template.original, 'y') self._run_main([], """ paths: x: y """, func) def test_default_paths_preserved(self): default_formats = ui.get_path_formats() def func(lib, opts, args): self.assertEqual(lib.path_formats[1:], default_formats) self._run_main([], """ paths: x: y """, func) def test_nonexistant_config_file(self): os.environ['BEETSCONFIG'] = '/xxxxx' ui.main(['version']) def test_nonexistant_db(self): def func(lib, opts, args): pass with self.assertRaises(ui.UserError): self._run_main([], """ library: /xxx/yyy/not/a/real/path """, func) def test_replacements_parsed(self): def func(lib, opts, args): replacements = lib.replacements self.assertEqual(replacements, [(re.compile(ur'[xy]'), u'z')]) self._run_main([], """ replace: '[xy]': z """, func) def test_multiple_replacements_parsed(self): def func(lib, opts, args): replacements = lib.replacements self.assertEqual(replacements, [ (re.compile(ur'[xy]'), u'z'), (re.compile(ur'foo'), u'bar'), ]) self._run_main([], """ replace: '[xy]': z foo: bar """, func) class ShowdiffTest(_common.TestCase): def setUp(self): super(ShowdiffTest, self).setUp() self.io.install() def test_showdiff_strings(self): commands._showdiff('field', 'old', 'new') out = self.io.getoutput() self.assertTrue('field' in out) def test_showdiff_identical(self): commands._showdiff('field', 'old', 'old') out = self.io.getoutput() self.assertFalse('field' in out) def test_showdiff_ints(self): commands._showdiff('field', 2, 3) out = self.io.getoutput() self.assertTrue('field' in out) def test_showdiff_ints_no_color(self): config['color'] = False commands._showdiff('field', 2, 3) out = self.io.getoutput() self.assertTrue('field' in out) def test_showdiff_shows_both(self): commands._showdiff('field', 'old', 'new') out = self.io.getoutput() self.assertTrue('old' in out) self.assertTrue('new' in out) def test_showdiff_floats_close_to_identical(self): commands._showdiff('field', 1.999, 2.001) out = self.io.getoutput() self.assertFalse('field' in out) def test_showdiff_floats_differenct(self): commands._showdiff('field', 1.999, 4.001) out = self.io.getoutput() self.assertTrue('field' in out) def test_showdiff_ints_colorizing_is_not_stringwise(self): commands._showdiff('field', 222, 333) complete_diff = self.io.getoutput().split()[1] commands._showdiff('field', 222, 232) partial_diff = self.io.getoutput().split()[1] self.assertEqual(complete_diff, partial_diff) class ShowChangeTest(_common.TestCase): def setUp(self): super(ShowChangeTest, self).setUp() self.io.install() self.items = [_common.item()] self.items[0].track = 1 self.items[0].path = '/path/to/file.mp3' self.info = autotag.AlbumInfo( u'the album', u'album id', u'the artist', u'artist id', [ autotag.TrackInfo(u'the title', u'track id', index=1) ]) def _show_change(self, items=None, info=None, cur_artist=u'the artist', cur_album=u'the album', dist=0.1): items = items or self.items info = info or self.info mapping = dict(zip(items, info.tracks)) config['color'] = False album_dist = distance(items, info, mapping) album_dist._penalties = {'album': [dist]} commands.show_change( cur_artist, cur_album, autotag.AlbumMatch(album_dist, info, mapping, set(), set()), ) return self.io.getoutput().lower() def test_null_change(self): msg = self._show_change() self.assertTrue('similarity: 90' in msg) self.assertTrue('tagging:' in msg) def test_album_data_change(self): msg = self._show_change(cur_artist='another artist', cur_album='another album') self.assertTrue('correcting tags from:' in msg) def test_item_data_change(self): self.items[0].title = u'different' msg = self._show_change() self.assertTrue('different -> the title' in msg) def test_item_data_change_with_unicode(self): self.items[0].title = u'caf\xe9' msg = self._show_change() self.assertTrue(u'caf\xe9 -> the title' in msg.decode('utf8')) def test_album_data_change_with_unicode(self): msg = self._show_change(cur_artist=u'caf\xe9', cur_album=u'another album') self.assertTrue('correcting tags from:' in msg) def test_item_data_change_title_missing(self): self.items[0].title = u'' msg = re.sub(r' +', ' ', self._show_change()) self.assertTrue('file.mp3 -> the title' in msg) def test_item_data_change_title_missing_with_unicode_filename(self): self.items[0].title = u'' self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf8') msg = re.sub(r' +', ' ', self._show_change().decode('utf8')) self.assertTrue(u'caf\xe9.mp3 -> the title' in msg or u'caf.mp3 ->' in msg) class PathFormatTest(_common.TestCase): def test_custom_paths_prepend(self): default_formats = ui.get_path_formats() config['paths'] = {u'foo': u'bar'} pf = ui.get_path_formats() key, tmpl = pf[0] self.assertEqual(key, 'foo') self.assertEqual(tmpl.original, 'bar') self.assertEqual(pf[1:], default_formats) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_vfs.py0000644000076500000240000000313412203275653016756 0ustar asampsonstaff00000000000000# This file is part of beets. # Copyright 2013, 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. """Tests for the virtual filesystem builder..""" import _common from _common import unittest from beets import library from beets import vfs class VFSTest(_common.TestCase): def setUp(self): super(VFSTest, self).setUp() self.lib = library.Library(':memory:', path_formats=[ ('default', 'albums/$album/$title'), ('singleton:true', 'tracks/$artist/$title'), ]) self.lib.add(_common.item()) self.lib.add_album([_common.item()]) self.tree = vfs.libtree(self.lib) def test_singleton_item(self): self.assertEqual(self.tree.dirs['tracks'].dirs['the artist']. files['the title'], 1) def test_album_item(self): self.assertEqual(self.tree.dirs['albums'].dirs['the album']. files['the title'], 2) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/test_zero.py0000644000076500000240000000243412207240712017131 0ustar asampsonstaff00000000000000"""Tests for the 'zero' plugin""" from _common import unittest from beets.library import Item from beetsplug.zero import ZeroPlugin class ZeroPluginTest(unittest.TestCase): def test_no_patterns(self): i = Item( comments='test comment', day=13, month=3, year=2012, ) z = ZeroPlugin() z.debug = False z.fields = ['comments', 'month', 'day'] z.patterns = {'comments': ['.'], 'month': ['.'], 'day': ['.']} z.write_event(i) self.assertEqual(i.comments, '') self.assertEqual(i.day, 0) self.assertEqual(i.month, 0) self.assertEqual(i.year, 2012) def test_patterns(self): i = Item( comments='from lame collection, ripped by eac', year=2012, ) z = ZeroPlugin() z.debug = False z.fields = ['comments', 'year'] z.patterns = {'comments': 'eac lame'.split(), 'year': '2098 2099'.split()} z.write_event(i) self.assertEqual(i.comments, '') self.assertEqual(i.year, 2012) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') beets-1.3.1/test/testall.py0000755000076500000240000000254412203275653016600 0ustar asampsonstaff00000000000000#!/usr/bin/env python # This file is part of beets. # Copyright 2013, 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 os import re import sys from _common import unittest pkgpath = os.path.dirname(__file__) or '.' sys.path.append(pkgpath) os.chdir(pkgpath) # Make sure we use local version of beetsplug and not system namespaced version # for tests try: del sys.modules["beetsplug"] except KeyError: pass def suite(): s = unittest.TestSuite() # Get the suite() of every module in this directory beginning with # "test_". for fname in os.listdir(pkgpath): match = re.match(r'(test_\S+)\.py$', fname) if match: modname = match.group(1) s.addTest(__import__(modname).suite()) return s if __name__ == '__main__': unittest.main(defaultTest='suite')