././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1638031078.540472 beets-1.6.0/0000755000076500000240000000000000000000000012325 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/LICENSE0000644000076500000240000000207000000000000013331 0ustar00asampsonstaffThe MIT License Copyright (c) 2010-2016 Adrian Sampson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/MANIFEST.in0000644000076500000240000000153100000000000014063 0ustar00asampsonstaff# Include tests (but avoid including *.pyc, etc.) prune test recursive-include test/rsrc * recursive-exclude test/rsrc *.pyc recursive-exclude test/rsrc *.pyo 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 # Shell completion template include beets/ui/completion_base.sh # Include extra bits recursive-include extra * ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1638031078.5406172 beets-1.6.0/PKG-INFO0000644000076500000240000001473400000000000013433 0ustar00asampsonstaffMetadata-Version: 2.1 Name: beets Version: 1.6.0 Summary: music tagger and library organizer Home-page: https://beets.io/ Author: Adrian Sampson Author-email: adrian@radbox.org License: MIT 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 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: Implementation :: CPython Provides-Extra: test Provides-Extra: lint Provides-Extra: absubmit Provides-Extra: fetchart Provides-Extra: embedart Provides-Extra: embyupdate Provides-Extra: chroma Provides-Extra: discogs Provides-Extra: beatport Provides-Extra: kodiupdate Provides-Extra: lastgenre Provides-Extra: lastimport Provides-Extra: lyrics Provides-Extra: mpdstats Provides-Extra: plexupdate Provides-Extra: web Provides-Extra: import Provides-Extra: thumbnails Provides-Extra: metasync Provides-Extra: sonosupdate Provides-Extra: scrub Provides-Extra: bpd Provides-Extra: replaygain Provides-Extra: reflink License-File: LICENSE .. image:: https://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets .. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets .. image:: https://github.com/beetbox/beets/workflows/ci/badge.svg?branch=master :target: https://github.com/beetbox/beets/actions .. image:: https://repology.org/badge/tiny-repos/beets.svg :target: https://repology.org/project/beets/versions beets ===== Beets is the media library management system for obsessive 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: - Fetch or calculate all the metadata you could possibly need: `album art`_, `lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic fingerprints`_. - Get metadata from `MusicBrainz`_, `Discogs`_, and `Beatport`_. Or guess metadata using songs' filenames or their acoustic fingerprints. - `Transcode audio`_ to any format you like. - Check your library for `duplicate tracks and albums`_ or for `albums that are missing tracks`_. - Clean up crufty tags left behind by other, less-awesome tools. - Embed and extract album art from files' metadata. - Browse your music library graphically through a Web browser and play it in any browser that supports `HTML5 Audio`_. - Analyze music files' metadata from the command line. - Listen to your library with a music player that speaks the `MPD`_ protocol and works with a staggering variety of interfaces. If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. .. _plugins: https://beets.readthedocs.org/page/plugins/ .. _MPD: https://www.musicpd.org/ .. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/ .. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html .. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html .. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html .. _Transcode audio: https://beets.readthedocs.org/page/plugins/convert.html .. _Discogs: https://www.discogs.com/ .. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html .. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html .. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html .. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html .. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html .. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html .. _MusicBrainz: https://musicbrainz.org/ .. _Beatport: https://www.beatport.com Install ------- You can install beets by typing ``pip install beets``. Beets has also been packaged in the `software repositories`_ of several distributions. Check out the `Getting Started`_ guide for more information. .. _Getting Started: https://beets.readthedocs.org/page/guides/main.html .. _software repositories: https://repology.org/project/beets/versions Contribute ---------- Thank you for considering contributing to ``beets``! Whether you're a programmer or not, you should be able to find all the info you need at `CONTRIBUTING.rst`_. .. _CONTRIBUTING.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst Read More --------- Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for news and updates. .. _its Web site: https://beets.io/ .. _@b33ts: https://twitter.com/b33ts/ Contact ------- * Encountered a bug you'd like to report? Check out our `issue tracker`_! * If your issue hasn't already been reported, please `open a new ticket`_ and we'll be in touch with you shortly. * If you'd like to vote on a feature/bug, simply give a :+1: on issues you'd like to see prioritized over others. * Need help/support, would like to start a discussion, have an idea for a new feature, or would just like to introduce yourself to the team? Check out `GitHub Discussions`_ or `Discourse`_! .. _GitHub Discussions: https://github.com/beetbox/beets/discussions .. _issue tracker: https://github.com/beetbox/beets/issues .. _open a new ticket: https://github.com/beetbox/beets/issues/new/choose .. _Discourse: https://discourse.beets.io/ Authors ------- Beets is by `Adrian Sampson`_ with a supporting cast of thousands. .. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1624915871.0 beets-1.6.0/README.rst0000644000076500000240000001212000000000000014010 0ustar00asampsonstaff.. image:: https://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets .. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets .. image:: https://github.com/beetbox/beets/workflows/ci/badge.svg?branch=master :target: https://github.com/beetbox/beets/actions .. image:: https://repology.org/badge/tiny-repos/beets.svg :target: https://repology.org/project/beets/versions beets ===== Beets is the media library management system for obsessive 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: - Fetch or calculate all the metadata you could possibly need: `album art`_, `lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic fingerprints`_. - Get metadata from `MusicBrainz`_, `Discogs`_, and `Beatport`_. Or guess metadata using songs' filenames or their acoustic fingerprints. - `Transcode audio`_ to any format you like. - Check your library for `duplicate tracks and albums`_ or for `albums that are missing tracks`_. - Clean up crufty tags left behind by other, less-awesome tools. - Embed and extract album art from files' metadata. - Browse your music library graphically through a Web browser and play it in any browser that supports `HTML5 Audio`_. - Analyze music files' metadata from the command line. - Listen to your library with a music player that speaks the `MPD`_ protocol and works with a staggering variety of interfaces. If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. .. _plugins: https://beets.readthedocs.org/page/plugins/ .. _MPD: https://www.musicpd.org/ .. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/ .. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html .. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html .. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html .. _Transcode audio: https://beets.readthedocs.org/page/plugins/convert.html .. _Discogs: https://www.discogs.com/ .. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html .. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html .. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html .. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html .. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html .. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html .. _MusicBrainz: https://musicbrainz.org/ .. _Beatport: https://www.beatport.com Install ------- You can install beets by typing ``pip install beets``. Beets has also been packaged in the `software repositories`_ of several distributions. Check out the `Getting Started`_ guide for more information. .. _Getting Started: https://beets.readthedocs.org/page/guides/main.html .. _software repositories: https://repology.org/project/beets/versions Contribute ---------- Thank you for considering contributing to ``beets``! Whether you're a programmer or not, you should be able to find all the info you need at `CONTRIBUTING.rst`_. .. _CONTRIBUTING.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst Read More --------- Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for news and updates. .. _its Web site: https://beets.io/ .. _@b33ts: https://twitter.com/b33ts/ Contact ------- * Encountered a bug you'd like to report? Check out our `issue tracker`_! * If your issue hasn't already been reported, please `open a new ticket`_ and we'll be in touch with you shortly. * If you'd like to vote on a feature/bug, simply give a :+1: on issues you'd like to see prioritized over others. * Need help/support, would like to start a discussion, have an idea for a new feature, or would just like to introduce yourself to the team? Check out `GitHub Discussions`_ or `Discourse`_! .. _GitHub Discussions: https://github.com/beetbox/beets/discussions .. _issue tracker: https://github.com/beetbox/beets/issues .. _open a new ticket: https://github.com/beetbox/beets/issues/new/choose .. _Discourse: https://discourse.beets.io/ Authors ------- Beets is by `Adrian Sampson`_ with a supporting cast of thousands. .. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/ ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1638031078.243357 beets-1.6.0/beets/0000755000076500000240000000000000000000000013427 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1638029764.0 beets-1.6.0/beets/__init__.py0000644000076500000240000000254400000000000015545 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import confuse from sys import stderr __version__ = '1.6.0' __author__ = 'Adrian Sampson ' class IncludeLazyConfig(confuse.LazyConfig): """A version of Confuse's LazyConfig that also merges in data from YAML files specified in an `include` setting. """ def read(self, user=True, defaults=True): super().read(user, defaults) try: for view in self['include']: self.set_file(view.as_filename()) except confuse.NotFoundError: pass except confuse.ConfigReadError as err: stderr.write("configuration `import` failed: {}" .format(err.reason)) config = IncludeLazyConfig('beets', __name__) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/__main__.py0000644000076500000240000000147100000000000015524 0ustar00asampsonstaff# This file is part of beets. # Copyright 2017, 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 __main__ module lets you run the beets CLI interface by typing `python -m beets`. """ import sys from .ui import main if __name__ == "__main__": main(sys.argv[1:]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/art.py0000644000076500000240000001736400000000000014602 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """High-level utilities for manipulating image files associated with music and items' embedded album art. """ import subprocess import platform from tempfile import NamedTemporaryFile import os from beets.util import displayable_path, syspath, bytestring_path from beets.util.artresizer import ArtResizer import mediafile def mediafile_image(image_path, maxwidth=None): """Return a `mediafile.Image` object for the path. """ with open(syspath(image_path), 'rb') as f: data = f.read() return mediafile.Image(data, type=mediafile.ImageType.front) def get_art(log, item): # Extract the art. try: mf = mediafile.MediaFile(syspath(item.path)) except mediafile.UnreadableFileError as exc: log.warning('Could not extract art from {0}: {1}', displayable_path(item.path), exc) return return mf.art def embed_item(log, item, imagepath, maxwidth=None, itempath=None, compare_threshold=0, ifempty=False, as_album=False, id3v23=None, quality=0): """Embed an image into the item's media file. """ # Conditions and filters. if compare_threshold: if not check_art_similarity(log, item, imagepath, compare_threshold): log.info('Image not similar; skipping.') return if ifempty and get_art(log, item): log.info('media file already contained art') return if maxwidth and not as_album: imagepath = resize_image(log, imagepath, maxwidth, quality) # Get the `Image` object from the file. try: log.debug('embedding {0}', displayable_path(imagepath)) image = mediafile_image(imagepath, maxwidth) except OSError as exc: log.warning('could not read image file: {0}', exc) return # Make sure the image kind is safe (some formats only support PNG # and JPEG). if image.mime_type not in ('image/jpeg', 'image/png'): log.info('not embedding image of unsupported type: {}', image.mime_type) return item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23) def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0, ifempty=False, quality=0): """Embed album art into all of the album's items. """ imagepath = album.artpath if not imagepath: log.info('No album art present for {0}', album) return if not os.path.isfile(syspath(imagepath)): log.info('Album art not found at {0} for {1}', displayable_path(imagepath), album) return if maxwidth: imagepath = resize_image(log, imagepath, maxwidth, quality) log.info('Embedding album art into {0}', album) for item in album.items(): embed_item(log, item, imagepath, maxwidth, None, compare_threshold, ifempty, as_album=True, quality=quality) def resize_image(log, imagepath, maxwidth, quality): """Returns path to an image resized to maxwidth and encoded with the specified quality level. """ log.debug('Resizing album art to {0} pixels wide and encoding at quality \ level {1}', maxwidth, quality) imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath), quality=quality) return imagepath def check_art_similarity(log, item, imagepath, compare_threshold): """A boolean indicating if an image is similar to embedded item art. """ with NamedTemporaryFile(delete=True) as f: art = extract(log, f.name, item) if art: is_windows = platform.system() == "Windows" # Converting images to grayscale tends to minimize the weight # of colors in the diff score. So we first convert both images # to grayscale and then pipe them into the `compare` command. # On Windows, ImageMagick doesn't support the magic \\?\ prefix # on paths, so we pass `prefix=False` to `syspath`. convert_cmd = ['convert', syspath(imagepath, prefix=False), syspath(art, prefix=False), '-colorspace', 'gray', 'MIFF:-'] compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:'] log.debug('comparing images with pipeline {} | {}', convert_cmd, compare_cmd) convert_proc = subprocess.Popen( convert_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=not is_windows, ) compare_proc = subprocess.Popen( compare_cmd, stdin=convert_proc.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=not is_windows, ) # Check the convert output. We're not interested in the # standard output; that gets piped to the next stage. convert_proc.stdout.close() convert_stderr = convert_proc.stderr.read() convert_proc.stderr.close() convert_proc.wait() if convert_proc.returncode: log.debug( 'ImageMagick convert failed with status {}: {!r}', convert_proc.returncode, convert_stderr, ) return # Check the compare output. stdout, stderr = compare_proc.communicate() if compare_proc.returncode: if compare_proc.returncode != 1: log.debug('ImageMagick compare failed: {0}, {1}', displayable_path(imagepath), displayable_path(art)) return out_str = stderr else: out_str = stdout try: phash_diff = float(out_str) except ValueError: log.debug('IM output is not a number: {0!r}', out_str) return log.debug('ImageMagick compare score: {0}', phash_diff) return phash_diff <= compare_threshold return True def extract(log, outpath, item): art = get_art(log, item) outpath = bytestring_path(outpath) if not art: log.info('No album art present in {0}, skipping.', item) return # Add an extension to the filename. ext = mediafile.image_extension(art) if not ext: log.warning('Unknown image type in {0}.', displayable_path(item.path)) return outpath += bytestring_path('.' + ext) log.info('Extracting album art from: {0} to: {1}', item, displayable_path(outpath)) with open(syspath(outpath), 'wb') as f: f.write(art) return outpath def extract_first(log, outpath, items): for item in items: real_path = extract(log, outpath, item) if real_path: return real_path def clear(log, lib, query): items = lib.items(query) log.info('Clearing album art from {0} items', len(items)) for item in items: log.debug('Clearing art for {0}', item) item.try_write(tags={'images': None}) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1638031078.2501943 beets-1.6.0/beets/autotag/0000755000076500000240000000000000000000000015073 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/autotag/__init__.py0000644000076500000240000001503700000000000017212 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Facilities for automatically determining files' correct metadata. """ from beets import logging from beets import config # Parts of external interface. from .hooks import ( # noqa AlbumInfo, TrackInfo, AlbumMatch, TrackMatch, Distance, ) from .match import tag_item, tag_album, Proposal # noqa from .match import Recommendation # noqa # Global logger. log = logging.getLogger('beets') # Metadata fields that are already hardcoded, or where the tag name changes. SPECIAL_FIELDS = { 'album': ( 'va', 'releasegroup_id', 'artist_id', 'album_id', 'mediums', 'tracks', 'year', 'month', 'day', 'artist', 'artist_credit', 'artist_sort', 'data_url' ), 'track': ( 'track_alt', 'artist_id', 'release_track_id', 'medium', 'index', 'medium_index', 'title', 'artist_credit', 'artist_sort', 'artist', 'track_id', 'medium_total', 'data_url', 'length' ) } # Additional utilities for the main interface. 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 item.mb_releasetrackid = track_info.release_track_id if track_info.artist_id: item.mb_artistid = track_info.artist_id for field, value in track_info.items(): # We only overwrite fields that are not already hardcoded. if field in SPECIAL_FIELDS['track']: continue if value is None: continue item[field] = value # 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.items(): # Artist or artist credit. if config['artist_credit']: item.artist = (track_info.artist_credit or track_info.artist or album_info.artist_credit or album_info.artist) item.albumartist = (album_info.artist_credit or album_info.artist) else: item.artist = (track_info.artist or album_info.artist) item.albumartist = album_info.artist # Album. 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) or 0 # If we don't even have a year, apply nothing. if suffix == 'year' and not value: break # Otherwise, set the fetched value (or 0 for the month # and day if not available). item[key] = value # If we're using original release date for both fields, # also set item.year = info.original_year, etc. if config['original_date']: item[suffix] = value # Title. item.title = track_info.title if config['per_disc_numbering']: # We want to let the track number be zero, but if the medium index # is not provided we need to fall back to the overall index. if track_info.medium_index is not None: item.track = track_info.medium_index else: item.track = track_info.index item.tracktotal = track_info.medium_total or len(album_info.tracks) 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_releasetrackid = track_info.release_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 # Track alt. item.track_alt = track_info.track_alt # Don't overwrite fields with empty values unless the # field is explicitly allowed to be overwritten for field, value in album_info.items(): if field in SPECIAL_FIELDS['album']: continue clobber = field in config['overwrite_null']['album'].as_str_seq() if value is None and not clobber: continue item[field] = value for field, value in track_info.items(): if field in SPECIAL_FIELDS['track']: continue clobber = field in config['overwrite_null']['track'].as_str_seq() value = getattr(track_info, field) if value is None and not clobber: continue item[field] = value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/autotag/hooks.py0000644000076500000240000005255200000000000016601 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Glue between metadata sources and the matching logic.""" from collections import namedtuple from functools import total_ordering import re from beets import logging from beets import plugins from beets import config from beets.util import as_string from beets.autotag import mb from jellyfish import levenshtein_distance from unidecode import unidecode log = logging.getLogger('beets') # The name of the type for patterns in re changed in Python 3.7. try: Pattern = re._pattern_type except AttributeError: Pattern = re.Pattern # Classes used to represent candidate options. class AttrDict(dict): """A dictionary that supports attribute ("dot") access, so `d.field` is equivalent to `d['field']`. """ def __getattr__(self, attr): if attr in self: return self.get(attr) else: raise AttributeError def __setattr__(self, key, value): self.__setitem__(key, value) def __hash__(self): return id(self) class AlbumInfo(AttrDict): """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 ``mediums`` along with the fields up through ``tracks`` are required. The others are optional and may be None. """ def __init__(self, tracks, album=None, album_id=None, artist=None, artist_id=None, 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, style=None, genre=None, albumstatus=None, media=None, albumdisambig=None, releasegroupdisambig=None, artist_credit=None, original_year=None, original_month=None, original_day=None, data_source=None, data_url=None, discogs_albumid=None, discogs_labelid=None, discogs_artistid=None, **kwargs): 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.style = style self.genre = genre self.albumstatus = albumstatus self.media = media self.albumdisambig = albumdisambig self.releasegroupdisambig = releasegroupdisambig 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 self.discogs_albumid = discogs_albumid self.discogs_labelid = discogs_labelid self.discogs_artistid = discogs_artistid self.update(kwargs) # 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='utf-8'): """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', 'style', 'genre', 'albumstatus', 'albumdisambig', 'releasegroupdisambig', 'artist_credit', 'media', 'discogs_albumid', 'discogs_labelid', 'discogs_artistid']: value = getattr(self, fld) if isinstance(value, bytes): setattr(self, fld, value.decode(codec, 'ignore')) for track in self.tracks: track.decode(codec) def copy(self): dupe = AlbumInfo([]) dupe.update(self) dupe.tracks = [track.copy() for track in self.tracks] return dupe class TrackInfo(AttrDict): """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 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=None, track_id=None, release_track_id=None, 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, media=None, lyricist=None, composer=None, composer_sort=None, arranger=None, track_alt=None, work=None, mb_workid=None, work_disambig=None, bpm=None, initial_key=None, genre=None, **kwargs): self.title = title self.track_id = track_id self.release_track_id = release_track_id self.artist = artist self.artist_id = artist_id self.length = length self.index = index self.media = media 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 self.lyricist = lyricist self.composer = composer self.composer_sort = composer_sort self.arranger = arranger self.track_alt = track_alt self.work = work self.mb_workid = mb_workid self.work_disambig = work_disambig self.bpm = bpm self.initial_key = initial_key self.genre = genre self.update(kwargs) # As above, work around a bug in python-musicbrainz-ngs. def decode(self, codec='utf-8'): """Ensure that all string attributes on this object are decoded to Unicode. """ for fld in ['title', 'artist', 'medium', 'artist_sort', 'disctitle', 'artist_credit', 'media']: value = getattr(self, fld) if isinstance(value, bytes): setattr(self, fld, value.decode(codec, 'ignore')) def copy(self): dupe = TrackInfo() dupe.update(self) return dupe # 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. """ assert isinstance(str1, str) assert isinstance(str2, str) str1 = as_string(unidecode(str1)) str2 = as_string(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_distance(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. """ if str1 is None and str2 is None: return 0.0 if str1 is None or str2 is None: return 1.0 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 = '{} {}'.format(word, str1[:-len(word) - 2]) if str2.endswith(', %s' % word): str2 = '{} {}'.format(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 return base_dist + penalty class LazyClassProperty: """A decorator implementing a read-only property that is *lazy* in the sense that the getter is only invoked once. Subsequent accesses through *any* instance use the cached result. """ def __init__(self, getter): self.getter = getter self.computed = False def __get__(self, obj, owner): if not self.computed: self.value = self.getter(owner) self.computed = True return self.value @total_ordering class Distance: """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 = {} @LazyClassProperty def _weights(cls): # noqa: N805 """A dictionary from keys to floating-point weights. """ weights_view = config['match']['distance_weights'] weights = {} for key in weights_view.keys(): weights[key] = weights_view[key].as_number() return weights # 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.items(): 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.items(): 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_and_dist: (-key_and_dist[1], key_and_dist[0]) ) def __hash__(self): return id(self) def __eq__(self, other): return self.distance == other # Behave like a float. def __lt__(self, other): return 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 def __str__(self): return f"{self.distance:.2f}" # 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, not {}'.format(type(dist)) ) for key, penalties in dist._penalties.items(): 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, Pattern): 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( f'`dist` must be between 0.0 and 1.0, not {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: album = mb.album_for_id(release_id) if album: plugins.send('albuminfo_received', info=album) return album 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: track = mb.track_for_id(recording_id) if track: plugins.send('trackinfo_received', info=track) return track except mb.MusicBrainzAPIError as exc: exc.log(log) def albums_for_id(album_id): """Get a list of albums for an ID.""" a = album_for_mbid(album_id) if a: yield a for a in plugins.album_for_id(album_id): if a: plugins.send('albuminfo_received', info=a) yield a def tracks_for_id(track_id): """Get a list of tracks for an ID.""" t = track_for_mbid(track_id) if t: yield t for t in plugins.track_for_id(track_id): if t: plugins.send('trackinfo_received', info=t) yield t @plugins.notify_info_yielded('albuminfo_received') def album_candidates(items, artist, album, va_likely, extra_tags): """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. ``extra_tags`` is an optional dictionary of additional tags used to further constrain the search. """ # Base candidates if we have album and artist to match. if artist and album: try: yield from mb.match_album(artist, album, len(items), extra_tags) except mb.MusicBrainzAPIError as exc: exc.log(log) # Also add VA matches from MusicBrainz where appropriate. if va_likely and album: try: yield from mb.match_album(None, album, len(items), extra_tags) except mb.MusicBrainzAPIError as exc: exc.log(log) # Candidates from plugins. yield from plugins.candidates(items, artist, album, va_likely, extra_tags) @plugins.notify_info_yielded('trackinfo_received') 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. """ # MusicBrainz candidates. if artist and title: try: yield from mb.match_track(artist, title) except mb.MusicBrainzAPIError as exc: exc.log(log) # Plugin candidates. yield from plugins.item_candidates(item, artist, title) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/autotag/match.py0000644000076500000240000004666500000000000016562 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Matches existing metadata with canonical information to identify releases and tracks. """ import datetime import re from munkres import Munkres from collections import namedtuple from beets import logging from beets import plugins from beets import config from beets.util import plurality from beets.autotag import hooks from beets.util.enumeration import OrderedEnum # 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 = ('', 'various artists', 'various', 'va', 'unknown') # Global logger. log = logging.getLogger('beets') # Recommendation enumeration. class Recommendation(OrderedEnum): """Indicates a qualitative suggestion to the user about what should be done with a given match. """ none = 0 low = 1 medium = 2 strong = 3 # A structure for holding a set of possible matches to choose between. This # consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo # objects) and a recommendation value. Proposal = namedtuple('Proposal', ('candidates', 'recommendation')) # 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 field in fields: values = [item[field] for item in items if item] likelies[field], freq = plurality(values) consensus[field] = (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. log.debug('Computing track assignment...') matching = Munkres().compute(costs) log.debug('...done.') # Produce the output matching. mapping = {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.items(): 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. """ albumids = (item.mb_albumid for item in items if item.mb_albumid) # Did any of the items have an MB album ID? try: first = next(albumids) except StopIteration: log.debug('No album ID found.') return None # Is there a consensus on the MB album ID? for other in albumids: if other != first: log.debug('No album ID consensus.') return None # If all album IDs are equal, look up the album. log.debug('Searching for discovered album ID: {0}', first) return hooks.album_for_mbid(first) 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(list(track_dist.keys())) max_rec_view = config['match']['max_rec'] for key in keys: if key in list(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 _sort_candidates(candidates): """Sort candidates by distance.""" return sorted(candidates, key=lambda match: match.distance) 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: {0} - {1} ({2})', info.artist, info.album, info.album_id) # Discard albums with zero tracks. if not info.tracks: log.debug('No tracks.') return # Don't duplicate. if info.album_id in results: log.debug('Duplicate.') return # Discard matches without required tags. for req_tag in config['match']['required'].as_str_seq(): if getattr(info, req_tag) is None: log.debug('Ignored. Missing required tag: {0}', req_tag) 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: {0}', penalty) return log.debug('Success. Distance: {0}', 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_ids=[]): """Return a tuple of the current artist name, the current album name, and a `Proposal` containing `AlbumMatch` candidates. The artist and album are the most common values of these fields among `items`. The `AlbumMatch` objects are generated by searching the metadata backends. By default, the metadata of the items is used for the search. This can be customized by setting the parameters. `search_ids` is a list of metadata backend IDs: if specified, it will restrict the candidates to those IDs, ignoring `search_artist` and `search album`. The `mapping` field of the album has the matched `items` as keys. The recommendation is calculated from the match quality of the candidates. """ # Get current metadata. likelies, consensus = current_metadata(items) cur_artist = likelies['artist'] cur_album = likelies['album'] log.debug('Tagging {0} - {1}', cur_artist, cur_album) # The output result (distance, AlbumInfo) tuples (keyed by MB album # ID). candidates = {} # Search by explicit ID. if search_ids: for search_id in search_ids: log.debug('Searching for album ID: {0}', search_id) for id_candidate in hooks.albums_for_id(search_id): _add_candidate(items, candidates, id_candidate) # 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(list(candidates.values())) log.debug('Album ID match recommendation is {0}', 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, \ Proposal(list(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('Search terms: {0} - {1}', search_artist, search_album) extra_tags = None if config['musicbrainz']['extra_tags']: tag_list = config['musicbrainz']['extra_tags'].get() extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list} log.debug('Additional search terms: {0}', extra_tags) # 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('Album might be VA: {0}', va_likely) # Get the results from the data sources. for matched_candidate in hooks.album_candidates(items, search_artist, search_album, va_likely, extra_tags): _add_candidate(items, candidates, matched_candidate) log.debug('Evaluating {0} candidates.', len(candidates)) # Sort and get the recommendation. candidates = _sort_candidates(candidates.values()) rec = _recommendation(candidates) return cur_artist, cur_album, Proposal(candidates, rec) def tag_item(item, search_artist=None, search_title=None, search_ids=[]): """Find metadata for a single track. Return a `Proposal` consisting of `TrackMatch` objects. `search_artist` and `search_title` may be used to override the current metadata for the purposes of the MusicBrainz title. `search_ids` may be used for restricting the search to a list of metadata backend IDs. """ # Holds candidates found so far: keys are MBIDs; values are # (distance, TrackInfo) pairs. candidates = {} # First, try matching by MusicBrainz ID. trackids = search_ids or [t for t in [item.mb_trackid] if t] if trackids: for trackid in trackids: log.debug('Searching for track ID: {0}', 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(_sort_candidates(candidates.values())) if rec == Recommendation.strong and \ not config['import']['timid']: log.debug('Track ID match.') return Proposal(_sort_candidates(candidates.values()), rec) # If we're searching by ID, don't proceed. if search_ids: if candidates: return Proposal(_sort_candidates(candidates.values()), rec) else: return Proposal([], Recommendation.none) # Search terms. if not (search_artist and search_title): search_artist, search_title = item.artist, item.title log.debug('Item search terms: {0} - {1}', 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 {0} candidates.', len(candidates)) candidates = _sort_candidates(candidates.values()) rec = _recommendation(candidates) return Proposal(candidates, rec) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/autotag/mb.py0000644000076500000240000005203400000000000016047 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Searches for albums in the MusicBrainz database. """ import musicbrainzngs import re import traceback from beets import logging from beets import plugins import beets.autotag.hooks import beets from beets import util from beets import config from collections import Counter from urllib.parse import urljoin VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377' BASE_URL = 'https://musicbrainz.org/' SKIPPED_TRACKS = ['[data track]'] FIELDS_TO_MB_KEYS = { 'catalognum': 'catno', 'country': 'country', 'label': 'label', 'media': 'format', 'year': 'date', } musicbrainzngs.set_useragent('beets', beets.__version__, 'https://beets.io/') 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 if isinstance(reason, musicbrainzngs.WebServiceError): reason = 'MusicBrainz not reachable' super().__init__(reason, verb, tb) def get_message(self): return '{} in {} with query {}'.format( self._reasonstr(), self.verb, repr(self.query) ) log = logging.getLogger('beets') RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', 'labels', 'artist-credits', 'aliases', 'recording-level-rels', 'work-rels', 'work-level-rels', 'artist-rels', 'isrcs'] BROWSE_INCLUDES = ['artist-credits', 'work-rels', 'artist-rels', 'recording-rels', 'release-rels'] if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES['recording']: BROWSE_INCLUDES.append("work-level-rels") BROWSE_CHUNKSIZE = 100 BROWSE_MAXTRACKS = 500 TRACK_INCLUDES = ['artists', 'aliases', 'isrcs'] if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']: TRACK_INCLUDES += ['work-level-rels', 'artist-rels'] if 'genres' in musicbrainzngs.VALID_INCLUDES['recording']: RELEASE_INCLUDES += ['genres'] def track_url(trackid): return urljoin(BASE_URL, 'recording/' + trackid) def album_url(albumid): return urljoin(BASE_URL, 'release/' + albumid) def configure(): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. """ hostname = config['musicbrainz']['host'].as_str() https = config['musicbrainz']['https'].get(bool) # Only call set_hostname when a custom server is configured. Since # musicbrainz-ngs connects to musicbrainz.org with HTTPS by default if hostname != "musicbrainz.org": musicbrainzngs.set_hostname(hostname, https) 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 primary aliases for this locale. matches = [a for a in aliases if a['locale'] == locale and 'primary' in a] # Skip to the next locale if we have no matches if not matches: continue return matches[0] def _preferred_release_event(release): """Given a release, select and return the user's preferred release event as a tuple of (country, release_date). Fall back to the default release event if a preferred event is not found. """ countries = config['match']['preferred']['countries'].as_str_seq() for country in countries: for event in release.get('release-event-list', {}): try: if country in event['area']['iso-3166-1-code-list']: return country, event['date'] except KeyError: pass return release.get('country'), release.get('date') 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, str): # 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( title=recording['title'], track_id=recording['id'], index=index, medium=medium, medium_index=medium_index, medium_total=medium_total, data_source='MusicBrainz', data_url=track_url(recording['id']), ) 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.trackdisambig = recording.get('disambiguation') if recording.get('isrc-list'): info.isrc = ';'.join(recording['isrc-list']) lyricist = [] composer = [] composer_sort = [] for work_relation in recording.get('work-relation-list', ()): if work_relation['type'] != 'performance': continue info.work = work_relation['work']['title'] info.mb_workid = work_relation['work']['id'] if 'disambiguation' in work_relation['work']: info.work_disambig = work_relation['work']['disambiguation'] for artist_relation in work_relation['work'].get( 'artist-relation-list', ()): if 'type' in artist_relation: type = artist_relation['type'] if type == 'lyricist': lyricist.append(artist_relation['artist']['name']) elif type == 'composer': composer.append(artist_relation['artist']['name']) composer_sort.append( artist_relation['artist']['sort-name']) if lyricist: info.lyricist = ', '.join(lyricist) if composer: info.composer = ', '.join(composer) info.composer_sort = ', '.join(composer_sort) arranger = [] for artist_relation in recording.get('artist-relation-list', ()): if 'type' in artist_relation: type = artist_relation['type'] if type == 'arranger': arranger.append(artist_relation['artist']['name']) if arranger: info.arranger = ', '.join(arranger) # Supplementary fields provided by plugins extra_trackdatas = plugins.send('mb_track_extract', data=recording) for extra_trackdata in extra_trackdatas: info.update(extra_trackdata) 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']) ntracks = sum(len(m['track-list']) for m in release['medium-list']) # The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list' # when the release has more than 500 tracks. So we use browse_recordings # on chunks of tracks to recover the same information in this case. if ntracks > BROWSE_MAXTRACKS: log.debug('Album {} has too many tracks', release['id']) recording_list = [] for i in range(0, ntracks, BROWSE_CHUNKSIZE): log.debug('Retrieving tracks starting at {}', i) recording_list.extend(musicbrainzngs.browse_recordings( release=release['id'], limit=BROWSE_CHUNKSIZE, includes=BROWSE_INCLUDES, offset=i)['recording-list']) track_map = {r['id']: r for r in recording_list} for medium in release['medium-list']: for recording in medium['track-list']: recording_info = track_map[recording['recording']['id']] recording['recording'] = recording_info # Basic info. track_infos = [] index = 0 for medium in release['medium-list']: disctitle = medium.get('title') format = medium.get('format') if format in config['match']['ignored_media'].as_str_seq(): continue all_tracks = medium['track-list'] if ('data-track-list' in medium and not config['match']['ignore_data_tracks']): all_tracks += medium['data-track-list'] track_count = len(all_tracks) if 'pregap' in medium: all_tracks.insert(0, medium['pregap']) for track in all_tracks: if ('title' in track['recording'] and track['recording']['title'] in SKIPPED_TRACKS): continue if ('video' in track['recording'] and track['recording']['video'] == 'true' and config['match']['ignore_video_tracks']): continue # Basic information from the recording. index += 1 ti = track_info( track['recording'], index, int(medium['position']), int(track['position']), track_count, ) ti.release_track_id = track['id'] ti.disctitle = disctitle ti.media = format ti.track_alt = track['number'] # 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( album=release['title'], album_id=release['id'], artist=artist_name, artist_id=release['artist-credit'][0]['artist']['id'], tracks=track_infos, mediums=len(release['medium-list']), artist_sort=artist_sort_name, artist_credit=artist_credit_name, data_source='MusicBrainz', data_url=album_url(release['id']), ) info.va = info.artist_id == VARIOUS_ARTISTS_ID if info.va: info.artist = config['va_name'].as_str() info.asin = release.get('asin') info.releasegroup_id = release['release-group']['id'] info.albumstatus = release.get('status') # Get the disambiguation strings at the release and release group level. if release['release-group'].get('disambiguation'): info.releasegroupdisambig = \ release['release-group'].get('disambiguation') if release.get('disambiguation'): info.albumdisambig = release.get('disambiguation') # Get the "classic" Release type. This data comes from a legacy API # feature before MusicBrainz supported multiple release types. if 'type' in release['release-group']: reltype = release['release-group']['type'] if reltype: info.albumtype = reltype.lower() # Set the new-style "primary" and "secondary" release types. albumtypes = [] if 'primary-type' in release['release-group']: rel_primarytype = release['release-group']['primary-type'] if rel_primarytype: albumtypes.append(rel_primarytype.lower()) if 'secondary-type-list' in release['release-group']: if release['release-group']['secondary-type-list']: for sec_type in release['release-group']['secondary-type-list']: albumtypes.append(sec_type.lower()) info.albumtypes = '; '.join(albumtypes) # Release events. info.country, release_date = _preferred_release_event(release) 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') if config['musicbrainz']['genres']: sources = [ release['release-group'].get('genre-list', []), release.get('genre-list', []), ] genres = Counter() for source in sources: for genreitem in source: genres[genreitem['name']] += int(genreitem['count']) info.genre = '; '.join(g[0] for g in sorted(genres.items(), key=lambda g: -g[1])) extra_albumdatas = plugins.send('mb_album_extract', data=release) for extra_albumdata in extra_albumdatas: info.update(extra_albumdata) info.decode() return info def match_album(artist, album, tracks=None, extra_tags=None): """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 and any other extra tags. """ # Build search criteria. criteria = {'release': album.lower().strip()} if artist is not None: criteria['artist'] = artist.lower().strip() else: # Various Artists search. criteria['arid'] = VARIOUS_ARTISTS_ID if tracks is not None: criteria['tracks'] = str(tracks) # Additional search cues from existing metadata. if extra_tags: for tag in extra_tags: key = FIELDS_TO_MB_KEYS[tag] value = str(extra_tags.get(tag, '')).lower().strip() if key == 'catno': value = value.replace(' ', '') if value: criteria[key] = value # Abort if we have no search terms. if not any(criteria.values()): return try: log.debug('Searching for MusicBrainz releases with: {!r}', criteria) res = musicbrainzngs.search_releases( limit=config['musicbrainz']['searchlimit'].get(int), **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): """Searches for a single track and returns an iterable of TrackInfo objects. May raise a MusicBrainzAPIError. """ criteria = { 'artist': artist.lower().strip(), 'recording': title.lower().strip(), } if not any(criteria.values()): return try: res = musicbrainzngs.search_recordings( limit=config['musicbrainz']['searchlimit'].get(int), **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(releaseid): """Fetches an album by its MusicBrainz ID and returns an AlbumInfo object or None if the album is not found. May raise a MusicBrainzAPIError. """ log.debug('Requesting MusicBrainz release {}', releaseid) albumid = _parse_id(releaseid) if not albumid: log.debug('Invalid MBID ({0}).', releaseid) 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(releaseid): """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(releaseid) if not trackid: log.debug('Invalid MBID ({0}).', releaseid) 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']) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1624915871.0 beets-1.6.0/beets/config_default.yaml0000644000076500000240000000566500000000000017300 0ustar00asampsonstafflibrary: library.db directory: ~/Music import: write: yes copy: yes move: no link: no hardlink: no reflink: no delete: no resume: ask incremental: no incremental_skip_later: no from_scratch: 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 group_albums: no pretend: false search_ids: [] duplicate_action: ask bell: no set_fields: {} clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information", "lost+found"] ignore_hidden: yes replace: '[\\/]': _ '^\.': _ '[\x00-\x1f]': _ '[<>:"\?\*\|]': _ '\.$': _ '\s+$': '' '^\s+': '' '^-': _ path_sep_replace: _ drive_sep_replace: _ asciify_paths: false art_filename: cover max_filename_length: 0 aunique: keys: albumartist album disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig bracket: '[]' overwrite_null: album: [] track: [] plugins: [] pluginpath: [] threaded: yes timeout: 5.0 per_disc_numbering: no verbose: 0 terminal_encoding: original_date: no artist_credit: no id3v23: no va_name: "Various Artists" ui: terminal_width: 80 length_diff_thresh: 10.0 color: yes colors: text_success: green text_warning: yellow text_error: red text_highlight: red text_highlight_minor: lightgray action_default: turquoise action: blue format_item: $artist - $album - $title format_album: $albumartist - $album time_format: '%Y-%m-%d %H:%M:%S' format_raw_length: no sort_album: albumartist+ album+ sort_item: artist+ album+ disc+ track+ sort_case_insensitive: yes 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 https: no ratelimit: 1 ratelimit_interval: 1.0 searchlimit: 5 extra_tags: [] genres: no 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: [] required: [] ignored_media: [] ignore_data_tracks: yes ignore_video_tracks: yes track_length_grace: 10 track_length_max: 30 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1638031078.254142 beets-1.6.0/beets/dbcore/0000755000076500000240000000000000000000000014665 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/dbcore/__init__.py0000644000076500000240000000202000000000000016770 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """DBCore is an abstract database package that forms the basis for beets' Library. """ from .db import Model, Database from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery from .types import Type from .queryparse import query_from_strings from .queryparse import sort_from_strings from .queryparse import parse_sorted_query from .query import InvalidQueryError # flake8: noqa ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/dbcore/db.py0000755000076500000240000011022500000000000015630 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """The central Model and Database constructs for DBCore. """ import time import os import re from collections import defaultdict import threading import sqlite3 import contextlib import beets from beets.util import functemplate from beets.util import py3_path from beets.dbcore import types from .query import MatchQuery, NullSort, TrueQuery from collections.abc import Mapping class DBAccessError(Exception): """The SQLite database became inaccessible. This can happen when trying to read or write the database when, for example, the database file is deleted or otherwise disappears. There is probably no way to recover from this error. """ class FormattedMapping(Mapping): """A `dict`-like formatted view of a model. The accessor `mapping[key]` returns the formatted version of `model[key]` as a unicode string. The `included_keys` parameter allows filtering the fields that are returned. By default all fields are returned. Limiting to specific keys can avoid expensive per-item database queries. If `for_path` is true, all path separators in the formatted values are replaced. """ ALL_KEYS = '*' def __init__(self, model, included_keys=ALL_KEYS, for_path=False): self.for_path = for_path self.model = model if included_keys == self.ALL_KEYS: # Performance note: this triggers a database query. self.model_keys = self.model.keys(True) else: self.model_keys = included_keys def __getitem__(self, key): if key in self.model_keys: return self._get_formatted(self.model, key) else: raise KeyError(key) def __iter__(self): return iter(self.model_keys) def __len__(self): return len(self.model_keys) def get(self, key, default=None): if default is None: default = self.model._type(key).format(None) return super().get(key, default) def _get_formatted(self, model, key): value = model._type(key).format(model.get(key)) if isinstance(value, bytes): value = value.decode('utf-8', 'ignore') if self.for_path: sep_repl = beets.config['path_sep_replace'].as_str() sep_drive = beets.config['drive_sep_replace'].as_str() if re.match(r'^\w:', value): value = re.sub(r'(?<=^\w):', sep_drive, value) for sep in (os.path.sep, os.path.altsep): if sep: value = value.replace(sep, sep_repl) return value class LazyConvertDict: """Lazily convert types for attributes fetched from the database """ def __init__(self, model_cls): """Initialize the object empty """ self.data = {} self.model_cls = model_cls self._converted = {} def init(self, data): """Set the base data that should be lazily converted """ self.data = data def _convert(self, key, value): """Convert the attribute type according the the SQL type """ return self.model_cls._type(key).from_sql(value) def __setitem__(self, key, value): """Set an attribute value, assume it's already converted """ self._converted[key] = value def __getitem__(self, key): """Get an attribute value, converting the type on demand if needed """ if key in self._converted: return self._converted[key] elif key in self.data: value = self._convert(key, self.data[key]) self._converted[key] = value return value def __delitem__(self, key): """Delete both converted and base data """ if key in self._converted: del self._converted[key] if key in self.data: del self.data[key] def keys(self): """Get a list of available field names for this object. """ return list(self._converted.keys()) + list(self.data.keys()) def copy(self): """Create a copy of the object. """ new = self.__class__(self.model_cls) new.data = self.data.copy() new._converted = self._converted.copy() return new # Act like a dictionary. def update(self, values): """Assign all values in the given dict. """ for key, value in values.items(): self[key] = value def items(self): """Iterate over (key, value) pairs that this object contains. Computed fields are not included. """ for key in self: yield key, self[key] 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 an attribute on this object. """ return key in self.keys() def __iter__(self): """Iterate over the available field names (excluding computed fields). """ return iter(self.keys()) # Abstract base for model classes. class Model: """An abstract object representing an object in the database. Model objects act like dictionaries (i.e., they allow subscript access like ``obj['field']``). The same field set is available via attribute access as a shortcut (i.e., ``obj.field``). Three kinds of attributes are available: * **Fixed attributes** come from a predetermined list of field names. These fields correspond to SQLite table columns and are thus fast to read, write, and query. * **Flexible attributes** are free-form and do not need to be listed ahead of time. * **Computed attributes** are read-only fields computed by a getter function provided by a plugin. Access to all three field types is uniform: ``obj.field`` works the same regardless of whether ``field`` is fixed, flexible, or computed. Model objects can optionally be associated with a `Library` object, in which case they can be loaded and stored from the database. Dirty flags are used to track which fields need to be stored. """ # Abstract components (to be provided by subclasses). _table = None """The main SQLite table name. """ _flex_table = None """The flex field SQLite table name. """ _fields = {} """A mapping indicating available "fixed" fields on this type. The keys are field names and the values are `Type` objects. """ _search_fields = () """The fields that should be queried by default by unqualified query terms. """ _types = {} """Optional Types for non-fixed (i.e., flexible and computed) fields. """ _sorts = {} """Optional named sort criteria. The keys are strings and the values are subclasses of `Sort`. """ _queries = {} """Named queries that use a field-like `name:value` syntax but which do not relate to any specific field. """ _always_dirty = False """By default, fields only become "dirty" when their value actually changes. Enabling this flag marks fields as dirty even when the new value is the same as the old value (e.g., `o.f = o.f`). """ _revision = -1 """A revision number from when the model was loaded from or written to the database. """ @classmethod def _getters(cls): """Return a mapping from field names to getter functions. """ # We could cache this if it becomes a performance problem to # gather the getter mapping every time. raise NotImplementedError() def _template_funcs(self): """Return a mapping from function names to text-transformer functions. """ # As above: we could consider caching this result. raise NotImplementedError() # Basic operation. def __init__(self, db=None, **values): """Create a new object with an optional Database association and initial field values. """ self._db = db self._dirty = set() self._values_fixed = LazyConvertDict(self) self._values_flex = LazyConvertDict(self) # Initial contents. self.update(values) self.clear_dirty() @classmethod def _awaken(cls, db=None, fixed_values={}, flex_values={}): """Create an object with values drawn from the database. This is a performance optimization: the checks involved with ordinary construction are bypassed. """ obj = cls(db) obj._values_fixed.init(fixed_values) obj._values_flex.init(flex_values) return obj def __repr__(self): return '{}({})'.format( type(self).__name__, ', '.join(f'{k}={v!r}' for k, v in dict(self).items()), ) def clear_dirty(self): """Mark all fields as *clean* (i.e., not needing to be stored to the database). Also update the revision. """ self._dirty = set() if self._db: self._revision = self._db.revision def _check_db(self, need_id=True): """Ensure that this object is associated with a database row: it has a reference to a database (`_db`) and an id. A ValueError exception is raised otherwise. """ if not self._db: raise ValueError( '{} has no database'.format(type(self).__name__) ) if need_id and not self.id: raise ValueError('{} has no id'.format(type(self).__name__)) def copy(self): """Create a copy of the model object. The field values and other state is duplicated, but the new copy remains associated with the same database as the old object. (A simple `copy.deepcopy` will not work because it would try to duplicate the SQLite connection.) """ new = self.__class__() new._db = self._db new._values_fixed = self._values_fixed.copy() new._values_flex = self._values_flex.copy() new._dirty = self._dirty.copy() return new # Essential field accessors. @classmethod def _type(cls, key): """Get the type of a field, a `Type` instance. If the field has no explicit type, it is given the base `Type`, which does no conversion. """ return cls._fields.get(key) or cls._types.get(key) or types.DEFAULT def _get(self, key, default=None, raise_=False): """Get the value for a field, or `default`. Alternatively, raise a KeyError if the field is not available. """ getters = self._getters() if key in getters: # Computed. return getters[key](self) elif key in self._fields: # Fixed. if key in self._values_fixed: return self._values_fixed[key] else: return self._type(key).null elif key in self._values_flex: # Flexible. return self._values_flex[key] elif raise_: raise KeyError(key) else: return default get = _get def __getitem__(self, key): """Get the value for a field. Raise a KeyError if the field is not available. """ return self._get(key, raise_=True) def _setitem(self, key, value): """Assign the value for a field, return whether new and old value differ. """ # Choose where to place the value. if key in self._fields: source = self._values_fixed else: source = self._values_flex # If the field has a type, filter the value. value = self._type(key).normalize(value) # Assign value and possibly mark as dirty. old_value = source.get(key) source[key] = value changed = old_value != value if self._always_dirty or changed: self._dirty.add(key) return changed def __setitem__(self, key, value): """Assign the value for a field. """ self._setitem(key, value) def __delitem__(self, key): """Remove a flexible attribute from the model. """ if key in self._values_flex: # Flexible. del self._values_flex[key] self._dirty.add(key) # Mark for dropping on store. elif key in self._fields: # Fixed setattr(self, key, self._type(key).null) elif key in self._getters(): # Computed. raise KeyError(f'computed field {key} cannot be deleted') else: raise KeyError(f'no such field {key}') def keys(self, computed=False): """Get a list of available field names for this object. The `computed` parameter controls whether computed (plugin-provided) fields are included in the key list. """ base_keys = list(self._fields) + list(self._values_flex.keys()) if computed: return base_keys + list(self._getters().keys()) else: return base_keys @classmethod def all_keys(cls): """Get a list of available keys for objects of this type. Includes fixed and computed fields. """ return list(cls._fields) + list(cls._getters().keys()) # Act like a dictionary. def update(self, values): """Assign all values in the given dict. """ for key, value in values.items(): self[key] = value def items(self): """Iterate over (key, value) pairs that this object contains. Computed fields are not included. """ for key in self: yield key, self[key] def __contains__(self, key): """Determine whether `key` is an attribute on this object. """ return key in self.keys(computed=True) def __iter__(self): """Iterate over the available field names (excluding computed fields). """ return iter(self.keys()) # Convenient attribute access. def __getattr__(self, key): if key.startswith('_'): raise AttributeError(f'model has no attribute {key!r}') else: try: return self[key] except KeyError: raise AttributeError(f'no such field {key!r}') def __setattr__(self, key, value): if key.startswith('_'): super().__setattr__(key, value) else: self[key] = value def __delattr__(self, key): if key.startswith('_'): super().__delattr__(key) else: del self[key] # Database interaction (CRUD methods). def store(self, fields=None): """Save the object's metadata into the library database. :param fields: the fields to be stored. If not specified, all fields will be. """ if fields is None: fields = self._fields self._check_db() # Build assignments for query. assignments = [] subvars = [] for key in fields: if key != 'id' and key in self._dirty: self._dirty.remove(key) assignments.append(key + '=?') value = self._type(key).to_sql(self[key]) subvars.append(value) assignments = ','.join(assignments) with self._db.transaction() as tx: # Main table update. if assignments: query = 'UPDATE {} SET {} WHERE id=?'.format( self._table, assignments ) subvars.append(self.id) tx.mutate(query, subvars) # Modified/added flexible attributes. for key, value in self._values_flex.items(): if key in self._dirty: self._dirty.remove(key) tx.mutate( 'INSERT INTO {} ' '(entity_id, key, value) ' 'VALUES (?, ?, ?);'.format(self._flex_table), (self.id, key, value), ) # Deleted flexible attributes. for key in self._dirty: tx.mutate( 'DELETE FROM {} ' 'WHERE entity_id=? AND key=?'.format(self._flex_table), (self.id, key) ) self.clear_dirty() def load(self): """Refresh the object's metadata from the library database. If check_revision is true, the database is only queried loaded when a transaction has been committed since the item was last loaded. """ self._check_db() if not self._dirty and self._db.revision == self._revision: # Exit early return stored_obj = self._db._get(type(self), self.id) assert stored_obj is not None, f"object {self.id} not in DB" self._values_fixed = LazyConvertDict(self) self._values_flex = LazyConvertDict(self) 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._db.transaction() as tx: tx.mutate( f'DELETE FROM {self._table} WHERE id=?', (self.id,) ) tx.mutate( f'DELETE FROM {self._flex_table} WHERE entity_id=?', (self.id,) ) def add(self, db=None): """Add the object to the library database. This object must be associated with a database; you can provide one via the `db` parameter or use the currently associated database. The object's `id` and `added` fields are set along with any current field values. """ if db: self._db = db self._check_db(False) with self._db.transaction() as tx: new_id = tx.mutate( f'INSERT INTO {self._table} DEFAULT VALUES' ) self.id = new_id self.added = time.time() # Mark every non-null field as dirty and store. for key in self: if self[key] is not None: self._dirty.add(key) self.store() # Formatting and templating. _formatter = FormattedMapping def formatted(self, included_keys=_formatter.ALL_KEYS, for_path=False): """Get a mapping containing all values on this object formatted as human-readable unicode strings. """ return self._formatter(self, included_keys, for_path) def evaluate_template(self, template, for_path=False): """Evaluate a template (a string or a `Template` object) using the object's fields. If `for_path` is true, then no new path separators will be added to the template. """ # Perform substitution. if isinstance(template, str): template = functemplate.template(template) return template.substitute(self.formatted(for_path=for_path), self._template_funcs()) # Parsing. @classmethod def _parse(cls, key, string): """Parse a string as a value for the given key. """ if not isinstance(string, str): raise TypeError("_parse() argument must be a string") return cls._type(key).parse(string) def set_parse(self, key, string): """Set the object's key to a value represented by a string. """ self[key] = self._parse(key, string) # Database controller and supporting interfaces. class Results: """An item query result set. Iterating over the collection lazily constructs LibModel objects that reflect database rows. """ def __init__(self, model_class, rows, db, flex_rows, query=None, sort=None): """Create a result set that will construct objects of type `model_class`. `model_class` is a subclass of `LibModel` that will be constructed. `rows` is a query result: a list of mappings. The new objects will be associated with the database `db`. If `query` is provided, it is used as a predicate to filter the results for a "slow query" that cannot be evaluated by the database directly. If `sort` is provided, it is used to sort the full list of results before returning. This means it is a "slow sort" and all objects must be built before returning the first one. """ self.model_class = model_class self.rows = rows self.db = db self.query = query self.sort = sort self.flex_rows = flex_rows # We keep a queue of rows we haven't yet consumed for # materialization. We preserve the original total number of # rows. self._rows = rows self._row_count = len(rows) # The materialized objects corresponding to rows that have been # consumed. self._objects = [] def _get_objects(self): """Construct and generate Model objects for they query. The objects are returned in the order emitted from the database; no slow sort is applied. For performance, this generator caches materialized objects to avoid constructing them more than once. This way, iterating over a `Results` object a second time should be much faster than the first. """ # Index flexible attributes by the item ID, so we have easier access flex_attrs = self._get_indexed_flex_attrs() index = 0 # Position in the materialized objects. while index < len(self._objects) or self._rows: # Are there previously-materialized objects to produce? if index < len(self._objects): yield self._objects[index] index += 1 # Otherwise, we consume another row, materialize its object # and produce it. else: while self._rows: row = self._rows.pop(0) obj = self._make_model(row, flex_attrs.get(row['id'], {})) # If there is a slow-query predicate, ensurer that the # object passes it. if not self.query or self.query.match(obj): self._objects.append(obj) index += 1 yield obj break def __iter__(self): """Construct and generate Model objects for all matching objects, in sorted order. """ if self.sort: # Slow sort. Must build the full list first. objects = self.sort.sort(list(self._get_objects())) return iter(objects) else: # Objects are pre-sorted (i.e., by the database). return self._get_objects() def _get_indexed_flex_attrs(self): """ Index flexible attributes by the entity id they belong to """ flex_values = {} for row in self.flex_rows: if row['entity_id'] not in flex_values: flex_values[row['entity_id']] = {} flex_values[row['entity_id']][row['key']] = row['value'] return flex_values def _make_model(self, row, flex_values={}): """ Create a Model object for the given row """ cols = dict(row) values = {k: v for (k, v) in cols.items() if not k[:4] == 'flex'} # Construct the Python object obj = self.model_class._awaken(self.db, values, flex_values) return obj def __len__(self): """Get the number of matching objects. """ if not self._rows: # Fully materialized. Just count the objects. return len(self._objects) elif self.query: # A slow query. Fall back to testing every object. count = 0 for obj in self: count += 1 return count else: # A fast query. Just count the rows. return self._row_count def __nonzero__(self): """Does this result contain any objects? """ return self.__bool__() def __bool__(self): """Does this result contain any objects? """ return bool(len(self)) def __getitem__(self, n): """Get the nth item in this result set. This is inefficient: all items up to n are materialized and thrown away. """ if not self._rows and not self.sort: # Fully materialized and already in order. Just look up the # object. return self._objects[n] it = iter(self) try: for i in range(n): next(it) return next(it) except StopIteration: raise IndexError(f'result index {n} out of range') def get(self): """Return the first matching object, or None if no objects match. """ it = iter(self) try: return next(it) except StopIteration: return None class Transaction: """A context manager for safe, concurrent access to the database. All SQL commands should be executed through a transaction. """ _mutated = False """A flag storing whether a mutation has been executed in the current transaction. """ def __init__(self, db): self.db = db def __enter__(self): """Begin a transaction. This transaction may be created while another is active in a different thread. """ with self.db._tx_stack() as stack: first = not stack stack.append(self) if first: # Beginning a "root" transaction, which corresponds to an # SQLite transaction. self.db._db_lock.acquire() return self def __exit__(self, exc_type, exc_value, traceback): """Complete a transaction. This must be the most recently entered but not yet exited transaction. If it is the last active transaction, the database updates are committed. """ # Beware of races; currently secured by db._db_lock self.db.revision += self._mutated with self.db._tx_stack() as stack: assert stack.pop() is self empty = not stack if empty: # Ending a "root" transaction. End the SQLite transaction. self.db._connection().commit() self._mutated = False self.db._db_lock.release() def query(self, statement, subvals=()): """Execute an SQL statement with substitution values and return a list of rows from the database. """ cursor = self.db._connection().execute(statement, subvals) return cursor.fetchall() def mutate(self, statement, subvals=()): """Execute an SQL statement with substitution values and return the row ID of the last affected row. """ try: cursor = self.db._connection().execute(statement, subvals) except sqlite3.OperationalError as e: # In two specific cases, SQLite reports an error while accessing # the underlying database file. We surface these exceptions as # DBAccessError so the application can abort. if e.args[0] in ("attempt to write a readonly database", "unable to open database file"): raise DBAccessError(e.args[0]) else: raise else: self._mutated = True return cursor.lastrowid def script(self, statements): """Execute a string containing multiple SQL statements.""" # We don't know whether this mutates, but quite likely it does. self._mutated = True self.db._connection().executescript(statements) class Database: """A container for Model objects that wraps an SQLite database as the backend. """ _models = () """The Model subclasses representing tables in this database. """ supports_extensions = hasattr(sqlite3.Connection, 'enable_load_extension') """Whether or not the current version of SQLite supports extensions""" revision = 0 """The current revision of the database. To be increased whenever data is written in a transaction. """ def __init__(self, path, timeout=5.0): self.path = path self.timeout = timeout self._connections = {} self._tx_stacks = defaultdict(list) self._extensions = [] # A lock to protect the _connections and _tx_stacks maps, which # both map thread IDs to private resources. self._shared_map_lock = threading.Lock() # A lock to protect access to the database itself. SQLite does # allow multiple threads to access the database at the same # time, but many users were experiencing crashes related to this # capability: where SQLite was compiled without HAVE_USLEEP, its # backoff algorithm in the case of contention was causing # whole-second sleeps (!) that would trigger its internal # timeout. Using this lock ensures only one SQLite transaction # is active at a time. self._db_lock = threading.Lock() # Set up database schema. for model_cls in self._models: self._make_table(model_cls._table, model_cls._fields) self._make_attribute_table(model_cls._flex_table) # Primitive access control: connections and transactions. def _connection(self): """Get a SQLite connection object to the underlying database. One connection object is created per thread. """ thread_id = threading.current_thread().ident with self._shared_map_lock: if thread_id in self._connections: return self._connections[thread_id] else: conn = self._create_connection() self._connections[thread_id] = conn return conn def _create_connection(self): """Create a SQLite connection to the underlying database. Makes a new connection every time. If you need to configure the connection settings (e.g., add custom functions), override this method. """ # Make a new connection. The `sqlite3` module can't use # bytestring paths here on Python 3, so we need to # provide a `str` using `py3_path`. conn = sqlite3.connect( py3_path(self.path), timeout=self.timeout ) if self.supports_extensions: conn.enable_load_extension(True) # Load any extension that are already loaded for other connections. for path in self._extensions: conn.load_extension(path) # Access SELECT results like dictionaries. conn.row_factory = sqlite3.Row return conn def _close(self): """Close the all connections to the underlying SQLite database from all threads. This does not render the database object unusable; new connections can still be opened on demand. """ with self._shared_map_lock: self._connections.clear() @contextlib.contextmanager def _tx_stack(self): """A context manager providing access to the current thread's transaction stack. The context manager synchronizes access to the stack map. Transactions should never migrate across threads. """ thread_id = threading.current_thread().ident with self._shared_map_lock: yield self._tx_stacks[thread_id] def transaction(self): """Get a :class:`Transaction` object for interacting directly with the underlying SQLite database. """ return Transaction(self) def load_extension(self, path): """Load an SQLite extension into all open connections.""" if not self.supports_extensions: raise ValueError( 'this sqlite3 installation does not support extensions') self._extensions.append(path) # Load the extension into every open connection. for conn in self._connections.values(): conn.load_extension(path) # Schema setup and migration. def _make_table(self, table, fields): """Set up the schema of the database. `fields` is a mapping from field names to `Type`s. Columns are added if necessary. """ # Get current schema. with self.transaction() as tx: rows = tx.query('PRAGMA table_info(%s)' % table) current_fields = {row[1] for row in rows} field_names = set(fields.keys()) if current_fields.issuperset(field_names): # Table exists and has all the required columns. return if not current_fields: # No table exists. columns = [] for name, typ in fields.items(): columns.append(f'{name} {typ.sql}') setup_sql = 'CREATE TABLE {} ({});\n'.format(table, ', '.join(columns)) else: # Table exists does not match the field set. setup_sql = '' for name, typ in fields.items(): if name in current_fields: continue setup_sql += 'ALTER TABLE {} ADD COLUMN {} {};\n'.format( table, name, typ.sql ) with self.transaction() as tx: tx.script(setup_sql) def _make_attribute_table(self, flex_table): """Create a table and associated index for flexible attributes for the given entity (if they don't exist). """ with self.transaction() as tx: tx.script(""" CREATE TABLE IF NOT EXISTS {0} ( id INTEGER PRIMARY KEY, entity_id INTEGER, key TEXT, value TEXT, UNIQUE(entity_id, key) ON CONFLICT REPLACE); CREATE INDEX IF NOT EXISTS {0}_by_entity ON {0} (entity_id); """.format(flex_table)) # Querying. def _fetch(self, model_cls, query=None, sort=None): """Fetch the objects of type `model_cls` matching the given query. The query may be given as a string, string sequence, a Query object, or None (to fetch everything). `sort` is an `Sort` object. """ query = query or TrueQuery() # A null query. sort = sort or NullSort() # Unsorted. where, subvals = query.clause() order_by = sort.order_clause() sql = ("SELECT * FROM {} WHERE {} {}").format( model_cls._table, where or '1', f"ORDER BY {order_by}" if order_by else '', ) # Fetch flexible attributes for items matching the main query. # Doing the per-item filtering in python is faster than issuing # one query per item to sqlite. flex_sql = (""" SELECT * FROM {} WHERE entity_id IN (SELECT id FROM {} WHERE {}); """.format( model_cls._flex_table, model_cls._table, where or '1', ) ) with self.transaction() as tx: rows = tx.query(sql, subvals) flex_rows = tx.query(flex_sql, subvals) return Results( model_cls, rows, self, flex_rows, None if where else query, # Slow query component. sort if sort.is_slow() else None, # Slow sort component. ) def _get(self, model_cls, id): """Get a Model object by its id or None if the id does not exist. """ return self._fetch(model_cls, MatchQuery('id', id)).get() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beets/dbcore/query.py0000644000076500000240000006766700000000000016432 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """The Query type hierarchy for DBCore. """ import re from operator import mul from beets import util from datetime import datetime, timedelta import unicodedata from functools import reduce class ParsingError(ValueError): """Abstract class for any unparseable user-requested album/query specification. """ class InvalidQueryError(ParsingError): """Represent any kind of invalid query. The query should be a unicode string or a list, which will be space-joined. """ def __init__(self, query, explanation): if isinstance(query, list): query = " ".join(query) message = f"'{query}': {explanation}" super().__init__(message) class InvalidQueryArgumentValueError(ParsingError): """Represent a query argument that could not be converted as expected. It exists to be caught in upper stack levels so a meaningful (i.e. with the query) InvalidQueryError can be raised. """ def __init__(self, what, expected, detail=None): message = f"'{what}' is not {expected}" if detail: message = f"{message}: {detail}" super().__init__(message) class Query: """An abstract class representing a query into the item database. """ def clause(self): """Generate an SQLite expression implementing the query. Return (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 def __repr__(self): return f"{self.__class__.__name__}()" def __eq__(self, other): return type(self) == type(other) def __hash__(self): return 0 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() def match(self, item): return self.value_match(self.pattern, item.get(self.field)) def __repr__(self): return ("{0.__class__.__name__}({0.field!r}, {0.pattern!r}, " "{0.fast})".format(self)) def __eq__(self, other): return super().__eq__(other) and \ self.field == other.field and self.pattern == other.pattern def __hash__(self): return hash((self.field, hash(self.pattern))) class MatchQuery(FieldQuery): """A query that looks for exact matches in an item field.""" def col_clause(self): return self.field + " = ?", [self.pattern] @classmethod def value_match(cls, pattern, value): return pattern == value class NoneQuery(FieldQuery): """A query that checks whether a field is null.""" def __init__(self, field, fast=True): super().__init__(field, None, fast) def col_clause(self): return self.field + " IS NULL", () def match(self, item): return item.get(self.field) is None def __repr__(self): return "{0.__class__.__name__}({0.field!r}, {0.fast})".format(self) class StringFieldQuery(FieldQuery): """A FieldQuery that converts values to strings before matching them. """ @classmethod def value_match(cls, pattern, value): """Determine whether the value matches the pattern. The value may have any type. """ return cls.string_match(pattern, util.as_string(value)) @classmethod def string_match(cls, pattern, value): """Determine whether the value matches the pattern. Both arguments are strings. Subclasses implement this method. """ raise NotImplementedError() class SubstringQuery(StringFieldQuery): """A query that matches a substring in a specific item field.""" def col_clause(self): pattern = (self.pattern .replace('\\', '\\\\') .replace('%', '\\%') .replace('_', '\\_')) search = '%' + pattern + '%' clause = self.field + " like ? escape '\\'" subvals = [search] return clause, subvals @classmethod def string_match(cls, pattern, value): return pattern.lower() in value.lower() class RegexpQuery(StringFieldQuery): """A query that matches a regular expression in a specific item field. Raises InvalidQueryError when the pattern is not a valid regular expression. """ def __init__(self, field, pattern, fast=True): super().__init__(field, pattern, fast) pattern = self._normalize(pattern) try: self.pattern = re.compile(self.pattern) except re.error as exc: # Invalid regular expression. raise InvalidQueryArgumentValueError(pattern, "a regular expression", format(exc)) @staticmethod def _normalize(s): """Normalize a Unicode string's representation (used on both patterns and matched values). """ return unicodedata.normalize('NFC', s) @classmethod def string_match(cls, pattern, value): return pattern.search(cls._normalize(value)) 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, fast=True): super().__init__(field, pattern, fast) if isinstance(pattern, str): self.pattern = util.str2bool(pattern) self.pattern = int(self.pattern) class BytesQuery(MatchQuery): """Match a raw bytes field (i.e., a path). This is a necessary hack to work around the `sqlite3` module's desire to treat `bytes` and `unicode` equivalently in Python 2. Always use this query instead of `MatchQuery` when matching on BLOB values. """ def __init__(self, field, pattern): super().__init__(field, pattern) # Use a buffer/memoryview representation of the pattern for SQLite # matching. This instructs SQLite to treat the blob as binary # rather than encoded Unicode. if isinstance(self.pattern, (str, bytes)): if isinstance(self.pattern, str): self.pattern = self.pattern.encode('utf-8') self.buf_pattern = memoryview(self.pattern) elif isinstance(self.pattern, memoryview): self.buf_pattern = self.pattern self.pattern = bytes(self.pattern) def col_clause(self): return self.field + " = ?", [self.buf_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. Raises InvalidQueryError when the pattern does not represent an int or a float. """ def _convert(self, s): """Convert a string to a numeric type (float or int). Return None if `s` is empty. Raise an InvalidQueryError if the string cannot be converted. """ # This is really just a bit of fun premature optimization. if not s: return None try: return int(s) except ValueError: try: return float(s) except ValueError: raise InvalidQueryArgumentValueError(s, "an int or a float") def __init__(self, field, pattern, fast=True): super().__init__(field, pattern, fast) 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): if self.field not in item: return False value = item[self.field] if isinstance(value, str): 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 ('{0} >= ? AND {0} <= ?'.format(self.field), (self.rangemin, self.rangemax)) elif self.rangemin is not None: return f'{self.field} >= ?', (self.rangemin,) elif self.rangemax is not None: return f'{self.field} <= ?', (self.rangemax,) else: return '1', () 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 # Act like a sequence. 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): """Return 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 def __repr__(self): return "{0.__class__.__name__}({0.subqueries!r})".format(self) def __eq__(self, other): return super().__eq__(other) and \ self.subqueries == other.subqueries def __hash__(self): """Since subqueries are mutable, this object should not be hashable. However and for conveniences purposes, it can be hashed. """ return reduce(mul, map(hash, self.subqueries), 1) 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().__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 def __repr__(self): return ("{0.__class__.__name__}({0.pattern!r}, {0.fields!r}, " "{0.query_class.__name__})".format(self)) def __eq__(self, other): return super().__eq__(other) and \ self.query_class == other.query_class def __hash__(self): return hash((self.pattern, tuple(self.fields), self.query_class)) 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 OrQuery(MutableCollectionQuery): """A conjunction of a list of other queries.""" def clause(self): return self.clause_with_joiner('or') def match(self, item): return any(q.match(item) for q in self.subqueries) class NotQuery(Query): """A query that matches the negation of its `subquery`, as a shorcut for performing `not(subquery)` without using regular expressions. """ def __init__(self, subquery): self.subquery = subquery def clause(self): clause, subvals = self.subquery.clause() if clause: return f'not ({clause})', subvals else: # If there is no clause, there is nothing to negate. All the logic # is handled by match() for slow queries. return clause, subvals def match(self, item): return not self.subquery.match(item) def __repr__(self): return "{0.__class__.__name__}({0.subquery!r})".format(self) def __eq__(self, other): return super().__eq__(other) and \ self.subquery == other.subquery def __hash__(self): return hash(('not', hash(self.subquery))) 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 # Time/date queries. def _to_epoch_time(date): """Convert a `datetime` object to an integer number of seconds since the (local) Unix epoch. """ if hasattr(date, 'timestamp'): # The `timestamp` method exists on Python 3.3+. return int(date.timestamp()) else: epoch = datetime.fromtimestamp(0) delta = date - epoch return int(delta.total_seconds()) def _parse_periods(pattern): """Parse a string containing two dates separated by two dots (..). Return a pair of `Period` objects. """ parts = pattern.split('..', 1) if len(parts) == 1: instant = Period.parse(parts[0]) return (instant, instant) else: start = Period.parse(parts[0]) end = Period.parse(parts[1]) return (start, end) class Period: """A period of time given by a date, time and precision. Example: 2014-01-01 10:50:30 with precision 'month' represents all instants of time during January 2014. """ precisions = ('year', 'month', 'day', 'hour', 'minute', 'second') date_formats = ( ('%Y',), # year ('%Y-%m',), # month ('%Y-%m-%d',), # day ('%Y-%m-%dT%H', '%Y-%m-%d %H'), # hour ('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M'), # minute ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S') # second ) relative_units = {'y': 365, 'm': 30, 'w': 7, 'd': 1} relative_re = '(?P[+|-]?)(?P[0-9]+)' + \ '(?P[y|m|w|d])' def __init__(self, date, precision): """Create a period with the given date (a `datetime` object) and precision (a string, one of "year", "month", "day", "hour", "minute", or "second"). """ if precision not in Period.precisions: raise ValueError(f'Invalid precision {precision}') self.date = date self.precision = precision @classmethod def parse(cls, string): """Parse a date and return a `Period` object or `None` if the string is empty, or raise an InvalidQueryArgumentValueError if the string cannot be parsed to a date. The date may be absolute or relative. Absolute dates look like `YYYY`, or `YYYY-MM-DD`, or `YYYY-MM-DD HH:MM:SS`, etc. Relative dates have three parts: - Optionally, a ``+`` or ``-`` sign indicating the future or the past. The default is the future. - A number: how much to add or subtract. - A letter indicating the unit: days, weeks, months or years (``d``, ``w``, ``m`` or ``y``). A "month" is exactly 30 days and a "year" is exactly 365 days. """ def find_date_and_format(string): for ord, format in enumerate(cls.date_formats): for format_option in format: try: date = datetime.strptime(string, format_option) return date, ord except ValueError: # Parsing failed. pass return (None, None) if not string: return None # Check for a relative date. match_dq = re.match(cls.relative_re, string) if match_dq: sign = match_dq.group('sign') quantity = match_dq.group('quantity') timespan = match_dq.group('timespan') # Add or subtract the given amount of time from the current # date. multiplier = -1 if sign == '-' else 1 days = cls.relative_units[timespan] date = datetime.now() + \ timedelta(days=int(quantity) * days) * multiplier return cls(date, cls.precisions[5]) # Check for an absolute date. date, ordinal = find_date_and_format(string) if date is None: raise InvalidQueryArgumentValueError(string, 'a valid date/time string') precision = cls.precisions[ordinal] return cls(date, precision) def open_right_endpoint(self): """Based on the precision, convert the period to a precise `datetime` for use as a right endpoint in a right-open interval. """ precision = self.precision date = self.date if 'year' == self.precision: return date.replace(year=date.year + 1, month=1) elif 'month' == precision: if (date.month < 12): return date.replace(month=date.month + 1) else: return date.replace(year=date.year + 1, month=1) elif 'day' == precision: return date + timedelta(days=1) elif 'hour' == precision: return date + timedelta(hours=1) elif 'minute' == precision: return date + timedelta(minutes=1) elif 'second' == precision: return date + timedelta(seconds=1) else: raise ValueError(f'unhandled precision {precision}') class DateInterval: """A closed-open interval of dates. A left endpoint of None means since the beginning of time. A right endpoint of None means towards infinity. """ def __init__(self, start, end): if start is not None and end is not None and not start < end: raise ValueError("start date {} is not before end date {}" .format(start, end)) self.start = start self.end = end @classmethod def from_periods(cls, start, end): """Create an interval with two Periods as the endpoints. """ end_date = end.open_right_endpoint() if end is not None else None start_date = start.date if start is not None else None return cls(start_date, end_date) def contains(self, date): if self.start is not None and date < self.start: return False if self.end is not None and date >= self.end: return False return True def __str__(self): return f'[{self.start}, {self.end})' class DateQuery(FieldQuery): """Matches date fields stored as seconds since Unix epoch time. Dates can be specified as ``year-month-day`` strings where only year is mandatory. The value of a date field can be matched against a date interval by using an ellipsis interval syntax similar to that of NumericQuery. """ def __init__(self, field, pattern, fast=True): super().__init__(field, pattern, fast) start, end = _parse_periods(pattern) self.interval = DateInterval.from_periods(start, end) def match(self, item): if self.field not in item: return False timestamp = float(item[self.field]) date = datetime.fromtimestamp(timestamp) return self.interval.contains(date) _clause_tmpl = "{0} {1} ?" def col_clause(self): clause_parts = [] subvals = [] if self.interval.start: clause_parts.append(self._clause_tmpl.format(self.field, ">=")) subvals.append(_to_epoch_time(self.interval.start)) if self.interval.end: clause_parts.append(self._clause_tmpl.format(self.field, "<")) subvals.append(_to_epoch_time(self.interval.end)) if clause_parts: # One- or two-sided interval. clause = ' AND '.join(clause_parts) else: # Match any date. clause = '1' return clause, subvals class DurationQuery(NumericQuery): """NumericQuery that allow human-friendly (M:SS) time interval formats. Converts the range(s) to a float value, and delegates on NumericQuery. Raises InvalidQueryError when the pattern does not represent an int, float or M:SS time interval. """ def _convert(self, s): """Convert a M:SS or numeric string to a float. Return None if `s` is empty. Raise an InvalidQueryError if the string cannot be converted. """ if not s: return None try: return util.raw_seconds_short(s) except ValueError: try: return float(s) except ValueError: raise InvalidQueryArgumentValueError( s, "a M:SS string or a float") # Sorting. class Sort: """An abstract class representing a sort operation for a query into the item database. """ def order_clause(self): """Generates a SQL fragment to be used in a ORDER BY clause, or None if no fragment is used (i.e., this is a slow sort). """ return None def sort(self, items): """Sort the list of objects and return a list. """ return sorted(items) def is_slow(self): """Indicate whether this query is *slow*, meaning that it cannot be executed in SQL and must be executed in Python. """ return False def __hash__(self): return 0 def __eq__(self, other): return type(self) == type(other) class MultipleSort(Sort): """Sort that encapsulates multiple sub-sorts. """ def __init__(self, sorts=None): self.sorts = sorts or [] def add_sort(self, sort): self.sorts.append(sort) def _sql_sorts(self): """Return the list of sub-sorts for which we can be (at least partially) fast. A contiguous suffix of fast (SQL-capable) sub-sorts are executable in SQL. The remaining, even if they are fast independently, must be executed slowly. """ sql_sorts = [] for sort in reversed(self.sorts): if not sort.order_clause() is None: sql_sorts.append(sort) else: break sql_sorts.reverse() return sql_sorts def order_clause(self): order_strings = [] for sort in self._sql_sorts(): order = sort.order_clause() order_strings.append(order) return ", ".join(order_strings) def is_slow(self): for sort in self.sorts: if sort.is_slow(): return True return False def sort(self, items): slow_sorts = [] switch_slow = False for sort in reversed(self.sorts): if switch_slow: slow_sorts.append(sort) elif sort.order_clause() is None: switch_slow = True slow_sorts.append(sort) else: pass for sort in slow_sorts: items = sort.sort(items) return items def __repr__(self): return f'MultipleSort({self.sorts!r})' def __hash__(self): return hash(tuple(self.sorts)) def __eq__(self, other): return super().__eq__(other) and \ self.sorts == other.sorts class FieldSort(Sort): """An abstract sort criterion that orders by a specific field (of any kind). """ def __init__(self, field, ascending=True, case_insensitive=True): self.field = field self.ascending = ascending self.case_insensitive = case_insensitive def sort(self, objs): # TODO: Conversion and null-detection here. In Python 3, # comparisons with None fail. We should also support flexible # attributes with different types without falling over. def key(item): field_val = item.get(self.field, '') if self.case_insensitive and isinstance(field_val, str): field_val = field_val.lower() return field_val return sorted(objs, key=key, reverse=not self.ascending) def __repr__(self): return '<{}: {}{}>'.format( type(self).__name__, self.field, '+' if self.ascending else '-', ) def __hash__(self): return hash((self.field, self.ascending)) def __eq__(self, other): return super().__eq__(other) and \ self.field == other.field and \ self.ascending == other.ascending class FixedFieldSort(FieldSort): """Sort object to sort on a fixed field. """ def order_clause(self): order = "ASC" if self.ascending else "DESC" if self.case_insensitive: field = '(CASE ' \ 'WHEN TYPEOF({0})="text" THEN LOWER({0}) ' \ 'WHEN TYPEOF({0})="blob" THEN LOWER({0}) ' \ 'ELSE {0} END)'.format(self.field) else: field = self.field return f"{field} {order}" class SlowFieldSort(FieldSort): """A sort criterion by some model field other than a fixed field: i.e., a computed or flexible field. """ def is_slow(self): return True class NullSort(Sort): """No sorting. Leave results unsorted.""" def sort(self, items): return items def __nonzero__(self): return self.__bool__() def __bool__(self): return False def __eq__(self, other): return type(self) == type(other) or other is None def __hash__(self): return 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/dbcore/queryparse.py0000644000076500000240000002272000000000000017442 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Parsing of strings into DBCore queries. """ import re import itertools from . import query PARSE_QUERY_PART_REGEX = re.compile( # Non-capturing optional segment for the keyword. r'(-|\^)?' # Negation prefixes. r'(?:' r'(\S+?)' # The field key. r'(? `(None, 'stapler', SubstringQuery, False)` - `'color:red'` -> `('color', 'red', SubstringQuery, False)` - `':^Quiet'` -> `(None, '^Quiet', RegexpQuery, False)`, because the `^` follows the `:` - `'color::b..e'` -> `('color', 'b..e', RegexpQuery, False)` - `'-color:red'` -> `('color', 'red', SubstringQuery, True)` """ # Apply the regular expression and extract the components. part = part.strip() match = PARSE_QUERY_PART_REGEX.match(part) assert match # Regex should always match negate = bool(match.group(1)) key = match.group(2) term = match.group(3).replace('\\:', ':') # Check whether there's a prefix in the query and use the # corresponding query type. for pre, query_class in prefixes.items(): if term.startswith(pre): return key, term[len(pre):], query_class, negate # No matching prefix, so use either the query class determined by # the field or the default as a fallback. query_class = query_classes.get(key, default_class) return key, term, query_class, negate def construct_query_part(model_cls, prefixes, query_part): """Parse a *query part* string and return a :class:`Query` object. :param model_cls: The :class:`Model` class that this is a query for. This is used to determine the appropriate query types for the model's fields. :param prefixes: A map from prefix strings to :class:`Query` types. :param query_part: The string to parse. See the documentation for `parse_query_part` for more information on query part syntax. """ # A shortcut for empty query parts. if not query_part: return query.TrueQuery() # Use `model_cls` to build up a map from field (or query) names to # `Query` classes. query_classes = {} for k, t in itertools.chain(model_cls._fields.items(), model_cls._types.items()): query_classes[k] = t.query query_classes.update(model_cls._queries) # Non-field queries. # Parse the string. key, pattern, query_class, negate = \ parse_query_part(query_part, query_classes, prefixes) # If there's no key (field name) specified, this is a "match # anything" query. if key is None: if issubclass(query_class, query.FieldQuery): # The query type matches a specific field, but none was # specified. So we use a version of the query that matches # any field. out_query = query.AnyFieldQuery(pattern, model_cls._search_fields, query_class) else: # Non-field query type. out_query = query_class(pattern) # Field queries get constructed according to the name of the field # they are querying. elif issubclass(query_class, query.FieldQuery): key = key.lower() out_query = query_class(key.lower(), pattern, key in model_cls._fields) # Non-field (named) query. else: out_query = query_class(pattern) # Apply negation. if negate: return query.NotQuery(out_query) else: return out_query def query_from_strings(query_cls, model_cls, prefixes, query_parts): """Creates a collection query of type `query_cls` from a list of strings in the format used by parse_query_part. `model_cls` determines how queries are constructed from strings. """ subqueries = [] for part in query_parts: subqueries.append(construct_query_part(model_cls, prefixes, part)) if not subqueries: # No terms in query. subqueries = [query.TrueQuery()] return query_cls(subqueries) def construct_sort_part(model_cls, part, case_insensitive=True): """Create a `Sort` from a single string criterion. `model_cls` is the `Model` being queried. `part` is a single string ending in ``+`` or ``-`` indicating the sort. `case_insensitive` indicates whether or not the sort should be performed in a case sensitive manner. """ assert part, "part must be a field name and + or -" field = part[:-1] assert field, "field is missing" direction = part[-1] assert direction in ('+', '-'), "part must end with + or -" is_ascending = direction == '+' if field in model_cls._sorts: sort = model_cls._sorts[field](model_cls, is_ascending, case_insensitive) elif field in model_cls._fields: sort = query.FixedFieldSort(field, is_ascending, case_insensitive) else: # Flexible or computed. sort = query.SlowFieldSort(field, is_ascending, case_insensitive) return sort def sort_from_strings(model_cls, sort_parts, case_insensitive=True): """Create a `Sort` from a list of sort criteria (strings). """ if not sort_parts: sort = query.NullSort() elif len(sort_parts) == 1: sort = construct_sort_part(model_cls, sort_parts[0], case_insensitive) else: sort = query.MultipleSort() for part in sort_parts: sort.add_sort(construct_sort_part(model_cls, part, case_insensitive)) return sort def parse_sorted_query(model_cls, parts, prefixes={}, case_insensitive=True): """Given a list of strings, create the `Query` and `Sort` that they represent. """ # Separate query token and sort token. query_parts = [] sort_parts = [] # Split up query in to comma-separated subqueries, each representing # an AndQuery, which need to be joined together in one OrQuery subquery_parts = [] for part in parts + [',']: if part.endswith(','): # Ensure we can catch "foo, bar" as well as "foo , bar" last_subquery_part = part[:-1] if last_subquery_part: subquery_parts.append(last_subquery_part) # Parse the subquery in to a single AndQuery # TODO: Avoid needlessly wrapping AndQueries containing 1 subquery? query_parts.append(query_from_strings( query.AndQuery, model_cls, prefixes, subquery_parts )) del subquery_parts[:] else: # Sort parts (1) end in + or -, (2) don't have a field, and # (3) consist of more than just the + or -. if part.endswith(('+', '-')) \ and ':' not in part \ and len(part) > 1: sort_parts.append(part) else: subquery_parts.append(part) # Avoid needlessly wrapping single statements in an OR q = query.OrQuery(query_parts) if len(query_parts) > 1 else query_parts[0] s = sort_from_strings(model_cls, sort_parts, case_insensitive) return q, s ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/dbcore/types.py0000644000076500000240000001456100000000000016412 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Representation of type information for DBCore model fields. """ from . import query from beets.util import str2bool # Abstract base. class Type: """An object encapsulating the type of a model field. Includes information about how to store, query, format, and parse a given field. """ sql = 'TEXT' """The SQLite column type for the value. """ query = query.SubstringQuery """The `Query` subclass to be used when querying the field. """ model_type = str """The Python type that is used to represent the value in the model. The model is guaranteed to return a value of this type if the field is accessed. To this end, the constructor is used by the `normalize` and `from_sql` methods and the `default` property. """ @property def null(self): """The value to be exposed when the underlying value is None. """ return self.model_type() def format(self, value): """Given a value of this type, produce a Unicode string representing the value. This is used in template evaluation. """ if value is None: value = self.null # `self.null` might be `None` if value is None: value = '' if isinstance(value, bytes): value = value.decode('utf-8', 'ignore') return str(value) def parse(self, string): """Parse a (possibly human-written) string and return the indicated value of this type. """ try: return self.model_type(string) except ValueError: return self.null def normalize(self, value): """Given a value that will be assigned into a field of this type, normalize the value to have the appropriate type. This base implementation only reinterprets `None`. """ if value is None: return self.null else: # TODO This should eventually be replaced by # `self.model_type(value)` return value def from_sql(self, sql_value): """Receives the value stored in the SQL backend and return the value to be stored in the model. For fixed fields the type of `value` is determined by the column type affinity given in the `sql` property and the SQL to Python mapping of the database adapter. For more information see: https://www.sqlite.org/datatype3.html https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types Flexible fields have the type affinity `TEXT`. This means the `sql_value` is either a `memoryview` or a `unicode` object` and the method must handle these in addition. """ if isinstance(sql_value, memoryview): sql_value = bytes(sql_value).decode('utf-8', 'ignore') if isinstance(sql_value, str): return self.parse(sql_value) else: return self.normalize(sql_value) def to_sql(self, model_value): """Convert a value as stored in the model object to a value used by the database adapter. """ return model_value # Reusable types. class Default(Type): null = None class Integer(Type): """A basic integer type. """ sql = 'INTEGER' query = query.NumericQuery model_type = int def normalize(self, value): try: return self.model_type(round(float(value))) except ValueError: return self.null except TypeError: return self.null class PaddedInt(Integer): """An integer field that is formatted with a given number of digits, padded with zeroes. """ def __init__(self, digits): self.digits = digits def format(self, value): return '{0:0{1}d}'.format(value or 0, self.digits) class NullPaddedInt(PaddedInt): """Same as `PaddedInt`, but does not normalize `None` to `0.0`. """ null = None class ScaledInt(Integer): """An integer whose formatting operation scales the number by a constant and adds a suffix. Good for units with large magnitudes. """ def __init__(self, unit, suffix=''): self.unit = unit self.suffix = suffix def format(self, value): return '{}{}'.format((value or 0) // self.unit, self.suffix) class Id(Integer): """An integer used as the row id or a foreign key in a SQLite table. This type is nullable: None values are not translated to zero. """ null = None def __init__(self, primary=True): if primary: self.sql = 'INTEGER PRIMARY KEY' class Float(Type): """A basic floating-point type. The `digits` parameter specifies how many decimal places to use in the human-readable representation. """ sql = 'REAL' query = query.NumericQuery model_type = float def __init__(self, digits=1): self.digits = digits def format(self, value): return '{0:.{1}f}'.format(value or 0, self.digits) class NullFloat(Float): """Same as `Float`, but does not normalize `None` to `0.0`. """ null = None class String(Type): """A Unicode string type. """ sql = 'TEXT' query = query.SubstringQuery def normalize(self, value): if value is None: return self.null else: return self.model_type(value) class Boolean(Type): """A boolean type. """ sql = 'INTEGER' query = query.BooleanQuery model_type = bool def format(self, value): return str(bool(value)) def parse(self, string): return str2bool(string) # Shared instances of common types. DEFAULT = Default() INTEGER = Integer() PRIMARY_ID = Id(True) FOREIGN_ID = Id(False) FLOAT = Float() NULL_FLOAT = NullFloat() STRING = String() BOOLEAN = Boolean() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/importer.py0000644000076500000240000017002500000000000015647 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Provides the basic, interface-agnostic workflow for importing and autotagging music files. """ import os import re import pickle import itertools from collections import defaultdict from tempfile import mkdtemp from bisect import insort, bisect_left from contextlib import contextmanager import shutil import time from beets import logging from beets import autotag from beets import library from beets import dbcore from beets import plugins from beets import util from beets import config from beets.util import pipeline, sorted_walk, ancestry, MoveOperation from beets.util import syspath, normpath, displayable_path from enum import Enum import mediafile action = Enum('action', ['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG']) # The RETAG action represents "don't apply any match, but do record # new metadata". It's not reachable via the standard command prompt but # can be used by plugins. QUEUE_SIZE = 128 SINGLE_ARTIST_THRESH = 0.25 PROGRESS_KEY = 'tagprogress' HISTORY_KEY = 'taghistory' # Global logger. log = logging.getLogger('beets') class ImportAbort(Exception): """Raised when the user aborts the tagging operation. """ pass # Utilities. def _open_state(): """Reads the state file, returning a dictionary.""" try: with open(config['statefile'].as_filename(), 'rb') as f: return pickle.load(f) except Exception as exc: # The `pickle` module can emit all sorts of exceptions during # unpickling, including ImportError. We use a catch-all # exception to avoid enumerating them all (the docs don't even have a # full list!). log.debug('state file could not be read: {0}', exc) return {} def _save_state(state): """Writes the state dictionary out to disk.""" try: with open(config['statefile'].as_filename(), 'wb') as f: pickle.dump(state, f) except OSError as exc: log.error('state file could not be written: {0}', exc) # Utilities for reading and writing the beets progress file, which # allows long tagging tasks to be resumed when they pause (or crash). def progress_read(): state = _open_state() return state.setdefault(PROGRESS_KEY, {}) @contextmanager def progress_write(): state = _open_state() progress = state.setdefault(PROGRESS_KEY, {}) yield progress _save_state(state) def progress_add(toppath, *paths): """Record that the files under all of the `paths` have been imported under `toppath`. """ with progress_write() as state: imported = state.setdefault(toppath, []) for path in paths: # Normally `progress_add` will be called with the path # argument increasing. This is because of the ordering in # `albums_in_dir`. We take advantage of that to make the # code faster if imported and imported[len(imported) - 1] <= path: imported.append(path) else: insort(imported, path) def progress_element(toppath, path): """Return whether `path` has been imported in `toppath`. """ state = progress_read() if toppath not in state: return False imported = state[toppath] i = bisect_left(imported, path) return i != len(imported) and imported[i] == path def has_progress(toppath): """Return `True` if there exist paths that have already been imported under `toppath`. """ state = progress_read() return toppath in state def progress_reset(toppath): with progress_write() as state: if toppath in state: del state[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. 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: """Controls an import action. Subclasses should implement methods to communicate with the user or otherwise make decisions. """ def __init__(self, lib, loghandler, paths, query): """Create a session. `lib` is a Library object. `loghandler` is a logging.Handler. Either `paths` or `query` is non-null and indicates the source of files to be imported. """ self.lib = lib self.logger = self._setup_logging(loghandler) self.paths = paths self.query = query self._is_resuming = {} self._merged_items = set() self._merged_dirs = set() # Normalize the paths. if self.paths: self.paths = list(map(normpath, self.paths)) def _setup_logging(self, loghandler): logger = logging.getLogger(__name__) logger.propagate = False if not loghandler: loghandler = logging.NullHandler() logger.handlers = [loghandler] return logger def set_config(self, config): """Set `config` property from global import config and make implied changes. """ # FIXME: Maybe this function should not exist and should instead # provide "decision wrappers" like "should_resume()", etc. iconfig = dict(config) self.config = iconfig # 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 if iconfig['reflink']: iconfig['reflink'] = iconfig['reflink'] \ .as_choice(['auto', True, False]) # Copy, move, reflink, link, and hardlink are mutually exclusive. if iconfig['move']: iconfig['copy'] = False iconfig['link'] = False iconfig['hardlink'] = False iconfig['reflink'] = False elif iconfig['link']: iconfig['copy'] = False iconfig['move'] = False iconfig['hardlink'] = False iconfig['reflink'] = False elif iconfig['hardlink']: iconfig['copy'] = False iconfig['move'] = False iconfig['link'] = False iconfig['reflink'] = False elif iconfig['reflink']: iconfig['copy'] = False iconfig['move'] = False iconfig['link'] = False iconfig['hardlink'] = False # Only delete when copying. if not iconfig['copy']: iconfig['delete'] = False self.want_resume = config['resume'].as_choice([True, False, 'ask']) def tag_log(self, status, paths): """Log a message about a given album to the importer log. The status should reflect the reason the album couldn't be tagged. """ self.logger.info('{0} {1}', status, displayable_path(paths)) 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 duplicate: # Duplicate: log all three choices (skip, keep both, and trump). if task.should_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, found_duplicates): raise NotImplementedError def choose_item(self, task): raise NotImplementedError def run(self): """Run the import task. """ self.logger.info('import started {0}', time.asctime()) self.set_config(config['import']) # Set up the pipeline. if self.query is None: stages = [read_tasks(self)] else: stages = [query_tasks(self)] # In pretend mode, just log what would otherwise be imported. if self.config['pretend']: stages += [log_files(self)] else: if self.config['group_albums'] and \ not self.config['singletons']: # Split directory tasks into one task for each album. stages += [group_albums(self)] # These stages either talk to the user to get a decision or, # in the case of a non-autotagged import, just choose to # import everything as-is. In *both* cases, these stages # also add the music to the library database, so later # stages need to read and write data from there. if self.config['autotag']: stages += [lookup_candidates(self), user_query(self)] else: stages += [import_asis(self)] # Plugin stages. for stage_func in plugins.early_import_stages(): stages.append(plugin_stage(self, stage_func)) for stage_func in plugins.import_stages(): stages.append(plugin_stage(self, stage_func)) stages += [manipulate_files(self)] pl = pipeline.Pipeline(stages) # Run the pipeline. plugins.send('import_begin', session=self) try: if config['threaded']: pl.run_parallel(QUEUE_SIZE) else: pl.run_sequential() except ImportAbort: # User aborted operation. Silently stop. pass # Incremental and resumed imports def already_imported(self, toppath, paths): """Returns true if the files belonging to this task have already been imported in a previous session. """ if self.is_resuming(toppath) \ and all([progress_element(toppath, p) for p in paths]): return True if self.config['incremental'] \ and tuple(paths) in self.history_dirs: return True return False @property def history_dirs(self): if not hasattr(self, '_history_dirs'): self._history_dirs = history_get() return self._history_dirs def already_merged(self, paths): """Returns true if all the paths being imported were part of a merge during previous tasks. """ for path in paths: if path not in self._merged_items \ and path not in self._merged_dirs: return False return True def mark_merged(self, paths): """Mark paths and directories as merged for future reimport tasks. """ self._merged_items.update(paths) dirs = {os.path.dirname(path) if os.path.isfile(path) else path for path in paths} self._merged_dirs.update(dirs) def is_resuming(self, toppath): """Return `True` if user wants to resume import of this path. You have to call `ask_resume` first to determine the return value. """ return self._is_resuming.get(toppath, False) def ask_resume(self, toppath): """If import of `toppath` was aborted in an earlier session, ask user if she wants to resume the import. Determines the return value of `is_resuming(toppath)`. """ if self.want_resume and has_progress(toppath): # Either accept immediately or prompt for input to decide. if self.want_resume is True or \ self.should_resume(toppath): log.warning('Resuming interrupted import of {0}', util.displayable_path(toppath)) self._is_resuming[toppath] = True else: # Clear progress; we're starting from the top. progress_reset(toppath) # The importer task class. class BaseImportTask: """An abstract base class for importer tasks. Tasks flow through the importer pipeline. Each stage can update them. """ def __init__(self, toppath, paths, items): """Create a task. The primary fields that define a task are: * `toppath`: The user-specified base directory that contains the music for this task. If the task has *no* user-specified base (for example, when importing based on an -L query), this can be None. This is used for tracking progress and history. * `paths`: A list of *specific* paths where the music for this task came from. These paths can be directories, when their entire contents are being imported, or files, when the task comprises individual tracks. This is used for progress/history tracking and for displaying the task to the user. * `items`: A list of `Item` objects representing the music being imported. These fields should not change after initialization. """ self.toppath = toppath self.paths = paths self.items = items class ImportTask(BaseImportTask): """Represents a single set of items to be imported along with its intermediate state. May represent an album or a single item. The import session and stages call the following methods in the given order. * `lookup_candidates()` Sets the `common_artist`, `common_album`, `candidates`, and `rec` attributes. `candidates` is a list of `AlbumMatch` objects. * `choose_match()` Uses the session to set the `match` attribute from the `candidates` list. * `find_duplicates()` Returns a list of albums from `lib` with the same artist and album name as the task. * `apply_metadata()` Sets the attributes of the items from the task's `match` attribute. * `add()` Add the imported items and album to the database. * `manipulate_files()` Copy, move, and write files depending on the session configuration. * `set_fields()` Sets the fields given at CLI or configuration to the specified values. * `finalize()` Update the import progress and cleanup the file system. """ def __init__(self, toppath, paths, items): super().__init__(toppath, paths, items) self.choice_flag = None self.cur_album = None self.cur_artist = None self.candidates = [] self.rec = None self.should_remove_duplicates = False self.should_merge_duplicates = False self.is_album = True self.search_ids = [] # user-supplied candidate IDs. 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. """ # Not part of the task structure: assert choice != action.APPLY # Only used internally. if choice in (action.SKIP, action.ASIS, action.TRACKS, action.ALBUMS, action.RETAG): self.choice_flag = choice self.match = None else: 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.toppath: progress_add(self.toppath, *self.paths) def save_history(self): """Save the directory in the history for incremental imports. """ if self.paths: history_add(self.paths) # Logical decisions. @property def apply(self): return self.choice_flag == action.APPLY @property def skip(self): return 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 or RETAG (in which case the data comes from the files' current metadata) or APPLY (data comes from the choice). """ if self.choice_flag in (action.ASIS, action.RETAG): return (self.cur_artist, self.cur_album) elif self.choice_flag is action.APPLY: return (self.match.info.artist, self.match.info.album) def imported_items(self): """Return a list of Items that should be added to the library. If the tasks applies an album match the method only returns the matched items. """ if self.choice_flag in (action.ASIS, action.RETAG): return list(self.items) elif self.choice_flag == action.APPLY: return list(self.match.mapping.keys()) else: assert False def apply_metadata(self): """Copy metadata from match info to the items. """ if config['import']['from_scratch']: for item in self.match.mapping: item.clear() autotag.apply_metadata(self.match.info, self.match.mapping) def duplicate_items(self, lib): duplicate_items = [] for album in self.find_duplicates(lib): duplicate_items += album.items() return duplicate_items def remove_duplicates(self, lib): duplicate_items = self.duplicate_items(lib) log.debug('removing {0} old duplicated items', len(duplicate_items)) for item in duplicate_items: item.remove() if lib.directory in util.ancestry(item.path): log.debug('deleting duplicate {0}', util.displayable_path(item.path)) util.remove(item.path) util.prune_dirs(os.path.dirname(item.path), lib.directory) def set_fields(self, lib): """Sets the fields given at CLI or configuration to the specified values, for both the album and all its items. """ items = self.imported_items() for field, view in config['import']['set_fields'].items(): value = view.get() log.debug('Set field {1}={2} for {0}', displayable_path(self.paths), field, value) self.album[field] = value for item in items: item[field] = value with lib.transaction(): for item in items: item.store() self.album.store() def finalize(self, session): """Save progress, clean up files, and emit plugin event. """ # Update progress. if session.want_resume: self.save_progress() if session.config['incremental'] and not ( # Should we skip recording to incremental list? self.skip and session.config['incremental_skip_later'] ): self.save_history() self.cleanup(copy=session.config['copy'], delete=session.config['delete'], move=session.config['move']) if not self.skip: self._emit_imported(session.lib) def cleanup(self, copy=False, delete=False, move=False): """Remove and prune imported paths. """ # Do not delete any files or prune directories when skipping. if self.skip: return items = self.imported_items() # When copying and deleting originals, delete old files. if copy and delete: new_paths = [os.path.realpath(item.path) for item in items] for old_path in self.old_paths: # Only delete files that were actually copied. if old_path not in new_paths: util.remove(syspath(old_path), False) self.prune(old_path) # When moving, prune empty directories containing the original files. elif move: for old_path in self.old_paths: self.prune(old_path) def _emit_imported(self, lib): plugins.send('album_imported', lib=lib, album=self.album) def handle_created(self, session): """Send the `import_task_created` event for this task. Return a list of tasks that should continue through the pipeline. By default, this is a list containing only the task itself, but plugins can replace the task with new ones. """ tasks = plugins.send('import_task_created', session=session, task=self) if not tasks: tasks = [self] else: # The plugins gave us a list of lists of tasks. Flatten it. tasks = [t for inner in tasks for t in inner] return tasks def lookup_candidates(self): """Retrieve and store candidates for this album. User-specified candidate IDs are stored in self.search_ids: if present, the initial lookup is restricted to only those IDs. """ artist, album, prop = \ autotag.tag_album(self.items, search_ids=self.search_ids) self.cur_artist = artist self.cur_album = album self.candidates = prop.candidates self.rec = prop.recommendation def find_duplicates(self, lib): """Return a list of albums from `lib` with the same artist and album name as the task. """ artist, album = self.chosen_ident() if artist is None: # As-is import with no artist. Skip check. return [] duplicates = [] task_paths = {i.path for i in self.items if i} duplicate_query = dbcore.AndQuery(( dbcore.MatchQuery('albumartist', artist), dbcore.MatchQuery('album', album), )) for album in lib.albums(duplicate_query): # Check whether the album paths are all present in the task # i.e. album is being completely re-imported by the task, # in which case it is not a duplicate (will be replaced). album_paths = {i.path for i in album.items()} if not (album_paths <= task_paths): duplicates.append(album) return duplicates def align_album_level_fields(self): """Make some album fields equal across `self.items`. For the RETAG action, we assume that the responsible for returning it (ie. a plugin) always ensures that the first item contains valid data on the relevant fields. """ changes = {} if self.choice_flag == action.ASIS: # Taking metadata "as-is". Guess whether this album is VA. plur_albumartist, freq = util.plurality( [i.albumartist or i.artist for i in self.items] ) if freq == len(self.items) or \ (freq > 1 and float(freq) / len(self.items) >= SINGLE_ARTIST_THRESH): # Single-artist album. changes['albumartist'] = plur_albumartist changes['comp'] = False else: # VA. changes['albumartist'] = config['va_name'].as_str() changes['comp'] = True elif self.choice_flag in (action.APPLY, action.RETAG): # Applying autotagged metadata. Just get AA from the first # item. if not self.items[0].albumartist: changes['albumartist'] = self.items[0].artist if not self.items[0].mb_albumartistid: changes['mb_albumartistid'] = self.items[0].mb_artistid # Apply new metadata. for item in self.items: item.update(changes) def manipulate_files(self, operation=None, write=False, session=None): """ Copy, move, link, hardlink or reflink (depending on `operation`) the files as well as write metadata. `operation` should be an instance of `util.MoveOperation`. If `write` is `True` metadata is written to the files. """ items = self.imported_items() # Save the original paths of all items for deletion and pruning # in the next step (finalization). self.old_paths = [item.path for item in items] for item in items: if operation is not None: # In copy and link modes, treat re-imports specially: # move in-library files. (Out-of-library files are # copied/moved as usual). old_path = item.path if (operation != MoveOperation.MOVE and self.replaced_items[item] and session.lib.directory in util.ancestry(old_path)): item.move() # We moved the item, so remove the # now-nonexistent file from old_paths. self.old_paths.remove(old_path) else: # A normal import. Just copy files and keep track of # old paths. item.move(operation) if write and (self.apply or self.choice_flag == action.RETAG): item.try_write() with session.lib.transaction(): for item in self.imported_items(): item.store() plugins.send('import_task_files', session=session, task=self) def add(self, lib): """Add the items as an album to the library and remove replaced items. """ self.align_album_level_fields() with lib.transaction(): self.record_replaced(lib) self.remove_replaced(lib) self.album = lib.add_album(self.imported_items()) if 'data_source' in self.imported_items()[0]: self.album.data_source = self.imported_items()[0].data_source self.reimport_metadata(lib) def record_replaced(self, lib): """Records the replaced items and albums in the `replaced_items` and `replaced_albums` dictionaries. """ self.replaced_items = defaultdict(list) self.replaced_albums = defaultdict(list) replaced_album_ids = set() for item in self.imported_items(): dup_items = list(lib.items( dbcore.query.BytesQuery('path', item.path) )) self.replaced_items[item] = dup_items for dup_item in dup_items: if (not dup_item.album_id or dup_item.album_id in replaced_album_ids): continue replaced_album = dup_item._cached_album if replaced_album: replaced_album_ids.add(dup_item.album_id) self.replaced_albums[replaced_album.path] = replaced_album def reimport_metadata(self, lib): """For reimports, preserves metadata for reimported items and albums. """ if self.is_album: replaced_album = self.replaced_albums.get(self.album.path) if replaced_album: self.album.added = replaced_album.added self.album.update(replaced_album._values_flex) self.album.artpath = replaced_album.artpath self.album.store() log.debug( 'Reimported album: added {0}, flexible ' 'attributes {1} from album {2} for {3}', self.album.added, replaced_album._values_flex.keys(), replaced_album.id, displayable_path(self.album.path) ) for item in self.imported_items(): dup_items = self.replaced_items[item] for dup_item in dup_items: if dup_item.added and dup_item.added != item.added: item.added = dup_item.added log.debug( 'Reimported item added {0} ' 'from item {1} for {2}', item.added, dup_item.id, displayable_path(item.path) ) item.update(dup_item._values_flex) log.debug( 'Reimported item flexible attributes {0} ' 'from item {1} for {2}', dup_item._values_flex.keys(), dup_item.id, displayable_path(item.path) ) item.store() def remove_replaced(self, lib): """Removes all the items from the library that have the same path as an item from this task. """ for item in self.imported_items(): for dup_item in self.replaced_items[item]: log.debug('Replacing item {0}: {1}', dup_item.id, displayable_path(item.path)) dup_item.remove() log.debug('{0} of {1} items replaced', sum(bool(l) for l in self.replaced_items.values()), len(self.imported_items())) def choose_match(self, session): """Ask the session which match should apply and apply it. """ choice = session.choose_match(self) self.set_choice(choice) session.log_choice(self) def reload(self): """Reload albums and items from the database. """ for item in self.imported_items(): item.load() self.album.load() # 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'].as_str_seq()) class SingletonImportTask(ImportTask): """ImportTask for a single track that is not associated to an album. """ def __init__(self, toppath, item): super().__init__(toppath, [item.path], [item]) self.item = item self.is_album = False self.paths = [item.path] def chosen_ident(self): assert self.choice_flag in (action.ASIS, action.APPLY, action.RETAG) if self.choice_flag in (action.ASIS, action.RETAG): 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 [self.item] def apply_metadata(self): autotag.apply_item_metadata(self.item, self.match.info) def _emit_imported(self, lib): for item in self.imported_items(): plugins.send('item_imported', lib=lib, item=item) def lookup_candidates(self): prop = autotag.tag_item(self.item, search_ids=self.search_ids) self.candidates = prop.candidates self.rec = prop.recommendation def find_duplicates(self, lib): """Return a list of items from `lib` that have the same artist and title as the task. """ artist, title = self.chosen_ident() found_items = [] query = dbcore.AndQuery(( dbcore.MatchQuery('artist', artist), dbcore.MatchQuery('title', title), )) for other_item in lib.items(query): # Existing items not considered duplicates. if other_item.path != self.item.path: found_items.append(other_item) return found_items duplicate_items = find_duplicates def add(self, lib): with lib.transaction(): self.record_replaced(lib) self.remove_replaced(lib) lib.add(self.item) self.reimport_metadata(lib) def infer_album_fields(self): raise NotImplementedError def choose_match(self, session): """Ask the session which match should apply and apply it. """ choice = session.choose_item(self) self.set_choice(choice) session.log_choice(self) def reload(self): self.item.load() def set_fields(self, lib): """Sets the fields given at CLI or configuration to the specified values, for the singleton item. """ for field, view in config['import']['set_fields'].items(): value = view.get() log.debug('Set field {1}={2} for {0}', displayable_path(self.paths), field, value) self.item[field] = value self.item.store() # FIXME The inheritance relationships are inverted. This is why there # are so many methods which pass. More responsibility should be delegated to # the BaseImportTask class. class SentinelImportTask(ImportTask): """A sentinel task marks the progress of an import and does not import any items itself. If only `toppath` is set the task indicates the end of a top-level directory import. If the `paths` argument is also given, the task indicates the progress in the `toppath` import. """ def __init__(self, toppath, paths): super().__init__(toppath, paths, ()) # TODO Remove the remaining attributes eventually self.should_remove_duplicates = False self.is_album = True self.choice_flag = None def save_history(self): pass def save_progress(self): if self.paths is None: # "Done" sentinel. progress_reset(self.toppath) else: # "Directory progress" sentinel for singletons progress_add(self.toppath, *self.paths) def skip(self): return True def set_choice(self, choice): raise NotImplementedError def cleanup(self, **kwargs): pass def _emit_imported(self, session): pass class ArchiveImportTask(SentinelImportTask): """An import task that represents the processing of an archive. `toppath` must be a `zip`, `tar`, or `rar` archive. Archive tasks serve two purposes: - First, it will unarchive the files to a temporary directory and return it. The client should read tasks from the resulting directory and send them through the pipeline. - Second, it will clean up the temporary directory when it proceeds through the pipeline. The client should send the archive task after sending the rest of the music tasks to make this work. """ def __init__(self, toppath): super().__init__(toppath, ()) self.extracted = False @classmethod def is_archive(cls, path): """Returns true if the given path points to an archive that can be handled. """ if not os.path.isfile(path): return False for path_test, _ in cls.handlers(): if path_test(util.py3_path(path)): return True return False @classmethod def handlers(cls): """Returns a list of archive handlers. Each handler is a `(path_test, ArchiveClass)` tuple. `path_test` is a function that returns `True` if the given path can be handled by `ArchiveClass`. `ArchiveClass` is a class that implements the same interface as `tarfile.TarFile`. """ if not hasattr(cls, '_handlers'): cls._handlers = [] from zipfile import is_zipfile, ZipFile cls._handlers.append((is_zipfile, ZipFile)) import tarfile cls._handlers.append((tarfile.is_tarfile, tarfile.open)) try: from rarfile import is_rarfile, RarFile except ImportError: pass else: cls._handlers.append((is_rarfile, RarFile)) try: from py7zr import is_7zfile, SevenZipFile except ImportError: pass else: cls._handlers.append((is_7zfile, SevenZipFile)) return cls._handlers def cleanup(self, **kwargs): """Removes the temporary directory the archive was extracted to. """ if self.extracted: log.debug('Removing extracted directory: {0}', displayable_path(self.toppath)) shutil.rmtree(self.toppath) def extract(self): """Extracts the archive to a temporary directory and sets `toppath` to that directory. """ for path_test, handler_class in self.handlers(): if path_test(util.py3_path(self.toppath)): break extract_to = mkdtemp() archive = handler_class(util.py3_path(self.toppath), mode='r') try: archive.extractall(extract_to) finally: archive.close() self.extracted = True self.toppath = extract_to class ImportTaskFactory: """Generate album and singleton import tasks for all media files indicated by a path. """ def __init__(self, toppath, session): """Create a new task factory. `toppath` is the user-specified path to search for music to import. `session` is the `ImportSession`, which controls how tasks are read from the directory. """ self.toppath = toppath self.session = session self.skipped = 0 # Skipped due to incremental/resume. self.imported = 0 # "Real" tasks created. self.is_archive = ArchiveImportTask.is_archive(syspath(toppath)) def tasks(self): """Yield all import tasks for music found in the user-specified path `self.toppath`. Any necessary sentinel tasks are also produced. During generation, update `self.skipped` and `self.imported` with the number of tasks that were not produced (due to incremental mode or resumed imports) and the number of concrete tasks actually produced, respectively. If `self.toppath` is an archive, it is adjusted to point to the extracted data. """ # Check whether this is an archive. if self.is_archive: archive_task = self.unarchive() if not archive_task: return # Search for music in the directory. for dirs, paths in self.paths(): if self.session.config['singletons']: for path in paths: tasks = self._create(self.singleton(path)) yield from tasks yield self.sentinel(dirs) else: tasks = self._create(self.album(paths, dirs)) yield from tasks # Produce the final sentinel for this toppath to indicate that # it is finished. This is usually just a SentinelImportTask, but # for archive imports, send the archive task instead (to remove # the extracted directory). if self.is_archive: yield archive_task else: yield self.sentinel() def _create(self, task): """Handle a new task to be emitted by the factory. Emit the `import_task_created` event and increment the `imported` count if the task is not skipped. Return the same task. If `task` is None, do nothing. """ if task: tasks = task.handle_created(self.session) self.imported += len(tasks) return tasks return [] def paths(self): """Walk `self.toppath` and yield `(dirs, files)` pairs where `files` are individual music files and `dirs` the set of containing directories where the music was found. This can either be a recursive search in the ordinary case, a single track when `toppath` is a file, a single directory in `flat` mode. """ if not os.path.isdir(syspath(self.toppath)): yield [self.toppath], [self.toppath] elif self.session.config['flat']: paths = [] for dirs, paths_in_dir in albums_in_dir(self.toppath): paths += paths_in_dir yield [self.toppath], paths else: for dirs, paths in albums_in_dir(self.toppath): yield dirs, paths def singleton(self, path): """Return a `SingletonImportTask` for the music file. """ if self.session.already_imported(self.toppath, [path]): log.debug('Skipping previously-imported path: {0}', displayable_path(path)) self.skipped += 1 return None item = self.read_item(path) if item: return SingletonImportTask(self.toppath, item) else: return None def album(self, paths, dirs=None): """Return a `ImportTask` with all media files from paths. `dirs` is a list of parent directories used to record already imported albums. """ if not paths: return None if dirs is None: dirs = list({os.path.dirname(p) for p in paths}) if self.session.already_imported(self.toppath, dirs): log.debug('Skipping previously-imported path: {0}', displayable_path(dirs)) self.skipped += 1 return None items = map(self.read_item, paths) items = [item for item in items if item] if items: return ImportTask(self.toppath, dirs, items) else: return None def sentinel(self, paths=None): """Return a `SentinelImportTask` indicating the end of a top-level directory import. """ return SentinelImportTask(self.toppath, paths) def unarchive(self): """Extract the archive for this `toppath`. Extract the archive to a new directory, adjust `toppath` to point to the extracted directory, and return an `ArchiveImportTask`. If extraction fails, return None. """ assert self.is_archive if not (self.session.config['move'] or self.session.config['copy']): log.warning("Archive importing requires either " "'copy' or 'move' to be enabled.") return log.debug('Extracting archive: {0}', displayable_path(self.toppath)) archive_task = ArchiveImportTask(self.toppath) try: archive_task.extract() except Exception as exc: log.error('extraction failed: {0}', exc) return # Now read albums from the extracted directory. self.toppath = archive_task.toppath log.debug('Archive extracted to: {0}', self.toppath) return archive_task def read_item(self, path): """Return an `Item` read from the path. If an item cannot be read, return `None` instead and log an error. """ try: return library.Item.from_path(path) except library.ReadError as exc: if isinstance(exc.reason, mediafile.FileTypeError): # Silently ignore non-music files. pass elif isinstance(exc.reason, mediafile.UnreadableFileError): log.warning('unreadable file: {0}', displayable_path(path)) else: log.error('error reading {0}: {1}', displayable_path(path), exc) # Pipeline utilities def _freshen_items(items): # Clear IDs from re-tagged items so they appear "fresh" when # we add them back to the library. for item in items: item.id = None item.album_id = None def _extend_pipeline(tasks, *stages): # Return pipeline extension for stages with list of tasks if type(tasks) == list: task_iter = iter(tasks) else: task_iter = tasks ipl = pipeline.Pipeline([task_iter] + list(stages)) return pipeline.multiple(ipl.pull()) # 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. """ skipped = 0 for toppath in session.paths: # Check whether we need to resume the import. session.ask_resume(toppath) # Generate tasks. task_factory = ImportTaskFactory(toppath, session) yield from task_factory.tasks() skipped += task_factory.skipped if not task_factory.imported: log.warning('No files imported from {0}', displayable_path(toppath)) # Show skipped directories (due to incremental/resume). if skipped: log.info('Skipped {0} paths.', 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 session.config['singletons']: # Search for items. for item in session.lib.items(session.query): task = SingletonImportTask(None, item) for task in task.handle_created(session): yield task else: # Search for albums. for album in session.lib.albums(session.query): log.debug('yielding album {0}: {1} - {2}', album.id, album.albumartist, album.album) items = list(album.items()) _freshen_items(items) task = ImportTask(None, [album.item_dir()], items) for task in task.handle_created(session): yield task @pipeline.mutator_stage def lookup_candidates(session, task): """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. """ if task.skip: # FIXME This gets duplicated a lot. We need a better # abstraction. return plugins.send('import_task_start', session=session, task=task) log.debug('Looking up: {0}', displayable_path(task.paths)) # Restrict the initial lookup to IDs specified by the user via the -m # option. Currently all the IDs are passed onto the tasks directly. task.search_ids = session.config['search_ids'].as_str_seq() task.lookup_candidates() @pipeline.stage def user_query(session, task): """A coroutine for interfacing with the user about the tagging process. The coroutine accepts an ImportTask objects. It uses the session's `choose_match` method to determine the `action` for this task. Depending on the action additional stages are executed and the processed task is yielded. It emits the ``import_task_choice`` event for plugins. Plugins have acces to the choice via the ``taks.choice_flag`` property and may choose to change it. """ if task.skip: return task if session.already_merged(task.paths): return pipeline.BUBBLE # Ask the user for a choice. task.choose_match(session) plugins.send('import_task_choice', session=session, task=task) # As-tracks: transition to singleton workflow. if task.choice_flag is action.TRACKS: # Set up a little pipeline for dealing with the singletons. def emitter(task): for item in task.items: task = SingletonImportTask(task.toppath, item) yield from task.handle_created(session) yield SentinelImportTask(task.toppath, task.paths) return _extend_pipeline(emitter(task), lookup_candidates(session), user_query(session)) # As albums: group items by albums and create task for each album if task.choice_flag is action.ALBUMS: return _extend_pipeline([task], group_albums(session), lookup_candidates(session), user_query(session)) resolve_duplicates(session, task) if task.should_merge_duplicates: # Create a new task for tagging the current items # and duplicates together duplicate_items = task.duplicate_items(session.lib) # Duplicates would be reimported so make them look "fresh" _freshen_items(duplicate_items) duplicate_paths = [item.path for item in duplicate_items] # Record merged paths in the session so they are not reimported session.mark_merged(duplicate_paths) merged_task = ImportTask(None, task.paths + duplicate_paths, task.items + duplicate_items) return _extend_pipeline([merged_task], lookup_candidates(session), user_query(session)) apply_choice(session, task) return task def resolve_duplicates(session, task): """Check if a task conflicts with items or albums already imported and ask the session to resolve this. """ if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG): found_duplicates = task.find_duplicates(session.lib) if found_duplicates: log.debug('found duplicates: {}'.format( [o.id for o in found_duplicates] )) # Get the default action to follow from config. duplicate_action = config['import']['duplicate_action'].as_choice({ 'skip': 's', 'keep': 'k', 'remove': 'r', 'merge': 'm', 'ask': 'a', }) log.debug('default action for duplicates: {0}', duplicate_action) if duplicate_action == 's': # Skip new. task.set_choice(action.SKIP) elif duplicate_action == 'k': # Keep both. Do nothing; leave the choice intact. pass elif duplicate_action == 'r': # Remove old. task.should_remove_duplicates = True elif duplicate_action == 'm': # Merge duplicates together task.should_merge_duplicates = True else: # No default action set; ask the session. session.resolve_duplicate(task, found_duplicates) session.log_choice(task, True) @pipeline.mutator_stage def import_asis(session, task): """Select the `action.ASIS` choice for all tasks. This stage replaces the initial_lookup and user_query stages when the importer is run without autotagging. """ if task.skip: return log.info('{}', displayable_path(task.paths)) task.set_choice(action.ASIS) apply_choice(session, task) def apply_choice(session, task): """Apply the task's choice to the Album or Item it contains and add it to the library. """ if task.skip: return # Change metadata. if task.apply: task.apply_metadata() plugins.send('import_task_apply', session=session, task=task) task.add(session.lib) # If ``set_fields`` is set, set those fields to the # configured values. # NOTE: This cannot be done before the ``task.add()`` call above, # because then the ``ImportTask`` won't have an `album` for which # it can set the fields. if config['import']['set_fields']: task.set_fields(session.lib) @pipeline.mutator_stage def plugin_stage(session, func, task): """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. """ if task.skip: return func(session, task) # Stage may modify DB, so re-load cached item data. # FIXME Importer plugins should not modify the database but instead # the albums and items attached to tasks. task.reload() @pipeline.stage def manipulate_files(session, task): """A coroutine (pipeline stage) that performs necessary file manipulations *after* items have been added to the library and finalizes each task. """ if not task.skip: if task.should_remove_duplicates: task.remove_duplicates(session.lib) if session.config['move']: operation = MoveOperation.MOVE elif session.config['copy']: operation = MoveOperation.COPY elif session.config['link']: operation = MoveOperation.LINK elif session.config['hardlink']: operation = MoveOperation.HARDLINK elif session.config['reflink']: operation = MoveOperation.REFLINK else: operation = None task.manipulate_files( operation, write=session.config['write'], session=session, ) # Progress, cleanup, and event. task.finalize(session) @pipeline.stage def log_files(session, task): """A coroutine (pipeline stage) to log each file to be imported. """ if isinstance(task, SingletonImportTask): log.info('Singleton: {0}', displayable_path(task.item['path'])) elif task.items: log.info('Album: {0}', displayable_path(task.paths[0])) for item in task.items: log.info(' {0}', displayable_path(item['path'])) def group_albums(session): """A pipeline stage that groups the items of each task into albums using their metadata. Groups are identified using their artist and album fields. The pipeline stage emits new album tasks for each discovered group. """ def group(item): return (item.albumartist or item.artist, item.album) task = None while True: task = yield task if task.skip: continue tasks = [] sorted_items = sorted(task.items, key=group) for _, items in itertools.groupby(sorted_items, group): items = list(items) task = ImportTask(task.toppath, [i.path for i in items], items) tasks += task.handle_created(session) tasks.append(SentinelImportTask(task.toppath, task.paths)) task = pipeline.multiple(tasks) MULTIDISC_MARKERS = (br'dis[ck]', br'cd') MULTIDISC_PAT_FMT = br'^(.*%s[\W_]*)\d' def is_subdir_of_any_in_list(path, dirs): """Returns True if path os a subdirectory of any directory in dirs (a list). In other case, returns False. """ ancestors = ancestry(path) return any(d in ancestors for d in dirs) 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 ignore = config['ignore'].as_str_seq() ignore_hidden = config['ignore_hidden'].get(bool) for root, dirs, files in sorted_walk(path, ignore=ignore, ignore_hidden=ignore_hidden, logger=log): items = [os.path.join(root, f) for f in files] # 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 (is_subdir_of_any_in_list(root, collapse_paths)) 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: # We're using replace on %s due to lack of .format() on bytestrings p = MULTIDISC_PAT_FMT.replace(b'%s', marker) marker_pat = re.compile(p, 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: subdir = util.bytestring_path(subdir) # The first directory dictates the pattern for # the remaining directories. if not subdir_pat: match = marker_pat.match(subdir) if match: match_group = re.escape(match.group(1)) subdir_pat = re.compile( b''.join([b'^', match_group, br'\d']), 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( b''.join([b'^', re.escape(match.group(1)), br'\d']), 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beets/library.py0000644000076500000240000016330400000000000015454 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """The core data store and collection logic for beets. """ import os import sys import unicodedata import time import re import string import shlex from beets import logging from mediafile import MediaFile, UnreadableFileError from beets import plugins from beets import util from beets.util import bytestring_path, syspath, normpath, samefile, \ MoveOperation, lazy_property from beets.util.functemplate import template, Template from beets import dbcore from beets.dbcore import types import beets # To use the SQLite "blob" type, it doesn't suffice to provide a byte # string; SQLite treats that as encoded text. Wrapping it in a # `memoryview` tells it that we actually mean non-text data. BLOB_TYPE = memoryview log = logging.getLogger('beets') # Library-specific query types. class PathQuery(dbcore.FieldQuery): """A query that matches all items under a given path. Matching can either be case-insensitive or case-sensitive. By default, the behavior depends on the OS: case-insensitive on Windows and case-sensitive otherwise. """ def __init__(self, field, pattern, fast=True, case_sensitive=None): """Create a path query. `pattern` must be a path, either to a file or a directory. `case_sensitive` can be a bool or `None`, indicating that the behavior should depend on the filesystem. """ super().__init__(field, pattern, fast) # By default, the case sensitivity depends on the filesystem # that the query path is located on. if case_sensitive is None: path = util.bytestring_path(util.normpath(pattern)) case_sensitive = beets.util.case_sensitive(path) self.case_sensitive = case_sensitive # Use a normalized-case pattern for case-insensitive matches. if not case_sensitive: pattern = pattern.lower() # Match the path as a single file. self.file_path = util.bytestring_path(util.normpath(pattern)) # As a directory (prefix). self.dir_path = util.bytestring_path(os.path.join(self.file_path, b'')) @classmethod def is_path_query(cls, query_part): """Try to guess whether a unicode query part is a path query. Condition: separator precedes colon and the file exists. """ colon = query_part.find(':') if colon != -1: query_part = query_part[:colon] # Test both `sep` and `altsep` (i.e., both slash and backslash on # Windows). return ( (os.sep in query_part or (os.altsep and os.altsep in query_part)) and os.path.exists(syspath(normpath(query_part))) ) def match(self, item): path = item.path if self.case_sensitive else item.path.lower() return (path == self.file_path) or path.startswith(self.dir_path) def col_clause(self): file_blob = BLOB_TYPE(self.file_path) dir_blob = BLOB_TYPE(self.dir_path) if self.case_sensitive: query_part = '({0} = ?) || (substr({0}, 1, ?) = ?)' else: query_part = '(BYTELOWER({0}) = BYTELOWER(?)) || \ (substr(BYTELOWER({0}), 1, ?) = BYTELOWER(?))' return query_part.format(self.field), \ (file_blob, len(dir_blob), dir_blob) # Library-specific field types. class DateType(types.Float): # TODO representation should be `datetime` object # TODO distinguish between date and time types query = dbcore.query.DateQuery def format(self, value): return time.strftime(beets.config['time_format'].as_str(), time.localtime(value or 0)) def parse(self, string): try: # Try a formatted date string. return time.mktime( time.strptime(string, beets.config['time_format'].as_str()) ) except ValueError: # Fall back to a plain timestamp number. try: return float(string) except ValueError: return self.null class PathType(types.Type): """A dbcore type for filesystem paths. These are represented as `bytes` objects, in keeping with the Unix filesystem abstraction. """ sql = 'BLOB' query = PathQuery model_type = bytes def __init__(self, nullable=False): """Create a path type object. `nullable` controls whether the type may be missing, i.e., None. """ self.nullable = nullable @property def null(self): if self.nullable: return None else: return b'' def format(self, value): return util.displayable_path(value) def parse(self, string): return normpath(bytestring_path(string)) def normalize(self, value): if isinstance(value, str): # Paths stored internally as encoded bytes. return bytestring_path(value) elif isinstance(value, BLOB_TYPE): # We unwrap buffers to bytes. return bytes(value) else: return value def from_sql(self, sql_value): return self.normalize(sql_value) def to_sql(self, value): if isinstance(value, bytes): value = BLOB_TYPE(value) return value class MusicalKey(types.String): """String representing the musical key of a song. The standard format is C, Cm, C#, C#m, etc. """ ENHARMONIC = { r'db': 'c#', r'eb': 'd#', r'gb': 'f#', r'ab': 'g#', r'bb': 'a#', } null = None def parse(self, key): key = key.lower() for flat, sharp in self.ENHARMONIC.items(): key = re.sub(flat, sharp, key) key = re.sub(r'[\W\s]+minor', 'm', key) key = re.sub(r'[\W\s]+major', '', key) return key.capitalize() def normalize(self, key): if key is None: return None else: return self.parse(key) class DurationType(types.Float): """Human-friendly (M:SS) representation of a time interval.""" query = dbcore.query.DurationQuery def format(self, value): if not beets.config['format_raw_length'].get(bool): return beets.ui.human_seconds_short(value or 0.0) else: return value def parse(self, string): try: # Try to format back hh:ss to seconds. return util.raw_seconds_short(string) except ValueError: # Fall back to a plain float. try: return float(string) except ValueError: return self.null # Library-specific sort types. class SmartArtistSort(dbcore.query.Sort): """Sort by artist (either album artist or track artist), prioritizing the sort field over the raw field. """ def __init__(self, model_cls, ascending=True, case_insensitive=True): self.album = model_cls is Album self.ascending = ascending self.case_insensitive = case_insensitive def order_clause(self): order = "ASC" if self.ascending else "DESC" field = 'albumartist' if self.album else 'artist' collate = 'COLLATE NOCASE' if self.case_insensitive else '' return ('(CASE {0}_sort WHEN NULL THEN {0} ' 'WHEN "" THEN {0} ' 'ELSE {0}_sort END) {1} {2}').format(field, collate, order) def sort(self, objs): if self.album: def field(a): return a.albumartist_sort or a.albumartist else: def field(i): return i.artist_sort or i.artist if self.case_insensitive: def key(x): return field(x).lower() else: key = field return sorted(objs, key=key, reverse=not self.ascending) # Special path format key. PF_KEY_DEFAULT = 'default' # Exceptions. class FileOperationError(Exception): """Indicates an error when interacting with a file on disk. Possibilities include an unsupported media type, a permissions error, and an unhandled Mutagen exception. """ def __init__(self, path, reason): """Create an exception describing an operation on the file at `path` with the underlying (chained) exception `reason`. """ super().__init__(path, reason) self.path = path self.reason = reason def text(self): """Get a string representing the error. Describes both the underlying reason and the file path in question. """ return '{}: {}'.format( util.displayable_path(self.path), str(self.reason) ) # define __str__ as text to avoid infinite loop on super() calls # with @six.python_2_unicode_compatible __str__ = text class ReadError(FileOperationError): """An error while reading a file (i.e. in `Item.read`). """ def __str__(self): return 'error reading ' + super().text() class WriteError(FileOperationError): """An error while writing a file (i.e. in `Item.write`). """ def __str__(self): return 'error writing ' + super().text() # Item and Album model classes. class LibModel(dbcore.Model): """Shared concrete functionality for Items and Albums. """ _format_config_key = None """Config key that specifies how an instance should be formatted. """ def _template_funcs(self): funcs = DefaultTemplateFunctions(self, self._db).functions() funcs.update(plugins.template_funcs()) return funcs def store(self, fields=None): super().store(fields) plugins.send('database_change', lib=self._db, model=self) def remove(self): super().remove() plugins.send('database_change', lib=self._db, model=self) def add(self, lib=None): super().add(lib) plugins.send('database_change', lib=self._db, model=self) def __format__(self, spec): if not spec: spec = beets.config[self._format_config_key].as_str() assert isinstance(spec, str) return self.evaluate_template(spec) def __str__(self): return format(self) def __bytes__(self): return self.__str__().encode('utf-8') class FormattedItemMapping(dbcore.db.FormattedMapping): """Add lookup for album-level fields. Album-level fields take precedence if `for_path` is true. """ ALL_KEYS = '*' def __init__(self, item, included_keys=ALL_KEYS, for_path=False): # We treat album and item keys specially here, # so exclude transitive album keys from the model's keys. super().__init__(item, included_keys=[], for_path=for_path) self.included_keys = included_keys if included_keys == self.ALL_KEYS: # Performance note: this triggers a database query. self.model_keys = item.keys(computed=True, with_album=False) else: self.model_keys = included_keys self.item = item @lazy_property def all_keys(self): return set(self.model_keys).union(self.album_keys) @lazy_property def album_keys(self): album_keys = [] if self.album: if self.included_keys == self.ALL_KEYS: # Performance note: this triggers a database query. for key in self.album.keys(computed=True): if key in Album.item_keys \ or key not in self.item._fields.keys(): album_keys.append(key) else: album_keys = self.included_keys return album_keys @property def album(self): return self.item._cached_album def _get(self, key): """Get the value for a key, either from the album or the item. Raise a KeyError for invalid keys. """ if self.for_path and key in self.album_keys: return self._get_formatted(self.album, key) elif key in self.model_keys: return self._get_formatted(self.model, key) elif key in self.album_keys: return self._get_formatted(self.album, key) else: raise KeyError(key) def __getitem__(self, key): """Get the value for a key. `artist` and `albumartist` are fallback values for each other when not set. """ value = self._get(key) # `artist` and `albumartist` fields fall back to one another. # This is helpful in path formats when the album artist is unset # on as-is imports. try: if key == 'artist' and not value: return self._get('albumartist') elif key == 'albumartist' and not value: return self._get('artist') except KeyError: pass return value def __iter__(self): return iter(self.all_keys) def __len__(self): return len(self.all_keys) class Item(LibModel): _table = 'items' _flex_table = 'item_attributes' _fields = { 'id': types.PRIMARY_ID, 'path': PathType(), 'album_id': types.FOREIGN_ID, 'title': types.STRING, 'artist': types.STRING, 'artist_sort': types.STRING, 'artist_credit': types.STRING, 'album': types.STRING, 'albumartist': types.STRING, 'albumartist_sort': types.STRING, 'albumartist_credit': types.STRING, 'genre': types.STRING, 'style': types.STRING, 'discogs_albumid': types.INTEGER, 'discogs_artistid': types.INTEGER, 'discogs_labelid': types.INTEGER, 'lyricist': types.STRING, 'composer': types.STRING, 'composer_sort': types.STRING, 'work': types.STRING, 'mb_workid': types.STRING, 'work_disambig': types.STRING, 'arranger': types.STRING, 'grouping': types.STRING, 'year': types.PaddedInt(4), 'month': types.PaddedInt(2), 'day': types.PaddedInt(2), 'track': types.PaddedInt(2), 'tracktotal': types.PaddedInt(2), 'disc': types.PaddedInt(2), 'disctotal': types.PaddedInt(2), 'lyrics': types.STRING, 'comments': types.STRING, 'bpm': types.INTEGER, 'comp': types.BOOLEAN, 'mb_trackid': types.STRING, 'mb_albumid': types.STRING, 'mb_artistid': types.STRING, 'mb_albumartistid': types.STRING, 'mb_releasetrackid': types.STRING, 'trackdisambig': types.STRING, 'albumtype': types.STRING, 'albumtypes': types.STRING, 'label': types.STRING, 'acoustid_fingerprint': types.STRING, 'acoustid_id': types.STRING, 'mb_releasegroupid': types.STRING, 'asin': types.STRING, 'isrc': types.STRING, 'catalognum': types.STRING, 'script': types.STRING, 'language': types.STRING, 'country': types.STRING, 'albumstatus': types.STRING, 'media': types.STRING, 'albumdisambig': types.STRING, 'releasegroupdisambig': types.STRING, 'disctitle': types.STRING, 'encoder': types.STRING, 'rg_track_gain': types.NULL_FLOAT, 'rg_track_peak': types.NULL_FLOAT, 'rg_album_gain': types.NULL_FLOAT, 'rg_album_peak': types.NULL_FLOAT, 'r128_track_gain': types.NullPaddedInt(6), 'r128_album_gain': types.NullPaddedInt(6), 'original_year': types.PaddedInt(4), 'original_month': types.PaddedInt(2), 'original_day': types.PaddedInt(2), 'initial_key': MusicalKey(), 'length': DurationType(), 'bitrate': types.ScaledInt(1000, 'kbps'), 'format': types.STRING, 'samplerate': types.ScaledInt(1000, 'kHz'), 'bitdepth': types.INTEGER, 'channels': types.INTEGER, 'mtime': DateType(), 'added': DateType(), } _search_fields = ('artist', 'title', 'comments', 'album', 'albumartist', 'genre') _types = { 'data_source': types.STRING, } _media_fields = set(MediaFile.readable_fields()) \ .intersection(_fields.keys()) """Set of item fields that are backed by `MediaFile` fields. Any kind of field (fixed, flexible, and computed) may be a media field. Only these fields are read from disk in `read` and written in `write`. """ _media_tag_fields = set(MediaFile.fields()).intersection(_fields.keys()) """Set of item fields that are backed by *writable* `MediaFile` tag fields. This excludes fields that represent audio data, such as `bitrate` or `length`. """ _formatter = FormattedItemMapping _sorts = {'artist': SmartArtistSort} _format_config_key = 'format_item' __album = None """Cached album object. Read-only.""" @property def _cached_album(self): """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. The instance is cached and refreshed on access. DO NOT MODIFY! If you want a copy to modify, use :meth:`get_album`. """ if not self.__album and self._db: self.__album = self._db.get_album(self) elif self.__album: self.__album.load() return self.__album @_cached_album.setter def _cached_album(self, album): self.__album = album @classmethod def _getters(cls): getters = plugins.item_field_getters() getters['singleton'] = lambda i: i.album_id is None getters['filesize'] = Item.try_filesize # In bytes. return getters @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, str): value = bytestring_path(value) elif isinstance(value, BLOB_TYPE): value = bytes(value) elif key == 'album_id': self._cached_album = None changed = super()._setitem(key, value) if changed and key in MediaFile.fields(): self.mtime = 0 # Reset mtime on dirty. def __getitem__(self, key): """Get the value for a field, falling back to the album if necessary. Raise a KeyError if the field is not available. """ try: return super().__getitem__(key) except KeyError: if self._cached_album: return self._cached_album[key] raise def __repr__(self): # This must not use `with_album=True`, because that might access # the database. When debugging, that is not guaranteed to succeed, and # can even deadlock due to the database lock. return '{}({})'.format( type(self).__name__, ', '.join('{}={!r}'.format(k, self[k]) for k in self.keys(with_album=False)), ) def keys(self, computed=False, with_album=True): """Get a list of available field names. `with_album` controls whether the album's fields are included. """ keys = super().keys(computed=computed) if with_album and self._cached_album: keys = set(keys) keys.update(self._cached_album.keys(computed=computed)) keys = list(keys) return keys def get(self, key, default=None, with_album=True): """Get the value for a given key or `default` if it does not exist. Set `with_album` to false to skip album fallback. """ try: return self._get(key, default, raise_=with_album) except KeyError: if self._cached_album: return self._cached_album.get(key, default) return default def update(self, values): """Set all key/value pairs in the mapping. If mtime is specified, it is not reset (as it might otherwise be). """ super().update(values) if self.mtime == 0 and 'mtime' in values: self.mtime = values['mtime'] def clear(self): """Set all key/value pairs to None.""" for key in self._media_tag_fields: setattr(self, key, None) 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._db: return None return self._db.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. Updates all the properties in `_media_fields` from the media file. Raises a `ReadError` if the file could not be read. """ if read_path is None: read_path = self.path else: read_path = normpath(read_path) try: mediafile = MediaFile(syspath(read_path)) except UnreadableFileError as exc: raise ReadError(read_path, exc) for key in self._media_fields: value = getattr(mediafile, key) if isinstance(value, int): if value.bit_length() > 63: value = 0 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, path=None, tags=None, id3v23=None): """Write the item's metadata to a media file. All fields in `_media_fields` are written to disk according to the values on this object. `path` is the path of the mediafile to write the data to. It defaults to the item's path. `tags` is a dictionary of additional metadata the should be written to the file. (These tags need not be in `_media_fields`.) `id3v23` will override the global `id3v23` config option if it is set to something other than `None`. Can raise either a `ReadError` or a `WriteError`. """ if path is None: path = self.path else: path = normpath(path) if id3v23 is None: id3v23 = beets.config['id3v23'].get(bool) # Get the data to write to the file. item_tags = dict(self) item_tags = {k: v for k, v in item_tags.items() if k in self._media_fields} # Only write media fields. if tags is not None: item_tags.update(tags) plugins.send('write', item=self, path=path, tags=item_tags) # Open the file. try: mediafile = MediaFile(syspath(path), id3v23=id3v23) except UnreadableFileError as exc: raise ReadError(path, exc) # Write the tags to the file. mediafile.update(item_tags) try: mediafile.save() except UnreadableFileError as exc: raise WriteError(self.path, exc) # The file has a new mtime. if path == self.path: self.mtime = self.current_mtime() plugins.send('after_write', item=self, path=path) def try_write(self, *args, **kwargs): """Calls `write()` but catches and logs `FileOperationError` exceptions. Returns `False` an exception was caught and `True` otherwise. """ try: self.write(*args, **kwargs) return True except FileOperationError as exc: log.error("{0}", exc) return False def try_sync(self, write, move, with_album=True): """Synchronize the item with the database and, possibly, updates its tags on disk and its path (by moving the file). `write` indicates whether to write new tags into the file. Similarly, `move` controls whether the path should be updated. In the latter case, files are *only* moved when they are inside their library's directory (if any). Similar to calling :meth:`write`, :meth:`move`, and :meth:`store` (conditionally). """ if write: self.try_write() if move: # Check whether this file is inside the library directory. if self._db and self._db.directory in util.ancestry(self.path): log.debug('moving {0} to synchronize path', util.displayable_path(self.path)) self.move(with_album=with_album) self.store() # Files themselves. def move_file(self, dest, operation=MoveOperation.MOVE): """Move, copy, link or hardlink the item's depending on `operation`, updating the path value if the move succeeds. If a file exists at `dest`, then it is slightly modified to be unique. `operation` should be an instance of `util.MoveOperation`. """ if not util.samefile(self.path, dest): dest = util.unique_path(dest) if operation == MoveOperation.MOVE: plugins.send("before_item_moved", item=self, source=self.path, destination=dest) util.move(self.path, dest) plugins.send("item_moved", item=self, source=self.path, destination=dest) elif operation == MoveOperation.COPY: util.copy(self.path, dest) plugins.send("item_copied", item=self, source=self.path, destination=dest) elif operation == MoveOperation.LINK: util.link(self.path, dest) plugins.send("item_linked", item=self, source=self.path, destination=dest) elif operation == MoveOperation.HARDLINK: util.hardlink(self.path, dest) plugins.send("item_hardlinked", item=self, source=self.path, destination=dest) elif operation == MoveOperation.REFLINK: util.reflink(self.path, dest, fallback=False) plugins.send("item_reflinked", item=self, source=self.path, destination=dest) elif operation == MoveOperation.REFLINK_AUTO: util.reflink(self.path, dest, fallback=True) plugins.send("item_reflinked", item=self, source=self.path, destination=dest) else: assert False, 'unknown MoveOperation' # 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))) def try_filesize(self): """Get the size of the underlying file in bytes. If the file is missing, return 0 (and log a warning). """ try: return os.path.getsize(syspath(self.path)) except (OSError, Exception) as exc: log.warning('could not get filesize: {0}', exc) return 0 # 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().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) # Send a 'item_removed' signal to plugins plugins.send('item_removed', item=self) # Delete the associated file. if delete: util.remove(self.path) util.prune_dirs(os.path.dirname(self.path), self._db.directory) self._db._memotable = {} def move(self, operation=MoveOperation.MOVE, basedir=None, with_album=True, store=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. Instead of moving the item it can also be copied, linked or hardlinked depending on `operation` which should be an instance of `util.MoveOperation`. `basedir` overrides the library base directory for the destination. If the item is in an album and `with_album` is `True`, the album is given an opportunity to move its art. By default, 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. If `store` is `False` however, the item won't be stored and you'll have to manually store it after invoking this method. """ 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, operation) if store: self.store() # If this item is in an album, move its art. if with_album: album = self.get_album() if album: album.move_art(operation) if store: album.store() # Prune vacated directory. if operation == MoveOperation.MOVE: util.prune_dirs(os.path.dirname(old_path), self._db.directory) # Templating. def destination(self, fragment=False, basedir=None, platform=None, path_formats=None, replacements=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() platform = platform or sys.platform basedir = basedir or self._db.directory path_formats = path_formats or self._db.path_formats if replacements is None: replacements = self._db.replacements # 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, _ = parse_query_string(query, type(self)) 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) # Prepare path for output: normalize Unicode characters. if platform == 'darwin': subpath = unicodedata.normalize('NFD', subpath) else: subpath = unicodedata.normalize('NFC', subpath) if beets.config['asciify_paths']: subpath = util.asciify_path( subpath, beets.config['path_sep_replace'].as_str() ) maxlen = beets.config['max_filename_length'].get(int) if not maxlen: # When zero, try to determine from filesystem. maxlen = util.max_filename_length(self._db.directory) subpath, fellback = util.legalize_path( subpath, replacements, maxlen, os.path.splitext(self.path)[1], fragment ) if fellback: # Print an error message if legalization fell back to # default replacements because of the maximum length. log.warning( 'Fell back to default replacements when naming ' 'file {}. Configure replacements to avoid lengthening ' 'the filename.', subpath ) if fragment: return util.as_string(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. """ _table = 'albums' _flex_table = 'album_attributes' _always_dirty = True _fields = { 'id': types.PRIMARY_ID, 'artpath': PathType(True), 'added': DateType(), 'albumartist': types.STRING, 'albumartist_sort': types.STRING, 'albumartist_credit': types.STRING, 'album': types.STRING, 'genre': types.STRING, 'style': types.STRING, 'discogs_albumid': types.INTEGER, 'discogs_artistid': types.INTEGER, 'discogs_labelid': types.INTEGER, 'year': types.PaddedInt(4), 'month': types.PaddedInt(2), 'day': types.PaddedInt(2), 'disctotal': types.PaddedInt(2), 'comp': types.BOOLEAN, 'mb_albumid': types.STRING, 'mb_albumartistid': types.STRING, 'albumtype': types.STRING, 'albumtypes': types.STRING, 'label': types.STRING, 'mb_releasegroupid': types.STRING, 'asin': types.STRING, 'catalognum': types.STRING, 'script': types.STRING, 'language': types.STRING, 'country': types.STRING, 'albumstatus': types.STRING, 'albumdisambig': types.STRING, 'releasegroupdisambig': types.STRING, 'rg_album_gain': types.NULL_FLOAT, 'rg_album_peak': types.NULL_FLOAT, 'r128_album_gain': types.NullPaddedInt(6), 'original_year': types.PaddedInt(4), 'original_month': types.PaddedInt(2), 'original_day': types.PaddedInt(2), } _search_fields = ('album', 'albumartist', 'genre') _types = { 'path': PathType(), 'data_source': types.STRING, } _sorts = { 'albumartist': SmartArtistSort, 'artist': SmartArtistSort, } item_keys = [ 'added', 'albumartist', 'albumartist_sort', 'albumartist_credit', 'album', 'genre', 'style', 'discogs_albumid', 'discogs_artistid', 'discogs_labelid', 'year', 'month', 'day', 'disctotal', 'comp', 'mb_albumid', 'mb_albumartistid', 'albumtype', 'albumtypes', 'label', 'mb_releasegroupid', 'asin', 'catalognum', 'script', 'language', 'country', 'albumstatus', 'albumdisambig', 'releasegroupdisambig', 'rg_album_gain', 'rg_album_peak', 'r128_album_gain', 'original_year', 'original_month', 'original_day', ] """List of keys that are set on an album's items. """ _format_config_key = 'format_album' @classmethod def _getters(cls): # In addition to plugin-provided computed fields, also expose # the album's directory as `path`. getters = plugins.album_field_getters() getters['path'] = Album.item_dir getters['albumtotal'] = Album._albumtotal return getters def items(self): """Returns an iterable over the items associated with this album. """ return self._db.items(dbcore.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().remove() # Send a 'album_removed' signal to plugins plugins.send('album_removed', album=self) # 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, operation=MoveOperation.MOVE): """Move, copy, link or hardlink (depending on `operation`) any existing album art so that it remains in the same directory as the items. `operation` should be an instance of `util.MoveOperation`. """ old_art = self.artpath if not old_art: return if not os.path.exists(old_art): log.error('removing reference to missing album art file {}', util.displayable_path(old_art)) self.artpath = None 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 {0} to {1}', util.displayable_path(old_art), util.displayable_path(new_art)) if operation == MoveOperation.MOVE: util.move(old_art, new_art) util.prune_dirs(os.path.dirname(old_art), self._db.directory) elif operation == MoveOperation.COPY: util.copy(old_art, new_art) elif operation == MoveOperation.LINK: util.link(old_art, new_art) elif operation == MoveOperation.HARDLINK: util.hardlink(old_art, new_art) elif operation == MoveOperation.REFLINK: util.reflink(old_art, new_art, fallback=False) elif operation == MoveOperation.REFLINK_AUTO: util.reflink(old_art, new_art, fallback=True) else: assert False, 'unknown MoveOperation' self.artpath = new_art def move(self, operation=MoveOperation.MOVE, basedir=None, store=True): """Move, copy, link or hardlink (depending on `operation`) all items to their destination. Any album art moves along with them. `basedir` overrides the library base directory for the destination. `operation` should be an instance of `util.MoveOperation`. By default, the album is stored to the database, persisting any modifications to its metadata. If `store` is `False` however, the album is not stored automatically, and you'll have to manually store it after invoking this method. """ basedir = basedir or self._db.directory # Ensure new metadata is available to items for destination # computation. if store: self.store() # Move items. items = list(self.items()) for item in items: item.move(operation, basedir=basedir, with_album=False, store=store) # Move art. self.move_art(operation) if store: 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 for album id %d' % self.id) return os.path.dirname(item.path) def _albumtotal(self): """Return the total number of tracks on all discs on the album """ if self.disctotal == 1 or not beets.config['per_disc_numbering']: return self.items()[0].tracktotal counted = [] total = 0 for item in self.items(): if item.disc in counted: continue total += item.tracktotal counted.append(item.disc) if len(counted) == self.disctotal: break return total 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'].as_str()) subpath = self.evaluate_template(filename_tmpl, True) if beets.config['asciify_paths']: subpath = util.asciify_path( subpath, beets.config['path_sep_replace'].as_str() ) subpath = util.sanitize_path(subpath, replacements=self._db.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. Sends an 'art_set' event with `self` as the sole argument. """ 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 plugins.send('art_set', album=self) def store(self, fields=None): """Update the database with the album information. The album's tracks are also updated. :param fields: The fields to be stored. If not specified, all fields will be. """ # Get modified track fields. track_updates = {} for key in self.item_keys: if key in self._dirty: track_updates[key] = self[key] with self._db.transaction(): super().store(fields) if track_updates: for item in self.items(): for key, value in track_updates.items(): item[key] = value item.store() def try_sync(self, write, move): """Synchronize the album and its items with the database. Optionally, also write any new tags into the files and update their paths. `write` indicates whether to write tags to the item files, and `move` controls whether files (both audio and album art) are moved. """ self.store() for item in self.items(): item.try_sync(write, move) # Query construction helpers. def parse_query_parts(parts, model_cls): """Given a beets query string as a list of components, return the `Query` and `Sort` they represent. Like `dbcore.parse_sorted_query`, with beets query prefixes and special path query detection. """ # Get query types and their prefix characters. prefixes = {':': dbcore.query.RegexpQuery} prefixes.update(plugins.queries()) # Special-case path-like queries, which are non-field queries # containing path separators (/). path_parts = [] non_path_parts = [] for s in parts: if PathQuery.is_path_query(s): path_parts.append(s) else: non_path_parts.append(s) case_insensitive = beets.config['sort_case_insensitive'].get(bool) query, sort = dbcore.parse_sorted_query( model_cls, non_path_parts, prefixes, case_insensitive ) # Add path queries to aggregate query. # Match field / flexattr depending on whether the model has the path field fast_path_query = 'path' in model_cls._fields query.subqueries += [PathQuery('path', s, fast_path_query) for s in path_parts] return query, sort def parse_query_string(s, model_cls): """Given a beets query string, return the `Query` and `Sort` they represent. The string is split into components using shell-like syntax. """ message = f"Query is not unicode: {s!r}" assert isinstance(s, str), message try: parts = shlex.split(s) except ValueError as exc: raise dbcore.InvalidQueryError(s, exc) return parse_query_parts(parts, model_cls) def _sqlite_bytelower(bytestring): """ A custom ``bytelower`` sqlite function so we can compare bytestrings in a semi case insensitive fashion. This is to work around sqlite builds are that compiled with ``-DSQLITE_LIKE_DOESNT_MATCH_BLOBS``. See ``https://github.com/beetbox/beets/issues/2172`` for details. """ return bytestring.lower() # The Library: interface to the database. class Library(dbcore.Database): """A database of music containing songs and albums. """ _models = (Item, Album) def __init__(self, path='library.blb', directory='~/Music', path_formats=((PF_KEY_DEFAULT, '$artist/$album/$track $title'),), replacements=None): timeout = beets.config['timeout'].as_number() super().__init__(path, timeout=timeout) self.directory = bytestring_path(normpath(directory)) self.path_formats = path_formats self.replacements = replacements self._memotable = {} # Used for template substitution performance. def _create_connection(self): conn = super()._create_connection() conn.create_function('bytelower', 1, _sqlite_bytelower) return conn # Adding objects to the database. def add(self, obj): """Add the :class:`Item` or :class:`Album` object to the library database. Return the object's new id. """ obj.add(self) self._memotable = {} return obj.id def add_album(self, items): """Create a new album consisting of a list of items. The items are added to the database if they don't yet have an ID. Return a new :class:`Album` object. The list items must not be empty. """ if not items: raise ValueError('need at least one item') # Create the album structure using metadata from the first item. values = {key: items[0][key] for key in Album.item_keys} album = Album(self, **values) # Add the album structure and set the items' album_id fields. # Store or add the items. with self.transaction(): album.add(self) for item in items: item.album_id = album.id if item.id is None: item.add(self) else: item.store() return album # Querying. def _fetch(self, model_cls, query, sort=None): """Parse a query and fetch. If a order specification is present in the query string the `sort` argument is ignored. """ # Parse the query, if necessary. try: parsed_sort = None if isinstance(query, str): query, parsed_sort = parse_query_string(query, model_cls) elif isinstance(query, (list, tuple)): query, parsed_sort = parse_query_parts(query, model_cls) except dbcore.query.InvalidQueryArgumentValueError as exc: raise dbcore.InvalidQueryError(query, exc) # Any non-null sort specified by the parsed query overrides the # provided sort. if parsed_sort and not isinstance(parsed_sort, dbcore.query.NullSort): sort = parsed_sort return super()._fetch( model_cls, query, sort ) @staticmethod def get_default_album_sort(): """Get a :class:`Sort` object for albums from the config option. """ return dbcore.sort_from_strings( Album, beets.config['sort_album'].as_str_seq()) @staticmethod def get_default_item_sort(): """Get a :class:`Sort` object for items from the config option. """ return dbcore.sort_from_strings( Item, beets.config['sort_item'].as_str_seq()) def albums(self, query=None, sort=None): """Get :class:`Album` objects matching the query. """ return self._fetch(Album, query, sort or self.get_default_album_sort()) def items(self, query=None, sort=None): """Get :class:`Item` objects matching the query. """ return self._fetch(Item, query, sort or self.get_default_item_sort()) # Convenience accessors. def get_item(self, id): """Fetch an :class:`Item` by its ID. Returns `None` if no match is found. """ return self._get(Item, id) def get_album(self, item_or_id): """Given an album ID or an item associated with an album, return an :class:`Album` object for the album. If no such album exists, returns `None`. """ if isinstance(item_or_id, int): album_id = item_or_id else: album_id = item_or_id.album_id if album_id is None: return None return self._get(Album, album_id) # Default path template resources. def _int_arg(s): """Convert a string argument to an integer for use in a template function. May raise a ValueError. """ return int(s.strip()) class DefaultTemplateFunctions: """A container class for the default functions provided to path templates. These functions are contained in an object to provide additional context to the functions -- specifically, the Item being evaluated. """ _prefix = 'tmpl_' def __init__(self, item=None, lib=None): """Parametrize the functions. If `item` or `lib` is None, then some functions (namely, ``aunique``) will always evaluate to the empty string. """ self.item = item self.lib = lib def functions(self): """Returns a dictionary containing the functions defined in this object. The keys are function names (as exposed in templates) and the values are Python functions. """ out = {} for key in self._func_names: out[key[len(self._prefix):]] = getattr(self, key) return out @staticmethod def tmpl_lower(s): """Convert a string to lower case.""" return s.lower() @staticmethod def tmpl_upper(s): """Covert a string to upper case.""" return s.upper() @staticmethod def tmpl_title(s): """Convert a string to title case.""" return string.capwords(s) @staticmethod def tmpl_left(s, chars): """Get the leftmost characters of a string.""" return s[0:_int_arg(chars)] @staticmethod def tmpl_right(s, chars): """Get the rightmost characters of a string.""" return s[-_int_arg(chars):] @staticmethod def tmpl_if(condition, trueval, falseval=''): """If ``condition`` is nonempty and nonzero, emit ``trueval``; otherwise, emit ``falseval`` (if provided). """ try: int_condition = _int_arg(condition) except ValueError: if condition.lower() == "false": return falseval else: condition = int_condition if condition: return trueval else: return falseval @staticmethod def tmpl_asciify(s): """Translate non-ASCII characters to their ASCII equivalents. """ return util.asciify_path(s, beets.config['path_sep_replace'].as_str()) @staticmethod def tmpl_time(s, fmt): """Format a time value using `strftime`. """ cur_fmt = beets.config['time_format'].as_str() return time.strftime(fmt, time.strptime(s, cur_fmt)) def tmpl_aunique(self, keys=None, disam=None, bracket=None): """Generate a string that is guaranteed to be unique among all albums in the library who share the same set of keys. A fields from "disam" is used in the string if one is sufficient to disambiguate the albums. Otherwise, a fallback opaque value is used. Both "keys" and "disam" should be given as whitespace-separated lists of field names, while "bracket" is a pair of characters to be used as brackets surrounding the disambiguator or empty to have no brackets. """ # Fast paths: no album, no item or library, or memoized value. if not self.item or not self.lib: return '' if isinstance(self.item, Item): album_id = self.item.album_id elif isinstance(self.item, Album): album_id = self.item.id if album_id is None: return '' memokey = ('aunique', keys, disam, album_id) memoval = self.lib._memotable.get(memokey) if memoval is not None: return memoval keys = keys or beets.config['aunique']['keys'].as_str() disam = disam or beets.config['aunique']['disambiguators'].as_str() if bracket is None: bracket = beets.config['aunique']['bracket'].as_str() keys = keys.split() disam = disam.split() # Assign a left and right bracket or leave blank if argument is empty. if len(bracket) == 2: bracket_l = bracket[0] bracket_r = bracket[1] else: bracket_l = '' bracket_r = '' album = self.lib.get_album(album_id) if not album: # Do nothing for singletons. self.lib._memotable[memokey] = '' return '' # Find matching albums to disambiguate with. subqueries = [] for key in keys: value = album.get(key, '') # Use slow queries for flexible attributes. fast = key in album.item_keys subqueries.append(dbcore.MatchQuery(key, value, fast)) albums = self.lib.albums(dbcore.AndQuery(subqueries)) # If there's only one album to matching these details, then do # nothing. if len(albums) == 1: self.lib._memotable[memokey] = '' return '' # Find the first disambiguator that distinguishes the albums. for disambiguator in disam: # Get the value for each album for the current field. disam_values = {a.get(disambiguator, '') for a in albums} # If the set of unique values is equal to the number of # albums in the disambiguation set, we're done -- this is # sufficient disambiguation. if len(disam_values) == len(albums): break else: # No disambiguator distinguished all fields. res = f' {bracket_l}{album.id}{bracket_r}' self.lib._memotable[memokey] = res return res # Flatten disambiguation value into a string. disam_value = album.formatted(for_path=True).get(disambiguator) # Return empty string if disambiguator is empty. if disam_value: res = f' {bracket_l}{disam_value}{bracket_r}' else: res = '' self.lib._memotable[memokey] = res return res @staticmethod def tmpl_first(s, count=1, skip=0, sep='; ', join_str='; '): """ Gets the item(s) from x to y in a string separated by something and join then with something :param s: the string :param count: The number of items included :param skip: The number of items skipped :param sep: the separator. Usually is '; ' (default) or '/ ' :param join_str: the string which will join the items, default '; '. """ skip = int(skip) count = skip + int(count) return join_str.join(s.split(sep)[skip:count]) def tmpl_ifdef(self, field, trueval='', falseval=''): """ If field exists return trueval or the field (default) otherwise, emit return falseval (if provided). :param field: The name of the field :param trueval: The string if the condition is true :param falseval: The string if the condition is false :return: The string, based on condition """ if field in self.item: return trueval if trueval else self.item.formatted().get(field) else: return falseval # Get the name of tmpl_* functions in the above class. DefaultTemplateFunctions._func_names = \ [s for s in dir(DefaultTemplateFunctions) if s.startswith(DefaultTemplateFunctions._prefix)] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/logging.py0000644000076500000240000001010100000000000015420 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A drop-in replacement for the standard-library `logging` module that allows {}-style log formatting on Python 2 and 3. Provides everything the "logging" module does. The only difference is that when getLogger(name) instantiates a logger that logger uses {}-style formatting. """ from copy import copy from logging import * # noqa import subprocess import threading def logsafe(val): """Coerce a potentially "problematic" value so it can be formatted in a Unicode log string. This works around a number of pitfalls when logging objects in Python 2: - Logging path names, which must be byte strings, requires conversion for output. - Some objects, including some exceptions, will crash when you call `unicode(v)` while `str(v)` works fine. CalledProcessError is an example. """ # Already Unicode. if isinstance(val, str): return val # Bytestring: needs decoding. elif isinstance(val, bytes): # Blindly convert with UTF-8. Eventually, it would be nice to # (a) only do this for paths, if they can be given a distinct # type, and (b) warn the developer if they do this for other # bytestrings. return val.decode('utf-8', 'replace') # A "problem" object: needs a workaround. elif isinstance(val, subprocess.CalledProcessError): try: return str(val) except UnicodeDecodeError: # An object with a broken __unicode__ formatter. Use __str__ # instead. return str(val).decode('utf-8', 'replace') # Other objects are used as-is so field access, etc., still works in # the format string. else: return val class StrFormatLogger(Logger): """A version of `Logger` that uses `str.format`-style formatting instead of %-style formatting. """ class _LogMessage: def __init__(self, msg, args, kwargs): self.msg = msg self.args = args self.kwargs = kwargs def __str__(self): args = [logsafe(a) for a in self.args] kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()} return self.msg.format(*args, **kwargs) def _log(self, level, msg, args, exc_info=None, extra=None, **kwargs): """Log msg.format(*args, **kwargs)""" m = self._LogMessage(msg, args, kwargs) return super()._log(level, m, (), exc_info, extra) class ThreadLocalLevelLogger(Logger): """A version of `Logger` whose level is thread-local instead of shared. """ def __init__(self, name, level=NOTSET): self._thread_level = threading.local() self.default_level = NOTSET super().__init__(name, level) @property def level(self): try: return self._thread_level.level except AttributeError: self._thread_level.level = self.default_level return self.level @level.setter def level(self, value): self._thread_level.level = value def set_global_level(self, level): """Set the level on the current thread + the default value for all threads. """ self.default_level = level self.setLevel(level) class BeetsLogger(ThreadLocalLevelLogger, StrFormatLogger): pass my_manager = copy(Logger.manager) my_manager.loggerClass = BeetsLogger def getLogger(name=None): # noqa if name: return my_manager.getLogger(name) else: return Logger.root ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/mediafile.py0000644000076500000240000000170300000000000015721 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import mediafile import warnings warnings.warn("beets.mediafile is deprecated; use mediafile instead") # Import everything from the mediafile module into this module. for key, value in mediafile.__dict__.items(): if key not in ['__name__']: globals()[key] = value del key, value, warnings, mediafile ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/plugins.py0000644000076500000240000006061500000000000015472 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Support for beets plugins.""" import traceback import re import inspect import abc from collections import defaultdict from functools import wraps import beets from beets import logging 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') class PluginConflictException(Exception): """Indicates that the services provided by one plugin conflict with those of another. For example two plugins may define different types for flexible fields. """ class PluginLogFilter(logging.Filter): """A logging filter that identifies the plugin that emitted a log message. """ def __init__(self, plugin): self.prefix = f'{plugin.name}: ' def filter(self, record): if hasattr(record.msg, 'msg') and isinstance(record.msg.msg, str): # A _LogMessage from our hacked-up Logging replacement. record.msg.msg = self.prefix + record.msg.msg elif isinstance(record.msg, str): record.msg = self.prefix + record.msg return True # Managing the plugins themselves. class BeetsPlugin: """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. """ 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 = {} self.early_import_stages = [] self.import_stages = [] self._log = log.getChild(self.name) self._log.setLevel(logging.NOTSET) # Use `beets` logger level. if not any(isinstance(f, PluginLogFilter) for f in self._log.filters): self._log.addFilter(PluginLogFilter(self)) def commands(self): """Should return a list of beets.ui.Subcommand objects for commands that should be added to beets' CLI. """ return () def _set_stage_log_level(self, stages): """Adjust all the stages in `stages` to WARNING logging level. """ return [self._set_log_level_and_params(logging.WARNING, stage) for stage in stages] def get_early_import_stages(self): """Return a list of functions that should be called as importer pipelines stages early in the pipeline. The callables are wrapped versions of the functions in `self.early_import_stages`. Wrapping provides some bookkeeping for the plugin: specifically, the logging level is adjusted to WARNING. """ return self._set_stage_log_level(self.early_import_stages) def get_import_stages(self): """Return a list of functions that should be called as importer pipelines stages. The callables are wrapped versions of the functions in `self.import_stages`. Wrapping provides some bookkeeping for the plugin: specifically, the logging level is adjusted to WARNING. """ return self._set_stage_log_level(self.import_stages) def _set_log_level_and_params(self, base_log_level, func): """Wrap `func` to temporarily set this plugin's logger level to `base_log_level` + config options (and restore it to its previous value after the function returns). Also determines which params may not be sent for backwards-compatibility. """ argspec = inspect.getfullargspec(func) @wraps(func) def wrapper(*args, **kwargs): assert self._log.level == logging.NOTSET verbosity = beets.config['verbose'].get(int) log_level = max(logging.DEBUG, base_log_level - 10 * verbosity) self._log.setLevel(log_level) if argspec.varkw is None: kwargs = {k: v for k, v in kwargs.items() if k in argspec.args} try: return func(*args, **kwargs) finally: self._log.setLevel(logging.NOTSET) return wrapper def queries(self): """Should return a dict mapping prefixes to Query 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, extra_tags=None): """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 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 def add_media_field(self, name, descriptor): """Add a field that is synchronized between media files and items. When a media field is added ``item.write()`` will set the name property of the item's MediaFile to ``item[name]`` and save the changes. Similarly ``item.read()`` will set ``item[name]`` to the value of the name property of the media file. ``descriptor`` must be an instance of ``mediafile.MediaField``. """ # Defer import to prevent circular dependency from beets import library mediafile.MediaFile.add_field(name, descriptor) library.Item._media_fields.add(name) _raw_listeners = None listeners = None def register_listener(self, event, func): """Add a function as a listener for the specified event. """ wrapped_func = self._set_log_level_and_params(logging.WARNING, func) cls = self.__class__ if cls.listeners is None or cls._raw_listeners is None: cls._raw_listeners = defaultdict(list) cls.listeners = defaultdict(list) if func not in cls._raw_listeners[event]: cls._raw_listeners[event].append(func) cls.listeners[event].append(wrapped_func) 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 = set() 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 = f'{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.warning('** plugin {0} 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 and obj not in _classes: _classes.add(obj) except Exception: log.warning( '** error loading plugin {}:\n{}', name, 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. """ if _instances: # After the first call, use cached instances for performance reasons. # See https://github.com/beetbox/beets/pull/3810 return list(_instances.values()) 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 Query subclasses all loaded plugins. """ out = {} for plugin in find_plugins(): out.update(plugin.queries()) return out def types(model_cls): # Gives us `item_types` and `album_types` attr_name = f'{model_cls.__name__.lower()}_types' types = {} for plugin in find_plugins(): plugin_types = getattr(plugin, attr_name, {}) for field in plugin_types: if field in types and plugin_types[field] != types[field]: raise PluginConflictException( 'Plugin {} defines flexible field {} ' 'which has already been defined with ' 'another type.'.format(plugin.name, field) ) types.update(plugin_types) return types def named_queries(model_cls): # Gather `item_queries` and `album_queries` from the plugins. attr_name = f'{model_cls.__name__.lower()}_queries' queries = {} for plugin in find_plugins(): plugin_queries = getattr(plugin, attr_name, {}) queries.update(plugin_queries) return queries 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, extra_tags=None): """Gets MusicBrainz candidates for an album from each plugin. """ for plugin in find_plugins(): yield from plugin.candidates(items, artist, album, va_likely, extra_tags) def item_candidates(item, artist, title): """Gets MusicBrainz candidates for an item from the plugins. """ for plugin in find_plugins(): yield from plugin.item_candidates(item, artist, title) def album_for_id(album_id): """Get AlbumInfo objects for a given ID string. """ for plugin in find_plugins(): album = plugin.album_for_id(album_id) if album: yield album def track_for_id(track_id): """Get TrackInfo objects for a given ID string. """ for plugin in find_plugins(): track = plugin.track_for_id(track_id) if track: yield track 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 early_import_stages(): """Get a list of early import stage functions defined by plugins.""" stages = [] for plugin in find_plugins(): stages += plugin.get_early_import_stages() return stages def import_stages(): """Get a list of import stage functions defined by plugins.""" stages = [] for plugin in find_plugins(): stages += plugin.get_import_stages() return stages # New-style (lazy) plugin-provided fields. def item_field_getters(): """Get a dictionary mapping field names to unary functions that compute the field's value. """ funcs = {} for plugin in find_plugins(): if plugin.template_fields: funcs.update(plugin.template_fields) return funcs def album_field_getters(): """As above, for album fields. """ funcs = {} for plugin in find_plugins(): if plugin.album_template_fields: funcs.update(plugin.album_template_fields) return funcs # 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): """Send an event to all assigned event listeners. `event` is the name of the event to send, all other named arguments are passed along to the handlers. Return a list of non-None values returned from the handlers. """ log.debug('Sending event: {0}', event) results = [] for handler in event_handlers()[event]: result = handler(**arguments) if result is not None: results.append(result) return results def feat_tokens(for_artist=True): """Return a regular expression that matches phrases like "featuring" that separate a main artist or a song title from secondary artists. The `for_artist` option determines whether the regex should be suitable for matching artist fields (the default) or title fields. """ feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.'] if for_artist: feat_words += ['with', 'vs', 'and', 'con', '&'] return r'(?<=\s)(?:{})(?=\s)'.format( '|'.join(re.escape(x) for x in feat_words) ) def sanitize_choices(choices, choices_all): """Clean up a stringlist configuration attribute: keep only choices elements present in choices_all, remove duplicate elements, expand '*' wildcard while keeping original stringlist order. """ seen = set() others = [x for x in choices_all if x not in choices] res = [] for s in choices: if s not in seen: if s in list(choices_all): res.append(s) elif s == '*': res.extend(others) seen.add(s) return res def sanitize_pairs(pairs, pairs_all): """Clean up a single-element mapping configuration attribute as returned by Confuse's `Pairs` template: keep only two-element tuples present in pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*') wildcards while keeping the original order. Note that ('*', '*') and ('*', 'whatever') have the same effect. For example, >>> sanitize_pairs( ... [('foo', 'baz bar'), ('key', '*'), ('*', '*')], ... [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'), ... ('key', 'value')] ... ) [('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')] """ pairs_all = list(pairs_all) seen = set() others = [x for x in pairs_all if x not in pairs] res = [] for k, values in pairs: for v in values.split(): x = (k, v) if x in pairs_all: if x not in seen: seen.add(x) res.append(x) elif k == '*': new = [o for o in others if o not in seen] seen.update(new) res.extend(new) elif v == '*': new = [o for o in others if o not in seen and o[0] == k] seen.update(new) res.extend(new) return res def notify_info_yielded(event): """Makes a generator send the event 'event' every time it yields. This decorator is supposed to decorate a generator, but any function returning an iterable should work. Each yielded value is passed to plugins using the 'info' parameter of 'send'. """ def decorator(generator): def decorated(*args, **kwargs): for v in generator(*args, **kwargs): send(event, info=v) yield v return decorated return decorator def get_distance(config, data_source, info): """Returns the ``data_source`` weight and the maximum source weight for albums or individual tracks. """ dist = beets.autotag.Distance() if info.data_source == data_source: dist.add('source', config['source_weight'].as_number()) return dist def apply_item_changes(lib, item, move, pretend, write): """Store, move, and write the item according to the arguments. :param lib: beets library. :type lib: beets.library.Library :param item: Item whose changes to apply. :type item: beets.library.Item :param move: Move the item if it's in the library. :type move: bool :param pretend: Return without moving, writing, or storing the item's metadata. :type pretend: bool :param write: Write the item's metadata to its media file. :type write: bool """ if pretend: return from beets import util # 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: item.try_write() item.store() class MetadataSourcePlugin(metaclass=abc.ABCMeta): def __init__(self): super().__init__() self.config.add({'source_weight': 0.5}) @abc.abstractproperty def id_regex(self): raise NotImplementedError @abc.abstractproperty def data_source(self): raise NotImplementedError @abc.abstractproperty def search_url(self): raise NotImplementedError @abc.abstractproperty def album_url(self): raise NotImplementedError @abc.abstractproperty def track_url(self): raise NotImplementedError @abc.abstractmethod def _search_api(self, query_type, filters, keywords=''): raise NotImplementedError @abc.abstractmethod def album_for_id(self, album_id): raise NotImplementedError @abc.abstractmethod def track_for_id(self, track_id=None, track_data=None): raise NotImplementedError @staticmethod def get_artist(artists, id_key='id', name_key='name'): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of artist object dicts. For each artist, this function moves articles (such as 'a', 'an', and 'the') to the front and strips trailing disambiguation numbers. It returns a tuple containing the comma-separated string of all normalized artists and the ``id`` of the main/first artist. :param artists: Iterable of artist dicts or lists returned by API. :type artists: list[dict] or list[list] :param id_key: Key or index corresponding to the value of ``id`` for the main/first artist. Defaults to 'id'. :type id_key: str or int :param name_key: Key or index corresponding to values of names to concatenate for the artist string (containing all artists). Defaults to 'name'. :type name_key: str or int :return: Normalized artist string. :rtype: str """ artist_id = None artist_names = [] for artist in artists: if not artist_id: artist_id = artist[id_key] name = artist[name_key] # 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) artist_names.append(name) artist = ', '.join(artist_names).replace(' ,', ',') or None return artist, artist_id def _get_id(self, url_type, id_): """Parse an ID from its URL if necessary. :param url_type: Type of URL. Either 'album' or 'track'. :type url_type: str :param id_: Album/track ID or URL. :type id_: str :return: Album/track ID. :rtype: str """ self._log.debug( "Searching {} for {} '{}'", self.data_source, url_type, id_ ) match = re.search(self.id_regex['pattern'].format(url_type), str(id_)) if match: id_ = match.group(self.id_regex['match_group']) if id_: return id_ return None def candidates(self, items, artist, album, va_likely, extra_tags=None): """Returns a list of AlbumInfo objects for Search API results matching an ``album`` and ``artist`` (if not various). :param items: List of items comprised by an album to be matched. :type items: list[beets.library.Item] :param artist: The artist of the album to be matched. :type artist: str :param album: The name of the album to be matched. :type album: str :param va_likely: True if the album to be matched likely has Various Artists. :type va_likely: bool :return: Candidate AlbumInfo objects. :rtype: list[beets.autotag.hooks.AlbumInfo] """ query_filters = {'album': album} if not va_likely: query_filters['artist'] = artist results = self._search_api(query_type='album', filters=query_filters) albums = [self.album_for_id(album_id=r['id']) for r in results] return [a for a in albums if a is not None] def item_candidates(self, item, artist, title): """Returns a list of TrackInfo objects for Search API results matching ``title`` and ``artist``. :param item: Singleton item to be matched. :type item: beets.library.Item :param artist: The artist of the track to be matched. :type artist: str :param title: The title of the track to be matched. :type title: str :return: Candidate TrackInfo objects. :rtype: list[beets.autotag.hooks.TrackInfo] """ tracks = self._search_api( query_type='track', keywords=title, filters={'artist': artist} ) return [self.track_for_id(track_data=track) for track in tracks] def album_distance(self, items, album_info, mapping): return get_distance( data_source=self.data_source, info=album_info, config=self.config ) def track_distance(self, item, track_info): return get_distance( data_source=self.data_source, info=track_info, config=self.config ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/random.py0000644000076500000240000000713400000000000015266 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Get a random song or album from the library. """ import random from operator import attrgetter from itertools import groupby def _length(obj, album): """Get the duration of an item or album. """ if album: return sum(i.length for i in obj.items()) else: return obj.length def _equal_chance_permutation(objs, field='albumartist', random_gen=None): """Generate (lazily) a permutation of the objects where every group with equal values for `field` have an equal chance of appearing in any given position. """ rand = random_gen or random # Group the objects by artist so we can sample from them. key = attrgetter(field) objs.sort(key=key) objs_by_artists = {} for artist, v in groupby(objs, key): objs_by_artists[artist] = list(v) # While we still have artists with music to choose from, pick one # randomly and pick a track from that artist. while objs_by_artists: # Choose an artist and an object for that artist, removing # this choice from the pool. artist = rand.choice(list(objs_by_artists.keys())) objs_from_artist = objs_by_artists[artist] i = rand.randint(0, len(objs_from_artist) - 1) yield 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] def _take(iter, num): """Return a list containing the first `num` values in `iter` (or fewer, if the iterable ends early). """ out = [] for val in iter: out.append(val) num -= 1 if num <= 0: break return out def _take_time(iter, secs, album): """Return a list containing the first values in `iter`, which should be Item or Album objects, that add up to the given amount of time in seconds. """ out = [] total_time = 0.0 for obj in iter: length = _length(obj, album) if total_time + length <= secs: out.append(obj) total_time += length return out def random_objs(objs, album, number=1, time=None, equal_chance=False, random_gen=None): """Get a random subset of the provided `objs`. If `number` is provided, produce that many matches. Otherwise, if `time` is provided, instead select a list whose total time is close to that number of minutes. If `equal_chance` is true, give each artist an equal chance of being included so that artists with more songs are not represented disproportionately. """ rand = random_gen or random # Permute the objects either in a straightforward way or an # artist-balanced way. if equal_chance: perm = _equal_chance_permutation(objs) else: perm = objs rand.shuffle(perm) # N.B. This shuffles the original list. # Select objects by time our count. if time: return _take_time(perm, time * 60, album) else: return _take(perm, number) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1638031078.257307 beets-1.6.0/beets/ui/0000755000076500000240000000000000000000000014044 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beets/ui/__init__.py0000644000076500000240000012551100000000000016162 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """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. """ import optparse import textwrap import sys from difflib import SequenceMatcher import sqlite3 import errno import re import struct import traceback import os.path from beets import logging 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 as_string from beets.autotag import mb from beets.dbcore import query as db_query from beets.dbcore import db import confuse # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == 'win32': try: import colorama except ImportError: pass else: colorama.init() log = logging.getLogger('beets') if not log.handlers: log.addHandler(logging.StreamHandler()) log.propagate = False # Don't propagate to root handler. PF_KEY_QUERIES = { 'comp': 'comp:true', 'singleton': 'singleton:true', } class UserError(Exception): """UI exception. Commands should throw this in order to display nonrecoverable errors to the user. """ # Encoding utilities. def _in_encoding(): """Get the encoding to use for *inputting* strings from the console. """ return _stream_encoding(sys.stdin) def _out_encoding(): """Get the encoding to use for *outputting* strings to the console. """ return _stream_encoding(sys.stdout) def _stream_encoding(stream, default='utf-8'): """A helper for `_in_encoding` and `_out_encoding`: get the stream's preferred encoding, using a configured override or a default fallback if neither is not specified. """ # Configured override? encoding = config['terminal_encoding'].get() if encoding: return encoding # For testing: When sys.stdout or sys.stdin is a StringIO under the # test harness, it doesn't have an `encoding` attribute. Just use # UTF-8. if not hasattr(stream, 'encoding'): return default # Python's guessed output stream encoding, or UTF-8 as a fallback # (e.g., when piped to a file). return stream.encoding or default def decargs(arglist): """Given a list of command-line argument bytestrings, attempts to decode them to Unicode strings when running under Python 2. """ return arglist def print_(*strings, **kwargs): """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. The arguments must be Unicode strings: `unicode` on Python 2; `str` on Python 3. The `end` keyword argument behaves similarly to the built-in `print` (it defaults to a newline). """ if not strings: strings = [''] assert isinstance(strings[0], str) txt = ' '.join(strings) txt += kwargs.get('end', '\n') # Encode the string and write it to stdout. # On Python 3, sys.stdout expects text strings and uses the # exception-throwing encoding error policy. To avoid throwing # errors and use our configurable encoding override, we use the # underlying bytes buffer instead. if hasattr(sys.stdout, 'buffer'): out = txt.encode(_out_encoding(), 'replace') sys.stdout.buffer.write(out) sys.stdout.buffer.flush() else: # In our test harnesses (e.g., DummyOut), sys.stdout.buffer # does not exist. We instead just record the text string. sys.stdout.write(txt) # Configuration wrappers. def _bool_fallback(a, b): """Given a boolean or None, return the original value or a fallback. """ if a is None: assert isinstance(b, bool) return b else: assert isinstance(a, bool) return a def should_write(write_opt=None): """Decide whether a command that updates metadata should also write tags, using the importer configuration as the default. """ return _bool_fallback(write_opt, config['import']['write'].get(bool)) def should_move(move_opt=None): """Decide whether a command that updates metadata should also move files when they're inside the library, using the importer configuration as the default. Specifically, commands should move files after metadata updates only when the importer is configured *either* to move *or* to copy files. They should avoid moving files when the importer is configured not to touch any filenames. """ return _bool_fallback( move_opt, config['import']['move'].get(bool) or config['import']['copy'].get(bool) ) # Input prompts. def input_(prompt=None): """Like `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. # https://bugs.python.org/issue1927 if prompt: print_(prompt, end=' ') try: resp = input() except EOFError: raise UserError('stdin stream ended while input required') return resp 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, str) 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('action_default' if is_default else 'action', 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('action_default', 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 input_select_objects(prompt, objs, rep, prompt_all=None): """Prompt to user to choose all, none, or some of the given objects. Return the list of selected objects. `prompt` is the prompt string to use for each question (it should be phrased as an imperative verb). If `prompt_all` is given, it is used instead of `prompt` for the first (yes(/no/select) question. `rep` is a function to call on each object to print it out when confirming objects individually. """ choice = input_options( ('y', 'n', 's'), False, '%s? (Yes/no/select)' % (prompt_all or prompt)) print() # Blank line. if choice == 'y': # Yes. return objs elif choice == 's': # Select. out = [] for obj in objs: rep(obj) answer = input_options( ('y', 'n', 'q'), True, '%s? (yes/no/quit)' % prompt, 'Enter Y or N:' ) if answer == 'y': out.append(obj) elif answer == 'q': return out return out else: # No. return [] # Human output formatting. def human_bytes(size): """Formats size, a number of bytes, in a human-readable way.""" powers = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'H'] unit = 'B' for power in powers: if size < 1024: return f"{size:3.1f} {power}{unit}" size /= 1024.0 unit = 'iB' 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 f"{interval:3.1f} {suffix}s" def human_seconds_short(interval): """Formats a number of seconds as a short human-readable M:SS string. """ interval = int(interval) return '%i:%02i' % (interval // 60, interval % 60) # Colorization. # ANSI terminal colorization code heavily inspired by pygments: # https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py # (pygments is by Tim Hatch, Armin Ronacher, et al.) COLOR_ESCAPE = "\x1b[" DARK_COLORS = { "black": 0, "darkred": 1, "darkgreen": 2, "brown": 3, "darkyellow": 3, "darkblue": 4, "purple": 5, "darkmagenta": 5, "teal": 6, "darkcyan": 6, "lightgray": 7 } LIGHT_COLORS = { "darkgray": 0, "red": 1, "green": 2, "yellow": 3, "blue": 4, "fuchsia": 5, "magenta": 5, "turquoise": 6, "cyan": 6, "white": 7 } RESET_COLOR = COLOR_ESCAPE + "39;49;00m" # These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS # as they are defined in the configuration files, see function: colorize COLOR_NAMES = ['text_success', 'text_warning', 'text_error', 'text_highlight', 'text_highlight_minor', 'action_default', 'action'] COLORS = None 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[color] + 30) elif color in LIGHT_COLORS: escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30) else: raise ValueError('no such color %s', color) return escape + text + RESET_COLOR def colorize(color_name, text): """Colorize text if colored output is enabled. (Like _colorize but conditional.) """ if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys(): return text global COLORS if not COLORS: COLORS = {name: config['ui']['colors'][name].as_str() for name in COLOR_NAMES} # In case a 3rd party plugin is still passing the actual color ('red') # instead of the abstract color name ('text_error') color = COLORS.get(color_name) if not color: log.debug('Invalid color_name: {0}', color_name) color = color_name return _colorize(color, text) def _colordiff(a, b, highlight='text_highlight', minor_highlight='text_highlight_minor'): """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, str) \ or not isinstance(b, str): # Non-strings: use ordinary equality. a = str(a) b = str(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 ''.join(a_out), ''.join(b_out) def colordiff(a, b, highlight='text_highlight'): """Colorize differences between two values if color is enabled. (Like _colordiff but conditional.) """ if config['ui']['color']: return _colordiff(a, b, highlight) else: return str(a), str(b) 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.as_str()))) return path_formats def get_replacements(): """Confuse validation function that reads regex/string pairs. """ replacements = [] for pattern, repl in config['replace'].get(dict).items(): repl = repl or '' try: replacements.append((re.compile(pattern), repl)) except re.error: raise UserError( 'malformed regular expression in replace: {}'.format( pattern ) ) return replacements 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 OSError: return fallback try: height, width = struct.unpack('hh', buf) except struct.error: return fallback return width FLOAT_EPSILON = 0.01 def _field_diff(field, old, old_fmt, new, new_fmt): """Given two Model objects and their formatted views, format their values for `field` and highlight changes among them. Return a human-readable string. If the value has not changed, return None instead. """ oldval = old.get(field) newval = new.get(field) # If no change, abort. if isinstance(oldval, float) and isinstance(newval, float) and \ abs(oldval - newval) < FLOAT_EPSILON: return None elif oldval == newval: return None # Get formatted values for output. oldstr = old_fmt.get(field, '') newstr = new_fmt.get(field, '') # For strings, highlight changes. For others, colorize the whole # thing. if isinstance(oldval, str): oldstr, newstr = colordiff(oldval, newstr) else: oldstr = colorize('text_error', oldstr) newstr = colorize('text_error', newstr) return f'{oldstr} -> {newstr}' def show_model_changes(new, old=None, fields=None, always=False): """Given a Model object, print a list of changes from its pristine version stored in the database. Return a boolean indicating whether any changes were found. `old` may be the "original" object to avoid using the pristine version from the database. `fields` may be a list of fields to restrict the detection to. `always` indicates whether the object is always identified, regardless of whether any changes are present. """ old = old or new._db._get(type(new), new.id) # Keep the formatted views around instead of re-creating them in each # iteration step old_fmt = old.formatted() new_fmt = new.formatted() # Build up lines showing changed fields. changes = [] for field in old: # Subset of the fields. Never show mtime. if field == 'mtime' or (fields and field not in fields): continue # Detect and show difference for this field. line = _field_diff(field, old, old_fmt, new, new_fmt) if line: changes.append(f' {field}: {line}') # New fields. for field in set(new) - set(old): if fields and field not in fields: continue changes.append(' {}: {}'.format( field, colorize('text_highlight', new_fmt[field]) )) # Print changes. if changes or always: print_(format(old)) if changes: print_('\n'.join(changes)) return bool(changes) def show_path_changes(path_changes): """Given a list of tuples (source, destination) that indicate the path changes, log the changes as INFO-level output to the beets log. The output is guaranteed to be unicode. Every pair is shown on a single line if the terminal width permits it, else it is split over two lines. E.g., Source -> Destination vs. Source -> Destination """ sources, destinations = zip(*path_changes) # Ensure unicode output sources = list(map(util.displayable_path, sources)) destinations = list(map(util.displayable_path, destinations)) # Calculate widths for terminal split col_width = (term_width() - len(' -> ')) // 2 max_width = len(max(sources + destinations, key=len)) if max_width > col_width: # Print every change over two lines for source, dest in zip(sources, destinations): color_source, color_dest = colordiff(source, dest) print_('{0} \n -> {1}'.format(color_source, color_dest)) else: # Print every change on a single line, and add a header title_pad = max_width - len('Source ') + len(' -> ') print_('Source {0} Destination'.format(' ' * title_pad)) for source, dest in zip(sources, destinations): pad = max_width - len(source) color_source, color_dest = colordiff(source, dest) print_('{0} {1} -> {2}'.format( color_source, ' ' * pad, color_dest, )) # Helper functions for option parsing. def _store_dict(option, opt_str, value, parser): """Custom action callback to parse options which have ``key=value`` pairs as values. All such pairs passed for this option are aggregated into a dictionary. """ dest = option.dest option_values = getattr(parser.values, dest, None) if option_values is None: # This is the first supplied ``key=value`` pair of option. # Initialize empty dictionary and get a reference to it. setattr(parser.values, dest, {}) option_values = getattr(parser.values, dest) # Decode the argument using the platform's argument encoding. value = util.text_string(value, util.arg_encoding()) try: key, value = value.split('=', 1) if not (key and value): raise ValueError except ValueError: raise UserError( "supplied argument `{}' is not of the form `key=value'" .format(value)) option_values[key] = value class CommonOptionsParser(optparse.OptionParser): """Offers a simple way to add common formatting options. Options available include: - matching albums instead of tracks: add_album_option() - showing paths instead of items/albums: add_path_option() - changing the format of displayed items/albums: add_format_option() The last one can have several behaviors: - against a special target - with a certain format - autodetected target with the album option Each method is fully documented in the related method. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._album_flags = False # this serves both as an indicator that we offer the feature AND allows # us to check whether it has been specified on the CLI - bypassing the # fact that arguments may be in any order def add_album_option(self, flags=('-a', '--album')): """Add a -a/--album option to match albums instead of tracks. If used then the format option can auto-detect whether we're setting the format for items or albums. Sets the album property on the options extracted from the CLI. """ album = optparse.Option(*flags, action='store_true', help='match albums instead of tracks') self.add_option(album) self._album_flags = set(flags) def _set_format(self, option, opt_str, value, parser, target=None, fmt=None, store_true=False): """Internal callback that sets the correct format while parsing CLI arguments. """ if store_true: setattr(parser.values, option.dest, True) # Use the explicitly specified format, or the string from the option. if fmt: value = fmt elif value: value, = decargs([value]) else: value = '' parser.values.format = value if target: config[target._format_config_key].set(value) else: if self._album_flags: if parser.values.album: target = library.Album else: # the option is either missing either not parsed yet if self._album_flags & set(parser.rargs): target = library.Album else: target = library.Item config[target._format_config_key].set(value) else: config[library.Item._format_config_key].set(value) config[library.Album._format_config_key].set(value) def add_path_option(self, flags=('-p', '--path')): """Add a -p/--path option to display the path instead of the default format. By default this affects both items and albums. If add_album_option() is used then the target will be autodetected. Sets the format property to '$path' on the options extracted from the CLI. """ path = optparse.Option(*flags, nargs=0, action='callback', callback=self._set_format, callback_kwargs={'fmt': '$path', 'store_true': True}, help='print paths for matched items or albums') self.add_option(path) def add_format_option(self, flags=('-f', '--format'), target=None): """Add -f/--format option to print some LibModel instances with a custom format. `target` is optional and can be one of ``library.Item``, 'item', ``library.Album`` and 'album'. Several behaviors are available: - if `target` is given then the format is only applied to that LibModel - if the album option is used then the target will be autodetected - otherwise the format is applied to both items and albums. Sets the format property on the options extracted from the CLI. """ kwargs = {} if target: if isinstance(target, str): target = {'item': library.Item, 'album': library.Album}[target] kwargs['target'] = target opt = optparse.Option(*flags, action='callback', callback=self._set_format, callback_kwargs=kwargs, help='print with custom format') self.add_option(opt) def add_all_common_options(self): """Add album, path and format options. """ self.add_album_option() self.add_path_option() self.add_format_option() # Subcommand parsing infrastructure. # # This is a fairly generic subcommand parser for optparse. It is # maintained externally here: # https://gist.github.com/462717 # There you will also find a better description of the code and a more # succinct example program. class Subcommand: """A subcommand of a root command-line application that may be invoked by a SubcommandOptionParser. """ def __init__(self, name, parser=None, help='', aliases=(), hide=False): """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 CommonOptionsParser. """ self.name = name self.parser = parser or CommonOptionsParser() self.aliases = aliases self.help = help self.hide = hide self._root_parser = None def print_help(self): self.parser.print_help() def parse_args(self, args): return self.parser.parse_args(args) @property def root_parser(self): return self._root_parser @root_parser.setter def root_parser(self, root_parser): self._root_parser = root_parser self.parser.prog = '{} {}'.format( as_string(root_parser.get_prog_name()), self.name) class SubcommandsOptionParser(CommonOptionsParser): """A variant of OptionParser that parses subcommands and their arguments. """ 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. """ # A more helpful default usage. if 'usage' not in kwargs: kwargs['usage'] = """ %prog COMMAND [ARGS...] %prog help COMMAND""" kwargs['add_help_option'] = False # Super constructor. super().__init__(*args, **kwargs) # Our root parser needs to stop on the first unrecognized argument. self.disable_interspersed_args() self.subcommands = [] def add_subcommand(self, *cmds): """Adds a Subcommand object to the parser's list of commands. """ for cmd in cmds: cmd.root_parser = self 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 = super().format_help(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 subcommands = [c for c in self.subcommands if not c.hide] subcommands.sort(key=lambda c: c.name) for subcommand in 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(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) help_line = help_lines[0] if help_lines else '' result.append("%*s%s\n" % (indent_first, "", help_line)) 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_global_options(self, args): """Parse options up to the subcommand argument. Returns a tuple of the options object and the remaining arguments. """ options, subargs = self.parse_args(args) # Force the help command if options.help: subargs = ['help'] elif options.version: subargs = ['version'] return options, subargs def parse_subcommand(self, args): """Given the `args` left unused by a `parse_global_options`, return the invoked subcommand, the subcommand options, and the subcommand arguments. """ # Help is default command if not args: args = ['help'] cmdname = args.pop(0) subcommand = self._subcommand_for_name(cmdname) if not subcommand: raise UserError(f"unknown command '{cmdname}'") suboptions, subargs = subcommand.parse_args(args) return subcommand, suboptions, subargs optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',) # The main entry point and bootstrapping. def _load_plugins(options, config): """Load the plugins specified on the command line or in the configuration. """ paths = config['pluginpath'].as_str_seq(split=False) paths = [util.normpath(p) for p in paths] log.debug('plugin paths: {0}', util.displayable_path(paths)) # On Python 3, the search paths need to be unicode. paths = [util.py3_path(p) for p in paths] # Extend the `beetsplug` package to include the plugin paths. import beetsplug beetsplug.__path__ = paths + list(beetsplug.__path__) # For backwards compatibility, also support plugin paths that # *contain* a `beetsplug` package. sys.path += paths # If we were given any plugins on the command line, use those. if options.plugins is not None: plugin_list = (options.plugins.split(',') if len(options.plugins) > 0 else []) else: plugin_list = config['plugins'].as_str_seq() plugins.load_plugins(plugin_list) return plugins def _setup(options, lib=None): """Prepare and global state and updates it with command line options. Returns a list of subcommands, a list of plugins, and a library instance. """ # Configure the MusicBrainz API. mb.configure() config = _configure(options) plugins = _load_plugins(options, config) # Add types and queries defined by plugins. plugin_types_album = plugins.types(library.Album) library.Album._types.update(plugin_types_album) item_types = plugin_types_album.copy() item_types.update(library.Item._types) item_types.update(plugins.types(library.Item)) library.Item._types = item_types library.Item._queries.update(plugins.named_queries(library.Item)) library.Album._queries.update(plugins.named_queries(library.Album)) plugins.send("pluginload") # Get the default subcommands. from beets.ui.commands import default_commands subcommands = list(default_commands) subcommands.extend(plugins.commands()) if lib is None: lib = _open_library(config) plugins.send("library_opened", lib=lib) return subcommands, plugins, lib def _configure(options): """Amend the global configuration object with command line options. """ # Add any additional config files specified with --config. This # special handling lets specified plugins get loaded before we # finish parsing the command line. if getattr(options, 'config', None) is not None: overlay_path = options.config del options.config config.set_file(overlay_path) else: overlay_path = None config.set_args(options) # Configure the logger. if config['verbose'].get(int): log.set_global_level(logging.DEBUG) else: log.set_global_level(logging.INFO) if overlay_path: log.debug('overlaying configuration: {0}', util.displayable_path(overlay_path)) config_path = config.user_config_path() if os.path.isfile(config_path): log.debug('user configuration: {0}', util.displayable_path(config_path)) else: log.debug('no user configuration found at {0}', util.displayable_path(config_path)) log.debug('data directory: {0}', util.displayable_path(config.config_dir())) return config def _open_library(config): """Create a new library instance from the configuration. """ dbpath = util.bytestring_path(config['library'].as_filename()) try: lib = library.Library( dbpath, config['directory'].as_filename(), get_path_formats(), get_replacements(), ) lib.get_item(0) # Test database connection. except (sqlite3.OperationalError, sqlite3.DatabaseError) as db_error: log.debug('{}', traceback.format_exc()) raise UserError("database file {} cannot not be opened: {}".format( util.displayable_path(dbpath), db_error )) log.debug('library database: {0}\n' 'library directory: {1}', util.displayable_path(lib.path), util.displayable_path(lib.directory)) return lib def _raw_main(args, lib=None): """A helper function for `main` without top-level exception handling. """ parser = SubcommandsOptionParser() parser.add_format_option(flags=('--format-item',), target=library.Item) parser.add_format_option(flags=('--format-album',), target=library.Album) 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='count', help='log more details (use twice for even more)') parser.add_option('-c', '--config', dest='config', help='path to configuration file') parser.add_option('-p', '--plugins', dest='plugins', help='a comma-separated list of plugins to load') parser.add_option('-h', '--help', dest='help', action='store_true', help='show this help message and exit') parser.add_option('--version', dest='version', action='store_true', help=optparse.SUPPRESS_HELP) options, subargs = parser.parse_global_options(args) # Special case for the `config --edit` command: bypass _setup so # that an invalid configuration does not prevent the editor from # starting. if subargs and subargs[0] == 'config' \ and ('-e' in subargs or '--edit' in subargs): from beets.ui.commands import config_edit return config_edit() test_lib = bool(lib) subcommands, plugins, lib = _setup(options, lib) parser.add_subcommand(*subcommands) subcommand, suboptions, subargs = parser.parse_subcommand(subargs) subcommand.func(lib, suboptions, subargs) plugins.send('cli_exit', lib=lib) if not test_lib: # Clean up the library unless it came from the test harness. lib._close() 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('error: {0}', message) sys.exit(1) except util.HumanReadableException as exc: exc.log(log) sys.exit(1) except library.FileOperationError as exc: # These errors have reasonable human-readable descriptions, but # we still want to log their tracebacks for debugging. log.debug('{}', traceback.format_exc()) log.error('{}', exc) sys.exit(1) except confuse.ConfigError as exc: log.error('configuration error: {0}', exc) sys.exit(1) except db_query.InvalidQueryError as exc: log.error('invalid query: {0}', exc) sys.exit(1) except OSError as exc: if exc.errno == errno.EPIPE: # "Broken pipe". End silently. sys.stderr.close() else: raise except KeyboardInterrupt: # Silently ignore ^C except in verbose mode. log.debug('{}', traceback.format_exc()) except db.DBAccessError as exc: log.error( 'database access error: {0}\n' 'the library file might have a permissions problem', exc ) sys.exit(1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/ui/commands.py0000755000076500000240000017310500000000000016231 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """This module provides the default commands for beets' command-line interface. """ import os import re from platform import python_version from collections import namedtuple, Counter from itertools import chain import beets from beets import ui from beets.ui import print_, input_, decargs, show_path_changes 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, \ MoveOperation from beets import library from beets import config from beets import logging from . import _store_dict VARIOUS_ARTISTS = 'Various Artists' PromptChoice = namedtuple('PromptChoice', ['short', 'long', 'callback']) # 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 # fields: Shows a list of available fields for queries and format strings. def _print_keys(query): """Given a SQLite query result, print the `key` field of each returned row, with indentation of 2 spaces. """ for row in query: print_(' ' * 2 + row['key']) def fields_func(lib, opts, args): def _print_rows(names): names.sort() print_(' ' + '\n '.join(names)) print_("Item fields:") _print_rows(library.Item.all_keys()) print_("Album fields:") _print_rows(library.Album.all_keys()) with lib.transaction() as tx: # The SQL uses the DISTINCT to get unique values from the query unique_fields = 'SELECT DISTINCT key FROM (%s)' print_("Item flexible attributes:") _print_keys(tx.query(unique_fields % library.Item._flex_table)) print_("Album flexible attributes:") _print_keys(tx.query(unique_fields % library.Album._flex_table)) fields_cmd = ui.Subcommand( 'fields', help='show fields available for queries and format strings' ) fields_cmd.func = fields_func default_commands.append(fields_cmd) # help: Print help text for commands class HelpCommand(ui.Subcommand): def __init__(self): super().__init__( 'help', aliases=('?',), help='give detailed help on a specific sub-command', ) def func(self, lib, opts, args): if args: cmdname = args[0] helpcommand = self.root_parser._subcommand_for_name(cmdname) if not helpcommand: raise ui.UserError(f"unknown command '{cmdname}'") helpcommand.print_help() else: self.root_parser.print_help() default_commands.append(HelpCommand()) # import: Autotagger and importer. # 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 and info.mediums > 1: disambig.append('{}x{}'.format( info.mediums, info.media )) else: disambig.append(info.media) if info.year: disambig.append(str(info.year)) if info.country: disambig.append(info.country) if info.label: disambig.append(info.label) if info.catalognum: disambig.append(info.catalognum) if info.albumdisambig: disambig.append(info.albumdisambig) if disambig: return ', '.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('text_success', out) elif dist <= config['match']['medium_rec_thresh'].as_number(): out = ui.colorize('text_warning', out) else: out = ui.colorize('text_error', 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('text_warning', '(%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 = f' {artist} - {album}' elif album: album_description = ' %s' % album else: album_description = ' (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 and mediums > 1: return f'{medium}-{medium_index}' else: return str(medium_index if medium_index is not None else index) else: return str(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 = '', '' if config['artist_credit']: artist_r = match.info.artist_credit 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_("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('text_highlight_minor', '(%s)' % disambig)) print_(' '.join(info)) # Tracks. pairs = list(match.mapping.items()) pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].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 = '{} {}: {}'.format(media, track_info.medium, track_info.disctitle) elif match.info.mediums > 1: lhs = f'{media} {track_info.medium}' elif track_info.disctitle: lhs = f'{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 = 'text_highlight_minor' else: color = 'text_highlight' templ = ui.colorize(color, ' (#{0})') lhs += templ.format(cur_track) rhs += templ.format(new_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) templ = ui.colorize('text_highlight', ' ({0})') lhs += templ.format(cur_length) rhs += templ.format(new_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_(f'{lhs} ->\n {rhs}') else: pad = max_width - lhs_width print_('{}{} -> {}'.format(lhs, ' ' * pad, rhs)) # Missing and unmatched tracks. if match.extra_tracks: print_('Missing tracks ({}/{} - {:.1%}):'.format( len(match.extra_tracks), len(match.info.tracks), len(match.extra_tracks) / len(match.info.tracks) )) pad_width = max(len(track_info.title) for track_info in match.extra_tracks) for track_info in match.extra_tracks: line = ' ! {0: <{width}} (#{1: >2})'.format(track_info.title, format_index(track_info), width=pad_width) if track_info.length: line += ' (%s)' % ui.human_seconds_short(track_info.length) print_(ui.colorize('text_warning', line)) if match.extra_items: print_('Unmatched tracks ({}):'.format(len(match.extra_items))) pad_width = max(len(item.title) for item in match.extra_items) for item in match.extra_items: line = ' ! {0: <{width}} (#{1: >2})'.format(item.title, format_index(item), width=pad_width) if item.length: line += ' (%s)' % ui.human_seconds_short(item.length) print_(ui.colorize('text_warning', 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_(f" {cur_artist} - {cur_title}") print_("To:") print_(f" {new_artist} - {new_title}") else: print_(f"Tagging track: {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('text_highlight_minor', '(%s)' % disambig)) print_(' '.join(info)) def summarize_items(items, singleton): """Produces a brief summary line describing a set of items. Used for manually resolving duplicates during import. `items` is a list of `Item` objects. `singleton` indicates whether this is an album or single-item import (if the latter, them `items` should only have one element). """ summary_parts = [] if not singleton: summary_parts.append("{} items".format(len(items))) format_counts = {} for item in items: format_counts[item.format] = format_counts.get(item.format, 0) + 1 if len(format_counts) == 1: # A single format. summary_parts.append(items[0].format) else: # Enumerate all the formats by decreasing frequencies: for fmt, count in sorted( format_counts.items(), key=lambda fmt_and_count: (-fmt_and_count[1], fmt_and_count[0]) ): summary_parts.append(f'{fmt} {count}') if items: average_bitrate = sum([item.bitrate for item in items]) / len(items) total_duration = sum([item.length for item in items]) total_filesize = sum([item.filesize for item in items]) summary_parts.append('{}kbps'.format(int(average_bitrate / 1000))) if items[0].format == "FLAC": sample_bits = '{}kHz/{} bit'.format( round(int(items[0].samplerate) / 1000, 1), items[0].bitdepth) summary_parts.append(sample_bits) summary_parts.append(ui.human_seconds_short(total_duration)) summary_parts.append(ui.human_bytes(total_filesize)) return ', '.join(summary_parts) def _summary_judgment(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 None if the user should be queried. Otherwise, returns an action. 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 config['import']['timid']: return None 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, choices=[]): """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. `choices` is a list of `PromptChoice`s to be used in each prompt. Returns one of the following: * the result of the choice, which may be SKIP or ASIS * a candidate (an AlbumMatch/TrackMatch object) * a chosen `PromptChoice` from `choices` """ # Sanity check. if singleton: assert item is not None else: assert cur_artist is not None assert cur_album is not None # Build helper variables for the prompt choices. choice_opts = tuple(c.long for c in choices) choice_actions = {c.short: c for c in choices} # Zero candidates. if not candidates: if singleton: print_("No matching recordings found.") else: print_("No matching release found for {} tracks." .format(itemcount)) print_('For help, see: ' 'https://beets.readthedocs.org/en/latest/faq.html#nomatch') sel = ui.input_options(choice_opts) if sel in choice_actions: return choice_actions[sel] 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_('Finding tags for {} "{} - {}".'.format( 'track' if singleton else 'album', item.artist if singleton else cur_artist, item.title if singleton else cur_album, )) print_('Candidates:') for i, match in enumerate(candidates): # Index, metadata, and distance. line = [ '{}.'.format(i + 1), '{} - {}'.format( match.info.artist, match.info.title if singleton else match.info.album, ), '({})'.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('text_highlight_minor', '(%s)' % disambig)) print_(' '.join(line)) # Ask the user for a choice. sel = ui.input_options(choice_opts, numrange=(1, len(candidates))) if sel == 'm': pass elif sel in choice_actions: return choice_actions[sel] 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. default = config['import']['default_action'].as_choice({ 'apply': 'a', 'skip': 's', 'asis': 'u', 'none': None, }) if default is None: require = True # Bell ring when user interaction is needed. if config['import']['bell']: ui.print_('\a', end='') sel = ui.input_options(('Apply', 'More candidates') + choice_opts, require=require, default=default) if sel == 'a': return match elif sel in choice_actions: return choice_actions[sel] def manual_search(session, task): """Get a new `Proposal` using manual search criteria. Input either an artist and album (for full albums) or artist and track name (for singletons) for manual search. """ artist = input_('Artist:').strip() name = input_('Album:' if task.is_album else 'Track:').strip() if task.is_album: _, _, prop = autotag.tag_album( task.items, artist, name ) return prop else: return autotag.tag_item(task.item, artist, name) def manual_id(session, task): """Get a new `Proposal` using a manually-entered ID. Input an ID, either for an album ("release") or a track ("recording"). """ prompt = 'Enter {} ID:'.format('release' if task.is_album else 'recording') search_id = input_(prompt).strip() if task.is_album: _, _, prop = autotag.tag_album( task.items, search_ids=search_id.split() ) return prop else: return autotag.tag_item(task.item, search_ids=search_id.split()) def abort_action(session, task): """A prompt choice callback that aborts the importer. """ raise importer.ImportAbort() 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, '\n') + ' ({} items)'.format(len(task.items))) # Let plugins display info or prompt the user before we go through the # process of selecting candidate. results = plugins.send('import_task_before_choice', session=self, task=task) actions = [action for action in results if action] if len(actions) == 1: return actions[0] elif len(actions) > 1: raise plugins.PluginConflictException( 'Only one handler for `import_task_before_choice` may return ' 'an action.') # Take immediate action if appropriate. action = _summary_judgment(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. while True: # Ask for a choice from the user. The result of # `choose_candidate` may be an `importer.action`, an # `AlbumMatch` object for a specific selection, or a # `PromptChoice`. choices = self._get_choices(task) choice = choose_candidate( task.candidates, False, task.rec, task.cur_artist, task.cur_album, itemcount=len(task.items), choices=choices ) # Basic choices that require no more action here. if choice in (importer.action.SKIP, importer.action.ASIS): # Pass selection to main control flow. return choice # Plugin-provided choices. We invoke the associated callback # function. elif choice in choices: post_choice = choice.callback(self, task) if isinstance(post_choice, importer.action): return post_choice elif isinstance(post_choice, autotag.Proposal): # Use the new candidates and continue around the loop. task.candidates = post_choice.candidates task.rec = post_choice.recommendation # Otherwise, we have a specific match selection. 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_(displayable_path(task.item.path)) candidates, rec = task.candidates, task.rec # Take immediate action if appropriate. action = _summary_judgment(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. choices = self._get_choices(task) choice = choose_candidate(candidates, True, rec, item=task.item, choices=choices) if choice in (importer.action.SKIP, importer.action.ASIS): return choice elif choice in choices: post_choice = choice.callback(self, task) if isinstance(post_choice, importer.action): return post_choice elif isinstance(post_choice, autotag.Proposal): candidates = post_choice.candidates rec = post_choice.recommendation else: # Chose a candidate. assert isinstance(choice, autotag.TrackMatch) return choice def resolve_duplicate(self, task, found_duplicates): """Decide what to do when a new album or item seems similar to one that's already in the library. """ log.warning("This {0} 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: # Print some detail about the existing and new items so the # user can make an informed decision. for duplicate in found_duplicates: print_("Old: " + summarize_items( list(duplicate.items()) if task.is_album else [duplicate], not task.is_album, )) print_("New: " + summarize_items( task.imported_items(), not task.is_album, )) sel = ui.input_options( ('Skip new', 'Keep all', 'Remove old', 'Merge all') ) 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.should_remove_duplicates = True elif sel == 'm': task.should_merge_duplicates = True else: assert False def should_resume(self, path): return ui.input_yn("Import of the directory:\n{}\n" "was interrupted. Resume (Y/n)?" .format(displayable_path(path))) def _get_choices(self, task): """Get the list of prompt choices that should be presented to the user. This consists of both built-in choices and ones provided by plugins. The `before_choose_candidate` event is sent to the plugins, with session and task as its parameters. Plugins are responsible for checking the right conditions and returning a list of `PromptChoice`s, which is flattened and checked for conflicts. If two or more choices have the same short letter, a warning is emitted and all but one choices are discarded, giving preference to the default importer choices. Returns a list of `PromptChoice`s. """ # Standard, built-in choices. choices = [ PromptChoice('s', 'Skip', lambda s, t: importer.action.SKIP), PromptChoice('u', 'Use as-is', lambda s, t: importer.action.ASIS) ] if task.is_album: choices += [ PromptChoice('t', 'as Tracks', lambda s, t: importer.action.TRACKS), PromptChoice('g', 'Group albums', lambda s, t: importer.action.ALBUMS), ] choices += [ PromptChoice('e', 'Enter search', manual_search), PromptChoice('i', 'enter Id', manual_id), PromptChoice('b', 'aBort', abort_action), ] # Send the before_choose_candidate event and flatten list. extra_choices = list(chain(*plugins.send('before_choose_candidate', session=self, task=task))) # Add a "dummy" choice for the other baked-in option, for # duplicate checking. all_choices = [ PromptChoice('a', 'Apply', None), ] + choices + extra_choices # Check for conflicts. short_letters = [c.short for c in all_choices] if len(short_letters) != len(set(short_letters)): # Duplicate short letter has been found. duplicates = [i for i, count in Counter(short_letters).items() if count > 1] for short in duplicates: # Keep the first of the choices, removing the rest. dup_choices = [c for c in all_choices if c.short == short] for c in dup_choices[1:]: log.warning("Prompt choice '{0}' removed due to conflict " "with '{1}' (short letter: '{2}')", c.long, dup_choices[0].long, c.short) extra_choices.remove(c) return choices + extra_choices # 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: if not os.path.exists(syspath(normpath(path))): raise ui.UserError('no such file or directory: {}'.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 = syspath(config['import']['log'].as_filename()) try: loghandler = logging.FileHandler(logpath) except OSError: raise ui.UserError("could not open log file for writing: " "{}".format(displayable_path(logpath))) else: loghandler = 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, loghandler, paths, query) session.run() # Emit event. plugins.send('import', lib=lib, paths=paths) 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') # On Python 2, we used to get filenames as raw bytes, which is # what we need. On Python 3, we need to undo the "helpful" # conversion to Unicode strings to get the real bytestring # filename. paths = [p.encode(util.arg_encoding(), 'surrogateescape') for p in paths] import_files(lib, paths, query) 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( '-m', '--move', action='store_true', dest='move', help="move tracks into the library (overrides -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( '--from-scratch', dest='from_scratch', action='store_true', help='erase existing metadata before applying new metadata' ) import_cmd.parser.add_option( '--flat', dest='flat', action='store_true', help='import an entire tree as a single album' ) import_cmd.parser.add_option( '-g', '--group-albums', dest='group_albums', action='store_true', help='group tracks in a folder into separate albums' ) import_cmd.parser.add_option( '--pretend', dest='pretend', action='store_true', help='just print the files to import' ) import_cmd.parser.add_option( '-S', '--search-id', dest='search_ids', action='append', metavar='ID', help='restrict matching to a specific metadata backend ID' ) import_cmd.parser.add_option( '--set', dest='set_fields', action='callback', callback=_store_dict, metavar='FIELD=VALUE', help='set the given fields to the supplied values' ) 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. """ if album: for album in lib.albums(query): ui.print_(format(album, fmt)) else: for item in lib.items(query): ui.print_(format(item, fmt)) def list_func(lib, opts, args): list_items(lib, decargs(args), opts.album) list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',)) list_cmd.parser.usage += "\n" \ 'Example: %prog -f \'$album: $title\' artist:beatles' list_cmd.parser.add_all_common_options() 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, fields): """For all the items matched by the query, update the library to reflect the item's embedded tags. :param fields: The fields to be stored. If not specified, all fields will be. """ with lib.transaction(): if move and fields is not None and 'path' not in fields: # Special case: if an item needs to be moved, the path field has to # updated; otherwise the new path will not be reflected in the # database. fields.append('path') 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_(format(item)) ui.print_(ui.colorize('text_error', ' deleted')) 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('skipping {0} because mtime is up to date ({1})', displayable_path(item.path), item.mtime) continue # Read new data. try: item.read() except library.ReadError as exc: log.error('error reading {0}: {1}', 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: old_item = lib.get_item(item.id) if old_item.albumartist == old_item.artist == item.artist: item.albumartist = old_item.albumartist item._dirty.discard('albumartist') # Check for and display changes. changed = ui.show_model_changes( item, fields=fields or library.Item._media_fields) # Save changes. if not pretend: if changed: # Move the item if it's in the library. if move and lib.directory in ancestry(item.path): item.move(store=False) item.store(fields=fields) affected_albums.add(item.album_id) else: # 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(fields=fields) # 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 {0}', album_id) continue first_item = album.items().get() # Update album structure to reflect an item in it. for key in library.Album.item_keys: album[key] = first_item[key] album.store(fields=fields) # Move album art (and any inconsistent items). if move and lib.directory in ancestry(first_item.path): log.debug('moving album {0}', album_id) # Manually moving and storing the album. items = list(album.items()) for item in items: item.move(store=False, with_album=False) item.store(fields=fields) album.move(store=False) album.store(fields=fields) def update_func(lib, opts, args): # Verify that the library folder exists to prevent accidental wipes. if not os.path.isdir(lib.directory): ui.print_("Library path is unavailable or does not exist.") ui.print_(lib.directory) if not ui.input_yn("Are you sure you want to continue (y/n)?", True): return update_items(lib, decargs(args), opts.album, ui.should_move(opts.move), opts.pretend, opts.fields) update_cmd = ui.Subcommand( 'update', help='update the library', aliases=('upd', 'up',) ) update_cmd.parser.add_album_option() update_cmd.parser.add_format_option() update_cmd.parser.add_option( '-m', '--move', action='store_true', dest='move', help="move files in the library directory" ) update_cmd.parser.add_option( '-M', '--nomove', action='store_false', 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', '--field', default=None, action='append', dest='fields', help='list of fields to update' ) update_cmd.func = update_func default_commands.append(update_cmd) # remove: Remove items from library, delete files. def remove_items(lib, query, album, delete, force): """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) objs = albums if album else items # Confirm file removal if not forcing removal. if not force: # Prepare confirmation with user. album_str = " in {} album{}".format( len(albums), 's' if len(albums) > 1 else '' ) if album else "" if delete: fmt = '$path - $title' prompt = 'Really DELETE' prompt_all = 'Really DELETE {} file{}{}'.format( len(items), 's' if len(items) > 1 else '', album_str ) else: fmt = '' prompt = 'Really remove from the library?' prompt_all = 'Really remove {} item{}{} from the library?'.format( len(items), 's' if len(items) > 1 else '', album_str ) # Helpers for printing affected items def fmt_track(t): ui.print_(format(t, fmt)) def fmt_album(a): ui.print_() for i in a.items(): fmt_track(i) fmt_obj = fmt_album if album else fmt_track # Show all the items. for o in objs: fmt_obj(o) # Confirm with user. objs = ui.input_select_objects(prompt, objs, fmt_obj, prompt_all=prompt_all) if not objs: return # Remove (and possibly delete) items. with lib.transaction(): for obj in objs: obj.remove(delete) def remove_func(lib, opts, args): remove_items(lib, decargs(args), opts.album, opts.delete, opts.force) 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( "-f", "--force", action="store_true", help="do not ask when removing items" ) remove_cmd.parser.add_album_option() 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() album_artists = set() for item in items: if exact: try: total_size += os.path.getsize(syspath(item.path)) except OSError as exc: log.info('could not get size of {}: {}', item.path, exc) else: total_size += int(item.length * item.bitrate / 8) total_time += item.length total_items += 1 artists.add(item.artist) album_artists.add(item.albumartist) if item.album_id: albums.add(item.album_id) size_str = '' + ui.human_bytes(total_size) if exact: size_str += f' ({total_size} bytes)' print_("""Tracks: {} Total time: {}{} {}: {} Artists: {} Albums: {} Album artists: {}""".format( total_items, ui.human_seconds(total_time), f' ({total_time:.2f} seconds)' if exact else '', 'Total size' if exact else 'Approximate total size', size_str, len(artists), len(albums), len(album_artists)), ) def stats_func(lib, opts, args): show_stats(lib, decargs(args), opts.exact) 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='exact size and time' ) 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__) print_(f'Python version {python_version()}') # Show plugins. names = sorted(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 modify_items(lib, mods, dels, query, write, move, album, confirm): """Modifies matching items according to user-specified assignments and deletions. `mods` is a dictionary of field and value pairse indicating assignments. `dels` is a list of fields to be deleted. """ # Parse key=value specifications into a dictionary. model_cls = library.Album if album else library.Item for key, value in mods.items(): mods[key] = model_cls._parse(key, value) # Get the items to modify. items, albums = _do_query(lib, query, album, False) objs = albums if album else items # Apply changes *temporarily*, preview them, and collect modified # objects. print_('Modifying {} {}s.' .format(len(objs), 'album' if album else 'item')) changed = [] for obj in objs: if print_and_modify(obj, mods, dels) and obj not in changed: changed.append(obj) # Still something to do? if not changed: print_('No changes to make.') return # Confirm action. if confirm: if write and move: extra = ', move and write tags' elif write: extra = ' and write tags' elif move: extra = ' and move' else: extra = '' changed = ui.input_select_objects( 'Really modify%s' % extra, changed, lambda o: print_and_modify(o, mods, dels) ) # Apply changes to database and files with lib.transaction(): for obj in changed: obj.try_sync(write, move) def print_and_modify(obj, mods, dels): """Print the modifications to an item and return a bool indicating whether any changes were made. `mods` is a dictionary of fields and values to update on the object; `dels` is a sequence of fields to delete. """ obj.update(mods) for field in dels: try: del obj[field] except KeyError: pass return ui.show_model_changes(obj) def modify_parse_args(args): """Split the arguments for the modify subcommand into query parts, assignments (field=value), and deletions (field!). Returns the result as a three-tuple in that order. """ mods = {} dels = [] query = [] for arg in args: if arg.endswith('!') and '=' not in arg and ':' not in arg: dels.append(arg[:-1]) # Strip trailing !. elif '=' in arg and ':' not in arg.split('=', 1)[0]: key, val = arg.split('=', 1) mods[key] = val else: query.append(arg) return query, mods, dels def modify_func(lib, opts, args): query, mods, dels = modify_parse_args(decargs(args)) if not mods and not dels: raise ui.UserError('no modifications specified') modify_items(lib, mods, dels, query, ui.should_write(opts.write), ui.should_move(opts.move), opts.album, not opts.yes) modify_cmd = ui.Subcommand( 'modify', help='change metadata fields', aliases=('mod',) ) modify_cmd.parser.add_option( '-m', '--move', action='store_true', dest='move', help="move files in the library directory" ) modify_cmd.parser.add_option( '-M', '--nomove', action='store_false', 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_album_option() modify_cmd.parser.add_format_option(target='item') modify_cmd.parser.add_option( '-y', '--yes', action='store_true', help='skip confirmation' ) 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, pretend, confirm=False, export=False): """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 num_objs = len(objs) # Filter out files that don't need to be moved. def isitemmoved(item): return item.path != item.destination(basedir=dest) def isalbummoved(album): return any(isitemmoved(i) for i in album.items()) objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] num_unmoved = num_objs - len(objs) # Report unmoved files that match the query. unmoved_msg = '' if num_unmoved > 0: unmoved_msg = f' ({num_unmoved} already in place)' copy = copy or export # Exporting always copies. action = 'Copying' if copy else 'Moving' act = 'copy' if copy else 'move' entity = 'album' if album else 'item' log.info('{0} {1} {2}{3}{4}.', action, len(objs), entity, 's' if len(objs) != 1 else '', unmoved_msg) if not objs: return if pretend: if album: show_path_changes([(item.path, item.destination(basedir=dest)) for obj in objs for item in obj.items()]) else: show_path_changes([(obj.path, obj.destination(basedir=dest)) for obj in objs]) else: if confirm: objs = ui.input_select_objects( 'Really %s' % act, objs, lambda o: show_path_changes( [(o.path, o.destination(basedir=dest))])) for obj in objs: log.debug('moving: {0}', util.displayable_path(obj.path)) if export: # Copy without affecting the database. obj.move(operation=MoveOperation.COPY, basedir=dest, store=False) else: # Ordinary move/copy: store the new path. if copy: obj.move(operation=MoveOperation.COPY, basedir=dest) else: obj.move(operation=MoveOperation.MOVE, basedir=dest) 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, opts.pretend, opts.timid, opts.export) 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( '-p', '--pretend', default=False, action='store_true', help='show how files would be moved, but don\'t touch anything' ) move_cmd.parser.add_option( '-t', '--timid', dest='timid', action='store_true', help='always confirm all actions' ) move_cmd.parser.add_option( '-e', '--export', default=False, action='store_true', help='copy without changing the database path' ) move_cmd.parser.add_album_option() move_cmd.func = move_func default_commands.append(move_cmd) # write: Write tags into files. def write_items(lib, query, pretend, force): """Write tag information from the database to the respective files in the filesystem. """ items, albums = _do_query(lib, query, False, False) for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): log.info('missing file: {0}', util.displayable_path(item.path)) continue # Get an Item object reflecting the "clean" (on-disk) state. try: clean_item = library.Item.from_path(item.path) except library.ReadError as exc: log.error('error reading {0}: {1}', displayable_path(item.path), exc) continue # Check for and display changes. changed = ui.show_model_changes(item, clean_item, library.Item._media_tag_fields, force) if (changed or force) and not pretend: # We use `try_sync` here to keep the mtime up to date in the # database. item.try_sync(True, False) def write_func(lib, opts, args): write_items(lib, decargs(args), opts.pretend, opts.force) write_cmd = ui.Subcommand('write', help='write tag information to files') write_cmd.parser.add_option( '-p', '--pretend', action='store_true', help="show all changes but do nothing" ) write_cmd.parser.add_option( '-f', '--force', action='store_true', help="write tags even if the existing tags match the database" ) write_cmd.func = write_func default_commands.append(write_cmd) # config: Show and edit user configuration. def config_func(lib, opts, args): # Make sure lazy configuration is loaded config.resolve() # Print paths. if opts.paths: filenames = [] for source in config.sources: if not opts.defaults and source.default: continue if source.filename: filenames.append(source.filename) # In case the user config file does not exist, prepend it to the # list. user_path = config.user_config_path() if user_path not in filenames: filenames.insert(0, user_path) for filename in filenames: print_(displayable_path(filename)) # Open in editor. elif opts.edit: config_edit() # Dump configuration. else: config_out = config.dump(full=opts.defaults, redact=opts.redact) if config_out.strip() != '{}': print_(util.text_string(config_out)) else: print("Empty configuration") def config_edit(): """Open a program to edit the user configuration. An empty config file is created if no existing config file exists. """ path = config.user_config_path() editor = util.editor_command() try: if not os.path.isfile(path): open(path, 'w+').close() util.interactive_open([path], editor) except OSError as exc: message = f"Could not edit configuration: {exc}" if not editor: message += ". Please set the EDITOR environment variable" raise ui.UserError(message) config_cmd = ui.Subcommand('config', help='show or edit the user configuration') config_cmd.parser.add_option( '-p', '--paths', action='store_true', help='show files that configuration was loaded from' ) config_cmd.parser.add_option( '-e', '--edit', action='store_true', help='edit user configuration with $EDITOR' ) config_cmd.parser.add_option( '-d', '--defaults', action='store_true', help='include the default configuration' ) config_cmd.parser.add_option( '-c', '--clear', action='store_false', dest='redact', default=True, help='do not redact sensitive fields' ) config_cmd.func = config_func default_commands.append(config_cmd) # completion: print completion script def print_completion(*args): for line in completion_script(default_commands + plugins.commands()): print_(line, end='') if not any(map(os.path.isfile, BASH_COMPLETION_PATHS)): log.warning('Warning: Unable to find the bash-completion package. ' 'Command line completion might not work.') BASH_COMPLETION_PATHS = map(syspath, [ '/etc/bash_completion', '/usr/share/bash-completion/bash_completion', '/usr/local/share/bash-completion/bash_completion', # SmartOS '/opt/local/share/bash-completion/bash_completion', # Homebrew (before bash-completion2) '/usr/local/etc/bash_completion', ]) def completion_script(commands): """Yield the full completion shell script as strings. ``commands`` is alist of ``ui.Subcommand`` instances to generate completion data for. """ base_script = os.path.join(os.path.dirname(__file__), 'completion_base.sh') with open(base_script) as base_script: yield util.text_string(base_script.read()) options = {} aliases = {} command_names = [] # Collect subcommands for cmd in commands: name = cmd.name command_names.append(name) for alias in cmd.aliases: if re.match(r'^\w+$', alias): aliases[alias] = name options[name] = {'flags': [], 'opts': []} for opts in cmd.parser._get_all_options()[1:]: if opts.action in ('store_true', 'store_false'): option_type = 'flags' else: option_type = 'opts' options[name][option_type].extend( opts._short_opts + opts._long_opts ) # Add global options options['_global'] = { 'flags': ['-v', '--verbose'], 'opts': '-l --library -c --config -d --directory -h --help'.split(' ') } # Add flags common to all commands options['_common'] = { 'flags': ['-h', '--help'] } # Start generating the script yield "_beet() {\n" # Command names yield " local commands='%s'\n" % ' '.join(command_names) yield "\n" # Command aliases yield " local aliases='%s'\n" % ' '.join(aliases.keys()) for alias, cmd in aliases.items(): yield " local alias__{}={}\n".format(alias.replace('-', '_'), cmd) yield '\n' # Fields yield " fields='%s'\n" % ' '.join( set( list(library.Item._fields.keys()) + list(library.Album._fields.keys()) ) ) # Command options for cmd, opts in options.items(): for option_type, option_list in opts.items(): if option_list: option_list = ' '.join(option_list) yield " local {}__{}='{}'\n".format( option_type, cmd.replace('-', '_'), option_list) yield ' _beet_dispatch\n' yield '}\n' completion_cmd = ui.Subcommand( 'completion', help='print shell script that provides command line completion' ) completion_cmd.func = print_completion completion_cmd.hide = True default_commands.append(completion_cmd) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/beets/ui/completion_base.sh0000644000076500000240000001054600000000000017551 0ustar00asampsonstaff# This file is part of beets. # Copyright (c) 2014, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # Completion for the `beet` command # ================================= # # Load this script to complete beets subcommands, options, and # queries. # # If a beets command is found on the command line it completes filenames and # the subcommand's options. Otherwise it will complete global options and # subcommands. If the previous option on the command line expects an argument, # it also completes filenames or directories. Options are only # completed if '-' has already been typed on the command line. # # Note that completion of plugin commands only works for those plugins # that were enabled when running `beet completion`. It does not check # plugins dynamically # # Currently, only Bash 3.2 and newer is supported and the # `bash-completion` package is requied. # # TODO # ---- # # * There are some issues with arguments that are quoted on the command line. # # * Complete arguments for the `--format` option by expanding field variables. # # beet ls -f "$tit[TAB] # beet ls -f "$title # # * Support long options with `=`, e.g. `--config=file`. Debian's bash # completion package can handle this. # # Determines the beets subcommand and dispatches the completion # accordingly. _beet_dispatch() { local cur prev cmd= COMPREPLY=() _get_comp_words_by_ref -n : cur prev # Look for the beets subcommand local arg for (( i=1; i < COMP_CWORD; i++ )); do arg="${COMP_WORDS[i]}" if _list_include_item "${opts___global}" $arg; then ((i++)) elif [[ "$arg" != -* ]]; then cmd="$arg" break fi done # Replace command shortcuts if [[ -n $cmd ]] && _list_include_item "$aliases" "$cmd"; then eval "cmd=\$alias__${cmd//-/_}" fi case $cmd in help) COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) ;; list|remove|move|update|write|stats) _beet_complete_query ;; "") _beet_complete_global ;; *) _beet_complete ;; esac } # Adds option and file completion to COMPREPLY for the subcommand $cmd _beet_complete() { if [[ $cur == -* ]]; then local opts flags completions eval "opts=\$opts__${cmd//-/_}" eval "flags=\$flags__${cmd//-/_}" completions="${flags___common} ${opts} ${flags}" COMPREPLY+=( $(compgen -W "$completions" -- $cur) ) else _filedir fi } # Add global options and subcommands to the completion _beet_complete_global() { case $prev in -h|--help) # Complete commands COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) return ;; -l|--library|-c|--config) # Filename completion _filedir return ;; -d|--directory) # Directory completion _filedir -d return ;; esac if [[ $cur == -* ]]; then local completions="$opts___global $flags___global" COMPREPLY+=( $(compgen -W "$completions" -- $cur) ) elif [[ -n $cur ]] && _list_include_item "$aliases" "$cur"; then local cmd eval "cmd=\$alias__${cur//-/_}" COMPREPLY+=( "$cmd" ) else COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) fi } _beet_complete_query() { local opts eval "opts=\$opts__${cmd//-/_}" if [[ $cur == -* ]] || _list_include_item "$opts" "$prev"; then _beet_complete elif [[ $cur != \'* && $cur != \"* && $cur != *:* ]]; then # Do not complete quoted queries or those who already have a field # set. compopt -o nospace COMPREPLY+=( $(compgen -S : -W "$fields" -- $cur) ) return 0 fi } # Returns true if the space separated list $1 includes $2 _list_include_item() { [[ " $1 " == *[[:space:]]$2[[:space:]]* ]] } # This is where beets dynamically adds the _beet function. This # function sets the variables $flags, $opts, $commands, and $aliases. complete -o filenames -F _beet beet ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1638031078.2643437 beets-1.6.0/beets/util/0000755000076500000240000000000000000000000014404 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beets/util/__init__.py0000644000076500000240000011007600000000000016522 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Miscellaneous utility functions.""" import os import sys import errno import locale import re import tempfile import shutil import fnmatch import functools from collections import Counter, namedtuple from multiprocessing.pool import ThreadPool import traceback import subprocess import platform import shlex from beets.util import hidden from unidecode import unidecode from enum import Enum MAX_FILENAME_LENGTH = 200 WINDOWS_MAGIC_PREFIX = '\\\\?\\' 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().__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, str): return self.reason elif isinstance(self.reason, bytes): return self.reason.decode('utf-8', 'ignore') elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError return self.reason.strerror else: return '"{}"'.format(str(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('{0}: {1}', 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().__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 {} {} to {}'.format( self._gerund(), displayable_path(self.paths[0]), displayable_path(self.paths[1]) ) elif self.verb in ('delete', 'write', 'create', 'read'): clause = 'while {} {}'.format( self._gerund(), displayable_path(self.paths[0]) ) else: clause = 'during {} of paths {}'.format( self.verb, ', '.join(displayable_path(p) for p in self.paths) ) return f'{self._reasonstr()} {clause}' class MoveOperation(Enum): """The file operations that e.g. various move functions can carry out. """ MOVE = 0 COPY = 1 LINK = 2 HARDLINK = 3 REFLINK = 4 REFLINK_AUTO = 5 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): """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`. """ out = [] last_path = None while path: path = os.path.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=(), ignore_hidden=False, 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 pathes aren't Unicode strings. path = bytestring_path(path) ignore = [bytestring_path(i) for i in ignore] # Get all the directories and files at this level. try: contents = os.listdir(syspath(path)) except OSError as exc: if logger: logger.warning('could not list directory {}: {}'.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): if logger: logger.debug('ignoring {} due to ignore rule {}'.format( 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 (ignore_hidden and not hidden.is_hidden(cur)) or not ignore_hidden: 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(...) yield from sorted_walk(cur, ignore, ignore_hidden, logger) def path_as_posix(path): """Return the string representation of the path with forward (/) slashes. """ return path.replace(b'\\', b'/') 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 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 clutter = [bytestring_path(c) for c in clutter] match_paths = [bytestring_path(d) for d in os.listdir(directory)] try: if fnmatch_all(match_paths, clutter): # Directory contains only clutter (or nothing). shutil.rmtree(directory) else: break except OSError: break def components(path): """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`. """ comps = [] ances = ancestry(path) for anc in ances: comp = os.path.basename(anc) if comp: comps.append(comp) else: # root comps.append(anc) last = os.path.basename(path) if last: comps.append(last) return comps def arg_encoding(): """Get the encoding for command-line arguments (and other OS locale-sensitive strings). """ try: return locale.getdefaultlocale()[1] or 'utf-8' except ValueError: # Invalid locale environment variable setting. To avoid # failing entirely for no good reason, assume UTF-8. return 'utf-8' 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 = 'utf-8' return encoding def bytestring_path(path): """Given a path, which is either a bytes or a unicode, returns a str path (ensuring that we never deal with Unicode pathnames). """ # Pass through bytestrings. if isinstance(path, bytes): 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 os.path.__name__ == 'ntpath' and path.startswith(WINDOWS_MAGIC_PREFIX): path = path[len(WINDOWS_MAGIC_PREFIX):] # Try to encode with default encodings, but fall back to utf-8. try: return path.encode(_fsencoding()) except (UnicodeError, LookupError): return path.encode('utf-8') PATH_SEP = bytestring_path(os.sep) def displayable_path(path, separator='; '): """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, str): return path elif not isinstance(path, bytes): # A non-string object: just get its unicode representation. return str(path) try: return path.decode(_fsencoding(), 'ignore') except (UnicodeError, LookupError): return path.decode('utf-8', 'ignore') def syspath(path, prefix=True): """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. """ # Don't do anything if we're not on windows if os.path.__name__ != 'ntpath': return path if not isinstance(path, str): # 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('utf-8') 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. # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX): if path.startswith('\\\\'): # UNC path. Final path should look like \\?\UNC\... path = 'UNC' + path[1:] path = WINDOWS_MAGIC_PREFIX + path return path def samefile(p1, p2): """Safer equality for paths.""" if p1 == p2: return True 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 as exc: raise FilesystemError(exc, 'delete', (path,), traceback.format_exc()) def copy(path, dest, replace=False): """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 os.path.exists(dest): raise FilesystemError('file exists', 'copy', (path, dest)) try: shutil.copyfile(path, dest) except OSError as exc: raise FilesystemError(exc, 'copy', (path, dest), traceback.format_exc()) def move(path, dest, replace=False): """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 os.path.isdir(path): raise FilesystemError(u'source is directory', 'move', (path, dest)) if os.path.isdir(dest): raise FilesystemError(u'destination is directory', 'move', (path, dest)) if samefile(path, dest): return path = syspath(path) dest = syspath(dest) if os.path.exists(dest) and not replace: raise FilesystemError('file exists', 'rename', (path, dest)) # First, try renaming the file. try: os.replace(path, dest) except OSError: tmp = tempfile.mktemp(suffix='.beets', prefix=py3_path(b'.' + os.path.basename(dest)), dir=py3_path(os.path.dirname(dest))) tmp = syspath(tmp) try: shutil.copyfile(path, tmp) os.replace(tmp, dest) tmp = None os.remove(path) except OSError as exc: raise FilesystemError(exc, 'move', (path, dest), traceback.format_exc()) finally: if tmp is not None: os.remove(tmp) def link(path, dest, replace=False): """Create a symbolic link from path to `dest`. Raises an OSError if `dest` already exists, unless `replace` is True. Does nothing if `path` == `dest`. """ if samefile(path, dest): return if os.path.exists(syspath(dest)) and not replace: raise FilesystemError('file exists', 'rename', (path, dest)) try: os.symlink(syspath(path), syspath(dest)) except NotImplementedError: # raised on python >= 3.2 and Windows versions before Vista raise FilesystemError('OS does not support symbolic links.' 'link', (path, dest), traceback.format_exc()) except OSError as exc: # TODO: Windows version checks can be removed for python 3 if hasattr('sys', 'getwindowsversion'): if sys.getwindowsversion()[0] < 6: # is before Vista exc = 'OS does not support symbolic links.' raise FilesystemError(exc, 'link', (path, dest), traceback.format_exc()) def hardlink(path, dest, replace=False): """Create a hard link from path to `dest`. Raises an OSError if `dest` already exists, unless `replace` is True. Does nothing if `path` == `dest`. """ if samefile(path, dest): return if os.path.exists(syspath(dest)) and not replace: raise FilesystemError('file exists', 'rename', (path, dest)) try: os.link(syspath(path), syspath(dest)) except NotImplementedError: raise FilesystemError('OS does not support hard links.' 'link', (path, dest), traceback.format_exc()) except OSError as exc: if exc.errno == errno.EXDEV: raise FilesystemError('Cannot hard link across devices.' 'link', (path, dest), traceback.format_exc()) else: raise FilesystemError(exc, 'link', (path, dest), traceback.format_exc()) def reflink(path, dest, replace=False, fallback=False): """Create a reflink from `dest` to `path`. Raise an `OSError` if `dest` already exists, unless `replace` is True. If `path` == `dest`, then do nothing. If reflinking fails and `fallback` is enabled, try copying the file instead. Otherwise, raise an error without trying a plain copy. May raise an `ImportError` if the `reflink` module is not available. """ import reflink as pyreflink if samefile(path, dest): return if os.path.exists(syspath(dest)) and not replace: raise FilesystemError('file exists', 'rename', (path, dest)) try: pyreflink.reflink(path, dest) except (NotImplementedError, pyreflink.ReflinkImpossibleError): if fallback: copy(path, dest, replace) else: raise FilesystemError('OS/filesystem does not support reflinks.', 'link', (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(br'\.(\d)+$', base) if match: num = int(match.group(1)) base = base[:match.start()] else: num = 0 while True: num += 1 suffix = f'.{num}'.encode() + ext new_path = base + suffix 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. # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx CHAR_REPLACE = [ (re.compile(r'[\\/]'), '_'), # / and \ -- forbidden everywhere. (re.compile(r'^\.'), '_'), # Leading dot (hidden files on Unix). (re.compile(r'[\x00-\x1f]'), ''), # Control characters. (re.compile(r'[<>:"\?\*\|]'), '_'), # Windows "reserved characters". (re.compile(r'\.$'), '_'), # Trailing dots. (re.compile(r'\s+$'), ''), # Trailing whitespace. ] def sanitize_path(path, 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. """ replacements = replacements or CHAR_REPLACE comps = components(path) if not comps: return '' for i, comp in enumerate(comps): for regex, repl in replacements: comp = regex.sub(repl, comp) comps[i] = comp return os.path.join(*comps) def truncate_path(path, 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. """ comps = components(path) out = [c[:length] for c in comps] base, ext = os.path.splitext(comps[-1]) if ext: # Last component has an extension. base = base[:length - len(ext)] out[-1] = base + ext return os.path.join(*out) def _legalize_stage(path, replacements, length, extension, fragment): """Perform a single round of path legalization steps (sanitation/replacement, encoding from Unicode to bytes, extension-appending, and truncation). Return the path (Unicode if `fragment` is set, `bytes` otherwise) and whether truncation was required. """ # Perform an initial sanitization including user replacements. path = sanitize_path(path, replacements) # Encode for the filesystem. if not fragment: path = bytestring_path(path) # Preserve extension. path += extension.lower() # Truncate too-long components. pre_truncate_path = path path = truncate_path(path, length) return path, path != pre_truncate_path def legalize_path(path, replacements, length, extension, fragment): """Given a path-like Unicode string, produce a legal path. Return the path and a flag indicating whether some replacements had to be ignored (see below). The legalization process (see `_legalize_stage`) consists of applying the sanitation rules in `replacements`, encoding the string to bytes (unless `fragment` is set), truncating components to `length`, appending the `extension`. This function performs up to three calls to `_legalize_stage` in case truncation conflicts with replacements (as can happen when truncation creates whitespace at the end of the string, for example). The limited number of iterations iterations avoids the possibility of an infinite loop of sanitation and truncation operations, which could be caused by replacement rules that make the string longer. The flag returned from this function indicates that the path has to be truncated twice (indicating that replacements made the string longer again after it was truncated); the application should probably log some sort of warning. """ if fragment: # Outputting Unicode. extension = extension.decode('utf-8', 'ignore') first_stage_path, _ = _legalize_stage( path, replacements, length, extension, fragment ) # Convert back to Unicode with extension removed. first_stage_path, _ = os.path.splitext(displayable_path(first_stage_path)) # Re-sanitize following truncation (including user replacements). second_stage_path, retruncated = _legalize_stage( first_stage_path, replacements, length, extension, fragment ) # If the path was once again truncated, discard user replacements # and run through one last legalization stage. if retruncated: second_stage_path, _ = _legalize_stage( first_stage_path, None, length, extension, fragment ) return second_stage_path, retruncated def py3_path(path): """Convert a bytestring path to Unicode on Python 3 only. On Python 2, return the bytestring path unchanged. This helps deal with APIs on Python 3 that *only* accept Unicode (i.e., `str` objects). I philosophically disagree with this decision, because paths are sadly bytes on Unix, but that's the way it is. So this function helps us "smuggle" the true bytes data through APIs that took Python 3's Unicode mandate too seriously. """ if isinstance(path, str): return path assert isinstance(path, bytes) return os.fsdecode(path) def str2bool(value): """Returns a boolean reflecting a human-entered string.""" return value.lower() in ('yes', '1', 'true', 't', 'y') 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 '' elif isinstance(value, memoryview): return bytes(value).decode('utf-8', 'ignore') elif isinstance(value, bytes): return value.decode('utf-8', 'ignore') else: return str(value) def text_string(value, encoding='utf-8'): """Convert a string, which can either be bytes or unicode, to unicode. Text (unicode) is left untouched; bytes are decoded. This is useful to convert from a "native string" (bytes on Python 2, str on Python 3) to a consistently unicode value. """ if isinstance(value, bytes): return value.decode(encoding) return value def plurality(objs): """Given a sequence of hashble objects, returns the object that is most common in the set and the its number of appearance. The sequence must contain at least one object. """ c = Counter(objs) if not c: raise ValueError('sequence must be non-empty') return c.most_common(1)[0] 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(command_output([ '/usr/sbin/sysctl', '-n', 'hw.ncpu', ]).stdout) except (ValueError, OSError, subprocess.CalledProcessError): 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 convert_command_args(args): """Convert command arguments to bytestrings on Python 2 and surrogate-escaped strings on Python 3.""" assert isinstance(args, list) def convert(arg): if isinstance(arg, bytes): arg = arg.decode(arg_encoding(), 'surrogateescape') return arg return [convert(a) for a in args] # stdout and stderr as bytes CommandOutput = namedtuple("CommandOutput", ("stdout", "stderr")) def command_output(cmd, shell=False): """Runs the command and returns its output after it has exited. Returns a CommandOutput. The attributes ``stdout`` and ``stderr`` contain byte strings of the respective output streams. ``cmd`` is a list of arguments starting with the command names. The arguments are bytes on Unix and strings on Windows. If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a shell to execute. If the process exits with a non-zero return code ``subprocess.CalledProcessError`` is raised. May also raise ``OSError``. This replaces `subprocess.check_output` which can have problems if lots of output is sent to stderr. """ cmd = convert_command_args(cmd) try: # python >= 3.3 devnull = subprocess.DEVNULL except AttributeError: devnull = open(os.devnull, 'r+b') proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=devnull, close_fds=platform.system() != 'Windows', shell=shell ) stdout, stderr = proc.communicate() if proc.returncode: raise subprocess.CalledProcessError( returncode=proc.returncode, cmd=' '.join(cmd), output=stdout + stderr, ) return CommandOutput(stdout, stderr) 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 def open_anything(): """Return the system command that dispatches execution to the correct program. """ sys_name = platform.system() if sys_name == 'Darwin': base_cmd = 'open' elif sys_name == 'Windows': base_cmd = 'start' else: # Assume Unix base_cmd = 'xdg-open' return base_cmd def editor_command(): """Get a command for opening a text file. Use the `EDITOR` environment variable by default. If it is not present, fall back to `open_anything()`, the platform-specific tool for opening files in general. """ editor = os.environ.get('EDITOR') if editor: return editor return open_anything() def interactive_open(targets, command): """Open the files in `targets` by `exec`ing a new `command`, given as a Unicode string. (The new program takes over, and Python execution ends: this does not fork a subprocess.) Can raise `OSError`. """ assert command # Split the command string into its arguments. try: args = shlex.split(command) except ValueError: # Malformed shell tokens. args = [command] args.insert(0, args[0]) # for argv[0] args += targets return os.execlp(*args) def _windows_long_path_name(short_path): """Use Windows' `GetLongPathNameW` via ctypes to get the canonical, long path given a short filename. """ if not isinstance(short_path, str): short_path = short_path.decode(_fsencoding()) import ctypes buf = ctypes.create_unicode_buffer(260) get_long_path_name_w = ctypes.windll.kernel32.GetLongPathNameW return_value = get_long_path_name_w(short_path, buf, 260) if return_value == 0 or return_value > 260: # An error occurred return short_path else: long_path = buf.value # GetLongPathNameW does not change the case of the drive # letter. if len(long_path) > 1 and long_path[1] == ':': long_path = long_path[0].upper() + long_path[1:] return long_path def case_sensitive(path): """Check whether the filesystem at the given path is case sensitive. To work best, the path should point to a file or a directory. If the path does not exist, assume a case sensitive file system on every platform except Windows. """ # A fallback in case the path does not exist. if not os.path.exists(syspath(path)): # By default, the case sensitivity depends on the platform. return platform.system() != 'Windows' # If an upper-case version of the path exists but a lower-case # version does not, then the filesystem must be case-sensitive. # (Otherwise, we have more work to do.) if not (os.path.exists(syspath(path.lower())) and os.path.exists(syspath(path.upper()))): return True # Both versions of the path exist on the file system. Check whether # they refer to different files by their inodes. Alas, # `os.path.samefile` is only available on Unix systems on Python 2. if platform.system() != 'Windows': return not os.path.samefile(syspath(path.lower()), syspath(path.upper())) # On Windows, we check whether the canonical, long filenames for the # files are the same. lower = _windows_long_path_name(path.lower()) upper = _windows_long_path_name(path.upper()) return lower != upper def raw_seconds_short(string): """Formats a human-readable M:SS string as a float (number of seconds). Raises ValueError if the conversion cannot take place due to `string` not being in the right format. """ match = re.match(r'^(\d+):([0-5]\d)$', string) if not match: raise ValueError('String not in M:SS format') minutes, seconds = map(int, match.groups()) return float(minutes * 60 + seconds) def asciify_path(path, sep_replace): """Decodes all unicode characters in a path into ASCII equivalents. Substitutions are provided by the unidecode module. Path separators in the input are preserved. Keyword arguments: path -- The path to be asciified. sep_replace -- the string to be used to replace extraneous path separators. """ # if this platform has an os.altsep, change it to os.sep. if os.altsep: path = path.replace(os.altsep, os.sep) path_components = path.split(os.sep) for index, item in enumerate(path_components): path_components[index] = unidecode(item).replace(os.sep, sep_replace) if os.altsep: path_components[index] = unidecode(item).replace( os.altsep, sep_replace ) return os.sep.join(path_components) def par_map(transform, items): """Apply the function `transform` to all the elements in the iterable `items`, like `map(transform, items)` but with no return value. The map *might* happen in parallel: it's parallel on Python 3 and sequential on Python 2. The parallelism uses threads (not processes), so this is only useful for IO-bound `transform`s. """ pool = ThreadPool() pool.map(transform, items) pool.close() pool.join() def lazy_property(func): """A decorator that creates a lazily evaluated property. On first access, the property is assigned the return value of `func`. This first value is stored, so that future accesses do not have to evaluate `func` again. This behaviour is useful when `func` is expensive to evaluate, and it is not certain that the result will be needed. """ field_name = '_' + func.__name__ @property @functools.wraps(func) def wrapper(self): if hasattr(self, field_name): return getattr(self, field_name) value = func(self) setattr(self, field_name, value) return value return wrapper def decode_commandline_path(path): """Prepare a path for substitution into commandline template. On Python 3, we need to construct the subprocess commands to invoke as a Unicode string. On Unix, this is a little unfortunate---the OS is expecting bytes---so we use surrogate escaping and decode with the argument encoding, which is the same encoding that will then be *reversed* to recover the same bytes before invoking the OS. On Windows, we want to preserve the Unicode filename "as is." """ # On Python 3, the template is a Unicode string, which only supports # substitution of Unicode variables. if platform.system() == 'Windows': return path.decode(_fsencoding()) else: return path.decode(arg_encoding(), 'surrogateescape') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beets/util/artresizer.py0000644000076500000240000003561000000000000017155 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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 subprocess import os import os.path import re from tempfile import NamedTemporaryFile from urllib.parse import urlencode from beets import logging from beets import util # Resizing methods PIL = 1 IMAGEMAGICK = 2 WEBPROXY = 3 PROXY_URL = 'https://images.weserv.nl/' log = logging.getLogger('beets') def resize_url(url, maxwidth, quality=0): """Return a proxied image URL that resizes the original image to maxwidth (preserving aspect ratio). """ params = { 'url': url.replace('http://', ''), 'w': maxwidth, } if quality > 0: params['q'] = quality return '{}?{}'.format(PROXY_URL, urlencode(params)) 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=util.py3_path(ext), delete=False) as f: return util.bytestring_path(f.name) def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0): """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('artresizer: PIL resizing {0} to {1}', 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) if quality == 0: # Use PIL's default quality. quality = -1 # progressive=False only affects JPEGs and is the default, # but we include it here for explicitness. im.save(util.py3_path(path_out), quality=quality, progressive=False) if max_filesize > 0: # If maximum filesize is set, we attempt to lower the quality of # jpeg conversion by a proportional amount, up to 3 attempts # First, set the maximum quality to either provided, or 95 if quality > 0: lower_qual = quality else: lower_qual = 95 for i in range(5): # 5 attempts is an abitrary choice filesize = os.stat(util.syspath(path_out)).st_size log.debug("PIL Pass {0} : Output size: {1}B", i, filesize) if filesize <= max_filesize: return path_out # The relationship between filesize & quality will be # image dependent. lower_qual -= 10 # Restrict quality dropping below 10 if lower_qual < 10: lower_qual = 10 # Use optimize flag to improve filesize decrease im.save(util.py3_path(path_out), quality=lower_qual, optimize=True, progressive=False) log.warning("PIL Failed to resize file to below {0}B", max_filesize) return path_out else: return path_out except OSError: log.error("PIL cannot create thumbnail for '{0}'", util.displayable_path(path_in)) return path_in def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0): """Resize using ImageMagick. Use the ``magick`` program or ``convert`` on older versions. Return the output path of resized image. """ path_out = path_out or temp_file_for(path_in) log.debug('artresizer: ImageMagick resizing {0} to {1}', util.displayable_path(path_in), util.displayable_path(path_out)) # "-resize WIDTHx>" shrinks images with the width larger # than the given width while maintaining the aspect ratio # with regards to the height. # ImageMagick already seems to default to no interlace, but we include it # here for the sake of explicitness. cmd = ArtResizer.shared.im_convert_cmd + [ util.syspath(path_in, prefix=False), '-resize', f'{maxwidth}x>', '-interlace', 'none', ] if quality > 0: cmd += ['-quality', f'{quality}'] # "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick to # SIZE in bytes. if max_filesize > 0: cmd += ['-define', f'jpeg:extent={max_filesize}b'] cmd.append(util.syspath(path_out, prefix=False)) try: util.command_output(cmd) except subprocess.CalledProcessError: log.warning('artresizer: IM convert failed for {0}', util.displayable_path(path_in)) return path_in return path_out BACKEND_FUNCS = { PIL: pil_resize, IMAGEMAGICK: im_resize, } def pil_getsize(path_in): from PIL import Image try: im = Image.open(util.syspath(path_in)) return im.size except OSError as exc: log.error("PIL could not read file {}: {}", util.displayable_path(path_in), exc) def im_getsize(path_in): cmd = ArtResizer.shared.im_identify_cmd + \ ['-format', '%w %h', util.syspath(path_in, prefix=False)] try: out = util.command_output(cmd).stdout except subprocess.CalledProcessError as exc: log.warning('ImageMagick size query failed') log.debug( '`convert` exited with (status {}) when ' 'getting size with command {}:\n{}', exc.returncode, cmd, exc.output.strip() ) return try: return tuple(map(int, out.split(b' '))) except IndexError: log.warning('Could not understand IM output: {0!r}', out) BACKEND_GET_SIZE = { PIL: pil_getsize, IMAGEMAGICK: im_getsize, } def pil_deinterlace(path_in, path_out=None): path_out = path_out or temp_file_for(path_in) from PIL import Image try: im = Image.open(util.syspath(path_in)) im.save(util.py3_path(path_out), progressive=False) return path_out except IOError: return path_in def im_deinterlace(path_in, path_out=None): path_out = path_out or temp_file_for(path_in) cmd = ArtResizer.shared.im_convert_cmd + [ util.syspath(path_in, prefix=False), '-interlace', 'none', util.syspath(path_out, prefix=False), ] try: util.command_output(cmd) return path_out except subprocess.CalledProcessError: return path_in DEINTERLACE_FUNCS = { PIL: pil_deinterlace, IMAGEMAGICK: im_deinterlace, } def im_get_format(filepath): cmd = ArtResizer.shared.im_identify_cmd + [ '-format', '%[magick]', util.syspath(filepath) ] try: return util.command_output(cmd).stdout except subprocess.CalledProcessError: return None def pil_get_format(filepath): from PIL import Image, UnidentifiedImageError try: with Image.open(util.syspath(filepath)) as im: return im.format except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError): log.exception("failed to detect image format for {}", filepath) return None BACKEND_GET_FORMAT = { PIL: pil_get_format, IMAGEMAGICK: im_get_format, } def im_convert_format(source, target, deinterlaced): cmd = ArtResizer.shared.im_convert_cmd + [ util.syspath(source), *(["-interlace", "none"] if deinterlaced else []), util.syspath(target), ] try: subprocess.check_call( cmd, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL ) return target except subprocess.CalledProcessError: return source def pil_convert_format(source, target, deinterlaced): from PIL import Image, UnidentifiedImageError try: with Image.open(util.syspath(source)) as im: im.save(util.py3_path(target), progressive=not deinterlaced) return target except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError, OSError): log.exception("failed to convert image {} -> {}", source, target) return source BACKEND_CONVERT_IMAGE_FORMAT = { PIL: pil_convert_format, IMAGEMAGICK: im_convert_format, } 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().__init__(name, bases, dict) cls._instance = None @property def shared(cls): if cls._instance is None: cls._instance = cls() return cls._instance class ArtResizer(metaclass=Shareable): """A singleton class that performs image resizes. """ def __init__(self): """Create a resizer object with an inferred method. """ self.method = self._check_method() log.debug("artresizer: method is {0}", self.method) self.can_compare = self._can_compare() # Use ImageMagick's magick binary when it's available. If it's # not, fall back to the older, separate convert and identify # commands. if self.method[0] == IMAGEMAGICK: self.im_legacy = self.method[2] if self.im_legacy: self.im_convert_cmd = ['convert'] self.im_identify_cmd = ['identify'] else: self.im_convert_cmd = ['magick'] self.im_identify_cmd = ['magick', 'identify'] def resize( self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0 ): """Manipulate an image file according to the method, returning a new path. For PIL or IMAGEMAGIC methods, resizes the image to a temporary file and encodes with the specified quality level. For WEBPROXY, returns `path_in` unmodified. """ if self.local: func = BACKEND_FUNCS[self.method[0]] return func(maxwidth, path_in, path_out, quality=quality, max_filesize=max_filesize) else: return path_in def deinterlace(self, path_in, path_out=None): if self.local: func = DEINTERLACE_FUNCS[self.method[0]] return func(path_in, path_out) else: return path_in def proxy_url(self, maxwidth, url, quality=0): """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, quality) @property def local(self): """A boolean indicating whether the resizing method is performed locally (i.e., PIL or ImageMagick). """ return self.method[0] in BACKEND_FUNCS def get_size(self, path_in): """Return the size of an image file as an int couple (width, height) in pixels. Only available locally. """ if self.local: func = BACKEND_GET_SIZE[self.method[0]] return func(path_in) def get_format(self, path_in): """Returns the format of the image as a string. Only available locally. """ if self.local: func = BACKEND_GET_FORMAT[self.method[0]] return func(path_in) def reformat(self, path_in, new_format, deinterlaced=True): """Converts image to desired format, updating its extension, but keeping the same filename. Only available locally. """ if not self.local: return path_in new_format = new_format.lower() # A nonexhaustive map of image "types" to extensions overrides new_format = { 'jpeg': 'jpg', }.get(new_format, new_format) fname, ext = os.path.splitext(path_in) path_new = fname + b'.' + new_format.encode('utf8') func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]] # allows the exception to propagate, while still making sure a changed # file path was removed result_path = path_in try: result_path = func(path_in, path_new, deinterlaced) finally: if result_path != path_in: os.unlink(path_in) return result_path def _can_compare(self): """A boolean indicating whether image comparison is available""" return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7) @staticmethod def _check_method(): """Return a tuple indicating an available method and its version. The result has at least two elements: - The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK. - The version. If the method is IMAGEMAGICK, there is also a third element: a bool flag indicating whether to use the `magick` binary or legacy single-purpose executables (`convert`, `identify`, etc.) """ version = get_im_version() if version: version, legacy = version return IMAGEMAGICK, version, legacy version = get_pil_version() if version: return PIL, version return WEBPROXY, (0) def get_im_version(): """Get the ImageMagick version and legacy flag as a pair. Or return None if ImageMagick is not available. """ for cmd_name, legacy in ((['magick'], False), (['convert'], True)): cmd = cmd_name + ['--version'] try: out = util.command_output(cmd).stdout except (subprocess.CalledProcessError, OSError) as exc: log.debug('ImageMagick version check failed: {}', exc) else: if b'imagemagick' in out.lower(): pattern = br".+ (\d+)\.(\d+)\.(\d+).*" match = re.search(pattern, out) if match: version = (int(match.group(1)), int(match.group(2)), int(match.group(3))) return version, legacy return None def get_pil_version(): """Get the PIL/Pillow version, or None if it is unavailable. """ try: __import__('PIL', fromlist=['Image']) return (0,) except ImportError: return None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/util/bluelet.py0000644000076500000240000004666200000000000016430 0ustar00asampsonstaff"""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 # Basic events used for thread scheduling. class Event: """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): raise self.exc_info[1].with_traceback(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 BaseException: # 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 = {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 OSError as exc: if isinstance(exc.args, tuple) and \ exc.args[0] == errno.EPIPE: # Broken pipe. Remote host disconnected. pass elif isinstance(exc.args, tuple) and \ exc.args[0] == errno.ECONNRESET: # Connection was reset by peer. 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 BaseException: # 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: """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: """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' % 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' % 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() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/util/confit.py0000644000076500000240000000172600000000000016246 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016-2019, 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 confuse import warnings warnings.warn("beets.util.confit is deprecated; use confuse instead") # Import everything from the confuse module into this module. for key, value in confuse.__dict__.items(): if key not in ['__name__']: globals()[key] = value # Cleanup namespace. del key, value, warnings, confuse ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/util/enumeration.py0000644000076500000240000000253200000000000017306 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from enum import Enum class OrderedEnum(Enum): """ An Enum subclass that allows comparison of members. """ def __ge__(self, other): if self.__class__ is other.__class__: return self.value >= other.value return NotImplemented def __gt__(self, other): if self.__class__ is other.__class__: return self.value > other.value return NotImplemented def __le__(self, other): if self.__class__ is other.__class__: return self.value <= other.value return NotImplemented def __lt__(self, other): if self.__class__ is other.__class__: return self.value < other.value return NotImplemented ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/util/functemplate.py0000644000076500000240000005002100000000000017443 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """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. """ import re import ast import dis import types import sys import functools SYMBOL_DELIM = '$' FUNC_DELIM = '%' GROUP_OPEN = '{' GROUP_CLOSE = '}' ARG_SEP = ',' ESCAPE_CHAR = '$' VARIABLE_PREFIX = '__var_' FUNCTION_PREFIX = '__func_' class Environment: """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. """ return ast.Constant(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, str): 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, []) 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. """ args_fields = { 'args': [ast.arg(arg=n, annotation=None) for n in arg_names], 'kwonlyargs': [], 'kw_defaults': [], 'defaults': [ex_literal(None) for _ in arg_names], } if 'posonlyargs' in ast.arguments._fields: # Added in Python 3.8. args_fields['posonlyargs'] = [] args = ast.arguments(**args_fields) func_def = ast.FunctionDef( name=name, args=args, body=statements, decorator_list=[], ) # The ast.Module signature changed in 3.8 to accept a list of types to # ignore. if sys.version_info >= (3, 8): mod = ast.Module([func_def], []) else: 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, {}, the_locals) return the_locals[name] # AST nodes for the template language. class Symbol: """A variable-substitution symbol in a template.""" def __init__(self, ident, original): self.ident = ident self.original = original def __repr__(self): return '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.""" ident = self.ident expr = ex_rvalue(VARIABLE_PREFIX + ident) return [expr], {ident}, set() class Call: """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 'Call({}, {}, {})'.format(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 '<%s>' % str(exc) return str(out) else: return self.original def translate(self): """Compile the function call.""" varnames = set() funcnames = {self.ident} 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(''), 'join', ast.Load()), [ex_call( 'map', [ ex_rvalue(str.__name__), ast.List(subexprs, ast.Load()), ] )], )) subexpr_call = ex_call( FUNCTION_PREFIX + self.ident, arg_exprs ) return [subexpr_call], varnames, funcnames class Expression: """Top-level template construct: contains a list of text blobs, Symbols, and Calls. """ def __init__(self, parts): self.parts = parts def __repr__(self): return '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, str): out.append(part) else: out.append(part.evaluate(env)) return ''.join(map(str, 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, str): 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: """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, in_argument=False): """ Create a new parser. :param in_arguments: boolean that indicates the parser is to be used for parsing function arguments, ie. considering commas (`ARG_SEP`) a special character """ self.string = string self.in_argument = in_argument self.pos = 0 self.parts = [] # Common parsing resources. special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE, ESCAPE_CHAR) special_char_re = re.compile(r'[%s]|\Z' % ''.join(re.escape(c) for c in special_chars)) escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP) terminator_chars = (GROUP_CLOSE,) 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. """ # Append comma (ARG_SEP) to the list of special characters only when # parsing function arguments. extra_special_chars = () special_char_re = self.special_char_re if self.in_argument: extra_special_chars = (ARG_SEP,) special_char_re = re.compile( r'[%s]|\Z' % ''.join( re.escape(c) for c in self.special_chars + extra_special_chars ) ) text_parts = [] while self.pos < len(self.string): char = self.string[self.pos] if char not in self.special_chars + extra_special_chars: # A non-special character. Skip to the next special # character, treating the interstice as literal text. next_pos = ( 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 self.terminator_chars + extra_special_chars: text_parts.append(char) self.pos += 1 break next_char = self.string[self.pos + 1] if char == ESCAPE_CHAR and next_char in (self.escapable_chars + extra_special_chars): # 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(''.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 self.terminator_chars + extra_special_chars: # 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(''.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:], in_argument=True) 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(r'\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) def cached(func): """Like the `functools.lru_cache` decorator, but works (as a no-op) on Python < 3.2. """ if hasattr(functools, 'lru_cache'): return functools.lru_cache(maxsize=128)(func) else: # Do nothing when lru_cache is not available. return func @cached def template(fmt): return Template(fmt) # External interface. class Template: """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 Exception: # 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 + varname) for funcname in funcnames: argnames.append(FUNCTION_PREFIX + 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 ''.join(parts) return wrapper_func # Performance tests. if __name__ == '__main__': import timeit _tmpl = Template('foo $bar %baz{foozle $bar barzle} $bar') _vars = {'bar': 'qux'} _funcs = {'baz': str.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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/util/hidden.py0000644000076500000240000000525700000000000016222 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Simple library to work out if a file is hidden on different platforms.""" import os import stat import ctypes import sys import beets.util def _is_hidden_osx(path): """Return whether or not a file is hidden on OS X. This uses os.lstat to work out if a file has the "hidden" flag. """ file_stat = os.lstat(beets.util.syspath(path)) if hasattr(file_stat, 'st_flags') and hasattr(stat, 'UF_HIDDEN'): return bool(file_stat.st_flags & stat.UF_HIDDEN) else: return False def _is_hidden_win(path): """Return whether or not a file is hidden on Windows. This uses GetFileAttributes to work out if a file has the "hidden" flag (FILE_ATTRIBUTE_HIDDEN). """ # FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation. hidden_mask = 2 # Retrieve the attributes for the file. attrs = ctypes.windll.kernel32.GetFileAttributesW(beets.util.syspath(path)) # Ensure we have valid attribues and compare them against the mask. return attrs >= 0 and attrs & hidden_mask def _is_hidden_dot(path): """Return whether or not a file starts with a dot. Files starting with a dot are seen as "hidden" files on Unix-based OSes. """ return os.path.basename(path).startswith(b'.') def is_hidden(path): """Return whether or not a file is hidden. `path` should be a bytestring filename. This method works differently depending on the platform it is called on. On OS X, it uses both the result of `is_hidden_osx` and `is_hidden_dot` to work out if a file is hidden. On Windows, it uses the result of `is_hidden_win` to work out if a file is hidden. On any other operating systems (i.e. Linux), it uses `is_hidden_dot` to work out if a file is hidden. """ # Run platform specific functions depending on the platform if sys.platform == 'darwin': return _is_hidden_osx(path) or _is_hidden_dot(path) elif sys.platform == 'win32': return _is_hidden_win(path) else: return _is_hidden_dot(path) __all__ = ['is_hidden'] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/util/pipeline.py0000644000076500000240000003767500000000000016605 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """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. """ import queue from threading import Thread, Lock import sys 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: # Originally, we set `maxsize` to 0 here, which is supposed to mean # an unlimited queue size. However, there is a race condition since # Python 3.2 when this attribute is changed while another thread is # waiting in put()/get() due to a full/empty queue. # Setting it to 2 is still hacky because Python does not give any # guarantee what happens if Queue methods/attributes are overwritten # when it is already in use. However, because of our dummy _put() # and _get() methods, it provides a workaround to let the queue appear # to be never empty or full. # See issue https://github.com/beetbox/beets/issues/2078 q.maxsize = 2 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: """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 stage(func): """Decorate a function to become a simple stage. >>> @stage ... def add(n, i): ... return i + n >>> pipe = Pipeline([ ... iter([1, 2, 3]), ... add(2), ... ]) >>> list(pipe.pull()) [3, 4, 5] """ def coro(*args): task = None while True: task = yield task task = func(*(args + (task,))) return coro def mutator_stage(func): """Decorate a function that manipulates items in a coroutine to become a simple stage. >>> @mutator_stage ... def setkey(key, item): ... item[key] = True >>> pipe = Pipeline([ ... iter([{'x': False}, {'a': False}]), ... setkey('x'), ... ]) >>> list(pipe.pull()) [{'x': True}, {'a': False, 'x': True}] """ def coro(*args): task = None while True: task = yield task func(*(args + (task,))) return coro 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().__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().__init__(all_threads) self.coro = coro self.out_queue = out_queue self.out_queue.acquire() def run(self): try: while True: with self.abort_lock: if self.abort_flag: return # Get the value from the generator. try: msg = next(self.coro) 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 BaseException: 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().__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. next(self.coro) 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 BaseException: 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().__init__(all_threads) self.coro = coro self.in_queue = in_queue def run(self): # Prime the coroutine. next(self.coro) 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 BaseException: self.abort_all(sys.exc_info()) return class Pipeline: """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. """ list(self.pull()) 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. """ queue_count = len(self.stages) - 1 queues = [CountedQueue(queue_size) for i in range(queue_count)] 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, queue_count): 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].is_alive(): threads[-1].join(1) except BaseException: # 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[1].with_traceback(exc_info[2]) def pull(self): """Yield elements from the end of the pipeline. Runs the stages sequentially until the last yields some messages. Each of the messages is then yielded by ``pulled.next()``. If the pipeline has a consumer, that is the last stage does not yield any messages, then pull will not yield any messages. 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:]: next(coro) # 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 for msg in msgs: yield msg # 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 print('received %i' % num) Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beets/vfs.py0000644000076500000240000000332600000000000014603 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1638031078.2473052 beets-1.6.0/beets.egg-info/0000755000076500000240000000000000000000000015121 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1638031078.0 beets-1.6.0/beets.egg-info/PKG-INFO0000644000076500000240000001473400000000000016227 0ustar00asampsonstaffMetadata-Version: 2.1 Name: beets Version: 1.6.0 Summary: music tagger and library organizer Home-page: https://beets.io/ Author: Adrian Sampson Author-email: adrian@radbox.org License: MIT 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 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: Implementation :: CPython Provides-Extra: test Provides-Extra: lint Provides-Extra: absubmit Provides-Extra: fetchart Provides-Extra: embedart Provides-Extra: embyupdate Provides-Extra: chroma Provides-Extra: discogs Provides-Extra: beatport Provides-Extra: kodiupdate Provides-Extra: lastgenre Provides-Extra: lastimport Provides-Extra: lyrics Provides-Extra: mpdstats Provides-Extra: plexupdate Provides-Extra: web Provides-Extra: import Provides-Extra: thumbnails Provides-Extra: metasync Provides-Extra: sonosupdate Provides-Extra: scrub Provides-Extra: bpd Provides-Extra: replaygain Provides-Extra: reflink License-File: LICENSE .. image:: https://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets .. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets .. image:: https://github.com/beetbox/beets/workflows/ci/badge.svg?branch=master :target: https://github.com/beetbox/beets/actions .. image:: https://repology.org/badge/tiny-repos/beets.svg :target: https://repology.org/project/beets/versions beets ===== Beets is the media library management system for obsessive 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: - Fetch or calculate all the metadata you could possibly need: `album art`_, `lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic fingerprints`_. - Get metadata from `MusicBrainz`_, `Discogs`_, and `Beatport`_. Or guess metadata using songs' filenames or their acoustic fingerprints. - `Transcode audio`_ to any format you like. - Check your library for `duplicate tracks and albums`_ or for `albums that are missing tracks`_. - Clean up crufty tags left behind by other, less-awesome tools. - Embed and extract album art from files' metadata. - Browse your music library graphically through a Web browser and play it in any browser that supports `HTML5 Audio`_. - Analyze music files' metadata from the command line. - Listen to your library with a music player that speaks the `MPD`_ protocol and works with a staggering variety of interfaces. If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. .. _plugins: https://beets.readthedocs.org/page/plugins/ .. _MPD: https://www.musicpd.org/ .. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/ .. _writing your own plugin: https://beets.readthedocs.org/page/dev/plugins.html .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html .. _albums that are missing tracks: https://beets.readthedocs.org/page/plugins/missing.html .. _duplicate tracks and albums: https://beets.readthedocs.org/page/plugins/duplicates.html .. _Transcode audio: https://beets.readthedocs.org/page/plugins/convert.html .. _Discogs: https://www.discogs.com/ .. _acoustic fingerprints: https://beets.readthedocs.org/page/plugins/chroma.html .. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html .. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html .. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html .. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html .. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html .. _MusicBrainz: https://musicbrainz.org/ .. _Beatport: https://www.beatport.com Install ------- You can install beets by typing ``pip install beets``. Beets has also been packaged in the `software repositories`_ of several distributions. Check out the `Getting Started`_ guide for more information. .. _Getting Started: https://beets.readthedocs.org/page/guides/main.html .. _software repositories: https://repology.org/project/beets/versions Contribute ---------- Thank you for considering contributing to ``beets``! Whether you're a programmer or not, you should be able to find all the info you need at `CONTRIBUTING.rst`_. .. _CONTRIBUTING.rst: https://github.com/beetbox/beets/blob/master/CONTRIBUTING.rst Read More --------- Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for news and updates. .. _its Web site: https://beets.io/ .. _@b33ts: https://twitter.com/b33ts/ Contact ------- * Encountered a bug you'd like to report? Check out our `issue tracker`_! * If your issue hasn't already been reported, please `open a new ticket`_ and we'll be in touch with you shortly. * If you'd like to vote on a feature/bug, simply give a :+1: on issues you'd like to see prioritized over others. * Need help/support, would like to start a discussion, have an idea for a new feature, or would just like to introduce yourself to the team? Check out `GitHub Discussions`_ or `Discourse`_! .. _GitHub Discussions: https://github.com/beetbox/beets/discussions .. _issue tracker: https://github.com/beetbox/beets/issues .. _open a new ticket: https://github.com/beetbox/beets/issues/new/choose .. _Discourse: https://discourse.beets.io/ Authors ------- Beets is by `Adrian Sampson`_ with a supporting cast of thousands. .. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1638031078.0 beets-1.6.0/beets.egg-info/SOURCES.txt0000644000076500000240000002123700000000000017012 0ustar00asampsonstaffLICENSE MANIFEST.in README.rst setup.cfg setup.py beets/__init__.py beets/__main__.py beets/art.py beets/config_default.yaml beets/importer.py beets/library.py beets/logging.py beets/mediafile.py beets/plugins.py beets/random.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/not-zip-safe beets.egg-info/pbr.json 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/dbcore/__init__.py beets/dbcore/db.py beets/dbcore/query.py beets/dbcore/queryparse.py beets/dbcore/types.py beets/ui/__init__.py beets/ui/commands.py beets/ui/completion_base.sh 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/hidden.py beets/util/pipeline.py beetsplug/__init__.py beetsplug/absubmit.py beetsplug/acousticbrainz.py beetsplug/albumtypes.py beetsplug/aura.py beetsplug/badfiles.py beetsplug/bareasc.py beetsplug/beatport.py beetsplug/bench.py beetsplug/bpm.py beetsplug/bpsync.py beetsplug/bucket.py beetsplug/chroma.py beetsplug/convert.py beetsplug/deezer.py beetsplug/discogs.py beetsplug/duplicates.py beetsplug/edit.py beetsplug/embedart.py beetsplug/embyupdate.py beetsplug/export.py beetsplug/fetchart.py beetsplug/filefilter.py beetsplug/fish.py beetsplug/freedesktop.py beetsplug/fromfilename.py beetsplug/ftintitle.py beetsplug/fuzzy.py beetsplug/gmusic.py beetsplug/hook.py beetsplug/ihate.py beetsplug/importadded.py beetsplug/importfeeds.py beetsplug/info.py beetsplug/inline.py beetsplug/ipfs.py beetsplug/keyfinder.py beetsplug/kodiupdate.py beetsplug/lastimport.py beetsplug/loadext.py beetsplug/lyrics.py beetsplug/mbcollection.py beetsplug/mbsubmit.py beetsplug/mbsync.py beetsplug/missing.py beetsplug/mpdstats.py beetsplug/mpdupdate.py beetsplug/parentwork.py beetsplug/permissions.py beetsplug/play.py beetsplug/playlist.py beetsplug/plexupdate.py beetsplug/random.py beetsplug/replaygain.py beetsplug/rewrite.py beetsplug/scrub.py beetsplug/smartplaylist.py beetsplug/sonosupdate.py beetsplug/spotify.py beetsplug/subsonicplaylist.py beetsplug/subsonicupdate.py beetsplug/the.py beetsplug/thumbnails.py beetsplug/types.py beetsplug/unimported.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/metasync/__init__.py beetsplug/metasync/amarok.py beetsplug/metasync/itunes.py 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/contributing.rst docs/faq.rst docs/index.rst docs/dev/cli.rst docs/dev/importer.rst docs/dev/index.rst docs/dev/library.rst docs/dev/plugins.rst docs/guides/advanced.rst docs/guides/index.rst docs/guides/main.rst docs/guides/tagger.rst docs/plugins/absubmit.rst docs/plugins/acousticbrainz.rst docs/plugins/albumtypes.rst docs/plugins/aura.rst docs/plugins/badfiles.rst docs/plugins/bareasc.rst docs/plugins/beatport.rst docs/plugins/beetsweb.png docs/plugins/bpd.rst docs/plugins/bpm.rst docs/plugins/bpsync.rst docs/plugins/bucket.rst docs/plugins/chroma.rst docs/plugins/convert.rst docs/plugins/deezer.rst docs/plugins/discogs.rst docs/plugins/duplicates.rst docs/plugins/edit.rst docs/plugins/embedart.rst docs/plugins/embyupdate.rst docs/plugins/export.rst docs/plugins/fetchart.rst docs/plugins/filefilter.rst docs/plugins/fish.rst docs/plugins/freedesktop.rst docs/plugins/fromfilename.rst docs/plugins/ftintitle.rst docs/plugins/fuzzy.rst docs/plugins/gmusic.rst docs/plugins/hook.rst docs/plugins/ihate.rst docs/plugins/importadded.rst docs/plugins/importfeeds.rst docs/plugins/index.rst docs/plugins/info.rst docs/plugins/inline.rst docs/plugins/ipfs.rst docs/plugins/keyfinder.rst docs/plugins/kodiupdate.rst docs/plugins/lastgenre.rst docs/plugins/lastimport.rst docs/plugins/loadext.rst docs/plugins/lyrics.rst docs/plugins/mbcollection.rst docs/plugins/mbsubmit.rst docs/plugins/mbsync.rst docs/plugins/metasync.rst docs/plugins/missing.rst docs/plugins/mpdstats.rst docs/plugins/mpdupdate.rst docs/plugins/parentwork.rst docs/plugins/permissions.rst docs/plugins/play.rst docs/plugins/playlist.rst docs/plugins/plexupdate.rst docs/plugins/random.rst docs/plugins/replaygain.rst docs/plugins/rewrite.rst docs/plugins/scrub.rst docs/plugins/smartplaylist.rst docs/plugins/sonosupdate.rst docs/plugins/spotify.rst docs/plugins/subsonicplaylist.rst docs/plugins/subsonicupdate.rst docs/plugins/the.rst docs/plugins/thumbnails.rst docs/plugins/types.rst docs/plugins/unimported.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 extra/_beet extra/ascii_logo.txt extra/beets.reg extra/release.py man/beet.1 man/beetsconfig.5 test/__init__.py test/_common.py test/helper.py test/lyrics_download_samples.py test/test_acousticbrainz.py test/test_albumtypes.py test/test_art.py test/test_art_resize.py test/test_autotag.py test/test_bareasc.py test/test_beatport.py test/test_bucket.py test/test_config_command.py test/test_convert.py test/test_datequery.py test/test_dbcore.py test/test_discogs.py test/test_edit.py test/test_embedart.py test/test_embyupdate.py test/test_export.py test/test_fetchart.py test/test_filefilter.py test/test_files.py test/test_ftintitle.py test/test_hidden.py test/test_hook.py test/test_ihate.py test/test_importadded.py test/test_importer.py test/test_importfeeds.py test/test_info.py test/test_ipfs.py test/test_keyfinder.py test/test_lastgenre.py test/test_library.py test/test_logging.py test/test_lyrics.py test/test_mb.py test/test_mbsubmit.py test/test_mbsync.py test/test_metasync.py test/test_mpdstats.py test/test_parentwork.py test/test_permissions.py test/test_pipeline.py test/test_play.py test/test_player.py test/test_playlist.py test/test_plexupdate.py test/test_plugin_mediafield.py test/test_plugins.py test/test_query.py test/test_random.py test/test_replaygain.py test/test_smartplaylist.py test/test_sort.py test/test_spotify.py test/test_subsonicupdate.py test/test_template.py test/test_the.py test/test_thumbnails.py test/test_types_plugin.py test/test_ui.py test/test_ui_commands.py test/test_ui_importer.py test/test_ui_init.py test/test_util.py test/test_vfs.py test/test_web.py test/test_zero.py test/testall.py test/rsrc/abbey-different.jpg test/rsrc/abbey-similar.jpg test/rsrc/abbey.jpg test/rsrc/archive.7z test/rsrc/archive.rar test/rsrc/bpm.mp3 test/rsrc/convert_stub.py test/rsrc/coverart.ogg test/rsrc/date.mp3 test/rsrc/date_with_slashes.ogg test/rsrc/discc.ogg test/rsrc/empty.aiff test/rsrc/empty.alac.m4a test/rsrc/empty.ape test/rsrc/empty.dsf test/rsrc/empty.flac test/rsrc/empty.m4a test/rsrc/empty.mp3 test/rsrc/empty.mpc test/rsrc/empty.ogg test/rsrc/empty.opus test/rsrc/empty.wma test/rsrc/empty.wv test/rsrc/emptylist.mp3 test/rsrc/full.aiff test/rsrc/full.alac.m4a test/rsrc/full.ape test/rsrc/full.dsf 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/image-2x3.jpg test/rsrc/image-2x3.png test/rsrc/image-2x3.tiff test/rsrc/image-jpeg.mp3 test/rsrc/image.ape test/rsrc/image.flac test/rsrc/image.m4a test/rsrc/image.mp3 test/rsrc/image.ogg test/rsrc/image.wma test/rsrc/image_unknown_type.mp3 test/rsrc/itunes_library_unix.xml test/rsrc/itunes_library_windows.xml test/rsrc/lyricstext.yaml test/rsrc/min.flac test/rsrc/min.m4a test/rsrc/min.mp3 test/rsrc/oldape.ape test/rsrc/only-magic-bytes.jpg test/rsrc/partial.flac test/rsrc/partial.m4a test/rsrc/partial.mp3 test/rsrc/pure.wma test/rsrc/soundcheck-nonascii.m4a test/rsrc/space_time.mp3 test/rsrc/t_time.m4a test/rsrc/test_completion.sh test/rsrc/unicode’d.mp3 test/rsrc/unparseable.aiff test/rsrc/unparseable.alac.m4a test/rsrc/unparseable.ape test/rsrc/unparseable.dsf test/rsrc/unparseable.flac test/rsrc/unparseable.m4a test/rsrc/unparseable.mp3 test/rsrc/unparseable.mpc test/rsrc/unparseable.ogg test/rsrc/unparseable.opus test/rsrc/unparseable.wma test/rsrc/unparseable.wv test/rsrc/year.ogg test/rsrc/acousticbrainz/data.json test/rsrc/beetsplug/test.py test/rsrc/lyrics/absolutelyricscom/ladymadonna.txt test/rsrc/lyrics/examplecom/beetssong.txt test/rsrc/lyrics/geniuscom/Wutangclancreamlyrics.txt test/rsrc/lyrics/geniuscom/sample.txt test/rsrc/spotify/album_info.json test/rsrc/spotify/missing_request.json test/rsrc/spotify/track_info.json test/rsrc/spotify/track_request.json././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1638031078.0 beets-1.6.0/beets.egg-info/dependency_links.txt0000644000076500000240000000000100000000000021167 0ustar00asampsonstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1638031078.0 beets-1.6.0/beets.egg-info/entry_points.txt0000644000076500000240000000005000000000000020412 0ustar00asampsonstaff[console_scripts] beet = beets.ui:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1559405816.0 beets-1.6.0/beets.egg-info/not-zip-safe0000644000076500000240000000000100000000000017347 0ustar00asampsonstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1497990744.0 beets-1.6.0/beets.egg-info/pbr.json0000644000076500000240000000006000000000000016573 0ustar00asampsonstaff{"is_release": false, "git_version": "336fdba9"}././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1638031078.0 beets-1.6.0/beets.egg-info/requires.txt0000644000076500000240000000152200000000000017521 0ustar00asampsonstaffunidecode musicbrainzngs>=0.4 pyyaml mediafile>=0.2.0 confuse>=1.0.0 munkres>=1.0.0 jellyfish [absubmit] requests [beatport] requests-oauthlib>=0.6.1 [bpd] PyGObject [chroma] pyacoustid [discogs] python3-discogs-client>=2.3.10 [embedart] Pillow [embyupdate] requests [fetchart] requests Pillow [import] rarfile py7zr [kodiupdate] requests [lastgenre] pylast [lastimport] pylast [lint] flake8 flake8-docstrings pep8-naming [lyrics] requests beautifulsoup4 langdetect [metasync] dbus-python [mpdstats] python-mpd2>=0.4.2 [plexupdate] requests [reflink] reflink [replaygain] PyGObject [scrub] mutagen>=1.33 [sonosupdate] soco [test] beautifulsoup4 coverage flask mock pylast pytest python-mpd2 pyxdg responses>=0.3.0 requests_oauthlib reflink rarfile python3-discogs-client py7zr [thumbnails] pyxdg Pillow [web] flask flask-cors ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1638031078.0 beets-1.6.0/beets.egg-info/top_level.txt0000644000076500000240000000002000000000000017643 0ustar00asampsonstaffbeets beetsplug ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1638031078.3128903 beets-1.6.0/beetsplug/0000755000076500000240000000000000000000000014317 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/__init__.py0000644000076500000240000000144200000000000016431 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A namespace package for beets plugins.""" # Make this a namespace package. from pkgutil import extend_path __path__ = extend_path(__path__, __name__) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/absubmit.py0000644000076500000240000001540300000000000016502 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Pieter Mulder. # # 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. """Calculate acoustic information and submit to AcousticBrainz. """ import errno import hashlib import json import os import subprocess import tempfile from distutils.spawn import find_executable import requests from beets import plugins from beets import util from beets import ui # We use this field to check whether AcousticBrainz info is present. PROBE_FIELD = 'mood_acoustic' class ABSubmitError(Exception): """Raised when failing to analyse file with extractor.""" def call(args): """Execute the command and return its output. Raise a AnalysisABSubmitError on failure. """ try: return util.command_output(args).stdout except subprocess.CalledProcessError as e: raise ABSubmitError( '{} exited with status {}'.format(args[0], e.returncode) ) class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'extractor': '', 'force': False, 'pretend': False }) self.extractor = self.config['extractor'].as_str() if self.extractor: self.extractor = util.normpath(self.extractor) # Expicit path to extractor if not os.path.isfile(self.extractor): raise ui.UserError( 'Extractor command does not exist: {0}.'. format(self.extractor) ) else: # Implicit path to extractor, search for it in path self.extractor = 'streaming_extractor_music' try: call([self.extractor]) except OSError: raise ui.UserError( 'No extractor command found: please install the extractor' ' binary from https://acousticbrainz.org/download' ) except ABSubmitError: # Extractor found, will exit with an error if not called with # the correct amount of arguments. pass # Get the executable location on the system, which we need # to calculate the SHA-1 hash. self.extractor = find_executable(self.extractor) # Calculate extractor hash. self.extractor_sha = hashlib.sha1() with open(self.extractor, 'rb') as extractor: self.extractor_sha.update(extractor.read()) self.extractor_sha = self.extractor_sha.hexdigest() base_url = 'https://acousticbrainz.org/api/v1/{mbid}/low-level' def commands(self): cmd = ui.Subcommand( 'absubmit', help='calculate and submit AcousticBrainz analysis' ) cmd.parser.add_option( '-f', '--force', dest='force_refetch', action='store_true', default=False, help='re-download data when already present' ) cmd.parser.add_option( '-p', '--pretend', dest='pretend_fetch', action='store_true', default=False, help='pretend to perform action, but show \ only files which would be processed' ) cmd.func = self.command return [cmd] def command(self, lib, opts, args): # Get items from arguments items = lib.items(ui.decargs(args)) self.opts = opts util.par_map(self.analyze_submit, items) def analyze_submit(self, item): analysis = self._get_analysis(item) if analysis: self._submit_data(item, analysis) def _get_analysis(self, item): mbid = item['mb_trackid'] # Avoid re-analyzing files that already have AB data. if not self.opts.force_refetch and not self.config['force']: if item.get(PROBE_FIELD): return None # If file has no MBID, skip it. if not mbid: self._log.info('Not analysing {}, missing ' 'musicbrainz track id.', item) return None if self.opts.pretend_fetch or self.config['pretend']: self._log.info('pretend action - extract item: {}', item) return None # Temporary file to save extractor output to, extractor only works # if an output file is given. Here we use a temporary file to copy # the data into a python object and then remove the file from the # system. tmp_file, filename = tempfile.mkstemp(suffix='.json') try: # Close the file, so the extractor can overwrite it. os.close(tmp_file) try: call([self.extractor, util.syspath(item.path), filename]) except ABSubmitError as e: self._log.warning( 'Failed to analyse {item} for AcousticBrainz: {error}', item=item, error=e ) return None with open(filename) as tmp_file: analysis = json.load(tmp_file) # Add the hash to the output. analysis['metadata']['version']['essentia_build_sha'] = \ self.extractor_sha return analysis finally: try: os.remove(filename) except OSError as e: # ENOENT means file does not exist, just ignore this error. if e.errno != errno.ENOENT: raise def _submit_data(self, item, data): mbid = item['mb_trackid'] headers = {'Content-Type': 'application/json'} response = requests.post(self.base_url.format(mbid=mbid), json=data, headers=headers) # Test that request was successful and raise an error on failure. if response.status_code != 200: try: message = response.json()['message'] except (ValueError, KeyError) as e: message = f'unable to get error message: {e}' self._log.error( 'Failed to submit AcousticBrainz analysis of {item}: ' '{message}).', item=item, message=message ) else: self._log.debug('Successfully submitted AcousticBrainz analysis ' 'for {}.', item) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/acousticbrainz.py0000644000076500000240000002706200000000000017720 0ustar00asampsonstaff# This file is part of beets. # Copyright 2015-2016, Ohm Patel. # # 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. """Fetch various AcousticBrainz metadata using MBID. """ from collections import defaultdict import requests from beets import plugins, ui from beets.dbcore import types ACOUSTIC_BASE = "https://acousticbrainz.org/" LEVELS = ["/low-level", "/high-level"] ABSCHEME = { 'highlevel': { 'danceability': { 'all': { 'danceable': 'danceable' } }, 'gender': { 'value': 'gender' }, 'genre_rosamerica': { 'value': 'genre_rosamerica' }, 'mood_acoustic': { 'all': { 'acoustic': 'mood_acoustic' } }, 'mood_aggressive': { 'all': { 'aggressive': 'mood_aggressive' } }, 'mood_electronic': { 'all': { 'electronic': 'mood_electronic' } }, 'mood_happy': { 'all': { 'happy': 'mood_happy' } }, 'mood_party': { 'all': { 'party': 'mood_party' } }, 'mood_relaxed': { 'all': { 'relaxed': 'mood_relaxed' } }, 'mood_sad': { 'all': { 'sad': 'mood_sad' } }, 'moods_mirex': { 'value': 'moods_mirex' }, 'ismir04_rhythm': { 'value': 'rhythm' }, 'tonal_atonal': { 'all': { 'tonal': 'tonal' } }, 'timbre': { 'value': 'timbre' }, 'voice_instrumental': { 'value': 'voice_instrumental' }, }, 'lowlevel': { 'average_loudness': 'average_loudness' }, 'rhythm': { 'bpm': 'bpm' }, 'tonal': { 'chords_changes_rate': 'chords_changes_rate', 'chords_key': 'chords_key', 'chords_number_rate': 'chords_number_rate', 'chords_scale': 'chords_scale', 'key_key': ('initial_key', 0), 'key_scale': ('initial_key', 1), 'key_strength': 'key_strength' } } class AcousticPlugin(plugins.BeetsPlugin): item_types = { 'average_loudness': types.Float(6), 'chords_changes_rate': types.Float(6), 'chords_key': types.STRING, 'chords_number_rate': types.Float(6), 'chords_scale': types.STRING, 'danceable': types.Float(6), 'gender': types.STRING, 'genre_rosamerica': types.STRING, 'initial_key': types.STRING, 'key_strength': types.Float(6), 'mood_acoustic': types.Float(6), 'mood_aggressive': types.Float(6), 'mood_electronic': types.Float(6), 'mood_happy': types.Float(6), 'mood_party': types.Float(6), 'mood_relaxed': types.Float(6), 'mood_sad': types.Float(6), 'moods_mirex': types.STRING, 'rhythm': types.Float(6), 'timbre': types.STRING, 'tonal': types.Float(6), 'voice_instrumental': types.STRING, } def __init__(self): super().__init__() self.config.add({ 'auto': True, 'force': False, 'tags': [] }) if self.config['auto']: self.register_listener('import_task_files', self.import_task_files) def commands(self): cmd = ui.Subcommand('acousticbrainz', help="fetch metadata from AcousticBrainz") cmd.parser.add_option( '-f', '--force', dest='force_refetch', action='store_true', default=False, help='re-download data when already present' ) def func(lib, opts, args): items = lib.items(ui.decargs(args)) self._fetch_info(items, ui.should_write(), opts.force_refetch or self.config['force']) cmd.func = func return [cmd] def import_task_files(self, session, task): """Function is called upon beet import. """ self._fetch_info(task.imported_items(), False, True) def _get_data(self, mbid): data = {} for url in _generate_urls(mbid): self._log.debug('fetching URL: {}', url) try: res = requests.get(url) except requests.RequestException as exc: self._log.info('request error: {}', exc) return {} if res.status_code == 404: self._log.info('recording ID {} not found', mbid) return {} try: data.update(res.json()) except ValueError: self._log.debug('Invalid Response: {}', res.text) return {} return data def _fetch_info(self, items, write, force): """Fetch additional information from AcousticBrainz for the `item`s. """ tags = self.config['tags'].as_str_seq() for item in items: # If we're not forcing re-downloading for all tracks, check # whether the data is already present. We use one # representative field name to check for previously fetched # data. if not force: mood_str = item.get('mood_acoustic', '') if mood_str: self._log.info('data already present for: {}', item) continue # We can only fetch data for tracks with MBIDs. if not item.mb_trackid: continue self._log.info('getting data for: {}', item) data = self._get_data(item.mb_trackid) if data: for attr, val in self._map_data_to_scheme(data, ABSCHEME): if not tags or attr in tags: self._log.debug('attribute {} of {} set to {}', attr, item, val) setattr(item, attr, val) else: self._log.debug('skipping attribute {} of {}' ' (value {}) due to config', attr, item, val) item.store() if write: item.try_write() def _map_data_to_scheme(self, data, scheme): """Given `data` as a structure of nested dictionaries, and `scheme` as a structure of nested dictionaries , `yield` tuples `(attr, val)` where `attr` and `val` are corresponding leaf nodes in `scheme` and `data`. As its name indicates, `scheme` defines how the data is structured, so this function tries to find leaf nodes in `data` that correspond to the leafs nodes of `scheme`, and not the other way around. Leaf nodes of `data` that do not exist in the `scheme` do not matter. If a leaf node of `scheme` is not present in `data`, no value is yielded for that attribute and a simple warning is issued. Finally, to account for attributes of which the value is split between several leaf nodes in `data`, leaf nodes of `scheme` can be tuples `(attr, order)` where `attr` is the attribute to which the leaf node belongs, and `order` is the place at which it should appear in the value. The different `value`s belonging to the same `attr` are simply joined with `' '`. This is hardcoded and not very flexible, but it gets the job done. For example: >>> scheme = { 'key1': 'attribute', 'key group': { 'subkey1': 'subattribute', 'subkey2': ('composite attribute', 0) }, 'key2': ('composite attribute', 1) } >>> data = { 'key1': 'value', 'key group': { 'subkey1': 'subvalue', 'subkey2': 'part 1 of composite attr' }, 'key2': 'part 2' } >>> print(list(_map_data_to_scheme(data, scheme))) [('subattribute', 'subvalue'), ('attribute', 'value'), ('composite attribute', 'part 1 of composite attr part 2')] """ # First, we traverse `scheme` and `data`, `yield`ing all the non # composites attributes straight away and populating the dictionary # `composites` with the composite attributes. # When we are finished traversing `scheme`, `composites` should # map each composite attribute to an ordered list of the values # belonging to the attribute, for example: # `composites = {'initial_key': ['B', 'minor']}`. # The recursive traversal. composites = defaultdict(list) yield from self._data_to_scheme_child(data, scheme, composites) # When composites has been populated, yield the composite attributes # by joining their parts. for composite_attr, value_parts in composites.items(): yield composite_attr, ' '.join(value_parts) def _data_to_scheme_child(self, subdata, subscheme, composites): """The recursive business logic of :meth:`_map_data_to_scheme`: Traverse two structures of nested dictionaries in parallel and `yield` tuples of corresponding leaf nodes. If a leaf node belongs to a composite attribute (is a `tuple`), populate `composites` rather than yielding straight away. All the child functions for a single traversal share the same `composites` instance, which is passed along. """ for k, v in subscheme.items(): if k in subdata: if type(v) == dict: yield from self._data_to_scheme_child(subdata[k], v, composites) elif type(v) == tuple: composite_attribute, part_number = v attribute_parts = composites[composite_attribute] # Parts are not guaranteed to be inserted in order while len(attribute_parts) <= part_number: attribute_parts.append('') attribute_parts[part_number] = subdata[k] else: yield v, subdata[k] else: self._log.warning('Acousticbrainz did not provide info' 'about {}', k) self._log.debug('Data {} could not be mapped to scheme {} ' 'because key {} was not found', subdata, v, k) def _generate_urls(mbid): """Generates AcousticBrainz end point urls for given `mbid`. """ for level in LEVELS: yield ACOUSTIC_BASE + mbid + level ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/albumtypes.py0000644000076500000240000000440500000000000017061 0ustar00asampsonstaff# This file is part of beets. # Copyright 2021, Edgars Supe. # # 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 an album template field for formatted album types.""" from beets.autotag.mb import VARIOUS_ARTISTS_ID from beets.library import Album from beets.plugins import BeetsPlugin class AlbumTypesPlugin(BeetsPlugin): """Adds an album template field for formatted album types.""" def __init__(self): """Init AlbumTypesPlugin.""" super().__init__() self.album_template_fields['atypes'] = self._atypes self.config.add({ 'types': [ ('ep', 'EP'), ('single', 'Single'), ('soundtrack', 'OST'), ('live', 'Live'), ('compilation', 'Anthology'), ('remix', 'Remix') ], 'ignore_va': ['compilation'], 'bracket': '[]' }) def _atypes(self, item: Album): """Returns a formatted string based on album's types.""" types = self.config['types'].as_pairs() ignore_va = self.config['ignore_va'].as_str_seq() bracket = self.config['bracket'].as_str() # Assign a left and right bracket or leave blank if argument is empty. if len(bracket) == 2: bracket_l = bracket[0] bracket_r = bracket[1] else: bracket_l = '' bracket_r = '' res = '' albumtypes = item.albumtypes.split('; ') is_va = item.mb_albumartistid == VARIOUS_ARTISTS_ID for type in types: if type[0] in albumtypes and type[1]: if not is_va or (type[0] not in ignore_va and is_va): res += f'{bracket_l}{type[1]}{bracket_r}' return res ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beetsplug/aura.py0000644000076500000240000010141100000000000015617 0ustar00asampsonstaff# This file is part of beets. # Copyright 2020, Callum Brown. # # 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. """An AURA server using Flask.""" from mimetypes import guess_type import re import os.path from os.path import isfile, getsize from beets.plugins import BeetsPlugin from beets.ui import Subcommand, _open_library from beets import config from beets.util import py3_path from beets.library import Item, Album from beets.dbcore.query import ( MatchQuery, NotQuery, RegexpQuery, AndQuery, FixedFieldSort, SlowFieldSort, MultipleSort, ) from flask import ( Blueprint, Flask, current_app, send_file, make_response, request, ) # Constants # AURA server information # TODO: Add version information SERVER_INFO = { "aura-version": "0", "server": "beets-aura", "server-version": "0.1", "auth-required": False, "features": ["albums", "artists", "images"], } # Maps AURA Track attribute to beets Item attribute TRACK_ATTR_MAP = { # Required "title": "title", "artist": "artist", # Optional "album": "album", "track": "track", # Track number on album "tracktotal": "tracktotal", "disc": "disc", "disctotal": "disctotal", "year": "year", "month": "month", "day": "day", "bpm": "bpm", "genre": "genre", "recording-mbid": "mb_trackid", # beets trackid is MB recording "track-mbid": "mb_releasetrackid", "composer": "composer", "albumartist": "albumartist", "comments": "comments", # Optional for Audio Metadata # TODO: Support the mimetype attribute, format != mime type # "mimetype": track.format, "duration": "length", "framerate": "samplerate", # I don't think beets has a framecount field # "framecount": ???, "channels": "channels", "bitrate": "bitrate", "bitdepth": "bitdepth", "size": "filesize", } # Maps AURA Album attribute to beets Album attribute ALBUM_ATTR_MAP = { # Required "title": "album", "artist": "albumartist", # Optional "tracktotal": "albumtotal", "disctotal": "disctotal", "year": "year", "month": "month", "day": "day", "genre": "genre", "release-mbid": "mb_albumid", "release-group-mbid": "mb_releasegroupid", } # Maps AURA Artist attribute to beets Item field # Artists are not first-class in beets, so information is extracted from # beets Items. ARTIST_ATTR_MAP = { # Required "name": "artist", # Optional "artist-mbid": "mb_artistid", } class AURADocument: """Base class for building AURA documents.""" @staticmethod def error(status, title, detail): """Make a response for an error following the JSON:API spec. Args: status: An HTTP status code string, e.g. "404 Not Found". title: A short, human-readable summary of the problem. detail: A human-readable explanation specific to this occurrence of the problem. """ document = { "errors": [{"status": status, "title": title, "detail": detail}] } return make_response(document, status) def translate_filters(self): """Translate filters from request arguments to a beets Query.""" # The format of each filter key in the request parameter is: # filter[]. This regex extracts . pattern = re.compile(r"filter\[(?P[a-zA-Z0-9_-]+)\]") queries = [] for key, value in request.args.items(): match = pattern.match(key) if match: # Extract attribute name from key aura_attr = match.group("attribute") # Get the beets version of the attribute name beets_attr = self.attribute_map.get(aura_attr, aura_attr) converter = self.get_attribute_converter(beets_attr) value = converter(value) # Add exact match query to list # Use a slow query so it works with all fields queries.append(MatchQuery(beets_attr, value, fast=False)) # NOTE: AURA doesn't officially support multiple queries return AndQuery(queries) def translate_sorts(self, sort_arg): """Translate an AURA sort parameter into a beets Sort. Args: sort_arg: The value of the 'sort' query parameter; a comma separated list of fields to sort by, in order. E.g. "-year,title". """ # Change HTTP query parameter to a list aura_sorts = sort_arg.strip(",").split(",") sorts = [] for aura_attr in aura_sorts: if aura_attr[0] == "-": ascending = False # Remove leading "-" aura_attr = aura_attr[1:] else: # JSON:API default ascending = True # Get the beets version of the attribute name beets_attr = self.attribute_map.get(aura_attr, aura_attr) # Use slow sort so it works with all fields (inc. computed) sorts.append(SlowFieldSort(beets_attr, ascending=ascending)) return MultipleSort(sorts) def paginate(self, collection): """Get a page of the collection and the URL to the next page. Args: collection: The raw data from which resource objects can be built. Could be an sqlite3.Cursor object (tracks and albums) or a list of strings (artists). """ # Pages start from zero page = request.args.get("page", 0, int) # Use page limit defined in config by default. default_limit = config["aura"]["page_limit"].get(int) limit = request.args.get("limit", default_limit, int) # start = offset of first item to return start = page * limit # end = offset of last item + 1 end = start + limit if end > len(collection): end = len(collection) next_url = None else: # Not the last page so work out links.next url if not request.args: # No existing arguments, so current page is 0 next_url = request.url + "?page=1" elif not request.args.get("page", None): # No existing page argument, so add one to the end next_url = request.url + "&page=1" else: # Increment page token by 1 next_url = request.url.replace( f"page={page}", "page={}".format(page + 1) ) # Get only the items in the page range data = [self.resource_object(collection[i]) for i in range(start, end)] return data, next_url def get_included(self, data, include_str): """Build a list of resource objects for inclusion. Args: data: An array of dicts in the form of resource objects. include_str: A comma separated list of resource types to include. E.g. "tracks,images". """ # Change HTTP query parameter to a list to_include = include_str.strip(",").split(",") # Build a list of unique type and id combinations # For each resource object in the primary data, iterate over it's # relationships. If a relationship matches one of the types # requested for inclusion (e.g. "albums") then add each type-id pair # under the "data" key to unique_identifiers, checking first that # it has not already been added. This ensures that no resources are # included more than once. unique_identifiers = [] for res_obj in data: for rel_name, rel_obj in res_obj["relationships"].items(): if rel_name in to_include: # NOTE: Assumes relationship is to-many for identifier in rel_obj["data"]: if identifier not in unique_identifiers: unique_identifiers.append(identifier) # TODO: I think this could be improved included = [] for identifier in unique_identifiers: res_type = identifier["type"] if res_type == "track": track_id = int(identifier["id"]) track = current_app.config["lib"].get_item(track_id) included.append(TrackDocument.resource_object(track)) elif res_type == "album": album_id = int(identifier["id"]) album = current_app.config["lib"].get_album(album_id) included.append(AlbumDocument.resource_object(album)) elif res_type == "artist": artist_id = identifier["id"] included.append(ArtistDocument.resource_object(artist_id)) elif res_type == "image": image_id = identifier["id"] included.append(ImageDocument.resource_object(image_id)) else: raise ValueError(f"Invalid resource type: {res_type}") return included def all_resources(self): """Build document for /tracks, /albums or /artists.""" query = self.translate_filters() sort_arg = request.args.get("sort", None) if sort_arg: sort = self.translate_sorts(sort_arg) # For each sort field add a query which ensures all results # have a non-empty, non-zero value for that field. for s in sort.sorts: query.subqueries.append( NotQuery( # Match empty fields (^$) or zero fields, (^0$) RegexpQuery(s.field, "(^$|^0$)", fast=False) ) ) else: sort = None # Get information from the library collection = self.get_collection(query=query, sort=sort) # Convert info to AURA form and paginate it data, next_url = self.paginate(collection) document = {"data": data} # If there are more pages then provide a way to access them if next_url: document["links"] = {"next": next_url} # Include related resources for each element in "data" include_str = request.args.get("include", None) if include_str: document["included"] = self.get_included(data, include_str) return document def single_resource_document(self, resource_object): """Build document for a specific requested resource. Args: resource_object: A dictionary in the form of a JSON:API resource object. """ document = {"data": resource_object} include_str = request.args.get("include", None) if include_str: # [document["data"]] is because arg needs to be list document["included"] = self.get_included( [document["data"]], include_str ) return document class TrackDocument(AURADocument): """Class for building documents for /tracks endpoints.""" attribute_map = TRACK_ATTR_MAP def get_collection(self, query=None, sort=None): """Get Item objects from the library. Args: query: A beets Query object or a beets query string. sort: A beets Sort object. """ return current_app.config["lib"].items(query, sort) def get_attribute_converter(self, beets_attr): """Work out what data type an attribute should be for beets. Args: beets_attr: The name of the beets attribute, e.g. "title". """ # filesize is a special field (read from disk not db?) if beets_attr == "filesize": converter = int else: try: # Look for field in list of Item fields # and get python type of database type. # See beets.library.Item and beets.dbcore.types converter = Item._fields[beets_attr].model_type except KeyError: # Fall back to string (NOTE: probably not good) converter = str return converter @staticmethod def resource_object(track): """Construct a JSON:API resource object from a beets Item. Args: track: A beets Item object. """ attributes = {} # Use aura => beets attribute map, e.g. size => filesize for aura_attr, beets_attr in TRACK_ATTR_MAP.items(): a = getattr(track, beets_attr) # Only set attribute if it's not None, 0, "", etc. # NOTE: This could result in required attributes not being set if a: attributes[aura_attr] = a # JSON:API one-to-many relationship to parent album relationships = { "artists": {"data": [{"type": "artist", "id": track.artist}]} } # Only add album relationship if not singleton if not track.singleton: relationships["albums"] = { "data": [{"type": "album", "id": str(track.album_id)}] } return { "type": "track", "id": str(track.id), "attributes": attributes, "relationships": relationships, } def single_resource(self, track_id): """Get track from the library and build a document. Args: track_id: The beets id of the track (integer). """ track = current_app.config["lib"].get_item(track_id) if not track: return self.error( "404 Not Found", "No track with the requested id.", "There is no track with an id of {} in the library.".format( track_id ), ) return self.single_resource_document(self.resource_object(track)) class AlbumDocument(AURADocument): """Class for building documents for /albums endpoints.""" attribute_map = ALBUM_ATTR_MAP def get_collection(self, query=None, sort=None): """Get Album objects from the library. Args: query: A beets Query object or a beets query string. sort: A beets Sort object. """ return current_app.config["lib"].albums(query, sort) def get_attribute_converter(self, beets_attr): """Work out what data type an attribute should be for beets. Args: beets_attr: The name of the beets attribute, e.g. "title". """ try: # Look for field in list of Album fields # and get python type of database type. # See beets.library.Album and beets.dbcore.types converter = Album._fields[beets_attr].model_type except KeyError: # Fall back to string (NOTE: probably not good) converter = str return converter @staticmethod def resource_object(album): """Construct a JSON:API resource object from a beets Album. Args: album: A beets Album object. """ attributes = {} # Use aura => beets attribute name map for aura_attr, beets_attr in ALBUM_ATTR_MAP.items(): a = getattr(album, beets_attr) # Only set attribute if it's not None, 0, "", etc. # NOTE: This could mean required attributes are not set if a: attributes[aura_attr] = a # Get beets Item objects for all tracks in the album sorted by # track number. Sorting is not required but it's nice. query = MatchQuery("album_id", album.id) sort = FixedFieldSort("track", ascending=True) tracks = current_app.config["lib"].items(query, sort) # JSON:API one-to-many relationship to tracks on the album relationships = { "tracks": { "data": [{"type": "track", "id": str(t.id)} for t in tracks] } } # Add images relationship if album has associated images if album.artpath: path = py3_path(album.artpath) filename = path.split("/")[-1] image_id = f"album-{album.id}-{filename}" relationships["images"] = { "data": [{"type": "image", "id": image_id}] } # Add artist relationship if artist name is same on tracks # Tracks are used to define artists so don't albumartist # Check for all tracks in case some have featured artists if album.albumartist in [t.artist for t in tracks]: relationships["artists"] = { "data": [{"type": "artist", "id": album.albumartist}] } return { "type": "album", "id": str(album.id), "attributes": attributes, "relationships": relationships, } def single_resource(self, album_id): """Get album from the library and build a document. Args: album_id: The beets id of the album (integer). """ album = current_app.config["lib"].get_album(album_id) if not album: return self.error( "404 Not Found", "No album with the requested id.", "There is no album with an id of {} in the library.".format( album_id ), ) return self.single_resource_document(self.resource_object(album)) class ArtistDocument(AURADocument): """Class for building documents for /artists endpoints.""" attribute_map = ARTIST_ATTR_MAP def get_collection(self, query=None, sort=None): """Get a list of artist names from the library. Args: query: A beets Query object or a beets query string. sort: A beets Sort object. """ # Gets only tracks with matching artist information tracks = current_app.config["lib"].items(query, sort) collection = [] for track in tracks: # Do not add duplicates if track.artist not in collection: collection.append(track.artist) return collection def get_attribute_converter(self, beets_attr): """Work out what data type an attribute should be for beets. Args: beets_attr: The name of the beets attribute, e.g. "artist". """ try: # Look for field in list of Item fields # and get python type of database type. # See beets.library.Item and beets.dbcore.types converter = Item._fields[beets_attr].model_type except KeyError: # Fall back to string (NOTE: probably not good) converter = str return converter @staticmethod def resource_object(artist_id): """Construct a JSON:API resource object for the given artist. Args: artist_id: A string which is the artist's name. """ # Get tracks where artist field exactly matches artist_id query = MatchQuery("artist", artist_id) tracks = current_app.config["lib"].items(query) if not tracks: return None # Get artist information from the first track # NOTE: It could be that the first track doesn't have a # MusicBrainz id but later tracks do, which isn't ideal. attributes = {} # Use aura => beets attribute map, e.g. artist => name for aura_attr, beets_attr in ARTIST_ATTR_MAP.items(): a = getattr(tracks[0], beets_attr) # Only set attribute if it's not None, 0, "", etc. # NOTE: This could mean required attributes are not set if a: attributes[aura_attr] = a relationships = { "tracks": { "data": [{"type": "track", "id": str(t.id)} for t in tracks] } } album_query = MatchQuery("albumartist", artist_id) albums = current_app.config["lib"].albums(query=album_query) if len(albums) != 0: relationships["albums"] = { "data": [{"type": "album", "id": str(a.id)} for a in albums] } return { "type": "artist", "id": artist_id, "attributes": attributes, "relationships": relationships, } def single_resource(self, artist_id): """Get info for the requested artist and build a document. Args: artist_id: A string which is the artist's name. """ artist_resource = self.resource_object(artist_id) if not artist_resource: return self.error( "404 Not Found", "No artist with the requested id.", "There is no artist with an id of {} in the library.".format( artist_id ), ) return self.single_resource_document(artist_resource) def safe_filename(fn): """Check whether a string is a simple (non-path) filename. For example, `foo.txt` is safe because it is a "plain" filename. But `foo/bar.txt` and `../foo.txt` and `.` are all non-safe because they can traverse to other directories other than the current one. """ # Rule out any directories. if os.path.basename(fn) != fn: return False # In single names, rule out Unix directory traversal names. if fn in ('.', '..'): return False return True class ImageDocument(AURADocument): """Class for building documents for /images/(id) endpoints.""" @staticmethod def get_image_path(image_id): """Works out the full path to the image with the given id. Returns None if there is no such image. Args: image_id: A string in the form "--". """ # Split image_id into its constituent parts id_split = image_id.split("-") if len(id_split) < 3: # image_id is not in the required format return None parent_type = id_split[0] parent_id = id_split[1] img_filename = "-".join(id_split[2:]) if not safe_filename(img_filename): return None # Get the path to the directory parent's images are in if parent_type == "album": album = current_app.config["lib"].get_album(int(parent_id)) if not album or not album.artpath: return None # Cut the filename off of artpath # This is in preparation for supporting images in the same # directory that are not tracked by beets. artpath = py3_path(album.artpath) dir_path = "/".join(artpath.split("/")[:-1]) else: # Images for other resource types are not supported return None img_path = os.path.join(dir_path, img_filename) # Check the image actually exists if isfile(img_path): return img_path else: return None @staticmethod def resource_object(image_id): """Construct a JSON:API resource object for the given image. Args: image_id: A string in the form "--". """ # Could be called as a static method, so can't use # self.get_image_path() image_path = ImageDocument.get_image_path(image_id) if not image_path: return None attributes = { "role": "cover", "mimetype": guess_type(image_path)[0], "size": getsize(image_path), } try: from PIL import Image except ImportError: pass else: im = Image.open(image_path) attributes["width"] = im.width attributes["height"] = im.height relationships = {} # Split id into [parent_type, parent_id, filename] id_split = image_id.split("-") relationships[id_split[0] + "s"] = { "data": [{"type": id_split[0], "id": id_split[1]}] } return { "id": image_id, "type": "image", # Remove attributes that are None, 0, "", etc. "attributes": {k: v for k, v in attributes.items() if v}, "relationships": relationships, } def single_resource(self, image_id): """Get info for the requested image and build a document. Args: image_id: A string in the form "--". """ image_resource = self.resource_object(image_id) if not image_resource: return self.error( "404 Not Found", "No image with the requested id.", "There is no image with an id of {} in the library.".format( image_id ), ) return self.single_resource_document(image_resource) # Initialise flask blueprint aura_bp = Blueprint("aura_bp", __name__) @aura_bp.route("/server") def server_info(): """Respond with info about the server.""" return {"data": {"type": "server", "id": "0", "attributes": SERVER_INFO}} # Track endpoints @aura_bp.route("/tracks") def all_tracks(): """Respond with a list of all tracks and related information.""" doc = TrackDocument() return doc.all_resources() @aura_bp.route("/tracks/") def single_track(track_id): """Respond with info about the specified track. Args: track_id: The id of the track provided in the URL (integer). """ doc = TrackDocument() return doc.single_resource(track_id) @aura_bp.route("/tracks//audio") def audio_file(track_id): """Supply an audio file for the specified track. Args: track_id: The id of the track provided in the URL (integer). """ track = current_app.config["lib"].get_item(track_id) if not track: return AURADocument.error( "404 Not Found", "No track with the requested id.", "There is no track with an id of {} in the library.".format( track_id ), ) path = py3_path(track.path) if not isfile(path): return AURADocument.error( "404 Not Found", "No audio file for the requested track.", ( "There is no audio file for track {} at the expected location" ).format(track_id), ) file_mimetype = guess_type(path)[0] if not file_mimetype: return AURADocument.error( "500 Internal Server Error", "Requested audio file has an unknown mimetype.", ( "The audio file for track {} has an unknown mimetype. " "Its file extension is {}." ).format(track_id, path.split(".")[-1]), ) # Check that the Accept header contains the file's mimetype # Takes into account */* and audio/* # Adding support for the bitrate parameter would require some effort so I # left it out. This means the client could be sent an error even if the # audio doesn't need transcoding. if not request.accept_mimetypes.best_match([file_mimetype]): return AURADocument.error( "406 Not Acceptable", "Unsupported MIME type or bitrate parameter in Accept header.", ( "The audio file for track {} is only available as {} and " "bitrate parameters are not supported." ).format(track_id, file_mimetype), ) return send_file( path, mimetype=file_mimetype, # Handles filename in Content-Disposition header as_attachment=True, # Tries to upgrade the stream to support range requests conditional=True, ) # Album endpoints @aura_bp.route("/albums") def all_albums(): """Respond with a list of all albums and related information.""" doc = AlbumDocument() return doc.all_resources() @aura_bp.route("/albums/") def single_album(album_id): """Respond with info about the specified album. Args: album_id: The id of the album provided in the URL (integer). """ doc = AlbumDocument() return doc.single_resource(album_id) # Artist endpoints # Artist ids are their names @aura_bp.route("/artists") def all_artists(): """Respond with a list of all artists and related information.""" doc = ArtistDocument() return doc.all_resources() # Using the path converter allows slashes in artist_id @aura_bp.route("/artists/") def single_artist(artist_id): """Respond with info about the specified artist. Args: artist_id: The id of the artist provided in the URL. A string which is the artist's name. """ doc = ArtistDocument() return doc.single_resource(artist_id) # Image endpoints # Image ids are in the form -- # For example: album-13-cover.jpg @aura_bp.route("/images/") def single_image(image_id): """Respond with info about the specified image. Args: image_id: The id of the image provided in the URL. A string in the form "--". """ doc = ImageDocument() return doc.single_resource(image_id) @aura_bp.route("/images//file") def image_file(image_id): """Supply an image file for the specified image. Args: image_id: The id of the image provided in the URL. A string in the form "--". """ img_path = ImageDocument.get_image_path(image_id) if not img_path: return AURADocument.error( "404 Not Found", "No image with the requested id.", "There is no image with an id of {} in the library".format( image_id ), ) return send_file(img_path) # WSGI app def create_app(): """An application factory for use by a WSGI server.""" config["aura"].add( { "host": "127.0.0.1", "port": 8337, "cors": [], "cors_supports_credentials": False, "page_limit": 500, } ) app = Flask(__name__) # Register AURA blueprint view functions under a URL prefix app.register_blueprint(aura_bp, url_prefix="/aura") # AURA specifies mimetype MUST be this app.config["JSONIFY_MIMETYPE"] = "application/vnd.api+json" # Disable auto-sorting of JSON keys app.config["JSON_SORT_KEYS"] = False # Provide a way to access the beets library # The normal method of using the Library and config provided in the # command function is not used because create_app() could be called # by an external WSGI server. # NOTE: this uses a 'private' function from beets.ui.__init__ app.config["lib"] = _open_library(config) # Enable CORS if required cors = config["aura"]["cors"].as_str_seq(list) if cors: from flask_cors import CORS # "Accept" is the only header clients use app.config["CORS_ALLOW_HEADERS"] = "Accept" app.config["CORS_RESOURCES"] = {r"/aura/*": {"origins": cors}} app.config["CORS_SUPPORTS_CREDENTIALS"] = config["aura"][ "cors_supports_credentials" ].get(bool) CORS(app) return app # Beets Plugin Hook class AURAPlugin(BeetsPlugin): """The BeetsPlugin subclass for the AURA server plugin.""" def __init__(self): """Add configuration options for the AURA plugin.""" super().__init__() def commands(self): """Add subcommand used to run the AURA server.""" def run_aura(lib, opts, args): """Run the application using Flask's built in-server. Args: lib: A beets Library object (not used). opts: Command line options. An optparse.Values object. args: The list of arguments to process (not used). """ app = create_app() # Start the built-in server (not intended for production) app.run( host=self.config["host"].get(str), port=self.config["port"].get(int), debug=opts.debug, threaded=True, ) run_aura_cmd = Subcommand("aura", help="run an AURA server") run_aura_cmd.parser.add_option( "-d", "--debug", action="store_true", default=False, help="use Flask debug mode", ) run_aura_cmd.func = run_aura return [run_aura_cmd] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/badfiles.py0000644000076500000240000001634300000000000016451 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, François-Xavier Thomas. # # 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. """Use command-line tools to check for audio file corruption. """ from subprocess import check_output, CalledProcessError, list2cmdline, STDOUT import shlex import os import errno import sys import confuse from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets.util import displayable_path, par_map from beets import ui from beets import importer class CheckerCommandException(Exception): """Raised when running a checker failed. Attributes: checker: Checker command name. path: Path to the file being validated. errno: Error number from the checker execution error. msg: Message from the checker execution error. """ def __init__(self, cmd, oserror): self.checker = cmd[0] self.path = cmd[-1] self.errno = oserror.errno self.msg = str(oserror) class BadFiles(BeetsPlugin): def __init__(self): super().__init__() self.verbose = False self.register_listener('import_task_start', self.on_import_task_start) self.register_listener('import_task_before_choice', self.on_import_task_before_choice) def run_command(self, cmd): self._log.debug("running command: {}", displayable_path(list2cmdline(cmd))) try: output = check_output(cmd, stderr=STDOUT) errors = 0 status = 0 except CalledProcessError as e: output = e.output errors = 1 status = e.returncode except OSError as e: raise CheckerCommandException(cmd, e) output = output.decode(sys.getdefaultencoding(), 'replace') return status, errors, [line for line in output.split("\n") if line] def check_mp3val(self, path): status, errors, output = self.run_command(["mp3val", path]) if status == 0: output = [line for line in output if line.startswith("WARNING:")] errors = len(output) return status, errors, output def check_flac(self, path): return self.run_command(["flac", "-wst", path]) def check_custom(self, command): def checker(path): cmd = shlex.split(command) cmd.append(path) return self.run_command(cmd) return checker def get_checker(self, ext): ext = ext.lower() try: command = self.config['commands'].get(dict).get(ext) except confuse.NotFoundError: command = None if command: return self.check_custom(command) if ext == "mp3": return self.check_mp3val if ext == "flac": return self.check_flac def check_item(self, item): # First, check whether the path exists. If not, the user # should probably run `beet update` to cleanup your library. dpath = displayable_path(item.path) self._log.debug("checking path: {}", dpath) if not os.path.exists(item.path): ui.print_("{}: file does not exist".format( ui.colorize('text_error', dpath))) # Run the checker against the file if one is found ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore') checker = self.get_checker(ext) if not checker: self._log.error("no checker specified in the config for {}", ext) return [] path = item.path if not isinstance(path, str): path = item.path.decode(sys.getfilesystemencoding()) try: status, errors, output = checker(path) except CheckerCommandException as e: if e.errno == errno.ENOENT: self._log.error( "command not found: {} when validating file: {}", e.checker, e.path ) else: self._log.error("error invoking {}: {}", e.checker, e.msg) return [] error_lines = [] if status > 0: error_lines.append( "{}: checker exited with status {}" .format(ui.colorize('text_error', dpath), status)) for line in output: error_lines.append(f" {line}") elif errors > 0: error_lines.append( "{}: checker found {} errors or warnings" .format(ui.colorize('text_warning', dpath), errors)) for line in output: error_lines.append(f" {line}") elif self.verbose: error_lines.append( "{}: ok".format(ui.colorize('text_success', dpath))) return error_lines def on_import_task_start(self, task, session): if not self.config['check_on_import'].get(False): return checks_failed = [] for item in task.items: error_lines = self.check_item(item) if error_lines: checks_failed.append(error_lines) if checks_failed: task._badfiles_checks_failed = checks_failed def on_import_task_before_choice(self, task, session): if hasattr(task, '_badfiles_checks_failed'): ui.print_('{} one or more files failed checks:' .format(ui.colorize('text_warning', 'BAD'))) for error in task._badfiles_checks_failed: for error_line in error: ui.print_(error_line) ui.print_() ui.print_('What would you like to do?') sel = ui.input_options(['aBort', 'skip', 'continue']) if sel == 's': return importer.action.SKIP elif sel == 'c': return None elif sel == 'b': raise importer.ImportAbort() else: raise Exception(f'Unexpected selection: {sel}') def command(self, lib, opts, args): # Get items from arguments items = lib.items(ui.decargs(args)) self.verbose = opts.verbose def check_and_print(item): for error_line in self.check_item(item): ui.print_(error_line) par_map(check_and_print, items) def commands(self): bad_command = Subcommand('bad', help='check for corrupt or missing files') bad_command.parser.add_option( '-v', '--verbose', action='store_true', default=False, dest='verbose', help='view results for both the bad and uncorrupted files' ) bad_command.func = self.command return [bad_command] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/bareasc.py0000644000076500000240000000546200000000000016300 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Philippe Mongeau. # Copyright 2021, Graham R. Cobb. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and ascociated 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 is adapted from Fuzzy in accordance to the licence of # that module """Provides a bare-ASCII matching query.""" from beets import ui from beets.ui import print_, decargs from beets.plugins import BeetsPlugin from beets.dbcore.query import StringFieldQuery from unidecode import unidecode class BareascQuery(StringFieldQuery): """Compare items using bare ASCII, without accents etc.""" @classmethod def string_match(cls, pattern, val): """Convert both pattern and string to plain ASCII before matching. If pattern is all lower case, also convert string to lower case so match is also case insensitive """ # smartcase if pattern.islower(): val = val.lower() pattern = unidecode(pattern) val = unidecode(val) return pattern in val class BareascPlugin(BeetsPlugin): """Plugin to provide bare-ASCII option for beets matching.""" def __init__(self): """Default prefix for selecting bare-ASCII matching is #.""" super().__init__() self.config.add({ 'prefix': '#', }) def queries(self): """Register bare-ASCII matching.""" prefix = self.config['prefix'].as_str() return {prefix: BareascQuery} def commands(self): """Add bareasc command as unidecode version of 'list'.""" cmd = ui.Subcommand('bareasc', help='unidecode version of beet list command') cmd.parser.usage += "\n" \ 'Example: %prog -f \'$album: $title\' artist:beatles' cmd.parser.add_all_common_options() cmd.func = self.unidecode_list return [cmd] def unidecode_list(self, lib, opts, args): """Emulate normal 'list' command but with unidecode output.""" query = decargs(args) album = opts.album # Copied from commands.py - list_items if album: for album in lib.albums(query): bare = unidecode(str(album)) print_(bare) else: for item in lib.items(query): bare = unidecode(str(item)) print_(bare) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/beatport.py0000644000076500000240000004414200000000000016516 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Beatport release and track search support to the autotagger """ import json import re from datetime import datetime, timedelta from requests_oauthlib import OAuth1Session from requests_oauthlib.oauth1_session import (TokenRequestDenied, TokenMissing, VerifierMissing) import beets import beets.ui from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance import confuse AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing) USER_AGENT = f'beets/{beets.__version__} +https://beets.io/' class BeatportAPIError(Exception): pass class BeatportObject: def __init__(self, data): self.beatport_id = data['id'] self.name = str(data['name']) if 'releaseDate' in data: self.release_date = datetime.strptime(data['releaseDate'], '%Y-%m-%d') if 'artists' in data: self.artists = [(x['id'], str(x['name'])) for x in data['artists']] if 'genres' in data: self.genres = [str(x['name']) for x in data['genres']] class BeatportClient: _api_base = 'https://oauth-api.beatport.com' def __init__(self, c_key, c_secret, auth_key=None, auth_secret=None): """ Initiate the client with OAuth information. For the initial authentication with the backend `auth_key` and `auth_secret` can be `None`. Use `get_authorize_url` and `get_access_token` to obtain them for subsequent uses of the API. :param c_key: OAuth1 client key :param c_secret: OAuth1 client secret :param auth_key: OAuth1 resource owner key :param auth_secret: OAuth1 resource owner secret """ self.api = OAuth1Session( client_key=c_key, client_secret=c_secret, resource_owner_key=auth_key, resource_owner_secret=auth_secret, callback_uri='oob') self.api.headers = {'User-Agent': USER_AGENT} def get_authorize_url(self): """ Generate the URL for the user to authorize the application. Retrieves a request token from the Beatport API and returns the corresponding authorization URL on their end that the user has to visit. This is the first step of the initial authorization process with the API. Once the user has visited the URL, call :py:method:`get_access_token` with the displayed data to complete the process. :returns: Authorization URL for the user to visit :rtype: unicode """ self.api.fetch_request_token( self._make_url('/identity/1/oauth/request-token')) return self.api.authorization_url( self._make_url('/identity/1/oauth/authorize')) def get_access_token(self, auth_data): """ Obtain the final access token and secret for the API. :param auth_data: URL-encoded authorization data as displayed at the authorization url (obtained via :py:meth:`get_authorize_url`) after signing in :type auth_data: unicode :returns: OAuth resource owner key and secret :rtype: (unicode, unicode) tuple """ self.api.parse_authorization_response( "https://beets.io/auth?" + auth_data) access_data = self.api.fetch_access_token( self._make_url('/identity/1/oauth/access-token')) return access_data['oauth_token'], access_data['oauth_token_secret'] def search(self, query, release_type='release', details=True): """ Perform a search of the Beatport catalogue. :param query: Query string :param release_type: Type of releases to search for, can be 'release' or 'track' :param details: Retrieve additional information about the search results. Currently this will fetch the tracklist for releases and do nothing for tracks :returns: Search results :rtype: generator that yields py:class:`BeatportRelease` or :py:class:`BeatportTrack` """ response = self._get('catalog/3/search', query=query, perPage=5, facets=[f'fieldType:{release_type}']) for item in response: if release_type == 'release': if details: release = self.get_release(item['id']) else: release = BeatportRelease(item) yield release elif release_type == 'track': yield BeatportTrack(item) def get_release(self, beatport_id): """ Get information about a single release. :param beatport_id: Beatport ID of the release :returns: The matching release :rtype: :py:class:`BeatportRelease` """ response = self._get('/catalog/3/releases', id=beatport_id) if response: release = BeatportRelease(response[0]) release.tracks = self.get_release_tracks(beatport_id) return release return None def get_release_tracks(self, beatport_id): """ Get all tracks for a given release. :param beatport_id: Beatport ID of the release :returns: Tracks in the matching release :rtype: list of :py:class:`BeatportTrack` """ response = self._get('/catalog/3/tracks', releaseId=beatport_id, perPage=100) return [BeatportTrack(t) for t in response] def get_track(self, beatport_id): """ Get information about a single track. :param beatport_id: Beatport ID of the track :returns: The matching track :rtype: :py:class:`BeatportTrack` """ response = self._get('/catalog/3/tracks', id=beatport_id) return BeatportTrack(response[0]) def _make_url(self, endpoint): """ Get complete URL for a given API endpoint. """ if not endpoint.startswith('/'): endpoint = '/' + endpoint return self._api_base + endpoint def _get(self, endpoint, **kwargs): """ Perform a GET request on a given API endpoint. Automatically extracts result data from the response and converts HTTP exceptions into :py:class:`BeatportAPIError` objects. """ try: response = self.api.get(self._make_url(endpoint), params=kwargs) except Exception as e: raise BeatportAPIError("Error connecting to Beatport API: {}" .format(e)) if not response: raise BeatportAPIError( "Error {0.status_code} for '{0.request.path_url}" .format(response)) return response.json()['results'] class BeatportRelease(BeatportObject): def __str__(self): if len(self.artists) < 4: artist_str = ", ".join(x[1] for x in self.artists) else: artist_str = "Various Artists" return "".format( artist_str, self.name, self.catalog_number, ) def __repr__(self): return str(self).encode('utf-8') 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 = "https://beatport.com/release/{}/{}".format( data['slug'], data['id']) self.genre = data.get('genre') class BeatportTrack(BeatportObject): def __str__(self): artist_str = ", ".join(x[1] for x in self.artists) return ("" .format(artist_str, self.name, self.mix_name)) def __repr__(self): return str(self).encode('utf-8') def __init__(self, data): BeatportObject.__init__(self, data) if 'title' in data: self.title = str(data['title']) if 'mixName' in data: self.mix_name = str(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 = "https://beatport.com/track/{}/{}" \ .format(data['slug'], data['id']) self.track_number = data.get('trackNumber') self.bpm = data.get('bpm') self.initial_key = str( (data.get('key') or {}).get('shortName') ) # Use 'subgenre' and if not present, 'genre' as a fallback. if data.get('subGenres'): self.genre = str(data['subGenres'][0].get('name')) elif data.get('genres'): self.genre = str(data['genres'][0].get('name')) class BeatportPlugin(BeetsPlugin): data_source = 'Beatport' def __init__(self): super().__init__() self.config.add({ 'apikey': '57713c3906af6f5def151b33601389176b37b429', 'apisecret': 'b3fe08c93c80aefd749fe871a16cd2bb32e2b954', 'tokenfile': 'beatport_token.json', 'source_weight': 0.5, }) self.config['apikey'].redact = True self.config['apisecret'].redact = True self.client = None self.register_listener('import_begin', self.setup) def setup(self, session=None): c_key = self.config['apikey'].as_str() c_secret = self.config['apisecret'].as_str() # Get the OAuth token from a file or log in. try: with open(self._tokenfile()) as f: tokendata = json.load(f) except OSError: # No token yet. Generate one. token, secret = self.authenticate(c_key, c_secret) else: token = tokendata['token'] secret = tokendata['secret'] self.client = BeatportClient(c_key, c_secret, token, secret) def authenticate(self, c_key, c_secret): # Get the link for the OAuth page. auth_client = BeatportClient(c_key, c_secret) try: url = auth_client.get_authorize_url() except AUTH_ERRORS as e: self._log.debug('authentication error: {0}', e) raise beets.ui.UserError('communication with Beatport failed') beets.ui.print_("To authenticate with Beatport, visit:") beets.ui.print_(url) # Ask for the verifier data and validate it. data = beets.ui.input_("Enter the string displayed in your browser:") try: token, secret = auth_client.get_access_token(data) except AUTH_ERRORS as e: self._log.debug('authentication error: {0}', e) raise beets.ui.UserError('Beatport token request failed') # Save the token for later use. self._log.debug('Beatport token {0}, secret {1}', token, secret) with open(self._tokenfile(), 'w') as f: json.dump({'token': token, 'secret': secret}, f) return token, secret def _tokenfile(self): """Get the path to the JSON file for storing the OAuth token. """ return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True)) def album_distance(self, items, album_info, mapping): """Returns the Beatport source weight and the maximum source weight for albums. """ return get_distance( data_source=self.data_source, info=album_info, config=self.config ) def track_distance(self, item, track_info): """Returns the Beatport source weight and the maximum source weight for individual tracks. """ return get_distance( data_source=self.data_source, info=track_info, config=self.config ) def candidates(self, items, artist, release, va_likely, extra_tags=None): """Returns a list of AlbumInfo objects for beatport search results matching release and artist (if not various). """ if va_likely: query = release else: query = f'{artist} {release}' try: return self._get_releases(query) except BeatportAPIError as e: self._log.debug('API Error: {0} (query: {1})', 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 = f'{artist} {title}' try: return self._get_tracks(query) except BeatportAPIError as e: self._log.debug('API Error: {0} (query: {1})', 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 query is not a valid ID or release is not found. """ self._log.debug('Searching for release {0}', release_id) match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id) if not match: self._log.debug('Not a valid Beatport release ID.') return None release = self.client.get_release(match.group(2)) if release: return self._get_album_info(release) return None 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 a valid Beatport ID or track is not found. """ self._log.debug('Searching for track {0}', track_id) match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id) if not match: self._log.debug('Not a valid Beatport track ID.') return None bp_track = self.client.get_track(match.group(2)) if bp_track is not None: return self._get_track_info(bp_track) return None 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, flags=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, flags=re.I) albums = [self._get_album_info(x) for x in self.client.search(query)] 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 = "Various Artists" tracks = [self._get_track_info(x) for x in release.tracks] 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='Digital', data_source=self.data_source, data_url=release.url, genre=release.genre) def _get_track_info(self, track): """Returns a TrackInfo object for a Beatport Track object. """ title = track.name if track.mix_name != "Original Mix": title += f" ({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=track.track_number, medium_index=track.track_number, data_source=self.data_source, data_url=track.url, bpm=track.bpm, initial_key=track.initial_key, genre=track.genre) 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. """ return MetadataSourcePlugin.get_artist( artists=artists, id_key=0, name_key=1 ) def _get_tracks(self, query): """Returns a list of TrackInfo objects for a Beatport query. """ bp_tracks = self.client.search(query, release_type='track') tracks = [self._get_track_info(x) for x in bp_tracks] return tracks ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/bench.py0000644000076500000240000001001700000000000015747 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Some simple performance benchmarks for beets. """ from beets.plugins import BeetsPlugin from beets import ui from beets import vfs from beets import library from beets.util.functemplate import Template from beets.autotag import match from beets import plugins from beets import importer import cProfile import timeit def aunique_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) def match_benchmark(lib, prof, query=None, album_id=None): # If no album ID is provided, we'll match against a suitably huge # album. if not album_id: album_id = '9c5c043e-bc69-4edb-81a4-1aaf9c81e6dc' # Get an album from the library to use as the source for the match. items = lib.albums(query).get().items() # Ensure fingerprinting is invoked (if enabled). plugins.send('import_task_start', task=importer.ImportTask(None, None, items), session=importer.ImportSession(lib, None, None, None)) # Run the match. def _run_match(): match.tag_album(items, search_ids=[album_id]) if prof: cProfile.runctx('_run_match()', {}, {'_run_match': _run_match}, 'match.prof') else: interval = timeit.timeit(_run_match, number=1) print('match duration:', interval) class BenchmarkPlugin(BeetsPlugin): """A plugin for performing some simple performance benchmarks. """ def commands(self): aunique_bench_cmd = ui.Subcommand('bench_aunique', help='benchmark for %aunique{}') aunique_bench_cmd.parser.add_option('-p', '--profile', action='store_true', default=False, help='performance profiling') aunique_bench_cmd.func = lambda lib, opts, args: \ aunique_benchmark(lib, opts.profile) match_bench_cmd = ui.Subcommand('bench_match', help='benchmark for track matching') match_bench_cmd.parser.add_option('-p', '--profile', action='store_true', default=False, help='performance profiling') match_bench_cmd.parser.add_option('-i', '--id', default=None, help='album ID to match against') match_bench_cmd.func = lambda lib, opts, args: \ match_benchmark(lib, opts.profile, ui.decargs(args), opts.id) return [aunique_bench_cmd, match_bench_cmd] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1638031078.3145611 beets-1.6.0/beetsplug/bpd/0000755000076500000240000000000000000000000015064 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/bpd/__init__.py0000644000076500000240000016004200000000000017200 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A 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. """ import re import sys from string import Template import traceback import random import time import math import inspect import socket import beets from beets.plugins import BeetsPlugin import beets.ui from beets import vfs from beets.util import bluelet from beets.library import Item from beets import dbcore from mediafile import MediaFile PROTOCOL_VERSION = '0.16.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 = "\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. 'close', 'commands', 'notcommands', 'password', 'ping', ) # List of subsystems/events used by the `idle` command. SUBSYSTEMS = [ 'update', 'player', 'mixer', 'options', 'playlist', 'database', # Related to unsupported commands: 'stored_playlist', 'output', 'subscription', 'sticker', 'message', 'partition', ] ITEM_KEYS_WRITABLE = set(MediaFile.fields()).intersection(Item._fields.keys()) # 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('$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. """ class BPDIdle(Exception): """Raised by a command to indicate the client wants to enter the idle state and should be notified when a relevant event happens. """ def __init__(self, subsystems): super().__init__() self.subsystems = set(subsystems) # Generic server infrastructure, implementing the basic protocol. class BaseServer: """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, ctrl_port, log, ctrl_host=None): """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. A separate control socket is established listening to `ctrl_host` on port `ctrl_port` which is used to forward notifications from the player and can be sent debug commands (e.g. using netcat). """ self.host, self.port, self.password = host, port, password self.ctrl_host, self.ctrl_port = ctrl_host or host, ctrl_port self.ctrl_sock = None self._log = log # Default server values. self.random = False self.repeat = False self.consume = False self.single = False self.volume = VOLUME_MAX self.crossfade = 0 self.mixrampdb = 0.0 self.mixrampdelay = float('nan') self.replay_gain_mode = 'off' self.playlist = [] self.playlist_version = 0 self.current_index = -1 self.paused = False self.error = None # Current connections self.connections = set() # Object for random numbers generation self.random_obj = random.Random() def connect(self, conn): """A new client has connected. """ self.connections.add(conn) def disconnect(self, conn): """Client has disconnected; clean up residual state. """ self.connections.remove(conn) def run(self): """Block and start listening for connections from clients. An interrupt (^C) closes the server. """ self.startup_time = time.time() def start(): yield bluelet.spawn( bluelet.server(self.ctrl_host, self.ctrl_port, ControlConnection.handler(self))) yield bluelet.server(self.host, self.port, MPDConnection.handler(self)) bluelet.run(start()) def dispatch_events(self): """If any clients have idle events ready, send them. """ # We need a copy of `self.connections` here since clients might # disconnect once we try and send to them, changing `self.connections`. for conn in list(self.connections): yield bluelet.spawn(conn.send_notifications()) def _ctrl_send(self, message): """Send some data over the control socket. If it's our first time, open the socket. The message should be a string without a terminal newline. """ if not self.ctrl_sock: self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port)) self.ctrl_sock.sendall((message + '\n').encode('utf-8')) def _send_event(self, event): """Notify subscribed connections of an event.""" for conn in self.connections: conn.notify(event) 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, single and repeat flags. No boundaries are checked. """ if self.repeat and self.single: 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 and self.single: 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_idle(self, conn, *subsystems): subsystems = subsystems or SUBSYSTEMS for system in subsystems: if system not in SUBSYSTEMS: raise BPDError(ERROR_ARG, f'Unrecognised idle event: {system}') raise BPDIdle(subsystems) # put the connection into idle mode def cmd_kill(self, conn): """Exits the server process.""" sys.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 'command: ' + cmd else: # Authenticated. Show all commands. for func in dir(self): if func.startswith('cmd_'): yield '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 '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 ( 'repeat: ' + str(int(self.repeat)), 'random: ' + str(int(self.random)), 'consume: ' + str(int(self.consume)), 'single: ' + str(int(self.single)), 'playlist: ' + str(self.playlist_version), 'playlistlength: ' + str(len(self.playlist)), 'mixrampdb: ' + str(self.mixrampdb), ) if self.volume > 0: yield 'volume: ' + str(self.volume) if not math.isnan(self.mixrampdelay): yield 'mixrampdelay: ' + str(self.mixrampdelay) if self.crossfade > 0: yield 'xfade: ' + str(self.crossfade) if self.current_index == -1: state = 'stop' elif self.paused: state = 'pause' else: state = 'play' yield 'state: ' + state if self.current_index != -1: # i.e., paused or playing current_id = self._item_id(self.playlist[self.current_index]) yield 'song: ' + str(self.current_index) yield 'songid: ' + str(current_id) if len(self.playlist) > self.current_index + 1: # If there's a next song, report its index too. next_id = self._item_id(self.playlist[self.current_index + 1]) yield 'nextsong: ' + str(self.current_index + 1) yield 'nextsongid: ' + str(next_id) if self.error: yield '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) self._send_event('options') def cmd_repeat(self, conn, state): """Set or unset repeat mode.""" self.repeat = cast_arg('intbool', state) self._send_event('options') def cmd_consume(self, conn, state): """Set or unset consume mode.""" self.consume = cast_arg('intbool', state) self._send_event('options') def cmd_single(self, conn, state): """Set or unset single mode.""" # TODO support oneshot in addition to 0 and 1 [MPD 0.20] self.single = cast_arg('intbool', state) self._send_event('options') 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, 'volume out of range') self.volume = vol self._send_event('mixer') def cmd_volume(self, conn, vol_delta): """Deprecated command to change the volume by a relative amount.""" vol_delta = cast_arg(int, vol_delta) return self.cmd_setvol(conn, self.volume + vol_delta) 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, 'crossfade time must be nonnegative') self._log.warning('crossfade is not implemented in bpd') self.crossfade = crossfade self._send_event('options') def cmd_mixrampdb(self, conn, db): """Set the mixramp normalised max volume in dB.""" db = cast_arg(float, db) if db > 0: raise BPDError(ERROR_ARG, 'mixrampdb time must be negative') self._log.warning('mixramp is not implemented in bpd') self.mixrampdb = db self._send_event('options') def cmd_mixrampdelay(self, conn, delay): """Set the mixramp delay in seconds.""" delay = cast_arg(float, delay) if delay < 0: raise BPDError(ERROR_ARG, 'mixrampdelay time must be nonnegative') self._log.warning('mixramp is not implemented in bpd') self.mixrampdelay = delay self._send_event('options') def cmd_replay_gain_mode(self, conn, mode): """Set the replay gain mode.""" if mode not in ['off', 'track', 'album', 'auto']: raise BPDError(ERROR_ARG, 'Unrecognised replay gain mode') self._log.warning('replay gain is not implemented in bpd') self.replay_gain_mode = mode self._send_event('options') def cmd_replay_gain_status(self, conn): """Get the replaygain mode.""" yield 'replay_gain_mode: ' + str(self.replay_gain_mode) def cmd_clear(self, conn): """Clear the playlist.""" self.playlist = [] self.playlist_version += 1 self.cmd_stop(conn) self._send_event('playlist') 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 self._send_event('playlist') 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 self._send_event('playlist') 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 self._send_event('playlist') 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=None): """Gives metadata information about the entire playlist or a single track, given by its index. """ if index is None: for track in self.playlist: yield self._item_info(track) else: indices = self._parse_range(index, accept_single_number=True) try: tracks = [self.playlist[i] for i in indices] except IndexError: raise ArgumentIndexError() for track in tracks: yield self._item_info(track) def cmd_playlistid(self, conn, track_id=None): if track_id is not None: track_id = cast_arg(int, track_id) track_id = self._id_to_index(track_id) return self.cmd_playlistinfo(conn, 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 'cpos: ' + str(idx) yield 'Id: ' + str(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.""" old_index = self.current_index self.current_index = self._succ_idx() if self.consume: # TODO how does consume interact with single+repeat? self.playlist.pop(old_index) if self.current_index > old_index: self.current_index -= 1 self.playlist_version += 1 self._send_event("playlist") if self.current_index >= len(self.playlist): # Fallen off the end. Move to stopped state or loop. if self.repeat: self.current_index = -1 return self.cmd_play(conn) return self.cmd_stop(conn) elif self.single and not self.repeat: return self.cmd_stop(conn) else: return self.cmd_play(conn) def cmd_previous(self, conn): """Step back to the last song.""" old_index = self.current_index self.current_index = self._prev_idx() if self.consume: self.playlist.pop(old_index) if self.current_index < 0: if self.repeat: self.current_index = len(self.playlist) - 1 else: self.current_index = 0 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) self._send_event('player') 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 self._send_event('player') 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 self._send_event('player') 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 self._send_event('player') def cmd_seekid(self, conn, track_id, pos): index = self._id_to_index(track_id) return self.cmd_seek(conn, index, pos) # Additions to the MPD protocol. def cmd_crash_TypeError(self, conn): # noqa: N802 """Deliberately trigger a TypeError for testing purposes. We want to test that the server properly responds with ERROR_SYSTEM without crashing, and that this is not treated as ERROR_ARG (since it is caused by a programming error, not a protocol error). """ 'a' + 2 class Connection: """A connection between a client and the server. """ def __init__(self, server, sock): """Create a new connection for the accepted socket `client`. """ self.server = server self.sock = sock self.address = '{}:{}'.format(*sock.sock.getpeername()) def debug(self, message, kind=' '): """Log a debug message about this connection. """ self.server._log.debug('{}[{}]: {}', kind, self.address, message) def run(self): pass 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, str): lines = [lines] out = NEWLINE.join(lines) + NEWLINE for l in out.split(NEWLINE)[:-1]: self.debug(l, kind='>') if isinstance(out, str): out = out.encode('utf-8') return self.sock.sendall(out) @classmethod def handler(cls, server): def _handle(sock): """Creates a new `Connection` and runs it. """ return cls(server, sock).run() return _handle class MPDConnection(Connection): """A connection that receives commands from an MPD-compatible client. """ def __init__(self, server, sock): """Create a new connection for the accepted socket `client`. """ super().__init__(server, sock) self.authenticated = False self.notifications = set() self.idle_subscriptions = set() 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 disconnect(self): """The connection has closed for any reason. """ self.server.disconnect(self) self.debug('disconnected', kind='*') def notify(self, event): """Queue up an event for sending to this client. """ self.notifications.add(event) def send_notifications(self, force_close_idle=False): """Send the client any queued events now. """ pending = self.notifications.intersection(self.idle_subscriptions) try: for event in pending: yield self.send(f'changed: {event}') if pending or force_close_idle: self.idle_subscriptions = set() self.notifications = self.notifications.difference(pending) yield self.send(RESP_OK) except bluelet.SocketClosedError: self.disconnect() # Client disappeared. def run(self): """Send a greeting to the client and begin processing commands as they arrive. """ self.debug('connected', kind='*') self.server.connect(self) yield self.send(HELLO) clist = None # Initially, no command list is being constructed. while True: line = yield self.sock.readline() if not line: self.disconnect() # Client disappeared. break line = line.strip() if not line: err = BPDError(ERROR_UNKNOWN, 'No command given') yield self.send(err.response()) self.disconnect() # Client sent a blank line. break line = line.decode('utf8') # MPD protocol uses UTF-8. for l in line.split(NEWLINE): self.debug(l, kind='<') if self.idle_subscriptions: # The connection is in idle mode. if line == 'noidle': yield bluelet.call(self.send_notifications(True)) else: err = BPDError(ERROR_UNKNOWN, f'Got command while idle: {line}') yield self.send(err.response()) break continue if line == 'noidle': # When not in idle, this command sends no response. continue 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. yield bluelet.call(self.server.dispatch_events()) 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() self.disconnect() # Client explicitly closed. return except BPDIdle as e: self.idle_subscriptions = e.subsystems self.debug('awaiting: {}'.format(' '.join(e.subsystems)), kind='z') yield bluelet.call(self.server.dispatch_events()) class ControlConnection(Connection): """A connection used to control BPD for debugging and internal events. """ def __init__(self, server, sock): """Create a new connection for the accepted socket `client`. """ super().__init__(server, sock) def debug(self, message, kind=' '): self.server._log.debug('CTRL {}[{}]: {}', kind, self.address, message) def run(self): """Listen for control commands and delegate to `ctrl_*` methods. """ self.debug('connected', kind='*') while True: line = yield self.sock.readline() if not line: break # Client disappeared. line = line.strip() if not line: break # Client sent a blank line. line = line.decode('utf8') # Protocol uses UTF-8. for l in line.split(NEWLINE): self.debug(l, kind='<') command = Command(line) try: func = command.delegate('ctrl_', self) yield bluelet.call(func(*command.args)) except (AttributeError, TypeError) as e: yield self.send('ERROR: {}'.format(e.args[0])) except Exception: yield self.send(['ERROR: server error', traceback.format_exc().rstrip()]) def ctrl_play_finished(self): """Callback from the player signalling a song finished playing. """ yield bluelet.call(self.server.dispatch_events()) def ctrl_profile(self): """Memory profiling for debugging. """ from guppy import hpy heap = hpy().heap() yield self.send(heap) def ctrl_nickname(self, oldlabel, newlabel): """Rename a client in the log messages. """ for c in self.server.connections: if c.address == oldlabel: c.address = newlabel break else: yield self.send(f'ERROR: no such client: {oldlabel}') class Command: """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] self.args.append(arg) def delegate(self, prefix, target, extra_args=0): """Get the target method that corresponds to this command. The `prefix` is prepended to the command name and then the resulting name is used to search `target` for a method with a compatible number of arguments. """ # Attempt to get correct command function. func_name = prefix + self.name if not hasattr(target, func_name): raise AttributeError(f'unknown command "{self.name}"') func = getattr(target, func_name) argspec = inspect.getfullargspec(func) # Check that `func` is able to handle the number of arguments sent # by the client (so we can raise ERROR_ARG instead of ERROR_SYSTEM). # Maximum accepted arguments: argspec includes "self". max_args = len(argspec.args) - 1 - extra_args # Minimum accepted arguments: some arguments might be optional. min_args = max_args if argspec.defaults: min_args -= len(argspec.defaults) wrong_num = (len(self.args) > max_args) or (len(self.args) < min_args) # If the command accepts a variable number of arguments skip the check. if wrong_num and not argspec.varargs: raise TypeError('wrong number of arguments for "{}"' .format(self.name), self.name) return func def run(self, conn): """A coroutine that executes the command on the given connection. """ try: # `conn` is an extra argument to all cmd handlers. func = self.delegate('cmd_', conn.server, extra_args=1) except AttributeError as e: raise BPDError(ERROR_UNKNOWN, e.args[0]) except TypeError as e: raise BPDError(ERROR_ARG, e.args[0], self.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, '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 BPDIdle: raise except Exception: # An "unintentional" error. Hide it from the client. conn.server._log.error('{}', traceback.format_exc()) raise BPDError(ERROR_SYSTEM, '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 delimiter 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, ctrl_port, log): 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 log.info('Starting server...') super().__init__(host, port, password, ctrl_port, log) self.lib = library self.player = gstplayer.GstPlayer(self.play_finished) self.cmd_update(None) log.info('Server ready and listening on {}:{}'.format( host, port)) log.debug('Listening for control signals on {}:{}'.format( host, ctrl_port)) def run(self): self.player.run() super().run() def play_finished(self): """A callback invoked every time our player finishes a track. """ self.cmd_next(None) self._ctrl_send('play_finished') # Metadata helper functions. def _item_info(self, item): info_lines = [ 'file: ' + item.destination(fragment=True), 'Time: ' + str(int(item.length)), 'duration: ' + f'{item.length:.3f}', 'Id: ' + str(item.id), ] try: pos = self._id_to_index(item.id) info_lines.append('Pos: ' + str(pos)) except ArgumentNotFoundError: # Don't include position if not in playlist. pass for tagtype, field in self.tagtype_map.items(): info_lines.append('{}: {}'.format( tagtype, str(getattr(item, field)))) return info_lines def _parse_range(self, items, accept_single_number=False): """Convert a range of positions to a list of item info. MPD specifies ranges as START:STOP (endpoint excluded) for some commands. Sometimes a single number can be provided instead. """ try: start, stop = str(items).split(':', 1) except ValueError: if accept_single_number: return [cast_arg(int, items)] raise BPDError(ERROR_ARG, 'bad range syntax') start = cast_arg(int, start) stop = cast_arg(int, stop) return range(start, stop) def _item_id(self, item): return item.id # Database updating. def cmd_update(self, conn, path='/'): """Updates the catalog to reflect the current database state. """ # Path is ignored. Also, the real MPD does this asynchronously; # this is done inline. self._log.debug('Building directory tree...') self.tree = vfs.libtree(self.lib) self._log.debug('Finished building directory tree.') self.updated_time = time.time() self._send_event('update') self._send_event('database') # 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('/') 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 + '/' + p2 return out.replace('//', '/').replace('//', '/') def cmd_lsinfo(self, conn, path="/"): """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.items())): dirpath = self._path_join(path, name) if dirpath.startswith("/"): # Strip leading slash (libmpc rejects this). dirpath = dirpath[1:] yield '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 'file: ' + basepath else: # List a directory. Recurse into both directories and files. for name, itemid in sorted(node.files.items()): newpath = self._path_join(basepath, name) # "yield from" yield from self._listall(newpath, itemid, info) for name, subdir in sorted(node.dirs.items()): newpath = self._path_join(basepath, name) yield 'directory: ' + newpath yield from self._listall(newpath, subdir, info) def cmd_listall(self, conn, path="/"): """Send the paths all items in the directory, recursively.""" return self._listall(path, self._resolve_path(path), False) def cmd_listallinfo(self, conn, path="/"): """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.items()): # "yield from" yield from self._all_items(itemid) for name, subdir in sorted(node.dirs.items()): yield from self._all_items(subdir) 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 'Id: ' + str(item.id) self.playlist_version += 1 self._send_event('playlist') 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): yield from super().cmd_status(conn) if self.current_index > -1: item = self.playlist[self.current_index] yield ( 'bitrate: ' + str(item.bitrate / 1000), 'audio: {}:{}:{}'.format( str(item.samplerate), str(item.bitdepth), str(item.channels), ), ) (pos, total) = self.player.time() yield ( 'time: {}:{}'.format( str(int(pos)), str(int(total)), ), 'elapsed: ' + f'{pos:.3f}', 'duration: ' + f'{total:.3f}', ) # 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 ( 'artists: ' + str(artists), 'albums: ' + str(albums), 'songs: ' + str(songs), 'uptime: ' + str(int(time.time() - self.startup_time)), 'playtime: ' + '0', # Missing. 'db_playtime: ' + str(int(totaltime)), 'db_update: ' + str(int(self.updated_time)), ) def cmd_decoders(self, conn): """Send list of supported decoders and formats.""" decoders = self.player.get_decoders() for name, (mimes, exts) in decoders.items(): yield f'plugin: {name}' for ext in exts: yield f'suffix: {ext}' for mime in mimes: yield f'mime_type: {mime}' # Searching. tagtype_map = { 'Artist': 'artist', 'ArtistSort': 'artist_sort', 'Album': 'album', 'Title': 'title', 'Track': 'track', 'AlbumArtist': 'albumartist', 'AlbumArtistSort': 'albumartist_sort', 'Label': 'label', 'Genre': 'genre', 'Date': 'year', 'OriginalDate': 'original_year', 'Composer': 'composer', 'Disc': 'disc', 'Comment': 'comments', 'MUSICBRAINZ_TRACKID': 'mb_trackid', 'MUSICBRAINZ_ALBUMID': 'mb_albumid', 'MUSICBRAINZ_ARTISTID': 'mb_artistid', 'MUSICBRAINZ_ALBUMARTISTID': 'mb_albumartistid', 'MUSICBRAINZ_RELEASETRACKID': 'mb_releasetrackid', } def cmd_tagtypes(self, conn): """Returns a list of the metadata (tag) fields available for searching. """ for tag in self.tagtype_map: yield '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, '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() == 'any': if any_query_type: queries.append(any_query_type(value, ITEM_KEYS_WRITABLE, query_type)) else: raise BPDError(ERROR_UNKNOWN, 'no such tagtype') else: _, key = self._tagtype_lookup(tag) queries.append(query_type(key, value)) return dbcore.query.AndQuery(queries) else: # No key-value pairs. return dbcore.query.TrueQuery() def cmd_search(self, conn, *kv): """Perform a substring match for items.""" query = self._metadata_query(dbcore.query.SubstringQuery, dbcore.query.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(dbcore.query.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) if len(kv) == 1: if show_tag_canon == 'Album': # If no tag was given, assume artist. This is because MPD # supports a short version of this command for fetching the # albums belonging to a particular artist, and some clients # rely on this behaviour (e.g. MPDroid, M.A.L.P.). kv = ('Artist', kv[0]) else: raise BPDError(ERROR_ARG, 'should be "Album" for 3 arguments') elif len(kv) % 2 != 0: raise BPDError(ERROR_ARG, 'Incorrect number of filter arguments') query = self._metadata_query(dbcore.query.MatchQuery, None, kv) clause, subvals = query.clause() statement = 'SELECT DISTINCT ' + show_key + \ ' FROM items WHERE ' + clause + \ ' ORDER BY ' + show_key self._log.debug(statement) with self.lib.transaction() as tx: rows = tx.query(statement, subvals) for row in rows: if not row[0]: # Skip any empty values of the field. continue yield show_tag_canon + ': ' + str(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(dbcore.query.MatchQuery(key, value)): songs += 1 playtime += item.length yield 'songs: ' + str(songs) yield 'playtime: ' + str(int(playtime)) # Persistent playlist manipulation. In MPD this is an optional feature so # these dummy implementations match MPD's behaviour with the feature off. def cmd_listplaylist(self, conn, playlist): raise BPDError(ERROR_NO_EXIST, 'No such playlist') def cmd_listplaylistinfo(self, conn, playlist): raise BPDError(ERROR_NO_EXIST, 'No such playlist') def cmd_listplaylists(self, conn): raise BPDError(ERROR_UNKNOWN, 'Stored playlists are disabled') def cmd_load(self, conn, playlist): raise BPDError(ERROR_NO_EXIST, 'Stored playlists are disabled') def cmd_playlistadd(self, conn, playlist, uri): raise BPDError(ERROR_UNKNOWN, 'Stored playlists are disabled') def cmd_playlistclear(self, conn, playlist): raise BPDError(ERROR_UNKNOWN, 'Stored playlists are disabled') def cmd_playlistdelete(self, conn, playlist, index): raise BPDError(ERROR_UNKNOWN, 'Stored playlists are disabled') def cmd_playlistmove(self, conn, playlist, from_index, to_index): raise BPDError(ERROR_UNKNOWN, 'Stored playlists are disabled') def cmd_rename(self, conn, playlist, new_name): raise BPDError(ERROR_UNKNOWN, 'Stored playlists are disabled') def cmd_rm(self, conn, playlist): raise BPDError(ERROR_UNKNOWN, 'Stored playlists are disabled') def cmd_save(self, conn, playlist): raise BPDError(ERROR_UNKNOWN, 'Stored playlists are disabled') # "Outputs." Just a dummy implementation because we don't control # any outputs. def cmd_outputs(self, conn): """List the available outputs.""" yield ( 'outputid: 0', 'outputname: gstreamer', '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, '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().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().cmd_pause(conn, state) if self.paused: self.player.pause() elif self.player.playing: self.player.play() def cmd_stop(self, conn): super().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(float, pos) super().cmd_seek(conn, index, pos) self.player.seek(pos) # Volume control. def cmd_setvol(self, conn, vol): vol = cast_arg(int, vol) super().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().__init__() self.config.add({ 'host': '', 'port': 6600, 'control_port': 6601, 'password': '', 'volume': VOLUME_MAX, }) self.config['password'].redact = True def start_bpd(self, lib, host, port, password, volume, ctrl_port): """Starts a BPD server.""" try: server = Server(lib, host, port, password, ctrl_port, self._log) server.cmd_setvol(None, volume) server.run() except NoGstreamerError: self._log.error('Gstreamer Python bindings not found.') self._log.error('Install "gstreamer1.0" and "python-gi"' 'or similar package to use BPD.') def commands(self): cmd = beets.ui.Subcommand( 'bpd', help='run an MPD-compatible music player server' ) def func(lib, opts, args): host = self.config['host'].as_str() host = args.pop(0) if args else host port = args.pop(0) if args else self.config['port'].get(int) if args: ctrl_port = args.pop(0) else: ctrl_port = self.config['control_port'].get(int) if args: raise beets.ui.UserError('too many arguments') password = self.config['password'].as_str() volume = self.config['volume'].get(int) self.start_bpd(lib, host, int(port), password, volume, int(ctrl_port)) cmd.func = func return [cmd] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/bpd/gstplayer.py0000644000076500000240000002326100000000000017454 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A wrapper for the GStreamer Python bindings that exposes a simple music player. """ import sys import time import _thread import os import copy import urllib from beets import ui import gi gi.require_version('Gst', '1.0') from gi.repository import GLib, Gst # noqa: E402 Gst.init(None) class QueryError(Exception): pass class GstPlayer: """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: # https://pygstdocs.berlios.de/pygst-tutorial/playbin.html (gone) # https://brettviren.github.io/pygst-tutorial-org/pygst-tutorial.html #### # Updated to GStreamer 1.0 with: # https://wiki.ubuntu.com/Novacut/GStreamer1.0 self.player = Gst.ElementFactory.make("playbin", "player") if self.player is None: raise ui.UserError("Could not create playbin") fakesink = Gst.ElementFactory.make("fakesink", "fakesink") if fakesink is None: raise ui.UserError("Could not create 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(Gst.CLOCK_TIME_NONE)[1] def _handle_message(self, bus, message): """Callback for status updates from GStreamer.""" if message.type == Gst.MessageType.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.MessageType.ERROR: # error self.player.set_state(Gst.State.NULL) err, debug = message.parse_error() print(f"Error: {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, str): path = path.encode('utf-8') uri = 'file://' + urllib.parse.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. def start(): loop = GLib.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: posq = self.player.query_position(fmt) if not posq[0]: raise QueryError("query_position failed") pos = posq[1] / (10 ** 9) lengthq = self.player.query_duration(fmt) if not lengthq[0]: raise QueryError("query_duration failed") length = lengthq[1] / (10 ** 9) self.cached_time = (pos, length) return (pos, length) except 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.SeekFlags.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 get_decoders(self): return get_decoders() def get_decoders(): """Get supported audio decoders from GStreamer. Returns a dict mapping decoder element names to the associated media types and file extensions. """ # We only care about audio decoder elements. filt = (Gst.ELEMENT_FACTORY_TYPE_DEPAYLOADER | Gst.ELEMENT_FACTORY_TYPE_DEMUXER | Gst.ELEMENT_FACTORY_TYPE_PARSER | Gst.ELEMENT_FACTORY_TYPE_DECODER | Gst.ELEMENT_FACTORY_TYPE_MEDIA_AUDIO) decoders = {} mime_types = set() for f in Gst.ElementFactory.list_get_elements(filt, Gst.Rank.NONE): for pad in f.get_static_pad_templates(): if pad.direction == Gst.PadDirection.SINK: caps = pad.static_caps.get() mimes = set() for i in range(caps.get_size()): struct = caps.get_structure(i) mime = struct.get_name() if mime == 'unknown/unknown': continue mimes.add(mime) mime_types.add(mime) if mimes: decoders[f.get_name()] = (mimes, set()) # Check all the TypeFindFactory plugin features form the registry. If they # are associated with an audio media type that we found above, get the list # of corresponding file extensions. mime_extensions = {mime: set() for mime in mime_types} for feat in Gst.Registry.get().get_feature_list(Gst.TypeFindFactory): caps = feat.get_caps() if caps: for i in range(caps.get_size()): struct = caps.get_structure(i) mime = struct.get_name() if mime in mime_types: mime_extensions[mime].update(feat.get_extensions()) # Fill in the slot we left for file extensions. for name, (mimes, exts) in decoders.items(): for mime in mimes: exts.update(mime_extensions[mime]) return decoders 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/bpm.py0000644000076500000240000000512700000000000015454 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, aroquen # # 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. """Determine BPM by pressing a key to the rhythm.""" import time from beets import ui from beets.plugins import BeetsPlugin def bpm(max_strokes): """Returns average BPM (possibly of a playing song) listening to Enter keystrokes. """ t0 = None dt = [] for i in range(max_strokes): # Press enter to the rhythm... s = input() if s == '': t1 = time.time() # Only start measuring at the second stroke if t0: dt.append(t1 - t0) t0 = t1 else: break # Return average BPM # bpm = (max_strokes-1) / sum(dt) * 60 ave = sum([1.0 / dti * 60 for dti in dt]) / len(dt) return ave class BPMPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'max_strokes': 3, 'overwrite': True, }) def commands(self): cmd = ui.Subcommand('bpm', help='determine bpm of a song by pressing ' 'a key to the rhythm') cmd.func = self.command return [cmd] def command(self, lib, opts, args): items = lib.items(ui.decargs(args)) write = ui.should_write() self.get_bpm(items, write) def get_bpm(self, items, write=False): overwrite = self.config['overwrite'].get(bool) if len(items) > 1: raise ValueError('Can only get bpm of one song at time') item = items[0] if item['bpm']: self._log.info('Found bpm {0}', item['bpm']) if not overwrite: return self._log.info('Press Enter {0} times to the rhythm or Ctrl-D ' 'to exit', self.config['max_strokes'].get(int)) new_bpm = bpm(self.config['max_strokes'].get(int)) item['bpm'] = int(new_bpm) if write: item.try_write() item.store() self._log.info('Added new bpm {0}', item['bpm']) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/bpsync.py0000644000076500000240000001467700000000000016206 0ustar00asampsonstaff# This file is part of beets. # Copyright 2019, Rahul Ahuja. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Update library's tags using Beatport. """ from beets.plugins import BeetsPlugin, apply_item_changes from beets import autotag, library, ui, util from .beatport import BeatportPlugin class BPSyncPlugin(BeetsPlugin): def __init__(self): super().__init__() self.beatport_plugin = BeatportPlugin() self.beatport_plugin.setup() def commands(self): cmd = ui.Subcommand('bpsync', help='update metadata from Beatport') cmd.parser.add_option( '-p', '--pretend', action='store_true', help='show all changes but do nothing', ) cmd.parser.add_option( '-m', '--move', action='store_true', dest='move', help="move files in the library directory", ) cmd.parser.add_option( '-M', '--nomove', action='store_false', dest='move', help="don't move files in library", ) cmd.parser.add_option( '-W', '--nowrite', action='store_false', default=None, dest='write', help="don't write updated metadata to files", ) cmd.parser.add_format_option() cmd.func = self.func return [cmd] def func(self, lib, opts, args): """Command handler for the bpsync function. """ move = ui.should_move(opts.move) pretend = opts.pretend write = ui.should_write(opts.write) query = ui.decargs(args) self.singletons(lib, query, move, pretend, write) self.albums(lib, query, move, pretend, write) def singletons(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for items matched by query. """ for item in lib.items(query + ['singleton:true']): if not item.mb_trackid: self._log.info( 'Skipping singleton with no mb_trackid: {}', item ) continue if not self.is_beatport_track(item): self._log.info( 'Skipping non-{} singleton: {}', self.beatport_plugin.data_source, item, ) continue # Apply. trackinfo = self.beatport_plugin.track_for_id(item.mb_trackid) with lib.transaction(): autotag.apply_item_metadata(item, trackinfo) apply_item_changes(lib, item, move, pretend, write) @staticmethod def is_beatport_track(item): return ( item.get('data_source') == BeatportPlugin.data_source and item.mb_trackid.isnumeric() ) def get_album_tracks(self, album): if not album.mb_albumid: self._log.info('Skipping album with no mb_albumid: {}', album) return False if not album.mb_albumid.isnumeric(): self._log.info( 'Skipping album with invalid {} ID: {}', self.beatport_plugin.data_source, album, ) return False items = list(album.items()) if album.get('data_source') == self.beatport_plugin.data_source: return items if not all(self.is_beatport_track(item) for item in items): self._log.info( 'Skipping non-{} release: {}', self.beatport_plugin.data_source, album, ) return False return items def albums(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for albums matched by query and their items. """ # Process matching albums. for album in lib.albums(query): # Do we have a valid Beatport album? items = self.get_album_tracks(album) if not items: continue # Get the Beatport album information. albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid) if not albuminfo: self._log.info( 'Release ID {} not found for album {}', album.mb_albumid, album, ) continue beatport_trackid_to_trackinfo = { track.track_id: track for track in albuminfo.tracks } library_trackid_to_item = { int(item.mb_trackid): item for item in items } item_to_trackinfo = { item: beatport_trackid_to_trackinfo[track_id] for track_id, item in library_trackid_to_item.items() } self._log.info('applying changes to {}', album) with lib.transaction(): autotag.apply_metadata(albuminfo, item_to_trackinfo) changed = False # Find any changed item to apply Beatport changes to album. any_changed_item = items[0] for item in items: item_changed = ui.show_model_changes(item) changed |= item_changed if item_changed: any_changed_item = item apply_item_changes(lib, item, move, pretend, write) if pretend or not changed: continue # Update album structure to reflect an item in it. for key in library.Album.item_keys: album[key] = any_changed_item[key] album.store() # Move album art (and any inconsistent items). if move and lib.directory in util.ancestry(items[0].path): self._log.debug('moving album {}', album) album.move() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/bucket.py0000644000076500000240000001756400000000000016163 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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. """Provides the %bucket{} function for path formatting. """ from datetime import datetime import re import string from itertools import tee from beets import plugins, ui ASCII_DIGITS = string.digits + string.ascii_lowercase class BucketError(Exception): pass def pairwise(iterable): "s -> (s0,s1), (s1,s2), (s2, s3), ..." a, b = tee(iterable) next(b, None) return zip(a, b) def span_from_str(span_str): """Build a span dict from the span string representation. """ def normalize_year(d, yearfrom): """Convert string to a 4 digits year """ if yearfrom < 100: raise BucketError("%d must be expressed on 4 digits" % yearfrom) # if two digits only, pick closest year that ends by these two # digits starting from yearfrom if d < 100: if (d % 100) < (yearfrom % 100): d = (yearfrom - yearfrom % 100) + 100 + d else: d = (yearfrom - yearfrom % 100) + d return d years = [int(x) for x in re.findall(r'\d+', span_str)] if not years: raise ui.UserError("invalid range defined for year bucket '%s': no " "year found" % span_str) try: years = [normalize_year(x, years[0]) for x in years] except BucketError as exc: raise ui.UserError("invalid range defined for year bucket '%s': %s" % (span_str, exc)) res = {'from': years[0], 'str': span_str} if len(years) > 1: res['to'] = years[-1] return res def complete_year_spans(spans): """Set the `to` value of spans if empty and sort them chronologically. """ spans.sort(key=lambda x: x['from']) for (x, y) in pairwise(spans): if 'to' not in x: x['to'] = y['from'] - 1 if spans and 'to' not in spans[-1]: spans[-1]['to'] = datetime.now().year def extend_year_spans(spans, spanlen, start=1900, end=2014): """Add new spans to given spans list so that every year of [start,end] belongs to a span. """ extended_spans = spans[:] for (x, y) in pairwise(spans): # if a gap between two spans, fill the gap with as much spans of # spanlen length as necessary for span_from in range(x['to'] + 1, y['from'], spanlen): extended_spans.append({'from': span_from}) # Create spans prior to declared ones for span_from in range(spans[0]['from'] - spanlen, start, -spanlen): extended_spans.append({'from': span_from}) # Create spans after the declared ones for span_from in range(spans[-1]['to'] + 1, end, spanlen): extended_spans.append({'from': span_from}) complete_year_spans(extended_spans) return extended_spans def build_year_spans(year_spans_str): """Build a chronologically ordered list of spans dict from unordered spans stringlist. """ spans = [] for elem in year_spans_str: spans.append(span_from_str(elem)) complete_year_spans(spans) return spans def str2fmt(s): """Deduces formatting syntax from a span string. """ regex = re.compile(r"(?P\D*)(?P\d+)(?P\D*)" r"(?P\d*)(?P\D*)") m = re.match(regex, s) res = {'fromnchars': len(m.group('fromyear')), 'tonchars': len(m.group('toyear'))} res['fmt'] = "{}%s{}{}{}".format(m.group('bef'), m.group('sep'), '%s' if res['tonchars'] else '', m.group('after')) return res def format_span(fmt, yearfrom, yearto, fromnchars, tonchars): """Return a span string representation. """ args = (str(yearfrom)[-fromnchars:]) if tonchars: args = (str(yearfrom)[-fromnchars:], str(yearto)[-tonchars:]) return fmt % args def extract_modes(spans): """Extract the most common spans lengths and representation formats """ rangelen = sorted([x['to'] - x['from'] + 1 for x in spans]) deflen = sorted(rangelen, key=rangelen.count)[-1] reprs = [str2fmt(x['str']) for x in spans] deffmt = sorted(reprs, key=reprs.count)[-1] return deflen, deffmt def build_alpha_spans(alpha_spans_str, alpha_regexs): """Extract alphanumerics from string and return sorted list of chars [from...to] """ spans = [] for elem in alpha_spans_str: if elem in alpha_regexs: spans.append(re.compile(alpha_regexs[elem])) else: bucket = sorted([x for x in elem.lower() if x.isalnum()]) if bucket: begin_index = ASCII_DIGITS.index(bucket[0]) end_index = ASCII_DIGITS.index(bucket[-1]) else: raise ui.UserError("invalid range defined for alpha bucket " "'%s': no alphanumeric character found" % elem) spans.append( re.compile( "^[" + ASCII_DIGITS[begin_index:end_index + 1] + ASCII_DIGITS[begin_index:end_index + 1].upper() + "]" ) ) return spans class BucketPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.template_funcs['bucket'] = self._tmpl_bucket self.config.add({ 'bucket_year': [], 'bucket_alpha': [], 'bucket_alpha_regex': {}, 'extrapolate': False }) self.setup() def setup(self): """Setup plugin from config options """ self.year_spans = build_year_spans(self.config['bucket_year'].get()) if self.year_spans and self.config['extrapolate']: [self.ys_len_mode, self.ys_repr_mode] = extract_modes(self.year_spans) self.year_spans = extend_year_spans(self.year_spans, self.ys_len_mode) self.alpha_spans = build_alpha_spans( self.config['bucket_alpha'].get(), self.config['bucket_alpha_regex'].get() ) def find_bucket_year(self, year): """Return bucket that matches given year or return the year if no matching bucket. """ for ys in self.year_spans: if ys['from'] <= int(year) <= ys['to']: if 'str' in ys: return ys['str'] else: return format_span(self.ys_repr_mode['fmt'], ys['from'], ys['to'], self.ys_repr_mode['fromnchars'], self.ys_repr_mode['tonchars']) return year def find_bucket_alpha(self, s): """Return alpha-range bucket that matches given string or return the string initial if no matching bucket. """ for (i, span) in enumerate(self.alpha_spans): if span.match(s): return self.config['bucket_alpha'].get()[i] return s[0].upper() def _tmpl_bucket(self, text, field=None): if not field and len(text) == 4 and text.isdigit(): field = 'year' if field == 'year': func = self.find_bucket_year else: func = self.find_bucket_alpha return func(text) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/chroma.py0000644000076500000240000002713700000000000016154 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """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.autotag import hooks import confuse import acoustid from collections import defaultdict from functools import partial import re 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? MAX_RECORDINGS = 5 MAX_RELEASES = 5 # 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 prefix(it, count): """Truncate an iterable to at most `count` items. """ for i, v in enumerate(it): if i >= count: break yield v def releases_key(release, countries, original_year): """Used as a key to sort releases by date then preferred country """ date = release.get('date') if date and original_year: year = date.get('year', 9999) month = date.get('month', 99) day = date.get('day', 99) else: year = 9999 month = 99 day = 99 # Uses index of preferred countries to sort country_key = 99 if release.get('country'): for i, country in enumerate(countries): if country.match(release['country']): country_key = i break return (year, month, day, country_key) def acoustid_match(log, 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 {0} failed: {1}', util.displayable_path(repr(path)), exc) return None fp = fp.decode() _fingerprints[path] = fp try: res = acoustid.lookup(API_KEY, fp, duration, meta='recordings releases') except acoustid.AcoustidError as exc: log.debug('fingerprint matching {0} failed: {1}', util.displayable_path(repr(path)), exc) return None log.debug('chroma: fingerprinted {0}', util.displayable_path(repr(path))) # Ensure the response is usable and parse it. if res['status'] != 'ok' or not res.get('results'): log.debug('no match found') return None result = res['results'][0] # Best match. if result['score'] < SCORE_THRESH: log.debug('no results above threshold') return None _acoustids[path] = result['id'] # Get recording and releases from the result if not result.get('recordings'): log.debug('no recordings found') return None recording_ids = [] releases = [] for recording in result['recordings']: recording_ids.append(recording['id']) if 'releases' in recording: releases.extend(recording['releases']) # The releases list is essentially in random order from the Acoustid lookup # so we optionally sort it using the match.preferred configuration options. # 'original_year' to sort the earliest first and # 'countries' to then sort preferred countries first. country_patterns = config['match']['preferred']['countries'].as_str_seq() countries = [re.compile(pat, re.I) for pat in country_patterns] original_year = config['match']['preferred']['original_year'] releases.sort(key=partial(releases_key, countries=countries, original_year=original_year)) release_ids = [rel['id'] for rel in releases] log.debug('matched recordings {0} on releases {1}', recording_ids, release_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.items(): if float(count) / len(items) > COMMON_REL_THRESH: yield release_id class AcoustidPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'auto': True, }) config['acoustid']['apikey'].redact = True if self.config['auto']: self.register_listener('import_task_start', self.fingerprint_task) self.register_listener('import_task_apply', apply_acoustid_metadata) def fingerprint_task(self, task, session): return fingerprint_task(self._log, task, session) 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, extra_tags=None): albums = [] for relid in prefix(_all_releases(items), MAX_RELEASES): album = hooks.album_for_mbid(relid) if album: albums.append(album) self._log.debug('acoustid album candidates: {0}', 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 prefix(recording_ids, MAX_RECORDINGS): track = hooks.track_for_mbid(recording_id) if track: tracks.append(track) self._log.debug('acoustid item candidates: {0}', 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'].as_str() except confuse.NotFoundError: raise ui.UserError('no Acoustid user API key provided') submit_items(self._log, 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(self._log, item, write=ui.should_write()) fingerprint_cmd.func = fingerprint_cmd_func return [submit_cmd, fingerprint_cmd] # Hooks into import process. def fingerprint_task(log, 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(log, item.path) 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(log, 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', len(data)) try: acoustid.submit(API_KEY, userkey, data) except acoustid.AcoustidError as exc: log.warning('acoustid submission error: {0}', exc) del data[:] for item in items: fp = fingerprint_item(log, item, write=ui.should_write()) # 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(log, 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('{0}: no duration available', util.displayable_path(item.path)) elif item.acoustid_fingerprint: if write: log.info('{0}: fingerprint exists, skipping', util.displayable_path(item.path)) else: log.info('{0}: using existing fingerprint', util.displayable_path(item.path)) return item.acoustid_fingerprint else: log.info('{0}: fingerprinting', util.displayable_path(item.path)) try: _, fp = acoustid.fingerprint_file(util.syspath(item.path)) item.acoustid_fingerprint = fp.decode() if write: log.info('{0}: writing fingerprint', util.displayable_path(item.path)) item.try_write() if item._db: item.store() return item.acoustid_fingerprint except acoustid.FingerprintGenerationError as exc: log.info('fingerprint generation failed: {0}', exc) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/convert.py0000644000076500000240000005016100000000000016354 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Jakob Schnitzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Converts tracks or albums to external directory """ from beets.util import par_map, decode_commandline_path, arg_encoding import os import threading import subprocess import tempfile import shlex from string import Template from beets import ui, util, plugins, config from beets.plugins import BeetsPlugin from confuse import ConfigTypeError from beets import art from beets.util.artresizer import ArtResizer from beets.library import parse_query_string from beets.library import Item _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. # Some convenient alternate names for formats. ALIASES = { 'wma': 'windows media', 'vorbis': 'ogg', } LOSSLESS_FORMATS = ['ape', 'flac', 'alac', 'wav', 'aiff'] def replace_ext(path, ext): """Return the path with its extension replaced by `ext`. The new extension must not contain a leading dot. """ ext_dot = b'.' + ext return os.path.splitext(path)[0] + ext_dot def get_format(fmt=None): """Return the command template and the extension from the config. """ if not fmt: fmt = config['convert']['format'].as_str().lower() fmt = ALIASES.get(fmt, fmt) try: format_info = config['convert']['formats'][fmt].get(dict) command = format_info['command'] extension = format_info.get('extension', fmt) except KeyError: raise ui.UserError( 'convert: format {} needs the "command" field' .format(fmt) ) except ConfigTypeError: command = config['convert']['formats'][fmt].get(str) extension = fmt # Convenience and backwards-compatibility shortcuts. keys = config['convert'].keys() if 'command' in keys: command = config['convert']['command'].as_str() elif 'opts' in keys: # Undocumented option for backwards compatibility with < 1.3.1. command = 'ffmpeg -i $source -y {} $dest'.format( config['convert']['opts'].as_str() ) if 'extension' in keys: extension = config['convert']['extension'].as_str() return (command.encode('utf-8'), extension.encode('utf-8')) def should_transcode(item, fmt): """Determine whether the item should be transcoded as part of conversion (i.e., its bitrate is high or it has the wrong format). """ no_convert_queries = config['convert']['no_convert'].as_str_seq() if no_convert_queries: for query_string in no_convert_queries: query, _ = parse_query_string(query_string, Item) if query.match(item): return False if config['convert']['never_convert_lossy_files'] and \ not (item.format.lower() in LOSSLESS_FORMATS): return False maxbr = config['convert']['max_bitrate'].get(int) return fmt.lower() != item.format.lower() or \ item.bitrate >= 1000 * maxbr class ConvertPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'dest': None, 'pretend': False, 'link': False, 'hardlink': False, 'threads': util.cpu_count(), 'format': 'mp3', 'id3v23': 'inherit', 'formats': { 'aac': { 'command': 'ffmpeg -i $source -y -vn -acodec aac ' '-aq 1 $dest', 'extension': 'm4a', }, 'alac': { 'command': 'ffmpeg -i $source -y -vn -acodec alac $dest', 'extension': 'm4a', }, 'flac': 'ffmpeg -i $source -y -vn -acodec flac $dest', 'mp3': 'ffmpeg -i $source -y -vn -aq 2 $dest', 'opus': 'ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest', 'ogg': 'ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest', 'wma': 'ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest', }, 'max_bitrate': 500, 'auto': False, 'tmpdir': None, 'quiet': False, 'embed': True, 'paths': {}, 'no_convert': '', 'never_convert_lossy_files': False, 'copy_album_art': False, 'album_art_maxwidth': 0, 'delete_originals': False, }) self.early_import_stages = [self.auto_convert] self.register_listener('import_task_files', self._cleanup) def commands(self): cmd = ui.Subcommand('convert', help='convert to external location') cmd.parser.add_option('-p', '--pretend', action='store_true', help='show actions but do nothing') 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.parser.add_option('-f', '--format', action='store', dest='format', help='set the target format of the tracks') cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes', help='do not ask for confirmation') cmd.parser.add_option('-l', '--link', action='store_true', dest='link', help='symlink files that do not \ need transcoding.') cmd.parser.add_option('-H', '--hardlink', action='store_true', dest='hardlink', help='hardlink files that do not \ need transcoding. Overrides --link.') cmd.parser.add_album_option() cmd.func = self.convert_func return [cmd] def auto_convert(self, config, task): if self.config['auto']: par_map(lambda item: self.convert_on_import(config.lib, item), task.imported_items()) # Utilities converted from functions to methods on logging overhaul def encode(self, command, source, dest, pretend=False): """Encode `source` to `dest` using command template `command`. Raises `subprocess.CalledProcessError` if the command exited with a non-zero status code. """ # The paths and arguments must be bytes. assert isinstance(command, bytes) assert isinstance(source, bytes) assert isinstance(dest, bytes) quiet = self.config['quiet'].get(bool) if not quiet and not pretend: self._log.info('Encoding {0}', util.displayable_path(source)) command = command.decode(arg_encoding(), 'surrogateescape') source = decode_commandline_path(source) dest = decode_commandline_path(dest) # Substitute $source and $dest in the argument list. args = shlex.split(command) encode_cmd = [] for i, arg in enumerate(args): args[i] = Template(arg).safe_substitute({ 'source': source, 'dest': dest, }) encode_cmd.append(args[i].encode(util.arg_encoding())) if pretend: self._log.info('{0}', ' '.join(ui.decargs(args))) return try: util.command_output(encode_cmd) except subprocess.CalledProcessError as exc: # Something went wrong (probably Ctrl+C), remove temporary files self._log.info('Encoding {0} failed. Cleaning up...', util.displayable_path(source)) self._log.debug('Command {0} exited with status {1}: {2}', args, exc.returncode, exc.output) util.remove(dest) util.prune_dirs(os.path.dirname(dest)) raise except OSError as exc: raise ui.UserError( "convert: couldn't invoke '{}': {}".format( ' '.join(ui.decargs(args)), exc ) ) if not quiet and not pretend: self._log.info('Finished encoding {0}', util.displayable_path(source)) def convert_item(self, dest_dir, keep_new, path_formats, fmt, pretend=False, link=False, hardlink=False): """A pipeline thread that converts `Item` objects from a library. """ command, ext = get_format(fmt) item, original, converted = None, None, None while True: item = yield (item, original, converted) dest = item.destination(basedir=dest_dir, path_formats=path_formats) # 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: original = dest converted = item.path if should_transcode(item, fmt): converted = replace_ext(converted, ext) else: original = item.path if should_transcode(item, fmt): dest = replace_ext(dest, ext) converted = dest # 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.) if not pretend: with _fs_lock: util.mkdirall(dest) if os.path.exists(util.syspath(dest)): self._log.info('Skipping {0} (target file exists)', util.displayable_path(item.path)) continue if keep_new: if pretend: self._log.info('mv {0} {1}', util.displayable_path(item.path), util.displayable_path(original)) else: self._log.info('Moving to {0}', util.displayable_path(original)) util.move(item.path, original) if should_transcode(item, fmt): linked = False try: self.encode(command, original, converted, pretend) except subprocess.CalledProcessError: continue else: linked = link or hardlink if pretend: msg = 'ln' if hardlink else ('ln -s' if link else 'cp') self._log.info('{2} {0} {1}', util.displayable_path(original), util.displayable_path(converted), msg) else: # No transcoding necessary. msg = 'Hardlinking' if hardlink \ else ('Linking' if link else 'Copying') self._log.info('{1} {0}', util.displayable_path(item.path), msg) if hardlink: util.hardlink(original, converted) elif link: util.link(original, converted) else: util.copy(original, converted) if pretend: continue id3v23 = self.config['id3v23'].as_choice([True, False, 'inherit']) if id3v23 == 'inherit': id3v23 = None # Write tags from the database to the converted file. item.try_write(path=converted, id3v23=id3v23) if keep_new: # If we're keeping the transcoded file, read it again (after # writing) to get new bitrate, duration, etc. item.path = converted item.read() item.store() # Store new path and audio data. if self.config['embed'] and not linked: album = item._cached_album if album and album.artpath: self._log.debug('embedding album art from {}', util.displayable_path(album.artpath)) art.embed_item(self._log, item, album.artpath, itempath=converted, id3v23=id3v23) if keep_new: plugins.send('after_convert', item=item, dest=dest, keepnew=True) else: plugins.send('after_convert', item=item, dest=converted, keepnew=False) def copy_album_art(self, album, dest_dir, path_formats, pretend=False, link=False, hardlink=False): """Copies or converts the associated cover art of the album. Album must have at least one track. """ if not album or not album.artpath: return album_item = album.items().get() # Album shouldn't be empty. if not album_item: return # Get the destination of the first item (track) of the album, we use # this function to format the path accordingly to path_formats. dest = album_item.destination(basedir=dest_dir, path_formats=path_formats) # Remove item from the path. dest = os.path.join(*util.components(dest)[:-1]) dest = album.art_destination(album.artpath, item_dir=dest) if album.artpath == dest: return if not pretend: util.mkdirall(dest) if os.path.exists(util.syspath(dest)): self._log.info('Skipping {0} (target file exists)', util.displayable_path(album.artpath)) return # Decide whether we need to resize the cover-art image. resize = False maxwidth = None if self.config['album_art_maxwidth']: maxwidth = self.config['album_art_maxwidth'].get(int) size = ArtResizer.shared.get_size(album.artpath) self._log.debug('image size: {}', size) if size: resize = size[0] > maxwidth else: self._log.warning('Could not get size of image (please see ' 'documentation for dependencies).') # Either copy or resize (while copying) the image. if resize: self._log.info('Resizing cover art from {0} to {1}', util.displayable_path(album.artpath), util.displayable_path(dest)) if not pretend: ArtResizer.shared.resize(maxwidth, album.artpath, dest) else: if pretend: msg = 'ln' if hardlink else ('ln -s' if link else 'cp') self._log.info('{2} {0} {1}', util.displayable_path(album.artpath), util.displayable_path(dest), msg) else: msg = 'Hardlinking' if hardlink \ else ('Linking' if link else 'Copying') self._log.info('{2} cover art from {0} to {1}', util.displayable_path(album.artpath), util.displayable_path(dest), msg) if hardlink: util.hardlink(album.artpath, dest) elif link: util.link(album.artpath, dest) else: util.copy(album.artpath, dest) def convert_func(self, lib, opts, args): dest = opts.dest or self.config['dest'].get() if not dest: raise ui.UserError('no convert destination set') dest = util.bytestring_path(dest) threads = opts.threads or self.config['threads'].get(int) path_formats = ui.get_path_formats(self.config['paths'] or None) fmt = opts.format or self.config['format'].as_str().lower() if opts.pretend is not None: pretend = opts.pretend else: pretend = self.config['pretend'].get(bool) if opts.hardlink is not None: hardlink = opts.hardlink link = False elif opts.link is not None: hardlink = False link = opts.link else: hardlink = self.config['hardlink'].get(bool) link = self.config['link'].get(bool) if opts.album: albums = lib.albums(ui.decargs(args)) items = [i for a in albums for i in a.items()] if not pretend: for a in albums: ui.print_(format(a, '')) else: items = list(lib.items(ui.decargs(args))) if not pretend: for i in items: ui.print_(format(i, '')) if not items: self._log.error('Empty query result.') return if not (pretend or opts.yes or ui.input_yn("Convert? (Y/n)")): return if opts.album and self.config['copy_album_art']: for album in albums: self.copy_album_art(album, dest, path_formats, pretend, link, hardlink) convert = [self.convert_item(dest, opts.keep_new, path_formats, fmt, pretend, link, hardlink) for _ in range(threads)] pipe = util.pipeline.Pipeline([iter(items), convert]) pipe.run_parallel() def convert_on_import(self, lib, item): """Transcode a file automatically after it is imported into the library. """ fmt = self.config['format'].as_str().lower() if should_transcode(item, fmt): command, ext = get_format() # Create a temporary file for the conversion. tmpdir = self.config['tmpdir'].get() if tmpdir: tmpdir = util.py3_path(util.bytestring_path(tmpdir)) fd, dest = tempfile.mkstemp(util.py3_path(b'.' + ext), dir=tmpdir) os.close(fd) dest = util.bytestring_path(dest) _temp_files.append(dest) # Delete the transcode later. # Convert. try: self.encode(command, item.path, dest) except subprocess.CalledProcessError: return # Change the newly-imported database entry to point to the # converted file. source_path = item.path item.path = dest item.write() item.read() # Load new audio information data. item.store() if self.config['delete_originals']: self._log.info('Removing original file {0}', source_path) util.remove(source_path, False) def _cleanup(self, 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/deezer.py0000644000076500000240000002041700000000000016153 0ustar00asampsonstaff# This file is part of beets. # Copyright 2019, Rahul Ahuja. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Deezer release and track search support to the autotagger """ import collections import unidecode import requests from beets import ui from beets.autotag import AlbumInfo, TrackInfo from beets.plugins import MetadataSourcePlugin, BeetsPlugin class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin): data_source = 'Deezer' # Base URLs for the Deezer API # Documentation: https://developers.deezer.com/api/ search_url = 'https://api.deezer.com/search/' album_url = 'https://api.deezer.com/album/' track_url = 'https://api.deezer.com/track/' id_regex = { 'pattern': r'(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)', 'match_group': 4, } def __init__(self): super().__init__() def album_for_id(self, album_id): """Fetch an album by its Deezer ID or URL and return an AlbumInfo object or None if the album is not found. :param album_id: Deezer ID or URL for the album. :type album_id: str :return: AlbumInfo object for album. :rtype: beets.autotag.hooks.AlbumInfo or None """ deezer_id = self._get_id('album', album_id) if deezer_id is None: return None album_data = requests.get(self.album_url + deezer_id).json() artist, artist_id = self.get_artist(album_data['contributors']) release_date = album_data['release_date'] date_parts = [int(part) for part in release_date.split('-')] num_date_parts = len(date_parts) if num_date_parts == 3: year, month, day = date_parts elif num_date_parts == 2: year, month = date_parts day = None elif num_date_parts == 1: year = date_parts[0] month = None day = None else: raise ui.UserError( "Invalid `release_date` returned " "by {} API: '{}'".format(self.data_source, release_date) ) tracks_data = requests.get( self.album_url + deezer_id + '/tracks' ).json()['data'] if not tracks_data: return None tracks = [] medium_totals = collections.defaultdict(int) for i, track_data in enumerate(tracks_data, start=1): track = self._get_track(track_data) track.index = i medium_totals[track.medium] += 1 tracks.append(track) for track in tracks: track.medium_total = medium_totals[track.medium] return AlbumInfo( album=album_data['title'], album_id=deezer_id, artist=artist, artist_credit=self.get_artist([album_data['artist']])[0], artist_id=artist_id, tracks=tracks, albumtype=album_data['record_type'], va=len(album_data['contributors']) == 1 and artist.lower() == 'various artists', year=year, month=month, day=day, label=album_data['label'], mediums=max(medium_totals.keys()), data_source=self.data_source, data_url=album_data['link'], ) def _get_track(self, track_data): """Convert a Deezer track object dict to a TrackInfo object. :param track_data: Deezer Track object dict :type track_data: dict :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ artist, artist_id = self.get_artist( track_data.get('contributors', [track_data['artist']]) ) return TrackInfo( title=track_data['title'], track_id=track_data['id'], artist=artist, artist_id=artist_id, length=track_data['duration'], index=track_data['track_position'], medium=track_data['disk_number'], medium_index=track_data['track_position'], data_source=self.data_source, data_url=track_data['link'], ) def track_for_id(self, track_id=None, track_data=None): """Fetch a track by its Deezer ID or URL and return a TrackInfo object or None if the track is not found. :param track_id: (Optional) Deezer ID or URL for the track. Either ``track_id`` or ``track_data`` must be provided. :type track_id: str :param track_data: (Optional) Simplified track object dict. May be provided instead of ``track_id`` to avoid unnecessary API calls. :type track_data: dict :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo or None """ if track_data is None: deezer_id = self._get_id('track', track_id) if deezer_id is None: return None track_data = requests.get(self.track_url + deezer_id).json() track = self._get_track(track_data) # Get album's tracks to set `track.index` (position on the entire # release) and `track.medium_total` (total number of tracks on # the track's disc). album_tracks_data = requests.get( self.album_url + str(track_data['album']['id']) + '/tracks' ).json()['data'] medium_total = 0 for i, track_data in enumerate(album_tracks_data, start=1): if track_data['disk_number'] == track.medium: medium_total += 1 if track_data['id'] == track.track_id: track.index = i track.medium_total = medium_total return track @staticmethod def _construct_search_query(filters=None, keywords=''): """Construct a query string with the specified filters and keywords to be provided to the Deezer Search API (https://developers.deezer.com/api/search). :param filters: (Optional) Field filters to apply. :type filters: dict :param keywords: (Optional) Query keywords to use. :type keywords: str :return: Query string to be provided to the Search API. :rtype: str """ query_components = [ keywords, ' '.join(f'{k}:"{v}"' for k, v in filters.items()), ] query = ' '.join([q for q in query_components if q]) if not isinstance(query, str): query = query.decode('utf8') return unidecode.unidecode(query) def _search_api(self, query_type, filters=None, keywords=''): """Query the Deezer Search API for the specified ``keywords``, applying the provided ``filters``. :param query_type: The Deezer Search API method to use. Valid types are: 'album', 'artist', 'history', 'playlist', 'podcast', 'radio', 'track', 'user', and 'track'. :type query_type: str :param filters: (Optional) Field filters to apply. :type filters: dict :param keywords: (Optional) Query keywords to use. :type keywords: str :return: JSON data for the class:`Response ` object or None if no search results are returned. :rtype: dict or None """ query = self._construct_search_query( keywords=keywords, filters=filters ) if not query: return None self._log.debug( f"Searching {self.data_source} for '{query}'" ) response = requests.get( self.search_url + query_type, params={'q': query} ) response.raise_for_status() response_data = response.json().get('data', []) self._log.debug( "Found {} result(s) from {} for '{}'", len(response_data), self.data_source, query, ) return response_data ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beetsplug/discogs.py0000644000076500000240000006202000000000000016324 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Discogs album search support to the autotagger. Requires the python3-discogs-client library. """ import beets.ui from beets import config from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance import confuse from discogs_client import Release, Master, Client from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError import http.client import beets import re import time import json import socket import os import traceback from string import ascii_lowercase USER_AGENT = f'beets/{beets.__version__} +https://beets.io/' API_KEY = 'rAzVUQYRaoFjeBjyWuWZ' API_SECRET = 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy' # Exceptions that discogs_client should really handle but does not. CONNECTION_ERRORS = (ConnectionError, socket.error, http.client.HTTPException, ValueError, # JSON decoding raises a ValueError. DiscogsAPIError) class DiscogsPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'apikey': API_KEY, 'apisecret': API_SECRET, 'tokenfile': 'discogs_token.json', 'source_weight': 0.5, 'user_token': '', 'separator': ', ', 'index_tracks': False, }) self.config['apikey'].redact = True self.config['apisecret'].redact = True self.config['user_token'].redact = True self.discogs_client = None self.register_listener('import_begin', self.setup) def setup(self, session=None): """Create the `discogs_client` field. Authenticate if necessary. """ c_key = self.config['apikey'].as_str() c_secret = self.config['apisecret'].as_str() # Try using a configured user token (bypassing OAuth login). user_token = self.config['user_token'].as_str() if user_token: # The rate limit for authenticated users goes up to 60 # requests per minute. self.discogs_client = Client(USER_AGENT, user_token=user_token) return # Get the OAuth token from a file or log in. try: with open(self._tokenfile()) as f: tokendata = json.load(f) except OSError: # No token yet. Generate one. token, secret = self.authenticate(c_key, c_secret) else: token = tokendata['token'] secret = tokendata['secret'] self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret) def reset_auth(self): """Delete token file & redo the auth steps. """ os.remove(self._tokenfile()) self.setup() def _tokenfile(self): """Get the path to the JSON file for storing the OAuth token. """ return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True)) def authenticate(self, c_key, c_secret): # Get the link for the OAuth page. auth_client = Client(USER_AGENT, c_key, c_secret) try: _, _, url = auth_client.get_authorize_url() except CONNECTION_ERRORS as e: self._log.debug('connection error: {0}', e) raise beets.ui.UserError('communication with Discogs failed') beets.ui.print_("To authenticate with Discogs, visit:") beets.ui.print_(url) # Ask for the code and validate it. code = beets.ui.input_("Enter the code:") try: token, secret = auth_client.get_access_token(code) except DiscogsAPIError: raise beets.ui.UserError('Discogs authorization failed') except CONNECTION_ERRORS as e: self._log.debug('connection error: {0}', e) raise beets.ui.UserError('Discogs token request failed') # Save the token for later use. self._log.debug('Discogs token {0}, secret {1}', token, secret) with open(self._tokenfile(), 'w') as f: json.dump({'token': token, 'secret': secret}, f) return token, secret def album_distance(self, items, album_info, mapping): """Returns the album distance. """ return get_distance( data_source='Discogs', info=album_info, config=self.config ) def track_distance(self, item, track_info): """Returns the track distance. """ return get_distance( data_source='Discogs', info=track_info, config=self.config ) def candidates(self, items, artist, album, va_likely, extra_tags=None): """Returns a list of AlbumInfo objects for discogs search results matching an album and artist (if not various). """ if not self.discogs_client: return if va_likely: query = album else: query = f'{artist} {album}' try: return self.get_albums(query) except DiscogsAPIError as e: self._log.debug('API Error: {0} (query: {1})', e, query) if e.status_code == 401: self.reset_auth() return self.candidates(items, artist, album, va_likely) else: return [] except CONNECTION_ERRORS: self._log.debug('Connection error in album search', exc_info=True) return [] @staticmethod def extract_release_id_regex(album_id): """Returns the Discogs_id or None.""" # Discogs-IDs are simple integers. In order to avoid confusion with # other metadata plugins, we only look for very specific formats of the # input string: # - plain integer, optionally wrapped in brackets and prefixed by an # 'r', as this is how discogs displays the release ID on its webpage. # - legacy url format: discogs.com//release/ # - current url format: discogs.com/release/- # See #291, #4080 and #4085 for the discussions leading up to these # patterns. # Regex has been tested here https://regex101.com/r/wyLdB4/2 for pattern in [ r'^\[?r?(?P\d+)\]?$', r'discogs\.com/release/(?P\d+)-', r'discogs\.com/[^/]+/release/(?P\d+)', ]: match = re.search(pattern, album_id) if match: return int(match.group('id')) return None 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. """ if not self.discogs_client: return self._log.debug('Searching for release {0}', album_id) discogs_id = self.extract_release_id_regex(album_id) if not discogs_id: return None result = Release(self.discogs_client, {'id': discogs_id}) # Try to obtain title to verify that we indeed have a valid Release try: getattr(result, 'title') except DiscogsAPIError as e: if e.status_code != 404: self._log.debug('API Error: {0} (query: {1})', e, result.data['resource_url']) if e.status_code == 401: self.reset_auth() return self.album_for_id(album_id) return None except CONNECTION_ERRORS: self._log.debug('Connection error in album lookup', exc_info=True) 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) try: releases = self.discogs_client.search(query, type='release').page(1) except CONNECTION_ERRORS: self._log.debug("Communication error while searching for {0!r}", query, exc_info=True) return [] return [album for album in map(self.get_album_info, releases[:5]) if album] def get_master_year(self, master_id): """Fetches a master release given its Discogs ID and returns its year or None if the master release is not found. """ self._log.debug('Searching for master release {0}', master_id) result = Master(self.discogs_client, {'id': master_id}) try: year = result.fetch('year') return year except DiscogsAPIError as e: if e.status_code != 404: self._log.debug('API Error: {0} (query: {1})', e, result.data['resource_url']) if e.status_code == 401: self.reset_auth() return self.get_master_year(master_id) return None except CONNECTION_ERRORS: self._log.debug('Connection error in master release lookup', exc_info=True) return None def get_album_info(self, result): """Returns an AlbumInfo object for a discogs Release object. """ # Explicitly reload the `Release` fields, as they might not be yet # present if the result is from a `discogs_client.search()`. if not result.data.get('artists'): result.refresh() # Sanity check for required fields. The list of required fields is # defined at Guideline 1.3.1.a, but in practice some releases might be # lacking some of these fields. This function expects at least: # `artists` (>0), `title`, `id`, `tracklist` (>0) # https://www.discogs.com/help/doc/submission-guidelines-general-rules if not all([result.data.get(k) for k in ['artists', 'title', 'id', 'tracklist']]): self._log.warning("Release does not contain the required fields") return None artist, artist_id = MetadataSourcePlugin.get_artist( [a.data for a in result.artists] ) album = re.sub(r' +', ' ', result.title) album_id = result.data['id'] # 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']) # Extract information for the optional AlbumInfo fields, if possible. va = result.data['artists'][0].get('name', '').lower() == 'various' year = result.data.get('year') mediums = [t.medium for t in tracks] country = result.data.get('country') data_url = result.data.get('uri') style = self.format(result.data.get('styles')) genre = self.format(result.data.get('genres')) discogs_albumid = self.extract_release_id(result.data.get('uri')) # Extract information for the optional AlbumInfo fields that are # contained on nested discogs fields. albumtype = media = label = catalogno = labelid = None if result.data.get('formats'): albumtype = ', '.join( result.data['formats'][0].get('descriptions', [])) or None media = result.data['formats'][0]['name'] if result.data.get('labels'): label = result.data['labels'][0].get('name') catalogno = result.data['labels'][0].get('catno') labelid = result.data['labels'][0].get('id') # Additional cleanups (various artists name, catalog number, media). if va: artist = config['va_name'].as_str() if catalogno == 'none': catalogno = None # Explicitly set the `media` for the tracks, since it is expected by # `autotag.apply_metadata`, and set `medium_total`. for track in tracks: track.media = media track.medium_total = mediums.count(track.medium) # Discogs does not have track IDs. Invent our own IDs as proposed # in #2336. track.track_id = str(album_id) + "-" + track.track_alt # Retrieve master release id (returns None if there isn't one). master_id = result.data.get('master_id') # Assume `original_year` is equal to `year` for releases without # a master release, otherwise fetch the master release. original_year = self.get_master_year(master_id) if master_id else year return AlbumInfo(album=album, album_id=album_id, artist=artist, artist_id=artist_id, tracks=tracks, albumtype=albumtype, va=va, year=year, label=label, mediums=len(set(mediums)), releasegroup_id=master_id, catalognum=catalogno, country=country, style=style, genre=genre, media=media, original_year=original_year, data_source='Discogs', data_url=data_url, discogs_albumid=discogs_albumid, discogs_labelid=labelid, discogs_artistid=artist_id) def format(self, classification): if classification: return self.config['separator'].as_str() \ .join(sorted(classification)) else: return None def extract_release_id(self, uri): if uri: return uri.split("/")[-1] else: return None def get_tracks(self, tracklist): """Returns a list of TrackInfo objects for a discogs tracklist. """ try: clean_tracklist = self.coalesce_tracks(tracklist) except Exception as exc: # FIXME: this is an extra precaution for making sure there are no # side effects after #2222. It should be removed after further # testing. self._log.debug('{}', traceback.format_exc()) self._log.error('uncaught exception in coalesce_tracks: {}', exc) clean_tracklist = tracklist tracks = [] index_tracks = {} index = 0 # Distinct works and intra-work divisions, as defined by index tracks. divisions, next_divisions = [], [] for track in clean_tracklist: # Only real tracks have `position`. Otherwise, it's an index track. if track['position']: index += 1 if next_divisions: # End of a block of index tracks: update the current # divisions. divisions += next_divisions del next_divisions[:] track_info = self.get_track_info(track, index, divisions) track_info.track_alt = track['position'] tracks.append(track_info) else: next_divisions.append(track['title']) # We expect new levels of division at the beginning of the # tracklist (and possibly elsewhere). try: divisions.pop() except IndexError: pass 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, side_count = 0, 0, 0 sides_per_medium = 1 # If a medium has two sides (ie. vinyl or cassette), each pair of # consecutive sides should belong to the same medium. if all([track.medium is not None for track in tracks]): m = sorted({track.medium.lower() for track in tracks}) # If all track.medium are single consecutive letters, assume it is # a 2-sided medium. if ''.join(m) in ascii_lowercase: sides_per_medium = 2 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. # side_count is the number of mediums or medium sides (in the case # of two-sided mediums) that were seen before. medium_is_index = track.medium and not track.medium_index and ( len(track.medium) != 1 or # Not within standard incremental medium values (A, B, C, ...). ord(track.medium) - 64 != side_count + 1 ) if not medium_is_index and medium != track.medium: side_count += 1 if sides_per_medium == 2: if side_count % sides_per_medium: # Two-sided medium changed. Reset index_count. index_count = 0 medium_count += 1 else: # Medium changed. Reset index_count. medium_count += 1 index_count = 0 medium = track.medium index_count += 1 medium_count = 1 if medium_count == 0 else medium_count 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 coalesce_tracks(self, raw_tracklist): """Pre-process a tracklist, merging subtracks into a single track. The title for the merged track is the one from the previous index track, if present; otherwise it is a combination of the subtracks titles. """ def add_merged_subtracks(tracklist, subtracks): """Modify `tracklist` in place, merging a list of `subtracks` into a single track into `tracklist`.""" # Calculate position based on first subtrack, without subindex. idx, medium_idx, sub_idx = \ self.get_track_index(subtracks[0]['position']) position = '{}{}'.format(idx or '', medium_idx or '') if tracklist and not tracklist[-1]['position']: # Assume the previous index track contains the track title. if sub_idx: # "Convert" the track title to a real track, discarding the # subtracks assuming they are logical divisions of a # physical track (12.2.9 Subtracks). tracklist[-1]['position'] = position else: # Promote the subtracks to real tracks, discarding the # index track, assuming the subtracks are physical tracks. index_track = tracklist.pop() # Fix artists when they are specified on the index track. if index_track.get('artists'): for subtrack in subtracks: if not subtrack.get('artists'): subtrack['artists'] = index_track['artists'] # Concatenate index with track title when index_tracks # option is set if self.config['index_tracks']: for subtrack in subtracks: subtrack['title'] = '{}: {}'.format( index_track['title'], subtrack['title']) tracklist.extend(subtracks) else: # Merge the subtracks, pick a title, and append the new track. track = subtracks[0].copy() track['title'] = ' / '.join([t['title'] for t in subtracks]) tracklist.append(track) # Pre-process the tracklist, trying to identify subtracks. subtracks = [] tracklist = [] prev_subindex = '' for track in raw_tracklist: # Regular subtrack (track with subindex). if track['position']: _, _, subindex = self.get_track_index(track['position']) if subindex: if subindex.rjust(len(raw_tracklist)) > prev_subindex: # Subtrack still part of the current main track. subtracks.append(track) else: # Subtrack part of a new group (..., 1.3, *2.1*, ...). add_merged_subtracks(tracklist, subtracks) subtracks = [track] prev_subindex = subindex.rjust(len(raw_tracklist)) continue # Index track with nested sub_tracks. if not track['position'] and 'sub_tracks' in track: # Append the index track, assuming it contains the track title. tracklist.append(track) add_merged_subtracks(tracklist, track['sub_tracks']) continue # Regular track or index track without nested sub_tracks. if subtracks: add_merged_subtracks(tracklist, subtracks) subtracks = [] prev_subindex = '' tracklist.append(track) # Merge and add the remaining subtracks, if any. if subtracks: add_merged_subtracks(tracklist, subtracks) return tracklist def get_track_info(self, track, index, divisions): """Returns a TrackInfo object for a discogs track. """ title = track['title'] if self.config['index_tracks']: prefix = ', '.join(divisions) if prefix: title = f'{prefix}: {title}' track_id = None medium, medium_index, _ = self.get_track_index(track['position']) artist, artist_id = MetadataSourcePlugin.get_artist( track.get('artists', []) ) length = self.get_track_length(track['duration']) return TrackInfo(title=title, track_id=track_id, artist=artist, artist_id=artist_id, length=length, index=index, medium=medium, medium_index=medium_index) def get_track_index(self, position): """Returns the medium, medium index and subtrack index for a discogs track position.""" # Match the standard Discogs positions (12.2.9), which can have several # forms (1, 1-1, A1, A1.1, A1a, ...). match = re.match( r'^(.*?)' # medium: everything before medium_index. r'(\d*?)' # medium_index: a number at the end of # `position`, except if followed by a subtrack # index. # subtrack_index: can only be matched if medium # or medium_index have been matched, and can be r'((?<=\w)\.[\w]+' # - a dot followed by a string (A.1, 2.A) r'|(?<=\d)[A-Z]+' # - a string that follows a number (1A, B2a) r')?' r'$', position.upper() ) if match: medium, index, subindex = match.groups() if subindex and subindex.startswith('.'): subindex = subindex[1:] else: self._log.debug('Invalid position: {0}', position) medium = index = subindex = None return medium or None, index or None, subindex 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/duplicates.py0000644000076500000240000003174200000000000017035 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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 shlex from beets.plugins import BeetsPlugin from beets.ui import decargs, print_, Subcommand, UserError from beets.util import command_output, displayable_path, subprocess, \ bytestring_path, MoveOperation, decode_commandline_path from beets.library import Item, Album PLUGIN = 'duplicates' class DuplicatesPlugin(BeetsPlugin): """List duplicate tracks or albums """ def __init__(self): super().__init__() self.config.add({ 'album': False, 'checksum': '', 'copy': '', 'count': False, 'delete': False, 'format': '', 'full': False, 'keys': [], 'merge': False, 'move': '', 'path': False, 'tiebreak': {}, 'strict': False, 'tag': '', }) self._command = Subcommand('duplicates', help=__doc__, aliases=['dup']) self._command.parser.add_option( '-c', '--count', dest='count', action='store_true', help='show duplicate counts', ) self._command.parser.add_option( '-C', '--checksum', dest='checksum', action='store', metavar='PROG', help='report duplicates based on arbitrary command', ) self._command.parser.add_option( '-d', '--delete', dest='delete', action='store_true', help='delete items from library and disk', ) self._command.parser.add_option( '-F', '--full', dest='full', action='store_true', help='show all versions of duplicate tracks or albums', ) self._command.parser.add_option( '-s', '--strict', dest='strict', action='store_true', help='report duplicates only if all attributes are set', ) self._command.parser.add_option( '-k', '--key', dest='keys', action='append', metavar='KEY', help='report duplicates based on keys (use multiple times)', ) self._command.parser.add_option( '-M', '--merge', dest='merge', action='store_true', help='merge duplicate items', ) self._command.parser.add_option( '-m', '--move', dest='move', action='store', metavar='DEST', help='move items to dest', ) self._command.parser.add_option( '-o', '--copy', dest='copy', action='store', metavar='DEST', help='copy items to dest', ) self._command.parser.add_option( '-t', '--tag', dest='tag', action='store', help='tag matched items with \'k=v\' attribute', ) self._command.parser.add_all_common_options() def commands(self): def _dup(lib, opts, args): self.config.set_args(opts) album = self.config['album'].get(bool) checksum = self.config['checksum'].get(str) copy = bytestring_path(self.config['copy'].as_str()) count = self.config['count'].get(bool) delete = self.config['delete'].get(bool) fmt = self.config['format'].get(str) full = self.config['full'].get(bool) keys = self.config['keys'].as_str_seq() merge = self.config['merge'].get(bool) move = bytestring_path(self.config['move'].as_str()) path = self.config['path'].get(bool) tiebreak = self.config['tiebreak'].get(dict) strict = self.config['strict'].get(bool) tag = self.config['tag'].get(str) if album: if not keys: keys = ['mb_albumid'] items = lib.albums(decargs(args)) else: if not keys: keys = ['mb_trackid', 'mb_albumid'] items = lib.items(decargs(args)) # If there's nothing to do, return early. The code below assumes # `items` to be non-empty. if not items: return if path: fmt = '$path' # Default format string for count mode. if count and not fmt: if album: fmt = '$albumartist - $album' else: fmt = '$albumartist - $album - $title' fmt += ': {0}' if checksum: for i in items: k, _ = self._checksum(i, checksum) keys = [k] for obj_id, obj_count, objs in self._duplicates(items, keys=keys, full=full, strict=strict, tiebreak=tiebreak, merge=merge): if obj_id: # Skip empty IDs. for o in objs: self._process_item(o, copy=copy, move=move, delete=delete, tag=tag, fmt=fmt.format(obj_count)) self._command.func = _dup return [self._command] def _process_item(self, item, copy=False, move=False, delete=False, tag=False, fmt=''): """Process Item `item`. """ print_(format(item, fmt)) if copy: item.move(basedir=copy, operation=MoveOperation.COPY) item.store() if move: item.move(basedir=move) item.store() if delete: item.remove(delete=True) if tag: try: k, v = tag.split('=') except Exception: raise UserError( f"{PLUGIN}: can't parse k=v tag: {tag}" ) setattr(item, k, v) item.store() def _checksum(self, item, prog): """Run external `prog` on file path associated with `item`, cache output as flexattr on a key that is the name of the program, and return the key, checksum tuple. """ args = [p.format(file=decode_commandline_path(item.path)) for p in shlex.split(prog)] key = args[0] checksum = getattr(item, key, False) if not checksum: self._log.debug('key {0} on item {1} not cached:' 'computing checksum', key, displayable_path(item.path)) try: checksum = command_output(args).stdout setattr(item, key, checksum) item.store() self._log.debug('computed checksum for {0} using {1}', item.title, key) except subprocess.CalledProcessError as e: self._log.debug('failed to checksum {0}: {1}', displayable_path(item.path), e) else: self._log.debug('key {0} on item {1} cached:' 'not computing checksum', key, displayable_path(item.path)) return key, checksum def _group_by(self, objs, keys, strict): """Return a dictionary with keys arbitrary concatenations of attributes and values lists of objects (Albums or Items) with those keys. If strict, all attributes must be defined for a duplicate match. """ import collections counts = collections.defaultdict(list) for obj in objs: values = [getattr(obj, k, None) for k in keys] values = [v for v in values if v not in (None, '')] if strict and len(values) < len(keys): self._log.debug('some keys {0} on item {1} are null or empty:' ' skipping', keys, displayable_path(obj.path)) elif (not strict and not len(values)): self._log.debug('all keys {0} on item {1} are null or empty:' ' skipping', keys, displayable_path(obj.path)) else: key = tuple(values) counts[key].append(obj) return counts def _order(self, objs, tiebreak=None): """Return the objects (Items or Albums) sorted by descending order of priority. If provided, the `tiebreak` dict indicates the field to use to prioritize the objects. Otherwise, Items are placed in order of "completeness" (objects with more non-null fields come first) and Albums are ordered by their track count. """ kind = 'items' if all(isinstance(o, Item) for o in objs) else 'albums' if tiebreak and kind in tiebreak.keys(): key = lambda x: tuple(getattr(x, k) for k in tiebreak[kind]) else: if kind == 'items': def truthy(v): # Avoid a Unicode warning by avoiding comparison # between a bytes object and the empty Unicode # string ''. return v is not None and \ (v != '' if isinstance(v, str) else True) fields = Item.all_keys() key = lambda x: sum(1 for f in fields if truthy(getattr(x, f))) else: key = lambda x: len(x.items()) return sorted(objs, key=key, reverse=True) def _merge_items(self, objs): """Merge Item objs by copying missing fields from items in the tail to the head item. Return same number of items, with the head item modified. """ fields = Item.all_keys() for f in fields: for o in objs[1:]: if getattr(objs[0], f, None) in (None, ''): value = getattr(o, f, None) if value: self._log.debug('key {0} on item {1} is null ' 'or empty: setting from item {2}', f, displayable_path(objs[0].path), displayable_path(o.path)) setattr(objs[0], f, value) objs[0].store() break return objs def _merge_albums(self, objs): """Merge Album objs by copying missing items from albums in the tail to the head album. Return same number of albums, with the head album modified.""" ids = [i.mb_trackid for i in objs[0].items()] for o in objs[1:]: for i in o.items(): if i.mb_trackid not in ids: missing = Item.from_path(i.path) missing.album_id = objs[0].id missing.add(i._db) self._log.debug('item {0} missing from album {1}:' ' merging from {2} into {3}', missing, objs[0], displayable_path(o.path), displayable_path(missing.destination())) missing.move(operation=MoveOperation.COPY) return objs def _merge(self, objs): """Merge duplicate items. See ``_merge_items`` and ``_merge_albums`` for the relevant strategies. """ kind = Item if all(isinstance(o, Item) for o in objs) else Album if kind is Item: objs = self._merge_items(objs) else: objs = self._merge_albums(objs) return objs def _duplicates(self, objs, keys, full, strict, tiebreak, merge): """Generate triples of keys, duplicate counts, and constituent objects. """ offset = 0 if full else 1 for k, objs in self._group_by(objs, keys, strict).items(): if len(objs) > 1: objs = self._order(objs, tiebreak) if merge: objs = self._merge(objs) yield (k, len(objs) - offset, objs[offset:]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/edit.py0000644000076500000240000003310400000000000015617 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016 # # 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. """Open metadata information in a text editor to let the user edit it. """ from beets import plugins from beets import util from beets import ui from beets.dbcore import types from beets.importer import action from beets.ui.commands import _do_query, PromptChoice import codecs import subprocess import yaml from tempfile import NamedTemporaryFile import os import shlex # These "safe" types can avoid the format/parse cycle that most fields go # through: they are safe to edit with native YAML types. SAFE_TYPES = (types.Float, types.Integer, types.Boolean) class ParseError(Exception): """The modified file is unreadable. The user should be offered a chance to fix the error. """ def edit(filename, log): """Open `filename` in a text editor. """ cmd = shlex.split(util.editor_command()) cmd.append(filename) log.debug('invoking editor command: {!r}', cmd) try: subprocess.call(cmd) except OSError as exc: raise ui.UserError('could not run editor command {!r}: {}'.format( cmd[0], exc )) def dump(arg): """Dump a sequence of dictionaries as YAML for editing. """ return yaml.safe_dump_all( arg, allow_unicode=True, default_flow_style=False, ) def load(s): """Read a sequence of YAML documents back to a list of dictionaries with string keys. Can raise a `ParseError`. """ try: out = [] for d in yaml.safe_load_all(s): if not isinstance(d, dict): raise ParseError( 'each entry must be a dictionary; found {}'.format( type(d).__name__ ) ) # Convert all keys to strings. They started out as strings, # but the user may have inadvertently messed this up. out.append({str(k): v for k, v in d.items()}) except yaml.YAMLError as e: raise ParseError(f'invalid YAML: {e}') return out def _safe_value(obj, key, value): """Check whether the `value` is safe to represent in YAML and trust as returned from parsed YAML. This ensures that values do not change their type when the user edits their YAML representation. """ typ = obj._type(key) return isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type) def flatten(obj, fields): """Represent `obj`, a `dbcore.Model` object, as a dictionary for serialization. Only include the given `fields` if provided; otherwise, include everything. The resulting dictionary's keys are strings and the values are safely YAML-serializable types. """ # Format each value. d = {} for key in obj.keys(): value = obj[key] if _safe_value(obj, key, value): # A safe value that is faithfully representable in YAML. d[key] = value else: # A value that should be edited as a string. d[key] = obj.formatted()[key] # Possibly filter field names. if fields: return {k: v for k, v in d.items() if k in fields} else: return d def apply_(obj, data): """Set the fields of a `dbcore.Model` object according to a dictionary. This is the opposite of `flatten`. The `data` dictionary should have strings as values. """ for key, value in data.items(): if _safe_value(obj, key, value): # A safe value *stayed* represented as a safe type. Assign it # directly. obj[key] = value else: # Either the field was stringified originally or the user changed # it from a safe type to an unsafe one. Parse it as a string. obj.set_parse(key, str(value)) class EditPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.config.add({ # The default fields to edit. 'albumfields': 'album albumartist', 'itemfields': 'track title artist album', # Silently ignore any changes to these fields. 'ignore_fields': 'id path', }) self.register_listener('before_choose_candidate', self.before_choose_candidate_listener) def commands(self): edit_command = ui.Subcommand( 'edit', help='interactively edit metadata' ) edit_command.parser.add_option( '-f', '--field', metavar='FIELD', action='append', help='edit this field also', ) edit_command.parser.add_option( '--all', action='store_true', dest='all', help='edit all fields', ) edit_command.parser.add_album_option() edit_command.func = self._edit_command return [edit_command] def _edit_command(self, lib, opts, args): """The CLI command function for the `beet edit` command. """ # Get the objects to edit. query = ui.decargs(args) items, albums = _do_query(lib, query, opts.album, False) objs = albums if opts.album else items if not objs: ui.print_('Nothing to edit.') return # Get the fields to edit. if opts.all: fields = None else: fields = self._get_fields(opts.album, opts.field) self.edit(opts.album, objs, fields) def _get_fields(self, album, extra): """Get the set of fields to edit. """ # Start with the configured base fields. if album: fields = self.config['albumfields'].as_str_seq() else: fields = self.config['itemfields'].as_str_seq() # Add the requested extra fields. if extra: fields += extra # Ensure we always have the `id` field for identification. fields.append('id') return set(fields) def edit(self, album, objs, fields): """The core editor function. - `album`: A flag indicating whether we're editing Items or Albums. - `objs`: The `Item`s or `Album`s to edit. - `fields`: The set of field names to edit (or None to edit everything). """ # Present the YAML to the user and let her change it. success = self.edit_objects(objs, fields) # Save the new data. if success: self.save_changes(objs) def edit_objects(self, objs, fields): """Dump a set of Model objects to a file as text, ask the user to edit it, and apply any changes to the objects. Return a boolean indicating whether the edit succeeded. """ # Get the content to edit as raw data structures. old_data = [flatten(o, fields) for o in objs] # Set up a temporary file with the initial data for editing. new = NamedTemporaryFile(mode='w', suffix='.yaml', delete=False, encoding='utf-8') old_str = dump(old_data) new.write(old_str) new.close() # Loop until we have parseable data and the user confirms. try: while True: # Ask the user to edit the data. edit(new.name, self._log) # Read the data back after editing and check whether anything # changed. with codecs.open(new.name, encoding='utf-8') as f: new_str = f.read() if new_str == old_str: ui.print_("No changes; aborting.") return False # Parse the updated data. try: new_data = load(new_str) except ParseError as e: ui.print_(f"Could not read data: {e}") if ui.input_yn("Edit again to fix? (Y/n)", True): continue else: return False # Show the changes. # If the objects are not on the DB yet, we need a copy of their # original state for show_model_changes. objs_old = [obj.copy() if obj.id < 0 else None for obj in objs] self.apply_data(objs, old_data, new_data) changed = False for obj, obj_old in zip(objs, objs_old): changed |= ui.show_model_changes(obj, obj_old) if not changed: ui.print_('No changes to apply.') return False # Confirm the changes. choice = ui.input_options( ('continue Editing', 'apply', 'cancel') ) if choice == 'a': # Apply. return True elif choice == 'c': # Cancel. return False elif choice == 'e': # Keep editing. # Reset the temporary changes to the objects. I we have a # copy from above, use that, else reload from the database. objs = [(old_obj or obj) for old_obj, obj in zip(objs_old, objs)] for obj in objs: if not obj.id < 0: obj.load() continue # Remove the temporary file before returning. finally: os.remove(new.name) def apply_data(self, objs, old_data, new_data): """Take potentially-updated data and apply it to a set of Model objects. The objects are not written back to the database, so the changes are temporary. """ if len(old_data) != len(new_data): self._log.warning('number of objects changed from {} to {}', len(old_data), len(new_data)) obj_by_id = {o.id: o for o in objs} ignore_fields = self.config['ignore_fields'].as_str_seq() for old_dict, new_dict in zip(old_data, new_data): # Prohibit any changes to forbidden fields to avoid # clobbering `id` and such by mistake. forbidden = False for key in ignore_fields: if old_dict.get(key) != new_dict.get(key): self._log.warning('ignoring object whose {} changed', key) forbidden = True break if forbidden: continue id_ = int(old_dict['id']) apply_(obj_by_id[id_], new_dict) def save_changes(self, objs): """Save a list of updated Model objects to the database. """ # Save to the database and possibly write tags. for ob in objs: if ob._dirty: self._log.debug('saving changes to {}', ob) ob.try_sync(ui.should_write(), ui.should_move()) # Methods for interactive importer execution. def before_choose_candidate_listener(self, session, task): """Append an "Edit" choice and an "edit Candidates" choice (if there are candidates) to the interactive importer prompt. """ choices = [PromptChoice('d', 'eDit', self.importer_edit)] if task.candidates: choices.append(PromptChoice('c', 'edit Candidates', self.importer_edit_candidate)) return choices def importer_edit(self, session, task): """Callback for invoking the functionality during an interactive import session on the *original* item tags. """ # Assign negative temporary ids to Items that are not in the database # yet. By using negative values, no clash with items in the database # can occur. for i, obj in enumerate(task.items, start=1): # The importer may set the id to None when re-importing albums. if not obj._db or obj.id is None: obj.id = -i # Present the YAML to the user and let her change it. fields = self._get_fields(album=False, extra=[]) success = self.edit_objects(task.items, fields) # Remove temporary ids. for obj in task.items: if obj.id < 0: obj.id = None # Save the new data. if success: # Return action.RETAG, which makes the importer write the tags # to the files if needed without re-applying metadata. return action.RETAG else: # Edit cancelled / no edits made. Revert changes. for obj in task.items: obj.read() def importer_edit_candidate(self, session, task): """Callback for invoking the functionality during an interactive import session on a *candidate*. The candidate's metadata is applied to the original items. """ # Prompt the user for a candidate. sel = ui.input_options([], numrange=(1, len(task.candidates))) # Force applying the candidate on the items. task.match = task.candidates[sel - 1] task.apply_metadata() return self.importer_edit(session, task) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/embedart.py0000644000076500000240000001655100000000000016464 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Allows beets to embed album art into file metadata.""" import os.path from beets.plugins import BeetsPlugin from beets import ui from beets.ui import print_, decargs from beets.util import syspath, normpath, displayable_path, bytestring_path from beets.util.artresizer import ArtResizer from beets import config from beets import art def _confirm(objs, album): """Show the list of affected objects (items or albums) and confirm that the user wants to modify their artwork. `album` is a Boolean indicating whether these are albums (as opposed to items). """ noun = 'album' if album else 'file' prompt = 'Modify artwork for {} {}{} (Y/n)?'.format( len(objs), noun, 's' if len(objs) > 1 else '' ) # Show all the items or albums. for obj in objs: print_(format(obj)) # Confirm with user. return ui.input_yn(prompt) class EmbedCoverArtPlugin(BeetsPlugin): """Allows albumart to be embedded into the actual files. """ def __init__(self): super().__init__() self.config.add({ 'maxwidth': 0, 'auto': True, 'compare_threshold': 0, 'ifempty': False, 'remove_art_file': False, 'quality': 0, }) if self.config['maxwidth'].get(int) and not ArtResizer.shared.local: self.config['maxwidth'] = 0 self._log.warning("ImageMagick or PIL not found; " "'maxwidth' option ignored") if self.config['compare_threshold'].get(int) and not \ ArtResizer.shared.can_compare: self.config['compare_threshold'] = 0 self._log.warning("ImageMagick 6.8.7 or higher not installed; " "'compare_threshold' option ignored") self.register_listener('art_set', self.process_album) 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' ) embed_cmd.parser.add_option( "-y", "--yes", action="store_true", help="skip confirmation" ) maxwidth = self.config['maxwidth'].get(int) quality = self.config['quality'].get(int) compare_threshold = self.config['compare_threshold'].get(int) ifempty = self.config['ifempty'].get(bool) def embed_func(lib, opts, args): if opts.file: imagepath = normpath(opts.file) if not os.path.isfile(syspath(imagepath)): raise ui.UserError('image file {} not found'.format( displayable_path(imagepath) )) items = lib.items(decargs(args)) # Confirm with user. if not opts.yes and not _confirm(items, not opts.file): return for item in items: art.embed_item(self._log, item, imagepath, maxwidth, None, compare_threshold, ifempty, quality=quality) else: albums = lib.albums(decargs(args)) # Confirm with user. if not opts.yes and not _confirm(albums, not opts.file): return for album in albums: art.embed_album(self._log, album, maxwidth, False, compare_threshold, ifempty, quality=quality) self.remove_artfile(album) 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', ) extract_cmd.parser.add_option( '-n', dest='filename', help='image filename to create for all matched albums', ) extract_cmd.parser.add_option( '-a', dest='associate', action='store_true', help='associate the extracted images with the album', ) def extract_func(lib, opts, args): if opts.outpath: art.extract_first(self._log, normpath(opts.outpath), lib.items(decargs(args))) else: filename = bytestring_path(opts.filename or config['art_filename'].get()) if os.path.dirname(filename) != b'': self._log.error( "Only specify a name rather than a path for -n") return for album in lib.albums(decargs(args)): artpath = normpath(os.path.join(album.path, filename)) artpath = art.extract_first(self._log, artpath, album.items()) if artpath and opts.associate: album.set_art(artpath) album.store() extract_cmd.func = extract_func # Clear command. clear_cmd = ui.Subcommand( 'clearart', help='remove images from file metadata', ) clear_cmd.parser.add_option( "-y", "--yes", action="store_true", help="skip confirmation" ) def clear_func(lib, opts, args): items = lib.items(decargs(args)) # Confirm with user. if not opts.yes and not _confirm(items, False): return art.clear(self._log, lib, decargs(args)) clear_cmd.func = clear_func return [embed_cmd, extract_cmd, clear_cmd] def process_album(self, album): """Automatically embed art after art has been set """ if self.config['auto'] and ui.should_write(): max_width = self.config['maxwidth'].get(int) art.embed_album(self._log, album, max_width, True, self.config['compare_threshold'].get(int), self.config['ifempty'].get(bool)) self.remove_artfile(album) def remove_artfile(self, album): """Possibly delete the album art file for an album (if the appropriate configuration option is enabled). """ if self.config['remove_art_file'] and album.artpath: if os.path.isfile(album.artpath): self._log.debug('Removing album art file for {0}', album) os.remove(album.artpath) album.artpath = None album.store() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/embyupdate.py0000644000076500000240000001330300000000000017030 0ustar00asampsonstaff"""Updates the Emby Library whenever the beets library is changed. emby: host: localhost port: 8096 username: user apikey: apikey password: password """ import hashlib import requests from urllib.parse import urlencode, urljoin, parse_qs, urlsplit, urlunsplit from beets import config from beets.plugins import BeetsPlugin def api_url(host, port, endpoint): """Returns a joined url. Takes host, port and endpoint and generates a valid emby API url. :param host: Hostname of the emby server :param port: Portnumber of the emby server :param endpoint: API endpoint :type host: str :type port: int :type endpoint: str :returns: Full API url :rtype: str """ # check if http or https is defined as host and create hostname hostname_list = [host] if host.startswith('http://') or host.startswith('https://'): hostname = ''.join(hostname_list) else: hostname_list.insert(0, 'http://') hostname = ''.join(hostname_list) joined = urljoin( '{hostname}:{port}'.format( hostname=hostname, port=port ), endpoint ) scheme, netloc, path, query_string, fragment = urlsplit(joined) query_params = parse_qs(query_string) query_params['format'] = ['json'] new_query_string = urlencode(query_params, doseq=True) return urlunsplit((scheme, netloc, path, new_query_string, fragment)) def password_data(username, password): """Returns a dict with username and its encoded password. :param username: Emby username :param password: Emby password :type username: str :type password: str :returns: Dictionary with username and encoded password :rtype: dict """ return { 'username': username, 'password': hashlib.sha1(password.encode('utf-8')).hexdigest(), 'passwordMd5': hashlib.md5(password.encode('utf-8')).hexdigest() } def create_headers(user_id, token=None): """Return header dict that is needed to talk to the Emby API. :param user_id: Emby user ID :param token: Authentication token for Emby :type user_id: str :type token: str :returns: Headers for requests :rtype: dict """ headers = {} authorization = ( 'MediaBrowser UserId="{user_id}", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ).format(user_id=user_id) headers['x-emby-authorization'] = authorization if token: headers['x-mediabrowser-token'] = token return headers def get_token(host, port, headers, auth_data): """Return token for a user. :param host: Emby host :param port: Emby port :param headers: Headers for requests :param auth_data: Username and encoded password for authentication :type host: str :type port: int :type headers: dict :type auth_data: dict :returns: Access Token :rtype: str """ url = api_url(host, port, '/Users/AuthenticateByName') r = requests.post(url, headers=headers, data=auth_data) return r.json().get('AccessToken') def get_user(host, port, username): """Return user dict from server or None if there is no user. :param host: Emby host :param port: Emby port :username: Username :type host: str :type port: int :type username: str :returns: Matched Users :rtype: list """ url = api_url(host, port, '/Users/Public') r = requests.get(url) user = [i for i in r.json() if i['Name'] == username] return user class EmbyUpdate(BeetsPlugin): def __init__(self): super().__init__() # Adding defaults. config['emby'].add({ 'host': 'http://localhost', 'port': 8096, 'apikey': None, 'password': None, }) self.register_listener('database_change', self.listen_for_db_change) def listen_for_db_change(self, lib, model): """Listens for beets db change and register the update for the end. """ self.register_listener('cli_exit', self.update) def update(self, lib): """When the client exists try to send refresh request to Emby. """ self._log.info('Updating Emby library...') host = config['emby']['host'].get() port = config['emby']['port'].get() username = config['emby']['username'].get() password = config['emby']['password'].get() token = config['emby']['apikey'].get() # Check if at least a apikey or password is given. if not any([password, token]): self._log.warning('Provide at least Emby password or apikey.') return # Get user information from the Emby API. user = get_user(host, port, username) if not user: self._log.warning(f'User {username} could not be found.') return if not token: # Create Authentication data and headers. auth_data = password_data(username, password) headers = create_headers(user[0]['Id']) # Get authentication token. token = get_token(host, port, headers, auth_data) if not token: self._log.warning( 'Could not get token for user {0}', username ) return # Recreate headers with a token. headers = create_headers(user[0]['Id'], token=token) # Trigger the Update. url = api_url(host, port, '/Library/Refresh') r = requests.post(url, headers=headers) if r.status_code != 204: self._log.warning('Update could not be triggered') else: self._log.info('Update triggered.') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beetsplug/export.py0000644000076500000240000001707100000000000016220 0ustar00asampsonstaff# This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Exports data from beets """ import sys import codecs import json import csv from xml.etree import ElementTree from datetime import datetime, date from beets.plugins import BeetsPlugin from beets import ui from beets import util import mediafile from beetsplug.info import library_data, tag_data class ExportEncoder(json.JSONEncoder): """Deals with dates because JSON doesn't have a standard""" def default(self, o): if isinstance(o, (datetime, date)): return o.isoformat() return json.JSONEncoder.default(self, o) class ExportPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'default_format': 'json', 'json': { # JSON module formatting options. 'formatting': { 'ensure_ascii': False, 'indent': 4, 'separators': (',', ': '), 'sort_keys': True } }, 'jsonlines': { # JSON Lines formatting options. 'formatting': { 'ensure_ascii': False, 'separators': (',', ': '), 'sort_keys': True } }, 'csv': { # CSV module formatting options. 'formatting': { # The delimiter used to seperate columns. 'delimiter': ',', # The dialect to use when formating the file output. 'dialect': 'excel' } }, 'xml': { # XML module formatting options. 'formatting': {} } # TODO: Use something like the edit plugin # 'item_fields': [] }) def commands(self): cmd = ui.Subcommand('export', help='export data from beets') cmd.func = self.run cmd.parser.add_option( '-l', '--library', action='store_true', help='show library fields instead of tags', ) cmd.parser.add_option( '-a', '--album', action='store_true', help='show album fields instead of tracks (implies "--library")', ) cmd.parser.add_option( '--append', action='store_true', default=False, help='if should append data to the file', ) cmd.parser.add_option( '-i', '--include-keys', default=[], action='append', dest='included_keys', help='comma separated list of keys to show', ) cmd.parser.add_option( '-o', '--output', help='path for the output file. If not given, will print the data' ) cmd.parser.add_option( '-f', '--format', default='json', help="the output format: json (default), jsonlines, csv, or xml" ) return [cmd] def run(self, lib, opts, args): file_path = opts.output file_mode = 'a' if opts.append else 'w' file_format = opts.format or self.config['default_format'].get(str) file_format_is_line_based = (file_format == 'jsonlines') format_options = self.config[file_format]['formatting'].get(dict) export_format = ExportFormat.factory( file_type=file_format, **{ 'file_path': file_path, 'file_mode': file_mode } ) if opts.library or opts.album: data_collector = library_data else: data_collector = tag_data included_keys = [] for keys in opts.included_keys: included_keys.extend(keys.split(',')) items = [] for data_emitter in data_collector( lib, ui.decargs(args), album=opts.album, ): try: data, item = data_emitter(included_keys or '*') except (mediafile.UnreadableFileError, OSError) as ex: self._log.error('cannot read file: {0}', ex) continue for key, value in data.items(): if isinstance(value, bytes): data[key] = util.displayable_path(value) if file_format_is_line_based: export_format.export(data, **format_options) else: items += [data] if not file_format_is_line_based: export_format.export(items, **format_options) class ExportFormat: """The output format type""" def __init__(self, file_path, file_mode='w', encoding='utf-8'): self.path = file_path self.mode = file_mode self.encoding = encoding # creates a file object to write/append or sets to stdout self.out_stream = codecs.open(self.path, self.mode, self.encoding) \ if self.path else sys.stdout @classmethod def factory(cls, file_type, **kwargs): if file_type in ["json", "jsonlines"]: return JsonFormat(**kwargs) elif file_type == "csv": return CSVFormat(**kwargs) elif file_type == "xml": return XMLFormat(**kwargs) else: raise NotImplementedError() def export(self, data, **kwargs): raise NotImplementedError() class JsonFormat(ExportFormat): """Saves in a json file""" def __init__(self, file_path, file_mode='w', encoding='utf-8'): super().__init__(file_path, file_mode, encoding) def export(self, data, **kwargs): json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs) self.out_stream.write('\n') class CSVFormat(ExportFormat): """Saves in a csv file""" def __init__(self, file_path, file_mode='w', encoding='utf-8'): super().__init__(file_path, file_mode, encoding) def export(self, data, **kwargs): header = list(data[0].keys()) if data else [] writer = csv.DictWriter(self.out_stream, fieldnames=header, **kwargs) writer.writeheader() writer.writerows(data) class XMLFormat(ExportFormat): """Saves in a xml file""" def __init__(self, file_path, file_mode='w', encoding='utf-8'): super().__init__(file_path, file_mode, encoding) def export(self, data, **kwargs): # Creates the XML file structure. library = ElementTree.Element('library') tracks = ElementTree.SubElement(library, 'tracks') if data and isinstance(data[0], dict): for index, item in enumerate(data): track = ElementTree.SubElement(tracks, 'track') for key, value in item.items(): track_details = ElementTree.SubElement(track, key) track_details.text = value # Depending on the version of python the encoding needs to change try: data = ElementTree.tostring(library, encoding='unicode', **kwargs) except LookupError: data = ElementTree.tostring(library, encoding='utf-8', **kwargs) self.out_stream.write(data) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/beetsplug/fetchart.py0000644000076500000240000012660200000000000016500 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Fetches album art. """ from contextlib import closing import os import re from tempfile import NamedTemporaryFile from collections import OrderedDict import requests from beets import plugins from beets import importer from beets import ui from beets import util from beets import config from mediafile import image_mime_type from beets.util.artresizer import ArtResizer from beets.util import sorted_walk from beets.util import syspath, bytestring_path, py3_path import confuse CONTENT_TYPES = { 'image/jpeg': [b'jpg', b'jpeg'], 'image/png': [b'png'] } IMAGE_EXTENSIONS = [ext for exts in CONTENT_TYPES.values() for ext in exts] class Candidate: """Holds information about a matching artwork, deals with validation of dimension restrictions and resizing. """ CANDIDATE_BAD = 0 CANDIDATE_EXACT = 1 CANDIDATE_DOWNSCALE = 2 CANDIDATE_DOWNSIZE = 3 CANDIDATE_DEINTERLACE = 4 CANDIDATE_REFORMAT = 5 MATCH_EXACT = 0 MATCH_FALLBACK = 1 def __init__(self, log, path=None, url=None, source='', match=None, size=None): self._log = log self.path = path self.url = url self.source = source self.check = None self.match = match self.size = size def _validate(self, plugin): """Determine whether the candidate artwork is valid based on its dimensions (width and ratio). Return `CANDIDATE_BAD` if the file is unusable. Return `CANDIDATE_EXACT` if the file is usable as-is. Return `CANDIDATE_DOWNSCALE` if the file must be rescaled. Return `CANDIDATE_DOWNSIZE` if the file must be resized, and possibly also rescaled. Return `CANDIDATE_DEINTERLACE` if the file must be deinterlaced. Return `CANDIDATE_REFORMAT` if the file has to be converted. """ if not self.path: return self.CANDIDATE_BAD if (not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth or plugin.max_filesize or plugin.deinterlace or plugin.cover_format)): return self.CANDIDATE_EXACT # get_size returns None if no local imaging backend is available if not self.size: self.size = ArtResizer.shared.get_size(self.path) self._log.debug('image size: {}', self.size) if not self.size: self._log.warning('Could not get size of image (please see ' 'documentation for dependencies). ' 'The configuration options `minwidth`, ' '`enforce_ratio` and `max_filesize` ' 'may be violated.') return self.CANDIDATE_EXACT short_edge = min(self.size) long_edge = max(self.size) # Check minimum dimension. if plugin.minwidth and self.size[0] < plugin.minwidth: self._log.debug('image too small ({} < {})', self.size[0], plugin.minwidth) return self.CANDIDATE_BAD # Check aspect ratio. edge_diff = long_edge - short_edge if plugin.enforce_ratio: if plugin.margin_px: if edge_diff > plugin.margin_px: self._log.debug('image is not close enough to being ' 'square, ({} - {} > {})', long_edge, short_edge, plugin.margin_px) return self.CANDIDATE_BAD elif plugin.margin_percent: margin_px = plugin.margin_percent * long_edge if edge_diff > margin_px: self._log.debug('image is not close enough to being ' 'square, ({} - {} > {})', long_edge, short_edge, margin_px) return self.CANDIDATE_BAD elif edge_diff: # also reached for margin_px == 0 and margin_percent == 0.0 self._log.debug('image is not square ({} != {})', self.size[0], self.size[1]) return self.CANDIDATE_BAD # Check maximum dimension. downscale = False if plugin.maxwidth and self.size[0] > plugin.maxwidth: self._log.debug('image needs rescaling ({} > {})', self.size[0], plugin.maxwidth) downscale = True # Check filesize. downsize = False if plugin.max_filesize: filesize = os.stat(syspath(self.path)).st_size if filesize > plugin.max_filesize: self._log.debug('image needs resizing ({}B > {}B)', filesize, plugin.max_filesize) downsize = True # Check image format reformat = False if plugin.cover_format: fmt = ArtResizer.shared.get_format(self.path) reformat = fmt != plugin.cover_format if reformat: self._log.debug('image needs reformatting: {} -> {}', fmt, plugin.cover_format) if downscale: return self.CANDIDATE_DOWNSCALE elif downsize: return self.CANDIDATE_DOWNSIZE elif plugin.deinterlace: return self.CANDIDATE_DEINTERLACE elif reformat: return self.CANDIDATE_REFORMAT else: return self.CANDIDATE_EXACT def validate(self, plugin): self.check = self._validate(plugin) return self.check def resize(self, plugin): if self.check == self.CANDIDATE_DOWNSCALE: self.path = \ ArtResizer.shared.resize(plugin.maxwidth, self.path, quality=plugin.quality, max_filesize=plugin.max_filesize) elif self.check == self.CANDIDATE_DOWNSIZE: # dimensions are correct, so maxwidth is set to maximum dimension self.path = \ ArtResizer.shared.resize(max(self.size), self.path, quality=plugin.quality, max_filesize=plugin.max_filesize) elif self.check == self.CANDIDATE_DEINTERLACE: self.path = ArtResizer.shared.deinterlace(self.path) elif self.check == self.CANDIDATE_REFORMAT: self.path = ArtResizer.shared.reformat( self.path, plugin.cover_format, deinterlaced=plugin.deinterlace, ) def _logged_get(log, *args, **kwargs): """Like `requests.get`, but logs the effective URL to the specified `log` at the `DEBUG` level. Use the optional `message` parameter to specify what to log before the URL. By default, the string is "getting URL". Also sets the User-Agent header to indicate beets. """ # Use some arguments with the `send` call but most with the # `Request` construction. This is a cheap, magic-filled way to # emulate `requests.get` or, more pertinently, # `requests.Session.request`. req_kwargs = kwargs send_kwargs = {} for arg in ('stream', 'verify', 'proxies', 'cert', 'timeout'): if arg in kwargs: send_kwargs[arg] = req_kwargs.pop(arg) # Our special logging message parameter. if 'message' in kwargs: message = kwargs.pop('message') else: message = 'getting URL' req = requests.Request('GET', *args, **req_kwargs) with requests.Session() as s: s.headers = {'User-Agent': 'beets'} prepped = s.prepare_request(req) settings = s.merge_environment_settings( prepped.url, {}, None, None, None ) send_kwargs.update(settings) log.debug('{}: {}', message, prepped.url) return s.send(prepped, **send_kwargs) class RequestMixin: """Adds a Requests wrapper to the class that uses the logger, which must be named `self._log`. """ def request(self, *args, **kwargs): """Like `requests.get`, but uses the logger `self._log`. See also `_logged_get`. """ return _logged_get(self._log, *args, **kwargs) # ART SOURCES ################################################################ class ArtSource(RequestMixin): VALID_MATCHING_CRITERIA = ['default'] def __init__(self, log, config, match_by=None): self._log = log self._config = config self.match_by = match_by or self.VALID_MATCHING_CRITERIA def get(self, album, plugin, paths): raise NotImplementedError() def _candidate(self, **kwargs): return Candidate(source=self, log=self._log, **kwargs) def fetch_image(self, candidate, plugin): raise NotImplementedError() def cleanup(self, candidate): pass class LocalArtSource(ArtSource): IS_LOCAL = True LOC_STR = 'local' def fetch_image(self, candidate, plugin): pass class RemoteArtSource(ArtSource): IS_LOCAL = False LOC_STR = 'remote' def fetch_image(self, candidate, plugin): """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. """ if plugin.maxwidth: candidate.url = ArtResizer.shared.proxy_url(plugin.maxwidth, candidate.url) try: with closing(self.request(candidate.url, stream=True, message='downloading image')) as resp: ct = resp.headers.get('Content-Type', None) # Download the image to a temporary file. As some servers # (notably fanart.tv) have proven to return wrong Content-Types # when images were uploaded with a bad file extension, do not # rely on it. Instead validate the type using the file magic # and only then determine the extension. data = resp.iter_content(chunk_size=1024) header = b'' for chunk in data: header += chunk if len(header) >= 32: # The imghdr module will only read 32 bytes, and our # own additions in mediafile even less. break else: # server didn't return enough data, i.e. corrupt image return real_ct = image_mime_type(header) if real_ct is None: # detection by file magic failed, fall back to the # server-supplied Content-Type # Is our type detection failsafe enough to drop this? real_ct = ct if real_ct not in CONTENT_TYPES: self._log.debug('not a supported image: {}', real_ct or 'unknown content type') return ext = b'.' + CONTENT_TYPES[real_ct][0] if real_ct != ct: self._log.warning('Server specified {}, but returned a ' '{} image. Correcting the extension ' 'to {}', ct, real_ct, ext) suffix = py3_path(ext) with NamedTemporaryFile(suffix=suffix, delete=False) as fh: # write the first already loaded part of the image fh.write(header) # download the remaining part of the image for chunk in data: fh.write(chunk) self._log.debug('downloaded art to: {0}', util.displayable_path(fh.name)) candidate.path = util.bytestring_path(fh.name) return except (OSError, requests.RequestException, TypeError) as exc: # Handling TypeError works around a urllib3 bug: # https://github.com/shazow/urllib3/issues/556 self._log.debug('error fetching art: {}', exc) return def cleanup(self, candidate): if candidate.path: try: util.remove(path=candidate.path) except util.FilesystemError as exc: self._log.debug('error cleaning up tmp art: {}', exc) class CoverArtArchive(RemoteArtSource): NAME = "Cover Art Archive" VALID_MATCHING_CRITERIA = ['release', 'releasegroup'] VALID_THUMBNAIL_SIZES = [250, 500, 1200] URL = 'https://coverartarchive.org/release/{mbid}' GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}' def get(self, album, plugin, paths): """Return the Cover Art Archive and Cover Art Archive release group URLs using album MusicBrainz release ID and release group ID. """ def get_image_urls(url, size_suffix=None): try: response = self.request(url) except requests.RequestException: self._log.debug('{}: error receiving response' .format(self.NAME)) return try: data = response.json() except ValueError: self._log.debug('{}: error loading response: {}' .format(self.NAME, response.text)) return for item in data.get('images', []): try: if 'Front' not in item['types']: continue if size_suffix: yield item['thumbnails'][size_suffix] else: yield item['image'] except KeyError: pass release_url = self.URL.format(mbid=album.mb_albumid) release_group_url = self.GROUP_URL.format(mbid=album.mb_releasegroupid) # Cover Art Archive API offers pre-resized thumbnails at several sizes. # If the maxwidth config matches one of the already available sizes # fetch it directly intead of fetching the full sized image and # resizing it. size_suffix = None if plugin.maxwidth in self.VALID_THUMBNAIL_SIZES: size_suffix = "-" + str(plugin.maxwidth) if 'release' in self.match_by and album.mb_albumid: for url in get_image_urls(release_url, size_suffix): yield self._candidate(url=url, match=Candidate.MATCH_EXACT) if 'releasegroup' in self.match_by and album.mb_releasegroupid: for url in get_image_urls(release_group_url): yield self._candidate(url=url, match=Candidate.MATCH_FALLBACK) class Amazon(RemoteArtSource): NAME = "Amazon" URL = 'https://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' INDICES = (1, 2) def get(self, album, plugin, paths): """Generate URLs using Amazon ID (ASIN) string. """ if album.asin: for index in self.INDICES: yield self._candidate(url=self.URL % (album.asin, index), match=Candidate.MATCH_EXACT) class AlbumArtOrg(RemoteArtSource): NAME = "AlbumArt.org scraper" URL = 'https://www.albumart.org/index_detail.php' PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' def get(self, album, plugin, paths): """Return art URL from AlbumArt.org using album ASIN. """ if not album.asin: return # Get the page from albumart.org. try: resp = self.request(self.URL, params={'asin': album.asin}) self._log.debug('scraped art URL: {0}', resp.url) except requests.RequestException: self._log.debug('error scraping art page') return # Search the page for the image URL. m = re.search(self.PAT, resp.text) if m: image_url = m.group(1) yield self._candidate(url=image_url, match=Candidate.MATCH_EXACT) else: self._log.debug('no image found on page') class GoogleImages(RemoteArtSource): NAME = "Google Images" URL = 'https://www.googleapis.com/customsearch/v1' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.key = self._config['google_key'].get(), self.cx = self._config['google_engine'].get(), def get(self, album, plugin, paths): """Return art URL from google custom search engine given an album title and interpreter. """ if not (album.albumartist and album.album): return search_string = (album.albumartist + ',' + album.album).encode('utf-8') try: response = self.request(self.URL, params={ 'key': self.key, 'cx': self.cx, 'q': search_string, 'searchType': 'image' }) except requests.RequestException: self._log.debug('google: error receiving response') return # Get results using JSON. try: data = response.json() except ValueError: self._log.debug('google: error loading response: {}' .format(response.text)) return if 'error' in data: reason = data['error']['errors'][0]['reason'] self._log.debug('google fetchart error: {0}', reason) return if 'items' in data.keys(): for item in data['items']: yield self._candidate(url=item['link'], match=Candidate.MATCH_EXACT) class FanartTV(RemoteArtSource): """Art from fanart.tv requested using their API""" NAME = "fanart.tv" API_URL = 'https://webservice.fanart.tv/v3/' API_ALBUMS = API_URL + 'music/albums/' PROJECT_KEY = '61a7d0ab4e67162b7a0c7c35915cd48e' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.client_key = self._config['fanarttv_key'].get() def get(self, album, plugin, paths): if not album.mb_releasegroupid: return try: response = self.request( self.API_ALBUMS + album.mb_releasegroupid, headers={'api-key': self.PROJECT_KEY, 'client-key': self.client_key}) except requests.RequestException: self._log.debug('fanart.tv: error receiving response') return try: data = response.json() except ValueError: self._log.debug('fanart.tv: error loading response: {}', response.text) return if 'status' in data and data['status'] == 'error': if 'not found' in data['error message'].lower(): self._log.debug('fanart.tv: no image found') elif 'api key' in data['error message'].lower(): self._log.warning('fanart.tv: Invalid API key given, please ' 'enter a valid one in your config file.') else: self._log.debug('fanart.tv: error on request: {}', data['error message']) return matches = [] # can there be more than one releasegroupid per response? for mbid, art in data.get('albums', {}).items(): # there might be more art referenced, e.g. cdart, and an albumcover # might not be present, even if the request was successful if album.mb_releasegroupid == mbid and 'albumcover' in art: matches.extend(art['albumcover']) # can this actually occur? else: self._log.debug('fanart.tv: unexpected mb_releasegroupid in ' 'response!') matches.sort(key=lambda x: x['likes'], reverse=True) for item in matches: # fanart.tv has a strict size requirement for album art to be # uploaded yield self._candidate(url=item['url'], match=Candidate.MATCH_EXACT, size=(1000, 1000)) class ITunesStore(RemoteArtSource): NAME = "iTunes Store" API_URL = 'https://itunes.apple.com/search' def get(self, album, plugin, paths): """Return art URL from iTunes Store given an album title. """ if not (album.albumartist and album.album): return payload = { 'term': album.albumartist + ' ' + album.album, 'entity': 'album', 'media': 'music', 'limit': 200 } try: r = self.request(self.API_URL, params=payload) r.raise_for_status() except requests.RequestException as e: self._log.debug('iTunes search failed: {0}', e) return try: candidates = r.json()['results'] except ValueError as e: self._log.debug('Could not decode json response: {0}', e) return except KeyError as e: self._log.debug('{} not found in json. Fields are {} ', e, list(r.json().keys())) return if not candidates: self._log.debug('iTunes search for {!r} got no results', payload['term']) return if self._config['high_resolution']: image_suffix = '100000x100000-999' else: image_suffix = '1200x1200bb' for c in candidates: try: if (c['artistName'] == album.albumartist and c['collectionName'] == album.album): art_url = c['artworkUrl100'] art_url = art_url.replace('100x100bb', image_suffix) yield self._candidate(url=art_url, match=Candidate.MATCH_EXACT) except KeyError as e: self._log.debug('Malformed itunes candidate: {} not found in {}', # NOQA E501 e, list(c.keys())) try: fallback_art_url = candidates[0]['artworkUrl100'] fallback_art_url = fallback_art_url.replace('100x100bb', image_suffix) yield self._candidate(url=fallback_art_url, match=Candidate.MATCH_FALLBACK) except KeyError as e: self._log.debug('Malformed itunes candidate: {} not found in {}', e, list(c.keys())) class Wikipedia(RemoteArtSource): NAME = "Wikipedia (queried through DBpedia)" DBPEDIA_URL = 'https://dbpedia.org/sparql' WIKIPEDIA_URL = 'https://en.wikipedia.org/w/api.php' SPARQL_QUERY = '''PREFIX rdf: PREFIX dbpprop: PREFIX owl: PREFIX rdfs: PREFIX foaf: SELECT DISTINCT ?pageId ?coverFilename WHERE {{ ?subject owl:wikiPageID ?pageId . ?subject dbpprop:name ?name . ?subject rdfs:label ?label . {{ ?subject dbpprop:artist ?artist }} UNION {{ ?subject owl:artist ?artist }} {{ ?artist foaf:name "{artist}"@en }} UNION {{ ?artist dbpprop:name "{artist}"@en }} ?subject rdf:type . ?subject dbpprop:cover ?coverFilename . FILTER ( regex(?name, "{album}", "i") ) }} Limit 1''' def get(self, album, plugin, paths): if not (album.albumartist and album.album): return # Find the name of the cover art filename on DBpedia cover_filename, page_id = None, None try: dbpedia_response = self.request( self.DBPEDIA_URL, params={ 'format': 'application/sparql-results+json', 'timeout': 2500, 'query': self.SPARQL_QUERY.format( artist=album.albumartist.title(), album=album.album) }, headers={'content-type': 'application/json'}, ) except requests.RequestException: self._log.debug('dbpedia: error receiving response') return try: data = dbpedia_response.json() results = data['results']['bindings'] if results: cover_filename = 'File:' + results[0]['coverFilename']['value'] page_id = results[0]['pageId']['value'] else: self._log.debug('wikipedia: album not found on dbpedia') except (ValueError, KeyError, IndexError): self._log.debug('wikipedia: error scraping dbpedia response: {}', dbpedia_response.text) # Ensure we have a filename before attempting to query wikipedia if not (cover_filename and page_id): return # DBPedia sometimes provides an incomplete cover_filename, indicated # by the filename having a space before the extension, e.g., 'foo .bar' # An additional Wikipedia call can help to find the real filename. # This may be removed once the DBPedia issue is resolved, see: # https://github.com/dbpedia/extraction-framework/issues/396 if ' .' in cover_filename and \ '.' not in cover_filename.split(' .')[-1]: self._log.debug( 'wikipedia: dbpedia provided incomplete cover_filename' ) lpart, rpart = cover_filename.rsplit(' .', 1) # Query all the images in the page try: wikipedia_response = self.request( self.WIKIPEDIA_URL, params={ 'format': 'json', 'action': 'query', 'continue': '', 'prop': 'images', 'pageids': page_id, }, headers={'content-type': 'application/json'}, ) except requests.RequestException: self._log.debug('wikipedia: error receiving response') return # Try to see if one of the images on the pages matches our # incomplete cover_filename try: data = wikipedia_response.json() results = data['query']['pages'][page_id]['images'] for result in results: if re.match(re.escape(lpart) + r'.*?\.' + re.escape(rpart), result['title']): cover_filename = result['title'] break except (ValueError, KeyError): self._log.debug( 'wikipedia: failed to retrieve a cover_filename' ) return # Find the absolute url of the cover art on Wikipedia try: wikipedia_response = self.request( self.WIKIPEDIA_URL, params={ 'format': 'json', 'action': 'query', 'continue': '', 'prop': 'imageinfo', 'iiprop': 'url', 'titles': cover_filename.encode('utf-8'), }, headers={'content-type': 'application/json'}, ) except requests.RequestException: self._log.debug('wikipedia: error receiving response') return try: data = wikipedia_response.json() results = data['query']['pages'] for _, result in results.items(): image_url = result['imageinfo'][0]['url'] yield self._candidate(url=image_url, match=Candidate.MATCH_EXACT) except (ValueError, KeyError, IndexError): self._log.debug('wikipedia: error scraping imageinfo') return class FileSystem(LocalArtSource): NAME = "Filesystem" @staticmethod def filename_priority(filename, cover_names): """Sort order for image names. Return indexes of cover names found in the image filename. This means that images with lower-numbered and more keywords will have higher priority. """ return [idx for (idx, x) in enumerate(cover_names) if x in filename] def get(self, album, plugin, paths): """Look for album art files in the specified directories. """ if not paths: return cover_names = list(map(util.bytestring_path, plugin.cover_names)) cover_names_str = b'|'.join(cover_names) cover_pat = br''.join([br"(\b|_)(", cover_names_str, br")(\b|_)"]) for path in paths: if not os.path.isdir(syspath(path)): continue # Find all files that look like images in the directory. images = [] ignore = config['ignore'].as_str_seq() ignore_hidden = config['ignore_hidden'].get(bool) for _, _, files in sorted_walk(path, ignore=ignore, ignore_hidden=ignore_hidden): for fn in files: fn = bytestring_path(fn) for ext in IMAGE_EXTENSIONS: if fn.lower().endswith(b'.' + ext) and \ os.path.isfile(syspath(os.path.join(path, fn))): images.append(fn) # Look for "preferred" filenames. images = sorted(images, key=lambda x: self.filename_priority(x, cover_names)) remaining = [] for fn in images: if re.search(cover_pat, os.path.splitext(fn)[0], re.I): self._log.debug('using well-named art file {0}', util.displayable_path(fn)) yield self._candidate(path=os.path.join(path, fn), match=Candidate.MATCH_EXACT) else: remaining.append(fn) # Fall back to any image in the folder. if remaining and not plugin.cautious: self._log.debug('using fallback art file {0}', util.displayable_path(remaining[0])) yield self._candidate(path=os.path.join(path, remaining[0]), match=Candidate.MATCH_FALLBACK) class LastFM(RemoteArtSource): NAME = "Last.fm" # Sizes in priority order. SIZES = OrderedDict([ ('mega', (300, 300)), ('extralarge', (300, 300)), ('large', (174, 174)), ('medium', (64, 64)), ('small', (34, 34)), ]) API_URL = 'https://ws.audioscrobbler.com/2.0' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.key = self._config['lastfm_key'].get(), def get(self, album, plugin, paths): if not album.mb_albumid: return try: response = self.request(self.API_URL, params={ 'method': 'album.getinfo', 'api_key': self.key, 'mbid': album.mb_albumid, 'format': 'json', }) except requests.RequestException: self._log.debug('lastfm: error receiving response') return try: data = response.json() if 'error' in data: if data['error'] == 6: self._log.debug('lastfm: no results for {}', album.mb_albumid) else: self._log.error( 'lastfm: failed to get album info: {} ({})', data['message'], data['error']) else: images = {image['size']: image['#text'] for image in data['album']['image']} # Provide candidates in order of size. for size in self.SIZES.keys(): if size in images: yield self._candidate(url=images[size], size=self.SIZES[size]) except ValueError: self._log.debug('lastfm: error loading response: {}' .format(response.text)) return # Try each source in turn. SOURCES_ALL = ['filesystem', 'coverart', 'itunes', 'amazon', 'albumart', 'wikipedia', 'google', 'fanarttv', 'lastfm'] ART_SOURCES = { 'filesystem': FileSystem, 'coverart': CoverArtArchive, 'itunes': ITunesStore, 'albumart': AlbumArtOrg, 'amazon': Amazon, 'wikipedia': Wikipedia, 'google': GoogleImages, 'fanarttv': FanartTV, 'lastfm': LastFM, } SOURCE_NAMES = {v: k for k, v in ART_SOURCES.items()} # PLUGIN LOGIC ############################################################### class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): PAT_PX = r"(0|[1-9][0-9]*)px" PAT_PERCENT = r"(100(\.00?)?|[1-9]?[0-9](\.[0-9]{1,2})?)%" def __init__(self): super().__init__() # Holds candidates corresponding to downloaded images between # fetching them and placing them in the filesystem. self.art_candidates = {} self.config.add({ 'auto': True, 'minwidth': 0, 'maxwidth': 0, 'quality': 0, 'max_filesize': 0, 'enforce_ratio': False, 'cautious': False, 'cover_names': ['cover', 'front', 'art', 'album', 'folder'], 'sources': ['filesystem', 'coverart', 'itunes', 'amazon', 'albumart'], 'google_key': None, 'google_engine': '001442825323518660753:hrh5ch1gjzm', 'fanarttv_key': None, 'lastfm_key': None, 'store_source': False, 'high_resolution': False, 'deinterlace': False, 'cover_format': None, }) self.config['google_key'].redact = True self.config['fanarttv_key'].redact = True self.config['lastfm_key'].redact = True self.minwidth = self.config['minwidth'].get(int) self.maxwidth = self.config['maxwidth'].get(int) self.max_filesize = self.config['max_filesize'].get(int) self.quality = self.config['quality'].get(int) # allow both pixel and percentage-based margin specifications self.enforce_ratio = self.config['enforce_ratio'].get( confuse.OneOf([bool, confuse.String(pattern=self.PAT_PX), confuse.String(pattern=self.PAT_PERCENT)])) self.margin_px = None self.margin_percent = None self.deinterlace = self.config['deinterlace'].get(bool) if type(self.enforce_ratio) is str: if self.enforce_ratio[-1] == '%': self.margin_percent = float(self.enforce_ratio[:-1]) / 100 elif self.enforce_ratio[-2:] == 'px': self.margin_px = int(self.enforce_ratio[:-2]) else: # shouldn't happen raise confuse.ConfigValueError() self.enforce_ratio = True cover_names = self.config['cover_names'].as_str_seq() self.cover_names = list(map(util.bytestring_path, cover_names)) self.cautious = self.config['cautious'].get(bool) self.store_source = self.config['store_source'].get(bool) self.src_removed = (config['import']['delete'].get(bool) or config['import']['move'].get(bool)) self.cover_format = self.config['cover_format'].get( confuse.Optional(str) ) 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) available_sources = list(SOURCES_ALL) if not self.config['google_key'].get() and \ 'google' in available_sources: available_sources.remove('google') if not self.config['lastfm_key'].get() and \ 'lastfm' in available_sources: available_sources.remove('lastfm') available_sources = [(s, c) for s in available_sources for c in ART_SOURCES[s].VALID_MATCHING_CRITERIA] sources = plugins.sanitize_pairs( self.config['sources'].as_pairs(default_value='*'), available_sources) if 'remote_priority' in self.config: self._log.warning( 'The `fetch_art.remote_priority` configuration option has ' 'been deprecated. Instead, place `filesystem` at the end of ' 'your `sources` list.') if self.config['remote_priority'].get(bool): fs = [] others = [] for s, c in sources: if s == 'filesystem': fs.append((s, c)) else: others.append((s, c)) sources = others + fs self.sources = [ART_SOURCES[s](self._log, self.config, match_by=[c]) for s, c in sources] # 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.album.artpath and os.path.isfile(task.album.artpath): # Album already has art (probably a re-import); skip it. return if task.choice_flag == importer.action.ASIS: # For as-is imports, don't search Web sources for art. local = True elif task.choice_flag in (importer.action.APPLY, importer.action.RETAG): # Search everywhere for art. local = False else: # For any other choices (e.g., TRACKS), do nothing. return candidate = self.art_for_album(task.album, task.paths, local) if candidate: self.art_candidates[task] = candidate def _set_art(self, album, candidate, delete=False): album.set_art(candidate.path, delete) if self.store_source: # store the source of the chosen artwork in a flexible field self._log.debug( "Storing art_source for {0.albumartist} - {0.album}", album) album.art_source = SOURCE_NAMES[type(candidate.source)] album.store() # 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_candidates: candidate = self.art_candidates.pop(task) self._set_art(task.album, candidate, not self.src_removed) if self.src_removed: task.prune(candidate.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' ) cmd.parser.add_option( '-q', '--quiet', dest='quiet', action='store_true', default=False, help='quiet mode: do not output albums that already have artwork' ) def func(lib, opts, args): self.batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force, opts.quiet) cmd.func = func return [cmd] # Utilities converted from functions to methods on logging overhaul def art_for_album(self, album, paths, 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 `quality` then resized images are saved at the specified quality level. If `local_only`, then only local image files from the filesystem are returned; no network requests are made. """ out = None for source in self.sources: if source.IS_LOCAL or not local_only: self._log.debug( 'trying source {0} for album {1.albumartist} - {1.album}', SOURCE_NAMES[type(source)], album, ) # URLs might be invalid at this point, or the image may not # fulfill the requirements for candidate in source.get(album, self, paths): source.fetch_image(candidate, self) if candidate.validate(self): out = candidate self._log.debug( 'using {0.LOC_STR} image {1}'.format( source, util.displayable_path(out.path))) break # Remove temporary files for invalid candidates. source.cleanup(candidate) if out: break if out: out.resize(self) return out def batch_fetch_art(self, lib, albums, force, quiet): """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 and os.path.isfile(album.artpath): if not quiet: message = ui.colorize('text_highlight_minor', 'has album art') self._log.info('{0}: {1}', album, message) else: # In ordinary invocations, look for images on the # filesystem. When forcing, however, always go to the Web # sources. local_paths = None if force else [album.path] candidate = self.art_for_album(album, local_paths) if candidate: self._set_art(album, candidate) message = ui.colorize('text_success', 'found album art') else: message = ui.colorize('text_error', 'no art found') self._log.info('{0}: {1}', album, message) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/filefilter.py0000644000076500000240000000554700000000000017031 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Malte Ried. # # 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. """Filter imported files using a regular expression. """ import re from beets import config from beets.util import bytestring_path from beets.plugins import BeetsPlugin from beets.importer import SingletonImportTask class FileFilterPlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener('import_task_created', self.import_task_created_event) self.config.add({ 'path': '.*' }) self.path_album_regex = \ self.path_singleton_regex = \ re.compile(bytestring_path(self.config['path'].get())) if 'album_path' in self.config: self.path_album_regex = re.compile( bytestring_path(self.config['album_path'].get())) if 'singleton_path' in self.config: self.path_singleton_regex = re.compile( bytestring_path(self.config['singleton_path'].get())) def import_task_created_event(self, session, task): if task.items and len(task.items) > 0: items_to_import = [] for item in task.items: if self.file_filter(item['path']): items_to_import.append(item) if len(items_to_import) > 0: task.items = items_to_import else: # Returning an empty list of tasks from the handler # drops the task from the rest of the importer pipeline. return [] elif isinstance(task, SingletonImportTask): if not self.file_filter(task.item['path']): return [] # If not filtered, return the original task unchanged. return [task] def file_filter(self, full_path): """Checks if the configured regular expressions allow the import of the file given in full_path. """ import_config = dict(config['import']) full_path = bytestring_path(full_path) if 'singletons' not in import_config or not import_config[ 'singletons']: # Album return self.path_album_regex.match(full_path) is not None else: # Singleton return self.path_singleton_regex.match(full_path) is not None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/fish.py0000644000076500000240000002376000000000000015632 0ustar00asampsonstaff# This file is part of beets. # Copyright 2015, winters jean-marie. # Copyright 2020, Justin Mayer # # 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 plugin generates tab completions for Beets commands for the Fish shell , including completions for Beets commands, plugin commands, and option flags. Also generated are completions for all the album and track fields, suggesting for example `genre:` or `album:` when querying the Beets database. Completions for the *values* of those fields are not generated by default but can be added via the `-e` / `--extravalues` flag. For example: `beet fish -e genre -e albumartist` """ from beets.plugins import BeetsPlugin from beets import library, ui from beets.ui import commands from operator import attrgetter import os BL_NEED2 = """complete -c beet -n '__fish_beet_needs_command' {} {}\n""" BL_USE3 = """complete -c beet -n '__fish_beet_using_command {}' {} {}\n""" BL_SUBS = """complete -c beet -n '__fish_at_level {} ""' {} {}\n""" BL_EXTRA3 = """complete -c beet -n '__fish_beet_use_extra {}' {} {}\n""" HEAD = ''' function __fish_beet_needs_command set cmd (commandline -opc) if test (count $cmd) -eq 1 return 0 end return 1 end function __fish_beet_using_command set cmd (commandline -opc) set needle (count $cmd) if test $needle -gt 1 if begin test $argv[1] = $cmd[2]; and not contains -- $cmd[$needle] $FIELDS; end return 0 end end return 1 end function __fish_beet_use_extra set cmd (commandline -opc) set needle (count $cmd) if test $argv[2] = $cmd[$needle] return 0 end return 1 end ''' class FishPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('fish', help='generate Fish shell tab completions') cmd.func = self.run cmd.parser.add_option('-f', '--noFields', action='store_true', default=False, help='omit album/track field completions') cmd.parser.add_option( '-e', '--extravalues', action='append', type='choice', choices=library.Item.all_keys() + library.Album.all_keys(), help='include specified field *values* in completions') return [cmd] def run(self, lib, opts, args): # Gather the commands from Beets core and its plugins. # Collect the album and track fields. # If specified, also collect the values for these fields. # Make a giant string of all the above, formatted in a way that # allows Fish to do tab completion for the `beet` command. home_dir = os.path.expanduser("~") completion_dir = os.path.join(home_dir, '.config/fish/completions') try: os.makedirs(completion_dir) except OSError: if not os.path.isdir(completion_dir): raise completion_file_path = os.path.join(completion_dir, 'beet.fish') nobasicfields = opts.noFields # Do not complete for album/track fields extravalues = opts.extravalues # e.g., Also complete artists names beetcmds = sorted( (commands.default_commands + commands.plugins.commands()), key=attrgetter('name')) fields = sorted(set( library.Album.all_keys() + library.Item.all_keys())) # Collect commands, their aliases, and their help text cmd_names_help = [] for cmd in beetcmds: names = list(cmd.aliases) names.append(cmd.name) for name in names: cmd_names_help.append((name, cmd.help)) # Concatenate the string totstring = HEAD + "\n" totstring += get_cmds_list([name[0] for name in cmd_names_help]) totstring += '' if nobasicfields else get_standard_fields(fields) totstring += get_extravalues(lib, extravalues) if extravalues else '' totstring += "\n" + "# ====== {} =====".format( "setup basic beet completion") + "\n" * 2 totstring += get_basic_beet_options() totstring += "\n" + "# ====== {} =====".format( "setup field completion for subcommands") + "\n" totstring += get_subcommands( cmd_names_help, nobasicfields, extravalues) # Set up completion for all the command options totstring += get_all_commands(beetcmds) with open(completion_file_path, 'w') as fish_file: fish_file.write(totstring) def _escape(name): # Escape ? in fish if name == "?": name = "\\" + name return name def get_cmds_list(cmds_names): # Make a list of all Beets core & plugin commands substr = '' substr += ( "set CMDS " + " ".join(cmds_names) + ("\n" * 2) ) return substr def get_standard_fields(fields): # Make a list of album/track fields and append with ':' fields = (field + ":" for field in fields) substr = '' substr += ( "set FIELDS " + " ".join(fields) + ("\n" * 2) ) return substr def get_extravalues(lib, extravalues): # Make a list of all values from an album/track field. # 'beet ls albumartist: ' yields completions for ABBA, Beatles, etc. word = '' values_set = get_set_of_values_for_field(lib, extravalues) for fld in extravalues: extraname = fld.upper() + 'S' word += ( "set " + extraname + " " + " ".join(sorted(values_set[fld])) + ("\n" * 2) ) return word def get_set_of_values_for_field(lib, fields): # Get unique values from a specified album/track field fields_dict = {} for each in fields: fields_dict[each] = set() for item in lib.items(): for field in fields: fields_dict[field].add(wrap(item[field])) return fields_dict def get_basic_beet_options(): word = ( BL_NEED2.format("-l format-item", "-f -d 'print with custom format'") + BL_NEED2.format("-l format-album", "-f -d 'print with custom format'") + BL_NEED2.format("-s l -l library", "-f -r -d 'library database file to use'") + BL_NEED2.format("-s d -l directory", "-f -r -d 'destination music directory'") + BL_NEED2.format("-s v -l verbose", "-f -d 'print debugging information'") + BL_NEED2.format("-s c -l config", "-f -r -d 'path to configuration file'") + BL_NEED2.format("-s h -l help", "-f -d 'print this help message and exit'")) return word def get_subcommands(cmd_name_and_help, nobasicfields, extravalues): # Formatting for Fish to complete our fields/values word = "" for cmdname, cmdhelp in cmd_name_and_help: cmdname = _escape(cmdname) word += "\n" + "# ------ {} -------".format( "fieldsetups for " + cmdname) + "\n" word += ( BL_NEED2.format( ("-a " + cmdname), ("-f " + "-d " + wrap(clean_whitespace(cmdhelp))))) if nobasicfields is False: word += ( BL_USE3.format( cmdname, ("-a " + wrap("$FIELDS")), ("-f " + "-d " + wrap("fieldname")))) if extravalues: for f in extravalues: setvar = wrap("$" + f.upper() + "S") word += " ".join(BL_EXTRA3.format( (cmdname + " " + f + ":"), ('-f ' + '-A ' + '-a ' + setvar), ('-d ' + wrap(f))).split()) + "\n" return word def get_all_commands(beetcmds): # Formatting for Fish to complete command options word = "" for cmd in beetcmds: names = list(cmd.aliases) names.append(cmd.name) for name in names: name = _escape(name) word += "\n" word += ("\n" * 2) + "# ====== {} =====".format( "completions for " + name) + "\n" for option in cmd.parser._get_all_options()[1:]: cmd_l = (" -l " + option._long_opts[0].replace('--', '') )if option._long_opts else '' cmd_s = (" -s " + option._short_opts[0].replace('-', '') ) if option._short_opts else '' cmd_need_arg = ' -r ' if option.nargs in [1] else '' cmd_helpstr = (" -d " + wrap(' '.join(option.help.split())) ) if option.help else '' cmd_arglist = (' -a ' + wrap(" ".join(option.choices)) ) if option.choices else '' word += " ".join(BL_USE3.format( name, (cmd_need_arg + cmd_s + cmd_l + " -f " + cmd_arglist), cmd_helpstr).split()) + "\n" word = (word + " ".join(BL_USE3.format( name, ("-s " + "h " + "-l " + "help" + " -f "), ('-d ' + wrap("print help") + "\n") ).split())) return word def clean_whitespace(word): # Remove excess whitespace and tabs in a string return " ".join(word.split()) def wrap(word): # Need " or ' around strings but watch out if they're in the string sptoken = '\"' if ('"') in word and ("'") in word: word.replace('"', sptoken) return '"' + word + '"' tok = '"' if "'" in word else "'" return tok + word + tok ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/freedesktop.py0000644000076500000240000000253600000000000017212 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Matt Lichtenberg. # # 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. """Creates freedesktop.org-compliant .directory files on an album level. """ from beets.plugins import BeetsPlugin from beets import ui class FreedesktopPlugin(BeetsPlugin): def commands(self): deprecated = ui.Subcommand( "freedesktop", help="Print a message to redirect to thumbnails --dolphin") deprecated.func = self.deprecation_message return [deprecated] def deprecation_message(self, lib, opts, args): ui.print_("This plugin is deprecated. Its functionality is " "superseded by the 'thumbnails' plugin") ui.print_("'thumbnails --dolphin' replaces freedesktop. See doc & " "changelog for more information") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/fromfilename.py0000644000076500000240000001217000000000000017336 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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 = [ # Useful patterns. r'^(?P.+)[\-_](?P.+)[\-_](?P<tag>.*)$', r'^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$', r'^(?P<artist>.+)[\-_](?P<title>.+)$', r'^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)$', r'^(?P<title>.+)$', r'^(?P<track>\d+)[\s.\-_]+(?P<title>.+)$', r'^(?P<track>\d+)\s+(?P<title>.+)$', r'^(?P<title>.+) by (?P<artist>.+)$', r'^(?P<track>\d+).*$', ] # Titles considered "empty" and in need of replacement. BAD_TITLE_PATTERNS = [ r'^$', ] 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 and m.groupdict(): # Only yield a match when the regex applies *and* has # capture groups. Otherwise, no information can be extracted # from the filename. 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 = list(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 = str(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): def __init__(self): super().__init__() self.register_listener('import_task_start', filename_task) 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) ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/ftintitle.py������������������������������������������������������������������0000644�0000765�0000024�00000013632�00000000000�016700� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, 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. """ import re from beets import plugins from beets import ui from beets.util import displayable_path 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. """ # split on the first "feat". regex = re.compile(plugins.feat_tokens(), re.IGNORECASE) parts = [s.strip() for s in regex.split(artist, 1)] if len(parts) == 1: return parts[0], None else: return tuple(parts) def contains_feat(title): """Determine whether the title contains a "featured" marker. """ return bool(re.search(plugins.feat_tokens(), title, flags=re.IGNORECASE)) def find_feat_part(artist, albumartist): """Attempt to find featured artists in the item's artist fields and return the results. Returns None if no featured artist found. """ # Look for the album artist in the artist field. If it's not # present, give up. albumartist_split = artist.split(albumartist, 1) if len(albumartist_split) <= 1: return None # 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]) return feat_part # 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 lhs: return lhs return None class FtInTitlePlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'auto': True, 'drop': False, 'format': 'feat. {0}', }) self._command = ui.Subcommand( 'ftintitle', help='move featured artists to the title field') self._command.parser.add_option( '-d', '--drop', dest='drop', action='store_true', default=None, help='drop featuring from artists and ignore title update') if self.config['auto']: self.import_stages = [self.imported] def commands(self): def func(lib, opts, args): self.config.set_args(opts) drop_feat = self.config['drop'].get(bool) write = ui.should_write() for item in lib.items(ui.decargs(args)): self.ft_in_title(item, drop_feat) item.store() if write: item.try_write() self._command.func = func return [self._command] def imported(self, session, task): """Import hook for moving featuring artist automatically. """ drop_feat = self.config['drop'].get(bool) for item in task.imported_items(): self.ft_in_title(item, drop_feat) item.store() def update_metadata(self, item, feat_part, drop_feat): """Choose how to add new artists to the title and set the new metadata. Also, print out messages about any changes that are made. If `drop_feat` is set, then do not add the artist to the title; just remove it from the artist field. """ # In all cases, update the artist fields. self._log.info('artist: {0} -> {1}', item.artist, item.albumartist) item.artist = item.albumartist if item.artist_sort: # Just strip the featured artist from the sort name. item.artist_sort, _ = split_on_feat(item.artist_sort) # Only update the title if it does not already contain a featured # artist and if we do not drop featuring information. if not drop_feat and not contains_feat(item.title): feat_format = self.config['format'].as_str() new_format = feat_format.format(feat_part) new_title = f"{item.title} {new_format}" self._log.info('title: {0} -> {1}', item.title, new_title) item.title = new_title def ft_in_title(self, item, drop_feat): """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 and albumartist: self._log.info('{}', displayable_path(item.path)) feat_part = None # Attempt to find the featured artist. feat_part = find_feat_part(artist, albumartist) # If we have a featuring artist, move it to the title. if feat_part: self.update_metadata(item, feat_part, drop_feat) else: self._log.info('no featuring artists found') ������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/fuzzy.py����������������������������������������������������������������������0000644�0000765�0000024�00000002671�00000000000�016066� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Provides a fuzzy matching query. """ from beets.plugins import BeetsPlugin from beets.dbcore.query import StringFieldQuery from beets import config import difflib class FuzzyQuery(StringFieldQuery): @classmethod def string_match(cls, pattern, val): # smartcase if pattern.islower(): val = val.lower() query_matcher = difflib.SequenceMatcher(None, pattern, val) threshold = config['fuzzy']['threshold'].as_number() return query_matcher.quick_ratio() >= threshold class FuzzyPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'prefix': '~', 'threshold': 0.7, }) def queries(self): prefix = self.config['prefix'].as_str() return {prefix: FuzzyQuery} �����������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1637959898.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/gmusic.py���������������������������������������������������������������������0000644�0000765�0000024�00000002002�00000000000�016152� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Deprecation warning for the removed gmusic plugin.""" from beets.plugins import BeetsPlugin class Gmusic(BeetsPlugin): def __init__(self): super().__init__() self._log.warning("The 'gmusic' plugin has been removed following the" " shutdown of Google Play Music. Remove the plugin" " from your configuration to silence this warning.") ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/hook.py�����������������������������������������������������������������������0000644�0000765�0000024�00000007710�00000000000�015636� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2015, 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 custom commands to be run when an event is emitted by beets""" import string import subprocess import shlex from beets.plugins import BeetsPlugin from beets.util import arg_encoding class CodingFormatter(string.Formatter): """A variant of `string.Formatter` that converts everything to `unicode` strings. This is necessary on Python 2, where formatting otherwise occurs on bytestrings. It intercepts two points in the formatting process to decode the format string and all fields using the specified encoding. If decoding fails, the values are used as-is. """ def __init__(self, coding): """Creates a new coding formatter with the provided coding.""" self._coding = coding def format(self, format_string, *args, **kwargs): """Formats the provided string using the provided arguments and keyword arguments. This method decodes the format string using the formatter's coding. See str.format and string.Formatter.format. """ if isinstance(format_string, bytes): format_string = format_string.decode(self._coding) return super().format(format_string, *args, **kwargs) def convert_field(self, value, conversion): """Converts the provided value given a conversion type. This method decodes the converted value using the formatter's coding. See string.Formatter.convert_field. """ converted = super().convert_field(value, conversion) if isinstance(converted, bytes): return converted.decode(self._coding) return converted class HookPlugin(BeetsPlugin): """Allows custom commands to be run when an event is emitted by beets""" def __init__(self): super().__init__() self.config.add({ 'hooks': [] }) hooks = self.config['hooks'].get(list) for hook_index in range(len(hooks)): hook = self.config['hooks'][hook_index] hook_event = hook['event'].as_str() hook_command = hook['command'].as_str() self.create_and_register_hook(hook_event, hook_command) def create_and_register_hook(self, event, command): def hook_function(**kwargs): if command is None or len(command) == 0: self._log.error('invalid command "{0}"', command) return # Use a string formatter that works on Unicode strings. formatter = CodingFormatter(arg_encoding()) command_pieces = shlex.split(command) for i, piece in enumerate(command_pieces): command_pieces[i] = formatter.format(piece, event=event, **kwargs) self._log.debug('running command "{0}" for event {1}', ' '.join(command_pieces), event) try: subprocess.check_call(command_pieces) except subprocess.CalledProcessError as exc: self._log.error('hook for {0} exited with status {1}', event, exc.returncode) except OSError as exc: self._log.error('hook for {0} failed: {1}', event, exc) self.register_listener(event, hook_function) ��������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/ihate.py����������������������������������������������������������������������0000644�0000765�0000024�00000005475�00000000000�015776� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, 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).""" from beets.plugins import BeetsPlugin from beets.importer import action from beets.library import parse_query_string from beets.library import Item from beets.library import Album __author__ = 'baobab@heresiarch.info' __version__ = '2.0' def summary(task): """Given an ImportTask, produce a short string identifying the object. """ if task.is_album: return f'{task.cur_artist} - {task.cur_album}' else: return f'{task.item.artist} - {task.item.title}' class IHatePlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener('import_task_choice', self.import_task_choice_event) self.config.add({ 'warn': [], 'skip': [], }) @classmethod def do_i_hate_this(cls, task, action_patterns): """Process group of patterns (warn or skip) and returns True if task is hated and not whitelisted. """ if action_patterns: for query_string in action_patterns: query, _ = parse_query_string( query_string, Album if task.is_album else Item, ) if any(query.match(item) for item in task.imported_items()): return True return False def import_task_choice_event(self, session, task): skip_queries = self.config['skip'].as_str_seq() warn_queries = self.config['warn'].as_str_seq() if task.choice_flag == action.APPLY: if skip_queries or warn_queries: self._log.debug('processing your hate') if self.do_i_hate_this(task, skip_queries): task.choice_flag = action.SKIP self._log.info('skipped: {0}', summary(task)) return if self.do_i_hate_this(task, warn_queries): self._log.info('you may hate this: {0}', summary(task)) else: self._log.debug('nothing to do') else: self._log.debug('user made a decision, nothing to do') ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/importadded.py����������������������������������������������������������������0000644�0000765�0000024�00000012723�00000000000�017172� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Populate an item's `added` and `mtime` fields by using the file modification time (mtime) of the item's source file before import. Reimported albums and items are skipped. """ import os from beets import util from beets import importer from beets.plugins import BeetsPlugin class ImportAddedPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'preserve_mtimes': False, 'preserve_write_mtimes': False, }) # item.id for new items that were reimported self.reimported_item_ids = None # album.path for old albums that were replaced by a reimported album self.replaced_album_paths = None # item path in the library to the mtime of the source file self.item_mtime = {} register = self.register_listener register('import_task_created', self.check_config) register('import_task_created', self.record_if_inplace) register('import_task_files', self.record_reimported) register('before_item_moved', self.record_import_mtime) register('item_copied', self.record_import_mtime) register('item_linked', self.record_import_mtime) register('item_hardlinked', self.record_import_mtime) register('album_imported', self.update_album_times) register('item_imported', self.update_item_times) register('after_write', self.update_after_write_time) def check_config(self, task, session): self.config['preserve_mtimes'].get(bool) def reimported_item(self, item): return item.id in self.reimported_item_ids def reimported_album(self, album): return album.path in self.replaced_album_paths def record_if_inplace(self, task, session): if not (session.config['copy'] or session.config['move'] or session.config['link'] or session.config['hardlink']): self._log.debug("In place import detected, recording mtimes from " "source paths") items = [task.item] \ if isinstance(task, importer.SingletonImportTask) \ else task.items for item in items: self.record_import_mtime(item, item.path, item.path) def record_reimported(self, task, session): self.reimported_item_ids = {item.id for item, replaced_items in task.replaced_items.items() if replaced_items} self.replaced_album_paths = set(task.replaced_albums.keys()) def write_file_mtime(self, path, mtime): """Write the given mtime to the destination path. """ stat = os.stat(util.syspath(path)) os.utime(util.syspath(path), (stat.st_atime, mtime)) def write_item_mtime(self, item, mtime): """Write the given mtime to an item's `mtime` field and to the mtime of the item's file. """ # The file's mtime on disk must be in sync with the item's mtime self.write_file_mtime(util.syspath(item.path), mtime) item.mtime = mtime def record_import_mtime(self, item, source, destination): """Record the file mtime of an item's path before its import. """ mtime = os.stat(util.syspath(source)).st_mtime self.item_mtime[destination] = mtime self._log.debug("Recorded mtime {0} for item '{1}' imported from " "'{2}'", mtime, util.displayable_path(destination), util.displayable_path(source)) def update_album_times(self, lib, album): if self.reimported_album(album): self._log.debug("Album '{0}' is reimported, skipping import of " "added dates for the album and its items.", util.displayable_path(album.path)) return album_mtimes = [] for item in album.items(): mtime = self.item_mtime.pop(item.path, None) if mtime: album_mtimes.append(mtime) if self.config['preserve_mtimes'].get(bool): self.write_item_mtime(item, mtime) item.store() album.added = min(album_mtimes) self._log.debug("Import of album '{0}', selected album.added={1} " "from item file mtimes.", album.album, album.added) album.store() def update_item_times(self, lib, item): if self.reimported_item(item): self._log.debug("Item '{0}' is reimported, skipping import of " "added date.", util.displayable_path(item.path)) return mtime = self.item_mtime.pop(item.path, None) if mtime: item.added = mtime if self.config['preserve_mtimes'].get(bool): self.write_item_mtime(item, mtime) self._log.debug("Import of item '{0}', selected item.added={1}", util.displayable_path(item.path), item.added) item.store() def update_after_write_time(self, item, path): """Update the mtime of the item's file with the item.added value after each write of the item if `preserve_write_mtimes` is enabled. """ if item.added: if self.config['preserve_write_mtimes'].get(bool): self.write_item_mtime(item, item.added) self._log.debug("Write of item '{0}', selected item.added={1}", util.displayable_path(item.path), item.added) ���������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/importfeeds.py����������������������������������������������������������������0000644�0000765�0000024�00000010450�00000000000�017212� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, 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. Also allow printing the new file locations to stdout in case one wants to manually add music to a player by its path. """ import datetime import os import re from beets.plugins import BeetsPlugin from beets.util import mkdirall, normpath, syspath, bytestring_path, link from beets import config M3U_DEFAULT_NAME = 'imported.m3u' 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. """ mkdirall(m3u_path) with open(syspath(m3u_path), 'ab') as f: for path in items_paths: f.write(path + b'\n') class ImportFeedsPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'formats': [], 'm3u_name': 'imported.m3u', 'dir': None, 'relative_to': None, 'absolute_path': False, }) relative_to = self.config['relative_to'].get() if relative_to: self.config['relative_to'] = normpath(relative_to) else: self.config['relative_to'] = self.get_feeds_dir() self.register_listener('album_imported', self.album_imported) self.register_listener('item_imported', self.item_imported) def get_feeds_dir(self): feeds_dir = self.config['dir'].get() if feeds_dir: return os.path.expanduser(bytestring_path(feeds_dir)) return config['directory'].as_filename() def _record_items(self, lib, basename, items): """Records relative paths to the given items for each feed format """ feedsdir = bytestring_path(self.get_feeds_dir()) formats = self.config['formats'].as_str_seq() relative_to = self.config['relative_to'].get() \ or self.get_feeds_dir() relative_to = bytestring_path(relative_to) paths = [] for item in items: if self.config['absolute_path']: paths.append(item.path) else: try: relpath = os.path.relpath(item.path, relative_to) except ValueError: # On Windows, it is sometimes not possible to construct a # relative path (if the files are on different disks). relpath = item.path paths.append(relpath) if 'm3u' in formats: m3u_basename = bytestring_path( self.config['m3u_name'].as_str()) m3u_path = os.path.join(feedsdir, m3u_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)): link(path, dest) if 'echo' in formats: self._log.info("Location of imported music:") for path in paths: self._log.info(" {0}", path) def album_imported(self, lib, album): self._record_items(lib, album.album, album.items()) def item_imported(self, lib, item): self._record_items(lib, item.title, [item]) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1637959898.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/info.py�����������������������������������������������������������������������0000644�0000765�0000024�00000015653�00000000000�015636� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Shows file metadata. """ import os from beets.plugins import BeetsPlugin from beets import ui import mediafile from beets.library import Item from beets.util import displayable_path, normpath, syspath def tag_data(lib, args, album=False): query = [] for arg in args: path = normpath(arg) if os.path.isfile(syspath(path)): yield tag_data_emitter(path) else: query.append(arg) if query: for item in lib.items(query): yield tag_data_emitter(item.path) def tag_fields(): fields = set(mediafile.MediaFile.readable_fields()) fields.add('art') return fields def tag_data_emitter(path): def emitter(included_keys): if included_keys == '*': fields = tag_fields() else: fields = included_keys if 'images' in fields: # We can't serialize the image data. fields.remove('images') mf = mediafile.MediaFile(syspath(path)) tags = {} for field in fields: if field == 'art': tags[field] = mf.art is not None else: tags[field] = getattr(mf, field, None) # create a temporary Item to take advantage of __format__ item = Item.from_path(syspath(path)) return tags, item return emitter def library_data(lib, args, album=False): for item in lib.albums(args) if album else lib.items(args): yield library_data_emitter(item) def library_data_emitter(item): def emitter(included_keys): data = dict(item.formatted(included_keys=included_keys)) return data, item return emitter def update_summary(summary, tags): for key, value in tags.items(): if key not in summary: summary[key] = value elif summary[key] != value: summary[key] = '[various]' return summary def print_data(data, item=None, fmt=None): """Print, with optional formatting, the fields of a single element. If no format string `fmt` is passed, the entries on `data` are printed one in each line, with the format 'field: value'. If `fmt` is not `None`, the `item` is printed according to `fmt`, using the `Item.__format__` machinery. """ if fmt: # use fmt specified by the user ui.print_(format(item, fmt)) return path = displayable_path(item.path) if item else None formatted = {} for key, value in data.items(): if isinstance(value, list): formatted[key] = '; '.join(value) if value is not None: formatted[key] = value if len(formatted) == 0: return maxwidth = max(len(key) for key in formatted) lineformat = f'{{0:>{maxwidth}}}: {{1}}' if path: ui.print_(displayable_path(path)) for field in sorted(formatted): value = formatted[field] if isinstance(value, list): value = '; '.join(value) ui.print_(lineformat.format(field, value)) def print_data_keys(data, item=None): """Print only the keys (field names) for an item. """ path = displayable_path(item.path) if item else None formatted = [] for key, value in data.items(): formatted.append(key) if len(formatted) == 0: return line_format = '{0}{{0}}'.format(' ' * 4) if path: ui.print_(displayable_path(path)) for field in sorted(formatted): ui.print_(line_format.format(field)) class InfoPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('info', help='show file metadata') cmd.func = self.run cmd.parser.add_option( '-l', '--library', action='store_true', help='show library fields instead of tags', ) cmd.parser.add_option( '-a', '--album', action='store_true', help='show album fields instead of tracks (implies "--library")', ) cmd.parser.add_option( '-s', '--summarize', action='store_true', help='summarize the tags of all files', ) cmd.parser.add_option( '-i', '--include-keys', default=[], action='append', dest='included_keys', help='comma separated list of keys to show', ) cmd.parser.add_option( '-k', '--keys-only', action='store_true', help='show only the keys', ) cmd.parser.add_format_option(target='item') return [cmd] def run(self, lib, opts, args): """Print tag info or library data for each file referenced by args. Main entry point for the `beet info ARGS...` command. If an argument is a path pointing to an existing file, then the tags of that file are printed. All other arguments are considered queries, and for each item matching all those queries the tags from the file are printed. If `opts.summarize` is true, the function merges all tags into one dictionary and only prints that. If two files have different values for the same tag, the value is set to '[various]' """ if opts.library or opts.album: data_collector = library_data else: data_collector = tag_data included_keys = [] for keys in opts.included_keys: included_keys.extend(keys.split(',')) # Drop path even if user provides it multiple times included_keys = [k for k in included_keys if k != 'path'] first = True summary = {} for data_emitter in data_collector( lib, ui.decargs(args), album=opts.album, ): try: data, item = data_emitter(included_keys or '*') except (mediafile.UnreadableFileError, OSError) as ex: self._log.error('cannot read file: {0}', ex) continue if opts.summarize: update_summary(summary, data) else: if not first: ui.print_() if opts.keys_only: print_data_keys(data, item) else: fmt = ui.decargs([opts.format])[0] if opts.format else None print_data(data, item, fmt) first = False if opts.summarize: print_data(summary) �������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/inline.py���������������������������������������������������������������������0000644�0000765�0000024�00000010407�00000000000�016151� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Allows inline path template customization code in the config file. """ import traceback import itertools from beets.plugins import BeetsPlugin from beets import config FUNC_NAME = '__INLINE_FUNC__' class InlineError(Exception): """Raised when a runtime error occurs in an inline expression. """ def __init__(self, code, exc): super().__init__( ("error in inline path field code:\n" "%s\n%s: %s") % (code, type(exc).__name__, str(exc)) ) def _compile_func(body): """Given Python code for a function body, return a compiled callable that invokes that code. """ body = 'def {}():\n {}'.format( FUNC_NAME, body.replace('\n', '\n ') ) code = compile(body, 'inline', 'exec') env = {} eval(code, env) return env[FUNC_NAME] class InlinePlugin(BeetsPlugin): def __init__(self): super().__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()): self._log.debug('adding item field {0}', key) func = self.compile_inline(view.as_str(), False) if func is not None: self.template_fields[key] = func # Album fields. for key, view in config['album_fields'].items(): self._log.debug('adding album field {0}', key) func = self.compile_inline(view.as_str(), True) if func is not None: self.album_template_fields[key] = func def compile_inline(self, 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(f'({python_code})', 'inline', 'eval') except SyntaxError: # Fall back to a function body. try: func = _compile_func(python_code) except SyntaxError: self._log.error('syntax error in inline field definition:\n' '{0}', 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): old_globals = dict(func.__globals__) func.__globals__.update(_dict_for(obj)) try: return func() except Exception as exc: raise InlineError(python_code, exc) finally: func.__globals__.clear() func.__globals__.update(old_globals) return _func_func ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/ipfs.py�����������������������������������������������������������������������0000644�0000765�0000024�00000024222�00000000000�015634� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds support for ipfs. Requires go-ipfs and a running ipfs daemon """ from beets import ui, util, library, config from beets.plugins import BeetsPlugin import subprocess import shutil import os import tempfile class IPFSPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'auto': True, 'nocopy': False, }) if self.config['auto']: self.import_stages = [self.auto_add] def commands(self): cmd = ui.Subcommand('ipfs', help='interact with ipfs') cmd.parser.add_option('-a', '--add', dest='add', action='store_true', help='Add to ipfs') cmd.parser.add_option('-g', '--get', dest='get', action='store_true', help='Get from ipfs') cmd.parser.add_option('-p', '--publish', dest='publish', action='store_true', help='Publish local library to ipfs') cmd.parser.add_option('-i', '--import', dest='_import', action='store_true', help='Import remote library from ipfs') cmd.parser.add_option('-l', '--list', dest='_list', action='store_true', help='Query imported libraries') cmd.parser.add_option('-m', '--play', dest='play', action='store_true', help='Play music from remote libraries') def func(lib, opts, args): if opts.add: for album in lib.albums(ui.decargs(args)): if len(album.items()) == 0: self._log.info('{0} does not contain items, aborting', album) self.ipfs_add(album) album.store() if opts.get: self.ipfs_get(lib, ui.decargs(args)) if opts.publish: self.ipfs_publish(lib) if opts._import: self.ipfs_import(lib, ui.decargs(args)) if opts._list: self.ipfs_list(lib, ui.decargs(args)) if opts.play: self.ipfs_play(lib, opts, ui.decargs(args)) cmd.func = func return [cmd] def auto_add(self, session, task): if task.is_album: if self.ipfs_add(task.album): task.album.store() def ipfs_play(self, lib, opts, args): from beetsplug.play import PlayPlugin jlib = self.get_remote_lib(lib) player = PlayPlugin() config['play']['relative_to'] = None player.album = True player.play_music(jlib, player, args) def ipfs_add(self, album): try: album_dir = album.item_dir() except AttributeError: return False try: if album.ipfs: self._log.debug('{0} already added', album_dir) # Already added to ipfs return False except AttributeError: pass self._log.info('Adding {0} to ipfs', album_dir) if self.config['nocopy']: cmd = "ipfs add --nocopy -q -r".split() else: cmd = "ipfs add -q -r".split() cmd.append(album_dir) try: output = util.command_output(cmd).stdout.split() except (OSError, subprocess.CalledProcessError) as exc: self._log.error('Failed to add {0}, error: {1}', album_dir, exc) return False length = len(output) for linenr, line in enumerate(output): line = line.strip() if linenr == length - 1: # last printed line is the album hash self._log.info("album: {0}", line) album.ipfs = line else: try: item = album.items()[linenr] self._log.info("item: {0}", line) item.ipfs = line item.store() except IndexError: # if there's non music files in the to-add folder they'll # get ignored here pass return True def ipfs_get(self, lib, query): query = query[0] # Check if query is a hash # TODO: generalize to other hashes; probably use a multihash # implementation if query.startswith("Qm") and len(query) == 46: self.ipfs_get_from_hash(lib, query) else: albums = self.query(lib, query) for album in albums: self.ipfs_get_from_hash(lib, album.ipfs) def ipfs_get_from_hash(self, lib, _hash): try: cmd = "ipfs get".split() cmd.append(_hash) util.command_output(cmd) except (OSError, subprocess.CalledProcessError) as err: self._log.error('Failed to get {0} from ipfs.\n{1}', _hash, err.output) return False self._log.info('Getting {0} from ipfs', _hash) imp = ui.commands.TerminalImportSession(lib, loghandler=None, query=None, paths=[_hash]) imp.run() shutil.rmtree(_hash) def ipfs_publish(self, lib): with tempfile.NamedTemporaryFile() as tmp: self.ipfs_added_albums(lib, tmp.name) try: if self.config['nocopy']: cmd = "ipfs add --nocopy -q ".split() else: cmd = "ipfs add -q ".split() cmd.append(tmp.name) output = util.command_output(cmd).stdout except (OSError, subprocess.CalledProcessError) as err: msg = f"Failed to publish library. Error: {err}" self._log.error(msg) return False self._log.info("hash of library: {0}", output) def ipfs_import(self, lib, args): _hash = args[0] if len(args) > 1: lib_name = args[1] else: lib_name = _hash lib_root = os.path.dirname(lib.path) remote_libs = os.path.join(lib_root, b"remotes") if not os.path.exists(remote_libs): try: os.makedirs(remote_libs) except OSError as e: msg = f"Could not create {remote_libs}. Error: {e}" self._log.error(msg) return False path = os.path.join(remote_libs, lib_name.encode() + b".db") if not os.path.exists(path): cmd = f"ipfs get {_hash} -o".split() cmd.append(path) try: util.command_output(cmd) except (OSError, subprocess.CalledProcessError): self._log.error(f"Could not import {_hash}") return False # add all albums from remotes into a combined library jpath = os.path.join(remote_libs, b"joined.db") jlib = library.Library(jpath) nlib = library.Library(path) for album in nlib.albums(): if not self.already_added(album, jlib): new_album = [] for item in album.items(): item.id = None new_album.append(item) added_album = jlib.add_album(new_album) added_album.ipfs = album.ipfs added_album.store() def already_added(self, check, jlib): for jalbum in jlib.albums(): if jalbum.mb_albumid == check.mb_albumid: return True return False def ipfs_list(self, lib, args): fmt = config['format_album'].get() try: albums = self.query(lib, args) except OSError: ui.print_("No imported libraries yet.") return for album in albums: ui.print_(format(album, fmt), " : ", album.ipfs.decode()) def query(self, lib, args): rlib = self.get_remote_lib(lib) albums = rlib.albums(args) return albums def get_remote_lib(self, lib): lib_root = os.path.dirname(lib.path) remote_libs = os.path.join(lib_root, b"remotes") path = os.path.join(remote_libs, b"joined.db") if not os.path.isfile(path): raise OSError return library.Library(path) def ipfs_added_albums(self, rlib, tmpname): """ Returns a new library with only albums/items added to ipfs """ tmplib = library.Library(tmpname) for album in rlib.albums(): try: if album.ipfs: self.create_new_album(album, tmplib) except AttributeError: pass return tmplib def create_new_album(self, album, tmplib): items = [] for item in album.items(): try: if not item.ipfs: break except AttributeError: pass item_path = os.path.basename(item.path).decode( util._fsencoding(), 'ignore' ) # Clear current path from item item.path = f'/ipfs/{album.ipfs}/{item_path}' item.id = None items.append(item) if len(items) < 1: return False self._log.info("Adding '{0}' to temporary library", album) new_album = tmplib.add_album(items) new_album.ipfs = album.ipfs new_album.store() ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/keyfinder.py������������������������������������������������������������������0000644�0000765�0000024�00000006575�00000000000�016666� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Uses the `KeyFinder` program to add the `initial_key` field. """ import os.path import subprocess from beets import ui from beets import util from beets.plugins import BeetsPlugin class KeyFinderPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'bin': 'KeyFinder', 'auto': True, 'overwrite': False, }) if self.config['auto'].get(bool): self.import_stages = [self.imported] def commands(self): cmd = ui.Subcommand('keyfinder', help='detect and add initial key from audio') cmd.func = self.command return [cmd] def command(self, lib, opts, args): self.find_key(lib.items(ui.decargs(args)), write=ui.should_write()) def imported(self, session, task): self.find_key(task.imported_items()) def find_key(self, items, write=False): overwrite = self.config['overwrite'].get(bool) command = [self.config['bin'].as_str()] # The KeyFinder GUI program needs the -f flag before the path. # keyfinder-cli is similar, but just wants the path with no flag. if 'keyfinder-cli' not in os.path.basename(command[0]).lower(): command.append('-f') for item in items: if item['initial_key'] and not overwrite: continue try: output = util.command_output(command + [util.syspath( item.path)]).stdout except (subprocess.CalledProcessError, OSError) as exc: self._log.error('execution failed: {0}', exc) continue except UnicodeEncodeError: # Workaround for Python 2 Windows bug. # https://bugs.python.org/issue1759845 self._log.error('execution failed for Unicode path: {0!r}', item.path) continue try: key_raw = output.rsplit(None, 1)[-1] except IndexError: # Sometimes keyfinder-cli returns 0 but with no key, usually # when the file is silent or corrupt, so we log and skip. self._log.error('no key returned for path: {0}', item.path) continue try: key = util.text_string(key_raw) except UnicodeDecodeError: self._log.error('output is invalid UTF-8') continue item['initial_key'] = key self._log.info('added computed initial key {0} for {1}', key, util.displayable_path(item.path)) if write: item.try_write() item.store() �����������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/kodiupdate.py�����������������������������������������������������������������0000644�0000765�0000024�00000005704�00000000000�017030� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2017, Pauli Kettunen. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Updates a Kodi library whenever the beets library is changed. This is based on the Plex Update plugin. Put something like the following in your config.yaml to configure: kodi: host: localhost port: 8080 user: user pwd: secret """ import requests from beets import config from beets.plugins import BeetsPlugin def update_kodi(host, port, user, password): """Sends request to the Kodi api to start a library refresh. """ url = f"http://{host}:{port}/jsonrpc" """Content-Type: application/json is mandatory according to the kodi jsonrpc documentation""" headers = {'Content-Type': 'application/json'} # Create the payload. Id seems to be mandatory. payload = {'jsonrpc': '2.0', 'method': 'AudioLibrary.Scan', 'id': 1} r = requests.post( url, auth=(user, password), json=payload, headers=headers) return r class KodiUpdate(BeetsPlugin): def __init__(self): super().__init__() # Adding defaults. config['kodi'].add({ 'host': 'localhost', 'port': 8080, 'user': 'kodi', 'pwd': 'kodi'}) config['kodi']['pwd'].redact = True self.register_listener('database_change', self.listen_for_db_change) def listen_for_db_change(self, lib, model): """Listens for beets db change and register the update""" self.register_listener('cli_exit', self.update) def update(self, lib): """When the client exists try to send refresh request to Kodi server. """ self._log.info('Requesting a Kodi library update...') # Try to send update request. try: r = update_kodi( config['kodi']['host'].get(), config['kodi']['port'].get(), config['kodi']['user'].get(), config['kodi']['pwd'].get()) r.raise_for_status() except requests.exceptions.RequestException as e: self._log.warning('Kodi update failed: {0}', str(e)) return json = r.json() if json.get('result') != 'OK': self._log.warning('Kodi update failed: JSON response was {0!r}', json) return self._log.info('Kodi update triggered') ������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000034�00000000000�010212� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������28 mtime=1638031078.3173363 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/lastgenre/��������������������������������������������������������������������0000755�0000765�0000024�00000000000�00000000000�016303� 5����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/lastgenre/__init__.py���������������������������������������������������������0000644�0000765�0000024�00000040721�00000000000�020420� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Gets genres for imported music based on Last.fm tags. Uses a provided whitelist file to determine which tags are valid genres. The included (default) genre list was originally produced by scraping Wikipedia and has been edited to remove some questionable entries. The scraper script used is available here: https://gist.github.com/1241307 """ import pylast import codecs import os import yaml import traceback from beets import plugins from beets import ui from beets import config from beets.util import normpath, plurality from beets import library LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) PYLAST_EXCEPTIONS = ( pylast.WSError, pylast.MalformedResponseError, pylast.NetworkError, ) REPLACE = { '\u2010': '-', } def deduplicate(seq): """Remove duplicates from sequence wile preserving order. """ seen = set() return [x for x in seq if x not in seen and not seen.add(x)] # 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 + [str(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] # Main plugin logic. WHITELIST = os.path.join(os.path.dirname(__file__), 'genres.txt') C14N_TREE = os.path.join(os.path.dirname(__file__), 'genres-tree.yaml') class LastGenrePlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'whitelist': True, 'min_weight': 10, 'count': 1, 'fallback': None, 'canonical': False, 'source': 'album', 'force': True, 'auto': True, 'separator': ', ', 'prefer_specific': False, 'title_case': True, }) self.setup() def setup(self): """Setup plugin from config options """ if self.config['auto']: self.import_stages = [self.imported] self._genre_cache = {} # Read the whitelist file if enabled. self.whitelist = set() wl_filename = self.config['whitelist'].get() if wl_filename in (True, ''): # Indicates the default whitelist. wl_filename = WHITELIST if wl_filename: wl_filename = normpath(wl_filename) with open(wl_filename, 'rb') as f: for line in f: line = line.decode('utf-8').strip().lower() if line and not line.startswith('#'): self.whitelist.add(line) # Read the genres tree for canonicalization if enabled. self.c14n_branches = [] c14n_filename = self.config['canonical'].get() self.canonicalize = c14n_filename is not False # Default tree if c14n_filename in (True, ''): c14n_filename = C14N_TREE elif not self.canonicalize and self.config['prefer_specific'].get(): # prefer_specific requires a tree, load default tree c14n_filename = C14N_TREE # Read the tree if c14n_filename: self._log.debug('Loading canonicalization tree {0}', c14n_filename) c14n_filename = normpath(c14n_filename) with codecs.open(c14n_filename, 'r', encoding='utf-8') as f: genres_tree = yaml.safe_load(f) flatten_tree(genres_tree, [], self.c14n_branches) @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_depth(self, tag): """Find the depth of a tag in the genres tree. """ depth = None for key, value in enumerate(self.c14n_branches): if tag in value: depth = value.index(tag) break return depth def _sort_by_depth(self, tags): """Given a list of tags, sort the tags by their depths in the genre tree. """ depth_tag_pairs = [(self._get_depth(t), t) for t in tags] depth_tag_pairs = [e for e in depth_tag_pairs if e[0] is not None] depth_tag_pairs.sort(reverse=True) return [p[1] for p in depth_tag_pairs] def _resolve_genres(self, tags): """Given a list of strings, return a genre by joining them into a single string and (optionally) canonicalizing each. """ if not tags: return None count = self.config['count'].get(int) if self.canonicalize: # Extend the list to consider tags parents in the c14n tree tags_all = [] for tag in tags: # Add parents that are in the whitelist, or add the oldest # ancestor if no whitelist if self.whitelist: parents = [x for x in find_parents(tag, self.c14n_branches) if self._is_allowed(x)] else: parents = [find_parents(tag, self.c14n_branches)[-1]] tags_all += parents # Stop if we have enough tags already, unless we need to find # the most specific tag (instead of the most popular). if (not self.config['prefer_specific'] and len(tags_all) >= count): break tags = tags_all tags = deduplicate(tags) # Sort the tags by specificity. if self.config['prefer_specific']: tags = self._sort_by_depth(tags) # c14n only adds allowed genres but we may have had forbidden genres in # the original tags list tags = [self._format_tag(x) for x in tags if self._is_allowed(x)] return self.config['separator'].as_str().join( tags[:self.config['count'].get(int)] ) def _format_tag(self, tag): if self.config["title_case"]: return tag.title() return tag def fetch_genre(self, lastfm_obj): """Return the genre for a pylast entity or None if no suitable genre can be found. Ex. 'Electronic, House, Dance' """ min_weight = self.config['min_weight'].get(int) return self._resolve_genres(self._tags_for(lastfm_obj, min_weight)) def _is_allowed(self, genre): """Determine whether the genre is present in the whitelist, returning a boolean. """ if genre is None: return False if not self.whitelist or genre in self.whitelist: return True return False # Cached entity lookups. def _last_lookup(self, 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. Before the lookup, each argument is has some Unicode characters replaced with rough ASCII equivalents in order to return better results from the Last.fm database. """ # Shortcut if we're missing metadata. if any(not s for s in args): return None key = '{}.{}'.format(entity, '-'.join(str(a) for a in args)) if key in self._genre_cache: return self._genre_cache[key] else: args_replaced = [] for arg in args: for k, v in REPLACE.items(): arg = arg.replace(k, v) args_replaced.append(arg) genre = self.fetch_genre(method(*args_replaced)) self._genre_cache[key] = genre return genre def fetch_album_genre(self, obj): """Return the album genre for this Item or Album. """ return self._last_lookup( 'album', LASTFM.get_album, obj.albumartist, obj.album ) def fetch_album_artist_genre(self, obj): """Return the album artist genre for this Item or Album. """ return self._last_lookup( 'artist', LASTFM.get_artist, obj.albumartist ) def fetch_artist_genre(self, item): """Returns the track artist genre for this Item. """ return self._last_lookup( 'artist', LASTFM.get_artist, item.artist ) def fetch_track_genre(self, obj): """Returns the track genre for this Item. """ return self._last_lookup( 'track', LASTFM.get_track, obj.artist, obj.title ) 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 self._is_allowed(obj.genre): return obj.genre, 'keep' # Track genre (for Items only). if isinstance(obj, library.Item): if 'track' in self.sources: result = self.fetch_track_genre(obj) if result: return result, 'track' # Album genre. if 'album' in self.sources: result = self.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 = self.fetch_artist_genre(obj) elif obj.albumartist != config['va_name'].as_str(): result = self.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 = self.fetch_track_genre(item) if not item_genre: item_genre = self.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 = self._resolve_genres([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', 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' ) lastgenre_cmd.parser.add_option( '-A', '--items', action='store_false', dest='album', help='match items instead of albums') lastgenre_cmd.parser.add_option( '-a', '--albums', action='store_true', dest='album', help='match albums instead of items') lastgenre_cmd.parser.set_defaults(album=True) def lastgenre_func(lib, opts, args): write = ui.should_write() self.config.set_args(opts) if opts.album: # Fetch genres for whole albums for album in lib.albums(ui.decargs(args)): album.genre, src = self._get_genre(album) self._log.info('genre for album {0} ({1}): {0.genre}', album, src) 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() self._log.info( 'genre for track {0} ({1}): {0.genre}', item, src) if write: item.try_write() else: # Just query singletons, i.e. items that are not part of # an album for item in lib.items(ui.decargs(args)): item.genre, src = self._get_genre(item) self._log.debug('added last.fm item genre ({0}): {1}', src, item.genre) item.store() 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 = task.album album.genre, src = self._get_genre(album) self._log.debug('added last.fm album genre ({0}): {1}', src, album.genre) album.store() if 'track' in self.sources: for item in album.items(): item.genre, src = self._get_genre(item) self._log.debug('added last.fm item genre ({0}): {1}', src, item.genre) item.store() else: item = task.item item.genre, src = self._get_genre(item) self._log.debug('added last.fm item genre ({0}): {1}', src, item.genre) item.store() def _tags_for(self, obj, min_weight=None): """Core genre identification routine. Given a pylast entity (album or track), return a list of tag names for that entity. Return an empty list if the entity is not found or another error occurs. If `min_weight` is specified, tags are filtered by weight. """ # Work around an inconsistency in pylast where # Album.get_top_tags() does not return TopItem instances. # https://github.com/pylast/pylast/issues/86 if isinstance(obj, pylast.Album): obj = super(pylast.Album, obj) try: res = obj.get_top_tags() except PYLAST_EXCEPTIONS as exc: self._log.debug('last.fm error: {0}', exc) return [] except Exception as exc: # Isolate bugs in pylast. self._log.debug('{}', traceback.format_exc()) self._log.error('error in pylast library: {0}', exc) return [] # Filter by weight (optionally). if min_weight: res = [el for el in res if (int(el.weight or 0)) >= min_weight] # Get strings from tags. res = [el.item.get_name().lower() for el in res] return res �����������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1594724155.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/lastgenre/genres-tree.yaml����������������������������������������������������0000644�0000765�0000024�00000036510�00000000000�021414� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������- african: - african heavy metal - african hip hop - afrobeat - apala - benga - bikutsi - bongo flava - cape jazz - chimurenga - coupé-décalé - fuji music - genge - highlife - hiplife - isicathamiya - jit - jùjú - kapuka - kizomba - kuduro - kwaito - kwela - makossa - maloya - marrabenta - mbalax - mbaqanga - mbube - morna - museve - palm-wine - raï - sakara - sega - seggae - semba - soukous - taarab - zouglou - asian: - east asian: - anison - c-pop - cantopop - enka - hong kong english pop - j-pop - k-pop - kayÅkyoku - korean pop - mandopop - onkyokei - taiwanese pop - fann at-tanbura - fijiri - khaliji - liwa - sawt - south and southeast asian: - baila - bhangra - bhojpuri - dangdut - filmi - indian pop - lavani - luk thung: - luk krung - manila sound - morlam - pinoy pop - pop sunda - ragini - thai pop - avant-garde: - experimental music - lo-fi - musique concrète - blues: - african blues - blues rock - blues shouter - british blues - canadian blues - chicago blues - classic female blues - contemporary r&b - country blues - delta blues - detroit blues - electric blues - gospel blues - hill country blues - hokum blues - jazz blues - jump blues - kansas city blues - louisiana blues - memphis blues - piano blues - piedmont blues - punk blues - soul blues - st. louis blues - swamp blues - texas blues - west coast blues - caribbean and latin american: - bachata - baithak gana - bolero - brazilian: - axé - bossa nova - brazilian rock - brega - choro - forró - frevo - funk carioca - lambada - maracatu - música popular brasileira - música sertaneja - pagode - samba - samba rock - tecnobrega - tropicalia - zouk-lambada - calypso - chutney - chutney soca - compas - mambo - merengue - méringue - other latin: - chicha - criolla - cumbia - huayno - mariachi - ranchera - tejano - punta - punta rock - rasin - reggaeton - salsa - soca - son - timba - twoubadou - zouk - classical: - ballet - baroque: - baroque music - cantata - chamber music: - string quartet - classical music - concerto: - concerto grosso - contemporary classical - modern classical - opera - oratorio - orchestra: - orchestral - symphonic - symphony - organum - mass: - requiem - sacred music: - cantique - gregorian chant - sonata - comedy: - comedy music - comedy rock - humor - parody music - stand-up - country: - alternative country: - cowpunk - americana - australian country music - bakersfield sound - bluegrass: - progressive bluegrass - reactionary bluegrass - blues country - cajun: - cajun fiddle tunes - christian country music - classic country - close harmony - country pop - country rap - country rock - country soul - cowboy/western music - dansband music - franco-country - gulf and western - hellbilly music - hokum - honky tonk - instrumental country - lubbock sound - nashville sound - neotraditional country - outlaw country - progressive country - psychobilly/punkabilly - red dirt - rockabilly - sertanejo - texas country - traditional country music - truck-driving country - western swing - zydeco - easy listening: - background music - beautiful music - elevator music - furniture music - lounge music - middle of the road - new-age music - electronic: - ambient: - ambient dub - ambient house - ambient techno - dark ambient - drone music - illbient - isolationism - lowercase - asian underground - breakbeat: - 4-beat - acid breaks - baltimore club - big beat - breakbeat hardcore - broken beat - florida breaks - nu skool breaks - chiptune: - bitpop - game boy music - nintendocore - video game music - yorkshire bleeps and bass - disco: - cosmic disco - disco polo - euro disco - italo disco - nu-disco - space disco - downtempo: - acid jazz - balearic beat - chill out - dub music - dubtronica - ethnic electronica - moombahton - nu jazz - trip hop - drum and bass: - darkcore - darkstep - drumfunk - drumstep - hardstep - intelligent drum and bass - jump-up - liquid funk - neurofunk - oldschool jungle: - darkside jungle - ragga jungle - raggacore - sambass - techstep - electro: - crunk - electro backbeat - electro-grime - electropop - electroacoustic: - acousmatic music - computer music - electroacoustic improvisation - field recording - live coding - live electronics - soundscape composition - tape music - electronic rock: - alternative dance: - baggy - madchester - dance-punk - dance-rock - dark wave - electroclash - electronicore - electropunk - ethereal wave - indietronica - new rave - space rock - synthpop - synthpunk - electronica: - berlin school - chillwave - electronic art music - electronic dance music - folktronica - freestyle music - glitch - idm - laptronica - skweee - sound art - synthcore - eurodance: - bubblegum dance - italo dance - turbofolk - hardcore: - bouncy house - bouncy techno - breakcore - digital hardcore - doomcore - dubstyle - gabber - happy hardcore - hardstyle - jumpstyle - makina - speedcore - terrorcore - uk hardcore - hi-nrg: - eurobeat - hard nrg - new beat - house: - acid house - chicago house - deep house - diva house - dutch house - electro house - freestyle house - french house - funky house - ghetto house - hardbag - hip house - italo house - latin house - minimal house - progressive house - rave music - swing house - tech house - tribal house - uk hard house - us garage - vocal house - industrial: - aggrotech - coldwave - cybergrind - dark electro - death industrial - electro-industrial - electronic body music: - futurepop - industrial metal: - neue deutsche härte - industrial rock - noise: - japanoise - power electronics - power noise - witch house - post-disco: - boogie - dance-pop - progressive: - progressive house/trance: - disco house - dream house - space house - progressive breaks - progressive drum & bass - progressive techno - techno: - acid techno - detroit techno - free tekno - ghettotech - minimal - nortec - schranz - techno-dnb - technopop - tecno brega - toytown techno - trance: - acid trance - classic trance - dream trance - goa trance: - dark psytrance - full on - psybreaks - psyprog - suomisaundi - hard trance - tech trance - uplifting trance: - orchestral uplifting - vocal trance - uk garage: - 2-step - 4x4 - bassline - breakstep - dubstep - funky - grime - speed garage - trap - folk: - american folk revival - anti-folk - british folk revival - celtic music - contemporary folk - filk music - freak folk - indie folk - industrial folk - neofolk - progressive folk - psychedelic folk - sung poetry - techno-folk - hip hop: - alternative hip hop - avant-garde hip hop - chap hop - christian hip hop - conscious hip hop - country-rap - crunkcore - cumbia rap - east coast hip hop: - brick city club - hardcore hip hop - mafioso rap - new jersey hip hop - electro music - freestyle rap - g-funk - gangsta rap - golden age hip hop - hip hop soul - hip pop - hyphy - industrial hip hop - instrumental hip hop - jazz rap - low bap - lyrical hip hop - merenrap - midwest hip hop: - chicago hip hop - detroit hip hop - horrorcore - st. louis hip hop - twin cities hip hop - motswako - nerdcore - new jack swing - new school hip hop - old school hip hop - political hip hop - rap opera - rap rock: - rap metal - rapcore - songo-salsa - southern hip hop: - atlanta hip hop: - snap music - bounce music - houston hip hop: - chopped and screwed - miami bass - turntablism - underground hip hop - urban pasifika - west coast hip hop: - chicano rap - jerkin' - jazz: - asian american jazz - avant-garde jazz - bebop - boogie-woogie - british dance band - chamber jazz - continental jazz - cool jazz - crossover jazz - cubop - dixieland - ethno jazz - european free jazz - free funk - free improvisation - free jazz - gypsy jazz - hard bop - jazz fusion - jazz rock - jazz-funk - kansas city jazz - latin jazz - livetronica - m-base - mainstream jazz - modal jazz - neo-bop jazz - neo-swing - novelty ragtime - orchestral jazz - post-bop - punk jazz - ragtime - shibuya-kei - ska jazz - smooth jazz - soul jazz - straight-ahead jazz - stride jazz - swing - third stream - trad jazz - vocal jazz - west coast gypsy jazz - west coast jazz - other: - worldbeat - pop: - adult contemporary - arab pop - baroque pop - bubblegum pop - chanson - christian pop - classical crossover - europop: - austropop - balkan pop - french pop - latin pop - laïkó - nederpop - russian pop - iranian pop - jangle pop - latin ballad - levenslied - louisiana swamp pop - mexican pop - motorpop - new romanticism - pop rap - popera - psychedelic pop - schlager - soft rock - sophisti-pop - space age pop - sunshine pop - surf pop - teen pop - traditional pop music - turkish pop - vispop - wonky pop - rhythm and blues: - funk: - deep funk - go-go - p-funk - soul: - blue-eyed soul - neo soul - northern soul - rock: - alternative rock: - britpop: - post-britpop - dream pop - grunge: - post-grunge - indie pop: - dunedin sound - twee pop - indie rock - noise pop - nu metal - post-punk revival - post-rock: - post-metal - sadcore - shoegaze - slowcore - art rock - beat music - chinese rock - christian rock - dark cabaret - desert rock - experimental rock - folk rock - garage rock - glam rock - hard rock - heavy metal: - alternative metal: - funk metal - black metal: - viking metal - christian metal - death metal: - death/doom - goregrind - melodic death metal - technical death metal - doom metal: - epic doom metal - funeral doom - drone metal - epic metal - folk metal: - celtic metal - medieval metal - pagan metal - funk metal - glam metal - gothic metal - industrial metal: - industrial death metal - metalcore: - deathcore - mathcore: - djent - synthcore - neoclassical metal - post-metal - power metal: - progressive power metal - progressive metal - sludge metal - speed metal - stoner rock: - stoner metal - symphonic metal - thrash metal: - crossover thrash - groove metal - progressive thrash metal - teutonic thrash metal - traditional heavy metal - math rock - new wave: - world fusion - paisley underground - pop rock - post-punk: - gothic rock - no wave - noise rock - power pop - progressive rock: - canterbury scene - krautrock - new prog - rock in opposition - psychedelic rock: - acid rock - freakbeat - neo-psychedelia - raga rock - punk rock: - anarcho punk: - crust punk: - d-beat - art punk - christian punk - deathrock - folk punk: - celtic punk - gypsy punk - garage punk - grindcore: - crustgrind - noisegrind - hardcore punk: - post-hardcore: - emo: - screamo - powerviolence - street punk - thrashcore - horror punk - oi! - pop punk - psychobilly - riot grrrl - ska punk: - ska-core - skate punk - rock and roll - southern rock - sufi rock - surf rock - visual kei: - nagoya kei - reggae: - roots reggae - reggae fusion - reggae en español: - spanish reggae - reggae 110 - reggae bultrón - romantic flow - lovers rock - raggamuffin: - ragga - dancehall - ska: - 2 tone - dub - rocksteady ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1594724155.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/lastgenre/genres.txt����������������������������������������������������������0000644�0000765�0000024�00000041642�00000000000�020336� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������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 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 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 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 classical music classical music era clicks n cuts close harmony club music cocobale coimbra fado coladeira colombianas combined rhythm comedy comedy rap comedy rock comic opera comparsa compas direct compas meringue concert overture concerto concerto grosso congo conjunto contemporary christian contemporary christian music contemporary classical 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 epic doom metal epic metal eremwu eu 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 humor 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 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 metal 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 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 orchestral 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 power metal progressive rock progressive trance progressive thrash metal 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 110 reggae bultrón reggae en español 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 flow romantic period in music rondeaux ronggeng roots reggae roots rock roots rock reggae rumba russian pop rímur sabar sacred harp sacred music 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 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 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 stand-up 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 symphonic black metal symphonic metal symphonic poem symphonic rock symphony synthcore 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 teutonic thrash metal 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 heavy metal 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 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 ����������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/lastimport.py�����������������������������������������������������������������0000644�0000765�0000024�00000020413�00000000000�017067� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Rafael Bodill https://github.com/rafi # # 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 pylast from pylast import TopItem, _extract, _number from beets import ui from beets import dbcore from beets import config from beets import plugins from beets.dbcore import types API_URL = 'https://ws.audioscrobbler.com/2.0/' class LastImportPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() config['lastfm'].add({ 'user': '', 'api_key': plugins.LASTFM_KEY, }) config['lastfm']['api_key'].redact = True self.config.add({ 'per_page': 500, 'retry_limit': 3, }) self.item_types = { 'play_count': types.INTEGER, } def commands(self): cmd = ui.Subcommand('lastimport', help='import last.fm play-count') def func(lib, opts, args): import_lastfm(lib, self._log) cmd.func = func return [cmd] class CustomUser(pylast.User): """ Custom user class derived from pylast.User, and overriding the _get_things method to return MBID and album. Also introduces new get_top_tracks_by_page method to allow access to more than one page of top tracks. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def _get_things(self, method, thing, thing_type, params=None, cacheable=True): """Returns a list of the most played thing_types by this thing, in a tuple with the total number of pages of results. Includes an MBID, if found. """ doc = self._request( self.ws_prefix + "." + method, cacheable, params) toptracks_node = doc.getElementsByTagName('toptracks')[0] total_pages = int(toptracks_node.getAttribute('totalPages')) seq = [] for node in doc.getElementsByTagName(thing): title = _extract(node, "name") artist = _extract(node, "name", 1) mbid = _extract(node, "mbid") playcount = _number(_extract(node, "playcount")) thing = thing_type(artist, title, self.network) thing.mbid = mbid seq.append(TopItem(thing, playcount)) return seq, total_pages def get_top_tracks_by_page(self, period=pylast.PERIOD_OVERALL, limit=None, page=1, cacheable=True): """Returns the top tracks played by a user, in a tuple with the total number of pages of results. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_1MONTH o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS """ params = self._get_params() params['period'] = period params['page'] = page if limit: params['limit'] = limit return self._get_things( "getTopTracks", "track", pylast.Track, params, cacheable) def import_lastfm(lib, log): user = config['lastfm']['user'].as_str() per_page = config['lastimport']['per_page'].get(int) if not user: raise ui.UserError('You must specify a user name for lastimport') log.info('Fetching last.fm library for @{0}', user) page_total = 1 page_current = 0 found_total = 0 unknown_total = 0 retry_limit = config['lastimport']['retry_limit'].get(int) # Iterate through a yet to be known page total count while page_current < page_total: log.info('Querying page #{0}{1}...', page_current + 1, f'/{page_total}' if page_total > 1 else '') for retry in range(0, retry_limit): tracks, page_total = fetch_tracks(user, page_current + 1, per_page) if page_total < 1: # It means nothing to us! raise ui.UserError('Last.fm reported no data.') if tracks: found, unknown = process_tracks(lib, tracks, log) found_total += found unknown_total += unknown break else: log.error('ERROR: unable to read page #{0}', page_current + 1) if retry < retry_limit: log.info( 'Retrying page #{0}... ({1}/{2} retry)', page_current + 1, retry + 1, retry_limit ) else: log.error('FAIL: unable to fetch page #{0}, ', 'tried {1} times', page_current, retry + 1) page_current += 1 log.info('... done!') log.info('finished processing {0} song pages', page_total) log.info('{0} unknown play-counts', unknown_total) log.info('{0} play-counts imported', found_total) def fetch_tracks(user, page, limit): """ JSON format: [ { "mbid": "...", "artist": "...", "title": "...", "playcount": "..." } ] """ network = pylast.LastFMNetwork(api_key=config['lastfm']['api_key']) user_obj = CustomUser(user, network) results, total_pages =\ user_obj.get_top_tracks_by_page(limit=limit, page=page) return [ { "mbid": track.item.mbid if track.item.mbid else '', "artist": { "name": track.item.artist.name }, "name": track.item.title, "playcount": track.weight } for track in results ], total_pages def process_tracks(lib, tracks, log): total = len(tracks) total_found = 0 total_fails = 0 log.info('Received {0} tracks in this page, processing...', total) for num in range(0, total): song = None trackid = tracks[num]['mbid'].strip() artist = tracks[num]['artist'].get('name', '').strip() title = tracks[num]['name'].strip() album = '' if 'album' in tracks[num]: album = tracks[num]['album'].get('name', '').strip() log.debug('query: {0} - {1} ({2})', artist, title, album) # First try to query by musicbrainz's trackid if trackid: song = lib.items( dbcore.query.MatchQuery('mb_trackid', trackid) ).get() # If not, try just artist/title if song is None: log.debug('no album match, trying by artist/title') query = dbcore.AndQuery([ dbcore.query.SubstringQuery('artist', artist), dbcore.query.SubstringQuery('title', title) ]) song = lib.items(query).get() # Last resort, try just replacing to utf-8 quote if song is None: title = title.replace("'", '\u2019') log.debug('no title match, trying utf-8 single quote') query = dbcore.AndQuery([ dbcore.query.SubstringQuery('artist', artist), dbcore.query.SubstringQuery('title', title) ]) song = lib.items(query).get() if song is not None: count = int(song.get('play_count', 0)) new_count = int(tracks[num]['playcount']) log.debug('match: {0} - {1} ({2}) ' 'updating: play_count {3} => {4}', song.artist, song.title, song.album, count, new_count) song['play_count'] = new_count song.store() total_found += 1 else: total_fails += 1 log.info(' - No match: {0} - {1} ({2})', artist, title, album) if total_fails > 0: log.info('Acquired {0}/{1} play-counts ({2} unknown)', total_found, total, total_fails) return total_found, total_fails �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/loadext.py��������������������������������������������������������������������0000644�0000765�0000024�00000002730�00000000000�016333� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2019, Jack Wilsdon <jack.wilsdon@gmail.com> # # 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. """Load SQLite extensions. """ from beets.dbcore import Database from beets.plugins import BeetsPlugin import sqlite3 class LoadExtPlugin(BeetsPlugin): def __init__(self): super().__init__() if not Database.supports_extensions: self._log.warn('loadext is enabled but the current SQLite ' 'installation does not support extensions') return self.register_listener('library_opened', self.library_opened) def library_opened(self, lib): for v in self.config: ext = v.as_filename() self._log.debug('loading extension {}', ext) try: lib.load_extension(ext) except sqlite3.OperationalError as e: self._log.error('failed to load extension {}: {}', ext, e) ����������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1637959898.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/lyrics.py���������������������������������������������������������������������0000644�0000765�0000024�00000102572�00000000000�016205� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Fetches, embeds, and displays lyrics. """ import difflib import errno import itertools import json import struct import os.path import re import requests import unicodedata from unidecode import unidecode import warnings import urllib try: import bs4 from bs4 import SoupStrainer HAS_BEAUTIFUL_SOUP = True except ImportError: HAS_BEAUTIFUL_SOUP = False try: import langdetect HAS_LANGDETECT = True except ImportError: HAS_LANGDETECT = False try: # PY3: HTMLParseError was removed in 3.5 as strict mode # was deprecated in 3.3. # https://docs.python.org/3.3/library/html.parser.html from html.parser import HTMLParseError except ImportError: class HTMLParseError(Exception): pass from beets import plugins from beets import ui import beets DIV_RE = re.compile(r'<(/?)div>?', re.I) COMMENT_RE = re.compile(r'<!--.*-->', re.S) TAG_RE = re.compile(r'<[^>]*>') BREAK_RE = re.compile(r'\n?\s*<br([\s|/][^>]*)*>\s*\n?', re.I) URL_CHARACTERS = { '\u2018': "'", '\u2019': "'", '\u201c': '"', '\u201d': '"', '\u2010': '-', '\u2011': '-', '\u2012': '-', '\u2013': '-', '\u2014': '-', '\u2015': '-', '\u2016': '-', '\u2026': '...', } USER_AGENT = f'beets/{beets.__version__}' # The content for the base index.rst generated in ReST mode. REST_INDEX_TEMPLATE = '''Lyrics ====== * :ref:`Song index <genindex>` * :ref:`search` Artist index: .. toctree:: :maxdepth: 1 :glob: artists/* ''' # The content for the base conf.py generated. REST_CONF_TEMPLATE = '''# -*- coding: utf-8 -*- master_doc = 'index' project = 'Lyrics' copyright = 'none' author = 'Various Authors' latex_documents = [ (master_doc, 'Lyrics.tex', project, author, 'manual'), ] epub_title = project epub_author = author epub_publisher = author epub_copyright = copyright epub_exclude_files = ['search.html'] epub_tocdepth = 1 epub_tocdup = False ''' # Utilities. def unichar(i): try: return chr(i) except ValueError: return struct.pack('i', i).decode('utf-32') def unescape(text): """Resolve &#xxx; HTML entities (and some others).""" if isinstance(text, bytes): text = text.decode('utf-8', 'ignore') out = text.replace(' ', ' ') def replchar(m): num = m.group(1) return unichar(int(num)) out = re.sub("&#(\\d+);", replchar, out) return out def extract_text_between(html, start_marker, end_marker): try: _, html = html.split(start_marker, 1) html, _ = html.split(end_marker, 1) except ValueError: return '' return html def search_pairs(item): """Yield a pairs of artists and titles to search for. The first item in the pair is the name of the artist, the second item is a list of song names. In addition to the artist and title obtained from the `item` the method tries to strip extra information like paranthesized suffixes and featured artists from the strings and add them as candidates. The artist sort name is added as a fallback candidate to help in cases where artist name includes special characters or is in a non-latin script. The method also tries to split multiple titles separated with `/`. """ def generate_alternatives(string, patterns): """Generate string alternatives by extracting first matching group for each given pattern. """ alternatives = [string] for pattern in patterns: match = re.search(pattern, string, re.IGNORECASE) if match: alternatives.append(match.group(1)) return alternatives title, artist, artist_sort = item.title, item.artist, item.artist_sort patterns = [ # Remove any featuring artists from the artists name fr"(.*?) {plugins.feat_tokens()}"] artists = generate_alternatives(artist, patterns) # Use the artist_sort as fallback only if it differs from artist to avoid # repeated remote requests with the same search terms if artist != artist_sort: artists.append(artist_sort) patterns = [ # Remove a parenthesized suffix from a title string. Common # examples include (live), (remix), and (acoustic). r"(.+?)\s+[(].*[)]$", # Remove any featuring artists from the title r"(.*?) {}".format(plugins.feat_tokens(for_artist=False)), # Remove part of title after colon ':' for songs with subtitles r"(.+?)\s*:.*"] titles = generate_alternatives(title, patterns) # Check for a dual song (e.g. Pink Floyd - Speak to Me / Breathe) # and each of them. multi_titles = [] for title in titles: multi_titles.append([title]) if '/' in title: multi_titles.append([x.strip() for x in title.split('/')]) return itertools.product(artists, multi_titles) def slug(text): """Make a URL-safe, human-readable version of the given text This will do the following: 1. decode unicode characters into ASCII 2. shift everything to lowercase 3. strip whitespace 4. replace other non-word characters with dashes 5. strip extra dashes This somewhat duplicates the :func:`Google.slugify` function but slugify is not as generic as this one, which can be reused elsewhere. """ return re.sub(r'\W+', '-', unidecode(text).lower().strip()).strip('-') if HAS_BEAUTIFUL_SOUP: def try_parse_html(html, **kwargs): try: return bs4.BeautifulSoup(html, 'html.parser', **kwargs) except HTMLParseError: return None else: def try_parse_html(html, **kwargs): return None class Backend: REQUIRES_BS = False def __init__(self, config, log): self._log = log @staticmethod def _encode(s): """Encode the string for inclusion in a URL""" if isinstance(s, str): for char, repl in URL_CHARACTERS.items(): s = s.replace(char, repl) s = s.encode('utf-8', 'ignore') return urllib.parse.quote(s) def build_url(self, artist, title): return self.URL_PATTERN % (self._encode(artist.title()), self._encode(title.title())) def fetch_url(self, url): """Retrieve the content at a given URL, or return None if the source is unreachable. """ try: # Disable the InsecureRequestWarning that comes from using # `verify=false`. # https://github.com/kennethreitz/requests/issues/2214 # We're not overly worried about the NSA MITMing our lyrics scraper with warnings.catch_warnings(): warnings.simplefilter('ignore') r = requests.get(url, verify=False, headers={ 'User-Agent': USER_AGENT, }) except requests.RequestException as exc: self._log.debug('lyrics request failed: {0}', exc) return if r.status_code == requests.codes.ok: return r.text else: self._log.debug('failed to fetch: {0} ({1})', url, r.status_code) return None def fetch(self, artist, title): raise NotImplementedError() class MusiXmatch(Backend): REPLACEMENTS = { r'\s+': '-', '<': 'Less_Than', '>': 'Greater_Than', '#': 'Number_', r'[\[\{]': '(', r'[\]\}]': ')', } URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s' @classmethod def _encode(cls, s): for old, new in cls.REPLACEMENTS.items(): s = re.sub(old, new, s) return super()._encode(s) def fetch(self, artist, title): url = self.build_url(artist, title) html = self.fetch_url(url) if not html: return None if "We detected that your IP is blocked" in html: self._log.warning('we are blocked at MusixMatch: url %s failed' % url) return None html_parts = html.split('<p class="mxm-lyrics__content') # Sometimes lyrics come in 2 or more parts lyrics_parts = [] for html_part in html_parts: lyrics_parts.append(extract_text_between(html_part, '>', '</p>')) lyrics = '\n'.join(lyrics_parts) lyrics = lyrics.strip(',"').replace('\\n', '\n') # another odd case: sometimes only that string remains, for # missing songs. this seems to happen after being blocked # above, when filling in the CAPTCHA. if "Instant lyrics for all your music." in lyrics: return None # sometimes there are non-existent lyrics with some content if 'Lyrics | Musixmatch' in lyrics: return None return lyrics class Genius(Backend): """Fetch lyrics from Genius via genius-api. Simply adapted from bigishdata.com/2016/09/27/getting-song-lyrics-from-geniuss-api-scraping/ """ REQUIRES_BS = True base_url = "https://api.genius.com" def __init__(self, config, log): super().__init__(config, log) self.api_key = config['genius_api_key'].as_str() self.headers = { 'Authorization': "Bearer %s" % self.api_key, 'User-Agent': USER_AGENT, } def fetch(self, artist, title): """Fetch lyrics from genius.com Because genius doesn't allow accesssing lyrics via the api, we first query the api for a url matching our artist & title, then attempt to scrape that url for the lyrics. """ json = self._search(artist, title) if not json: self._log.debug('Genius API request returned invalid JSON') return None # find a matching artist in the json for hit in json["response"]["hits"]: hit_artist = hit["result"]["primary_artist"]["name"] if slug(hit_artist) == slug(artist): html = self.fetch_url(hit["result"]["url"]) if not html: return None return self._scrape_lyrics_from_html(html) self._log.debug('Genius failed to find a matching artist for \'{0}\'', artist) return None def _search(self, artist, title): """Searches the genius api for a given artist and title https://docs.genius.com/#search-h2 :returns: json response """ search_url = self.base_url + "/search" data = {'q': title + " " + artist.lower()} try: response = requests.get( search_url, data=data, headers=self.headers) except requests.RequestException as exc: self._log.debug('Genius API request failed: {0}', exc) return None try: return response.json() except ValueError: return None def _scrape_lyrics_from_html(self, html): """Scrape lyrics from a given genius.com html""" soup = try_parse_html(html) if not soup: return # Remove script tags that they put in the middle of the lyrics. [h.extract() for h in soup('script')] # Most of the time, the page contains a div with class="lyrics" where # all of the lyrics can be found already correctly formatted # Sometimes, though, it packages the lyrics into separate divs, most # likely for easier ad placement lyrics_div = soup.find("div", class_="lyrics") if not lyrics_div: self._log.debug('Received unusual song page html') verse_div = soup.find("div", class_=re.compile("Lyrics__Container")) if not verse_div: if soup.find("div", class_=re.compile("LyricsPlaceholder__Message"), string="This song is an instrumental"): self._log.debug('Detected instrumental') return "[Instrumental]" else: self._log.debug("Couldn't scrape page using known layouts") return None lyrics_div = verse_div.parent for br in lyrics_div.find_all("br"): br.replace_with("\n") ads = lyrics_div.find_all("div", class_=re.compile("InreadAd__Container")) for ad in ads: ad.replace_with("\n") return lyrics_div.get_text() class Tekstowo(Backend): # Fetch lyrics from Tekstowo.pl. REQUIRES_BS = True BASE_URL = 'http://www.tekstowo.pl' URL_PATTERN = BASE_URL + '/wyszukaj.html?search-title=%s&search-artist=%s' def fetch(self, artist, title): url = self.build_url(title, artist) search_results = self.fetch_url(url) if not search_results: return None song_page_url = self.parse_search_results(search_results) if not song_page_url: return None song_page_html = self.fetch_url(song_page_url) if not song_page_html: return None return self.extract_lyrics(song_page_html) def parse_search_results(self, html): html = _scrape_strip_cruft(html) html = _scrape_merge_paragraphs(html) soup = try_parse_html(html) if not soup: return None content_div = soup.find("div", class_="content") if not content_div: return None card_div = content_div.find("div", class_="card") if not card_div: return None song_rows = card_div.find_all("div", class_="box-przeboje") if not song_rows: return None song_row = song_rows[0] if not song_row: return None link = song_row.find('a') if not link: return None return self.BASE_URL + link.get('href') def extract_lyrics(self, html): html = _scrape_strip_cruft(html) html = _scrape_merge_paragraphs(html) soup = try_parse_html(html) if not soup: return None lyrics_div = soup.find("div", class_="song-text") if not lyrics_div: return None return lyrics_div.get_text() def remove_credits(text): """Remove first/last line of text if it contains the word 'lyrics' eg 'Lyrics by songsdatabase.com' """ textlines = text.split('\n') credits = None for i in (0, -1): if textlines and 'lyrics' in textlines[i].lower(): credits = textlines.pop(i) if credits: text = '\n'.join(textlines) return text def _scrape_strip_cruft(html, plain_text_out=False): """Clean up HTML """ html = unescape(html) html = html.replace('\r', '\n') # Normalize EOL. html = re.sub(r' +', ' ', html) # Whitespaces collapse. html = BREAK_RE.sub('\n', html) # <br> eats up surrounding '\n'. html = re.sub(r'(?s)<(script).*?</\1>', '', html) # Strip script tags. html = re.sub('\u2005', " ", html) # replace unicode with regular space if plain_text_out: # Strip remaining HTML tags html = COMMENT_RE.sub('', html) html = TAG_RE.sub('', html) html = '\n'.join([x.strip() for x in html.strip().split('\n')]) html = re.sub(r'\n{3,}', r'\n\n', html) return html def _scrape_merge_paragraphs(html): html = re.sub(r'</p>\s*<p(\s*[^>]*)>', '\n', html) return re.sub(r'<div .*>\s*</div>', '\n', html) def scrape_lyrics_from_html(html): """Scrape lyrics from a URL. If no lyrics can be found, return None instead. """ def is_text_notcode(text): length = len(text) return (length > 20 and text.count(' ') > length / 25 and (text.find('{') == -1 or text.find(';') == -1)) html = _scrape_strip_cruft(html) html = _scrape_merge_paragraphs(html) # extract all long text blocks that are not code soup = try_parse_html(html, parse_only=SoupStrainer(text=is_text_notcode)) if not soup: return None # Get the longest text element (if any). strings = sorted(soup.stripped_strings, key=len, reverse=True) if strings: return strings[0] else: return None class Google(Backend): """Fetch lyrics from Google search results.""" REQUIRES_BS = True def __init__(self, config, log): super().__init__(config, log) self.api_key = config['google_API_key'].as_str() self.engine_id = config['google_engine_ID'].as_str() def is_lyrics(self, text, artist=None): """Determine whether the text seems to be valid lyrics. """ if not text: return False bad_triggers_occ = [] nb_lines = text.count('\n') if nb_lines <= 1: self._log.debug("Ignoring too short lyrics '{0}'", text) return False elif nb_lines < 5: bad_triggers_occ.append('too_short') else: # Lyrics look legit, remove credits to avoid being penalized # further down text = remove_credits(text) bad_triggers = ['lyrics', 'copyright', 'property', 'links'] if artist: bad_triggers += [artist] for item in bad_triggers: bad_triggers_occ += [item] * len(re.findall(r'\W%s\W' % item, text, re.I)) if bad_triggers_occ: self._log.debug('Bad triggers detected: {0}', bad_triggers_occ) return len(bad_triggers_occ) < 2 def slugify(self, text): """Normalize a string and remove non-alphanumeric characters. """ text = re.sub(r"[-'_\s]", '_', text) text = re.sub(r"_+", '_', text).strip('_') pat = r"([^,\(]*)\((.*?)\)" # Remove content within parentheses text = re.sub(pat, r'\g<1>', text).strip() try: text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore') text = str(re.sub(r'[-\s]+', ' ', text.decode('utf-8'))) except UnicodeDecodeError: self._log.exception("Failing to normalize '{0}'", text) return text BY_TRANS = ['by', 'par', 'de', 'von'] LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte'] def is_page_candidate(self, url_link, url_title, title, artist): """Return True if the URL title makes it a good candidate to be a page that contains lyrics of title by artist. """ title = self.slugify(title.lower()) artist = self.slugify(artist.lower()) sitename = re.search("//([^/]+)/.*", self.slugify(url_link.lower())).group(1) url_title = self.slugify(url_title.lower()) # Check if URL title contains song title (exact match) if url_title.find(title) != -1: return True # or try extracting song title from URL title and check if # they are close enough tokens = [by + '_' + artist for by in self.BY_TRANS] + \ [artist, sitename, sitename.replace('www.', '')] + \ self.LYRICS_TRANS tokens = [re.escape(t) for t in tokens] song_title = re.sub('(%s)' % '|'.join(tokens), '', url_title) song_title = song_title.strip('_|') typo_ratio = .9 ratio = difflib.SequenceMatcher(None, song_title, title).ratio() return ratio >= typo_ratio def fetch(self, artist, title): query = f"{artist} {title}" url = 'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' \ % (self.api_key, self.engine_id, urllib.parse.quote(query.encode('utf-8'))) data = self.fetch_url(url) if not data: self._log.debug('google backend returned no data') return None try: data = json.loads(data) except ValueError as exc: self._log.debug('google backend returned malformed JSON: {}', exc) if 'error' in data: reason = data['error']['errors'][0]['reason'] self._log.debug('google backend error: {0}', reason) return None if 'items' in data.keys(): for item in data['items']: url_link = item['link'] url_title = item.get('title', '') if not self.is_page_candidate(url_link, url_title, title, artist): continue html = self.fetch_url(url_link) if not html: continue lyrics = scrape_lyrics_from_html(html) if not lyrics: continue if self.is_lyrics(lyrics, artist): self._log.debug('got lyrics from {0}', item['displayLink']) return lyrics return None class LyricsPlugin(plugins.BeetsPlugin): SOURCES = ['google', 'musixmatch', 'genius', 'tekstowo'] SOURCE_BACKENDS = { 'google': Google, 'musixmatch': MusiXmatch, 'genius': Genius, 'tekstowo': Tekstowo, } def __init__(self): super().__init__() self.import_stages = [self.imported] self.config.add({ 'auto': True, 'bing_client_secret': None, 'bing_lang_from': [], 'bing_lang_to': None, 'google_API_key': None, 'google_engine_ID': '009217259823014548361:lndtuqkycfu', 'genius_api_key': "Ryq93pUGm8bM6eUWwD_M3NOFFDAtp2yEE7W" "76V-uFL5jks5dNvcGCdarqFjDhP9c", 'fallback': None, 'force': False, 'local': False, 'sources': self.SOURCES, }) self.config['bing_client_secret'].redact = True self.config['google_API_key'].redact = True self.config['google_engine_ID'].redact = True self.config['genius_api_key'].redact = True # State information for the ReST writer. # First, the current artist we're writing. self.artist = 'Unknown artist' # The current album: False means no album yet. self.album = False # The current rest file content. None means the file is not # open yet. self.rest = None available_sources = list(self.SOURCES) sources = plugins.sanitize_choices( self.config['sources'].as_str_seq(), available_sources) if not HAS_BEAUTIFUL_SOUP: sources = self.sanitize_bs_sources(sources) if 'google' in sources: if not self.config['google_API_key'].get(): # We log a *debug* message here because the default # configuration includes `google`. This way, the source # is silent by default but can be enabled just by # setting an API key. self._log.debug('Disabling google source: ' 'no API key configured.') sources.remove('google') self.config['bing_lang_from'] = [ x.lower() for x in self.config['bing_lang_from'].as_str_seq()] self.bing_auth_token = None if not HAS_LANGDETECT and self.config['bing_client_secret'].get(): self._log.warning('To use bing translations, you need to ' 'install the langdetect module. See the ' 'documentation for further details.') self.backends = [self.SOURCE_BACKENDS[source](self.config, self._log) for source in sources] def sanitize_bs_sources(self, sources): enabled_sources = [] for source in sources: if self.SOURCE_BACKENDS[source].REQUIRES_BS: self._log.debug('To use the %s lyrics source, you must ' 'install the beautifulsoup4 module. See ' 'the documentation for further details.' % source) else: enabled_sources.append(source) return enabled_sources def get_bing_access_token(self): params = { 'client_id': 'beets', 'client_secret': self.config['bing_client_secret'], 'scope': "https://api.microsofttranslator.com", 'grant_type': 'client_credentials', } oauth_url = 'https://datamarket.accesscontrol.windows.net/v2/OAuth2-13' oauth_token = json.loads(requests.post( oauth_url, data=urllib.parse.urlencode(params)).content) if 'access_token' in oauth_token: return "Bearer " + oauth_token['access_token'] else: self._log.warning('Could not get Bing Translate API access token.' ' Check your "bing_client_secret" password') def commands(self): cmd = ui.Subcommand('lyrics', help='fetch song lyrics') cmd.parser.add_option( '-p', '--print', dest='printlyr', action='store_true', default=False, help='print lyrics to console', ) cmd.parser.add_option( '-r', '--write-rest', dest='writerest', action='store', default=None, metavar='dir', help='write lyrics to given directory as ReST files', ) cmd.parser.add_option( '-f', '--force', dest='force_refetch', action='store_true', default=False, help='always re-download lyrics', ) cmd.parser.add_option( '-l', '--local', dest='local_only', action='store_true', default=False, help='do not fetch missing lyrics', ) def func(lib, opts, args): # The "write to files" option corresponds to the # import_write config value. write = ui.should_write() if opts.writerest: self.writerest_indexes(opts.writerest) items = lib.items(ui.decargs(args)) for item in items: if not opts.local_only and not self.config['local']: self.fetch_item_lyrics( lib, item, write, opts.force_refetch or self.config['force'], ) if item.lyrics: if opts.printlyr: ui.print_(item.lyrics) if opts.writerest: self.appendrest(opts.writerest, item) if opts.writerest and items: # flush last artist & write to ReST self.writerest(opts.writerest) ui.print_('ReST files generated. to build, use one of:') ui.print_(' sphinx-build -b html %s _build/html' % opts.writerest) ui.print_(' sphinx-build -b epub %s _build/epub' % opts.writerest) ui.print_((' sphinx-build -b latex %s _build/latex ' '&& make -C _build/latex all-pdf') % opts.writerest) cmd.func = func return [cmd] def appendrest(self, directory, item): """Append the item to an ReST file This will keep state (in the `rest` variable) in order to avoid writing continuously to the same files. """ if slug(self.artist) != slug(item.albumartist): # Write current file and start a new one ~ item.albumartist self.writerest(directory) self.artist = item.albumartist.strip() self.rest = "%s\n%s\n\n.. contents::\n :local:\n\n" \ % (self.artist, '=' * len(self.artist)) if self.album != item.album: tmpalbum = self.album = item.album.strip() if self.album == '': tmpalbum = 'Unknown album' self.rest += "{}\n{}\n\n".format(tmpalbum, '-' * len(tmpalbum)) title_str = ":index:`%s`" % item.title.strip() block = '| ' + item.lyrics.replace('\n', '\n| ') self.rest += "{}\n{}\n\n{}\n\n".format(title_str, '~' * len(title_str), block) def writerest(self, directory): """Write self.rest to a ReST file """ if self.rest is not None and self.artist is not None: path = os.path.join(directory, 'artists', slug(self.artist) + '.rst') with open(path, 'wb') as output: output.write(self.rest.encode('utf-8')) def writerest_indexes(self, directory): """Write conf.py and index.rst files necessary for Sphinx We write minimal configurations that are necessary for Sphinx to operate. We do not overwrite existing files so that customizations are respected.""" try: os.makedirs(os.path.join(directory, 'artists')) except OSError as e: if e.errno == errno.EEXIST: pass else: raise indexfile = os.path.join(directory, 'index.rst') if not os.path.exists(indexfile): with open(indexfile, 'w') as output: output.write(REST_INDEX_TEMPLATE) conffile = os.path.join(directory, 'conf.py') if not os.path.exists(conffile): with open(conffile, 'w') as output: output.write(REST_CONF_TEMPLATE) def imported(self, session, task): """Import hook for fetching lyrics automatically. """ if self.config['auto']: for item in task.imported_items(): self.fetch_item_lyrics(session.lib, item, False, self.config['force']) def fetch_item_lyrics(self, lib, item, write, force): """Fetch and store lyrics for a single item. If ``write``, then the lyrics will also be written to the file itself. """ # Skip if the item already has lyrics. if not force and item.lyrics: self._log.info('lyrics already present: {0}', item) return lyrics = None for artist, titles in search_pairs(item): lyrics = [self.get_lyrics(artist, title) for title in titles] if any(lyrics): break lyrics = "\n\n---\n\n".join([l for l in lyrics if l]) if lyrics: self._log.info('fetched lyrics: {0}', item) if HAS_LANGDETECT and self.config['bing_client_secret'].get(): lang_from = langdetect.detect(lyrics) if self.config['bing_lang_to'].get() != lang_from and ( not self.config['bing_lang_from'] or ( lang_from in self.config[ 'bing_lang_from'].as_str_seq())): lyrics = self.append_translation( lyrics, self.config['bing_lang_to']) else: self._log.info('lyrics not found: {0}', item) fallback = self.config['fallback'].get() if fallback: lyrics = fallback else: return item.lyrics = lyrics if write: item.try_write() item.store() def get_lyrics(self, artist, title): """Fetch lyrics, trying each source in turn. Return a string or None if no lyrics were found. """ for backend in self.backends: lyrics = backend.fetch(artist, title) if lyrics: self._log.debug('got lyrics from backend: {0}', backend.__class__.__name__) return _scrape_strip_cruft(lyrics, True) def append_translation(self, text, to_lang): from xml.etree import ElementTree if not self.bing_auth_token: self.bing_auth_token = self.get_bing_access_token() if self.bing_auth_token: # Extract unique lines to limit API request size per song text_lines = set(text.split('\n')) url = ('https://api.microsofttranslator.com/v2/Http.svc/' 'Translate?text=%s&to=%s' % ('|'.join(text_lines), to_lang)) r = requests.get(url, headers={"Authorization ": self.bing_auth_token}) if r.status_code != 200: self._log.debug('translation API error {}: {}', r.status_code, r.text) if 'token has expired' in r.text: self.bing_auth_token = None return self.append_translation(text, to_lang) return text lines_translated = ElementTree.fromstring( r.text.encode('utf-8')).text # Use a translation mapping dict to build resulting lyrics translations = dict(zip(text_lines, lines_translated.split('|'))) result = '' for line in text.split('\n'): result += '{} / {}\n'.format(line, translations[line]) return result ��������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/mbcollection.py���������������������������������������������������������������0000644�0000765�0000024�00000014014�00000000000�017343� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright (c) 2011, Jeffrey Aylesworth <mail@jeffrey.red> # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets import ui from beets import config import musicbrainzngs import re SUBMISSION_CHUNK_SIZE = 200 FETCH_CHUNK_SIZE = 100 UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$' def mb_call(func, *args, **kwargs): """Call a MusicBrainz API function and catch exceptions. """ try: return func(*args, **kwargs) except musicbrainzngs.AuthenticationError: raise ui.UserError('authentication with MusicBrainz failed') except (musicbrainzngs.ResponseError, musicbrainzngs.NetworkError) as exc: raise ui.UserError(f'MusicBrainz API error: {exc}') except musicbrainzngs.UsageError: raise ui.UserError('MusicBrainz credentials missing') def submit_albums(collection_id, release_ids): """Add all of the release IDs to the indicated collection. Multiple requests are made if there are many release IDs to submit. """ for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE): chunk = release_ids[i:i + SUBMISSION_CHUNK_SIZE] mb_call( musicbrainzngs.add_releases_to_collection, collection_id, chunk ) class MusicBrainzCollectionPlugin(BeetsPlugin): def __init__(self): super().__init__() config['musicbrainz']['pass'].redact = True musicbrainzngs.auth( config['musicbrainz']['user'].as_str(), config['musicbrainz']['pass'].as_str(), ) self.config.add({ 'auto': False, 'collection': '', 'remove': False, }) if self.config['auto']: self.import_stages = [self.imported] def _get_collection(self): collections = mb_call(musicbrainzngs.get_collections) if not collections['collection-list']: raise ui.UserError('no collections exist for user') # Get all collection IDs, avoiding event collections collection_ids = [x['id'] for x in collections['collection-list']] if not collection_ids: raise ui.UserError('No collection found.') # Check that the collection exists so we can present a nice error collection = self.config['collection'].as_str() if collection: if collection not in collection_ids: raise ui.UserError('invalid collection ID: {}' .format(collection)) return collection # No specified collection. Just return the first collection ID return collection_ids[0] def _get_albums_in_collection(self, id): def _fetch(offset): res = mb_call( musicbrainzngs.get_releases_in_collection, id, limit=FETCH_CHUNK_SIZE, offset=offset )['collection'] return [x['id'] for x in res['release-list']], res['release-count'] offset = 0 albums_in_collection, release_count = _fetch(offset) for i in range(0, release_count, FETCH_CHUNK_SIZE): albums_in_collection += _fetch(offset)[0] offset += FETCH_CHUNK_SIZE return albums_in_collection def commands(self): mbupdate = Subcommand('mbupdate', help='Update MusicBrainz collection') mbupdate.parser.add_option('-r', '--remove', action='store_true', default=None, dest='remove', help='Remove albums not in beets library') mbupdate.func = self.update_collection return [mbupdate] def remove_missing(self, collection_id, lib_albums): lib_ids = {x.mb_albumid for x in lib_albums} albums_in_collection = self._get_albums_in_collection(collection_id) remove_me = list(set(albums_in_collection) - lib_ids) for i in range(0, len(remove_me), FETCH_CHUNK_SIZE): chunk = remove_me[i:i + FETCH_CHUNK_SIZE] mb_call( musicbrainzngs.remove_releases_from_collection, collection_id, chunk ) def update_collection(self, lib, opts, args): self.config.set_args(opts) remove_missing = self.config['remove'].get(bool) self.update_album_list(lib, lib.albums(), remove_missing) def imported(self, session, task): """Add each imported album to the collection. """ if task.is_album: self.update_album_list(session.lib, [task.album]) def update_album_list(self, lib, album_list, remove_missing=False): """Update the MusicBrainz collection from a list of Beets albums """ collection_id = self._get_collection() # Get a list of all the album IDs. album_ids = [] for album in album_list: aid = album.mb_albumid if aid: if re.match(UUID_REGEX, aid): album_ids.append(aid) else: self._log.info('skipping invalid MBID: {0}', aid) # Submit to MusicBrainz. self._log.info( 'Updating MusicBrainz collection {0}...', collection_id ) submit_albums(collection_id, album_ids) if remove_missing: self.remove_missing(collection_id, lib.albums()) self._log.info('...MusicBrainz collection updated.') ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/mbsubmit.py�������������������������������������������������������������������0000644�0000765�0000024�00000004073�00000000000�016517� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Aid in submitting information to MusicBrainz. This plugin allows the user to print track information in a format that is parseable by the MusicBrainz track parser [1]. Programmatic submitting is not implemented by MusicBrainz yet. [1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings """ from beets.autotag import Recommendation from beets.plugins import BeetsPlugin from beets.ui.commands import PromptChoice from beetsplug.info import print_data class MBSubmitPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'format': '$track. $title - $artist ($length)', 'threshold': 'medium', }) # Validate and store threshold. self.threshold = self.config['threshold'].as_choice({ 'none': Recommendation.none, 'low': Recommendation.low, 'medium': Recommendation.medium, 'strong': Recommendation.strong }) self.register_listener('before_choose_candidate', self.before_choose_candidate_event) def before_choose_candidate_event(self, session, task): if task.rec <= self.threshold: return [PromptChoice('p', 'Print tracks', self.print_tracks)] def print_tracks(self, session, task): for i in sorted(task.items, key=lambda i: i.track): print_data(None, i, self.config['format'].as_str()) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/mbsync.py���������������������������������������������������������������������0000644�0000765�0000024�00000016375�00000000000�016200� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Jakob Schnitzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Update library's tags using MusicBrainz. """ from beets.plugins import BeetsPlugin, apply_item_changes from beets import autotag, library, ui, util from beets.autotag import hooks from collections import defaultdict import re MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}" class MBSyncPlugin(BeetsPlugin): def __init__(self): super().__init__() def commands(self): cmd = ui.Subcommand('mbsync', help='update metadata from musicbrainz') cmd.parser.add_option( '-p', '--pretend', action='store_true', help='show all changes but do nothing') cmd.parser.add_option( '-m', '--move', action='store_true', dest='move', help="move files in the library directory") cmd.parser.add_option( '-M', '--nomove', action='store_false', dest='move', help="don't move files in library") cmd.parser.add_option( '-W', '--nowrite', action='store_false', default=None, dest='write', help="don't write updated metadata to files") cmd.parser.add_format_option() cmd.func = self.func return [cmd] def func(self, lib, opts, args): """Command handler for the mbsync function. """ move = ui.should_move(opts.move) pretend = opts.pretend write = ui.should_write(opts.write) query = ui.decargs(args) self.singletons(lib, query, move, pretend, write) self.albums(lib, query, move, pretend, write) def singletons(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for items matched by query. """ for item in lib.items(query + ['singleton:true']): item_formatted = format(item) if not item.mb_trackid: self._log.info('Skipping singleton with no mb_trackid: {0}', item_formatted) continue # Do we have a valid MusicBrainz track ID? if not re.match(MBID_REGEX, item.mb_trackid): self._log.info('Skipping singleton with invalid mb_trackid:' + ' {0}', item_formatted) continue # Get the MusicBrainz recording info. track_info = hooks.track_for_mbid(item.mb_trackid) if not track_info: self._log.info('Recording ID not found: {0} for track {0}', item.mb_trackid, item_formatted) continue # Apply. with lib.transaction(): autotag.apply_item_metadata(item, track_info) apply_item_changes(lib, item, move, pretend, write) def albums(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for albums matched by query and their items. """ # Process matching albums. for a in lib.albums(query): album_formatted = format(a) if not a.mb_albumid: self._log.info('Skipping album with no mb_albumid: {0}', album_formatted) continue items = list(a.items()) # Do we have a valid MusicBrainz album ID? if not re.match(MBID_REGEX, a.mb_albumid): self._log.info('Skipping album with invalid mb_albumid: {0}', album_formatted) continue # Get the MusicBrainz album information. album_info = hooks.album_for_mbid(a.mb_albumid) if not album_info: self._log.info('Release ID {0} not found for album {1}', a.mb_albumid, album_formatted) continue # Map release track and recording MBIDs to their information. # Recordings can appear multiple times on a release, so each MBID # maps to a list of TrackInfo objects. releasetrack_index = {} track_index = defaultdict(list) for track_info in album_info.tracks: releasetrack_index[track_info.release_track_id] = track_info track_index[track_info.track_id].append(track_info) # Construct a track mapping according to MBIDs (release track MBIDs # first, if available, and recording MBIDs otherwise). This should # work for albums that have missing or extra tracks. mapping = {} for item in items: if item.mb_releasetrackid and \ item.mb_releasetrackid in releasetrack_index: mapping[item] = releasetrack_index[item.mb_releasetrackid] else: candidates = track_index[item.mb_trackid] if len(candidates) == 1: mapping[item] = candidates[0] else: # If there are multiple copies of a recording, they are # disambiguated using their disc and track number. for c in candidates: if (c.medium_index == item.track and c.medium == item.disc): mapping[item] = c break # Apply. self._log.debug('applying changes to {}', album_formatted) with lib.transaction(): autotag.apply_metadata(album_info, mapping) changed = False # Find any changed item to apply MusicBrainz changes to album. any_changed_item = items[0] for item in items: item_changed = ui.show_model_changes(item) changed |= item_changed if item_changed: any_changed_item = item apply_item_changes(lib, item, move, pretend, write) if not changed: # No change to any item. continue if not pretend: # Update album structure to reflect an item in it. for key in library.Album.item_keys: a[key] = any_changed_item[key] a.store() # Move album art (and any inconsistent items). if move and lib.directory in util.ancestry(items[0].path): self._log.debug('moving album {0}', album_formatted) a.move() �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000034�00000000000�010212� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������28 mtime=1638031078.3198867 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/metasync/���������������������������������������������������������������������0000755�0000765�0000024�00000000000�00000000000�016142� 5����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/metasync/__init__.py����������������������������������������������������������0000644�0000765�0000024�00000010271�00000000000�020254� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Heinz Wiesinger. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Synchronize information from music player libraries """ from abc import abstractmethod, ABCMeta from importlib import import_module from confuse import ConfigValueError from beets import ui from beets.plugins import BeetsPlugin METASYNC_MODULE = 'beetsplug.metasync' # Dictionary to map the MODULE and the CLASS NAME of meta sources SOURCES = { 'amarok': 'Amarok', 'itunes': 'Itunes', } class MetaSource(metaclass=ABCMeta): def __init__(self, config, log): self.item_types = {} self.config = config self._log = log @abstractmethod def sync_from_source(self, item): pass def load_meta_sources(): """ Returns a dictionary of all the MetaSources E.g., {'itunes': Itunes} with isinstance(Itunes, MetaSource) true """ meta_sources = {} for module_path, class_name in SOURCES.items(): module = import_module(METASYNC_MODULE + '.' + module_path) meta_sources[class_name.lower()] = getattr(module, class_name) return meta_sources META_SOURCES = load_meta_sources() def load_item_types(): """ Returns a dictionary containing the item_types of all the MetaSources """ item_types = {} for meta_source in META_SOURCES.values(): item_types.update(meta_source.item_types) return item_types class MetaSyncPlugin(BeetsPlugin): item_types = load_item_types() def __init__(self): super().__init__() def commands(self): cmd = ui.Subcommand('metasync', help='update metadata from music player libraries') cmd.parser.add_option('-p', '--pretend', action='store_true', help='show all changes but do nothing') cmd.parser.add_option('-s', '--source', default=[], action='append', dest='sources', help='comma-separated list of sources to sync') cmd.parser.add_format_option() cmd.func = self.func return [cmd] def func(self, lib, opts, args): """Command handler for the metasync function. """ pretend = opts.pretend query = ui.decargs(args) sources = [] for source in opts.sources: sources.extend(source.split(',')) sources = sources or self.config['source'].as_str_seq() meta_source_instances = {} items = lib.items(query) # Avoid needlessly instantiating meta sources (can be expensive) if not items: self._log.info('No items found matching query') return # Instantiate the meta sources for player in sources: try: cls = META_SOURCES[player] except KeyError: self._log.error('Unknown metadata source \'{}\''.format( player)) try: meta_source_instances[player] = cls(self.config, self._log) except (ImportError, ConfigValueError) as e: self._log.error('Failed to instantiate metadata source ' '\'{}\': {}'.format(player, e)) # Avoid needlessly iterating over items if not meta_source_instances: self._log.error('No valid metadata sources found') return # Sync the items with all of the meta sources for item in items: for meta_source in meta_source_instances.values(): meta_source.sync_from_source(item) changed = ui.show_model_changes(item) if changed and not pretend: item.store() ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/metasync/amarok.py������������������������������������������������������������0000644�0000765�0000024�00000007661�00000000000�020000� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Heinz Wiesinger. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Synchronize information from amarok's library via dbus """ from os.path import basename from datetime import datetime from time import mktime from xml.sax.saxutils import quoteattr from beets.util import displayable_path from beets.dbcore import types from beets.library import DateType from beetsplug.metasync import MetaSource def import_dbus(): try: return __import__('dbus') except ImportError: return None dbus = import_dbus() class Amarok(MetaSource): item_types = { 'amarok_rating': types.INTEGER, 'amarok_score': types.FLOAT, 'amarok_uid': types.STRING, 'amarok_playcount': types.INTEGER, 'amarok_firstplayed': DateType(), 'amarok_lastplayed': DateType(), } query_xml = '<query version="1.0"> \ <filters> \ <and><include field="filename" value=%s /></and> \ </filters> \ </query>' def __init__(self, config, log): super().__init__(config, log) if not dbus: raise ImportError('failed to import dbus') self.collection = \ dbus.SessionBus().get_object('org.kde.amarok', '/Collection') def sync_from_source(self, item): path = displayable_path(item.path) # amarok unfortunately doesn't allow searching for the full path, only # for the patch relative to the mount point. But the full path is part # of the result set. So query for the filename and then try to match # the correct item from the results we get back results = self.collection.Query( self.query_xml % quoteattr(basename(path)) ) for result in results: if result['xesam:url'] != path: continue item.amarok_rating = result['xesam:userRating'] item.amarok_score = result['xesam:autoRating'] item.amarok_playcount = result['xesam:useCount'] item.amarok_uid = \ result['xesam:id'].replace('amarok-sqltrackuid://', '') if result['xesam:firstUsed'][0][0] != 0: # These dates are stored as timestamps in amarok's db, but # exposed over dbus as fixed integers in the current timezone. first_played = datetime( result['xesam:firstUsed'][0][0], result['xesam:firstUsed'][0][1], result['xesam:firstUsed'][0][2], result['xesam:firstUsed'][1][0], result['xesam:firstUsed'][1][1], result['xesam:firstUsed'][1][2] ) if result['xesam:lastUsed'][0][0] != 0: last_played = datetime( result['xesam:lastUsed'][0][0], result['xesam:lastUsed'][0][1], result['xesam:lastUsed'][0][2], result['xesam:lastUsed'][1][0], result['xesam:lastUsed'][1][1], result['xesam:lastUsed'][1][2] ) else: last_played = first_played item.amarok_firstplayed = mktime(first_played.timetuple()) item.amarok_lastplayed = mktime(last_played.timetuple()) �������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/metasync/itunes.py������������������������������������������������������������0000644�0000765�0000024�00000010577�00000000000�020035� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Tom Jaspers. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Synchronize information from iTunes's library """ from contextlib import contextmanager import os import shutil import tempfile import plistlib from urllib.parse import urlparse, unquote from time import mktime from beets import util from beets.dbcore import types from beets.library import DateType from confuse import ConfigValueError from beetsplug.metasync import MetaSource @contextmanager def create_temporary_copy(path): temp_dir = tempfile.mkdtemp() temp_path = os.path.join(temp_dir, 'temp_itunes_lib') shutil.copyfile(path, temp_path) try: yield temp_path finally: shutil.rmtree(temp_dir) def _norm_itunes_path(path): # Itunes prepends the location with 'file://' on posix systems, # and with 'file://localhost/' on Windows systems. # The actual path to the file is always saved as posix form # E.g., 'file://Users/Music/bar' or 'file://localhost/G:/Music/bar' # The entire path will also be capitalized (e.g., '/Music/Alt-J') # Note that this means the path will always have a leading separator, # which is unwanted in the case of Windows systems. # E.g., '\\G:\\Music\\bar' needs to be stripped to 'G:\\Music\\bar' return util.bytestring_path(os.path.normpath( unquote(urlparse(path).path)).lstrip('\\')).lower() class Itunes(MetaSource): item_types = { 'itunes_rating': types.INTEGER, # 0..100 scale 'itunes_playcount': types.INTEGER, 'itunes_skipcount': types.INTEGER, 'itunes_lastplayed': DateType(), 'itunes_lastskipped': DateType(), 'itunes_dateadded': DateType(), } def __init__(self, config, log): super().__init__(config, log) config.add({'itunes': { 'library': '~/Music/iTunes/iTunes Library.xml' }}) # Load the iTunes library, which has to be the .xml one (not the .itl) library_path = config['itunes']['library'].as_filename() try: self._log.debug( f'loading iTunes library from {library_path}') with create_temporary_copy(library_path) as library_copy: with open(library_copy, 'rb') as library_copy_f: raw_library = plistlib.load(library_copy_f) except OSError as e: raise ConfigValueError('invalid iTunes library: ' + e.strerror) except Exception: # It's likely the user configured their '.itl' library (<> xml) if os.path.splitext(library_path)[1].lower() != '.xml': hint = ': please ensure that the configured path' \ ' points to the .XML library' else: hint = '' raise ConfigValueError('invalid iTunes library' + hint) # Make the iTunes library queryable using the path self.collection = {_norm_itunes_path(track['Location']): track for track in raw_library['Tracks'].values() if 'Location' in track} def sync_from_source(self, item): result = self.collection.get(util.bytestring_path(item.path).lower()) if not result: self._log.warning(f'no iTunes match found for {item}') return item.itunes_rating = result.get('Rating') item.itunes_playcount = result.get('Play Count') item.itunes_skipcount = result.get('Skip Count') if result.get('Play Date UTC'): item.itunes_lastplayed = mktime( result.get('Play Date UTC').timetuple()) if result.get('Skip Date'): item.itunes_lastskipped = mktime( result.get('Skip Date').timetuple()) if result.get('Date Added'): item.itunes_dateadded = mktime( result.get('Date Added').timetuple()) ���������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/missing.py��������������������������������������������������������������������0000644�0000765�0000024�00000017467�00000000000�016361� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Pedro Silva. # Copyright 2017, Quentin Young. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """List missing tracks. """ import musicbrainzngs from musicbrainzngs.musicbrainz import MusicBrainzError from collections import defaultdict from beets.autotag import hooks from beets.library import Item from beets.plugins import BeetsPlugin from beets.ui import decargs, print_, Subcommand from beets import config from beets.dbcore import types def _missing_count(album): """Return number of missing items in `album`. """ return (album.albumtotal or 0) - len(album.items()) def _item(track_info, album_info, album_id): """Build and return `item` from `track_info` and `album info` objects. `item` is missing what fields cannot be obtained from MusicBrainz alone (encoder, rg_track_gain, rg_track_peak, rg_album_gain, rg_album_peak, original_year, original_month, original_day, length, bitrate, format, samplerate, bitdepth, channels, mtime.) """ t = track_info a = album_info return Item(**{ 'album_id': album_id, 'album': a.album, 'albumartist': a.artist, 'albumartist_credit': a.artist_credit, 'albumartist_sort': a.artist_sort, 'albumdisambig': a.albumdisambig, 'albumstatus': a.albumstatus, 'albumtype': a.albumtype, 'artist': t.artist, 'artist_credit': t.artist_credit, 'artist_sort': t.artist_sort, 'asin': a.asin, 'catalognum': a.catalognum, 'comp': a.va, 'country': a.country, 'day': a.day, 'disc': t.medium, 'disctitle': t.disctitle, 'disctotal': a.mediums, 'label': a.label, 'language': a.language, 'length': t.length, 'mb_albumid': a.album_id, 'mb_artistid': t.artist_id, 'mb_releasegroupid': a.releasegroup_id, 'mb_trackid': t.track_id, 'media': t.media, 'month': a.month, 'script': a.script, 'title': t.title, 'track': t.index, 'tracktotal': len(a.tracks), 'year': a.year, }) class MissingPlugin(BeetsPlugin): """List missing tracks """ album_types = { 'missing': types.INTEGER, } def __init__(self): super().__init__() self.config.add({ 'count': False, 'total': False, 'album': False, }) self.album_template_fields['missing'] = _missing_count self._command = Subcommand('missing', help=__doc__, aliases=['miss']) self._command.parser.add_option( '-c', '--count', dest='count', action='store_true', help='count missing tracks per album') self._command.parser.add_option( '-t', '--total', dest='total', action='store_true', help='count total of missing tracks') self._command.parser.add_option( '-a', '--album', dest='album', action='store_true', help='show missing albums for artist instead of tracks') self._command.parser.add_format_option() def commands(self): def _miss(lib, opts, args): self.config.set_args(opts) albms = self.config['album'].get() helper = self._missing_albums if albms else self._missing_tracks helper(lib, decargs(args)) self._command.func = _miss return [self._command] def _missing_tracks(self, lib, query): """Print a listing of tracks missing from each album in the library matching query. """ albums = lib.albums(query) count = self.config['count'].get() total = self.config['total'].get() fmt = config['format_album' if count else 'format_item'].get() if total: print(sum([_missing_count(a) for a in albums])) return # Default format string for count mode. if count: fmt += ': $missing' for album in albums: if count: if _missing_count(album): print_(format(album, fmt)) else: for item in self._missing(album): print_(format(item, fmt)) def _missing_albums(self, lib, query): """Print a listing of albums missing from each artist in the library matching query. """ total = self.config['total'].get() albums = lib.albums(query) # build dict mapping artist to list of their albums in library albums_by_artist = defaultdict(list) for alb in albums: artist = (alb['albumartist'], alb['mb_albumartistid']) albums_by_artist[artist].append(alb) total_missing = 0 # build dict mapping artist to list of all albums for artist, albums in albums_by_artist.items(): if artist[1] is None or artist[1] == "": albs_no_mbid = ["'" + a['album'] + "'" for a in albums] self._log.info( "No musicbrainz ID for artist '{}' found in album(s) {}; " "skipping", artist[0], ", ".join(albs_no_mbid) ) continue try: resp = musicbrainzngs.browse_release_groups(artist=artist[1]) release_groups = resp['release-group-list'] except MusicBrainzError as err: self._log.info( "Couldn't fetch info for artist '{}' ({}) - '{}'", artist[0], artist[1], err ) continue missing = [] present = [] for rg in release_groups: missing.append(rg) for alb in albums: if alb['mb_releasegroupid'] == rg['id']: missing.remove(rg) present.append(rg) break total_missing += len(missing) if total: continue missing_titles = {rg['title'] for rg in missing} for release_title in missing_titles: print_("{} - {}".format(artist[0], release_title)) if total: print(total_missing) def _missing(self, album): """Query MusicBrainz to determine items missing from `album`. """ item_mbids = [x.mb_trackid for x in album.items()] if len(list(album.items())) < album.albumtotal: # fetch missing items # TODO: Implement caching that without breaking other stuff album_info = hooks.album_for_mbid(album.mb_albumid) for track_info in getattr(album_info, 'tracks', []): if track_info.track_id not in item_mbids: item = _item(track_info, album_info, album.id) self._log.debug('track {0} in album {1}', track_info.track_id, album_info.album_id) yield item ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/mpdstats.py�������������������������������������������������������������������0000644�0000765�0000024�00000027766�00000000000�016552� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Peter Schnebel and Johann Klähn. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import mpd import time import os from beets import ui from beets import config from beets import plugins from beets import library from beets.util import displayable_path from beets.dbcore import types # If we lose the connection, how many times do we want to retry and how # much time should we wait between retries? RETRIES = 10 RETRY_INTERVAL = 5 mpd_config = config['mpd'] def is_url(path): """Try to determine if the path is an URL. """ if isinstance(path, bytes): # if it's bytes, then it's a path return False return path.split('://', 1)[0] in ['http', 'https'] class MPDClientWrapper: def __init__(self, log): self._log = log self.music_directory = mpd_config['music_directory'].as_str() self.strip_path = mpd_config['strip_path'].as_str() # Ensure strip_path end with '/' if not self.strip_path.endswith('/'): self.strip_path += '/' self._log.debug('music_directory: {0}', self.music_directory) self._log.debug('strip_path: {0}', self.strip_path) self.client = mpd.MPDClient() def connect(self): """Connect to the MPD. """ host = mpd_config['host'].as_str() port = mpd_config['port'].get(int) if host[0] in ['/', '~']: host = os.path.expanduser(host) self._log.info('connecting to {0}:{1}', host, port) try: self.client.connect(host, port) except OSError as e: raise ui.UserError(f'could not connect to MPD: {e}') password = mpd_config['password'].as_str() if password: try: self.client.password(password) except mpd.CommandError as e: raise ui.UserError( f'could not authenticate to MPD: {e}' ) def disconnect(self): """Disconnect from the MPD. """ self.client.close() self.client.disconnect() def get(self, command, retries=RETRIES): """Wrapper for requests to the MPD server. Tries to re-connect if the connection was lost (f.ex. during MPD's library refresh). """ try: return getattr(self.client, command)() except (OSError, mpd.ConnectionError) as err: self._log.error('{0}', err) if retries <= 0: # if we exited without breaking, we couldn't reconnect in time :( raise ui.UserError('communication with MPD server failed') time.sleep(RETRY_INTERVAL) try: self.disconnect() except mpd.ConnectionError: pass self.connect() return self.get(command, retries=retries - 1) def currentsong(self): """Return the path to the currently playing song, along with its songid. Prefixes paths with the music_directory, to get the absolute path. In some cases, we need to remove the local path from MPD server, we replace 'strip_path' with ''. `strip_path` defaults to ''. """ result = None entry = self.get('currentsong') if 'file' in entry: if not is_url(entry['file']): file = entry['file'] if file.startswith(self.strip_path): file = file[len(self.strip_path):] result = os.path.join(self.music_directory, file) else: result = entry['file'] self._log.debug('returning: {0}', result) return result, entry.get('id') def status(self): """Return the current status of the MPD. """ return self.get('status') def events(self): """Return list of events. This may block a long time while waiting for an answer from MPD. """ return self.get('idle') class MPDStats: def __init__(self, lib, log): self.lib = lib self._log = log self.do_rating = mpd_config['rating'].get(bool) self.rating_mix = mpd_config['rating_mix'].get(float) self.time_threshold = 10.0 # TODO: maybe add config option? self.now_playing = None self.mpd = MPDClientWrapper(log) def rating(self, play_count, skip_count, rating, skipped): """Calculate a new rating for a song based on play count, skip count, old rating and the fact if it was skipped or not. """ if skipped: rolling = (rating - rating / 2.0) else: rolling = (rating + (1.0 - rating) / 2.0) stable = (play_count + 1.0) / (play_count + skip_count + 2.0) return (self.rating_mix * stable + (1.0 - self.rating_mix) * rolling) def get_item(self, path): """Return the beets item related to path. """ query = library.PathQuery('path', path) item = self.lib.items(query).get() if item: return item else: self._log.info('item not found: {0}', displayable_path(path)) def update_item(self, item, attribute, value=None, increment=None): """Update the beets item. Set attribute to value or increment the value of attribute. If the increment argument is used the value is cast to the corresponding type. """ if item is None: return if increment is not None: item.load() value = type(increment)(item.get(attribute, 0)) + increment if value is not None: item[attribute] = value item.store() self._log.debug('updated: {0} = {1} [{2}]', attribute, item[attribute], displayable_path(item.path)) def update_rating(self, item, skipped): """Update the rating for a beets item. The `item` can either be a beets `Item` or None. If the item is None, nothing changes. """ if item is None: return item.load() rating = self.rating( int(item.get('play_count', 0)), int(item.get('skip_count', 0)), float(item.get('rating', 0.5)), skipped) self.update_item(item, 'rating', rating) def handle_song_change(self, song): """Determine if a song was skipped or not and update its attributes. To this end the difference between the song's supposed end time and the current time is calculated. If it's greater than a threshold, the song is considered skipped. Returns whether the change was manual (skipped previous song or not) """ diff = abs(song['remaining'] - (time.time() - song['started'])) skipped = diff >= self.time_threshold if skipped: self.handle_skipped(song) else: self.handle_played(song) if self.do_rating: self.update_rating(song['beets_item'], skipped) return skipped def handle_played(self, song): """Updates the play count of a song. """ self.update_item(song['beets_item'], 'play_count', increment=1) self._log.info('played {0}', displayable_path(song['path'])) def handle_skipped(self, song): """Updates the skip count of a song. """ self.update_item(song['beets_item'], 'skip_count', increment=1) self._log.info('skipped {0}', displayable_path(song['path'])) def on_stop(self, status): self._log.info('stop') # if the current song stays the same it means that we stopped on the # current track and should not record a skip. if self.now_playing and self.now_playing['id'] != status.get('songid'): self.handle_song_change(self.now_playing) self.now_playing = None def on_pause(self, status): self._log.info('pause') self.now_playing = None def on_play(self, status): path, songid = self.mpd.currentsong() if not path: return played, duration = map(int, status['time'].split(':', 1)) remaining = duration - played if self.now_playing: if self.now_playing['path'] != path: self.handle_song_change(self.now_playing) else: # In case we got mpd play event with same song playing # multiple times, # assume low diff means redundant second play event # after natural song start. diff = abs(time.time() - self.now_playing['started']) if diff <= self.time_threshold: return if self.now_playing['path'] == path and played == 0: self.handle_song_change(self.now_playing) if is_url(path): self._log.info('playing stream {0}', displayable_path(path)) self.now_playing = None return self._log.info('playing {0}', displayable_path(path)) self.now_playing = { 'started': time.time(), 'remaining': remaining, 'path': path, 'id': songid, 'beets_item': self.get_item(path), } self.update_item(self.now_playing['beets_item'], 'last_played', value=int(time.time())) def run(self): self.mpd.connect() events = ['player'] while True: if 'player' in events: status = self.mpd.status() handler = getattr(self, 'on_' + status['state'], None) if handler: handler(status) else: self._log.debug('unhandled status "{0}"', status) events = self.mpd.events() class MPDStatsPlugin(plugins.BeetsPlugin): item_types = { 'play_count': types.INTEGER, 'skip_count': types.INTEGER, 'last_played': library.DateType(), 'rating': types.FLOAT, } def __init__(self): super().__init__() mpd_config.add({ 'music_directory': config['directory'].as_filename(), 'strip_path': '', 'rating': True, 'rating_mix': 0.75, 'host': os.environ.get('MPD_HOST', 'localhost'), 'port': int(os.environ.get('MPD_PORT', 6600)), 'password': '', }) mpd_config['password'].redact = True def commands(self): cmd = ui.Subcommand( 'mpdstats', help='run a MPD client to gather play statistics') cmd.parser.add_option( '--host', dest='host', type='string', help='set the hostname of the server to connect to') cmd.parser.add_option( '--port', dest='port', type='int', help='set the port of the MPD server to connect to') cmd.parser.add_option( '--password', dest='password', type='string', help='set the password of the MPD server to connect to') def func(lib, opts, args): mpd_config.set_args(opts) # Overrides for MPD settings. if opts.host: mpd_config['host'] = opts.host.decode('utf-8') if opts.port: mpd_config['host'] = int(opts.port) if opts.password: mpd_config['password'] = opts.password.decode('utf-8') try: MPDStats(lib, self._log).run() except KeyboardInterrupt: pass cmd.func = func return [cmd] ����������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/mpdupdate.py������������������������������������������������������������������0000644�0000765�0000024�00000010031�00000000000�016647� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Updates an MPD index whenever the library is changed. Put something like the following in your config.yaml to configure: mpd: host: localhost port: 6600 password: seekrit """ from beets.plugins import BeetsPlugin import os import socket from beets import config # No need to introduce a dependency on an MPD library for such a # simple use case. Here's a simple socket abstraction to make things # easier. class BufferedSocket: """Socket abstraction that allows reading by line.""" def __init__(self, host, port, sep=b'\n'): if host[0] in ['/', '~']: self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.connect(os.path.expanduser(host)) else: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((host, port)) self.buf = b'' self.sep = sep def readline(self): while self.sep not in self.buf: data = self.sock.recv(1024) if not data: break self.buf += data if self.sep in self.buf: res, self.buf = self.buf.split(self.sep, 1) return res + self.sep else: return b'' def send(self, data): self.sock.send(data) def close(self): self.sock.close() class MPDUpdatePlugin(BeetsPlugin): def __init__(self): super().__init__() config['mpd'].add({ 'host': os.environ.get('MPD_HOST', 'localhost'), 'port': int(os.environ.get('MPD_PORT', 6600)), 'password': '', }) config['mpd']['password'].redact = True # For backwards compatibility, use any values from the # plugin-specific "mpdupdate" section. for key in config['mpd'].keys(): if self.config[key].exists(): config['mpd'][key] = self.config[key].get() self.register_listener('database_change', self.db_change) def db_change(self, lib, model): self.register_listener('cli_exit', self.update) def update(self, lib): self.update_mpd( config['mpd']['host'].as_str(), config['mpd']['port'].get(int), config['mpd']['password'].as_str(), ) def update_mpd(self, host='localhost', port=6600, password=None): """Sends the "update" command to the MPD server indicated, possibly authenticating with a password first. """ self._log.info('Updating MPD database...') try: s = BufferedSocket(host, port) except OSError as e: self._log.warning('MPD connection failed: {0}', str(e.strerror)) return resp = s.readline() if b'OK MPD' not in resp: self._log.warning('MPD connection failed: {0!r}', resp) return if password: s.send(b'password "%s"\n' % password.encode('utf8')) resp = s.readline() if b'OK' not in resp: self._log.warning('Authentication failed: {0!r}', resp) s.send(b'close\n') s.close() return s.send(b'update\n') resp = s.readline() if b'updating_db' not in resp: self._log.warning('Update failed: {0!r}', resp) s.send(b'close\n') s.close() self._log.info('Database updated.') �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/parentwork.py�����������������������������������������������������������������0000644�0000765�0000024�00000017414�00000000000�017074� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2017, Dorian Soergel. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Gets parent work, its disambiguation and id, composer, composer sort name and work composition date """ from beets import ui from beets.plugins import BeetsPlugin import musicbrainzngs def direct_parent_id(mb_workid, work_date=None): """Given a Musicbrainz work id, find the id one of the works the work is part of and the first composition date it encounters. """ work_info = musicbrainzngs.get_work_by_id(mb_workid, includes=["work-rels", "artist-rels"]) if 'artist-relation-list' in work_info['work'] and work_date is None: for artist in work_info['work']['artist-relation-list']: if artist['type'] == 'composer': if 'end' in artist.keys(): work_date = artist['end'] if 'work-relation-list' in work_info['work']: for direct_parent in work_info['work']['work-relation-list']: if direct_parent['type'] == 'parts' \ and direct_parent.get('direction') == 'backward': direct_id = direct_parent['work']['id'] return direct_id, work_date return None, work_date def work_parent_id(mb_workid): """Find the parent work id and composition date of a work given its id. """ work_date = None while True: new_mb_workid, work_date = direct_parent_id(mb_workid, work_date) if not new_mb_workid: return mb_workid, work_date mb_workid = new_mb_workid return mb_workid, work_date def find_parentwork_info(mb_workid): """Get the MusicBrainz information dict about a parent work, including the artist relations, and the composition date for a work's parent work. """ parent_id, work_date = work_parent_id(mb_workid) work_info = musicbrainzngs.get_work_by_id(parent_id, includes=["artist-rels"]) return work_info, work_date class ParentWorkPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'auto': False, 'force': False, }) if self.config['auto']: self.import_stages = [self.imported] def commands(self): def func(lib, opts, args): self.config.set_args(opts) force_parent = self.config['force'].get(bool) write = ui.should_write() for item in lib.items(ui.decargs(args)): changed = self.find_work(item, force_parent) if changed: item.store() if write: item.try_write() command = ui.Subcommand( 'parentwork', help='fetch parent works, composers and dates') command.parser.add_option( '-f', '--force', dest='force', action='store_true', default=None, help='re-fetch when parent work is already present') command.func = func return [command] def imported(self, session, task): """Import hook for fetching parent works automatically. """ force_parent = self.config['force'].get(bool) for item in task.imported_items(): self.find_work(item, force_parent) item.store() def get_info(self, item, work_info): """Given the parent work info dict, fetch parent_composer, parent_composer_sort, parentwork, parentwork_disambig, mb_workid and composer_ids. """ parent_composer = [] parent_composer_sort = [] parentwork_info = {} composer_exists = False if 'artist-relation-list' in work_info['work']: for artist in work_info['work']['artist-relation-list']: if artist['type'] == 'composer': composer_exists = True parent_composer.append(artist['artist']['name']) parent_composer_sort.append(artist['artist']['sort-name']) if 'end' in artist.keys(): parentwork_info["parentwork_date"] = artist['end'] parentwork_info['parent_composer'] = ', '.join(parent_composer) parentwork_info['parent_composer_sort'] = ', '.join( parent_composer_sort) if not composer_exists: self._log.debug( 'no composer for {}; add one at ' 'https://musicbrainz.org/work/{}', item, work_info['work']['id'], ) parentwork_info['parentwork'] = work_info['work']['title'] parentwork_info['mb_parentworkid'] = work_info['work']['id'] if 'disambiguation' in work_info['work']: parentwork_info['parentwork_disambig'] = work_info[ 'work']['disambiguation'] else: parentwork_info['parentwork_disambig'] = None return parentwork_info def find_work(self, item, force): """Finds the parent work of a recording and populates the tags accordingly. The parent work is found recursively, by finding the direct parent repeatedly until there are no more links in the chain. We return the final, topmost work in the chain. Namely, the tags parentwork, parentwork_disambig, mb_parentworkid, parent_composer, parent_composer_sort and work_date are populated. """ if not item.mb_workid: self._log.info('No work for {}, \ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid) return hasparent = hasattr(item, 'parentwork') work_changed = True if hasattr(item, 'parentwork_workid_current'): work_changed = item.parentwork_workid_current != item.mb_workid if force or not hasparent or work_changed: try: work_info, work_date = find_parentwork_info(item.mb_workid) except musicbrainzngs.musicbrainz.WebServiceError as e: self._log.debug("error fetching work: {}", e) return parent_info = self.get_info(item, work_info) parent_info['parentwork_workid_current'] = item.mb_workid if 'parent_composer' in parent_info: self._log.debug("Work fetched: {} - {}", parent_info['parentwork'], parent_info['parent_composer']) else: self._log.debug("Work fetched: {} - no parent composer", parent_info['parentwork']) elif hasparent: self._log.debug("{}: Work present, skipping", item) return # apply all non-null values to the item for key, value in parent_info.items(): if value: item[key] = value if work_date: item['work_date'] = work_date return ui.show_model_changes( item, fields=['parentwork', 'parentwork_disambig', 'mb_parentworkid', 'parent_composer', 'parent_composer_sort', 'work_date', 'parentwork_workid_current', 'parentwork_date']) ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/permissions.py����������������������������������������������������������������0000644�0000765�0000024�00000010014�00000000000�017240� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Fixes file permissions after the file gets written on import. Put something like the following in your config.yaml to configure: permissions: file: 644 dir: 755 """ import os from beets import config, util from beets.plugins import BeetsPlugin from beets.util import ancestry def convert_perm(perm): """Convert a string to an integer, interpreting the text as octal. Or, if `perm` is an integer, reinterpret it as an octal number that has been "misinterpreted" as decimal. """ if isinstance(perm, int): perm = str(perm) return int(perm, 8) def check_permissions(path, permission): """Check whether the file's permissions equal the given vector. Return a boolean. """ return oct(os.stat(path).st_mode & 0o777) == oct(permission) def assert_permissions(path, permission, log): """Check whether the file's permissions are as expected, otherwise, log a warning message. Return a boolean indicating the match, like `check_permissions`. """ if not check_permissions(util.syspath(path), permission): log.warning( 'could not set permissions on {}', util.displayable_path(path), ) log.debug( 'set permissions to {}, but permissions are now {}', permission, os.stat(util.syspath(path)).st_mode & 0o777, ) def dirs_in_library(library, item): """Creates a list of ancestor directories in the beets library path. """ return [ancestor for ancestor in ancestry(item) if ancestor.startswith(library)][1:] class Permissions(BeetsPlugin): def __init__(self): super().__init__() # Adding defaults. self.config.add({ 'file': '644', 'dir': '755', }) self.register_listener('item_imported', self.fix) self.register_listener('album_imported', self.fix) self.register_listener('art_set', self.fix_art) def fix(self, lib, item=None, album=None): """Fix the permissions for an imported Item or Album. """ files = [] dirs = set() if item: files.append(item.path) dirs.update(dirs_in_library(lib.directory, item.path)) elif album: for album_item in album.items(): files.append(album_item.path) dirs.update(dirs_in_library(lib.directory, album_item.path)) self.set_permissions(files=files, dirs=dirs) def fix_art(self, album): """Fix the permission for Album art file. """ if album.artpath: self.set_permissions(files=[album.artpath]) def set_permissions(self, files=[], dirs=[]): # Get the configured permissions. The user can specify this either a # string (in YAML quotes) or, for convenience, as an integer so the # quotes can be omitted. In the latter case, we need to reinterpret the # integer as octal, not decimal. file_perm = config['permissions']['file'].get() dir_perm = config['permissions']['dir'].get() file_perm = convert_perm(file_perm) dir_perm = convert_perm(dir_perm) for path in files: # Changing permissions on the destination file. self._log.debug( 'setting file permissions on {}', util.displayable_path(path), ) os.chmod(util.syspath(path), file_perm) # Checks if the destination path has the permissions configured. assert_permissions(path, file_perm, self._log) # Change permissions for the directories. for path in dirs: # Changing permissions on the destination directory. self._log.debug( 'setting directory permissions on {}', util.displayable_path(path), ) os.chmod(util.syspath(path), dir_perm) # Checks if the destination path has the permissions configured. assert_permissions(path, dir_perm, self._log) ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/play.py�����������������������������������������������������������������������0000644�0000765�0000024�00000016775�00000000000�015656� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, David Hamp-Gonsalves # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Send the results of a query to the configured music player as a playlist. """ from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets.ui.commands import PromptChoice from beets import config from beets import ui from beets import util from os.path import relpath from tempfile import NamedTemporaryFile import subprocess import shlex # Indicate where arguments should be inserted into the command string. # If this is missing, they're placed at the end. ARGS_MARKER = '$args' def play(command_str, selection, paths, open_args, log, item_type='track', keep_open=False): """Play items in paths with command_str and optional arguments. If keep_open, return to beets, otherwise exit once command runs. """ # Print number of tracks or albums to be played, log command to be run. item_type += 's' if len(selection) > 1 else '' ui.print_('Playing {} {}.'.format(len(selection), item_type)) log.debug('executing command: {} {!r}', command_str, open_args) try: if keep_open: command = shlex.split(command_str) command = command + open_args subprocess.call(command) else: util.interactive_open(open_args, command_str) except OSError as exc: raise ui.UserError( f"Could not play the query: {exc}") class PlayPlugin(BeetsPlugin): def __init__(self): super().__init__() config['play'].add({ 'command': None, 'use_folders': False, 'relative_to': None, 'raw': False, 'warning_threshold': 100, 'bom': False, }) self.register_listener('before_choose_candidate', self.before_choose_candidate_listener) def commands(self): play_command = Subcommand( 'play', help='send music to a player as a playlist' ) play_command.parser.add_album_option() play_command.parser.add_option( '-A', '--args', action='store', help='add additional arguments to the command', ) play_command.parser.add_option( '-y', '--yes', action="store_true", help='skip the warning threshold', ) play_command.func = self._play_command return [play_command] def _play_command(self, lib, opts, args): """The CLI command function for `beet play`. Create a list of paths from query, determine if tracks or albums are to be played. """ use_folders = config['play']['use_folders'].get(bool) relative_to = config['play']['relative_to'].get() if relative_to: relative_to = util.normpath(relative_to) # Perform search by album and add folders rather than tracks to # playlist. if opts.album: selection = lib.albums(ui.decargs(args)) paths = [] sort = lib.get_default_album_sort() for album in selection: if use_folders: paths.append(album.item_dir()) else: paths.extend(item.path for item in sort.sort(album.items())) item_type = 'album' # Perform item query and add tracks to playlist. else: selection = lib.items(ui.decargs(args)) paths = [item.path for item in selection] item_type = 'track' if relative_to: paths = [relpath(path, relative_to) for path in paths] if not selection: ui.print_(ui.colorize('text_warning', f'No {item_type} to play.')) return open_args = self._playlist_or_paths(paths) command_str = self._command_str(opts.args) # Check if the selection exceeds configured threshold. If True, # cancel, otherwise proceed with play command. if opts.yes or not self._exceeds_threshold( selection, command_str, open_args, item_type): play(command_str, selection, paths, open_args, self._log, item_type) def _command_str(self, args=None): """Create a command string from the config command and optional args. """ command_str = config['play']['command'].get() if not command_str: return util.open_anything() # Add optional arguments to the player command. if args: if ARGS_MARKER in command_str: return command_str.replace(ARGS_MARKER, args) else: return f"{command_str} {args}" else: # Don't include the marker in the command. return command_str.replace(" " + ARGS_MARKER, "") def _playlist_or_paths(self, paths): """Return either the raw paths of items or a playlist of the items. """ if config['play']['raw']: return paths else: return [self._create_tmp_playlist(paths)] def _exceeds_threshold(self, selection, command_str, open_args, item_type='track'): """Prompt user whether to abort if playlist exceeds threshold. If True, cancel playback. If False, execute play command. """ warning_threshold = config['play']['warning_threshold'].get(int) # Warn user before playing any huge playlists. if warning_threshold and len(selection) > warning_threshold: if len(selection) > 1: item_type += 's' ui.print_(ui.colorize( 'text_warning', 'You are about to queue {} {}.'.format( len(selection), item_type))) if ui.input_options(('Continue', 'Abort')) == 'a': return True return False def _create_tmp_playlist(self, paths_list): """Create a temporary .m3u file. Return the filename. """ utf8_bom = config['play']['bom'].get(bool) m3u = NamedTemporaryFile('wb', suffix='.m3u', delete=False) if utf8_bom: m3u.write(b'\xEF\xBB\xBF') for item in paths_list: m3u.write(item + b'\n') m3u.close() return m3u.name def before_choose_candidate_listener(self, session, task): """Append a "Play" choice to the interactive importer prompt. """ return [PromptChoice('y', 'plaY', self.importer_play)] def importer_play(self, session, task): """Get items from current import task and send to play function. """ selection = task.items paths = [item.path for item in selection] open_args = self._playlist_or_paths(paths) command_str = self._command_str() if not self._exceeds_threshold(selection, command_str, open_args): play(command_str, selection, paths, open_args, self._log, keep_open=True) ���././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/playlist.py�������������������������������������������������������������������0000644�0000765�0000024�00000015271�00000000000�016540� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os import fnmatch import tempfile import beets from beets.util import path_as_posix class PlaylistQuery(beets.dbcore.Query): """Matches files listed by a playlist file. """ def __init__(self, pattern): self.pattern = pattern config = beets.config['playlist'] # Get the full path to the playlist playlist_paths = ( pattern, os.path.abspath(os.path.join( config['playlist_dir'].as_filename(), f'{pattern}.m3u', )), ) self.paths = [] for playlist_path in playlist_paths: if not fnmatch.fnmatch(playlist_path, '*.[mM]3[uU]'): # This is not am M3U playlist, skip this candidate continue try: f = open(beets.util.syspath(playlist_path), mode='rb') except OSError: continue if config['relative_to'].get() == 'library': relative_to = beets.config['directory'].as_filename() elif config['relative_to'].get() == 'playlist': relative_to = os.path.dirname(playlist_path) else: relative_to = config['relative_to'].as_filename() relative_to = beets.util.bytestring_path(relative_to) for line in f: if line[0] == '#': # ignore comments, and extm3u extension continue self.paths.append(beets.util.normpath( os.path.join(relative_to, line.rstrip()) )) f.close() break def col_clause(self): if not self.paths: # Playlist is empty return '0', () clause = 'path IN ({})'.format(', '.join('?' for path in self.paths)) return clause, (beets.library.BLOB_TYPE(p) for p in self.paths) def match(self, item): return item.path in self.paths class PlaylistPlugin(beets.plugins.BeetsPlugin): item_queries = {'playlist': PlaylistQuery} def __init__(self): super().__init__() self.config.add({ 'auto': False, 'playlist_dir': '.', 'relative_to': 'library', 'forward_slash': False, }) self.playlist_dir = self.config['playlist_dir'].as_filename() self.changes = {} if self.config['relative_to'].get() == 'library': self.relative_to = beets.util.bytestring_path( beets.config['directory'].as_filename()) elif self.config['relative_to'].get() != 'playlist': self.relative_to = beets.util.bytestring_path( self.config['relative_to'].as_filename()) else: self.relative_to = None if self.config['auto']: self.register_listener('item_moved', self.item_moved) self.register_listener('item_removed', self.item_removed) self.register_listener('cli_exit', self.cli_exit) def item_moved(self, item, source, destination): self.changes[source] = destination def item_removed(self, item): if not os.path.exists(beets.util.syspath(item.path)): self.changes[item.path] = None def cli_exit(self, lib): for playlist in self.find_playlists(): self._log.info(f'Updating playlist: {playlist}') base_dir = beets.util.bytestring_path( self.relative_to if self.relative_to else os.path.dirname(playlist) ) try: self.update_playlist(playlist, base_dir) except beets.util.FilesystemError: self._log.error('Failed to update playlist: {}'.format( beets.util.displayable_path(playlist))) def find_playlists(self): """Find M3U playlists in the playlist directory.""" try: dir_contents = os.listdir(beets.util.syspath(self.playlist_dir)) except OSError: self._log.warning('Unable to open playlist directory {}'.format( beets.util.displayable_path(self.playlist_dir))) return for filename in dir_contents: if fnmatch.fnmatch(filename, '*.[mM]3[uU]'): yield os.path.join(self.playlist_dir, filename) def update_playlist(self, filename, base_dir): """Find M3U playlists in the specified directory.""" changes = 0 deletions = 0 with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as tempfp: new_playlist = tempfp.name with open(filename, mode='rb') as fp: for line in fp: original_path = line.rstrip(b'\r\n') # Ensure that path from playlist is absolute is_relative = not os.path.isabs(line) if is_relative: lookup = os.path.join(base_dir, original_path) else: lookup = original_path try: new_path = self.changes[beets.util.normpath(lookup)] except KeyError: if self.config['forward_slash']: line = path_as_posix(line) tempfp.write(line) else: if new_path is None: # Item has been deleted deletions += 1 continue changes += 1 if is_relative: new_path = os.path.relpath(new_path, base_dir) line = line.replace(original_path, new_path) if self.config['forward_slash']: line = path_as_posix(line) tempfp.write(line) if changes or deletions: self._log.info( 'Updated playlist {} ({} changes, {} deletions)'.format( filename, changes, deletions)) beets.util.copy(new_playlist, filename, replace=True) beets.util.remove(new_playlist) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/plexupdate.py�����������������������������������������������������������������0000644�0000765�0000024�00000006761�00000000000�017056� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Updates an Plex library whenever the beets library is changed. Plex Home users enter the Plex Token to enable updating. Put something like the following in your config.yaml to configure: plex: host: localhost port: 32400 token: token """ import requests from xml.etree import ElementTree from urllib.parse import urljoin, urlencode from beets import config from beets.plugins import BeetsPlugin def get_music_section(host, port, token, library_name, secure, ignore_cert_errors): """Getting the section key for the music library in Plex. """ api_endpoint = append_token('library/sections', token) url = urljoin('{}://{}:{}'.format(get_protocol(secure), host, port), api_endpoint) # Sends request. r = requests.get(url, verify=not ignore_cert_errors) # Parse xml tree and extract music section key. tree = ElementTree.fromstring(r.content) for child in tree.findall('Directory'): if child.get('title') == library_name: return child.get('key') def update_plex(host, port, token, library_name, secure, ignore_cert_errors): """Ignore certificate errors if configured to. """ if ignore_cert_errors: import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) """Sends request to the Plex api to start a library refresh. """ # Getting section key and build url. section_key = get_music_section(host, port, token, library_name, secure, ignore_cert_errors) api_endpoint = f'library/sections/{section_key}/refresh' api_endpoint = append_token(api_endpoint, token) url = urljoin('{}://{}:{}'.format(get_protocol(secure), host, port), api_endpoint) # Sends request and returns requests object. r = requests.get(url, verify=not ignore_cert_errors) return r def append_token(url, token): """Appends the Plex Home token to the api call if required. """ if token: url += '?' + urlencode({'X-Plex-Token': token}) return url def get_protocol(secure): if secure: return 'https' else: return 'http' class PlexUpdate(BeetsPlugin): def __init__(self): super().__init__() # Adding defaults. config['plex'].add({ 'host': 'localhost', 'port': 32400, 'token': '', 'library_name': 'Music', 'secure': False, 'ignore_cert_errors': False}) config['plex']['token'].redact = True self.register_listener('database_change', self.listen_for_db_change) def listen_for_db_change(self, lib, model): """Listens for beets db change and register the update for the end""" self.register_listener('cli_exit', self.update) def update(self, lib): """When the client exists try to send refresh request to Plex server. """ self._log.info('Updating Plex library...') # Try to send update request. try: update_plex( config['plex']['host'].get(), config['plex']['port'].get(), config['plex']['token'].get(), config['plex']['library_name'].get(), config['plex']['secure'].get(bool), config['plex']['ignore_cert_errors'].get(bool)) self._log.info('... started.') except requests.exceptions.RequestException: self._log.warning('Update failed.') ���������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/random.py���������������������������������������������������������������������0000644�0000765�0000024�00000003645�00000000000�016161� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Get a random song or album from the library. """ from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs, print_ from beets.random import random_objs def random_func(lib, opts, args): """Select some random items or albums and print the results. """ # Fetch all the objects matching the query into a list. query = decargs(args) if opts.album: objs = list(lib.albums(query)) else: objs = list(lib.items(query)) # Print a random subset. objs = random_objs(objs, opts.album, opts.number, opts.time, opts.equal_chance) for obj in objs: print_(format(obj)) random_cmd = Subcommand('random', help='choose a random track or album') random_cmd.parser.add_option( '-n', '--number', action='store', type="int", help='number of objects to choose', default=1) random_cmd.parser.add_option( '-e', '--equal-chance', action='store_true', help='each artist has the same chance') random_cmd.parser.add_option( '-t', '--time', action='store', type="float", help='total length in minutes of objects to choose') random_cmd.parser.add_all_common_options() random_cmd.func = random_func class Random(BeetsPlugin): def commands(self): return [random_cmd] �������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/replaygain.py�����������������������������������������������������������������0000644�0000765�0000024�00000140063�00000000000�017030� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import collections import enum import math import os import signal import subprocess import sys import warnings from multiprocessing.pool import ThreadPool, RUN from six.moves import queue from threading import Thread, Event from beets import ui from beets.plugins import BeetsPlugin from beets.util import (syspath, command_output, displayable_path, py3_path, cpu_count) # Utilities. class ReplayGainError(Exception): """Raised when a local (to a track or an album) error occurs in one of the backends. """ class FatalReplayGainError(Exception): """Raised when a fatal error occurs in one of the backends. """ class FatalGstreamerPluginReplayGainError(FatalReplayGainError): """Raised when a fatal error occurs in the GStreamerBackend when loading the required plugins.""" def call(args, **kwargs): """Execute the command and return its output or raise a ReplayGainError on failure. """ try: return command_output(args, **kwargs) except subprocess.CalledProcessError as e: raise ReplayGainError( "{} exited with status {}".format(args[0], e.returncode) ) except UnicodeEncodeError: # Due to a bug in Python 2's subprocess on Windows, Unicode # filenames can fail to encode on that platform. See: # https://github.com/google-code-export/beets/issues/499 raise ReplayGainError("argument encoding failed") def after_version(version_a, version_b): return tuple(int(s) for s in version_a.split('.')) \ >= tuple(int(s) for s in version_b.split('.')) def db_to_lufs(db): """Convert db to LUFS. According to https://wiki.hydrogenaud.io/index.php?title= ReplayGain_2.0_specification#Reference_level """ return db - 107 def lufs_to_db(db): """Convert LUFS to db. According to https://wiki.hydrogenaud.io/index.php?title= ReplayGain_2.0_specification#Reference_level """ return db + 107 # Backend base and plumbing classes. # gain: in LU to reference level # peak: part of full scale (FS is 1.0) Gain = collections.namedtuple("Gain", "gain peak") # album_gain: Gain object # track_gains: list of Gain objects AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains") class Peak(enum.Enum): none = 0 true = 1 sample = 2 class Backend: """An abstract class representing engine for calculating RG values. """ do_parallel = False def __init__(self, config, log): """Initialize the backend with the configuration view for the plugin. """ self._log = log def compute_track_gain(self, items, target_level, peak): """Computes the track gain of the given tracks, returns a list of Gain objects. """ raise NotImplementedError() def compute_album_gain(self, items, target_level, peak): """Computes the album gain of the given album, returns an AlbumGain object. """ raise NotImplementedError() # ffmpeg backend class FfmpegBackend(Backend): """A replaygain backend using ffmpeg's ebur128 filter. """ do_parallel = True def __init__(self, config, log): super().__init__(config, log) self._ffmpeg_path = "ffmpeg" # check that ffmpeg is installed try: ffmpeg_version_out = call([self._ffmpeg_path, "-version"]) except OSError: raise FatalReplayGainError( f"could not find ffmpeg at {self._ffmpeg_path}" ) incompatible_ffmpeg = True for line in ffmpeg_version_out.stdout.splitlines(): if line.startswith(b"configuration:"): if b"--enable-libebur128" in line: incompatible_ffmpeg = False if line.startswith(b"libavfilter"): version = line.split(b" ", 1)[1].split(b"/", 1)[0].split(b".") version = tuple(map(int, version)) if version >= (6, 67, 100): incompatible_ffmpeg = False if incompatible_ffmpeg: raise FatalReplayGainError( "Installed FFmpeg version does not support ReplayGain." "calculation. Either libavfilter version 6.67.100 or above or" "the --enable-libebur128 configuration option is required." ) def compute_track_gain(self, items, target_level, peak): """Computes the track gain of the given tracks, returns a list of Gain objects (the track gains). """ gains = [] for item in items: gains.append( self._analyse_item( item, target_level, peak, count_blocks=False, )[0] # take only the gain, discarding number of gating blocks ) return gains def compute_album_gain(self, items, target_level, peak): """Computes the album gain of the given album, returns an AlbumGain object. """ target_level_lufs = db_to_lufs(target_level) # analyse tracks # list of track Gain objects track_gains = [] # maximum peak album_peak = 0 # sum of BS.1770 gating block powers sum_powers = 0 # total number of BS.1770 gating blocks n_blocks = 0 for item in items: track_gain, track_n_blocks = self._analyse_item( item, target_level, peak ) track_gains.append(track_gain) # album peak is maximum track peak album_peak = max(album_peak, track_gain.peak) # prepare album_gain calculation # total number of blocks is sum of track blocks n_blocks += track_n_blocks # convert `LU to target_level` -> LUFS track_loudness = target_level_lufs - track_gain.gain # This reverses ITU-R BS.1770-4 p. 6 equation (5) to convert # from loudness to power. The result is the average gating # block power. track_power = 10**((track_loudness + 0.691) / 10) # Weight that average power by the number of gating blocks to # get the sum of all their powers. Add that to the sum of all # block powers in this album. sum_powers += track_power * track_n_blocks # calculate album gain if n_blocks > 0: # compare ITU-R BS.1770-4 p. 6 equation (5) # Album gain is the replaygain of the concatenation of all tracks. album_gain = -0.691 + 10 * math.log10(sum_powers / n_blocks) else: album_gain = -70 # convert LUFS -> `LU to target_level` album_gain = target_level_lufs - album_gain self._log.debug( "{}: gain {} LU, peak {}" .format(items, album_gain, album_peak) ) return AlbumGain(Gain(album_gain, album_peak), track_gains) def _construct_cmd(self, item, peak_method): """Construct the shell command to analyse items.""" return [ self._ffmpeg_path, "-nostats", "-hide_banner", "-i", item.path, "-map", "a:0", "-filter", f"ebur128=peak={peak_method}", "-f", "null", "-", ] def _analyse_item(self, item, target_level, peak, count_blocks=True): """Analyse item. Return a pair of a Gain object and the number of gating blocks above the threshold. If `count_blocks` is False, the number of gating blocks returned will be 0. """ target_level_lufs = db_to_lufs(target_level) peak_method = peak.name # call ffmpeg self._log.debug(f"analyzing {item}") cmd = self._construct_cmd(item, peak_method) self._log.debug( 'executing {0}', ' '.join(map(displayable_path, cmd)) ) output = call(cmd).stderr.splitlines() # parse output if peak == Peak.none: peak = 0 else: line_peak = self._find_line( output, f" {peak_method.capitalize()} peak:".encode(), start_line=len(output) - 1, step_size=-1, ) peak = self._parse_float( output[self._find_line( output, b" Peak:", line_peak, )] ) # convert TPFS -> part of FS peak = 10**(peak / 20) line_integrated_loudness = self._find_line( output, b" Integrated loudness:", start_line=len(output) - 1, step_size=-1, ) gain = self._parse_float( output[self._find_line( output, b" I:", line_integrated_loudness, )] ) # convert LUFS -> LU from target level gain = target_level_lufs - gain # count BS.1770 gating blocks n_blocks = 0 if count_blocks: gating_threshold = self._parse_float( output[self._find_line( output, b" Threshold:", start_line=line_integrated_loudness, )] ) for line in output: if not line.startswith(b"[Parsed_ebur128"): continue if line.endswith(b"Summary:"): continue line = line.split(b"M:", 1) if len(line) < 2: continue if self._parse_float(b"M: " + line[1]) >= gating_threshold: n_blocks += 1 self._log.debug( "{}: {} blocks over {} LUFS" .format(item, n_blocks, gating_threshold) ) self._log.debug( "{}: gain {} LU, peak {}" .format(item, gain, peak) ) return Gain(gain, peak), n_blocks def _find_line(self, output, search, start_line=0, step_size=1): """Return index of line beginning with `search`. Begins searching at index `start_line` in `output`. """ end_index = len(output) if step_size > 0 else -1 for i in range(start_line, end_index, step_size): if output[i].startswith(search): return i raise ReplayGainError( "ffmpeg output: missing {} after line {}" .format(repr(search), start_line) ) def _parse_float(self, line): """Extract a float from a key value pair in `line`. This format is expected: /[^:]:[[:space:]]*value.*/, where `value` is the float. """ # extract value value = line.split(b":", 1) if len(value) < 2: raise ReplayGainError( "ffmpeg output: expected key value pair, found {}" .format(line) ) value = value[1].lstrip() # strip unit value = value.split(b" ", 1)[0] # cast value to float try: return float(value) except ValueError: raise ReplayGainError( "ffmpeg output: expected float value, found {}" .format(value) ) # mpgain/aacgain CLI tool backend. class CommandBackend(Backend): do_parallel = True def __init__(self, config, log): super().__init__(config, log) config.add({ 'command': "", 'noclip': True, }) self.command = config["command"].as_str() if self.command: # Explicit executable path. if not os.path.isfile(self.command): raise FatalReplayGainError( 'replaygain command does not exist: {}'.format( self.command) ) else: # Check whether the program is in $PATH. for cmd in ('mp3gain', 'aacgain'): try: call([cmd, '-v']) self.command = cmd except OSError: pass if not self.command: raise FatalReplayGainError( 'no replaygain command found: install mp3gain or aacgain' ) self.noclip = config['noclip'].get(bool) def compute_track_gain(self, items, target_level, peak): """Computes the track gain of the given tracks, returns a list of TrackGain objects. """ supported_items = list(filter(self.format_supported, items)) output = self.compute_gain(supported_items, target_level, False) return output def compute_album_gain(self, items, target_level, peak): """Computes the album gain of the given album, returns an AlbumGain object. """ # TODO: What should be done when not all tracks in the album are # supported? supported_items = list(filter(self.format_supported, items)) if len(supported_items) != len(items): self._log.debug('tracks are of unsupported format') return AlbumGain(None, []) output = self.compute_gain(supported_items, target_level, True) return AlbumGain(output[-1], output[:-1]) def format_supported(self, item): """Checks whether the given item is supported by the selected tool. """ if 'mp3gain' in self.command and item.format != 'MP3': return False elif 'aacgain' in self.command and item.format not in ('MP3', 'AAC'): return False return True def compute_gain(self, items, target_level, is_album): """Computes the track or album gain of a list of items, returns a list of TrackGain objects. When computing album gain, the last TrackGain object returned is the album gain """ if len(items) == 0: self._log.debug('no supported tracks to analyze') return [] """Compute ReplayGain values and return a list of results dictionaries as given by `parse_tool_output`. """ # Construct shell command. The "-o" option makes the output # easily parseable (tab-delimited). "-s s" forces gain # recalculation even if tags are already present and disables # tag-writing; this turns the mp3gain/aacgain tool into a gain # calculator rather than a tag manipulator because we take care # of changing tags ourselves. cmd = [self.command, '-o', '-s', 's'] if self.noclip: # Adjust to avoid clipping. cmd = cmd + ['-k'] else: # Disable clipping warning. cmd = cmd + ['-c'] cmd = cmd + ['-d', str(int(target_level - 89))] cmd = cmd + [syspath(i.path) for i in items] self._log.debug('analyzing {0} files', len(items)) self._log.debug("executing {0}", " ".join(map(displayable_path, cmd))) output = call(cmd).stdout self._log.debug('analysis finished') return self.parse_tool_output(output, len(items) + (1 if is_album else 0)) def parse_tool_output(self, text, num_lines): """Given the tab-delimited output from an invocation of mp3gain or aacgain, parse the text and return a list of dictionaries containing information about each analyzed file. """ out = [] for line in text.split(b'\n')[1:num_lines + 1]: parts = line.split(b'\t') if len(parts) != 6 or parts[0] == b'File': self._log.debug('bad tool output: {0}', text) raise ReplayGainError('mp3gain failed') d = { 'file': parts[0], 'mp3gain': int(parts[1]), 'gain': float(parts[2]), 'peak': float(parts[3]) / (1 << 15), 'maxgain': int(parts[4]), 'mingain': int(parts[5]), } out.append(Gain(d['gain'], d['peak'])) return out # GStreamer-based backend. class GStreamerBackend(Backend): def __init__(self, config, log): super().__init__(config, log) self._import_gst() # Initialized a GStreamer pipeline of the form filesrc -> # decodebin -> audioconvert -> audioresample -> rganalysis -> # fakesink The connection between decodebin and audioconvert is # handled dynamically after decodebin figures out the type of # the input file. self._src = self.Gst.ElementFactory.make("filesrc", "src") self._decbin = self.Gst.ElementFactory.make("decodebin", "decbin") self._conv = self.Gst.ElementFactory.make("audioconvert", "conv") self._res = self.Gst.ElementFactory.make("audioresample", "res") self._rg = self.Gst.ElementFactory.make("rganalysis", "rg") if self._src is None or self._decbin is None or self._conv is None \ or self._res is None or self._rg is None: raise FatalGstreamerPluginReplayGainError( "Failed to load required GStreamer plugins" ) # We check which files need gain ourselves, so all files given # to rganalsys should have their gain computed, even if it # already exists. self._rg.set_property("forced", True) self._sink = self.Gst.ElementFactory.make("fakesink", "sink") self._pipe = self.Gst.Pipeline() self._pipe.add(self._src) self._pipe.add(self._decbin) self._pipe.add(self._conv) self._pipe.add(self._res) self._pipe.add(self._rg) self._pipe.add(self._sink) self._src.link(self._decbin) self._conv.link(self._res) self._res.link(self._rg) self._rg.link(self._sink) self._bus = self._pipe.get_bus() self._bus.add_signal_watch() self._bus.connect("message::eos", self._on_eos) self._bus.connect("message::error", self._on_error) self._bus.connect("message::tag", self._on_tag) # Needed for handling the dynamic connection between decodebin # and audioconvert self._decbin.connect("pad-added", self._on_pad_added) self._decbin.connect("pad-removed", self._on_pad_removed) self._main_loop = self.GLib.MainLoop() self._files = [] def _import_gst(self): """Import the necessary GObject-related modules and assign `Gst` and `GObject` fields on this object. """ try: import gi except ImportError: raise FatalReplayGainError( "Failed to load GStreamer: python-gi not found" ) try: gi.require_version('Gst', '1.0') except ValueError as e: raise FatalReplayGainError( f"Failed to load GStreamer 1.0: {e}" ) from gi.repository import GObject, Gst, GLib # Calling GObject.threads_init() is not needed for # PyGObject 3.10.2+ with warnings.catch_warnings(): warnings.simplefilter("ignore") GObject.threads_init() Gst.init([sys.argv[0]]) self.GObject = GObject self.GLib = GLib self.Gst = Gst def compute(self, files, target_level, album): self._error = None self._files = list(files) if len(self._files) == 0: return self._file_tags = collections.defaultdict(dict) self._rg.set_property("reference-level", target_level) if album: self._rg.set_property("num-tracks", len(self._files)) if self._set_first_file(): self._main_loop.run() if self._error is not None: raise self._error def compute_track_gain(self, items, target_level, peak): self.compute(items, target_level, False) if len(self._file_tags) != len(items): raise ReplayGainError("Some tracks did not receive tags") ret = [] for item in items: ret.append(Gain(self._file_tags[item]["TRACK_GAIN"], self._file_tags[item]["TRACK_PEAK"])) return ret def compute_album_gain(self, items, target_level, peak): items = list(items) self.compute(items, target_level, True) if len(self._file_tags) != len(items): raise ReplayGainError("Some items in album did not receive tags") # Collect track gains. track_gains = [] for item in items: try: gain = self._file_tags[item]["TRACK_GAIN"] peak = self._file_tags[item]["TRACK_PEAK"] except KeyError: raise ReplayGainError("results missing for track") track_gains.append(Gain(gain, peak)) # Get album gain information from the last track. last_tags = self._file_tags[items[-1]] try: gain = last_tags["ALBUM_GAIN"] peak = last_tags["ALBUM_PEAK"] except KeyError: raise ReplayGainError("results missing for album") return AlbumGain(Gain(gain, peak), track_gains) def close(self): self._bus.remove_signal_watch() def _on_eos(self, bus, message): # A file finished playing in all elements of the pipeline. The # RG tags have already been propagated. If we don't have a next # file, we stop processing. if not self._set_next_file(): self._pipe.set_state(self.Gst.State.NULL) self._main_loop.quit() def _on_error(self, bus, message): self._pipe.set_state(self.Gst.State.NULL) self._main_loop.quit() err, debug = message.parse_error() f = self._src.get_property("location") # A GStreamer error, either an unsupported format or a bug. self._error = ReplayGainError( f"Error {err!r} - {debug!r} on file {f!r}" ) def _on_tag(self, bus, message): tags = message.parse_tag() def handle_tag(taglist, tag, userdata): # The rganalysis element provides both the existing tags for # files and the new computes tags. In order to ensure we # store the computed tags, we overwrite the RG values of # received a second time. if tag == self.Gst.TAG_TRACK_GAIN: self._file_tags[self._file]["TRACK_GAIN"] = \ taglist.get_double(tag)[1] elif tag == self.Gst.TAG_TRACK_PEAK: self._file_tags[self._file]["TRACK_PEAK"] = \ taglist.get_double(tag)[1] elif tag == self.Gst.TAG_ALBUM_GAIN: self._file_tags[self._file]["ALBUM_GAIN"] = \ taglist.get_double(tag)[1] elif tag == self.Gst.TAG_ALBUM_PEAK: self._file_tags[self._file]["ALBUM_PEAK"] = \ taglist.get_double(tag)[1] elif tag == self.Gst.TAG_REFERENCE_LEVEL: self._file_tags[self._file]["REFERENCE_LEVEL"] = \ taglist.get_double(tag)[1] tags.foreach(handle_tag, None) def _set_first_file(self): if len(self._files) == 0: return False self._file = self._files.pop(0) self._pipe.set_state(self.Gst.State.NULL) self._src.set_property("location", py3_path(syspath(self._file.path))) self._pipe.set_state(self.Gst.State.PLAYING) return True def _set_file(self): """Initialize the filesrc element with the next file to be analyzed. """ # No more files, we're done if len(self._files) == 0: return False self._file = self._files.pop(0) # Ensure the filesrc element received the paused state of the # pipeline in a blocking manner self._src.sync_state_with_parent() self._src.get_state(self.Gst.CLOCK_TIME_NONE) # Ensure the decodebin element receives the paused state of the # pipeline in a blocking manner self._decbin.sync_state_with_parent() self._decbin.get_state(self.Gst.CLOCK_TIME_NONE) # Disconnect the decodebin element from the pipeline, set its # state to READY to to clear it. self._decbin.unlink(self._conv) self._decbin.set_state(self.Gst.State.READY) # Set a new file on the filesrc element, can only be done in the # READY state self._src.set_state(self.Gst.State.READY) self._src.set_property("location", py3_path(syspath(self._file.path))) self._decbin.link(self._conv) self._pipe.set_state(self.Gst.State.READY) return True def _set_next_file(self): """Set the next file to be analyzed while keeping the pipeline in the PAUSED state so that the rganalysis element can correctly handle album gain. """ # A blocking pause self._pipe.set_state(self.Gst.State.PAUSED) self._pipe.get_state(self.Gst.CLOCK_TIME_NONE) # Try setting the next file ret = self._set_file() if ret: # Seek to the beginning in order to clear the EOS state of the # various elements of the pipeline self._pipe.seek_simple(self.Gst.Format.TIME, self.Gst.SeekFlags.FLUSH, 0) self._pipe.set_state(self.Gst.State.PLAYING) return ret def _on_pad_added(self, decbin, pad): sink_pad = self._conv.get_compatible_pad(pad, None) assert(sink_pad is not None) pad.link(sink_pad) def _on_pad_removed(self, decbin, pad): # Called when the decodebin element is disconnected from the # rest of the pipeline while switching input files peer = pad.get_peer() assert(peer is None) class AudioToolsBackend(Backend): """ReplayGain backend that uses `Python Audio Tools <http://audiotools.sourceforge.net/>`_ and its capabilities to read more file formats and compute ReplayGain values using it replaygain module. """ def __init__(self, config, log): super().__init__(config, log) self._import_audiotools() def _import_audiotools(self): """Check whether it's possible to import the necessary modules. There is no check on the file formats at runtime. :raises :exc:`ReplayGainError`: if the modules cannot be imported """ try: import audiotools import audiotools.replaygain except ImportError: raise FatalReplayGainError( "Failed to load audiotools: audiotools not found" ) self._mod_audiotools = audiotools self._mod_replaygain = audiotools.replaygain def open_audio_file(self, item): """Open the file to read the PCM stream from the using ``item.path``. :return: the audiofile instance :rtype: :class:`audiotools.AudioFile` :raises :exc:`ReplayGainError`: if the file is not found or the file format is not supported """ try: audiofile = self._mod_audiotools.open(py3_path(syspath(item.path))) except OSError: raise ReplayGainError( f"File {item.path} was not found" ) except self._mod_audiotools.UnsupportedFile: raise ReplayGainError( f"Unsupported file type {item.format}" ) return audiofile def init_replaygain(self, audiofile, item): """Return an initialized :class:`audiotools.replaygain.ReplayGain` instance, which requires the sample rate of the song(s) on which the ReplayGain values will be computed. The item is passed in case the sample rate is invalid to log the stored item sample rate. :return: initialized replagain object :rtype: :class:`audiotools.replaygain.ReplayGain` :raises: :exc:`ReplayGainError` if the sample rate is invalid """ try: rg = self._mod_replaygain.ReplayGain(audiofile.sample_rate()) except ValueError: raise ReplayGainError( f"Unsupported sample rate {item.samplerate}") return return rg def compute_track_gain(self, items, target_level, peak): """Compute ReplayGain values for the requested items. :return list: list of :class:`Gain` objects """ return [self._compute_track_gain(item, target_level) for item in items] def _with_target_level(self, gain, target_level): """Return `gain` relative to `target_level`. Assumes `gain` is relative to 89 db. """ return gain + (target_level - 89) def _title_gain(self, rg, audiofile, target_level): """Get the gain result pair from PyAudioTools using the `ReplayGain` instance `rg` for the given `audiofile`. Wraps `rg.title_gain(audiofile.to_pcm())` and throws a `ReplayGainError` when the library fails. """ try: # The method needs an audiotools.PCMReader instance that can # be obtained from an audiofile instance. gain, peak = rg.title_gain(audiofile.to_pcm()) except ValueError as exc: # `audiotools.replaygain` can raise a `ValueError` if the sample # rate is incorrect. self._log.debug('error in rg.title_gain() call: {}', exc) raise ReplayGainError('audiotools audio data error') return self._with_target_level(gain, target_level), peak def _compute_track_gain(self, item, target_level): """Compute ReplayGain value for the requested item. :rtype: :class:`Gain` """ audiofile = self.open_audio_file(item) rg = self.init_replaygain(audiofile, item) # Each call to title_gain on a ReplayGain object returns peak and gain # of the track. rg_track_gain, rg_track_peak = self._title_gain( rg, audiofile, target_level ) self._log.debug('ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}', item.artist, item.title, rg_track_gain, rg_track_peak) return Gain(gain=rg_track_gain, peak=rg_track_peak) def compute_album_gain(self, items, target_level, peak): """Compute ReplayGain values for the requested album and its items. :rtype: :class:`AlbumGain` """ # The first item is taken and opened to get the sample rate to # initialize the replaygain object. The object is used for all the # tracks in the album to get the album values. item = list(items)[0] audiofile = self.open_audio_file(item) rg = self.init_replaygain(audiofile, item) track_gains = [] for item in items: audiofile = self.open_audio_file(item) rg_track_gain, rg_track_peak = self._title_gain( rg, audiofile, target_level ) track_gains.append( Gain(gain=rg_track_gain, peak=rg_track_peak) ) self._log.debug('ReplayGain for track {0}: {1:.2f}, {2:.2f}', item, rg_track_gain, rg_track_peak) # After getting the values for all tracks, it's possible to get the # album values. rg_album_gain, rg_album_peak = rg.album_gain() rg_album_gain = self._with_target_level(rg_album_gain, target_level) self._log.debug('ReplayGain for album {0}: {1:.2f}, {2:.2f}', items[0].album, rg_album_gain, rg_album_peak) return AlbumGain( Gain(gain=rg_album_gain, peak=rg_album_peak), track_gains=track_gains ) class ExceptionWatcher(Thread): """Monitors a queue for exceptions asynchronously. Once an exception occurs, raise it and execute a callback. """ def __init__(self, queue, callback): self._queue = queue self._callback = callback self._stopevent = Event() Thread.__init__(self) def run(self): while not self._stopevent.is_set(): try: exc = self._queue.get_nowait() self._callback() raise exc[1].with_traceback(exc[2]) except queue.Empty: # No exceptions yet, loop back to check # whether `_stopevent` is set pass def join(self, timeout=None): self._stopevent.set() Thread.join(self, timeout) # Main plugin logic. class ReplayGainPlugin(BeetsPlugin): """Provides ReplayGain analysis. """ backends = { "command": CommandBackend, "gstreamer": GStreamerBackend, "audiotools": AudioToolsBackend, "ffmpeg": FfmpegBackend, } peak_methods = { "true": Peak.true, "sample": Peak.sample, } def __init__(self): super().__init__() # default backend is 'command' for backward-compatibility. self.config.add({ 'overwrite': False, 'auto': True, 'backend': 'command', 'threads': cpu_count(), 'parallel_on_import': False, 'per_disc': False, 'peak': 'true', 'targetlevel': 89, 'r128': ['Opus'], 'r128_targetlevel': lufs_to_db(-23), }) self.overwrite = self.config['overwrite'].get(bool) self.per_disc = self.config['per_disc'].get(bool) # Remember which backend is used for CLI feedback self.backend_name = self.config['backend'].as_str() if self.backend_name not in self.backends: raise ui.UserError( "Selected ReplayGain backend {} is not supported. " "Please select one of: {}".format( self.backend_name, ', '.join(self.backends.keys()) ) ) peak_method = self.config["peak"].as_str() if peak_method not in self.peak_methods: raise ui.UserError( "Selected ReplayGain peak method {} is not supported. " "Please select one of: {}".format( peak_method, ', '.join(self.peak_methods.keys()) ) ) self._peak_method = self.peak_methods[peak_method] # On-import analysis. if self.config['auto']: self.register_listener('import_begin', self.import_begin) self.register_listener('import', self.import_end) self.import_stages = [self.imported] # Formats to use R128. self.r128_whitelist = self.config['r128'].as_str_seq() try: self.backend_instance = self.backends[self.backend_name]( self.config, self._log ) except (ReplayGainError, FatalReplayGainError) as e: raise ui.UserError( f'replaygain initialization failed: {e}') def should_use_r128(self, item): """Checks the plugin setting to decide whether the calculation should be done using the EBU R128 standard and use R128_ tags instead. """ return item.format in self.r128_whitelist def track_requires_gain(self, item): return self.overwrite or \ (self.should_use_r128(item) and not item.r128_track_gain) or \ (not self.should_use_r128(item) and (not item.rg_track_gain or not item.rg_track_peak)) def album_requires_gain(self, album): # Skip calculating gain only when *all* files don't need # recalculation. This way, if any file among an album's tracks # needs recalculation, we still get an accurate album gain # value. return self.overwrite or \ any([self.should_use_r128(item) and (not item.r128_track_gain or not item.r128_album_gain) for item in album.items()]) or \ any([not self.should_use_r128(item) and (not item.rg_album_gain or not item.rg_album_peak) for item in album.items()]) def store_track_gain(self, item, track_gain): item.rg_track_gain = track_gain.gain item.rg_track_peak = track_gain.peak item.store() self._log.debug('applied track gain {0} LU, peak {1} of FS', item.rg_track_gain, item.rg_track_peak) def store_album_gain(self, item, album_gain): item.rg_album_gain = album_gain.gain item.rg_album_peak = album_gain.peak item.store() self._log.debug('applied album gain {0} LU, peak {1} of FS', item.rg_album_gain, item.rg_album_peak) def store_track_r128_gain(self, item, track_gain): item.r128_track_gain = track_gain.gain item.store() self._log.debug('applied r128 track gain {0} LU', item.r128_track_gain) def store_album_r128_gain(self, item, album_gain): item.r128_album_gain = album_gain.gain item.store() self._log.debug('applied r128 album gain {0} LU', item.r128_album_gain) def tag_specific_values(self, items): """Return some tag specific values. Returns a tuple (store_track_gain, store_album_gain, target_level, peak_method). """ if any([self.should_use_r128(item) for item in items]): store_track_gain = self.store_track_r128_gain store_album_gain = self.store_album_r128_gain target_level = self.config['r128_targetlevel'].as_number() peak = Peak.none # R128_* tags do not store the track/album peak else: store_track_gain = self.store_track_gain store_album_gain = self.store_album_gain target_level = self.config['targetlevel'].as_number() peak = self._peak_method return store_track_gain, store_album_gain, target_level, peak def handle_album(self, album, write, force=False): """Compute album and track replay gain store it in all of the album's items. If ``write`` is truthy then ``item.write()`` is called for each item. If replay gain information is already present in all items, nothing is done. """ if not force and not self.album_requires_gain(album): self._log.info('Skipping album {0}', album) return if (any([self.should_use_r128(item) for item in album.items()]) and not all([self.should_use_r128(item) for item in album.items()])): self._log.error( "Cannot calculate gain for album {0} (incompatible formats)", album) return self._log.info('analyzing {0}', album) tag_vals = self.tag_specific_values(album.items()) store_track_gain, store_album_gain, target_level, peak = tag_vals discs = {} if self.per_disc: for item in album.items(): if discs.get(item.disc) is None: discs[item.disc] = [] discs[item.disc].append(item) else: discs[1] = album.items() for discnumber, items in discs.items(): def _store_album(album_gain): if not album_gain or not album_gain.album_gain \ or len(album_gain.track_gains) != len(items): # In some cases, backends fail to produce a valid # `album_gain` without throwing FatalReplayGainError # => raise non-fatal exception & continue raise ReplayGainError( "ReplayGain backend `{}` failed " "for some tracks in album {}" .format(self.backend_name, album) ) for item, track_gain in zip(items, album_gain.track_gains): store_track_gain(item, track_gain) store_album_gain(item, album_gain.album_gain) if write: item.try_write() self._log.debug('done analyzing {0}', item) try: self._apply( self.backend_instance.compute_album_gain, args=(), kwds={ "items": list(items), "target_level": target_level, "peak": peak }, callback=_store_album ) except ReplayGainError as e: self._log.info("ReplayGain error: {0}", e) except FatalReplayGainError as e: raise ui.UserError( f"Fatal replay gain error: {e}") def handle_track(self, item, write, force=False): """Compute track replay gain and store it in the item. If ``write`` is truthy then ``item.write()`` is called to write the data to disk. If replay gain information is already present in the item, nothing is done. """ if not force and not self.track_requires_gain(item): self._log.info('Skipping track {0}', item) return tag_vals = self.tag_specific_values([item]) store_track_gain, store_album_gain, target_level, peak = tag_vals def _store_track(track_gains): if not track_gains or len(track_gains) != 1: # In some cases, backends fail to produce a valid # `track_gains` without throwing FatalReplayGainError # => raise non-fatal exception & continue raise ReplayGainError( "ReplayGain backend `{}` failed for track {}" .format(self.backend_name, item) ) store_track_gain(item, track_gains[0]) if write: item.try_write() self._log.debug('done analyzing {0}', item) try: self._apply( self.backend_instance.compute_track_gain, args=(), kwds={ "items": [item], "target_level": target_level, "peak": peak, }, callback=_store_track ) except ReplayGainError as e: self._log.info("ReplayGain error: {0}", e) except FatalReplayGainError as e: raise ui.UserError(f"Fatal replay gain error: {e}") def _has_pool(self): """Check whether a `ThreadPool` is running instance in `self.pool` """ if hasattr(self, 'pool'): if isinstance(self.pool, ThreadPool) and self.pool._state == RUN: return True return False def open_pool(self, threads): """Open a `ThreadPool` instance in `self.pool` """ if not self._has_pool() and self.backend_instance.do_parallel: self.pool = ThreadPool(threads) self.exc_queue = queue.Queue() signal.signal(signal.SIGINT, self._interrupt) self.exc_watcher = ExceptionWatcher( self.exc_queue, # threads push exceptions here self.terminate_pool # abort once an exception occurs ) self.exc_watcher.start() def _apply(self, func, args, kwds, callback): if self._has_pool(): def catch_exc(func, exc_queue, log): """Wrapper to catch raised exceptions in threads """ def wfunc(*args, **kwargs): try: return func(*args, **kwargs) except ReplayGainError as e: log.info(e.args[0]) # log non-fatal exceptions except Exception: exc_queue.put(sys.exc_info()) return wfunc # Wrap function and callback to catch exceptions func = catch_exc(func, self.exc_queue, self._log) callback = catch_exc(callback, self.exc_queue, self._log) self.pool.apply_async(func, args, kwds, callback) else: callback(func(*args, **kwds)) def terminate_pool(self): """Terminate the `ThreadPool` instance in `self.pool` (e.g. stop execution in case of exception) """ # Don't call self._as_pool() here, # self.pool._state may not be == RUN if hasattr(self, 'pool') and isinstance(self.pool, ThreadPool): self.pool.terminate() self.pool.join() # self.exc_watcher.join() def _interrupt(self, signal, frame): try: self._log.info('interrupted') self.terminate_pool() sys.exit(0) except SystemExit: # Silence raised SystemExit ~ exit(0) pass def close_pool(self): """Close the `ThreadPool` instance in `self.pool` (if there is one) """ if self._has_pool(): self.pool.close() self.pool.join() self.exc_watcher.join() def import_begin(self, session): """Handle `import_begin` event -> open pool """ threads = self.config['threads'].get(int) if self.config['parallel_on_import'] \ and self.config['auto'] \ and threads: self.open_pool(threads) def import_end(self, paths): """Handle `import` event -> close pool """ self.close_pool() def imported(self, session, task): """Add replay gain info to items or albums of ``task``. """ if self.config['auto']: if task.is_album: self.handle_album(task.album, False) else: self.handle_track(task.item, False) def command_func(self, lib, opts, args): try: write = ui.should_write(opts.write) force = opts.force # Bypass self.open_pool() if called with `--threads 0` if opts.threads != 0: threads = opts.threads or self.config['threads'].get(int) self.open_pool(threads) if opts.album: albums = lib.albums(ui.decargs(args)) self._log.info( "Analyzing {} albums ~ {} backend..." .format(len(albums), self.backend_name) ) for album in albums: self.handle_album(album, write, force) else: items = lib.items(ui.decargs(args)) self._log.info( "Analyzing {} tracks ~ {} backend..." .format(len(items), self.backend_name) ) for item in items: self.handle_track(item, write, force) self.close_pool() except (SystemExit, KeyboardInterrupt): # Silence interrupt exceptions pass def commands(self): """Return the "replaygain" ui subcommand. """ cmd = ui.Subcommand('replaygain', help='analyze for ReplayGain') cmd.parser.add_album_option() cmd.parser.add_option( "-t", "--threads", dest="threads", type=int, help='change the number of threads, \ defaults to maximum available processors' ) cmd.parser.add_option( "-f", "--force", dest="force", action="store_true", default=False, help="analyze all files, including those that " "already have ReplayGain metadata") cmd.parser.add_option( "-w", "--write", default=None, action="store_true", help="write new metadata to files' tags") cmd.parser.add_option( "-W", "--nowrite", dest="write", action="store_false", help="don't write metadata (opposite of -w)") cmd.func = self.command_func return [cmd] �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/rewrite.py��������������������������������������������������������������������0000644�0000765�0000024�00000005275�00000000000�016363� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Uses user-specified rewriting rules to canonicalize names for path formats. """ import re from collections import defaultdict from beets.plugins import BeetsPlugin from beets import ui from beets import library def rewriter(field, rules): """Create a template field function that rewrites the given field with the given rewriting rules. ``rules`` must be a list of (pattern, replacement) pairs. """ def fieldfunc(item): value = item._values_fixed[field] for pattern, replacement in rules: if pattern.match(value.lower()): # Rewrite activated. return replacement # Not activated; return original value. return value return fieldfunc class RewritePlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({}) # Gather all the rewrite rules for each field. rules = defaultdict(list) for key, view in self.config.items(): value = view.as_str() try: fieldname, pattern = key.split(None, 1) except ValueError: raise ui.UserError("invalid rewrite specification") if fieldname not in library.Item._fields: raise ui.UserError("invalid field name (%s) in rewriter" % fieldname) self._log.debug('adding template field {0}', key) pattern = re.compile(pattern.lower()) rules[fieldname].append((pattern, value)) if fieldname == 'artist': # Special case for the artist field: apply the same # rewrite for "albumartist" as well. rules['albumartist'].append((pattern, value)) # Replace each template field with the new rewriter function. for fieldname, fieldrules in rules.items(): getter = rewriter(fieldname, fieldrules) self.template_fields[fieldname] = getter if fieldname in library.Album._fields: self.album_template_fields[fieldname] = getter �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/scrub.py����������������������������������������������������������������������0000644�0000765�0000024�00000012200�00000000000�016002� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Cleans extraneous metadata from files' tags via a command or automatically whenever tags are written. """ from beets.plugins import BeetsPlugin from beets import ui from beets import util from beets import config import mediafile import mutagen _MUTAGEN_FORMATS = { 'asf': 'ASF', 'apev2': 'APEv2File', 'flac': 'FLAC', 'id3': 'ID3FileType', 'mp3': 'MP3', 'mp4': 'MP4', 'oggflac': 'OggFLAC', 'oggspeex': 'OggSpeex', 'oggtheora': 'OggTheora', 'oggvorbis': 'OggVorbis', 'oggopus': 'OggOpus', 'trueaudio': 'TrueAudio', 'wavpack': 'WavPack', 'monkeysaudio': 'MonkeysAudio', 'optimfrog': 'OptimFROG', } class ScrubPlugin(BeetsPlugin): """Removes extraneous metadata from files' tags.""" def __init__(self): super().__init__() self.config.add({ 'auto': True, }) if self.config['auto']: self.register_listener("import_task_files", self.import_task_files) def commands(self): def scrub_func(lib, opts, args): # Walk through matching files and remove tags. for item in lib.items(ui.decargs(args)): self._log.info('scrubbing: {0}', util.displayable_path(item.path)) self._scrub_item(item, opts.write) scrub_cmd = ui.Subcommand('scrub', help='clean audio tags') scrub_cmd.parser.add_option( '-W', '--nowrite', dest='write', action='store_false', default=True, help='leave tags empty') scrub_cmd.func = scrub_func return [scrub_cmd] @staticmethod def _mutagen_classes(): """Get a list of file type classes from the Mutagen module. """ classes = [] for modname, clsname in _MUTAGEN_FORMATS.items(): mod = __import__(f'mutagen.{modname}', fromlist=[clsname]) classes.append(getattr(mod, clsname)) return classes def _scrub(self, path): """Remove all tags from a file. """ for cls in self._mutagen_classes(): # Try opening the file with this type, but just skip in the # event of any error. try: f = cls(util.syspath(path)) except Exception: continue if f.tags is None: continue # Remove the tag for this type. try: f.delete() except NotImplementedError: # Some Mutagen metadata subclasses (namely, ASFTag) do not # support .delete(), presumably because it is impossible to # remove them. In this case, we just remove all the tags. for tag in f.keys(): del f[tag] f.save() except (OSError, mutagen.MutagenError) as exc: self._log.error('could not scrub {0}: {1}', util.displayable_path(path), exc) def _scrub_item(self, item, restore=True): """Remove tags from an Item's associated file and, if `restore` is enabled, write the database's tags back to the file. """ # Get album art if we need to restore it. if restore: try: mf = mediafile.MediaFile(util.syspath(item.path), config['id3v23'].get(bool)) except mediafile.UnreadableFileError as exc: self._log.error('could not open file to scrub: {0}', exc) return images = mf.images # Remove all tags. self._scrub(item.path) # Restore tags, if enabled. if restore: self._log.debug('writing new tags after scrub') item.try_write() if images: self._log.debug('restoring art') try: mf = mediafile.MediaFile(util.syspath(item.path), config['id3v23'].get(bool)) mf.images = images mf.save() except mediafile.UnreadableFileError as exc: self._log.error('could not write tags: {0}', exc) def import_task_files(self, session, task): """Automatically scrub imported files.""" for item in task.imported_items(): self._log.debug('auto-scrubbing {0}', util.displayable_path(item.path)) self._scrub_item(item) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/smartplaylist.py��������������������������������������������������������������0000644�0000765�0000024�00000021037�00000000000�017604� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, 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 beets.plugins import BeetsPlugin from beets import ui from beets.util import (mkdirall, normpath, sanitize_path, syspath, bytestring_path, path_as_posix) from beets.library import Item, Album, parse_query_string from beets.dbcore import OrQuery from beets.dbcore.query import MultipleSort, ParsingError import os try: from urllib.request import pathname2url except ImportError: # python2 is a bit different from urllib import pathname2url class SmartPlaylistPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'relative_to': None, 'playlist_dir': '.', 'auto': True, 'playlists': [], 'forward_slash': False, 'prefix': '', 'urlencode': False, }) self.config['prefix'].redact = True # May contain username/password. self._matched_playlists = None self._unmatched_playlists = None if self.config['auto']: self.register_listener('database_change', self.db_change) def commands(self): spl_update = ui.Subcommand( 'splupdate', help='update the smart playlists. Playlist names may be ' 'passed as arguments.' ) spl_update.func = self.update_cmd return [spl_update] def update_cmd(self, lib, opts, args): self.build_queries() if args: args = set(ui.decargs(args)) for a in list(args): if not a.endswith(".m3u"): args.add(f"{a}.m3u") playlists = {(name, q, a_q) for name, q, a_q in self._unmatched_playlists if name in args} if not playlists: raise ui.UserError( 'No playlist matching any of {} found'.format( [name for name, _, _ in self._unmatched_playlists]) ) self._matched_playlists = playlists self._unmatched_playlists -= playlists else: self._matched_playlists = self._unmatched_playlists self.update_playlists(lib) def build_queries(self): """ Instantiate queries for the playlists. Each playlist has 2 queries: one or items one for albums, each with a sort. We must also remember its name. _unmatched_playlists is a set of tuples (name, (q, q_sort), (album_q, album_q_sort)). sort may be any sort, or NullSort, or None. None and NullSort are equivalent and both eval to False. More precisely - it will be NullSort when a playlist query ('query' or 'album_query') is a single item or a list with 1 element - it will be None when there are multiple items i a query """ self._unmatched_playlists = set() self._matched_playlists = set() for playlist in self.config['playlists'].get(list): if 'name' not in playlist: self._log.warning("playlist configuration is missing name") continue playlist_data = (playlist['name'],) try: for key, model_cls in (('query', Item), ('album_query', Album)): qs = playlist.get(key) if qs is None: query_and_sort = None, None elif isinstance(qs, str): query_and_sort = parse_query_string(qs, model_cls) elif len(qs) == 1: query_and_sort = parse_query_string(qs[0], model_cls) else: # multiple queries and sorts queries, sorts = zip(*(parse_query_string(q, model_cls) for q in qs)) query = OrQuery(queries) final_sorts = [] for s in sorts: if s: if isinstance(s, MultipleSort): final_sorts += s.sorts else: final_sorts.append(s) if not final_sorts: sort = None elif len(final_sorts) == 1: sort, = final_sorts else: sort = MultipleSort(final_sorts) query_and_sort = query, sort playlist_data += (query_and_sort,) except ParsingError as exc: self._log.warning("invalid query in playlist {}: {}", playlist['name'], exc) continue self._unmatched_playlists.add(playlist_data) def matches(self, model, query, album_query): if album_query and isinstance(model, Album): return album_query.match(model) if query and isinstance(model, Item): return query.match(model) return False def db_change(self, lib, model): if self._unmatched_playlists is None: self.build_queries() for playlist in self._unmatched_playlists: n, (q, _), (a_q, _) = playlist if self.matches(model, q, a_q): self._log.debug( "{0} will be updated because of {1}", n, model) self._matched_playlists.add(playlist) self.register_listener('cli_exit', self.update_playlists) self._unmatched_playlists -= self._matched_playlists def update_playlists(self, lib): self._log.info("Updating {0} smart playlists...", len(self._matched_playlists)) playlist_dir = self.config['playlist_dir'].as_filename() playlist_dir = bytestring_path(playlist_dir) relative_to = self.config['relative_to'].get() if relative_to: relative_to = normpath(relative_to) # Maps playlist filenames to lists of track filenames. m3us = {} for playlist in self._matched_playlists: name, (query, q_sort), (album_query, a_q_sort) = playlist self._log.debug("Creating playlist {0}", name) items = [] if query: items.extend(lib.items(query, q_sort)) if album_query: for album in lib.albums(album_query, a_q_sort): items.extend(album.items()) # As we allow tags in the m3u names, we'll need to iterate through # the items and generate the correct m3u file names. for item in items: m3u_name = item.evaluate_template(name, True) m3u_name = sanitize_path(m3u_name, lib.replacements) if m3u_name not in m3us: m3us[m3u_name] = [] item_path = item.path if relative_to: item_path = os.path.relpath(item.path, relative_to) if item_path not in m3us[m3u_name]: m3us[m3u_name].append(item_path) prefix = bytestring_path(self.config['prefix'].as_str()) # Write all of the accumulated track lists to files. for m3u in m3us: m3u_path = normpath(os.path.join(playlist_dir, bytestring_path(m3u))) mkdirall(m3u_path) with open(syspath(m3u_path), 'wb') as f: for path in m3us[m3u]: if self.config['forward_slash'].get(): path = path_as_posix(path) if self.config['urlencode']: path = bytestring_path(pathname2url(path)) f.write(prefix + path + b'\n') self._log.info("{0} playlists updated", len(self._matched_playlists)) �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/sonosupdate.py����������������������������������������������������������������0000644�0000765�0000024�00000003106�00000000000�017235� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2018, Tobias Sauerwein. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Updates a Sonos library whenever the beets library is changed. This is based on the Kodi Update plugin. """ from beets.plugins import BeetsPlugin import soco class SonosUpdate(BeetsPlugin): def __init__(self): super().__init__() self.register_listener('database_change', self.listen_for_db_change) def listen_for_db_change(self, lib, model): """Listens for beets db change and register the update""" self.register_listener('cli_exit', self.update) def update(self, lib): """When the client exists try to send refresh request to a Sonos controler. """ self._log.info('Requesting a Sonos library update...') device = soco.discovery.any_soco() if device: device.music_library.start_library_update() else: self._log.warning('Could not find a Sonos device.') return self._log.info('Sonos update triggered') ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/spotify.py��������������������������������������������������������������������0000644�0000765�0000024�00000045573�00000000000�016404� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2019, Rahul Ahuja. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Adds Spotify release and track search support to the autotagger, along with Spotify playlist construction. """ import re import json import base64 import webbrowser import collections import unidecode import requests import confuse from beets import ui from beets.autotag.hooks import AlbumInfo, TrackInfo from beets.plugins import MetadataSourcePlugin, BeetsPlugin class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): data_source = 'Spotify' # Base URLs for the Spotify API # Documentation: https://developer.spotify.com/web-api oauth_token_url = 'https://accounts.spotify.com/api/token' open_track_url = 'https://open.spotify.com/track/' search_url = 'https://api.spotify.com/v1/search' album_url = 'https://api.spotify.com/v1/albums/' track_url = 'https://api.spotify.com/v1/tracks/' # Spotify IDs consist of 22 alphanumeric characters # (zero-left-padded base62 representation of randomly generated UUID4) id_regex = { 'pattern': r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})', 'match_group': 2, } def __init__(self): super().__init__() self.config.add( { 'mode': 'list', 'tiebreak': 'popularity', 'show_failures': False, 'artist_field': 'albumartist', 'album_field': 'album', 'track_field': 'title', 'region_filter': None, 'regex': [], 'client_id': '4e414367a1d14c75a5c5129a627fcab8', 'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc', 'tokenfile': 'spotify_token.json', } ) self.config['client_secret'].redact = True self.tokenfile = self.config['tokenfile'].get( confuse.Filename(in_app_dir=True) ) # Path to the JSON file for storing the OAuth access token. self.setup() def setup(self): """Retrieve previously saved OAuth token or generate a new one.""" try: with open(self.tokenfile) as f: token_data = json.load(f) except OSError: self._authenticate() else: self.access_token = token_data['access_token'] def _authenticate(self): """Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow """ headers = { 'Authorization': 'Basic {}'.format( base64.b64encode( ':'.join( self.config[k].as_str() for k in ('client_id', 'client_secret') ).encode() ).decode() ) } response = requests.post( self.oauth_token_url, data={'grant_type': 'client_credentials'}, headers=headers, ) try: response.raise_for_status() except requests.exceptions.HTTPError as e: raise ui.UserError( 'Spotify authorization failed: {}\n{}'.format( e, response.text ) ) self.access_token = response.json()['access_token'] # Save the token for later use. self._log.debug( '{} access token: {}', self.data_source, self.access_token ) with open(self.tokenfile, 'w') as f: json.dump({'access_token': self.access_token}, f) def _handle_response(self, request_type, url, params=None): """Send a request, reauthenticating if necessary. :param request_type: Type of :class:`Request` constructor, e.g. ``requests.get``, ``requests.post``, etc. :type request_type: function :param url: URL for the new :class:`Request` object. :type url: str :param params: (optional) list of tuples or bytes to send in the query string for the :class:`Request`. :type params: dict :return: JSON data for the class:`Response <Response>` object. :rtype: dict """ response = request_type( url, headers={'Authorization': f'Bearer {self.access_token}'}, params=params, ) if response.status_code != 200: if 'token expired' in response.text: self._log.debug( '{} access token has expired. Reauthenticating.', self.data_source, ) self._authenticate() return self._handle_response(request_type, url, params=params) else: raise ui.UserError( '{} API error:\n{}\nURL:\n{}\nparams:\n{}'.format( self.data_source, response.text, url, params ) ) return response.json() def album_for_id(self, album_id): """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. :param album_id: Spotify ID or URL for the album :type album_id: str :return: AlbumInfo object for album :rtype: beets.autotag.hooks.AlbumInfo or None """ spotify_id = self._get_id('album', album_id) if spotify_id is None: return None album_data = self._handle_response( requests.get, self.album_url + spotify_id ) artist, artist_id = self.get_artist(album_data['artists']) date_parts = [ int(part) for part in album_data['release_date'].split('-') ] release_date_precision = album_data['release_date_precision'] if release_date_precision == 'day': year, month, day = date_parts elif release_date_precision == 'month': year, month = date_parts day = None elif release_date_precision == 'year': year = date_parts[0] month = None day = None else: raise ui.UserError( "Invalid `release_date_precision` returned " "by {} API: '{}'".format( self.data_source, release_date_precision ) ) tracks = [] medium_totals = collections.defaultdict(int) for i, track_data in enumerate(album_data['tracks']['items'], start=1): track = self._get_track(track_data) track.index = i medium_totals[track.medium] += 1 tracks.append(track) for track in tracks: track.medium_total = medium_totals[track.medium] return AlbumInfo( album=album_data['name'], album_id=spotify_id, artist=artist, artist_id=artist_id, tracks=tracks, albumtype=album_data['album_type'], va=len(album_data['artists']) == 1 and artist.lower() == 'various artists', year=year, month=month, day=day, label=album_data['label'], mediums=max(medium_totals.keys()), data_source=self.data_source, data_url=album_data['external_urls']['spotify'], ) def _get_track(self, track_data): """Convert a Spotify track object dict to a TrackInfo object. :param track_data: Simplified track object (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) :type track_data: dict :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ artist, artist_id = self.get_artist(track_data['artists']) return TrackInfo( title=track_data['name'], track_id=track_data['id'], artist=artist, artist_id=artist_id, length=track_data['duration_ms'] / 1000, index=track_data['track_number'], medium=track_data['disc_number'], medium_index=track_data['track_number'], data_source=self.data_source, data_url=track_data['external_urls']['spotify'], ) def track_for_id(self, track_id=None, track_data=None): """Fetch a track by its Spotify ID or URL and return a TrackInfo object or None if the track is not found. :param track_id: (Optional) Spotify ID or URL for the track. Either ``track_id`` or ``track_data`` must be provided. :type track_id: str :param track_data: (Optional) Simplified track object dict. May be provided instead of ``track_id`` to avoid unnecessary API calls. :type track_data: dict :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo or None """ if track_data is None: spotify_id = self._get_id('track', track_id) if spotify_id is None: return None track_data = self._handle_response( requests.get, self.track_url + spotify_id ) track = self._get_track(track_data) # Get album's tracks to set `track.index` (position on the entire # release) and `track.medium_total` (total number of tracks on # the track's disc). album_data = self._handle_response( requests.get, self.album_url + track_data['album']['id'] ) medium_total = 0 for i, track_data in enumerate(album_data['tracks']['items'], start=1): if track_data['disc_number'] == track.medium: medium_total += 1 if track_data['id'] == track.track_id: track.index = i track.medium_total = medium_total return track @staticmethod def _construct_search_query(filters=None, keywords=''): """Construct a query string with the specified filters and keywords to be provided to the Spotify Search API (https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines). :param filters: (Optional) Field filters to apply. :type filters: dict :param keywords: (Optional) Query keywords to use. :type keywords: str :return: Query string to be provided to the Search API. :rtype: str """ query_components = [ keywords, ' '.join(':'.join((k, v)) for k, v in filters.items()), ] query = ' '.join([q for q in query_components if q]) if not isinstance(query, str): query = query.decode('utf8') return unidecode.unidecode(query) def _search_api(self, query_type, filters=None, keywords=''): """Query the Spotify Search API for the specified ``keywords``, applying the provided ``filters``. :param query_type: Item type to search across. Valid types are: 'album', 'artist', 'playlist', and 'track'. :type query_type: str :param filters: (Optional) Field filters to apply. :type filters: dict :param keywords: (Optional) Query keywords to use. :type keywords: str :return: JSON data for the class:`Response <Response>` object or None if no search results are returned. :rtype: dict or None """ query = self._construct_search_query( keywords=keywords, filters=filters ) if not query: return None self._log.debug( f"Searching {self.data_source} for '{query}'" ) response_data = ( self._handle_response( requests.get, self.search_url, params={'q': query, 'type': query_type}, ) .get(query_type + 's', {}) .get('items', []) ) self._log.debug( "Found {} result(s) from {} for '{}'", len(response_data), self.data_source, query, ) return response_data def commands(self): def queries(lib, opts, args): success = self._parse_opts(opts) if success: results = self._match_library_tracks(lib, ui.decargs(args)) self._output_match_results(results) spotify_cmd = ui.Subcommand( 'spotify', help=f'build a {self.data_source} playlist' ) spotify_cmd.parser.add_option( '-m', '--mode', action='store', help='"open" to open {} with playlist, ' '"list" to print (default)'.format(self.data_source), ) spotify_cmd.parser.add_option( '-f', '--show-failures', action='store_true', dest='show_failures', help='list tracks that did not match a {} ID'.format( self.data_source ), ) spotify_cmd.func = queries return [spotify_cmd] def _parse_opts(self, opts): if opts.mode: self.config['mode'].set(opts.mode) if opts.show_failures: self.config['show_failures'].set(True) if self.config['mode'].get() not in ['list', 'open']: self._log.warning( '{0} is not a valid mode', self.config['mode'].get() ) return False self.opts = opts return True def _match_library_tracks(self, library, keywords): """Get a list of simplified track object dicts for library tracks matching the specified ``keywords``. :param library: beets library object to query. :type library: beets.library.Library :param keywords: Query to match library items against. :type keywords: str :return: List of simplified track object dicts for library items matching the specified query. :rtype: list[dict] """ results = [] failures = [] items = library.items(keywords) if not items: self._log.debug( 'Your beets query returned no items, skipping {}.', self.data_source, ) return self._log.info('Processing {} tracks...', len(items)) for item in items: # Apply regex transformations if provided for regex in self.config['regex'].get(): if ( not regex['field'] or not regex['search'] or not regex['replace'] ): continue value = item[regex['field']] item[regex['field']] = re.sub( regex['search'], regex['replace'], value ) # Custom values can be passed in the config (just in case) artist = item[self.config['artist_field'].get()] album = item[self.config['album_field'].get()] keywords = item[self.config['track_field'].get()] # Query the Web API for each track, look for the items' JSON data query_filters = {'artist': artist, 'album': album} response_data_tracks = self._search_api( query_type='track', keywords=keywords, filters=query_filters ) if not response_data_tracks: query = self._construct_search_query( keywords=keywords, filters=query_filters ) failures.append(query) continue # Apply market filter if requested region_filter = self.config['region_filter'].get() if region_filter: response_data_tracks = [ track_data for track_data in response_data_tracks if region_filter in track_data['available_markets'] ] if ( len(response_data_tracks) == 1 or self.config['tiebreak'].get() == 'first' ): self._log.debug( '{} track(s) found, count: {}', self.data_source, len(response_data_tracks), ) chosen_result = response_data_tracks[0] else: # Use the popularity filter self._log.debug( 'Most popular track chosen, count: {}', len(response_data_tracks), ) chosen_result = max( response_data_tracks, key=lambda x: x['popularity'] ) results.append(chosen_result) failure_count = len(failures) if failure_count > 0: if self.config['show_failures'].get(): self._log.info( '{} track(s) did not match a {} ID:', failure_count, self.data_source, ) for track in failures: self._log.info('track: {}', track) self._log.info('') else: self._log.warning( '{} track(s) did not match a {} ID:\n' 'use --show-failures to display', failure_count, self.data_source, ) return results def _output_match_results(self, results): """Open a playlist or print Spotify URLs for the provided track object dicts. :param results: List of simplified track object dicts (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) :type results: list[dict] """ if results: spotify_ids = [track_data['id'] for track_data in results] if self.config['mode'].get() == 'open': self._log.info( 'Attempting to open {} with playlist'.format( self.data_source ) ) spotify_url = 'spotify:trackset:Playlist:' + ','.join( spotify_ids ) webbrowser.open(spotify_url) else: for spotify_id in spotify_ids: print(self.open_track_url + spotify_id) else: self._log.warning( f'No {self.data_source} tracks found from beets query' ) �������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/subsonicplaylist.py�����������������������������������������������������������0000644�0000765�0000024�00000014241�00000000000�020302� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2019, Joris Jensen # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import random import string from xml.etree import ElementTree from hashlib import md5 from urllib.parse import urlencode import requests from beets.dbcore import AndQuery from beets.dbcore.query import MatchQuery from beets.plugins import BeetsPlugin from beets.ui import Subcommand __author__ = 'https://github.com/MrNuggelz' def filter_to_be_removed(items, keys): if len(items) > len(keys): dont_remove = [] for artist, album, title in keys: for item in items: if artist == item['artist'] and \ album == item['album'] and \ title == item['title']: dont_remove.append(item) return [item for item in items if item not in dont_remove] else: def to_be_removed(item): for artist, album, title in keys: if artist == item['artist'] and\ album == item['album'] and\ title == item['title']: return False return True return [item for item in items if to_be_removed(item)] class SubsonicPlaylistPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { 'delete': False, 'playlist_ids': [], 'playlist_names': [], 'username': '', 'password': '' } ) self.config['password'].redact = True def update_tags(self, playlist_dict, lib): with lib.transaction(): for query, playlist_tag in playlist_dict.items(): query = AndQuery([MatchQuery("artist", query[0]), MatchQuery("album", query[1]), MatchQuery("title", query[2])]) items = lib.items(query) if not items: self._log.warn("{} | track not found ({})", playlist_tag, query) continue for item in items: item.subsonic_playlist = playlist_tag item.try_sync(write=True, move=False) def get_playlist(self, playlist_id): xml = self.send('getPlaylist', {'id': playlist_id}).text playlist = ElementTree.fromstring(xml)[0] if playlist.attrib.get('code', '200') != '200': alt_error = 'error getting playlist, but no error message found' self._log.warn(playlist.attrib.get('message', alt_error)) return name = playlist.attrib.get('name', 'undefined') tracks = [(t.attrib['artist'], t.attrib['album'], t.attrib['title']) for t in playlist] return name, tracks def commands(self): def build_playlist(lib, opts, args): self.config.set_args(opts) ids = self.config['playlist_ids'].as_str_seq() if self.config['playlist_names'].as_str_seq(): playlists = ElementTree.fromstring( self.send('getPlaylists').text)[0] if playlists.attrib.get('code', '200') != '200': alt_error = 'error getting playlists,' \ ' but no error message found' self._log.warn( playlists.attrib.get('message', alt_error)) return for name in self.config['playlist_names'].as_str_seq(): for playlist in playlists: if name == playlist.attrib['name']: ids.append(playlist.attrib['id']) playlist_dict = self.get_playlists(ids) # delete old tags if self.config['delete']: existing = list(lib.items('subsonic_playlist:";"')) to_be_removed = filter_to_be_removed( existing, playlist_dict.keys()) for item in to_be_removed: item['subsonic_playlist'] = '' with lib.transaction(): item.try_sync(write=True, move=False) self.update_tags(playlist_dict, lib) subsonicplaylist_cmds = Subcommand( 'subsonicplaylist', help='import a subsonic playlist' ) subsonicplaylist_cmds.parser.add_option( '-d', '--delete', action='store_true', help='delete tag from items not in any playlist anymore', ) subsonicplaylist_cmds.func = build_playlist return [subsonicplaylist_cmds] def generate_token(self): salt = ''.join(random.choices(string.ascii_lowercase + string.digits)) return md5( (self.config['password'].get() + salt).encode()).hexdigest(), salt def send(self, endpoint, params=None): if params is None: params = {} a, b = self.generate_token() params['u'] = self.config['username'] params['t'] = a params['s'] = b params['v'] = '1.12.0' params['c'] = 'beets' resp = requests.get('{}/rest/{}?{}'.format( self.config['base_url'].get(), endpoint, urlencode(params)) ) return resp def get_playlists(self, ids): output = {} for playlist_id in ids: name, tracks = self.get_playlist(playlist_id) for track in tracks: if track not in output: output[track] = ';' output[track] += name + ';' return output ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/subsonicupdate.py�������������������������������������������������������������0000644�0000765�0000024�00000011534�00000000000�017725� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Updates Subsonic library on Beets import Your Beets configuration file should contain a "subsonic" section like the following: subsonic: url: https://mydomain.com:443/subsonic user: username pass: password auth: token For older Subsonic versions, token authentication is not supported, use password instead: subsonic: url: https://mydomain.com:443/subsonic user: username pass: password auth: pass """ import hashlib import random import string import requests from binascii import hexlify from beets import config from beets.plugins import BeetsPlugin __author__ = 'https://github.com/maffo999' class SubsonicUpdate(BeetsPlugin): def __init__(self): super().__init__() # Set default configuration values config['subsonic'].add({ 'user': 'admin', 'pass': 'admin', 'url': 'http://localhost:4040', 'auth': 'token', }) config['subsonic']['pass'].redact = True self.register_listener('import', self.start_scan) @staticmethod def __create_token(): """Create salt and token from given password. :return: The generated salt and hashed token """ password = config['subsonic']['pass'].as_str() # Pick the random sequence and salt the password r = string.ascii_letters + string.digits salt = "".join([random.choice(r) for _ in range(6)]) salted_password = password + salt token = hashlib.md5(salted_password.encode('utf-8')).hexdigest() # Put together the payload of the request to the server and the URL return salt, token @staticmethod def __format_url(endpoint): """Get the Subsonic URL to trigger the given endpoint. Uses either the url config option or the deprecated host, port, and context_path config options together. :return: Endpoint for updating Subsonic """ url = config['subsonic']['url'].as_str() if url and url.endswith('/'): url = url[:-1] # @deprecated("Use url config option instead") if not url: host = config['subsonic']['host'].as_str() port = config['subsonic']['port'].get(int) context_path = config['subsonic']['contextpath'].as_str() if context_path == '/': context_path = '' url = f"http://{host}:{port}{context_path}" return url + f'/rest/{endpoint}' def start_scan(self): user = config['subsonic']['user'].as_str() auth = config['subsonic']['auth'].as_str() url = self.__format_url("startScan") self._log.debug('URL is {0}', url) self._log.debug('auth type is {0}', config['subsonic']['auth']) if auth == "token": salt, token = self.__create_token() payload = { 'u': user, 't': token, 's': salt, 'v': '1.13.0', # Subsonic 5.3 and newer 'c': 'beets', 'f': 'json' } elif auth == "password": password = config['subsonic']['pass'].as_str() encpass = hexlify(password.encode()).decode() payload = { 'u': user, 'p': f'enc:{encpass}', 'v': '1.12.0', 'c': 'beets', 'f': 'json' } else: return try: response = requests.get(url, params=payload) json = response.json() if response.status_code == 200 and \ json['subsonic-response']['status'] == "ok": count = json['subsonic-response']['scanStatus']['count'] self._log.info( f'Updating Subsonic; scanning {count} tracks') elif response.status_code == 200 and \ json['subsonic-response']['status'] == "failed": error_message = json['subsonic-response']['error']['message'] self._log.error(f'Error: {error_message}') else: self._log.error('Error: {0}', json) except Exception as error: self._log.error(f'Error: {error}') ��������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/the.py������������������������������������������������������������������������0000644�0000765�0000024�00000006104�00000000000�015452� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, 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 from beets.plugins import BeetsPlugin __author__ = 'baobab@heresiarch.info' __version__ = '1.1' PATTERN_THE = '^the\\s' PATTERN_A = '^[a][n]?\\s' FORMAT = '{0}, {1}' class ThePlugin(BeetsPlugin): patterns = [] def __init__(self): super().__init__() self.template_funcs['the'] = self.the_template_func self.config.add({ 'the': True, 'a': True, 'format': '{0}, {1}', 'strip': False, 'patterns': [], }) self.patterns = self.config['patterns'].as_str_seq() for p in self.patterns: if p: try: re.compile(p) except re.error: self._log.error('invalid pattern: {0}', p) else: if not (p.startswith('^') or p.endswith('$')): self._log.warning('warning: \"{0}\" will not ' 'match string start/end', p) if self.config['a']: self.patterns = [PATTERN_A] + self.patterns if self.config['the']: self.patterns = [PATTERN_THE] + self.patterns if not self.patterns: self._log.warning('no patterns defined!') def unthe(self, text, pattern): """Moves pattern in the path format string or strips it text -- text to handle pattern -- regexp pattern (case ignore is already on) strip -- if True, pattern will be removed """ if text: r = re.compile(pattern, flags=re.IGNORECASE) try: t = r.findall(text)[0] except IndexError: return text else: r = re.sub(r, '', text).strip() if self.config['strip']: return r else: fmt = self.config['format'].as_str() return fmt.format(r, t.strip()).strip() else: return '' def the_template_func(self, text): if not self.patterns: return text if text: for p in self.patterns: r = self.unthe(text, p) if r != text: self._log.debug('\"{0}\" -> \"{1}\"', text, r) break return r else: return '' ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/thumbnails.py�����������������������������������������������������������������0000644�0000765�0000024�00000024035�00000000000�017043� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Bruno Cauet # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Create freedesktop.org-compliant thumbnails for album folders This plugin is POSIX-only. Spec: standards.freedesktop.org/thumbnail-spec/latest/index.html """ from hashlib import md5 import os import shutil from itertools import chain from pathlib import PurePosixPath import ctypes import ctypes.util from xdg import BaseDirectory from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs from beets import util from beets.util.artresizer import ArtResizer, get_im_version, get_pil_version BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails") NORMAL_DIR = util.bytestring_path(os.path.join(BASE_DIR, "normal")) LARGE_DIR = util.bytestring_path(os.path.join(BASE_DIR, "large")) class ThumbnailsPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'auto': True, 'force': False, 'dolphin': False, }) self.write_metadata = None if self.config['auto'] and self._check_local_ok(): self.register_listener('art_set', self.process_album) def commands(self): thumbnails_command = Subcommand("thumbnails", help="Create album thumbnails") thumbnails_command.parser.add_option( '-f', '--force', dest='force', action='store_true', default=False, help='force regeneration of thumbnails deemed fine (existing & ' 'recent enough)') thumbnails_command.parser.add_option( '--dolphin', dest='dolphin', action='store_true', default=False, help="create Dolphin-compatible thumbnail information (for KDE)") thumbnails_command.func = self.process_query return [thumbnails_command] def process_query(self, lib, opts, args): self.config.set_args(opts) if self._check_local_ok(): for album in lib.albums(decargs(args)): self.process_album(album) def _check_local_ok(self): """Check that's everythings ready: - local capability to resize images - thumbnail dirs exist (create them if needed) - detect whether we'll use PIL or IM - detect whether we'll use GIO or Python to get URIs """ if not ArtResizer.shared.local: self._log.warning("No local image resizing capabilities, " "cannot generate thumbnails") return False for dir in (NORMAL_DIR, LARGE_DIR): if not os.path.exists(dir): os.makedirs(dir) if get_im_version(): self.write_metadata = write_metadata_im tool = "IM" else: assert get_pil_version() # since we're local self.write_metadata = write_metadata_pil tool = "PIL" self._log.debug("using {0} to write metadata", tool) uri_getter = GioURI() if not uri_getter.available: uri_getter = PathlibURI() self._log.debug("using {0.name} to compute URIs", uri_getter) self.get_uri = uri_getter.uri return True def process_album(self, album): """Produce thumbnails for the album folder. """ self._log.debug('generating thumbnail for {0}', album) if not album.artpath: self._log.info('album {0} has no art', album) return if self.config['dolphin']: self.make_dolphin_cover_thumbnail(album) size = ArtResizer.shared.get_size(album.artpath) if not size: self._log.warning('problem getting the picture size for {0}', album.artpath) return wrote = True if max(size) >= 256: wrote &= self.make_cover_thumbnail(album, 256, LARGE_DIR) wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR) if wrote: self._log.info('wrote thumbnail for {0}', album) else: self._log.info('nothing to do for {0}', album) def make_cover_thumbnail(self, album, size, target_dir): """Make a thumbnail of given size for `album` and put it in `target_dir`. """ target = os.path.join(target_dir, self.thumbnail_file_name(album.path)) if os.path.exists(target) and \ os.stat(target).st_mtime > os.stat(album.artpath).st_mtime: if self.config['force']: self._log.debug("found a suitable {1}x{1} thumbnail for {0}, " "forcing regeneration", album, size) else: self._log.debug("{1}x{1} thumbnail for {0} exists and is " "recent enough", album, size) return False resized = ArtResizer.shared.resize(size, album.artpath, util.syspath(target)) self.add_tags(album, util.syspath(resized)) shutil.move(resized, target) return True def thumbnail_file_name(self, path): """Compute the thumbnail file name See https://standards.freedesktop.org/thumbnail-spec/latest/x227.html """ uri = self.get_uri(path) hash = md5(uri.encode('utf-8')).hexdigest() return util.bytestring_path(f"{hash}.png") def add_tags(self, album, image_path): """Write required metadata to the thumbnail See https://standards.freedesktop.org/thumbnail-spec/latest/x142.html """ mtime = os.stat(album.artpath).st_mtime metadata = {"Thumb::URI": self.get_uri(album.artpath), "Thumb::MTime": str(mtime)} try: self.write_metadata(image_path, metadata) except Exception: self._log.exception("could not write metadata to {0}", util.displayable_path(image_path)) def make_dolphin_cover_thumbnail(self, album): outfilename = os.path.join(album.path, b".directory") if os.path.exists(outfilename): return artfile = os.path.split(album.artpath)[1] with open(outfilename, 'w') as f: f.write('[Desktop Entry]\n') f.write('Icon=./{}'.format(artfile.decode('utf-8'))) f.close() self._log.debug("Wrote file {0}", util.displayable_path(outfilename)) def write_metadata_im(file, metadata): """Enrich the file metadata with `metadata` dict thanks to IM.""" command = ['convert', file] + \ list(chain.from_iterable(('-set', k, v) for k, v in metadata.items())) + [file] util.command_output(command) return True def write_metadata_pil(file, metadata): """Enrich the file metadata with `metadata` dict thanks to PIL.""" from PIL import Image, PngImagePlugin im = Image.open(file) meta = PngImagePlugin.PngInfo() for k, v in metadata.items(): meta.add_text(k, v, 0) im.save(file, "PNG", pnginfo=meta) return True class URIGetter: available = False name = "Abstract base" def uri(self, path): raise NotImplementedError() class PathlibURI(URIGetter): available = True name = "Python Pathlib" def uri(self, path): return PurePosixPath(util.py3_path(path)).as_uri() def copy_c_string(c_string): """Copy a `ctypes.POINTER(ctypes.c_char)` value into a new Python string and return it. The old memory is then safe to free. """ # This is a pretty dumb way to get a string copy, but it seems to # work. A more surefire way would be to allocate a ctypes buffer and copy # the data with `memcpy` or somesuch. s = ctypes.cast(c_string, ctypes.c_char_p).value return b'' + s class GioURI(URIGetter): """Use gio URI function g_file_get_uri. Paths must be utf-8 encoded. """ name = "GIO" def __init__(self): self.libgio = self.get_library() self.available = bool(self.libgio) if self.available: self.libgio.g_type_init() # for glib < 2.36 self.libgio.g_file_get_uri.argtypes = [ctypes.c_char_p] self.libgio.g_file_new_for_path.restype = ctypes.c_void_p self.libgio.g_file_get_uri.argtypes = [ctypes.c_void_p] self.libgio.g_file_get_uri.restype = ctypes.POINTER(ctypes.c_char) self.libgio.g_object_unref.argtypes = [ctypes.c_void_p] def get_library(self): lib_name = ctypes.util.find_library("gio-2") try: if not lib_name: return False return ctypes.cdll.LoadLibrary(lib_name) except OSError: return False def uri(self, path): g_file_ptr = self.libgio.g_file_new_for_path(path) if not g_file_ptr: raise RuntimeError("No gfile pointer received for {}".format( util.displayable_path(path))) try: uri_ptr = self.libgio.g_file_get_uri(g_file_ptr) finally: self.libgio.g_object_unref(g_file_ptr) if not uri_ptr: self.libgio.g_free(uri_ptr) raise RuntimeError("No URI received from the gfile pointer for " "{}".format(util.displayable_path(path))) try: uri = copy_c_string(uri_ptr) finally: self.libgio.g_free(uri_ptr) try: return uri.decode(util._fsencoding()) except UnicodeDecodeError: raise RuntimeError( f"Could not decode filename from GIO: {uri!r}" ) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/types.py����������������������������������������������������������������������0000644�0000765�0000024�00000003135�00000000000�016037� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from beets.plugins import BeetsPlugin from beets.dbcore import types from confuse import ConfigValueError from beets import library class TypesPlugin(BeetsPlugin): @property def item_types(self): return self._types() @property def album_types(self): return self._types() def _types(self): if not self.config.exists(): return {} mytypes = {} for key, value in self.config.items(): if value.get() == 'int': mytypes[key] = types.INTEGER elif value.get() == 'float': mytypes[key] = types.FLOAT elif value.get() == 'bool': mytypes[key] = types.BOOLEAN elif value.get() == 'date': mytypes[key] = library.DateType() else: raise ConfigValueError( "unknown type '{}' for the '{}' field" .format(value, key)) return mytypes �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1637959898.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/unimported.py�����������������������������������������������������������������0000644�0000765�0000024�00000004364�00000000000�017066� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2019, Joris Jensen # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """ List all files in the library folder which are not listed in the beets library database, including art files """ import os from beets import util from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ __author__ = 'https://github.com/MrNuggelz' class Unimported(BeetsPlugin): def __init__(self): super().__init__() self.config.add( { 'ignore_extensions': [] } ) def commands(self): def print_unimported(lib, opts, args): ignore_exts = [ ('.' + x).encode() for x in self.config["ignore_extensions"].as_str_seq() ] ignore_dirs = [ os.path.join(lib.directory, x.encode()) for x in self.config["ignore_subdirectories"].as_str_seq() ] in_folder = { os.path.join(r, file) for r, d, f in os.walk(lib.directory) for file in f if not any( [file.endswith(ext) for ext in ignore_exts] + [r in ignore_dirs] ) } in_library = {x.path for x in lib.items()} art_files = {x.artpath for x in lib.albums()} for f in in_folder - in_library - art_files: print_(util.displayable_path(f)) unimported = Subcommand( 'unimported', help='list all files in the library folder which are not listed' ' in the beets library database') unimported.func = print_unimported return [unimported] ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000034�00000000000�010212� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������28 mtime=1638031078.3205144 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/��������������������������������������������������������������������������0000755�0000765�0000024�00000000000�00000000000�015074� 5����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1632858662.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/__init__.py���������������������������������������������������������������0000644�0000765�0000024�00000036667�00000000000�017227� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """A Web interface to beets.""" from beets.plugins import BeetsPlugin from beets import ui from beets import util import beets.library import flask from flask import g, jsonify from werkzeug.routing import BaseConverter, PathConverter import os from unidecode import unidecode import json import base64 # Utilities. def _rep(obj, expand=False): """Get a flat -- i.e., JSON-ish -- representation of a beets Item or Album object. For Albums, `expand` dictates whether tracks are included. """ out = dict(obj) if isinstance(obj, beets.library.Item): if app.config.get('INCLUDE_PATHS', False): out['path'] = util.displayable_path(out['path']) else: del out['path'] # Filter all bytes attributes and convert them to strings. for key, value in out.items(): if isinstance(out[key], bytes): out[key] = base64.b64encode(value).decode('ascii') # Get the size (in bytes) of the backing file. This is useful # for the Tomahawk resolver API. try: out['size'] = os.path.getsize(util.syspath(obj.path)) except OSError: out['size'] = 0 return out elif isinstance(obj, beets.library.Album): if app.config.get('INCLUDE_PATHS', False): out['artpath'] = util.displayable_path(out['artpath']) else: del out['artpath'] if expand: out['items'] = [_rep(item) for item in obj.items()] return out def json_generator(items, root, expand=False): """Generator that dumps list of beets Items or Albums as JSON :param root: root key for JSON :param items: list of :class:`Item` or :class:`Album` to dump :param expand: If true every :class:`Album` contains its items in the json representation :returns: generator that yields strings """ yield '{"%s":[' % root first = True for item in items: if first: first = False else: yield ',' yield json.dumps(_rep(item, expand=expand)) yield ']}' def is_expand(): """Returns whether the current request is for an expanded response.""" return flask.request.args.get('expand') is not None def is_delete(): """Returns whether the current delete request should remove the selected files. """ return flask.request.args.get('delete') is not None def get_method(): """Returns the HTTP method of the current request.""" return flask.request.method def resource(name, patchable=False): """Decorates a function to handle RESTful HTTP requests for a resource. """ def make_responder(retriever): def responder(ids): entities = [retriever(id) for id in ids] entities = [entity for entity in entities if entity] if get_method() == "DELETE": if app.config.get('READONLY', True): return flask.abort(405) for entity in entities: entity.remove(delete=is_delete()) return flask.make_response(jsonify({'deleted': True}), 200) elif get_method() == "PATCH" and patchable: if app.config.get('READONLY', True): return flask.abort(405) for entity in entities: entity.update(flask.request.get_json()) entity.try_sync(True, False) # write, don't move if len(entities) == 1: return flask.jsonify(_rep(entities[0], expand=is_expand())) elif entities: return app.response_class( json_generator(entities, root=name), mimetype='application/json' ) elif get_method() == "GET": if len(entities) == 1: return flask.jsonify(_rep(entities[0], expand=is_expand())) elif entities: return app.response_class( json_generator(entities, root=name), mimetype='application/json' ) else: return flask.abort(404) else: return flask.abort(405) responder.__name__ = f'get_{name}' return responder return make_responder def resource_query(name, patchable=False): """Decorates a function to handle RESTful HTTP queries for resources. """ def make_responder(query_func): def responder(queries): entities = query_func(queries) if get_method() == "DELETE": if app.config.get('READONLY', True): return flask.abort(405) for entity in entities: entity.remove(delete=is_delete()) return flask.make_response(jsonify({'deleted': True}), 200) elif get_method() == "PATCH" and patchable: if app.config.get('READONLY', True): return flask.abort(405) for entity in entities: entity.update(flask.request.get_json()) entity.try_sync(True, False) # write, don't move return app.response_class( json_generator(entities, root=name), mimetype='application/json' ) elif get_method() == "GET": return app.response_class( json_generator( entities, root='results', expand=is_expand() ), mimetype='application/json' ) else: return flask.abort(405) responder.__name__ = f'query_{name}' return responder return make_responder def resource_list(name): """Decorates a function to handle RESTful HTTP request for a list of resources. """ def make_responder(list_all): def responder(): return app.response_class( json_generator(list_all(), root=name, expand=is_expand()), mimetype='application/json' ) responder.__name__ = f'all_{name}' return responder return make_responder def _get_unique_table_field_values(model, field, sort_field): """ retrieve all unique values belonging to a key from a model """ if field not in model.all_keys() or sort_field not in model.all_keys(): raise KeyError with g.lib.transaction() as tx: rows = tx.query('SELECT DISTINCT "{}" FROM "{}" ORDER BY "{}"' .format(field, model._table, sort_field)) return [row[0] for row in rows] class IdListConverter(BaseConverter): """Converts comma separated lists of ids in urls to integer lists. """ def to_python(self, value): ids = [] for id in value.split(','): try: ids.append(int(id)) except ValueError: pass return ids def to_url(self, value): return ','.join(str(v) for v in value) class QueryConverter(PathConverter): """Converts slash separated lists of queries in the url to string list. """ def to_python(self, value): queries = value.split('/') """Do not do path substitution on regex value tests""" return [query if '::' in query else query.replace('\\', os.sep) for query in queries] def to_url(self, value): return ','.join([v.replace(os.sep, '\\') for v in value]) class EverythingConverter(PathConverter): regex = '.*?' # Flask setup. app = flask.Flask(__name__) app.url_map.converters['idlist'] = IdListConverter app.url_map.converters['query'] = QueryConverter app.url_map.converters['everything'] = EverythingConverter @app.before_request def before_request(): g.lib = app.config['lib'] # Items. @app.route('/item/<idlist:ids>', methods=["GET", "DELETE", "PATCH"]) @resource('items', patchable=True) def get_item(id): return g.lib.get_item(id) @app.route('/item/') @app.route('/item/query/') @resource_list('items') def all_items(): return g.lib.items() @app.route('/item/<int:item_id>/file') def item_file(item_id): item = g.lib.get_item(item_id) # On Windows under Python 2, Flask wants a Unicode path. On Python 3, it # *always* wants a Unicode path. if os.name == 'nt': item_path = util.syspath(item.path) else: item_path = util.py3_path(item.path) try: unicode_item_path = util.text_string(item.path) except (UnicodeDecodeError, UnicodeEncodeError): unicode_item_path = util.displayable_path(item.path) base_filename = os.path.basename(unicode_item_path) try: # Imitate http.server behaviour base_filename.encode("latin-1", "strict") except UnicodeEncodeError: safe_filename = unidecode(base_filename) else: safe_filename = base_filename response = flask.send_file( item_path, as_attachment=True, attachment_filename=safe_filename ) response.headers['Content-Length'] = os.path.getsize(item_path) return response @app.route('/item/query/<query:queries>', methods=["GET", "DELETE", "PATCH"]) @resource_query('items', patchable=True) def item_query(queries): return g.lib.items(queries) @app.route('/item/path/<everything:path>') def item_at_path(path): query = beets.library.PathQuery('path', path.encode('utf-8')) item = g.lib.items(query).get() if item: return flask.jsonify(_rep(item)) else: return flask.abort(404) @app.route('/item/values/<string:key>') def item_unique_field_values(key): sort_key = flask.request.args.get('sort_key', key) try: values = _get_unique_table_field_values(beets.library.Item, key, sort_key) except KeyError: return flask.abort(404) return flask.jsonify(values=values) # Albums. @app.route('/album/<idlist:ids>', methods=["GET", "DELETE"]) @resource('albums') def get_album(id): return g.lib.get_album(id) @app.route('/album/') @app.route('/album/query/') @resource_list('albums') def all_albums(): return g.lib.albums() @app.route('/album/query/<query:queries>', methods=["GET", "DELETE"]) @resource_query('albums') def album_query(queries): return g.lib.albums(queries) @app.route('/album/<int:album_id>/art') def album_art(album_id): album = g.lib.get_album(album_id) if album and album.artpath: return flask.send_file(album.artpath.decode()) else: return flask.abort(404) @app.route('/album/values/<string:key>') def album_unique_field_values(key): sort_key = flask.request.args.get('sort_key', key) try: values = _get_unique_table_field_values(beets.library.Album, key, sort_key) except KeyError: return flask.abort(404) return flask.jsonify(values=values) # Artists. @app.route('/artist/') def all_artists(): with g.lib.transaction() as tx: rows = tx.query("SELECT DISTINCT albumartist FROM albums") all_artists = [row[0] for row in rows] return flask.jsonify(artist_names=all_artists) # Library information. @app.route('/stats') def stats(): with g.lib.transaction() as tx: item_rows = tx.query("SELECT COUNT(*) FROM items") album_rows = tx.query("SELECT COUNT(*) FROM albums") return flask.jsonify({ 'items': item_rows[0][0], 'albums': album_rows[0][0], }) # UI. @app.route('/') def home(): return flask.render_template('index.html') # Plugin hook. class WebPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'host': '127.0.0.1', 'port': 8337, 'cors': '', 'cors_supports_credentials': False, 'reverse_proxy': False, 'include_paths': False, 'readonly': True, }) def commands(self): cmd = ui.Subcommand('web', help='start a Web interface') cmd.parser.add_option('-d', '--debug', action='store_true', default=False, help='debug mode') def func(lib, opts, args): args = ui.decargs(args) if args: self.config['host'] = args.pop(0) if args: self.config['port'] = int(args.pop(0)) app.config['lib'] = lib # Normalizes json output app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False app.config['INCLUDE_PATHS'] = self.config['include_paths'] app.config['READONLY'] = self.config['readonly'] # Enable CORS if required. if self.config['cors']: self._log.info('Enabling CORS with origin: {0}', self.config['cors']) from flask_cors import CORS app.config['CORS_ALLOW_HEADERS'] = "Content-Type" app.config['CORS_RESOURCES'] = { r"/*": {"origins": self.config['cors'].get(str)} } CORS( app, supports_credentials=self.config[ 'cors_supports_credentials' ].get(bool) ) # Allow serving behind a reverse proxy if self.config['reverse_proxy']: app.wsgi_app = ReverseProxied(app.wsgi_app) # Start the web application. app.run(host=self.config['host'].as_str(), port=self.config['port'].get(int), debug=opts.debug, threaded=True) cmd.func = func return [cmd] class ReverseProxied: '''Wrap the application in this middleware and configure the front-end server to add these headers, to let you quietly bind this to a URL other than / and to an HTTP scheme that is different than what is used locally. In nginx: location /myprefix { proxy_pass http://192.168.0.1:5001; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Scheme $scheme; proxy_set_header X-Script-Name /myprefix; } From: http://flask.pocoo.org/snippets/35/ :param app: the WSGI application ''' def __init__(self, app): self.app = app def __call__(self, environ, start_response): script_name = environ.get('HTTP_X_SCRIPT_NAME', '') if script_name: environ['SCRIPT_NAME'] = script_name path_info = environ['PATH_INFO'] if path_info.startswith(script_name): environ['PATH_INFO'] = path_info[len(script_name):] scheme = environ.get('HTTP_X_SCHEME', '') if scheme: environ['wsgi.url_scheme'] = scheme return self.app(environ, start_response) �������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000034�00000000000�010212� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������28 mtime=1638031078.3265946 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/static/�������������������������������������������������������������������0000755�0000765�0000024�00000000000�00000000000�016363� 5����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1589741074.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/static/backbone.js��������������������������������������������������������0000644�0000765�0000024�00000123137�00000000000�020474� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������// 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); ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1589741074.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/static/beets.css����������������������������������������������������������0000644�0000765�0000024�00000005607�00000000000�020207� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������body { font-family: Helvetica, Arial, sans-serif; } #header { position: fixed; left: 0; right: 0; top: 0; height: 36px; color: white; cursor: default; /* shadowy border */ box-shadow: 0 0 20px #999; -webkit-box-shadow: 0 0 20px #999; -moz-box-shadow: 0 0 20px #999; /* background gradient */ background: #0e0e0e; background: -moz-linear-gradient(top, #6b6b6b 0%, #0e0e0e 100%); background: -webkit-linear-gradient(top, #6b6b6b 0%,#0e0e0e 100%); } #header h1 { font-size: 1.1em; font-weight: bold; color: white; margin: 0.35em; float: left; } #entities { width: 17em; position: fixed; top: 36px; left: 0; bottom: 0; margin: 0; z-index: 1; background: #dde4eb; /* shadowy border */ box-shadow: 0 0 20px #666; -webkit-box-shadow: 0 0 20px #666; -moz-box-shadow: 0 0 20px #666; } #queryForm { display: block; text-align: center; margin: 0.25em 0; } #query { width: 95%; font-size: 1em; } #entities ul { width: 17em; position: fixed; top: 36px; left: 0; bottom: 0; margin: 2.2em 0 0 0; padding: 0; overflow-y: auto; overflow-x: hidden; } #entities ul li { list-style: none; padding: 4px 8px; margin: 0; cursor: default; } #entities ul li.selected { background: #7abcff; background: -moz-linear-gradient(top, #7abcff 0%, #60abf8 44%, #4096ee 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#7abcff), color-stop(44%,#60abf8), color-stop(100%,#4096ee)); color: white; } #entities ul li .playing { margin-left: 5px; font-size: 0.9em; } #main-detail, #extra-detail { position: fixed; left: 17em; margin: 1.0em 0 0 1.5em; } #main-detail { top: 36px; height: 98px; } #main-detail .artist, #main-detail .album, #main-detail .title { display: block; } #main-detail .title { font-size: 1.3em; font-weight: bold; } #main-detail .albumtitle { font-style: italic; } #extra-detail { overflow-x: hidden; overflow-y: auto; top: 134px; bottom: 0; right: 0; } /*Fix for correctly displaying line breaks in lyrics*/ #extra-detail .lyrics { white-space: pre-wrap; } #extra-detail dl dt, #extra-detail dl dd { list-style: none; margin: 0; padding: 0; } #extra-detail dl dt { width: 10em; float: left; text-align: right; font-weight: bold; clear: both; } #extra-detail dl dd { margin-left: 10.5em; } #player { float: left; width: 150px; height: 36px; } #player .play, #player .pause, #player .disabled { -webkit-appearance: none; font-size: 1em; font-family: Helvetica, Arial, sans-serif; background: none; border: none; color: white; padding: 5px; margin: 0; text-align: center; width: 36px; height: 36px; } #player .disabled { color: #666; } �������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1589741074.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/static/beets.js�����������������������������������������������������������0000644�0000765�0000024�00000021233�00000000000�020024� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������// Format times as minutes and seconds. var timeFormat = function(secs) { if (secs == undefined || isNaN(secs)) { return '0:00'; } secs = Math.round(secs); var mins = '' + Math.floor(secs / 60); secs = '' + (secs % 60); if (secs.length < 2) { secs = '0' + secs; } return mins + ':' + secs; } // jQuery extension encapsulating event hookups for audio element controls. $.fn.player = function(debug) { // Selected element should contain an HTML5 Audio element. var audio = $('audio', this).get(0); // Control elements that may be present, identified by class. var playBtn = $('.play', this); var pauseBtn = $('.pause', this); var disabledInd = $('.disabled', this); var timesEl = $('.times', this); var curTimeEl = $('.currentTime', this); var totalTimeEl = $('.totalTime', this); var sliderPlayedEl = $('.slider .played', this); var sliderLoadedEl = $('.slider .loaded', this); // Button events. playBtn.click(function() { audio.play(); }); pauseBtn.click(function(ev) { audio.pause(); }); // Utilities. var timePercent = function(cur, total) { if (cur == undefined || isNaN(cur) || total == undefined || isNaN(total) || total == 0) { return 0; } var ratio = cur / total; if (ratio > 1.0) { ratio = 1.0; } return (Math.round(ratio * 10000) / 100) + '%'; } // Event helpers. var dbg = function(msg) { if (debug) console.log(msg); } var showState = function() { if (audio.duration == undefined || isNaN(audio.duration)) { playBtn.hide(); pauseBtn.hide(); disabledInd.show(); timesEl.hide(); } else if (audio.paused) { playBtn.show(); pauseBtn.hide(); disabledInd.hide(); timesEl.show(); } else { playBtn.hide(); pauseBtn.show(); disabledInd.hide(); timesEl.show(); } } var showTimes = function() { curTimeEl.text(timeFormat(audio.currentTime)); totalTimeEl.text(timeFormat(audio.duration)); sliderPlayedEl.css('width', timePercent(audio.currentTime, audio.duration)); // last time buffered var bufferEnd = 0; for (var i = 0; i < audio.buffered.length; ++i) { if (audio.buffered.end(i) > bufferEnd) bufferEnd = audio.buffered.end(i); } sliderLoadedEl.css('width', timePercent(bufferEnd, audio.duration)); } // Initialize controls. showState(); showTimes(); // Bind events. $('audio', this).bind({ playing: function() { dbg('playing'); showState(); }, pause: function() { dbg('pause'); showState(); }, ended: function() { dbg('ended'); showState(); }, progress: function() { dbg('progress ' + audio.buffered); }, timeupdate: function() { dbg('timeupdate ' + audio.currentTime); showTimes(); }, durationchange: function() { dbg('durationchange ' + audio.duration); showState(); showTimes(); }, loadeddata: function() { dbg('loadeddata'); }, loadedmetadata: function() { dbg('loadedmetadata'); } }); } // Simple selection disable for jQuery. // Cut-and-paste from: // https://stackoverflow.com/questions/2700000 $.fn.disableSelection = function() { $(this).attr('unselectable', 'on') .css('-moz-user-select', 'none') .each(function() { this.onselectstart = function() { return false; }; }); }; $(function() { // Routes. var BeetsRouter = Backbone.Router.extend({ routes: { "item/query/:query": "itemQuery", }, itemQuery: function(query) { var queryURL = query.split(/\s+/).map(encodeURIComponent).join('/'); $.getJSON('item/query/' + queryURL, function(data) { var models = _.map( data['results'], function(d) { return new Item(d); } ); var results = new Items(models); app.showItems(results); }); } }); var router = new BeetsRouter(); // Model. var Item = Backbone.Model.extend({ urlRoot: 'item' }); var Items = Backbone.Collection.extend({ model: Item }); // Item views. var ItemEntryView = Backbone.View.extend({ tagName: "li", template: _.template($('#item-entry-template').html()), events: { 'click': 'select', 'dblclick': 'play' }, initialize: function() { this.playing = false; }, render: function() { $(this.el).html(this.template(this.model.toJSON())); this.setPlaying(this.playing); return this; }, select: function() { app.selectItem(this); }, play: function() { app.playItem(this.model); }, setPlaying: function(val) { this.playing = val; if (val) this.$('.playing').show(); else this.$('.playing').hide(); } }); //Holds Title, Artist, Album etc. var ItemMainDetailView = Backbone.View.extend({ tagName: "div", template: _.template($('#item-main-detail-template').html()), events: { 'click .play': 'play', }, render: function() { $(this.el).html(this.template(this.model.toJSON())); return this; }, play: function() { app.playItem(this.model); } }); // Holds Track no., Format, MusicBrainz link, Lyrics, Comments etc. var ItemExtraDetailView = Backbone.View.extend({ tagName: "div", template: _.template($('#item-extra-detail-template').html()), render: function() { $(this.el).html(this.template(this.model.toJSON())); return this; } }); // Main app view. var AppView = Backbone.View.extend({ el: $('body'), events: { 'submit #queryForm': 'querySubmit', }, querySubmit: function(ev) { ev.preventDefault(); router.navigate('item/query/' + encodeURIComponent($('#query').val()), true); }, initialize: function() { this.playingItem = null; this.shownItems = null; // Not sure why these events won't bind automatically. this.$('audio').bind({ 'play': _.bind(this.audioPlay, this), 'pause': _.bind(this.audioPause, this), 'ended': _.bind(this.audioEnded, this) }); }, showItems: function(items) { this.shownItems = items; $('#results').empty(); items.each(function(item) { var view = new ItemEntryView({model: item}); item.entryView = view; $('#results').append(view.render().el); }); }, selectItem: function(view) { // Mark row as selected. $('#results li').removeClass("selected"); $(view.el).addClass("selected"); // Show main and extra detail. var mainDetailView = new ItemMainDetailView({model: view.model}); $('#main-detail').empty().append(mainDetailView.render().el); var extraDetailView = new ItemExtraDetailView({model: view.model}); $('#extra-detail').empty().append(extraDetailView.render().el); }, playItem: function(item) { var url = 'item/' + item.get('id') + '/file'; $('#player audio').attr('src', url); $('#player audio').get(0).play(); if (this.playingItem != null) { this.playingItem.entryView.setPlaying(false); } item.entryView.setPlaying(true); this.playingItem = item; }, audioPause: function() { this.playingItem.entryView.setPlaying(false); }, audioPlay: function() { if (this.playingItem != null) this.playingItem.entryView.setPlaying(true); }, audioEnded: function() { this.playingItem.entryView.setPlaying(false); // Try to play the next track. var idx = this.shownItems.indexOf(this.playingItem); if (idx == -1) { // Not in current list. return; } var nextIdx = idx + 1; if (nextIdx >= this.shownItems.size()) { // End of list. return; } this.playItem(this.shownItems.at(nextIdx)); } }); var app = new AppView(); // App setup. Backbone.history.start({pushState: false}); // Disable selection on UI elements. $('#entities ul').disableSelection(); $('#header').disableSelection(); // Audio player setup. $('#player').player(); }); ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1589741074.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/static/jquery.js����������������������������������������������������������0000644�0000765�0000024�00000744653�00000000000�020263� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������/*! * jQuery JavaScript Library v1.7.1 * http://jquery.com/ * * Copyright 2016, John Resig * Dual licensed under the MIT or GPL Version 2 licenses. * http://jquery.org/license * * Includes Sizzle.js * http://sizzlejs.com/ * Copyright 2016, The Dojo Foundation * Released under the MIT, BSD, and GPL Licenses. * * Date: Mon Nov 21 21:11:03 2011 -0500 */ (function( window, undefined ) { // Use the correct document accordingly with window argument (sandbox) var document = window.document, navigator = window.navigator, location = window.location; var jQuery = (function() { // Define a local copy of jQuery var jQuery = function( selector, context ) { // The jQuery object is actually just the init constructor 'enhanced' return new jQuery.fn.init( selector, context, rootjQuery ); }, // Map over jQuery in case of overwrite _jQuery = window.jQuery, // Map over the $ in case of overwrite _$ = window.$, // A central reference to the root jQuery(document) rootjQuery, // A simple way to check for HTML strings or ID strings // Prioritize #id over <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 2016, The Dojo Foundation * Released under the MIT, BSD, and GPL Licenses. * More information: http://sizzlejs.com/ */ (function(){ var chunker = /((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^\[\]]*\]|['"][^'"]*['"]|[^\[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g, expando = "sizcache" + (Math.random() + '').replace('.', ''), done = 0, toString = Object.prototype.toString, hasDuplicate = false, baseHasDuplicate = true, rBackslash = /\\/g, rReturn = /\r\n/g, rNonWord = /\W/; // Here we check if the JavaScript engine is using some sort of // optimization where it does not always call our comparision // function. If that is the case, discard the hasDuplicate value. // Thus far that includes Google Chrome. [0, 0].sort(function() { baseHasDuplicate = false; return 0; }); var Sizzle = function( selector, context, results, seed ) { results = results || []; context = context || document; var origContext = context; if ( context.nodeType !== 1 && context.nodeType !== 9 ) { return []; } if ( !selector || typeof selector !== "string" ) { return results; } var m, set, checkSet, extra, ret, cur, pop, i, prune = true, contextXML = Sizzle.isXML( context ), parts = [], soFar = selector; // Reset the position of the chunker regexp (start from head) do { chunker.exec( "" ); m = chunker.exec( soFar ); if ( m ) { soFar = m[3]; parts.push( m[1] ); if ( m[2] ) { extra = m[3]; break; } } } while ( m ); if ( parts.length > 1 && origPOS.exec( selector ) ) { if ( parts.length === 2 && Expr.relative[ parts[0] ] ) { set = posProcess( parts[0] + parts[1], context, seed ); } else { set = Expr.relative[ parts[0] ] ? [ context ] : Sizzle( parts.shift(), context ); while ( parts.length ) { selector = parts.shift(); if ( Expr.relative[ selector ] ) { selector += parts.shift(); } set = posProcess( selector, set, seed ); } } } else { // Take a shortcut and set the context if the root selector is an ID // (but not if it'll be faster if the inner selector is an ID) if ( !seed && parts.length > 1 && context.nodeType === 9 && !contextXML && Expr.match.ID.test(parts[0]) && !Expr.match.ID.test(parts[parts.length - 1]) ) { ret = Sizzle.find( parts.shift(), context, contextXML ); context = ret.expr ? Sizzle.filter( ret.expr, ret.set )[0] : ret.set[0]; } if ( context ) { ret = seed ? { expr: parts.pop(), set: makeArray(seed) } : Sizzle.find( parts.pop(), parts.length === 1 && (parts[0] === "~" || parts[0] === "+") && context.parentNode ? context.parentNode : context, contextXML ); set = ret.expr ? Sizzle.filter( ret.expr, ret.set ) : ret.set; if ( parts.length > 0 ) { checkSet = makeArray( set ); } else { prune = false; } while ( parts.length ) { cur = parts.pop(); pop = cur; if ( !Expr.relative[ cur ] ) { cur = ""; } else { pop = parts.pop(); } if ( pop == null ) { pop = context; } Expr.relative[ cur ]( checkSet, pop, contextXML ); } } else { checkSet = parts = []; } } if ( !checkSet ) { checkSet = set; } if ( !checkSet ) { Sizzle.error( cur || selector ); } if ( toString.call(checkSet) === "[object Array]" ) { if ( !prune ) { results.push.apply( results, checkSet ); } else if ( context && context.nodeType === 1 ) { for ( i = 0; checkSet[i] != null; i++ ) { if ( checkSet[i] && (checkSet[i] === true || checkSet[i].nodeType === 1 && Sizzle.contains(context, checkSet[i])) ) { results.push( set[i] ); } } } else { for ( i = 0; checkSet[i] != null; i++ ) { if ( checkSet[i] && checkSet[i].nodeType === 1 ) { results.push( set[i] ); } } } } else { makeArray( checkSet, results ); } if ( extra ) { Sizzle( extra, origContext, results, seed ); Sizzle.uniqueSort( results ); } return results; }; Sizzle.uniqueSort = function( results ) { if ( sortOrder ) { hasDuplicate = baseHasDuplicate; results.sort( sortOrder ); if ( hasDuplicate ) { for ( var i = 1; i < results.length; i++ ) { if ( results[i] === results[ i - 1 ] ) { results.splice( i--, 1 ); } } } } return results; }; Sizzle.matches = function( expr, set ) { return Sizzle( expr, null, null, set ); }; Sizzle.matchesSelector = function( node, expr ) { return Sizzle( expr, null, null, [node] ).length > 0; }; Sizzle.find = function( expr, context, isXML ) { var set, i, len, match, type, left; if ( !expr ) { return []; } for ( i = 0, len = Expr.order.length; i < len; i++ ) { type = Expr.order[i]; if ( (match = Expr.leftMatch[ type ].exec( expr )) ) { left = match[1]; match.splice( 1, 1 ); if ( left.substr( left.length - 1 ) !== "\\" ) { match[1] = (match[1] || "").replace( rBackslash, "" ); set = Expr.find[ type ]( match, context, isXML ); if ( set != null ) { expr = expr.replace( Expr.match[ type ], "" ); break; } } } } if ( !set ) { set = typeof context.getElementsByTagName !== "undefined" ? context.getElementsByTagName( "*" ) : []; } return { set: set, expr: expr }; }; Sizzle.filter = function( expr, set, inplace, not ) { var match, anyFound, type, found, item, filter, left, i, pass, old = expr, result = [], curLoop = set, isXMLFilter = set && set[0] && Sizzle.isXML( set[0] ); while ( expr && set.length ) { for ( type in Expr.filter ) { if ( (match = Expr.leftMatch[ type ].exec( expr )) != null && match[2] ) { filter = Expr.filter[ type ]; left = match[1]; anyFound = false; match.splice(1,1); if ( left.substr( left.length - 1 ) === "\\" ) { continue; } if ( curLoop === result ) { result = []; } if ( Expr.preFilter[ type ] ) { match = Expr.preFilter[ type ]( match, curLoop, inplace, result, not, isXMLFilter ); if ( !match ) { anyFound = found = true; } else if ( match === true ) { continue; } } if ( match ) { for ( i = 0; (item = curLoop[i]) != null; i++ ) { if ( item ) { found = filter( item, match, i, curLoop ); pass = not ^ found; if ( inplace && found != null ) { if ( pass ) { anyFound = true; } else { curLoop[i] = false; } } else if ( pass ) { result.push( item ); anyFound = true; } } } } if ( found !== undefined ) { if ( !inplace ) { curLoop = result; } expr = expr.replace( Expr.match[ type ], "" ); if ( !anyFound ) { return []; } break; } } } // Improper expression if ( expr === old ) { if ( anyFound == null ) { Sizzle.error( expr ); } else { break; } } old = expr; } return curLoop; }; Sizzle.error = function( msg ) { throw new Error( "Syntax error, unrecognized expression: " + msg ); }; /** * Utility function for retreiving the text value of an array of DOM nodes * @param {Array|Element} elem */ var getText = Sizzle.getText = function( elem ) { var i, node, nodeType = elem.nodeType, ret = ""; if ( nodeType ) { if ( nodeType === 1 || nodeType === 9 ) { // Use textContent || innerText for elements if ( typeof elem.textContent === 'string' ) { return elem.textContent; } else if ( typeof elem.innerText === 'string' ) { // Replace IE's carriage returns return elem.innerText.replace( rReturn, '' ); } else { // Traverse it's children for ( elem = elem.firstChild; elem; elem = elem.nextSibling) { ret += getText( elem ); } } } else if ( nodeType === 3 || nodeType === 4 ) { return elem.nodeValue; } } else { // If no nodeType, this is expected to be an array for ( i = 0; (node = elem[i]); i++ ) { // Do not traverse comment nodes if ( node.nodeType !== 8 ) { ret += getText( node ); } } } return ret; }; var Expr = Sizzle.selectors = { order: [ "ID", "NAME", "TAG" ], match: { ID: /#((?:[\w\u00c0-\uFFFF\-]|\\.)+)/, CLASS: /\.((?:[\w\u00c0-\uFFFF\-]|\\.)+)/, NAME: /\[name=['"]*((?:[\w\u00c0-\uFFFF\-]|\\.)+)['"]*\]/, ATTR: /\[\s*((?:[\w\u00c0-\uFFFF\-]|\\.)+)\s*(?:(\S?=)\s*(?:(['"])(.*?)\3|(#?(?:[\w\u00c0-\uFFFF\-]|\\.)*)|)|)\s*\]/, TAG: /^((?:[\w\u00c0-\uFFFF\*\-]|\\.)+)/, CHILD: /:(only|nth|last|first)-child(?:\(\s*(even|odd|(?:[+\-]?\d+|(?:[+\-]?\d*)?n\s*(?:[+\-]\s*\d+)?))\s*\))?/, POS: /:(nth|eq|gt|lt|first|last|even|odd)(?:\((\d*)\))?(?=[^\-]|$)/, PSEUDO: /:((?:[\w\u00c0-\uFFFF\-]|\\.)+)(?:\((['"]?)((?:\([^\)]+\)|[^\(\)]*)+)\2\))?/ }, leftMatch: {}, attrMap: { "class": "className", "for": "htmlFor" }, attrHandle: { href: function( elem ) { return elem.getAttribute( "href" ); }, type: function( elem ) { return elem.getAttribute( "type" ); } }, relative: { "+": function(checkSet, part){ var isPartStr = typeof part === "string", isTag = isPartStr && !rNonWord.test( part ), isPartStrNotTag = isPartStr && !isTag; if ( isTag ) { part = part.toLowerCase(); } for ( var i = 0, l = checkSet.length, elem; i < l; i++ ) { if ( (elem = checkSet[i]) ) { while ( (elem = elem.previousSibling) && elem.nodeType !== 1 ) {} checkSet[i] = isPartStrNotTag || elem && elem.nodeName.toLowerCase() === part ? elem || false : elem === part; } } if ( isPartStrNotTag ) { Sizzle.filter( part, checkSet, true ); } }, ">": function( checkSet, part ) { var elem, isPartStr = typeof part === "string", i = 0, l = checkSet.length; if ( isPartStr && !rNonWord.test( part ) ) { part = part.toLowerCase(); for ( ; i < l; i++ ) { elem = checkSet[i]; if ( elem ) { var parent = elem.parentNode; checkSet[i] = parent.nodeName.toLowerCase() === part ? parent : false; } } } else { for ( ; i < l; i++ ) { elem = checkSet[i]; if ( elem ) { checkSet[i] = isPartStr ? elem.parentNode : elem.parentNode === part; } } if ( isPartStr ) { Sizzle.filter( part, checkSet, true ); } } }, "": function(checkSet, part, isXML){ var nodeCheck, doneName = done++, checkFn = dirCheck; if ( typeof part === "string" && !rNonWord.test( part ) ) { part = part.toLowerCase(); nodeCheck = part; checkFn = dirNodeCheck; } checkFn( "parentNode", part, doneName, checkSet, nodeCheck, isXML ); }, "~": function( checkSet, part, isXML ) { var nodeCheck, doneName = done++, checkFn = dirCheck; if ( typeof part === "string" && !rNonWord.test( part ) ) { part = part.toLowerCase(); nodeCheck = part; checkFn = dirNodeCheck; } checkFn( "previousSibling", part, doneName, checkSet, nodeCheck, isXML ); } }, find: { ID: function( match, context, isXML ) { if ( typeof context.getElementById !== "undefined" && !isXML ) { var m = context.getElementById(match[1]); // Check parentNode to catch when Blackberry 4.6 returns // nodes that are no longer in the document #6963 return m && m.parentNode ? [m] : []; } }, NAME: function( match, context ) { if ( typeof context.getElementsByName !== "undefined" ) { var ret = [], results = context.getElementsByName( match[1] ); for ( var i = 0, l = results.length; i < l; i++ ) { if ( results[i].getAttribute("name") === match[1] ) { ret.push( results[i] ); } } return ret.length === 0 ? null : ret; } }, TAG: function( match, context ) { if ( typeof context.getElementsByTagName !== "undefined" ) { return context.getElementsByTagName( match[1] ); } } }, preFilter: { CLASS: function( match, curLoop, inplace, result, not, isXML ) { match = " " + match[1].replace( rBackslash, "" ) + " "; if ( isXML ) { return match; } for ( var i = 0, elem; (elem = curLoop[i]) != null; i++ ) { if ( elem ) { if ( not ^ (elem.className && (" " + elem.className + " ").replace(/[\t\n\r]/g, " ").indexOf(match) >= 0) ) { if ( !inplace ) { result.push( elem ); } } else if ( inplace ) { curLoop[i] = false; } } } return false; }, ID: function( match ) { return match[1].replace( rBackslash, "" ); }, TAG: function( match, curLoop ) { return match[1].replace( rBackslash, "" ).toLowerCase(); }, CHILD: function( match ) { if ( match[1] === "nth" ) { if ( !match[2] ) { Sizzle.error( match[0] ); } match[2] = match[2].replace(/^\+|\s*/g, ''); // parse equations like 'even', 'odd', '5', '2n', '3n+2', '4n-1', '-n+6' var test = /(-?)(\d*)(?:n([+\-]?\d*))?/.exec( match[2] === "even" && "2n" || match[2] === "odd" && "2n+1" || !/\D/.test( match[2] ) && "0n+" + match[2] || match[2]); // calculate the numbers (first)n+(last) including if they are negative match[2] = (test[1] + (test[2] || 1)) - 0; match[3] = test[3] - 0; } else if ( match[2] ) { Sizzle.error( match[0] ); } // TODO: Move to normal caching system match[0] = done++; return match; }, ATTR: function( match, curLoop, inplace, result, not, isXML ) { var name = match[1] = match[1].replace( rBackslash, "" ); if ( !isXML && Expr.attrMap[name] ) { match[1] = Expr.attrMap[name]; } // Handle if an un-quoted value was used match[4] = ( match[4] || match[5] || "" ).replace( rBackslash, "" ); if ( match[2] === "~=" ) { match[4] = " " + match[4] + " "; } return match; }, PSEUDO: function( match, curLoop, inplace, result, not ) { if ( match[1] === "not" ) { // If we're dealing with a complex expression, or a simple one if ( ( chunker.exec(match[3]) || "" ).length > 1 || /^\w/.test(match[3]) ) { match[3] = Sizzle(match[3], null, null, curLoop); } else { var ret = Sizzle.filter(match[3], curLoop, inplace, true ^ not); if ( !inplace ) { result.push.apply( result, ret ); } return false; } } else if ( Expr.match.POS.test( match[0] ) || Expr.match.CHILD.test( match[0] ) ) { return true; } return match; }, POS: function( match ) { match.unshift( true ); return match; } }, filters: { enabled: function( elem ) { return elem.disabled === false && elem.type !== "hidden"; }, disabled: function( elem ) { return elem.disabled === true; }, checked: function( elem ) { return elem.checked === true; }, selected: function( elem ) { // Accessing this property makes selected-by-default // options in Safari work properly if ( elem.parentNode ) { elem.parentNode.selectedIndex; } return elem.selected === true; }, parent: function( elem ) { return !!elem.firstChild; }, empty: function( elem ) { return !elem.firstChild; }, has: function( elem, i, match ) { return !!Sizzle( match[3], elem ).length; }, header: function( elem ) { return (/h\d/i).test( elem.nodeName ); }, text: function( elem ) { var attr = elem.getAttribute( "type" ), type = elem.type; // IE6 and 7 will map elem.type to 'text' for new HTML5 types (search, etc) // use getAttribute instead to test this case return elem.nodeName.toLowerCase() === "input" && "text" === type && ( attr === type || attr === null ); }, radio: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "radio" === elem.type; }, checkbox: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "checkbox" === elem.type; }, file: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "file" === elem.type; }, password: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "password" === elem.type; }, submit: function( elem ) { var name = elem.nodeName.toLowerCase(); return (name === "input" || name === "button") && "submit" === elem.type; }, image: function( elem ) { return elem.nodeName.toLowerCase() === "input" && "image" === elem.type; }, reset: function( elem ) { var name = elem.nodeName.toLowerCase(); return (name === "input" || name === "button") && "reset" === elem.type; }, button: function( elem ) { var name = elem.nodeName.toLowerCase(); return name === "input" && "button" === elem.type || name === "button"; }, input: function( elem ) { return (/input|select|textarea|button/i).test( elem.nodeName ); }, focus: function( elem ) { return elem === elem.ownerDocument.activeElement; } }, setFilters: { first: function( elem, i ) { return i === 0; }, last: function( elem, i, match, array ) { return i === array.length - 1; }, even: function( elem, i ) { return i % 2 === 0; }, odd: function( elem, i ) { return i % 2 === 1; }, lt: function( elem, i, match ) { return i < match[3] - 0; }, gt: function( elem, i, match ) { return i > match[3] - 0; }, nth: function( elem, i, match ) { return match[3] - 0 === i; }, eq: function( elem, i, match ) { return match[3] - 0 === i; } }, filter: { PSEUDO: function( elem, match, i, array ) { var name = match[1], filter = Expr.filters[ name ]; if ( filter ) { return filter( elem, i, match, array ); } else if ( name === "contains" ) { return (elem.textContent || elem.innerText || getText([ elem ]) || "").indexOf(match[3]) >= 0; } else if ( name === "not" ) { var not = match[3]; for ( var j = 0, l = not.length; j < l; j++ ) { if ( not[j] === elem ) { return false; } } return true; } else { Sizzle.error( name ); } }, CHILD: function( elem, match ) { var first, last, doneName, parent, cache, count, diff, type = match[1], node = elem; switch ( type ) { case "only": case "first": while ( (node = node.previousSibling) ) { if ( node.nodeType === 1 ) { return false; } } if ( type === "first" ) { return true; } node = elem; case "last": while ( (node = node.nextSibling) ) { if ( node.nodeType === 1 ) { return false; } } return true; case "nth": first = match[2]; last = match[3]; if ( first === 1 && last === 0 ) { return true; } doneName = match[0]; parent = elem.parentNode; if ( parent && (parent[ expando ] !== doneName || !elem.nodeIndex) ) { count = 0; for ( node = parent.firstChild; node; node = node.nextSibling ) { if ( node.nodeType === 1 ) { node.nodeIndex = ++count; } } parent[ expando ] = doneName; } diff = elem.nodeIndex - last; if ( first === 0 ) { return diff === 0; } else { return ( diff % first === 0 && diff / first >= 0 ); } } }, ID: function( elem, match ) { return elem.nodeType === 1 && elem.getAttribute("id") === match; }, TAG: function( elem, match ) { return (match === "*" && elem.nodeType === 1) || !!elem.nodeName && elem.nodeName.toLowerCase() === match; }, CLASS: function( elem, match ) { return (" " + (elem.className || elem.getAttribute("class")) + " ") .indexOf( match ) > -1; }, ATTR: function( elem, match ) { var name = match[1], result = Sizzle.attr ? Sizzle.attr( elem, name ) : Expr.attrHandle[ name ] ? Expr.attrHandle[ name ]( elem ) : elem[ name ] != null ? elem[ name ] : elem.getAttribute( name ), value = result + "", type = match[2], check = match[4]; return result == null ? type === "!=" : !type && Sizzle.attr ? result != null : type === "=" ? value === check : type === "*=" ? value.indexOf(check) >= 0 : type === "~=" ? (" " + value + " ").indexOf(check) >= 0 : !check ? value && result !== false : type === "!=" ? value !== check : type === "^=" ? value.indexOf(check) === 0 : type === "$=" ? value.substr(value.length - check.length) === check : type === "|=" ? value === check || value.substr(0, check.length + 1) === check + "-" : false; }, POS: function( elem, match, i, array ) { var name = match[2], filter = Expr.setFilters[ name ]; if ( filter ) { return filter( elem, i, match, array ); } } } }; var origPOS = Expr.match.POS, fescape = function(all, num){ return "\\" + (num - 0 + 1); }; for ( var type in Expr.match ) { Expr.match[ type ] = new RegExp( Expr.match[ type ].source + (/(?![^\[]*\])(?![^\(]*\))/.source) ); Expr.leftMatch[ type ] = new RegExp( /(^(?:.|\r|\n)*?)/.source + Expr.match[ type ].source.replace(/\\(\d+)/g, fescape) ); } var makeArray = function( array, results ) { array = Array.prototype.slice.call( array, 0 ); if ( results ) { results.push.apply( results, array ); return results; } return array; }; // Perform a simple check to determine if the browser is capable of // converting a NodeList to an array using builtin methods. // Also verifies that the returned array holds DOM nodes // (which is not the case in the Blackberry browser) try { Array.prototype.slice.call( document.documentElement.childNodes, 0 )[0].nodeType; // Provide a fallback method if it does not work } catch( e ) { makeArray = function( array, results ) { var i = 0, ret = results || []; if ( toString.call(array) === "[object Array]" ) { Array.prototype.push.apply( ret, array ); } else { if ( typeof array.length === "number" ) { for ( var l = array.length; i < l; i++ ) { ret.push( array[i] ); } } else { for ( ; array[i]; i++ ) { ret.push( array[i] ); } } } return ret; }; } var sortOrder, siblingCheck; if ( document.documentElement.compareDocumentPosition ) { sortOrder = function( a, b ) { if ( a === b ) { hasDuplicate = true; return 0; } if ( !a.compareDocumentPosition || !b.compareDocumentPosition ) { return a.compareDocumentPosition ? -1 : 1; } return a.compareDocumentPosition(b) & 4 ? -1 : 1; }; } else { sortOrder = function( a, b ) { // The nodes are identical, we can exit early if ( a === b ) { hasDuplicate = true; return 0; // Fallback to using sourceIndex (in IE) if it's available on both nodes } else if ( a.sourceIndex && b.sourceIndex ) { return a.sourceIndex - b.sourceIndex; } var al, bl, ap = [], bp = [], aup = a.parentNode, bup = b.parentNode, cur = aup; // If the nodes are siblings (or identical) we can do a quick check if ( aup === bup ) { return siblingCheck( a, b ); // If no parents were found then the nodes are disconnected } else if ( !aup ) { return -1; } else if ( !bup ) { return 1; } // Otherwise they're somewhere else in the tree so we need // to build up a full list of the parentNodes for comparison while ( cur ) { ap.unshift( cur ); cur = cur.parentNode; } cur = bup; while ( cur ) { bp.unshift( cur ); cur = cur.parentNode; } al = ap.length; bl = bp.length; // Start walking down the tree looking for a discrepancy for ( var i = 0; i < al && i < bl; i++ ) { if ( ap[i] !== bp[i] ) { return siblingCheck( ap[i], bp[i] ); } } // We ended someplace up the tree so do a sibling check return i === al ? siblingCheck( a, bp[i], -1 ) : siblingCheck( ap[i], b, 1 ); }; siblingCheck = function( a, b, ret ) { if ( a === b ) { return ret; } var cur = a.nextSibling; while ( cur ) { if ( cur === b ) { return -1; } cur = cur.nextSibling; } return 1; }; } // Check to see if the browser returns elements by name when // querying by getElementById (and provide a workaround) (function(){ // We're going to inject a fake input element with a specified name var form = document.createElement("div"), id = "script" + (new Date()).getTime(), root = document.documentElement; form.innerHTML = "<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 ); �������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1589741074.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/static/underscore.js������������������������������������������������������0000644�0000765�0000024�00000103302�00000000000�021071� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������// 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); ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000033�00000000000�010211� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������27 mtime=1638031078.327727 �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/templates/����������������������������������������������������������������0000755�0000765�0000024�00000000000�00000000000�017072� 5����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1589741074.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������beets-1.6.0/beetsplug/web/templates/index.html������������������������������������������������������0000644�0000765�0000024�00000006352�00000000000�021075� 0����������������������������������������������������������������������������������������������������ustar�00asampson������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������<!DOCTYPE html> <html> <head> <title>beets
././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/beetsplug/zero.py0000644000076500000240000001304300000000000015651 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Blemjhoo Tezoulbr . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """ Clears tag fields in media files.""" import re from beets.plugins import BeetsPlugin from mediafile import MediaFile from beets.importer import action from beets.ui import Subcommand, decargs, input_yn import confuse __author__ = 'baobab@heresiarch.info' class ZeroPlugin(BeetsPlugin): def __init__(self): super().__init__() self.register_listener('write', self.write_event) self.register_listener('import_task_choice', self.import_task_choice_event) self.config.add({ 'auto': True, 'fields': [], 'keep_fields': [], 'update_database': False, }) self.fields_to_progs = {} self.warned = False """Read the bulk of the config into `self.fields_to_progs`. After construction, `fields_to_progs` contains all the fields that should be zeroed as keys and maps each of those to a list of compiled regexes (progs) as values. A field is zeroed if its value matches one of the associated progs. If progs is empty, then the associated field is always zeroed. """ if self.config['fields'] and self.config['keep_fields']: self._log.warning( 'cannot blacklist and whitelist at the same time' ) # Blacklist mode. elif self.config['fields']: for field in self.config['fields'].as_str_seq(): self._set_pattern(field) # Whitelist mode. elif self.config['keep_fields']: for field in MediaFile.fields(): if (field not in self.config['keep_fields'].as_str_seq() and # These fields should always be preserved. field not in ('id', 'path', 'album_id')): self._set_pattern(field) def commands(self): zero_command = Subcommand('zero', help='set fields to null') def zero_fields(lib, opts, args): if not decargs(args) and not input_yn( "Remove fields for all items? (Y/n)", True): return for item in lib.items(decargs(args)): self.process_item(item) zero_command.func = zero_fields return [zero_command] def _set_pattern(self, field): """Populate `self.fields_to_progs` for a given field. Do some sanity checks then compile the regexes. """ if field not in MediaFile.fields(): self._log.error('invalid field: {0}', field) elif field in ('id', 'path', 'album_id'): self._log.warning('field \'{0}\' ignored, zeroing ' 'it would be dangerous', field) else: try: for pattern in self.config[field].as_str_seq(): prog = re.compile(pattern, re.IGNORECASE) self.fields_to_progs.setdefault(field, []).append(prog) except confuse.NotFoundError: # Matches everything self.fields_to_progs[field] = [] def import_task_choice_event(self, session, task): if task.choice_flag == action.ASIS and not self.warned: self._log.warning('cannot zero in \"as-is\" mode') self.warned = True # TODO request write in as-is mode def write_event(self, item, path, tags): if self.config['auto']: self.set_fields(item, tags) def set_fields(self, item, tags): """Set values in `tags` to `None` if the field is in `self.fields_to_progs` and any of the corresponding `progs` matches the field value. Also update the `item` itself if `update_database` is set in the config. """ fields_set = False if not self.fields_to_progs: self._log.warning('no fields, nothing to do') return False for field, progs in self.fields_to_progs.items(): if field in tags: value = tags[field] match = _match_progs(tags[field], progs) else: value = '' match = not progs if match: fields_set = True self._log.debug('{0}: {1} -> None', field, value) tags[field] = None if self.config['update_database']: item[field] = None return fields_set def process_item(self, item): tags = dict(item) if self.set_fields(item, tags): item.write(tags=tags) if self.config['update_database']: item.store(fields=tags) def _match_progs(value, progs): """Check if `value` (as string) is matching any of the compiled regexes in the `progs` list. """ if not progs: return True for prog in progs: if prog.search(str(value)): return True return False ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1638031078.334225 beets-1.6.0/docs/0000755000076500000240000000000000000000000013255 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/docs/Makefile0000644000076500000240000001123100000000000014713 0ustar00asampsonstaff# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # When both are available, use Sphinx 2.x for autodoc compatibility. ifeq ($(shell which sphinx-build2 >/dev/null 2>&1 ; echo $$?),0) SPHINXBUILD = sphinx-build2 endif # 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 auto 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." ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1638030940.0 beets-1.6.0/docs/changelog.rst0000644000076500000240000072357100000000000015755 0ustar00asampsonstaffChangelog ========= 1.6.0 (November 27, 2021) ------------------------- This release is our first experiment with time-based releases! We are aiming to publish a new release of beets every 3 months. We therefore have a healthy but not dizzyingly long list of new features and fixes. With this release, beets now requires Python 3.6 or later (it removes support for Python 2.7, 3.4, and 3.5). There are also a few other dependency changes---if you're a maintainer of a beets package for a package manager, thank you for your ongoing efforts, and please see the list of notes below. Major new features: * When fetching genres from MusicBrainz, we now include genres from the release group (in addition to the release). We also prioritize genres based on the number of votes. Thanks to :user:`aereaux`. * Primary and secondary release types from MusicBrainz are now stored in a new ``albumtypes`` field. Thanks to :user:`edgars-supe`. :bug:`2200` * An accompanying new :doc:`/plugins/albumtypes` includes some options for formatting this new ``albumtypes`` field. Thanks to :user:`edgars-supe`. Other new things: * :doc:`/plugins/permissions`: The plugin now sets cover art permissions to match the audio file permissions. * :doc:`/plugins/unimported`: A new configuration option supports excluding specific subdirectories in library. * :doc:`/plugins/info`: Add support for an ``--album`` flag. * :doc:`/plugins/export`: Similarly add support for an ``--album`` flag. * ``beet move`` now highlights path differences in color (when enabled). * When moving files and a direct rename of a file is not possible (for example, when crossing filesystems), beets now copies to a temporary file in the target folder first and then moves to the destination instead of directly copying the target path. This gets us closer to always updating files atomically. Thanks to :user:`catap`. :bug:`4060` * :doc:`/plugins/fetchart`: Add a new option to store cover art as non-progressive image. This is useful for DAPs that do not support progressive images. Set ``deinterlace: yes`` in your configuration to enable this conversion. * :doc:`/plugins/fetchart`: Add a new option to change the file format of cover art images. This may also be useful for DAPs that only support some image formats. * Support flexible attributes in ``%aunique``. :bug:`2678` :bug:`3553` * Make ``%aunique`` faster, especially when using inline fields. :bug:`4145` Bug fixes: * :doc:`/plugins/lyrics`: Fix a crash when Beautiful Soup is not installed. :bug:`4027` * :doc:`/plugins/discogs`: Support a new Discogs URL format for IDs. :bug:`4080` * :doc:`/plugins/discogs`: Remove built-in rate-limiting because the Discogs Python library we use now has its own rate-limiting. :bug: `4108` * :doc:`/plugins/export`: Fix some duplicated output. * :doc:`/plugins/aura`: Fix a potential security hole when serving image files. :bug:`4160` For plugin developers: * :py:meth:`beets.library.Item.destination` now accepts a `replacements` argument to be used in favor of the default. * The `pluginload` event is now sent after plugin types and queries are available, not before. * A new plugin event, `album_removed`, is called when an album is removed from the library (even when its file is not deleted from disk). Here are some notes for packagers: * As noted above, the minimum Python version is now 3.6. * We fixed a flaky test, named `test_album_art` in the `test_zero.py` file, that some distributions had disabled. Disabling this test should no longer be necessary. :bug:`4037` :bug:`4038` * This version of beets no longer depends on the `six`_ library. :bug:`4030` * The `gmusic` plugin was removed since Google Play Music has been shut down. Thus, the optional dependency on `gmusicapi` does not exist anymore. :bug:`4089` 1.5.0 (August 19, 2021) ----------------------- This long overdue release of beets includes far too many exciting and useful features than could ever be satisfactorily enumerated. As a technical detail, it also introduces two new external libraries: `MediaFile`_ and `Confuse`_ used to be part of beets but are now reusable dependencies---packagers, please take note. Finally, this is the last version of beets where we intend to support Python 2.x and 3.5; future releases will soon require Python 3.6. One non-technical change is that we moved our official ``#beets`` home on IRC from freenode to `Libera.Chat`_. .. _Libera.Chat: https://libera.chat/ Major new features: * Fields in queries now fall back to an item's album and check its fields too. Notably, this allows querying items by an album's attribute: in other words, ``beet list foo:bar`` will not only find tracks with the `foo` attribute; it will also find tracks *on albums* that have the `foo` attribute. This may be particularly useful in the :ref:`path-format-config`, which matches individual items to decide which path to use. Thanks to :user:`FichteFoll`. :bug:`2797` :bug:`2988` * A new :ref:`reflink` config option instructs the importer to create fast, copy-on-write file clones on filesystems that support them. Thanks to :user:`rubdos`. * A new :doc:`/plugins/unimported` lets you find untracked files in your library directory. * The :doc:`/plugins/aura` has arrived! Try out the future of remote music library access today. * We now fetch information about `works`_ from MusicBrainz. MusicBrainz matches provide the fields ``work`` (the title), ``mb_workid`` (the MBID), and ``work_disambig`` (the disambiguation string). Thanks to :user:`dosoe`. :bug:`2580` :bug:`3272` * A new :doc:`/plugins/parentwork` gets information about the original work, which is useful for classical music. Thanks to :user:`dosoe`. :bug:`2580` :bug:`3279` * :doc:`/plugins/bpd`: BPD now supports most of the features of version 0.16 of the MPD protocol. This is enough to get it talking to more complicated clients like ncmpcpp, but there are still some incompatibilities, largely due to MPD commands we don't support yet. (Let us know if you find an MPD client that doesn't get along with BPD!) :bug:`3214` :bug:`800` * A new :doc:`/plugins/deezer` can autotag tracks and albums using the `Deezer`_ database. Thanks to :user:`rhlahuja`. :bug:`3355` * A new :doc:`/plugins/bareasc` provides a new query type: "bare ASCII" queries that ignore accented characters, treating them as though they were plain ASCII characters. Use the ``#`` prefix with :ref:`list-cmd` or other commands. :bug:`3882` * :doc:`/plugins/fetchart`: The plugin can now get album art from `last.fm`_. :bug:`3530` * :doc:`/plugins/web`: The API now supports the HTTP `DELETE` and `PATCH` methods for modifying items. They are disabled by default; set ``readonly: no`` in your configuration file to enable modification via the API. :bug:`3870` Other new things: * ``beet remove`` now also allows interactive selection of items from the query, similar to ``beet modify``. * Enable HTTPS for MusicBrainz by default and add configuration option `https` for custom servers. See :ref:`musicbrainz-config` for more details. * :doc:`/plugins/mpdstats`: Add a new `strip_path` option to help build the right local path from MPD information. * :doc:`/plugins/convert`: Conversion can now parallelize conversion jobs on Python 3. * :doc:`/plugins/lastgenre`: Add a new `title_case` config option to make title-case formatting optional. * There's a new message when running ``beet config`` when there's no available configuration file. :bug:`3779` * When importing a duplicate album, the prompt now says "keep all" instead of "keep both" to reflect that there may be more than two albums involved. :bug:`3569` * :doc:`/plugins/chroma`: The plugin now updates file metadata after generating fingerprints through the `submit` command. * :doc:`/plugins/lastgenre`: Added more heavy metal genres to the built-in genre filter lists. * A new :doc:`/plugins/subsonicplaylist` can import playlists from a Subsonic server. * :doc:`/plugins/subsonicupdate`: The plugin now automatically chooses between token- and password-based authentication based on the server version. * A new :ref:`extra_tags` configuration option lets you use more metadata in MusicBrainz queries to further narrow the search. * A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets. * :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality`` option that controls the quality of the image output when the image is resized. * :doc:`plugins/keyfinder`: Added support for `keyfinder-cli`_. Thanks to :user:`BrainDamage`. * :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to allow downloading of higher resolution iTunes artwork (at the expense of file size). :bug:`3391` * :doc:`plugins/discogs`: The plugin applies two new fields: `discogs_labelid` and `discogs_artistid`. :bug:`3413` * :doc:`/plugins/export`: Added a new ``-f`` (``--format``) flag, which can export your data as JSON, JSON lines, CSV, or XML. Thanks to :user:`austinmm`. :bug:`3402` * :doc:`/plugins/convert`: Added a new ``-l`` (``--link``) flag and ``link`` option as well as the ``-H`` (``--hardlink``) flag and ``hardlink`` option, which symlink or hardlink files that do not need to be converted (instead of copying them). :bug:`2324` * :doc:`/plugins/replaygain`: The plugin now supports a ``per_disc`` option that enables calculation of album ReplayGain on disc level instead of album level. Thanks to :user:`samuelnilsson`. :bug:`293` * :doc:`/plugins/replaygain`: The new ``ffmpeg`` ReplayGain backend supports ``R128_`` tags. :bug:`3056` * :doc:`plugins/replaygain`: A new ``r128_targetlevel`` configuration option defines the reference volume for files using ``R128_`` tags. ``targetlevel`` only configures the reference volume for ``REPLAYGAIN_`` files. :bug:`3065` * :doc:`/plugins/discogs`: The plugin now collects the "style" field. Thanks to :user:`thedevilisinthedetails`. :bug:`2579` :bug:`3251` * :doc:`/plugins/absubmit`: By default, the plugin now avoids re-analyzing files that already have AcousticBrainz data. There are new ``force`` and ``pretend`` options to help control this new behavior. Thanks to :user:`SusannaMaria`. :bug:`3318` * :doc:`/plugins/discogs`: The plugin now also gets genre information and a new ``discogs_albumid`` field from the Discogs API. Thanks to :user:`thedevilisinthedetails`. :bug:`465` :bug:`3322` * :doc:`/plugins/acousticbrainz`: The plugin now fetches two more additional fields: ``moods_mirex`` and ``timbre``. Thanks to :user:`malcops`. :bug:`2860` * :doc:`/plugins/playlist` and :doc:`/plugins/smartplaylist`: A new ``forward_slash`` config option facilitates compatibility with MPD on Windows. Thanks to :user:`MartyLake`. :bug:`3331` :bug:`3334` * The `data_source` field, which indicates which metadata source was used during an autotagging import, is now also applied as an album-level flexible attribute. :bug:`3350` :bug:`1693` * :doc:`/plugins/beatport`: The plugin now gets the musical key, BPM, and genre for each track. :bug:`2080` * A new :doc:`/plugins/bpsync` can synchronize metadata changes from the Beatport database (like the existing :doc:`/plugins/mbsync` for MusicBrainz). * :doc:`/plugins/hook`: The plugin now treats non-zero exit codes as errors. :bug:`3409` * :doc:`/plugins/subsonicupdate`: A new ``url`` configuration replaces the older (and now deprecated) separate ``host``, ``port``, and ``contextpath`` config options. As a consequence, the plugin can now talk to Subsonic over HTTPS. Thanks to :user:`jef`. :bug:`3449` * :doc:`/plugins/discogs`: The new ``index_tracks`` option enables incorporation of work names and intra-work divisions into imported track titles. Thanks to :user:`cole-miller`. :bug:`3459` * :doc:`/plugins/web`: The query API now interprets backslashes as path separators to support path queries. Thanks to :user:`nmeum`. :bug:`3567` * ``beet import`` now handles tar archives with bzip2 or gzip compression. :bug:`3606` * ``beet import`` *also* now handles 7z archives, via the `py7zr`_ library. Thanks to :user:`arogl`. :bug:`3906` * :doc:`/plugins/plexupdate`: Added an option to use a secure connection to Plex server, and to ignore certificate validation errors if necessary. :bug:`2871` * :doc:`/plugins/convert`: A new ``delete_originals`` configuration option can delete the source files after conversion during import. Thanks to :user:`logan-arens`. :bug:`2947` * There is a new ``--plugins`` (or ``-p``) CLI flag to specify a list of plugins to load. * A new :ref:`genres` option fetches genre information from MusicBrainz. This functionality depends on functionality that is currently unreleased in the `python-musicbrainzngs`_ library: see PR `#266 `_. Thanks to :user:`aereaux`. * :doc:`/plugins/replaygain`: Analysis now happens in parallel using the ``command`` and ``ffmpeg`` backends. :bug:`3478` * :doc:`plugins/replaygain`: The bs1770gain backend is removed. Thanks to :user:`SamuelCook`. * Added ``trackdisambig`` which stores the recording disambiguation from MusicBrainz for each track. :bug:`1904` * :doc:`plugins/fetchart`: The new ``max_filesize`` configuration sets a maximum target image file size. * :doc:`/plugins/badfiles`: Checkers can now run during import with the ``check_on_import`` config option. * :doc:`/plugins/export`: The plugin is now much faster when using the `--include-keys` option is used. Thanks to :user:`ssssam`. * The importer's :ref:`set_fields` option now saves all updated fields to on-disk metadata. :bug:`3925` :bug:`3927` * We now fetch ISRC identifiers from MusicBrainz. Thanks to :user:`aereaux`. * :doc:`/plugins/metasync`: The plugin now also fetches the "Date Added" field from iTunes databases and stores it in the ``itunes_dateadded`` field. Thanks to :user:`sandersantema`. * :doc:`/plugins/lyrics`: Added a new Tekstowo.pl lyrics provider. Thanks to various people for the implementation and for reporting issues with the initial version. :bug:`3344` :bug:`3904` :bug:`3905` :bug:`3994` * ``beet update`` will now confirm that the user still wants to update if their library folder cannot be found, preventing the user from accidentally wiping out their beets database. Thanks to user: `logan-arens`. :bug:`1934` Fixes: * Adapt to breaking changes in Python's ``ast`` module in Python 3.8. * :doc:`/plugins/beatport`: Fix the assignment of the `genre` field, and rename `musical_key` to `initial_key`. :bug:`3387` * :doc:`/plugins/lyrics`: Fixed the Musixmatch backend for lyrics pages when lyrics are divided into multiple elements on the webpage, and when the lyrics are missing. * :doc:`/plugins/web`: Allow use of the backslash character in regex queries. :bug:`3867` * :doc:`/plugins/web`: Fixed a small bug that caused the album art path to be redacted even when ``include_paths`` option is set. :bug:`3866` * :doc:`/plugins/discogs`: Fixed a bug with the ``index_tracks`` option that sometimes caused the index to be discarded. Also, remove the extra semicolon that was added when there is no index track. * :doc:`/plugins/subsonicupdate`: The API client was using the `POST` method rather the `GET` method. Also includes better exception handling, response parsing, and tests. * :doc:`/plugins/the`: Fixed incorrect regex for "the" that matched any 3-letter combination of the letters t, h, e. :bug:`3701` * :doc:`/plugins/fetchart`: Fixed a bug that caused the plugin to not take environment variables, such as proxy servers, into account when making requests. :bug:`3450` * :doc:`/plugins/fetchart`: Temporary files for fetched album art that fail validation are now removed. * :doc:`/plugins/inline`: In function-style field definitions that refer to flexible attributes, values could stick around from one function invocation to the next. This meant that, when displaying a list of objects, later objects could seem to reuse values from earlier objects when they were missing a value for a given field. These values are now properly undefined. :bug:`2406` * :doc:`/plugins/bpd`: Seeking by fractions of a second now works as intended, fixing crashes in MPD clients like mpDris2 on seek. The ``playlistid`` command now works properly in its zero-argument form. :bug:`3214` * :doc:`/plugins/replaygain`: Fix a Python 3 incompatibility in the Python Audio Tools backend. :bug:`3305` * :doc:`/plugins/importadded`: Fixed a crash that occurred when the ``after_write`` signal was emitted. :bug:`3301` * :doc:`plugins/replaygain`: Fix the storage format for R128 gain tags. :bug:`3311` :bug:`3314` * :doc:`/plugins/discogs`: Fixed a crash that occurred when the master URI isn't set in the API response. :bug:`2965` :bug:`3239` * :doc:`/plugins/spotify`: Fix handling of year-only release dates returned by the Spotify albums API. Thanks to :user:`rhlahuja`. :bug:`3343` * Fixed a bug that caused the UI to display incorrect track numbers for tracks with index 0 when the ``per_disc_numbering`` option was set. :bug:`3346` * ``none_rec_action`` does not import automatically when ``timid`` is enabled. Thanks to :user:`RollingStar`. :bug:`3242` * Fix a bug that caused a crash when tagging items with the beatport plugin. :bug:`3374` * ``beet import`` now logs which files are ignored when in debug mode. :bug:`3764` * :doc:`/plugins/bpd`: Fix the transition to next track when in consume mode. Thanks to :user:`aereaux`. :bug:`3437` * :doc:`/plugins/lyrics`: Fix a corner-case with Genius lowercase artist names :bug:`3446` * :doc:`/plugins/parentwork`: Don't save tracks when nothing has changed. :bug:`3492` * Added a warning when configuration files defined in the `include` directive of the configuration file fail to be imported. :bug:`3498` * Added normalization to integer values in the database, which should avoid problems where fields like ``bpm`` would sometimes store non-integer values. :bug:`762` :bug:`3507` :bug:`3508` * Fix a crash when querying for null values. :bug:`3516` :bug:`3517` * :doc:`/plugins/lyrics`: Tolerate a missing lyrics div in the Genius scraper. Thanks to :user:`thejli21`. :bug:`3535` :bug:`3554` * :doc:`/plugins/lyrics`: Use the artist sort name to search for lyrics, which can help find matches when the artist name has special characters. Thanks to :user:`hashhar`. :bug:`3340` :bug:`3558` * :doc:`/plugins/replaygain`: Trying to calculate volume gain for an album consisting of some formats using ``ReplayGain`` and some using ``R128`` will no longer crash; instead it is skipped and and a message is logged. The log message has also been rewritten for to improve clarity. Thanks to :user:`autrimpo`. :bug:`3533` * :doc:`/plugins/lyrics`: Adapt the Genius backend to changes in markup to reduce the scraping failure rate. :bug:`3535` :bug:`3594` * :doc:`/plugins/lyrics`: Fix a crash when writing ReST files for a query without results or fetched lyrics. :bug:`2805` * :doc:`/plugins/fetchart`: Attempt to fetch pre-resized thumbnails from Cover Art Archive if the ``maxwidth`` option matches one of the sizes supported by the Cover Art Archive API. Thanks to :user:`trolley`. :bug:`3637` * :doc:`/plugins/ipfs`: Fix Python 3 compatibility. Thanks to :user:`musoke`. :bug:`2554` * Fix a bug that caused metadata starting with something resembling a drive letter to be incorrectly split into an extra directory after the colon. :bug:`3685` * :doc:`/plugins/mpdstats`: Don't record a skip when stopping MPD, as MPD keeps the current track in the queue. Thanks to :user:`aereaux`. :bug:`3722` * String-typed fields are now normalized to string values, avoiding an occasional crash when using both the :doc:`/plugins/fetchart` and the :doc:`/plugins/discogs` together. :bug:`3773` :bug:`3774` * Fix a bug causing PIL to generate poor quality JPEGs when resizing artwork. :bug:`3743` * :doc:`plugins/keyfinder`: Catch output from ``keyfinder-cli`` that is missing key. :bug:`2242` * :doc:`plugins/replaygain`: Disable parallel analysis on import by default. :bug:`3819` * :doc:`/plugins/mpdstats`: Fix Python 2/3 compatibility :bug:`3798` * :doc:`/plugins/discogs`: Replace the deprecated official `discogs-client` library with the community supported `python3-discogs-client`_ library. :bug:`3608` * :doc:`/plugins/chroma`: Fixed submitting AcoustID information for tracks that already have a fingerprint. :bug:`3834` * Allow equals within the value part of the ``--set`` option to the ``beet import`` command. :bug:`2984` * Duplicates can now generate checksums. Thanks :user:`wisp3rwind` for the pointer to how to solve. Thanks to :user:`arogl`. :bug:`2873` * Templates that use ``%ifdef`` now produce the expected behavior when used in conjunction with non-string fields from the :doc:`/plugins/types`. :bug:`3852` * :doc:`/plugins/lyrics`: Fix crashes when a website could not be retrieved, affecting at least the Genius source. :bug:`3970` * :doc:`/plugins/duplicates`: Fix a crash when running the ``dup`` command with a query that returns no results. :bug:`3943` * :doc:`/plugins/beatport`: Fix the default assignment of the musical key. :bug:`3377` * :doc:`/plugins/lyrics`: Improved searching on the Genius backend when the artist contains special characters. :bug:`3634` * :doc:`/plugins/parentwork`: Also get the composition date of the parent work, instead of just the child work. Thanks to :user:`aereaux`. :bug:`3650` * :doc:`/plugins/lyrics`: Fix a bug in the heuristic for detecting valid lyrics in the Google source. :bug:`2969` * :doc:`/plugins/thumbnails`: Fix a crash due to an incorrect string type on Python 3. :bug:`3360` * :doc:`/plugins/fetchart`: The Cover Art Archive source now iterates over all front images instead of blindly selecting the first one. * :doc:`/plugins/lyrics`: Removed the LyricWiki source (the site shut down on 21/09/2020). * :doc:`/plugins/subsonicupdate`: The plugin is now functional again. A new `auth` configuration option is required in the configuration to specify the flavor of authentication to use. :bug:`4002` For plugin developers: * `MediaFile`_ has been split into a standalone project. Where you used to do ``from beets import mediafile``, now just do ``import mediafile``. Beets re-exports MediaFile at the old location for backwards-compatibility, but a deprecation warning is raised if you do this since we might drop this wrapper in a future release. * Similarly, we've replaced beets' configuration library (previously called Confit) with a standalone version called `Confuse`_. Where you used to do ``from beets.util import confit``, now just do ``import confuse``. The code is almost identical apart from the name change. Again, we'll re-export at the old location (with a deprecation warning) for backwards compatibility, but we might stop doing this in a future release. * ``beets.util.command_output`` now returns a named tuple containing both the standard output and the standard error data instead of just stdout alone. Client code will need to access the ``stdout`` attribute on the return value. Thanks to :user:`zsinskri`. :bug:`3329` * There were sporadic failures in ``test.test_player``. Hopefully these are fixed. If they resurface, please reopen the relevant issue. :bug:`3309` :bug:`3330` * The ``beets.plugins.MetadataSourcePlugin`` base class has been added to simplify development of plugins which query album, track, and search APIs to provide metadata matches for the importer. Refer to the :doc:`/plugins/spotify` and the :doc:`/plugins/deezer` for examples of using this template class. :bug:`3355` * Accessing fields on an `Item` now falls back to the album's attributes. So, for example, ``item.foo`` will first look for a field `foo` on `item` and, if it doesn't exist, next tries looking for a field named `foo` on the album that contains `item`. If you specifically want to access an item's attributes, use ``Item.get(key, with_album=False)``. :bug:`2988` * ``Item.keys`` also has a ``with_album`` argument now, defaulting to ``True``. * A ``revision`` attribute has been added to ``Database``. It is increased on every transaction that mutates it. :bug:`2988` * The classes ``AlbumInfo`` and ``TrackInfo`` now convey arbitrary attributes instead of a fixed, built-in set of field names (which was important to address :bug:`1547`). Thanks to :user:`dosoe`. * Two new events, ``mb_album_extract`` and ``mb_track_extract``, let plugins add new fields based on MusicBrainz data. Thanks to :user:`dosoe`. For packagers: * Beets' library for manipulating media file metadata has now been split to a standalone project called `MediaFile`_, released as :pypi:`mediafile`. Beets now depends on this new package. Beets now depends on Mutagen transitively through MediaFile rather than directly, except in the case of one of beets' plugins (in particular, the :doc:`/plugins/scrub`). * Beets' library for configuration has been split into a standalone project called `Confuse`_, released as :pypi:`confuse`. Beets now depends on this package. Confuse has existed separately for some time and is used by unrelated projects, but until now we've been bundling a copy within beets. * We attempted to fix an unreliable test, so a patch to `skip `_ or `repair `_ the test may no longer be necessary. * This version drops support for Python 3.4. * We have removed an optional dependency on bs1770gain. .. _Fish shell: https://fishshell.com/ .. _MediaFile: https://github.com/beetbox/mediafile .. _Confuse: https://github.com/beetbox/confuse .. _works: https://musicbrainz.org/doc/Work .. _Deezer: https://www.deezer.com .. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli .. _last.fm: https://last.fm .. _python3-discogs-client: https://github.com/joalla/discogs_client .. _py7zr: https://pypi.org/project/py7zr/ 1.4.9 (May 30, 2019) -------------------- This small update is part of our attempt to release new versions more often! There are a few important fixes, and we're clearing the deck for a change to beets' dependencies in the next version. The new feature is: * You can use the `NO_COLOR`_ environment variable to disable terminal colors. :bug:`3273` There are some fixes in this release: * Fix a regression in the last release that made the image resizer fail to detect older versions of ImageMagick. :bug:`3269` * :doc:`/plugins/gmusic`: The ``oauth_file`` config option now supports more flexible path values, including ``~`` for the home directory. :bug:`3270` * :doc:`/plugins/gmusic`: Fix a crash when using version 12.0.0 or later of the ``gmusicapi`` module. :bug:`3270` * Fix an incompatibility with Python 3.8's AST changes. :bug:`3278` Here's a note for packagers: * ``pathlib`` is now an optional test dependency on Python 3.4+, removing the need for `a Debian patch `_. :bug:`3275` .. _NO_COLOR: https://no-color.org 1.4.8 (May 16, 2019) -------------------- This release is far too long in coming, but it's a good one. There is the usual torrent of new features and a ridiculously long line of fixes, but there are also some crucial maintenance changes. We officially support Python 3.7 and 3.8, and some performance optimizations can (anecdotally) make listing your library more than three times faster than in the previous version. The new core features are: * A new :ref:`config-aunique` configuration option allows setting default options for the :ref:`aunique` template function. * The ``albumdisambig`` field no longer includes the MusicBrainz release group disambiguation comment. A new ``releasegroupdisambig`` field has been added. :bug:`3024` * The :ref:`modify-cmd` command now allows resetting fixed attributes. For example, ``beet modify -a artist:beatles artpath!`` resets ``artpath`` attribute from matching albums back to the default value. :bug:`2497` * A new importer option, :ref:`ignore_data_tracks`, lets you skip audio tracks contained in data files. :bug:`3021` There are some new plugins: * The :doc:`/plugins/playlist` can query the beets library using M3U playlists. Thanks to :user:`Holzhaus` and :user:`Xenopathic`. :bug:`123` :bug:`3145` * The :doc:`/plugins/loadext` allows loading of SQLite extensions, primarily for use with the ICU SQLite extension for internationalization. :bug:`3160` :bug:`3226` * The :doc:`/plugins/subsonicupdate` can automatically update your Subsonic library. Thanks to :user:`maffo999`. :bug:`3001` And many improvements to existing plugins: * :doc:`/plugins/lastgenre`: Added option ``-A`` to match individual tracks and singletons. :bug:`3220` :bug:`3219` * :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some issues with foobar2000 and Winamp. Thanks to :user:`mz2212`. :bug:`2944` * :doc:`/plugins/gmusic`: * Add a new option to automatically upload to Google Play Music library on track import. Thanks to :user:`shuaiscott`. * Add new options for Google Play Music authentication. Thanks to :user:`thetarkus`. :bug:`3002` * :doc:`/plugins/replaygain`: ``albumpeak`` on large collections is calculated as the average, not the maximum. :bug:`3008` :bug:`3009` * :doc:`/plugins/chroma`: * Now optionally has a bias toward looking up more relevant releases according to the :ref:`preferred` configuration options. Thanks to :user:`archer4499`. :bug:`3017` * Fingerprint values are now properly stored as strings, which prevents strange repeated output when running ``beet write``. Thanks to :user:`Holzhaus`. :bug:`3097` :bug:`2942` * :doc:`/plugins/convert`: The plugin now has an ``id3v23`` option that allows you to override the global ``id3v23`` option. Thanks to :user:`Holzhaus`. :bug:`3104` * :doc:`/plugins/spotify`: * The plugin now uses OAuth for authentication to the Spotify API. Thanks to :user:`rhlahuja`. :bug:`2694` :bug:`3123` * The plugin now works as an import metadata provider: you can match tracks and albums using the Spotify database. Thanks to :user:`rhlahuja`. :bug:`3123` * :doc:`/plugins/ipfs`: The plugin now supports a ``nocopy`` option which passes that flag to ipfs. Thanks to :user:`wildthyme`. * :doc:`/plugins/discogs`: The plugin now has rate limiting for the Discogs API. :bug:`3081` * :doc:`/plugins/mpdstats`, :doc:`/plugins/mpdupdate`: These plugins now use the ``MPD_PORT`` environment variable if no port is specified in the configuration file. :bug:`3223` * :doc:`/plugins/bpd`: * MPD protocol commands ``consume`` and ``single`` are now supported along with updated semantics for ``repeat`` and ``previous`` and new fields for ``status``. The bpd server now understands and ignores some additional commands. :bug:`3200` :bug:`800` * MPD protocol command ``idle`` is now supported, allowing the MPD version to be bumped to 0.14. :bug:`3205` :bug:`800` * MPD protocol command ``decoders`` is now supported. :bug:`3222` * The plugin now uses the main beets logging system. The special-purpose ``--debug`` flag has been removed. Thanks to :user:`arcresu`. :bug:`3196` * :doc:`/plugins/mbsync`: The plugin no longer queries MusicBrainz when either the ``mb_albumid`` or ``mb_trackid`` field is invalid. See also the discussion on `Google Groups`_ Thanks to :user:`arogl`. * :doc:`/plugins/export`: The plugin now also exports ``path`` field if the user explicitly specifies it with ``-i`` parameter. This only works when exporting library fields. :bug:`3084` * :doc:`/plugins/acousticbrainz`: The plugin now declares types for all its fields, which enables easier querying and avoids a problem where very small numbers would be stored as strings. Thanks to :user:`rain0r`. :bug:`2790` :bug:`3238` .. _Google Groups: https://groups.google.com/forum/#!searchin/beets-users/mbsync|sort:date/beets-users/iwCF6bNdh9A/i1xl4Gx8BQAJ Some improvements have been focused on improving beets' performance: * Querying the library is now faster: * We only convert fields that need to be displayed. Thanks to :user:`pprkut`. :bug:`3089` * We now compile templates once and reuse them instead of recompiling them to print out each matching object. Thanks to :user:`SimonPersson`. :bug:`3258` * Querying the library for items is now faster, for all queries that do not need to access album level properties. This was implemented by lazily fetching the album only when needed. Thanks to :user:`SimonPersson`. :bug:`3260` * :doc:`/plugins/absubmit`, :doc:`/plugins/badfiles`: Analysis now works in parallel (on Python 3 only). Thanks to :user:`bemeurer`. :bug:`2442` :bug:`3003` * :doc:`/plugins/mpdstats`: Use the ``currentsong`` MPD command instead of ``playlist`` to get the current song, improving performance when the playlist is long. Thanks to :user:`ray66`. :bug:`3207` :bug:`2752` Several improvements are related to usability: * The disambiguation string for identifying albums in the importer now shows the catalog number. Thanks to :user:`8h2a`. :bug:`2951` * Added whitespace padding to missing tracks dialog to improve readability. Thanks to :user:`jams2`. :bug:`2962` * The :ref:`move-cmd` command now lists the number of items already in-place. Thanks to :user:`RollingStar`. :bug:`3117` * Modify selection can now be applied early without selecting every item. :bug:`3083` * Beets now emits more useful messages during startup if SQLite returns an error. The SQLite error message is now attached to the beets message. :bug:`3005` * Fixed a confusing typo when the :doc:`/plugins/convert` plugin copies the art covers. :bug:`3063` Many fixes have been focused on issues where beets would previously crash: * Avoid a crash when archive extraction fails during import. :bug:`3041` * Missing album art file during an update no longer causes a fatal exception (instead, an error is logged and the missing file path is removed from the library). :bug:`3030` * When updating the database, beets no longer tries to move album art twice. :bug:`3189` * Fix an unhandled exception when pruning empty directories. :bug:`1996` :bug:`3209` * :doc:`/plugins/fetchart`: Added network connection error handling to backends so that beets won't crash if a request fails. Thanks to :user:`Holzhaus`. :bug:`1579` * :doc:`/plugins/badfiles`: Avoid a crash when the underlying tool emits undecodable output. :bug:`3165` * :doc:`/plugins/beatport`: Avoid a crash when the server produces an error. :bug:`3184` * :doc:`/plugins/bpd`: Fix crashes in the bpd server during exception handling. :bug:`3200` * :doc:`/plugins/bpd`: Fix a crash triggered when certain clients tried to list the albums belonging to a particular artist. :bug:`3007` :bug:`3215` * :doc:`/plugins/replaygain`: Avoid a crash when the ``bs1770gain`` tool emits malformed XML. :bug:`2983` :bug:`3247` There are many fixes related to compatibility with our dependencies including addressing changes interfaces: * On Python 2, pin the :pypi:`jellyfish` requirement to version 0.6.0 for compatibility. * Fix compatibility with Python 3.7 and its change to a name in the :stdlib:`re` module. :bug:`2978` * Fix several uses of deprecated standard-library features on Python 3.7. Thanks to :user:`arcresu`. :bug:`3197` * Fix compatibility with pre-release versions of Python 3.8. :bug:`3201` :bug:`3202` * :doc:`/plugins/web`: Fix an error when using more recent versions of Flask with CORS enabled. Thanks to :user:`rveachkc`. :bug:`2979`: :bug:`2980` * Avoid some deprecation warnings with certain versions of the MusicBrainz library. Thanks to :user:`zhelezov`. :bug:`2826` :bug:`3092` * Restore iTunes Store album art source, and remove the dependency on :pypi:`python-itunes`, which had gone unmaintained and was not Python-3-compatible. Thanks to :user:`ocelma` for creating :pypi:`python-itunes` in the first place. Thanks to :user:`nathdwek`. :bug:`2371` :bug:`2551` :bug:`2718` * :doc:`/plugins/lastgenre`, :doc:`/plugins/edit`: Avoid a deprecation warnings from the :pypi:`PyYAML` library by switching to the safe loader. Thanks to :user:`translit` and :user:`sbraz`. :bug:`3192` :bug:`3225` * Fix a problem when resizing images with :pypi:`PIL`/:pypi:`pillow` on Python 3. Thanks to :user:`architek`. :bug:`2504` :bug:`3029` And there are many other fixes: * R128 normalization tags are now properly deleted from files when the values are missing. Thanks to :user:`autrimpo`. :bug:`2757` * Display the artist credit when matching albums if the :ref:`artist_credit` configuration option is set. :bug:`2953` * With the :ref:`from_scratch` configuration option set, only writable fields are cleared. Beets now no longer ignores the format your music is saved in. :bug:`2972` * The ``%aunique`` template function now works correctly with the ``-f/--format`` option. :bug:`3043` * Fixed the ordering of items when manually selecting changes while updating tags Thanks to :user:`TaizoSimpson`. :bug:`3501` * The ``%title`` template function now works correctly with apostrophes. Thanks to :user:`GuilhermeHideki`. :bug:`3033` * :doc:`/plugins/lastgenre`: It's now possible to set the ``prefer_specific`` option without also setting ``canonical``. :bug:`2973` * :doc:`/plugins/fetchart`: The plugin now respects the ``ignore`` and ``ignore_hidden`` settings. :bug:`1632` * :doc:`/plugins/hook`: Fix byte string interpolation in hook commands. :bug:`2967` :bug:`3167` * :doc:`/plugins/the`: Log a message when something has changed, not when it hasn't. Thanks to :user:`arcresu`. :bug:`3195` * :doc:`/plugins/lastgenre`: The ``force`` config option now actually works. :bug:`2704` :bug:`3054` * Resizing image files with ImageMagick now avoids problems on systems where there is a ``convert`` command that is *not* ImageMagick's by using the ``magick`` executable when it is available. Thanks to :user:`ababyduck`. :bug:`2093` :bug:`3236` There is one new thing for plugin developers to know about: * In addition to prefix-based field queries, plugins can now define *named queries* that are not associated with any specific field. For example, the new :doc:`/plugins/playlist` supports queries like ``playlist:name`` although there is no field named ``playlist``. See :ref:`extend-query` for details. And some messages for packagers: * Note the changes to the dependencies on :pypi:`jellyfish` and :pypi:`munkres`. * The optional :pypi:`python-itunes` dependency has been removed. * Python versions 3.7 and 3.8 are now supported. 1.4.7 (May 29, 2018) -------------------- This new release includes lots of new features in the importer and the metadata source backends that it uses. We've changed how the beets importer handles non-audio tracks listed in metadata sources like MusicBrainz: * The importer now ignores non-audio tracks (namely, data and video tracks) listed in MusicBrainz. Also, a new option, :ref:`ignore_video_tracks`, lets you return to the old behavior and include these video tracks. :bug:`1210` * A new importer option, :ref:`ignored_media`, can let you skip certain media formats. :bug:`2688` There are other subtle improvements to metadata handling in the importer: * In the MusicBrainz backend, beets now imports the ``musicbrainz_releasetrackid`` field. This is a first step toward :bug:`406`. Thanks to :user:`Rawrmonkeys`. * A new importer configuration option, :ref:`artist_credit`, will tell beets to prefer the artist credit over the artist when autotagging. :bug:`1249` And there are even more new features: * :doc:`/plugins/replaygain`: The ``beet replaygain`` command now has ``--force``, ``--write`` and ``--nowrite`` options. :bug:`2778` * A new importer configuration option, :ref:`incremental_skip_later`, lets you avoid recording skipped directories to the list of "processed" directories in :ref:`incremental` mode. This way, you can revisit them later with another import. Thanks to :user:`sekjun9878`. :bug:`2773` * :doc:`/plugins/fetchart`: The configuration options now support finer-grained control via the ``sources`` option. You can now specify the search order for different *matching strategies* within different backends. * :doc:`/plugins/web`: A new ``cors_supports_credentials`` configuration option lets in-browser clients communicate with the server even when it is protected by an authorization mechanism (a proxy with HTTP authentication enabled, for example). * A new :doc:`/plugins/sonosupdate` plugin automatically notifies Sonos controllers to update the music library when the beets library changes. Thanks to :user:`cgtobi`. * :doc:`/plugins/discogs`: The plugin now stores master release IDs into ``mb_releasegroupid``. It also "simulates" track IDs using the release ID and the track list position. Thanks to :user:`dbogdanov`. :bug:`2336` * :doc:`/plugins/discogs`: Fetch the original year from master releases. :bug:`1122` There are lots and lots of fixes: * :doc:`/plugins/replaygain`: Fix a corner-case with the ``bs1770gain`` backend where ReplayGain values were assigned to the wrong files. The plugin now requires version 0.4.6 or later of the ``bs1770gain`` tool. :bug:`2777` * :doc:`/plugins/lyrics`: The plugin no longer crashes in the Genius source when BeautifulSoup is not found. Instead, it just logs a message and disables the source. :bug:`2911` * :doc:`/plugins/lyrics`: Handle network and API errors when communicating with Genius. :bug:`2771` * :doc:`/plugins/lyrics`: The ``lyrics`` command previously wrote ReST files by default, even when you didn't ask for them. This default has been fixed. * :doc:`/plugins/lyrics`: When writing ReST files, the ``lyrics`` command now groups lyrics by the ``albumartist`` field, rather than ``artist``. :bug:`2924` * Plugins can now see updated import task state, such as when rejecting the initial candidates and finding new ones via a manual search. Notably, this means that the importer prompt options that the :doc:`/plugins/edit` provides show up more reliably after doing a secondary import search. :bug:`2441` :bug:`2731` * :doc:`/plugins/importadded`: Fix a crash on non-autotagged imports. Thanks to :user:`m42i`. :bug:`2601` :bug:`1918` * :doc:`/plugins/plexupdate`: The Plex token is now redacted in configuration output. Thanks to :user:`Kovrinic`. :bug:`2804` * Avoid a crash when importing a non-ASCII filename when using an ASCII locale on Unix under Python 3. :bug:`2793` :bug:`2803` * Fix a problem caused by time zone misalignment that could make date queries fail to match certain dates that are near the edges of a range. For example, querying for dates within a certain month would fail to match dates within hours of the end of that month. :bug:`2652` * :doc:`/plugins/convert`: The plugin now runs before other plugin-provided import stages, which addresses an issue with generating ReplayGain data incompatible between the source and target file formats. Thanks to :user:`autrimpo`. :bug:`2814` * :doc:`/plugins/ftintitle`: The ``drop`` config option had no effect; it now does what it says it should do. :bug:`2817` * Importing a release with multiple release events now selects the event based on the order of your :ref:`preferred` countries rather than the order of release events in MusicBrainz. :bug:`2816` * :doc:`/plugins/web`: The time display in the web interface would incorrectly jump at the 30-second mark of every minute. Now, it correctly changes over at zero seconds. :bug:`2822` * :doc:`/plugins/web`: Fetching album art now works (instead of throwing an exception) under Python 3. Additionally, the server will now return a 404 response when the album ID is unknown (instead of throwing an exception and producing a 500 response). :bug:`2823` * :doc:`/plugins/web`: Fix an exception on Python 3 for filenames with non-Latin1 characters. (These characters are now converted to their ASCII equivalents.) :bug:`2815` * Partially fix bash completion for subcommand names that contain hyphens. Thanks to :user:`jhermann`. :bug:`2836` :bug:`2837` * :doc:`/plugins/replaygain`: Really fix album gain calculation using the GStreamer backend. :bug:`2846` * Avoid an error when doing a "no-op" move on non-existent files (i.e., moving a file onto itself). :bug:`2863` * :doc:`/plugins/discogs`: Fix the ``medium`` and ``medium_index`` values, which were occasionally incorrect for releases with two-sided mediums such as vinyl. Also fix the ``medium_total`` value, which now contains total number of tracks on the medium to which a track belongs, not the total number of different mediums present on the release. Thanks to :user:`dbogdanov`. :bug:`2887` * The importer now supports audio files contained in data tracks when they are listed in MusicBrainz: the corresponding audio tracks are now merged into the main track list. Thanks to :user:`jdetrey`. :bug:`1638` * :doc:`/plugins/keyfinder`: Avoid a crash when trying to process unmatched tracks. :bug:`2537` * :doc:`/plugins/mbsync`: Support MusicBrainz recording ID changes, relying on release track IDs instead. Thanks to :user:`jdetrey`. :bug:`1234` * :doc:`/plugins/mbsync`: We can now successfully update albums even when the first track has a missing MusicBrainz recording ID. :bug:`2920` There are a couple of changes for developers: * Plugins can now run their import stages *early*, before other plugins. Use the ``early_import_stages`` list instead of plain ``import_stages`` to request this behavior. :bug:`2814` * We again properly send ``albuminfo_received`` and ``trackinfo_received`` in all cases, most notably when using the ``mbsync`` plugin. This was a regression since version 1.4.1. :bug:`2921` 1.4.6 (December 21, 2017) ------------------------- The highlight of this release is "album merging," an oft-requested option in the importer to add new tracks to an existing album you already have in your library. This way, you no longer need to resort to removing the partial album from your library, combining the files manually, and importing again. Here are the larger new features in this release: * When the importer finds duplicate albums, you can now merge all the tracks---old and new---together and try importing them as a single, combined album. Thanks to :user:`udiboy1209`. :bug:`112` :bug:`2725` * :doc:`/plugins/lyrics`: The plugin can now produce reStructuredText files for beautiful, readable books of lyrics. Thanks to :user:`anarcat`. :bug:`2628` * A new :ref:`from_scratch` configuration option makes the importer remove old metadata before applying new metadata. This new feature complements the :doc:`zero ` and :doc:`scrub ` plugins but is slightly different: beets clears out all the old tags it knows about and only keeps the new data it gets from the remote metadata source. Thanks to :user:`tummychow`. :bug:`934` :bug:`2755` There are also somewhat littler, but still great, new features: * :doc:`/plugins/convert`: A new ``no_convert`` option lets you skip transcoding items matching a query. Instead, the files are just copied as-is. Thanks to :user:`Stunner`. :bug:`2732` :bug:`2751` * :doc:`/plugins/fetchart`: A new quiet switch that only prints out messages when album art is missing. Thanks to :user:`euri10`. :bug:`2683` * :doc:`/plugins/mbcollection`: You can configure a custom MusicBrainz collection via the new ``collection`` configuration option. :bug:`2685` * :doc:`/plugins/mbcollection`: The collection update command can now remove albums from collections that are longer in the beets library. * :doc:`/plugins/fetchart`: The ``clearart`` command now asks for confirmation before touching your files. Thanks to :user:`konman2`. :bug:`2708` :bug:`2427` * :doc:`/plugins/mpdstats`: The plugin now correctly updates song statistics when MPD switches from a song to a stream and when it plays the same song multiple times consecutively. :bug:`2707` * :doc:`/plugins/acousticbrainz`: The plugin can now be configured to write only a specific list of tags. Thanks to :user:`woparry`. There are lots and lots of bug fixes: * :doc:`/plugins/hook`: Fixed a problem where accessing non-string properties of ``item`` or ``album`` (e.g., ``item.track``) would cause a crash. Thanks to :user:`broddo`. :bug:`2740` * :doc:`/plugins/play`: When ``relative_to`` is set, the plugin correctly emits relative paths even when querying for albums rather than tracks. Thanks to :user:`j000`. :bug:`2702` * We suppress a spurious Python warning about a ``BrokenPipeError`` being ignored. This was an issue when using beets in simple shell scripts. Thanks to :user:`Azphreal`. :bug:`2622` :bug:`2631` * :doc:`/plugins/replaygain`: Fix a regression in the previous release related to the new R128 tags. :bug:`2615` :bug:`2623` * :doc:`/plugins/lyrics`: The MusixMatch backend now detects and warns when the server has blocked the client. Thanks to :user:`anarcat`. :bug:`2634` :bug:`2632` * :doc:`/plugins/importfeeds`: Fix an error on Python 3 in certain configurations. Thanks to :user:`djl`. :bug:`2467` :bug:`2658` * :doc:`/plugins/edit`: Fix a bug when editing items during a re-import with the ``-L`` flag. Previously, diffs against against unrelated items could be shown or beets could crash. :bug:`2659` * :doc:`/plugins/kodiupdate`: Fix the server URL and add better error reporting. :bug:`2662` * Fixed a problem where "no-op" modifications would reset files' mtimes, resulting in unnecessary writes. This most prominently affected the :doc:`/plugins/edit` when saving the text file without making changes to some music. :bug:`2667` * :doc:`/plugins/chroma`: Fix a crash when running the ``submit`` command on Python 3 on Windows with non-ASCII filenames. :bug:`2671` * :doc:`/plugins/absubmit`: Fix an occasional crash on Python 3 when the AB analysis tool produced non-ASCII metadata. :bug:`2673` * :doc:`/plugins/duplicates`: Use the default tiebreak for items or albums when the configuration only specifies a tiebreak for the other kind of entity. Thanks to :user:`cgevans`. :bug:`2758` * :doc:`/plugins/duplicates`: Fix the ``--key`` command line option, which was ignored. * :doc:`/plugins/replaygain`: Fix album ReplayGain calculation with the GStreamer backend. :bug:`2636` * :doc:`/plugins/scrub`: Handle errors when manipulating files using newer versions of Mutagen. :bug:`2716` * :doc:`/plugins/fetchart`: The plugin no longer gets skipped during import when the "Edit Candidates" option is used from the :doc:`/plugins/edit`. :bug:`2734` * Fix a crash when numeric metadata fields contain just a minus or plus sign with no following numbers. Thanks to :user:`eigengrau`. :bug:`2741` * :doc:`/plugins/fromfilename`: Recognize file names that contain *only* a track number, such as `01.mp3`. Also, the plugin now allows underscores as a separator between fields. Thanks to :user:`Vrihub`. :bug:`2738` :bug:`2759` * Fixed an issue where images would be resized according to their longest edge, instead of their width, when using the ``maxwidth`` config option in the :doc:`/plugins/fetchart` and :doc:`/plugins/embedart`. Thanks to :user:`sekjun9878`. :bug:`2729` There are some changes for developers: * "Fixed fields" in Album and Item objects are now more strict about translating missing values into type-specific null-like values. This should help in cases where a string field is unexpectedly `None` sometimes instead of just showing up as an empty string. :bug:`2605` * Refactored the move functions the `beets.library` module and the `manipulate_files` function in `beets.importer` to use a single parameter describing the file operation instead of multiple Boolean flags. There is a new numerated type describing how to move, copy, or link files. :bug:`2682` 1.4.5 (June 20, 2017) --------------------- Version 1.4.5 adds some oft-requested features. When you're importing files, you can now manually set fields on the new music. Date queries have gotten much more powerful: you can write precise queries down to the second, and we now have *relative* queries like ``-1w``, which means *one week ago*. Here are the new features: * You can now set fields to certain values during :ref:`import-cmd`, using either a ``--set field=value`` command-line flag or a new :ref:`set_fields` configuration option under the `importer` section. Thanks to :user:`bartkl`. :bug:`1881` :bug:`2581` * :ref:`Date queries ` can now include times, so you can filter your music down to the second. Thanks to :user:`discopatrick`. :bug:`2506` :bug:`2528` * :ref:`Date queries ` can also be *relative*. You can say ``added:-1w..`` to match music added in the last week, for example. Thanks to :user:`euri10`. :bug:`2598` * A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586` * :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from classic ReplayGain data for formats that need it (namely, Ogg Opus). A new `r128` configuration option enables this behavior for specific formats. Thanks to :user:`autrimpo`. :bug:`2557` :bug:`2560` * The :ref:`move-cmd` command gained a new ``--export`` flag, which copies files to an external location without changing their paths in the library database. Thanks to :user:`SpirosChadoulos`. :bug:`435` :bug:`2510` There are also some bug fixes: * :doc:`/plugins/lastgenre`: Fix a crash when using the `prefer_specific` and `canonical` options together. Thanks to :user:`yacoob`. :bug:`2459` :bug:`2583` * :doc:`/plugins/web`: Fix a crash on Windows under Python 2 when serving non-ASCII filenames. Thanks to :user:`robot3498712`. :bug:`2592` :bug:`2593` * :doc:`/plugins/metasync`: Fix a crash in the Amarok backend when filenames contain quotes. Thanks to :user:`aranc23`. :bug:`2595` :bug:`2596` * More informative error messages are displayed when the file format is not recognized. :bug:`2599` 1.4.4 (June 10, 2017) --------------------- This release built up a longer-than-normal list of nifty new features. We now support DSF audio files and the importer can hard-link your files, for example. Here's a full list of new features: * Added support for DSF files, once a future version of Mutagen is released that supports them. Thanks to :user:`docbobo`. :bug:`459` :bug:`2379` * A new :ref:`hardlink` config option instructs the importer to create hard links on filesystems that support them. Thanks to :user:`jacobwgillespie`. :bug:`2445` * A new :doc:`/plugins/kodiupdate` lets you keep your Kodi library in sync with beets. Thanks to :user:`Pauligrinder`. :bug:`2411` * A new :ref:`bell` configuration option under the ``import`` section enables a terminal bell when input is required. Thanks to :user:`SpirosChadoulos`. :bug:`2366` :bug:`2495` * A new field, ``composer_sort``, is now supported and fetched from MusicBrainz. Thanks to :user:`dosoe`. :bug:`2519` :bug:`2529` * The MusicBrainz backend and :doc:`/plugins/discogs` now both provide a new attribute called ``track_alt`` that stores more nuanced, possibly non-numeric track index data. For example, some vinyl or tape media will report the side of the record using a letter instead of a number in that field. :bug:`1831` :bug:`2363` * :doc:`/plugins/web`: Added a new endpoint, ``/item/path/foo``, which will return the item info for the file at the given path, or 404. * :doc:`/plugins/web`: Added a new config option, ``include_paths``, which will cause paths to be included in item API responses if set to true. * The ``%aunique`` template function for :ref:`aunique` now takes a third argument that specifies which brackets to use around the disambiguator value. The argument can be any two characters that represent the left and right brackets. It defaults to `[]` and can also be blank to turn off bracketing. :bug:`2397` :bug:`2399` * Added a ``--move`` or ``-m`` option to the importer so that the files can be moved to the library instead of being copied or added "in place." :bug:`2252` :bug:`2429` * :doc:`/plugins/badfiles`: Added a ``--verbose`` or ``-v`` option. Results are now displayed only for corrupted files by default and for all the files when the verbose option is set. :bug:`1654` :bug:`2434` * :doc:`/plugins/embedart`: The explicit ``embedart`` command now asks for confirmation before embedding art into music files. Thanks to :user:`Stunner`. :bug:`1999` * You can now run beets by typing `python -m beets`. :bug:`2453` * :doc:`/plugins/smartplaylist`: Different playlist specifications that generate identically-named playlist files no longer conflict; instead, the resulting lists of tracks are concatenated. :bug:`2468` * :doc:`/plugins/missing`: A new mode lets you see missing albums from artists you have in your library. Thanks to :user:`qlyoung`. :bug:`2481` * :doc:`/plugins/web` : Add new `reverse_proxy` config option to allow serving the web plugins under a reverse proxy. * Importing a release with multiple release events now selects the event based on your :ref:`preferred` countries. :bug:`2501` * :doc:`/plugins/play`: A new ``-y`` or ``--yes`` parameter lets you skip the warning message if you enqueue more items than the warning threshold usually allows. * Fix a bug where commands which forked subprocesses would sometimes prevent further inputs. This bug mainly affected :doc:`/plugins/convert`. Thanks to :user:`jansol`. :bug:`2488` :bug:`2524` There are also quite a few fixes: * In the :ref:`replace` configuration option, we now replace a leading hyphen (-) with an underscore. :bug:`549` :bug:`2509` * :doc:`/plugins/absubmit`: We no longer filter audio files for specific formats---we will attempt the submission process for all formats. :bug:`2471` * :doc:`/plugins/mpdupdate`: Fix Python 3 compatibility. :bug:`2381` * :doc:`/plugins/replaygain`: Fix Python 3 compatibility in the ``bs1770gain`` backend. :bug:`2382` * :doc:`/plugins/bpd`: Report playback times as integers. :bug:`2394` * :doc:`/plugins/mpdstats`: Fix Python 3 compatibility. The plugin also now requires version 0.4.2 or later of the ``python-mpd2`` library. :bug:`2405` * :doc:`/plugins/mpdstats`: Improve handling of MPD status queries. * :doc:`/plugins/badfiles`: Fix Python 3 compatibility. * Fix some cases where album-level ReplayGain/SoundCheck metadata would be written to files incorrectly. :bug:`2426` * :doc:`/plugins/badfiles`: The command no longer bails out if the validator command is not found or exits with an error. :bug:`2430` :bug:`2433` * :doc:`/plugins/lyrics`: The Google search backend no longer crashes when the server responds with an error. :bug:`2437` * :doc:`/plugins/discogs`: You can now authenticate with Discogs using a personal access token. :bug:`2447` * Fix Python 3 compatibility when extracting rar archives in the importer. Thanks to :user:`Lompik`. :bug:`2443` :bug:`2448` * :doc:`/plugins/duplicates`: Fix Python 3 compatibility when using the ``copy`` and ``move`` options. :bug:`2444` * :doc:`/plugins/mbsubmit`: The tracks are now sorted properly. Thanks to :user:`awesomer`. :bug:`2457` * :doc:`/plugins/thumbnails`: Fix a string-related crash on Python 3. :bug:`2466` * :doc:`/plugins/beatport`: More than just 10 songs are now fetched per album. :bug:`2469` * On Python 3, the :ref:`terminal_encoding` setting is respected again for output and printing will no longer crash on systems configured with a limited encoding. * :doc:`/plugins/convert`: The default configuration uses FFmpeg's built-in AAC codec instead of faac. Thanks to :user:`jansol`. :bug:`2484` * Fix the importer's detection of multi-disc albums when other subdirectories are present. :bug:`2493` * Invalid date queries now print an error message instead of being silently ignored. Thanks to :user:`discopatrick`. :bug:`2513` :bug:`2517` * When the SQLite database stops being accessible, we now print a friendly error message. Thanks to :user:`Mary011196`. :bug:`1676` :bug:`2508` * :doc:`/plugins/web`: Avoid a crash when sending binary data, such as Chromaprint fingerprints, in music attributes. :bug:`2542` :bug:`2532` * Fix a hang when parsing templates that end in newlines. :bug:`2562` * Fix a crash when reading non-ASCII characters in configuration files on Windows under Python 3. :bug:`2456` :bug:`2565` :bug:`2566` We removed backends from two metadata plugins because of bitrot: * :doc:`/plugins/lyrics`: The Lyrics.com backend has been removed. (It stopped working because of changes to the site's URL structure.) :bug:`2548` :bug:`2549` * :doc:`/plugins/fetchart`: The documentation no longer recommends iTunes Store artwork lookup because the unmaintained `python-itunes`_ is broken. Want to adopt it? :bug:`2371` :bug:`1610` .. _python-itunes: https://github.com/ocelma/python-itunes 1.4.3 (January 9, 2017) ----------------------- Happy new year! This new version includes a cornucopia of new features from contributors, including new tags related to classical music and a new :doc:`/plugins/absubmit` for performing acoustic analysis on your music. The :doc:`/plugins/random` has a new mode that lets you generate time-limited music---for example, you might generate a random playlist that lasts the perfect length for your walk to work. We also access as many Web services as possible over secure connections now---HTTPS everywhere! The most visible new features are: * We now support the composer, lyricist, and arranger tags. The MusicBrainz data source will fetch data for these fields when the next version of `python-musicbrainzngs`_ is released. Thanks to :user:`ibmibmibm`. :bug:`506` :bug:`507` :bug:`1547` :bug:`2333` * A new :doc:`/plugins/absubmit` lets you run acoustic analysis software and upload the results for others to use. Thanks to :user:`inytar`. :bug:`2253` :bug:`2342` * :doc:`/plugins/play`: The plugin now provides an importer prompt choice to play the music you're about to import. Thanks to :user:`diomekes`. :bug:`2008` :bug:`2360` * We now use SSL to access Web services whenever possible. That includes MusicBrainz itself, several album art sources, some lyrics sources, and other servers. Thanks to :user:`tigranl`. :bug:`2307` * :doc:`/plugins/random`: A new ``--time`` option lets you generate a random playlist that takes a given amount of time. Thanks to :user:`diomekes`. :bug:`2305` :bug:`2322` Some smaller new features: * :doc:`/plugins/zero`: A new ``zero`` command manually triggers the zero plugin. Thanks to :user:`SJoshBrown`. :bug:`2274` :bug:`2329` * :doc:`/plugins/acousticbrainz`: The plugin will avoid re-downloading data for files that already have it by default. You can override this behavior using a new ``force`` option. Thanks to :user:`SusannaMaria`. :bug:`2347` :bug:`2349` * :doc:`/plugins/bpm`: The ``import.write`` configuration option now decides whether or not to write tracks after updating their BPM. :bug:`1992` And the fixes: * :doc:`/plugins/bpd`: Fix a crash on non-ASCII MPD commands. :bug:`2332` * :doc:`/plugins/scrub`: Avoid a crash when files cannot be read or written. :bug:`2351` * :doc:`/plugins/scrub`: The image type values on scrubbed files are preserved instead of being reset to "other." :bug:`2339` * :doc:`/plugins/web`: Fix a crash on Python 3 when serving files from the filesystem. :bug:`2353` * :doc:`/plugins/discogs`: Improve the handling of releases that contain subtracks. :bug:`2318` * :doc:`/plugins/discogs`: Fix a crash when a release does not contain format information, and increase robustness when other fields are missing. :bug:`2302` * :doc:`/plugins/lyrics`: The plugin now reports a beets-specific User-Agent header when requesting lyrics. :bug:`2357` * :doc:`/plugins/embyupdate`: The plugin now checks whether an API key or a password is provided in the configuration. * :doc:`/plugins/play`: The misspelled configuration option ``warning_treshold`` is no longer supported. For plugin developers: when providing new importer prompt choices (see :ref:`append_prompt_choices`), you can now provide new candidates for the user to consider. For example, you might provide an alternative strategy for picking between the available alternatives or for looking up a release on MusicBrainz. 1.4.2 (December 16, 2016) ------------------------- This is just a little bug fix release. With 1.4.2, we're also confident enough to recommend that anyone who's interested give Python 3 a try: bugs may still lurk, but we've deemed things safe enough for broad adoption. If you can, please install beets with ``pip3`` instead of ``pip2`` this time and let us know how it goes! Here are the fixes: * :doc:`/plugins/badfiles`: Fix a crash on non-ASCII filenames. :bug:`2299` * The ``%asciify{}`` path formatting function and the :ref:`asciify-paths` setting properly substitute path separators generated by converting some Unicode characters, such as ½ and ¢, into ASCII. * :doc:`/plugins/convert`: Fix a logging-related crash when filenames contain curly braces. Thanks to :user:`kierdavis`. :bug:`2323` * We've rolled back some changes to the included zsh completion script that were causing problems for some users. :bug:`2266` Also, we've removed some special handling for logging in the :doc:`/plugins/discogs` that we believe was unnecessary. If spurious log messages appear in this version, please let us know by filing a bug. 1.4.1 (November 25, 2016) ------------------------- Version 1.4 has **alpha-level** Python 3 support. Thanks to the heroic efforts of :user:`jrobeson`, beets should run both under Python 2.7, as before, and now under Python 3.4 and above. The support is still new: it undoubtedly contains bugs, so it may replace all your music with Limp Bizkit---but if you're brave and you have backups, please try installing on Python 3. Let us know how it goes. If you package beets for distribution, here's what you'll want to know: * This version of beets now depends on the `six`_ library. * We also bumped our minimum required version of `Mutagen`_ to 1.33 (from 1.27). * Please don't package beets as a Python 3 application *yet*, even though most things work under Python 3.4 and later. This version also makes a few changes to the command-line interface and configuration that you may need to know about: * :doc:`/plugins/duplicates`: The ``duplicates`` command no longer accepts multiple field arguments in the form ``-k title albumartist album``. Each argument must be prefixed with ``-k``, as in ``-k title -k albumartist -k album``. * The old top-level ``colors`` configuration option has been removed (the setting is now under ``ui``). * The deprecated ``list_format_album`` and ``list_format_item`` configuration options have been removed (see :ref:`format_album` and :ref:`format_item`). The are a few new features: * :doc:`/plugins/mpdupdate`, :doc:`/plugins/mpdstats`: When the ``host`` option is not set, these plugins will now look for the ``$MPD_HOST`` environment variable before falling back to ``localhost``. Thanks to :user:`tarruda`. :bug:`2175` * :doc:`/plugins/web`: Added an ``expand`` option to show the items of an album. :bug:`2050` * :doc:`/plugins/embyupdate`: The plugin can now use an API key instead of a password to authenticate with Emby. :bug:`2045` :bug:`2117` * :doc:`/plugins/acousticbrainz`: The plugin now adds a ``bpm`` field. * ``beet --version`` now includes the Python version used to run beets. * :doc:`/reference/pathformat` can now include unescaped commas (``,``) when they are not part of a function call. :bug:`2166` :bug:`2213` * The :ref:`update-cmd` command takes a new ``-F`` flag to specify the fields to update. Thanks to :user:`dangmai`. :bug:`2229` :bug:`2231` And there are a few bug fixes too: * :doc:`/plugins/convert`: The plugin no longer asks for confirmation if the query did not return anything to convert. :bug:`2260` :bug:`2262` * :doc:`/plugins/embedart`: The plugin now uses ``jpg`` as an extension rather than ``jpeg``, to ensure consistency with the :doc:`plugins/fetchart`. Thanks to :user:`tweitzel`. :bug:`2254` :bug:`2255` * :doc:`/plugins/embedart`: The plugin now works for all jpeg files, including those that are only recognizable by their magic bytes. :bug:`1545` :bug:`2255` * :doc:`/plugins/web`: The JSON output is no longer pretty-printed (for a space savings). :bug:`2050` * :doc:`/plugins/permissions`: Fix a regression in the previous release where the plugin would always fail to set permissions (and log a warning). :bug:`2089` * :doc:`/plugins/beatport`: Use track numbers from Beatport (instead of determining them from the order of tracks) and set the `medium_index` value. * With :ref:`per_disc_numbering` enabled, some metadata sources (notably, the :doc:`/plugins/beatport`) would not set the track number at all. This is fixed. :bug:`2085` * :doc:`/plugins/play`: Fix ``$args`` getting passed verbatim to the play command if it was set in the configuration but ``-A`` or ``--args`` was omitted. * With :ref:`ignore_hidden` enabled, non-UTF-8 filenames would cause a crash. This is fixed. :bug:`2168` * :doc:`/plugins/embyupdate`: Fixes authentication header problem that caused a problem that it was not possible to get tokens from the Emby API. * :doc:`/plugins/lyrics`: Some titles use a colon to separate the main title from a subtitle. To find more matches, the plugin now also searches for lyrics using the part part preceding the colon character. :bug:`2206` * Fix a crash when a query uses a date field and some items are missing that field. :bug:`1938` * :doc:`/plugins/discogs`: Subtracks are now detected and combined into a single track, two-sided mediums are treated as single discs, and tracks have ``media``, ``medium_total`` and ``medium`` set correctly. :bug:`2222` :bug:`2228`. * :doc:`/plugins/missing`: ``missing`` is now treated as an integer, allowing the use of (for example) ranges in queries. * :doc:`/plugins/smartplaylist`: Playlist names will be sanitized to ensure valid filenames. :bug:`2258` * The ID3 APIC tag now uses the Latin-1 encoding when possible instead of a Unicode encoding. This should increase compatibility with other software, especially with iTunes and when using ID3v2.3. Thanks to :user:`lazka`. :bug:`899` :bug:`2264` :bug:`2270` The last release, 1.3.19, also erroneously reported its version as "1.3.18" when you typed ``beet version``. This has been corrected. .. _six: https://pypi.org/project/six/ 1.3.19 (June 25, 2016) ---------------------- This is primarily a bug fix release: it cleans up a couple of regressions that appeared in the last version. But it also features the triumphant return of the :doc:`/plugins/beatport` and a modernized :doc:`/plugins/bpd`. It's also the first version where beets passes all its tests on Windows! May this herald a new age of cross-platform reliability for beets. New features: * :doc:`/plugins/beatport`: This metadata source plugin has arisen from the dead! It now works with Beatport's new OAuth-based API. Thanks to :user:`jbaiter`. :bug:`1989` :bug:`2067` * :doc:`/plugins/bpd`: The plugin now uses the modern GStreamer 1.0 instead of the old 0.10. Thanks to :user:`philippbeckmann`. :bug:`2057` :bug:`2062` * A new ``--force`` option for the :ref:`remove-cmd` command allows removal of items without prompting beforehand. :bug:`2042` * A new :ref:`duplicate_action` importer config option controls how duplicate albums or tracks treated in import task. :bug:`185` Some fixes for Windows: * Queries are now detected as paths when they contain backslashes (in addition to forward slashes). This only applies on Windows. * :doc:`/plugins/embedart`: Image similarity comparison with ImageMagick should now work on Windows. * :doc:`/plugins/fetchart`: The plugin should work more reliably with non-ASCII paths. And other fixes: * :doc:`/plugins/replaygain`: The ``bs1770gain`` backend now correctly calculates sample peak instead of true peak. This comes with a major speed increase. :bug:`2031` * :doc:`/plugins/lyrics`: Avoid a crash and a spurious warning introduced in the last version about a Google API key, which appeared even when you hadn't enabled the Google lyrics source. * Fix a hard-coded path to ``bash-completion`` to work better with Homebrew installations. Thanks to :user:`bismark`. :bug:`2038` * Fix a crash introduced in the previous version when the standard input was connected to a Unix pipe. :bug:`2041` * Fix a crash when specifying non-ASCII format strings on the command line with the ``-f`` option for many commands. :bug:`2063` * :doc:`/plugins/fetchart`: Determine the file extension for downloaded images based on the image's magic bytes. The plugin prints a warning if result is not consistent with the server-supplied ``Content-Type`` header. In previous versions, the plugin would use a ``.jpg`` extension for all images. :bug:`2053` 1.3.18 (May 31, 2016) --------------------- This update adds a new :doc:`/plugins/hook` that lets you integrate beets with command-line tools and an :doc:`/plugins/export` that can dump data from the beets database as JSON. You can also automatically translate lyrics using a machine translation service. The ``echonest`` plugin has been removed in this version because the API it used is `shutting down`_. You might want to try the :doc:`/plugins/acousticbrainz` instead. .. _shutting down: https://developer.spotify.com/news-stories/2016/03/29/api-improvements-update/ Some of the larger new features: * The new :doc:`/plugins/hook` lets you execute commands in response to beets events. * The new :doc:`/plugins/export` can export data from beets' database as JSON. Thanks to :user:`GuilhermeHideki`. * :doc:`/plugins/lyrics`: The plugin can now translate the fetched lyrics to your native language using the Bing translation API. Thanks to :user:`Kraymer`. * :doc:`/plugins/fetchart`: Album art can now be fetched from `fanart.tv`_. Smaller new things: * There are two new functions available in templates: ``%first`` and ``%ifdef``. See :ref:`template-functions`. * :doc:`/plugins/convert`: A new `album_art_maxwidth` setting lets you resize album art while copying it. * :doc:`/plugins/convert`: The `extension` setting is now optional for conversion formats. By default, the extension is the same as the name of the configured format. * :doc:`/plugins/importadded`: A new `preserve_write_mtimes` option lets you preserve mtime of files even when beets updates their metadata. * :doc:`/plugins/fetchart`: The `enforce_ratio` option now lets you tolerate images that are *almost* square but differ slightly from an exact 1:1 aspect ratio. * :doc:`/plugins/fetchart`: The plugin can now optionally save the artwork's source in an attribute in the database. * The :ref:`terminal_encoding` configuration option can now also override the *input* encoding. (Previously, it only affected the encoding of the standard *output* stream.) * A new :ref:`ignore_hidden` configuration option lets you ignore files that your OS marks as invisible. * :doc:`/plugins/web`: A new `values` endpoint lets you get the distinct values of a field. Thanks to :user:`sumpfralle`. :bug:`2010` .. _fanart.tv: https://fanart.tv/ Fixes: * Fix a problem with the :ref:`stats-cmd` command in exact mode when filenames on Windows use non-ASCII characters. :bug:`1891` * Fix a crash when iTunes Sound Check tags contained invalid data. :bug:`1895` * :doc:`/plugins/mbcollection`: The plugin now redacts your MusicBrainz password in the ``beet config`` output. :bug:`1907` * :doc:`/plugins/scrub`: Fix an occasional problem where scrubbing on import could undo the :ref:`id3v23` setting. :bug:`1903` * :doc:`/plugins/lyrics`: Add compatibility with some changes to the LyricsWiki page markup. :bug:`1912` :bug:`1909` * :doc:`/plugins/lyrics`: Fix retrieval from Musixmatch by improving the way we guess the URL for lyrics on that service. :bug:`1880` * :doc:`/plugins/edit`: Fail gracefully when the configured text editor command can't be invoked. :bug:`1927` * :doc:`/plugins/fetchart`: Fix a crash in the Wikipedia backend on non-ASCII artist and album names. :bug:`1960` * :doc:`/plugins/convert`: Change the default `ogg` encoding quality from 2 to 3 (to fit the default from the `oggenc(1)` manpage). :bug:`1982` * :doc:`/plugins/convert`: The `never_convert_lossy_files` option now considers AIFF a lossless format. :bug:`2005` * :doc:`/plugins/web`: A proper 404 error, instead of an internal exception, is returned when missing album art is requested. Thanks to :user:`sumpfralle`. :bug:`2011` * Tolerate more malformed floating-point numbers in metadata tags. :bug:`2014` * The :ref:`ignore` configuration option now includes the ``lost+found`` directory by default. * :doc:`/plugins/acousticbrainz`: AcousticBrainz lookups are now done over HTTPS. Thanks to :user:`Freso`. :bug:`2007` 1.3.17 (February 7, 2016) ------------------------- This release introduces one new plugin to fetch audio information from the `AcousticBrainz`_ project and another plugin to make it easier to submit your handcrafted metadata back to MusicBrainz. The importer also gained two oft-requested features: a way to skip the initial search process by specifying an ID ahead of time, and a way to *manually* provide metadata in the middle of the import process (via the :doc:`/plugins/edit`). Also, as of this release, the beets project has some new Internet homes! Our new domain name is `beets.io`_, and we have a shiny new GitHub organization: `beetbox`_. Here are the big new features: * A new :doc:`/plugins/acousticbrainz` fetches acoustic-analysis information from the `AcousticBrainz`_ project. Thanks to :user:`opatel99`, and thanks to `Google Code-In`_! :bug:`1784` * A new :doc:`/plugins/mbsubmit` lets you print music's current metadata in a format that the MusicBrainz data parser can understand. You can trigger it during an interactive import session. :bug:`1779` * A new ``--search-id`` importer option lets you manually specify IDs (i.e., MBIDs or Discogs IDs) for imported music. Doing this skips the initial candidate search, which can be important for huge albums where this initial lookup is slow. Also, the ``enter Id`` prompt choice now accepts several IDs, separated by spaces. :bug:`1808` * :doc:`/plugins/edit`: You can now edit metadata *on the fly* during the import process. The plugin provides two new interactive options: one to edit *your music's* metadata, and one to edit the *matched metadata* retrieved from MusicBrainz (or another data source). This feature is still in its early stages, so please send feedback if you find anything missing. :bug:`1846` :bug:`396` There are even more new features: * :doc:`/plugins/fetchart`: The Google Images backend has been restored. It now requires an API key from Google. Thanks to :user:`lcharlick`. :bug:`1778` * :doc:`/plugins/info`: A new option will print only fields' names and not their values. Thanks to :user:`GuilhermeHideki`. :bug:`1812` * The :ref:`fields-cmd` command now displays flexible attributes. Thanks to :user:`GuilhermeHideki`. :bug:`1818` * The :ref:`modify-cmd` command lets you interactively select which albums or items you want to change. :bug:`1843` * The :ref:`move-cmd` command gained a new ``--timid`` flag to print and confirm which files you want to move. :bug:`1843` * The :ref:`move-cmd` command no longer prints filenames for files that don't actually need to be moved. :bug:`1583` .. _Google Code-In: https://codein.withgoogle.com/ .. _AcousticBrainz: https://acousticbrainz.org/ Fixes: * :doc:`/plugins/play`: Fix a regression in the last version where there was no default command. :bug:`1793` * :doc:`/plugins/lastimport`: The plugin now works again after being broken by some unannounced changes to the Last.fm API. :bug:`1574` * :doc:`/plugins/play`: Fixed a typo in a configuration option. The option is now ``warning_threshold`` instead of ``warning_treshold``, but we kept the old name around for compatibility. Thanks to :user:`JesseWeinstein`. :bug:`1802` :bug:`1803` * :doc:`/plugins/edit`: Editing metadata now moves files, when appropriate (like the :ref:`modify-cmd` command). :bug:`1804` * The :ref:`stats-cmd` command no longer crashes when files are missing or inaccessible. :bug:`1806` * :doc:`/plugins/fetchart`: Possibly fix a Unicode-related crash when using some versions of pyOpenSSL. :bug:`1805` * :doc:`/plugins/replaygain`: Fix an intermittent crash with the GStreamer backend. :bug:`1855` * :doc:`/plugins/lastimport`: The plugin now works with the beets API key by default. You can still provide a different key the configuration. * :doc:`/plugins/replaygain`: Fix a crash using the Python Audio Tools backend. :bug:`1873` .. _beets.io: https://beets.io/ .. _Beetbox: https://github.com/beetbox 1.3.16 (December 28, 2015) -------------------------- The big news in this release is a new :doc:`interactive editor plugin `. It's really nifty: you can now change your music's metadata by making changes in a visual text editor, which can sometimes be far more efficient than the built-in :ref:`modify-cmd` command. No more carefully retyping the same artist name with slight capitalization changes. This version also adds an oft-requested "not" operator to beets' queries, so you can exclude music from any operation. It also brings friendlier formatting (and querying!) of song durations. The big new stuff: * A new :doc:`/plugins/edit` lets you manually edit your music's metadata using your favorite text editor. :bug:`164` :bug:`1706` * Queries can now use "not" logic. Type a ``^`` before part of a query to *exclude* matching music from the results. For example, ``beet list -a beatles ^album:1`` will find all your albums by the Beatles except for their singles compilation, "1." See :ref:`not_query`. :bug:`819` :bug:`1728` * A new :doc:`/plugins/embyupdate` can trigger a library refresh on an `Emby`_ server when your beets database changes. * Track length is now displayed as "M:SS" rather than a raw number of seconds. Queries on track length also accept this format: for example, ``beet list length:5:30..`` will find all your tracks that have a duration over 5 minutes and 30 seconds. You can turn off this new behavior using the ``format_raw_length`` configuration option. :bug:`1749` Smaller changes: * Three commands, ``modify``, ``update``, and ``mbsync``, would previously move files by default after changing their metadata. Now, these commands will only move files if you have the :ref:`config-import-copy` or :ref:`config-import-move` options enabled in your importer configuration. This way, if you configure the importer not to touch your filenames, other commands will respect that decision by default too. Each command also sprouted a ``--move`` command-line option to override this default (in addition to the ``--nomove`` flag they already had). :bug:`1697` * A new configuration option, ``va_name``, controls the album artist name for various-artists albums. The setting defaults to "Various Artists," the MusicBrainz standard. In order to match MusicBrainz, the :doc:`/plugins/discogs` also adopts the same setting. * :doc:`/plugins/info`: The ``info`` command now accepts a ``-f/--format`` option for customizing how items are displayed, just like the built-in ``list`` command. :bug:`1737` Some changes for developers: * Two new :ref:`plugin hooks `, ``albuminfo_received`` and ``trackinfo_received``, let plugins intercept metadata as soon as it is received, before it is applied to music in the database. :bug:`872` * Plugins can now add options to the interactive importer prompts. See :ref:`append_prompt_choices`. :bug:`1758` Fixes: * :doc:`/plugins/plexupdate`: Fix a crash when Plex libraries use non-ASCII collection names. :bug:`1649` * :doc:`/plugins/discogs`: Maybe fix a crash when using some versions of the ``requests`` library. :bug:`1656` * Fix a race in the importer when importing two albums with the same artist and name in quick succession. The importer would fail to detect them as duplicates, claiming that there were "empty albums" in the database even when there were not. :bug:`1652` * :doc:`plugins/lastgenre`: Clean up the reggae-related genres somewhat. Thanks to :user:`Freso`. :bug:`1661` * The importer now correctly moves album art files when re-importing. :bug:`314` * :doc:`/plugins/fetchart`: In auto mode, the plugin now skips albums that already have art attached to them so as not to interfere with re-imports. :bug:`314` * :doc:`plugins/fetchart`: The plugin now only resizes album art if necessary, rather than always by default. :bug:`1264` * :doc:`plugins/fetchart`: Fix a bug where a database reference to a non-existent album art file would prevent the command from fetching new art. :bug:`1126` * :doc:`/plugins/thumbnails`: Fix a crash with Unicode paths. :bug:`1686` * :doc:`/plugins/embedart`: The ``remove_art_file`` option now works on import (as well as with the explicit command). :bug:`1662` :bug:`1675` * :doc:`/plugins/metasync`: Fix a crash when syncing with recent versions of iTunes. :bug:`1700` * :doc:`/plugins/duplicates`: Fix a crash when merging items. :bug:`1699` * :doc:`/plugins/smartplaylist`: More gracefully handle malformed queries and missing configuration. * Fix a crash with some files with unreadable iTunes SoundCheck metadata. :bug:`1666` * :doc:`/plugins/thumbnails`: Fix a nasty segmentation fault crash that arose with some library versions. :bug:`1433` * :doc:`/plugins/convert`: Fix a crash with Unicode paths in ``--pretend`` mode. :bug:`1735` * Fix a crash when sorting by nonexistent fields on queries. :bug:`1734` * Probably fix some mysterious errors when dealing with images using ImageMagick on Windows. :bug:`1721` * Fix a crash when writing some Unicode comment strings to MP3s that used older encodings. The encoding is now always updated to UTF-8. :bug:`879` * :doc:`/plugins/fetchart`: The Google Images backend has been removed. It used an API that has been shut down. :bug:`1760` * :doc:`/plugins/lyrics`: Fix a crash in the Google backend when searching for bands with regular-expression characters in their names, like Sunn O))). :bug:`1673` * :doc:`/plugins/scrub`: In ``auto`` mode, the plugin now *actually* only scrubs files on import, as the documentation always claimed it did---not every time files were written, as it previously did. :bug:`1657` * :doc:`/plugins/scrub`: Also in ``auto`` mode, album art is now correctly restored. :bug:`1657` * Possibly allow flexible attributes to be used with the ``%aunique`` template function. :bug:`1775` * :doc:`/plugins/lyrics`: The Genius backend is now more robust to communication errors. The backend has also been disabled by default, since the API it depends on is currently down. :bug:`1770` .. _Emby: https://emby.media 1.3.15 (October 17, 2015) ------------------------- This release adds a new plugin for checking file quality and a new source for lyrics. The larger features are: * A new :doc:`/plugins/badfiles` helps you scan for corruption in your music collection. Thanks to :user:`fxthomas`. :bug:`1568` * :doc:`/plugins/lyrics`: You can now fetch lyrics from Genius.com. Thanks to :user:`sadatay`. :bug:`1626` :bug:`1639` * :doc:`/plugins/zero`: The plugin can now use a "whitelist" policy as an alternative to the (default) "blacklist" mode. Thanks to :user:`adkow`. :bug:`1621` :bug:`1641` And there are smaller new features too: * Add new color aliases for standard terminal color names (e.g., cyan and magenta). Thanks to :user:`mathstuf`. :bug:`1548` * :doc:`/plugins/play`: A new ``--args`` option lets you specify options for the player command. :bug:`1532` * :doc:`/plugins/play`: A new ``raw`` configuration option lets the command work with players (such as VLC) that expect music filenames as arguments, rather than in a playlist. Thanks to :user:`nathdwek`. :bug:`1578` * :doc:`/plugins/play`: You can now configure the number of tracks that trigger a "lots of music" warning. :bug:`1577` * :doc:`/plugins/embedart`: A new ``remove_art_file`` option lets you clean up if you prefer *only* embedded album art. Thanks to :user:`jackwilsdon`. :bug:`1591` :bug:`733` * :doc:`/plugins/plexupdate`: A new ``library_name`` option allows you to select which Plex library to update. :bug:`1572` :bug:`1595` * A new ``include`` option lets you import external configuration files. This release has plenty of fixes: * :doc:`/plugins/lastgenre`: Fix a bug that prevented tag popularity from being considered. Thanks to :user:`svoos`. :bug:`1559` * Fixed a bug where plugins wouldn't be notified of the deletion of an item's art, for example with the ``clearart`` command from the :doc:`/plugins/embedart`. Thanks to :user:`nathdwek`. :bug:`1565` * :doc:`/plugins/fetchart`: The Google Images source is disabled by default (as it was before beets 1.3.9), as is the Wikipedia source (which was causing lots of unnecessary delays due to DBpedia downtime). To re-enable these sources, add ``wikipedia google`` to your ``sources`` configuration option. * The :ref:`list-cmd` command's help output now has a small query and format string example. Thanks to :user:`pkess`. :bug:`1582` * :doc:`/plugins/fetchart`: The plugin now fetches PNGs but not GIFs. (It still fetches JPEGs.) This avoids an error when trying to embed images, since not all formats support GIFs. :bug:`1588` * Date fields are now written in the correct order (year-month-day), which eliminates an intermittent bug where the latter two fields would not get written to files. Thanks to :user:`jdetrey`. :bug:`1303` :bug:`1589` * :doc:`/plugins/replaygain`: Avoid a crash when the PyAudioTools backend encounters an error. :bug:`1592` * The case sensitivity of path queries is more useful now: rather than just guessing based on the platform, we now check the case sensitivity of your filesystem. :bug:`1586` * Case-insensitive path queries might have returned nothing because of a wrong SQL query. * Fix a crash when a query contains a "+" or "-" alone in a component. :bug:`1605` * Fixed unit of file size to powers of two (MiB, GiB, etc.) instead of powers of ten (MB, GB, etc.). :bug:`1623` 1.3.14 (August 2, 2015) ----------------------- This is mainly a bugfix release, but we also have a nifty new plugin for `ipfs`_ and a bunch of new configuration options. The new features: * A new :doc:`/plugins/ipfs` lets you share music via a new, global, decentralized filesystem. :bug:`1397` * :doc:`/plugins/duplicates`: You can now merge duplicate track metadata (when detecting duplicate items), or duplicate album tracks (when detecting duplicate albums). * :doc:`/plugins/duplicates`: Duplicate resolution now uses an ordering to prioritize duplicates. By default, it prefers music with more complete metadata, but you can configure it to use any list of attributes. * :doc:`/plugins/metasync`: Added a new backend to fetch metadata from iTunes. This plugin is still in an experimental phase. :bug:`1450` * The `move` command has a new ``--pretend`` option, making the command show how the items will be moved without actually changing anything. * The importer now supports matching of "pregap" or HTOA (hidden track-one audio) tracks when they are listed in MusicBrainz. (This feature depends on a new version of the `python-musicbrainzngs`_ library that is not yet released, but will start working when it is available.) Thanks to :user:`ruippeixotog`. :bug:`1104` :bug:`1493` * :doc:`/plugins/plexupdate`: A new ``token`` configuration option lets you specify a key for Plex Home setups. Thanks to :user:`edcarroll`. :bug:`1494` Fixes: * :doc:`/plugins/fetchart`: Complain when the `enforce_ratio` or `min_width` options are enabled but no local imaging backend is available to carry them out. :bug:`1460` * :doc:`/plugins/importfeeds`: Avoid generating incorrect m3u filename when both of the `m3u` and `m3u_multi` options are enabled. :bug:`1490` * :doc:`/plugins/duplicates`: Avoid a crash when misconfigured. :bug:`1457` * :doc:`/plugins/mpdstats`: Avoid a crash when the music played is not in the beets library. Thanks to :user:`CodyReichert`. :bug:`1443` * Fix a crash with ArtResizer on Windows systems (affecting :doc:`/plugins/embedart`, :doc:`/plugins/fetchart`, and :doc:`/plugins/thumbnails`). :bug:`1448` * :doc:`/plugins/permissions`: Fix an error with non-ASCII paths. :bug:`1449` * Fix sorting by paths when the :ref:`sort_case_insensitive` option is enabled. :bug:`1451` * :doc:`/plugins/embedart`: Avoid an error when trying to embed invalid images into MPEG-4 files. * :doc:`/plugins/fetchart`: The Wikipedia source can now better deal artists that use non-standard capitalization (e.g., alt-J, dEUS). * :doc:`/plugins/web`: Fix searching for non-ASCII queries. Thanks to :user:`oldtopman`. :bug:`1470` * :doc:`/plugins/mpdupdate`: We now recommend the newer ``python-mpd2`` library instead of its unmaintained parent. Thanks to :user:`Somasis`. :bug:`1472` * The importer interface and log file now output a useful list of files (instead of the word "None") when in album-grouping mode. :bug:`1475` :bug:`825` * Fix some logging errors when filenames and other user-provided strings contain curly braces. :bug:`1481` * Regular expression queries over paths now work more reliably with non-ASCII characters in filenames. :bug:`1482` * Fix a bug where the autotagger's :ref:`ignored` setting was sometimes, well, ignored. :bug:`1487` * Fix a bug with Unicode strings when generating image thumbnails. :bug:`1485` * :doc:`/plugins/keyfinder`: Fix handling of Unicode paths. :bug:`1502` * :doc:`/plugins/fetchart`: When album art is already present, the message is now printed in the ``text_highlight_minor`` color (light gray). Thanks to :user:`Somasis`. :bug:`1512` * Some messages in the console UI now use plural nouns correctly. Thanks to :user:`JesseWeinstein`. :bug:`1521` * Sorting numerical fields (such as track) now works again. :bug:`1511` * :doc:`/plugins/replaygain`: Missing GStreamer plugins now cause a helpful error message instead of a crash. :bug:`1518` * Fix an edge case when producing sanitized filenames where the maximum path length conflicted with the :ref:`replace` rules. Thanks to Ben Ockmore. :bug:`496` :bug:`1361` * Fix an incompatibility with OS X 10.11 (where ``/usr/sbin`` seems not to be on the user's path by default). * Fix an incompatibility with certain JPEG files. Here's a relevant `Python bug`_. Thanks to :user:`nathdwek`. :bug:`1545` * Fix the :ref:`group_albums` importer mode so that it works correctly when files are not already in order by album. :bug:`1550` * The ``fields`` command no longer separates built-in fields from plugin-provided ones. This distinction was becoming increasingly unreliable. * :doc:`/plugins/duplicates`: Fix a Unicode warning when paths contained non-ASCII characters. :bug:`1551` * :doc:`/plugins/fetchart`: Work around a urllib3 bug that could cause a crash. :bug:`1555` :bug:`1556` * When you edit the configuration file with ``beet config -e`` and the file does not exist, beets creates an empty file before editing it. This fixes an error on OS X, where the ``open`` command does not work with non-existent files. :bug:`1480` * :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows under Python 3. :bug:`2515` :bug:`2516` .. _Python bug: https://bugs.python.org/issue16512 .. _ipfs: https://ipfs.io 1.3.13 (April 24, 2015) ----------------------- This is a tiny bug-fix release. It copes with a dependency upgrade that broke beets. There are just two fixes: * Fix compatibility with `Jellyfish`_ version 0.5.0. * :doc:`/plugins/embedart`: In ``auto`` mode (the import hook), the plugin now respects the ``write`` config option under ``import``. If this is disabled, album art is no longer embedded on import in order to leave files untouched---in effect, ``auto`` is implicitly disabled. :bug:`1427` 1.3.12 (April 18, 2015) ----------------------- This little update makes queries more powerful, sorts music more intelligently, and removes a performance bottleneck. There's an experimental new plugin for synchronizing metadata with music players. Packagers should also note a new dependency in this version: the `Jellyfish`_ Python library makes our text comparisons (a big part of the auto-tagging process) go much faster. New features: * Queries can now use **"or" logic**: if you use a comma to separate parts of a query, items and albums will match *either* side of the comma. For example, ``beet ls foo , bar`` will get all the items matching `foo` or matching `bar`. See :ref:`combiningqueries`. :bug:`1423` * The autotagger's **matching algorithm is faster**. We now use the `Jellyfish`_ library to compute string similarity, which is better optimized than our hand-rolled edit distance implementation. :bug:`1389` * Sorting is now **case insensitive** by default. This means that artists will be sorted lexicographically regardless of case. For example, the artist alt-J will now properly sort before YACHT. (Previously, it would have ended up at the end of the list, after all the capital-letter artists.) You can turn this new behavior off using the :ref:`sort_case_insensitive` configuration option. See :ref:`query-sort`. :bug:`1429` * An experimental new :doc:`/plugins/metasync` lets you get metadata from your favorite music players, starting with Amarok. :bug:`1386` * :doc:`/plugins/fetchart`: There are new settings to control what constitutes "acceptable" images. The `minwidth` option constrains the minimum image width in pixels and the `enforce_ratio` option requires that images be square. :bug:`1394` Little fixes and improvements: * :doc:`/plugins/fetchart`: Remove a hard size limit when fetching from the Cover Art Archive. * The output of the :ref:`fields-cmd` command is now sorted. Thanks to :user:`multikatt`. :bug:`1402` * :doc:`/plugins/replaygain`: Fix a number of issues with the new ``bs1770gain`` backend on Windows. Also, fix missing debug output in import mode. :bug:`1398` * Beets should now be better at guessing the appropriate output encoding on Windows. (Specifically, the console output encoding is guessed separately from the encoding for command-line arguments.) A bug was also fixed where beets would ignore the locale settings and use UTF-8 by default. :bug:`1419` * :doc:`/plugins/discogs`: Better error handling when we can't communicate with Discogs on setup. :bug:`1417` * :doc:`/plugins/importadded`: Fix a crash when importing singletons in-place. :bug:`1416` * :doc:`/plugins/fuzzy`: Fix a regression causing a crash in the last release. :bug:`1422` * Fix a crash when the importer cannot open its log file. Thanks to :user:`barsanuphe`. :bug:`1426` * Fix an error when trying to write tags for items with flexible fields called `date` and `original_date` (which are not built-in beets fields). :bug:`1404` .. _Jellyfish: https://github.com/sunlightlabs/jellyfish 1.3.11 (April 5, 2015) ---------------------- In this release, we refactored the logging system to be more flexible and more useful. There are more granular levels of verbosity, the output from plugins should be more consistent, and several kinds of logging bugs should be impossible in the future. There are also two new plugins: one for filtering the files you import and an evolved plugin for using album art as directory thumbnails in file managers. There's a new source for album art, and the importer now records the source of match data. This is a particularly huge release---there's lots more below. There's one big change with this release: **Python 2.6 is no longer supported**. You'll need Python 2.7. Please trust us when we say this let us remove a surprising number of ugly hacks throughout the code. Major new features and bigger changes: * There are now **multiple levels of output verbosity**. On the command line, you can make beets somewhat verbose with ``-v`` or very verbose with ``-vv``. For the importer especially, this makes the first verbose mode much more manageable, while still preserving an option for overwhelmingly verbose debug output. :bug:`1244` * A new :doc:`/plugins/filefilter` lets you write regular expressions to automatically **avoid importing** certain files. Thanks to :user:`mried`. :bug:`1186` * A new :doc:`/plugins/thumbnails` generates cover-art **thumbnails for album folders** for Freedesktop.org-compliant file managers. (This replaces the :doc:`/plugins/freedesktop`, which only worked with the Dolphin file manager.) * :doc:`/plugins/replaygain`: There is a new backend that uses the `bs1770gain`_ analysis tool. Thanks to :user:`jmwatte`. :bug:`1343` * A new ``filesize`` field on items indicates the number of bytes in the file. :bug:`1291` * A new :ref:`searchlimit` configuration option allows you to specify how many search results you wish to see when looking up releases at MusicBrainz during import. :bug:`1245` * The importer now records the data source for a match in a new flexible attribute ``data_source`` on items and albums. :bug:`1311` * The colors used in the terminal interface are now configurable via the new config option ``colors``, nested under the option ``ui``. (Also, the `color` config option has been moved from top-level to under ``ui``. Beets will respect the old color setting, but will warn the user with a deprecation message.) :bug:`1238` * :doc:`/plugins/fetchart`: There's a new Wikipedia image source that uses DBpedia to find albums. Thanks to Tom Jaspers. :bug:`1194` * In the :ref:`config-cmd` command, the output is now redacted by default. Sensitive information like passwords and API keys is not included. The new ``--clear`` option disables redaction. :bug:`1376` You should probably also know about these core changes to the way beets works: * As mentioned above, Python 2.6 is no longer supported. * The ``tracktotal`` attribute is now a *track-level field* instead of an album-level one. This field stores the total number of tracks on the album, or if the :ref:`per_disc_numbering` config option is set, the total number of tracks on a particular medium (i.e., disc). The field was causing problems with that :ref:`per_disc_numbering` mode: different discs on the same album needed different track totals. The field can now work correctly in either mode. * To replace ``tracktotal`` as an album-level field, there is a new ``albumtotal`` computed attribute that provides the total number of tracks on the album. (The :ref:`per_disc_numbering` option has no influence on this field.) * The `list_format_album` and `list_format_item` configuration keys now affect (almost) every place where objects are printed and logged. (Previously, they only controlled the :ref:`list-cmd` command and a few other scattered pieces.) :bug:`1269` * Relatedly, the ``beet`` program now accept top-level options ``--format-item`` and ``--format-album`` before any subcommand to control how items and albums are displayed. :bug:`1271` * `list_format_album` and `list_format_album` have respectively been renamed :ref:`format_album` and :ref:`format_item`. The old names still work but each triggers a warning message. :bug:`1271` * :ref:`Path queries ` are automatically triggered only if the path targeted by the query exists. Previously, just having a slash somewhere in the query was enough, so ``beet ls AC/DC`` wouldn't work to refer to the artist. There are also lots of medium-sized features in this update: * :doc:`/plugins/duplicates`: The command has a new ``--strict`` option that will only report duplicates if all attributes are explicitly set. :bug:`1000` * :doc:`/plugins/smartplaylist`: Playlist updating should now be faster: the plugin detects, for each playlist, whether it needs to be regenerated, instead of obliviously regenerating all of them. The ``splupdate`` command can now also take additional parameters that indicate the names of the playlists to regenerate. * :doc:`/plugins/play`: The command shows the output of the underlying player command and lets you interact with it. :bug:`1321` * The summary shown to compare duplicate albums during import now displays the old and new filesizes. :bug:`1291` * :doc:`/plugins/lastgenre`: Add *comedy*, *humor*, and *stand-up* as well as a longer list of classical music genre tags to the built-in whitelist and canonicalization tree. :bug:`1206` :bug:`1239` :bug:`1240` * :doc:`/plugins/web`: Add support for *cross-origin resource sharing* for more flexible in-browser clients. Thanks to Andre Miller. :bug:`1236` :bug:`1237` * :doc:`plugins/mbsync`: A new ``-f/--format`` option controls the output format when listing unrecognized items. The output is also now more helpful by default. :bug:`1246` * :doc:`/plugins/fetchart`: A new option, ``-n``, extracts the cover art of all matched albums into their respective directories. Another new flag, ``-a``, associates the extracted files with the albums in the database. :bug:`1261` * :doc:`/plugins/info`: A new option, ``-i``, can display only a specified subset of properties. :bug:`1287` * The number of missing/unmatched tracks is shown during import. :bug:`1088` * :doc:`/plugins/permissions`: The plugin now also adjusts the permissions of the directories. (Previously, it only affected files.) :bug:`1308` :bug:`1324` * :doc:`/plugins/ftintitle`: You can now configure the format that the plugin uses to add the artist to the title. Thanks to :user:`amishb`. :bug:`1377` And many little fixes and improvements: * :doc:`/plugins/replaygain`: Stop applying replaygain directly to source files when using the mp3gain backend. :bug:`1316` * Path queries are case-sensitive on non-Windows OSes. :bug:`1165` * :doc:`/plugins/lyrics`: Silence a warning about insecure requests in the new MusixMatch backend. :bug:`1204` * Fix a crash when ``beet`` is invoked without arguments. :bug:`1205` :bug:`1207` * :doc:`/plugins/fetchart`: Do not attempt to import directories as album art. :bug:`1177` :bug:`1211` * :doc:`/plugins/mpdstats`: Avoid double-counting some play events. :bug:`773` :bug:`1212` * Fix a crash when the importer deals with Unicode metadata in ``--pretend`` mode. :bug:`1214` * :doc:`/plugins/smartplaylist`: Fix ``album_query`` so that individual files are added to the playlist instead of directories. :bug:`1225` * Remove the ``beatport`` plugin. `Beatport`_ has shut off public access to their API and denied our request for an account. We have not heard from the company since 2013, so we are assuming access will not be restored. * Incremental imports now (once again) show a "skipped N directories" message. * :doc:`/plugins/embedart`: Handle errors in ImageMagick's output. :bug:`1241` * :doc:`/plugins/keyfinder`: Parse the underlying tool's output more robustly. :bug:`1248` * :doc:`/plugins/embedart`: We now show a comprehensible error message when ``beet embedart -f FILE`` is given a non-existent path. :bug:`1252` * Fix a crash when a file has an unrecognized image type tag. Thanks to Matthias Kiefer. :bug:`1260` * :doc:`/plugins/importfeeds` and :doc:`/plugins/smartplaylist`: Automatically create parent directories for playlist files (instead of crashing when the parent directory does not exist). :bug:`1266` * The :ref:`write-cmd` command no longer tries to "write" non-writable fields, such as the bitrate. :bug:`1268` * The error message when MusicBrainz is not reachable on the network is now much clearer. Thanks to Tom Jaspers. :bug:`1190` :bug:`1272` * Improve error messages when parsing query strings with shlex. :bug:`1290` * :doc:`/plugins/embedart`: Fix a crash that occurred when used together with the *check* plugin. :bug:`1241` * :doc:`/plugins/scrub`: Log an error instead of stopping when the ``beet scrub`` command cannot write a file. Also, avoid problems on Windows with Unicode filenames. :bug:`1297` * :doc:`/plugins/discogs`: Handle and log more kinds of communication errors. :bug:`1299` :bug:`1305` * :doc:`/plugins/lastgenre`: Bugs in the `pylast` library can no longer crash beets. * :doc:`/plugins/convert`: You can now configure the temporary directory for conversions. Thanks to :user:`autochthe`. :bug:`1382` :bug:`1383` * :doc:`/plugins/rewrite`: Fix a regression that prevented the plugin's rewriting from applying to album-level fields like ``$albumartist``. :bug:`1393` * :doc:`/plugins/play`: The plugin now sorts items according to the configuration in album mode. * :doc:`/plugins/fetchart`: The name for extracted art files is taken from the ``art_filename`` configuration option. :bug:`1258` * When there's a parse error in a query (for example, when you type a malformed date in a :ref:`date query `), beets now stops with an error instead of silently ignoring the query component. * :doc:`/plugins/smartplaylist`: Stream-friendly smart playlists. The ``splupdate`` command can now also add a URL-encodable prefix to every path in the playlist file. For developers: * The ``database_change`` event now sends the item or album that is subject to a change. * The ``OptionParser`` is now a ``CommonOptionsParser`` that offers facilities for adding usual options (``--album``, ``--path`` and ``--format``). See :ref:`add_subcommands`. :bug:`1271` * The logging system in beets has been overhauled. Plugins now each have their own logger, which helps by automatically adjusting the verbosity level in import mode and by prefixing the plugin's name. Logging levels are dynamically set when a plugin is called, depending on how it is called (import stage, event or direct command). Finally, logging calls can (and should!) use modern ``{}``-style string formatting lazily. See :ref:`plugin-logging` in the plugin API docs. * A new ``import_task_created`` event lets you manipulate import tasks immediately after they are initialized. It's also possible to replace the originally created tasks by returning new ones using this event. .. _bs1770gain: http://bs1770gain.sourceforge.net 1.3.10 (January 5, 2015) ------------------------ This version adds a healthy helping of new features and fixes a critical MPEG-4--related bug. There are more lyrics sources, there new plugins for managing permissions and integrating with `Plex`_, and the importer has a new ``--pretend`` flag that shows which music *would* be imported. One backwards-compatibility note: the :doc:`/plugins/lyrics` now requires the `requests`_ library. If you use this plugin, you will need to install the library by typing ``pip install requests`` or the equivalent for your OS. Also, as an advance warning, this will be one of the last releases to support Python 2.6. If you have a system that cannot run Python 2.7, please consider upgrading soon. The new features are: * A new :doc:`/plugins/permissions` makes it easy to fix permissions on music files as they are imported. Thanks to :user:`xsteadfastx`. :bug:`1098` * A new :doc:`/plugins/plexupdate` lets you notify a `Plex`_ server when the database changes. Thanks again to xsteadfastx. :bug:`1120` * The :ref:`import-cmd` command now has a ``--pretend`` flag that lists the files that will be imported. Thanks to :user:`mried`. :bug:`1162` * :doc:`/plugins/lyrics`: Add `Musixmatch`_ source and introduce a new ``sources`` config option that lets you choose exactly where to look for lyrics and in which order. * :doc:`/plugins/lyrics`: Add Brazilian and Spanish sources to Google custom search engine. * Add a warning when importing a directory that contains no music. :bug:`1116` :bug:`1127` * :doc:`/plugins/zero`: Can now remove embedded images. :bug:`1129` :bug:`1100` * The :ref:`config-cmd` command can now be used to edit the configuration even when it has syntax errors. :bug:`1123` :bug:`1128` * :doc:`/plugins/lyrics`: Added a new ``force`` config option. :bug:`1150` As usual, there are loads of little fixes and improvements: * Fix a new crash with the latest version of Mutagen (1.26). * :doc:`/plugins/lyrics`: Avoid fetching truncated lyrics from the Google backed by merging text blocks separated by empty ``
`` tags before scraping. * We now print a better error message when the database file is corrupted. * :doc:`/plugins/discogs`: Only prompt for authentication when running the :ref:`import-cmd` command. :bug:`1123` * When deleting fields with the :ref:`modify-cmd` command, do not crash when the field cannot be removed (i.e., when it does not exist, when it is a built-in field, or when it is a computed field). :bug:`1124` * The deprecated ``echonest_tempo`` plugin has been removed. Please use the ``echonest`` plugin instead. * ``echonest`` plugin: Fingerprint-based lookup has been removed in accordance with `API changes`_. :bug:`1121` * ``echonest`` plugin: Avoid a crash when the song has no duration information. :bug:`896` * :doc:`/plugins/lyrics`: Avoid a crash when retrieving non-ASCII lyrics from the Google backend. :bug:`1135` :bug:`1136` * :doc:`/plugins/smartplaylist`: Sort specifiers are now respected in queries. Thanks to :user:`djl`. :bug:`1138` :bug:`1137` * :doc:`/plugins/ftintitle` and :doc:`/plugins/lyrics`: Featuring artists can now be detected when they use the Spanish word *con*. :bug:`1060` :bug:`1143` * :doc:`/plugins/mbcollection`: Fix an "HTTP 400" error caused by a change in the MusicBrainz API. :bug:`1152` * The ``%`` and ``_`` characters in path queries do not invoke their special SQL meaning anymore. :bug:`1146` * :doc:`/plugins/convert`: Command-line argument construction now works on Windows. Thanks to :user:`mluds`. :bug:`1026` :bug:`1157` :bug:`1158` * :doc:`/plugins/embedart`: Fix an erroneous missing-art error on Windows. Thanks to :user:`mluds`. :bug:`1163` * :doc:`/plugins/importadded`: Now works with in-place and symlinked imports. :bug:`1170` * :doc:`/plugins/ftintitle`: The plugin is now quiet when it runs as part of the import process. Thanks to :user:`Freso`. :bug:`1176` :bug:`1172` * :doc:`/plugins/ftintitle`: Fix weird behavior when the same artist appears twice in the artist string. Thanks to Marc Addeo. :bug:`1179` :bug:`1181` * :doc:`/plugins/lastgenre`: Match songs more robustly when they contain dashes. Thanks to :user:`djl`. :bug:`1156` * The :ref:`config-cmd` command can now use ``$EDITOR`` variables with arguments. .. _API changes: https://web.archive.org/web/20160814092627/https://developer.echonest.com/forums/thread/3650 .. _Plex: https://plex.tv/ .. _musixmatch: https://www.musixmatch.com/ 1.3.9 (November 17, 2014) ------------------------- This release adds two new standard plugins to beets: one for synchronizing Last.fm listening data and one for integrating with Linux desktops. And at long last, imports can now create symbolic links to music files instead of copying or moving them. We also gained the ability to search for album art on the iTunes Store and a new way to compute ReplayGain levels. The major new features are: * A new :doc:`/plugins/lastimport` lets you download your play count data from Last.fm into a flexible attribute. Thanks to Rafael Bodill. * A new :doc:`/plugins/freedesktop` creates metadata files for Freedesktop.org--compliant file managers. Thanks to :user:`kerobaros`. :bug:`1056`, :bug:`707` * A new :ref:`link` option in the ``import`` section creates symbolic links during import instead of moving or copying. Thanks to Rovanion Luckey. :bug:`710`, :bug:`114` * :doc:`/plugins/fetchart`: You can now search for art on the iTunes Store. There's also a new ``sources`` config option that lets you choose exactly where to look for images and in which order. * :doc:`/plugins/replaygain`: A new Python Audio Tools backend was added. Thanks to Francesco Rubino. :bug:`1070` * :doc:`/plugins/embedart`: You can now automatically check that new art looks similar to existing art---ensuring that you only get a better "version" of the art you already have. See :ref:`image-similarity-check`. * :doc:`/plugins/ftintitle`: The plugin now runs automatically on import. To disable this, unset the ``auto`` config flag. There are also core improvements and other substantial additions: * The ``media`` attribute is now a *track-level field* instead of an album-level one. This field stores the delivery mechanism for the music, so in its album-level incarnation, it could not represent heterogeneous releases---for example, an album consisting of a CD and a DVD. Now, tracks accurately indicate the media they appear on. Thanks to Heinz Wiesinger. * Re-imports of your existing music (see :ref:`reimport`) now preserve its added date and flexible attributes. Thanks to Stig Inge Lea Bjørnsen. * Slow queries, such as those over flexible attributes, should now be much faster when used with certain commands---notably, the :doc:`/plugins/play`. * :doc:`/plugins/bpd`: Add a new configuration option for setting the default volume. Thanks to IndiGit. * :doc:`/plugins/embedart`: A new ``ifempty`` config option lets you only embed album art when no album art is present. Thanks to kerobaros. * :doc:`/plugins/discogs`: Authenticate with the Discogs server. The plugin now requires a Discogs account due to new API restrictions. Thanks to :user:`multikatt`. :bug:`1027`, :bug:`1040` And countless little improvements and fixes: * Standard cover art in APEv2 metadata is now supported. Thanks to Matthias Kiefer. :bug:`1042` * :doc:`/plugins/convert`: Avoid a crash when embedding cover art fails. * :doc:`/plugins/mpdstats`: Fix an error on start (introduced in the previous version). Thanks to Zach Denton. * :doc:`/plugins/convert`: The ``--yes`` command-line flag no longer expects an argument. * :doc:`/plugins/play`: Remove the temporary .m3u file after sending it to the player. * The importer no longer tries to highlight partial differences in numeric quantities (track numbers and durations), which was often confusing. * Date-based queries that are malformed (not parse-able) no longer crash beets and instead fail silently. * :doc:`/plugins/duplicates`: Emit an error when the ``checksum`` config option is set incorrectly. * The migration from pre-1.1, non-YAML configuration files has been removed. If you need to upgrade an old config file, use an older version of beets temporarily. * :doc:`/plugins/discogs`: Recover from HTTP errors when communicating with the Discogs servers. Thanks to Dustin Rodriguez. * :doc:`/plugins/embedart`: Do not log "embedding album art into..." messages during the import process. * Fix a crash in the autotagger when files had only whitespace in their metadata. * :doc:`/plugins/play`: Fix a potential crash when the command outputs special characters. :bug:`1041` * :doc:`/plugins/web`: Queries typed into the search field are now treated as separate query components. :bug:`1045` * Date tags that use slashes instead of dashes as separators are now interpreted correctly. And WMA (ASF) files now map the ``comments`` field to the "Description" tag (in addition to "WM/Comments"). Thanks to Matthias Kiefer. :bug:`1043` * :doc:`/plugins/embedart`: Avoid resizing the image multiple times when embedding into an album. Thanks to :user:`kerobaros`. :bug:`1028`, :bug:`1036` * :doc:`/plugins/discogs`: Avoid a situation where a trailing comma could be appended to some artist names. :bug:`1049` * The output of the :ref:`stats-cmd` command is slightly different: the approximate size is now marked as such, and the total number of seconds only appears in exact mode. * :doc:`/plugins/convert`: A new ``copy_album_art`` option puts images alongside converted files. Thanks to Ãngel Alonso. :bug:`1050`, :bug:`1055` * There is no longer a "conflict" between two plugins that declare the same field with the same type. Thanks to Peter Schnebel. :bug:`1059` :bug:`1061` * :doc:`/plugins/chroma`: Limit the number of releases and recordings fetched as the result of an Acoustid match to avoid extremely long processing times for very popular music. :bug:`1068` * Fix an issue where modifying an album's field without actually changing it would not update the corresponding tracks to bring differing tracks back in line with the album. :bug:`856` * ``echonest`` plugin: When communicating with the Echo Nest servers fails repeatedly, log an error instead of exiting. :bug:`1096` * :doc:`/plugins/lyrics`: Avoid an error when the Google source returns a result without a title. Thanks to Alberto Leal. :bug:`1097` * Importing an archive will no longer leave temporary files behind in ``/tmp``. Thanks to :user:`multikatt`. :bug:`1067`, :bug:`1091` 1.3.8 (September 17, 2014) -------------------------- This release has two big new chunks of functionality. Queries now support **sorting** and user-defined fields can now have **types**. If you want to see all your songs in reverse chronological order, just type ``beet list year-``. It couldn't be easier. For details, see :ref:`query-sort`. Flexible field types mean that some functionality that has previously only worked for built-in fields, like range queries, can now work with plugin- and user-defined fields too. For starters, the ``echonest`` plugin and :doc:`/plugins/mpdstats` now mark the types of the fields they provide---so you can now say, for example, ``beet ls liveness:0.5..1.5`` for the Echo Nest "liveness" attribute. The :doc:`/plugins/types` makes it easy to specify field types in your config file. One upgrade note: if you use the :doc:`/plugins/discogs`, you will need to upgrade the Discogs client library to use this version. Just type ``pip install -U discogs-client``. Other new features: * :doc:`/plugins/info`: Target files can now be specified through library queries (in addition to filenames). The ``--library`` option prints library fields instead of tags. Multiple files can be summarized together with the new ``--summarize`` option. * :doc:`/plugins/mbcollection`: A new option lets you automatically update your collection on import. Thanks to Olin Gay. * :doc:`/plugins/convert`: A new ``never_convert_lossy_files`` option can prevent lossy transcoding. Thanks to Simon Kohlmeyer. * :doc:`/plugins/convert`: A new ``--yes`` command-line flag skips the confirmation. Still more fixes and little improvements: * Invalid state files don't crash the importer. * :doc:`/plugins/lyrics`: Only strip featured artists and parenthesized title suffixes if no lyrics for the original artist and title were found. * Fix a crash when reading some files with missing tags. * :doc:`/plugins/discogs`: Compatibility with the new 2.0 version of the `discogs_client`_ Python library. If you were using the old version, you wil need to upgrade to the latest version of the library to use the correspondingly new version of the plugin (e.g., with ``pip install -U discogs-client``). Thanks to Andriy Kohut. * Fix a crash when writing files that can't be read. Thanks to Jocelyn De La Rosa. * The :ref:`stats-cmd` command now counts album artists. The album count also more accurately reflects the number of albums in the database. * :doc:`/plugins/convert`: Avoid crashes when tags cannot be written to newly converted files. * Formatting templates with item data no longer confusingly shows album-level data when the two are inconsistent. * Resuming imports and beginning incremental imports should now be much faster when there is a lot of previously-imported music to skip. * :doc:`/plugins/lyrics`: Remove ``

Lady Madonna - The Beatles

The Beatles - Lady Madonna

Lady Madonna, children at your feet.
Wonder how you manage to make ends meet.
Who finds the money? When you pay the rent?
Did you think that money was heaven sent?
Friday night arrives without a suitcase.
Sunday morning creep in like a nun.
Monday's child has learned to tie his bootlace.
See how they run.
Lady Madonna, baby at your breast.
Wonder how you manage to feed the rest.
See how they run.
Lady Madonna, lying on the bed,
Listen to the music playing in your head.
Tuesday afternoon is never ending.
Wednesday morning papers didn't come.
Thursday night you stockings needed mending.
See how they run.
Lady Madonna, children at your feet.
Wonder how you manage to make ends meet.

view 9,779 times, correct by Diesel

comments

././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1638031078.5203447 beets-1.6.0/test/rsrc/lyrics/examplecom/0000755000076500000240000000000000000000000017714 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1594724155.0 beets-1.6.0/test/rsrc/lyrics/examplecom/beetssong.txt0000644000076500000240000007034300000000000022455 0ustar00asampsonstaff John Doe - beets song Lyrics

beets song Lyrics



John Doe beets song lyrics
Lyrics search for Artist - Song:

Back to the: Music Lyrics > John Doe lyrics > beets song lyrics

John Doe
beets song lyrics

Ringtones left icon Send "beets song" Ringtone to your Cell Ringtones right icon

Beets is the media library management system for obsessive 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: 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
Ringtones left icon Send "beets song" Ringtone to your Cell Ringtones right icon

Share beets song lyrics

  RATE THIS SONG!

Add to Favorites Lyrics Email to a Friend John Doe - beets song Lyrics
Rating:

Use the following form to post your meaning of this song, rate it, or submit comments about this song.


0
Name:
Comment:
Type maps backwards (spam prevention):


There are no comments for this song yet.
ToneFuse Music

././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1638031078.532614 beets-1.6.0/test/rsrc/lyrics/geniuscom/0000755000076500000240000000000000000000000017553 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1597098591.0 beets-1.6.0/test/rsrc/lyrics/geniuscom/Wutangclancreamlyrics.txt0000644000076500000240000314075500000000000024674 0ustar00asampsonstaff Wu-Tang Clan – C.R.E.A.M. Lyrics | Genius Lyrics
{{:: 'cloud_flare_always_on_short_message' | i18n }}
Check @genius for updates. We'll have things fixed soon.
Https%3a%2f%2fimages

C.R.E.A.M.

Wu-Tang Clan

About “C.R.E.A.M.â€

Arguably one of the most iconic songs in hip-hop, the underlying idea of “C.R.E.A.M.†is found in its title—cash rules everything. The timeless piano riffs and background vocals come from a chopped up sample of The Charmels‘ 1967 record, “As Long As I’ve Got You,†that make up the entire track.

Although it was released as an official single in 1994, “C.R.E.A.M.†was first recorded in 1991, around the same time as RZA’s assault case, and featured himself and Ghostface Killah. The track went through several revisions and was later re-recorded by Raekwon and Inspectah Deck in 1993—an early title of the song was “Lifestyles of the Mega-Rich.â€

In 2017, RZA explained to Power 106 how the final version of the track came together:

Once we got to the studio, I decided that this track had to be on the Wu-Tang album. I reminded Rae and Deck of their verses—their verses were long. […] Method Man, the master of hooks at the time, came in with this hook right here: ‘cash rules everything around me, cream, get the money.’ Once he added that element, I knew it was going to be a smash.

Since its release, the song and chorus have been referenced countless times by several artists. It has also been featured in movies such as Eminem’s 8 Mile and the N.W.A biopic, Straight Outta Compton.

  • What has RZA said about the song?

  • What has Raekwon said about the song?

    ‘C.R.E.A.M.’ did a lot for my career personally. It gave me an opportunity to revisit the times where that cream meant that much to us. So, yeah, when I think of this record it just automatically puts me back into ‘87/’88 where we were standing in front of the building. It’s cold outside. We didn’t care. We’re out there, all black on trying to make dollars. Just trying to make some money and trying to eat. Survive.

    This song, I remember writing to the beat a long time ago before we actually came out. That beat is old. That was probably like a ‘89 beat. RZA had it that long because he had a bunch of breaks. He had all kind of things and he was making beats back then, but we was just picking and that beat happened to always sit around and I would be like, ‘I want that beat, so don’t give that beat to nobody.’ And he kept his word and let me have it.

    Meth came up with the hook but our dude named Raider Ruckus, this was like Meth’s homeboy back then, like they was real close, he came up with the phrase ‘cash rules everything around me.’ So when he showed Meth what it was and was like, ‘Cash rules everything around me,’ Meth was like, ‘Word, you right!’ And turned it into a movie, and I came in later that day and heard it and co-signed it.

    via Complex

  • What has U-God said about the song?

    “C.R.E.A.M.†is a true song. Everything Inspectah Deck and Raekwon said is 100 percent true. Not one line in that entire song is a lie, or even a slight exaggeration. Deck did sell base, and he did go to jail at the age of fifteen. Rae was sticking up white boys on ball courts, rocking the same damn ’Lo sweater. And of “course, Meth on the hook was like butter on the popcorn. Meth knew the hard times, too, being out there smoking woolies and pumping crack, etc. That raspy shit he was kicking just echoed in everyone’s head long after the song was done playing.

    The realism on “C.R.E.A.M.†is what resonates with so many people all over the world. People everywhere know that sentiment of being slaves to the dollar. Cash is king, and we are its lowly subjects. That’s pretty much the case in every nation around the world, the desperation to put your life and your freedom on the line to make a couple dollars. Whether you’re working, stripping, hustling, or slinging, whether you’re a business owner or homeless, cash rules everything around us.

    Source: Raw:My Journey into Wu-Tang

  • What songs were sampled on the beat for “C.R.E.A.M.?â€

    The vocals and background sample that can be heard on the song’s intro were taken taken from The Charmels’ 1967 song “As Long as I’ve Got Youâ€:

    The classic keys sample that can be heard throughout the beat was also taken from the previously mentioned song:

  • What has Method Man said about the song?

    Meth told Complex,

    ‘C.R.E.A.M.’ was the one that really put us on the map if you wanna be technical. I wasn’t there when they recorded ‘C.R.E.A.M.’ I came in after the fact. RZA was like, ‘Put a hook on this song’ and I put a hook on it. That’s how it always went. I liked doing hooks.

    The hook for that was done by my man Raider Ruckus. We used to work at the Statue of Liberty and when we were coming home we used to come up with all these made-up words that were acronyms.

    We had words like ‘BIBWAM’ which meant, ‘Bitches Is Busted Without A Man’ and all this other crazy shit. Raider Ruckus was so ill with the way he put the words together. We would call money ‘cream’ so he took each letter and made a word out of it and killed it the way he did it.

    Something like that had never been done before as far as a hook or even a way of speaking. This is just showing and proving that we paid attention in class when we was kids. You can’t do shit like that unless you got a brain in your fucking head! You got to have some level of intelligence to do something like that.

    The best acronym for a word that I heard was ‘P.R.O.J.E.C.T.S.’ by Killah Priest. He said ‘People Relying On Just Enough Cash To Survive.’ And he’s the one that came up with ‘Basic Instructions Before Leaving Earth,’ the acronym for B.I.B.L.E. This ain’t no fluke shit man.

    There’s a reason you got millions upon millions of fucking kids running around with Wu-Tang tattoos. You don’t just put something on your body permanently unless it’s official. At that time, when you’re coming out brand new and representing where you come from, everybody from that area wants you to win because they win. That’s what it was like for us.

    We were the only dudes from Staten Island doing it so everybody from Staten Island wanted us to win. Not just dudes from Staten Island, but dudes from Brooklyn too because they had peoples in the group too. Then it was just grimy niggas who loved to see real shit, saying, ‘We riding with them Wu-Tang niggas. Fuck all that shiny suit shit!’ That ain’t no take on Puff, a lot of niggas was wearing suits and shit man, but that ain’t us.

"C.R.E.A.M." Track Info

././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1594724155.0 beets-1.6.0/test/rsrc/lyrics/geniuscom/sample.txt0000644000076500000240000002420700000000000021602 0ustar00asampsonstaff SAMPLE – SONG Lyrics | g-example Lyrics
#

SONG

SAMPLE

SONG Lyrics

!!!! MISSING LYRICS HERE !!!
More on g-example
././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1624915871.0 beets-1.6.0/test/rsrc/lyricstext.yaml0000644000076500000240000000752200000000000017361 0ustar00asampsonstaff# Song used by LyricsGooglePluginMachineryTest Beets_song: | beets is the media library management system for obsessive 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 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 missing_texts: | Lyricsmania staff is working hard for you to add $TITLE lyrics as soon as they'll be released by $ARTIST, check back soon! In case you have the lyrics to $TITLE and want to send them to us, fill out the following form. # Songs lyrics used to test the different sources present in the google custom search engine. # Text is randomized for copyright infringement reason. Amsterdam: | coup corps coeur invitent mains comme trop morue le hantent mais la dames joli revenir aux mangent croquer pleine plantent rire de sortent pleins fortune d'amsterdam bruit ruisselants large poissons braguette leur putains blanches jusque pissent dans soleils dansent et port bien vertu nez sur chaleur femmes rotant dorment marins boivent bu les que d'un qui je une cou hambourg plus ils dents ou tournent or berges d'ailleurs tout ciel haubans ce son lueurs en lune ont mouchent leurs long frottant jusqu'en vous regard montrent langueurs chantent tordent pleure donnent drames mornes des panse pour un sent encore referment nappes au meurent geste quand puis alors frites grosses batave expire naissent reboivent oriflammes grave riant a enfin rance fier y bouffer s'entendre se mieux Lady_Madonna: | feed his money tuesday manage didn't head feet see arrives at in madonna rest morning children wonder how make thursday your to sunday music papers come tie you has was is listen suitcase ends friday run that needed breast they child baby mending on lady learned a nun like did wednesday bed think without afternoon night meet the playing lying Jazz_n_blues: | all shoes money through follow blow til father to his hit jazz kiss now cool bar cause 50 night heading i'll says yeah cash forgot blues out what for ways away fingers waiting got ever bold screen sixty throw wait on about last compton days o pick love wall had within jeans jd next miss standing from it's two long fight extravagant tell today more buy shopping that didn't what's but russian up can parkway balance my and gone am it as at in check if bags when cross machine take you drinks coke june wrong coming fancy's i n' impatient so the main's spend that's Hey_it_s_ok: | and forget be when please it against fighting mama cause ! again what said things papa hey to much lovers way wet was too do drink and i who forgive hey fourteen please know not wanted had myself ok friends bed times looked swear act found the my mean Black_magic_woman: | blind heart sticks just don't into back alone see need yes your out devil make that to black got you might me woman turning spell stop baby with 'round a on stone messin' magic i of tricks up leave turn bad so pick she's my can't u_n_eye: | let see cool bed for sometimes are place told in yeah or ride open hide blame knee your my borders perfect i of laying lies they love the night all out saying fast things said that on face hit hell no low not bullets bullet fly time maybe over is roof a it know now airplane where tekst and tonight brakes just waste we go an to you was going eye start need insane cross gotta historia mood life with hurts too whoa me fight little every oh would thousand but high tekstu lay space do down private edycji ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/min.flac0000644000076500000240000005260200000000000015674 0ustar00asampsonstafffLaC"÷ Ä@ð¬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¦././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/min.m4a0000644000076500000240000001334600000000000015452 0ustar00asampsonstaff 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ƒì\¬ „ô@À\././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/min.mp30000644000076500000240000003102400000000000015461 0ustar00asampsonstaffID34COMengiTunPGAP0TENiTunes 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À[Àÿû`Àÿ€ÞÀ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/oldape.ape0000644000076500000240000003320300000000000016211 0ustar00asampsonstaffMAC 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¦././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/partial.m4a0000644000076500000240000001334600000000000016323 0ustar00asampsonstaff 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ƒì\¬ „ô@À\././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/partial.mp30000644000076500000240000003102400000000000016332 0ustar00asampsonstaffID34COMengiTunPGAP0TENiTunes 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À[Àÿû`Àÿ€ÞÀ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/pure.wma0000644000076500000240000005620000000000000015741 0ustar00asampsonstaff0&²uŽfϦ٪bÎlB¡Ü«ŒG©ÏŽäÀ SehÄQ€>ÕÞ±ÐtÐÊ› € € ôµ¿_.©ÏŽãÀ SebÒÓ«º©ÏŽæÀ Se4êËøÅ¯[wH„gªŒDúLÊ”#D˜”ÑI¡ANEpT3&²uŽfϦ٪bÎljthe titlethe artistthe comments@¤ÐÒãÒ—ð É^¨P‘Ü··©ÏŽæÀ 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Ä7NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/soundcheck-nonascii.m4a0000644000076500000240000001334600000000000020616 0ustar00asampsonstaff 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.5N----meancom.apple.iTunesnameLabeldatathe labelR----meancom.apple.iTunesnamepublisherdatathe labely----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-5a953e8ef628¨----meancom.apple.iTunesnameiTunNORMpdata 000009DD 00000ACD 00004337 0000460C 00029224 000220C3 00007FFF 00007FFF 0001FC7B 0001E5E5�¼----meancom.apple.iTunesnameiTunSMPB„data 00000000 00000840 0000037C 000000000000AC44 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000"©lyrdatathe lyrics$©cmtdatathe comments$©grpdatathe grouping(aART datathe album artistØ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ƒì\¬ „ô@À\././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/space_time.mp30000644000076500000240000003102400000000000017007 0ustar00asampsonstaffID34TIT2fullTPE1 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À[Àÿû`Àÿ€ÞÀ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1638031078.5398388 beets-1.6.0/test/rsrc/spotify/0000755000076500000240000000000000000000000015752 5ustar00asampsonstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/test/rsrc/spotify/album_info.json0000644000076500000240000006510600000000000020770 0ustar00asampsonstaff{ "album_type": "compilation", "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/0LyfQWJT6nXafLPZqxe9Of" }, "href": "https://api.spotify.com/v1/artists/0LyfQWJT6nXafLPZqxe9Of", "id": "0LyfQWJT6nXafLPZqxe9Of", "name": "Various Artists", "type": "artist", "uri": "spotify:artist:0LyfQWJT6nXafLPZqxe9Of" } ], "available_markets": [], "copyrights": [ { "text": "2013 Back Lot Music", "type": "C" }, { "text": "2013 Back Lot Music", "type": "P" } ], "external_ids": { "upc": "857970002363" }, "external_urls": { "spotify": "https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" }, "genres": [], "href": "https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", "id": "5l3zEmMrOhOzG8d8s83GOL", "images": [ { "height": 640, "url": "https://i.scdn.co/image/ab67616d0000b27399140a62d43aec760f6172a2", "width": 640 }, { "height": 300, "url": "https://i.scdn.co/image/ab67616d00001e0299140a62d43aec760f6172a2", "width": 300 }, { "height": 64, "url": "https://i.scdn.co/image/ab67616d0000485199140a62d43aec760f6172a2", "width": 64 } ], "label": "Back Lot Music", "name": "Despicable Me 2 (Original Motion Picture Soundtrack)", "popularity": 0, "release_date": "2013-06-18", "release_date_precision": "day", "total_tracks": 24, "tracks": { "href": "https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL/tracks?offset=0&limit=50", "items": [ { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/5nLYd9ST4Cnwy6NHaCxbj8" }, "href": "https://api.spotify.com/v1/artists/5nLYd9ST4Cnwy6NHaCxbj8", "id": "5nLYd9ST4Cnwy6NHaCxbj8", "name": "CeeLo Green", "type": "artist", "uri": "spotify:artist:5nLYd9ST4Cnwy6NHaCxbj8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 221805, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/3EiEbQAR44icEkz3rsMI0N" }, "href": "https://api.spotify.com/v1/tracks/3EiEbQAR44icEkz3rsMI0N", "id": "3EiEbQAR44icEkz3rsMI0N", "is_local": false, "name": "Scream", "preview_url": null, "track_number": 1, "type": "track", "uri": "spotify:track:3EiEbQAR44icEkz3rsMI0N" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/3NVrWkcHOtmPbMSvgHmijZ" }, "href": "https://api.spotify.com/v1/artists/3NVrWkcHOtmPbMSvgHmijZ", "id": "3NVrWkcHOtmPbMSvgHmijZ", "name": "The Minions", "type": "artist", "uri": "spotify:artist:3NVrWkcHOtmPbMSvgHmijZ" } ], "available_markets": [], "disc_number": 1, "duration_ms": 39065, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/1G4Z91vvEGTYd2ZgOD0MuN" }, "href": "https://api.spotify.com/v1/tracks/1G4Z91vvEGTYd2ZgOD0MuN", "id": "1G4Z91vvEGTYd2ZgOD0MuN", "is_local": false, "name": "Another Irish Drinking Song", "preview_url": null, "track_number": 2, "type": "track", "uri": "spotify:track:1G4Z91vvEGTYd2ZgOD0MuN" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 176078, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/7DKqhn3Aa0NT9N9GAcagda" }, "href": "https://api.spotify.com/v1/tracks/7DKqhn3Aa0NT9N9GAcagda", "id": "7DKqhn3Aa0NT9N9GAcagda", "is_local": false, "name": "Just a Cloud Away", "preview_url": null, "track_number": 3, "type": "track", "uri": "spotify:track:7DKqhn3Aa0NT9N9GAcagda" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 233305, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" }, "href": "https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", "id": "6NPVjNh8Jhru9xOmyQigds", "is_local": false, "name": "Happy", "preview_url": null, "track_number": 4, "type": "track", "uri": "spotify:track:6NPVjNh8Jhru9xOmyQigds" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/3NVrWkcHOtmPbMSvgHmijZ" }, "href": "https://api.spotify.com/v1/artists/3NVrWkcHOtmPbMSvgHmijZ", "id": "3NVrWkcHOtmPbMSvgHmijZ", "name": "The Minions", "type": "artist", "uri": "spotify:artist:3NVrWkcHOtmPbMSvgHmijZ" } ], "available_markets": [], "disc_number": 1, "duration_ms": 98211, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/5HSqCeDCn2EEGR5ORwaHA0" }, "href": "https://api.spotify.com/v1/tracks/5HSqCeDCn2EEGR5ORwaHA0", "id": "5HSqCeDCn2EEGR5ORwaHA0", "is_local": false, "name": "I Swear", "preview_url": null, "track_number": 5, "type": "track", "uri": "spotify:track:5HSqCeDCn2EEGR5ORwaHA0" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/3NVrWkcHOtmPbMSvgHmijZ" }, "href": "https://api.spotify.com/v1/artists/3NVrWkcHOtmPbMSvgHmijZ", "id": "3NVrWkcHOtmPbMSvgHmijZ", "name": "The Minions", "type": "artist", "uri": "spotify:artist:3NVrWkcHOtmPbMSvgHmijZ" } ], "available_markets": [], "disc_number": 1, "duration_ms": 175291, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/2Ls4QknWvBoGSeAlNKw0Xj" }, "href": "https://api.spotify.com/v1/tracks/2Ls4QknWvBoGSeAlNKw0Xj", "id": "2Ls4QknWvBoGSeAlNKw0Xj", "is_local": false, "name": "Y.M.C.A.", "preview_url": null, "track_number": 6, "type": "track", "uri": "spotify:track:2Ls4QknWvBoGSeAlNKw0Xj" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 206105, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/1XkUmKLbm1tzVtrkdj2Ou8" }, "href": "https://api.spotify.com/v1/tracks/1XkUmKLbm1tzVtrkdj2Ou8", "id": "1XkUmKLbm1tzVtrkdj2Ou8", "is_local": false, "name": "Fun, Fun, Fun", "preview_url": null, "track_number": 7, "type": "track", "uri": "spotify:track:1XkUmKLbm1tzVtrkdj2Ou8" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 254705, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/42lHGtAZd6xVLC789afLWt" }, "href": "https://api.spotify.com/v1/tracks/42lHGtAZd6xVLC789afLWt", "id": "42lHGtAZd6xVLC789afLWt", "is_local": false, "name": "Despicable Me", "preview_url": null, "track_number": 8, "type": "track", "uri": "spotify:track:42lHGtAZd6xVLC789afLWt" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 126825, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/7uAC260NViRKyYW4st4vri" }, "href": "https://api.spotify.com/v1/tracks/7uAC260NViRKyYW4st4vri", "id": "7uAC260NViRKyYW4st4vri", "is_local": false, "name": "PX-41 Labs", "preview_url": null, "track_number": 9, "type": "track", "uri": "spotify:track:7uAC260NViRKyYW4st4vri" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 87118, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6YLmc6yT7OGiNwbShHuEN2" }, "href": "https://api.spotify.com/v1/tracks/6YLmc6yT7OGiNwbShHuEN2", "id": "6YLmc6yT7OGiNwbShHuEN2", "is_local": false, "name": "The Fairy Party", "preview_url": null, "track_number": 10, "type": "track", "uri": "spotify:track:6YLmc6yT7OGiNwbShHuEN2" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 339478, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/5lwsXhSXKFoxoGOFLZdQX6" }, "href": "https://api.spotify.com/v1/tracks/5lwsXhSXKFoxoGOFLZdQX6", "id": "5lwsXhSXKFoxoGOFLZdQX6", "is_local": false, "name": "Lucy And The AVL", "preview_url": null, "track_number": 11, "type": "track", "uri": "spotify:track:5lwsXhSXKFoxoGOFLZdQX6" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 87478, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/2FlWtPuBMGo0a0X7LGETyk" }, "href": "https://api.spotify.com/v1/tracks/2FlWtPuBMGo0a0X7LGETyk", "id": "2FlWtPuBMGo0a0X7LGETyk", "is_local": false, "name": "Goodbye Nefario", "preview_url": null, "track_number": 12, "type": "track", "uri": "spotify:track:2FlWtPuBMGo0a0X7LGETyk" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 86998, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/3YnhGNADeUaoBTjB1uGUjh" }, "href": "https://api.spotify.com/v1/tracks/3YnhGNADeUaoBTjB1uGUjh", "id": "3YnhGNADeUaoBTjB1uGUjh", "is_local": false, "name": "Time for Bed", "preview_url": null, "track_number": 13, "type": "track", "uri": "spotify:track:3YnhGNADeUaoBTjB1uGUjh" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 180265, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6npUKThV4XI20VLW5ryr5O" }, "href": "https://api.spotify.com/v1/tracks/6npUKThV4XI20VLW5ryr5O", "id": "6npUKThV4XI20VLW5ryr5O", "is_local": false, "name": "Break-In", "preview_url": null, "track_number": 14, "type": "track", "uri": "spotify:track:6npUKThV4XI20VLW5ryr5O" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 95011, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/1qyFlqVfbgyiM7tQ2Jy9vC" }, "href": "https://api.spotify.com/v1/tracks/1qyFlqVfbgyiM7tQ2Jy9vC", "id": "1qyFlqVfbgyiM7tQ2Jy9vC", "is_local": false, "name": "Stalking Floyd Eaglesan", "preview_url": null, "track_number": 15, "type": "track", "uri": "spotify:track:1qyFlqVfbgyiM7tQ2Jy9vC" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 189771, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/4DRQctGiqjJkbFa7iTK4pb" }, "href": "https://api.spotify.com/v1/tracks/4DRQctGiqjJkbFa7iTK4pb", "id": "4DRQctGiqjJkbFa7iTK4pb", "is_local": false, "name": "Moving to Australia", "preview_url": null, "track_number": 16, "type": "track", "uri": "spotify:track:4DRQctGiqjJkbFa7iTK4pb" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 85878, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/1TSjM9GY2oN6RO6aYGN25n" }, "href": "https://api.spotify.com/v1/tracks/1TSjM9GY2oN6RO6aYGN25n", "id": "1TSjM9GY2oN6RO6aYGN25n", "is_local": false, "name": "Going to Save the World", "preview_url": null, "track_number": 17, "type": "track", "uri": "spotify:track:1TSjM9GY2oN6RO6aYGN25n" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 87158, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/3AEMuoglM1myQ8ouIyh8LG" }, "href": "https://api.spotify.com/v1/tracks/3AEMuoglM1myQ8ouIyh8LG", "id": "3AEMuoglM1myQ8ouIyh8LG", "is_local": false, "name": "El Macho", "preview_url": null, "track_number": 18, "type": "track", "uri": "spotify:track:3AEMuoglM1myQ8ouIyh8LG" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 47438, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/2d7fEVYdZnjlya3MPEma21" }, "href": "https://api.spotify.com/v1/tracks/2d7fEVYdZnjlya3MPEma21", "id": "2d7fEVYdZnjlya3MPEma21", "is_local": false, "name": "Jillian", "preview_url": null, "track_number": 19, "type": "track", "uri": "spotify:track:2d7fEVYdZnjlya3MPEma21" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 89398, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/7h8WnOo4Fh6NvfTUnR7nOa" }, "href": "https://api.spotify.com/v1/tracks/7h8WnOo4Fh6NvfTUnR7nOa", "id": "7h8WnOo4Fh6NvfTUnR7nOa", "is_local": false, "name": "Take Her Home", "preview_url": null, "track_number": 20, "type": "track", "uri": "spotify:track:7h8WnOo4Fh6NvfTUnR7nOa" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 212691, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/25A9ZlegjJ0z2fI1PgTqy2" }, "href": "https://api.spotify.com/v1/tracks/25A9ZlegjJ0z2fI1PgTqy2", "id": "25A9ZlegjJ0z2fI1PgTqy2", "is_local": false, "name": "El Macho's Lair", "preview_url": null, "track_number": 21, "type": "track", "uri": "spotify:track:25A9ZlegjJ0z2fI1PgTqy2" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 117745, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/48GwOCuPhWKDktq3efmfRg" }, "href": "https://api.spotify.com/v1/tracks/48GwOCuPhWKDktq3efmfRg", "id": "48GwOCuPhWKDktq3efmfRg", "is_local": false, "name": "Home Invasion", "preview_url": null, "track_number": 22, "type": "track", "uri": "spotify:track:48GwOCuPhWKDktq3efmfRg" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RaHCHhZWBXn460JpMaicz" }, "href": "https://api.spotify.com/v1/artists/2RaHCHhZWBXn460JpMaicz", "id": "2RaHCHhZWBXn460JpMaicz", "name": "Heitor Pereira", "type": "artist", "uri": "spotify:artist:2RaHCHhZWBXn460JpMaicz" } ], "available_markets": [], "disc_number": 1, "duration_ms": 443251, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/6dZkl2egcKVm8rO9W7pPWa" }, "href": "https://api.spotify.com/v1/tracks/6dZkl2egcKVm8rO9W7pPWa", "id": "6dZkl2egcKVm8rO9W7pPWa", "is_local": false, "name": "The Big Battle", "preview_url": null, "track_number": 23, "type": "track", "uri": "spotify:track:6dZkl2egcKVm8rO9W7pPWa" }, { "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/3NVrWkcHOtmPbMSvgHmijZ" }, "href": "https://api.spotify.com/v1/artists/3NVrWkcHOtmPbMSvgHmijZ", "id": "3NVrWkcHOtmPbMSvgHmijZ", "name": "The Minions", "type": "artist", "uri": "spotify:artist:3NVrWkcHOtmPbMSvgHmijZ" } ], "available_markets": [], "disc_number": 1, "duration_ms": 13886, "explicit": false, "external_urls": { "spotify": "https://open.spotify.com/track/2L0OyiAepqAbKvUZfWovOJ" }, "href": "https://api.spotify.com/v1/tracks/2L0OyiAepqAbKvUZfWovOJ", "id": "2L0OyiAepqAbKvUZfWovOJ", "is_local": false, "name": "Ba Do Bleep", "preview_url": null, "track_number": 24, "type": "track", "uri": "spotify:track:2L0OyiAepqAbKvUZfWovOJ" } ], "limit": 50, "next": null, "offset": 0, "previous": null, "total": 24 }, "type": "album", "uri": "spotify:album:5l3zEmMrOhOzG8d8s83GOL" }././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/spotify/missing_request.json0000644000076500000240000000045000000000000022065 0ustar00asampsonstaff{ "tracks" : { "href" : "https://api.spotify.com/v1/search?query=duifhjslkef+album%3Alkajsdflakjsd+artist%3A&offset=0&limit=20&type=track", "items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0 } }././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/test/rsrc/spotify/track_info.json0000644000076500000240000000454200000000000020771 0ustar00asampsonstaff{ "album": { "album_type": "compilation", "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/0LyfQWJT6nXafLPZqxe9Of" }, "href": "https://api.spotify.com/v1/artists/0LyfQWJT6nXafLPZqxe9Of", "id": "0LyfQWJT6nXafLPZqxe9Of", "name": "Various Artists", "type": "artist", "uri": "spotify:artist:0LyfQWJT6nXafLPZqxe9Of" } ], "available_markets": [], "external_urls": { "spotify": "https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" }, "href": "https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", "id": "5l3zEmMrOhOzG8d8s83GOL", "images": [ { "height": 640, "url": "https://i.scdn.co/image/ab67616d0000b27399140a62d43aec760f6172a2", "width": 640 }, { "height": 300, "url": "https://i.scdn.co/image/ab67616d00001e0299140a62d43aec760f6172a2", "width": 300 }, { "height": 64, "url": "https://i.scdn.co/image/ab67616d0000485199140a62d43aec760f6172a2", "width": 64 } ], "name": "Despicable Me 2 (Original Motion Picture Soundtrack)", "release_date": "2013-06-18", "release_date_precision": "day", "total_tracks": 24, "type": "album", "uri": "spotify:album:5l3zEmMrOhOzG8d8s83GOL" }, "artists": [ { "external_urls": { "spotify": "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href": "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id": "2RdwBSPQiwcmiDo9kixcl8", "name": "Pharrell Williams", "type": "artist", "uri": "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets": [], "disc_number": 1, "duration_ms": 233305, "explicit": false, "external_ids": { "isrc": "USQ4E1300686" }, "external_urls": { "spotify": "https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" }, "href": "https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", "id": "6NPVjNh8Jhru9xOmyQigds", "is_local": false, "name": "Happy", "popularity": 1, "preview_url": null, "track_number": 4, "type": "track", "uri": "spotify:track:6NPVjNh8Jhru9xOmyQigds" }././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/spotify/track_request.json0000644000076500000240000001004700000000000021523 0ustar00asampsonstaff{ "tracks":{ "href":"https://api.spotify.com/v1/search?query=Happy+album%3ADespicable+Me+2+artist%3APharrell+Williams&offset=0&limit=20&type=track", "items":[ { "album":{ "album_type":"compilation", "available_markets":[ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], "external_urls":{ "spotify":"https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" }, "href":"https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", "id":"5l3zEmMrOhOzG8d8s83GOL", "images":[ { "height":640, "width":640, "url":"https://i.scdn.co/image/cb7905340c132365bbaee3f17498f062858382e8" }, { "height":300, "width":300, "url":"https://i.scdn.co/image/af369120f0b20099d6784ab31c88256113f10ffb" }, { "height":64, "width":64, "url":"https://i.scdn.co/image/9dad385ddf2e7db0bef20cec1fcbdb08689d9ae8" } ], "name":"Despicable Me 2 (Original Motion Picture Soundtrack)", "type":"album", "uri":"spotify:album:5l3zEmMrOhOzG8d8s83GOL" }, "artists":[ { "external_urls":{ "spotify":"https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" }, "href":"https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", "id":"2RdwBSPQiwcmiDo9kixcl8", "name":"Pharrell Williams", "type":"artist", "uri":"spotify:artist:2RdwBSPQiwcmiDo9kixcl8" } ], "available_markets":[ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], "disc_number":1, "duration_ms":233305, "explicit":false, "external_ids":{ "isrc":"USQ4E1300686" }, "external_urls":{ "spotify":"https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" }, "href":"https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", "id":"6NPVjNh8Jhru9xOmyQigds", "name":"Happy", "popularity":89, "preview_url":"https://p.scdn.co/mp3-preview/6b00000be293e6b25f61c33e206a0c522b5cbc87", "track_number":4, "type":"track", "uri":"spotify:track:6NPVjNh8Jhru9xOmyQigds" } ], "limit":20, "next":null, "offset":0, "previous":null, "total":1 } } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/t_time.m4a0000644000076500000240000001334600000000000016150 0ustar00asampsonstaff 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ƒì\¬ „ô@À\././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/test_completion.sh0000644000076500000240000000566200000000000020032 0ustar00asampsonstaff# Function stub compopt() { return 0; } initcli() { COMP_WORDS=( "beet" "$@" ) let COMP_CWORD=${#COMP_WORDS[@]}-1 COMP_LINE="${COMP_WORDS[@]}" let COMP_POINT=${#COMP_LINE} _beet } completes() { for word in "$@"; do [[ " ${COMPREPLY[@]} " == *[[:space:]]$word[[:space:]]* ]] || return 1 done } COMMANDS='fields import list update remove stats version modify move write help' HELP_OPTS='-h --help' test_commands() { initcli '' && completes $COMMANDS && initcli -v '' && completes $COMMANDS && initcli -l help '' && completes $COMMANDS && initcli -d list '' && completes $COMMANDS && initcli -h '' && completes $COMMANDS && true } test_command_aliases() { initcli ls && completes list && initcli l && ! completes ls && initcli im && completes import && true } test_global_opts() { initcli - && completes \ -l --library \ -d --directory \ -h --help \ -c --config \ -v --verbose && true } test_global_file_opts() { # FIXME somehow file completion only works when the completion # function is called by the shell completion utilities. So we can't # test it here initcli --library '' && completes $(compgen -d) && initcli -l '' && completes $(compgen -d) && initcli --config '' && completes $(compgen -d) && initcli -c '' && completes $(compgen -d) && true } test_global_dir_opts() { initcli --directory '' && completes $(compgen -d) && initcli -d '' && completes $(compgen -d) && true } test_fields_command() { initcli fields - && completes -h --help && initcli fields '' && completes $(compgen -d) && true } test_import_files() { initcli import '' && completes $(compgen -d) && initcli import --copy -P '' && completes $(compgen -d) && initcli import --log '' && completes $(compgen -d) && true } test_import_options() { initcli imp - completes \ -h --help \ -c --copy -C --nocopy \ -w --write -W --nowrite \ -a --autotag -A --noautotag \ -p --resume -P --noresume \ -l --log --flat } test_list_options() { initcli list - completes \ -h --help \ -a --album \ -p --path } test_list_query() { initcli list 'x' && [[ -z "${COMPREPLY[@]}" ]] && initcli list 'art' && completes \ 'artist:' \ 'artpath:' && initcli list 'artits:x' && [[ -z "${COMPREPLY[@]}" ]] && true } test_help_command() { initcli help '' && completes $COMMANDS && true } test_plugin_command() { initcli te && completes test && initcli test - && completes -o --option && true } run_tests() { local tests=$(set | \ grep --extended-regexp --only-matching '^test_[a-zA-Z_]* \(\) $' |\ grep --extended-regexp --only-matching '[a-zA-Z_]*' ) local fail=0 if [[ -n $@ ]]; then tests="$@" fi for t in $tests; do $t || { fail=1 && echo "$t failed" >&2; } done return $fail } run_tests "$@" && echo "completion tests passed" ././@PaxHeader0000000000000000000000000000010400000000000010210 xustar0046 path=beets-1.6.0/test/rsrc/unicode’d.mp3 22 mtime=1629566162.0 beets-1.6.0/test/rsrc/unicode?d.mp30000644000076500000240000003102400000000000016567 0ustar00asampsonstaffID34TIT2fullTPE1 the artistTRCK2/3TALB the albumTPOS4/5TDRC2001TCON the genreTBPM6TCMP1TDOR0000TIPL arrangerTPUB the labelTCOMthe composerTIT1the groupingTENCiTunes v7.6.2COMMengiTunPGAP0USLTengthe lyricsCOMMengthe commentsTPE2the album artistTXXXR128_ALBUM_GAIN0TXXXR128_TRACK_GAIN0TXXXREPLAYGAIN_TRACK_GAIN0.00 dBTXXX REPLAYGAIN_TRACK_PEAK0.000244TXXX;MusicBrainz Album Id9e873859-8aa4-4790-b985-5a953e8ef628UFID;http://musicbrainz.org8b882575-08a5-4452-a7a7-cbb8a1531f9eTXXX<MusicBrainz Artist Id7cf0ea9d-86b9-4dad-ba9e-2355a64899eaCOMMhengiTunNORM 000003E8 000003E8 000009C4 000009C4 00000000 00000000 00000007 00000007 00000000 00000000COMMengiTunSMPB 00000000 00000210 00000A2C 000000000000AC44 00000000 000021AC 00000000 00000000 00000000 00000000 00000000 00000000ÿû`À 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À[Àÿû`Àÿ€ÞÀ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/unparseable.aiff0000644000076500000240000025432000000000000017413 0ustar00asampsonstaffFORMXÈAIFFCOMM¬D@¬DSSNDXÿÿÿþÿþÿþÿÿÿþÿþÿýÿüÿþÿÿÿýÿþÿÿÿÿÿÿÿýÿûÿýÿþÿüÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿþÿþÿþÿþÿÿÿþÿüÿûÿýÿÿÿþÿþÿþÿþÿþÿþÿýÿÿÿÿÿÿÿÿÿýÿÿÿþÿÿÿþÿþÿüÿýÿþÿþÿýÿýÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿþÿÿÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿýÿÿÿÿÿÿÿýÿûÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿÿÿþÿÿÿþÿÿÿþÿýÿÿÿÿÿþÿþÿýÿþÿþÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿûÿþÿÿÿýÿþÿÿÿüÿþÿþÿýÿüÿÿÿýÿýÿýÿÿÿþÿÿÿþÿÿÿÿÿýÿÿÿÿÿþÿþÿüÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿúÿüÿÿÿÿÿýÿûÿýÿýÿÿÿÿÿþÿþÿþÿÿÿþÿþÿýÿÿÿþÿþÿÿÿÿÿÿÿýÿýÿþÿýÿþÿþÿÿÿþÿýÿÿÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿýÿÿÿÿÿþÿþÿÿÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿýÿÿÿÿÿþÿûÿúÿþÿÿÿÿÿþÿýÿýÿþÿþÿÿÿþÿýÿþÿûÿýÿÿÿÿÿþÿþÿýÿÿÿýÿýÿýÿÿÿþÿýÿþÿÿÿþÿüÿýÿÿÿþÿýÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿýÿþÿûÿüÿÿÿÿÿþÿüÿûÿþÿÿÿÿÿýÿûÿþÿþÿþÿûÿûÿÿÿþÿüÿýÿþÿÿÿÿÿþÿýÿþÿÿÿþÿýÿýÿþÿþÿþÿÿÿýÿûÿýÿýÿüÿýÿþÿþÿþÿÿÿÿÿþÿÿÿþÿþÿÿÿýÿüÿþÿÿÿþÿþÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿÿÿýÿüÿþÿþÿþÿýÿÿÿþÿÿÿýÿýÿþÿþÿþÿÿÿÿÿÿÿýÿþÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿýÿýÿýÿÿÿþÿÿÿÿÿÿÿüÿýÿÿÿþÿýÿÿÿþÿþÿÿÿþÿÿÿþÿýÿýÿýÿþÿÿÿþÿýÿüÿûÿûÿýÿþÿÿÿþÿýÿüÿýÿþÿýÿþÿýÿýÿûÿüÿþÿýÿûÿüÿüÿþÿþÿþÿþÿþÿþÿÿÿþÿÿÿþÿýÿÿÿÿÿüÿýÿÿÿþÿþÿÿÿþÿÿÿÿÿüÿýÿÿÿÿÿüÿþÿÿÿÿÿÿÿýÿþÿþÿýÿÿÿýÿýÿüÿýÿþÿþÿÿÿþÿüÿüÿþÿþÿÿÿüÿûÿüÿûÿýÿÿÿÿÿþÿûÿüÿþÿüÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿüÿÿÿÿÿÿÿþÿýÿþÿýÿüÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿþÿþÿüÿûÿýÿÿÿþÿÿÿüÿýÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿþÿþÿþÿüÿûÿûÿüÿþÿÿÿþÿþÿÿÿÿÿýÿýÿþÿþÿûÿþÿÿÿþÿÿÿÿÿÿÿýÿþÿÿÿýÿüÿÿÿÿÿüÿÿÿÿÿþÿþÿþÿÿÿÿÿüÿûÿýÿýÿÿÿÿÿÿÿüÿÿÿÿÿþÿþÿþÿþÿþÿþÿÿÿÿÿüÿÿÿÿÿýÿÿÿþÿþÿýÿÿÿÿÿþÿýÿÿÿþÿÿÿÿÿÿÿþÿüÿýÿÿÿþÿýÿÿÿþÿþÿýÿÿÿþÿÿÿþÿÿÿþÿýÿüÿýÿþÿÿÿþÿýÿýÿýÿüÿÿÿýÿüÿüÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿüÿþÿÿÿÿÿÿÿþÿüÿþÿýÿûÿüÿþÿýÿþÿÿÿýÿþÿÿÿþÿþÿÿÿþÿÿÿþÿüÿüÿüÿÿÿþÿüÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿýÿüÿþÿþÿÿÿýÿþÿþÿÿÿþÿþÿüÿÿÿþÿÿÿþÿûÿúÿýÿÿÿþÿÿÿÿÿÿÿýÿþÿýÿýÿþÿþÿþÿþÿÿÿþÿþÿýÿüÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿþÿÿÿþÿþÿþÿÿÿþÿýÿýÿüÿýÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿýÿþÿÿÿþÿýÿþÿýÿüÿþÿÿÿÿÿÿÿþÿÿÿÿÿüÿÿÿþÿþÿÿÿþÿþÿÿÿþÿÿÿýÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿþÿýÿÿÿþÿþÿÿÿþÿÿÿþÿþÿýÿþÿýÿþÿþÿýÿüÿüÿþÿÿÿÿÿþÿýÿþÿþÿþÿþÿÿÿÿÿüÿüÿýÿþÿþÿþÿÿÿýÿùÿûÿýÿþÿþÿþÿÿÿÿÿüÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿýÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿþÿÿÿÿÿþÿüÿþÿÿÿþÿûÿýÿýÿýÿýÿþÿþÿÿÿÿÿÿÿþÿÿÿüÿüÿýÿýÿûÿÿÿýÿýÿþÿýÿÿÿÿÿûÿýÿýÿýÿýÿüÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿüÿÿÿÿÿþÿþÿÿÿüÿþÿþÿþÿþÿþÿþÿþÿÿÿþÿþÿÿÿþÿýÿüÿüÿþÿþÿþÿþÿüÿýÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿýÿþÿÿÿÿÿþÿÿÿÿÿýÿýÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿþÿþÿþÿÿÿþÿýÿþÿýÿýÿüÿþÿÿÿüÿüÿüÿýÿýÿüÿüÿþÿÿÿýÿûÿûÿüÿþÿüÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿþÿÿÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿþÿþÿþÿÿÿþÿþÿþÿþÿúÿûÿüÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿþÿþÿÿÿþÿþÿÿÿýÿþÿüÿüÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿüÿýÿûÿüÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿûÿþÿýÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿþÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿþÿýÿþÿÿÿþÿþÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿþÿÿÿþÿüÿüÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿþÿþÿÿÿþÿÿÿþÿÿÿÿÿÿÿýÿýÿýÿþÿüÿûÿýÿþÿýÿÿÿÿÿÿÿýÿÿÿÿÿþÿÿÿÿÿüÿýÿþÿýÿÿÿÿÿþÿýÿüÿýÿþÿþÿÿÿþÿüÿûÿûÿþÿÿÿþÿÿÿþÿÿÿÿÿþÿþÿÿÿüÿÿÿýÿüÿþÿþÿÿÿÿÿýÿþÿÿÿþÿýÿþÿþÿÿÿÿÿþÿÿÿþÿþÿÿÿüÿüÿÿÿþÿþÿÿÿþÿÿÿþÿÿÿþÿþÿþÿþÿþÿÿÿüÿýÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿþÿýÿüÿÿÿÿÿþÿÿÿÿÿþÿþÿþÿþÿÿÿÿÿþÿýÿþÿÿÿþÿÿÿþÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿýÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿÿÿþÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿÿÿýÿûÿþÿÿÿþÿýÿüÿýÿþÿþÿþÿÿÿýÿûÿûÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿýÿüÿþÿþÿýÿþÿþÿþÿþÿÿÿþÿþÿýÿÿÿþÿüÿýÿýÿþÿûÿüÿýÿÿÿÿÿýÿÿÿÿÿýÿþÿþÿüÿýÿýÿýÿýÿþÿþÿÿÿþÿÿÿÿÿýÿûÿþÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿýÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿûÿüÿþÿÿÿüÿüÿÿÿÿÿþÿþÿÿÿýÿýÿþÿþÿýÿýÿýÿþÿÿÿþÿýÿþÿüÿüÿþÿûÿþÿýÿþÿÿÿþÿþÿþÿþÿÿÿþÿþÿýÿýÿÿÿÿÿþÿþÿüÿþÿÿÿÿÿþÿþÿþÿýÿÿÿÿÿÿÿþÿþÿþÿýÿþÿÿÿÿÿýÿýÿÿÿÿÿÿÿýÿþÿÿÿþÿþÿýÿüÿÿÿÿÿþÿþÿþÿÿÿýÿýÿþÿþÿÿÿÿÿÿÿÿÿüÿüÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿþÿÿÿÿÿýÿþÿÿÿþÿþÿÿÿýÿûÿýÿÿÿþÿýÿýÿÿÿÿÿÿÿýÿþÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿþÿüÿþÿþÿÿÿÿÿþÿþÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿüÿýÿþÿÿÿþÿýÿÿÿþÿýÿýÿþÿûÿûÿþÿÿÿÿÿþÿýÿþÿÿÿÿÿýÿþÿÿÿþÿÿÿþÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿþÿýÿüÿüÿüÿýÿÿÿÿÿÿÿþÿýÿþÿÿÿýÿÿÿÿÿýÿýÿüÿýÿþÿþÿýÿþÿÿÿüÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿüÿûÿýÿÿÿÿÿÿÿÿÿþÿýÿýÿýÿþÿþÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿþÿÿÿþÿýÿÿÿþÿüÿüÿÿÿÿÿÿÿÿÿýÿþÿýÿýÿþÿÿÿÿÿÿÿþÿÿÿýÿýÿýÿÿÿýÿÿÿÿÿÿÿÿÿÿÿþÿüÿüÿüÿüÿüÿüÿýÿÿÿþÿÿÿþÿþÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿüÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿýÿþÿÿÿþÿþÿþÿþÿÿÿýÿüÿþÿûÿüÿþÿþÿþÿÿÿÿÿÿÿüÿüÿýÿþÿþÿþÿÿÿþÿþÿýÿúÿýÿÿÿþÿÿÿþÿýÿÿÿÿÿþÿþÿþÿýÿýÿýÿþÿÿÿþÿþÿþÿÿÿýÿýÿÿÿÿÿÿÿüÿûÿýÿÿÿÿÿþÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿýÿþÿýÿþÿýÿþÿÿÿþÿþÿÿÿÿÿýÿýÿþÿýÿþÿþÿÿÿÿÿýÿýÿÿÿÿÿþÿÿÿýÿÿÿÿÿýÿþÿÿÿýÿþÿýÿþÿÿÿýÿþÿþÿüÿüÿþÿþÿüÿýÿÿÿÿÿýÿÿÿÿÿýÿÿÿÿÿþÿþÿÿÿýÿýÿýÿþÿÿÿÿÿþÿÿÿÿÿÿÿýÿûÿüÿþÿÿÿþÿþÿûÿûÿþÿþÿüÿýÿÿÿþÿüÿýÿÿÿþÿüÿýÿþÿÿÿýÿþÿýÿüÿüÿüÿÿÿýÿüÿþÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿþÿþÿÿÿþÿþÿÿÿýÿýÿþÿþÿÿÿÿÿýÿÿÿÿÿÿÿþÿþÿþÿÿÿÿÿüÿÿÿÿÿýÿþÿþÿÿÿþÿþÿÿÿûÿüÿÿÿÿÿÿÿÿÿþÿüÿûÿýÿÿÿÿÿÿÿÿÿÿÿýÿûÿýÿþÿþÿýÿüÿþÿÿÿÿÿþÿþÿüÿýÿÿÿÿÿÿÿþÿûÿýÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿÿÿþÿþÿÿÿþÿÿÿÿÿýÿþÿÿÿþÿýÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿýÿþÿÿÿýÿûÿüÿþÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿþÿýÿþÿýÿÿÿÿÿþÿþÿýÿþÿþÿÿÿÿÿüÿýÿþÿüÿüÿûÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿüÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿþÿþÿüÿÿÿþÿÿÿÿÿþÿÿÿþÿüÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿýÿþÿüÿýÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿüÿýÿÿÿýÿûÿýÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿýÿýÿþÿÿÿüÿüÿÿÿþÿÿÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿýÿþÿþÿýÿýÿÿÿüÿüÿþÿþÿÿÿÿÿþÿþÿþÿýÿÿÿÿÿÿÿþÿþÿþÿþÿÿÿÿÿýÿÿÿÿÿüÿýÿþÿÿÿþÿüÿþÿþÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿþÿÿÿþÿþÿþÿþÿþÿÿÿÿÿþÿþÿÿÿÿÿþÿþÿÿÿüÿýÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿýÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿþÿýÿþÿÿÿÿÿÿÿÿÿýÿüÿþÿÿÿÿÿþÿýÿþÿþÿþÿýÿþÿÿÿýÿüÿþÿÿÿýÿþÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿýÿÿÿÿÿÿÿþÿüÿûÿüÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿüÿüÿþÿýÿûÿúÿüÿÿÿÿÿÿÿÿÿýÿþÿÿÿþÿýÿúÿûÿþÿýÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿþÿÿÿýÿüÿýÿþÿüÿýÿÿÿþÿÿÿþÿþÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿúÿýÿÿÿýÿþÿÿÿÿÿþÿûÿùÿýÿþÿþÿÿÿþÿÿÿþÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿýÿýÿýÿûÿûÿþÿÿÿýÿþÿÿÿÿÿÿÿþÿþÿýÿüÿýÿÿÿýÿÿÿÿÿþÿþÿÿÿÿÿÿÿýÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿþÿþÿýÿþÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿýÿüÿþÿÿÿýÿûÿûÿþÿÿÿÿÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿýÿþÿþÿþÿþÿÿÿþÿþÿÿÿÿÿüÿþÿÿÿþÿþÿþÿþÿÿÿÿÿüÿúÿÿÿþÿûÿûÿúÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿüÿýÿþÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿþÿþÿÿÿþÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿûÿþÿÿÿþÿÿÿþÿþÿÿÿüÿýÿÿÿüÿüÿþÿþÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿþÿÿÿÿÿýÿüÿýÿüÿÿÿþÿýÿþÿÿÿÿÿýÿýÿÿÿÿÿÿÿþÿýÿüÿýÿÿÿýÿþÿÿÿÿÿýÿþÿÿÿýÿþÿþÿÿÿüÿÿÿÿÿÿÿþÿýÿþÿýÿýÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿþÿÿÿÿÿþÿÿÿþÿþÿþÿþÿÿÿÿÿÿÿþÿþÿþÿþÿüÿüÿýÿÿÿÿÿþÿýÿþÿþÿÿÿýÿûÿüÿÿÿýÿþÿýÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿüÿýÿþÿÿÿÿÿþÿÿÿþÿüÿýÿþÿþÿÿÿÿÿÿÿþÿþÿÿÿýÿþÿÿÿþÿÿÿýÿýÿþÿÿÿÿÿþÿÿÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿüÿþÿÿÿþÿÿÿýÿüÿþÿÿÿýÿþÿÿÿÿÿýÿûÿþÿüÿýÿÿÿþÿþÿÿÿþÿÿÿþÿÿÿþÿýÿüÿýÿýÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿüÿýÿÿÿþÿÿÿþÿþÿÿÿÿÿüÿýÿÿÿÿÿþÿþÿýÿÿÿÿÿþÿÿÿþÿþÿþÿþÿþÿþÿþÿÿÿÿÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿýÿÿÿþÿýÿÿÿÿÿýÿûÿÿÿÿÿÿÿÿÿÿÿþÿûÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿÿÿþÿþÿüÿüÿýÿþÿýÿüÿþÿÿÿýÿûÿüÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿþÿþÿÿÿÿÿýÿþÿüÿüÿÿÿÿÿýÿþÿÿÿÿÿýÿþÿýÿüÿÿÿÿÿÿÿþÿþÿýÿþÿÿÿÿÿþÿýÿüÿýÿýÿýÿÿÿÿÿýÿÿÿÿÿýÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿþÿýÿýÿÿÿþÿþÿüÿþÿþÿÿÿýÿýÿýÿýÿþÿüÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿÿÿþÿþÿÿÿýÿþÿýÿÿÿýÿýÿþÿÿÿÿÿþÿÿÿþÿþÿûÿüÿýÿýÿþÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿüÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿýÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿüÿüÿýÿÿÿÿÿÿÿÿÿÿÿüÿýÿýÿüÿþÿÿÿÿÿþÿþÿýÿþÿýÿýÿÿÿüÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿüÿýÿÿÿþÿþÿþÿüÿýÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿýÿýÿþÿýÿþÿÿÿÿÿÿÿÿÿþÿüÿûÿýÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿýÿüÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿýÿýÿþÿÿÿÿÿüÿÿÿÿÿüÿþÿþÿÿÿýÿþÿÿÿÿÿÿÿþÿüÿýÿÿÿÿÿýÿûÿþÿÿÿýÿýÿÿÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿýÿüÿþÿÿÿýÿýÿÿÿÿÿþÿÿÿÿÿþÿýÿÿÿüÿûÿüÿÿÿÿÿÿÿÿÿÿÿýÿþÿýÿÿÿÿÿÿÿýÿÿÿÿÿÿÿþÿüÿþÿÿÿÿÿÿÿýÿÿÿþÿÿÿûÿùÿüÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿüÿýÿþÿüÿýÿüÿýÿþÿÿÿÿÿüÿÿÿÿÿüÿÿÿÿÿûÿúÿûÿÿÿÿÿþÿþÿÿÿþÿýÿüÿþÿþÿýÿýÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿþÿýÿýÿþÿþÿýÿüÿýÿÿÿýÿþÿþÿüÿüÿþÿþÿýÿÿÿþÿüÿþÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿÿÿþÿþÿÿÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿþÿýÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿýÿþÿþÿþÿüÿýÿÿÿÿÿþÿÿÿÿÿþÿýÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿýÿþÿÿÿÿÿýÿþÿÿÿýÿýÿýÿýÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿýÿþÿÿÿþÿþÿþÿýÿýÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿýÿþÿþÿýÿþÿÿÿýÿÿÿþÿÿÿýÿüÿÿÿÿÿûÿûÿýÿÿÿÿÿýÿýÿýÿÿÿýÿýÿüÿþÿýÿûÿýÿÿÿýÿùÿúÿüÿÿÿþÿþÿÿÿüÿþÿÿÿÿÿþÿþÿþÿýÿüÿÿÿþÿüÿýÿÿÿþÿÿÿþÿÿÿþÿýÿüÿýÿýÿýÿÿÿþÿþÿÿÿþÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿüÿþÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿüÿüÿþÿÿÿþÿýÿýÿÿÿþÿÿÿÿÿþÿýÿþÿýÿþÿûÿüÿþÿþÿþÿÿÿþÿÿÿþÿýÿýÿÿÿÿÿþÿþÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿþÿýÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿýÿÿÿÿÿÿÿýÿþÿýÿûÿûÿÿÿÿÿýÿÿÿýÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿýÿýÿÿÿþÿÿÿÿÿþÿþÿÿÿþÿÿÿþÿýÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿúÿûÿýÿÿÿÿÿþÿüÿýÿþÿþÿüÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿÿÿþÿþÿýÿÿÿþÿýÿýÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿüÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿþÿþÿþÿüÿÿÿÿÿþÿÿÿýÿüÿÿÿÿÿýÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿýÿýÿýÿýÿýÿýÿÿÿþÿüÿþÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿýÿþÿÿÿÿÿþÿüÿýÿÿÿÿÿÿÿþÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿþÿþÿþÿþÿýÿþÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿüÿüÿÿÿÿÿýÿýÿþÿÿÿþÿüÿýÿýÿþÿþÿýÿûÿýÿÿÿþÿüÿûÿýÿýÿÿÿþÿýÿÿÿþÿÿÿüÿÿÿÿÿþÿÿÿÿÿþÿýÿþÿþÿþÿÿÿÿÿüÿüÿýÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿÿÿþÿþÿÿÿþÿýÿýÿþÿþÿÿÿþÿÿÿÿÿÿÿýÿÿÿýÿüÿýÿýÿþÿýÿýÿÿÿýÿýÿþÿÿÿÿÿýÿýÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿþÿþÿþÿþÿÿÿÿÿýÿýÿÿÿþÿýÿÿÿÿÿÿÿÿÿüÿýÿÿÿþÿýÿÿÿþÿÿÿþÿþÿÿÿýÿÿÿÿÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿüÿûÿþÿÿÿýÿýÿÿÿÿÿýÿþÿþÿûÿûÿýÿÿÿþÿýÿþÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿýÿûÿüÿþÿÿÿþÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿûÿýÿûÿüÿþÿýÿþÿþÿûÿüÿÿÿýÿýÿýÿýÿÿÿüÿÿÿþÿýÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿýÿýÿþÿþÿýÿþÿþÿýÿÿÿþÿÿÿÿÿÿÿüÿûÿúÿýÿÿÿÿÿýÿüÿüÿþÿþÿÿÿüÿþÿÿÿÿÿÿÿþÿýÿýÿÿÿþÿþÿþÿÿÿüÿýÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿÿÿÿÿþÿýÿýÿÿÿüÿýÿÿÿÿÿÿÿüÿýÿÿÿþÿþÿÿÿþÿþÿûÿùÿûÿþÿþÿÿÿÿÿÿÿÿÿüÿþÿÿÿýÿÿÿÿÿþÿþÿþÿþÿþÿþÿÿÿþÿÿÿÿÿþÿýÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿÿÿýÿùÿúÿþÿÿÿþÿýÿýÿþÿþÿÿÿÿÿþÿýÿþÿýÿýÿþÿüÿüÿþÿþÿÿÿýÿüÿÿÿÿÿýÿüÿüÿþÿþÿþÿþÿþÿþÿþÿÿÿýÿÿÿÿÿÿÿþÿþÿÿÿþÿÿÿþÿÿÿþÿÿÿÿÿþÿýÿýÿüÿûÿþÿÿÿýÿþÿþÿÿÿÿÿýÿüÿþÿþÿýÿýÿþÿÿÿþÿÿÿÿÿþÿûÿýÿýÿýÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿüÿÿÿÿÿüÿýÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿþÿüÿüÿþÿÿÿÿÿÿÿÿÿÿÿýÿüÿÿÿÿÿüÿþÿÿÿÿÿýÿüÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿýÿûÿúÿýÿÿÿþÿýÿüÿüÿþÿýÿÿÿÿÿýÿþÿÿÿþÿýÿüÿüÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿþÿÿÿûÿüÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿýÿýÿýÿýÿþÿÿÿþÿÿÿÿÿüÿûÿýÿÿÿþÿþÿþÿþÿÿÿÿÿýÿÿÿþÿÿÿÿÿþÿÿÿÿÿýÿúÿüÿÿÿÿÿÿÿþÿýÿÿÿýÿûÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿþÿýÿþÿÿÿþÿþÿþÿýÿÿÿÿÿÿÿüÿúÿûÿÿÿÿÿþÿÿÿþÿÿÿÿÿþÿýÿüÿÿÿÿÿþÿþÿþÿÿÿýÿþÿÿÿþÿþÿÿÿÿÿýÿûÿýÿþÿþÿÿÿþÿþÿÿÿÿÿüÿüÿýÿþÿÿÿýÿþÿüÿþÿÿÿÿÿþÿÿÿÿÿýÿûÿüÿþÿÿÿýÿþÿÿÿþÿþÿÿÿþÿÿÿþÿÿÿþÿÿÿþÿþÿÿÿþÿüÿýÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿýÿûÿüÿþÿÿÿÿÿþÿüÿÿÿþÿÿÿþÿýÿýÿÿÿþÿüÿúÿúÿûÿÿÿþÿÿÿþÿüÿûÿûÿýÿþÿýÿþÿÿÿþÿÿÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿþÿÿÿþÿþÿýÿÿÿüÿûÿüÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿýÿþÿþÿýÿüÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿþÿúÿûÿþÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿüÿýÿýÿüÿüÿüÿþÿÿÿÿÿÿÿýÿüÿÿÿþÿýÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿþÿþÿÿÿþÿüÿüÿüÿûÿÿÿÿÿüÿýÿÿÿÿÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿýÿþÿýÿÿÿÿÿþÿþÿúÿþÿÿÿúÿüÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿþÿýÿþÿþÿýÿÿÿþÿþÿþÿþÿÿÿÿÿþÿüÿüÿýÿþÿüÿüÿÿÿÿÿûÿûÿüÿþÿüÿüÿþÿþÿÿÿþÿþÿÿÿþÿüÿýÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿüÿüÿþÿÿÿþÿýÿþÿÿÿþÿþÿþÿýÿþÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿÿÿýÿýÿÿÿÿÿþÿüÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿýÿþÿýÿüÿýÿÿÿþÿþÿüÿýÿÿÿþÿýÿÿÿÿÿþÿþÿþÿýÿþÿþÿþÿÿÿÿÿþÿýÿþÿýÿÿÿÿÿýÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿÿÿÿÿÿÿýÿÿÿÿÿþÿÿÿÿÿþÿüÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿüÿüÿüÿüÿþÿÿÿýÿüÿþÿýÿþÿþÿýÿýÿþÿýÿþÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿþÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿþÿýÿúÿþÿÿÿüÿýÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿþÿýÿýÿþÿýÿþÿÿÿýÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿýÿýÿÿÿýÿýÿÿÿþÿþÿüÿýÿýÿûÿüÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿýÿÿÿýÿþÿÿÿÿÿÿÿþÿþÿþÿþÿüÿýÿÿÿþÿþÿÿÿÿÿÿÿüÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿþÿýÿÿÿþÿÿÿþÿýÿÿÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿýÿþÿýÿýÿÿÿÿÿÿÿþÿýÿüÿýÿýÿÿÿþÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿýÿþÿüÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿýÿüÿüÿûÿýÿþÿýÿýÿýÿûÿýÿýÿýÿýÿþÿûÿþÿÿÿþÿÿÿÿÿþÿÿÿüÿûÿýÿÿÿþÿüÿýÿÿÿýÿýÿýÿþÿÿÿÿÿýÿýÿÿÿþÿÿÿýÿýÿýÿýÿþÿþÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿþÿÿÿýÿûÿýÿÿÿÿÿÿÿýÿýÿÿÿÿÿýÿþÿÿÿýÿýÿÿÿÿÿÿÿþÿûÿùÿúÿþÿÿÿþÿÿÿþÿÿÿþÿÿÿþÿþÿüÿúÿûÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿýÿýÿýÿþÿÿÿþÿþÿýÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿýÿþÿÿÿÿÿýÿþÿÿÿþÿÿÿþÿÿÿþÿüÿûÿþÿýÿÿÿþÿþÿýÿýÿýÿýÿûÿüÿþÿÿÿþÿþÿýÿúÿúÿúÿûÿÿÿÿÿþÿÿÿÿÿýÿüÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿýÿýÿÿÿÿÿÿÿþÿþÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿüÿüÿþÿýÿüÿýÿþÿÿÿÿÿüÿûÿþÿüÿýÿýÿþÿýÿýÿÿÿþÿüÿýÿÿÿþÿüÿýÿþÿþÿÿÿýÿþÿÿÿýÿþÿþÿÿÿÿÿþÿþÿýÿüÿþÿÿÿÿÿÿÿÿÿþÿüÿþÿþÿÿÿüÿýÿÿÿýÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿüÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿþÿýÿÿÿþÿþÿþÿÿÿþÿÿÿÿÿýÿþÿÿÿýÿýÿüÿýÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿýÿüÿüÿüÿýÿþÿýÿýÿÿÿÿÿþÿýÿýÿÿÿþÿýÿÿÿÿÿÿÿþÿýÿÿÿÿÿûÿûÿûÿûÿýÿþÿþÿþÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿüÿþÿÿÿÿÿÿÿÿÿÿÿþÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿýÿüÿþÿþÿþÿýÿÿÿþÿýÿýÿþÿýÿþÿþÿüÿþÿýÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿýÿûÿüÿþÿÿÿÿÿÿÿÿÿýÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿþÿýÿüÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿþÿýÿüÿüÿüÿüÿüÿüÿýÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿþÿÿÿþÿüÿþÿÿÿþÿýÿýÿÿÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿþÿþÿÿÿþÿþÿþÿÿÿÿÿþÿþÿþÿÿÿüÿûÿÿÿÿÿýÿþÿÿÿÿÿþÿûÿûÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿûÿüÿÿÿþÿûÿüÿÿÿÿÿÿÿÿÿÿÿüÿüÿÿÿÿÿüÿþÿþÿýÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿÿÿÿÿÿÿþÿÿÿýÿüÿþÿþÿþÿþÿüÿÿÿÿÿÿÿýÿýÿÿÿýÿýÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿþÿþÿÿÿþÿýÿýÿþÿÿÿÿÿþÿÿÿÿÿÿÿýÿüÿþÿþÿþÿÿÿþÿÿÿÿÿüÿüÿÿÿþÿþÿþÿýÿþÿÿÿÿÿýÿÿÿþÿýÿþÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿþÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿúÿùÿüÿþÿýÿýÿÿÿþÿýÿÿÿþÿüÿýÿÿÿýÿúÿýÿýÿûÿûÿÿÿþÿýÿûÿüÿþÿþÿÿÿýÿüÿþÿÿÿþÿýÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿüÿýÿÿÿþÿýÿüÿÿÿÿÿþÿþÿþÿþÿþÿýÿüÿüÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿüÿûÿýÿþÿþÿþÿþÿþÿþÿüÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿþÿýÿýÿþÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿþÿýÿþÿýÿýÿþÿþÿûÿùÿúÿýÿþÿþÿþÿþÿþÿÿÿþÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿýÿþÿÿÿýÿûÿúÿüÿÿÿýÿþÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿüÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿþÿýÿüÿÿÿÿÿüÿýÿÿÿþÿúÿùÿþÿÿÿÿÿÿÿþÿýÿþÿýÿþÿýÿþÿþÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿüÿýÿÿÿÿÿýÿÿÿþÿþÿþÿþÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿüÿùÿüÿÿÿýÿÿÿÿÿþÿýÿÿÿüÿýÿþÿþÿýÿýÿÿÿÿÿÿÿüÿþÿþÿÿÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿýÿüÿþÿÿÿýÿüÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿýÿýÿüÿþÿÿÿýÿýÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿýÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿúÿûÿÿÿÿÿüÿýÿþÿýÿþÿüÿýÿÿÿþÿüÿûÿýÿýÿÿÿÿÿþÿýÿÿÿýÿþÿÿÿþÿÿÿþÿýÿÿÿÿÿþÿýÿÿÿýÿþÿýÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿýÿýÿþÿÿÿýÿÿÿýÿþÿÿÿÿÿþÿüÿýÿÿÿþÿýÿüÿþÿýÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿþÿÿÿüÿýÿþÿýÿÿÿÿÿþÿýÿþÿýÿþÿÿÿþÿüÿþÿýÿÿÿÿÿþÿýÿýÿþÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿþÿþÿýÿþÿÿÿþÿÿÿþÿþÿûÿýÿÿÿÿÿþÿýÿýÿüÿûÿþÿÿÿþÿþÿÿÿÿÿüÿûÿÿÿþÿþÿþÿÿÿþÿúÿþÿþÿÿÿÿÿþÿþÿýÿþÿþÿýÿÿÿÿÿþÿûÿýÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿýÿûÿýÿÿÿüÿþÿþÿÿÿüÿÿÿÿÿþÿüÿûÿúÿüÿÿÿüÿúÿþÿþÿÿÿÿÿþÿýÿýÿþÿüÿûÿýÿÿÿþÿýÿýÿÿÿýÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿúÿùÿýÿÿÿþÿüÿýÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿüÿüÿüÿýÿÿÿþÿüÿýÿýÿþÿþÿÿÿÿÿþÿüÿýÿýÿÿÿÿÿþÿþÿþÿÿÿþÿüÿþÿýÿþÿÿÿÿÿþÿÿÿþÿÿÿýÿÿÿÿÿýÿþÿÿÿÿÿýÿýÿÿÿþÿÿÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿþÿþÿþÿýÿþÿÿÿÿÿÿÿÿÿüÿÿÿþÿþÿþÿÿÿÿÿþÿþÿþÿÿÿÿÿüÿýÿÿÿÿÿÿÿþÿýÿþÿþÿüÿüÿýÿþÿüÿûÿþÿÿÿÿÿýÿüÿýÿýÿþÿýÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿýÿýÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿþÿþÿþÿýÿýÿþÿÿÿþÿÿÿþÿÿÿþÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿÿÿýÿýÿþÿÿÿÿÿýÿþÿÿÿþÿÿÿÿÿýÿüÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿþÿþÿÿÿþÿÿÿýÿþÿþÿÿÿýÿýÿþÿÿÿÿÿÿÿþÿþÿþÿþÿþÿüÿüÿýÿýÿþÿþÿÿÿþÿüÿüÿýÿÿÿýÿûÿûÿÿÿþÿþÿþÿþÿþÿÿÿÿÿþÿþÿÿÿþÿÿÿýÿþÿÿÿÿÿþÿýÿüÿüÿüÿÿÿÿÿþÿÿÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿþÿÿÿþÿþÿüÿþÿÿÿþÿþÿÿÿþÿþÿÿÿþÿýÿýÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿþÿûÿýÿýÿþÿÿÿÿÿþÿÿÿþÿÿÿüÿýÿýÿýÿÿÿþÿþÿÿÿÿÿýÿüÿýÿüÿýÿþÿþÿþÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿüÿþÿÿÿþÿüÿþÿÿÿÿÿüÿýÿÿÿþÿýÿýÿþÿýÿûÿüÿýÿýÿþÿÿÿÿÿÿÿüÿúÿýÿþÿýÿþÿÿÿÿÿþÿÿÿÿÿüÿýÿþÿþÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿþÿþÿþÿÿÿþÿüÿýÿþÿýÿÿÿÿÿÿÿýÿüÿþÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿýÿüÿýÿþÿþÿþÿþÿüÿüÿþÿüÿÿÿþÿþÿýÿÿÿÿÿþÿýÿþÿýÿþÿüÿýÿýÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿýÿÿÿÿÿüÿýÿþÿýÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿüÿüÿýÿÿÿÿÿýÿýÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿýÿûÿüÿÿÿþÿÿÿþÿþÿÿÿÿÿþÿþÿþÿþÿþÿýÿþÿÿÿýÿýÿÿÿýÿýÿüÿþÿûÿýÿþÿÿÿýÿûÿüÿþÿÿÿÿÿÿÿþÿýÿýÿþÿÿÿÿÿÿÿþÿûÿùÿúÿýÿþÿþÿýÿþÿýÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿþÿþÿüÿýÿþÿþÿýÿýÿýÿýÿýÿýÿþÿýÿþÿüÿþÿþÿþÿÿÿÿÿÿÿýÿüÿüÿýÿÿÿþÿüÿüÿÿÿþÿúÿúÿþÿþÿüÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿýÿýÿÿÿÿÿÿÿþÿüÿýÿþÿÿÿÿÿÿÿÿÿþÿþÿüÿþÿÿÿþÿþÿÿÿþÿÿÿþÿþÿþÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿþÿÿÿþÿÿÿþÿÿÿþÿþÿÿÿýÿûÿþÿÿÿÿÿýÿýÿýÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿþÿÿÿÿÿýÿüÿÿÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿûÿýÿÿÿþÿþÿÿÿþÿþÿüÿùÿùÿýÿþÿÿÿþÿÿÿÿÿÿÿüÿûÿüÿÿÿÿÿþÿýÿÿÿþÿýÿýÿÿÿþÿÿÿþÿþÿÿÿÿÿýÿýÿÿÿýÿüÿþÿÿÿþÿþÿþÿþÿþÿÿÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿþÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿþÿÿÿÿÿÿÿþÿþÿþÿýÿþÿÿÿÿÿÿÿýÿüÿýÿÿÿÿÿÿÿüÿüÿýÿþÿþÿÿÿÿÿÿÿÿÿþÿüÿþÿþÿýÿþÿþÿÿÿýÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿÿÿüÿýÿÿÿþÿýÿÿÿýÿüÿýÿýÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿþÿýÿþÿþÿÿÿÿÿþÿýÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿýÿþÿþÿþÿþÿÿÿýÿþÿÿÿþÿþÿþÿþÿÿÿÿÿþÿþÿûÿûÿþÿÿÿþÿþÿþÿÿÿþÿüÿûÿýÿÿÿÿÿþÿýÿþÿüÿüÿþÿÿÿÿÿÿÿüÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿüÿýÿÿÿþÿýÿÿÿÿÿþÿþÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿþÿþÿþÿÿÿþÿþÿÿÿþÿýÿÿÿþÿÿÿÿÿýÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿýÿýÿýÿýÿýÿþÿýÿþÿþÿÿÿÿÿýÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿþÿþÿþÿÿÿþÿÿÿþÿþÿþÿýÿýÿýÿýÿÿÿÿÿþÿþÿþÿþÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿýÿýÿþÿþÿÿÿÿÿýÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿüÿþÿÿÿÿÿÿÿþÿýÿþÿþÿþÿÿÿÿÿýÿþÿþÿþÿÿÿþÿþÿÿÿÿÿýÿþÿÿÿÿÿýÿýÿýÿþÿûÿüÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿþÿýÿýÿýÿýÿýÿþÿÿÿÿÿÿÿþÿüÿûÿýÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿüÿüÿþÿÿÿþÿþÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿþÿÿÿýÿüÿÿÿÿÿþÿýÿüÿýÿþÿÿÿýÿÿÿþÿÿÿþÿþÿþÿÿÿþÿÿÿÿÿþÿýÿÿÿýÿýÿüÿþÿüÿúÿüÿþÿýÿþÿüÿüÿÿÿÿÿÿÿÿÿýÿþÿýÿþÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿÿÿýÿûÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿýÿýÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿýÿýÿýÿþÿýÿýÿýÿûÿüÿÿÿÿÿþÿþÿÿÿþÿþÿÿÿþÿÿÿýÿüÿýÿÿÿÿÿþÿÿÿÿÿþÿþÿþÿþÿÿÿýÿþÿÿÿþÿþÿþÿþÿýÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿüÿþÿÿÿýÿÿÿþÿûÿýÿÿÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿÿÿÿÿÿÿÿÿþÿýÿüÿþÿÿÿþÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿþÿýÿþÿÿÿýÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿýÿýÿýÿþÿþÿüÿûÿýÿýÿýÿýÿþÿÿÿþÿþÿÿÿÿÿýÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿýÿþÿþÿýÿþÿûÿþÿÿÿÿÿÿÿþÿýÿÿÿýÿúÿþÿÿÿüÿþÿÿÿþÿÿÿÿÿþÿþÿþÿþÿÿÿýÿüÿÿÿÿÿüÿÿÿÿÿÿÿÿÿüÿûÿýÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿýÿþÿþÿýÿþÿÿÿÿÿþÿÿÿÿÿýÿýÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿüÿýÿþÿþÿýÿýÿûÿÿÿÿÿüÿÿÿÿÿþÿÿÿýÿÿÿþÿûÿüÿýÿÿÿþÿþÿþÿýÿýÿÿÿÿÿÿÿÿÿþÿýÿýÿþÿÿÿþÿþÿýÿüÿýÿÿÿýÿüÿþÿþÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿþÿüÿûÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿýÿþÿÿÿþÿýÿþÿÿÿÿÿýÿûÿýÿþÿþÿÿÿþÿÿÿþÿýÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿüÿýÿÿÿþÿþÿýÿþÿÿÿþÿÿÿþÿþÿûÿýÿÿÿÿÿýÿþÿýÿýÿÿÿÿÿþÿþÿýÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿþÿÿÿþÿüÿÿÿÿÿüÿýÿûÿýÿýÿüÿýÿüÿüÿÿÿýÿýÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿüÿþÿýÿÿÿÿÿþÿýÿþÿþÿÿÿÿÿþÿüÿþÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿÿÿÿÿýÿþÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿþÿýÿýÿýÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿüÿûÿüÿÿÿÿÿýÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿüÿþÿþÿýÿýÿþÿÿÿÿÿüÿýÿþÿÿÿþÿÿÿûÿûÿþÿüÿûÿÿÿÿÿÿÿþÿþÿýÿþÿþÿüÿþÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿüÿÿÿþÿÿÿþÿüÿþÿÿÿÿÿÿÿÿÿýÿýÿþÿþÿüÿýÿþÿüÿûÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿüÿýÿÿÿÿÿÿÿÿÿýÿüÿýÿÿÿÿÿÿÿýÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿýÿþÿþÿýÿþÿýÿþÿþÿþÿþÿýÿüÿÿÿþÿüÿûÿûÿþÿÿÿþÿÿÿþÿþÿþÿþÿþÿÿÿþÿþÿúÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿûÿüÿÿÿÿÿÿÿýÿÿÿþÿþÿÿÿþÿþÿþÿÿÿÿÿüÿýÿþÿþÿÿÿÿÿüÿüÿþÿÿÿþÿþÿÿÿÿÿÿÿýÿþÿýÿþÿÿÿþÿþÿÿÿÿÿýÿýÿÿÿÿÿþÿþÿýÿþÿÿÿýÿÿÿÿÿÿÿÿÿüÿûÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿüÿýÿþÿþÿþÿþÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿþÿýÿýÿýÿûÿûÿüÿþÿýÿýÿþÿþÿþÿýÿþÿýÿþÿÿÿþÿýÿüÿþÿýÿüÿþÿÿÿÿÿþÿþÿþÿþÿþÿüÿüÿþÿýÿýÿÿÿÿÿÿÿüÿþÿþÿÿÿþÿÿÿþÿÿÿÿÿÿÿýÿüÿüÿþÿÿÿÿÿüÿýÿýÿþÿýÿþÿÿÿÿÿÿÿþÿýÿýÿüÿÿÿÿÿÿÿýÿþÿýÿýÿÿÿýÿÿÿÿÿÿÿÿÿþÿÿÿþÿýÿúÿüÿýÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿþÿÿÿÿÿÿÿýÿþÿÿÿýÿþÿýÿÿÿÿÿþÿýÿýÿýÿþÿÿÿÿÿþÿÿÿÿÿýÿÿÿÿÿþÿýÿÿÿýÿüÿûÿþÿÿÿûÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿþÿýÿÿÿÿÿÿÿÿÿþÿüÿþÿÿÿýÿÿÿÿÿÿÿÿÿþÿýÿþÿþÿûÿþÿþÿüÿþÿýÿüÿþÿþÿþÿþÿüÿýÿÿÿÿÿþÿþÿÿÿþÿþÿýÿüÿüÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿþÿýÿþÿÿÿþÿýÿýÿþÿÿÿÿÿýÿüÿûÿúÿúÿýÿÿÿþÿÿÿþÿüÿûÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿÿÿÿÿÿÿýÿÿÿþÿüÿþÿþÿþÿþÿþÿÿÿþÿýÿþÿÿÿÿÿþÿþÿýÿþÿþÿüÿþÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿþÿüÿþÿþÿþÿÿÿýÿüÿüÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿýÿÿÿÿÿþÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿüÿþÿýÿÿÿÿÿÿÿÿÿýÿüÿýÿþÿÿÿÿÿþÿýÿþÿÿÿþÿÿÿýÿþÿÿÿÿÿþÿýÿÿÿüÿûÿþÿýÿÿÿÿÿÿÿþÿÿÿýÿûÿûÿúÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿÿÿüÿûÿþÿÿÿÿÿÿÿþÿýÿüÿýÿþÿýÿþÿÿÿþÿþÿÿÿþÿþÿþÿþÿÿÿþÿþÿþÿþÿþÿþÿþÿþÿþÿýÿüÿÿÿþÿýÿÿÿýÿÿÿÿÿþÿýÿþÿÿÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿýÿþÿÿÿþÿýÿûÿûÿþÿÿÿÿÿÿÿûÿûÿþÿýÿþÿýÿÿÿÿÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿûÿüÿþÿÿÿþÿýÿþÿþÿþÿÿÿÿÿÿÿþÿÿÿüÿýÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿýÿüÿþÿþÿÿÿüÿÿÿþÿýÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿþÿþÿÿÿþÿþÿþÿþÿýÿüÿþÿÿÿýÿýÿÿÿÿÿýÿüÿþÿÿÿþÿûÿýÿþÿþÿÿÿþÿþÿþÿýÿÿÿýÿÿÿÿÿþÿýÿÿÿýÿýÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿüÿûÿûÿþÿþÿÿÿÿÿÿÿÿÿýÿýÿþÿþÿÿÿÿÿÿÿþÿþÿýÿþÿþÿþÿÿÿþÿýÿýÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿþÿþÿýÿûÿýÿÿÿþÿþÿÿÿÿÿþÿþÿüÿþÿþÿÿÿþÿÿÿþÿÿÿÿÿÿÿýÿÿÿÿÿüÿûÿüÿüÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿýÿþÿûÿûÿþÿÿÿÿÿþÿýÿûÿÿÿÿÿÿÿÿÿþÿýÿýÿüÿýÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿþÿýÿýÿþÿýÿÿÿÿÿüÿüÿýÿÿÿþÿÿÿÿÿýÿÿÿþÿÿÿþÿþÿþÿýÿÿÿÿÿÿÿÿÿüÿúÿûÿýÿÿÿþÿýÿÿÿýÿýÿÿÿÿÿýÿþÿþÿýÿÿÿþÿÿÿþÿÿÿÿÿþÿýÿýÿþÿþÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿüÿýÿÿÿýÿýÿûÿúÿüÿþÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿþÿþÿþÿÿÿÿÿüÿýÿÿÿþÿýÿýÿÿÿÿÿÿÿþÿüÿùÿûÿþÿÿÿÿÿÿÿÿÿþÿýÿüÿþÿÿÿÿÿþÿÿÿþÿþÿýÿýÿþÿÿÿþÿÿÿÿÿþÿÿÿýÿýÿýÿýÿýÿýÿþÿÿÿÿÿýÿþÿýÿûÿþÿÿÿýÿþÿüÿýÿÿÿÿÿþÿþÿÿÿýÿúÿüÿÿÿþÿþÿýÿÿÿþÿÿÿþÿþÿþÿÿÿÿÿþÿüÿýÿüÿúÿûÿýÿÿÿþÿýÿÿÿþÿþÿüÿýÿÿÿþÿýÿþÿýÿÿÿþÿþÿÿÿþÿþÿþÿþÿûÿýÿÿÿÿÿþÿýÿýÿÿÿÿÿþÿýÿÿÿþÿÿÿþÿþÿýÿÿÿþÿÿÿÿÿÿÿýÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿýÿýÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüÿûÿüÿýÿýÿþÿÿÿýÿýÿÿÿýÿÿÿÿÿþÿÿÿþÿýÿþÿþÿþÿþÿüÿûÿÿÿþÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿýÿþÿÿÿÿÿÿÿýÿÿÿÿÿþÿþÿþÿþÿÿÿþÿýÿýÿÿÿüÿüÿþÿÿÿüÿÿÿÿÿÿÿÿÿþÿÿÿþÿüÿûÿþÿÿÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿþÿýÿÿÿþÿþÿþÿþÿÿÿþÿýÿÿÿÿÿþÿþÿþÿýÿþÿýÿþÿþÿýÿþÿÿÿÿÿþÿýÿÿÿþÿüÿýÿýÿýÿþÿûÿûÿûÿüÿþÿÿÿþÿþÿþÿþÿÿÿÿÿýÿþÿýÿýÿýÿþÿÿÿýÿüÿÿÿÿÿÿÿÿÿýÿüÿüÿÿÿýÿüÿþÿþÿþÿþÿþÿþÿþÿýÿþÿþÿÿÿþÿýÿÿÿÿÿýÿÿÿþÿýÿþÿýÿýÿÿÿýÿþÿþÿýÿÿÿþÿÿÿþÿûÿûÿþÿýÿýÿÿÿÿÿýÿþÿÿÿýÿüÿýÿýÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿþÿûÿýÿÿÿÿÿÿÿÿÿþÿþÿýÿÿÿþÿüÿüÿúÿüÿÿÿÿÿþÿþÿþÿþÿþÿþÿþÿþÿüÿÿÿþÿþÿþÿþÿûÿýÿÿÿÿÿþÿûÿüÿýÿþÿþÿÿÿþÿþÿþÿýÿýÿþÿýÿÿÿÿÿÿÿþÿþÿýÿýÿþÿýÿýÿýÿÿÿþÿþÿýÿÿÿýÿþÿýÿÿÿÿÿþÿüÿýÿüÿýÿÿÿüÿùÿúÿþÿÿÿþÿüÿüÿÿÿÿÿþÿÿÿÿÿÿÿÿÿüÿüÿüÿýÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿýÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿþÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿúÿûÿþÿÿÿÿÿüÿýÿÿÿþÿüÿýÿÿÿþÿþÿÿÿþÿþÿþÿýÿÿÿþÿþÿþÿýÿÿÿÿÿüÿýÿÿÿþÿÿÿþÿýÿüÿÿÿÿÿþÿÿÿþÿýÿþÿÿÿýÿýÿþÿýÿûÿýÿþÿÿÿþÿþÿÿÿýÿýÿýÿþÿÿÿþÿýÿÿÿÿÿÿÿþÿþÿýÿþÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿþÿüÿýÿÿÿÿÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿþÿþÿýÿúÿýÿþÿþÿÿÿÿÿÿÿÿÿûÿúÿýÿýÿûÿûÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿüÿýÿÿÿþÿûÿûÿýÿüÿýÿÿÿÿÿÿÿÿÿýÿýÿûÿýÿûÿþÿÿÿüÿþÿÿÿýÿûÿûÿýÿýÿÿÿÿÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿþÿýÿüÿýÿÿÿþÿÿÿÿÿÿÿþÿÿÿþÿüÿýÿþÿþÿÿÿÿÿýÿÿÿÿÿÿÿÿÿþÿûÿüÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿûÿûÿüÿþÿÿÿüÿÿÿþÿüÿþÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿüÿþÿþÿýÿýÿýÿýÿÿÿÿÿþÿÿÿÿÿÿÿýÿþÿÿÿÿÿýÿÿÿýÿÿÿýÿþÿþÿýÿþÿýÿüÿýÿýÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿýÿüÿþÿþÿÿÿÿÿüÿþÿýÿÿÿÿÿÿÿþÿýÿÿÿþÿÿÿÿÿÿÿüÿûÿþÿÿÿÿÿüÿþÿÿÿýÿüÿþÿÿÿþÿÿÿþÿÿÿüÿüÿýÿþÿþÿýÿýÿýÿÿÿþÿÿÿþÿüÿúÿûÿÿÿÿÿÿÿÿÿÿÿüÿýÿýÿûÿûÿûÿûÿýÿþÿþÿýÿÿÿþÿüÿþÿþÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿüÿýÿÿÿþÿüÿýÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿüÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿüÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿýÿüÿþÿþÿýÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿÿÿÿÿþÿþÿþÿÿÿÿÿþÿüÿþÿÿÿþÿþÿÿÿÿÿþÿýÿýÿþÿþÿÿÿüÿüÿûÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿûÿüÿýÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿýÿþÿþÿüÿþÿÿÿþÿþÿüÿüÿÿÿÿÿþÿÿÿÿÿÿÿþÿüÿþÿýÿýÿþÿýÿüÿÿÿþÿÿÿýÿþÿýÿþÿÿÿýÿüÿþÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿÿÿÿÿýÿûÿúÿþÿþÿÿÿþÿÿÿýÿüÿþÿþÿÿÿüÿþÿþÿÿÿýÿýÿþÿÿÿþÿüÿüÿûÿýÿÿÿÿÿÿÿýÿÿÿþÿüÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿýÿýÿýÿýÿÿÿÿÿÿÿÿÿÿÿýÿþÿýÿþÿýÿÿÿÿÿÿÿþÿýÿüÿýÿÿÿÿÿþÿýÿýÿüÿüÿýÿÿÿþÿÿÿÿÿýÿüÿûÿüÿÿÿÿÿÿÿþÿýÿûÿùÿûÿýÿÿÿþÿþÿÿÿþÿýÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿüÿüÿþÿþÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿýÿýÿýÿýÿþÿþÿüÿþÿÿÿÿÿÿÿÿÿþÿþÿüÿýÿÿÿÿÿþÿýÿüÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿýÿÿÿÿÿþÿþÿýÿüÿþÿÿÿýÿÿÿýÿÿÿýÿþÿÿÿþÿþÿÿÿÿÿýÿýÿýÿþÿÿÿÿÿÿÿþÿýÿÿÿÿÿüÿýÿþÿÿÿþÿÿÿÿÿÿÿþÿýÿþÿþÿþÿþÿþÿÿÿÿÿüÿüÿÿÿÿÿýÿüÿþÿÿÿþÿÿÿÿÿýÿüÿÿÿýÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿþÿþÿýÿþÿýÿþÿÿÿÿÿÿÿþÿüÿýÿÿÿþÿýÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿýÿþÿþÿþÿÿÿþÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿþÿÿÿþÿÿÿÿÿýÿþÿþÿþÿÿÿÿÿÿÿþÿþÿÿÿþÿþÿþÿüÿüÿýÿÿÿþÿÿÿÿÿÿÿýÿüÿýÿþÿüÿÿÿÿÿÿÿýÿýÿþÿþÿþÿÿÿÿÿþÿÿÿýÿüÿÿÿÿÿþÿÿÿÿÿþÿýÿþÿýÿýÿüÿûÿûÿþÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿýÿþÿÿÿÿÿþÿþÿþÿýÿÿÿÿÿûÿþÿÿÿÿÿýÿýÿþÿÿÿþÿýÿÿÿýÿüÿüÿþÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿýÿýÿÿÿþÿüÿþÿüÿýÿÿÿÿÿþÿþÿüÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿüÿýÿþÿþÿýÿþÿýÿýÿÿÿÿÿÿÿÿÿýÿþÿþÿÿÿýÿÿÿþÿýÿþÿüÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿüÿþÿÿÿÿÿüÿýÿþÿþÿÿÿÿÿýÿüÿþÿþÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿüÿþÿþÿþÿþÿÿÿþÿýÿþÿÿÿýÿúÿûÿüÿÿÿþÿþÿþÿÿÿÿÿýÿüÿüÿþÿþÿþÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿÿÿýÿþÿÿÿýÿýÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿþÿþÿþÿÿÿÿÿÿÿþÿýÿýÿþÿÿÿÿÿþÿÿÿÿÿþÿýÿýÿþÿþÿþÿýÿþÿýÿþÿýÿÿÿþÿþÿþÿþÿüÿýÿÿÿÿÿÿÿÿÿþÿüÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿþÿÿÿþÿþÿþÿþÿþÿÿÿþÿÿÿþÿÿÿýÿþÿýÿüÿÿÿÿÿüÿþÿÿÿþÿþÿþÿûÿûÿýÿÿÿÿÿÿÿÿÿÿÿÿÿüÿþÿþÿÿÿÿÿüÿüÿÿÿýÿýÿÿÿÿÿÿÿÿÿýÿýÿýÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿúÿýÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿþÿÿÿÿÿýÿýÿýÿýÿÿÿþÿÿÿÿÿÿÿÿÿýÿþÿýÿýÿÿÿÿÿþÿýÿþÿýÿÿÿÿÿþÿÿÿþÿýÿýÿÿÿýÿüÿýÿÿÿÿÿÿÿþÿýÿþÿýÿýÿýÿþÿÿÿþÿþÿþÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿüÿúÿüÿÿÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿüÿþÿþÿþÿÿÿþÿþÿþÿÿÿÿÿýÿþÿûÿûÿüÿÿÿÿÿþÿûÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿþÿüÿþÿÿÿÿÿþÿÿÿýÿÿÿþÿþÿüÿüÿÿÿÿÿýÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿÿÿþÿÿÿÿÿÿÿÿÿýÿþÿÿÿþÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿýÿûÿþÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿýÿüÿûÿûÿýÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿþÿþÿýÿþÿþÿþÿþÿþÿþÿÿÿýÿýÿÿÿÿÿüÿÿÿÿÿþÿýÿüÿþÿþÿþÿÿÿýÿÿÿÿÿÿÿþÿÿÿþÿþÿýÿÿÿþÿÿÿþÿþÿÿÿþÿüÿýÿþÿþÿýÿþÿÿÿýÿýÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿþÿÿÿýÿýÿüÿûÿýÿÿÿþÿþÿþÿþÿÿÿþÿýÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿÿÿüÿþÿýÿþÿÿÿþÿþÿþÿþÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿüÿûÿþÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿþÿþÿûÿüÿÿÿÿÿÿÿÿÿÿÿþÿûÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿÿÿþÿþÿýÿýÿÿÿþÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿýÿýÿýÿþÿûÿýÿÿÿÿÿÿÿþÿûÿüÿþÿÿÿÿÿÿÿþÿÿÿÿÿýÿÿÿüÿþÿÿÿÿÿýÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿûÿüÿþÿÿÿÿÿÿÿýÿýÿýÿûÿþÿýÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿüÿýÿûÿýÿÿÿÿÿþÿþÿÿÿþÿýÿþÿþÿýÿÿÿÿÿüÿúÿûÿÿÿþÿûÿþÿÿÿÿÿÿÿÿÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüÿûÿþÿÿÿÿÿÿÿÿÿýÿýÿÿÿýÿþÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿûÿúÿÿÿÿÿýÿþÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿüÿûÿÿÿÿÿÿÿýÿýÿþÿÿÿþÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿýÿÿÿÿÿýÿüÿþÿþÿÿÿþÿþÿþÿþÿþÿþÿÿÿÿÿýÿüÿÿÿÿÿþÿÿÿþÿýÿýÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿþÿýÿþÿþÿýÿÿÿþÿÿÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿþÿþÿþÿÿÿþÿüÿûÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿþÿýÿýÿüÿýÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿüÿþÿÿÿýÿÿÿÿÿÿÿýÿýÿÿÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿýÿýÿüÿþÿÿÿþÿþÿþÿþÿÿÿÿÿÿÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿýÿþÿþÿýÿüÿýÿþÿþÿþÿþÿÿÿþÿþÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿþÿÿÿÿÿþÿýÿþÿýÿýÿþÿÿÿÿÿýÿûÿýÿþÿÿÿþÿýÿÿÿþÿþÿþÿÿÿýÿüÿþÿýÿüÿýÿÿÿÿÿÿÿþÿüÿüÿÿÿÿÿþÿüÿþÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿþÿÿÿÿÿÿÿýÿüÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿüÿýÿÿÿþÿýÿýÿýÿûÿýÿþÿþÿýÿüÿýÿÿÿþÿþÿþÿýÿÿÿÿÿÿÿþÿüÿúÿûÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüÿüÿüÿþÿýÿúÿúÿûÿýÿÿÿþÿüÿûÿýÿýÿÿÿýÿþÿýÿýÿþÿþÿûÿüÿýÿüÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿûÿþÿþÿÿÿÿÿýÿþÿüÿýÿýÿþÿÿÿÿÿÿÿÿÿûÿýÿýÿüÿýÿþÿýÿþÿÿÿýÿþÿÿÿýÿüÿþÿýÿüÿÿÿþÿÿÿÿÿÿÿûÿüÿÿÿÿÿþÿþÿÿÿýÿüÿþÿÿÿþÿþÿþÿýÿýÿþÿÿÿýÿüÿþÿÿÿýÿüÿýÿýÿýÿýÿÿÿÿÿüÿüÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿþÿýÿÿÿýÿúÿúÿýÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿüÿýÿþÿþÿþÿýÿþÿþÿýÿüÿýÿþÿþÿÿÿþÿýÿÿÿþÿüÿüÿÿÿÿÿÿÿýÿüÿûÿüÿÿÿÿÿþÿþÿþÿþÿþÿÿÿÿÿýÿýÿýÿûÿýÿüÿûÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüÿûÿþÿÿÿÿÿÿÿÿÿýÿþÿþÿÿÿÿÿÿÿýÿûÿûÿüÿýÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿþÿÿÿýÿüÿþÿÿÿþÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿýÿÿÿýÿýÿþÿüÿûÿýÿÿÿþÿþÿÿÿÿÿþÿþÿýÿüÿþÿþÿþÿÿÿþÿýÿÿÿþÿÿÿûÿýÿÿÿÿÿþÿþÿþÿþÿþÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿýÿÿÿÿÿþÿýÿþÿþÿþÿþÿÿÿþÿþÿÿÿÿÿþÿúÿúÿþÿÿÿýÿÿÿÿÿÿÿýÿüÿýÿýÿþÿþÿþÿÿÿÿÿþÿÿÿþÿüÿÿÿþÿþÿþÿÿÿýÿúÿûÿÿÿÿÿýÿþÿþÿÿÿÿÿÿÿþÿÿÿýÿÿÿþÿÿÿÿÿÿÿüÿûÿûÿüÿþÿþÿüÿüÿþÿþÿÿÿÿÿýÿüÿüÿþÿþÿþÿþÿýÿüÿüÿþÿýÿüÿýÿþÿþÿÿÿÿÿýÿýÿÿÿþÿþÿÿÿþÿþÿþÿÿÿÿÿüÿüÿüÿüÿÿÿýÿþÿÿÿÿÿÿÿþÿûÿûÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿþÿÿÿýÿÿÿÿÿþÿýÿüÿýÿþÿÿÿþÿûÿûÿÿÿÿÿÿÿþÿýÿýÿþÿþÿþÿÿÿýÿýÿþÿÿÿþÿýÿþÿþÿýÿýÿþÿþÿÿÿÿÿüÿÿÿÿÿþÿýÿþÿüÿüÿþÿÿÿþÿþÿþÿþÿþÿüÿúÿüÿÿÿüÿýÿÿÿþÿýÿýÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿþÿþÿýÿûÿýÿÿÿþÿýÿþÿþÿýÿýÿýÿýÿþÿÿÿýÿÿÿÿÿýÿþÿÿÿþÿýÿýÿÿÿÿÿýÿûÿýÿûÿûÿýÿýÿýÿýÿýÿÿÿþÿþÿýÿüÿýÿþÿÿÿþÿüÿýÿÿÿþÿÿÿþÿüÿþÿÿÿûÿýÿÿÿÿÿýÿÿÿÿÿþÿÿÿÿÿüÿüÿüÿÿÿÿÿÿÿþÿÿÿüÿûÿþÿþÿýÿÿÿþÿýÿÿÿýÿýÿþÿýÿýÿýÿÿÿÿÿþÿüÿüÿÿÿýÿûÿþÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿþÿûÿüÿþÿþÿþÿÿÿþÿþÿþÿüÿýÿþÿýÿûÿüÿþÿÿÿÿÿüÿýÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿûÿýÿÿÿÿÿþÿýÿþÿþÿÿÿýÿüÿýÿýÿüÿÿÿþÿÿÿþÿÿÿÿÿýÿüÿþÿÿÿÿÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿýÿüÿüÿþÿýÿþÿþÿýÿüÿþÿÿÿýÿýÿûÿþÿþÿüÿýÿýÿþÿþÿÿÿÿÿþÿüÿýÿÿÿÿÿþÿüÿþÿÿÿüÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿÿÿýÿüÿÿÿÿÿýÿüÿþÿÿÿýÿüÿÿÿýÿÿÿüÿûÿþÿÿÿÿÿýÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿüÿýÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿþÿÿÿýÿÿÿÿÿþÿýÿÿÿÿÿÿÿüÿûÿýÿþÿþÿþÿþÿþÿþÿýÿüÿÿÿþÿûÿþÿþÿþÿýÿÿÿÿÿüÿÿÿÿÿþÿþÿÿÿýÿüÿýÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿÿÿÿÿÿÿþÿþÿþÿÿÿýÿþÿÿÿÿÿýÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿýÿþÿÿÿýÿþÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿþÿýÿüÿûÿûÿýÿÿÿÿÿþÿýÿýÿüÿýÿÿÿÿÿþÿþÿþÿþÿþÿþÿýÿýÿþÿýÿüÿþÿÿÿÿÿýÿýÿþÿÿÿüÿýÿÿÿýÿüÿýÿÿÿþÿþÿÿÿþÿÿÿÿÿÿÿýÿüÿþÿÿÿÿÿÿÿþÿþÿÿÿþÿûÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿüÿüÿÿÿÿÿûÿüÿýÿþÿþÿýÿÿÿþÿÿÿýÿþÿÿÿÿÿýÿýÿýÿüÿýÿþÿýÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿýÿýÿÿÿüÿýÿþÿýÿýÿýÿýÿÿÿýÿýÿýÿüÿÿÿÿÿþÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿýÿÿÿþÿûÿùÿýÿÿÿÿÿÿÿÿÿýÿüÿþÿþÿþÿÿÿÿÿÿÿþÿýÿþÿüÿýÿþÿþÿÿÿþÿþÿüÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿýÿýÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿýÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿþÿÿÿÿÿýÿþÿÿÿýÿÿÿÿÿýÿýÿþÿÿÿÿÿþÿýÿÿÿÿÿþÿþÿþÿþÿÿÿýÿþÿÿÿÿÿþÿÿÿÿÿýÿüÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿþÿÿÿýÿþÿÿÿÿÿÿÿýÿþÿýÿüÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿÿÿÿÿýÿþÿþÿþÿüÿþÿþÿüÿüÿþÿþÿþÿýÿÿÿÿÿýÿÿÿÿÿüÿýÿþÿÿÿþÿþÿÿÿÿÿüÿýÿþÿÿÿÿÿýÿþÿþÿÿÿÿÿþÿþÿüÿýÿþÿÿÿþÿÿÿÿÿÿÿüÿýÿÿÿÿÿþÿþÿýÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿýÿûÿûÿþÿÿÿýÿþÿþÿþÿþÿýÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿþÿýÿþÿþÿÿÿÿÿÿÿþÿýÿþÿþÿÿÿÿÿþÿþÿþÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿýÿüÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿüÿýÿÿÿþÿüÿúÿûÿþÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿýÿýÿþÿÿÿÿÿÿÿþÿýÿýÿýÿÿÿÿÿþÿÿÿÿÿÿÿýÿþÿÿÿÿÿýÿýÿýÿþÿÿÿÿÿÿÿýÿüÿûÿüÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿüÿûÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿÿÿþÿÿÿþÿýÿüÿÿÿþÿÿÿÿÿþÿþÿýÿüÿüÿþÿýÿüÿþÿýÿþÿûÿþÿýÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿúÿýÿÿÿÿÿþÿýÿþÿÿÿÿÿþÿþÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿýÿýÿýÿýÿýÿüÿýÿþÿýÿþÿýÿÿÿþÿüÿýÿÿÿÿÿþÿþÿþÿÿÿþÿÿÿÿÿþÿÿÿüÿýÿûÿüÿýÿÿÿÿÿþÿÿÿÿÿüÿþÿÿÿüÿýÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿýÿÿÿþÿýÿÿÿþÿþÿýÿþÿþÿþÿýÿÿÿÿÿþÿþÿÿÿÿÿýÿýÿüÿþÿÿÿþÿýÿüÿþÿþÿýÿüÿüÿÿÿÿÿÿÿýÿþÿûÿûÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿûÿûÿþÿþÿþÿþÿýÿüÿüÿÿÿþÿþÿÿÿþÿÿÿþÿüÿüÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿÿÿÿÿüÿýÿþÿþÿÿÿÿÿþÿþÿþÿûÿûÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿþÿÿÿÿÿþÿþÿþÿûÿúÿüÿþÿýÿûÿûÿýÿþÿÿÿÿÿÿÿüÿýÿÿÿÿÿþÿþÿÿÿþÿÿÿþÿýÿþÿÿÿþÿýÿþÿÿÿþÿþÿþÿþÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿþÿþÿþÿÿÿþÿþÿüÿüÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿþÿûÿýÿþÿþÿþÿþÿþÿþÿÿÿÿÿüÿüÿýÿþÿýÿþÿÿÿÿÿÿÿþÿþÿüÿüÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿþÿýÿÿÿÿÿþÿüÿýÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿþÿþÿýÿþÿüÿþÿþÿþÿÿÿþÿÿÿþÿÿÿýÿþÿÿÿÿÿþÿþÿýÿüÿþÿýÿýÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿýÿýÿÿÿÿÿÿÿüÿýÿÿÿÿÿþÿÿÿýÿþÿÿÿþÿþÿÿÿÿÿüÿýÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüÿüÿÿÿþÿþÿÿÿþÿþÿÿÿþÿýÿÿÿÿÿþÿþÿÿÿüÿüÿÿÿþÿÿÿÿÿýÿÿÿþÿÿÿÿÿÿÿýÿÿÿÿÿýÿýÿýÿÿÿýÿýÿþÿÿÿÿÿþÿÿÿýÿýÿýÿÿÿýÿüÿýÿþÿþÿþÿýÿüÿþÿÿÿýÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿþÿÿÿþÿÿÿÿÿÿÿÿÿýÿüÿÿÿýÿýÿþÿÿÿÿÿþÿýÿýÿûÿýÿÿÿÿÿÿÿÿÿÿÿþÿüÿüÿÿÿÿÿýÿüÿüÿüÿÿÿÿÿüÿþÿýÿýÿýÿýÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿýÿûÿüÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿþÿÿÿþÿüÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿýÿýÿÿÿÿÿýÿýÿýÿÿÿþÿþÿÿÿþÿþÿÿÿÿÿþÿþÿýÿþÿÿÿýÿýÿÿÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿþÿýÿÿÿþÿüÿþÿþÿÿÿþÿþÿüÿüÿÿÿþÿþÿÿÿÿÿÿÿþÿüÿþÿþÿþÿýÿýÿþÿýÿþÿþÿÿÿÿÿÿÿýÿüÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿþÿþÿÿÿþÿþÿþÿþÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿüÿûÿüÿþÿÿÿþÿÿÿÿÿþÿþÿýÿüÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿþÿþÿþÿþÿþÿþÿþÿÿÿþÿþÿÿÿýÿüÿþÿüÿüÿûÿýÿýÿþÿþÿýÿþÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿüÿüÿÿÿÿÿüÿüÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿûÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿÿÿÿÿýÿýÿûÿüÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿþÿýÿþÿýÿýÿþÿÿÿÿÿþÿûÿýÿÿÿþÿþÿÿÿÿÿþÿüÿþÿÿÿýÿþÿþÿþÿÿÿÿÿüÿúÿüÿýÿüÿþÿÿÿþÿüÿùÿûÿþÿÿÿþÿÿÿÿÿýÿýÿÿÿþÿûÿþÿÿÿþÿþÿÿÿþÿþÿýÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿþÿþÿþÿþÿýÿüÿüÿþÿÿÿÿÿüÿýÿþÿÿÿûÿþÿÿÿýÿýÿþÿÿÿþÿüÿûÿýÿÿÿÿÿÿÿþÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿüÿùÿüÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿýÿÿÿÿÿþÿÿÿÿÿýÿüÿýÿÿÿþÿýÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿýÿüÿÿÿÿÿÿÿþÿÿÿÿÿüÿûÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿþÿÿÿÿÿþÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿûÿüÿþÿÿÿþÿþÿýÿýÿÿÿþÿüÿþÿþÿýÿýÿþÿÿÿÿÿþÿûÿýÿþÿþÿþÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿýÿüÿþÿÿÿýÿþÿÿÿÿÿÿÿþÿÿÿþÿþÿýÿüÿüÿþÿþÿýÿûÿýÿýÿýÿýÿÿÿÿÿÿÿÿÿýÿüÿþÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿÿÿþÿÿÿýÿýÿþÿÿÿÿÿüÿûÿþÿüÿüÿÿÿÿÿþÿýÿÿÿÿÿýÿþÿþÿÿÿþÿÿÿÿÿýÿþÿþÿýÿþÿÿÿüÿüÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿýÿþÿüÿýÿüÿüÿþÿþÿüÿüÿûÿüÿÿÿÿÿýÿûÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿýÿþÿÿÿþÿÿÿýÿúÿûÿþÿýÿÿÿÿÿþÿþÿýÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿýÿýÿþÿÿÿÿÿþÿÿÿÿÿýÿýÿþÿþÿÿÿÿÿýÿÿÿþÿÿÿÿÿÿÿþÿÿÿýÿýÿþÿÿÿÿÿþÿþÿýÿüÿÿÿÿÿüÿýÿÿÿÿÿÿÿýÿýÿýÿþÿýÿÿÿýÿýÿþÿÿÿÿÿýÿÿÿýÿÿÿÿÿþÿýÿýÿýÿþÿþÿýÿþÿÿÿýÿþÿþÿþÿÿÿþÿüÿûÿýÿÿÿþÿýÿÿÿÿÿÿÿþÿýÿþÿþÿþÿÿÿþÿÿÿþÿþÿþÿüÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿþÿÿÿýÿýÿýÿþÿþÿÿÿüÿýÿþÿýÿþÿÿÿÿÿþÿýÿþÿüÿÿÿÿÿýÿüÿûÿýÿþÿÿÿþÿþÿýÿýÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿûÿûÿþÿüÿýÿýÿþÿþÿýÿýÿÿÿÿÿÿÿýÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿýÿþÿþÿÿÿÿÿýÿÿÿþÿýÿüÿýÿÿÿüÿýÿþÿýÿþÿÿÿþÿÿÿþÿÿÿÿÿþÿüÿýÿýÿþÿÿÿÿÿÿÿýÿþÿýÿýÿþÿÿÿþÿýÿüÿüÿÿÿÿÿüÿþÿýÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿûÿüÿýÿýÿýÿþÿúÿüÿþÿþÿÿÿþÿþÿÿÿýÿýÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿÿÿþÿþÿüÿýÿÿÿþÿüÿýÿýÿþÿþÿÿÿÿÿþÿýÿÿÿÿÿüÿþÿþÿýÿüÿþÿþÿÿÿüÿþÿþÿýÿüÿÿÿÿÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿýÿþÿþÿþÿÿÿþÿÿÿÿÿúÿüÿÿÿýÿüÿþÿþÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿþÿÿÿÿÿüÿþÿÿÿþÿþÿÿÿýÿþÿÿÿýÿýÿüÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿýÿÿÿÿÿüÿÿÿýÿûÿþÿûÿýÿÿÿÿÿþÿÿÿÿÿüÿýÿÿÿþÿþÿÿÿÿÿÿÿþÿýÿÿÿþÿýÿÿÿþÿýÿÿÿþÿýÿþÿþÿýÿþÿÿÿþÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿþÿÿÿÿÿýÿüÿÿÿÿÿþÿýÿüÿÿÿþÿþÿþÿÿÿýÿýÿþÿÿÿýÿûÿþÿÿÿüÿüÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿýÿÿÿÿÿüÿüÿþÿÿÿþÿüÿýÿÿÿþÿýÿýÿþÿÿÿþÿÿÿÿÿýÿýÿþÿýÿûÿüÿþÿÿÿÿÿþÿÿÿþÿÿÿþÿÿÿÿÿþÿþÿÿÿýÿþÿýÿþÿÿÿýÿþÿüÿþÿýÿûÿýÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿýÿýÿþÿþÿþÿÿÿÿÿÿÿþÿþÿþÿÿÿüÿüÿýÿýÿÿÿüÿýÿýÿûÿýÿÿÿýÿýÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿþÿÿÿýÿþÿÿÿþÿþÿýÿúÿûÿÿÿÿÿÿÿÿÿþÿÿÿýÿýÿüÿþÿÿÿþÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿþÿýÿþÿþÿýÿüÿÿÿÿÿþÿýÿýÿÿÿþÿþÿþÿþÿþÿýÿýÿþÿþÿüÿýÿÿÿþÿüÿýÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿüÿþÿÿÿÿÿýÿÿÿÿÿÿÿþÿýÿÿÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿýÿþÿýÿüÿýÿþÿþÿþÿþÿþÿþÿÿÿÿÿüÿÿÿÿÿþÿþÿþÿþÿþÿþÿþÿÿÿÿÿýÿýÿýÿþÿÿÿýÿÿÿþÿþÿþÿþÿüÿþÿÿÿÿÿþÿÿÿÿÿûÿúÿýÿÿÿþÿýÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿÿÿÿÿþÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿþÿþÿÿÿÿÿþÿýÿþÿÿÿþÿþÿÿÿþÿÿÿýÿùÿúÿüÿþÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿüÿÿÿÿÿüÿþÿüÿüÿÿÿþÿýÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿþÿÿÿþÿÿÿþÿýÿþÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿýÿÿÿÿÿÿÿÿÿþÿüÿûÿûÿûÿúÿüÿþÿýÿüÿþÿÿÿýÿüÿûÿüÿÿÿÿÿÿÿýÿÿÿýÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿÿÿÿÿþÿÿÿýÿûÿûÿüÿýÿþÿþÿûÿúÿþÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿüÿÿÿýÿýÿýÿþÿÿÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿÿÿþÿüÿýÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿþÿþÿÿÿýÿüÿþÿÿÿýÿüÿÿÿÿÿýÿþÿÿÿþÿüÿýÿþÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿýÿüÿüÿüÿüÿÿÿÿÿýÿÿÿÿÿþÿþÿþÿýÿûÿþÿÿÿþÿÿÿþÿýÿþÿýÿÿÿþÿÿÿÿÿýÿüÿüÿþÿÿÿýÿýÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿýÿüÿþÿÿÿÿÿýÿüÿûÿýÿÿÿÿÿþÿýÿÿÿÿÿþÿþÿþÿþÿþÿþÿþÿþÿþÿÿÿþÿüÿþÿþÿüÿüÿÿÿÿÿþÿþÿþÿÿÿþÿýÿüÿýÿÿÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿþÿýÿÿÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿþÿüÿûÿúÿûÿýÿþÿÿÿþÿýÿÿÿÿÿþÿÿÿþÿüÿýÿÿÿýÿýÿþÿþÿÿÿþÿþÿþÿÿÿþÿýÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿýÿûÿùÿúÿþÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿÿÿÿÿÿÿüÿþÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿþÿÿÿÿÿýÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿþÿüÿþÿÿÿþÿþÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿýÿÿÿÿÿüÿýÿÿÿþÿÿÿÿÿþÿþÿÿÿýÿþÿýÿÿÿÿÿÿÿýÿüÿÿÿÿÿýÿÿÿþÿþÿþÿÿÿþÿüÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿþÿüÿýÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿüÿýÿþÿÿÿÿÿýÿýÿýÿýÿýÿýÿüÿÿÿÿÿþÿÿÿÿÿÿÿþÿýÿýÿþÿýÿÿÿÿÿþÿÿÿþÿüÿûÿýÿþÿþÿþÿþÿýÿýÿýÿýÿÿÿÿÿþÿþÿÿÿýÿþÿýÿûÿüÿÿÿþÿýÿþÿÿÿþÿýÿþÿþÿüÿþÿýÿÿÿÿÿþÿÿÿþÿÿÿýÿüÿýÿþÿþÿüÿþÿýÿüÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿüÿýÿþÿÿÿÿÿÿÿþÿÿÿþÿýÿÿÿþÿýÿÿÿÿÿþÿÿÿþÿûÿùÿûÿÿÿÿÿüÿüÿÿÿþÿÿÿÿÿÿÿüÿüÿüÿüÿüÿýÿýÿûÿüÿþÿýÿýÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿüÿýÿÿÿþÿþÿÿÿÿÿþÿþÿÿÿþÿþÿþÿýÿüÿýÿüÿûÿþÿýÿÿÿþÿþÿþÿýÿüÿþÿþÿþÿÿÿÿÿýÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿüÿûÿýÿÿÿÿÿþÿÿÿÿÿÿÿÿÿýÿüÿýÿþÿþÿÿÿþÿÿÿüÿýÿýÿýÿÿÿýÿýÿþÿÿÿÿÿÿÿüÿûÿþÿÿÿÿÿþÿÿÿÿÿþÿþÿýÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿýÿþÿþÿþÿþÿÿÿþÿþÿþÿÿÿüÿüÿÿÿþÿÿÿÿÿþÿþÿþÿýÿýÿþÿþÿÿÿþÿýÿüÿüÿûÿýÿþÿþÿÿÿýÿþÿÿÿÿÿþÿþÿÿÿÿÿÿÿþÿüÿüÿþÿþÿÿÿþÿþÿÿÿÿÿþÿÿÿþÿþÿýÿþÿÿÿþÿþÿüÿþÿþÿÿÿÿÿýÿÿÿÿÿüÿýÿÿÿþÿþÿýÿýÿüÿýÿÿÿþÿýÿþÿýÿÿÿÿÿþÿýÿýÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿýÿþÿÿÿÿÿÿÿýÿýÿÿÿÿÿþÿÿÿÿÿüÿüÿýÿÿÿÿÿþÿÿÿÿÿÿÿþÿýÿÿÿüÿûÿüÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿüÿûÿüÿÿÿÿÿÿÿýÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿþÿýÿþÿýÿÿÿÿÿþÿÿÿþÿüÿÿÿþÿÿÿýÿÿÿÿÿÿÿþÿüÿüÿüÿþÿýÿþÿýÿüÿüÿÿÿÿÿþÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿþÿÿÿþÿûÿüÿÿÿÿÿýÿÿÿýÿþÿÿÿÿÿýÿþÿýÿþÿÿÿþÿþÿþÿýÿûÿýÿÿÿþÿÿÿÿÿýÿýÿþÿýÿÿÿþÿüÿýÿÿÿþÿüÿýÿÿÿÿÿÿÿýÿüÿþÿþÿþÿþÿþÿýÿúÿýÿÿÿÿÿþÿÿÿÿÿüÿüÿþÿýÿýÿÿÿÿÿýÿÿÿþÿþÿþÿþÿþÿýÿûÿúÿüÿþÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿÿÿþÿüÿýÿþÿüÿýÿþÿþÿþÿþÿþÿÿÿüÿüÿÿÿýÿýÿÿÿÿÿþÿüÿûÿûÿÿÿÿÿÿÿþÿýÿþÿÿÿþÿýÿýÿÿÿþÿüÿÿÿÿÿýÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿýÿýÿýÿþÿþÿþÿþÿÿÿÿÿýÿÿÿÿÿþÿþÿüÿþÿÿÿþÿþÿþÿþÿýÿþÿþÿþÿüÿýÿÿÿþÿÿÿþÿþÿþÿþÿýÿýÿþÿÿÿþÿþÿþÿþÿÿÿýÿýÿýÿÿÿÿÿÿÿþÿþÿþÿÿÿþÿþÿüÿþÿþÿþÿÿÿþÿþÿÿÿÿÿþÿýÿüÿÿÿÿÿüÿþÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿþÿþÿþÿþÿÿÿýÿüÿþÿþÿþÿÿÿÿÿüÿýÿÿÿÿÿÿÿÿÿÿÿÿÿýÿüÿûÿüÿÿÿÿÿÿÿýÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿýÿþÿÿÿýÿýÿÿÿþÿýÿþÿýÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿýÿþÿýÿüÿÿÿÿÿþÿþÿþÿþÿþÿÿÿÿÿýÿüÿþÿÿÿýÿþÿÿÿýÿýÿüÿûÿþÿÿÿýÿýÿÿÿþÿþÿþÿþÿþÿÿÿþÿÿÿÿÿüÿúÿýÿÿÿýÿüÿþÿÿÿýÿýÿÿÿÿÿþÿýÿÿÿþÿþÿþÿýÿþÿÿÿþÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿýÿÿÿÿÿýÿÿÿÿÿÿÿýÿýÿýÿÿÿþÿþÿÿÿÿÿþÿýÿþÿÿÿþÿÿÿÿÿýÿþÿüÿúÿýÿýÿüÿýÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿýÿýÿÿÿþÿýÿÿÿÿÿÿÿüÿýÿÿÿþÿýÿÿÿüÿûÿþÿýÿýÿÿÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿþÿÿÿþÿÿÿþÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿþÿþÿÿÿþÿþÿýÿþÿÿÿýÿüÿÿÿþÿúÿûÿÿÿÿÿþÿüÿûÿýÿÿÿÿÿÿÿÿÿýÿüÿþÿÿÿÿÿþÿþÿýÿþÿþÿüÿýÿýÿüÿþÿÿÿÿÿûÿýÿþÿþÿÿÿþÿýÿÿÿÿÿýÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿþÿÿÿÿÿþÿýÿÿÿÿÿÿÿþÿýÿüÿÿÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿýÿÿÿÿÿüÿûÿþÿÿÿÿÿþÿýÿÿÿýÿÿÿÿÿýÿÿÿÿÿÿÿÿÿýÿþÿÿÿþÿÿÿýÿþÿÿÿÿÿÿÿÿÿþÿýÿüÿýÿþÿÿÿýÿýÿÿÿÿÿÿÿÿÿþÿýÿýÿüÿþÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿþÿþÿûÿýÿÿÿÿÿþÿÿÿÿÿÿÿÿÿþÿúÿüÿÿÿþÿýÿÿÿÿÿþÿÿÿÿÿÿÿýÿÿÿÿÿüÿýÿÿÿþÿÿÿþÿýÿÿÿÿÿüÿÿÿÿÿýÿþÿþÿÿÿÿÿÿÿÿÿþÿÿÿýÿýÿÿÿþÿÿÿÿÿýÿÿÿþÿýÿþÿþÿþÿüÿþÿþÿýÿýÿýÿÿÿýÿýÿÿÿýÿþÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿÿÿþÿþÿýÿüÿüÿüÿýÿýÿýÿþÿþÿÿÿÿÿÿÿÿÿþÿýÿÿÿþÿýÿÿÿÿÿÿÿÿÿÿÿýÿþÿþÿÿÿÿÿþÿÿÿÿÿýÿþÿýÿýÿýÿýÿþÿÿÿÿÿýÿþÿýÿÿÿýÿýÿþÿÿÿýÿþÿÿÿÿÿþÿþÿýÿýÿÿÿÿÿþÿÿÿþÿþÿÿÿþÿþÿýÿüÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿýÿþÿÿÿýÿýÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿüÿÿÿþÿþÿýÿüÿýÿÿÿþÿýÿüÿüÿýÿüÿÿÿþÿþÿýÿþÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿüÿûÿýÿÿÿþÿýÿÿÿÿÿÿÿÿÿýÿþÿþÿÿÿÿÿþÿÿÿþÿýÿÿÿÿÿÿÿÿÿýÿþÿýÿÿÿÿÿÿÿÿÿýÿýÿÿÿÿÿÿÿÿÿþÿýÿþÿþÿþÿþÿýÿþÿþÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿýÿüÿÿÿÿÿÿÿüÿüÿüÿûÿûÿþÿÿÿÿÿÿÿüÿûÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿýÿþÿþÿÿÿýÿüÿþÿþÿÿÿþÿýÿüÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿýÿÿÿÿÿþÿÿÿÿÿýÿþÿÿÿüÿüÿþÿÿÿþÿüÿûÿýÿÿÿýÿýÿýÿÿÿþÿÿÿþÿýÿþÿÿÿÿÿÿÿþÿýÿÿÿÿÿþÿýÿÿÿÿÿþÿÿÿþÿþÿþÿýÿþÿþÿÿÿÿÿýÿÿÿÿÿÿÿÿÿþÿýÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿüÿÿÿÿÿûÿýÿÿÿþÿýÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿýÿÿÿþÿþÿÿÿýÿüÿýÿÿÿÿÿýÿþÿÿÿÿÿÿÿýÿýÿýÿÿÿÿÿþÿýÿüÿûÿÿÿþÿþÿþÿþÿþÿÿÿþÿÿÿþÿþÿÿÿþÿýÿýÿüÿþÿýÿÿÿÿÿþÿÿÿÿÿÿÿýÿýÿþÿÿÿÿÿþÿýÿüÿüÿýÿÿÿÿÿÿÿÿÿþÿþÿþÿþÿþÿÿÿÿÿÿÿÿÿþÿþÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿÿÿýÿüÿýÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿýÿüÿüÿüÿÿÿþÿüÿýÿþÿüÿùÿøÿûÿÿÿÿÿÿÿÿÿÿÿþÿþÿýÿþÿþÿþÿüÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿüÿûÿýÿþÿýÿÿÿÿÿþÿÿÿÿÿÿÿÿÿýÿûÿýÿÿÿÿÿþÿýÿÿÿÿÿÿÿÿÿýÿþÿýÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿýÿýÿûÿþÿýÿÿÿÿÿüÿûÿýÿÿÿÿÿþÿþÿýÿÿÿþÿýÿýÿþÿûÿüÿÿÿýÿþÿÿÿÿÿÿÿüÿüÿÿÿÿÿÿÿýÿûÿþÿÿÿÿÿÿÿÿÿþÿþÿþÿÿÿÿÿÿÿýÿþÿþÿÿÿÿÿÿÿþÿÿÿÿÿýÿûÿüÿþÿýÿüÿþÿþÿÿÿÿÿþÿÿÿþÿÿÿþÿþÿþÿþÿÿÿÿÿþÿýÿþÿÿÿÿÿþÿÿÿÿÿýÿÿÿþÿüÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿþÿüÿÿÿÿÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿýÿþÿûÿüÿÿÿÿÿþÿþÿþÿÿÿþÿÿÿÿÿÿÿþÿþÿÿÿÿÿÿÿÿÿýÿþÿüÿûÿýÿÿÿÿÿþÿþÿÿÿþÿÿÿÿÿþÿüÿýÿþÿÿÿÿÿÿÿÿÿÿÿýÿúÿûÿÿÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿüÿþÿÿÿþÿÿÿÿÿþÿýÿüÿýÿþÿþÿþÿÿÿþÿÿÿþÿþÿÿÿþÿýÿýÿÿÿþÿüÿÿÿþÿÿÿþÿýÿÿÿÿÿþÿÿÿÿÿþÿüÿÿÿÿÿüÿüÿüÿþÿüÿþÿþÿýÿúÿüÿÿÿüÿûÿþÿÿÿÿÿýÿþÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿÿÿÿÿþÿýÿüÿüÿÿÿýÿûÿûÿüÿýÿÿÿÿÿÿÿÿÿÿÿýÿÿÿÿÿÿÿÿÿþÿþÿÿÿþÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿþÿÿÿþÿÿÿþÿÿÿþÿüÿþÿþÿüÿüÿüÿýÿýÿþÿþÿþÿþÿþÿýÿÿÿüÿüÿÿÿþÿÿÿÿÿÿÿýÿþÿýÿÿÿþÿþÿþÿüÿþÿþÿþÿýÿüÿÿÿÿÿþÿÿÿþÿÿÿþÿÿÿÿÿüÿüÿÿÿþÿÿÿÿÿÿÿÿÿÿÿüÿÿÿÿÿþÿÿÿýÿüÿþÿÿÿýÿþÿýÿüÿþÿþÿþÿÿÿþÿþÿÿÿÿÿþÿÿÿÿÿÿÿÿÿÿÿþÿÿÿþÿþÿÿÿÿÿÿÿýÿúÿûÿþÿÿÿþÿüÿÿÿÿÿýÿüÿýÿÿÿþÿÿÿýÿýÿþÿÿÿÿÿýÿûÿüÿþÿÿÿÿÿÿÿÿÿþÿÿÿÿÿþÿÿÿýÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿýÿÿÿüÿüÿþÿþÿÿÿÿÿýÿýÿÿÿÿÿýÿÿÿýÿþÿþÿÿÿþÿýÿþÿþÿÿÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿýÿýÿÿÿýÿÿÿÿÿýÿþÿÿÿþÿþÿÿÿþÿþÿþÿÿÿþÿÿÿÿÿþÿýÿþÿÿÿýÿýÿüÿþÿÿÿÿÿüÿþÿÿÿýÿýÿþÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿþÿüÿþÿÿÿÿID3 ID3././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/unparseable.alac.m4a0000644000076500000240000001534400000000000020067 0ustar00asampsonstaff ftypM4A M4A mp42isom æmoovlmvhdÍÏáÖÍÏáÖ¬D¬D@îtrak\tkhdÍÏáÖÍÏáÖ¬D@Šmdia mdhdÍÏáÖÍÏáÖ¬D¬DUÄ"hdlrsoun@minfsmhd$dinfdref url stblXstsdHalac¬D$alac( ÿ¬UF¬D stts  D(stsc@stsz #·9ßÂçɬI9Rstco´’ „udta |meta"hdlrmdirappl+ilst#©daydataOct 3, 1995 #freeòfree ìmdat øÇÿ„ÿ€òÆÝ0À-ÇqÞÇ]¿(+¦?ࣸÿ€+F?àø8Â;½øŒa×®óîòŒ8ÀãÑ„D ;ˆÂ; íî@tŒ#º0ë×y€úFÜíµÏ Ç]à7xývïGvï ÛÊ'Pu”¼æ:ïÀGpúÆÜy»~€ðï]à}ãq‡q‡^à7o@ݽÀGpÿ{Œ8R0ˆ„v»~€üÌuÞþîðü¾0Žàþpÿ€¤àþ]Gq{€ðÞÿù{ÿð2ÿ€òÆ×Þ¼¥ÿ]Œ8~q„wð.Á×oÑÊÿIŒ#»w€þGpÿ½Œ8À"ã»Àð Á×xþ©Gp¤aÃþpÿ€rŽàþApÿ€*ŽàþýGvïzïÿdŒ#¸HÇûÆzíúܽÀ uÞ½,:ïÀãðnqú6\ Hèç=æ9Ï@?´bè €ÿ€¤aÀG;Æ.€ÿ€0Ž€”aÀÇ+{€Œ]nð»^€ÆüHs°`s°}£@~C­Ûk¨óŒ uØô£@À&Ç[·À0£ àç`ÎÀÿZŒ/ølbèø bëw€ sglÉ@þ'ºÝ¼€ýG@vì¼bå=v>ñ…ÿ· 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§ü¾ÿ‚‘p././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/unparseable.ape0000644000076500000240000003220400000000000017246 0ustar00asampsonstaffMAC –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¦././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/unparseable.m4a0000644000076500000240000001334600000000000017170 0ustar00asampsonstaff 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#©daydataOct 3, 1995 4freefree(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ƒì\¬ „ô@À\././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/unparseable.mp30000644000076500000240000002260100000000000017200 0ustar00asampsonstaffID3TDRC Oct 3, 1995ÿû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€ªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªªª././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/unparseable.mpc0000644000076500000240000000435400000000000017265 0ustar00asampsonstaffMP+' \@”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}]Ü././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/unparseable.opus0000644000076500000240000002010700000000000017466 0ustar00asampsonstaffOggSte¿/ä\ OpusHeaddD¬OggSte¿/ó®YOpusTags libopus 1.1&ENCODER=opusenc from opus-tools 0.1.2 DATE=Oct 3, 1995OggS€»te¿/dYoá3ÿ@š™›——‘™–•™”“––—“—–—–™•˜š›š™—’”˜™™˜–“–š—“”—•˜——•–øøø™9Ü>ø-ùèE½BSu53œ¬ù€$/bÊ0•Ó”:ê'X*[|äàRˆ‚ûc»} :£$ÖzØúÕì§sD“÷INÀrF›wå¡óß.!^x*iVñº}´üÞð×]ë{¯àpT ÌŽØ2ÃâÝ*©À`þKùÑkd1¤œ%Ý£¡è\ý“ŸèˆŠË|±‹]­(hÎҮ䨒)4¾‰±«ÀFž•[ =Lj„¹nåü{Ì>žor3±@Žlc¬ý7 ìVU«§ÐÔ qx[»Žãt]{ŹbêС‘1ãDt>í¨çרoÖB«¾KÊŸ’qÓAm·Ï]ή¬·1%6ÀÎ ao»¤iâÆ¥e¦¤,]”Ø“ït߉Bs…F¸mFŒyó;&øaß:¶WrÌÛÖÈr‡0–¾ i1U·…ž_-@X)/C’®Î{.j݈Û*ç§‘>‰LÖ`/±Þ!8®ù‡=­—}‰ÅµމćøD®ø#òÂVmR Z›Øà;ÁÄ ¥ì{ŽT>¸vSBû ›¼<û‡­c‹Î>˜+ëðáh¸}«¶´LU$#r¹!Q¦\øaÕ™ãÓØe¦¨X,ÞR.bn²o<“Nïðî\«ãq ˜Jª–ûš˜|®£2ˆÔÝp¼QûahG —L”oEÏ5€qžŽ¸¬')„Ó½¹Å½”Q<5¸"ªöŽ‚t”nÁ;5úç8óü˃fû 3â)ò½‘…´±Ã\³))…òª¹Aà’⪫þi>|5|/9ÇÞBf³Ï1Ž-7/5,ã…øaÕ¬w訿ñÔžüJàrU•íÒÏ’=¯7eŽÜ#þ$zÔž ï¦Ò…7 ¥´^6r•âÊoUâZË ›®X*ÖØêe4^è>òG®íÛßä^à|¨é1¡MìÙu'r]oˆ1H¯Àrü%¸­´ 0æ,3Veªª—xD®?·1­K+,Iq 3YÏw,k¿r ˜eLøaÕ±›ÀàvâÄB Ýœ[Õûüaõ†Pcž»,DY°¡©µKuæ?‡öÛ^kùéÿÏý0<õòFWÒã¤~•ãºx›vø†¦Ä#޽ìq䞦×Ê´Á þvÞÅ þù§Gúî+bGÙ©VeöàIT`ìÜ)‡õäL¦ðø3ÕͼÁɯ3¦Ë÷ÈjŒøaÕÔÁsc0fx‹ë{Šžâ##½òYÿéƒ i„[•~€ é¤×Pz À¿ò6 _ýÊþ4 øaÒÚ§ÁçÌXZ1§gºW·b°úÌÚ³‚]­*ˆß~ÒSä„$“툈œfÏÑ+ˆ3ju[#v›òÝ$XHe\n§§Áj£’»*ß5zŸº™c— ¥[Éêo®„eës¿‘ágɇžÂÝȘ¶¹ ÷ù€²| ¾›|–å»-¨O# b±k0úó¤þ)BøaÕ­]xúë|°òd¡Û‘Ñ‹,–0ä]ZÕšIë­"”1"¬0j;Zj#ÛR-—£§¿7Ê·ãYC½â¶º+Í/8ïùêíÛ/õàæþmíOžùGÏZòå^Zfe ¢5,k¸tÍ…—`\€@ÌòãŒQûgºJìfËž™o=âp,ª° F“H¼NVŽ”ùL<ÃøaÕ°¿ë+…'µ²cÇìùcI]¿}(ñ»ªq6#ghóJðwÖMQj¡q)H6$HæzyXW«8‰9vý¢¸]_YóoDóõ§þm6PÞæ<}WÎ\Oµð%îh‡:㨃.¨Ü"®6Ñs ÑKeG»îvd@9ˆ‚Ӹ󟕞ÕÝ/­øJ|2ÿT=/øaÕ¬Öé÷‡†8ƒ©z #<öœCݸEÈéˆ|Ñ&ökÈÏáT{SXîwÕ¡•´a³äõTÚ-ÚEy S/ó^:9‚@ÜùÑ¥ãM Ù©±ÓÙ•¬°EBFÒ—<°ôx>–2°Ÿ|ó'í¥v„ýWg*uaôR–76AøaÞíÛ¬!ÂÀ‹˜­³ô¿³7­*Šs 0of £´ÄH!Þvæwꑊþp•ÆyE#U Lƒô%í.µG.#ž µIŽ4I(j g^ލþ˜f"~÷Ãoæ +µsW(š—4<ñ~íÞå4ÑfÄviÆ:ƒ§¶LΕZΞ'üÔpÆå.œL1Ý-54UNü] ÎøaÞÌÐ[J2ÉŽ]ïF·ñ@Ü1r\¨ýÓe(lþ¨<¸i t[æ¹Øëÿölg5ÁiF`éq­IÁíÌyP±71àa-Ýï>B\6™&vÎUõ;ú HŽgÒm‹Zrç#±XFÄûí (qËñ\ÿdøaÒf/1@÷qŽ#'•zíûú.ˆB޲%C€‰yG¬òK¯öRfQk*¥ÿ1ͬõsËb°6ûOÃ8á) TP¨pNøeï0!óÆ1îØ™½‚îÙ§Léd˜ÐNâ,Š<19d«Ð"ÅÑlïÔÜó•&aÇËêo1»±ª vJd ÊÁ¢êž|o,/b¼ bl%\;Ï“vÆøaÕÙ)[" áX‡GImìpßÙ?…!ÞÑûðÉBç¡û2<·Pù•Ï?¨$9îNß[œþø°ß¶¹¤êmÐñs¾ <=ƒýú š²=U‰—o»‚zƾäƒe äž[¿¨dÉ‚ÝXšŽp †f á Wð [^fSãú®LxÙ?AâÑ÷6ù,ë`ÕÄ¥{l?@ù#føaß(ùí÷÷cïŸv°TÕf^ªì“Y.ÙXÃFåÛ‘o«ÚÍn„Y)o-ü¿Tÿ¤ˆ6AÎ&c@ŽôϘ*@ñ+D¡-úD}N  ¤<–…)˜þϤ¿z²ÛÍî–±‘ˆ³hp—¢½.¬7’¹Aš7S‰<Ÿq=·=zÒÿaSsZÁ,…:ï’Ð[Q ¬‘â ëøaß&¾³ñ4DP{uömÄ€N—ùÜéÀÆ}œzIX¼¬¼i«ô‡s ¢&éÛqØeX¯˜ >!B¬Qˆ—A‹1ú”GäaÉs¦(a?ÚþUº_Ì 8d~ƒ?û¡dZÝ5Ðæ?Ó@mâopÔDnB`t·Úž;ú yûÎéaŒ?2X"¥§õ™M;lȰ,Æ™kåíøaÕÔÒì”ÁÁ›08n"Oj ÅEõ¶'ؘ¶z=ŽÁr®¯ÝzÆ.HtÒ0Âu/÷}ëŽ<{ÒMú ðioò†é@€“S«öèx8‚+÷À¡uÃ𾊚NJ1õ'¨Jäîb; `Û´<—G$&Ë5¡,þ*ñíë¯3óÿûÖ„ìÖ³3Ëç"ᛟv×ÔÇ\ øaÕ¬ï±Pžªº*›oqÞ6Q [3!¾ÿF@)P{°²áû€Ï‘gÇtþ™ÒsY…rɈ(/?B„ y rÞxËm_ôÞÁè«`–/ïãšZQ?´ e ƒú?FggýòèêæAxß-G&ݦcIkÈçNá7;”&)àFj0'6óP´Ô;*øaÕ›Y@´«Š•æ ž ã†c t¦¥¬/Ó/ÊVàfLª•ÅqkˆIÓy‚êBð+Ð?¨„z§¸C¿®έ(Ú¿äýÅ»S¢¸‰$Ájz{åAà€ŽÒÒmÏw?Ì_v÷;üùA©ÚÜwÃ’ï'CMºÌt…u ðÇæ·áœAÁm‰tåûJw®¤%`­øaÕ«G¥ÄáA0à^.a¶E*èŒ)@çïþ”ÃX˜bù3ËXrDð–)–-6«B\!ž¸Ø¤‘‹Ï1BÝ¡€q¯Rmj>݃0’M„‹âí…{6‚QSñVչè ªëm¤§,ÞúÛLõï±Ã[Âþ ¡‘8}•¦¹¹î€Ž *·wh8;)ƒßÛœf ÝN¡xÓ†2JûøaÕ¬žØé”W"ט“¹0"J­®>òX‹iäã‰U&A3J¶Ÿ?7£ ¸S4âv"£9Ù›úŽª×âñáNn=Í™pÛóÖ;pU| cpŽZå !+ï“‘¦¹šx,V%Þ.:¿ÆÅÈ L,àz1àç·ÌٻѰïÈaOS˜Â«ÆÆCîì¦È¬r4¼àm„ؼøaÕ›b`®éðàÝ÷'³#p' H{™¸Æ^ÙïÃÛq±ßw2z¹ýþø\ÑzÇ&ꓺB"LªkÊó*8ËM1P’çņ›h<Éî»àðA‹hMM<‘ŠMû†ªÃUÄ•¶=¸a-y#¯h×£SÓ š³Sç˜<üªï4xþÙ´“ˆËXP '"¥ðÖjÚ›ãVÅUóŽR–˜”ÿµžøaÓ,{Qwšs¶µq²þcšAºÔ!<ÀE{mݤëý0•OêÐ_QAY§ß' oìÔ˜DßJDvêÍÈÚ„÷ð7mÐ`fŸÍî'ÑúàB‰Aç¥R:ƒN°e>ˆ7Õ!Ö÷-èŸàM™Œ˜c†Z1ÅÏ÷Ñ?TØf¢DPèJ3 f›‡áEx *—œÝ|͵¥ª©.R¿áøaÞÌ0§Ü \}$—ë-§d’‡Ž«ÙÝ€õWù‡CTNI´Ò3Œº³\޽ø2pg&¢WvÔ-¨Å4Ù‰í÷2ãe?òŠå­MG®×vn‹ê˜™u›œHÿ¦á¢¿i0¥%Çnj¶‹£–®ñŸrü6àU‡& Z¬’QCRÌ£Õh ˆL=µZàøuˆÀã=Tܽ±pŒ¼4óèøaÕ›öÿª­•–Ò±3,öD©¨:¬Â€]\ÓËü²6ßk Ê‹?GoÁ»ïCiôÄõh’‹› Õ³k «£–ë™-´¸CSĹ¡Oìç‹·¬Äµ´(@ •±„6O×¾l6K.òÐíŒKAÏ|ˆÚ˜¿Q[gïpöUаu³ªÅy ¦±8eïí2½ò ÷½´øaÖt\ntþÒQ•ʺÇU Ã`Ž#î,mÂ+ic¶ôÚGà>úAã é#…Ý€F÷òõœ:lžÇâç٤͵‘O3UCÁ[z÷ÒÝY§.Bß# 1oå å»>áǯ£1>e²…ƒ{x=G Â*b¿ºÕT×ͧÓ|½|¦%ê?Ô3Ǭ6Eiç/“ïªbTá›ËAh˜wТ/dÜøaÞòûèr^|ùxh¶´%ãà'•ØGJC» _Ö6¡ÚK5(¥”]Øn¡§Û-OrŠd 7Mµ•vr¼UüŽMܸ!5„M;ƒÓÅwøVY‘7±û½äÜ.öÎ)’´Öúxz‡“zQœþ:ÀßàÙò´„ºâæ(:áúºƒjŠƒÉ%×# Ñwœ3”n44·.}A-hìøaÞìg×îÜgµR=Øþ¼g£Ï¾š9d t©´/-ØVx*Þ¡+ã{ŽíûÛ÷l›(R2›öMĬéÔOê@Æe³‹î¤Ù ƹ’øcÿ<É.e#èäOP8ÈáJÅò=Ø­¾J€µÝñøßU8¿ [^îl»¬9¦îíDK¾\ËÁD#‡øaÖtq0Ô‡†gåàƒœúÌKØd¹Ì.Èïý›^ŸàýR6H Å"*ªÞ!Í—ŠãªvO¯îQçN™YßÇ@„ãÚÂqå ßXÒ(ïÔøD®b& üz>Ú¦[™S;9•L‹tNöÔ•‡ƒ'#e¥HÛ†]“öœ\eL°.ùþÚò@hÿT¶Ù›DGAŸèOeÝ[­Bƒá$:ØnË^áì$$?§Oâ›ÝÚ&*èôÑYìøaÖqê¨*ûñ:¾ÝôýR¹†ê¨Ø&ý)ÿ0)ó1L¤ï¹ºúe -Ü`Ýœ›ý«¾v"ZÄn,9t£ü¯Hp°²ÖC Žá%¸ß¨/F¨öÄKŠ+ι½2}ï(,#B¬çŒú×i4Øû]ÅxçY¿²Ó ±Ë½—lQ-éBÒ aÖe8‚9ÞxMÝýY²à.b½LC\@euøaÓYÀ®vÃÛ¤tì|¦ åÞïar·°×M ž{Àq†$¨2¯ÒC4T’]ºOµàøw†¼ mÎÏL—D3ƒWöØÙS¦@9uãݶV®l5a—PãÕG™ aEH˜^¯"kòpHnºñw×°÷©™5GæÇF®e…¹eÄÔÕôÄXaÕŸ  L¶[‘q„­øaÕªºh8Óü:¡ÄQ}6‘(aæf4¬\Á;Š_Y¼­´ðþOT±Hÿ•,îÐ\mÚœ+WW]ÙpW=Ù.‡¥Óí¶°ä6¿K§ŠÊ»5~ùª8œd”ô7O,Ëú],Í3zrš¹OSóžPÔÁŸyiÚCÁ¢WÝÅ[#m~a!X[×UÁ‰zò0øaÕ¬É&ö®NEoJ5ã_¬ä.‚m{(%™Ëû, ,YÄàóªEôŒ3÷Iõ3M(ßpLØr&}t®ô¿çNë5R³›ÅÀÓ‘ýÆåŒ`f .IŸs®ý6°©öt±¼º €ívé'3‹¬)²Ï%N³°z›páRo\› ´çžvò÷íÏêÉlÌ/Œ7 CøaÖt Zx’Q‡D‚W9I¦sÅ-÷Å0ïèiirŸØ^:jûÎ?ÅPT-ô7ÕN pÿ›XYÉs€ªÚúÔuo—¿OAŠa"ÉB--g©ð¸Íšnܪ‘*œäƒ8Ó–ÔÑjRtL-/jyÜWŽQÆ• u‚~….þÌ!`h¼>BÐ<éàð)#,~³)øaÓLuÛ*Du=fÍQÍpåùay±GB鯸B¯¨Eéùr/£ (£­%è<™ûÜØƒ“Ü´²‚ŤáΙ<´üçP ºg· ïïHaob–iªU/ÁÁ,§ËÖþ c&狈 ¸¥êz¦â•÷ûZКÜTÚé¥o‰øßöŸêœA˜õÝyQå*+öVýó&ªÑÂøaÕªÐÌLÙíÄLÌ¥lëNoŒg"ü©é\=ZJñòŠ Ïô¹Ô¦ ßiÍ+vÜb/:túÙš ~^kçY¨D/5ÍlT·½è$k}Õq¼¿1—²n”³’òÅ QÛ_঺Û*"µúNoƃÇcjNÁºg4fDÆß7Ñ»­Êt“ç×bH¢Ïüt0Ž ŠTSøaÕ¬uPúÄä.ÞÐ#m‡¶ìØ;†åžy,J•"ĉ)ìá¶ipݹ8ݧ"®ý1…¨Wü€QcCS_t7Éø°ý÷‚./¹?]Èž÷ɰ«%¯ /±¶n†…9Hã% ¼ç‡Y—99u63[Ó¸‚üˆ\hø‹'Ž—ÇMfžt½Q›ÙÁXMsR½ÑA/ÏøaÕªÙ¼ãZ Ö†ÿI!vŠu›dH®ÅÇ<½ ‘Ÿ{o¨ Ä<@å<@ìj†C%¨TáäDs[kâ­oÃñÝÏàt³ÿ÷ìÖpªÁ~“?lÎ1Ne$Ä}úE‚Âth9üÿ•ÕEÖt@VNU}qŽ&._+#ò°sÿZ´Èé‘>Zäûky°¾FwÝŸ–…ˆŽ`…OêZøaÕ°jß ½Ee m›þ¤¿ò0Ú^;£(87ù—Æ ×µ”k¢ühP(N©ò–@_#äÔœÀœmû§¬–(¬?T‹eá“ÁË2¯l Ð’¤n"-–lÏðŸYXì˜_ü›Ž¬¼-o^|4 BÏêˆL“W=Tz +H„å.E­^fkÕÞ±ÐtÐÊ› € € ôµ¿_.©ÏŽãÀ SebÒÓ«º©ÏŽæÀ Se4êËøÅ¯[wH„gªŒDúLÊ”#D˜”ÑI¡ANEpT3&²uŽfϦ٪bÎl,@¤ÐÒãÒ—ð É^¨PHWM/YearOct 3, 1995‘Ü··©ÏŽæÀ 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Ä7NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/unparseable.wv0000644000076500000240000003120000000000000017130 0ustar00asampsonstaffwvpk 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Ð8  DATEOct 3, 1995APETAGEXÐ8€././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589741074.0 beets-1.6.0/test/rsrc/year.ogg0000644000076500000240000002055400000000000015721 0ustar00asampsonstaffOggSþ–ç=݃[vorbisD¬€»€»€»¸OggSþ–ç=EËÖ—:ÿÿÿÿÿÿÿÿÿÿÿÿÿ2vorbisXiph.Org libVorbis I 20050304 YEAR=2000vorbisBCVcT)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}]Ü././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_acousticbrainz.py0000644000076500000240000000734100000000000017742 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Nathan Dwek. # # 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 'acousticbrainz' plugin. """ import json import os.path import unittest from test._common import RSRC from beetsplug.acousticbrainz import AcousticPlugin, ABSCHEME class MapDataToSchemeTest(unittest.TestCase): def test_basic(self): ab = AcousticPlugin() data = {'key 1': 'value 1', 'key 2': 'value 2'} scheme = {'key 1': 'attribute 1', 'key 2': 'attribute 2'} mapping = set(ab._map_data_to_scheme(data, scheme)) self.assertEqual(mapping, {('attribute 1', 'value 1'), ('attribute 2', 'value 2')}) def test_recurse(self): ab = AcousticPlugin() data = { 'key': 'value', 'group': { 'subkey': 'subvalue', 'subgroup': { 'subsubkey': 'subsubvalue' } } } scheme = { 'key': 'attribute 1', 'group': { 'subkey': 'attribute 2', 'subgroup': { 'subsubkey': 'attribute 3' } } } mapping = set(ab._map_data_to_scheme(data, scheme)) self.assertEqual(mapping, {('attribute 1', 'value'), ('attribute 2', 'subvalue'), ('attribute 3', 'subsubvalue')}) def test_composite(self): ab = AcousticPlugin() data = {'key 1': 'part 1', 'key 2': 'part 2'} scheme = {'key 1': ('attribute', 0), 'key 2': ('attribute', 1)} mapping = set(ab._map_data_to_scheme(data, scheme)) self.assertEqual(mapping, {('attribute', 'part 1 part 2')}) def test_realistic(self): ab = AcousticPlugin() data_path = os.path.join(RSRC, b'acousticbrainz/data.json') with open(data_path) as res: data = json.load(res) mapping = set(ab._map_data_to_scheme(data, ABSCHEME)) expected = { ('chords_key', 'A'), ('average_loudness', 0.815025985241), ('mood_acoustic', 0.415711194277), ('chords_changes_rate', 0.0445116683841), ('tonal', 0.874250173569), ('mood_sad', 0.299694597721), ('bpm', 162.532119751), ('gender', 'female'), ('initial_key', 'A minor'), ('chords_number_rate', 0.00194468453992), ('mood_relaxed', 0.123632438481), ('chords_scale', 'minor'), ('voice_instrumental', 'instrumental'), ('key_strength', 0.636936545372), ('genre_rosamerica', 'roc'), ('mood_party', 0.234383180737), ('mood_aggressive', 0.0779221653938), ('danceable', 0.143928021193), ('rhythm', 'VienneseWaltz'), ('mood_electronic', 0.339881360531), ('mood_happy', 0.0894767045975), ('moods_mirex', "Cluster3"), ('timbre', "bright") } self.assertEqual(mapping, expected) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_albumtypes.py0000644000076500000240000000756400000000000017116 0ustar00asampsonstaff# This file is part of beets. # Copyright 2021, Edgars Supe. # # 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 'albumtypes' plugin.""" import unittest from beets.autotag.mb import VARIOUS_ARTISTS_ID from beetsplug.albumtypes import AlbumTypesPlugin from test.helper import TestHelper class AlbumTypesPluginTest(unittest.TestCase, TestHelper): """Tests for albumtypes plugin.""" def setUp(self): """Set up tests.""" self.setup_beets() self.load_plugins('albumtypes') def tearDown(self): """Tear down tests.""" self.unload_plugins() self.teardown_beets() def test_renames_types(self): """Tests if the plugin correctly renames the specified types.""" self._set_config( types=[('ep', 'EP'), ('remix', 'Remix')], ignore_va=[], bracket='()' ) album = self._create_album(album_types=['ep', 'remix']) subject = AlbumTypesPlugin() result = subject._atypes(album) self.assertEqual('(EP)(Remix)', result) return def test_returns_only_specified_types(self): """Tests if the plugin returns only non-blank types given in config.""" self._set_config( types=[('ep', 'EP'), ('soundtrack', '')], ignore_va=[], bracket='()' ) album = self._create_album(album_types=['ep', 'remix', 'soundtrack']) subject = AlbumTypesPlugin() result = subject._atypes(album) self.assertEqual('(EP)', result) def test_respects_type_order(self): """Tests if the types are returned in the same order as config.""" self._set_config( types=[('remix', 'Remix'), ('ep', 'EP')], ignore_va=[], bracket='()' ) album = self._create_album(album_types=['ep', 'remix']) subject = AlbumTypesPlugin() result = subject._atypes(album) self.assertEqual('(Remix)(EP)', result) return def test_ignores_va(self): """Tests if the specified type is ignored for VA albums.""" self._set_config( types=[('ep', 'EP'), ('soundtrack', 'OST')], ignore_va=['ep'], bracket='()' ) album = self._create_album( album_types=['ep', 'soundtrack'], artist_id=VARIOUS_ARTISTS_ID ) subject = AlbumTypesPlugin() result = subject._atypes(album) self.assertEqual('(OST)', result) def test_respects_defaults(self): """Tests if the plugin uses the default values if config not given.""" album = self._create_album( album_types=['ep', 'single', 'soundtrack', 'live', 'compilation', 'remix'], artist_id=VARIOUS_ARTISTS_ID ) subject = AlbumTypesPlugin() result = subject._atypes(album) self.assertEqual('[EP][Single][OST][Live][Remix]', result) def _set_config(self, types: [(str, str)], ignore_va: [str], bracket: str): self.config['albumtypes']['types'] = types self.config['albumtypes']['ignore_va'] = ignore_va self.config['albumtypes']['bracket'] = bracket def _create_album(self, album_types: [str], artist_id: str = 0): return self.add_album( albumtypes='; '.join(album_types), mb_albumartistid=artist_id ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_art.py0000644000076500000240000010347100000000000015511 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the album art fetchers.""" import os import shutil import unittest import responses from unittest.mock import patch from test import _common from test.helper import capture_log from beetsplug import fetchart from beets.autotag import AlbumInfo, AlbumMatch from beets import config from beets import library from beets import importer from beets import logging from beets import util from beets.util.artresizer import ArtResizer, WEBPROXY import confuse logger = logging.getLogger('beets.test_art') class Settings(): """Used to pass settings to the ArtSources when the plugin isn't fully instantiated. """ def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class UseThePlugin(_common.TestCase): def setUp(self): super().setUp() self.plugin = fetchart.FetchArtPlugin() class FetchImageHelper(_common.TestCase): """Helper mixin for mocking requests when fetching images with remote art sources. """ @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) IMAGEHEADER = {'image/jpeg': b'\x00' * 6 + b'JFIF', 'image/png': b'\211PNG\r\n\032\n', } def mock_response(self, url, content_type='image/jpeg', file_type=None): if file_type is None: file_type = content_type responses.add(responses.GET, url, content_type=content_type, # imghdr reads 32 bytes body=self.IMAGEHEADER.get( file_type, b'').ljust(32, b'\x00')) class CAAHelper(): """Helper mixin for mocking requests to the Cover Art Archive.""" MBID_RELASE = 'rid' MBID_GROUP = 'rgid' RELEASE_URL = 'coverartarchive.org/release/{}' \ .format(MBID_RELASE) GROUP_URL = 'coverartarchive.org/release-group/{}' \ .format(MBID_GROUP) RELEASE_URL = "https://" + RELEASE_URL GROUP_URL = "https://" + GROUP_URL RESPONSE_RELEASE = """{ "images": [ { "approved": false, "back": false, "comment": "GIF", "edit": 12345, "front": true, "id": 12345, "image": "http://coverartarchive.org/release/rid/12345.gif", "thumbnails": { "1200": "http://coverartarchive.org/release/rid/12345-1200.jpg", "250": "http://coverartarchive.org/release/rid/12345-250.jpg", "500": "http://coverartarchive.org/release/rid/12345-500.jpg", "large": "http://coverartarchive.org/release/rid/12345-500.jpg", "small": "http://coverartarchive.org/release/rid/12345-250.jpg" }, "types": [ "Front" ] }, { "approved": false, "back": false, "comment": "", "edit": 12345, "front": false, "id": 12345, "image": "http://coverartarchive.org/release/rid/12345.jpg", "thumbnails": { "1200": "http://coverartarchive.org/release/rid/12345-1200.jpg", "250": "http://coverartarchive.org/release/rid/12345-250.jpg", "500": "http://coverartarchive.org/release/rid/12345-500.jpg", "large": "http://coverartarchive.org/release/rid/12345-500.jpg", "small": "http://coverartarchive.org/release/rid/12345-250.jpg" }, "types": [ "Front" ] } ], "release": "https://musicbrainz.org/release/releaseid" }""" RESPONSE_GROUP = """{ "images": [ { "approved": false, "back": false, "comment": "", "edit": 12345, "front": true, "id": 12345, "image": "http://coverartarchive.org/release/releaseid/12345.jpg", "thumbnails": { "1200": "http://coverartarchive.org/release/rgid/12345-1200.jpg", "250": "http://coverartarchive.org/release/rgid/12345-250.jpg", "500": "http://coverartarchive.org/release/rgid/12345-500.jpg", "large": "http://coverartarchive.org/release/rgid/12345-500.jpg", "small": "http://coverartarchive.org/release/rgid/12345-250.jpg" }, "types": [ "Front" ] } ], "release": "https://musicbrainz.org/release/release-id" }""" def mock_caa_response(self, url, json): responses.add(responses.GET, url, body=json, content_type='application/json') class FetchImageTest(FetchImageHelper, UseThePlugin): URL = 'http://example.com/test.jpg' def setUp(self): super().setUp() self.dpath = os.path.join(self.temp_dir, b'arttest') self.source = fetchart.RemoteArtSource(logger, self.plugin.config) self.settings = Settings(maxwidth=0) self.candidate = fetchart.Candidate(logger, url=self.URL) def test_invalid_type_returns_none(self): self.mock_response(self.URL, 'image/watercolour') self.source.fetch_image(self.candidate, self.settings) self.assertEqual(self.candidate.path, None) def test_jpeg_type_returns_path(self): self.mock_response(self.URL, 'image/jpeg') self.source.fetch_image(self.candidate, self.settings) self.assertNotEqual(self.candidate.path, None) def test_extension_set_by_content_type(self): self.mock_response(self.URL, 'image/png') self.source.fetch_image(self.candidate, self.settings) self.assertEqual(os.path.splitext(self.candidate.path)[1], b'.png') self.assertExists(self.candidate.path) def test_does_not_rely_on_server_content_type(self): self.mock_response(self.URL, 'image/jpeg', 'image/png') self.source.fetch_image(self.candidate, self.settings) self.assertEqual(os.path.splitext(self.candidate.path)[1], b'.png') self.assertExists(self.candidate.path) class FSArtTest(UseThePlugin): def setUp(self): super().setUp() self.dpath = os.path.join(self.temp_dir, b'arttest') os.mkdir(self.dpath) self.source = fetchart.FileSystem(logger, self.plugin.config) self.settings = Settings(cautious=False, cover_names=('art',)) def test_finds_jpg_in_directory(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) candidate = next(self.source.get(None, self.settings, [self.dpath])) self.assertEqual(candidate.path, os.path.join(self.dpath, b'a.jpg')) def test_appropriately_named_file_takes_precedence(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) _common.touch(os.path.join(self.dpath, b'art.jpg')) candidate = next(self.source.get(None, self.settings, [self.dpath])) self.assertEqual(candidate.path, os.path.join(self.dpath, b'art.jpg')) def test_non_image_file_not_identified(self): _common.touch(os.path.join(self.dpath, b'a.txt')) with self.assertRaises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_cautious_skips_fallback(self): _common.touch(os.path.join(self.dpath, b'a.jpg')) self.settings.cautious = True with self.assertRaises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_empty_dir(self): with self.assertRaises(StopIteration): next(self.source.get(None, self.settings, [self.dpath])) def test_precedence_amongst_correct_files(self): images = [b'front-cover.jpg', b'front.jpg', b'back.jpg'] paths = [os.path.join(self.dpath, i) for i in images] for p in paths: _common.touch(p) self.settings.cover_names = ['cover', 'front', 'back'] candidates = [candidate.path for candidate in self.source.get(None, self.settings, [self.dpath])] self.assertEqual(candidates, paths) class CombinedTest(FetchImageHelper, UseThePlugin, CAAHelper): ASIN = 'xxxx' MBID = 'releaseid' AMAZON_URL = 'https://images.amazon.com/images/P/{}.01.LZZZZZZZ.jpg' \ .format(ASIN) AAO_URL = 'https://www.albumart.org/index_detail.php?asin={}' \ .format(ASIN) def setUp(self): super().setUp() self.dpath = os.path.join(self.temp_dir, b'arttest') os.mkdir(self.dpath) def test_main_interface_returns_amazon_art(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, None) self.assertIsNotNone(candidate) def test_main_interface_returns_none_for_missing_asin_and_path(self): album = _common.Bag() candidate = self.plugin.art_for_album(album, None) self.assertIsNone(candidate) def test_main_interface_gives_precedence_to_fs_art(self): _common.touch(os.path.join(self.dpath, b'art.jpg')) self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, [self.dpath]) self.assertIsNotNone(candidate) self.assertEqual(candidate.path, os.path.join(self.dpath, b'art.jpg')) def test_main_interface_falls_back_to_amazon(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) candidate = self.plugin.art_for_album(album, [self.dpath]) self.assertIsNotNone(candidate) self.assertFalse(candidate.path.startswith(self.dpath)) def test_main_interface_tries_amazon_before_aao(self): self.mock_response(self.AMAZON_URL) album = _common.Bag(asin=self.ASIN) self.plugin.art_for_album(album, [self.dpath]) self.assertEqual(len(responses.calls), 1) self.assertEqual(responses.calls[0].request.url, self.AMAZON_URL) def test_main_interface_falls_back_to_aao(self): self.mock_response(self.AMAZON_URL, content_type='text/html') album = _common.Bag(asin=self.ASIN) self.plugin.art_for_album(album, [self.dpath]) self.assertEqual(responses.calls[-1].request.url, self.AAO_URL) def test_main_interface_uses_caa_when_mbid_available(self): self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE) self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP) self.mock_response('http://coverartarchive.org/release/rid/12345.gif', content_type='image/gif') self.mock_response('http://coverartarchive.org/release/rid/12345.jpg', content_type='image/jpeg') album = _common.Bag(mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP, asin=self.ASIN) candidate = self.plugin.art_for_album(album, None) self.assertIsNotNone(candidate) self.assertEqual(len(responses.calls), 3) self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL) def test_local_only_does_not_access_network(self): album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) self.plugin.art_for_album(album, None, local_only=True) self.assertEqual(len(responses.calls), 0) def test_local_only_gets_fs_image(self): _common.touch(os.path.join(self.dpath, b'art.jpg')) album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN) candidate = self.plugin.art_for_album(album, [self.dpath], local_only=True) self.assertIsNotNone(candidate) self.assertEqual(candidate.path, os.path.join(self.dpath, b'art.jpg')) self.assertEqual(len(responses.calls), 0) class AAOTest(UseThePlugin): ASIN = 'xxxx' AAO_URL = f'https://www.albumart.org/index_detail.php?asin={ASIN}' def setUp(self): super().setUp() self.source = fetchart.AlbumArtOrg(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def mock_response(self, url, body): responses.add(responses.GET, url, body=body, content_type='text/html', match_querystring=True) def test_aao_scraper_finds_image(self): body = """
\"View """ self.mock_response(self.AAO_URL, body) album = _common.Bag(asin=self.ASIN) candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'TARGET_URL') def test_aao_scraper_returns_no_result_when_no_image_present(self): self.mock_response(self.AAO_URL, 'blah blah') album = _common.Bag(asin=self.ASIN) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) class ITunesStoreTest(UseThePlugin): def setUp(self): super().setUp() self.source = fetchart.ITunesStore(logger, self.plugin.config) self.settings = Settings() self.album = _common.Bag(albumartist="some artist", album="some album") @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def mock_response(self, url, json): responses.add(responses.GET, url, body=json, content_type='application/json') def test_itunesstore_finds_image(self): json = """{ "results": [ { "artistName": "some artist", "collectionName": "some album", "artworkUrl100": "url_to_the_image" } ] }""" self.mock_response(fetchart.ITunesStore.API_URL, json) candidate = next(self.source.get(self.album, self.settings, [])) self.assertEqual(candidate.url, 'url_to_the_image') self.assertEqual(candidate.match, fetchart.Candidate.MATCH_EXACT) def test_itunesstore_no_result(self): json = '{"results": []}' self.mock_response(fetchart.ITunesStore.API_URL, json) expected = "got no results" with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): next(self.source.get(self.album, self.settings, [])) self.assertIn(expected, logs[1]) def test_itunesstore_requestexception(self): responses.add(responses.GET, fetchart.ITunesStore.API_URL, json={'error': 'not found'}, status=404) expected = 'iTunes search failed: 404 Client Error' with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): next(self.source.get(self.album, self.settings, [])) self.assertIn(expected, logs[1]) def test_itunesstore_fallback_match(self): json = """{ "results": [ { "collectionName": "some album", "artworkUrl100": "url_to_the_image" } ] }""" self.mock_response(fetchart.ITunesStore.API_URL, json) candidate = next(self.source.get(self.album, self.settings, [])) self.assertEqual(candidate.url, 'url_to_the_image') self.assertEqual(candidate.match, fetchart.Candidate.MATCH_FALLBACK) def test_itunesstore_returns_result_without_artwork(self): json = """{ "results": [ { "artistName": "some artist", "collectionName": "some album" } ] }""" self.mock_response(fetchart.ITunesStore.API_URL, json) expected = 'Malformed itunes candidate' with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): next(self.source.get(self.album, self.settings, [])) self.assertIn(expected, logs[1]) def test_itunesstore_returns_no_result_when_error_received(self): json = '{"error": {"errors": [{"reason": "some reason"}]}}' self.mock_response(fetchart.ITunesStore.API_URL, json) expected = "not found in json. Fields are" with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): next(self.source.get(self.album, self.settings, [])) self.assertIn(expected, logs[1]) def test_itunesstore_returns_no_result_with_malformed_response(self): json = """bla blup""" self.mock_response(fetchart.ITunesStore.API_URL, json) expected = "Could not decode json response:" with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): next(self.source.get(self.album, self.settings, [])) self.assertIn(expected, logs[1]) class GoogleImageTest(UseThePlugin): def setUp(self): super().setUp() self.source = fetchart.GoogleImages(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def mock_response(self, url, json): responses.add(responses.GET, url, body=json, content_type='application/json') def test_google_art_finds_image(self): album = _common.Bag(albumartist="some artist", album="some album") json = '{"items": [{"link": "url_to_the_image"}]}' self.mock_response(fetchart.GoogleImages.URL, json) candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'url_to_the_image') def test_google_art_returns_no_result_when_error_received(self): album = _common.Bag(albumartist="some artist", album="some album") json = '{"error": {"errors": [{"reason": "some reason"}]}}' self.mock_response(fetchart.GoogleImages.URL, json) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) def test_google_art_returns_no_result_with_malformed_response(self): album = _common.Bag(albumartist="some artist", album="some album") json = """bla blup""" self.mock_response(fetchart.GoogleImages.URL, json) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) class CoverArtArchiveTest(UseThePlugin, CAAHelper): def setUp(self): super().setUp() self.source = fetchart.CoverArtArchive(logger, self.plugin.config) self.settings = Settings(maxwidth=0) @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def test_caa_finds_image(self): album = _common.Bag(mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP) self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE) self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP) candidates = list(self.source.get(album, self.settings, [])) self.assertEqual(len(candidates), 3) self.assertEqual(len(responses.calls), 2) self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL) class FanartTVTest(UseThePlugin): RESPONSE_MULTIPLE = """{ "name": "artistname", "mbid_id": "artistid", "albums": { "thereleasegroupid": { "albumcover": [ { "id": "24", "url": "http://example.com/1.jpg", "likes": "0" }, { "id": "42", "url": "http://example.com/2.jpg", "likes": "0" }, { "id": "23", "url": "http://example.com/3.jpg", "likes": "0" } ], "cdart": [ { "id": "123", "url": "http://example.com/4.jpg", "likes": "0", "disc": "1", "size": "1000" } ] } } }""" RESPONSE_NO_ART = """{ "name": "artistname", "mbid_id": "artistid", "albums": { "thereleasegroupid": { "cdart": [ { "id": "123", "url": "http://example.com/4.jpg", "likes": "0", "disc": "1", "size": "1000" } ] } } }""" RESPONSE_ERROR = """{ "status": "error", "error message": "the error message" }""" RESPONSE_MALFORMED = "bla blup" def setUp(self): super().setUp() self.source = fetchart.FanartTV(logger, self.plugin.config) self.settings = Settings() @responses.activate def run(self, *args, **kwargs): super().run(*args, **kwargs) def mock_response(self, url, json): responses.add(responses.GET, url, body=json, content_type='application/json') def test_fanarttv_finds_image(self): album = _common.Bag(mb_releasegroupid='thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + 'thereleasegroupid', self.RESPONSE_MULTIPLE) candidate = next(self.source.get(album, self.settings, [])) self.assertEqual(candidate.url, 'http://example.com/1.jpg') def test_fanarttv_returns_no_result_when_error_received(self): album = _common.Bag(mb_releasegroupid='thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + 'thereleasegroupid', self.RESPONSE_ERROR) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) def test_fanarttv_returns_no_result_with_malformed_response(self): album = _common.Bag(mb_releasegroupid='thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + 'thereleasegroupid', self.RESPONSE_MALFORMED) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) def test_fanarttv_only_other_images(self): # The source used to fail when there were images present, but no cover album = _common.Bag(mb_releasegroupid='thereleasegroupid') self.mock_response(fetchart.FanartTV.API_ALBUMS + 'thereleasegroupid', self.RESPONSE_NO_ART) with self.assertRaises(StopIteration): next(self.source.get(album, self.settings, [])) @_common.slow_test() class ArtImporterTest(UseThePlugin): def setUp(self): super().setUp() # Mock the album art fetcher to always return our test file. self.art_file = os.path.join(self.temp_dir, b'tmpcover.jpg') _common.touch(self.art_file) self.old_afa = self.plugin.art_for_album self.afa_response = fetchart.Candidate(logger, path=self.art_file) def art_for_album(i, p, local_only=False): return self.afa_response self.plugin.art_for_album = art_for_album # Test library. self.libpath = os.path.join(self.temp_dir, b'tmplib.blb') self.libdir = os.path.join(self.temp_dir, b'tmplib') os.mkdir(self.libdir) os.mkdir(os.path.join(self.libdir, b'album')) itempath = os.path.join(self.libdir, b'album', b'test.mp3') shutil.copyfile(os.path.join(_common.RSRC, b'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 import configuration. 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 = self.album 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): self.lib._connection().close() super().tearDown() self.plugin.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), b'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): self.plugin.src_removed = 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), b'cover.jpg') shutil.copyfile(self.art_file, artdest) self.afa_response = fetchart.Candidate(logger, path=artdest) self._fetch_art(True) def test_fetch_art_if_imported_file_deleted(self): # See #1126. Test the following scenario: # - Album art imported, `album.artpath` set. # - Imported album art file subsequently deleted (by user or other # program). # `fetchart` should import album art again instead of printing the # message " has album art". self._fetch_art(True) util.remove(self.album.artpath) self.plugin.batch_fetch_art(self.lib, self.lib.albums(), force=False, quiet=False) self.assertExists(self.album.artpath) class ArtForAlbumTest(UseThePlugin): """ Tests that fetchart.art_for_album respects the scale & filesize configurations (e.g., minwidth, enforce_ratio, max_filesize) """ IMG_225x225 = os.path.join(_common.RSRC, b'abbey.jpg') IMG_348x348 = os.path.join(_common.RSRC, b'abbey-different.jpg') IMG_500x490 = os.path.join(_common.RSRC, b'abbey-similar.jpg') IMG_225x225_SIZE = os.stat(util.syspath(IMG_225x225)).st_size IMG_348x348_SIZE = os.stat(util.syspath(IMG_348x348)).st_size def setUp(self): super().setUp() self.old_fs_source_get = fetchart.FileSystem.get def fs_source_get(_self, album, settings, paths): if paths: yield fetchart.Candidate(logger, path=self.image_file) fetchart.FileSystem.get = fs_source_get self.album = _common.Bag() def tearDown(self): fetchart.FileSystem.get = self.old_fs_source_get super().tearDown() def _assertImageIsValidArt(self, image_file, should_exist): # noqa self.assertExists(image_file) self.image_file = image_file candidate = self.plugin.art_for_album(self.album, [''], True) if should_exist: self.assertNotEqual(candidate, None) self.assertEqual(candidate.path, self.image_file) self.assertExists(candidate.path) else: self.assertIsNone(candidate) def _assertImageResized(self, image_file, should_resize): # noqa self.image_file = image_file with patch.object(ArtResizer.shared, 'resize') as mock_resize: self.plugin.art_for_album(self.album, [''], True) self.assertEqual(mock_resize.called, should_resize) def _require_backend(self): """Skip the test if the art resizer doesn't have ImageMagick or PIL (so comparisons and measurements are unavailable). """ if ArtResizer.shared.method[0] == WEBPROXY: self.skipTest("ArtResizer has no local imaging backend available") def test_respect_minwidth(self): self._require_backend() self.plugin.minwidth = 300 self._assertImageIsValidArt(self.IMG_225x225, False) self._assertImageIsValidArt(self.IMG_348x348, True) def test_respect_enforce_ratio_yes(self): self._require_backend() self.plugin.enforce_ratio = True self._assertImageIsValidArt(self.IMG_500x490, False) self._assertImageIsValidArt(self.IMG_225x225, True) def test_respect_enforce_ratio_no(self): self.plugin.enforce_ratio = False self._assertImageIsValidArt(self.IMG_500x490, True) def test_respect_enforce_ratio_px_above(self): self._require_backend() self.plugin.enforce_ratio = True self.plugin.margin_px = 5 self._assertImageIsValidArt(self.IMG_500x490, False) def test_respect_enforce_ratio_px_below(self): self._require_backend() self.plugin.enforce_ratio = True self.plugin.margin_px = 15 self._assertImageIsValidArt(self.IMG_500x490, True) def test_respect_enforce_ratio_percent_above(self): self._require_backend() self.plugin.enforce_ratio = True self.plugin.margin_percent = (500 - 490) / 500 * 0.5 self._assertImageIsValidArt(self.IMG_500x490, False) def test_respect_enforce_ratio_percent_below(self): self._require_backend() self.plugin.enforce_ratio = True self.plugin.margin_percent = (500 - 490) / 500 * 1.5 self._assertImageIsValidArt(self.IMG_500x490, True) def test_resize_if_necessary(self): self._require_backend() self.plugin.maxwidth = 300 self._assertImageResized(self.IMG_225x225, False) self._assertImageResized(self.IMG_348x348, True) def test_fileresize(self): self._require_backend() self.plugin.max_filesize = self.IMG_225x225_SIZE // 2 self._assertImageResized(self.IMG_225x225, True) def test_fileresize_if_necessary(self): self._require_backend() self.plugin.max_filesize = self.IMG_225x225_SIZE self._assertImageResized(self.IMG_225x225, False) self._assertImageIsValidArt(self.IMG_225x225, True) def test_fileresize_no_scale(self): self._require_backend() self.plugin.maxwidth = 300 self.plugin.max_filesize = self.IMG_225x225_SIZE // 2 self._assertImageResized(self.IMG_225x225, True) def test_fileresize_and_scale(self): self._require_backend() self.plugin.maxwidth = 200 self.plugin.max_filesize = self.IMG_225x225_SIZE // 2 self._assertImageResized(self.IMG_225x225, True) class DeprecatedConfigTest(_common.TestCase): """While refactoring the plugin, the remote_priority option was deprecated, and a new codepath should translate its effect. Check that it actually does so. """ # If we subclassed UseThePlugin, the configuration change would either be # overwritten by _common.TestCase or be set after constructing the # plugin object def setUp(self): super().setUp() config['fetchart']['remote_priority'] = True self.plugin = fetchart.FetchArtPlugin() def test_moves_filesystem_to_end(self): self.assertEqual(type(self.plugin.sources[-1]), fetchart.FileSystem) class EnforceRatioConfigTest(_common.TestCase): """Throw some data at the regexes.""" def _load_with_config(self, values, should_raise): if should_raise: for v in values: config['fetchart']['enforce_ratio'] = v with self.assertRaises(confuse.ConfigValueError): fetchart.FetchArtPlugin() else: for v in values: config['fetchart']['enforce_ratio'] = v fetchart.FetchArtPlugin() def test_px(self): self._load_with_config('0px 4px 12px 123px'.split(), False) self._load_with_config('00px stuff5px'.split(), True) def test_percent(self): self._load_with_config('0% 0.00% 5.1% 5% 100%'.split(), False) self._load_with_config('00% 1.234% foo5% 100.1%'.split(), True) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/test/test_art_resize.py0000644000076500000240000001062300000000000017066 0ustar00asampsonstaff# This file is part of beets. # Copyright 2020, David Swarbrick. # # 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 image resizing based on filesize.""" import unittest import os from test import _common from test.helper import TestHelper from beets.util import command_output, syspath from beets.util.artresizer import ( pil_resize, im_resize, get_im_version, get_pil_version, pil_deinterlace, im_deinterlace, ArtResizer, ) class ArtResizerFileSizeTest(_common.TestCase, TestHelper): """Unittest test case for Art Resizer to a specific filesize.""" IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg") IMG_225x225_SIZE = os.stat(syspath(IMG_225x225)).st_size def setUp(self): """Called before each test, setting up beets.""" self.setup_beets() def tearDown(self): """Called after each test, unloading all plugins.""" self.teardown_beets() def _test_img_resize(self, resize_func): """Test resizing based on file size, given a resize_func.""" # Check quality setting unaffected by new parameter im_95_qual = resize_func( 225, self.IMG_225x225, quality=95, max_filesize=0, ) # check valid path returned - max_filesize hasn't broken resize command self.assertExists(im_95_qual) # Attempt a lower filesize with same quality im_a = resize_func( 225, self.IMG_225x225, quality=95, max_filesize=0.9 * os.stat(syspath(im_95_qual)).st_size, ) self.assertExists(im_a) # target size was achieved self.assertLess(os.stat(syspath(im_a)).st_size, os.stat(syspath(im_95_qual)).st_size) # Attempt with lower initial quality im_75_qual = resize_func( 225, self.IMG_225x225, quality=75, max_filesize=0, ) self.assertExists(im_75_qual) im_b = resize_func( 225, self.IMG_225x225, quality=95, max_filesize=0.9 * os.stat(syspath(im_75_qual)).st_size, ) self.assertExists(im_b) # Check high (initial) quality still gives a smaller filesize self.assertLess(os.stat(syspath(im_b)).st_size, os.stat(syspath(im_75_qual)).st_size) @unittest.skipUnless(get_pil_version(), "PIL not available") def test_pil_file_resize(self): """Test PIL resize function is lowering file size.""" self._test_img_resize(pil_resize) @unittest.skipUnless(get_im_version(), "ImageMagick not available") def test_im_file_resize(self): """Test IM resize function is lowering file size.""" self._test_img_resize(im_resize) @unittest.skipUnless(get_pil_version(), "PIL not available") def test_pil_file_deinterlace(self): """Test PIL deinterlace function. Check if pil_deinterlace function returns images that are non-progressive """ path = pil_deinterlace(self.IMG_225x225) from PIL import Image with Image.open(path) as img: self.assertFalse('progression' in img.info) @unittest.skipUnless(get_im_version(), "ImageMagick not available") def test_im_file_deinterlace(self): """Test ImageMagick deinterlace function. Check if im_deinterlace function returns images that are non-progressive. """ path = im_deinterlace(self.IMG_225x225) cmd = ArtResizer.shared.im_identify_cmd + [ '-format', '%[interlace]', syspath(path, prefix=False), ] out = command_output(cmd).stdout self.assertTrue(out == b'None') def suite(): """Run this suite of tests.""" return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == "__main__": unittest.main(defaultTest="suite") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_autotag.py0000644000076500000240000010350500000000000016365 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for autotagging functionality. """ import re import unittest from test import _common 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.assertTrue(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(**{f: '{}_{}'.format(f, i or 1) for f in fields}) for i in range(5)] likelies, _ = match.current_metadata(items) for f in fields: if isinstance(likelies[f], int): self.assertEqual(likelies[f], 0) else: self.assertEqual(likelies[f], '%s_1' % f) def _make_item(title, track, artist='some artist'): return Item(title=title, track=track, artist=artist, album='some album', length=1, mb_trackid='', mb_albumid='', mb_artistid='') def _make_trackinfo(): return [ TrackInfo(title='one', track_id=None, artist='some artist', length=1, index=1), TrackInfo(title='two', track_id=None, artist='some artist', length=1, index=2), TrackInfo(title='three', track_id=None, artist='some artist', length=1, index=3), ] def _clear_weights(): """Hack around the lazy descriptor used to cache weights for Distance calculations. """ Distance.__dict__['_weights'].computed = False class DistanceTest(_common.TestCase): def tearDown(self): super().tearDown() _clear_weights() 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('abc', 'bcd') dist.add_string('string', 'abc', 'bcd') self.assertEqual(dist._penalties['string'], [sdist]) self.assertNotEqual(dist._penalties['string'], [0]) def test_add_string_none(self): dist = Distance() dist.add_string('string', None, 'string') self.assertEqual(dist._penalties['string'], [1]) def test_add_string_both_none(self): dist = Distance() dist.add_string('string', None, None) self.assertEqual(dist._penalties['string'], [0]) def test_distance(self): config['match']['distance_weights']['album'] = 2.0 config['match']['distance_weights']['medium'] = 1.0 _clear_weights() 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 _clear_weights() 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 _clear_weights() 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 _clear_weights() 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 _clear_weights() 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('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('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('one', 1) item.artist = '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('one', 1) item.artist = '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('one', 1)) items.append(_make_item('two', 2)) items.append(_make_item('three', 3)) info = AlbumInfo( artist='some artist', album='some album', tracks=_make_trackinfo(), va=False ) self.assertEqual(self._dist(items, info), 0) def test_incomplete_album(self): items = [] items.append(_make_item('one', 1)) items.append(_make_item('three', 3)) info = AlbumInfo( artist='some artist', album='some album', tracks=_make_trackinfo(), va=False ) 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('one', 1)) items.append(_make_item('two', 2)) items.append(_make_item('three', 3)) info = AlbumInfo( artist='someone else', album='some album', tracks=_make_trackinfo(), va=False ) self.assertNotEqual(self._dist(items, info), 0) def test_comp_track_artists_match(self): items = [] items.append(_make_item('one', 1)) items.append(_make_item('two', 2)) items.append(_make_item('three', 3)) info = AlbumInfo( artist='should be ignored', album='some album', tracks=_make_trackinfo(), va=True ) 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('one', 1)) items.append(_make_item('two', 2)) items.append(_make_item('three', 3)) info = AlbumInfo( artist='should be ignored', album='some album', tracks=_make_trackinfo(), va=True ) 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('one', 1)) items.append(_make_item('two', 2, 'someone else')) items.append(_make_item('three', 3)) info = AlbumInfo( artist='some artist', album='some album', tracks=_make_trackinfo(), va=True ) self.assertNotEqual(self._dist(items, info), 0) def test_tracks_out_of_order(self): items = [] items.append(_make_item('one', 1)) items.append(_make_item('three', 2)) items.append(_make_item('two', 3)) info = AlbumInfo( artist='some artist', album='some album', tracks=_make_trackinfo(), va=False ) dist = self._dist(items, info) self.assertTrue(0 < dist < 0.2) def test_two_medium_release(self): items = [] items.append(_make_item('one', 1)) items.append(_make_item('two', 2)) items.append(_make_item('three', 3)) info = AlbumInfo( artist='some artist', album='some album', tracks=_make_trackinfo(), va=False ) 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('one', 1)) items.append(_make_item('two', 2)) items.append(_make_item('three', 1)) info = AlbumInfo( artist='some artist', album='some album', tracks=_make_trackinfo(), va=False ) 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) 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('one', 1)) items.append(self.item('three', 2)) items.append(self.item('two', 3)) trackinfo = [] trackinfo.append(TrackInfo(title='one')) trackinfo.append(TrackInfo(title='two')) trackinfo.append(TrackInfo(title='three')) 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('one', 1)) items.append(self.item('three', 1)) items.append(self.item('two', 1)) trackinfo = [] trackinfo.append(TrackInfo(title='one')) trackinfo.append(TrackInfo(title='two')) trackinfo.append(TrackInfo(title='three')) 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('one', 1)) items.append(self.item('three', 3)) trackinfo = [] trackinfo.append(TrackInfo(title='one')) trackinfo.append(TrackInfo(title='two')) trackinfo.append(TrackInfo(title='three')) 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('one', 1)) items.append(self.item('two', 2)) items.append(self.item('three', 3)) trackinfo = [] trackinfo.append(TrackInfo(title='one')) trackinfo.append(TrackInfo(title='three')) 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='ben harper', album='burn to shine', title=f'ben harper - Burn to Shine {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=title, length=length, index=index) trackinfo = [] trackinfo.append(info(1, 'Alone', 238.893)) trackinfo.append(info(2, 'The Woman in You', 341.44)) trackinfo.append(info(3, 'Less', 245.59999999999999)) trackinfo.append(info(4, 'Two Hands of a Prayer', 470.49299999999999)) trackinfo.append(info(5, 'Please Bleed', 277.86599999999999)) trackinfo.append(info(6, 'Suzie Blue', 269.30599999999998)) trackinfo.append(info(7, 'Steal My Kisses', 245.36000000000001)) trackinfo.append(info(8, 'Burn to Shine', 214.90600000000001)) trackinfo.append(info(9, 'Show Me a Little Shame', 224.0929999999999)) trackinfo.append(info(10, 'Forgiven', 317.19999999999999)) trackinfo.append(info(11, 'Beloved One', 243.733)) trackinfo.append(info(12, '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.items(): self.assertEqual(items.index(item), trackinfo.index(info)) class ApplyTestUtil: def _apply(self, info=None, per_disc_numbering=False, artist_credit=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 config['artist_credit'] = artist_credit autotag.apply_metadata(info, mapping) class ApplyTest(_common.TestCase, ApplyTestUtil): def setUp(self): super().setUp() self.items = [] self.items.append(Item({})) self.items.append(Item({})) trackinfo = [] trackinfo.append(TrackInfo( title='oneNew', track_id='dfa939ec-118c-4d0f-84a0-60f3d1e6522c', medium=1, medium_index=1, medium_total=1, index=1, artist_credit='trackArtistCredit', artist_sort='trackArtistSort', )) trackinfo.append(TrackInfo( title='twoNew', track_id='40130ed1-a27c-42fd-a328-1ebefb6caef4', medium=2, medium_index=1, index=2, medium_total=1, )) self.info = AlbumInfo( tracks=trackinfo, artist='artistNew', album='albumNew', album_id='7edb51cb-77d6-4416-a23c-3a8c2994a2c7', artist_id='a6623d39-2d8e-4f70-8242-0a9553b91e50', artist_credit='albumArtistCredit', artist_sort='albumArtistSort', albumtype='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_artist_credit(self): self._apply(artist_credit=True) self.assertEqual(self.items[0].artist, 'trackArtistCredit') self.assertEqual(self.items[1].artist, 'albumArtistCredit') self.assertEqual(self.items[0].albumartist, 'albumArtistCredit') self.assertEqual(self.items[1].albumartist, 'albumArtistCredit') def test_artist_credit_prefers_artist_over_albumartist_credit(self): self.info.tracks[0].artist = 'oldArtist' self.info.tracks[0].artist_credit = None self._apply(artist_credit=True) self.assertEqual(self.items[0].artist, 'oldArtist') def test_artist_credit_falls_back_to_albumartist(self): self.info.artist_credit = None self._apply(artist_credit=True) self.assertEqual(self.items[1].artist, 'artistNew') 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 = self.info.copy() self._apply(info=my_info) self.assertEqual(self.items[0].artist, 'artistNew') self.assertEqual(self.items[1].artist, 'artistNew') def test_album_artist_overridden_by_nonempty_track_artist(self): my_info = self.info.copy() 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') def test_full_date_applied(self): my_info = self.info.copy() my_info.year = 2013 my_info.month = 12 my_info.day = 18 self._apply(info=my_info) self.assertEqual(self.items[0].year, 2013) self.assertEqual(self.items[0].month, 12) self.assertEqual(self.items[0].day, 18) def test_date_only_zeros_month_and_day(self): self.items = [] self.items.append(Item(year=1, month=2, day=3)) self.items.append(Item(year=4, month=5, day=6)) my_info = self.info.copy() my_info.year = 2013 self._apply(info=my_info) self.assertEqual(self.items[0].year, 2013) self.assertEqual(self.items[0].month, 0) self.assertEqual(self.items[0].day, 0) def test_missing_date_applies_nothing(self): self.items = [] self.items.append(Item(year=1, month=2, day=3)) self.items.append(Item(year=4, month=5, day=6)) self._apply() self.assertEqual(self.items[0].year, 1) self.assertEqual(self.items[0].month, 2) self.assertEqual(self.items[0].day, 3) def test_data_source_applied(self): my_info = self.info.copy() my_info.data_source = 'MusicBrainz' self._apply(info=my_info) self.assertEqual(self.items[0].data_source, 'MusicBrainz') class ApplyCompilationTest(_common.TestCase, ApplyTestUtil): def setUp(self): super().setUp() self.items = [] self.items.append(Item({})) self.items.append(Item({})) trackinfo = [] trackinfo.append(TrackInfo( title='oneNew', track_id='dfa939ec-118c-4d0f-84a0-60f3d1e6522c', artist='artistOneNew', artist_id='a05686fc-9db2-4c23-b99e-77f5db3e5282', index=1, )) trackinfo.append(TrackInfo( title='twoNew', track_id='40130ed1-a27c-42fd-a328-1ebefb6caef4', artist='artistTwoNew', artist_id='80b3cf5e-18fe-4c59-98c7-e5bb87210710', index=2, )) self.info = AlbumInfo( tracks=trackinfo, artist='variousNew', album='albumNew', album_id='3b69ea40-39b8-487f-8818-04b6eff8c21a', artist_id='89ad4ac3-39f7-470e-963a-56509c546377', albumtype='compilation', ) 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 = self.info.copy() 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('Some String', 'Some String') self.assertEqual(dist, 0.0) def test_different_strings(self): dist = string_dist('Some String', 'Totally Different') self.assertNotEqual(dist, 0.0) def test_punctuation_ignored(self): dist = string_dist('Some String', 'Some.String!') self.assertEqual(dist, 0.0) def test_case_ignored(self): dist = string_dist('Some String', 'sOME sTring') self.assertEqual(dist, 0.0) def test_leading_the_has_lower_weight(self): dist1 = string_dist('XXX Band Name', 'Band Name') dist2 = string_dist('The Band Name', 'Band Name') self.assertTrue(dist2 < dist1) def test_parens_have_lower_weight(self): dist1 = string_dist('One .Two.', 'One') dist2 = string_dist('One (Two)', 'One') self.assertTrue(dist2 < dist1) def test_brackets_have_lower_weight(self): dist1 = string_dist('One .Two.', 'One') dist2 = string_dist('One [Two]', 'One') self.assertTrue(dist2 < dist1) def test_ep_label_has_zero_weight(self): dist = string_dist('My Song (EP)', 'My Song') self.assertEqual(dist, 0.0) def test_featured_has_lower_weight(self): dist1 = string_dist('My Song blah Someone', 'My Song') dist2 = string_dist('My Song feat Someone', 'My Song') self.assertTrue(dist2 < dist1) def test_postfix_the(self): dist = string_dist('The Song Title', 'Song Title, The') self.assertEqual(dist, 0.0) def test_postfix_a(self): dist = string_dist('A Song Title', 'Song Title, A') self.assertEqual(dist, 0.0) def test_postfix_an(self): dist = string_dist('An Album Title', 'Album Title, An') self.assertEqual(dist, 0.0) def test_empty_strings(self): dist = string_dist('', '') self.assertEqual(dist, 0.0) def test_solo_pattern(self): # Just make sure these don't crash. string_dist('The ', '') string_dist('(EP)', '(EP)') string_dist(', An', '') def test_heuristic_does_not_harm_distance(self): dist = string_dist('Untitled', '[Untitled]') self.assertEqual(dist, 0.0) def test_ampersand_expansion(self): dist = string_dist('And', '&') self.assertEqual(dist, 0.0) def test_accented_characters(self): dist = string_dist('\xe9\xe1\xf1', 'ean') self.assertEqual(dist, 0.0) class EnumTest(_common.TestCase): """ Test Enum Subclasses defined in beets.util.enumeration """ def test_ordered_enum(self): OrderedEnumClass = match.OrderedEnum('OrderedEnumTest', ['a', 'b', 'c']) # noqa self.assertLess(OrderedEnumClass.a, OrderedEnumClass.b) self.assertLess(OrderedEnumClass.a, OrderedEnumClass.c) self.assertLess(OrderedEnumClass.b, OrderedEnumClass.c) self.assertGreater(OrderedEnumClass.b, OrderedEnumClass.a) self.assertGreater(OrderedEnumClass.c, OrderedEnumClass.a) self.assertGreater(OrderedEnumClass.c, OrderedEnumClass.b) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_bareasc.py0000644000076500000240000001102100000000000016310 0ustar00asampsonstaff# This file is part of beets. # Copyright 2021, Graham R. Cobb. """Tests for the 'bareasc' plugin.""" import unittest from test.helper import capture_stdout, TestHelper from beets import logging class BareascPluginTest(unittest.TestCase, TestHelper): """Test bare ASCII query matching.""" def setUp(self): """Set up test environment for bare ASCII query matching.""" self.setup_beets() self.log = logging.getLogger('beets.web') self.config['bareasc']['prefix'] = '#' self.load_plugins('bareasc') # Add library elements. Note that self.lib.add overrides any "id=" # and assigns the next free id number. self.add_item(title='with accents', album_id=2, artist='Antonín Dvořák') self.add_item(title='without accents', artist='Antonín Dvorak') self.add_item(title='with umlaut', album_id=2, artist='Brüggen') self.add_item(title='without umlaut or e', artist='Bruggen') self.add_item(title='without umlaut with e', artist='Brueggen') def test_search_normal_noaccent(self): """Normal search, no accents, not using bare-ASCII match. Finds just the unaccented entry. """ items = self.lib.items('dvorak') self.assertEqual(len(items), 1) self.assertEqual([items[0].title], ['without accents']) def test_search_normal_accent(self): """Normal search, with accents, not using bare-ASCII match. Finds just the accented entry. """ items = self.lib.items('dvořák') self.assertEqual(len(items), 1) self.assertEqual([items[0].title], ['with accents']) def test_search_bareasc_noaccent(self): """Bare-ASCII search, no accents. Finds both entries. """ items = self.lib.items('#dvorak') self.assertEqual(len(items), 2) self.assertEqual( {items[0].title, items[1].title}, {'without accents', 'with accents'} ) def test_search_bareasc_accent(self): """Bare-ASCII search, with accents. Finds both entries. """ items = self.lib.items('#dvořák') self.assertEqual(len(items), 2) self.assertEqual( {items[0].title, items[1].title}, {'without accents', 'with accents'} ) def test_search_bareasc_wrong_accent(self): """Bare-ASCII search, with incorrect accent. Finds both entries. """ items = self.lib.items('#dvořäk') self.assertEqual(len(items), 2) self.assertEqual( {items[0].title, items[1].title}, {'without accents', 'with accents'} ) def test_search_bareasc_noumlaut(self): """Bare-ASCII search, with no umlaut. Finds entry with 'u' not 'ue', although German speaker would normally replace ü with ue. This is expected behaviour for this simple plugin. """ items = self.lib.items('#Bruggen') self.assertEqual(len(items), 2) self.assertEqual( {items[0].title, items[1].title}, {'without umlaut or e', 'with umlaut'} ) def test_search_bareasc_umlaut(self): """Bare-ASCII search, with umlaut. Finds entry with 'u' not 'ue', although German speaker would normally replace ü with ue. This is expected behaviour for this simple plugin. """ items = self.lib.items('#Brüggen') self.assertEqual(len(items), 2) self.assertEqual( {items[0].title, items[1].title}, {'without umlaut or e', 'with umlaut'} ) def test_bareasc_list_output(self): """Bare-ASCII version of list command - check output.""" with capture_stdout() as output: self.run_command('bareasc', 'with accents') self.assertIn('Antonin Dvorak', output.getvalue()) def test_bareasc_format_output(self): """Bare-ASCII version of list -f command - check output.""" with capture_stdout() as output: self.run_command('bareasc', 'with accents', '-f', '$artist:: $title') self.assertEqual('Antonin Dvorak:: with accents\n', output.getvalue()) def suite(): """loader.""" return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_beatport.py0000644000076500000240000004600400000000000016541 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'beatport' plugin. """ import unittest from test import _common from test.helper import TestHelper from datetime import timedelta from beetsplug import beatport from beets import library class BeatportTest(_common.TestCase, TestHelper): def _make_release_response(self): """Returns a dict that mimics a response from the beatport API. The results were retrieved from: https://oauth-api.beatport.com/catalog/3/releases?id=1742984 The list of elements on the returned dict is incomplete, including just those required for the tests on this class. """ results = { "id": 1742984, "type": "release", "name": "Charade", "slug": "charade", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "audioFormat": "", "category": "Release", "currentStatus": "General Content", "catalogNumber": "GR089", "description": "", "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings" }, "artists": [{ "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist" }], "genres": [{ "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre" }], } return results def _make_tracks_response(self): """Return a list that mimics a response from the beatport API. The results were retrieved from: https://oauth-api.beatport.com/catalog/3/tracks?releaseId=1742984 The list of elements on the returned list is incomplete, including just those required for the tests on this class. """ results = [{ "id": 7817567, "type": "track", "sku": "track-7817567", "name": "Mirage a Trois", "trackNumber": 1, "mixName": "Original Mix", "title": "Mirage a Trois (Original Mix)", "slug": "mirage-a-trois-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "7:05", "lengthMs": 425421, "bpm": 90, "key": { "standard": { "letter": "G", "sharp": False, "flat": False, "chord": "minor" }, "shortName": "Gmin" }, "artists": [{ "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist" }], "genres": [{ "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre" }], "subGenres": [{ "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre" }], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade" }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True } }, { "id": 7817568, "type": "track", "sku": "track-7817568", "name": "Aeon Bahamut", "trackNumber": 2, "mixName": "Original Mix", "title": "Aeon Bahamut (Original Mix)", "slug": "aeon-bahamut-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "7:38", "lengthMs": 458000, "bpm": 100, "key": { "standard": { "letter": "G", "sharp": False, "flat": False, "chord": "major" }, "shortName": "Gmaj" }, "artists": [{ "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist" }], "genres": [{ "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre" }], "subGenres": [{ "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre" }], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade" }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True } }, { "id": 7817569, "type": "track", "sku": "track-7817569", "name": "Trancendental Medication", "trackNumber": 3, "mixName": "Original Mix", "title": "Trancendental Medication (Original Mix)", "slug": "trancendental-medication-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "1:08", "lengthMs": 68571, "bpm": 141, "key": { "standard": { "letter": "F", "sharp": False, "flat": False, "chord": "major" }, "shortName": "Fmaj" }, "artists": [{ "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist" }], "genres": [{ "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre" }], "subGenres": [{ "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre" }], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade" }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True } }, { "id": 7817570, "type": "track", "sku": "track-7817570", "name": "A List of Instructions for When I'm Human", "trackNumber": 4, "mixName": "Original Mix", "title": "A List of Instructions for When I'm Human (Original Mix)", "slug": "a-list-of-instructions-for-when-im-human-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "6:57", "lengthMs": 417913, "bpm": 88, "key": { "standard": { "letter": "A", "sharp": False, "flat": False, "chord": "minor" }, "shortName": "Amin" }, "artists": [{ "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist" }], "genres": [{ "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre" }], "subGenres": [{ "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre" }], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade" }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True } }, { "id": 7817571, "type": "track", "sku": "track-7817571", "name": "The Great Shenanigan", "trackNumber": 5, "mixName": "Original Mix", "title": "The Great Shenanigan (Original Mix)", "slug": "the-great-shenanigan-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "9:49", "lengthMs": 589875, "bpm": 123, "key": { "standard": { "letter": "E", "sharp": False, "flat": True, "chord": "major" }, "shortName": "E♭maj" }, "artists": [{ "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist" }], "genres": [{ "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre" }], "subGenres": [{ "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre" }], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade" }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True } }, { "id": 7817572, "type": "track", "sku": "track-7817572", "name": "Charade", "trackNumber": 6, "mixName": "Original Mix", "title": "Charade (Original Mix)", "slug": "charade-original-mix", "releaseDate": "2016-04-11", "publishDate": "2016-04-11", "currentStatus": "General Content", "length": "7:05", "lengthMs": 425423, "bpm": 123, "key": { "standard": { "letter": "A", "sharp": False, "flat": False, "chord": "major" }, "shortName": "Amaj" }, "artists": [{ "id": 326158, "name": "Supersillyus", "slug": "supersillyus", "type": "artist" }], "genres": [{ "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre" }], "subGenres": [{ "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre" }], "release": { "id": 1742984, "name": "Charade", "type": "release", "slug": "charade" }, "label": { "id": 24539, "name": "Gravitas Recordings", "type": "label", "slug": "gravitas-recordings", "status": True } }] return results def setUp(self): self.setup_beets() self.load_plugins('beatport') self.lib = library.Library(':memory:') # Set up 'album'. response_release = self._make_release_response() self.album = beatport.BeatportRelease(response_release) # Set up 'tracks'. response_tracks = self._make_tracks_response() self.tracks = [beatport.BeatportTrack(t) for t in response_tracks] # Set up 'test_album'. self.test_album = self.mk_test_album() # Set up 'test_tracks' self.test_tracks = self.test_album.items() def tearDown(self): self.unload_plugins() self.teardown_beets() def mk_test_album(self): items = [_common.item() for _ in range(6)] for item in items: item.album = 'Charade' item.catalognum = 'GR089' item.label = 'Gravitas Recordings' item.artist = 'Supersillyus' item.year = 2016 item.comp = False item.label_name = 'Gravitas Recordings' item.genre = 'Glitch Hop' item.year = 2016 item.month = 4 item.day = 11 item.mix_name = 'Original Mix' items[0].title = 'Mirage a Trois' items[1].title = 'Aeon Bahamut' items[2].title = 'Trancendental Medication' items[3].title = 'A List of Instructions for When I\'m Human' items[4].title = 'The Great Shenanigan' items[5].title = 'Charade' items[0].length = timedelta(minutes=7, seconds=5).total_seconds() items[1].length = timedelta(minutes=7, seconds=38).total_seconds() items[2].length = timedelta(minutes=1, seconds=8).total_seconds() items[3].length = timedelta(minutes=6, seconds=57).total_seconds() items[4].length = timedelta(minutes=9, seconds=49).total_seconds() items[5].length = timedelta(minutes=7, seconds=5).total_seconds() items[0].url = 'mirage-a-trois-original-mix' items[1].url = 'aeon-bahamut-original-mix' items[2].url = 'trancendental-medication-original-mix' items[3].url = 'a-list-of-instructions-for-when-im-human-original-mix' items[4].url = 'the-great-shenanigan-original-mix' items[5].url = 'charade-original-mix' counter = 0 for item in items: counter += 1 item.track_number = counter items[0].bpm = 90 items[1].bpm = 100 items[2].bpm = 141 items[3].bpm = 88 items[4].bpm = 123 items[5].bpm = 123 items[0].initial_key = 'Gmin' items[1].initial_key = 'Gmaj' items[2].initial_key = 'Fmaj' items[3].initial_key = 'Amin' items[4].initial_key = 'E♭maj' items[5].initial_key = 'Amaj' for item in items: self.lib.add(item) album = self.lib.add_album(items) album.store() return album # Test BeatportRelease. def test_album_name_applied(self): self.assertEqual(self.album.name, self.test_album['album']) def test_catalog_number_applied(self): self.assertEqual(self.album.catalog_number, self.test_album['catalognum']) def test_label_applied(self): self.assertEqual(self.album.label_name, self.test_album['label']) def test_category_applied(self): self.assertEqual(self.album.category, 'Release') def test_album_url_applied(self): self.assertEqual(self.album.url, 'https://beatport.com/release/charade/1742984') # Test BeatportTrack. def test_title_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): self.assertEqual(track.name, test_track.title) def test_mix_name_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): self.assertEqual(track.mix_name, test_track.mix_name) def test_length_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): self.assertEqual(int(track.length.total_seconds()), int(test_track.length)) def test_track_url_applied(self): # Specify beatport ids here because an 'item.id' is beets-internal. ids = [ 7817567, 7817568, 7817569, 7817570, 7817571, 7817572, ] # Concatenate with 'id' to pass strict equality test. for track, test_track, id in zip(self.tracks, self.test_tracks, ids): self.assertEqual( track.url, 'https://beatport.com/track/' + test_track.url + '/' + str(id)) def test_bpm_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): self.assertEqual(track.bpm, test_track.bpm) def test_initial_key_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): self.assertEqual(track.initial_key, test_track.initial_key) def test_genre_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): self.assertEqual(track.genre, test_track.genre) class BeatportResponseEmptyTest(_common.TestCase, TestHelper): def _make_tracks_response(self): results = [{ "id": 7817567, "name": "Mirage a Trois", "genres": [{ "id": 9, "name": "Breaks", "slug": "breaks", "type": "genre" }], "subGenres": [{ "id": 209, "name": "Glitch Hop", "slug": "glitch-hop", "type": "subgenre" }], }] return results def setUp(self): self.setup_beets() self.load_plugins('beatport') self.lib = library.Library(':memory:') # Set up 'tracks'. self.response_tracks = self._make_tracks_response() self.tracks = [beatport.BeatportTrack(t) for t in self.response_tracks] # Make alias to be congruent with class `BeatportTest`. self.test_tracks = self.response_tracks def tearDown(self): self.unload_plugins() self.teardown_beets() def test_response_tracks_empty(self): response_tracks = [] tracks = [beatport.BeatportTrack(t) for t in response_tracks] self.assertEqual(tracks, []) def test_sub_genre_empty_fallback(self): """No 'sub_genre' is provided. Test if fallback to 'genre' works. """ self.response_tracks[0]['subGenres'] = [] tracks = [beatport.BeatportTrack(t) for t in self.response_tracks] self.test_tracks[0]['subGenres'] = [] self.assertEqual(tracks[0].genre, self.test_tracks[0]['genres'][0]['name']) def test_genre_empty(self): """No 'genre' is provided. Test if 'sub_genre' is applied. """ self.response_tracks[0]['genres'] = [] tracks = [beatport.BeatportTrack(t) for t in self.response_tracks] self.test_tracks[0]['genres'] = [] self.assertEqual(tracks[0].genre, self.test_tracks[0]['subGenres'][0]['name']) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_bucket.py0000644000076500000240000001645400000000000016204 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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. """Tests for the 'bucket' plugin.""" import unittest from beetsplug import bucket from beets import config, ui from test.helper import TestHelper class BucketPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.plugin = bucket.BucketPlugin() def tearDown(self): self.teardown_beets() def _setup_config(self, bucket_year=[], bucket_alpha=[], bucket_alpha_regex={}, extrapolate=False): config['bucket']['bucket_year'] = bucket_year config['bucket']['bucket_alpha'] = bucket_alpha config['bucket']['bucket_alpha_regex'] = bucket_alpha_regex config['bucket']['extrapolate'] = extrapolate self.plugin.setup() def test_year_single_year(self): """If a single year is given, range starts from this year and stops at the year preceding the one of next bucket.""" self._setup_config(bucket_year=['1950s', '1970s']) self.assertEqual(self.plugin._tmpl_bucket('1959'), '1950s') self.assertEqual(self.plugin._tmpl_bucket('1969'), '1950s') def test_year_single_year_last_folder(self): """If a single year is given for the last bucket, extend it to current year.""" self._setup_config(bucket_year=['1950', '1970']) self.assertEqual(self.plugin._tmpl_bucket('2014'), '1970') self.assertEqual(self.plugin._tmpl_bucket('2025'), '2025') def test_year_two_years(self): """Buckets can be named with the 'from-to' syntax.""" self._setup_config(bucket_year=['1950-59', '1960-1969']) self.assertEqual(self.plugin._tmpl_bucket('1959'), '1950-59') self.assertEqual(self.plugin._tmpl_bucket('1969'), '1960-1969') def test_year_multiple_years(self): """Buckets can be named by listing all the years""" self._setup_config(bucket_year=['1950,51,52,53']) self.assertEqual(self.plugin._tmpl_bucket('1953'), '1950,51,52,53') self.assertEqual(self.plugin._tmpl_bucket('1974'), '1974') def test_year_out_of_range(self): """If no range match, return the year""" self._setup_config(bucket_year=['1950-59', '1960-69']) self.assertEqual(self.plugin._tmpl_bucket('1974'), '1974') self._setup_config(bucket_year=[]) self.assertEqual(self.plugin._tmpl_bucket('1974'), '1974') def test_year_out_of_range_extrapolate(self): """If no defined range match, extrapolate all ranges using the most common syntax amongst existing buckets and return the matching one.""" self._setup_config(bucket_year=['1950-59', '1960-69'], extrapolate=True) self.assertEqual(self.plugin._tmpl_bucket('1914'), '1910-19') # pick single year format self._setup_config(bucket_year=['1962-81', '2002', '2012'], extrapolate=True) self.assertEqual(self.plugin._tmpl_bucket('1983'), '1982') # pick from-end format self._setup_config(bucket_year=['1962-81', '2002', '2012-14'], extrapolate=True) self.assertEqual(self.plugin._tmpl_bucket('1983'), '1982-01') # extrapolate add ranges, but never modifies existing ones self._setup_config(bucket_year=['1932', '1942', '1952', '1962-81', '2002'], extrapolate=True) self.assertEqual(self.plugin._tmpl_bucket('1975'), '1962-81') def test_alpha_all_chars(self): """Alphabet buckets can be named by listing all their chars""" self._setup_config(bucket_alpha=['ABCD', 'FGH', 'IJKL']) self.assertEqual(self.plugin._tmpl_bucket('garry'), 'FGH') def test_alpha_first_last_chars(self): """Alphabet buckets can be named by listing the 'from-to' syntax""" self._setup_config(bucket_alpha=['0->9', 'A->D', 'F-H', 'I->Z']) self.assertEqual(self.plugin._tmpl_bucket('garry'), 'F-H') self.assertEqual(self.plugin._tmpl_bucket('2pac'), '0->9') def test_alpha_out_of_range(self): """If no range match, return the initial""" self._setup_config(bucket_alpha=['ABCD', 'FGH', 'IJKL']) self.assertEqual(self.plugin._tmpl_bucket('errol'), 'E') self._setup_config(bucket_alpha=[]) self.assertEqual(self.plugin._tmpl_bucket('errol'), 'E') def test_alpha_regex(self): """Check regex is used""" self._setup_config(bucket_alpha=['foo', 'bar'], bucket_alpha_regex={'foo': '^[a-d]', 'bar': '^[e-z]'}) self.assertEqual(self.plugin._tmpl_bucket('alpha'), 'foo') self.assertEqual(self.plugin._tmpl_bucket('delta'), 'foo') self.assertEqual(self.plugin._tmpl_bucket('zeta'), 'bar') self.assertEqual(self.plugin._tmpl_bucket('Alpha'), 'A') def test_alpha_regex_mix(self): """Check mixing regex and non-regex is possible""" self._setup_config(bucket_alpha=['A - D', 'E - L'], bucket_alpha_regex={'A - D': '^[0-9a-dA-D…äÄ]'}) self.assertEqual(self.plugin._tmpl_bucket('alpha'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('Ärzte'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('112'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('…and Oceans'), 'A - D') self.assertEqual(self.plugin._tmpl_bucket('Eagles'), 'E - L') def test_bad_alpha_range_def(self): """If bad alpha range definition, a UserError is raised.""" with self.assertRaises(ui.UserError): self._setup_config(bucket_alpha=['$%']) def test_bad_year_range_def_no4digits(self): """If bad year range definition, a UserError is raised. Range origin must be expressed on 4 digits. """ with self.assertRaises(ui.UserError): self._setup_config(bucket_year=['62-64']) def test_bad_year_range_def_nodigits(self): """If bad year range definition, a UserError is raised. At least the range origin must be declared. """ with self.assertRaises(ui.UserError): self._setup_config(bucket_year=['nodigits']) def check_span_from_str(self, sstr, dfrom, dto): d = bucket.span_from_str(sstr) self.assertEqual(dfrom, d['from']) self.assertEqual(dto, d['to']) def test_span_from_str(self): self.check_span_from_str("1980 2000", 1980, 2000) self.check_span_from_str("1980 00", 1980, 2000) self.check_span_from_str("1930 00", 1930, 2000) self.check_span_from_str("1930 50", 1930, 1950) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_config_command.py0000644000076500000240000001115700000000000017665 0ustar00asampsonstaffimport os import yaml from unittest.mock import patch from tempfile import mkdtemp from shutil import rmtree import unittest from beets import ui from beets import config from test.helper import TestHelper from beets.library import Library class ConfigCommandTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = Library(':memory:') self.temp_dir = mkdtemp() if 'EDITOR' in os.environ: del os.environ['EDITOR'] os.environ['BEETSDIR'] = self.temp_dir self.config_path = os.path.join(self.temp_dir, 'config.yaml') with open(self.config_path, 'w') as file: file.write('library: lib\n') file.write('option: value\n') file.write('password: password_value') self.cli_config_path = os.path.join(self.temp_dir, 'cli_config.yaml') with open(self.cli_config_path, 'w') as file: file.write('option: cli overwrite') config.clear() config['password'].redact = True config._materialized = False def tearDown(self): rmtree(self.temp_dir) def _run_with_yaml_output(self, *args): output = self.run_with_output(*args) return yaml.safe_load(output) def test_show_user_config(self): output = self._run_with_yaml_output('config', '-c') self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'password_value') def test_show_user_config_with_defaults(self): output = self._run_with_yaml_output('config', '-dc') self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'password_value') self.assertEqual(output['library'], 'lib') self.assertEqual(output['import']['timid'], False) def test_show_user_config_with_cli(self): output = self._run_with_yaml_output('--config', self.cli_config_path, 'config') self.assertEqual(output['library'], 'lib') self.assertEqual(output['option'], 'cli overwrite') def test_show_redacted_user_config(self): output = self._run_with_yaml_output('config') self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'REDACTED') def test_show_redacted_user_config_with_defaults(self): output = self._run_with_yaml_output('config', '-d') self.assertEqual(output['option'], 'value') self.assertEqual(output['password'], 'REDACTED') self.assertEqual(output['import']['timid'], False) def test_config_paths(self): output = self.run_with_output('config', '-p') paths = output.split('\n') self.assertEqual(len(paths), 2) self.assertEqual(paths[0], self.config_path) def test_config_paths_with_cli(self): output = self.run_with_output('--config', self.cli_config_path, 'config', '-p') paths = output.split('\n') self.assertEqual(len(paths), 3) self.assertEqual(paths[0], self.cli_config_path) def test_edit_config_with_editor_env(self): os.environ['EDITOR'] = 'myeditor' with patch('os.execlp') as execlp: self.run_command('config', '-e') execlp.assert_called_once_with( 'myeditor', 'myeditor', self.config_path) def test_edit_config_with_automatic_open(self): with patch('beets.util.open_anything') as open: open.return_value = 'please_open' with patch('os.execlp') as execlp: self.run_command('config', '-e') execlp.assert_called_once_with( 'please_open', 'please_open', self.config_path) def test_config_editor_not_found(self): with self.assertRaises(ui.UserError) as user_error: with patch('os.execlp') as execlp: execlp.side_effect = OSError('here is problem') self.run_command('config', '-e') self.assertIn('Could not edit configuration', str(user_error.exception)) self.assertIn('here is problem', str(user_error.exception)) def test_edit_invalid_config_file(self): with open(self.config_path, 'w') as file: file.write('invalid: [') config.clear() config._materialized = False os.environ['EDITOR'] = 'myeditor' with patch('os.execlp') as execlp: self.run_command('config', '-e') execlp.assert_called_once_with( 'myeditor', 'myeditor', self.config_path) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_convert.py0000644000076500000240000002416700000000000016407 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import fnmatch import sys import re import os.path import unittest from test import _common from test import helper from test.helper import control_stdin, capture_log from mediafile import MediaFile from beets import util def shell_quote(text): import shlex return shlex.quote(text) class TestHelper(helper.TestHelper): def tagged_copy_cmd(self, tag): """Return a conversion command that copies files and appends `tag` to the copy. """ if re.search('[^a-zA-Z0-9]', tag): raise ValueError("tag '{}' must only contain letters and digits" .format(tag)) # A Python script that copies the file and appends a tag. stub = os.path.join(_common.RSRC, b'convert_stub.py').decode('utf-8') return "{} {} $source $dest {}".format(shell_quote(sys.executable), shell_quote(stub), tag) def assertFileTag(self, path, tag): # noqa """Assert that the path is a file and the files content ends with `tag`. """ display_tag = tag tag = tag.encode('utf-8') self.assertTrue(os.path.isfile(path), '{} is not a file'.format( util.displayable_path(path))) with open(path, 'rb') as f: f.seek(-len(display_tag), os.SEEK_END) self.assertEqual(f.read(), tag, '{} is not tagged with {}' .format( util.displayable_path(path), display_tag)) def assertNoFileTag(self, path, tag): # noqa """Assert that the path is a file and the files content does not end with `tag`. """ display_tag = tag tag = tag.encode('utf-8') self.assertTrue(os.path.isfile(path), '{} is not a file'.format( util.displayable_path(path))) with open(path, 'rb') as f: f.seek(-len(tag), os.SEEK_END) self.assertNotEqual(f.read(), tag, '{} is unexpectedly tagged with {}' .format( util.displayable_path(path), display_tag)) @_common.slow_test() class ImportConvertTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets(disk=True) # Converter is threaded self.importer = self.create_importer() self.load_plugins('convert') self.config['convert'] = { 'dest': os.path.join(self.temp_dir, b'convert'), 'command': self.tagged_copy_cmd('convert'), # Enforce running convert 'max_bitrate': 1, 'auto': True, 'quiet': False, } def tearDown(self): self.unload_plugins() self.teardown_beets() def test_import_converted(self): self.importer.run() item = self.lib.items().get() self.assertFileTag(item.path, 'convert') @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_import_original_on_convert_error(self): # `false` exits with non-zero code self.config['convert']['command'] = 'false' self.importer.run() item = self.lib.items().get() self.assertIsNotNone(item) self.assertTrue(os.path.isfile(item.path)) def test_delete_originals(self): self.config['convert']['delete_originals'] = True self.importer.run() for path in self.importer.paths: for root, dirnames, filenames in os.walk(path): self.assertTrue(len(fnmatch.filter(filenames, '*.mp3')) == 0, 'Non-empty import directory {}' .format(util.displayable_path(path))) class ConvertCommand: """A mixin providing a utility method to run the `convert`command in tests. """ def run_convert_path(self, path, *args): """Run the `convert` command on a given path.""" # The path is currently a filesystem bytestring. Convert it to # an argument bytestring. path = path.decode(util._fsencoding()).encode(util.arg_encoding()) args = args + (b'path:' + path,) return self.run_command('convert', *args) def run_convert(self, *args): """Run the `convert` command on `self.item`.""" return self.run_convert_path(self.item.path, *args) @_common.slow_test() class ConvertCliTest(unittest.TestCase, TestHelper, ConvertCommand): def setUp(self): self.setup_beets(disk=True) # Converter is threaded self.album = self.add_album_fixture(ext='ogg') self.item = self.album.items()[0] self.load_plugins('convert') self.convert_dest = util.bytestring_path( os.path.join(self.temp_dir, b'convert_dest') ) self.config['convert'] = { 'dest': self.convert_dest, 'paths': {'default': 'converted'}, 'format': 'mp3', 'formats': { 'mp3': self.tagged_copy_cmd('mp3'), 'opus': { 'command': self.tagged_copy_cmd('opus'), 'extension': 'ops', } } } def tearDown(self): self.unload_plugins() self.teardown_beets() def test_convert(self): with control_stdin('y'): self.run_convert() converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') def test_convert_with_auto_confirmation(self): self.run_convert('--yes') converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') def test_reject_confirmation(self): with control_stdin('n'): self.run_convert() converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFalse(os.path.isfile(converted)) def test_convert_keep_new(self): self.assertEqual(os.path.splitext(self.item.path)[1], b'.ogg') with control_stdin('y'): self.run_convert('--keep-new') self.item.load() self.assertEqual(os.path.splitext(self.item.path)[1], b'.mp3') def test_format_option(self): with control_stdin('y'): self.run_convert('--format', 'opus') converted = os.path.join(self.convert_dest, b'converted.ops') self.assertFileTag(converted, 'opus') def test_embed_album_art(self): self.config['convert']['embed'] = True image_path = os.path.join(_common.RSRC, b'image-2x3.jpg') self.album.artpath = image_path self.album.store() with open(os.path.join(image_path), 'rb') as f: image_data = f.read() with control_stdin('y'): self.run_convert() converted = os.path.join(self.convert_dest, b'converted.mp3') mediafile = MediaFile(converted) self.assertEqual(mediafile.images[0].data, image_data) def test_skip_existing(self): converted = os.path.join(self.convert_dest, b'converted.mp3') self.touch(converted, content='XXX') self.run_convert('--yes') with open(converted) as f: self.assertEqual(f.read(), 'XXX') def test_pretend(self): self.run_convert('--pretend') converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFalse(os.path.exists(converted)) def test_empty_query(self): with capture_log('beets.convert') as logs: self.run_convert('An impossible query') self.assertEqual(logs[0], 'convert: Empty query result.') @_common.slow_test() class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper, ConvertCommand): """Test the effect of the `never_convert_lossy_files` option. """ def setUp(self): self.setup_beets(disk=True) # Converter is threaded self.load_plugins('convert') self.convert_dest = os.path.join(self.temp_dir, b'convert_dest') self.config['convert'] = { 'dest': self.convert_dest, 'paths': {'default': 'converted'}, 'never_convert_lossy_files': True, 'format': 'mp3', 'formats': { 'mp3': self.tagged_copy_cmd('mp3'), } } def tearDown(self): self.unload_plugins() self.teardown_beets() def test_transcode_from_lossles(self): [item] = self.add_item_fixtures(ext='flac') with control_stdin('y'): self.run_convert_path(item.path) converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') def test_transcode_from_lossy(self): self.config['convert']['never_convert_lossy_files'] = False [item] = self.add_item_fixtures(ext='ogg') with control_stdin('y'): self.run_convert_path(item.path) converted = os.path.join(self.convert_dest, b'converted.mp3') self.assertFileTag(converted, 'mp3') def test_transcode_from_lossy_prevented(self): [item] = self.add_item_fixtures(ext='ogg') with control_stdin('y'): self.run_convert_path(item.path) converted = os.path.join(self.convert_dest, b'converted.ogg') self.assertNoFileTag(converted, 'mp3') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_datequery.py0000644000076500000240000002750000000000000016724 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test for dbcore's date-based queries. """ from test import _common from datetime import datetime, timedelta import unittest import time from beets.dbcore.query import _parse_periods, DateInterval, DateQuery,\ InvalidQueryArgumentValueError def _date(string): return datetime.strptime(string, '%Y-%m-%dT%H:%M:%S') def _datepattern(datetimedate): return datetimedate.strftime('%Y-%m-%dT%H:%M:%S') class DateIntervalTest(unittest.TestCase): def test_year_precision_intervals(self): self.assertContains('2000..2001', '2000-01-01T00:00:00') self.assertContains('2000..2001', '2001-06-20T14:15:16') self.assertContains('2000..2001', '2001-12-31T23:59:59') self.assertExcludes('2000..2001', '1999-12-31T23:59:59') self.assertExcludes('2000..2001', '2002-01-01T00:00:00') self.assertContains('2000..', '2000-01-01T00:00:00') self.assertContains('2000..', '2099-10-11T00:00:00') self.assertExcludes('2000..', '1999-12-31T23:59:59') self.assertContains('..2001', '2001-12-31T23:59:59') self.assertExcludes('..2001', '2002-01-01T00:00:00') self.assertContains('-1d..1d', _datepattern(datetime.now())) self.assertExcludes('-2d..-1d', _datepattern(datetime.now())) def test_day_precision_intervals(self): self.assertContains('2000-06-20..2000-06-20', '2000-06-20T00:00:00') self.assertContains('2000-06-20..2000-06-20', '2000-06-20T10:20:30') self.assertContains('2000-06-20..2000-06-20', '2000-06-20T23:59:59') self.assertExcludes('2000-06-20..2000-06-20', '2000-06-19T23:59:59') self.assertExcludes('2000-06-20..2000-06-20', '2000-06-21T00:00:00') def test_month_precision_intervals(self): self.assertContains('1999-12..2000-02', '1999-12-01T00:00:00') self.assertContains('1999-12..2000-02', '2000-02-15T05:06:07') self.assertContains('1999-12..2000-02', '2000-02-29T23:59:59') self.assertExcludes('1999-12..2000-02', '1999-11-30T23:59:59') self.assertExcludes('1999-12..2000-02', '2000-03-01T00:00:00') def test_hour_precision_intervals(self): # test with 'T' separator self.assertExcludes('2000-01-01T12..2000-01-01T13', '2000-01-01T11:59:59') self.assertContains('2000-01-01T12..2000-01-01T13', '2000-01-01T12:00:00') self.assertContains('2000-01-01T12..2000-01-01T13', '2000-01-01T12:30:00') self.assertContains('2000-01-01T12..2000-01-01T13', '2000-01-01T13:30:00') self.assertContains('2000-01-01T12..2000-01-01T13', '2000-01-01T13:59:59') self.assertExcludes('2000-01-01T12..2000-01-01T13', '2000-01-01T14:00:00') self.assertExcludes('2000-01-01T12..2000-01-01T13', '2000-01-01T14:30:00') # test non-range query self.assertContains('2008-12-01T22', '2008-12-01T22:30:00') self.assertExcludes('2008-12-01T22', '2008-12-01T23:30:00') def test_minute_precision_intervals(self): self.assertExcludes('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:29:59') self.assertContains('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:30:00') self.assertContains('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:30:30') self.assertContains('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:31:59') self.assertExcludes('2000-01-01T12:30..2000-01-01T12:31', '2000-01-01T12:32:00') def test_second_precision_intervals(self): self.assertExcludes('2000-01-01T12:30:50..2000-01-01T12:30:55', '2000-01-01T12:30:49') self.assertContains('2000-01-01T12:30:50..2000-01-01T12:30:55', '2000-01-01T12:30:50') self.assertContains('2000-01-01T12:30:50..2000-01-01T12:30:55', '2000-01-01T12:30:55') self.assertExcludes('2000-01-01T12:30:50..2000-01-01T12:30:55', '2000-01-01T12:30:56') def test_unbounded_endpoints(self): self.assertContains('..', date=datetime.max) self.assertContains('..', date=datetime.min) self.assertContains('..', '1000-01-01T00:00:00') def assertContains(self, interval_pattern, date_pattern=None, date=None): # noqa if date is None: date = _date(date_pattern) (start, end) = _parse_periods(interval_pattern) interval = DateInterval.from_periods(start, end) self.assertTrue(interval.contains(date)) def assertExcludes(self, interval_pattern, date_pattern): # noqa date = _date(date_pattern) (start, end) = _parse_periods(interval_pattern) interval = DateInterval.from_periods(start, end) self.assertFalse(interval.contains(date)) def _parsetime(s): return time.mktime(datetime.strptime(s, '%Y-%m-%d %H:%M').timetuple()) class DateQueryTest(_common.LibTestCase): def setUp(self): super().setUp() self.i.added = _parsetime('2013-03-30 22:21') self.i.store() def test_single_month_match_fast(self): query = DateQuery('added', '2013-03') matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_single_month_nonmatch_fast(self): query = DateQuery('added', '2013-04') matched = self.lib.items(query) self.assertEqual(len(matched), 0) def test_single_month_match_slow(self): query = DateQuery('added', '2013-03') self.assertTrue(query.match(self.i)) def test_single_month_nonmatch_slow(self): query = DateQuery('added', '2013-04') self.assertFalse(query.match(self.i)) def test_single_day_match_fast(self): query = DateQuery('added', '2013-03-30') matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_single_day_nonmatch_fast(self): query = DateQuery('added', '2013-03-31') matched = self.lib.items(query) self.assertEqual(len(matched), 0) class DateQueryTestRelative(_common.LibTestCase): def setUp(self): super().setUp() # We pick a date near a month changeover, which can reveal some time # zone bugs. self._now = datetime(2017, 12, 31, 22, 55, 4, 101332) self.i.added = _parsetime(self._now.strftime('%Y-%m-%d %H:%M')) self.i.store() def test_single_month_match_fast(self): query = DateQuery('added', self._now.strftime('%Y-%m')) matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_single_month_nonmatch_fast(self): query = DateQuery('added', (self._now + timedelta(days=30)) .strftime('%Y-%m')) matched = self.lib.items(query) self.assertEqual(len(matched), 0) def test_single_month_match_slow(self): query = DateQuery('added', self._now.strftime('%Y-%m')) self.assertTrue(query.match(self.i)) def test_single_month_nonmatch_slow(self): query = DateQuery('added', (self._now + timedelta(days=30)) .strftime('%Y-%m')) self.assertFalse(query.match(self.i)) def test_single_day_match_fast(self): query = DateQuery('added', self._now.strftime('%Y-%m-%d')) matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_single_day_nonmatch_fast(self): query = DateQuery('added', (self._now + timedelta(days=1)) .strftime('%Y-%m-%d')) matched = self.lib.items(query) self.assertEqual(len(matched), 0) class DateQueryTestRelativeMore(_common.LibTestCase): def setUp(self): super().setUp() self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M')) self.i.store() def test_relative(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '-4' + timespan + '..+4' + timespan) matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_relative_fail(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '-2' + timespan + '..-1' + timespan) matched = self.lib.items(query) self.assertEqual(len(matched), 0) def test_start_relative(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '-4' + timespan + '..') matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_start_relative_fail(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '4' + timespan + '..') matched = self.lib.items(query) self.assertEqual(len(matched), 0) def test_end_relative(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '..+4' + timespan) matched = self.lib.items(query) self.assertEqual(len(matched), 1) def test_end_relative_fail(self): for timespan in ['d', 'w', 'm', 'y']: query = DateQuery('added', '..-4' + timespan) matched = self.lib.items(query) self.assertEqual(len(matched), 0) class DateQueryConstructTest(unittest.TestCase): def test_long_numbers(self): with self.assertRaises(InvalidQueryArgumentValueError): DateQuery('added', '1409830085..1412422089') def test_too_many_components(self): with self.assertRaises(InvalidQueryArgumentValueError): DateQuery('added', '12-34-56-78') def test_invalid_date_query(self): q_list = [ '2001-01-0a', '2001-0a', '200a', '2001-01-01..2001-01-0a', '2001-0a..2001-01', '200a..2002', '20aa..', '..2aa' ] for q in q_list: with self.assertRaises(InvalidQueryArgumentValueError): DateQuery('added', q) def test_datetime_uppercase_t_separator(self): date_query = DateQuery('added', '2000-01-01T12') self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) def test_datetime_lowercase_t_separator(self): date_query = DateQuery('added', '2000-01-01t12') self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) def test_datetime_space_separator(self): date_query = DateQuery('added', '2000-01-01 12') self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12)) self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13)) def test_datetime_invalid_separator(self): with self.assertRaises(InvalidQueryArgumentValueError): DateQuery('added', '2000-01-01x12') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_dbcore.py0000644000076500000240000005625300000000000016166 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the DBCore database abstraction. """ import os import shutil import sqlite3 import unittest from test import _common from beets import dbcore from tempfile import mkstemp # Fixture: concrete database and model classes. For migration tests, we # have multiple models with different numbers of fields. class SortFixture(dbcore.query.FieldSort): pass class QueryFixture(dbcore.query.Query): def __init__(self, pattern): self.pattern = pattern def clause(self): return None, () def match(self): return True class ModelFixture1(dbcore.Model): _table = 'test' _flex_table = 'testflex' _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, 'field_two': dbcore.types.STRING, } _types = { 'some_float_field': dbcore.types.FLOAT, } _sorts = { 'some_sort': SortFixture, } _queries = { 'some_query': QueryFixture, } @classmethod def _getters(cls): return {} def _template_funcs(self): return {} class DatabaseFixture1(dbcore.Database): _models = (ModelFixture1,) pass class ModelFixture2(ModelFixture1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, 'field_two': dbcore.types.INTEGER, } class DatabaseFixture2(dbcore.Database): _models = (ModelFixture2,) pass class ModelFixture3(ModelFixture1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, 'field_two': dbcore.types.INTEGER, 'field_three': dbcore.types.INTEGER, } class DatabaseFixture3(dbcore.Database): _models = (ModelFixture3,) pass class ModelFixture4(ModelFixture1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, 'field_two': dbcore.types.INTEGER, 'field_three': dbcore.types.INTEGER, 'field_four': dbcore.types.INTEGER, } class DatabaseFixture4(dbcore.Database): _models = (ModelFixture4,) pass class AnotherModelFixture(ModelFixture1): _table = 'another' _flex_table = 'anotherflex' _fields = { 'id': dbcore.types.PRIMARY_ID, 'foo': dbcore.types.INTEGER, } class ModelFixture5(ModelFixture1): _fields = { 'some_string_field': dbcore.types.STRING, 'some_float_field': dbcore.types.FLOAT, 'some_boolean_field': dbcore.types.BOOLEAN, } class DatabaseFixture5(dbcore.Database): _models = (ModelFixture5,) pass class DatabaseFixtureTwoModels(dbcore.Database): _models = (ModelFixture2, AnotherModelFixture) pass class ModelFixtureWithGetters(dbcore.Model): @classmethod def _getters(cls): return {'aComputedField': (lambda s: 'thing')} def _template_funcs(self): return {} @_common.slow_test() class MigrationTest(unittest.TestCase): """Tests the ability to change the database schema between versions. """ @classmethod def setUpClass(cls): handle, cls.orig_libfile = mkstemp('orig_db') os.close(handle) # Set up a database with the two-field schema. old_lib = DatabaseFixture2(cls.orig_libfile) # Add an item to the old library. old_lib._connection().execute( 'insert into test (field_one, field_two) values (4, 2)' ) old_lib._connection().commit() del old_lib @classmethod def tearDownClass(cls): os.remove(cls.orig_libfile) def setUp(self): handle, self.libfile = mkstemp('db') os.close(handle) shutil.copyfile(self.orig_libfile, self.libfile) def tearDown(self): os.remove(self.libfile) def test_open_with_same_fields_leaves_untouched(self): new_lib = DatabaseFixture2(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() self.assertEqual(len(row.keys()), len(ModelFixture2._fields)) def test_open_with_new_field_adds_column(self): new_lib = DatabaseFixture3(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() self.assertEqual(len(row.keys()), len(ModelFixture3._fields)) def test_open_with_fewer_fields_leaves_untouched(self): new_lib = DatabaseFixture1(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() self.assertEqual(len(row.keys()), len(ModelFixture2._fields)) def test_open_with_multiple_new_fields(self): new_lib = DatabaseFixture4(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() self.assertEqual(len(row.keys()), len(ModelFixture4._fields)) def test_extra_model_adds_table(self): new_lib = DatabaseFixtureTwoModels(self.libfile) try: new_lib._connection().execute("select * from another") except sqlite3.OperationalError: self.fail("select failed") class TransactionTest(unittest.TestCase): def setUp(self): self.db = DatabaseFixture1(':memory:') def tearDown(self): self.db._connection().close() def test_mutate_increase_revision(self): old_rev = self.db.revision with self.db.transaction() as tx: tx.mutate( 'INSERT INTO {} ' '(field_one) ' 'VALUES (?);'.format(ModelFixture1._table), (111,), ) self.assertGreater(self.db.revision, old_rev) def test_query_no_increase_revision(self): old_rev = self.db.revision with self.db.transaction() as tx: tx.query('PRAGMA table_info(%s)' % ModelFixture1._table) self.assertEqual(self.db.revision, old_rev) class ModelTest(unittest.TestCase): def setUp(self): self.db = DatabaseFixture1(':memory:') def tearDown(self): self.db._connection().close() def test_add_model(self): model = ModelFixture1() model.add(self.db) rows = self.db._connection().execute('select * from test').fetchall() self.assertEqual(len(rows), 1) def test_store_fixed_field(self): model = ModelFixture1() model.add(self.db) model.field_one = 123 model.store() row = self.db._connection().execute('select * from test').fetchone() self.assertEqual(row['field_one'], 123) def test_revision(self): old_rev = self.db.revision model = ModelFixture1() model.add(self.db) model.store() self.assertEqual(model._revision, self.db.revision) self.assertGreater(self.db.revision, old_rev) mid_rev = self.db.revision model2 = ModelFixture1() model2.add(self.db) model2.store() self.assertGreater(model2._revision, mid_rev) self.assertGreater(self.db.revision, model._revision) # revision changed, so the model should be re-loaded model.load() self.assertEqual(model._revision, self.db.revision) # revision did not change, so no reload mod2_old_rev = model2._revision model2.load() self.assertEqual(model2._revision, mod2_old_rev) def test_retrieve_by_id(self): model = ModelFixture1() model.add(self.db) other_model = self.db._get(ModelFixture1, model.id) self.assertEqual(model.id, other_model.id) def test_store_and_retrieve_flexattr(self): model = ModelFixture1() model.add(self.db) model.foo = 'bar' model.store() other_model = self.db._get(ModelFixture1, model.id) self.assertEqual(other_model.foo, 'bar') def test_delete_flexattr(self): model = ModelFixture1() model['foo'] = 'bar' self.assertTrue('foo' in model) del model['foo'] self.assertFalse('foo' in model) def test_delete_flexattr_via_dot(self): model = ModelFixture1() model['foo'] = 'bar' self.assertTrue('foo' in model) del model.foo self.assertFalse('foo' in model) def test_delete_flexattr_persists(self): model = ModelFixture1() model.add(self.db) model.foo = 'bar' model.store() model = self.db._get(ModelFixture1, model.id) del model['foo'] model.store() model = self.db._get(ModelFixture1, model.id) self.assertFalse('foo' in model) def test_delete_non_existent_attribute(self): model = ModelFixture1() with self.assertRaises(KeyError): del model['foo'] def test_delete_fixed_attribute(self): model = ModelFixture5() model.some_string_field = 'foo' model.some_float_field = 1.23 model.some_boolean_field = True for field, type_ in model._fields.items(): self.assertNotEqual(model[field], type_.null) for field, type_ in model._fields.items(): del model[field] self.assertEqual(model[field], type_.null) def test_null_value_normalization_by_type(self): model = ModelFixture1() model.field_one = None self.assertEqual(model.field_one, 0) def test_null_value_stays_none_for_untyped_field(self): model = ModelFixture1() model.foo = None self.assertEqual(model.foo, None) def test_normalization_for_typed_flex_fields(self): model = ModelFixture1() model.some_float_field = None self.assertEqual(model.some_float_field, 0.0) def test_load_deleted_flex_field(self): model1 = ModelFixture1() model1['flex_field'] = True model1.add(self.db) model2 = self.db._get(ModelFixture1, model1.id) self.assertIn('flex_field', model2) del model1['flex_field'] model1.store() model2.load() self.assertNotIn('flex_field', model2) def test_check_db_fails(self): with self.assertRaisesRegex(ValueError, 'no database'): dbcore.Model()._check_db() with self.assertRaisesRegex(ValueError, 'no id'): ModelFixture1(self.db)._check_db() dbcore.Model(self.db)._check_db(need_id=False) def test_missing_field(self): with self.assertRaises(AttributeError): ModelFixture1(self.db).nonExistingKey def test_computed_field(self): model = ModelFixtureWithGetters() self.assertEqual(model.aComputedField, 'thing') with self.assertRaisesRegex(KeyError, 'computed field .+ deleted'): del model.aComputedField def test_items(self): model = ModelFixture1(self.db) model.id = 5 self.assertEqual({('id', 5), ('field_one', 0), ('field_two', '')}, set(model.items())) def test_delete_internal_field(self): model = dbcore.Model() del model._db with self.assertRaises(AttributeError): model._db def test_parse_nonstring(self): with self.assertRaisesRegex(TypeError, "must be a string"): dbcore.Model._parse(None, 42) class FormatTest(unittest.TestCase): def test_format_fixed_field_integer(self): model = ModelFixture1() model.field_one = 155 value = model.formatted().get('field_one') self.assertEqual(value, '155') def test_format_fixed_field_integer_normalized(self): """The normalize method of the Integer class rounds floats """ model = ModelFixture1() model.field_one = 142.432 value = model.formatted().get('field_one') self.assertEqual(value, '142') model.field_one = 142.863 value = model.formatted().get('field_one') self.assertEqual(value, '143') def test_format_fixed_field_string(self): model = ModelFixture1() model.field_two = 'caf\xe9' value = model.formatted().get('field_two') self.assertEqual(value, 'caf\xe9') def test_format_flex_field(self): model = ModelFixture1() model.other_field = 'caf\xe9' value = model.formatted().get('other_field') self.assertEqual(value, 'caf\xe9') def test_format_flex_field_bytes(self): model = ModelFixture1() model.other_field = 'caf\xe9'.encode() value = model.formatted().get('other_field') self.assertTrue(isinstance(value, str)) self.assertEqual(value, 'caf\xe9') def test_format_unset_field(self): model = ModelFixture1() value = model.formatted().get('other_field') self.assertEqual(value, '') def test_format_typed_flex_field(self): model = ModelFixture1() model.some_float_field = 3.14159265358979 value = model.formatted().get('some_float_field') self.assertEqual(value, '3.1') class FormattedMappingTest(unittest.TestCase): def test_keys_equal_model_keys(self): model = ModelFixture1() formatted = model.formatted() self.assertEqual(set(model.keys(True)), set(formatted.keys())) def test_get_unset_field(self): model = ModelFixture1() formatted = model.formatted() with self.assertRaises(KeyError): formatted['other_field'] def test_get_method_with_default(self): model = ModelFixture1() formatted = model.formatted() self.assertEqual(formatted.get('other_field'), '') def test_get_method_with_specified_default(self): model = ModelFixture1() formatted = model.formatted() self.assertEqual(formatted.get('other_field', 'default'), 'default') class ParseTest(unittest.TestCase): def test_parse_fixed_field(self): value = ModelFixture1._parse('field_one', '2') self.assertIsInstance(value, int) self.assertEqual(value, 2) def test_parse_flex_field(self): value = ModelFixture1._parse('some_float_field', '2') self.assertIsInstance(value, float) self.assertEqual(value, 2.0) def test_parse_untyped_field(self): value = ModelFixture1._parse('field_nine', '2') self.assertEqual(value, '2') class QueryParseTest(unittest.TestCase): def pqp(self, part): return dbcore.queryparse.parse_query_part( part, {'year': dbcore.query.NumericQuery}, {':': dbcore.query.RegexpQuery}, )[:-1] # remove the negate flag def test_one_basic_term(self): q = 'test' r = (None, 'test', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) def test_one_keyed_term(self): q = 'test:val' r = ('test', 'val', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) def test_colon_at_end(self): q = 'test:' r = ('test', '', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) def test_one_basic_regexp(self): q = r':regexp' r = (None, 'regexp', dbcore.query.RegexpQuery) self.assertEqual(self.pqp(q), r) def test_keyed_regexp(self): q = r'test::regexp' r = ('test', 'regexp', dbcore.query.RegexpQuery) self.assertEqual(self.pqp(q), r) def test_escaped_colon(self): q = r'test\:val' r = (None, 'test:val', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) def test_escaped_colon_in_regexp(self): q = r':test\:regexp' r = (None, 'test:regexp', dbcore.query.RegexpQuery) self.assertEqual(self.pqp(q), r) def test_single_year(self): q = 'year:1999' r = ('year', '1999', dbcore.query.NumericQuery) self.assertEqual(self.pqp(q), r) def test_multiple_years(self): q = 'year:1999..2010' r = ('year', '1999..2010', dbcore.query.NumericQuery) self.assertEqual(self.pqp(q), r) def test_empty_query_part(self): q = '' r = (None, '', dbcore.query.SubstringQuery) self.assertEqual(self.pqp(q), r) class QueryFromStringsTest(unittest.TestCase): def qfs(self, strings): return dbcore.queryparse.query_from_strings( dbcore.query.AndQuery, ModelFixture1, {':': dbcore.query.RegexpQuery}, strings, ) def test_zero_parts(self): q = self.qfs([]) self.assertIsInstance(q, dbcore.query.AndQuery) self.assertEqual(len(q.subqueries), 1) self.assertIsInstance(q.subqueries[0], dbcore.query.TrueQuery) def test_two_parts(self): q = self.qfs(['foo', 'bar:baz']) self.assertIsInstance(q, dbcore.query.AndQuery) self.assertEqual(len(q.subqueries), 2) self.assertIsInstance(q.subqueries[0], dbcore.query.AnyFieldQuery) self.assertIsInstance(q.subqueries[1], dbcore.query.SubstringQuery) def test_parse_fixed_type_query(self): q = self.qfs(['field_one:2..3']) self.assertIsInstance(q.subqueries[0], dbcore.query.NumericQuery) def test_parse_flex_type_query(self): q = self.qfs(['some_float_field:2..3']) self.assertIsInstance(q.subqueries[0], dbcore.query.NumericQuery) def test_empty_query_part(self): q = self.qfs(['']) self.assertIsInstance(q.subqueries[0], dbcore.query.TrueQuery) def test_parse_named_query(self): q = self.qfs(['some_query:foo']) self.assertIsInstance(q.subqueries[0], QueryFixture) class SortFromStringsTest(unittest.TestCase): def sfs(self, strings): return dbcore.queryparse.sort_from_strings( ModelFixture1, strings, ) def test_zero_parts(self): s = self.sfs([]) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(s, dbcore.query.NullSort()) def test_one_parts(self): s = self.sfs(['field+']) self.assertIsInstance(s, dbcore.query.Sort) def test_two_parts(self): s = self.sfs(['field+', 'another_field-']) self.assertIsInstance(s, dbcore.query.MultipleSort) self.assertEqual(len(s.sorts), 2) def test_fixed_field_sort(self): s = self.sfs(['field_one+']) self.assertIsInstance(s, dbcore.query.FixedFieldSort) self.assertEqual(s, dbcore.query.FixedFieldSort('field_one')) def test_flex_field_sort(self): s = self.sfs(['flex_field+']) self.assertIsInstance(s, dbcore.query.SlowFieldSort) self.assertEqual(s, dbcore.query.SlowFieldSort('flex_field')) def test_special_sort(self): s = self.sfs(['some_sort+']) self.assertIsInstance(s, SortFixture) class ParseSortedQueryTest(unittest.TestCase): def psq(self, parts): return dbcore.parse_sorted_query( ModelFixture1, parts.split(), ) def test_and_query(self): q, s = self.psq('foo bar') self.assertIsInstance(q, dbcore.query.AndQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 2) def test_or_query(self): q, s = self.psq('foo , bar') self.assertIsInstance(q, dbcore.query.OrQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 2) def test_no_space_before_comma_or_query(self): q, s = self.psq('foo, bar') self.assertIsInstance(q, dbcore.query.OrQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 2) def test_no_spaces_or_query(self): q, s = self.psq('foo,bar') self.assertIsInstance(q, dbcore.query.AndQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 1) def test_trailing_comma_or_query(self): q, s = self.psq('foo , bar ,') self.assertIsInstance(q, dbcore.query.OrQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 3) def test_leading_comma_or_query(self): q, s = self.psq(', foo , bar') self.assertIsInstance(q, dbcore.query.OrQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 3) def test_only_direction(self): q, s = self.psq('-') self.assertIsInstance(q, dbcore.query.AndQuery) self.assertIsInstance(s, dbcore.query.NullSort) self.assertEqual(len(q.subqueries), 1) class ResultsIteratorTest(unittest.TestCase): def setUp(self): self.db = DatabaseFixture1(':memory:') model = ModelFixture1() model['foo'] = 'baz' model.add(self.db) model = ModelFixture1() model['foo'] = 'bar' model.add(self.db) def tearDown(self): self.db._connection().close() def test_iterate_once(self): objs = self.db._fetch(ModelFixture1) self.assertEqual(len(list(objs)), 2) def test_iterate_twice(self): objs = self.db._fetch(ModelFixture1) list(objs) self.assertEqual(len(list(objs)), 2) def test_concurrent_iterators(self): results = self.db._fetch(ModelFixture1) it1 = iter(results) it2 = iter(results) next(it1) list(it2) self.assertEqual(len(list(it1)), 1) def test_slow_query(self): q = dbcore.query.SubstringQuery('foo', 'ba', False) objs = self.db._fetch(ModelFixture1, q) self.assertEqual(len(list(objs)), 2) def test_slow_query_negative(self): q = dbcore.query.SubstringQuery('foo', 'qux', False) objs = self.db._fetch(ModelFixture1, q) self.assertEqual(len(list(objs)), 0) def test_iterate_slow_sort(self): s = dbcore.query.SlowFieldSort('foo') res = self.db._fetch(ModelFixture1, sort=s) objs = list(res) self.assertEqual(objs[0].foo, 'bar') self.assertEqual(objs[1].foo, 'baz') def test_unsorted_subscript(self): objs = self.db._fetch(ModelFixture1) self.assertEqual(objs[0].foo, 'baz') self.assertEqual(objs[1].foo, 'bar') def test_slow_sort_subscript(self): s = dbcore.query.SlowFieldSort('foo') objs = self.db._fetch(ModelFixture1, sort=s) self.assertEqual(objs[0].foo, 'bar') self.assertEqual(objs[1].foo, 'baz') def test_length(self): objs = self.db._fetch(ModelFixture1) self.assertEqual(len(objs), 2) def test_out_of_range(self): objs = self.db._fetch(ModelFixture1) with self.assertRaises(IndexError): objs[100] def test_no_results(self): self.assertIsNone(self.db._fetch( ModelFixture1, dbcore.query.FalseQuery()).get()) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/test/test_discogs.py0000644000076500000240000003732000000000000016355 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for discogs plugin. """ import unittest from test import _common from test._common import Bag from test.helper import capture_log from beetsplug.discogs import DiscogsPlugin class DGAlbumInfoTest(_common.TestCase): def _make_release(self, tracks=None): """Returns a Bag that mimics a discogs_client.Release. The list of elements on the returned Bag is incomplete, including just those required for the tests on this class.""" data = { 'id': 'ALBUM ID', 'uri': 'https://www.discogs.com/release/release/13633721', 'title': 'ALBUM TITLE', 'year': '3001', 'artists': [{ 'name': 'ARTIST NAME', 'id': 'ARTIST ID', 'join': ',' }], 'formats': [{ 'descriptions': ['FORMAT DESC 1', 'FORMAT DESC 2'], 'name': 'FORMAT', 'qty': 1 }], 'styles': [ 'STYLE1', 'STYLE2' ], 'genres': [ 'GENRE1', 'GENRE2' ], 'labels': [{ 'name': 'LABEL NAME', 'catno': 'CATALOG NUMBER', }], 'tracklist': [] } if tracks: for recording in tracks: data['tracklist'].append(recording) return Bag(data=data, # Make some fields available as properties, as they are # accessed by DiscogsPlugin methods. title=data['title'], artists=[Bag(data=d) for d in data['artists']]) def _make_track(self, title, position='', duration='', type_=None): track = { 'title': title, 'position': position, 'duration': duration } if type_ is not None: # Test samples on discogs_client do not have a 'type_' field, but # the API seems to return it. Values: 'track' for regular tracks, # 'heading' for descriptive texts (ie. not real tracks - 12.13.2). track['type_'] = type_ return track def _make_release_from_positions(self, positions): """Return a Bag that mimics a discogs_client.Release with a tracklist where tracks have the specified `positions`.""" tracks = [self._make_track('TITLE%s' % i, position) for (i, position) in enumerate(positions, start=1)] return self._make_release(tracks) def test_parse_media_for_tracks(self): tracks = [self._make_track('TITLE ONE', '1', '01:01'), self._make_track('TITLE TWO', '2', '02:02')] release = self._make_release(tracks=tracks) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.media, 'FORMAT') self.assertEqual(t[0].media, d.media) self.assertEqual(t[1].media, d.media) def test_parse_medium_numbers_single_medium(self): release = self._make_release_from_positions(['1', '2']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.mediums, 1) self.assertEqual(t[0].medium, 1) self.assertEqual(t[0].medium_total, 2) self.assertEqual(t[1].medium, 1) self.assertEqual(t[0].medium_total, 2) def test_parse_medium_numbers_two_mediums(self): release = self._make_release_from_positions(['1-1', '2-1']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.mediums, 2) self.assertEqual(t[0].medium, 1) self.assertEqual(t[0].medium_total, 1) self.assertEqual(t[1].medium, 2) self.assertEqual(t[1].medium_total, 1) def test_parse_medium_numbers_two_mediums_two_sided(self): release = self._make_release_from_positions(['A1', 'B1', 'C1']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.mediums, 2) self.assertEqual(t[0].medium, 1) self.assertEqual(t[0].medium_total, 2) self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[1].medium, 1) self.assertEqual(t[1].medium_total, 2) self.assertEqual(t[1].medium_index, 2) self.assertEqual(t[2].medium, 2) self.assertEqual(t[2].medium_total, 1) self.assertEqual(t[2].medium_index, 1) def test_parse_track_indices(self): release = self._make_release_from_positions(['1', '2']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[0].index, 1) self.assertEqual(t[0].medium_total, 2) self.assertEqual(t[1].medium_index, 2) self.assertEqual(t[1].index, 2) self.assertEqual(t[1].medium_total, 2) def test_parse_track_indices_several_media(self): release = self._make_release_from_positions(['1-1', '1-2', '2-1', '3-1']) d = DiscogsPlugin().get_album_info(release) t = d.tracks self.assertEqual(d.mediums, 3) self.assertEqual(t[0].medium_index, 1) self.assertEqual(t[0].index, 1) self.assertEqual(t[0].medium_total, 2) self.assertEqual(t[1].medium_index, 2) self.assertEqual(t[1].index, 2) self.assertEqual(t[1].medium_total, 2) self.assertEqual(t[2].medium_index, 1) self.assertEqual(t[2].index, 3) self.assertEqual(t[2].medium_total, 1) self.assertEqual(t[3].medium_index, 1) self.assertEqual(t[3].index, 4) self.assertEqual(t[3].medium_total, 1) def test_parse_position(self): """Test the conversion of discogs `position` to medium, medium_index and subtrack_index.""" # List of tuples (discogs_position, (medium, medium_index, subindex) positions = [('1', (None, '1', None)), ('A12', ('A', '12', None)), ('12-34', ('12-', '34', None)), ('CD1-1', ('CD1-', '1', None)), ('1.12', (None, '1', '12')), ('12.a', (None, '12', 'A')), ('12.34', (None, '12', '34')), ('1ab', (None, '1', 'AB')), # Non-standard ('IV', ('IV', None, None)), ] d = DiscogsPlugin() for position, expected in positions: self.assertEqual(d.get_track_index(position), expected) def test_parse_tracklist_without_sides(self): """Test standard Discogs position 12.2.9#1: "without sides".""" release = self._make_release_from_positions(['1', '2', '3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) def test_parse_tracklist_with_sides(self): """Test standard Discogs position 12.2.9#2: "with sides".""" release = self._make_release_from_positions(['A1', 'A2', 'B1', 'B2']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) # 2 sides = 1 LP self.assertEqual(len(d.tracks), 4) def test_parse_tracklist_multiple_lp(self): """Test standard Discogs position 12.2.9#3: "multiple LP".""" release = self._make_release_from_positions(['A1', 'A2', 'B1', 'C1']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 2) # 3 sides = 1 LP + 1 LP self.assertEqual(len(d.tracks), 4) def test_parse_tracklist_multiple_cd(self): """Test standard Discogs position 12.2.9#4: "multiple CDs".""" release = self._make_release_from_positions(['1-1', '1-2', '2-1', '3-1']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 3) self.assertEqual(len(d.tracks), 4) def test_parse_tracklist_non_standard(self): """Test non standard Discogs position.""" release = self._make_release_from_positions(['I', 'II', 'III', 'IV']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 4) def test_parse_tracklist_subtracks_dot(self): """Test standard Discogs position 12.2.9#5: "sub tracks, dots".""" release = self._make_release_from_positions(['1', '2.1', '2.2', '3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) release = self._make_release_from_positions(['A1', 'A2.1', 'A2.2', 'A3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) def test_parse_tracklist_subtracks_letter(self): """Test standard Discogs position 12.2.9#5: "sub tracks, letter".""" release = self._make_release_from_positions(['A1', 'A2a', 'A2b', 'A3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) release = self._make_release_from_positions(['A1', 'A2.a', 'A2.b', 'A3']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) def test_parse_tracklist_subtracks_extra_material(self): """Test standard Discogs position 12.2.9#6: "extra material".""" release = self._make_release_from_positions(['1', '2', 'Video 1']) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 2) self.assertEqual(len(d.tracks), 3) def test_parse_tracklist_subtracks_indices(self): """Test parsing of subtracks that include index tracks.""" release = self._make_release_from_positions(['', '', '1.1', '1.2']) # Track 1: Index track with medium title release.data['tracklist'][0]['title'] = 'MEDIUM TITLE' # Track 2: Index track with track group title release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(d.tracks[0].disctitle, 'MEDIUM TITLE') self.assertEqual(len(d.tracks), 1) self.assertEqual(d.tracks[0].title, 'TRACK GROUP TITLE') def test_parse_tracklist_subtracks_nested_logical(self): """Test parsing of subtracks defined inside a index track that are logical subtracks (ie. should be grouped together into a single track). """ release = self._make_release_from_positions(['1', '', '3']) # Track 2: Index track with track group title, and sub_tracks release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' release.data['tracklist'][1]['sub_tracks'] = [ self._make_track('TITLE ONE', '2.1', '01:01'), self._make_track('TITLE TWO', '2.2', '02:02') ] d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 3) self.assertEqual(d.tracks[1].title, 'TRACK GROUP TITLE') def test_parse_tracklist_subtracks_nested_physical(self): """Test parsing of subtracks defined inside a index track that are physical subtracks (ie. should not be grouped together). """ release = self._make_release_from_positions(['1', '', '4']) # Track 2: Index track with track group title, and sub_tracks release.data['tracklist'][1]['title'] = 'TRACK GROUP TITLE' release.data['tracklist'][1]['sub_tracks'] = [ self._make_track('TITLE ONE', '2', '01:01'), self._make_track('TITLE TWO', '3', '02:02') ] d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 1) self.assertEqual(len(d.tracks), 4) self.assertEqual(d.tracks[1].title, 'TITLE ONE') self.assertEqual(d.tracks[2].title, 'TITLE TWO') def test_parse_tracklist_disctitles(self): """Test parsing of index tracks that act as disc titles.""" release = self._make_release_from_positions(['', '1-1', '1-2', '', '2-1']) # Track 1: Index track with medium title (Cd1) release.data['tracklist'][0]['title'] = 'MEDIUM TITLE CD1' # Track 4: Index track with medium title (Cd2) release.data['tracklist'][3]['title'] = 'MEDIUM TITLE CD2' d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.mediums, 2) self.assertEqual(d.tracks[0].disctitle, 'MEDIUM TITLE CD1') self.assertEqual(d.tracks[1].disctitle, 'MEDIUM TITLE CD1') self.assertEqual(d.tracks[2].disctitle, 'MEDIUM TITLE CD2') self.assertEqual(len(d.tracks), 3) def test_parse_minimal_release(self): """Test parsing of a release with the minimal amount of information.""" data = {'id': 123, 'tracklist': [self._make_track('A', '1', '01:01')], 'artists': [{'name': 'ARTIST NAME', 'id': 321, 'join': ''}], 'title': 'TITLE'} release = Bag(data=data, title=data['title'], artists=[Bag(data=d) for d in data['artists']]) d = DiscogsPlugin().get_album_info(release) self.assertEqual(d.artist, 'ARTIST NAME') self.assertEqual(d.album, 'TITLE') self.assertEqual(len(d.tracks), 1) def test_parse_release_without_required_fields(self): """Test parsing of a release that does not have the required fields.""" release = Bag(data={}, refresh=lambda *args: None) with capture_log() as logs: d = DiscogsPlugin().get_album_info(release) self.assertEqual(d, None) self.assertIn('Release does not contain the required fields', logs[0]) def test_album_for_id(self): """Test parsing for a valid Discogs release_id""" test_patterns = [('http://www.discogs.com/G%C3%BCnther-Lause-Meru-Ep/release/4354798', 4354798), # NOQA E501 ('http://www.discogs.com/release/4354798-G%C3%BCnther-Lause-Meru-Ep', 4354798), # NOQA E501 ('http://www.discogs.com/G%C3%BCnther-4354798Lause-Meru-Ep/release/4354798', 4354798), # NOQA E501 ('http://www.discogs.com/release/4354798-G%C3%BCnther-4354798Lause-Meru-Ep/', 4354798), # NOQA E501 ('[r4354798]', 4354798), ('r4354798', 4354798), ('4354798', 4354798), ('yet-another-metadata-provider.org/foo/12345', ''), ('005b84a0-ecd6-39f1-b2f6-6eb48756b268', ''), ] for test_pattern, expected in test_patterns: match = DiscogsPlugin.extract_release_id_regex(test_pattern) if not match: match = '' self.assertEqual(match, expected) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_edit.py0000644000076500000240000004772600000000000015662 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import codecs import unittest from unittest.mock import patch from test import _common from test.helper import TestHelper, control_stdin from test.test_ui_importer import TerminalImportSessionSetup from test.test_importer import ImportHelper, AutotagStub from beets.dbcore.query import TrueQuery from beets.library import Item from beetsplug.edit import EditPlugin class ModifyFileMocker: """Helper for modifying a file, replacing or editing its contents. Used for mocking the calls to the external editor during testing. """ def __init__(self, contents=None, replacements=None): """ `self.contents` and `self.replacements` are initialized here, in order to keep the rest of the functions of this class with the same signature as `EditPlugin.get_editor()`, making mocking easier. - `contents`: string with the contents of the file to be used for `overwrite_contents()` - `replacement`: dict with the in-place replacements to be used for `replace_contents()`, in the form {'previous string': 'new string'} TODO: check if it can be solved more elegantly with a decorator """ self.contents = contents self.replacements = replacements self.action = self.overwrite_contents if replacements: self.action = self.replace_contents # The two methods below mock the `edit` utility function in the plugin. def overwrite_contents(self, filename, log): """Modify `filename`, replacing its contents with `self.contents`. If `self.contents` is empty, the file remains unchanged. """ if self.contents: with codecs.open(filename, 'w', encoding='utf-8') as f: f.write(self.contents) def replace_contents(self, filename, log): """Modify `filename`, reading its contents and replacing the strings specified in `self.replacements`. """ with codecs.open(filename, 'r', encoding='utf-8') as f: contents = f.read() for old, new_ in self.replacements.items(): contents = contents.replace(old, new_) with codecs.open(filename, 'w', encoding='utf-8') as f: f.write(contents) class EditMixin: """Helper containing some common functionality used for the Edit tests.""" def assertItemFieldsModified(self, library_items, items, fields=[], # noqa allowed=['path']): """Assert that items in the library (`lib_items`) have different values on the specified `fields` (and *only* on those fields), compared to `items`. An empty `fields` list results in asserting that no modifications have been performed. `allowed` is a list of field changes that are ignored (they may or may not have changed; the assertion doesn't care). """ for lib_item, item in zip(library_items, items): diff_fields = [field for field in lib_item._fields if lib_item[field] != item[field]] self.assertEqual(set(diff_fields).difference(allowed), set(fields)) def run_mocked_interpreter(self, modify_file_args={}, stdin=[]): """Run the edit command during an import session, with mocked stdin and yaml writing. """ m = ModifyFileMocker(**modify_file_args) with patch('beetsplug.edit.edit', side_effect=m.action): with control_stdin('\n'.join(stdin)): self.importer.run() def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]): """Run the edit command, with mocked stdin and yaml writing, and passing `args` to `run_command`.""" m = ModifyFileMocker(**modify_file_args) with patch('beetsplug.edit.edit', side_effect=m.action): with control_stdin('\n'.join(stdin)): self.run_command('edit', *args) @_common.slow_test() @patch('beets.library.Item.write') class EditCommandTest(unittest.TestCase, TestHelper, EditMixin): """Black box tests for `beetsplug.edit`. Command line interaction is simulated using `test.helper.control_stdin()`, and yaml editing via an external editor is simulated using `ModifyFileMocker`. """ ALBUM_COUNT = 1 TRACK_COUNT = 10 def setUp(self): self.setup_beets() self.load_plugins('edit') # Add an album, storing the original fields for comparison. self.album = self.add_album_fixture(track_count=self.TRACK_COUNT) self.album_orig = {f: self.album[f] for f in self.album._fields} self.items_orig = [{f: item[f] for f in item._fields} for item in self.album.items()] def tearDown(self): EditPlugin.listeners = None self.teardown_beets() self.unload_plugins() def assertCounts(self, mock_write, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, # noqa write_call_count=TRACK_COUNT, title_starts_with=''): """Several common assertions on Album, Track and call counts.""" self.assertEqual(len(self.lib.albums()), album_count) self.assertEqual(len(self.lib.items()), track_count) self.assertEqual(mock_write.call_count, write_call_count) self.assertTrue(all(i.title.startswith(title_starts_with) for i in self.lib.items())) def test_title_edit_discard(self, mock_write): """Edit title for all items in the library, then discard changes.""" # Edit track titles. self.run_mocked_command({'replacements': {'t\u00eftle': 'modified t\u00eftle'}}, # Cancel. ['c']) self.assertCounts(mock_write, write_call_count=0, title_starts_with='t\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, []) def test_title_edit_apply(self, mock_write): """Edit title for all items in the library, then apply changes.""" # Edit track titles. self.run_mocked_command({'replacements': {'t\u00eftle': 'modified t\u00eftle'}}, # Apply changes. ['a']) self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT, title_starts_with='modified t\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, ['title', 'mtime']) def test_single_title_edit_apply(self, mock_write): """Edit title for one item in the library, then apply changes.""" # Edit one track title. self.run_mocked_command({'replacements': {'t\u00eftle 9': 'modified t\u00eftle 9'}}, # Apply changes. ['a']) self.assertCounts(mock_write, write_call_count=1,) # No changes except on last item. self.assertItemFieldsModified(list(self.album.items())[:-1], self.items_orig[:-1], []) self.assertEqual(list(self.album.items())[-1].title, 'modified t\u00eftle 9') def test_noedit(self, mock_write): """Do not edit anything.""" # Do not edit anything. self.run_mocked_command({'contents': None}, # No stdin. []) self.assertCounts(mock_write, write_call_count=0, title_starts_with='t\u00eftle') self.assertItemFieldsModified(self.album.items(), self.items_orig, []) def test_album_edit_apply(self, mock_write): """Edit the album field for all items in the library, apply changes. By design, the album should not be updated."" """ # Edit album. self.run_mocked_command({'replacements': {'\u00e4lbum': 'modified \u00e4lbum'}}, # Apply changes. ['a']) self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertItemFieldsModified(self.album.items(), self.items_orig, ['album', 'mtime']) # Ensure album is *not* modified. self.album.load() self.assertEqual(self.album.album, '\u00e4lbum') def test_single_edit_add_field(self, mock_write): """Edit the yaml file appending an extra field to the first item, then apply changes.""" # Append "foo: bar" to item with id == 2. ("id: 1" would match both # "id: 1" and "id: 10") self.run_mocked_command({'replacements': {"id: 2": "id: 2\nfoo: bar"}}, # Apply changes. ['a']) self.assertEqual(self.lib.items('id:2')[0].foo, 'bar') # Even though a flexible attribute was written (which is not directly # written to the tags), write should still be called since templates # might use it. self.assertCounts(mock_write, write_call_count=1, title_starts_with='t\u00eftle') def test_a_album_edit_apply(self, mock_write): """Album query (-a), edit album field, apply changes.""" self.run_mocked_command({'replacements': {'\u00e4lbum': 'modified \u00e4lbum'}}, # Apply changes. ['a'], args=['-a']) self.album.load() self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertEqual(self.album.album, 'modified \u00e4lbum') self.assertItemFieldsModified(self.album.items(), self.items_orig, ['album', 'mtime']) def test_a_albumartist_edit_apply(self, mock_write): """Album query (-a), edit albumartist field, apply changes.""" self.run_mocked_command({'replacements': {'album artist': 'modified album artist'}}, # Apply changes. ['a'], args=['-a']) self.album.load() self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) self.assertEqual(self.album.albumartist, 'the modified album artist') self.assertItemFieldsModified(self.album.items(), self.items_orig, ['albumartist', 'mtime']) def test_malformed_yaml(self, mock_write): """Edit the yaml file incorrectly (resulting in a malformed yaml document).""" # Edit the yaml file to an invalid file. self.run_mocked_command({'contents': '!MALFORMED'}, # Edit again to fix? No. ['n']) self.assertCounts(mock_write, write_call_count=0, title_starts_with='t\u00eftle') def test_invalid_yaml(self, mock_write): """Edit the yaml file incorrectly (resulting in a well-formed but invalid yaml document).""" # Edit the yaml file to an invalid but parseable file. self.run_mocked_command({'contents': 'wellformed: yes, but invalid'}, # No stdin. []) self.assertCounts(mock_write, write_call_count=0, title_starts_with='t\u00eftle') @_common.slow_test() class EditDuringImporterTest(TerminalImportSessionSetup, unittest.TestCase, ImportHelper, TestHelper, EditMixin): """TODO """ IGNORED = ['added', 'album_id', 'id', 'mtime', 'path'] def setUp(self): self.setup_beets() self.load_plugins('edit') # Create some mediafiles, and store them for comparison. self._create_import_dir(3) self.items_orig = [Item.from_path(f.path) for f in self.media_files] self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.GOOD self.config['import']['timid'] = True def tearDown(self): EditPlugin.listeners = None self.unload_plugins() self.teardown_beets() self.matcher.restore() def test_edit_apply_asis(self): """Edit the album field for all items in the library, apply changes, using the original item tags. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {'Tag Title': 'Edited Title'}}, # eDit, Apply changes. ['d', 'a']) # Check that only the 'title' field is modified. self.assertItemFieldsModified(self.lib.items(), self.items_orig, ['title'], self.IGNORED + ['albumartist', 'mb_albumartistid']) self.assertTrue(all('Edited Title' in i.title for i in self.lib.items())) # Ensure album is *not* fetched from a candidate. self.assertEqual(self.lib.albums()[0].mb_albumid, '') def test_edit_discard_asis(self): """Edit the album field for all items in the library, discard changes, using the original item tags. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {'Tag Title': 'Edited Title'}}, # eDit, Cancel, Use as-is. ['d', 'c', 'u']) # Check that nothing is modified, the album is imported ASIS. self.assertItemFieldsModified(self.lib.items(), self.items_orig, [], self.IGNORED + ['albumartist', 'mb_albumartistid']) self.assertTrue(all('Tag Title' in i.title for i in self.lib.items())) # Ensure album is *not* fetched from a candidate. self.assertEqual(self.lib.albums()[0].mb_albumid, '') def test_edit_apply_candidate(self): """Edit the album field for all items in the library, apply changes, using a candidate. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {'Applied Title': 'Edited Title'}}, # edit Candidates, 1, Apply changes. ['c', '1', 'a']) # Check that 'title' field is modified, and other fields come from # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid for i in self.lib.items())) # Ensure album is fetched from a candidate. self.assertIn('albumid', self.lib.albums()[0].mb_albumid) def test_edit_retag_apply(self): """Import the album using a candidate, then retag and edit and apply changes. """ self._setup_import_session() self.run_mocked_interpreter({}, # 1, Apply changes. ['1', 'a']) # Retag and edit track titles. On retag, the importer will reset items # ids but not the db connections. self.importer.paths = [] self.importer.query = TrueQuery() self.run_mocked_interpreter({'replacements': {'Applied Title': 'Edited Title'}}, # eDit, Apply changes. ['d', 'a']) # Check that 'title' field is modified, and other fields come from # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid for i in self.lib.items())) # Ensure album is fetched from a candidate. self.assertIn('albumid', self.lib.albums()[0].mb_albumid) def test_edit_discard_candidate(self): """Edit the album field for all items in the library, discard changes, using a candidate. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {'Applied Title': 'Edited Title'}}, # edit Candidates, 1, Apply changes. ['c', '1', 'a']) # Check that 'title' field is modified, and other fields come from # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid for i in self.lib.items())) # Ensure album is fetched from a candidate. self.assertIn('albumid', self.lib.albums()[0].mb_albumid) def test_edit_apply_asis_singleton(self): """Edit the album field for all items in the library, apply changes, using the original item tags and singleton mode. """ self._setup_import_session(singletons=True) # Edit track titles. self.run_mocked_interpreter({'replacements': {'Tag Title': 'Edited Title'}}, # eDit, Apply changes, aBort. ['d', 'a', 'b']) # Check that only the 'title' field is modified. self.assertItemFieldsModified(self.lib.items(), self.items_orig, ['title'], self.IGNORED + ['albumartist', 'mb_albumartistid']) self.assertTrue(all('Edited Title' in i.title for i in self.lib.items())) def test_edit_apply_candidate_singleton(self): """Edit the album field for all items in the library, apply changes, using a candidate and singleton mode. """ self._setup_import_session() # Edit track titles. self.run_mocked_interpreter({'replacements': {'Applied Title': 'Edited Title'}}, # edit Candidates, 1, Apply changes, aBort. ['c', '1', 'a', 'b']) # Check that 'title' field is modified, and other fields come from # the candidate. self.assertTrue(all('Edited Title ' in i.title for i in self.lib.items())) self.assertTrue(all('match ' in i.mb_trackid for i in self.lib.items())) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_embedart.py0000644000076500000240000002461700000000000016512 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os.path import shutil from unittest.mock import patch, MagicMock import tempfile import unittest from test import _common from test.helper import TestHelper from mediafile import MediaFile from beets import config, logging, ui from beets.util import syspath, displayable_path from beets.util.artresizer import ArtResizer from beets import art def require_artresizer_compare(test): def wrapper(*args, **kwargs): if not ArtResizer.shared.can_compare: raise unittest.SkipTest("compare not available") else: return test(*args, **kwargs) wrapper.__name__ = test.__name__ return wrapper class EmbedartCliTest(_common.TestCase, TestHelper): small_artpath = os.path.join(_common.RSRC, b'image-2x3.jpg') abbey_artpath = os.path.join(_common.RSRC, b'abbey.jpg') abbey_similarpath = os.path.join(_common.RSRC, b'abbey-similar.jpg') abbey_differentpath = os.path.join(_common.RSRC, b'abbey-different.jpg') def setUp(self): super().setUp() self.io.install() self.setup_beets() # Converter is threaded self.load_plugins('embedart') def _setup_data(self, artpath=None): if not artpath: artpath = self.small_artpath with open(syspath(artpath), 'rb') as f: self.image_data = f.read() def tearDown(self): self.unload_plugins() self.teardown_beets() def test_embed_art_from_file_with_yes_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput('y') self.run_command('embedart', '-f', self.small_artpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) def test_embed_art_from_file_with_no_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput('n') self.run_command('embedart', '-f', self.small_artpath) mediafile = MediaFile(syspath(item.path)) # make sure that images array is empty (nothing embedded) self.assertEqual(len(mediafile.images), 0) def test_embed_art_from_file(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.run_command('embedart', '-y', '-f', self.small_artpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) def test_embed_art_from_album(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] album.artpath = self.small_artpath album.store() self.run_command('embedart', '-y') mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) def test_embed_art_remove_art_file(self): self._setup_data() album = self.add_album_fixture() logging.getLogger('beets.embedart').setLevel(logging.DEBUG) handle, tmp_path = tempfile.mkstemp() os.write(handle, self.image_data) os.close(handle) album.artpath = tmp_path album.store() config['embedart']['remove_art_file'] = True self.run_command('embedart', '-y') if os.path.isfile(tmp_path): os.remove(tmp_path) self.fail(f'Artwork file {tmp_path} was not deleted') def test_art_file_missing(self): self.add_album_fixture() logging.getLogger('beets.embedart').setLevel(logging.DEBUG) with self.assertRaises(ui.UserError): self.run_command('embedart', '-y', '-f', '/doesnotexist') def test_embed_non_image_file(self): album = self.add_album_fixture() logging.getLogger('beets.embedart').setLevel(logging.DEBUG) handle, tmp_path = tempfile.mkstemp() os.write(handle, b'I am not an image.') os.close(handle) try: self.run_command('embedart', '-y', '-f', tmp_path) finally: os.remove(tmp_path) mediafile = MediaFile(syspath(album.items()[0].path)) self.assertFalse(mediafile.images) # No image added. @require_artresizer_compare def test_reject_different_art(self): self._setup_data(self.abbey_artpath) album = self.add_album_fixture() item = album.items()[0] self.run_command('embedart', '-y', '-f', self.abbey_artpath) config['embedart']['compare_threshold'] = 20 self.run_command('embedart', '-y', '-f', self.abbey_differentpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data, 'Image written is not {}'.format( displayable_path(self.abbey_artpath))) @require_artresizer_compare def test_accept_similar_art(self): self._setup_data(self.abbey_similarpath) album = self.add_album_fixture() item = album.items()[0] self.run_command('embedart', '-y', '-f', self.abbey_artpath) config['embedart']['compare_threshold'] = 20 self.run_command('embedart', '-y', '-f', self.abbey_similarpath) mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data, 'Image written is not {}'.format( displayable_path(self.abbey_similarpath))) def test_non_ascii_album_path(self): resource_path = os.path.join(_common.RSRC, b'image.mp3') album = self.add_album_fixture() trackpath = album.items()[0].path albumpath = album.path shutil.copy(syspath(resource_path), syspath(trackpath)) self.run_command('extractart', '-n', 'extracted') self.assertExists(os.path.join(albumpath, b'extracted.png')) def test_extracted_extension(self): resource_path = os.path.join(_common.RSRC, b'image-jpeg.mp3') album = self.add_album_fixture() trackpath = album.items()[0].path albumpath = album.path shutil.copy(syspath(resource_path), syspath(trackpath)) self.run_command('extractart', '-n', 'extracted') self.assertExists(os.path.join(albumpath, b'extracted.jpg')) def test_clear_art_with_yes_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput('y') self.run_command('embedart', '-f', self.small_artpath) self.io.addinput('y') self.run_command('clearart') mediafile = MediaFile(syspath(item.path)) self.assertEqual(len(mediafile.images), 0) def test_clear_art_with_no_input(self): self._setup_data() album = self.add_album_fixture() item = album.items()[0] self.io.addinput('y') self.run_command('embedart', '-f', self.small_artpath) self.io.addinput('n') self.run_command('clearart') mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.images[0].data, self.image_data) @patch('beets.art.subprocess') @patch('beets.art.extract') class ArtSimilarityTest(unittest.TestCase): def setUp(self): self.item = _common.item() self.log = logging.getLogger('beets.embedart') def _similarity(self, threshold): return art.check_art_similarity(self.log, self.item, b'path', threshold) def _popen(self, status=0, stdout="", stderr=""): """Create a mock `Popen` object.""" popen = MagicMock(returncode=status) popen.communicate.return_value = stdout, stderr return popen def _mock_popens(self, mock_extract, mock_subprocess, compare_status=0, compare_stdout="", compare_stderr="", convert_status=0): mock_extract.return_value = b'extracted_path' mock_subprocess.Popen.side_effect = [ # The `convert` call. self._popen(convert_status), # The `compare` call. self._popen(compare_status, compare_stdout, compare_stderr), ] def test_compare_success_similar(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, "10", "err") self.assertTrue(self._similarity(20)) def test_compare_success_different(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, "10", "err") self.assertFalse(self._similarity(5)) def test_compare_status1_similar(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 1, "out", "10") self.assertTrue(self._similarity(20)) def test_compare_status1_different(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 1, "out", "10") self.assertFalse(self._similarity(5)) def test_compare_failed(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 2, "out", "10") self.assertIsNone(self._similarity(20)) def test_compare_parsing_error(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 0, "foo", "bar") self.assertIsNone(self._similarity(20)) def test_compare_parsing_error_and_failure(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, 1, "foo", "bar") self.assertIsNone(self._similarity(20)) def test_convert_failure(self, mock_extract, mock_subprocess): self._mock_popens(mock_extract, mock_subprocess, convert_status=1) self.assertIsNone(self._similarity(20)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_embyupdate.py0000644000076500000240000002350100000000000017055 0ustar00asampsonstafffrom test.helper import TestHelper from beetsplug import embyupdate import unittest import responses class EmbyUpdateTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('embyupdate') self.config['emby'] = { 'host': 'localhost', 'port': 8096, 'username': 'username', 'password': 'password' } def tearDown(self): self.teardown_beets() self.unload_plugins() def test_api_url_only_name(self): self.assertEqual( embyupdate.api_url(self.config['emby']['host'].get(), self.config['emby']['port'].get(), '/Library/Refresh'), 'http://localhost:8096/Library/Refresh?format=json' ) def test_api_url_http(self): self.assertEqual( embyupdate.api_url('http://localhost', self.config['emby']['port'].get(), '/Library/Refresh'), 'http://localhost:8096/Library/Refresh?format=json' ) def test_api_url_https(self): self.assertEqual( embyupdate.api_url('https://localhost', self.config['emby']['port'].get(), '/Library/Refresh'), 'https://localhost:8096/Library/Refresh?format=json' ) def test_password_data(self): self.assertEqual( embyupdate.password_data(self.config['emby']['username'].get(), self.config['emby']['password'].get()), { 'username': 'username', 'password': '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', 'passwordMd5': '5f4dcc3b5aa765d61d8327deb882cf99' } ) def test_create_header_no_token(self): self.assertEqual( embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721'), { 'x-emby-authorization': ( 'MediaBrowser ' 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ) } ) def test_create_header_with_token(self): self.assertEqual( embyupdate.create_headers('e8837bc1-ad67-520e-8cd2-f629e3155721', token='abc123'), { 'x-emby-authorization': ( 'MediaBrowser ' 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ), 'x-mediabrowser-token': 'abc123' } ) @responses.activate def test_get_token(self): body = ('{"User":{"Name":"username", ' '"ServerId":"1efa5077976bfa92bc71652404f646ec",' '"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,' '"HasConfiguredPassword":true,' '"HasConfiguredEasyPassword":false,' '"LastLoginDate":"2015-11-09T08:35:03.6357440Z",' '"LastActivityDate":"2015-11-09T08:35:03.6665060Z",' '"Configuration":{"AudioLanguagePreference":"",' '"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",' '"DisplayMissingEpisodes":false,' '"DisplayUnairedEpisodes":false,' '"GroupMoviesIntoBoxSets":false,' '"DisplayChannelsWithinViews":[],' '"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],' '"SubtitleMode":"Default","DisplayCollectionsView":true,' '"DisplayFoldersView":false,"EnableLocalPassword":false,' '"OrderedViews":[],"IncludeTrailersInSuggestions":true,' '"EnableCinemaMode":true,"LatestItemsExcludes":[],' '"PlainFolderViews":[],"HidePlayedInLatest":true,' '"DisplayChannelsInline":false},' '"Policy":{"IsAdministrator":true,"IsHidden":false,' '"IsDisabled":false,"BlockedTags":[],' '"EnableUserPreferenceAccess":true,"AccessSchedules":[],' '"BlockUnratedItems":[],' '"EnableRemoteControlOfOtherUsers":false,' '"EnableSharedDeviceControl":true,' '"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,' '"EnableMediaPlayback":true,' '"EnableAudioPlaybackTranscoding":true,' '"EnableVideoPlaybackTranscoding":true,' '"EnableContentDeletion":false,' '"EnableContentDownloading":true,"EnableSync":true,' '"EnableSyncTranscoding":true,"EnabledDevices":[],' '"EnableAllDevices":true,"EnabledChannels":[],' '"EnableAllChannels":true,"EnabledFolders":[],' '"EnableAllFolders":true,"InvalidLoginAttemptCount":0,' '"EnablePublicSharing":true}},' '"SessionInfo":{"SupportedCommands":[],' '"QueueableMediaTypes":[],"PlayableMediaTypes":[],' '"Id":"89f3b33f8b3a56af22088733ad1d76b3",' '"UserId":"2ec276a2642e54a19b612b9418a8bd3b",' '"UserName":"username","AdditionalUsers":[],' '"ApplicationVersion":"Unknown version",' '"Client":"Unknown app",' '"LastActivityDate":"2015-11-09T08:35:03.6665060Z",' '"DeviceName":"Unknown device","DeviceId":"Unknown device id",' '"SupportsRemoteControl":false,"PlayState":{"CanSeek":false,' '"IsPaused":false,"IsMuted":false,"RepeatMode":"RepeatNone"}},' '"AccessToken":"4b19180cf02748f7b95c7e8e76562fc8",' '"ServerId":"1efa5077976bfa92bc71652404f646ec"}') responses.add(responses.POST, ('http://localhost:8096' '/Users/AuthenticateByName'), body=body, status=200, content_type='application/json') headers = { 'x-emby-authorization': ( 'MediaBrowser ' 'UserId="e8837bc1-ad67-520e-8cd2-f629e3155721", ' 'Client="other", ' 'Device="beets", ' 'DeviceId="beets", ' 'Version="0.0.0"' ) } auth_data = { 'username': 'username', 'password': '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', 'passwordMd5': '5f4dcc3b5aa765d61d8327deb882cf99' } self.assertEqual( embyupdate.get_token('http://localhost', 8096, headers, auth_data), '4b19180cf02748f7b95c7e8e76562fc8') @responses.activate def test_get_user(self): body = ('[{"Name":"username",' '"ServerId":"1efa5077976bfa92bc71652404f646ec",' '"Id":"2ec276a2642e54a19b612b9418a8bd3b","HasPassword":true,' '"HasConfiguredPassword":true,' '"HasConfiguredEasyPassword":false,' '"LastLoginDate":"2015-11-09T08:35:03.6357440Z",' '"LastActivityDate":"2015-11-09T08:42:39.3693220Z",' '"Configuration":{"AudioLanguagePreference":"",' '"PlayDefaultAudioTrack":true,"SubtitleLanguagePreference":"",' '"DisplayMissingEpisodes":false,' '"DisplayUnairedEpisodes":false,' '"GroupMoviesIntoBoxSets":false,' '"DisplayChannelsWithinViews":[],' '"ExcludeFoldersFromGrouping":[],"GroupedFolders":[],' '"SubtitleMode":"Default","DisplayCollectionsView":true,' '"DisplayFoldersView":false,"EnableLocalPassword":false,' '"OrderedViews":[],"IncludeTrailersInSuggestions":true,' '"EnableCinemaMode":true,"LatestItemsExcludes":[],' '"PlainFolderViews":[],"HidePlayedInLatest":true,' '"DisplayChannelsInline":false},' '"Policy":{"IsAdministrator":true,"IsHidden":false,' '"IsDisabled":false,"BlockedTags":[],' '"EnableUserPreferenceAccess":true,"AccessSchedules":[],' '"BlockUnratedItems":[],' '"EnableRemoteControlOfOtherUsers":false,' '"EnableSharedDeviceControl":true,' '"EnableLiveTvManagement":true,"EnableLiveTvAccess":true,' '"EnableMediaPlayback":true,' '"EnableAudioPlaybackTranscoding":true,' '"EnableVideoPlaybackTranscoding":true,' '"EnableContentDeletion":false,' '"EnableContentDownloading":true,' '"EnableSync":true,"EnableSyncTranscoding":true,' '"EnabledDevices":[],"EnableAllDevices":true,' '"EnabledChannels":[],"EnableAllChannels":true,' '"EnabledFolders":[],"EnableAllFolders":true,' '"InvalidLoginAttemptCount":0,"EnablePublicSharing":true}}]') responses.add(responses.GET, 'http://localhost:8096/Users/Public', body=body, status=200, content_type='application/json') response = embyupdate.get_user('http://localhost', 8096, 'username') self.assertEqual(response[0]['Id'], '2ec276a2642e54a19b612b9418a8bd3b') self.assertEqual(response[0]['Name'], 'username') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_export.py0000644000076500000240000000706500000000000016246 0ustar00asampsonstaff# This file is part of beets. # Copyright 2019, Carl Suster # # 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 beets.export utilities associated with the export plugin. """ import unittest from test.helper import TestHelper import re # used to test csv format import json from xml.etree.ElementTree import Element from xml.etree import ElementTree class ExportPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('export') self.test_values = {'title': 'xtitle', 'album': 'xalbum'} def tearDown(self): self.unload_plugins() self.teardown_beets() def execute_command(self, format_type, artist): query = ','.join(self.test_values.keys()) out = self.run_with_output( 'export', '-f', format_type, '-i', query, artist ) return out def create_item(self): item, = self.add_item_fixtures() item.artist = 'xartist' item.title = self.test_values['title'] item.album = self.test_values['album'] item.write() item.store() return item def test_json_output(self): item1 = self.create_item() out = self.execute_command( format_type='json', artist=item1.artist ) json_data = json.loads(out)[0] for key, val in self.test_values.items(): self.assertTrue(key in json_data) self.assertEqual(val, json_data[key]) def test_jsonlines_output(self): item1 = self.create_item() out = self.execute_command( format_type='jsonlines', artist=item1.artist ) json_data = json.loads(out) for key, val in self.test_values.items(): self.assertTrue(key in json_data) self.assertEqual(val, json_data[key]) def test_csv_output(self): item1 = self.create_item() out = self.execute_command( format_type='csv', artist=item1.artist ) csv_list = re.split('\r', re.sub('\n', '', out)) head = re.split(',', csv_list[0]) vals = re.split(',|\r', csv_list[1]) for index, column in enumerate(head): self.assertTrue(self.test_values.get(column, None) is not None) self.assertEqual(vals[index], self.test_values[column]) def test_xml_output(self): item1 = self.create_item() out = self.execute_command( format_type='xml', artist=item1.artist ) library = ElementTree.fromstring(out) self.assertIsInstance(library, Element) for track in library[0]: for details in track: tag = details.tag txt = details.text self.assertTrue(tag in self.test_values, msg=tag) self.assertEqual(self.test_values[tag], txt, msg=txt) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_fetchart.py0000644000076500000240000000763700000000000016532 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import ctypes import os import sys import unittest from test.helper import TestHelper from beets import util class FetchartCliTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('fetchart') self.config['fetchart']['cover_names'] = 'c\xc3\xb6ver.jpg' self.config['art_filename'] = 'mycover' self.album = self.add_album() self.cover_path = os.path.join(self.album.path, b'mycover.jpg') def tearDown(self): self.unload_plugins() self.teardown_beets() def check_cover_is_stored(self): self.assertEqual(self.album['artpath'], self.cover_path) with open(util.syspath(self.cover_path)) as f: self.assertEqual(f.read(), 'IMAGE') def hide_file_windows(self): hidden_mask = 2 success = ctypes.windll.kernel32.SetFileAttributesW(self.cover_path, hidden_mask) if not success: self.skipTest("unable to set file attributes") def test_set_art_from_folder(self): self.touch(b'c\xc3\xb6ver.jpg', dir=self.album.path, content='IMAGE') self.run_command('fetchart') self.album.load() self.check_cover_is_stored() def test_filesystem_does_not_pick_up_folder(self): os.makedirs(os.path.join(self.album.path, b'mycover.jpg')) self.run_command('fetchart') self.album.load() self.assertEqual(self.album['artpath'], None) def test_filesystem_does_not_pick_up_ignored_file(self): self.touch(b'co_ver.jpg', dir=self.album.path, content='IMAGE') self.config['ignore'] = ['*_*'] self.run_command('fetchart') self.album.load() self.assertEqual(self.album['artpath'], None) def test_filesystem_picks_up_non_ignored_file(self): self.touch(b'cover.jpg', dir=self.album.path, content='IMAGE') self.config['ignore'] = ['*_*'] self.run_command('fetchart') self.album.load() self.check_cover_is_stored() def test_filesystem_does_not_pick_up_hidden_file(self): self.touch(b'.cover.jpg', dir=self.album.path, content='IMAGE') if sys.platform == 'win32': self.hide_file_windows() self.config['ignore'] = [] # By default, ignore includes '.*'. self.config['ignore_hidden'] = True self.run_command('fetchart') self.album.load() self.assertEqual(self.album['artpath'], None) def test_filesystem_picks_up_non_hidden_file(self): self.touch(b'cover.jpg', dir=self.album.path, content='IMAGE') self.config['ignore_hidden'] = True self.run_command('fetchart') self.album.load() self.check_cover_is_stored() def test_filesystem_picks_up_hidden_file(self): self.touch(b'.cover.jpg', dir=self.album.path, content='IMAGE') if sys.platform == 'win32': self.hide_file_windows() self.config['ignore'] = [] # By default, ignore includes '.*'. self.config['ignore_hidden'] = False self.run_command('fetchart') self.album.load() self.check_cover_is_stored() def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_filefilter.py0000644000076500000240000001764200000000000017054 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Malte Ried. # # 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 `filefilter` plugin. """ import os import shutil import unittest from test import _common from test.helper import capture_log from test.test_importer import ImportHelper from beets import config from mediafile import MediaFile from beets.util import displayable_path, bytestring_path from beetsplug.filefilter import FileFilterPlugin class FileFilterPluginTest(unittest.TestCase, ImportHelper): def setUp(self): self.setup_beets() self.__create_import_dir(2) self._setup_import_session() config['import']['pretend'] = True def tearDown(self): self.teardown_beets() def __copy_file(self, dest_path, metadata): # Copy files resource_path = os.path.join(_common.RSRC, b'full.mp3') shutil.copy(resource_path, dest_path) medium = MediaFile(dest_path) # Set metadata for attr in metadata: setattr(medium, attr, metadata[attr]) medium.save() def __create_import_dir(self, count): self.import_dir = os.path.join(self.temp_dir, b'testsrcdir') if os.path.isdir(self.import_dir): shutil.rmtree(self.import_dir) self.artist_path = os.path.join(self.import_dir, b'artist') self.album_path = os.path.join(self.artist_path, b'album') self.misc_path = os.path.join(self.import_dir, b'misc') os.makedirs(self.album_path) os.makedirs(self.misc_path) metadata = { 'artist': 'Tag Artist', 'album': 'Tag Album', 'albumartist': None, 'mb_trackid': None, 'mb_albumid': None, 'comp': None, } self.album_paths = [] for i in range(count): metadata['track'] = i + 1 metadata['title'] = 'Tag Title Album %d' % (i + 1) track_file = bytestring_path('%02d - track.mp3' % (i + 1)) dest_path = os.path.join(self.album_path, track_file) self.__copy_file(dest_path, metadata) self.album_paths.append(dest_path) self.artist_paths = [] metadata['album'] = None for i in range(count): metadata['track'] = i + 10 metadata['title'] = 'Tag Title Artist %d' % (i + 1) track_file = bytestring_path('track_%d.mp3' % (i + 1)) dest_path = os.path.join(self.artist_path, track_file) self.__copy_file(dest_path, metadata) self.artist_paths.append(dest_path) self.misc_paths = [] for i in range(count): metadata['artist'] = 'Artist %d' % (i + 42) metadata['track'] = i + 5 metadata['title'] = 'Tag Title Misc %d' % (i + 1) track_file = bytestring_path('track_%d.mp3' % (i + 1)) dest_path = os.path.join(self.misc_path, track_file) self.__copy_file(dest_path, metadata) self.misc_paths.append(dest_path) def __run(self, expected_lines, singletons=False): self.load_plugins('filefilter') import_files = [self.import_dir] self._setup_import_session(singletons=singletons) self.importer.paths = import_files with capture_log() as logs: self.importer.run() self.unload_plugins() FileFilterPlugin.listeners = None logs = [line for line in logs if not line.startswith('Sending event:')] self.assertEqual(logs, expected_lines) def test_import_default(self): """ The default configuration should import everything. """ self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), ' %s' % displayable_path(self.artist_paths[1]), 'Album: %s' % displayable_path(self.album_path), ' %s' % displayable_path(self.album_paths[0]), ' %s' % displayable_path(self.album_paths[1]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ' %s' % displayable_path(self.misc_paths[1]) ]) def test_import_nothing(self): config['filefilter']['path'] = 'not_there' self.__run(['No files imported from %s' % displayable_path( self.import_dir)]) # Global options def test_import_global(self): config['filefilter']['path'] = '.*track_1.*\\.mp3' self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ]) self.__run([ 'Singleton: %s' % displayable_path(self.artist_paths[0]), 'Singleton: %s' % displayable_path(self.misc_paths[0]) ], singletons=True) # Album options def test_import_album(self): config['filefilter']['album_path'] = '.*track_1.*\\.mp3' self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ]) self.__run([ 'Singleton: %s' % displayable_path(self.artist_paths[0]), 'Singleton: %s' % displayable_path(self.artist_paths[1]), 'Singleton: %s' % displayable_path(self.album_paths[0]), 'Singleton: %s' % displayable_path(self.album_paths[1]), 'Singleton: %s' % displayable_path(self.misc_paths[0]), 'Singleton: %s' % displayable_path(self.misc_paths[1]) ], singletons=True) # Singleton options def test_import_singleton(self): config['filefilter']['singleton_path'] = '.*track_1.*\\.mp3' self.__run([ 'Singleton: %s' % displayable_path(self.artist_paths[0]), 'Singleton: %s' % displayable_path(self.misc_paths[0]) ], singletons=True) self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), ' %s' % displayable_path(self.artist_paths[1]), 'Album: %s' % displayable_path(self.album_path), ' %s' % displayable_path(self.album_paths[0]), ' %s' % displayable_path(self.album_paths[1]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ' %s' % displayable_path(self.misc_paths[1]) ]) # Album and singleton options def test_import_both(self): config['filefilter']['album_path'] = '.*track_1.*\\.mp3' config['filefilter']['singleton_path'] = '.*track_2.*\\.mp3' self.__run([ 'Album: %s' % displayable_path(self.artist_path), ' %s' % displayable_path(self.artist_paths[0]), 'Album: %s' % displayable_path(self.misc_path), ' %s' % displayable_path(self.misc_paths[0]), ]) self.__run([ 'Singleton: %s' % displayable_path(self.artist_paths[1]), 'Singleton: %s' % displayable_path(self.misc_paths[1]) ], singletons=True) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_files.py0000644000076500000240000005625500000000000016034 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test file manipulation functionality of Item. """ import shutil import os import stat from os.path import join import unittest from test import _common from test._common import item, touch import beets.library from beets import util from beets.util import MoveOperation class MoveTest(_common.TestCase): def setUp(self): super().setUp() # make a temporary file self.path = join(self.temp_dir, b'temp.mp3') shutil.copy(join(_common.RSRC, b'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, b'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, b'one', b'two', b'three.mp3') self.otherdir = join(self.temp_dir, b'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, b'one', b'two', b'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(operation=MoveOperation.COPY) self.assertExists(self.dest) def test_copy_does_not_depart(self): self.i.move(operation=MoveOperation.COPY) self.assertExists(self.path) def test_reflink_arrives(self): self.i.move(operation=MoveOperation.REFLINK_AUTO) self.assertExists(self.dest) def test_reflink_does_not_depart(self): self.i.move(operation=MoveOperation.REFLINK_AUTO) self.assertExists(self.path) @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink") def test_force_reflink_arrives(self): self.i.move(operation=MoveOperation.REFLINK) self.assertExists(self.dest) @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink") def test_force_reflink_does_not_depart(self): self.i.move(operation=MoveOperation.REFLINK) 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(operation=MoveOperation.COPY) 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() self.assertEqual(self.i.path, old_path) def test_move_file_with_colon(self): self.i.artist = 'C:DOS' self.i.move() self.assertIn('C_DOS', self.i.path.decode()) def test_move_file_with_multiple_colons(self): print(beets.config['replace']) self.i.artist = 'COM:DOS' self.i.move() self.assertIn('COM_DOS', self.i.path.decode()) def test_move_file_with_colon_alt_separator(self): old = beets.config['drive_sep_replace'] beets.config["drive_sep_replace"] = '0' self.i.artist = 'C:DOS' self.i.move() self.assertIn('C0DOS', self.i.path.decode()) beets.config["drive_sep_replace"] = old def test_read_only_file_copied_writable(self): # Make the source file read-only. os.chmod(self.path, 0o444) try: self.i.move(operation=MoveOperation.COPY) self.assertTrue(os.access(self.i.path, os.W_OK)) finally: # Make everything writable so it can be cleaned up. os.chmod(self.path, 0o777) os.chmod(self.i.path, 0o777) 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)) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_arrives(self): self.i.move(operation=MoveOperation.LINK) self.assertExists(self.dest) self.assertTrue(os.path.islink(self.dest)) self.assertEqual(os.readlink(self.dest), self.path) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_does_not_depart(self): self.i.move(operation=MoveOperation.LINK) self.assertExists(self.path) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_link_changes_path(self): self.i.move(operation=MoveOperation.LINK) self.assertEqual(self.i.path, util.normpath(self.dest)) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_arrives(self): self.i.move(operation=MoveOperation.HARDLINK) self.assertExists(self.dest) s1 = os.stat(self.path) s2 = os.stat(self.dest) self.assertTrue( (s1[stat.ST_INO], s1[stat.ST_DEV]) == (s2[stat.ST_INO], s2[stat.ST_DEV]) ) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_does_not_depart(self): self.i.move(operation=MoveOperation.HARDLINK) self.assertExists(self.path) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_hardlink_changes_path(self): self.i.move(operation=MoveOperation.HARDLINK) self.assertEqual(self.i.path, util.normpath(self.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) def test_forward_slash(self): p = br'C:\a\b\c' a = br'C:/a/b/c' self.assertEqual(util.path_as_posix(p), a) class AlbumFileTest(_common.TestCase): def setUp(self): super().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, b'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, b'testotherdir') def test_albuminfo_move_changes_paths(self): self.ai.album = 'newAlbumName' self.ai.move() self.ai.store() self.i.load() self.assertTrue(b'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(operation=MoveOperation.COPY) self.ai.store() self.i.load() self.assertTrue(os.path.exists(oldpath)) self.assertTrue(os.path.exists(self.i.path)) @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink") def test_albuminfo_move_reflinks_file(self): oldpath = self.i.path self.ai.album = 'newAlbumName' self.ai.move(operation=MoveOperation.REFLINK) 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(b'testotherdir' in self.i.path) class ArtFileTest(_common.TestCase): def setUp(self): super().setUp() # Make library and item. self.lib = beets.library.Library(':memory:') self.libdir = os.path.join(self.temp_dir, b'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, b'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(b'testotherdir' in newart) def test_setart_copies_image(self): os.remove(self.art) newart = os.path.join(self.libdir, b'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) 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, b'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) 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, b'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) # 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, b'newart.jpg') touch(newart) i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) # 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, b'newart.jpg') touch(newart) os.chmod(newart, 0o400) # read-only try: i2 = item() i2.path = self.i.path i2.artist = 'someArtist' ai = self.lib.add_album((i2,)) i2.move(operation=MoveOperation.COPY) 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, 0o777) os.chmod(ai.artpath, 0o777) 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(b'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(b'different_album' in artpath) self.assertEqual(artpath, oldartpath) self.assertExists(oldartpath) class RemoveTest(_common.TestCase): def setUp(self): super().setUp() # Make library and item. self.lib = beets.library.Library(':memory:') self.libdir = os.path.join(self.temp_dir, b'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, b'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, b'.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.join(self.temp_dir, b'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, b'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().setUp() self.path = os.path.join(self.temp_dir, b'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 + b'XXX', True) except OSError: self.fail('OSError when removing path') class SafeMoveCopyTest(_common.TestCase): def setUp(self): super().setUp() self.path = os.path.join(self.temp_dir, b'testfile') touch(self.path) self.otherpath = os.path.join(self.temp_dir, b'testfile2') touch(self.otherpath) self.dest = self.path + b'.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) @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink") def test_successful_reflink(self): util.reflink(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) @unittest.skipUnless(_common.HAVE_REFLINK, "need reflink") def test_unsuccessful_reflink(self): with self.assertRaises(util.FilesystemError): util.reflink(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().setUp() self.base = os.path.join(self.temp_dir, b'testdir') os.mkdir(self.base) self.sub = os.path.join(self.base, b'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, b'another'), self.base) self.assertExists(self.base) self.assertNotExists(self.sub) class WalkTest(_common.TestCase): def setUp(self): super().setUp() self.base = os.path.join(self.temp_dir, b'testdir') os.mkdir(self.base) touch(os.path.join(self.base, b'y')) touch(os.path.join(self.base, b'x')) os.mkdir(os.path.join(self.base, b'd')) touch(os.path.join(self.base, b'd', b'z')) def test_sorted_files(self): res = list(util.sorted_walk(self.base)) self.assertEqual(len(res), 2) self.assertEqual(res[0], (self.base, [b'd'], [b'x', b'y'])) self.assertEqual(res[1], (os.path.join(self.base, b'd'), [], [b'z'])) def test_ignore_file(self): res = list(util.sorted_walk(self.base, (b'x',))) self.assertEqual(len(res), 2) self.assertEqual(res[0], (self.base, [b'd'], [b'y'])) self.assertEqual(res[1], (os.path.join(self.base, b'd'), [], [b'z'])) def test_ignore_directory(self): res = list(util.sorted_walk(self.base, (b'd',))) self.assertEqual(len(res), 1) self.assertEqual(res[0], (self.base, [], [b'x', b'y'])) def test_ignore_everything(self): res = list(util.sorted_walk(self.base, (b'*',))) self.assertEqual(len(res), 1) self.assertEqual(res[0], (self.base, [], [])) class UniquePathTest(_common.TestCase): def setUp(self): super().setUp() self.base = os.path.join(self.temp_dir, b'testdir') os.mkdir(self.base) touch(os.path.join(self.base, b'x.mp3')) touch(os.path.join(self.base, b'x.1.mp3')) touch(os.path.join(self.base, b'x.2.mp3')) touch(os.path.join(self.base, b'y.mp3')) def test_new_file_unchanged(self): path = util.unique_path(os.path.join(self.base, b'z.mp3')) self.assertEqual(path, os.path.join(self.base, b'z.mp3')) def test_conflicting_file_appends_1(self): path = util.unique_path(os.path.join(self.base, b'y.mp3')) self.assertEqual(path, os.path.join(self.base, b'y.1.mp3')) def test_conflicting_file_appends_higher_number(self): path = util.unique_path(os.path.join(self.base, b'x.mp3')) self.assertEqual(path, os.path.join(self.base, b'x.3.mp3')) def test_conflicting_file_with_number_increases_number(self): path = util.unique_path(os.path.join(self.base, b'x.1.mp3')) self.assertEqual(path, os.path.join(self.base, b'x.3.mp3')) class MkDirAllTest(_common.TestCase): def test_parent_exists(self): path = os.path.join(self.temp_dir, b'foo', b'bar', b'baz', b'qux.mp3') util.mkdirall(path) self.assertTrue(os.path.isdir( os.path.join(self.temp_dir, b'foo', b'bar', b'baz') )) def test_child_does_not_exist(self): path = os.path.join(self.temp_dir, b'foo', b'bar', b'baz', b'qux.mp3') util.mkdirall(path) self.assertTrue(not os.path.exists( os.path.join(self.temp_dir, b'foo', b'bar', b'baz', b'qux.mp3') )) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_ftintitle.py0000644000076500000240000001502700000000000016724 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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. """Tests for the 'ftintitle' plugin.""" import unittest from test.helper import TestHelper from beetsplug import ftintitle class FtInTitlePluginFunctional(unittest.TestCase, TestHelper): def setUp(self): """Set up configuration""" self.setup_beets() self.load_plugins('ftintitle') def tearDown(self): self.unload_plugins() self.teardown_beets() def _ft_add_item(self, path, artist, title, aartist): return self.add_item(path=path, artist=artist, artist_sort=artist, title=title, albumartist=aartist) def _ft_set_config(self, ftformat, drop=False, auto=True): self.config['ftintitle']['format'] = ftformat self.config['ftintitle']['drop'] = drop self.config['ftintitle']['auto'] = auto def test_functional_drop(self): item = self._ft_add_item('/', 'Alice ft Bob', 'Song 1', 'Alice') self.run_command('ftintitle', '-d') item.load() self.assertEqual(item['artist'], 'Alice') self.assertEqual(item['title'], 'Song 1') def test_functional_not_found(self): item = self._ft_add_item('/', 'Alice ft Bob', 'Song 1', 'George') self.run_command('ftintitle', '-d') item.load() # item should be unchanged self.assertEqual(item['artist'], 'Alice ft Bob') self.assertEqual(item['title'], 'Song 1') def test_functional_custom_format(self): self._ft_set_config('feat. {0}') item = self._ft_add_item('/', 'Alice ft Bob', 'Song 1', 'Alice') self.run_command('ftintitle') item.load() self.assertEqual(item['artist'], 'Alice') self.assertEqual(item['title'], 'Song 1 feat. Bob') self._ft_set_config('featuring {0}') item = self._ft_add_item('/', 'Alice feat. Bob', 'Song 1', 'Alice') self.run_command('ftintitle') item.load() self.assertEqual(item['artist'], 'Alice') self.assertEqual(item['title'], 'Song 1 featuring Bob') self._ft_set_config('with {0}') item = self._ft_add_item('/', 'Alice feat Bob', 'Song 1', 'Alice') self.run_command('ftintitle') item.load() self.assertEqual(item['artist'], 'Alice') self.assertEqual(item['title'], 'Song 1 with Bob') class FtInTitlePluginTest(unittest.TestCase): def setUp(self): """Set up configuration""" ftintitle.FtInTitlePlugin() def test_find_feat_part(self): test_cases = [ { 'artist': 'Alice ft. Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice feat Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice featuring Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice & Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice and Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice With Bob', 'album_artist': 'Alice', 'feat_part': 'Bob' }, { 'artist': 'Alice defeat Bob', 'album_artist': 'Alice', 'feat_part': None }, { 'artist': 'Alice & Bob', 'album_artist': 'Bob', 'feat_part': 'Alice' }, { 'artist': 'Alice ft. Bob', 'album_artist': 'Bob', 'feat_part': 'Alice' }, { 'artist': 'Alice ft. Carol', 'album_artist': 'Bob', 'feat_part': None }, ] for test_case in test_cases: feat_part = ftintitle.find_feat_part( test_case['artist'], test_case['album_artist'] ) self.assertEqual(feat_part, test_case['feat_part']) def test_split_on_feat(self): parts = ftintitle.split_on_feat('Alice ft. Bob') self.assertEqual(parts, ('Alice', 'Bob')) parts = ftintitle.split_on_feat('Alice feat Bob') self.assertEqual(parts, ('Alice', 'Bob')) parts = ftintitle.split_on_feat('Alice feat. Bob') self.assertEqual(parts, ('Alice', 'Bob')) parts = ftintitle.split_on_feat('Alice featuring Bob') self.assertEqual(parts, ('Alice', 'Bob')) parts = ftintitle.split_on_feat('Alice & Bob') self.assertEqual(parts, ('Alice', 'Bob')) parts = ftintitle.split_on_feat('Alice and Bob') self.assertEqual(parts, ('Alice', 'Bob')) parts = ftintitle.split_on_feat('Alice With Bob') self.assertEqual(parts, ('Alice', 'Bob')) parts = ftintitle.split_on_feat('Alice defeat Bob') self.assertEqual(parts, ('Alice defeat Bob', None)) def test_contains_feat(self): self.assertTrue(ftintitle.contains_feat('Alice ft. Bob')) self.assertTrue(ftintitle.contains_feat('Alice feat. Bob')) self.assertTrue(ftintitle.contains_feat('Alice feat Bob')) self.assertTrue(ftintitle.contains_feat('Alice featuring Bob')) self.assertTrue(ftintitle.contains_feat('Alice & Bob')) self.assertTrue(ftintitle.contains_feat('Alice and Bob')) self.assertTrue(ftintitle.contains_feat('Alice With Bob')) self.assertFalse(ftintitle.contains_feat('Alice defeat Bob')) self.assertFalse(ftintitle.contains_feat('Aliceft.Bob')) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_hidden.py0000644000076500000240000000503300000000000016151 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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. """Tests for the 'hidden' utility.""" import unittest import sys import tempfile from beets.util import hidden from beets import util import subprocess import errno import ctypes class HiddenFileTest(unittest.TestCase): def setUp(self): pass def test_osx_hidden(self): if not sys.platform == 'darwin': self.skipTest('sys.platform is not darwin') return with tempfile.NamedTemporaryFile(delete=False) as f: try: command = ["chflags", "hidden", f.name] subprocess.Popen(command).wait() except OSError as e: if e.errno == errno.ENOENT: self.skipTest("unable to find chflags") else: raise e self.assertTrue(hidden.is_hidden(f.name)) def test_windows_hidden(self): if not sys.platform == 'win32': self.skipTest('sys.platform is not windows') return # FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation. hidden_mask = 2 with tempfile.NamedTemporaryFile() as f: # Hide the file using success = ctypes.windll.kernel32.SetFileAttributesW(f.name, hidden_mask) if not success: self.skipTest("unable to set file attributes") self.assertTrue(hidden.is_hidden(f.name)) def test_other_hidden(self): if sys.platform == 'darwin' or sys.platform == 'win32': self.skipTest('sys.platform is known') return with tempfile.NamedTemporaryFile(prefix='.tmp') as f: fn = util.bytestring_path(f.name) self.assertTrue(hidden.is_hidden(fn)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_hook.py0000644000076500000240000001234300000000000015660 0ustar00asampsonstaff# This file is part of beets. # Copyright 2015, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os.path import sys import tempfile import unittest from test import _common from test.helper import TestHelper, capture_log from beets import config from beets import plugins def get_temporary_path(): temporary_directory = tempfile._get_default_tempdir() temporary_name = next(tempfile._get_candidate_names()) return os.path.join(temporary_directory, temporary_name) class HookTest(_common.TestCase, TestHelper): TEST_HOOK_COUNT = 5 def setUp(self): self.setup_beets() def tearDown(self): self.unload_plugins() self.teardown_beets() def _add_hook(self, event, command): hook = { 'event': event, 'command': command } hooks = config['hook']['hooks'].get(list) if 'hook' in config else [] hooks.append(hook) config['hook']['hooks'] = hooks def test_hook_empty_command(self): self._add_hook('test_event', '') self.load_plugins('hook') with capture_log('beets.hook') as logs: plugins.send('test_event') self.assertIn('hook: invalid command ""', logs) @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_hook_non_zero_exit(self): self._add_hook('test_event', 'sh -c "exit 1"') self.load_plugins('hook') with capture_log('beets.hook') as logs: plugins.send('test_event') self.assertIn('hook: hook for test_event exited with status 1', logs) def test_hook_non_existent_command(self): self._add_hook('test_event', 'non-existent-command') self.load_plugins('hook') with capture_log('beets.hook') as logs: plugins.send('test_event') self.assertTrue(any( message.startswith("hook: hook for test_event failed: ") for message in logs)) @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_hook_no_arguments(self): temporary_paths = [ get_temporary_path() for i in range(self.TEST_HOOK_COUNT) ] for index, path in enumerate(temporary_paths): self._add_hook(f'test_no_argument_event_{index}', f'touch "{path}"') self.load_plugins('hook') for index in range(len(temporary_paths)): plugins.send(f'test_no_argument_event_{index}') for path in temporary_paths: self.assertTrue(os.path.isfile(path)) os.remove(path) @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_hook_event_substitution(self): temporary_directory = tempfile._get_default_tempdir() event_names = [f'test_event_event_{i}' for i in range(self.TEST_HOOK_COUNT)] for event in event_names: self._add_hook(event, f'touch "{temporary_directory}/{{event}}"') self.load_plugins('hook') for event in event_names: plugins.send(event) for event in event_names: path = os.path.join(temporary_directory, event) self.assertTrue(os.path.isfile(path)) os.remove(path) @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_hook_argument_substitution(self): temporary_paths = [ get_temporary_path() for i in range(self.TEST_HOOK_COUNT) ] for index, path in enumerate(temporary_paths): self._add_hook(f'test_argument_event_{index}', 'touch "{path}"') self.load_plugins('hook') for index, path in enumerate(temporary_paths): plugins.send(f'test_argument_event_{index}', path=path) for path in temporary_paths: self.assertTrue(os.path.isfile(path)) os.remove(path) @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_hook_bytes_interpolation(self): temporary_paths = [ get_temporary_path().encode('utf-8') for i in range(self.TEST_HOOK_COUNT) ] for index, path in enumerate(temporary_paths): self._add_hook(f'test_bytes_event_{index}', 'touch "{path}"') self.load_plugins('hook') for index, path in enumerate(temporary_paths): plugins.send(f'test_bytes_event_{index}', path=path) for path in temporary_paths: self.assertTrue(os.path.isfile(path)) os.remove(path) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_ihate.py0000644000076500000240000000326400000000000016014 0ustar00asampsonstaff"""Tests for the 'ihate' plugin""" import unittest from beets import importer from beets.library import Item from beetsplug.ihate import IHatePlugin class IHatePluginTest(unittest.TestCase): def test_hate(self): match_pattern = {} test_item = Item( genre='TestGenre', album='TestAlbum', artist='TestArtist') task = importer.SingletonImportTask(None, test_item) # Empty query should let it pass. self.assertFalse(IHatePlugin.do_i_hate_this(task, match_pattern)) # 1 query match. match_pattern = ["artist:bad_artist", "artist:TestArtist"] self.assertTrue(IHatePlugin.do_i_hate_this(task, match_pattern)) # 2 query matches, either should trigger. match_pattern = ["album:test", "artist:testartist"] self.assertTrue(IHatePlugin.do_i_hate_this(task, match_pattern)) # Query is blocked by AND clause. match_pattern = ["album:notthis genre:testgenre"] self.assertFalse(IHatePlugin.do_i_hate_this(task, match_pattern)) # Both queries are blocked by AND clause with unmatched condition. match_pattern = ["album:notthis genre:testgenre", "artist:testartist album:notthis"] self.assertFalse(IHatePlugin.do_i_hate_this(task, match_pattern)) # Only one query should fire. match_pattern = ["album:testalbum genre:testgenre", "artist:testartist album:notthis"] self.assertTrue(IHatePlugin.do_i_hate_this(task, match_pattern)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_importadded.py0000644000076500000240000001573700000000000017226 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Stig Inge Lea Bjornsen. # # 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 `importadded` plugin.""" import os import unittest from test.test_importer import ImportHelper, AutotagStub from beets import importer from beets import util from beetsplug.importadded import ImportAddedPlugin _listeners = ImportAddedPlugin.listeners def preserve_plugin_listeners(): """Preserve the initial plugin listeners as they would otherwise be deleted after the first setup / tear down cycle. """ if not ImportAddedPlugin.listeners: ImportAddedPlugin.listeners = _listeners def modify_mtimes(paths, offset=-60000): for i, path in enumerate(paths, start=1): mstat = os.stat(path) os.utime(path, (mstat.st_atime, mstat.st_mtime + offset * i)) class ImportAddedTest(unittest.TestCase, ImportHelper): # The minimum mtime of the files to be imported min_mtime = None def setUp(self): preserve_plugin_listeners() self.setup_beets() self.load_plugins('importadded') self._create_import_dir(2) # Different mtimes on the files to be imported in order to test the # plugin modify_mtimes(mfile.path for mfile in self.media_files) self.min_mtime = min(os.path.getmtime(mfile.path) for mfile in self.media_files) self.matcher = AutotagStub().install() self.matcher.macthin = AutotagStub.GOOD self._setup_import_session() self.importer.add_choice(importer.action.APPLY) def tearDown(self): self.unload_plugins() self.teardown_beets() self.matcher.restore() def find_media_file(self, item): """Find the pre-import MediaFile for an Item""" for m in self.media_files: if m.title.replace('Tag', 'Applied') == item.title: return m raise AssertionError("No MediaFile found for Item " + util.displayable_path(item.path)) def assertEqualTimes(self, first, second, msg=None): # noqa """For comparing file modification times at a sufficient precision""" self.assertAlmostEqual(first, second, places=4, msg=msg) def assertAlbumImport(self): # noqa self.importer.run() album = self.lib.albums().get() self.assertEqual(album.added, self.min_mtime) for item in album.items(): self.assertEqual(item.added, self.min_mtime) def test_import_album_with_added_dates(self): self.assertAlbumImport() def test_import_album_inplace_with_added_dates(self): self.config['import']['copy'] = False self.config['import']['move'] = False self.config['import']['link'] = False self.config['import']['hardlink'] = False self.assertAlbumImport() def test_import_album_with_preserved_mtimes(self): self.config['importadded']['preserve_mtimes'] = True self.importer.run() album = self.lib.albums().get() self.assertEqual(album.added, self.min_mtime) for item in album.items(): self.assertEqualTimes(item.added, self.min_mtime) mediafile_mtime = os.path.getmtime(self.find_media_file(item).path) self.assertEqualTimes(item.mtime, mediafile_mtime) self.assertEqualTimes(os.path.getmtime(item.path), mediafile_mtime) def test_reimported_album_skipped(self): # Import and record the original added dates self.importer.run() album = self.lib.albums().get() album_added_before = album.added items_added_before = {item.path: item.added for item in album.items()} # Newer Item path mtimes as if Beets had modified them modify_mtimes(items_added_before.keys(), offset=10000) # Reimport self._setup_import_session(import_dir=album.path) self.importer.run() # Verify the reimported items album = self.lib.albums().get() self.assertEqualTimes(album.added, album_added_before) items_added_after = {item.path: item.added for item in album.items()} for item_path, added_after in items_added_after.items(): self.assertEqualTimes(items_added_before[item_path], added_after, "reimport modified Item.added for " + util.displayable_path(item_path)) def test_import_singletons_with_added_dates(self): self.config['import']['singletons'] = True self.importer.run() for item in self.lib.items(): mfile = self.find_media_file(item) self.assertEqualTimes(item.added, os.path.getmtime(mfile.path)) def test_import_singletons_with_preserved_mtimes(self): self.config['import']['singletons'] = True self.config['importadded']['preserve_mtimes'] = True self.importer.run() for item in self.lib.items(): mediafile_mtime = os.path.getmtime(self.find_media_file(item).path) self.assertEqualTimes(item.added, mediafile_mtime) self.assertEqualTimes(item.mtime, mediafile_mtime) self.assertEqualTimes(os.path.getmtime(item.path), mediafile_mtime) def test_reimported_singletons_skipped(self): self.config['import']['singletons'] = True # Import and record the original added dates self.importer.run() items_added_before = {item.path: item.added for item in self.lib.items()} # Newer Item path mtimes as if Beets had modified them modify_mtimes(items_added_before.keys(), offset=10000) # Reimport import_dir = os.path.dirname(list(items_added_before.keys())[0]) self._setup_import_session(import_dir=import_dir, singletons=True) self.importer.run() # Verify the reimported items items_added_after = {item.path: item.added for item in self.lib.items()} for item_path, added_after in items_added_after.items(): self.assertEqualTimes(items_added_before[item_path], added_after, "reimport modified Item.added for " + util.displayable_path(item_path)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_importer.py0000644000076500000240000021227600000000000016570 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the general importer functionality. """ import os import re import shutil import unicodedata import sys import stat from six import StringIO from tempfile import mkstemp from zipfile import ZipFile from tarfile import TarFile from unittest.mock import patch, Mock import unittest from test import _common from beets.util import displayable_path, bytestring_path, py3_path from test.helper import TestHelper, has_program, capture_log from test.helper import ImportSessionFixture from beets import importer from beets.importer import albums_in_dir from mediafile import MediaFile from beets import autotag from beets.autotag import AlbumInfo, TrackInfo, AlbumMatch from beets import config from beets import logging from beets import util class AutotagStub: """Stub out MusicBrainz album and track matcher and control what the autotagger returns. """ NONE = 'NONE' IDENT = 'IDENT' GOOD = 'GOOD' BAD = 'BAD' MISSING = 'MISSING' """Generate an album match for all but one track """ length = 2 matching = IDENT def install(self): self.mb_match_album = autotag.mb.match_album self.mb_match_track = autotag.mb.match_track self.mb_album_for_id = autotag.mb.album_for_id self.mb_track_for_id = autotag.mb.track_for_id autotag.mb.match_album = self.match_album autotag.mb.match_track = self.match_track autotag.mb.album_for_id = self.album_for_id autotag.mb.track_for_id = self.track_for_id return self def restore(self): autotag.mb.match_album = self.mb_match_album autotag.mb.match_track = self.mb_match_track autotag.mb.album_for_id = self.mb_album_for_id autotag.mb.track_for_id = self.mb_track_for_id def match_album(self, albumartist, album, tracks, extra_tags): if self.matching == self.IDENT: yield self._make_album_match(albumartist, album, tracks) elif self.matching == self.GOOD: for i in range(self.length): yield self._make_album_match(albumartist, album, tracks, i) elif self.matching == self.BAD: for i in range(self.length): yield self._make_album_match(albumartist, album, tracks, i + 1) elif self.matching == self.MISSING: yield self._make_album_match(albumartist, album, tracks, missing=1) def match_track(self, artist, title): yield TrackInfo( title=title.replace('Tag', 'Applied'), track_id='trackid', artist=artist.replace('Tag', 'Applied'), artist_id='artistid', length=1, index=0, ) def album_for_id(self, mbid): return None def track_for_id(self, mbid): return None def _make_track_match(self, artist, album, number): return TrackInfo( title='Applied Title %d' % number, track_id='match %d' % number, artist=artist, length=1, index=0, ) def _make_album_match(self, artist, album, tracks, distance=0, missing=0): if distance: id = ' ' + 'M' * distance else: id = '' if artist is None: artist = "Various Artists" else: artist = artist.replace('Tag', 'Applied') + id album = album.replace('Tag', 'Applied') + id track_infos = [] for i in range(tracks - missing): track_infos.append(self._make_track_match(artist, album, i + 1)) return AlbumInfo( artist=artist, album=album, tracks=track_infos, va=False, album_id='albumid' + id, artist_id='artistid' + id, albumtype='soundtrack' ) class ImportHelper(TestHelper): """Provides tools to setup a library, a directory containing files that are to be imported and an import session. The class also provides stubs for the autotagging library and several assertions for the library. """ def setup_beets(self, disk=False): super().setup_beets(disk) self.lib.path_formats = [ ('default', os.path.join('$artist', '$album', '$title')), ('singleton:true', os.path.join('singletons', '$title')), ('comp:true', os.path.join('compilations', '$album', '$title')), ] def _create_import_dir(self, count=3): """Creates a directory with media files to import. Sets ``self.import_dir`` to the path of the directory. Also sets ``self.import_media`` to a list :class:`MediaFile` for all the files in the directory. The directory has following layout the_album/ track_1.mp3 track_2.mp3 track_3.mp3 :param count: Number of files to create """ self.import_dir = os.path.join(self.temp_dir, b'testsrcdir') if os.path.isdir(self.import_dir): shutil.rmtree(self.import_dir) album_path = os.path.join(self.import_dir, b'the_album') os.makedirs(album_path) resource_path = os.path.join(_common.RSRC, b'full.mp3') metadata = { 'artist': 'Tag Artist', 'album': 'Tag Album', 'albumartist': None, 'mb_trackid': None, 'mb_albumid': None, 'comp': None } self.media_files = [] for i in range(count): # Copy files medium_path = os.path.join( album_path, bytestring_path('track_%d.mp3' % (i + 1)) ) shutil.copy(resource_path, medium_path) medium = MediaFile(medium_path) # Set metadata metadata['track'] = i + 1 metadata['title'] = 'Tag Title %d' % (i + 1) for attr in metadata: setattr(medium, attr, metadata[attr]) medium.save() self.media_files.append(medium) self.import_media = self.media_files def _setup_import_session(self, import_dir=None, delete=False, threaded=False, copy=True, singletons=False, move=False, autotag=True, link=False, hardlink=False): config['import']['copy'] = copy config['import']['delete'] = delete config['import']['timid'] = True config['threaded'] = False config['import']['singletons'] = singletons config['import']['move'] = move config['import']['autotag'] = autotag config['import']['resume'] = False config['import']['link'] = link config['import']['hardlink'] = hardlink self.importer = ImportSessionFixture( self.lib, loghandler=None, query=None, paths=[import_dir or self.import_dir] ) def assert_file_in_lib(self, *segments): """Join the ``segments`` and assert that this path exists in the library directory """ self.assertExists(os.path.join(self.libdir, *segments)) def assert_file_not_in_lib(self, *segments): """Join the ``segments`` and assert that this path exists in the library directory """ self.assertNotExists(os.path.join(self.libdir, *segments)) def assert_lib_dir_empty(self): self.assertEqual(len(os.listdir(self.libdir)), 0) @_common.slow_test() class NonAutotaggedImportTest(_common.TestCase, ImportHelper): def setUp(self): self.setup_beets(disk=True) self._create_import_dir(2) self._setup_import_session(autotag=False) def tearDown(self): self.teardown_beets() def test_album_created_with_track_artist(self): self.importer.run() albums = self.lib.albums() self.assertEqual(len(albums), 1) self.assertEqual(albums[0].albumartist, 'Tag Artist') def test_import_copy_arrives(self): self.importer.run() for mediafile in self.import_media: self.assert_file_in_lib( b'Tag Artist', b'Tag Album', util.bytestring_path(f'{mediafile.title}.mp3')) def test_threaded_import_copy_arrives(self): config['threaded'] = True self.importer.run() for mediafile in self.import_media: self.assert_file_in_lib( b'Tag Artist', b'Tag Album', util.bytestring_path(f'{mediafile.title}.mp3')) def test_import_with_move_deletes_import_files(self): config['import']['move'] = True for mediafile in self.import_media: self.assertExists(mediafile.path) self.importer.run() for mediafile in self.import_media: self.assertNotExists(mediafile.path) def test_import_with_move_prunes_directory_empty(self): config['import']['move'] = True self.assertExists(os.path.join(self.import_dir, b'the_album')) self.importer.run() self.assertNotExists(os.path.join(self.import_dir, b'the_album')) def test_import_with_move_prunes_with_extra_clutter(self): f = open(os.path.join(self.import_dir, b'the_album', b'alog.log'), 'w') f.close() config['clutter'] = ['*.log'] config['import']['move'] = True self.assertExists(os.path.join(self.import_dir, b'the_album')) self.importer.run() self.assertNotExists(os.path.join(self.import_dir, b'the_album')) def test_threaded_import_move_arrives(self): config['import']['move'] = True config['import']['threaded'] = True self.importer.run() for mediafile in self.import_media: self.assert_file_in_lib( b'Tag Artist', b'Tag Album', util.bytestring_path(f'{mediafile.title}.mp3')) def test_threaded_import_move_deletes_import(self): config['import']['move'] = True config['threaded'] = True self.importer.run() for mediafile in self.import_media: self.assertNotExists(mediafile.path) def test_import_without_delete_retains_files(self): config['import']['delete'] = False self.importer.run() for mediafile in self.import_media: self.assertExists(mediafile.path) def test_import_with_delete_removes_files(self): config['import']['delete'] = True self.importer.run() for mediafile in self.import_media: self.assertNotExists(mediafile.path) def test_import_with_delete_prunes_directory_empty(self): config['import']['delete'] = True self.assertExists(os.path.join(self.import_dir, b'the_album')) self.importer.run() self.assertNotExists(os.path.join(self.import_dir, b'the_album')) @unittest.skipUnless(_common.HAVE_SYMLINK, "need symlinks") def test_import_link_arrives(self): config['import']['link'] = True self.importer.run() for mediafile in self.import_media: filename = os.path.join( self.libdir, b'Tag Artist', b'Tag Album', util.bytestring_path(f'{mediafile.title}.mp3') ) self.assertExists(filename) self.assertTrue(os.path.islink(filename)) self.assert_equal_path( util.bytestring_path(os.readlink(filename)), mediafile.path ) @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") def test_import_hardlink_arrives(self): config['import']['hardlink'] = True self.importer.run() for mediafile in self.import_media: filename = os.path.join( self.libdir, b'Tag Artist', b'Tag Album', util.bytestring_path(f'{mediafile.title}.mp3') ) self.assertExists(filename) s1 = os.stat(mediafile.path) s2 = os.stat(filename) self.assertTrue( (s1[stat.ST_INO], s1[stat.ST_DEV]) == (s2[stat.ST_INO], s2[stat.ST_DEV]) ) def create_archive(session): (handle, path) = mkstemp(dir=py3_path(session.temp_dir)) os.close(handle) archive = ZipFile(py3_path(path), mode='w') archive.write(os.path.join(_common.RSRC, b'full.mp3'), 'full.mp3') archive.close() path = bytestring_path(path) return path class RmTempTest(unittest.TestCase, ImportHelper, _common.Assertions): """Tests that temporarily extracted archives are properly removed after usage. """ def setUp(self): self.setup_beets() self.want_resume = False self.config['incremental'] = False self._old_home = None def tearDown(self): self.teardown_beets() def test_rm(self): zip_path = create_archive(self) archive_task = importer.ArchiveImportTask(zip_path) archive_task.extract() tmp_path = archive_task.toppath self._setup_import_session(autotag=False, import_dir=tmp_path) self.assertExists(tmp_path) archive_task.finalize(self) self.assertNotExists(tmp_path) class ImportZipTest(unittest.TestCase, ImportHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_import_zip(self): zip_path = create_archive(self) self.assertEqual(len(self.lib.items()), 0) self.assertEqual(len(self.lib.albums()), 0) self._setup_import_session(autotag=False, import_dir=zip_path) self.importer.run() self.assertEqual(len(self.lib.items()), 1) self.assertEqual(len(self.lib.albums()), 1) class ImportTarTest(ImportZipTest): def create_archive(self): (handle, path) = mkstemp(dir=self.temp_dir) os.close(handle) archive = TarFile(py3_path(path), mode='w') archive.add(os.path.join(_common.RSRC, b'full.mp3'), 'full.mp3') archive.close() return path @unittest.skipIf(not has_program('unrar'), 'unrar program not found') class ImportRarTest(ImportZipTest): def create_archive(self): return os.path.join(_common.RSRC, b'archive.rar') class Import7zTest(ImportZipTest): def create_archive(self): return os.path.join(_common.RSRC, b'archive.7z') @unittest.skip('Implement me!') class ImportPasswordRarTest(ImportZipTest): def create_archive(self): return os.path.join(_common.RSRC, b'password.rar') class ImportSingletonTest(_common.TestCase, ImportHelper): """Test ``APPLY`` and ``ASIS`` choices for an import session with singletons config set to True. """ def setUp(self): self.setup_beets() self._create_import_dir(1) self._setup_import_session() config['import']['singletons'] = True self.matcher = AutotagStub().install() def tearDown(self): self.teardown_beets() self.matcher.restore() def test_apply_asis_adds_track(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.items().get().title, 'Tag Title 1') def test_apply_asis_does_not_add_album(self): self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get(), None) def test_apply_asis_adds_singleton_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assert_file_in_lib(b'singletons', b'Tag Title 1.mp3') def test_apply_candidate_adds_track(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, 'Applied Title 1') def test_apply_candidate_does_not_add_album(self): self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.albums().get(), None) def test_apply_candidate_adds_singleton_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assert_file_in_lib(b'singletons', b'Applied Title 1.mp3') def test_skip_does_not_add_first_track(self): self.importer.add_choice(importer.action.SKIP) self.importer.run() self.assertEqual(self.lib.items().get(), None) def test_skip_adds_other_tracks(self): self._create_import_dir(2) self.importer.add_choice(importer.action.SKIP) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(len(self.lib.items()), 1) def test_import_single_files(self): resource_path = os.path.join(_common.RSRC, b'empty.mp3') single_path = os.path.join(self.import_dir, b'track_2.mp3') shutil.copy(resource_path, single_path) import_files = [ os.path.join(self.import_dir, b'the_album'), single_path ] self._setup_import_session(singletons=False) self.importer.paths = import_files self.importer.add_choice(importer.action.ASIS) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(len(self.lib.items()), 2) self.assertEqual(len(self.lib.albums()), 2) def test_set_fields(self): genre = "\U0001F3B7 Jazz" collection = "To Listen" config['import']['set_fields'] = { 'collection': collection, 'genre': genre } # As-is item import. self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() for item in self.lib.items(): item.load() # TODO: Not sure this is necessary. self.assertEqual(item.genre, genre) self.assertEqual(item.collection, collection) # Remove item from library to test again with APPLY choice. item.remove() # Autotagged. self.assertEqual(self.lib.albums().get(), None) self.importer.clear_choices() self.importer.add_choice(importer.action.APPLY) self.importer.run() for item in self.lib.items(): item.load() self.assertEqual(item.genre, genre) self.assertEqual(item.collection, collection) class ImportTest(_common.TestCase, ImportHelper): """Test APPLY, ASIS and SKIP choices. """ def setUp(self): self.setup_beets() self._create_import_dir(1) self._setup_import_session() self.matcher = AutotagStub().install() self.matcher.macthin = AutotagStub.GOOD def tearDown(self): self.teardown_beets() self.matcher.restore() def test_apply_asis_adds_album(self): self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().album, 'Tag Album') def test_apply_asis_adds_tracks(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.items().get().title, 'Tag Title 1') def test_apply_asis_adds_album_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assert_file_in_lib( b'Tag Artist', b'Tag Album', b'Tag Title 1.mp3') def test_apply_candidate_adds_album(self): self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.albums().get().album, 'Applied Album') def test_apply_candidate_adds_tracks(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, 'Applied Title 1') def test_apply_candidate_adds_album_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assert_file_in_lib( b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') def test_apply_from_scratch_removes_other_metadata(self): config['import']['from_scratch'] = True for mediafile in self.import_media: mediafile.genre = 'Tag Genre' mediafile.save() self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().genre, '') def test_apply_from_scratch_keeps_format(self): config['import']['from_scratch'] = True self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().format, 'MP3') def test_apply_from_scratch_keeps_bitrate(self): config['import']['from_scratch'] = True bitrate = 80000 self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().bitrate, bitrate) def test_apply_with_move_deletes_import(self): config['import']['move'] = True import_file = os.path.join( self.import_dir, b'the_album', b'track_1.mp3') self.assertExists(import_file) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertNotExists(import_file) def test_apply_with_delete_deletes_import(self): config['import']['delete'] = True import_file = os.path.join(self.import_dir, b'the_album', b'track_1.mp3') self.assertExists(import_file) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertNotExists(import_file) def test_skip_does_not_add_track(self): self.importer.add_choice(importer.action.SKIP) self.importer.run() self.assertEqual(self.lib.items().get(), None) def test_skip_non_album_dirs(self): self.assertTrue(os.path.isdir( os.path.join(self.import_dir, b'the_album'))) self.touch(b'cruft', dir=self.import_dir) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len(self.lib.albums()), 1) def test_unmatched_tracks_not_added(self): self._create_import_dir(2) self.matcher.matching = self.matcher.MISSING self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len(self.lib.items()), 1) def test_empty_directory_warning(self): import_dir = os.path.join(self.temp_dir, b'empty') self.touch(b'non-audio', dir=import_dir) self._setup_import_session(import_dir=import_dir) with capture_log() as logs: self.importer.run() import_dir = displayable_path(import_dir) self.assertIn(f'No files imported from {import_dir}', logs) def test_empty_directory_singleton_warning(self): import_dir = os.path.join(self.temp_dir, b'empty') self.touch(b'non-audio', dir=import_dir) self._setup_import_session(import_dir=import_dir, singletons=True) with capture_log() as logs: self.importer.run() import_dir = displayable_path(import_dir) self.assertIn(f'No files imported from {import_dir}', logs) def test_asis_no_data_source(self): self.assertEqual(self.lib.items().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() with self.assertRaises(AttributeError): self.lib.items().get().data_source def test_set_fields(self): genre = "\U0001F3B7 Jazz" collection = "To Listen" comments = "managed by beets" config['import']['set_fields'] = { 'genre': genre, 'collection': collection, 'comments': comments } # As-is album import. self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.ASIS) self.importer.run() for album in self.lib.albums(): album.load() # TODO: Not sure this is necessary. self.assertEqual(album.genre, genre) self.assertEqual(album.comments, comments) for item in album.items(): self.assertEqual( item.get("genre", with_album=False), genre) self.assertEqual( item.get("collection", with_album=False), collection) self.assertEqual( item.get("comments", with_album=False), comments) # Remove album from library to test again with APPLY choice. album.remove() # Autotagged. self.assertEqual(self.lib.albums().get(), None) self.importer.clear_choices() self.importer.add_choice(importer.action.APPLY) self.importer.run() for album in self.lib.albums(): album.load() self.assertEqual(album.genre, genre) self.assertEqual(album.comments, comments) for item in album.items(): self.assertEqual( item.get("genre", with_album=False), genre) self.assertEqual( item.get("collection", with_album=False), collection) self.assertEqual( item.get("comments", with_album=False), comments) class ImportTracksTest(_common.TestCase, ImportHelper): """Test TRACKS and APPLY choice. """ def setUp(self): self.setup_beets() self._create_import_dir(1) self._setup_import_session() self.matcher = AutotagStub().install() def tearDown(self): self.teardown_beets() self.matcher.restore() def test_apply_tracks_adds_singleton_track(self): self.assertEqual(self.lib.items().get(), None) self.assertEqual(self.lib.albums().get(), None) self.importer.add_choice(importer.action.TRACKS) self.importer.add_choice(importer.action.APPLY) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, 'Applied Title 1') self.assertEqual(self.lib.albums().get(), None) def test_apply_tracks_adds_singleton_path(self): self.assert_lib_dir_empty() self.importer.add_choice(importer.action.TRACKS) self.importer.add_choice(importer.action.APPLY) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assert_file_in_lib(b'singletons', b'Applied Title 1.mp3') class ImportCompilationTest(_common.TestCase, ImportHelper): """Test ASIS import of a folder containing tracks with different artists. """ def setUp(self): self.setup_beets() self._create_import_dir(3) self._setup_import_session() self.matcher = AutotagStub().install() def tearDown(self): self.teardown_beets() self.matcher.restore() def test_asis_homogenous_sets_albumartist(self): self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().albumartist, 'Tag Artist') for item in self.lib.items(): self.assertEqual(item.albumartist, 'Tag Artist') def test_asis_heterogenous_sets_various_albumartist(self): self.import_media[0].artist = 'Other Artist' self.import_media[0].save() self.import_media[1].artist = 'Another Artist' self.import_media[1].save() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().albumartist, 'Various Artists') for item in self.lib.items(): self.assertEqual(item.albumartist, 'Various Artists') def test_asis_heterogenous_sets_sompilation(self): self.import_media[0].artist = 'Other Artist' self.import_media[0].save() self.import_media[1].artist = 'Another Artist' self.import_media[1].save() self.importer.add_choice(importer.action.ASIS) self.importer.run() for item in self.lib.items(): self.assertTrue(item.comp) def test_asis_sets_majority_albumartist(self): self.import_media[0].artist = 'Other Artist' self.import_media[0].save() self.import_media[1].artist = 'Other Artist' self.import_media[1].save() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().albumartist, 'Other Artist') for item in self.lib.items(): self.assertEqual(item.albumartist, 'Other Artist') def test_asis_albumartist_tag_sets_albumartist(self): self.import_media[0].artist = 'Other Artist' self.import_media[1].artist = 'Another Artist' for mediafile in self.import_media: mediafile.albumartist = 'Album Artist' mediafile.mb_albumartistid = 'Album Artist ID' mediafile.save() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.albums().get().albumartist, 'Album Artist') self.assertEqual(self.lib.albums().get().mb_albumartistid, 'Album Artist ID') for item in self.lib.items(): self.assertEqual(item.albumartist, 'Album Artist') self.assertEqual(item.mb_albumartistid, 'Album Artist ID') class ImportExistingTest(_common.TestCase, ImportHelper): """Test importing files that are already in the library directory. """ def setUp(self): self.setup_beets() self._create_import_dir(1) self.matcher = AutotagStub().install() self._setup_import_session() self.setup_importer = self.importer self.setup_importer.default_choice = importer.action.APPLY self._setup_import_session(import_dir=self.libdir) def tearDown(self): self.teardown_beets() self.matcher.restore() def test_does_not_duplicate_item(self): self.setup_importer.run() self.assertEqual(len(self.lib.items()), 1) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len(self.lib.items()), 1) def test_does_not_duplicate_album(self): self.setup_importer.run() self.assertEqual(len(self.lib.albums()), 1) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len(self.lib.albums()), 1) def test_does_not_duplicate_singleton_track(self): self.setup_importer.add_choice(importer.action.TRACKS) self.setup_importer.add_choice(importer.action.APPLY) self.setup_importer.run() self.assertEqual(len(self.lib.items()), 1) self.importer.add_choice(importer.action.TRACKS) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(len(self.lib.items()), 1) def test_asis_updates_metadata(self): self.setup_importer.run() medium = MediaFile(self.lib.items().get().path) medium.title = 'New Title' medium.save() self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assertEqual(self.lib.items().get().title, 'New Title') def test_asis_updated_moves_file(self): self.setup_importer.run() medium = MediaFile(self.lib.items().get().path) medium.title = 'New Title' medium.save() old_path = os.path.join(b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') self.assert_file_in_lib(old_path) self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assert_file_in_lib(b'Applied Artist', b'Applied Album', b'New Title.mp3') self.assert_file_not_in_lib(old_path) def test_asis_updated_without_copy_does_not_move_file(self): self.setup_importer.run() medium = MediaFile(self.lib.items().get().path) medium.title = 'New Title' medium.save() old_path = os.path.join(b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') self.assert_file_in_lib(old_path) config['import']['copy'] = False self.importer.add_choice(importer.action.ASIS) self.importer.run() self.assert_file_not_in_lib(b'Applied Artist', b'Applied Album', b'New Title.mp3') self.assert_file_in_lib(old_path) def test_outside_file_is_copied(self): config['import']['copy'] = False self.setup_importer.run() self.assert_equal_path(self.lib.items().get().path, self.import_media[0].path) config['import']['copy'] = True self._setup_import_session() self.importer.add_choice(importer.action.APPLY) self.importer.run() new_path = os.path.join(b'Applied Artist', b'Applied Album', b'Applied Title 1.mp3') self.assert_file_in_lib(new_path) self.assert_equal_path(self.lib.items().get().path, os.path.join(self.libdir, new_path)) def test_outside_file_is_moved(self): config['import']['copy'] = False self.setup_importer.run() self.assert_equal_path(self.lib.items().get().path, self.import_media[0].path) self._setup_import_session(move=True) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertNotExists(self.import_media[0].path) class GroupAlbumsImportTest(_common.TestCase, ImportHelper): def setUp(self): self.setup_beets() self._create_import_dir(3) self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.NONE self._setup_import_session() # Split tracks into two albums and use both as-is self.importer.add_choice(importer.action.ALBUMS) self.importer.add_choice(importer.action.ASIS) self.importer.add_choice(importer.action.ASIS) def tearDown(self): self.teardown_beets() self.matcher.restore() def test_add_album_for_different_artist_and_different_album(self): self.import_media[0].artist = "Artist B" self.import_media[0].album = "Album B" self.import_media[0].save() self.importer.run() albums = {album.album for album in self.lib.albums()} self.assertEqual(albums, {'Album B', 'Tag Album'}) def test_add_album_for_different_artist_and_same_albumartist(self): self.import_media[0].artist = "Artist B" self.import_media[0].albumartist = "Album Artist" self.import_media[0].save() self.import_media[1].artist = "Artist C" self.import_media[1].albumartist = "Album Artist" self.import_media[1].save() self.importer.run() artists = {album.albumartist for album in self.lib.albums()} self.assertEqual(artists, {'Album Artist', 'Tag Artist'}) def test_add_album_for_same_artist_and_different_album(self): self.import_media[0].album = "Album B" self.import_media[0].save() self.importer.run() albums = {album.album for album in self.lib.albums()} self.assertEqual(albums, {'Album B', 'Tag Album'}) def test_add_album_for_same_album_and_different_artist(self): self.import_media[0].artist = "Artist B" self.import_media[0].save() self.importer.run() artists = {album.albumartist for album in self.lib.albums()} self.assertEqual(artists, {'Artist B', 'Tag Artist'}) def test_incremental(self): config['import']['incremental'] = True self.import_media[0].album = "Album B" self.import_media[0].save() self.importer.run() albums = {album.album for album in self.lib.albums()} self.assertEqual(albums, {'Album B', 'Tag Album'}) class GlobalGroupAlbumsImportTest(GroupAlbumsImportTest): def setUp(self): super().setUp() self.importer.clear_choices() self.importer.default_choice = importer.action.ASIS config['import']['group_albums'] = True class ChooseCandidateTest(_common.TestCase, ImportHelper): def setUp(self): self.setup_beets() self._create_import_dir(1) self._setup_import_session() self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.BAD def tearDown(self): self.teardown_beets() self.matcher.restore() def test_choose_first_candidate(self): self.importer.add_choice(1) self.importer.run() self.assertEqual(self.lib.albums().get().album, 'Applied Album M') def test_choose_second_candidate(self): self.importer.add_choice(2) self.importer.run() self.assertEqual(self.lib.albums().get().album, 'Applied Album MM') class InferAlbumDataTest(_common.TestCase): def setUp(self): super().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) def test_asis_homogenous_single_artist(self): self.task.set_choice(importer.action.ASIS) self.task.align_album_level_fields() 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.task.align_album_level_fields() 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.task.align_album_level_fields() 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.task.align_album_level_fields() self.assertFalse(self.items[0].comp) self.assertEqual(self.items[0].albumartist, self.items[2].artist) def test_asis_track_albumartist_override(self): self.items[0].artist = 'another artist' self.items[1].artist = 'some other artist' for item in self.items: item.albumartist = 'some album artist' item.mb_albumartistid = 'some album artist id' self.task.set_choice(importer.action.ASIS) self.task.align_album_level_fields() self.assertEqual(self.items[0].albumartist, 'some album artist') self.assertEqual(self.items[0].mb_albumartistid, 'some album artist id') def test_apply_gets_artist_and_id(self): self.task.set_choice(AlbumMatch(0, None, {}, set(), set())) # APPLY self.task.align_album_level_fields() 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.task.align_album_level_fields() 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.task.align_album_level_fields() self.assertFalse(self.items[0].comp) def test_album_info(*args, **kwargs): """Create an AlbumInfo object for testing. """ track_info = TrackInfo( title='new title', track_id='trackid', index=0, ) album_info = AlbumInfo( artist='artist', album='album', tracks=[track_info], album_id='albumid', artist_id='artistid', ) return iter([album_info]) @patch('beets.autotag.mb.match_album', Mock(side_effect=test_album_info)) class ImportDuplicateAlbumTest(unittest.TestCase, TestHelper, _common.Assertions): def setUp(self): self.setup_beets() # Original album self.add_album_fixture(albumartist='artist', album='album') # Create import session self.importer = self.create_importer() config['import']['autotag'] = True def tearDown(self): self.teardown_beets() def test_remove_duplicate_album(self): item = self.lib.items().get() self.assertEqual(item.title, 't\xeftle 0') self.assertExists(item.path) self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() self.assertNotExists(item.path) self.assertEqual(len(self.lib.albums()), 1) self.assertEqual(len(self.lib.items()), 1) item = self.lib.items().get() self.assertEqual(item.title, 'new title') def test_no_autotag_keeps_duplicate_album(self): config['import']['autotag'] = False item = self.lib.items().get() self.assertEqual(item.title, 't\xeftle 0') self.assertExists(item.path) # Imported item has the same artist and album as the one in the # library. import_file = os.path.join(self.importer.paths[0], b'album 0', b'track 0.mp3') import_file = MediaFile(import_file) import_file.artist = item['artist'] import_file.albumartist = item['artist'] import_file.album = item['album'] import_file.title = 'new title' self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() self.assertExists(item.path) self.assertEqual(len(self.lib.albums()), 2) self.assertEqual(len(self.lib.items()), 2) def test_keep_duplicate_album(self): self.importer.default_resolution = self.importer.Resolution.KEEPBOTH self.importer.run() self.assertEqual(len(self.lib.albums()), 2) self.assertEqual(len(self.lib.items()), 2) def test_skip_duplicate_album(self): item = self.lib.items().get() self.assertEqual(item.title, 't\xeftle 0') self.importer.default_resolution = self.importer.Resolution.SKIP self.importer.run() self.assertEqual(len(self.lib.albums()), 1) self.assertEqual(len(self.lib.items()), 1) item = self.lib.items().get() self.assertEqual(item.title, 't\xeftle 0') def test_merge_duplicate_album(self): self.importer.default_resolution = self.importer.Resolution.MERGE self.importer.run() self.assertEqual(len(self.lib.albums()), 1) def test_twice_in_import_dir(self): self.skipTest('write me') def add_album_fixture(self, **kwargs): # TODO move this into upstream album = super().add_album_fixture() album.update(kwargs) album.store() return album def test_track_info(*args, **kwargs): return iter([TrackInfo( artist='artist', title='title', track_id='new trackid', index=0,)]) @patch('beets.autotag.mb.match_track', Mock(side_effect=test_track_info)) class ImportDuplicateSingletonTest(unittest.TestCase, TestHelper, _common.Assertions): def setUp(self): self.setup_beets() # Original file in library self.add_item_fixture(artist='artist', title='title', mb_trackid='old trackid') # Import session self.importer = self.create_importer() config['import']['autotag'] = True config['import']['singletons'] = True def tearDown(self): self.teardown_beets() def test_remove_duplicate(self): item = self.lib.items().get() self.assertEqual(item.mb_trackid, 'old trackid') self.assertExists(item.path) self.importer.default_resolution = self.importer.Resolution.REMOVE self.importer.run() self.assertNotExists(item.path) self.assertEqual(len(self.lib.items()), 1) item = self.lib.items().get() self.assertEqual(item.mb_trackid, 'new trackid') def test_keep_duplicate(self): self.assertEqual(len(self.lib.items()), 1) self.importer.default_resolution = self.importer.Resolution.KEEPBOTH self.importer.run() self.assertEqual(len(self.lib.items()), 2) def test_skip_duplicate(self): item = self.lib.items().get() self.assertEqual(item.mb_trackid, 'old trackid') self.importer.default_resolution = self.importer.Resolution.SKIP self.importer.run() self.assertEqual(len(self.lib.items()), 1) item = self.lib.items().get() self.assertEqual(item.mb_trackid, 'old trackid') def test_twice_in_import_dir(self): self.skipTest('write me') def add_item_fixture(self, **kwargs): # Move this to TestHelper item = self.add_item_fixtures()[0] item.update(kwargs) item.store() return item class TagLogTest(_common.TestCase): def test_tag_log_line(self): sio = StringIO() handler = logging.StreamHandler(sio) session = _common.import_session(loghandler=handler) session.tag_log('status', 'path') self.assertIn('status path', sio.getvalue()) def test_tag_log_unicode(self): sio = StringIO() handler = logging.StreamHandler(sio) session = _common.import_session(loghandler=handler) session.tag_log('status', 'caf\xe9') # send unicode self.assertIn('status caf\xe9', sio.getvalue()) class ResumeImportTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() @patch('beets.plugins.send') def test_resume_album(self, plugins_send): self.importer = self.create_importer(album_count=2) self.config['import']['resume'] = True # Aborts import after one album. This also ensures that we skip # the first album in the second try. def raise_exception(event, **kwargs): if event == 'album_imported': raise importer.ImportAbort plugins_send.side_effect = raise_exception self.importer.run() self.assertEqual(len(self.lib.albums()), 1) self.assertIsNotNone(self.lib.albums('album:album 0').get()) self.importer.run() self.assertEqual(len(self.lib.albums()), 2) self.assertIsNotNone(self.lib.albums('album:album 1').get()) @patch('beets.plugins.send') def test_resume_singleton(self, plugins_send): self.importer = self.create_importer(item_count=2) self.config['import']['resume'] = True self.config['import']['singletons'] = True # Aborts import after one track. This also ensures that we skip # the first album in the second try. def raise_exception(event, **kwargs): if event == 'item_imported': raise importer.ImportAbort plugins_send.side_effect = raise_exception self.importer.run() self.assertEqual(len(self.lib.items()), 1) self.assertIsNotNone(self.lib.items('title:track 0').get()) self.importer.run() self.assertEqual(len(self.lib.items()), 2) self.assertIsNotNone(self.lib.items('title:track 1').get()) class IncrementalImportTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.config['import']['incremental'] = True def tearDown(self): self.teardown_beets() def test_incremental_album(self): importer = self.create_importer(album_count=1) importer.run() # Change album name so the original file would be imported again # if incremental was off. album = self.lib.albums().get() album['album'] = 'edited album' album.store() importer = self.create_importer(album_count=1) importer.run() self.assertEqual(len(self.lib.albums()), 2) def test_incremental_item(self): self.config['import']['singletons'] = True importer = self.create_importer(item_count=1) importer.run() # Change track name so the original file would be imported again # if incremental was off. item = self.lib.items().get() item['artist'] = 'edited artist' item.store() importer = self.create_importer(item_count=1) importer.run() self.assertEqual(len(self.lib.items()), 2) def test_invalid_state_file(self): importer = self.create_importer() with open(self.config['statefile'].as_filename(), 'wb') as f: f.write(b'000') importer.run() self.assertEqual(len(self.lib.albums()), 1) def _mkmp3(path): shutil.copyfile(os.path.join(_common.RSRC, b'min.mp3'), path) class AlbumsInDirTest(_common.TestCase): def setUp(self): super().setUp() # create a directory structure for testing self.base = os.path.abspath(os.path.join(self.temp_dir, b'tempdir')) os.mkdir(self.base) os.mkdir(os.path.join(self.base, b'album1')) os.mkdir(os.path.join(self.base, b'album2')) os.mkdir(os.path.join(self.base, b'more')) os.mkdir(os.path.join(self.base, b'more', b'album3')) os.mkdir(os.path.join(self.base, b'more', b'album4')) _mkmp3(os.path.join(self.base, b'album1', b'album1song1.mp3')) _mkmp3(os.path.join(self.base, b'album1', b'album1song2.mp3')) _mkmp3(os.path.join(self.base, b'album2', b'album2song.mp3')) _mkmp3(os.path.join(self.base, b'more', b'album3', b'album3song.mp3')) _mkmp3(os.path.join(self.base, b'more', b'album4', b'album4song.mp3')) def test_finds_all_albums(self): albums = list(albums_in_dir(self.base)) self.assertEqual(len(albums), 4) def test_separates_contents(self): found = [] for _, album in albums_in_dir(self.base): found.append(re.search(br'album(.)song', album[0]).group(1)) self.assertTrue(b'1' in found) self.assertTrue(b'2' in found) self.assertTrue(b'3' in found) self.assertTrue(b'4' in found) def test_finds_multiple_songs(self): for _, album in albums_in_dir(self.base): n = re.search(br'album(.)song', album[0]).group(1) if n == b'1': self.assertEqual(len(album), 2) else: self.assertEqual(len(album), 1) class MultiDiscAlbumsInDirTest(_common.TestCase): def create_music(self, files=True, ascii=True): """Create some music in multiple album directories. `files` indicates whether to create the files (otherwise, only directories are made). `ascii` indicates ACII-only filenames; otherwise, we use Unicode names. """ self.base = os.path.abspath(os.path.join(self.temp_dir, b'tempdir')) os.mkdir(self.base) name = b'CAT' if ascii else util.bytestring_path('C\xc1T') name_alt_case = b'CAt' if ascii else util.bytestring_path('C\xc1t') self.dirs = [ # Nested album, multiple subdirs. # Also, false positive marker in root dir, and subtitle for disc 3. os.path.join(self.base, b'ABCD1234'), os.path.join(self.base, b'ABCD1234', b'cd 1'), os.path.join(self.base, b'ABCD1234', b'cd 3 - bonus'), # Nested album, single subdir. # Also, punctuation between marker and disc number. os.path.join(self.base, b'album'), os.path.join(self.base, b'album', b'cd _ 1'), # Flattened album, case typo. # Also, false positive marker in parent dir. os.path.join(self.base, b'artist [CD5]'), os.path.join(self.base, b'artist [CD5]', name + b' disc 1'), os.path.join(self.base, b'artist [CD5]', name_alt_case + b' disc 2'), # Single disc album, sorted between CAT discs. os.path.join(self.base, b'artist [CD5]', name + b'S'), ] self.files = [ os.path.join(self.base, b'ABCD1234', b'cd 1', b'song1.mp3'), os.path.join(self.base, b'ABCD1234', b'cd 3 - bonus', b'song2.mp3'), os.path.join(self.base, b'ABCD1234', b'cd 3 - bonus', b'song3.mp3'), os.path.join(self.base, b'album', b'cd _ 1', b'song4.mp3'), os.path.join(self.base, b'artist [CD5]', name + b' disc 1', b'song5.mp3'), os.path.join(self.base, b'artist [CD5]', name_alt_case + b' disc 2', b'song6.mp3'), os.path.join(self.base, b'artist [CD5]', name + b'S', b'song7.mp3'), ] if not ascii: self.dirs = [self._normalize_path(p) for p in self.dirs] self.files = [self._normalize_path(p) for p in self.files] for path in self.dirs: os.mkdir(util.syspath(path)) if files: for path in self.files: _mkmp3(util.syspath(path)) def _normalize_path(self, path): """Normalize a path's Unicode combining form according to the platform. """ path = path.decode('utf-8') norm_form = 'NFD' if sys.platform == 'darwin' else 'NFC' path = unicodedata.normalize(norm_form, path) return path.encode('utf-8') def test_coalesce_nested_album_multiple_subdirs(self): self.create_music() albums = list(albums_in_dir(self.base)) self.assertEqual(len(albums), 4) root, items = albums[0] self.assertEqual(root, self.dirs[0:3]) self.assertEqual(len(items), 3) def test_coalesce_nested_album_single_subdir(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[1] self.assertEqual(root, self.dirs[3:5]) self.assertEqual(len(items), 1) def test_coalesce_flattened_album_case_typo(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[2] self.assertEqual(root, self.dirs[6:8]) self.assertEqual(len(items), 2) def test_single_disc_album(self): self.create_music() albums = list(albums_in_dir(self.base)) root, items = albums[3] self.assertEqual(root, self.dirs[8:]) self.assertEqual(len(items), 1) def test_do_not_yield_empty_album(self): self.create_music(files=False) albums = list(albums_in_dir(self.base)) self.assertEqual(len(albums), 0) def test_single_disc_unicode(self): self.create_music(ascii=False) albums = list(albums_in_dir(self.base)) root, items = albums[3] self.assertEqual(root, self.dirs[8:]) self.assertEqual(len(items), 1) def test_coalesce_multiple_unicode(self): self.create_music(ascii=False) albums = list(albums_in_dir(self.base)) self.assertEqual(len(albums), 4) root, items = albums[0] self.assertEqual(root, self.dirs[0:3]) self.assertEqual(len(items), 3) class ReimportTest(unittest.TestCase, ImportHelper, _common.Assertions): """Test "re-imports", in which the autotagging machinery is used for music that's already in the library. This works by importing new database entries for the same files and replacing the old data with the new data. We also copy over flexible attributes and the added date. """ def setUp(self): self.setup_beets() # The existing album. album = self.add_album_fixture() album.added = 4242.0 album.foo = 'bar' # Some flexible attribute. album.store() item = album.items().get() item.baz = 'qux' item.added = 4747.0 item.store() # Set up an import pipeline with a "good" match. self.matcher = AutotagStub().install() self.matcher.matching = AutotagStub.GOOD def tearDown(self): self.teardown_beets() self.matcher.restore() def _setup_session(self, singletons=False): self._setup_import_session(self._album().path, singletons=singletons) self.importer.add_choice(importer.action.APPLY) def _album(self): return self.lib.albums().get() def _item(self): return self.lib.items().get() def test_reimported_album_gets_new_metadata(self): self._setup_session() self.assertEqual(self._album().album, '\xe4lbum') self.importer.run() self.assertEqual(self._album().album, 'the album') def test_reimported_album_preserves_flexattr(self): self._setup_session() self.importer.run() self.assertEqual(self._album().foo, 'bar') def test_reimported_album_preserves_added(self): self._setup_session() self.importer.run() self.assertEqual(self._album().added, 4242.0) def test_reimported_album_preserves_item_flexattr(self): self._setup_session() self.importer.run() self.assertEqual(self._item().baz, 'qux') def test_reimported_album_preserves_item_added(self): self._setup_session() self.importer.run() self.assertEqual(self._item().added, 4747.0) def test_reimported_item_gets_new_metadata(self): self._setup_session(True) self.assertEqual(self._item().title, 't\xeftle 0') self.importer.run() self.assertEqual(self._item().title, 'full') def test_reimported_item_preserves_flexattr(self): self._setup_session(True) self.importer.run() self.assertEqual(self._item().baz, 'qux') def test_reimported_item_preserves_added(self): self._setup_session(True) self.importer.run() self.assertEqual(self._item().added, 4747.0) def test_reimported_item_preserves_art(self): self._setup_session() art_source = os.path.join(_common.RSRC, b'abbey.jpg') replaced_album = self._album() replaced_album.set_art(art_source) replaced_album.store() old_artpath = replaced_album.artpath self.importer.run() new_album = self._album() new_artpath = new_album.art_destination(art_source) self.assertEqual(new_album.artpath, new_artpath) self.assertExists(new_artpath) if new_artpath != old_artpath: self.assertNotExists(old_artpath) class ImportPretendTest(_common.TestCase, ImportHelper): """ Test the pretend commandline option """ def __init__(self, method_name='runTest'): super().__init__(method_name) self.matcher = None def setUp(self): super().setUp() self.setup_beets() self.__create_import_dir() self.__create_empty_import_dir() self._setup_import_session() config['import']['pretend'] = True self.matcher = AutotagStub().install() self.io.install() def tearDown(self): self.teardown_beets() self.matcher.restore() def __create_import_dir(self): self._create_import_dir(1) resource_path = os.path.join(_common.RSRC, b'empty.mp3') single_path = os.path.join(self.import_dir, b'track_2.mp3') shutil.copy(resource_path, single_path) self.import_paths = [ os.path.join(self.import_dir, b'the_album'), single_path ] self.import_files = [ displayable_path( os.path.join(self.import_paths[0], b'track_1.mp3')), displayable_path(single_path) ] def __create_empty_import_dir(self): path = os.path.join(self.temp_dir, b'empty') os.makedirs(path) self.empty_path = path def __run(self, import_paths, singletons=True): self._setup_import_session(singletons=singletons) self.importer.paths = import_paths with capture_log() as logs: self.importer.run() logs = [line for line in logs if not line.startswith('Sending event:')] self.assertEqual(len(self.lib.items()), 0) self.assertEqual(len(self.lib.albums()), 0) return logs def test_import_singletons_pretend(self): logs = self.__run(self.import_paths) self.assertEqual(logs, [ 'Singleton: %s' % displayable_path(self.import_files[0]), 'Singleton: %s' % displayable_path(self.import_paths[1])]) def test_import_album_pretend(self): logs = self.__run(self.import_paths, singletons=False) self.assertEqual(logs, [ 'Album: %s' % displayable_path(self.import_paths[0]), ' %s' % displayable_path(self.import_files[0]), 'Album: %s' % displayable_path(self.import_paths[1]), ' %s' % displayable_path(self.import_paths[1])]) def test_import_pretend_empty(self): logs = self.__run([self.empty_path]) self.assertEqual(logs, ['No files imported from {}' .format(displayable_path(self.empty_path))]) # Helpers for ImportMusicBrainzIdTest. def mocked_get_release_by_id(id_, includes=[], release_status=[], release_type=[]): """Mimic musicbrainzngs.get_release_by_id, accepting only a restricted list of MB ids (ID_RELEASE_0, ID_RELEASE_1). The returned dict differs only in the release title and artist name, so that ID_RELEASE_0 is a closer match to the items created by ImportHelper._create_import_dir().""" # Map IDs to (release title, artist), so the distances are different. releases = {ImportMusicBrainzIdTest.ID_RELEASE_0: ('VALID_RELEASE_0', 'TAG ARTIST'), ImportMusicBrainzIdTest.ID_RELEASE_1: ('VALID_RELEASE_1', 'DISTANT_MATCH')} return { 'release': { 'title': releases[id_][0], 'id': id_, 'medium-list': [{ 'track-list': [{ 'id': 'baz', 'recording': { 'title': 'foo', 'id': 'bar', 'length': 59, }, 'position': 9, 'number': 'A2' }], 'position': 5, }], 'artist-credit': [{ 'artist': { 'name': releases[id_][1], 'id': 'some-id', }, }], 'release-group': { 'id': 'another-id', } } } def mocked_get_recording_by_id(id_, includes=[], release_status=[], release_type=[]): """Mimic musicbrainzngs.get_recording_by_id, accepting only a restricted list of MB ids (ID_RECORDING_0, ID_RECORDING_1). The returned dict differs only in the recording title and artist name, so that ID_RECORDING_0 is a closer match to the items created by ImportHelper._create_import_dir().""" # Map IDs to (recording title, artist), so the distances are different. releases = {ImportMusicBrainzIdTest.ID_RECORDING_0: ('VALID_RECORDING_0', 'TAG ARTIST'), ImportMusicBrainzIdTest.ID_RECORDING_1: ('VALID_RECORDING_1', 'DISTANT_MATCH')} return { 'recording': { 'title': releases[id_][0], 'id': id_, 'length': 59, 'artist-credit': [{ 'artist': { 'name': releases[id_][1], 'id': 'some-id', }, }], } } @patch('musicbrainzngs.get_recording_by_id', Mock(side_effect=mocked_get_recording_by_id)) @patch('musicbrainzngs.get_release_by_id', Mock(side_effect=mocked_get_release_by_id)) class ImportMusicBrainzIdTest(_common.TestCase, ImportHelper): """Test the --musicbrainzid argument.""" MB_RELEASE_PREFIX = 'https://musicbrainz.org/release/' MB_RECORDING_PREFIX = 'https://musicbrainz.org/recording/' ID_RELEASE_0 = '00000000-0000-0000-0000-000000000000' ID_RELEASE_1 = '11111111-1111-1111-1111-111111111111' ID_RECORDING_0 = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' ID_RECORDING_1 = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' def setUp(self): self.setup_beets() self._create_import_dir(1) def tearDown(self): self.teardown_beets() def test_one_mbid_one_album(self): self.config['import']['search_ids'] = \ [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0] self._setup_import_session() self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.albums().get().album, 'VALID_RELEASE_0') def test_several_mbid_one_album(self): self.config['import']['search_ids'] = \ [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0, self.MB_RELEASE_PREFIX + self.ID_RELEASE_1] self._setup_import_session() self.importer.add_choice(2) # Pick the 2nd best match (release 1). self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.albums().get().album, 'VALID_RELEASE_1') def test_one_mbid_one_singleton(self): self.config['import']['search_ids'] = \ [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0] self._setup_import_session(singletons=True) self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, 'VALID_RECORDING_0') def test_several_mbid_one_singleton(self): self.config['import']['search_ids'] = \ [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0, self.MB_RECORDING_PREFIX + self.ID_RECORDING_1] self._setup_import_session(singletons=True) self.importer.add_choice(2) # Pick the 2nd best match (recording 1). self.importer.add_choice(importer.action.APPLY) self.importer.run() self.assertEqual(self.lib.items().get().title, 'VALID_RECORDING_1') def test_candidates_album(self): """Test directly ImportTask.lookup_candidates().""" task = importer.ImportTask(paths=self.import_dir, toppath='top path', items=[_common.item()]) task.search_ids = [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0, self.MB_RELEASE_PREFIX + self.ID_RELEASE_1, 'an invalid and discarded id'] task.lookup_candidates() self.assertEqual({'VALID_RELEASE_0', 'VALID_RELEASE_1'}, {c.info.album for c in task.candidates}) def test_candidates_singleton(self): """Test directly SingletonImportTask.lookup_candidates().""" task = importer.SingletonImportTask(toppath='top path', item=_common.item()) task.search_ids = [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0, self.MB_RECORDING_PREFIX + self.ID_RECORDING_1, 'an invalid and discarded id'] task.lookup_candidates() self.assertEqual({'VALID_RECORDING_0', 'VALID_RECORDING_1'}, {c.info.title for c in task.candidates}) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_importfeeds.py0000644000076500000240000000411300000000000017235 0ustar00asampsonstaffimport os import os.path import tempfile import shutil import unittest from beets import config from beets.library import Item, Album, Library from beetsplug.importfeeds import ImportFeedsPlugin class ImportfeedsTestTest(unittest.TestCase): def setUp(self): config.clear() config.read(user=False) self.importfeeds = ImportFeedsPlugin() self.lib = Library(':memory:') self.feeds_dir = tempfile.mkdtemp() config['importfeeds']['dir'] = self.feeds_dir def tearDown(self): shutil.rmtree(self.feeds_dir) def test_multi_format_album_playlist(self): config['importfeeds']['formats'] = 'm3u_multi' album = Album(album='album/name', id=1) item_path = os.path.join('path', 'to', 'item') item = Item(title='song', album_id=1, path=item_path) self.lib.add(album) self.lib.add(item) self.importfeeds.album_imported(self.lib, album) playlist_path = os.path.join(self.feeds_dir, os.listdir(self.feeds_dir)[0]) self.assertTrue(playlist_path.endswith('album_name.m3u')) with open(playlist_path) as playlist: self.assertIn(item_path, playlist.read()) def test_playlist_in_subdir(self): config['importfeeds']['formats'] = 'm3u' config['importfeeds']['m3u_name'] = \ os.path.join('subdir', 'imported.m3u') album = Album(album='album/name', id=1) item_path = os.path.join('path', 'to', 'item') item = Item(title='song', album_id=1, path=item_path) self.lib.add(album) self.lib.add(item) self.importfeeds.album_imported(self.lib, album) playlist = os.path.join(self.feeds_dir, config['importfeeds']['m3u_name'].get()) playlist_subdir = os.path.dirname(playlist) self.assertTrue(os.path.isdir(playlist_subdir)) self.assertTrue(os.path.isfile(playlist)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_info.py0000644000076500000240000000642500000000000015657 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import unittest from test.helper import TestHelper from mediafile import MediaFile from beets.util import displayable_path class InfoTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('info') def tearDown(self): self.unload_plugins() self.teardown_beets() def test_path(self): path = self.create_mediafile_fixture() mediafile = MediaFile(path) mediafile.albumartist = 'AAA' mediafile.disctitle = 'DDD' mediafile.genres = ['a', 'b', 'c'] mediafile.composer = None mediafile.save() out = self.run_with_output('info', path) self.assertIn(path, out) self.assertIn('albumartist: AAA', out) self.assertIn('disctitle: DDD', out) self.assertIn('genres: a; b; c', out) self.assertNotIn('composer:', out) self.remove_mediafile_fixtures() def test_item_query(self): item1, item2 = self.add_item_fixtures(count=2) item1.album = 'xxxx' item1.write() item1.album = 'yyyy' item1.store() out = self.run_with_output('info', 'album:yyyy') self.assertIn(displayable_path(item1.path), out) self.assertIn('album: xxxx', out) self.assertNotIn(displayable_path(item2.path), out) def test_item_library_query(self): item, = self.add_item_fixtures() item.album = 'xxxx' item.store() out = self.run_with_output('info', '--library', 'album:xxxx') self.assertIn(displayable_path(item.path), out) self.assertIn('album: xxxx', out) def test_collect_item_and_path(self): path = self.create_mediafile_fixture() mediafile = MediaFile(path) item, = self.add_item_fixtures() item.album = mediafile.album = 'AAA' item.tracktotal = mediafile.tracktotal = 5 item.title = 'TTT' mediafile.title = 'SSS' item.write() item.store() mediafile.save() out = self.run_with_output('info', '--summarize', 'album:AAA', path) self.assertIn('album: AAA', out) self.assertIn('tracktotal: 5', out) self.assertIn('title: [various]', out) self.remove_mediafile_fixtures() def test_custom_format(self): self.add_item_fixtures() out = self.run_with_output('info', '--library', '--format', '$track. $title - $artist ($length)') self.assertEqual('02. tïtle 0 - the artist (0:01)\n', out) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_ipfs.py0000644000076500000240000000643000000000000015661 0ustar00asampsonstaff# This file is part of beets. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from unittest.mock import patch, Mock from beets import library from beets.util import bytestring_path, _fsencoding from beetsplug.ipfs import IPFSPlugin import unittest import os from test import _common from test.helper import TestHelper @patch('beets.util.command_output', Mock()) class IPFSPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('ipfs') self.lib = library.Library(":memory:") def tearDown(self): self.unload_plugins() self.teardown_beets() def test_stored_hashes(self): test_album = self.mk_test_album() ipfs = IPFSPlugin() added_albums = ipfs.ipfs_added_albums(self.lib, self.lib.path) added_album = added_albums.get_album(1) self.assertEqual(added_album.ipfs, test_album.ipfs) found = False want_item = test_album.items()[2] for check_item in added_album.items(): try: if check_item.get('ipfs', with_album=False): ipfs_item = os.path.basename(want_item.path).decode( _fsencoding(), ) want_path = '/ipfs/{}/{}'.format(test_album.ipfs, ipfs_item) want_path = bytestring_path(want_path) self.assertEqual(check_item.path, want_path) self.assertEqual(check_item.get('ipfs', with_album=False), want_item.ipfs) self.assertEqual(check_item.title, want_item.title) found = True except AttributeError: pass self.assertTrue(found) def mk_test_album(self): items = [_common.item() for _ in range(3)] items[0].title = 'foo bar' items[0].artist = '1one' items[0].album = 'baz' items[0].year = 2001 items[0].comp = True items[1].title = 'baz qux' items[1].artist = '2two' items[1].album = 'baz' items[1].year = 2002 items[1].comp = True items[2].title = 'beets 4 eva' items[2].artist = '3three' items[2].album = 'foo' items[2].year = 2003 items[2].comp = False items[2].ipfs = 'QmfM9ic5LJj7V6ecozFx1MkSoaaiq3PXfhJoFvyqzpLXSk' for item in items: self.lib.add(item) album = self.lib.add_album(items) album.ipfs = "QmfM9ic5LJj7V6ecozFx1MkSoaaiq3PXfhJoFvyqzpLXSf" album.store() return album def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_keyfinder.py0000644000076500000240000000541600000000000016703 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from unittest.mock import patch import unittest from test.helper import TestHelper from beets.library import Item from beets import util @patch('beets.util.command_output') class KeyFinderTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('keyfinder') def tearDown(self): self.teardown_beets() self.unload_plugins() def test_add_key(self, command_output): item = Item(path='/file') item.add(self.lib) command_output.return_value = util.CommandOutput(b"dbm", b"") self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], 'C#m') command_output.assert_called_with( ['KeyFinder', '-f', util.syspath(item.path)]) def test_add_key_on_import(self, command_output): command_output.return_value = util.CommandOutput(b"dbm", b"") importer = self.create_importer() importer.run() item = self.lib.items().get() self.assertEqual(item['initial_key'], 'C#m') def test_force_overwrite(self, command_output): self.config['keyfinder']['overwrite'] = True item = Item(path='/file', initial_key='F') item.add(self.lib) command_output.return_value = util.CommandOutput(b"C#m", b"") self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], 'C#m') def test_do_not_overwrite(self, command_output): item = Item(path='/file', initial_key='F') item.add(self.lib) command_output.return_value = util.CommandOutput(b"dbm", b"") self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], 'F') def test_no_key(self, command_output): item = Item(path='/file') item.add(self.lib) command_output.return_value = util.CommandOutput(b"", b"") self.run_command('keyfinder') item.load() self.assertEqual(item['initial_key'], None) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_lastgenre.py0000644000076500000240000002217200000000000016705 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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. """Tests for the 'lastgenre' plugin.""" import unittest from unittest.mock import Mock from test import _common from beetsplug import lastgenre from beets import config from test.helper import TestHelper class LastGenrePluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.plugin = lastgenre.LastGenrePlugin() def tearDown(self): self.teardown_beets() def _setup_config(self, whitelist=False, canonical=False, count=1, prefer_specific=False): config['lastgenre']['canonical'] = canonical config['lastgenre']['count'] = count config['lastgenre']['prefer_specific'] = prefer_specific if isinstance(whitelist, (bool, (str,))): # Filename, default, or disabled. config['lastgenre']['whitelist'] = whitelist self.plugin.setup() if not isinstance(whitelist, (bool, (str,))): # Explicit list of genres. self.plugin.whitelist = whitelist def test_default(self): """Fetch genres with whitelist and c14n deactivated """ self._setup_config() self.assertEqual(self.plugin._resolve_genres(['delta blues']), 'Delta Blues') def test_c14n_only(self): """Default c14n tree funnels up to most common genre except for *wrong* genres that stay unchanged. """ self._setup_config(canonical=True, count=99) self.assertEqual(self.plugin._resolve_genres(['delta blues']), 'Blues') self.assertEqual(self.plugin._resolve_genres(['iota blues']), 'Iota Blues') def test_whitelist_only(self): """Default whitelist rejects *wrong* (non existing) genres. """ self._setup_config(whitelist=True) self.assertEqual(self.plugin._resolve_genres(['iota blues']), '') def test_whitelist_c14n(self): """Default whitelist and c14n both activated result in all parents genres being selected (from specific to common). """ self._setup_config(canonical=True, whitelist=True, count=99) self.assertEqual(self.plugin._resolve_genres(['delta blues']), 'Delta Blues, Blues') def test_whitelist_custom(self): """Keep only genres that are in the whitelist. """ self._setup_config(whitelist={'blues', 'rock', 'jazz'}, count=2) self.assertEqual(self.plugin._resolve_genres(['pop', 'blues']), 'Blues') self._setup_config(canonical='', whitelist={'rock'}) self.assertEqual(self.plugin._resolve_genres(['delta blues']), '') def test_count(self): """Keep the n first genres, as we expect them to be sorted from more to less popular. """ self._setup_config(whitelist={'blues', 'rock', 'jazz'}, count=2) self.assertEqual(self.plugin._resolve_genres( ['jazz', 'pop', 'rock', 'blues']), 'Jazz, Rock') def test_count_c14n(self): """Keep the n first genres, after having applied c14n when necessary """ self._setup_config(whitelist={'blues', 'rock', 'jazz'}, canonical=True, count=2) # thanks to c14n, 'blues' superseeds 'country blues' and takes the # second slot self.assertEqual(self.plugin._resolve_genres( ['jazz', 'pop', 'country blues', 'rock']), 'Jazz, Blues') def test_c14n_whitelist(self): """Genres first pass through c14n and are then filtered """ self._setup_config(canonical=True, whitelist={'rock'}) self.assertEqual(self.plugin._resolve_genres(['delta blues']), '') def test_empty_string_enables_canonical(self): """For backwards compatibility, setting the `canonical` option to the empty string enables it using the default tree. """ self._setup_config(canonical='', count=99) self.assertEqual(self.plugin._resolve_genres(['delta blues']), 'Blues') def test_empty_string_enables_whitelist(self): """Again for backwards compatibility, setting the `whitelist` option to the empty string enables the default set of genres. """ self._setup_config(whitelist='') self.assertEqual(self.plugin._resolve_genres(['iota blues']), '') def test_prefer_specific_loads_tree(self): """When prefer_specific is enabled but canonical is not the tree still has to be loaded. """ self._setup_config(prefer_specific=True, canonical=False) self.assertNotEqual(self.plugin.c14n_branches, []) def test_prefer_specific_without_canonical(self): """Prefer_specific works without canonical. """ self._setup_config(prefer_specific=True, canonical=False, count=4) self.assertEqual(self.plugin._resolve_genres( ['math rock', 'post-rock']), 'Post-Rock, Math Rock') def test_no_duplicate(self): """Remove duplicated genres. """ self._setup_config(count=99) self.assertEqual(self.plugin._resolve_genres(['blues', 'blues']), 'Blues') def test_tags_for(self): class MockPylastElem: def __init__(self, name): self.name = name def get_name(self): return self.name class MockPylastObj: def get_top_tags(self): tag1 = Mock() tag1.weight = 90 tag1.item = MockPylastElem('Pop') tag2 = Mock() tag2.weight = 40 tag2.item = MockPylastElem('Rap') return [tag1, tag2] plugin = lastgenre.LastGenrePlugin() res = plugin._tags_for(MockPylastObj()) self.assertEqual(res, ['pop', 'rap']) res = plugin._tags_for(MockPylastObj(), min_weight=50) self.assertEqual(res, ['pop']) def test_get_genre(self): mock_genres = {'track': '1', 'album': '2', 'artist': '3'} def mock_fetch_track_genre(self, obj=None): return mock_genres['track'] def mock_fetch_album_genre(self, obj): return mock_genres['album'] def mock_fetch_artist_genre(self, obj): return mock_genres['artist'] lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre self._setup_config(whitelist=False) item = _common.item() item.genre = mock_genres['track'] config['lastgenre'] = {'force': False} res = self.plugin._get_genre(item) self.assertEqual(res, (item.genre, 'keep')) config['lastgenre'] = {'force': True, 'source': 'track'} res = self.plugin._get_genre(item) self.assertEqual(res, (mock_genres['track'], 'track')) config['lastgenre'] = {'source': 'album'} res = self.plugin._get_genre(item) self.assertEqual(res, (mock_genres['album'], 'album')) config['lastgenre'] = {'source': 'artist'} res = self.plugin._get_genre(item) self.assertEqual(res, (mock_genres['artist'], 'artist')) mock_genres['artist'] = None res = self.plugin._get_genre(item) self.assertEqual(res, (item.genre, 'original')) config['lastgenre'] = {'fallback': 'rap'} item.genre = None res = self.plugin._get_genre(item) self.assertEqual(res, (config['lastgenre']['fallback'].get(), 'fallback')) def test_sort_by_depth(self): self._setup_config(canonical=True) # Normal case. tags = ('electronic', 'ambient', 'post-rock', 'downtempo') res = self.plugin._sort_by_depth(tags) self.assertEqual( res, ['post-rock', 'downtempo', 'ambient', 'electronic']) # Non-canonical tag ('chillout') present. tags = ('electronic', 'ambient', 'chillout') res = self.plugin._sort_by_depth(tags) self.assertEqual(res, ['ambient', 'electronic']) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/test/test_library.py0000644000076500000240000013172200000000000016367 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for non-query database functions of Item. """ import os import os.path import stat import shutil import re import unicodedata import sys import time import unittest from test import _common from test._common import item import beets.library import beets.dbcore.query from beets import util from beets import plugins from beets import config from mediafile import MediaFile, UnreadableFileError from beets.util import syspath, bytestring_path from test.helper import TestHelper # 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().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, b'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().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') def test_album_fallback(self): # integration test of item-album fallback lib = beets.library.Library(':memory:') i = item(lib) album = lib.add_album([i]) album['flex'] = 'foo' album.store() self.assertTrue('flex' in i) self.assertFalse('flex' in i.keys(with_album=False)) self.assertEqual(i['flex'], 'foo') self.assertEqual(i.get('flex'), 'foo') self.assertEqual(i.get('flex', with_album=False), None) self.assertEqual(i.get('flexx'), None) class DestinationTest(_common.TestCase): def setUp(self): super().setUp() # default directory is ~/Music and the only reason why it was switched # to ~/.Music is to confirm that tests works well when path to # temporary directory contains . self.lib = beets.library.Library(':memory:', '~/.Music') self.i = item(self.lib) def tearDown(self): super().tearDown() self.lib._connection().close() # Reset config if it was changed in test cases config.clear() config.read(user=False, defaults=True) def test_directory_works_with_trailing_slash(self): self.lib.directory = b'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 = b'one' self.lib.path_formats = [('default', 'two')] self.assertEqual(self.i.destination(), np('one/two')) def test_destination_substitutes_metadata_values(self): self.lib.directory = b'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 = b'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 = b'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 = b'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 = b'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(b'one' in dest) self.assertTrue(b'two' in dest) self.assertFalse(b'one/two' in dest) def test_destination_escapes_leading_dot(self): self.i.album = '.something' dest = self.i.destination() self.assertTrue(b'something' in dest) self.assertFalse(b'/.something' 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(b'one', b'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(util.PATH_SEP): self.assertTrue(len(c) <= 255) def test_destination_long_names_keep_extension(self): self.i.title = 'X' * 300 self.i.path = b'something.extn' dest = self.i.destination() self.assertEqual(dest[-5:], b'.extn') def test_distination_windows_removes_both_separators(self): self.i.title = 'one \\ two / three.mp3' with _common.platform_windows(): p = self.i.destination() self.assertFalse(b'one \\ two' in p) self.assertFalse(b'one / two' in p) self.assertFalse(b'two \\ three' in p) self.assertFalse(b'two / three' in p) def test_path_with_format(self): self.lib.path_formats = [('default', '$artist/$album ($format)')] p = self.i.destination() self.assertTrue(b'(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 = b'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 = b'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 = b'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 = b'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 = b'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 = b'one' self.lib.path_formats = [ ('default', 'two'), ('albumtype:anothertype', 'four'), ('comp:true', 'three'), ] self.assertEqual(self.i.destination(), np('one/three')) def test_get_formatted_does_not_replace_separators(self): with _common.platform_posix(): name = os.path.join('a', 'b') self.i.title = name newname = self.i.formatted().get('title') self.assertEqual(name, newname) def test_get_formatted_pads_with_zero(self): with _common.platform_posix(): self.i.track = 1 name = self.i.formatted().get('track') self.assertTrue(name.startswith('0')) def test_get_formatted_uses_kbps_bitrate(self): with _common.platform_posix(): self.i.bitrate = 12345 val = self.i.formatted().get('bitrate') self.assertEqual(val, '12kbps') def test_get_formatted_uses_khz_samplerate(self): with _common.platform_posix(): self.i.samplerate = 12345 val = self.i.formatted().get('samplerate') self.assertEqual(val, '12kHz') def test_get_formatted_datetime(self): with _common.platform_posix(): self.i.added = 1368302461.210265 val = self.i.formatted().get('added') self.assertTrue(val.startswith('2013')) def test_get_formatted_none(self): with _common.platform_posix(): self.i.some_other_field = None val = self.i.formatted().get('some_other_field') self.assertEqual(val, '') 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(util.PATH_SEP, 1)[1], b'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(util.PATH_SEP, 1)[1], b'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(util.PATH_SEP, 1)[1], b'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(util.PATH_SEP, 1)[1], b'something') def test_unicode_normalized_nfd_on_mac(self): instr = unicodedata.normalize('NFC', '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', 'caf\xe9') self.lib.path_formats = [('default', instr)] dest = self.i.destination(platform='linux', 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 = 'h\u0259d' self.lib.path_formats = [('default', '$title')] p = self.i.destination() self.assertFalse(b'?' in p) # We use UTF-8 to encode Windows paths now. self.assertTrue('h\u0259d'.encode() in p) finally: sys.getfilesystemencoding = oldfunc def test_unicode_extension_in_fragment(self): self.lib.path_formats = [('default', 'foo')] self.i.path = util.bytestring_path('bar.caf\xe9') dest = self.i.destination(platform='linux', fragment=True) self.assertEqual(dest, 'foo.caf\xe9') def test_asciify_and_replace(self): config['asciify_paths'] = True self.lib.replacements = [(re.compile('"'), 'q')] self.lib.directory = b'lib' self.lib.path_formats = [('default', '$title')] self.i.title = '\u201c\u00f6\u2014\u00cf\u201d' self.assertEqual(self.i.destination(), np('lib/qo--Iq')) def test_asciify_character_expanding_to_slash(self): config['asciify_paths'] = True self.lib.directory = b'lib' self.lib.path_formats = [('default', '$title')] self.i.title = 'ab\xa2\xbdd' self.assertEqual(self.i.destination(), np('lib/abC_ 1_2 d')) def test_destination_with_replacements(self): self.lib.directory = b'base' self.lib.replacements = [(re.compile(r'a'), 'e')] self.lib.path_formats = [('default', '$album/$title')] self.i.title = 'foo' self.i.album = 'bar' self.assertEqual(self.i.destination(), np('base/ber/foo')) def test_destination_with_replacements_argument(self): self.lib.directory = b'base' self.lib.replacements = [(re.compile(r'a'), 'f')] self.lib.path_formats = [('default', '$album/$title')] self.i.title = 'foo' self.i.album = 'bar' replacements = [(re.compile(r'a'), 'e')] self.assertEqual(self.i.destination(replacements=replacements), np('base/ber/foo')) @unittest.skip('unimplemented: #359') def test_destination_with_empty_component(self): self.lib.directory = b'base' self.lib.replacements = [(re.compile(r'^$'), '_')] self.lib.path_formats = [('default', '$album/$artist/$title')] self.i.title = 'three' self.i.artist = '' self.i.albumartist = '' self.i.album = 'one' self.assertEqual(self.i.destination(), np('base/one/_/three')) @unittest.skip('unimplemented: #359') def test_destination_with_empty_final_component(self): self.lib.directory = b'base' self.lib.replacements = [(re.compile(r'^$'), '_')] self.lib.path_formats = [('default', '$album/$title')] self.i.title = '' self.i.album = 'one' self.i.path = 'foo.mp3' self.assertEqual(self.i.destination(), np('base/one/_.mp3')) def test_legalize_path_one_for_one_replacement(self): # Use a replacement that should always replace the last X in any # path component with a Z. self.lib.replacements = [ (re.compile(r'X$'), 'Z'), ] # Construct an item whose untruncated path ends with a Y but whose # truncated version ends with an X. self.i.title = 'X' * 300 + 'Y' # The final path should reflect the replacement. dest = self.i.destination() self.assertEqual(dest[-2:], b'XZ') def test_legalize_path_one_for_many_replacement(self): # Use a replacement that should always replace the last X in any # path component with four Zs. self.lib.replacements = [ (re.compile(r'X$'), 'ZZZZ'), ] # Construct an item whose untruncated path ends with a Y but whose # truncated version ends with an X. self.i.title = 'X' * 300 + 'Y' # The final path should ignore the user replacement and create a path # of the correct length, containing Xs. dest = self.i.destination() self.assertEqual(dest[-2:], b'XX') def test_album_field_query(self): self.lib.directory = b'one' self.lib.path_formats = [('default', 'two'), ('flex:foo', 'three')] album = self.lib.add_album([self.i]) self.assertEqual(self.i.destination(), np('one/two')) album['flex'] = 'foo' album.store() self.assertEqual(self.i.destination(), np('one/three')) def test_album_field_in_template(self): self.lib.directory = b'one' self.lib.path_formats = [('default', '$flex/two')] album = self.lib.add_album([self.i]) album['flex'] = 'foo' album.store() self.assertEqual(self.i.destination(), np('one/foo/two')) class ItemFormattedMappingTest(_common.LibTestCase): def test_formatted_item_value(self): formatted = self.i.formatted() self.assertEqual(formatted['artist'], 'the artist') def test_get_unset_field(self): formatted = self.i.formatted() with self.assertRaises(KeyError): formatted['other_field'] def test_get_method_with_default(self): formatted = self.i.formatted() self.assertEqual(formatted.get('other_field'), '') def test_get_method_with_specified_default(self): formatted = self.i.formatted() self.assertEqual(formatted.get('other_field', 'default'), 'default') def test_item_precedence(self): album = self.lib.add_album([self.i]) album['artist'] = 'foo' album.store() self.assertNotEqual('foo', self.i.formatted().get('artist')) def test_album_flex_field(self): album = self.lib.add_album([self.i]) album['flex'] = 'foo' album.store() self.assertEqual('foo', self.i.formatted().get('flex')) def test_album_field_overrides_item_field_for_path(self): # Make the album inconsistent with the item. album = self.lib.add_album([self.i]) album.album = 'foo' album.store() self.i.album = 'bar' self.i.store() # Ensure the album takes precedence. formatted = self.i.formatted(for_path=True) self.assertEqual(formatted['album'], 'foo') def test_artist_falls_back_to_albumartist(self): self.i.artist = '' formatted = self.i.formatted() self.assertEqual(formatted['artist'], 'the album artist') def test_albumartist_falls_back_to_artist(self): self.i.albumartist = '' formatted = self.i.formatted() self.assertEqual(formatted['albumartist'], 'the artist') def test_both_artist_and_albumartist_empty(self): self.i.artist = '' self.i.albumartist = '' formatted = self.i.formatted() self.assertEqual(formatted['albumartist'], '') class PathFormattingMixin: """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 with _common.platform_posix(): actual = i.destination() self.assertEqual(actual, dest) class DestinationFunctionTest(_common.TestCase, PathFormattingMixin): def setUp(self): super().setUp() self.lib = beets.library.Library(':memory:') self.lib.directory = b'/base' self.lib.path_formats = [('default', 'path')] self.i = item(self.lib) def tearDown(self): super().tearDown() self.lib._connection().close() def test_upper_case_literal(self): self._setf('%upper{foo}') self._assert_dest(b'/base/FOO') def test_upper_case_variable(self): self._setf('%upper{$title}') self._assert_dest(b'/base/THE TITLE') def test_title_case_variable(self): self._setf('%title{$title}') self._assert_dest(b'/base/The Title') def test_title_case_variable_aphostrophe(self): self._setf('%title{I can\'t}') self._assert_dest(b'/base/I Can\'t') def test_asciify_variable(self): self._setf('%asciify{ab\xa2\xbdd}') self._assert_dest(b'/base/abC_ 1_2 d') def test_left_variable(self): self._setf('%left{$title, 3}') self._assert_dest(b'/base/the') def test_right_variable(self): self._setf('%right{$title,3}') self._assert_dest(b'/base/tle') def test_if_false(self): self._setf('x%if{,foo}') self._assert_dest(b'/base/x') def test_if_false_value(self): self._setf('x%if{false,foo}') self._assert_dest(b'/base/x') def test_if_true(self): self._setf('%if{bar,foo}') self._assert_dest(b'/base/foo') def test_if_else_false(self): self._setf('%if{,foo,baz}') self._assert_dest(b'/base/baz') def test_if_else_false_value(self): self._setf('%if{false,foo,baz}') self._assert_dest(b'/base/baz') def test_if_int_value(self): self._setf('%if{0,foo,baz}') self._assert_dest(b'/base/baz') def test_nonexistent_function(self): self._setf('%foo{bar}') self._assert_dest(b'/base/%foo{bar}') def test_if_def_field_return_self(self): self.i.bar = 3 self._setf('%ifdef{bar}') self._assert_dest(b'/base/3') def test_if_def_field_not_defined(self): self._setf(' %ifdef{bar}/$artist') self._assert_dest(b'/base/the artist') def test_if_def_field_not_defined_2(self): self._setf('$artist/%ifdef{bar}') self._assert_dest(b'/base/the artist') def test_if_def_true(self): self._setf('%ifdef{artist,cool}') self._assert_dest(b'/base/cool') def test_if_def_true_complete(self): self.i.series = "Now" self._setf('%ifdef{series,$series Series,Albums}/$album') self._assert_dest(b'/base/Now Series/the album') def test_if_def_false_complete(self): self._setf('%ifdef{plays,$plays,not_played}') self._assert_dest(b'/base/not_played') def test_first(self): self.i.genres = "Pop; Rock; Classical Crossover" self._setf('%first{$genres}') self._assert_dest(b'/base/Pop') def test_first_skip(self): self.i.genres = "Pop; Rock; Classical Crossover" self._setf('%first{$genres,1,2}') self._assert_dest(b'/base/Classical Crossover') def test_first_different_sep(self): self._setf('%first{Alice / Bob / Eve,2,0, / , & }') self._assert_dest(b'/base/Alice & Bob') class DisambiguationTest(_common.TestCase, PathFormattingMixin): def setUp(self): super().setUp() self.lib = beets.library.Library(':memory:') self.lib.directory = b'/base' self.lib.path_formats = [('default', '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('foo%aunique{albumartist album,year}/$title') def tearDown(self): super().tearDown() self.lib._connection().close() def test_unique_expands_to_disambiguating_year(self): self._assert_dest(b'/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('foo%aunique{}/$title') self._assert_dest(b'/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(b'/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(b'/base/foo [1]/the title', self.i1) self._assert_dest(b'/base/foo [2]/the title', self.i2) def test_unique_falls_back_to_second_distinguishing_field(self): self._setf('foo%aunique{albumartist album,month year}/$title') self._assert_dest(b'/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('foo%aunique{albumartist album,albumtype}/$title') self._assert_dest(b'/base/foo [foo_bar]/the title', self.i1) def test_drop_empty_disambig_string(self): album1 = self.lib.get_album(self.i1) album1.albumdisambig = None album2 = self.lib.get_album(self.i2) album2.albumdisambig = 'foo' album1.store() album2.store() self._setf('foo%aunique{albumartist album,albumdisambig}/$title') self._assert_dest(b'/base/foo/the title', self.i1) def test_change_brackets(self): self._setf('foo%aunique{albumartist album,year,()}/$title') self._assert_dest(b'/base/foo (2001)/the title', self.i1) def test_remove_brackets(self): self._setf('foo%aunique{albumartist album,year,}/$title') self._assert_dest(b'/base/foo 2001/the title', self.i1) def test_key_flexible_attribute(self): album1 = self.lib.get_album(self.i1) album1.flex = 'flex1' album2 = self.lib.get_album(self.i2) album2.flex = 'flex2' album1.store() album2.store() self._setf('foo%aunique{albumartist album flex,year}/$title') self._assert_dest(b'/base/foo/the title', self.i1) class PluginDestinationTest(_common.TestCase): def setUp(self): super().setUp() # Mock beets.plugins.item_field_getters. self._tv_map = {} def field_getters(): getters = {} for key, value in self._tv_map.items(): getters[key] = lambda _: value return getters self.old_field_getters = plugins.item_field_getters plugins.item_field_getters = field_getters self.lib = beets.library.Library(':memory:') self.lib.directory = b'/base' self.lib.path_formats = [('default', '$artist $foo')] self.i = item(self.lib) def tearDown(self): super().tearDown() plugins.item_field_getters = self.old_field_getters def _assert_dest(self, dest): with _common.platform_posix(): the_dest = self.i.destination() self.assertEqual(the_dest, b'/base/' + dest) def test_undefined_value_not_substituted(self): self._assert_dest(b'the artist $foo') def test_plugin_value_not_substituted(self): self._tv_map = { 'foo': 'bar', } self._assert_dest(b'the artist bar') def test_plugin_value_overrides_attribute(self): self._tv_map = { 'artist': 'bar', } self._assert_dest(b'bar $foo') def test_plugin_value_sanitized(self): self._tv_map = { 'foo': 'bar/baz', } self._assert_dest(b'the artist bar_baz') class AlbumInfoTest(_common.TestCase): def setUp(self): super().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, b'/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 i in ai.items(): if i.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) def test_noop_albuminfo_changes_affect_items(self): i = self.lib.items()[0] i.album = 'foobar' i.store() ai = self.lib.get_album(self.i) ai.album = ai.album ai.store() i = self.lib.items()[0] self.assertEqual(i.album, ai.album) class ArtDestinationTest(_common.TestCase): def setUp(self): super().setUp() config['art_filename'] = 'artimage' config['replace'] = {'X': 'Y'} self.lib = beets.library.Library( ':memory:', replacements=[(re.compile('X'), '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') new_art = bytestring_path('%sartimage.jpg' % os.path.sep) self.assertTrue(new_art 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'] = 'artXimage' art = self.ai.art_destination('something.jpg') self.assertTrue(b'artYimage' in art) class PathStringTest(_common.TestCase): def setUp(self): super().setUp() self.lib = beets.library.Library(':memory:') self.i = item(self.lib) def test_item_path_is_bytestring(self): self.assertTrue(isinstance(self.i.path, bytes)) def test_fetched_item_path_is_bytestring(self): i = list(self.lib.items())[0] self.assertTrue(isinstance(i.path, bytes)) def test_unicode_path_becomes_bytestring(self): self.i.path = 'unicodepath' self.assertTrue(isinstance(self.i.path, bytes)) def test_unicode_in_database_becomes_bytestring(self): self.lib._connection().execute(""" update items set path=? where id=? """, (self.i.id, 'somepath')) i = list(self.lib.items())[0] self.assertTrue(isinstance(i.path, bytes)) def test_special_chars_preserved_in_database(self): path = 'b\xe1r'.encode() 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'.encode() 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 = 'b\xe1r' dest = self.i.destination() self.assertTrue(isinstance(dest, bytes)) def test_art_destination_returns_bytestring(self): self.i.artist = 'b\xe1r' alb = self.lib.add_album([self.i]) dest = alb.art_destination('image.jpg') self.assertTrue(isinstance(dest, bytes)) 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 = 'b\xe1r?' new_path = util.sanitize_path(path) self.assertTrue(new_path.startswith('b\xe1r')) def test_sanitize_path_returns_unicode(self): path = 'b\xe1r?' new_path = util.sanitize_path(path) self.assertTrue(isinstance(new_path, str)) def test_unicode_artpath_becomes_bytestring(self): alb = self.lib.add_album([self.i]) alb.artpath = 'somep\xe1th' self.assertTrue(isinstance(alb.artpath, bytes)) 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=?", ('somep\xe1th', alb.id) ) alb = self.lib.get_album(alb.id) self.assertTrue(isinstance(alb.artpath, bytes)) class MtimeTest(_common.TestCase): def setUp(self): super().setUp() self.ipath = os.path.join(self.temp_dir, b'testfile.mp3') shutil.copy(os.path.join(_common.RSRC, b'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().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().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) class TemplateTest(_common.LibTestCase): def test_year_formatted_in_template(self): self.i.year = 123 self.i.store() self.assertEqual(self.i.evaluate_template('$year'), '0123') def test_album_flexattr_appears_in_item_template(self): self.album = self.lib.add_album([self.i]) self.album.foo = 'baz' self.album.store() self.assertEqual(self.i.evaluate_template('$foo'), 'baz') def test_album_and_item_format(self): config['format_album'] = 'foö $foo' album = beets.library.Album() album.foo = 'bar' album.tagada = 'togodo' self.assertEqual(f"{album}", "foö bar") self.assertEqual(f"{album:$tagada}", "togodo") self.assertEqual(str(album), "foö bar") self.assertEqual(bytes(album), b"fo\xc3\xb6 bar") config['format_item'] = 'bar $foo' item = beets.library.Item() item.foo = 'bar' item.tagada = 'togodo' self.assertEqual(f"{item}", "bar bar") self.assertEqual(f"{item:$tagada}", "togodo") class UnicodePathTest(_common.LibTestCase): def test_unicode_path(self): self.i.path = os.path.join(_common.RSRC, 'unicode\u2019d.mp3'.encode()) # If there are any problems with unicode paths, we will raise # here and fail. self.i.read() self.i.write() class WriteTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_write_nonexistant(self): item = self.create_item() item.path = b'/path/does/not/exist' with self.assertRaises(beets.library.ReadError): item.write() def test_no_write_permission(self): item = self.add_item_fixture() path = syspath(item.path) os.chmod(path, stat.S_IRUSR) try: self.assertRaises(beets.library.WriteError, item.write) finally: # Restore write permissions so the file can be cleaned up. os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) def test_write_with_custom_path(self): item = self.add_item_fixture() custom_path = os.path.join(self.temp_dir, b'custom.mp3') shutil.copy(syspath(item.path), syspath(custom_path)) item['artist'] = 'new artist' self.assertNotEqual(MediaFile(syspath(custom_path)).artist, 'new artist') self.assertNotEqual(MediaFile(syspath(item.path)).artist, 'new artist') item.write(custom_path) self.assertEqual(MediaFile(syspath(custom_path)).artist, 'new artist') self.assertNotEqual(MediaFile(syspath(item.path)).artist, 'new artist') def test_write_custom_tags(self): item = self.add_item_fixture(artist='old artist') item.write(tags={'artist': 'new artist'}) self.assertNotEqual(item.artist, 'new artist') self.assertEqual(MediaFile(syspath(item.path)).artist, 'new artist') def test_write_date_field(self): # Since `date` is not a MediaField, this should do nothing. item = self.add_item_fixture() clean_year = item.year item.date = 'foo' item.write() self.assertEqual(MediaFile(syspath(item.path)).year, clean_year) class ItemReadTest(unittest.TestCase): def test_unreadable_raise_read_error(self): unreadable = os.path.join(_common.RSRC, b'image-2x3.png') item = beets.library.Item() with self.assertRaises(beets.library.ReadError) as cm: item.read(unreadable) self.assertIsInstance(cm.exception.reason, UnreadableFileError) def test_nonexistent_raise_read_error(self): item = beets.library.Item() with self.assertRaises(beets.library.ReadError): item.read('/thisfiledoesnotexist') class FilesizeTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_filesize(self): item = self.add_item_fixture() self.assertNotEqual(item.filesize, 0) def test_nonexistent_file(self): item = beets.library.Item() self.assertEqual(item.filesize, 0) class ParseQueryTest(unittest.TestCase): def test_parse_invalid_query_string(self): with self.assertRaises(beets.dbcore.InvalidQueryError) as raised: beets.library.parse_query_string('foo"', None) self.assertIsInstance(raised.exception, beets.dbcore.query.ParsingError) def test_parse_bytes(self): with self.assertRaises(AssertionError): beets.library.parse_query_string(b"query", None) class LibraryFieldTypesTest(unittest.TestCase): """Test format() and parse() for library-specific field types""" def test_datetype(self): t = beets.library.DateType() # format time_format = beets.config['time_format'].as_str() time_local = time.strftime(time_format, time.localtime(123456789)) self.assertEqual(time_local, t.format(123456789)) # parse self.assertEqual(123456789.0, t.parse(time_local)) self.assertEqual(123456789.0, t.parse('123456789.0')) self.assertEqual(t.null, t.parse('not123456789.0')) self.assertEqual(t.null, t.parse('1973-11-29')) def test_pathtype(self): t = beets.library.PathType() # format self.assertEqual('/tmp', t.format('/tmp')) self.assertEqual('/tmp/\xe4lbum', t.format('/tmp/\u00e4lbum')) # parse self.assertEqual(np(b'/tmp'), t.parse('/tmp')) self.assertEqual(np(b'/tmp/\xc3\xa4lbum'), t.parse('/tmp/\u00e4lbum/')) def test_musicalkey(self): t = beets.library.MusicalKey() # parse self.assertEqual('C#m', t.parse('c#m')) self.assertEqual('Gm', t.parse('g minor')) self.assertEqual('Not c#m', t.parse('not C#m')) def test_durationtype(self): t = beets.library.DurationType() # format self.assertEqual('1:01', t.format(61.23)) self.assertEqual('60:01', t.format(3601.23)) self.assertEqual('0:00', t.format(None)) # parse self.assertEqual(61.0, t.parse('1:01')) self.assertEqual(61.23, t.parse('61.23')) self.assertEqual(3601.0, t.parse('60:01')) self.assertEqual(t.null, t.parse('1:00:01')) self.assertEqual(t.null, t.parse('not61.23')) # config format_raw_length beets.config['format_raw_length'] = True self.assertEqual(61.23, t.format(61.23)) self.assertEqual(3601.23, t.format(3601.23)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_logging.py0000644000076500000240000002424300000000000016350 0ustar00asampsonstaff"""Stupid tests that ensure logging works as expected""" import sys import threading import logging as log from six import StringIO import unittest import beets.logging as blog from beets import plugins, ui import beetsplug from test import _common from test._common import TestCase from test import helper class LoggingTest(TestCase): def test_logging_management(self): l1 = log.getLogger("foo123") l2 = blog.getLogger("foo123") self.assertEqual(l1, l2) self.assertEqual(l1.__class__, log.Logger) l3 = blog.getLogger("bar123") l4 = log.getLogger("bar123") self.assertEqual(l3, l4) self.assertEqual(l3.__class__, blog.BeetsLogger) self.assertIsInstance(l3, (blog.StrFormatLogger, blog.ThreadLocalLevelLogger)) l5 = l3.getChild("shalala") self.assertEqual(l5.__class__, blog.BeetsLogger) l6 = blog.getLogger() self.assertNotEqual(l1, l6) def test_str_format_logging(self): l = blog.getLogger("baz123") stream = StringIO() handler = log.StreamHandler(stream) l.addHandler(handler) l.propagate = False l.warning("foo {0} {bar}", "oof", bar="baz") handler.flush() self.assertTrue(stream.getvalue(), "foo oof baz") class LoggingLevelTest(unittest.TestCase, helper.TestHelper): class DummyModule: class DummyPlugin(plugins.BeetsPlugin): def __init__(self): plugins.BeetsPlugin.__init__(self, 'dummy') self.import_stages = [self.import_stage] self.register_listener('dummy_event', self.listener) def log_all(self, name): self._log.debug('debug ' + name) self._log.info('info ' + name) self._log.warning('warning ' + name) def commands(self): cmd = ui.Subcommand('dummy') cmd.func = lambda _, __, ___: self.log_all('cmd') return (cmd,) def import_stage(self, session, task): self.log_all('import_stage') def listener(self): self.log_all('listener') def setUp(self): sys.modules['beetsplug.dummy'] = self.DummyModule beetsplug.dummy = self.DummyModule self.setup_beets() self.load_plugins('dummy') def tearDown(self): self.unload_plugins() self.teardown_beets() del beetsplug.dummy sys.modules.pop('beetsplug.dummy') self.DummyModule.DummyPlugin.listeners = None self.DummyModule.DummyPlugin._raw_listeners = None def test_command_level0(self): self.config['verbose'] = 0 with helper.capture_log() as logs: self.run_command('dummy') self.assertIn('dummy: warning cmd', logs) self.assertIn('dummy: info cmd', logs) self.assertNotIn('dummy: debug cmd', logs) def test_command_level1(self): self.config['verbose'] = 1 with helper.capture_log() as logs: self.run_command('dummy') self.assertIn('dummy: warning cmd', logs) self.assertIn('dummy: info cmd', logs) self.assertIn('dummy: debug cmd', logs) def test_command_level2(self): self.config['verbose'] = 2 with helper.capture_log() as logs: self.run_command('dummy') self.assertIn('dummy: warning cmd', logs) self.assertIn('dummy: info cmd', logs) self.assertIn('dummy: debug cmd', logs) def test_listener_level0(self): self.config['verbose'] = 0 with helper.capture_log() as logs: plugins.send('dummy_event') self.assertIn('dummy: warning listener', logs) self.assertNotIn('dummy: info listener', logs) self.assertNotIn('dummy: debug listener', logs) def test_listener_level1(self): self.config['verbose'] = 1 with helper.capture_log() as logs: plugins.send('dummy_event') self.assertIn('dummy: warning listener', logs) self.assertIn('dummy: info listener', logs) self.assertNotIn('dummy: debug listener', logs) def test_listener_level2(self): self.config['verbose'] = 2 with helper.capture_log() as logs: plugins.send('dummy_event') self.assertIn('dummy: warning listener', logs) self.assertIn('dummy: info listener', logs) self.assertIn('dummy: debug listener', logs) def test_import_stage_level0(self): self.config['verbose'] = 0 with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertIn('dummy: warning import_stage', logs) self.assertNotIn('dummy: info import_stage', logs) self.assertNotIn('dummy: debug import_stage', logs) def test_import_stage_level1(self): self.config['verbose'] = 1 with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertIn('dummy: warning import_stage', logs) self.assertIn('dummy: info import_stage', logs) self.assertNotIn('dummy: debug import_stage', logs) def test_import_stage_level2(self): self.config['verbose'] = 2 with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertIn('dummy: warning import_stage', logs) self.assertIn('dummy: info import_stage', logs) self.assertIn('dummy: debug import_stage', logs) @_common.slow_test() class ConcurrentEventsTest(TestCase, helper.TestHelper): """Similar to LoggingLevelTest but lower-level and focused on multiple events interaction. Since this is a bit heavy we don't do it in LoggingLevelTest. """ class DummyPlugin(plugins.BeetsPlugin): def __init__(self, test_case): plugins.BeetsPlugin.__init__(self, 'dummy') self.register_listener('dummy_event1', self.listener1) self.register_listener('dummy_event2', self.listener2) self.lock1 = threading.Lock() self.lock2 = threading.Lock() self.test_case = test_case self.exc_info = None self.t1_step = self.t2_step = 0 def log_all(self, name): self._log.debug('debug ' + name) self._log.info('info ' + name) self._log.warning('warning ' + name) def listener1(self): try: self.test_case.assertEqual(self._log.level, log.INFO) self.t1_step = 1 self.lock1.acquire() self.test_case.assertEqual(self._log.level, log.INFO) self.t1_step = 2 except Exception: import sys self.exc_info = sys.exc_info() def listener2(self): try: self.test_case.assertEqual(self._log.level, log.DEBUG) self.t2_step = 1 self.lock2.acquire() self.test_case.assertEqual(self._log.level, log.DEBUG) self.t2_step = 2 except Exception: import sys self.exc_info = sys.exc_info() def setUp(self): self.setup_beets(disk=True) def tearDown(self): self.teardown_beets() def test_concurrent_events(self): dp = self.DummyPlugin(self) def check_dp_exc(): if dp.exc_info: raise None.with_traceback(dp.exc_info[2]) try: dp.lock1.acquire() dp.lock2.acquire() self.assertEqual(dp._log.level, log.NOTSET) self.config['verbose'] = 1 t1 = threading.Thread(target=dp.listeners['dummy_event1'][0]) t1.start() # blocked. t1 tested its log level while dp.t1_step != 1: check_dp_exc() self.assertTrue(t1.is_alive()) self.assertEqual(dp._log.level, log.NOTSET) self.config['verbose'] = 2 t2 = threading.Thread(target=dp.listeners['dummy_event2'][0]) t2.start() # blocked. t2 tested its log level while dp.t2_step != 1: check_dp_exc() self.assertTrue(t2.is_alive()) self.assertEqual(dp._log.level, log.NOTSET) dp.lock1.release() # dummy_event1 tests its log level + finishes while dp.t1_step != 2: check_dp_exc() t1.join(.1) self.assertFalse(t1.is_alive()) self.assertTrue(t2.is_alive()) self.assertEqual(dp._log.level, log.NOTSET) dp.lock2.release() # dummy_event2 tests its log level + finishes while dp.t2_step != 2: check_dp_exc() t2.join(.1) self.assertFalse(t2.is_alive()) except Exception: print("Alive threads:", threading.enumerate()) if dp.lock1.locked(): print("Releasing lock1 after exception in test") dp.lock1.release() if dp.lock2.locked(): print("Releasing lock2 after exception in test") dp.lock2.release() print("Alive threads:", threading.enumerate()) raise def test_root_logger_levels(self): """Root logger level should be shared between threads. """ self.config['threaded'] = True blog.getLogger('beets').set_global_level(blog.WARNING) with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertEqual(logs, []) blog.getLogger('beets').set_global_level(blog.INFO) with helper.capture_log() as logs: importer = self.create_importer() importer.run() for l in logs: self.assertIn("import", l) self.assertIn("album", l) blog.getLogger('beets').set_global_level(blog.DEBUG) with helper.capture_log() as logs: importer = self.create_importer() importer.run() self.assertIn("Sending event: database_change", logs) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_lyrics.py0000644000076500000240000005135400000000000016232 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, 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. """Tests for the 'lyrics' plugin.""" import itertools import os import re import unittest import confuse from unittest.mock import MagicMock, patch from beets import logging from beets.library import Item from beets.util import bytestring_path from beetsplug import lyrics from test import _common log = logging.getLogger('beets.test_lyrics') raw_backend = lyrics.Backend({}, log) google = lyrics.Google(MagicMock(), log) genius = lyrics.Genius(MagicMock(), log) class LyricsPluginTest(unittest.TestCase): def setUp(self): """Set up configuration.""" lyrics.LyricsPlugin() def test_search_artist(self): item = Item(artist='Alice ft. Bob', title='song') self.assertIn(('Alice ft. Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice feat Bob', title='song') self.assertIn(('Alice feat Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice feat. Bob', title='song') self.assertIn(('Alice feat. Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice feats Bob', title='song') self.assertIn(('Alice feats Bob', ['song']), lyrics.search_pairs(item)) self.assertNotIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice featuring Bob', title='song') self.assertIn(('Alice featuring Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice & Bob', title='song') self.assertIn(('Alice & Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice and Bob', title='song') self.assertIn(('Alice and Bob', ['song']), lyrics.search_pairs(item)) self.assertIn(('Alice', ['song']), lyrics.search_pairs(item)) item = Item(artist='Alice and Bob', title='song') self.assertEqual(('Alice and Bob', ['song']), list(lyrics.search_pairs(item))[0]) def test_search_artist_sort(self): item = Item(artist='CHVRCHΞS', title='song', artist_sort='CHVRCHES') self.assertIn(('CHVRCHΞS', ['song']), lyrics.search_pairs(item)) self.assertIn(('CHVRCHES', ['song']), lyrics.search_pairs(item)) # Make sure that the original artist name is still the first entry self.assertEqual(('CHVRCHΞS', ['song']), list(lyrics.search_pairs(item))[0]) item = Item(artist='横山克', title='song', artist_sort='Masaru Yokoyama') self.assertIn(('横山克', ['song']), lyrics.search_pairs(item)) self.assertIn(('Masaru Yokoyama', ['song']), lyrics.search_pairs(item)) # Make sure that the original artist name is still the first entry self.assertEqual(('横山克', ['song']), list(lyrics.search_pairs(item))[0]) def test_search_pairs_multi_titles(self): item = Item(title='1 / 2', artist='A') self.assertIn(('A', ['1 / 2']), lyrics.search_pairs(item)) self.assertIn(('A', ['1', '2']), lyrics.search_pairs(item)) item = Item(title='1/2', artist='A') self.assertIn(('A', ['1/2']), lyrics.search_pairs(item)) self.assertIn(('A', ['1', '2']), lyrics.search_pairs(item)) def test_search_pairs_titles(self): item = Item(title='Song (live)', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song (live)']), lyrics.search_pairs(item)) item = Item(title='Song (live) (new)', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song (live) (new)']), lyrics.search_pairs(item)) item = Item(title='Song (live (new))', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song (live (new))']), lyrics.search_pairs(item)) item = Item(title='Song ft. B', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song ft. B']), lyrics.search_pairs(item)) item = Item(title='Song featuring B', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song featuring B']), lyrics.search_pairs(item)) item = Item(title='Song and B', artist='A') self.assertNotIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song and B']), lyrics.search_pairs(item)) item = Item(title='Song: B', artist='A') self.assertIn(('A', ['Song']), lyrics.search_pairs(item)) self.assertIn(('A', ['Song: B']), lyrics.search_pairs(item)) def test_remove_credits(self): self.assertEqual( lyrics.remove_credits("""It's close to midnight Lyrics brought by example.com"""), "It's close to midnight" ) self.assertEqual( lyrics.remove_credits("""Lyrics brought by example.com"""), "" ) # don't remove 2nd verse for the only reason it contains 'lyrics' word text = """Look at all the shit that i done bought her See lyrics ain't nothin if the beat aint crackin""" self.assertEqual(lyrics.remove_credits(text), text) def test_is_lyrics(self): texts = ['LyricsMania.com - Copyright (c) 2013 - All Rights Reserved'] texts += ["""All material found on this site is property\n of mywickedsongtext brand"""] for t in texts: self.assertFalse(google.is_lyrics(t)) def test_slugify(self): text = "http://site.com/\xe7afe-au_lait(boisson)" self.assertEqual(google.slugify(text), 'http://site.com/cafe_au_lait') def test_scrape_strip_cruft(self): text = """  one
two !

four""" self.assertEqual(lyrics._scrape_strip_cruft(text, True), "one\ntwo !\n\nfour") def test_scrape_strip_scripts(self): text = """foobaz""" self.assertEqual(lyrics._scrape_strip_cruft(text, True), "foobaz") def test_scrape_strip_tag_in_comment(self): text = """fooqux""" self.assertEqual(lyrics._scrape_strip_cruft(text, True), "fooqux") def test_scrape_merge_paragraphs(self): text = "one

two

three" self.assertEqual(lyrics._scrape_merge_paragraphs(text), "one\ntwo\nthree") def test_missing_lyrics(self): self.assertFalse(google.is_lyrics(LYRICS_TEXTS['missing_texts'])) def url_to_filename(url): url = re.sub(r'https?://|www.', '', url) fn = "".join(x for x in url if (x.isalnum() or x == '/')) fn = fn.split('/') fn = os.path.join(LYRICS_ROOT_DIR, bytestring_path(fn[0]), bytestring_path(fn[-1] + '.txt')) return fn class MockFetchUrl: def __init__(self, pathval='fetched_path'): self.pathval = pathval self.fetched = None def __call__(self, url, filename=None): self.fetched = url fn = url_to_filename(url) with open(fn, encoding="utf8") as f: content = f.read() return content def is_lyrics_content_ok(title, text): """Compare lyrics text to expected lyrics for given title.""" if not text: return keywords = set(LYRICS_TEXTS[google.slugify(title)].split()) words = {x.strip(".?, ") for x in text.lower().split()} return keywords <= words LYRICS_ROOT_DIR = os.path.join(_common.RSRC, b'lyrics') yaml_path = os.path.join(_common.RSRC, b'lyricstext.yaml') LYRICS_TEXTS = confuse.load_yaml(yaml_path) class LyricsGoogleBaseTest(unittest.TestCase): def setUp(self): """Set up configuration.""" try: __import__('bs4') except ImportError: self.skipTest('Beautiful Soup 4 not available') class LyricsPluginSourcesTest(LyricsGoogleBaseTest): """Check that beets google custom search engine sources are correctly scraped. """ DEFAULT_SONG = dict(artist='The Beatles', title='Lady Madonna') DEFAULT_SOURCES = [ # dict(artist=u'Santana', title=u'Black magic woman', # backend=lyrics.MusiXmatch), dict(DEFAULT_SONG, backend=lyrics.Genius, # GitHub actions is on some form of Cloudflare blacklist. skip=os.environ.get('GITHUB_ACTIONS') == 'true'), dict(artist='Boy In Space', title='u n eye', backend=lyrics.Tekstowo), ] GOOGLE_SOURCES = [ dict(DEFAULT_SONG, url='http://www.absolutelyrics.com', path='/lyrics/view/the_beatles/lady_madonna'), dict(DEFAULT_SONG, url='http://www.azlyrics.com', path='/lyrics/beatles/ladymadonna.html', # AZLyrics returns a 403 on GitHub actions. skip=os.environ.get('GITHUB_ACTIONS') == 'true'), dict(DEFAULT_SONG, url='http://www.chartlyrics.com', path='/_LsLsZ7P4EK-F-LD4dJgDQ/Lady+Madonna.aspx'), # dict(DEFAULT_SONG, # url=u'http://www.elyricsworld.com', # path=u'/lady_madonna_lyrics_beatles.html'), dict(url='http://www.lacoccinelle.net', artist='Jacques Brel', title="Amsterdam", path='/paroles-officielles/275679.html'), dict(DEFAULT_SONG, url='http://letras.mus.br/', path='the-beatles/275/'), dict(DEFAULT_SONG, url='http://www.lyricsmania.com/', path='lady_madonna_lyrics_the_beatles.html'), dict(DEFAULT_SONG, url='http://www.lyricsmode.com', path='/lyrics/b/beatles/lady_madonna.html'), dict(url='http://www.lyricsontop.com', artist='Amy Winehouse', title="Jazz'n'blues", path='/amy-winehouse-songs/jazz-n-blues-lyrics.html'), # dict(DEFAULT_SONG, # url='http://www.metrolyrics.com/', # path='lady-madonna-lyrics-beatles.html'), # dict(url='http://www.musica.com/', path='letras.asp?letra=2738', # artist=u'Santana', title=u'Black magic woman'), dict(url='http://www.paroles.net/', artist='Lilly Wood & the prick', title="Hey it's ok", path='lilly-wood-the-prick/paroles-hey-it-s-ok'), dict(DEFAULT_SONG, url='http://www.songlyrics.com', path='/the-beatles/lady-madonna-lyrics'), dict(DEFAULT_SONG, url='http://www.sweetslyrics.com', path='/761696.The%20Beatles%20-%20Lady%20Madonna.html') ] def setUp(self): LyricsGoogleBaseTest.setUp(self) self.plugin = lyrics.LyricsPlugin() @unittest.skipUnless( os.environ.get('INTEGRATION_TEST', '0') == '1', 'integration testing not enabled') def test_backend_sources_ok(self): """Test default backends with songs known to exist in respective databases. """ errors = [] # Don't test any sources marked as skipped. sources = [s for s in self.DEFAULT_SOURCES if not s.get("skip", False)] for s in sources: res = s['backend'](self.plugin.config, self.plugin._log).fetch( s['artist'], s['title']) if not is_lyrics_content_ok(s['title'], res): errors.append(s['backend'].__name__) self.assertFalse(errors) @unittest.skipUnless( os.environ.get('INTEGRATION_TEST', '0') == '1', 'integration testing not enabled') def test_google_sources_ok(self): """Test if lyrics present on websites registered in beets google custom search engine are correctly scraped. """ # Don't test any sources marked as skipped. sources = [s for s in self.GOOGLE_SOURCES if not s.get("skip", False)] for s in sources: url = s['url'] + s['path'] res = lyrics.scrape_lyrics_from_html( raw_backend.fetch_url(url)) self.assertTrue(google.is_lyrics(res), url) self.assertTrue(is_lyrics_content_ok(s['title'], res), url) class LyricsGooglePluginMachineryTest(LyricsGoogleBaseTest): """Test scraping heuristics on a fake html page. """ source = dict(url='http://www.example.com', artist='John Doe', title='Beets song', path='/lyrics/beetssong') def setUp(self): """Set up configuration""" LyricsGoogleBaseTest.setUp(self) self.plugin = lyrics.LyricsPlugin() @patch.object(lyrics.Backend, 'fetch_url', MockFetchUrl()) def test_mocked_source_ok(self): """Test that lyrics of the mocked page are correctly scraped""" url = self.source['url'] + self.source['path'] res = lyrics.scrape_lyrics_from_html(raw_backend.fetch_url(url)) self.assertTrue(google.is_lyrics(res), url) self.assertTrue(is_lyrics_content_ok(self.source['title'], res), url) @patch.object(lyrics.Backend, 'fetch_url', MockFetchUrl()) def test_is_page_candidate_exact_match(self): """Test matching html page title with song infos -- when song infos are present in the title. """ from bs4 import SoupStrainer, BeautifulSoup s = self.source url = str(s['url'] + s['path']) html = raw_backend.fetch_url(url) soup = BeautifulSoup(html, "html.parser", parse_only=SoupStrainer('title')) self.assertEqual( google.is_page_candidate(url, soup.title.string, s['title'], s['artist']), True, url) def test_is_page_candidate_fuzzy_match(self): """Test matching html page title with song infos -- when song infos are not present in the title. """ s = self.source url = s['url'] + s['path'] url_title = 'example.com | Beats song by John doe' # very small diffs (typo) are ok eg 'beats' vs 'beets' with same artist self.assertEqual(google.is_page_candidate(url, url_title, s['title'], s['artist']), True, url) # reject different title url_title = 'example.com | seets bong lyrics by John doe' self.assertEqual(google.is_page_candidate(url, url_title, s['title'], s['artist']), False, url) def test_is_page_candidate_special_chars(self): """Ensure that `is_page_candidate` doesn't crash when the artist and such contain special regular expression characters. """ # https://github.com/beetbox/beets/issues/1673 s = self.source url = s['url'] + s['path'] url_title = 'foo' google.is_page_candidate(url, url_title, s['title'], 'Sunn O)))') # test Genius backend class GeniusBaseTest(unittest.TestCase): def setUp(self): """Set up configuration.""" try: __import__('bs4') except ImportError: self.skipTest('Beautiful Soup 4 not available') class GeniusScrapeLyricsFromHtmlTest(GeniusBaseTest): """tests Genius._scrape_lyrics_from_html()""" def setUp(self): """Set up configuration""" GeniusBaseTest.setUp(self) self.plugin = lyrics.LyricsPlugin() def test_no_lyrics_div(self): """Ensure we don't crash when the scraping the html for a genius page doesn't contain

""" # https://github.com/beetbox/beets/issues/3535 # expected return value None url = 'https://genius.com/sample' mock = MockFetchUrl() self.assertEqual(genius._scrape_lyrics_from_html(mock(url)), None) def test_good_lyrics(self): """Ensure we are able to scrape a page with lyrics""" url = 'https://genius.com/Wu-tang-clan-cream-lyrics' mock = MockFetchUrl() self.assertIsNotNone(genius._scrape_lyrics_from_html(mock(url))) # TODO: find an example of a lyrics page with multiple divs and test it class GeniusFetchTest(GeniusBaseTest): """tests Genius.fetch()""" def setUp(self): """Set up configuration""" GeniusBaseTest.setUp(self) self.plugin = lyrics.LyricsPlugin() @patch.object(lyrics.Genius, '_scrape_lyrics_from_html') @patch.object(lyrics.Backend, 'fetch_url', return_value=True) def test_json(self, mock_fetch_url, mock_scrape): """Ensure we're finding artist matches""" with patch.object( lyrics.Genius, '_search', return_value={ "response": { "hits": [ { "result": { "primary_artist": { "name": "\u200Bblackbear", }, "url": "blackbear_url" } }, { "result": { "primary_artist": { "name": "El\u002Dp" }, "url": "El-p_url" } } ] } } ) as mock_json: # genius uses zero-width-spaces (\u200B) for lowercase # artists so we make sure we can match those self.assertIsNotNone(genius.fetch('blackbear', 'Idfc')) mock_fetch_url.assert_called_once_with("blackbear_url") mock_scrape.assert_called_once_with(True) # genius uses the hypen minus (\u002D) as their dash self.assertIsNotNone(genius.fetch('El-p', 'Idfc')) mock_fetch_url.assert_called_with('El-p_url') mock_scrape.assert_called_with(True) # test no matching artist self.assertIsNone(genius.fetch('doesntexist', 'none')) # test invalid json mock_json.return_value = None self.assertIsNone(genius.fetch('blackbear', 'Idfc')) # TODO: add integration test hitting real api # test utilties class SlugTests(unittest.TestCase): def test_slug(self): # plain ascii passthrough text = "test" self.assertEqual(lyrics.slug(text), 'test') # german unicode and capitals text = "Mørdag" self.assertEqual(lyrics.slug(text), 'mordag') # more accents and quotes text = "l'été c'est fait pour jouer" self.assertEqual(lyrics.slug(text), 'l-ete-c-est-fait-pour-jouer') # accents, parens and spaces text = "\xe7afe au lait (boisson)" self.assertEqual(lyrics.slug(text), 'cafe-au-lait-boisson') text = "Multiple spaces -- and symbols! -- merged" self.assertEqual(lyrics.slug(text), 'multiple-spaces-and-symbols-merged') text = "\u200Bno-width-space" self.assertEqual(lyrics.slug(text), 'no-width-space') # variations of dashes should get standardized dashes = ['\u200D', '\u2010'] for dash1, dash2 in itertools.combinations(dashes, 2): self.assertEqual(lyrics.slug(dash1), lyrics.slug(dash2)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_mb.py0000644000076500000240000006173100000000000015323 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for MusicBrainz API wrapper. """ from test import _common from beets.autotag import mb from beets import config import unittest from unittest import mock class MBAlbumInfoTest(_common.TestCase): def _make_release(self, date_str='2009', tracks=None, track_length=None, track_artist=False, data_tracks=None, medium_format='FORMAT'): 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', } i = 0 track_list = [] if tracks: for recording in tracks: i += 1 track = { 'id': 'RELEASE TRACK ID %d' % i, 'recording': recording, 'position': i, 'number': 'A1', } 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) data_track_list = [] if data_tracks: for recording in data_tracks: i += 1 data_track = { 'id': 'RELEASE TRACK ID %d' % i, 'recording': recording, 'position': i, 'number': 'A1', } data_track_list.append(data_track) release['medium-list'].append({ 'position': '1', 'track-list': track_list, 'data-track-list': data_track_list, 'format': medium_format, 'title': 'MEDIUM TITLE', }) return release def _make_track(self, title, tr_id, duration, artist=False, video=False, disambiguation=None): 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', } ] if video: track['video'] = 'true' if disambiguation: track['disambiguation'] = disambiguation 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 = [{ 'id': 'RELEASE TRACK ID 2', 'recording': tracks[1], 'position': '1', 'number': 'A1', }] 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, 'R_DISAMBIGUATION') self.assertEqual(d.releasegroupdisambig, 'RG_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') def test_data_source(self): release = self._make_release() d = mb.album_info(release) self.assertEqual(d.data_source, 'MusicBrainz') def test_ignored_media(self): config['match']['ignored_media'] = ['IGNORED1', 'IGNORED2'] 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, medium_format="IGNORED1") d = mb.album_info(release) self.assertEqual(len(d.tracks), 0) def test_no_ignored_media(self): config['match']['ignored_media'] = ['IGNORED1', 'IGNORED2'] 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, medium_format="NON-IGNORED") d = mb.album_info(release) self.assertEqual(len(d.tracks), 2) def test_skip_data_track(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('[data track]', 'ID DATA TRACK', 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(len(d.tracks), 2) self.assertEqual(d.tracks[0].title, 'TITLE ONE') self.assertEqual(d.tracks[1].title, 'TITLE TWO') def test_skip_audio_data_tracks_by_default(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] data_tracks = [self._make_track('TITLE AUDIO DATA', 'ID DATA TRACK', 100.0 * 1000.0)] release = self._make_release(tracks=tracks, data_tracks=data_tracks) d = mb.album_info(release) self.assertEqual(len(d.tracks), 2) self.assertEqual(d.tracks[0].title, 'TITLE ONE') self.assertEqual(d.tracks[1].title, 'TITLE TWO') def test_no_skip_audio_data_tracks_if_configured(self): config['match']['ignore_data_tracks'] = False tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] data_tracks = [self._make_track('TITLE AUDIO DATA', 'ID DATA TRACK', 100.0 * 1000.0)] release = self._make_release(tracks=tracks, data_tracks=data_tracks) d = mb.album_info(release) self.assertEqual(len(d.tracks), 3) self.assertEqual(d.tracks[0].title, 'TITLE ONE') self.assertEqual(d.tracks[1].title, 'TITLE TWO') self.assertEqual(d.tracks[2].title, 'TITLE AUDIO DATA') def test_skip_video_tracks_by_default(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE VIDEO', 'ID VIDEO', 100.0 * 1000.0, False, True), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=tracks) d = mb.album_info(release) self.assertEqual(len(d.tracks), 2) self.assertEqual(d.tracks[0].title, 'TITLE ONE') self.assertEqual(d.tracks[1].title, 'TITLE TWO') def test_skip_video_data_tracks_by_default(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] data_tracks = [self._make_track('TITLE VIDEO', 'ID VIDEO', 100.0 * 1000.0, False, True)] release = self._make_release(tracks=tracks, data_tracks=data_tracks) d = mb.album_info(release) self.assertEqual(len(d.tracks), 2) self.assertEqual(d.tracks[0].title, 'TITLE ONE') self.assertEqual(d.tracks[1].title, 'TITLE TWO') def test_no_skip_video_tracks_if_configured(self): config['match']['ignore_data_tracks'] = False config['match']['ignore_video_tracks'] = False tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE VIDEO', 'ID VIDEO', 100.0 * 1000.0, False, True), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] release = self._make_release(tracks=tracks) d = mb.album_info(release) self.assertEqual(len(d.tracks), 3) self.assertEqual(d.tracks[0].title, 'TITLE ONE') self.assertEqual(d.tracks[1].title, 'TITLE VIDEO') self.assertEqual(d.tracks[2].title, 'TITLE TWO') def test_no_skip_video_data_tracks_if_configured(self): config['match']['ignore_data_tracks'] = False config['match']['ignore_video_tracks'] = False tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0)] data_tracks = [self._make_track('TITLE VIDEO', 'ID VIDEO', 100.0 * 1000.0, False, True)] release = self._make_release(tracks=tracks, data_tracks=data_tracks) d = mb.album_info(release) self.assertEqual(len(d.tracks), 3) self.assertEqual(d.tracks[0].title, 'TITLE ONE') self.assertEqual(d.tracks[1].title, 'TITLE TWO') self.assertEqual(d.tracks[2].title, 'TITLE VIDEO') def test_track_disambiguation(self): tracks = [self._make_track('TITLE ONE', 'ID ONE', 100.0 * 1000.0), self._make_track('TITLE TWO', 'ID TWO', 200.0 * 1000.0, disambiguation="SECOND TRACK")] release = self._make_release(tracks=tracks) d = mb.album_info(release) t = d.tracks self.assertEqual(len(t), 2) self.assertEqual(t[0].trackdisambig, None) self.assertEqual(t[1].trackdisambig, "SECOND TRACK") 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 = "https://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', primary=True) self._add_alias(credit_dict, suffix='en_GB', locale='en_GB', primary=True) self._add_alias(credit_dict, suffix='fr', locale='fr') self._add_alias(credit_dict, suffix='fr_P', locale='fr', primary=True) self._add_alias(credit_dict, suffix='pt_BR', locale='pt_BR') # test no alias config['import']['languages'] = [''] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('NAME', 'SORT', 'CREDIT')) # test en primary config['import']['languages'] = ['en'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) # test en_GB en primary 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 primary 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')) # test for not matching non-primary config['import']['languages'] = ['pt_BR', 'fr'] flat = mb._flatten_artist_credit([credit_dict]) self.assertEqual(flat, ('ALIASfr_P', 'ALIASSORTfr_P', 'CREDIT')) class MBLibraryTest(unittest.TestCase): def test_match_track(self): with mock.patch('musicbrainzngs.search_recordings') as p: p.return_value = { 'recording-list': [{ 'title': 'foo', 'id': 'bar', 'length': 42, }], } ti = list(mb.match_track('hello', 'there'))[0] p.assert_called_with(artist='hello', recording='there', limit=5) self.assertEqual(ti.title, 'foo') self.assertEqual(ti.track_id, 'bar') def test_match_album(self): mbid = 'd2a6f856-b553-40a0-ac54-a321e8e2da99' with mock.patch('musicbrainzngs.search_releases') as sp: sp.return_value = { 'release-list': [{ 'id': mbid, }], } with mock.patch('musicbrainzngs.get_release_by_id') as gp: gp.return_value = { 'release': { 'title': 'hi', 'id': mbid, 'medium-list': [{ 'track-list': [{ 'id': 'baz', 'recording': { 'title': 'foo', 'id': 'bar', 'length': 42, }, 'position': 9, 'number': 'A1', }], 'position': 5, }], 'artist-credit': [{ 'artist': { 'name': 'some-artist', 'id': 'some-id', }, }], 'release-group': { 'id': 'another-id', } } } ai = list(mb.match_album('hello', 'there'))[0] sp.assert_called_with(artist='hello', release='there', limit=5) gp.assert_called_with(mbid, mock.ANY) self.assertEqual(ai.tracks[0].title, 'foo') self.assertEqual(ai.album, 'hi') def test_match_track_empty(self): with mock.patch('musicbrainzngs.search_recordings') as p: til = list(mb.match_track(' ', ' ')) self.assertFalse(p.called) self.assertEqual(til, []) def test_match_album_empty(self): with mock.patch('musicbrainzngs.search_releases') as p: ail = list(mb.match_album(' ', ' ')) self.assertFalse(p.called) self.assertEqual(ail, []) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_mbsubmit.py0000644000076500000240000000511000000000000016534 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson and Diego Moreda. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import unittest from test.helper import capture_stdout, control_stdin, TestHelper from test.test_importer import ImportHelper, AutotagStub from test.test_ui_importer import TerminalImportSessionSetup class MBSubmitPluginTest(TerminalImportSessionSetup, unittest.TestCase, ImportHelper, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('mbsubmit') self._create_import_dir(2) self._setup_import_session() self.matcher = AutotagStub().install() def tearDown(self): self.unload_plugins() self.teardown_beets() self.matcher.restore() def test_print_tracks_output(self): """Test the output of the "print tracks" choice.""" self.matcher.matching = AutotagStub.BAD with capture_stdout() as output: with control_stdin('\n'.join(['p', 's'])): # Print tracks; Skip self.importer.run() # Manually build the string for comparing the output. tracklist = ('Print tracks? ' '01. Tag Title 1 - Tag Artist (0:01)\n' '02. Tag Title 2 - Tag Artist (0:01)') self.assertIn(tracklist, output.getvalue()) def test_print_tracks_output_as_tracks(self): """Test the output of the "print tracks" choice, as singletons.""" self.matcher.matching = AutotagStub.BAD with capture_stdout() as output: with control_stdin('\n'.join(['t', 's', 'p', 's'])): # as Tracks; Skip; Print tracks; Skip self.importer.run() # Manually build the string for comparing the output. tracklist = ('Print tracks? ' '02. Tag Title 2 - Tag Artist (0:01)') self.assertIn(tracklist, output.getvalue()) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_mbsync.py0000644000076500000240000001506600000000000016220 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import unittest from unittest.mock import patch from test.helper import TestHelper,\ generate_album_info, \ generate_track_info, \ capture_log from beets import config from beets.library import Item class MbsyncCliTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('mbsync') def tearDown(self): self.unload_plugins() self.teardown_beets() @patch('beets.autotag.mb.album_for_id') @patch('beets.autotag.mb.track_for_id') def test_update_library(self, track_for_id, album_for_id): album_for_id.return_value = \ generate_album_info( 'album id', [('track id', {'release_track_id': 'release track id'})] ) track_for_id.return_value = \ generate_track_info('singleton track id', {'title': 'singleton info'}) album_item = Item( album='old title', mb_albumid='81ae60d4-5b75-38df-903a-db2cfa51c2c6', mb_trackid='old track id', mb_releasetrackid='release track id', path='' ) album = self.lib.add_album([album_item]) item = Item( title='old title', mb_trackid='b8c2cf90-83f9-3b5f-8ccd-31fb866fcf37', path='', ) self.lib.add(item) with capture_log() as logs: self.run_command('mbsync') self.assertIn('Sending event: albuminfo_received', logs) self.assertIn('Sending event: trackinfo_received', logs) item.load() self.assertEqual(item.title, 'singleton info') album_item.load() self.assertEqual(album_item.title, 'track info') self.assertEqual(album_item.mb_trackid, 'track id') album.load() self.assertEqual(album.album, 'album info') def test_message_when_skipping(self): config['format_item'] = '$artist - $album - $title' config['format_album'] = '$albumartist - $album' # Test album with no mb_albumid. # The default format for an album include $albumartist so # set that here, too. album_invalid = Item( albumartist='album info', album='album info', path='' ) self.lib.add_album([album_invalid]) # default format with capture_log('beets.mbsync') as logs: self.run_command('mbsync') e = 'mbsync: Skipping album with no mb_albumid: ' + \ 'album info - album info' self.assertEqual(e, logs[0]) # custom format with capture_log('beets.mbsync') as logs: self.run_command('mbsync', '-f', "'$album'") e = "mbsync: Skipping album with no mb_albumid: 'album info'" self.assertEqual(e, logs[0]) # restore the config config['format_item'] = '$artist - $album - $title' config['format_album'] = '$albumartist - $album' # Test singleton with no mb_trackid. # The default singleton format includes $artist and $album # so we need to stub them here item_invalid = Item( artist='album info', album='album info', title='old title', path='', ) self.lib.add(item_invalid) # default format with capture_log('beets.mbsync') as logs: self.run_command('mbsync') e = 'mbsync: Skipping singleton with no mb_trackid: ' + \ 'album info - album info - old title' self.assertEqual(e, logs[0]) # custom format with capture_log('beets.mbsync') as logs: self.run_command('mbsync', '-f', "'$title'") e = "mbsync: Skipping singleton with no mb_trackid: 'old title'" self.assertEqual(e, logs[0]) def test_message_when_invalid(self): config['format_item'] = '$artist - $album - $title' config['format_album'] = '$albumartist - $album' # Test album with invalid mb_albumid. # The default format for an album include $albumartist so # set that here, too. album_invalid = Item( albumartist='album info', album='album info', mb_albumid='a1b2c3d4', path='' ) self.lib.add_album([album_invalid]) # default format with capture_log('beets.mbsync') as logs: self.run_command('mbsync') e = 'mbsync: Skipping album with invalid mb_albumid: ' + \ 'album info - album info' self.assertEqual(e, logs[0]) # custom format with capture_log('beets.mbsync') as logs: self.run_command('mbsync', '-f', "'$album'") e = "mbsync: Skipping album with invalid mb_albumid: 'album info'" self.assertEqual(e, logs[0]) # restore the config config['format_item'] = '$artist - $album - $title' config['format_album'] = '$albumartist - $album' # Test singleton with invalid mb_trackid. # The default singleton format includes $artist and $album # so we need to stub them here item_invalid = Item( artist='album info', album='album info', title='old title', mb_trackid='a1b2c3d4', path='', ) self.lib.add(item_invalid) # default format with capture_log('beets.mbsync') as logs: self.run_command('mbsync') e = 'mbsync: Skipping singleton with invalid mb_trackid: ' + \ 'album info - album info - old title' self.assertEqual(e, logs[0]) # custom format with capture_log('beets.mbsync') as logs: self.run_command('mbsync', '-f', "'$title'") e = "mbsync: Skipping singleton with invalid mb_trackid: 'old title'" self.assertEqual(e, logs[0]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_metasync.py0000644000076500000240000001125600000000000016545 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Tom Jaspers. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os import platform import time from datetime import datetime from beets.library import Item from beets.util import py3_path import unittest from test import _common from test.helper import TestHelper def _parsetime(s): return time.mktime(datetime.strptime(s, '%Y-%m-%d %H:%M:%S').timetuple()) def _is_windows(): return platform.system() == "Windows" class MetaSyncTest(_common.TestCase, TestHelper): itunes_library_unix = os.path.join(_common.RSRC, b'itunes_library_unix.xml') itunes_library_windows = os.path.join(_common.RSRC, b'itunes_library_windows.xml') def setUp(self): self.setup_beets() self.load_plugins('metasync') self.config['metasync']['source'] = 'itunes' if _is_windows(): self.config['metasync']['itunes']['library'] = \ py3_path(self.itunes_library_windows) else: self.config['metasync']['itunes']['library'] = \ py3_path(self.itunes_library_unix) self._set_up_data() def _set_up_data(self): items = [_common.item() for _ in range(2)] items[0].title = 'Tessellate' items[0].artist = 'alt-J' items[0].albumartist = 'alt-J' items[0].album = 'An Awesome Wave' items[0].itunes_rating = 60 items[1].title = 'Breezeblocks' items[1].artist = 'alt-J' items[1].albumartist = 'alt-J' items[1].album = 'An Awesome Wave' if _is_windows(): items[0].path = \ 'G:\\Music\\Alt-J\\An Awesome Wave\\03 Tessellate.mp3' items[1].path = \ 'G:\\Music\\Alt-J\\An Awesome Wave\\04 Breezeblocks.mp3' else: items[0].path = '/Music/Alt-J/An Awesome Wave/03 Tessellate.mp3' items[1].path = '/Music/Alt-J/An Awesome Wave/04 Breezeblocks.mp3' for item in items: self.lib.add(item) def tearDown(self): self.unload_plugins() self.teardown_beets() def test_load_item_types(self): # This test also verifies that the MetaSources have loaded correctly self.assertIn('amarok_score', Item._types) self.assertIn('itunes_rating', Item._types) def test_pretend_sync_from_itunes(self): out = self.run_with_output('metasync', '-p') self.assertIn('itunes_rating: 60 -> 80', out) self.assertIn('itunes_rating: 100', out) self.assertIn('itunes_playcount: 31', out) self.assertIn('itunes_skipcount: 3', out) self.assertIn('itunes_lastplayed: 2015-05-04 12:20:51', out) self.assertIn('itunes_lastskipped: 2015-02-05 15:41:04', out) self.assertIn('itunes_dateadded: 2014-04-24 09:28:38', out) self.assertEqual(self.lib.items()[0].itunes_rating, 60) def test_sync_from_itunes(self): self.run_command('metasync') self.assertEqual(self.lib.items()[0].itunes_rating, 80) self.assertEqual(self.lib.items()[0].itunes_playcount, 0) self.assertEqual(self.lib.items()[0].itunes_skipcount, 3) self.assertFalse(hasattr(self.lib.items()[0], 'itunes_lastplayed')) self.assertEqual(self.lib.items()[0].itunes_lastskipped, _parsetime('2015-02-05 15:41:04')) self.assertEqual(self.lib.items()[0].itunes_dateadded, _parsetime('2014-04-24 09:28:38')) self.assertEqual(self.lib.items()[1].itunes_rating, 100) self.assertEqual(self.lib.items()[1].itunes_playcount, 31) self.assertEqual(self.lib.items()[1].itunes_skipcount, 0) self.assertEqual(self.lib.items()[1].itunes_lastplayed, _parsetime('2015-05-04 12:20:51')) self.assertEqual(self.lib.items()[1].itunes_dateadded, _parsetime('2014-04-24 09:28:38')) self.assertFalse(hasattr(self.lib.items()[1], 'itunes_lastskipped')) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_mpdstats.py0000644000076500000240000000560200000000000016557 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016 # # 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 unittest from unittest.mock import Mock, patch, call, ANY from test.helper import TestHelper from beets.library import Item from beetsplug.mpdstats import MPDStats from beets import util class MPDStatsTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('mpdstats') def tearDown(self): self.teardown_beets() self.unload_plugins() def test_update_rating(self): item = Item(title='title', path='', id=1) item.add(self.lib) log = Mock() mpdstats = MPDStats(self.lib, log) self.assertFalse(mpdstats.update_rating(item, True)) self.assertFalse(mpdstats.update_rating(None, True)) def test_get_item(self): item_path = util.normpath('/foo/bar.flac') item = Item(title='title', path=item_path, id=1) item.add(self.lib) log = Mock() mpdstats = MPDStats(self.lib, log) self.assertEqual(str(mpdstats.get_item(item_path)), str(item)) self.assertIsNone(mpdstats.get_item('/some/non-existing/path')) self.assertIn('item not found:', log.info.call_args[0][0]) FAKE_UNKNOWN_STATE = 'some-unknown-one' STATUSES = [{'state': FAKE_UNKNOWN_STATE}, {'state': 'pause'}, {'state': 'play', 'songid': 1, 'time': '0:1'}, {'state': 'stop'}] EVENTS = [["player"]] * (len(STATUSES) - 1) + [KeyboardInterrupt] item_path = util.normpath('/foo/bar.flac') songid = 1 @patch("beetsplug.mpdstats.MPDClientWrapper", return_value=Mock(**{ "events.side_effect": EVENTS, "status.side_effect": STATUSES, "currentsong.return_value": (item_path, songid)})) def test_run_mpdstats(self, mpd_mock): item = Item(title='title', path=self.item_path, id=1) item.add(self.lib) log = Mock() try: MPDStats(self.lib, log).run() except KeyboardInterrupt: pass log.debug.assert_has_calls( [call('unhandled status "{0}"', ANY)]) log.info.assert_has_calls( [call('pause'), call('playing {0}', ANY), call('stop')]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_parentwork.py0000644000076500000240000001616200000000000017117 0ustar00asampsonstaff# This file is part of beets. # Copyright 2017, Dorian Soergel # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the 'parentwork' plugin.""" import os import unittest from test.helper import TestHelper from unittest.mock import patch from beets.library import Item from beetsplug import parentwork work = {'work': {'id': '1', 'title': 'work', 'work-relation-list': [{'type': 'parts', 'direction': 'backward', 'work': {'id': '2'}}], 'artist-relation-list': [{'type': 'composer', 'artist': {'name': 'random composer', 'sort-name': 'composer, random'}}]}} dp_work = {'work': {'id': '2', 'title': 'directparentwork', 'work-relation-list': [{'type': 'parts', 'direction': 'backward', 'work': {'id': '3'}}], 'artist-relation-list': [{'type': 'composer', 'artist': {'name': 'random composer', 'sort-name': 'composer, random' }}]}} p_work = {'work': {'id': '3', 'title': 'parentwork', 'artist-relation-list': [{'type': 'composer', 'artist': {'name': 'random composer', 'sort-name': 'composer, random'}}]}} def mock_workid_response(mbid, includes): if mbid == '1': return work elif mbid == '2': return dp_work elif mbid == '3': return p_work class ParentWorkIntegrationTest(unittest.TestCase, TestHelper): def setUp(self): """Set up configuration""" self.setup_beets() self.load_plugins('parentwork') def tearDown(self): self.unload_plugins() self.teardown_beets() # test how it works with real musicbrainz data @unittest.skipUnless( os.environ.get('INTEGRATION_TEST', '0') == '1', 'integration testing not enabled') def test_normal_case_real(self): item = Item(path='/file', mb_workid='e27bda6e-531e-36d3-9cd7-b8ebc18e8c53', parentwork_workid_current='e27bda6e-531e-36d3-9cd7-\ b8ebc18e8c53') item.add(self.lib) self.run_command('parentwork') item.load() self.assertEqual(item['mb_parentworkid'], '32c8943f-1b27-3a23-8660-4567f4847c94') @unittest.skipUnless( os.environ.get('INTEGRATION_TEST', '0') == '1', 'integration testing not enabled') def test_force_real(self): self.config['parentwork']['force'] = True item = Item(path='/file', mb_workid='e27bda6e-531e-36d3-9cd7-b8ebc18e8c53', mb_parentworkid='XXX', parentwork_workid_current='e27bda6e-531e-36d3-9cd7-\ b8ebc18e8c53', parentwork='whatever') item.add(self.lib) self.run_command('parentwork') item.load() self.assertEqual(item['mb_parentworkid'], '32c8943f-1b27-3a23-8660-4567f4847c94') @unittest.skipUnless( os.environ.get('INTEGRATION_TEST', '0') == '1', 'integration testing not enabled') def test_no_force_real(self): self.config['parentwork']['force'] = False item = Item(path='/file', mb_workid='e27bda6e-531e-36d3-9cd7-\ b8ebc18e8c53', mb_parentworkid='XXX', parentwork_workid_current='e27bda6e-531e-36d3-9cd7-\ b8ebc18e8c53', parentwork='whatever') item.add(self.lib) self.run_command('parentwork') item.load() self.assertEqual(item['mb_parentworkid'], 'XXX') # test different cases, still with Matthew Passion Ouverture or Mozart # requiem @unittest.skipUnless( os.environ.get('INTEGRATION_TEST', '0') == '1', 'integration testing not enabled') def test_direct_parent_work_real(self): mb_workid = '2e4a3668-458d-3b2a-8be2-0b08e0d8243a' self.assertEqual('f04b42df-7251-4d86-a5ee-67cfa49580d1', parentwork.direct_parent_id(mb_workid)[0]) self.assertEqual('45afb3b2-18ac-4187-bc72-beb1b1c194ba', parentwork.work_parent_id(mb_workid)[0]) class ParentWorkTest(unittest.TestCase, TestHelper): def setUp(self): """Set up configuration""" self.setup_beets() self.load_plugins('parentwork') self.patcher = patch('musicbrainzngs.get_work_by_id', side_effect=mock_workid_response) self.patcher.start() def tearDown(self): self.unload_plugins() self.teardown_beets() self.patcher.stop() def test_normal_case(self): item = Item(path='/file', mb_workid='1', parentwork_workid_current='1') item.add(self.lib) self.run_command('parentwork') item.load() self.assertEqual(item['mb_parentworkid'], '3') def test_force(self): self.config['parentwork']['force'] = True item = Item(path='/file', mb_workid='1', mb_parentworkid='XXX', parentwork_workid_current='1', parentwork='parentwork') item.add(self.lib) self.run_command('parentwork') item.load() self.assertEqual(item['mb_parentworkid'], '3') def test_no_force(self): self.config['parentwork']['force'] = False item = Item(path='/file', mb_workid='1', mb_parentworkid='XXX', parentwork_workid_current='1', parentwork='parentwork') item.add(self.lib) self.run_command('parentwork') item.load() self.assertEqual(item['mb_parentworkid'], 'XXX') def test_direct_parent_work(self): self.assertEqual('2', parentwork.direct_parent_id('1')[0]) self.assertEqual('3', parentwork.work_parent_id('1')[0]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_permissions.py0000644000076500000240000000647600000000000017305 0ustar00asampsonstaff"""Tests for the 'permissions' plugin. """ import os import platform import unittest from unittest.mock import patch, Mock from test.helper import TestHelper from test._common import touch from beets.util import displayable_path from beetsplug.permissions import (check_permissions, convert_perm, dirs_in_library) class PermissionsPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('permissions') self.config['permissions'] = { 'file': '777', 'dir': '777'} def tearDown(self): self.teardown_beets() self.unload_plugins() def test_permissions_on_album_imported(self): self.do_thing(True) def test_permissions_on_item_imported(self): self.config['import']['singletons'] = True self.do_thing(True) @patch("os.chmod", Mock()) def test_failing_to_set_permissions(self): self.do_thing(False) def do_thing(self, expect_success): if platform.system() == 'Windows': self.skipTest('permissions not available on Windows') def get_stat(v): return os.stat( os.path.join(self.temp_dir, b'import', *v)).st_mode & 0o777 self.importer = self.create_importer() typs = ['file', 'dir'] track_file = (b'album 0', b'track 0.mp3') self.exp_perms = { True: {k: convert_perm(self.config['permissions'][k].get()) for k in typs}, False: {k: get_stat(v) for (k, v) in zip(typs, (track_file, ()))} } self.importer.run() item = self.lib.items().get() self.assertPerms(item.path, 'file', expect_success) for path in dirs_in_library(self.lib.directory, item.path): self.assertPerms(path, 'dir', expect_success) def assertPerms(self, path, typ, expect_success): # noqa for x in [(True, self.exp_perms[expect_success][typ], '!='), (False, self.exp_perms[not expect_success][typ], '==')]: msg = '{} : {} {} {}'.format( displayable_path(path), oct(os.stat(path).st_mode), x[2], oct(x[1]) ) self.assertEqual(x[0], check_permissions(path, x[1]), msg=msg) def test_convert_perm_from_string(self): self.assertEqual(convert_perm('10'), 8) def test_convert_perm_from_int(self): self.assertEqual(convert_perm(10), 8) def test_permissions_on_set_art(self): self.do_set_art(True) @patch("os.chmod", Mock()) def test_failing_permissions_on_set_art(self): self.do_set_art(False) def do_set_art(self, expect_success): if platform.system() == 'Windows': self.skipTest('permissions not available on Windows') self.importer = self.create_importer() self.importer.run() album = self.lib.albums().get() artpath = os.path.join(self.temp_dir, b'cover.jpg') touch(artpath) album.set_art(artpath) self.assertEqual(expect_success, check_permissions(album.artpath, 0o777)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_pipeline.py0000644000076500000240000001516100000000000016526 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test the "pipeline.py" restricted parallel programming library. """ import unittest from beets.util import pipeline # Some simple pipeline stages for testing. def _produce(num=5): yield from range(num) 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 ExceptionFixture(Exception): pass def _exc_work(num=3): i = None while True: i = yield i if i == num: raise ExceptionFixture() 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]) def test_pull(self): pl = pipeline.Pipeline((_produce(), _work())) self.assertEqual(list(pl.pull()), [0, 2, 4, 6, 8]) def test_pull_chain(self): pl = pipeline.Pipeline((_produce(), _work())) pl2 = pipeline.Pipeline((pl.pull(), _work())) self.assertEqual(list(pl2.pull()), [0, 4, 8, 12, 16]) 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), {0, 2, 4, 6, 8}) def test_pull(self): pl = pipeline.Pipeline((_produce(), (_work(), _work()))) self.assertEqual(list(pl.pull()), [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(ExceptionFixture, self.pl.run_sequential) def test_run_parallel(self): self.assertRaises(ExceptionFixture, self.pl.run_parallel) def test_pull(self): pl = pipeline.Pipeline((_produce(), _exc_work())) pull = pl.pull() for i in range(3): next(pull) self.assertRaises(ExceptionFixture, pull.__next__) 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(ExceptionFixture, 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(ExceptionFixture, 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), {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]) def test_pull(self): pl = pipeline.Pipeline((_produce(), _bub_work())) self.assertEqual(list(pl.pull()), [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 test_pull(self): pl = pipeline.Pipeline((_produce(), _multi_work())) self.assertEqual(list(pl.pull()), [0, 0, 1, -1, 2, -2, 3, -3, 4, -4]) class StageDecoratorTest(unittest.TestCase): def test_stage_decorator(self): @pipeline.stage def add(n, i): return i + n pl = pipeline.Pipeline([ iter([1, 2, 3]), add(2) ]) self.assertEqual(list(pl.pull()), [3, 4, 5]) def test_mutator_stage_decorator(self): @pipeline.mutator_stage def setkey(key, item): item[key] = True pl = pipeline.Pipeline([ iter([{'x': False}, {'a': False}]), setkey('x'), ]) self.assertEqual(list(pl.pull()), [{'x': True}, {'a': False, 'x': True}]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_play.py0000644000076500000240000001146500000000000015671 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Jesse Weinstein # # 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 play plugin""" import os import sys import unittest from unittest.mock import patch, ANY from test.helper import TestHelper, control_stdin from beets.ui import UserError from beets.util import open_anything @patch('beetsplug.play.util.interactive_open') class PlayPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('play') self.item = self.add_item(album='a nice älbum', title='aNiceTitle') self.lib.add_album([self.item]) self.config['play']['command'] = 'echo' def tearDown(self): self.teardown_beets() self.unload_plugins() def run_and_assert(self, open_mock, args=('title:aNiceTitle',), expected_cmd='echo', expected_playlist=None): self.run_command('play', *args) open_mock.assert_called_once_with(ANY, expected_cmd) expected_playlist = expected_playlist or self.item.path.decode('utf-8') exp_playlist = expected_playlist + '\n' with open(open_mock.call_args[0][0][0], 'rb') as playlist: self.assertEqual(exp_playlist, playlist.read().decode('utf-8')) def test_basic(self, open_mock): self.run_and_assert(open_mock) def test_album_option(self, open_mock): self.run_and_assert(open_mock, ['-a', 'nice']) def test_args_option(self, open_mock): self.run_and_assert( open_mock, ['-A', 'foo', 'title:aNiceTitle'], 'echo foo') def test_args_option_in_middle(self, open_mock): self.config['play']['command'] = 'echo $args other' self.run_and_assert( open_mock, ['-A', 'foo', 'title:aNiceTitle'], 'echo foo other') def test_unset_args_option_in_middle(self, open_mock): self.config['play']['command'] = 'echo $args other' self.run_and_assert( open_mock, ['title:aNiceTitle'], 'echo other') @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_relative_to(self, open_mock): self.config['play']['command'] = 'echo' self.config['play']['relative_to'] = '/something' path = os.path.relpath(self.item.path, b'/something') playlist = path.decode('utf-8') self.run_and_assert( open_mock, expected_cmd='echo', expected_playlist=playlist) def test_use_folders(self, open_mock): self.config['play']['command'] = None self.config['play']['use_folders'] = True self.run_command('play', '-a', 'nice') open_mock.assert_called_once_with(ANY, open_anything()) with open(open_mock.call_args[0][0][0], 'rb') as f: playlist = f.read().decode('utf-8') self.assertEqual('{}\n'.format( os.path.dirname(self.item.path.decode('utf-8'))), playlist) def test_raw(self, open_mock): self.config['play']['raw'] = True self.run_command('play', 'nice') open_mock.assert_called_once_with([self.item.path], 'echo') def test_not_found(self, open_mock): self.run_command('play', 'not found') open_mock.assert_not_called() def test_warning_threshold(self, open_mock): self.config['play']['warning_threshold'] = 1 self.add_item(title='another NiceTitle') with control_stdin("a"): self.run_command('play', 'nice') open_mock.assert_not_called() def test_skip_warning_threshold_bypass(self, open_mock): self.config['play']['warning_threshold'] = 1 self.other_item = self.add_item(title='another NiceTitle') expected_playlist = '{}\n{}'.format( self.item.path.decode('utf-8'), self.other_item.path.decode('utf-8')) with control_stdin("a"): self.run_and_assert( open_mock, ['-y', 'NiceTitle'], expected_playlist=expected_playlist) def test_command_failed(self, open_mock): open_mock.side_effect = OSError("some reason") with self.assertRaises(UserError): self.run_command('play', 'title:aNiceTitle') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_player.py0000644000076500000240000011476200000000000016224 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for BPD's implementation of the MPD protocol. """ import unittest from test.helper import TestHelper import os import sys import multiprocessing as mp import threading import socket import time import yaml import tempfile from contextlib import contextmanager from beets.util import py3_path, bluelet from beetsplug import bpd import confuse # Mock GstPlayer so that the forked process doesn't attempt to import gi: from unittest import mock import imp gstplayer = imp.new_module("beetsplug.bpd.gstplayer") def _gstplayer_play(*_): # noqa: 42 bpd.gstplayer._GstPlayer.playing = True return mock.DEFAULT gstplayer._GstPlayer = mock.MagicMock( spec_set=[ "time", "volume", "playing", "run", "play_file", "pause", "stop", "seek", "play", "get_decoders", ], **{ 'playing': False, 'volume': 0, 'time.return_value': (0, 0), 'play_file.side_effect': _gstplayer_play, 'play.side_effect': _gstplayer_play, 'get_decoders.return_value': {'default': ({'audio/mpeg'}, {'mp3'})}, }) gstplayer.GstPlayer = lambda _: gstplayer._GstPlayer sys.modules["beetsplug.bpd.gstplayer"] = gstplayer bpd.gstplayer = gstplayer class CommandParseTest(unittest.TestCase): def test_no_args(self): s = r'command' c = bpd.Command(s) self.assertEqual(c.name, 'command') self.assertEqual(c.args, []) def test_one_unquoted_arg(self): s = r'command hello' c = bpd.Command(s) self.assertEqual(c.name, 'command') self.assertEqual(c.args, ['hello']) def test_two_unquoted_args(self): s = r'command hello there' c = bpd.Command(s) self.assertEqual(c.name, 'command') self.assertEqual(c.args, ['hello', 'there']) def test_one_quoted_arg(self): s = r'command "hello there"' c = bpd.Command(s) self.assertEqual(c.name, 'command') self.assertEqual(c.args, ['hello there']) def test_heterogenous_args(self): s = r'command "hello there" sir' c = bpd.Command(s) self.assertEqual(c.name, 'command') self.assertEqual(c.args, ['hello there', 'sir']) def test_quote_in_arg(self): s = r'command "hello \" there"' c = bpd.Command(s) self.assertEqual(c.args, ['hello " there']) def test_backslash_in_arg(self): s = r'command "hello \\ there"' c = bpd.Command(s) self.assertEqual(c.args, ['hello \\ there']) class MPCResponse: def __init__(self, raw_response): body = b'\n'.join(raw_response.split(b'\n')[:-2]).decode('utf-8') self.data = self._parse_body(body) status = raw_response.split(b'\n')[-2].decode('utf-8') self.ok, self.err_data = self._parse_status(status) def _parse_status(self, status): """ Parses the first response line, which contains the status. """ if status.startswith('OK') or status.startswith('list_OK'): return True, None elif status.startswith('ACK'): code, rest = status[5:].split('@', 1) pos, rest = rest.split(']', 1) cmd, rest = rest[2:].split('}') return False, (int(code), int(pos), cmd, rest[1:]) else: raise RuntimeError(f'Unexpected status: {status!r}') def _parse_body(self, body): """ Messages are generally in the format "header: content". Convert them into a dict, storing the values for repeated headers as lists of strings, and non-repeated ones as string. """ data = {} repeated_headers = set() for line in body.split('\n'): if not line: continue if ':' not in line: raise RuntimeError(f'Unexpected line: {line!r}') header, content = line.split(':', 1) content = content.lstrip() if header in repeated_headers: data[header].append(content) elif header in data: data[header] = [data[header], content] repeated_headers.add(header) else: data[header] = content return data class MPCClient: def __init__(self, sock, do_hello=True): self.sock = sock self.buf = b'' if do_hello: hello = self.get_response() if not hello.ok: raise RuntimeError('Bad hello') def get_response(self, force_multi=None): """ Wait for a full server response and wrap it in a helper class. If the request was a batch request then this will return a list of `MPCResponse`s, one for each processed subcommand. """ response = b'' responses = [] while True: line = self.readline() response += line if line.startswith(b'OK') or line.startswith(b'ACK'): if force_multi or any(responses): if line.startswith(b'ACK'): responses.append(MPCResponse(response)) n_remaining = force_multi - len(responses) responses.extend([None] * n_remaining) return responses else: return MPCResponse(response) if line.startswith(b'list_OK'): responses.append(MPCResponse(response)) response = b'' elif not line: raise RuntimeError(f'Unexpected response: {line!r}') def serialise_command(self, command, *args): cmd = [command.encode('utf-8')] for arg in [a.encode('utf-8') for a in args]: if b' ' in arg: cmd.append(b'"' + arg + b'"') else: cmd.append(arg) return b' '.join(cmd) + b'\n' def send_command(self, command, *args): request = self.serialise_command(command, *args) self.sock.sendall(request) return self.get_response() def send_commands(self, *commands): """ Use MPD command batching to send multiple commands at once. Each item of commands is a tuple containing a command followed by any arguments. """ requests = [] for command_and_args in commands: command = command_and_args[0] args = command_and_args[1:] requests.append(self.serialise_command(command, *args)) requests.insert(0, b'command_list_ok_begin\n') requests.append(b'command_list_end\n') request = b''.join(requests) self.sock.sendall(request) return self.get_response(force_multi=len(commands)) def readline(self, terminator=b'\n', bufsize=1024): """ Reads a line of data from the socket. """ while True: if terminator in self.buf: line, self.buf = self.buf.split(terminator, 1) line += terminator return line self.sock.settimeout(1) data = self.sock.recv(bufsize) if data: self.buf += data else: line = self.buf self.buf = b'' return line def implements(commands, expectedFailure=False): # noqa: N803 def _test(self): with self.run_bpd() as client: response = client.send_command('commands') self._assert_ok(response) implemented = response.data['command'] self.assertEqual(commands.intersection(implemented), commands) return unittest.expectedFailure(_test) if expectedFailure else _test bluelet_listener = bluelet.Listener @mock.patch("beets.util.bluelet.Listener") def start_server(args, assigned_port, listener_patch): """Start the bpd server, writing the port to `assigned_port`. """ def listener_wrap(host, port): """Wrap `bluelet.Listener`, writing the port to `assigend_port`. """ # `bluelet.Listener` has previously been saved to # `bluelet_listener` as this function will replace it at its # original location. listener = bluelet_listener(host, port) # read port assigned by OS assigned_port.put_nowait(listener.sock.getsockname()[1]) return listener listener_patch.side_effect = listener_wrap import beets.ui beets.ui.main(args) class BPDTestHelper(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets(disk=True) self.load_plugins('bpd') self.item1 = self.add_item( title='Track One Title', track=1, album='Album Title', artist='Artist Name') self.item2 = self.add_item( title='Track Two Title', track=2, album='Album Title', artist='Artist Name') self.lib.add_album([self.item1, self.item2]) def tearDown(self): self.teardown_beets() self.unload_plugins() @contextmanager def run_bpd(self, host='localhost', password=None, do_hello=True, second_client=False): """ Runs BPD in another process, configured with the same library database as we created in the setUp method. Exposes a client that is connected to the server, and kills the server at the end. """ # Create a config file: config = { 'pluginpath': [py3_path(self.temp_dir)], 'plugins': 'bpd', # use port 0 to let the OS choose a free port 'bpd': {'host': host, 'port': 0, 'control_port': 0}, } if password: config['bpd']['password'] = password config_file = tempfile.NamedTemporaryFile( mode='wb', dir=py3_path(self.temp_dir), suffix='.yaml', delete=False) config_file.write( yaml.dump(config, Dumper=confuse.Dumper, encoding='utf-8')) config_file.close() # Fork and launch BPD in the new process: assigned_port = mp.Queue(2) # 2 slots, `control_port` and `port` server = mp.Process(target=start_server, args=([ '--library', self.config['library'].as_filename(), '--directory', py3_path(self.libdir), '--config', py3_path(config_file.name), 'bpd' ], assigned_port)) server.start() try: assigned_port.get(timeout=1) # skip control_port port = assigned_port.get(timeout=0.5) # read port sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: sock.connect((host, port)) if second_client: sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: sock2.connect((host, port)) yield ( MPCClient(sock, do_hello), MPCClient(sock2, do_hello), ) finally: sock2.close() else: yield MPCClient(sock, do_hello) finally: sock.close() finally: server.terminate() server.join(timeout=0.2) def _assert_ok(self, *responses): for response in responses: self.assertTrue(response is not None) self.assertTrue(response.ok, 'Response failed: {}'.format( response.err_data)) def _assert_failed(self, response, code, pos=None): """ Check that a command failed with a specific error code. If this is a list of responses, first check all preceding commands were OK. """ if pos is not None: previous_commands = response[0:pos] self._assert_ok(*previous_commands) response = response[pos] self.assertFalse(response.ok) if pos is not None: self.assertEqual(pos, response.err_data[1]) if code is not None: self.assertEqual(code, response.err_data[0]) def _bpd_add(self, client, *items, **kwargs): """ Add the given item to the BPD playlist or queue. """ paths = ['/'.join([ item.artist, item.album, py3_path(os.path.basename(item.path))]) for item in items] playlist = kwargs.get('playlist') if playlist: commands = [('playlistadd', playlist, path) for path in paths] else: commands = [('add', path) for path in paths] responses = client.send_commands(*commands) self._assert_ok(*responses) class BPDTest(BPDTestHelper): def test_server_hello(self): with self.run_bpd(do_hello=False) as client: self.assertEqual(client.readline(), b'OK MPD 0.16.0\n') def test_unknown_cmd(self): with self.run_bpd() as client: response = client.send_command('notacommand') self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_unexpected_argument(self): with self.run_bpd() as client: response = client.send_command('ping', 'extra argument') self._assert_failed(response, bpd.ERROR_ARG) def test_missing_argument(self): with self.run_bpd() as client: response = client.send_command('add') self._assert_failed(response, bpd.ERROR_ARG) def test_system_error(self): with self.run_bpd() as client: response = client.send_command('crash_TypeError') self._assert_failed(response, bpd.ERROR_SYSTEM) def test_empty_request(self): with self.run_bpd() as client: response = client.send_command('') self._assert_failed(response, bpd.ERROR_UNKNOWN) class BPDQueryTest(BPDTestHelper): test_implements_query = implements({ 'clearerror', }) def test_cmd_currentsong(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) responses = client.send_commands( ('play',), ('currentsong',), ('stop',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('1', responses[1].data['Id']) self.assertNotIn('Id', responses[3].data) def test_cmd_currentsong_tagtypes(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) responses = client.send_commands( ('play',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual( BPDConnectionTest.TAGTYPES.union(BPDQueueTest.METADATA), set(responses[1].data.keys())) def test_cmd_status(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('status',), ('play',), ('status',)) self._assert_ok(*responses) fields_not_playing = { 'repeat', 'random', 'single', 'consume', 'playlist', 'playlistlength', 'mixrampdb', 'state', 'volume' } self.assertEqual(fields_not_playing, set(responses[0].data.keys())) fields_playing = fields_not_playing | { 'song', 'songid', 'time', 'elapsed', 'bitrate', 'duration', 'audio', 'nextsong', 'nextsongid' } self.assertEqual(fields_playing, set(responses[2].data.keys())) def test_cmd_stats(self): with self.run_bpd() as client: response = client.send_command('stats') self._assert_ok(response) details = {'artists', 'albums', 'songs', 'uptime', 'db_playtime', 'db_update', 'playtime'} self.assertEqual(details, set(response.data.keys())) def test_cmd_idle(self): def _toggle(c): for _ in range(3): rs = c.send_commands(('play',), ('pause',)) # time.sleep(0.05) # uncomment if test is flaky if any(not r.ok for r in rs): raise RuntimeError('Toggler failed') with self.run_bpd(second_client=True) as (client, client2): self._bpd_add(client, self.item1, self.item2) toggler = threading.Thread(target=_toggle, args=(client2,)) toggler.start() # Idling will hang until the toggler thread changes the play state. # Since the client sockets have a 1s timeout set at worst this will # raise a socket.timeout and fail the test if the toggler thread # manages to finish before the idle command is sent here. response = client.send_command('idle', 'player') toggler.join() self._assert_ok(response) def test_cmd_idle_with_pending(self): with self.run_bpd(second_client=True) as (client, client2): response1 = client.send_command('random', '1') response2 = client2.send_command('idle') self._assert_ok(response1, response2) self.assertEqual('options', response2.data['changed']) def test_cmd_noidle(self): with self.run_bpd() as client: # Manually send a command without reading a response. request = client.serialise_command('idle') client.sock.sendall(request) time.sleep(0.01) response = client.send_command('noidle') self._assert_ok(response) def test_cmd_noidle_when_not_idle(self): with self.run_bpd() as client: # Manually send a command without reading a response. request = client.serialise_command('noidle') client.sock.sendall(request) response = client.send_command('notacommand') self._assert_failed(response, bpd.ERROR_UNKNOWN) class BPDPlaybackTest(BPDTestHelper): test_implements_playback = implements({ 'random', }) def test_cmd_consume(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('consume', '0'), ('playlistinfo',), ('next',), ('playlistinfo',), ('consume', '1'), ('playlistinfo',), ('play', '0'), ('next',), ('playlistinfo',), ('status',)) self._assert_ok(*responses) self.assertEqual(responses[1].data['Id'], responses[3].data['Id']) self.assertEqual(['1', '2'], responses[5].data['Id']) self.assertEqual('2', responses[8].data['Id']) self.assertEqual('1', responses[9].data['consume']) self.assertEqual('play', responses[9].data['state']) def test_cmd_consume_in_reverse(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('consume', '1'), ('play', '1'), ('playlistinfo',), ('previous',), ('playlistinfo',), ('status',)) self._assert_ok(*responses) self.assertEqual(['1', '2'], responses[2].data['Id']) self.assertEqual('1', responses[4].data['Id']) self.assertEqual('play', responses[5].data['state']) def test_cmd_single(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('status',), ('single', '1'), ('play',), ('status',), ('next',), ('status',)) self._assert_ok(*responses) self.assertEqual('0', responses[0].data['single']) self.assertEqual('1', responses[3].data['single']) self.assertEqual('play', responses[3].data['state']) self.assertEqual('stop', responses[5].data['state']) def test_cmd_repeat(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('repeat', '1'), ('play',), ('currentsong',), ('next',), ('currentsong',), ('next',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('1', responses[2].data['Id']) self.assertEqual('2', responses[4].data['Id']) self.assertEqual('1', responses[6].data['Id']) def test_cmd_repeat_with_single(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('repeat', '1'), ('single', '1'), ('play',), ('currentsong',), ('next',), ('status',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('1', responses[3].data['Id']) self.assertEqual('play', responses[5].data['state']) self.assertEqual('1', responses[6].data['Id']) def test_cmd_repeat_in_reverse(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('repeat', '1'), ('play',), ('currentsong',), ('previous',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('1', responses[2].data['Id']) self.assertEqual('2', responses[4].data['Id']) def test_cmd_repeat_with_single_in_reverse(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('repeat', '1'), ('single', '1'), ('play',), ('currentsong',), ('previous',), ('status',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('1', responses[3].data['Id']) self.assertEqual('play', responses[5].data['state']) self.assertEqual('1', responses[6].data['Id']) def test_cmd_crossfade(self): with self.run_bpd() as client: responses = client.send_commands( ('status',), ('crossfade', '123'), ('status',), ('crossfade', '-2')) response = client.send_command('crossfade', '0.5') self._assert_failed(responses, bpd.ERROR_ARG, pos=3) self._assert_failed(response, bpd.ERROR_ARG) self.assertNotIn('xfade', responses[0].data) self.assertAlmostEqual(123, int(responses[2].data['xfade'])) def test_cmd_mixrampdb(self): with self.run_bpd() as client: responses = client.send_commands( ('mixrampdb', '-17'), ('status',)) self._assert_ok(*responses) self.assertAlmostEqual(-17, float(responses[1].data['mixrampdb'])) def test_cmd_mixrampdelay(self): with self.run_bpd() as client: responses = client.send_commands( ('mixrampdelay', '2'), ('status',), ('mixrampdelay', 'nan'), ('status',), ('mixrampdelay', '-2')) self._assert_failed(responses, bpd.ERROR_ARG, pos=4) self.assertAlmostEqual(2, float(responses[1].data['mixrampdelay'])) self.assertNotIn('mixrampdelay', responses[3].data) def test_cmd_setvol(self): with self.run_bpd() as client: responses = client.send_commands( ('setvol', '67'), ('status',), ('setvol', '32'), ('status',), ('setvol', '101')) self._assert_failed(responses, bpd.ERROR_ARG, pos=4) self.assertEqual('67', responses[1].data['volume']) self.assertEqual('32', responses[3].data['volume']) def test_cmd_volume(self): with self.run_bpd() as client: responses = client.send_commands( ('setvol', '10'), ('volume', '5'), ('volume', '-2'), ('status',)) self._assert_ok(*responses) self.assertEqual('13', responses[3].data['volume']) def test_cmd_replay_gain(self): with self.run_bpd() as client: responses = client.send_commands( ('replay_gain_mode', 'track'), ('replay_gain_status',), ('replay_gain_mode', 'notanoption')) self._assert_failed(responses, bpd.ERROR_ARG, pos=2) self.assertAlmostEqual('track', responses[1].data['replay_gain_mode']) class BPDControlTest(BPDTestHelper): test_implements_control = implements({ 'seek', 'seekid', 'seekcur', }, expectedFailure=True) def test_cmd_play(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('status',), ('play',), ('status',), ('play', '1'), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('stop', responses[0].data['state']) self.assertEqual('play', responses[2].data['state']) self.assertEqual('2', responses[4].data['Id']) def test_cmd_playid(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('playid', '2'), ('currentsong',), ('clear',)) self._bpd_add(client, self.item2, self.item1) responses.extend(client.send_commands( ('playid', '2'), ('currentsong',))) self._assert_ok(*responses) self.assertEqual('2', responses[1].data['Id']) self.assertEqual('2', responses[4].data['Id']) def test_cmd_pause(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) responses = client.send_commands( ('play',), ('pause',), ('status',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('pause', responses[2].data['state']) self.assertEqual('1', responses[3].data['Id']) def test_cmd_stop(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) responses = client.send_commands( ('play',), ('stop',), ('status',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('stop', responses[2].data['state']) self.assertNotIn('Id', responses[3].data) def test_cmd_next(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('play',), ('currentsong',), ('next',), ('currentsong',), ('next',), ('status',)) self._assert_ok(*responses) self.assertEqual('1', responses[1].data['Id']) self.assertEqual('2', responses[3].data['Id']) self.assertEqual('stop', responses[5].data['state']) def test_cmd_previous(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('play', '1'), ('currentsong',), ('previous',), ('currentsong',), ('previous',), ('status',), ('currentsong',)) self._assert_ok(*responses) self.assertEqual('2', responses[1].data['Id']) self.assertEqual('1', responses[3].data['Id']) self.assertEqual('play', responses[5].data['state']) self.assertEqual('1', responses[6].data['Id']) class BPDQueueTest(BPDTestHelper): test_implements_queue = implements({ 'addid', 'clear', 'delete', 'deleteid', 'move', 'moveid', 'playlist', 'playlistfind', 'playlistsearch', 'plchanges', 'plchangesposid', 'prio', 'prioid', 'rangeid', 'shuffle', 'swap', 'swapid', 'addtagid', 'cleartagid', }, expectedFailure=True) METADATA = {'Pos', 'Time', 'Id', 'file', 'duration'} def test_cmd_add(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) def test_cmd_playlistinfo(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('playlistinfo',), ('playlistinfo', '0'), ('playlistinfo', '0:2'), ('playlistinfo', '200')) self._assert_failed(responses, bpd.ERROR_ARG, pos=3) self.assertEqual('1', responses[1].data['Id']) self.assertEqual(['1', '2'], responses[2].data['Id']) def test_cmd_playlistinfo_tagtypes(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) response = client.send_command('playlistinfo', '0') self._assert_ok(response) self.assertEqual( BPDConnectionTest.TAGTYPES.union(BPDQueueTest.METADATA), set(response.data.keys())) def test_cmd_playlistid(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( ('playlistid', '2'), ('playlistid',)) self._assert_ok(*responses) self.assertEqual('Track Two Title', responses[0].data['Title']) self.assertEqual(['1', '2'], responses[1].data['Track']) class BPDPlaylistsTest(BPDTestHelper): test_implements_playlists = implements({'playlistadd'}) def test_cmd_listplaylist(self): with self.run_bpd() as client: response = client.send_command('listplaylist', 'anything') self._assert_failed(response, bpd.ERROR_NO_EXIST) def test_cmd_listplaylistinfo(self): with self.run_bpd() as client: response = client.send_command('listplaylistinfo', 'anything') self._assert_failed(response, bpd.ERROR_NO_EXIST) def test_cmd_listplaylists(self): with self.run_bpd() as client: response = client.send_command('listplaylists') self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_load(self): with self.run_bpd() as client: response = client.send_command('load', 'anything') self._assert_failed(response, bpd.ERROR_NO_EXIST) @unittest.skip def test_cmd_playlistadd(self): with self.run_bpd() as client: self._bpd_add(client, self.item1, playlist='anything') def test_cmd_playlistclear(self): with self.run_bpd() as client: response = client.send_command('playlistclear', 'anything') self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_playlistdelete(self): with self.run_bpd() as client: response = client.send_command('playlistdelete', 'anything', '0') self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_playlistmove(self): with self.run_bpd() as client: response = client.send_command( 'playlistmove', 'anything', '0', '1') self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_rename(self): with self.run_bpd() as client: response = client.send_command('rename', 'anything', 'newname') self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_rm(self): with self.run_bpd() as client: response = client.send_command('rm', 'anything') self._assert_failed(response, bpd.ERROR_UNKNOWN) def test_cmd_save(self): with self.run_bpd() as client: self._bpd_add(client, self.item1) response = client.send_command('save', 'newplaylist') self._assert_failed(response, bpd.ERROR_UNKNOWN) class BPDDatabaseTest(BPDTestHelper): test_implements_database = implements({ 'albumart', 'find', 'findadd', 'listall', 'listallinfo', 'listfiles', 'readcomments', 'searchadd', 'searchaddpl', 'update', 'rescan', }, expectedFailure=True) def test_cmd_search(self): with self.run_bpd() as client: response = client.send_command('search', 'track', '1') self._assert_ok(response) self.assertEqual(self.item1.title, response.data['Title']) def test_cmd_list(self): with self.run_bpd() as client: responses = client.send_commands( ('list', 'album'), ('list', 'track'), ('list', 'album', 'artist', 'Artist Name', 'track')) self._assert_failed(responses, bpd.ERROR_ARG, pos=2) self.assertEqual('Album Title', responses[0].data['Album']) self.assertEqual(['1', '2'], responses[1].data['Track']) def test_cmd_list_three_arg_form(self): with self.run_bpd() as client: responses = client.send_commands( ('list', 'album', 'artist', 'Artist Name'), ('list', 'album', 'Artist Name'), ('list', 'track', 'Artist Name')) self._assert_failed(responses, bpd.ERROR_ARG, pos=2) self.assertEqual(responses[0].data, responses[1].data) def test_cmd_lsinfo(self): with self.run_bpd() as client: response1 = client.send_command('lsinfo') self._assert_ok(response1) response2 = client.send_command( 'lsinfo', response1.data['directory']) self._assert_ok(response2) response3 = client.send_command( 'lsinfo', response2.data['directory']) self._assert_ok(response3) self.assertIn(self.item1.title, response3.data['Title']) def test_cmd_count(self): with self.run_bpd() as client: response = client.send_command('count', 'track', '1') self._assert_ok(response) self.assertEqual('1', response.data['songs']) self.assertEqual('0', response.data['playtime']) class BPDMountsTest(BPDTestHelper): test_implements_mounts = implements({ 'mount', 'unmount', 'listmounts', 'listneighbors', }, expectedFailure=True) class BPDStickerTest(BPDTestHelper): test_implements_stickers = implements({ 'sticker', }, expectedFailure=True) class BPDConnectionTest(BPDTestHelper): test_implements_connection = implements({ 'close', 'kill', }) ALL_MPD_TAGTYPES = { 'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist', 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre', 'Date', 'Composer', 'Performer', 'Comment', 'Disc', 'Label', 'OriginalDate', 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID', 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID', 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID', } UNSUPPORTED_TAGTYPES = { 'MUSICBRAINZ_WORKID', # not tracked by beets 'Performer', # not tracked by beets 'AlbumSort', # not tracked by beets 'Name', # junk field for internet radio } TAGTYPES = ALL_MPD_TAGTYPES.difference(UNSUPPORTED_TAGTYPES) def test_cmd_password(self): with self.run_bpd(password='abc123') as client: response = client.send_command('status') self._assert_failed(response, bpd.ERROR_PERMISSION) response = client.send_command('password', 'wrong') self._assert_failed(response, bpd.ERROR_PASSWORD) responses = client.send_commands( ('password', 'abc123'), ('status',)) self._assert_ok(*responses) def test_cmd_ping(self): with self.run_bpd() as client: response = client.send_command('ping') self._assert_ok(response) def test_cmd_tagtypes(self): with self.run_bpd() as client: response = client.send_command('tagtypes') self._assert_ok(response) self.assertEqual( self.TAGTYPES, set(response.data['tagtype'])) @unittest.skip def test_tagtypes_mask(self): with self.run_bpd() as client: response = client.send_command('tagtypes', 'clear') self._assert_ok(response) class BPDPartitionTest(BPDTestHelper): test_implements_partitions = implements({ 'partition', 'listpartitions', 'newpartition', }, expectedFailure=True) class BPDDeviceTest(BPDTestHelper): test_implements_devices = implements({ 'disableoutput', 'enableoutput', 'toggleoutput', 'outputs', }, expectedFailure=True) class BPDReflectionTest(BPDTestHelper): test_implements_reflection = implements({ 'config', 'commands', 'notcommands', 'urlhandlers', }, expectedFailure=True) def test_cmd_decoders(self): with self.run_bpd() as client: response = client.send_command('decoders') self._assert_ok(response) self.assertEqual('default', response.data['plugin']) self.assertEqual('mp3', response.data['suffix']) self.assertEqual('audio/mpeg', response.data['mime_type']) class BPDPeersTest(BPDTestHelper): test_implements_peers = implements({ 'subscribe', 'unsubscribe', 'channels', 'readmessages', 'sendmessage', }, expectedFailure=True) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_playlist.py0000644000076500000240000002556600000000000016574 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os import shutil import tempfile import unittest from shlex import quote from test import _common from test import helper import beets class PlaylistTestHelper(helper.TestHelper): def setUp(self): self.setup_beets() self.lib = beets.library.Library(':memory:') self.music_dir = os.path.expanduser(os.path.join('~', 'Music')) i1 = _common.item() i1.path = beets.util.normpath(os.path.join( self.music_dir, 'a', 'b', 'c.mp3', )) i1.title = 'some item' i1.album = 'some album' self.lib.add(i1) self.lib.add_album([i1]) i2 = _common.item() i2.path = beets.util.normpath(os.path.join( self.music_dir, 'd', 'e', 'f.mp3', )) i2.title = 'another item' i2.album = 'another album' self.lib.add(i2) self.lib.add_album([i2]) i3 = _common.item() i3.path = beets.util.normpath(os.path.join( self.music_dir, 'x', 'y', 'z.mp3', )) i3.title = 'yet another item' i3.album = 'yet another album' self.lib.add(i3) self.lib.add_album([i3]) self.playlist_dir = tempfile.mkdtemp() self.config['directory'] = self.music_dir self.config['playlist']['playlist_dir'] = self.playlist_dir self.setup_test() self.load_plugins('playlist') def setup_test(self): raise NotImplementedError def tearDown(self): self.unload_plugins() shutil.rmtree(self.playlist_dir) self.teardown_beets() class PlaylistQueryTestHelper(PlaylistTestHelper): def test_name_query_with_absolute_paths_in_playlist(self): q = 'playlist:absolute' results = self.lib.items(q) self.assertEqual({i.title for i in results}, { 'some item', 'another item', }) def test_path_query_with_absolute_paths_in_playlist(self): q = 'playlist:{}'.format(quote(os.path.join( self.playlist_dir, 'absolute.m3u', ))) results = self.lib.items(q) self.assertEqual({i.title for i in results}, { 'some item', 'another item', }) def test_name_query_with_relative_paths_in_playlist(self): q = 'playlist:relative' results = self.lib.items(q) self.assertEqual({i.title for i in results}, { 'some item', 'another item', }) def test_path_query_with_relative_paths_in_playlist(self): q = 'playlist:{}'.format(quote(os.path.join( self.playlist_dir, 'relative.m3u', ))) results = self.lib.items(q) self.assertEqual({i.title for i in results}, { 'some item', 'another item', }) def test_name_query_with_nonexisting_playlist(self): q = 'playlist:nonexisting' results = self.lib.items(q) self.assertEqual(set(results), set()) def test_path_query_with_nonexisting_playlist(self): q = 'playlist:{}'.format(quote(os.path.join( self.playlist_dir, self.playlist_dir, 'nonexisting.m3u', ))) results = self.lib.items(q) self.assertEqual(set(results), set()) class PlaylistTestRelativeToLib(PlaylistQueryTestHelper, unittest.TestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{}\n'.format(os.path.join( self.music_dir, 'a', 'b', 'c.mp3'))) f.write('{}\n'.format(os.path.join( self.music_dir, 'd', 'e', 'f.mp3'))) f.write('{}\n'.format(os.path.join( self.music_dir, 'nonexisting.mp3'))) with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: f.write('{}\n'.format(os.path.join('a', 'b', 'c.mp3'))) f.write('{}\n'.format(os.path.join('d', 'e', 'f.mp3'))) f.write('{}\n'.format('nonexisting.mp3')) self.config['playlist']['relative_to'] = 'library' class PlaylistTestRelativeToDir(PlaylistQueryTestHelper, unittest.TestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{}\n'.format(os.path.join( self.music_dir, 'a', 'b', 'c.mp3'))) f.write('{}\n'.format(os.path.join( self.music_dir, 'd', 'e', 'f.mp3'))) f.write('{}\n'.format(os.path.join( self.music_dir, 'nonexisting.mp3'))) with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: f.write('{}\n'.format(os.path.join('a', 'b', 'c.mp3'))) f.write('{}\n'.format(os.path.join('d', 'e', 'f.mp3'))) f.write('{}\n'.format('nonexisting.mp3')) self.config['playlist']['relative_to'] = self.music_dir class PlaylistTestRelativeToPls(PlaylistQueryTestHelper, unittest.TestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{}\n'.format(os.path.join( self.music_dir, 'a', 'b', 'c.mp3'))) f.write('{}\n'.format(os.path.join( self.music_dir, 'd', 'e', 'f.mp3'))) f.write('{}\n'.format(os.path.join( self.music_dir, 'nonexisting.mp3'))) with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: f.write('{}\n'.format(os.path.relpath( os.path.join(self.music_dir, 'a', 'b', 'c.mp3'), start=self.playlist_dir, ))) f.write('{}\n'.format(os.path.relpath( os.path.join(self.music_dir, 'd', 'e', 'f.mp3'), start=self.playlist_dir, ))) f.write('{}\n'.format(os.path.relpath( os.path.join(self.music_dir, 'nonexisting.mp3'), start=self.playlist_dir, ))) self.config['playlist']['relative_to'] = 'playlist' self.config['playlist']['playlist_dir'] = self.playlist_dir class PlaylistUpdateTestHelper(PlaylistTestHelper): def setup_test(self): with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{}\n'.format(os.path.join( self.music_dir, 'a', 'b', 'c.mp3'))) f.write('{}\n'.format(os.path.join( self.music_dir, 'd', 'e', 'f.mp3'))) f.write('{}\n'.format(os.path.join( self.music_dir, 'nonexisting.mp3'))) with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: f.write('{}\n'.format(os.path.join('a', 'b', 'c.mp3'))) f.write('{}\n'.format(os.path.join('d', 'e', 'f.mp3'))) f.write('{}\n'.format('nonexisting.mp3')) self.config['playlist']['auto'] = True self.config['playlist']['relative_to'] = 'library' class PlaylistTestItemMoved(PlaylistUpdateTestHelper, unittest.TestCase): def test_item_moved(self): # Emit item_moved event for an item that is in a playlist results = self.lib.items('path:{}'.format(quote( os.path.join(self.music_dir, 'd', 'e', 'f.mp3')))) item = results[0] beets.plugins.send( 'item_moved', item=item, source=item.path, destination=beets.util.bytestring_path( os.path.join(self.music_dir, 'g', 'h', 'i.mp3'))) # Emit item_moved event for an item that is not in a playlist results = self.lib.items('path:{}'.format(quote( os.path.join(self.music_dir, 'x', 'y', 'z.mp3')))) item = results[0] beets.plugins.send( 'item_moved', item=item, source=item.path, destination=beets.util.bytestring_path( os.path.join(self.music_dir, 'u', 'v', 'w.mp3'))) # Emit cli_exit event beets.plugins.send('cli_exit', lib=self.lib) # Check playlist with absolute paths playlist_path = os.path.join(self.playlist_dir, 'absolute.m3u') with open(playlist_path) as f: lines = [line.strip() for line in f.readlines()] self.assertEqual(lines, [ os.path.join(self.music_dir, 'a', 'b', 'c.mp3'), os.path.join(self.music_dir, 'g', 'h', 'i.mp3'), os.path.join(self.music_dir, 'nonexisting.mp3'), ]) # Check playlist with relative paths playlist_path = os.path.join(self.playlist_dir, 'relative.m3u') with open(playlist_path) as f: lines = [line.strip() for line in f.readlines()] self.assertEqual(lines, [ os.path.join('a', 'b', 'c.mp3'), os.path.join('g', 'h', 'i.mp3'), 'nonexisting.mp3', ]) class PlaylistTestItemRemoved(PlaylistUpdateTestHelper, unittest.TestCase): def test_item_removed(self): # Emit item_removed event for an item that is in a playlist results = self.lib.items('path:{}'.format(quote( os.path.join(self.music_dir, 'd', 'e', 'f.mp3')))) item = results[0] beets.plugins.send('item_removed', item=item) # Emit item_removed event for an item that is not in a playlist results = self.lib.items('path:{}'.format(quote( os.path.join(self.music_dir, 'x', 'y', 'z.mp3')))) item = results[0] beets.plugins.send('item_removed', item=item) # Emit cli_exit event beets.plugins.send('cli_exit', lib=self.lib) # Check playlist with absolute paths playlist_path = os.path.join(self.playlist_dir, 'absolute.m3u') with open(playlist_path) as f: lines = [line.strip() for line in f.readlines()] self.assertEqual(lines, [ os.path.join(self.music_dir, 'a', 'b', 'c.mp3'), os.path.join(self.music_dir, 'nonexisting.mp3'), ]) # Check playlist with relative paths playlist_path = os.path.join(self.playlist_dir, 'relative.m3u') with open(playlist_path) as f: lines = [line.strip() for line in f.readlines()] self.assertEqual(lines, [ os.path.join('a', 'b', 'c.mp3'), 'nonexisting.mp3', ]) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_plexupdate.py0000644000076500000240000001206400000000000017073 0ustar00asampsonstafffrom test.helper import TestHelper from beetsplug.plexupdate import get_music_section, update_plex import unittest import responses class PlexUpdateTest(unittest.TestCase, TestHelper): def add_response_get_music_section(self, section_name='Music'): """Create response for mocking the get_music_section function. """ escaped_section_name = section_name.replace('"', '\\"') body = ( '' '' '' '' '' '' '' '' '' '' '' '') status = 200 content_type = 'text/xml;charset=utf-8' responses.add(responses.GET, 'http://localhost:32400/library/sections', body=body, status=status, content_type=content_type) def add_response_update_plex(self): """Create response for mocking the update_plex function. """ body = '' status = 200 content_type = 'text/html' responses.add(responses.GET, 'http://localhost:32400/library/sections/2/refresh', body=body, status=status, content_type=content_type) def setUp(self): self.setup_beets() self.load_plugins('plexupdate') self.config['plex'] = { 'host': 'localhost', 'port': 32400} def tearDown(self): self.teardown_beets() self.unload_plugins() @responses.activate def test_get_music_section(self): # Adding response. self.add_response_get_music_section() # Test if section key is "2" out of the mocking data. self.assertEqual(get_music_section( self.config['plex']['host'], self.config['plex']['port'], self.config['plex']['token'], self.config['plex']['library_name'].get(), self.config['plex']['secure'], self.config['plex']['ignore_cert_errors']), '2') @responses.activate def test_get_named_music_section(self): # Adding response. self.add_response_get_music_section('My Music Library') self.assertEqual(get_music_section( self.config['plex']['host'], self.config['plex']['port'], self.config['plex']['token'], 'My Music Library', self.config['plex']['secure'], self.config['plex']['ignore_cert_errors']), '2') @responses.activate def test_update_plex(self): # Adding responses. self.add_response_get_music_section() self.add_response_update_plex() # Testing status code of the mocking request. self.assertEqual(update_plex( self.config['plex']['host'], self.config['plex']['port'], self.config['plex']['token'], self.config['plex']['library_name'].get(), self.config['plex']['secure'], self.config['plex']['ignore_cert_errors']).status_code, 200) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_plugin_mediafield.py0000644000076500000240000000712300000000000020361 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests the facility that lets plugins add custom field to MediaFile. """ import os import shutil import unittest from test import _common from beets.library import Item import mediafile from beets.plugins import BeetsPlugin from beets.util import bytestring_path field_extension = mediafile.MediaField( mediafile.MP3DescStorageStyle('customtag'), mediafile.MP4StorageStyle('----:com.apple.iTunes:customtag'), mediafile.StorageStyle('customtag'), mediafile.ASFStorageStyle('customtag'), ) class ExtendedFieldTestMixin(_common.TestCase): def _mediafile_fixture(self, name, extension='mp3'): name = bytestring_path(name + '.' + extension) src = os.path.join(_common.RSRC, name) target = os.path.join(self.temp_dir, name) shutil.copy(src, target) return mediafile.MediaFile(target) def test_extended_field_write(self): plugin = BeetsPlugin() plugin.add_media_field('customtag', field_extension) try: mf = self._mediafile_fixture('empty') mf.customtag = 'F#' mf.save() mf = mediafile.MediaFile(mf.path) self.assertEqual(mf.customtag, 'F#') finally: delattr(mediafile.MediaFile, 'customtag') Item._media_fields.remove('customtag') def test_write_extended_tag_from_item(self): plugin = BeetsPlugin() plugin.add_media_field('customtag', field_extension) try: mf = self._mediafile_fixture('empty') self.assertIsNone(mf.customtag) item = Item(path=mf.path, customtag='Gb') item.write() mf = mediafile.MediaFile(mf.path) self.assertEqual(mf.customtag, 'Gb') finally: delattr(mediafile.MediaFile, 'customtag') Item._media_fields.remove('customtag') def test_read_flexible_attribute_from_file(self): plugin = BeetsPlugin() plugin.add_media_field('customtag', field_extension) try: mf = self._mediafile_fixture('empty') mf.update({'customtag': 'F#'}) mf.save() item = Item.from_path(mf.path) self.assertEqual(item['customtag'], 'F#') finally: delattr(mediafile.MediaFile, 'customtag') Item._media_fields.remove('customtag') def test_invalid_descriptor(self): with self.assertRaises(ValueError) as cm: mediafile.MediaFile.add_field('somekey', True) self.assertIn('must be an instance of MediaField', str(cm.exception)) def test_overwrite_property(self): with self.assertRaises(ValueError) as cm: mediafile.MediaFile.add_field('artist', mediafile.MediaField()) self.assertIn('property "artist" already exists', str(cm.exception)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_plugins.py0000644000076500000240000004734400000000000016412 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os from unittest.mock import patch, Mock, ANY import shutil import itertools import unittest from beets.importer import SingletonImportTask, SentinelImportTask, \ ArchiveImportTask, action from beets import plugins, config, ui from beets.library import Item from beets.dbcore import types from mediafile import MediaFile from beets.util import displayable_path, bytestring_path, syspath from test.test_importer import ImportHelper, AutotagStub from test.test_ui_importer import TerminalImportSessionSetup from test._common import RSRC from test import helper class TestHelper(helper.TestHelper): def setup_plugin_loader(self): # FIXME the mocking code is horrific, but this is the lowest and # earliest level of the plugin mechanism we can hook into. self.load_plugins() self._plugin_loader_patch = patch('beets.plugins.load_plugins') self._plugin_classes = set() load_plugins = self._plugin_loader_patch.start() def myload(names=()): plugins._classes.update(self._plugin_classes) load_plugins.side_effect = myload self.setup_beets() def teardown_plugin_loader(self): self._plugin_loader_patch.stop() self.unload_plugins() def register_plugin(self, plugin_class): self._plugin_classes.add(plugin_class) class ItemTypesTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_plugin_loader() def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def test_flex_field_type(self): class RatingPlugin(plugins.BeetsPlugin): item_types = {'rating': types.Float()} self.register_plugin(RatingPlugin) self.config['plugins'] = 'rating' item = Item(path='apath', artist='aaa') item.add(self.lib) # Do not match unset values out = self.run_with_output('ls', 'rating:1..3') self.assertNotIn('aaa', out) self.run_command('modify', 'rating=2', '--yes') # Match in range out = self.run_with_output('ls', 'rating:1..3') self.assertIn('aaa', out) # Don't match out of range out = self.run_with_output('ls', 'rating:3..5') self.assertNotIn('aaa', out) class ItemWriteTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_plugin_loader() self.setup_beets() class EventListenerPlugin(plugins.BeetsPlugin): pass self.event_listener_plugin = EventListenerPlugin() self.register_plugin(EventListenerPlugin) def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def test_change_tags(self): def on_write(item=None, path=None, tags=None): if tags['artist'] == 'XXX': tags['artist'] = 'YYY' self.register_listener('write', on_write) item = self.add_item_fixture(artist='XXX') item.write() mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.artist, 'YYY') def register_listener(self, event, func): self.event_listener_plugin.register_listener(event, func) class ItemTypeConflictTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_plugin_loader() self.setup_beets() def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def test_mismatch(self): class EventListenerPlugin(plugins.BeetsPlugin): item_types = {'duplicate': types.INTEGER} class AdventListenerPlugin(plugins.BeetsPlugin): item_types = {'duplicate': types.FLOAT} self.event_listener_plugin = EventListenerPlugin self.advent_listener_plugin = AdventListenerPlugin self.register_plugin(EventListenerPlugin) self.register_plugin(AdventListenerPlugin) self.assertRaises(plugins.PluginConflictException, plugins.types, Item ) def test_match(self): class EventListenerPlugin(plugins.BeetsPlugin): item_types = {'duplicate': types.INTEGER} class AdventListenerPlugin(plugins.BeetsPlugin): item_types = {'duplicate': types.INTEGER} self.event_listener_plugin = EventListenerPlugin self.advent_listener_plugin = AdventListenerPlugin self.register_plugin(EventListenerPlugin) self.register_plugin(AdventListenerPlugin) self.assertNotEqual(None, plugins.types(Item)) class EventsTest(unittest.TestCase, ImportHelper, TestHelper): def setUp(self): self.setup_plugin_loader() self.setup_beets() self.__create_import_dir(2) config['import']['pretend'] = True def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def __copy_file(self, dest_path, metadata): # Copy files resource_path = os.path.join(RSRC, b'full.mp3') shutil.copy(resource_path, dest_path) medium = MediaFile(dest_path) # Set metadata for attr in metadata: setattr(medium, attr, metadata[attr]) medium.save() def __create_import_dir(self, count): self.import_dir = os.path.join(self.temp_dir, b'testsrcdir') if os.path.isdir(self.import_dir): shutil.rmtree(self.import_dir) self.album_path = os.path.join(self.import_dir, b'album') os.makedirs(self.album_path) metadata = { 'artist': 'Tag Artist', 'album': 'Tag Album', 'albumartist': None, 'mb_trackid': None, 'mb_albumid': None, 'comp': None } self.file_paths = [] for i in range(count): metadata['track'] = i + 1 metadata['title'] = 'Tag Title Album %d' % (i + 1) track_file = bytestring_path('%02d - track.mp3' % (i + 1)) dest_path = os.path.join(self.album_path, track_file) self.__copy_file(dest_path, metadata) self.file_paths.append(dest_path) def test_import_task_created(self): import_files = [self.import_dir] self._setup_import_session(singletons=False) self.importer.paths = import_files with helper.capture_log() as logs: self.importer.run() self.unload_plugins() # Exactly one event should have been imported (for the album). # Sentinels do not get emitted. self.assertEqual(logs.count('Sending event: import_task_created'), 1) logs = [line for line in logs if not line.startswith( 'Sending event:')] self.assertEqual(logs, [ 'Album: {}'.format(displayable_path( os.path.join(self.import_dir, b'album'))), ' {}'.format(displayable_path(self.file_paths[0])), ' {}'.format(displayable_path(self.file_paths[1])), ]) def test_import_task_created_with_plugin(self): class ToSingletonPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener('import_task_created', self.import_task_created_event) def import_task_created_event(self, session, task): if isinstance(task, SingletonImportTask) \ or isinstance(task, SentinelImportTask)\ or isinstance(task, ArchiveImportTask): return task new_tasks = [] for item in task.items: new_tasks.append(SingletonImportTask(task.toppath, item)) return new_tasks to_singleton_plugin = ToSingletonPlugin self.register_plugin(to_singleton_plugin) import_files = [self.import_dir] self._setup_import_session(singletons=False) self.importer.paths = import_files with helper.capture_log() as logs: self.importer.run() self.unload_plugins() # Exactly one event should have been imported (for the album). # Sentinels do not get emitted. self.assertEqual(logs.count('Sending event: import_task_created'), 1) logs = [line for line in logs if not line.startswith( 'Sending event:')] self.assertEqual(logs, [ 'Singleton: {}'.format(displayable_path(self.file_paths[0])), 'Singleton: {}'.format(displayable_path(self.file_paths[1])), ]) class HelpersTest(unittest.TestCase): def test_sanitize_choices(self): self.assertEqual( plugins.sanitize_choices(['A', 'Z'], ('A', 'B')), ['A']) self.assertEqual( plugins.sanitize_choices(['A', 'A'], ('A')), ['A']) self.assertEqual( plugins.sanitize_choices(['D', '*', 'A'], ('A', 'B', 'C', 'D')), ['D', 'B', 'C', 'A']) class ListenersTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_plugin_loader() def tearDown(self): self.teardown_plugin_loader() self.teardown_beets() def test_register(self): class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener('cli_exit', self.dummy) self.register_listener('cli_exit', self.dummy) def dummy(self): pass d = DummyPlugin() self.assertEqual(DummyPlugin._raw_listeners['cli_exit'], [d.dummy]) d2 = DummyPlugin() self.assertEqual(DummyPlugin._raw_listeners['cli_exit'], [d.dummy, d2.dummy]) d.register_listener('cli_exit', d2.dummy) self.assertEqual(DummyPlugin._raw_listeners['cli_exit'], [d.dummy, d2.dummy]) @patch('beets.plugins.find_plugins') @patch('beets.plugins.inspect') def test_events_called(self, mock_inspect, mock_find_plugins): mock_inspect.getargspec.args.return_value = None class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.foo = Mock(__name__='foo') self.register_listener('event_foo', self.foo) self.bar = Mock(__name__='bar') self.register_listener('event_bar', self.bar) d = DummyPlugin() mock_find_plugins.return_value = d, plugins.send('event') d.foo.assert_has_calls([]) d.bar.assert_has_calls([]) plugins.send('event_foo', var="tagada") d.foo.assert_called_once_with(var="tagada") d.bar.assert_has_calls([]) @patch('beets.plugins.find_plugins') def test_listener_params(self, mock_find_plugins): test = self class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() for i in itertools.count(1): try: meth = getattr(self, f'dummy{i}') except AttributeError: break self.register_listener(f'event{i}', meth) def dummy1(self, foo): test.assertEqual(foo, 5) def dummy2(self, foo=None): test.assertEqual(foo, 5) def dummy3(self): # argument cut off pass def dummy4(self, bar=None): # argument cut off pass def dummy5(self, bar): test.assertFalse(True) # more complex exmaples def dummy6(self, foo, bar=None): test.assertEqual(foo, 5) test.assertEqual(bar, None) def dummy7(self, foo, **kwargs): test.assertEqual(foo, 5) test.assertEqual(kwargs, {}) def dummy8(self, foo, bar, **kwargs): test.assertFalse(True) def dummy9(self, **kwargs): test.assertEqual(kwargs, {"foo": 5}) d = DummyPlugin() mock_find_plugins.return_value = d, plugins.send('event1', foo=5) plugins.send('event2', foo=5) plugins.send('event3', foo=5) plugins.send('event4', foo=5) with self.assertRaises(TypeError): plugins.send('event5', foo=5) plugins.send('event6', foo=5) plugins.send('event7', foo=5) with self.assertRaises(TypeError): plugins.send('event8', foo=5) plugins.send('event9', foo=5) class PromptChoicesTest(TerminalImportSessionSetup, unittest.TestCase, ImportHelper, TestHelper): def setUp(self): self.setup_plugin_loader() self.setup_beets() self._create_import_dir(3) self._setup_import_session() self.matcher = AutotagStub().install() # keep track of ui.input_option() calls self.input_options_patcher = patch('beets.ui.input_options', side_effect=ui.input_options) self.mock_input_options = self.input_options_patcher.start() def tearDown(self): self.input_options_patcher.stop() self.teardown_plugin_loader() self.teardown_beets() self.matcher.restore() def test_plugin_choices_in_ui_input_options_album(self): """Test the presence of plugin choices on the prompt (album).""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('f', 'Foo', None), ui.commands.PromptChoice('r', 'baR', None)] self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', 'as Tracks', 'Group albums', 'Enter search', 'enter Id', 'aBort') + ('Foo', 'baR') self.importer.add_choice(action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with(opts, default='a', require=ANY) def test_plugin_choices_in_ui_input_options_singleton(self): """Test the presence of plugin choices on the prompt (singleton).""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('f', 'Foo', None), ui.commands.PromptChoice('r', 'baR', None)] self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', 'Enter search', 'enter Id', 'aBort') + ('Foo', 'baR') config['import']['singletons'] = True self.importer.add_choice(action.SKIP) self.importer.run() self.mock_input_options.assert_called_with(opts, default='a', require=ANY) def test_choices_conflicts(self): """Test the short letter conflict solving.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('a', 'A foo', None), # dupe ui.commands.PromptChoice('z', 'baZ', None), # ok ui.commands.PromptChoice('z', 'Zupe', None), # dupe ui.commands.PromptChoice('z', 'Zoo', None)] # dupe self.register_plugin(DummyPlugin) # Default options + not dupe extra choices by the plugin ('baZ') opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', 'as Tracks', 'Group albums', 'Enter search', 'enter Id', 'aBort') + ('baZ',) self.importer.add_choice(action.SKIP) self.importer.run() self.mock_input_options.assert_called_once_with(opts, default='a', require=ANY) def test_plugin_callback(self): """Test that plugin callbacks are being called upon user choice.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('f', 'Foo', self.foo)] def foo(self, session, task): pass self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', 'as Tracks', 'Group albums', 'Enter search', 'enter Id', 'aBort') + ('Foo',) # DummyPlugin.foo() should be called once with patch.object(DummyPlugin, 'foo', autospec=True) as mock_foo: with helper.control_stdin('\n'.join(['f', 's'])): self.importer.run() self.assertEqual(mock_foo.call_count, 1) # input_options should be called twice, as foo() returns None self.assertEqual(self.mock_input_options.call_count, 2) self.mock_input_options.assert_called_with(opts, default='a', require=ANY) def test_plugin_callback_return(self): """Test that plugin callbacks that return a value exit the loop.""" class DummyPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() self.register_listener('before_choose_candidate', self.return_choices) def return_choices(self, session, task): return [ui.commands.PromptChoice('f', 'Foo', self.foo)] def foo(self, session, task): return action.SKIP self.register_plugin(DummyPlugin) # Default options + extra choices by the plugin ('Foo', 'Bar') opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', 'as Tracks', 'Group albums', 'Enter search', 'enter Id', 'aBort') + ('Foo',) # DummyPlugin.foo() should be called once with helper.control_stdin('f\n'): self.importer.run() # input_options should be called once, as foo() returns SKIP self.mock_input_options.assert_called_once_with(opts, default='a', require=ANY) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_query.py0000644000076500000240000011061200000000000016063 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Various tests for querying the library database. """ from functools import partial from unittest.mock import patch import os import sys import unittest from test import _common from test import helper import beets.library from beets import dbcore from beets.dbcore import types from beets.dbcore.query import (NoneQuery, ParsingError, InvalidQueryArgumentValueError) from beets.library import Library, Item from beets import util import platform class TestHelper(helper.TestHelper): def assertInResult(self, item, results): # noqa result_ids = [i.id for i in results] self.assertIn(item.id, result_ids) def assertNotInResult(self, item, results): # noqa result_ids = [i.id for i in results] self.assertNotIn(item.id, result_ids) class AnyFieldQueryTest(_common.LibTestCase): def test_no_restriction(self): q = dbcore.query.AnyFieldQuery( 'title', beets.library.Item._fields.keys(), dbcore.query.SubstringQuery ) self.assertEqual(self.lib.items(q).get().title, 'the title') def test_restriction_completeness(self): q = dbcore.query.AnyFieldQuery('title', ['title'], dbcore.query.SubstringQuery) self.assertEqual(self.lib.items(q).get().title, 'the title') def test_restriction_soundness(self): q = dbcore.query.AnyFieldQuery('title', ['artist'], dbcore.query.SubstringQuery) self.assertEqual(self.lib.items(q).get(), None) def test_eq(self): q1 = dbcore.query.AnyFieldQuery('foo', ['bar'], dbcore.query.SubstringQuery) q2 = dbcore.query.AnyFieldQuery('foo', ['bar'], dbcore.query.SubstringQuery) self.assertEqual(q1, q2) q2.query_class = None self.assertNotEqual(q1, q2) class AssertsMixin: def assert_items_matched(self, results, titles): self.assertEqual({i.title for i in results}, set(titles)) def assert_albums_matched(self, results, albums): self.assertEqual({a.album for a in results}, set(albums)) # 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().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.album = self.lib.add_album(items[:2]) def assert_items_matched_all(self, results): self.assert_items_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_items_matched_all(results) def test_get_none(self): q = None results = self.lib.items(q) self.assert_items_matched_all(results) def test_get_one_keyed_term(self): q = 'title:qux' results = self.lib.items(q) self.assert_items_matched(results, ['baz qux']) def test_get_one_keyed_regexp(self): q = 'artist::t.+r' results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) def test_get_one_unkeyed_term(self): q = 'three' results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) def test_get_one_unkeyed_regexp(self): q = ':x$' results = self.lib.items(q) self.assert_items_matched(results, ['baz qux']) def test_get_no_matches(self): q = 'popebear' results = self.lib.items(q) self.assert_items_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_items_matched(results, []) def test_term_case_insensitive(self): q = 'oNE' results = self.lib.items(q) self.assert_items_matched(results, ['foo bar']) def test_regexp_case_sensitive(self): q = ':oNE' results = self.lib.items(q) self.assert_items_matched(results, []) q = ':one' results = self.lib.items(q) self.assert_items_matched(results, ['foo bar']) def test_term_case_insensitive_with_key(self): q = 'artist:thrEE' results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) def test_key_case_insensitive(self): q = 'ArTiST:three' results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) def test_unkeyed_term_matches_multiple_columns(self): q = 'baz' results = self.lib.items(q) self.assert_items_matched(results, [ 'foo bar', 'baz qux', ]) def test_unkeyed_regexp_matches_multiple_columns(self): q = ':z$' results = self.lib.items(q) self.assert_items_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_items_matched(results, ['baz qux']) def test_keyed_regexp_matches_only_one_column(self): q = 'title::baz' results = self.lib.items(q) self.assert_items_matched(results, [ 'baz qux', ]) def test_multiple_terms_narrow_search(self): q = 'qux baz' results = self.lib.items(q) self.assert_items_matched(results, [ 'baz qux', ]) def test_multiple_regexps_narrow_search(self): q = ':baz :qux' results = self.lib.items(q) self.assert_items_matched(results, ['baz qux']) def test_mixed_terms_regexps_narrow_search(self): q = ':baz qux' results = self.lib.items(q) self.assert_items_matched(results, ['baz qux']) def test_single_year(self): q = 'year:2001' results = self.lib.items(q) self.assert_items_matched(results, ['foo bar']) def test_year_range(self): q = 'year:2000..2002' results = self.lib.items(q) self.assert_items_matched(results, [ 'foo bar', 'baz qux', ]) def test_singleton_true(self): q = 'singleton:true' results = self.lib.items(q) self.assert_items_matched(results, ['beets 4 eva']) def test_singleton_false(self): q = 'singleton:false' results = self.lib.items(q) self.assert_items_matched(results, ['foo bar', 'baz qux']) def test_compilation_true(self): q = 'comp:true' results = self.lib.items(q) self.assert_items_matched(results, ['foo bar', 'baz qux']) def test_compilation_false(self): q = 'comp:false' results = self.lib.items(q) self.assert_items_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 = 'caf\xe9' item.store() q = 'title:caf\xe9' results = self.lib.items(q) self.assert_items_matched(results, ['caf\xe9']) def test_numeric_search_positive(self): q = dbcore.query.NumericQuery('year', '2001') results = self.lib.items(q) self.assertTrue(results) def test_numeric_search_negative(self): q = dbcore.query.NumericQuery('year', '1999') results = self.lib.items(q) self.assertFalse(results) def test_album_field_fallback(self): self.album['albumflex'] = 'foo' self.album.store() q = 'albumflex:foo' results = self.lib.items(q) self.assert_items_matched(results, [ 'foo bar', 'baz qux', ]) def test_invalid_query(self): with self.assertRaises(InvalidQueryArgumentValueError) as raised: dbcore.query.NumericQuery('year', '199a') self.assertIn('not an int', str(raised.exception)) with self.assertRaises(InvalidQueryArgumentValueError) as raised: dbcore.query.RegexpQuery('year', '199(') exception_text = str(raised.exception) self.assertIn('not a regular expression', exception_text) self.assertIn('unterminated subpattern', exception_text) self.assertIsInstance(raised.exception, ParsingError) class MatchTest(_common.TestCase): def setUp(self): super().setUp() self.item = _common.item() def test_regex_match_positive(self): q = dbcore.query.RegexpQuery('album', '^the album$') self.assertTrue(q.match(self.item)) def test_regex_match_negative(self): q = dbcore.query.RegexpQuery('album', '^album$') self.assertFalse(q.match(self.item)) def test_regex_match_non_string_value(self): q = dbcore.query.RegexpQuery('disc', '^6$') self.assertTrue(q.match(self.item)) def test_substring_match_positive(self): q = dbcore.query.SubstringQuery('album', 'album') self.assertTrue(q.match(self.item)) def test_substring_match_negative(self): q = dbcore.query.SubstringQuery('album', 'ablum') self.assertFalse(q.match(self.item)) def test_substring_match_non_string_value(self): q = dbcore.query.SubstringQuery('disc', '6') self.assertTrue(q.match(self.item)) def test_year_match_positive(self): q = dbcore.query.NumericQuery('year', '1') self.assertTrue(q.match(self.item)) def test_year_match_negative(self): q = dbcore.query.NumericQuery('year', '10') self.assertFalse(q.match(self.item)) def test_bitrate_range_positive(self): q = dbcore.query.NumericQuery('bitrate', '100000..200000') self.assertTrue(q.match(self.item)) def test_bitrate_range_negative(self): q = dbcore.query.NumericQuery('bitrate', '200000..300000') self.assertFalse(q.match(self.item)) def test_open_range(self): dbcore.query.NumericQuery('bitrate', '100000..') def test_eq(self): q1 = dbcore.query.MatchQuery('foo', 'bar') q2 = dbcore.query.MatchQuery('foo', 'bar') q3 = dbcore.query.MatchQuery('foo', 'baz') q4 = dbcore.query.StringFieldQuery('foo', 'bar') self.assertEqual(q1, q2) self.assertNotEqual(q1, q3) self.assertNotEqual(q1, q4) self.assertNotEqual(q3, q4) class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): def setUp(self): super().setUp() # This is the item we'll try to match. self.i.path = util.normpath('/a/b/c.mp3') self.i.title = 'path item' self.i.album = 'path album' self.i.store() self.lib.add_album([self.i]) # A second item for testing exclusion. i2 = _common.item() i2.path = util.normpath('/x/y/z.mp3') i2.title = 'another item' i2.album = 'another album' self.lib.add(i2) self.lib.add_album([i2]) # Unadorned path queries with path separators in them are considered # path queries only when the path in question actually exists. So we # mock the existence check to return true. self.patcher_exists = patch('beets.library.os.path.exists') self.patcher_exists.start().return_value = True # We have to create function samefile as it does not exist on # Windows and python 2.7 self.patcher_samefile = patch('beets.library.os.path.samefile', create=True) self.patcher_samefile.start().return_value = True def tearDown(self): super().tearDown() self.patcher_samefile.stop() self.patcher_exists.stop() def test_path_exact_match(self): q = 'path:/a/b/c.mp3' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) results = self.lib.albums(q) self.assert_albums_matched(results, []) @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_parent_directory_no_slash(self): q = 'path:/a' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) results = self.lib.albums(q) self.assert_albums_matched(results, ['path album']) @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_parent_directory_with_slash(self): q = 'path:/a/' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) results = self.lib.albums(q) self.assert_albums_matched(results, ['path album']) def test_no_match(self): q = 'path:/xyzzy/' results = self.lib.items(q) self.assert_items_matched(results, []) results = self.lib.albums(q) self.assert_albums_matched(results, []) def test_fragment_no_match(self): q = 'path:/b/' results = self.lib.items(q) self.assert_items_matched(results, []) results = self.lib.albums(q) self.assert_albums_matched(results, []) def test_nonnorm_path(self): q = 'path:/x/../a/b' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) results = self.lib.albums(q) self.assert_albums_matched(results, ['path album']) def test_slashed_query_matches_path(self): q = '/a/b' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) results = self.lib.albums(q) self.assert_albums_matched(results, ['path album']) @unittest.skip('unfixed (#1865)') def test_path_query_in_or_query(self): q = '/a/b , /a/b' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) def test_non_slashed_does_not_match_path(self): q = 'c.mp3' results = self.lib.items(q) self.assert_items_matched(results, []) results = self.lib.albums(q) self.assert_albums_matched(results, []) def test_slashes_in_explicit_field_does_not_match_path(self): q = 'title:/a/b' results = self.lib.items(q) self.assert_items_matched(results, []) def test_path_item_regex(self): q = 'path::c\\.mp3$' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) def test_path_album_regex(self): q = 'path::b' results = self.lib.albums(q) self.assert_albums_matched(results, ['path album']) def test_escape_underscore(self): self.add_album(path=b'/a/_/title.mp3', title='with underscore', album='album with underscore') q = 'path:/a/_' results = self.lib.items(q) self.assert_items_matched(results, ['with underscore']) results = self.lib.albums(q) self.assert_albums_matched(results, ['album with underscore']) def test_escape_percent(self): self.add_album(path=b'/a/%/title.mp3', title='with percent', album='album with percent') q = 'path:/a/%' results = self.lib.items(q) self.assert_items_matched(results, ['with percent']) results = self.lib.albums(q) self.assert_albums_matched(results, ['album with percent']) def test_escape_backslash(self): self.add_album(path=br'/a/\x/title.mp3', title='with backslash', album='album with backslash') q = 'path:/a/\\\\x' results = self.lib.items(q) self.assert_items_matched(results, ['with backslash']) results = self.lib.albums(q) self.assert_albums_matched(results, ['album with backslash']) def test_case_sensitivity(self): self.add_album(path=b'/A/B/C2.mp3', title='caps path') makeq = partial(beets.library.PathQuery, 'path', '/A/B') results = self.lib.items(makeq(case_sensitive=True)) self.assert_items_matched(results, ['caps path']) results = self.lib.items(makeq(case_sensitive=False)) self.assert_items_matched(results, ['path item', 'caps path']) # Check for correct case sensitivity selection (this check # only works on non-Windows OSes). with _common.system_mock('Darwin'): # exists = True and samefile = True => Case insensitive q = makeq() self.assertEqual(q.case_sensitive, False) # exists = True and samefile = False => Case sensitive self.patcher_samefile.stop() self.patcher_samefile.start().return_value = False try: q = makeq() self.assertEqual(q.case_sensitive, True) finally: self.patcher_samefile.stop() self.patcher_samefile.start().return_value = True # Test platform-aware default sensitivity when the library path # does not exist. For the duration of this check, we change the # `os.path.exists` mock to return False. self.patcher_exists.stop() self.patcher_exists.start().return_value = False try: with _common.system_mock('Darwin'): q = makeq() self.assertEqual(q.case_sensitive, True) with _common.system_mock('Windows'): q = makeq() self.assertEqual(q.case_sensitive, False) finally: # Restore the `os.path.exists` mock to its original state. self.patcher_exists.stop() self.patcher_exists.start().return_value = True @patch('beets.library.os') def test_path_sep_detection(self, mock_os): mock_os.sep = '/' mock_os.altsep = None mock_os.path.exists = lambda p: True is_path = beets.library.PathQuery.is_path_query self.assertTrue(is_path('/foo/bar')) self.assertTrue(is_path('foo/bar')) self.assertTrue(is_path('foo/')) self.assertFalse(is_path('foo')) self.assertTrue(is_path('foo/:bar')) self.assertFalse(is_path('foo:bar/')) self.assertFalse(is_path('foo:/bar')) def test_detect_absolute_path(self): if platform.system() == 'Windows': # Because the absolute path begins with something like C:, we # can't disambiguate it from an ordinary query. self.skipTest('Windows absolute paths do not work as queries') # Don't patch `os.path.exists`; we'll actually create a file when # it exists. self.patcher_exists.stop() is_path = beets.library.PathQuery.is_path_query try: path = self.touch(os.path.join(b'foo', b'bar')) path = path.decode('utf-8') # The file itself. self.assertTrue(is_path(path)) # The parent directory. parent = os.path.dirname(path) self.assertTrue(is_path(parent)) # Some non-existent path. self.assertFalse(is_path(path + 'baz')) finally: # Restart the `os.path.exists` patch. self.patcher_exists.start() def test_detect_relative_path(self): self.patcher_exists.stop() is_path = beets.library.PathQuery.is_path_query try: self.touch(os.path.join(b'foo', b'bar')) # Temporarily change directory so relative paths work. cur_dir = os.getcwd() try: os.chdir(self.temp_dir) self.assertTrue(is_path('foo/')) self.assertTrue(is_path('foo/bar')) self.assertTrue(is_path('foo/bar:tagada')) self.assertFalse(is_path('bar')) finally: os.chdir(cur_dir) finally: self.patcher_exists.start() class IntQueryTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = Library(':memory:') def tearDown(self): Item._types = {} def test_exact_value_match(self): item = self.add_item(bpm=120) matched = self.lib.items('bpm:120').get() self.assertEqual(item.id, matched.id) def test_range_match(self): item = self.add_item(bpm=120) self.add_item(bpm=130) matched = self.lib.items('bpm:110..125') self.assertEqual(1, len(matched)) self.assertEqual(item.id, matched.get().id) def test_flex_range_match(self): Item._types = {'myint': types.Integer()} item = self.add_item(myint=2) matched = self.lib.items('myint:2').get() self.assertEqual(item.id, matched.id) def test_flex_dont_match_missing(self): Item._types = {'myint': types.Integer()} self.add_item() matched = self.lib.items('myint:2').get() self.assertIsNone(matched) def test_no_substring_match(self): self.add_item(bpm=120) matched = self.lib.items('bpm:12').get() self.assertIsNone(matched) class BoolQueryTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = Library(':memory:') Item._types = {'flexbool': types.Boolean()} def tearDown(self): Item._types = {} def test_parse_true(self): item_true = self.add_item(comp=True) item_false = self.add_item(comp=False) matched = self.lib.items('comp:true') self.assertInResult(item_true, matched) self.assertNotInResult(item_false, matched) def test_flex_parse_true(self): item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items('flexbool:true') self.assertInResult(item_true, matched) self.assertNotInResult(item_false, matched) def test_flex_parse_false(self): item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items('flexbool:false') self.assertInResult(item_false, matched) self.assertNotInResult(item_true, matched) def test_flex_parse_1(self): item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items('flexbool:1') self.assertInResult(item_true, matched) self.assertNotInResult(item_false, matched) def test_flex_parse_0(self): item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items('flexbool:0') self.assertInResult(item_false, matched) self.assertNotInResult(item_true, matched) def test_flex_parse_any_string(self): # TODO this should be the other way around item_true = self.add_item(flexbool=True) item_false = self.add_item(flexbool=False) matched = self.lib.items('flexbool:something') self.assertInResult(item_false, matched) self.assertNotInResult(item_true, matched) 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_items_matched(items, ['beets 4 eva']) def test_items_does_not_match_year(self): items = self.lib.items('2001') self.assert_items_matched(items, []) class NoneQueryTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = Library(':memory:') def test_match_singletons(self): singleton = self.add_item() album_item = self.add_album().items().get() matched = self.lib.items(NoneQuery('album_id')) self.assertInResult(singleton, matched) self.assertNotInResult(album_item, matched) def test_match_after_set_none(self): item = self.add_item(rg_track_gain=0) matched = self.lib.items(NoneQuery('rg_track_gain')) self.assertNotInResult(item, matched) item['rg_track_gain'] = None item.store() matched = self.lib.items(NoneQuery('rg_track_gain')) self.assertInResult(item, matched) def test_match_slow(self): item = self.add_item() matched = self.lib.items(NoneQuery('rg_track_peak', fast=False)) self.assertInResult(item, matched) def test_match_slow_after_set_none(self): item = self.add_item(rg_track_gain=0) matched = self.lib.items(NoneQuery('rg_track_gain', fast=False)) self.assertNotInResult(item, matched) item['rg_track_gain'] = None item.store() matched = self.lib.items(NoneQuery('rg_track_gain', fast=False)) self.assertInResult(item, matched) class NotQueryMatchTest(_common.TestCase): """Test `query.NotQuery` matching against a single item, using the same cases and assertions as on `MatchTest`, plus assertion on the negated queries (ie. assertTrue(q) -> assertFalse(NotQuery(q))). """ def setUp(self): super().setUp() self.item = _common.item() def test_regex_match_positive(self): q = dbcore.query.RegexpQuery('album', '^the album$') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_regex_match_negative(self): q = dbcore.query.RegexpQuery('album', '^album$') self.assertFalse(q.match(self.item)) self.assertTrue(dbcore.query.NotQuery(q).match(self.item)) def test_regex_match_non_string_value(self): q = dbcore.query.RegexpQuery('disc', '^6$') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_substring_match_positive(self): q = dbcore.query.SubstringQuery('album', 'album') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_substring_match_negative(self): q = dbcore.query.SubstringQuery('album', 'ablum') self.assertFalse(q.match(self.item)) self.assertTrue(dbcore.query.NotQuery(q).match(self.item)) def test_substring_match_non_string_value(self): q = dbcore.query.SubstringQuery('disc', '6') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_year_match_positive(self): q = dbcore.query.NumericQuery('year', '1') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_year_match_negative(self): q = dbcore.query.NumericQuery('year', '10') self.assertFalse(q.match(self.item)) self.assertTrue(dbcore.query.NotQuery(q).match(self.item)) def test_bitrate_range_positive(self): q = dbcore.query.NumericQuery('bitrate', '100000..200000') self.assertTrue(q.match(self.item)) self.assertFalse(dbcore.query.NotQuery(q).match(self.item)) def test_bitrate_range_negative(self): q = dbcore.query.NumericQuery('bitrate', '200000..300000') self.assertFalse(q.match(self.item)) self.assertTrue(dbcore.query.NotQuery(q).match(self.item)) def test_open_range(self): q = dbcore.query.NumericQuery('bitrate', '100000..') dbcore.query.NotQuery(q) class NotQueryTest(DummyDataTestCase): """Test `query.NotQuery` against the dummy data: - `test_type_xxx`: tests for the negation of a particular XxxQuery class. - `test_get_yyy`: tests on query strings (similar to `GetTest`) """ def assertNegationProperties(self, q): # noqa """Given a Query `q`, assert that: - q OR not(q) == all items - q AND not(q) == 0 - not(not(q)) == q """ not_q = dbcore.query.NotQuery(q) # assert using OrQuery, AndQuery q_or = dbcore.query.OrQuery([q, not_q]) q_and = dbcore.query.AndQuery([q, not_q]) self.assert_items_matched_all(self.lib.items(q_or)) self.assert_items_matched(self.lib.items(q_and), []) # assert manually checking the item titles all_titles = {i.title for i in self.lib.items()} q_results = {i.title for i in self.lib.items(q)} not_q_results = {i.title for i in self.lib.items(not_q)} self.assertEqual(q_results.union(not_q_results), all_titles) self.assertEqual(q_results.intersection(not_q_results), set()) # round trip not_not_q = dbcore.query.NotQuery(not_q) self.assertEqual({i.title for i in self.lib.items(q)}, {i.title for i in self.lib.items(not_not_q)}) def test_type_and(self): # not(a and b) <-> not(a) or not(b) q = dbcore.query.AndQuery([ dbcore.query.BooleanQuery('comp', True), dbcore.query.NumericQuery('year', '2002')], ) not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, ['foo bar', 'beets 4 eva']) self.assertNegationProperties(q) def test_type_anyfield(self): q = dbcore.query.AnyFieldQuery('foo', ['title', 'artist', 'album'], dbcore.query.SubstringQuery) not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, ['baz qux']) self.assertNegationProperties(q) def test_type_boolean(self): q = dbcore.query.BooleanQuery('comp', True) not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, ['beets 4 eva']) self.assertNegationProperties(q) def test_type_date(self): q = dbcore.query.DateQuery('added', '2000-01-01') not_results = self.lib.items(dbcore.query.NotQuery(q)) # query date is in the past, thus the 'not' results should contain all # items self.assert_items_matched(not_results, ['foo bar', 'baz qux', 'beets 4 eva']) self.assertNegationProperties(q) def test_type_false(self): q = dbcore.query.FalseQuery() not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched_all(not_results) self.assertNegationProperties(q) def test_type_match(self): q = dbcore.query.MatchQuery('year', '2003') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, ['foo bar', 'baz qux']) self.assertNegationProperties(q) def test_type_none(self): q = dbcore.query.NoneQuery('rg_track_gain') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, []) self.assertNegationProperties(q) def test_type_numeric(self): q = dbcore.query.NumericQuery('year', '2001..2002') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, ['beets 4 eva']) self.assertNegationProperties(q) def test_type_or(self): # not(a or b) <-> not(a) and not(b) q = dbcore.query.OrQuery([dbcore.query.BooleanQuery('comp', True), dbcore.query.NumericQuery('year', '2002')]) not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, ['beets 4 eva']) self.assertNegationProperties(q) def test_type_regexp(self): q = dbcore.query.RegexpQuery('artist', '^t') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, ['foo bar']) self.assertNegationProperties(q) def test_type_substring(self): q = dbcore.query.SubstringQuery('album', 'ba') not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, ['beets 4 eva']) self.assertNegationProperties(q) def test_type_true(self): q = dbcore.query.TrueQuery() not_results = self.lib.items(dbcore.query.NotQuery(q)) self.assert_items_matched(not_results, []) self.assertNegationProperties(q) def test_get_prefixes_keyed(self): """Test both negation prefixes on a keyed query.""" q0 = '-title:qux' q1 = '^title:qux' results0 = self.lib.items(q0) results1 = self.lib.items(q1) self.assert_items_matched(results0, ['foo bar', 'beets 4 eva']) self.assert_items_matched(results1, ['foo bar', 'beets 4 eva']) def test_get_prefixes_unkeyed(self): """Test both negation prefixes on an unkeyed query.""" q0 = '-qux' q1 = '^qux' results0 = self.lib.items(q0) results1 = self.lib.items(q1) self.assert_items_matched(results0, ['foo bar', 'beets 4 eva']) self.assert_items_matched(results1, ['foo bar', 'beets 4 eva']) def test_get_one_keyed_regexp(self): q = '-artist::t.+r' results = self.lib.items(q) self.assert_items_matched(results, ['foo bar', 'baz qux']) def test_get_one_unkeyed_regexp(self): q = '-:x$' results = self.lib.items(q) self.assert_items_matched(results, ['foo bar', 'beets 4 eva']) def test_get_multiple_terms(self): q = 'baz -bar' results = self.lib.items(q) self.assert_items_matched(results, ['baz qux']) def test_get_mixed_terms(self): q = 'baz -title:bar' results = self.lib.items(q) self.assert_items_matched(results, ['baz qux']) def test_fast_vs_slow(self): """Test that the results are the same regardless of the `fast` flag for negated `FieldQuery`s. TODO: investigate NoneQuery(fast=False), as it is raising AttributeError: type object 'NoneQuery' has no attribute 'field' at NoneQuery.match() (due to being @classmethod, and no self?) """ classes = [(dbcore.query.DateQuery, ['added', '2001-01-01']), (dbcore.query.MatchQuery, ['artist', 'one']), # (dbcore.query.NoneQuery, ['rg_track_gain']), (dbcore.query.NumericQuery, ['year', '2002']), (dbcore.query.StringFieldQuery, ['year', '2001']), (dbcore.query.RegexpQuery, ['album', '^.a']), (dbcore.query.SubstringQuery, ['title', 'x'])] for klass, args in classes: q_fast = dbcore.query.NotQuery(klass(*(args + [True]))) q_slow = dbcore.query.NotQuery(klass(*(args + [False]))) try: self.assertEqual([i.title for i in self.lib.items(q_fast)], [i.title for i in self.lib.items(q_slow)]) except NotImplementedError: # ignore classes that do not provide `fast` implementation pass def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_random.py0000644000076500000240000000623600000000000016204 0ustar00asampsonstaff# This file is part of beets. # Copyright 2019, Carl Suster # # 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 beets.random utilities associated with the random plugin. """ import unittest from test.helper import TestHelper import math from random import Random from beets import random class RandomTest(unittest.TestCase, TestHelper): def setUp(self): self.lib = None self.artist1 = 'Artist 1' self.artist2 = 'Artist 2' self.item1 = self.create_item(artist=self.artist1) self.item2 = self.create_item(artist=self.artist2) self.items = [self.item1, self.item2] for _ in range(8): self.items.append(self.create_item(artist=self.artist2)) self.random_gen = Random() self.random_gen.seed(12345) def tearDown(self): pass def _stats(self, data): mean = sum(data) / len(data) stdev = math.sqrt( sum((p - mean) ** 2 for p in data) / (len(data) - 1)) quot, rem = divmod(len(data), 2) if rem: median = sorted(data)[quot] else: median = sum(sorted(data)[quot - 1:quot + 1]) / 2 return mean, stdev, median def test_equal_permutation(self): """We have a list of items where only one item is from artist1 and the rest are from artist2. If we permute weighted by the artist field then the solo track will almost always end up near the start. If we use a different field then it'll be in the middle on average. """ def experiment(field, histogram=False): """Permutes the list of items 500 times and calculates the position of self.item1 each time. Returns stats about that position. """ positions = [] for _ in range(500): shuffled = list(random._equal_chance_permutation( self.items, field=field, random_gen=self.random_gen)) positions.append(shuffled.index(self.item1)) # Print a histogram (useful for debugging). if histogram: for i in range(len(self.items)): print('{:2d} {}'.format(i, '*' * positions.count(i))) return self._stats(positions) mean1, stdev1, median1 = experiment('artist') mean2, stdev2, median2 = experiment('track') self.assertAlmostEqual(0, median1, delta=1) self.assertAlmostEqual(len(self.items) // 2, median2, delta=1) self.assertGreater(stdev2, stdev1) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_replaygain.py0000644000076500000240000001637100000000000017060 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import unittest from mediafile import MediaFile from beets import config from beetsplug.replaygain import (FatalGstreamerPluginReplayGainError, GStreamerBackend) from test.helper import TestHelper, has_program try: import gi gi.require_version('Gst', '1.0') GST_AVAILABLE = True except (ImportError, ValueError): GST_AVAILABLE = False if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']): GAIN_PROG_AVAILABLE = True else: GAIN_PROG_AVAILABLE = False FFMPEG_AVAILABLE = has_program('ffmpeg', ['-version']) def reset_replaygain(item): item['rg_track_peak'] = None item['rg_track_gain'] = None item['rg_album_gain'] = None item['rg_album_gain'] = None item.write() item.store() item.store() item.store() class ReplayGainCliTestBase(TestHelper): def setUp(self): self.setup_beets(disk=True) self.config['replaygain']['backend'] = self.backend try: self.load_plugins('replaygain') except Exception: import sys # store exception info so an error in teardown does not swallow it exc_info = sys.exc_info() try: self.teardown_beets() self.unload_plugins() except Exception: # if load_plugins() failed then setup is incomplete and # teardown operations may fail. In particular # {Item,Album} # may not have the _original_types attribute in unload_plugins pass raise None.with_traceback(exc_info[2]) album = self.add_album_fixture(2) for item in album.items(): reset_replaygain(item) def tearDown(self): self.teardown_beets() self.unload_plugins() def _reset_replaygain(self, item): item['rg_track_peak'] = None item['rg_track_gain'] = None item['rg_album_peak'] = None item['rg_album_gain'] = None item['r128_track_gain'] = None item['r128_album_gain'] = None item.write() item.store() def test_cli_saves_track_gain(self): for item in self.lib.items(): self.assertIsNone(item.rg_track_peak) self.assertIsNone(item.rg_track_gain) mediafile = MediaFile(item.path) self.assertIsNone(mediafile.rg_track_peak) self.assertIsNone(mediafile.rg_track_gain) self.run_command('replaygain') # Skip the test if rg_track_peak and rg_track gain is None, assuming # that it could only happen if the decoder plugins are missing. if all(i.rg_track_peak is None and i.rg_track_gain is None for i in self.lib.items()): self.skipTest('decoder plugins could not be loaded.') for item in self.lib.items(): self.assertIsNotNone(item.rg_track_peak) self.assertIsNotNone(item.rg_track_gain) mediafile = MediaFile(item.path) self.assertAlmostEqual( mediafile.rg_track_peak, item.rg_track_peak, places=6) self.assertAlmostEqual( mediafile.rg_track_gain, item.rg_track_gain, places=2) def test_cli_skips_calculated_tracks(self): self.run_command('replaygain') item = self.lib.items()[0] peak = item.rg_track_peak item.rg_track_gain = 0.0 self.run_command('replaygain') self.assertEqual(item.rg_track_gain, 0.0) self.assertEqual(item.rg_track_peak, peak) def test_cli_saves_album_gain_to_file(self): for item in self.lib.items(): mediafile = MediaFile(item.path) self.assertIsNone(mediafile.rg_album_peak) self.assertIsNone(mediafile.rg_album_gain) self.run_command('replaygain', '-a') peaks = [] gains = [] for item in self.lib.items(): mediafile = MediaFile(item.path) peaks.append(mediafile.rg_album_peak) gains.append(mediafile.rg_album_gain) # Make sure they are all the same self.assertEqual(max(peaks), min(peaks)) self.assertEqual(max(gains), min(gains)) self.assertNotEqual(max(gains), 0.0) self.assertNotEqual(max(peaks), 0.0) def test_cli_writes_only_r128_tags(self): if self.backend == "command": # opus not supported by command backend return album = self.add_album_fixture(2, ext="opus") for item in album.items(): self._reset_replaygain(item) self.run_command('replaygain', '-a') for item in album.items(): mediafile = MediaFile(item.path) # does not write REPLAYGAIN_* tags self.assertIsNone(mediafile.rg_track_gain) self.assertIsNone(mediafile.rg_album_gain) # writes R128_* tags self.assertIsNotNone(mediafile.r128_track_gain) self.assertIsNotNone(mediafile.r128_album_gain) def test_target_level_has_effect(self): item = self.lib.items()[0] def analyse(target_level): self.config['replaygain']['targetlevel'] = target_level self._reset_replaygain(item) self.run_command('replaygain', '-f') mediafile = MediaFile(item.path) return mediafile.rg_track_gain gain_relative_to_84 = analyse(84) gain_relative_to_89 = analyse(89) # check that second calculation did work if gain_relative_to_84 is not None: self.assertIsNotNone(gain_relative_to_89) self.assertNotEqual(gain_relative_to_84, gain_relative_to_89) @unittest.skipIf(not GST_AVAILABLE, 'gstreamer cannot be found') class ReplayGainGstCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = 'gstreamer' def setUp(self): try: # Check if required plugins can be loaded by instantiating a # GStreamerBackend (via its .__init__). config['replaygain']['targetlevel'] = 89 GStreamerBackend(config['replaygain'], None) except FatalGstreamerPluginReplayGainError as e: # Skip the test if plugins could not be loaded. self.skipTest(str(e)) super().setUp() @unittest.skipIf(not GAIN_PROG_AVAILABLE, 'no *gain command found') class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = 'command' @unittest.skipIf(not FFMPEG_AVAILABLE, 'ffmpeg cannot be found') class ReplayGainFfmpegTest(ReplayGainCliTestBase, unittest.TestCase): backend = 'ffmpeg' def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_smartplaylist.py0000644000076500000240000001776100000000000017641 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Bruno Cauet. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. from os import path, remove from tempfile import mkdtemp from shutil import rmtree import unittest from unittest.mock import Mock, MagicMock from beetsplug.smartplaylist import SmartPlaylistPlugin from beets.library import Item, Album, parse_query_string from beets.dbcore import OrQuery from beets.dbcore.query import NullSort, MultipleSort, FixedFieldSort from beets.util import syspath, bytestring_path, py3_path, CHAR_REPLACE from beets.ui import UserError from beets import config from test.helper import TestHelper class SmartPlaylistTest(unittest.TestCase): def test_build_queries(self): spl = SmartPlaylistPlugin() self.assertEqual(spl._matched_playlists, None) self.assertEqual(spl._unmatched_playlists, None) config['smartplaylist']['playlists'].set([]) spl.build_queries() self.assertEqual(spl._matched_playlists, set()) self.assertEqual(spl._unmatched_playlists, set()) config['smartplaylist']['playlists'].set([ {'name': 'foo', 'query': 'FOO foo'}, {'name': 'bar', 'album_query': ['BAR bar1', 'BAR bar2']}, {'name': 'baz', 'query': 'BAZ baz', 'album_query': 'BAZ baz'} ]) spl.build_queries() self.assertEqual(spl._matched_playlists, set()) foo_foo = parse_query_string('FOO foo', Item) baz_baz = parse_query_string('BAZ baz', Item) baz_baz2 = parse_query_string('BAZ baz', Album) bar_bar = OrQuery((parse_query_string('BAR bar1', Album)[0], parse_query_string('BAR bar2', Album)[0])) self.assertEqual(spl._unmatched_playlists, { ('foo', foo_foo, (None, None)), ('baz', baz_baz, baz_baz2), ('bar', (None, None), (bar_bar, None)), }) def test_build_queries_with_sorts(self): spl = SmartPlaylistPlugin() config['smartplaylist']['playlists'].set([ {'name': 'no_sort', 'query': 'foo'}, {'name': 'one_sort', 'query': 'foo year+'}, {'name': 'only_empty_sorts', 'query': ['foo', 'bar']}, {'name': 'one_non_empty_sort', 'query': ['foo year+', 'bar']}, {'name': 'multiple_sorts', 'query': ['foo year+', 'bar genre-']}, {'name': 'mixed', 'query': ['foo year+', 'bar', 'baz genre+ id-']} ]) spl.build_queries() sorts = {name: sort for name, (_, sort), _ in spl._unmatched_playlists} asseq = self.assertEqual # less cluttered code sort = FixedFieldSort # short cut since we're only dealing with this asseq(sorts["no_sort"], NullSort()) asseq(sorts["one_sort"], sort('year')) asseq(sorts["only_empty_sorts"], None) asseq(sorts["one_non_empty_sort"], sort('year')) asseq(sorts["multiple_sorts"], MultipleSort([sort('year'), sort('genre', False)])) asseq(sorts["mixed"], MultipleSort([sort('year'), sort('genre'), sort('id', False)])) def test_matches(self): spl = SmartPlaylistPlugin() a = MagicMock(Album) i = MagicMock(Item) self.assertFalse(spl.matches(i, None, None)) self.assertFalse(spl.matches(a, None, None)) query = Mock() query.match.side_effect = {i: True}.__getitem__ self.assertTrue(spl.matches(i, query, None)) self.assertFalse(spl.matches(a, query, None)) a_query = Mock() a_query.match.side_effect = {a: True}.__getitem__ self.assertFalse(spl.matches(i, None, a_query)) self.assertTrue(spl.matches(a, None, a_query)) self.assertTrue(spl.matches(i, query, a_query)) self.assertTrue(spl.matches(a, query, a_query)) def test_db_changes(self): spl = SmartPlaylistPlugin() nones = None, None pl1 = '1', ('q1', None), nones pl2 = '2', ('q2', None), nones pl3 = '3', ('q3', None), nones spl._unmatched_playlists = {pl1, pl2, pl3} spl._matched_playlists = set() spl.matches = Mock(return_value=False) spl.db_change(None, "nothing") self.assertEqual(spl._unmatched_playlists, {pl1, pl2, pl3}) self.assertEqual(spl._matched_playlists, set()) spl.matches.side_effect = lambda _, q, __: q == 'q3' spl.db_change(None, "matches 3") self.assertEqual(spl._unmatched_playlists, {pl1, pl2}) self.assertEqual(spl._matched_playlists, {pl3}) spl.matches.side_effect = lambda _, q, __: q == 'q1' spl.db_change(None, "matches 3") self.assertEqual(spl._matched_playlists, {pl1, pl3}) self.assertEqual(spl._unmatched_playlists, {pl2}) def test_playlist_update(self): spl = SmartPlaylistPlugin() i = Mock(path=b'/tagada.mp3') i.evaluate_template.side_effect = \ lambda pl, _: pl.replace(b'$title', b'ta:ga:da').decode() lib = Mock() lib.replacements = CHAR_REPLACE lib.items.return_value = [i] lib.albums.return_value = [] q = Mock() a_q = Mock() pl = b'$title-my.m3u', (q, None), (a_q, None) spl._matched_playlists = [pl] dir = bytestring_path(mkdtemp()) config['smartplaylist']['relative_to'] = False config['smartplaylist']['playlist_dir'] = py3_path(dir) try: spl.update_playlists(lib) except Exception: rmtree(dir) raise lib.items.assert_called_once_with(q, None) lib.albums.assert_called_once_with(a_q, None) m3u_filepath = path.join(dir, b'ta_ga_da-my_playlist_.m3u') self.assertTrue(path.exists(m3u_filepath)) with open(syspath(m3u_filepath), 'rb') as f: content = f.read() rmtree(dir) self.assertEqual(content, b'/tagada.mp3\n') class SmartPlaylistCLITest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.item = self.add_item() config['smartplaylist']['playlists'].set([ {'name': 'my_playlist.m3u', 'query': self.item.title}, {'name': 'all.m3u', 'query': ''} ]) config['smartplaylist']['playlist_dir'].set(py3_path(self.temp_dir)) self.load_plugins('smartplaylist') def tearDown(self): self.unload_plugins() self.teardown_beets() def test_splupdate(self): with self.assertRaises(UserError): self.run_with_output('splupdate', 'tagada') self.run_with_output('splupdate', 'my_playlist') m3u_path = path.join(self.temp_dir, b'my_playlist.m3u') self.assertTrue(path.exists(m3u_path)) with open(m3u_path, 'rb') as f: self.assertEqual(f.read(), self.item.path + b"\n") remove(m3u_path) self.run_with_output('splupdate', 'my_playlist.m3u') with open(m3u_path, 'rb') as f: self.assertEqual(f.read(), self.item.path + b"\n") remove(m3u_path) self.run_with_output('splupdate') for name in (b'my_playlist.m3u', b'all.m3u'): with open(path.join(self.temp_dir, name), 'rb') as f: self.assertEqual(f.read(), self.item.path + b"\n") def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_sort.py0000644000076500000240000004715600000000000015721 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Various tests for querying the library database. """ import unittest from test import _common import beets.library from beets import dbcore from beets import config # A test case class providing a library with some dummy data and some # assertions involving that data. class DummyDataTestCase(_common.TestCase): def setUp(self): super().setUp() self.lib = beets.library.Library(':memory:') albums = [_common.album() for _ in range(3)] albums[0].album = "Album A" albums[0].genre = "Rock" albums[0].year = 2001 albums[0].flex1 = "Flex1-1" albums[0].flex2 = "Flex2-A" albums[0].albumartist = "Foo" albums[0].albumartist_sort = None albums[1].album = "Album B" albums[1].genre = "Rock" albums[1].year = 2001 albums[1].flex1 = "Flex1-2" albums[1].flex2 = "Flex2-A" albums[1].albumartist = "Bar" albums[1].albumartist_sort = None albums[2].album = "Album C" albums[2].genre = "Jazz" albums[2].year = 2005 albums[2].flex1 = "Flex1-1" albums[2].flex2 = "Flex2-B" albums[2].albumartist = "Baz" albums[2].albumartist_sort = None for album in albums: self.lib.add(album) items = [_common.item() for _ in range(4)] items[0].title = 'Foo bar' items[0].artist = 'One' items[0].album = 'Baz' items[0].year = 2001 items[0].comp = True items[0].flex1 = "Flex1-0" items[0].flex2 = "Flex2-A" items[0].album_id = albums[0].id items[0].artist_sort = None items[0].path = "/path0.mp3" items[0].track = 1 items[1].title = 'Baz qux' items[1].artist = 'Two' items[1].album = 'Baz' items[1].year = 2002 items[1].comp = True items[1].flex1 = "Flex1-1" items[1].flex2 = "Flex2-A" items[1].album_id = albums[0].id items[1].artist_sort = None items[1].path = "/patH1.mp3" items[1].track = 2 items[2].title = 'Beets 4 eva' items[2].artist = 'Three' items[2].album = 'Foo' items[2].year = 2003 items[2].comp = False items[2].flex1 = "Flex1-2" items[2].flex2 = "Flex1-B" items[2].album_id = albums[1].id items[2].artist_sort = None items[2].path = "/paTH2.mp3" items[2].track = 3 items[3].title = 'Beets 4 eva' items[3].artist = 'Three' items[3].album = 'Foo2' items[3].year = 2004 items[3].comp = False items[3].flex1 = "Flex1-2" items[3].flex2 = "Flex1-C" items[3].album_id = albums[2].id items[3].artist_sort = None items[3].path = "/PATH3.mp3" items[3].track = 4 for item in items: self.lib.add(item) class SortFixedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = '' sort = dbcore.query.FixedFieldSort("year", True) results = self.lib.items(q, sort) self.assertLessEqual(results[0]['year'], results[1]['year']) self.assertEqual(results[0]['year'], 2001) # same thing with query string q = 'year+' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = '' sort = dbcore.query.FixedFieldSort("year", False) results = self.lib.items(q, sort) self.assertGreaterEqual(results[0]['year'], results[1]['year']) self.assertEqual(results[0]['year'], 2004) # same thing with query string q = 'year-' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_two_field_asc(self): q = '' s1 = dbcore.query.FixedFieldSort("album", True) s2 = dbcore.query.FixedFieldSort("year", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.items(q, sort) self.assertLessEqual(results[0]['album'], results[1]['album']) self.assertLessEqual(results[1]['album'], results[2]['album']) self.assertEqual(results[0]['album'], 'Baz') self.assertEqual(results[1]['album'], 'Baz') self.assertLessEqual(results[0]['year'], results[1]['year']) # same thing with query string q = 'album+ year+' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_path_field(self): q = '' sort = dbcore.query.FixedFieldSort('path', True) results = self.lib.items(q, sort) self.assertEqual(results[0]['path'], b'/path0.mp3') self.assertEqual(results[1]['path'], b'/patH1.mp3') self.assertEqual(results[2]['path'], b'/paTH2.mp3') self.assertEqual(results[3]['path'], b'/PATH3.mp3') class SortFlexFieldTest(DummyDataTestCase): def test_sort_asc(self): q = '' sort = dbcore.query.SlowFieldSort("flex1", True) results = self.lib.items(q, sort) self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) self.assertEqual(results[0]['flex1'], 'Flex1-0') # same thing with query string q = 'flex1+' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = '' sort = dbcore.query.SlowFieldSort("flex1", False) results = self.lib.items(q, sort) self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1']) self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1']) self.assertGreaterEqual(results[2]['flex1'], results[3]['flex1']) self.assertEqual(results[0]['flex1'], 'Flex1-2') # same thing with query string q = 'flex1-' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_two_field(self): q = '' s1 = dbcore.query.SlowFieldSort("flex2", False) s2 = dbcore.query.SlowFieldSort("flex1", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.items(q, sort) self.assertGreaterEqual(results[0]['flex2'], results[1]['flex2']) self.assertGreaterEqual(results[1]['flex2'], results[2]['flex2']) self.assertEqual(results[0]['flex2'], 'Flex2-A') self.assertEqual(results[1]['flex2'], 'Flex2-A') self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) # same thing with query string q = 'flex2- flex1+' results2 = self.lib.items(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class SortAlbumFixedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = '' sort = dbcore.query.FixedFieldSort("year", True) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['year'], results[1]['year']) self.assertEqual(results[0]['year'], 2001) # same thing with query string q = 'year+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = '' sort = dbcore.query.FixedFieldSort("year", False) results = self.lib.albums(q, sort) self.assertGreaterEqual(results[0]['year'], results[1]['year']) self.assertEqual(results[0]['year'], 2005) # same thing with query string q = 'year-' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_two_field_asc(self): q = '' s1 = dbcore.query.FixedFieldSort("genre", True) s2 = dbcore.query.FixedFieldSort("album", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['genre'], results[1]['genre']) self.assertLessEqual(results[1]['genre'], results[2]['genre']) self.assertEqual(results[1]['genre'], 'Rock') self.assertEqual(results[2]['genre'], 'Rock') self.assertLessEqual(results[1]['album'], results[2]['album']) # same thing with query string q = 'genre+ album+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class SortAlbumFlexFieldTest(DummyDataTestCase): def test_sort_asc(self): q = '' sort = dbcore.query.SlowFieldSort("flex1", True) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) self.assertLessEqual(results[1]['flex1'], results[2]['flex1']) # same thing with query string q = 'flex1+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = '' sort = dbcore.query.SlowFieldSort("flex1", False) results = self.lib.albums(q, sort) self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1']) self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1']) # same thing with query string q = 'flex1-' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_two_field_asc(self): q = '' s1 = dbcore.query.SlowFieldSort("flex2", True) s2 = dbcore.query.SlowFieldSort("flex1", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['flex2'], results[1]['flex2']) self.assertLessEqual(results[1]['flex2'], results[2]['flex2']) self.assertEqual(results[0]['flex2'], 'Flex2-A') self.assertEqual(results[1]['flex2'], 'Flex2-A') self.assertLessEqual(results[0]['flex1'], results[1]['flex1']) # same thing with query string q = 'flex2+ flex1+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class SortAlbumComputedFieldTest(DummyDataTestCase): def test_sort_asc(self): q = '' sort = dbcore.query.SlowFieldSort("path", True) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['path'], results[1]['path']) self.assertLessEqual(results[1]['path'], results[2]['path']) # same thing with query string q = 'path+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_sort_desc(self): q = '' sort = dbcore.query.SlowFieldSort("path", False) results = self.lib.albums(q, sort) self.assertGreaterEqual(results[0]['path'], results[1]['path']) self.assertGreaterEqual(results[1]['path'], results[2]['path']) # same thing with query string q = 'path-' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class SortCombinedFieldTest(DummyDataTestCase): def test_computed_first(self): q = '' s1 = dbcore.query.SlowFieldSort("path", True) s2 = dbcore.query.FixedFieldSort("year", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['path'], results[1]['path']) self.assertLessEqual(results[1]['path'], results[2]['path']) q = 'path+ year+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) def test_computed_second(self): q = '' s1 = dbcore.query.FixedFieldSort("year", True) s2 = dbcore.query.SlowFieldSort("path", True) sort = dbcore.query.MultipleSort() sort.add_sort(s1) sort.add_sort(s2) results = self.lib.albums(q, sort) self.assertLessEqual(results[0]['year'], results[1]['year']) self.assertLessEqual(results[1]['year'], results[2]['year']) self.assertLessEqual(results[0]['path'], results[1]['path']) q = 'year+ path+' results2 = self.lib.albums(q) for r1, r2 in zip(results, results2): self.assertEqual(r1.id, r2.id) class ConfigSortTest(DummyDataTestCase): def test_default_sort_item(self): results = list(self.lib.items()) self.assertLess(results[0].artist, results[1].artist) def test_config_opposite_sort_item(self): config['sort_item'] = 'artist-' results = list(self.lib.items()) self.assertGreater(results[0].artist, results[1].artist) def test_default_sort_album(self): results = list(self.lib.albums()) self.assertLess(results[0].albumartist, results[1].albumartist) def test_config_opposite_sort_album(self): config['sort_album'] = 'albumartist-' results = list(self.lib.albums()) self.assertGreater(results[0].albumartist, results[1].albumartist) class CaseSensitivityTest(DummyDataTestCase, _common.TestCase): """If case_insensitive is false, lower-case values should be placed after all upper-case values. E.g., `Foo Qux bar` """ def setUp(self): super().setUp() album = _common.album() album.album = "album" album.genre = "alternative" album.year = "2001" album.flex1 = "flex1" album.flex2 = "flex2-A" album.albumartist = "bar" album.albumartist_sort = None self.lib.add(album) item = _common.item() item.title = 'another' item.artist = 'lowercase' item.album = 'album' item.year = 2001 item.comp = True item.flex1 = "flex1" item.flex2 = "flex2-A" item.album_id = album.id item.artist_sort = None item.track = 10 self.lib.add(item) self.new_album = album self.new_item = item def tearDown(self): self.new_item.remove(delete=True) self.new_album.remove(delete=True) super().tearDown() def test_smart_artist_case_insensitive(self): config['sort_case_insensitive'] = True q = 'artist+' results = list(self.lib.items(q)) self.assertEqual(results[0].artist, 'lowercase') self.assertEqual(results[1].artist, 'One') def test_smart_artist_case_sensitive(self): config['sort_case_insensitive'] = False q = 'artist+' results = list(self.lib.items(q)) self.assertEqual(results[0].artist, 'One') self.assertEqual(results[-1].artist, 'lowercase') def test_fixed_field_case_insensitive(self): config['sort_case_insensitive'] = True q = 'album+' results = list(self.lib.albums(q)) self.assertEqual(results[0].album, 'album') self.assertEqual(results[1].album, 'Album A') def test_fixed_field_case_sensitive(self): config['sort_case_insensitive'] = False q = 'album+' results = list(self.lib.albums(q)) self.assertEqual(results[0].album, 'Album A') self.assertEqual(results[-1].album, 'album') def test_flex_field_case_insensitive(self): config['sort_case_insensitive'] = True q = 'flex1+' results = list(self.lib.items(q)) self.assertEqual(results[0].flex1, 'flex1') self.assertEqual(results[1].flex1, 'Flex1-0') def test_flex_field_case_sensitive(self): config['sort_case_insensitive'] = False q = 'flex1+' results = list(self.lib.items(q)) self.assertEqual(results[0].flex1, 'Flex1-0') self.assertEqual(results[-1].flex1, 'flex1') def test_case_sensitive_only_affects_text(self): config['sort_case_insensitive'] = True q = 'track+' results = list(self.lib.items(q)) # If the numerical values were sorted as strings, # then ['1', '10', '2'] would be valid. print([r.track for r in results]) self.assertEqual(results[0].track, 1) self.assertEqual(results[1].track, 2) self.assertEqual(results[-1].track, 10) class NonExistingFieldTest(DummyDataTestCase): """Test sorting by non-existing fields""" def test_non_existing_fields_not_fail(self): qs = ['foo+', 'foo-', '--', '-+', '+-', '++', '-foo-', '-foo+', '---'] q0 = 'foo+' results0 = list(self.lib.items(q0)) for q1 in qs: results1 = list(self.lib.items(q1)) for r1, r2 in zip(results0, results1): self.assertEqual(r1.id, r2.id) def test_combined_non_existing_field_asc(self): all_results = list(self.lib.items('id+')) q = 'foo+ id+' results = list(self.lib.items(q)) self.assertEqual(len(all_results), len(results)) for r1, r2 in zip(all_results, results): self.assertEqual(r1.id, r2.id) def test_combined_non_existing_field_desc(self): all_results = list(self.lib.items('id+')) q = 'foo- id+' results = list(self.lib.items(q)) self.assertEqual(len(all_results), len(results)) for r1, r2 in zip(all_results, results): self.assertEqual(r1.id, r2.id) def test_field_present_in_some_items(self): """Test ordering by a field not present on all items.""" # append 'foo' to two to items (1,2) items = self.lib.items('id+') ids = [i.id for i in items] items[1].foo = 'bar1' items[2].foo = 'bar2' items[1].store() items[2].store() results_asc = list(self.lib.items('foo+ id+')) self.assertEqual([i.id for i in results_asc], # items without field first [ids[0], ids[3], ids[1], ids[2]]) results_desc = list(self.lib.items('foo- id+')) self.assertEqual([i.id for i in results_desc], # items without field last [ids[2], ids[1], ids[0], ids[3]]) def test_negation_interaction(self): """Test the handling of negation and sorting together. If a string ends with a sorting suffix, it takes precedence over the NotQuery parsing. """ query, sort = beets.library.parse_query_string('-bar+', beets.library.Item) self.assertEqual(len(query.subqueries), 1) self.assertTrue(isinstance(query.subqueries[0], dbcore.query.TrueQuery)) self.assertTrue(isinstance(sort, dbcore.query.SlowFieldSort)) self.assertEqual(sort.field, '-bar') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1637959898.0 beets-1.6.0/test/test_spotify.py0000644000076500000240000001360000000000000016412 0ustar00asampsonstaff"""Tests for the 'spotify' plugin""" import os import responses import unittest from test import _common from beets import config from beets.library import Item from beetsplug import spotify from test.helper import TestHelper from six.moves.urllib.parse import parse_qs, urlparse class ArgumentsMock: def __init__(self, mode, show_failures): self.mode = mode self.show_failures = show_failures self.verbose = 1 def _params(url): """Get the query parameters from a URL.""" return parse_qs(urlparse(url).query) class SpotifyPluginTest(_common.TestCase, TestHelper): @responses.activate def setUp(self): config.clear() self.setup_beets() responses.add( responses.POST, spotify.SpotifyPlugin.oauth_token_url, status=200, json={ 'access_token': '3XyiC3raJySbIAV5LVYj1DaWbcocNi3LAJTNXRnYY' 'GVUl6mbbqXNhW3YcZnQgYXNWHFkVGSMlc0tMuvq8CF', 'token_type': 'Bearer', 'expires_in': 3600, 'scope': '', }, ) self.spotify = spotify.SpotifyPlugin() opts = ArgumentsMock("list", False) self.spotify._parse_opts(opts) def tearDown(self): self.teardown_beets() def test_args(self): opts = ArgumentsMock("fail", True) self.assertEqual(False, self.spotify._parse_opts(opts)) opts = ArgumentsMock("list", False) self.assertEqual(True, self.spotify._parse_opts(opts)) def test_empty_query(self): self.assertEqual( None, self.spotify._match_library_tracks(self.lib, "1=2") ) @responses.activate def test_missing_request(self): json_file = os.path.join( _common.RSRC, b'spotify', b'missing_request.json' ) with open(json_file, 'rb') as f: response_body = f.read() responses.add( responses.GET, spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type='application/json', ) item = Item( mb_trackid='01234', album='lkajsdflakjsd', albumartist='ujydfsuihse', title='duifhjslkef', length=10, ) item.add(self.lib) self.assertEqual([], self.spotify._match_library_tracks(self.lib, "")) params = _params(responses.calls[0].request.url) query = params['q'][0] self.assertIn('duifhjslkef', query) self.assertIn('artist:ujydfsuihse', query) self.assertIn('album:lkajsdflakjsd', query) self.assertEqual(params['type'], ['track']) @responses.activate def test_track_request(self): json_file = os.path.join( _common.RSRC, b'spotify', b'track_request.json' ) with open(json_file, 'rb') as f: response_body = f.read() responses.add( responses.GET, spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type='application/json', ) item = Item( mb_trackid='01234', album='Despicable Me 2', albumartist='Pharrell Williams', title='Happy', length=10, ) item.add(self.lib) results = self.spotify._match_library_tracks(self.lib, "Happy") self.assertEqual(1, len(results)) self.assertEqual("6NPVjNh8Jhru9xOmyQigds", results[0]['id']) self.spotify._output_match_results(results) params = _params(responses.calls[0].request.url) query = params['q'][0] self.assertIn('Happy', query) self.assertIn('artist:Pharrell Williams', query) self.assertIn('album:Despicable Me 2', query) self.assertEqual(params['type'], ['track']) @responses.activate def test_track_for_id(self): """Tests if plugin is able to fetch a track by its Spotify ID""" # Mock the Spotify 'Get Track' call json_file = os.path.join( _common.RSRC, b'spotify', b'track_info.json' ) with open(json_file, 'rb') as f: response_body = f.read() responses.add( responses.GET, spotify.SpotifyPlugin.track_url + '6NPVjNh8Jhru9xOmyQigds', body=response_body, status=200, content_type='application/json', ) # Mock the Spotify 'Get Album' call json_file = os.path.join( _common.RSRC, b'spotify', b'album_info.json' ) with open(json_file, 'rb') as f: response_body = f.read() responses.add( responses.GET, spotify.SpotifyPlugin.album_url + '5l3zEmMrOhOzG8d8s83GOL', body=response_body, status=200, content_type='application/json', ) # Mock the Spotify 'Search' call json_file = os.path.join( _common.RSRC, b'spotify', b'track_request.json' ) with open(json_file, 'rb') as f: response_body = f.read() responses.add( responses.GET, spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type='application/json', ) track_info = self.spotify.track_for_id('6NPVjNh8Jhru9xOmyQigds') item = Item( mb_trackid=track_info.track_id, albumartist=track_info.artist, title=track_info.title, length=track_info.length ) item.add(self.lib) results = self.spotify._match_library_tracks(self.lib, "Happy") self.assertEqual(1, len(results)) self.assertEqual("6NPVjNh8Jhru9xOmyQigds", results[0]['id']) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_subsonicupdate.py0000644000076500000240000001165100000000000017751 0ustar00asampsonstaff"""Tests for the 'subsonic' plugin.""" import responses import unittest from test import _common from beets import config from beetsplug import subsonicupdate from test.helper import TestHelper from urllib.parse import parse_qs, urlparse class ArgumentsMock: """Argument mocks for tests.""" def __init__(self, mode, show_failures): """Constructs ArgumentsMock.""" self.mode = mode self.show_failures = show_failures self.verbose = 1 def _params(url): """Get the query parameters from a URL.""" return parse_qs(urlparse(url).query) class SubsonicPluginTest(_common.TestCase, TestHelper): """Test class for subsonicupdate.""" @responses.activate def setUp(self): """Sets up config and plugin for test.""" config.clear() self.setup_beets() config["subsonic"]["user"] = "admin" config["subsonic"]["pass"] = "admin" config["subsonic"]["url"] = "http://localhost:4040" responses.add( responses.GET, 'http://localhost:4040/rest/ping.view', status=200, body=self.PING_BODY ) self.subsonicupdate = subsonicupdate.SubsonicUpdate() PING_BODY = ''' { "subsonic-response": { "status": "failed", "version": "1.15.0" } } ''' SUCCESS_BODY = ''' { "subsonic-response": { "status": "ok", "version": "1.15.0", "scanStatus": { "scanning": true, "count": 1000 } } } ''' FAILED_BODY = ''' { "subsonic-response": { "status": "failed", "version": "1.15.0", "error": { "code": 40, "message": "Wrong username or password." } } } ''' ERROR_BODY = ''' { "timestamp": 1599185854498, "status": 404, "error": "Not Found", "message": "No message available", "path": "/rest/startScn" } ''' def tearDown(self): """Tears down tests.""" self.teardown_beets() @responses.activate def test_start_scan(self): """Tests success path based on best case scenario.""" responses.add( responses.GET, 'http://localhost:4040/rest/startScan', status=200, body=self.SUCCESS_BODY ) self.subsonicupdate.start_scan() @responses.activate def test_start_scan_failed_bad_credentials(self): """Tests failed path based on bad credentials.""" responses.add( responses.GET, 'http://localhost:4040/rest/startScan', status=200, body=self.FAILED_BODY ) self.subsonicupdate.start_scan() @responses.activate def test_start_scan_failed_not_found(self): """Tests failed path based on resource not found.""" responses.add( responses.GET, 'http://localhost:4040/rest/startScan', status=404, body=self.ERROR_BODY ) self.subsonicupdate.start_scan() def test_start_scan_failed_unreachable(self): """Tests failed path based on service not available.""" self.subsonicupdate.start_scan() @responses.activate def test_url_with_context_path(self): """Tests success for included with contextPath.""" config["subsonic"]["url"] = "http://localhost:4040/contextPath/" responses.add( responses.GET, 'http://localhost:4040/contextPath/rest/startScan', status=200, body=self.SUCCESS_BODY ) self.subsonicupdate.start_scan() @responses.activate def test_url_with_trailing_forward_slash_url(self): """Tests success path based on trailing forward slash.""" config["subsonic"]["url"] = "http://localhost:4040/" responses.add( responses.GET, 'http://localhost:4040/rest/startScan', status=200, body=self.SUCCESS_BODY ) self.subsonicupdate.start_scan() @responses.activate def test_url_with_missing_port(self): """Tests failed path based on missing port.""" config["subsonic"]["url"] = "http://localhost/airsonic" responses.add( responses.GET, 'http://localhost/airsonic/rest/startScan', status=200, body=self.SUCCESS_BODY ) self.subsonicupdate.start_scan() @responses.activate def test_url_with_missing_schema(self): """Tests failed path based on missing schema.""" config["subsonic"]["url"] = "localhost:4040/airsonic" responses.add( responses.GET, 'http://localhost:4040/rest/startScan', status=200, body=self.SUCCESS_BODY ) self.subsonicupdate.start_scan() def suite(): """Default test suite.""" return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_template.py0000644000076500000240000002460500000000000016537 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for template engine. """ 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, str): textbuf.append(part) else: if textbuf: text = ''.join(textbuf) if text: yield text textbuf = [] yield part if textbuf: text = ''.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('')), []) def _assert_symbol(self, obj, ident): """Assert that an object is a Symbol with the given identifier. """ self.assertTrue(isinstance(obj, functemplate.Symbol), "not a Symbol: %s" % repr(obj)) self.assertEqual(obj.ident, ident, "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), "not a Call: %s" % repr(obj)) self.assertEqual(obj.ident, ident, "wrong identifier: %s vs. %s" % (repr(obj.ident), repr(ident))) self.assertEqual(len(obj.args), numargs, "wrong argument count in %s: %i vs. %i" % (repr(obj.ident), len(obj.args), numargs)) def test_plain_text(self): self.assertEqual(list(_normparse('hello world')), ['hello world']) def test_escaped_character_only(self): self.assertEqual(list(_normparse('$$')), ['$']) def test_escaped_character_in_text(self): self.assertEqual(list(_normparse('a $$ b')), ['a $ b']) def test_escaped_character_at_start(self): self.assertEqual(list(_normparse('$$ hello')), ['$ hello']) def test_escaped_character_at_end(self): self.assertEqual(list(_normparse('hello $$')), ['hello $']) def test_escaped_function_delim(self): self.assertEqual(list(_normparse('a $% b')), ['a % b']) def test_escaped_sep(self): self.assertEqual(list(_normparse('a $, b')), ['a , b']) def test_escaped_close_brace(self): self.assertEqual(list(_normparse('a $} b')), ['a } b']) def test_bare_value_delim_kept_intact(self): self.assertEqual(list(_normparse('a $ b')), ['a $ b']) def test_bare_function_delim_kept_intact(self): self.assertEqual(list(_normparse('a % b')), ['a % b']) def test_bare_opener_kept_intact(self): self.assertEqual(list(_normparse('a { b')), ['a { b']) def test_bare_closer_kept_intact(self): self.assertEqual(list(_normparse('a } b')), ['a } b']) def test_bare_sep_kept_intact(self): self.assertEqual(list(_normparse('a , b')), ['a , b']) def test_symbol_alone(self): parts = list(_normparse('$foo')) self.assertEqual(len(parts), 1) self._assert_symbol(parts[0], "foo") def test_symbol_in_text(self): parts = list(_normparse('hello $foo world')) self.assertEqual(len(parts), 3) self.assertEqual(parts[0], 'hello ') self._assert_symbol(parts[1], "foo") self.assertEqual(parts[2], ' world') def test_symbol_with_braces(self): parts = list(_normparse('hello${foo}world')) self.assertEqual(len(parts), 3) self.assertEqual(parts[0], 'hello') self._assert_symbol(parts[1], "foo") self.assertEqual(parts[2], 'world') def test_unclosed_braces_symbol(self): self.assertEqual(list(_normparse('a ${ b')), ['a ${ b']) def test_empty_braces_symbol(self): self.assertEqual(list(_normparse('a ${} b')), ['a ${} b']) def test_call_without_args_at_end(self): self.assertEqual(list(_normparse('foo %bar')), ['foo %bar']) def test_call_without_args(self): self.assertEqual(list(_normparse('foo %bar baz')), ['foo %bar baz']) def test_call_with_unclosed_args(self): self.assertEqual(list(_normparse('foo %bar{ baz')), ['foo %bar{ baz']) def test_call_with_unclosed_multiple_args(self): self.assertEqual(list(_normparse('foo %bar{bar,bar baz')), ['foo %bar{bar,bar baz']) def test_call_empty_arg(self): parts = list(_normparse('%foo{}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], "foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), []) def test_call_single_arg(self): parts = list(_normparse('%foo{bar}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], "foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), ['bar']) def test_call_two_args(self): parts = list(_normparse('%foo{bar,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], "foo", 2) self.assertEqual(list(_normexpr(parts[0].args[0])), ['bar']) self.assertEqual(list(_normexpr(parts[0].args[1])), ['baz']) def test_call_with_escaped_sep(self): parts = list(_normparse('%foo{bar$,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], "foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), ['bar,baz']) def test_call_with_escaped_close(self): parts = list(_normparse('%foo{bar$}baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], "foo", 1) self.assertEqual(list(_normexpr(parts[0].args[0])), ['bar}baz']) def test_call_with_symbol_argument(self): parts = list(_normparse('%foo{$bar,baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], "foo", 2) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_symbol(arg_parts[0], "bar") self.assertEqual(list(_normexpr(parts[0].args[1])), ["baz"]) def test_call_with_nested_call_argument(self): parts = list(_normparse('%foo{%bar{},baz}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], "foo", 2) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_call(arg_parts[0], "bar", 1) self.assertEqual(list(_normexpr(parts[0].args[1])), ["baz"]) def test_nested_call_with_argument(self): parts = list(_normparse('%foo{%bar{baz}}')) self.assertEqual(len(parts), 1) self._assert_call(parts[0], "foo", 1) arg_parts = list(_normexpr(parts[0].args[0])) self.assertEqual(len(arg_parts), 1) self._assert_call(arg_parts[0], "bar", 1) self.assertEqual(list(_normexpr(arg_parts[0].args[0])), ['baz']) def test_sep_before_call_two_args(self): parts = list(_normparse('hello, %foo{bar,baz}')) self.assertEqual(len(parts), 2) self.assertEqual(parts[0], 'hello, ') self._assert_call(parts[1], "foo", 2) self.assertEqual(list(_normexpr(parts[1].args[0])), ['bar']) self.assertEqual(list(_normexpr(parts[1].args[1])), ['baz']) def test_sep_with_symbols(self): parts = list(_normparse('hello,$foo,$bar')) self.assertEqual(len(parts), 4) self.assertEqual(parts[0], 'hello,') self._assert_symbol(parts[1], "foo") self.assertEqual(parts[2], ',') self._assert_symbol(parts[3], "bar") def test_newline_at_end(self): parts = list(_normparse('foo\n')) self.assertEqual(len(parts), 1) self.assertEqual(parts[0], 'foo\n') class EvalTest(unittest.TestCase): def _eval(self, template): values = { 'foo': 'bar', 'baz': 'BaR', } functions = { 'lower': str.lower, 'len': len, } return functemplate.Template(template).substitute(values, functions) def test_plain_text(self): self.assertEqual(self._eval("foo"), "foo") def test_subtitute_value(self): self.assertEqual(self._eval("$foo"), "bar") def test_subtitute_value_in_text(self): self.assertEqual(self._eval("hello $foo world"), "hello bar world") def test_not_subtitute_undefined_value(self): self.assertEqual(self._eval("$bar"), "$bar") def test_function_call(self): self.assertEqual(self._eval("%lower{FOO}"), "foo") def test_function_call_with_text(self): self.assertEqual(self._eval("A %lower{FOO} B"), "A foo B") def test_nested_function_call(self): self.assertEqual(self._eval("%lower{%lower{FOO}}"), "foo") def test_symbol_in_argument(self): self.assertEqual(self._eval("%lower{$baz}"), "bar") def test_function_call_exception(self): res = self._eval("%lower{a,b,c,d,e}") self.assertTrue(isinstance(res, str)) def test_function_returning_integer(self): self.assertEqual(self._eval("%len{foo}"), "3") def test_not_subtitute_undefined_func(self): self.assertEqual(self._eval("%bar{}"), "%bar{}") def test_not_subtitute_func_with_no_args(self): self.assertEqual(self._eval("%lower"), "%lower") def test_function_call_with_empty_arg(self): self.assertEqual(self._eval("%len{}"), "0") def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_the.py0000644000076500000240000000533300000000000015501 0ustar00asampsonstaff"""Tests for the 'the' plugin""" import unittest from test 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') self.assertEqual(ThePlugin().unthe('TET - Travailleur', PATTERN_THE), 'TET - Travailleur') 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] 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'] = ['^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'] = '{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') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_thumbnails.py0000644000076500000240000002603400000000000017070 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Bruno Cauet # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os.path from unittest.mock import Mock, patch, call from tempfile import mkdtemp from shutil import rmtree import unittest from test.helper import TestHelper from beets.util import bytestring_path from beetsplug.thumbnails import (ThumbnailsPlugin, NORMAL_DIR, LARGE_DIR, write_metadata_im, write_metadata_pil, PathlibURI, GioURI) class ThumbnailsTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() @patch('beetsplug.thumbnails.util') def test_write_metadata_im(self, mock_util): metadata = {"a": "A", "b": "B"} write_metadata_im("foo", metadata) try: command = "convert foo -set a A -set b B foo".split(' ') mock_util.command_output.assert_called_once_with(command) except AssertionError: command = "convert foo -set b B -set a A foo".split(' ') mock_util.command_output.assert_called_once_with(command) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.os.stat') def test_add_tags(self, mock_stat, _): plugin = ThumbnailsPlugin() plugin.write_metadata = Mock() plugin.get_uri = Mock(side_effect={b"/path/to/cover": "COVER_URI"}.__getitem__) album = Mock(artpath=b"/path/to/cover") mock_stat.return_value.st_mtime = 12345 plugin.add_tags(album, b"/path/to/thumbnail") metadata = {"Thumb::URI": "COVER_URI", "Thumb::MTime": "12345"} plugin.write_metadata.assert_called_once_with(b"/path/to/thumbnail", metadata) mock_stat.assert_called_once_with(album.artpath) @patch('beetsplug.thumbnails.os') @patch('beetsplug.thumbnails.ArtResizer') @patch('beetsplug.thumbnails.get_im_version') @patch('beetsplug.thumbnails.get_pil_version') @patch('beetsplug.thumbnails.GioURI') def test_check_local_ok(self, mock_giouri, mock_pil, mock_im, mock_artresizer, mock_os): # test local resizing capability mock_artresizer.shared.local = False plugin = ThumbnailsPlugin() self.assertFalse(plugin._check_local_ok()) # test dirs creation mock_artresizer.shared.local = True def exists(path): if path == NORMAL_DIR: return False if path == LARGE_DIR: return True raise ValueError(f"unexpected path {path!r}") mock_os.path.exists = exists plugin = ThumbnailsPlugin() mock_os.makedirs.assert_called_once_with(NORMAL_DIR) self.assertTrue(plugin._check_local_ok()) # test metadata writer function mock_os.path.exists = lambda _: True mock_pil.return_value = False mock_im.return_value = False with self.assertRaises(AssertionError): ThumbnailsPlugin() mock_pil.return_value = True self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_pil) mock_im.return_value = True self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im) mock_pil.return_value = False self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im) self.assertTrue(ThumbnailsPlugin()._check_local_ok()) # test URI getter function giouri_inst = mock_giouri.return_value giouri_inst.available = True self.assertEqual(ThumbnailsPlugin().get_uri, giouri_inst.uri) giouri_inst.available = False self.assertEqual(ThumbnailsPlugin().get_uri.__self__.__class__, PathlibURI) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.ArtResizer') @patch('beetsplug.thumbnails.util') @patch('beetsplug.thumbnails.os') @patch('beetsplug.thumbnails.shutil') def test_make_cover_thumbnail(self, mock_shutils, mock_os, mock_util, mock_artresizer, _): thumbnail_dir = os.path.normpath(b"/thumbnail/dir") md5_file = os.path.join(thumbnail_dir, b"md5") path_to_art = os.path.normpath(b"/path/to/art") mock_os.path.join = os.path.join # don't mock that function plugin = ThumbnailsPlugin() plugin.add_tags = Mock() album = Mock(artpath=path_to_art) mock_util.syspath.side_effect = lambda x: x plugin.thumbnail_file_name = Mock(return_value=b'md5') mock_os.path.exists.return_value = False def os_stat(target): if target == md5_file: return Mock(st_mtime=1) elif target == path_to_art: return Mock(st_mtime=2) else: raise ValueError(f"invalid target {target}") mock_os.stat.side_effect = os_stat plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) mock_os.path.exists.assert_called_once_with(md5_file) mock_os.stat.has_calls([call(md5_file), call(path_to_art)], any_order=True) resize = mock_artresizer.shared.resize resize.assert_called_once_with(12345, path_to_art, md5_file) plugin.add_tags.assert_called_once_with(album, resize.return_value) mock_shutils.move.assert_called_once_with(resize.return_value, md5_file) # now test with recent thumbnail & with force mock_os.path.exists.return_value = True plugin.force = False resize.reset_mock() def os_stat(target): if target == md5_file: return Mock(st_mtime=3) elif target == path_to_art: return Mock(st_mtime=2) else: raise ValueError(f"invalid target {target}") mock_os.stat.side_effect = os_stat plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) self.assertEqual(resize.call_count, 0) # and with force plugin.config['force'] = True plugin.make_cover_thumbnail(album, 12345, thumbnail_dir) resize.assert_called_once_with(12345, path_to_art, md5_file) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') def test_make_dolphin_cover_thumbnail(self, _): plugin = ThumbnailsPlugin() tmp = bytestring_path(mkdtemp()) album = Mock(path=tmp, artpath=os.path.join(tmp, b"cover.jpg")) plugin.make_dolphin_cover_thumbnail(album) with open(os.path.join(tmp, b".directory"), "rb") as f: self.assertEqual( f.read().splitlines(), [b"[Desktop Entry]", b"Icon=./cover.jpg"] ) # not rewritten when it already exists (yup that's a big limitation) album.artpath = b"/my/awesome/art.tiff" plugin.make_dolphin_cover_thumbnail(album) with open(os.path.join(tmp, b".directory"), "rb") as f: self.assertEqual( f.read().splitlines(), [b"[Desktop Entry]", b"Icon=./cover.jpg"] ) rmtree(tmp) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.ArtResizer') def test_process_album(self, mock_artresizer, _): get_size = mock_artresizer.shared.get_size plugin = ThumbnailsPlugin() make_cover = plugin.make_cover_thumbnail = Mock(return_value=True) make_dolphin = plugin.make_dolphin_cover_thumbnail = Mock() # no art album = Mock(artpath=None) plugin.process_album(album) self.assertEqual(get_size.call_count, 0) self.assertEqual(make_dolphin.call_count, 0) # cannot get art size album.artpath = b"/path/to/art" get_size.return_value = None plugin.process_album(album) get_size.assert_called_once_with(b"/path/to/art") self.assertEqual(make_cover.call_count, 0) # dolphin tests plugin.config['dolphin'] = False plugin.process_album(album) self.assertEqual(make_dolphin.call_count, 0) plugin.config['dolphin'] = True plugin.process_album(album) make_dolphin.assert_called_once_with(album) # small art get_size.return_value = 200, 200 plugin.process_album(album) make_cover.assert_called_once_with(album, 128, NORMAL_DIR) # big art make_cover.reset_mock() get_size.return_value = 500, 500 plugin.process_album(album) make_cover.has_calls([call(album, 128, NORMAL_DIR), call(album, 256, LARGE_DIR)], any_order=True) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.decargs') def test_invokations(self, mock_decargs, _): plugin = ThumbnailsPlugin() plugin.process_album = Mock() album = Mock() plugin.process_album.reset_mock() lib = Mock() album2 = Mock() lib.albums.return_value = [album, album2] plugin.process_query(lib, Mock(), None) lib.albums.assert_called_once_with(mock_decargs.return_value) plugin.process_album.has_calls([call(album), call(album2)], any_order=True) @patch('beetsplug.thumbnails.BaseDirectory') def test_thumbnail_file_name(self, mock_basedir): plug = ThumbnailsPlugin() plug.get_uri = Mock(return_value="file:///my/uri") self.assertEqual(plug.thumbnail_file_name(b'idontcare'), b"9488f5797fbe12ffb316d607dfd93d04.png") def test_uri(self): gio = GioURI() if not gio.available: self.skipTest("GIO library not found") self.assertEqual(gio.uri("/foo"), "file:///") # silent fail self.assertEqual(gio.uri(b"/foo"), "file:///foo") self.assertEqual(gio.uri(b"/foo!"), "file:///foo!") self.assertEqual( gio.uri(b'/music/\xec\x8b\xb8\xec\x9d\xb4'), 'file:///music/%EC%8B%B8%EC%9D%B4') class TestPathlibURI(): """Test PathlibURI class""" def test_uri(self): test_uri = PathlibURI() # test it won't break if we pass it bytes for a path test_uri.uri(b'/') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_types_plugin.py0000644000076500000240000001517500000000000017450 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import time from datetime import datetime import unittest from test.helper import TestHelper from confuse import ConfigValueError class TypesPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.load_plugins('types') def tearDown(self): self.unload_plugins() self.teardown_beets() def test_integer_modify_and_query(self): self.config['types'] = {'myint': 'int'} item = self.add_item(artist='aaa') # Do not match unset values out = self.list('myint:1..3') self.assertEqual('', out) self.modify('myint=2') item.load() self.assertEqual(item['myint'], 2) # Match in range out = self.list('myint:1..3') self.assertIn('aaa', out) def test_album_integer_modify_and_query(self): self.config['types'] = {'myint': 'int'} album = self.add_album(albumartist='aaa') # Do not match unset values out = self.list_album('myint:1..3') self.assertEqual('', out) self.modify('-a', 'myint=2') album.load() self.assertEqual(album['myint'], 2) # Match in range out = self.list_album('myint:1..3') self.assertIn('aaa', out) def test_float_modify_and_query(self): self.config['types'] = {'myfloat': 'float'} item = self.add_item(artist='aaa') # Do not match unset values out = self.list('myfloat:10..0') self.assertEqual('', out) self.modify('myfloat=-9.1') item.load() self.assertEqual(item['myfloat'], -9.1) # Match in range out = self.list('myfloat:-10..0') self.assertIn('aaa', out) def test_bool_modify_and_query(self): self.config['types'] = {'mybool': 'bool'} true = self.add_item(artist='true') false = self.add_item(artist='false') self.add_item(artist='unset') # Do not match unset values out = self.list('mybool:true, mybool:false') self.assertEqual('', out) # Set true self.modify('mybool=1', 'artist:true') true.load() self.assertEqual(true['mybool'], True) # Set false self.modify('mybool=false', 'artist:false') false.load() self.assertEqual(false['mybool'], False) # Query bools out = self.list('mybool:true', '$artist $mybool') self.assertEqual('true True', out) out = self.list('mybool:false', '$artist $mybool') # Dealing with unset fields? # self.assertEqual('false False', out) # out = self.list('mybool:', '$artist $mybool') # self.assertIn('unset $mybool', out) def test_date_modify_and_query(self): self.config['types'] = {'mydate': 'date'} # FIXME parsing should also work with default time format self.config['time_format'] = '%Y-%m-%d' old = self.add_item(artist='prince') new = self.add_item(artist='britney') # Do not match unset values out = self.list('mydate:..2000') self.assertEqual('', out) self.modify('mydate=1999-01-01', 'artist:prince') old.load() self.assertEqual(old['mydate'], mktime(1999, 1, 1)) self.modify('mydate=1999-12-30', 'artist:britney') new.load() self.assertEqual(new['mydate'], mktime(1999, 12, 30)) # Match in range out = self.list('mydate:..1999-07', '$artist $mydate') self.assertEqual('prince 1999-01-01', out) # FIXME some sort of timezone issue here # out = self.list('mydate:1999-12-30', '$artist $mydate') # self.assertEqual('britney 1999-12-30', out) def test_unknown_type_error(self): self.config['types'] = {'flex': 'unkown type'} with self.assertRaises(ConfigValueError): self.run_command('ls') def test_template_if_def(self): # Tests for a subtle bug when using %ifdef in templates along with # types that have truthy default values (e.g. '0', '0.0', 'False') # https://github.com/beetbox/beets/issues/3852 self.config['types'] = {'playcount': 'int', 'rating': 'float', 'starred': 'bool'} with_fields = self.add_item(artist='prince') self.modify('playcount=10', 'artist=prince') self.modify('rating=5.0', 'artist=prince') self.modify('starred=yes', 'artist=prince') with_fields.load() without_fields = self.add_item(artist='britney') int_template = '%ifdef{playcount,Play count: $playcount,Not played}' self.assertEqual(with_fields.evaluate_template(int_template), 'Play count: 10') self.assertEqual(without_fields.evaluate_template(int_template), 'Not played') float_template = '%ifdef{rating,Rating: $rating,Not rated}' self.assertEqual(with_fields.evaluate_template(float_template), 'Rating: 5.0') self.assertEqual(without_fields.evaluate_template(float_template), 'Not rated') bool_template = '%ifdef{starred,Starred: $starred,Not starred}' self.assertIn(with_fields.evaluate_template(bool_template).lower(), ('starred: true', 'starred: yes', 'starred: y')) self.assertEqual(without_fields.evaluate_template(bool_template), 'Not starred') def modify(self, *args): return self.run_with_output('modify', '--yes', '--nowrite', '--nomove', *args) def list(self, query, fmt='$artist - $album - $title'): return self.run_with_output('ls', '-f', fmt, query).strip() def list_album(self, query, fmt='$albumartist - $album - $title'): return self.run_with_output('ls', '-a', '-f', fmt, query).strip() def mktime(*args): return time.mktime(datetime(*args).timetuple()) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_ui.py0000644000076500000240000014511200000000000015336 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the command-line interface. """ import os import shutil import re import subprocess import platform import sys import unittest from unittest.mock import patch, Mock from test import _common from test.helper import capture_stdout, has_program, TestHelper, control_stdin from beets import library from beets import ui from beets.ui import commands from beets import autotag from beets.autotag.match import distance from mediafile import MediaFile from beets import config from beets import plugins from confuse import ConfigError from beets import util from beets.util import syspath, MoveOperation class ListTest(unittest.TestCase): def setUp(self): self.lib = library.Library(':memory:') self.item = _common.item() self.item.path = 'xxx/yyy' self.lib.add(self.item) self.lib.add_album([self.item]) def _run_list(self, query='', album=False, path=False, fmt=''): with capture_stdout() as stdout: commands.list_items(self.lib, query, album, fmt) return stdout def test_list_outputs_item(self): stdout = self._run_list() self.assertIn('the title', stdout.getvalue()) def test_list_unicode_query(self): self.item.title = 'na\xefve' self.item.store() self.lib._connection().commit() stdout = self._run_list(['na\xefve']) out = stdout.getvalue() self.assertTrue('na\xefve' in out) def test_list_item_path(self): stdout = self._run_list(fmt='$path') self.assertEqual(stdout.getvalue().strip(), 'xxx/yyy') def test_list_album_outputs_something(self): stdout = self._run_list(album=True) self.assertGreater(len(stdout.getvalue()), 0) def test_list_album_path(self): stdout = self._run_list(album=True, fmt='$path') self.assertEqual(stdout.getvalue().strip(), 'xxx') def test_list_album_omits_title(self): stdout = self._run_list(album=True) self.assertNotIn('the title', stdout.getvalue()) def test_list_uses_track_artist(self): stdout = self._run_list() self.assertIn('the artist', stdout.getvalue()) self.assertNotIn('the album artist', stdout.getvalue()) def test_list_album_uses_album_artist(self): stdout = self._run_list(album=True) self.assertNotIn('the artist', stdout.getvalue()) self.assertIn('the album artist', stdout.getvalue()) def test_list_item_format_artist(self): stdout = self._run_list(fmt='$artist') self.assertIn('the artist', stdout.getvalue()) def test_list_item_format_multiple(self): stdout = self._run_list(fmt='$artist - $album - $year') self.assertEqual('the artist - the album - 0001', stdout.getvalue().strip()) def test_list_album_format(self): stdout = self._run_list(album=True, fmt='$genre') self.assertIn('the genre', stdout.getvalue()) self.assertNotIn('the album', stdout.getvalue()) class RemoveTest(_common.TestCase, TestHelper): def setUp(self): super().setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, b'testlibdir') os.mkdir(self.libdir) # Copy a file into the library. self.lib = library.Library(':memory:', self.libdir) self.item_path = os.path.join(_common.RSRC, b'full.mp3') self.i = library.Item.from_path(self.item_path) self.lib.add(self.i) self.i.move(operation=MoveOperation.COPY) def test_remove_items_no_delete(self): self.io.addinput('y') commands.remove_items(self.lib, '', False, 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, False) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertFalse(os.path.exists(self.i.path)) def test_remove_items_with_force_no_delete(self): commands.remove_items(self.lib, '', False, False, True) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertTrue(os.path.exists(self.i.path)) def test_remove_items_with_force_delete(self): commands.remove_items(self.lib, '', False, True, True) items = self.lib.items() self.assertEqual(len(list(items)), 0) self.assertFalse(os.path.exists(self.i.path)) def test_remove_items_select_with_delete(self): i2 = library.Item.from_path(self.item_path) self.lib.add(i2) i2.move(operation=MoveOperation.COPY) for s in ('s', 'y', 'n'): self.io.addinput(s) commands.remove_items(self.lib, '', False, True, False) items = self.lib.items() self.assertEqual(len(list(items)), 1) # There is probably no guarantee that the items are queried in any # spcecific order, thus just ensure that exactly one was removed. # To improve upon this, self.io would need to have the capability to # generate input that depends on previous output. num_existing = 0 num_existing += 1 if os.path.exists(syspath(self.i.path)) else 0 num_existing += 1 if os.path.exists(syspath(i2.path)) else 0 self.assertEqual(num_existing, 1) def test_remove_albums_select_with_delete(self): a1 = self.add_album_fixture() a2 = self.add_album_fixture() path1 = a1.items()[0].path path2 = a2.items()[0].path items = self.lib.items() self.assertEqual(len(list(items)), 3) for s in ('s', 'y', 'n'): self.io.addinput(s) commands.remove_items(self.lib, '', True, True, False) items = self.lib.items() self.assertEqual(len(list(items)), 2) # incl. the item from setUp() # See test_remove_items_select_with_delete() num_existing = 0 num_existing += 1 if os.path.exists(syspath(path1)) else 0 num_existing += 1 if os.path.exists(syspath(path2)) else 0 self.assertEqual(num_existing, 1) class ModifyTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.album = self.add_album_fixture() [self.item] = self.album.items() def tearDown(self): self.teardown_beets() def modify_inp(self, inp, *args): with control_stdin(inp): self.run_command('modify', *args) def modify(self, *args): self.modify_inp('y', *args) # Item tests def test_modify_item(self): self.modify("title=newTitle") item = self.lib.items().get() self.assertEqual(item.title, 'newTitle') def test_modify_item_abort(self): item = self.lib.items().get() title = item.title self.modify_inp('n', "title=newTitle") item = self.lib.items().get() self.assertEqual(item.title, title) def test_modify_item_no_change(self): title = "Tracktitle" item = self.add_item_fixture(title=title) self.modify_inp('y', "title", f"title={title}") item = self.lib.items(title).get() self.assertEqual(item.title, title) def test_modify_write_tags(self): self.modify("title=newTitle") item = self.lib.items().get() item.read() self.assertEqual(item.title, 'newTitle') def test_modify_dont_write_tags(self): self.modify("--nowrite", "title=newTitle") item = self.lib.items().get() item.read() self.assertNotEqual(item.title, 'newTitle') def test_move(self): self.modify("title=newTitle") item = self.lib.items().get() self.assertIn(b'newTitle', item.path) def test_not_move(self): self.modify("--nomove", "title=newTitle") item = self.lib.items().get() self.assertNotIn(b'newTitle', item.path) def test_no_write_no_move(self): self.modify("--nomove", "--nowrite", "title=newTitle") item = self.lib.items().get() item.read() self.assertNotIn(b'newTitle', item.path) self.assertNotEqual(item.title, 'newTitle') def test_update_mtime(self): item = self.item old_mtime = item.mtime self.modify("title=newTitle") item.load() self.assertNotEqual(old_mtime, item.mtime) self.assertEqual(item.current_mtime(), item.mtime) def test_reset_mtime_with_no_write(self): item = self.item self.modify("--nowrite", "title=newTitle") item.load() self.assertEqual(0, item.mtime) def test_selective_modify(self): title = "Tracktitle" album = "album" original_artist = "composer" new_artist = "coverArtist" for i in range(0, 10): self.add_item_fixture(title=f"{title}{i}", artist=original_artist, album=album) self.modify_inp('s\ny\ny\ny\nn\nn\ny\ny\ny\ny\nn', title, f"artist={new_artist}") original_items = self.lib.items(f"artist:{original_artist}") new_items = self.lib.items(f"artist:{new_artist}") self.assertEqual(len(list(original_items)), 3) self.assertEqual(len(list(new_items)), 7) # Album Tests def test_modify_album(self): self.modify("--album", "album=newAlbum") album = self.lib.albums().get() self.assertEqual(album.album, 'newAlbum') def test_modify_album_write_tags(self): self.modify("--album", "album=newAlbum") item = self.lib.items().get() item.read() self.assertEqual(item.album, 'newAlbum') def test_modify_album_dont_write_tags(self): self.modify("--album", "--nowrite", "album=newAlbum") item = self.lib.items().get() item.read() self.assertEqual(item.album, 'the album') def test_album_move(self): self.modify("--album", "album=newAlbum") item = self.lib.items().get() item.read() self.assertIn(b'newAlbum', item.path) def test_album_not_move(self): self.modify("--nomove", "--album", "album=newAlbum") item = self.lib.items().get() item.read() self.assertNotIn(b'newAlbum', item.path) # Misc def test_write_initial_key_tag(self): self.modify("initial_key=C#m") item = self.lib.items().get() mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.initial_key, 'C#m') def test_set_flexattr(self): self.modify("flexattr=testAttr") item = self.lib.items().get() self.assertEqual(item.flexattr, 'testAttr') def test_remove_flexattr(self): item = self.lib.items().get() item.flexattr = 'testAttr' item.store() self.modify("flexattr!") item = self.lib.items().get() self.assertNotIn("flexattr", item) @unittest.skip('not yet implemented') def test_delete_initial_key_tag(self): item = self.lib.items().get() item.initial_key = 'C#m' item.write() item.store() mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.initial_key, 'C#m') self.modify("initial_key!") mediafile = MediaFile(syspath(item.path)) self.assertIsNone(mediafile.initial_key) def test_arg_parsing_colon_query(self): (query, mods, dels) = commands.modify_parse_args(["title:oldTitle", "title=newTitle"]) self.assertEqual(query, ["title:oldTitle"]) self.assertEqual(mods, {"title": "newTitle"}) def test_arg_parsing_delete(self): (query, mods, dels) = commands.modify_parse_args(["title:oldTitle", "title!"]) self.assertEqual(query, ["title:oldTitle"]) self.assertEqual(dels, ["title"]) def test_arg_parsing_query_with_exclaimation(self): (query, mods, dels) = commands.modify_parse_args(["title:oldTitle!", "title=newTitle!"]) self.assertEqual(query, ["title:oldTitle!"]) self.assertEqual(mods, {"title": "newTitle!"}) def test_arg_parsing_equals_in_value(self): (query, mods, dels) = commands.modify_parse_args(["title:foo=bar", "title=newTitle"]) self.assertEqual(query, ["title:foo=bar"]) self.assertEqual(mods, {"title": "newTitle"}) class WriteTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def write_cmd(self, *args): return self.run_with_output('write', *args) def test_update_mtime(self): item = self.add_item_fixture() item['title'] = 'a new title' item.store() item = self.lib.items().get() self.assertEqual(item.mtime, 0) self.write_cmd() item = self.lib.items().get() self.assertEqual(item.mtime, item.current_mtime()) def test_non_metadata_field_unchanged(self): """Changing a non-"tag" field like `bitrate` and writing should have no effect. """ # An item that starts out "clean". item = self.add_item_fixture() item.read() # ... but with a mismatched bitrate. item.bitrate = 123 item.store() output = self.write_cmd() self.assertEqual(output, '') def test_write_metadata_field(self): item = self.add_item_fixture() item.read() old_title = item.title item.title = 'new title' item.store() output = self.write_cmd() self.assertTrue(f'{old_title} -> new title' in output) class MoveTest(_common.TestCase): def setUp(self): super().setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, b'testlibdir') os.mkdir(self.libdir) self.itempath = os.path.join(self.libdir, b'srcfile') shutil.copy(os.path.join(_common.RSRC, b'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, b'testotherdir') def _move(self, query=(), dest=None, copy=False, album=False, pretend=False, export=False): commands.move_items(self.lib, dest, query, copy, album, pretend, export=export) def test_move_item(self): self._move() self.i.load() self.assertTrue(b'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(b'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(b'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(b'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(b'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(b'testotherdir' in self.i.path) self.assertExists(self.i.path) self.assertNotExists(self.itempath) def test_pretend_move_item(self): self._move(dest=self.otherdir, pretend=True) self.i.load() self.assertIn(b'srcfile', self.i.path) def test_pretend_move_album(self): self._move(album=True, pretend=True) self.i.load() self.assertIn(b'srcfile', self.i.path) def test_export_item_custom_dir(self): self._move(dest=self.otherdir, export=True) self.i.load() self.assertEqual(self.i.path, self.itempath) self.assertExists(self.otherdir) def test_export_album_custom_dir(self): self._move(dest=self.otherdir, album=True, export=True) self.i.load() self.assertEqual(self.i.path, self.itempath) self.assertExists(self.otherdir) def test_pretend_export_item(self): self._move(dest=self.otherdir, pretend=True, export=True) self.i.load() self.assertIn(b'srcfile', self.i.path) self.assertNotExists(self.otherdir) class UpdateTest(_common.TestCase): def setUp(self): super().setUp() self.io.install() self.libdir = os.path.join(self.temp_dir, b'testlibdir') # Copy a file into the library. self.lib = library.Library(':memory:', self.libdir) item_path = os.path.join(_common.RSRC, b'full.mp3') item_path_two = os.path.join(_common.RSRC, b'full.flac') self.i = library.Item.from_path(item_path) self.i2 = library.Item.from_path(item_path_two) self.lib.add(self.i) self.lib.add(self.i2) self.i.move(operation=MoveOperation.COPY) self.i2.move(operation=MoveOperation.COPY) self.album = self.lib.add_album([self.i, self.i2]) # Album art. artfile = os.path.join(self.temp_dir, b'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, fields=None): self.io.addinput('y') if reset_mtime: self.i.mtime = 0 self.i.store() commands.update_items(self.lib, query, album, move, False, fields=fields) def test_delete_removes_item(self): self.assertTrue(list(self.lib.items())) os.remove(self.i.path) os.remove(self.i2.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) os.remove(self.i2.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) os.remove(self.i2.path) self._update() self.assertNotExists(artpath) def test_modified_metadata_detected(self): mf = MediaFile(syspath(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(syspath(self.i.path)) mf.title = 'differentTitle' mf.save() self._update(move=True) item = self.lib.items().get() self.assertTrue(b'differentTitle' in item.path) def test_modified_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = 'differentTitle' mf.save() self._update(move=False) item = self.lib.items().get() self.assertTrue(b'differentTitle' not in item.path) def test_selective_modified_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = 'differentTitle' mf.genre = 'differentGenre' mf.save() self._update(move=True, fields=['title']) item = self.lib.items().get() self.assertTrue(b'differentTitle' in item.path) self.assertNotEqual(item.genre, 'differentGenre') def test_selective_modified_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.title = 'differentTitle' mf.genre = 'differentGenre' mf.save() self._update(move=False, fields=['title']) item = self.lib.items().get() self.assertTrue(b'differentTitle' not in item.path) self.assertNotEqual(item.genre, 'differentGenre') def test_modified_album_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = 'differentAlbum' mf.save() self._update(move=True) item = self.lib.items().get() self.assertTrue(b'differentAlbum' in item.path) def test_modified_album_metadata_art_moved(self): artpath = self.album.artpath mf = MediaFile(syspath(self.i.path)) mf.album = 'differentAlbum' mf.save() self._update(move=True) album = self.lib.albums()[0] self.assertNotEqual(artpath, album.artpath) self.assertIsNotNone(album.artpath) def test_selective_modified_album_metadata_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = 'differentAlbum' mf.genre = 'differentGenre' mf.save() self._update(move=True, fields=['album']) item = self.lib.items().get() self.assertTrue(b'differentAlbum' in item.path) self.assertNotEqual(item.genre, 'differentGenre') def test_selective_modified_album_metadata_not_moved(self): mf = MediaFile(syspath(self.i.path)) mf.album = 'differentAlbum' mf.genre = 'differentGenre' mf.save() self._update(move=True, fields=['genre']) item = self.lib.items().get() self.assertTrue(b'differentAlbum' not in item.path) self.assertEqual(item.genre, 'differentGenre') def test_mtime_match_skips_update(self): mf = MediaFile(syspath(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().setUp() self.io.install() def test_print_without_locale(self): lang = os.environ.get('LANG') if lang: del os.environ['LANG'] try: ui.print_('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_('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 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) @_common.slow_test() class ConfigTest(unittest.TestCase, TestHelper, _common.Assertions): def setUp(self): self.setup_beets() # Don't use the BEETSDIR from `helper`. Instead, we point the home # directory there. Some tests will set `BEETSDIR` themselves. del os.environ['BEETSDIR'] self._old_home = os.environ.get('HOME') os.environ['HOME'] = util.py3_path(self.temp_dir) # Also set APPDATA, the Windows equivalent of setting $HOME. self._old_appdata = os.environ.get('APPDATA') os.environ['APPDATA'] = \ util.py3_path(os.path.join(self.temp_dir, b'AppData', b'Roaming')) self._orig_cwd = os.getcwd() self.test_cmd = self._make_test_cmd() commands.default_commands.append(self.test_cmd) # Default user configuration if platform.system() == 'Windows': self.user_config_dir = os.path.join( self.temp_dir, b'AppData', b'Roaming', b'beets' ) else: self.user_config_dir = os.path.join( self.temp_dir, b'.config', b'beets' ) os.makedirs(self.user_config_dir) self.user_config_path = os.path.join(self.user_config_dir, b'config.yaml') # Custom BEETSDIR self.beetsdir = os.path.join(self.temp_dir, b'beetsdir') os.makedirs(self.beetsdir) self._reset_config() self.load_plugins() def tearDown(self): commands.default_commands.pop() os.chdir(self._orig_cwd) if self._old_home is not None: os.environ['HOME'] = self._old_home if self._old_appdata is None: del os.environ['APPDATA'] else: os.environ['APPDATA'] = self._old_appdata self.unload_plugins() self.teardown_beets() def _make_test_cmd(self): test_cmd = ui.Subcommand('test', help='test') def run(lib, options, args): test_cmd.lib = lib test_cmd.options = options test_cmd.args = args test_cmd.func = run return test_cmd def _reset_config(self): # Config should read files again on demand config.clear() config._materialized = False def write_config_file(self): return open(self.user_config_path, 'w') def test_paths_section_respected(self): with self.write_config_file() as config: config.write('paths: {x: y}') self.run_command('test', lib=None) key, template = self.test_cmd.lib.path_formats[0] self.assertEqual(key, 'x') self.assertEqual(template.original, 'y') def test_default_paths_preserved(self): default_formats = ui.get_path_formats() self._reset_config() with self.write_config_file() as config: config.write('paths: {x: y}') self.run_command('test', lib=None) key, template = self.test_cmd.lib.path_formats[0] self.assertEqual(key, 'x') self.assertEqual(template.original, 'y') self.assertEqual(self.test_cmd.lib.path_formats[1:], default_formats) def test_nonexistant_db(self): with self.write_config_file() as config: config.write('library: /xxx/yyy/not/a/real/path') with self.assertRaises(ui.UserError): self.run_command('test', lib=None) def test_user_config_file(self): with self.write_config_file() as file: file.write('anoption: value') self.run_command('test', lib=None) self.assertEqual(config['anoption'].get(), 'value') def test_replacements_parsed(self): with self.write_config_file() as config: config.write("replace: {'[xy]': z}") self.run_command('test', lib=None) replacements = self.test_cmd.lib.replacements repls = [(p.pattern, s) for p, s in replacements] # Compare patterns. self.assertEqual(repls, [('[xy]', 'z')]) def test_multiple_replacements_parsed(self): with self.write_config_file() as config: config.write("replace: {'[xy]': z, foo: bar}") self.run_command('test', lib=None) replacements = self.test_cmd.lib.replacements repls = [(p.pattern, s) for p, s in replacements] self.assertEqual(repls, [ ('[xy]', 'z'), ('foo', 'bar'), ]) def test_cli_config_option(self): config_path = os.path.join(self.temp_dir, b'config.yaml') with open(config_path, 'w') as file: file.write('anoption: value') self.run_command('--config', config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'value') def test_cli_config_file_overwrites_user_defaults(self): with open(self.user_config_path, 'w') as file: file.write('anoption: value') cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('anoption: cli overwrite') self.run_command('--config', cli_config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'cli overwrite') def test_cli_config_file_overwrites_beetsdir_defaults(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('anoption: value') cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('anoption: cli overwrite') self.run_command('--config', cli_config_path, 'test', lib=None) self.assertEqual(config['anoption'].get(), 'cli overwrite') # @unittest.skip('Difficult to implement with optparse') # def test_multiple_cli_config_files(self): # cli_config_path_1 = os.path.join(self.temp_dir, b'config.yaml') # cli_config_path_2 = os.path.join(self.temp_dir, b'config_2.yaml') # # with open(cli_config_path_1, 'w') as file: # file.write('first: value') # # with open(cli_config_path_2, 'w') as file: # file.write('second: value') # # self.run_command('--config', cli_config_path_1, # '--config', cli_config_path_2, 'test', lib=None) # self.assertEqual(config['first'].get(), 'value') # self.assertEqual(config['second'].get(), 'value') # # @unittest.skip('Difficult to implement with optparse') # def test_multiple_cli_config_overwrite(self): # cli_config_path = os.path.join(self.temp_dir, b'config.yaml') # cli_overwrite_config_path = os.path.join(self.temp_dir, # b'overwrite_config.yaml') # # with open(cli_config_path, 'w') as file: # file.write('anoption: value') # # with open(cli_overwrite_config_path, 'w') as file: # file.write('anoption: overwrite') # # self.run_command('--config', cli_config_path, # '--config', cli_overwrite_config_path, 'test') # self.assertEqual(config['anoption'].get(), 'cli overwrite') @unittest.skipIf(sys.platform, 'win32') # FIXME: fails on windows def test_cli_config_paths_resolve_relative_to_user_dir(self): cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') self.run_command('--config', cli_config_path, 'test', lib=None) self.assert_equal_path( util.bytestring_path(config['library'].as_filename()), os.path.join(self.user_config_dir, b'beets.db') ) self.assert_equal_path( util.bytestring_path(config['statefile'].as_filename()), os.path.join(self.user_config_dir, b'state') ) def test_cli_config_paths_resolve_relative_to_beetsdir(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') self.run_command('--config', cli_config_path, 'test', lib=None) self.assert_equal_path( util.bytestring_path(config['library'].as_filename()), os.path.join(self.beetsdir, b'beets.db') ) self.assert_equal_path( util.bytestring_path(config['statefile'].as_filename()), os.path.join(self.beetsdir, b'state') ) def test_command_line_option_relative_to_working_dir(self): config.read() os.chdir(self.temp_dir) self.run_command('--library', 'foo.db', 'test', lib=None) self.assert_equal_path(config['library'].as_filename(), os.path.join(os.getcwd(), 'foo.db')) def test_cli_config_file_loads_plugin_commands(self): cli_config_path = os.path.join(self.temp_dir, b'config.yaml') with open(cli_config_path, 'w') as file: file.write('pluginpath: %s\n' % _common.PLUGINPATH) file.write('plugins: test') self.run_command('--config', cli_config_path, 'plugin', lib=None) self.assertTrue(plugins.find_plugins()[0].is_test_plugin) def test_beetsdir_config(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('anoption: overwrite') config.read() self.assertEqual(config['anoption'].get(), 'overwrite') def test_beetsdir_points_to_file_error(self): beetsdir = os.path.join(self.temp_dir, b'beetsfile') open(beetsdir, 'a').close() os.environ['BEETSDIR'] = util.py3_path(beetsdir) self.assertRaises(ConfigError, self.run_command, 'test') def test_beetsdir_config_does_not_load_default_user_config(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) with open(self.user_config_path, 'w') as file: file.write('anoption: value') config.read() self.assertFalse(config['anoption'].exists()) def test_default_config_paths_resolve_relative_to_beetsdir(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) config.read() self.assert_equal_path( util.bytestring_path(config['library'].as_filename()), os.path.join(self.beetsdir, b'library.db') ) self.assert_equal_path( util.bytestring_path(config['statefile'].as_filename()), os.path.join(self.beetsdir, b'state.pickle') ) def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): os.environ['BEETSDIR'] = util.py3_path(self.beetsdir) env_config_path = os.path.join(self.beetsdir, b'config.yaml') with open(env_config_path, 'w') as file: file.write('library: beets.db\n') file.write('statefile: state') config.read() self.assert_equal_path( util.bytestring_path(config['library'].as_filename()), os.path.join(self.beetsdir, b'beets.db') ) self.assert_equal_path( util.bytestring_path(config['statefile'].as_filename()), os.path.join(self.beetsdir, b'state') ) class ShowModelChangeTest(_common.TestCase): def setUp(self): super().setUp() self.io.install() self.a = _common.item() self.b = _common.item() self.a.path = self.b.path def _show(self, **kwargs): change = ui.show_model_changes(self.a, self.b, **kwargs) out = self.io.getoutput() return change, out def test_identical(self): change, out = self._show() self.assertFalse(change) self.assertEqual(out, '') def test_string_fixed_field_change(self): self.b.title = 'x' change, out = self._show() self.assertTrue(change) self.assertTrue('title' in out) def test_int_fixed_field_change(self): self.b.track = 9 change, out = self._show() self.assertTrue(change) self.assertTrue('track' in out) def test_floats_close_to_identical(self): self.a.length = 1.00001 self.b.length = 1.00005 change, out = self._show() self.assertFalse(change) self.assertEqual(out, '') def test_floats_different(self): self.a.length = 1.00001 self.b.length = 2.00001 change, out = self._show() self.assertTrue(change) self.assertTrue('length' in out) def test_both_values_shown(self): self.a.title = 'foo' self.b.title = 'bar' change, out = self._show() self.assertTrue('foo' in out) self.assertTrue('bar' in out) class ShowChangeTest(_common.TestCase): def setUp(self): super().setUp() self.io.install() self.items = [_common.item()] self.items[0].track = 1 self.items[0].path = b'/path/to/file.mp3' self.info = autotag.AlbumInfo( album='the album', album_id='album id', artist='the artist', artist_id='artist id', tracks=[ autotag.TrackInfo(title='the title', track_id='track id', index=1) ] ) def _show_change(self, items=None, info=None, cur_artist='the artist', cur_album='the album', dist=0.1): """Return an unicode string representing the changes""" items = items or self.items info = info or self.info mapping = dict(zip(items, info.tracks)) config['ui']['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()), ) # FIXME decoding shouldn't be done here return util.text_string(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 = 'different' msg = self._show_change() self.assertTrue('different -> the title' in msg) def test_item_data_change_with_unicode(self): self.items[0].title = 'caf\xe9' msg = self._show_change() self.assertTrue('caf\xe9 -> the title' in msg) def test_album_data_change_with_unicode(self): msg = self._show_change(cur_artist='caf\xe9', cur_album='another album') self.assertTrue('correcting tags from:' in msg) def test_item_data_change_title_missing(self): self.items[0].title = '' 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 = '' self.items[0].path = '/path/to/caf\xe9.mp3'.encode() msg = re.sub(r' +', ' ', self._show_change()) self.assertTrue('caf\xe9.mp3 -> the title' in msg or 'caf.mp3 ->' in msg) @patch('beets.library.Item.try_filesize', Mock(return_value=987)) class SummarizeItemsTest(_common.TestCase): def setUp(self): super().setUp() item = library.Item() item.bitrate = 4321 item.length = 10 * 60 + 54 item.format = "F" self.item = item def test_summarize_item(self): summary = commands.summarize_items([], True) self.assertEqual(summary, "") summary = commands.summarize_items([self.item], True) self.assertEqual(summary, "F, 4kbps, 10:54, 987.0 B") def test_summarize_items(self): summary = commands.summarize_items([], False) self.assertEqual(summary, "0 items") summary = commands.summarize_items([self.item], False) self.assertEqual(summary, "1 items, F, 4kbps, 10:54, 987.0 B") # make a copy of self.item i2 = self.item.copy() summary = commands.summarize_items([self.item, i2], False) self.assertEqual(summary, "2 items, F, 4kbps, 21:48, 1.9 KiB") i2.format = "G" summary = commands.summarize_items([self.item, i2], False) self.assertEqual(summary, "2 items, F 1, G 1, 4kbps, 21:48, 1.9 KiB") summary = commands.summarize_items([self.item, i2, i2], False) self.assertEqual(summary, "3 items, G 2, F 1, 4kbps, 32:42, 2.9 KiB") class PathFormatTest(_common.TestCase): def test_custom_paths_prepend(self): default_formats = ui.get_path_formats() config['paths'] = {'foo': '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) @_common.slow_test() class PluginTest(_common.TestCase, TestHelper): def test_plugin_command_from_pluginpath(self): config['pluginpath'] = [_common.PLUGINPATH] config['plugins'] = ['test'] self.run_command('test', lib=None) @_common.slow_test() class CompletionTest(_common.TestCase, TestHelper): def test_completion(self): # Load plugin commands config['pluginpath'] = [_common.PLUGINPATH] config['plugins'] = ['test'] # Do not load any other bash completion scripts on the system. env = dict(os.environ) env['BASH_COMPLETION_DIR'] = os.devnull env['BASH_COMPLETION_COMPAT_DIR'] = os.devnull # Open a `bash` process to run the tests in. We'll pipe in bash # commands via stdin. cmd = os.environ.get('BEETS_TEST_SHELL', '/bin/bash --norc').split() if not has_program(cmd[0]): self.skipTest('bash not available') tester = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env) # Load bash_completion library. for path in commands.BASH_COMPLETION_PATHS: if os.path.exists(util.syspath(path)): bash_completion = path break else: self.skipTest('bash-completion script not found') try: with open(util.syspath(bash_completion), 'rb') as f: tester.stdin.writelines(f) except OSError: self.skipTest('could not read bash-completion script') # Load completion script. self.io.install() self.run_command('completion', lib=None) completion_script = self.io.getoutput().encode('utf-8') self.io.restore() tester.stdin.writelines(completion_script.splitlines(True)) # Load test suite. test_script_name = os.path.join(_common.RSRC, b'test_completion.sh') with open(test_script_name, 'rb') as test_script_file: tester.stdin.writelines(test_script_file) out, err = tester.communicate() if tester.returncode != 0 or out != b'completion tests passed\n': print(out.decode('utf-8')) self.fail('test/test_completion.sh did not execute properly') class CommonOptionsParserCliTest(unittest.TestCase, TestHelper): """Test CommonOptionsParser and formatting LibModel formatting on 'list' command. """ def setUp(self): self.setup_beets() self.item = _common.item() self.item.path = b'xxx/yyy' self.lib.add(self.item) self.lib.add_album([self.item]) self.load_plugins() def tearDown(self): self.unload_plugins() self.teardown_beets() def test_base(self): l = self.run_with_output('ls') self.assertEqual(l, 'the artist - the album - the title\n') l = self.run_with_output('ls', '-a') self.assertEqual(l, 'the album artist - the album\n') def test_path_option(self): l = self.run_with_output('ls', '-p') self.assertEqual(l, 'xxx/yyy\n') l = self.run_with_output('ls', '-a', '-p') self.assertEqual(l, 'xxx\n') def test_format_option(self): l = self.run_with_output('ls', '-f', '$artist') self.assertEqual(l, 'the artist\n') l = self.run_with_output('ls', '-a', '-f', '$albumartist') self.assertEqual(l, 'the album artist\n') def test_format_option_unicode(self): l = self.run_with_output(b'ls', b'-f', 'caf\xe9'.encode(util.arg_encoding())) self.assertEqual(l, 'caf\xe9\n') def test_root_format_option(self): l = self.run_with_output('--format-item', '$artist', '--format-album', 'foo', 'ls') self.assertEqual(l, 'the artist\n') l = self.run_with_output('--format-item', 'foo', '--format-album', '$albumartist', 'ls', '-a') self.assertEqual(l, 'the album artist\n') def test_help(self): l = self.run_with_output('help') self.assertIn('Usage:', l) l = self.run_with_output('help', 'list') self.assertIn('Usage:', l) with self.assertRaises(ui.UserError): self.run_command('help', 'this.is.not.a.real.command') def test_stats(self): l = self.run_with_output('stats') self.assertIn('Approximate total size:', l) # # Need to have more realistic library setup for this to work # l = self.run_with_output('stats', '-e') # self.assertIn('Total size:', l) def test_version(self): l = self.run_with_output('version') self.assertIn('Python version', l) self.assertIn('no plugins loaded', l) # # Need to have plugin loaded # l = self.run_with_output('version') # self.assertIn('plugins: ', l) class CommonOptionsParserTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_album_option(self): parser = ui.CommonOptionsParser() self.assertFalse(parser._album_flags) parser.add_album_option() self.assertTrue(bool(parser._album_flags)) self.assertEqual(parser.parse_args([]), ({'album': None}, [])) self.assertEqual(parser.parse_args(['-a']), ({'album': True}, [])) self.assertEqual(parser.parse_args(['--album']), ({'album': True}, [])) def test_path_option(self): parser = ui.CommonOptionsParser() parser.add_path_option() self.assertFalse(parser._album_flags) config['format_item'].set('$foo') self.assertEqual(parser.parse_args([]), ({'path': None}, [])) self.assertEqual(config['format_item'].as_str(), '$foo') self.assertEqual(parser.parse_args(['-p']), ({'path': True, 'format': '$path'}, [])) self.assertEqual(parser.parse_args(['--path']), ({'path': True, 'format': '$path'}, [])) self.assertEqual(config['format_item'].as_str(), '$path') self.assertEqual(config['format_album'].as_str(), '$path') def test_format_option(self): parser = ui.CommonOptionsParser() parser.add_format_option() self.assertFalse(parser._album_flags) config['format_item'].set('$foo') self.assertEqual(parser.parse_args([]), ({'format': None}, [])) self.assertEqual(config['format_item'].as_str(), '$foo') self.assertEqual(parser.parse_args(['-f', '$bar']), ({'format': '$bar'}, [])) self.assertEqual(parser.parse_args(['--format', '$baz']), ({'format': '$baz'}, [])) self.assertEqual(config['format_item'].as_str(), '$baz') self.assertEqual(config['format_album'].as_str(), '$baz') def test_format_option_with_target(self): with self.assertRaises(KeyError): ui.CommonOptionsParser().add_format_option(target='thingy') parser = ui.CommonOptionsParser() parser.add_format_option(target='item') config['format_item'].set('$item') config['format_album'].set('$album') self.assertEqual(parser.parse_args(['-f', '$bar']), ({'format': '$bar'}, [])) self.assertEqual(config['format_item'].as_str(), '$bar') self.assertEqual(config['format_album'].as_str(), '$album') def test_format_option_with_album(self): parser = ui.CommonOptionsParser() parser.add_album_option() parser.add_format_option() config['format_item'].set('$item') config['format_album'].set('$album') parser.parse_args(['-f', '$bar']) self.assertEqual(config['format_item'].as_str(), '$bar') self.assertEqual(config['format_album'].as_str(), '$album') parser.parse_args(['-a', '-f', '$foo']) self.assertEqual(config['format_item'].as_str(), '$bar') self.assertEqual(config['format_album'].as_str(), '$foo') parser.parse_args(['-f', '$foo2', '-a']) self.assertEqual(config['format_album'].as_str(), '$foo2') def test_add_all_common_options(self): parser = ui.CommonOptionsParser() parser.add_all_common_options() self.assertEqual(parser.parse_args([]), ({'album': None, 'path': None, 'format': None}, [])) class EncodingTest(_common.TestCase): """Tests for the `terminal_encoding` config option and our `_in_encoding` and `_out_encoding` utility functions. """ def out_encoding_overridden(self): config['terminal_encoding'] = 'fake_encoding' self.assertEqual(ui._out_encoding(), 'fake_encoding') def in_encoding_overridden(self): config['terminal_encoding'] = 'fake_encoding' self.assertEqual(ui._in_encoding(), 'fake_encoding') def out_encoding_default_utf8(self): with patch('sys.stdout') as stdout: stdout.encoding = None self.assertEqual(ui._out_encoding(), 'utf-8') def in_encoding_default_utf8(self): with patch('sys.stdin') as stdin: stdin.encoding = None self.assertEqual(ui._in_encoding(), 'utf-8') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_ui_commands.py0000644000076500000240000000713100000000000017215 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test module for file ui/commands.py """ import os import shutil import unittest from test import _common from beets import library from beets import ui from beets.ui import commands class QueryTest(_common.TestCase): def setUp(self): super().setUp() self.libdir = os.path.join(self.temp_dir, b'testlibdir') os.mkdir(self.libdir) # Add a file to the library but don't copy it in yet. self.lib = library.Library(':memory:', self.libdir) # Alternate destination directory. self.otherdir = os.path.join(self.temp_dir, b'testotherdir') def add_item(self, filename=b'srcfile', templatefile=b'full.mp3'): itempath = os.path.join(self.libdir, filename) shutil.copy(os.path.join(_common.RSRC, templatefile), itempath) item = library.Item.from_path(itempath) self.lib.add(item) return item, itempath def add_album(self, items): album = self.lib.add_album(items) return album def check_do_query(self, num_items, num_albums, q=(), album=False, also_items=True): items, albums = commands._do_query( self.lib, q, album, also_items) self.assertEqual(len(items), num_items) self.assertEqual(len(albums), num_albums) def test_query_empty(self): with self.assertRaises(ui.UserError): commands._do_query(self.lib, (), False) def test_query_empty_album(self): with self.assertRaises(ui.UserError): commands._do_query(self.lib, (), True) def test_query_item(self): self.add_item() self.check_do_query(1, 0, album=False) self.add_item() self.check_do_query(2, 0, album=False) def test_query_album(self): item, itempath = self.add_item() self.add_album([item]) self.check_do_query(1, 1, album=True) self.check_do_query(0, 1, album=True, also_items=False) item, itempath = self.add_item() item2, itempath = self.add_item() self.add_album([item, item2]) self.check_do_query(3, 2, album=True) self.check_do_query(0, 2, album=True, also_items=False) class FieldsTest(_common.LibTestCase): def setUp(self): super().setUp() self.io.install() def tearDown(self): self.io.restore() def remove_keys(self, l, text): for i in text: try: l.remove(i) except ValueError: pass def test_fields_func(self): commands.fields_func(self.lib, [], []) items = library.Item.all_keys() albums = library.Album.all_keys() output = self.io.stdout.get().split() self.remove_keys(items, output) self.remove_keys(albums, output) self.assertEqual(len(items), 0) self.assertEqual(len(albums), 0) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_ui_importer.py0000644000076500000240000001102700000000000017254 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests the TerminalImportSession. The tests are the same as in the test_importer module. But here the test importer inherits from ``TerminalImportSession``. So we test this class, too. """ import unittest from test._common import DummyIO from test import test_importer from beets.ui.commands import TerminalImportSession from beets import importer from beets import config class TerminalImportSessionFixture(TerminalImportSession): def __init__(self, *args, **kwargs): self.io = kwargs.pop('io') super().__init__(*args, **kwargs) self._choices = [] default_choice = importer.action.APPLY def add_choice(self, choice): self._choices.append(choice) def clear_choices(self): self._choices = [] def choose_match(self, task): self._add_choice_input() return super().choose_match(task) def choose_item(self, task): self._add_choice_input() return super().choose_item(task) def _add_choice_input(self): try: choice = self._choices.pop(0) except IndexError: choice = self.default_choice if choice == importer.action.APPLY: self.io.addinput('A') elif choice == importer.action.ASIS: self.io.addinput('U') elif choice == importer.action.ALBUMS: self.io.addinput('G') elif choice == importer.action.TRACKS: self.io.addinput('T') elif choice == importer.action.SKIP: self.io.addinput('S') elif isinstance(choice, int): self.io.addinput('M') self.io.addinput(str(choice)) self._add_choice_input() else: raise Exception('Unknown choice %s' % choice) class TerminalImportSessionSetup: """Overwrites test_importer.ImportHelper to provide a terminal importer """ def _setup_import_session(self, import_dir=None, delete=False, threaded=False, copy=True, singletons=False, move=False, autotag=True): config['import']['copy'] = copy config['import']['delete'] = delete config['import']['timid'] = True config['threaded'] = False config['import']['singletons'] = singletons config['import']['move'] = move config['import']['autotag'] = autotag config['import']['resume'] = False if not hasattr(self, 'io'): self.io = DummyIO() self.io.install() self.importer = TerminalImportSessionFixture( self.lib, loghandler=None, query=None, io=self.io, paths=[import_dir or self.import_dir], ) class NonAutotaggedImportTest(TerminalImportSessionSetup, test_importer.NonAutotaggedImportTest): pass class ImportTest(TerminalImportSessionSetup, test_importer.ImportTest): pass class ImportSingletonTest(TerminalImportSessionSetup, test_importer.ImportSingletonTest): pass class ImportTracksTest(TerminalImportSessionSetup, test_importer.ImportTracksTest): pass class ImportCompilationTest(TerminalImportSessionSetup, test_importer.ImportCompilationTest): pass class ImportExistingTest(TerminalImportSessionSetup, test_importer.ImportExistingTest): pass class ChooseCandidateTest(TerminalImportSessionSetup, test_importer.ChooseCandidateTest): pass class GroupAlbumsImportTest(TerminalImportSessionSetup, test_importer.GroupAlbumsImportTest): pass class GlobalGroupAlbumsImportTest(TerminalImportSessionSetup, test_importer.GlobalGroupAlbumsImportTest): pass def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_ui_init.py0000644000076500000240000000727300000000000016366 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Test module for file ui/__init__.py """ import unittest from test import _common from beets import ui class InputMethodsTest(_common.TestCase): def setUp(self): super().setUp() self.io.install() def _print_helper(self, s): print(s) def _print_helper2(self, s, prefix): print(prefix, s) def test_input_select_objects(self): full_items = ['1', '2', '3', '4', '5'] # Test no self.io.addinput('n') items = ui.input_select_objects( "Prompt", full_items, self._print_helper) self.assertEqual(items, []) # Test yes self.io.addinput('y') items = ui.input_select_objects( "Prompt", full_items, self._print_helper) self.assertEqual(items, full_items) # Test selective 1 self.io.addinput('s') self.io.addinput('n') self.io.addinput('y') self.io.addinput('n') self.io.addinput('y') self.io.addinput('n') items = ui.input_select_objects( "Prompt", full_items, self._print_helper) self.assertEqual(items, ['2', '4']) # Test selective 2 self.io.addinput('s') self.io.addinput('y') self.io.addinput('y') self.io.addinput('n') self.io.addinput('y') self.io.addinput('n') items = ui.input_select_objects( "Prompt", full_items, lambda s: self._print_helper2(s, "Prefix")) self.assertEqual(items, ['1', '2', '4']) # Test selective 3 self.io.addinput('s') self.io.addinput('y') self.io.addinput('n') self.io.addinput('y') self.io.addinput('q') items = ui.input_select_objects( "Prompt", full_items, self._print_helper) self.assertEqual(items, ['1', '3']) class InitTest(_common.LibTestCase): def setUp(self): super().setUp() def test_human_bytes(self): tests = [ (0, '0.0 B'), (30, '30.0 B'), (pow(2, 10), '1.0 KiB'), (pow(2, 20), '1.0 MiB'), (pow(2, 30), '1.0 GiB'), (pow(2, 40), '1.0 TiB'), (pow(2, 50), '1.0 PiB'), (pow(2, 60), '1.0 EiB'), (pow(2, 70), '1.0 ZiB'), (pow(2, 80), '1.0 YiB'), (pow(2, 90), '1.0 HiB'), (pow(2, 100), 'big'), ] for i, h in tests: self.assertEqual(h, ui.human_bytes(i)) def test_human_seconds(self): tests = [ (0, '0.0 seconds'), (30, '30.0 seconds'), (60, '1.0 minutes'), (90, '1.5 minutes'), (125, '2.1 minutes'), (3600, '1.0 hours'), (86400, '1.0 days'), (604800, '1.0 weeks'), (31449600, '1.0 years'), (314496000, '1.0 decades'), ] for i, h in tests: self.assertEqual(h, ui.human_seconds(i)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_util.py0000644000076500000240000001521600000000000015677 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for base utils from the beets.util package. """ import sys import re import os import subprocess import unittest from unittest.mock import patch, Mock from test import _common from beets import util class UtilTest(unittest.TestCase): def test_open_anything(self): with _common.system_mock('Windows'): self.assertEqual(util.open_anything(), 'start') with _common.system_mock('Darwin'): self.assertEqual(util.open_anything(), 'open') with _common.system_mock('Tagada'): self.assertEqual(util.open_anything(), 'xdg-open') @patch('os.execlp') @patch('beets.util.open_anything') def test_interactive_open(self, mock_open, mock_execlp): mock_open.return_value = 'tagada' util.interactive_open(['foo'], util.open_anything()) mock_execlp.assert_called_once_with('tagada', 'tagada', 'foo') mock_execlp.reset_mock() util.interactive_open(['foo'], 'bar') mock_execlp.assert_called_once_with('bar', 'bar', 'foo') def test_sanitize_unix_replaces_leading_dot(self): with _common.platform_posix(): p = util.sanitize_path('one/.two/three') self.assertFalse('.' in p) def test_sanitize_windows_replaces_trailing_dot(self): with _common.platform_windows(): p = util.sanitize_path('one/two./three') self.assertFalse('.' in p) def test_sanitize_windows_replaces_illegal_chars(self): with _common.platform_windows(): p = util.sanitize_path(':*?"<>|') 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_sanitize_windows_replaces_trailing_space(self): with _common.platform_windows(): p = util.sanitize_path('one/two /three') self.assertFalse(' ' in p) def test_sanitize_path_works_on_empty_string(self): with _common.platform_posix(): p = util.sanitize_path('') self.assertEqual(p, '') def test_sanitize_with_custom_replace_overrides_built_in_sub(self): with _common.platform_posix(): p = util.sanitize_path('a/.?/b', [ (re.compile(r'foo'), 'bar'), ]) self.assertEqual(p, 'a/.?/b') def test_sanitize_with_custom_replace_adds_replacements(self): with _common.platform_posix(): p = util.sanitize_path('foo/bar', [ (re.compile(r'foo'), 'bar'), ]) self.assertEqual(p, 'bar/bar') @unittest.skip('unimplemented: #359') def test_sanitize_empty_component(self): with _common.platform_posix(): p = util.sanitize_path('foo//bar', [ (re.compile(r'^$'), '_'), ]) self.assertEqual(p, 'foo/_/bar') def test_convert_command_args_keeps_undecodeable_bytes(self): arg = b'\x82' # non-ascii bytes cmd_args = util.convert_command_args([arg]) self.assertEqual(cmd_args[0], arg.decode(util.arg_encoding(), 'surrogateescape')) @patch('beets.util.subprocess.Popen') def test_command_output(self, mock_popen): def popen_fail(*args, **kwargs): m = Mock(returncode=1) m.communicate.return_value = 'foo', 'bar' return m mock_popen.side_effect = popen_fail with self.assertRaises(subprocess.CalledProcessError) as exc_context: util.command_output(['taga', '\xc3\xa9']) self.assertEqual(exc_context.exception.returncode, 1) self.assertEqual(exc_context.exception.cmd, 'taga \xc3\xa9') class PathConversionTest(_common.TestCase): def test_syspath_windows_format(self): with _common.platform_windows(): path = os.path.join('a', 'b', 'c') outpath = util.syspath(path) self.assertTrue(isinstance(outpath, str)) self.assertTrue(outpath.startswith('\\\\?\\')) def test_syspath_windows_format_unc_path(self): # The \\?\ prefix on Windows behaves differently with UNC # (network share) paths. path = '\\\\server\\share\\file.mp3' with _common.platform_windows(): outpath = util.syspath(path) self.assertTrue(isinstance(outpath, str)) self.assertEqual(outpath, '\\\\?\\UNC\\server\\share\\file.mp3') def test_syspath_posix_unchanged(self): with _common.platform_posix(): path = os.path.join('a', 'b', 'c') outpath = util.syspath(path) self.assertEqual(path, outpath) def _windows_bytestring_path(self, path): old_gfse = sys.getfilesystemencoding sys.getfilesystemencoding = lambda: 'mbcs' try: with _common.platform_windows(): return util.bytestring_path(path) finally: sys.getfilesystemencoding = old_gfse def test_bytestring_path_windows_encodes_utf8(self): path = 'caf\xe9' outpath = self._windows_bytestring_path(path) self.assertEqual(path, outpath.decode('utf-8')) def test_bytesting_path_windows_removes_magic_prefix(self): path = '\\\\?\\C:\\caf\xe9' outpath = self._windows_bytestring_path(path) self.assertEqual(outpath, 'C:\\caf\xe9'.encode()) class PathTruncationTest(_common.TestCase): def test_truncate_bytestring(self): with _common.platform_posix(): p = util.truncate_path(b'abcde/fgh', 4) self.assertEqual(p, b'abcd/fgh') def test_truncate_unicode(self): with _common.platform_posix(): p = util.truncate_path('abcde/fgh', 4) self.assertEqual(p, 'abcd/fgh') def test_truncate_preserves_extension(self): with _common.platform_posix(): p = util.truncate_path('abcde/fgh.ext', 5) self.assertEqual(p, 'abcde/f.ext') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_vfs.py0000644000076500000240000000311700000000000015515 0ustar00asampsonstaff# This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. """Tests for the virtual filesystem builder..""" import unittest from test import _common from beets import library from beets import vfs class VFSTest(_common.TestCase): def setUp(self): super().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') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_web.py0000644000076500000240000006350600000000000015504 0ustar00asampsonstaff"""Tests for the 'web' plugin""" import json import unittest import os.path import shutil from test import _common from beets.library import Item, Album from beetsplug import web import platform from beets import logging class WebPluginTest(_common.LibTestCase): def setUp(self): super().setUp() self.log = logging.getLogger('beets.web') if platform.system() == 'Windows': self.path_prefix = 'C:' else: self.path_prefix = '' # Add fixtures for track in self.lib.items(): track.remove() # Add library elements. Note that self.lib.add overrides any "id=" # and assigns the next free id number. # The following adds will create items #1, #2 and #3 path1 = self.path_prefix + os.sep + \ os.path.join(b'path_1').decode('utf-8') self.lib.add(Item(title='title', path=path1, album_id=2, artist='AAA Singers')) path2 = self.path_prefix + os.sep + \ os.path.join(b'somewhere', b'a').decode('utf-8') self.lib.add(Item(title='another title', path=path2, artist='AAA Singers')) path3 = self.path_prefix + os.sep + \ os.path.join(b'somewhere', b'abc').decode('utf-8') self.lib.add(Item(title='and a third', testattr='ABC', path=path3, album_id=2)) # The following adds will create albums #1 and #2 self.lib.add(Album(album='album', albumtest='xyz')) path4 = self.path_prefix + os.sep + \ os.path.join(b'somewhere2', b'art_path_2').decode('utf-8') self.lib.add(Album(album='other album', artpath=path4)) web.app.config['TESTING'] = True web.app.config['lib'] = self.lib web.app.config['INCLUDE_PATHS'] = False web.app.config['READONLY'] = True self.client = web.app.test_client() def test_config_include_paths_true(self): web.app.config['INCLUDE_PATHS'] = True response = self.client.get('/item/1') res_json = json.loads(response.data.decode('utf-8')) expected_path = self.path_prefix + os.sep \ + os.path.join(b'path_1').decode('utf-8') self.assertEqual(response.status_code, 200) self.assertEqual(res_json['path'], expected_path) web.app.config['INCLUDE_PATHS'] = False def test_config_include_artpaths_true(self): web.app.config['INCLUDE_PATHS'] = True response = self.client.get('/album/2') res_json = json.loads(response.data.decode('utf-8')) expected_path = self.path_prefix + os.sep \ + os.path.join(b'somewhere2', b'art_path_2').decode('utf-8') self.assertEqual(response.status_code, 200) self.assertEqual(res_json['artpath'], expected_path) web.app.config['INCLUDE_PATHS'] = False def test_config_include_paths_false(self): web.app.config['INCLUDE_PATHS'] = False response = self.client.get('/item/1') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertNotIn('path', res_json) def test_config_include_artpaths_false(self): web.app.config['INCLUDE_PATHS'] = False response = self.client.get('/album/2') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertNotIn('artpath', res_json) def test_get_all_items(self): response = self.client.get('/item/') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['items']), 3) def test_get_single_item_by_id(self): response = self.client.get('/item/1') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], 1) self.assertEqual(res_json['title'], 'title') def test_get_multiple_items_by_id(self): response = self.client.get('/item/1,2') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['items']), 2) response_titles = {item['title'] for item in res_json['items']} self.assertEqual(response_titles, {'title', 'another title'}) def test_get_single_item_not_found(self): response = self.client.get('/item/4') self.assertEqual(response.status_code, 404) def test_get_single_item_by_path(self): data_path = os.path.join(_common.RSRC, b'full.mp3') self.lib.add(Item.from_path(data_path)) response = self.client.get('/item/path/' + data_path.decode('utf-8')) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['title'], 'full') def test_get_single_item_by_path_not_found_if_not_in_library(self): data_path = os.path.join(_common.RSRC, b'full.mp3') # data_path points to a valid file, but we have not added the file # to the library. response = self.client.get('/item/path/' + data_path.decode('utf-8')) self.assertEqual(response.status_code, 404) def test_get_item_empty_query(self): """ testing item query: """ response = self.client.get('/item/query/') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['items']), 3) def test_get_simple_item_query(self): """ testing item query: another """ response = self.client.get('/item/query/another') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['title'], 'another title') def test_query_item_string(self): """ testing item query: testattr:ABC """ response = self.client.get('/item/query/testattr%3aABC') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['title'], 'and a third') def test_query_item_regex(self): """ testing item query: testattr::[A-C]+ """ response = self.client.get('/item/query/testattr%3a%3a[A-C]%2b') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['title'], 'and a third') def test_query_item_regex_backslash(self): # """ testing item query: testattr::\w+ """ response = self.client.get('/item/query/testattr%3a%3a%5cw%2b') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['title'], 'and a third') def test_query_item_path(self): # """ testing item query: path:\somewhere\a """ """ Note: path queries are special: the query item must match the path from the root all the way to a directory, so this matches 1 item """ """ Note: filesystem separators in the query must be '\' """ response = self.client.get('/item/query/path:' + self.path_prefix + '\\somewhere\\a') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['title'], 'another title') def test_get_all_albums(self): response = self.client.get('/album/') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) response_albums = [album['album'] for album in res_json['albums']] self.assertCountEqual(response_albums, ['album', 'other album']) def test_get_single_album_by_id(self): response = self.client.get('/album/2') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], 2) self.assertEqual(res_json['album'], 'other album') def test_get_multiple_albums_by_id(self): response = self.client.get('/album/1,2') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) response_albums = [album['album'] for album in res_json['albums']] self.assertCountEqual(response_albums, ['album', 'other album']) def test_get_album_empty_query(self): response = self.client.get('/album/query/') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['albums']), 2) def test_get_simple_album_query(self): response = self.client.get('/album/query/other') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['album'], 'other album') self.assertEqual(res_json['results'][0]['id'], 2) def test_get_album_details(self): response = self.client.get('/album/2?expand') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['items']), 2) self.assertEqual(res_json['items'][0]['album'], 'other album') self.assertEqual(res_json['items'][1]['album'], 'other album') response_track_titles = {item['title'] for item in res_json['items']} self.assertEqual(response_track_titles, {'title', 'and a third'}) def test_query_album_string(self): """ testing query: albumtest:xy """ response = self.client.get('/album/query/albumtest%3axy') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['album'], 'album') def test_query_album_artpath_regex(self): """ testing query: artpath::art_ """ response = self.client.get('/album/query/artpath%3a%3aart_') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['album'], 'other album') def test_query_album_regex_backslash(self): # """ testing query: albumtest::\w+ """ response = self.client.get('/album/query/albumtest%3a%3a%5cw%2b') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) self.assertEqual(res_json['results'][0]['album'], 'album') def test_get_stats(self): response = self.client.get('/stats') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['items'], 3) self.assertEqual(res_json['albums'], 2) def test_delete_item_id(self): web.app.config['READONLY'] = False # Create a temporary item item_id = self.lib.add(Item(title='test_delete_item_id', test_delete_item_id=1)) # Check we can find the temporary item we just created response = self.client.get('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) # Delete item by id response = self.client.delete('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) # Check the item has gone response = self.client.get('/item/' + str(item_id)) self.assertEqual(response.status_code, 404) # Note: if this fails, the item may still be around # and may cause other tests to fail def test_delete_item_without_file(self): web.app.config['READONLY'] = False # Create an item with a file ipath = os.path.join(self.temp_dir, b'testfile1.mp3') shutil.copy(os.path.join(_common.RSRC, b'full.mp3'), ipath) self.assertTrue(os.path.exists(ipath)) item_id = self.lib.add(Item.from_path(ipath)) # Check we can find the temporary item we just created response = self.client.get('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) # Delete item by id, without deleting file response = self.client.delete('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) # Check the item has gone response = self.client.get('/item/' + str(item_id)) self.assertEqual(response.status_code, 404) # Check the file has not gone self.assertTrue(os.path.exists(ipath)) os.remove(ipath) def test_delete_item_with_file(self): web.app.config['READONLY'] = False # Create an item with a file ipath = os.path.join(self.temp_dir, b'testfile2.mp3') shutil.copy(os.path.join(_common.RSRC, b'full.mp3'), ipath) self.assertTrue(os.path.exists(ipath)) item_id = self.lib.add(Item.from_path(ipath)) # Check we can find the temporary item we just created response = self.client.get('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) # Delete item by id, with file response = self.client.delete('/item/' + str(item_id) + '?delete') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) # Check the item has gone response = self.client.get('/item/' + str(item_id)) self.assertEqual(response.status_code, 404) # Check the file has gone self.assertFalse(os.path.exists(ipath)) def test_delete_item_query(self): web.app.config['READONLY'] = False # Create a temporary item self.lib.add(Item(title='test_delete_item_query', test_delete_item_query=1)) # Check we can find the temporary item we just created response = self.client.get('/item/query/test_delete_item_query') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) # Delete item by query response = self.client.delete('/item/query/test_delete_item_query') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) # Check the item has gone response = self.client.get('/item/query/test_delete_item_query') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 0) def test_delete_item_all_fails(self): """ DELETE is not supported for list all """ web.app.config['READONLY'] = False # Delete all items response = self.client.delete('/item/') self.assertEqual(response.status_code, 405) # Note: if this fails, all items have gone and rest of # tests wil fail! def test_delete_item_id_readonly(self): web.app.config['READONLY'] = True # Create a temporary item item_id = self.lib.add(Item(title='test_delete_item_id_ro', test_delete_item_id_ro=1)) # Check we can find the temporary item we just created response = self.client.get('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) # Try to delete item by id response = self.client.delete('/item/' + str(item_id)) self.assertEqual(response.status_code, 405) # Check the item has not gone response = self.client.get('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) # Remove it self.lib.get_item(item_id).remove() def test_delete_item_query_readonly(self): web.app.config['READONLY'] = True # Create a temporary item item_id = self.lib.add(Item(title='test_delete_item_q_ro', test_delete_item_q_ro=1)) # Check we can find the temporary item we just created response = self.client.get('/item/query/test_delete_item_q_ro') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) # Try to delete item by query response = self.client.delete('/item/query/test_delete_item_q_ro') self.assertEqual(response.status_code, 405) # Check the item has not gone response = self.client.get('/item/query/test_delete_item_q_ro') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) # Remove it self.lib.get_item(item_id).remove() def test_delete_album_id(self): web.app.config['READONLY'] = False # Create a temporary album album_id = self.lib.add(Album(album='test_delete_album_id', test_delete_album_id=1)) # Check we can find the temporary album we just created response = self.client.get('/album/' + str(album_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], album_id) # Delete album by id response = self.client.delete('/album/' + str(album_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) # Check the album has gone response = self.client.get('/album/' + str(album_id)) self.assertEqual(response.status_code, 404) # Note: if this fails, the album may still be around # and may cause other tests to fail def test_delete_album_query(self): web.app.config['READONLY'] = False # Create a temporary album self.lib.add(Album(album='test_delete_album_query', test_delete_album_query=1)) # Check we can find the temporary album we just created response = self.client.get('/album/query/test_delete_album_query') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) # Delete album response = self.client.delete('/album/query/test_delete_album_query') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) # Check the album has gone response = self.client.get('/album/query/test_delete_album_query') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 0) def test_delete_album_all_fails(self): """ DELETE is not supported for list all """ web.app.config['READONLY'] = False # Delete all albums response = self.client.delete('/album/') self.assertEqual(response.status_code, 405) # Note: if this fails, all albums have gone and rest of # tests wil fail! def test_delete_album_id_readonly(self): web.app.config['READONLY'] = True # Create a temporary album album_id = self.lib.add(Album(album='test_delete_album_id_ro', test_delete_album_id_ro=1)) # Check we can find the temporary album we just created response = self.client.get('/album/' + str(album_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], album_id) # Try to delete album by id response = self.client.delete('/album/' + str(album_id)) self.assertEqual(response.status_code, 405) # Check the item has not gone response = self.client.get('/album/' + str(album_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], album_id) # Remove it self.lib.get_album(album_id).remove() def test_delete_album_query_readonly(self): web.app.config['READONLY'] = True # Create a temporary album album_id = self.lib.add(Album(album='test_delete_album_query_ro', test_delete_album_query_ro=1)) # Check we can find the temporary album we just created response = self.client.get('/album/query/test_delete_album_query_ro') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) # Try to delete album response = self.client.delete( '/album/query/test_delete_album_query_ro' ) self.assertEqual(response.status_code, 405) # Check the album has not gone response = self.client.get('/album/query/test_delete_album_query_ro') res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(len(res_json['results']), 1) # Remove it self.lib.get_album(album_id).remove() def test_patch_item_id(self): # Note: PATCH is currently only implemented for track items, not albums web.app.config['READONLY'] = False # Create a temporary item item_id = self.lib.add(Item(title='test_patch_item_id', test_patch_f1=1, test_patch_f2="Old")) # Check we can find the temporary item we just created response = self.client.get('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) self.assertEqual( [res_json['test_patch_f1'], res_json['test_patch_f2']], ['1', 'Old']) # Patch item by id # patch_json = json.JSONEncoder().encode({"test_patch_f2": "New"}]}) response = self.client.patch('/item/' + str(item_id), json={"test_patch_f2": "New"}) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) self.assertEqual( [res_json['test_patch_f1'], res_json['test_patch_f2']], ['1', 'New']) # Check the update has really worked response = self.client.get('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) self.assertEqual( [res_json['test_patch_f1'], res_json['test_patch_f2']], ['1', 'New']) # Remove the item self.lib.get_item(item_id).remove() def test_patch_item_id_readonly(self): # Note: PATCH is currently only implemented for track items, not albums web.app.config['READONLY'] = True # Create a temporary item item_id = self.lib.add(Item(title='test_patch_item_id_ro', test_patch_f1=2, test_patch_f2="Old")) # Check we can find the temporary item we just created response = self.client.get('/item/' + str(item_id)) res_json = json.loads(response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertEqual(res_json['id'], item_id) self.assertEqual( [res_json['test_patch_f1'], res_json['test_patch_f2']], ['2', 'Old']) # Patch item by id # patch_json = json.JSONEncoder().encode({"test_patch_f2": "New"}) response = self.client.patch('/item/' + str(item_id), json={"test_patch_f2": "New"}) self.assertEqual(response.status_code, 405) # Remove the item self.lib.get_item(item_id).remove() def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/test_zero.py0000644000076500000240000002130000000000000015670 0ustar00asampsonstaff"""Tests for the 'zero' plugin""" import unittest from test.helper import TestHelper, control_stdin from beets.library import Item from beetsplug.zero import ZeroPlugin from mediafile import MediaFile from beets.util import syspath class ZeroPluginTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() self.config['zero'] = { 'fields': [], 'keep_fields': [], 'update_database': False, } def tearDown(self): ZeroPlugin.listeners = None self.teardown_beets() self.unload_plugins() def test_no_patterns(self): self.config['zero']['fields'] = ['comments', 'month'] item = self.add_item_fixture( comments='test comment', title='Title', month=1, year=2000, ) item.write() self.load_plugins('zero') item.write() mf = MediaFile(syspath(item.path)) self.assertIsNone(mf.comments) self.assertIsNone(mf.month) self.assertEqual(mf.title, 'Title') self.assertEqual(mf.year, 2000) def test_pattern_match(self): self.config['zero']['fields'] = ['comments'] self.config['zero']['comments'] = ['encoded by'] item = self.add_item_fixture(comments='encoded by encoder') item.write() self.load_plugins('zero') item.write() mf = MediaFile(syspath(item.path)) self.assertIsNone(mf.comments) def test_pattern_nomatch(self): self.config['zero']['fields'] = ['comments'] self.config['zero']['comments'] = ['encoded by'] item = self.add_item_fixture(comments='recorded at place') item.write() self.load_plugins('zero') item.write() mf = MediaFile(syspath(item.path)) self.assertEqual(mf.comments, 'recorded at place') def test_do_not_change_database(self): self.config['zero']['fields'] = ['year'] item = self.add_item_fixture(year=2000) item.write() self.load_plugins('zero') item.write() self.assertEqual(item['year'], 2000) def test_change_database(self): self.config['zero']['fields'] = ['year'] self.config['zero']['update_database'] = True item = self.add_item_fixture(year=2000) item.write() self.load_plugins('zero') item.write() self.assertEqual(item['year'], 0) def test_album_art(self): self.config['zero']['fields'] = ['images'] path = self.create_mediafile_fixture(images=['jpg']) item = Item.from_path(path) self.load_plugins('zero') item.write() mf = MediaFile(syspath(path)) self.assertEqual(0, len(mf.images)) def test_auto_false(self): self.config['zero']['fields'] = ['year'] self.config['zero']['update_database'] = True self.config['zero']['auto'] = False item = self.add_item_fixture(year=2000) item.write() self.load_plugins('zero') item.write() self.assertEqual(item['year'], 2000) def test_subcommand_update_database_true(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments='test comment' ) item.write() item_id = item.id self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = True self.config['zero']['auto'] = False self.load_plugins('zero') with control_stdin('y'): self.run_command('zero') mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mf.year, 2016) self.assertEqual(mf.comments, None) self.assertEqual(item['comments'], '') def test_subcommand_update_database_false(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments='test comment' ) item.write() item_id = item.id self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = False self.config['zero']['auto'] = False self.load_plugins('zero') with control_stdin('y'): self.run_command('zero') mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mf.year, 2016) self.assertEqual(item['comments'], 'test comment') self.assertEqual(mf.comments, None) def test_subcommand_query_include(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments='test comment' ) item.write() self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = False self.config['zero']['auto'] = False self.load_plugins('zero') self.run_command('zero', 'year: 2016') mf = MediaFile(syspath(item.path)) self.assertEqual(mf.year, 2016) self.assertEqual(mf.comments, None) def test_subcommand_query_exclude(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments='test comment' ) item.write() self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = False self.config['zero']['auto'] = False self.load_plugins('zero') self.run_command('zero', 'year: 0000') mf = MediaFile(syspath(item.path)) self.assertEqual(mf.year, 2016) self.assertEqual(mf.comments, 'test comment') def test_no_fields(self): item = self.add_item_fixture(year=2016) item.write() mediafile = MediaFile(syspath(item.path)) self.assertEqual(mediafile.year, 2016) item_id = item.id self.load_plugins('zero') with control_stdin('y'): self.run_command('zero') item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mediafile.year, 2016) def test_whitelist_and_blacklist(self): item = self.add_item_fixture(year=2016) item.write() mf = MediaFile(syspath(item.path)) self.assertEqual(mf.year, 2016) item_id = item.id self.config['zero']['fields'] = ['year'] self.config['zero']['keep_fields'] = ['comments'] self.load_plugins('zero') with control_stdin('y'): self.run_command('zero') item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mf.year, 2016) def test_keep_fields(self): item = self.add_item_fixture(year=2016, comments='test comment') self.config['zero']['keep_fields'] = ['year'] self.config['zero']['fields'] = None self.config['zero']['update_database'] = True tags = { 'comments': 'test comment', 'year': 2016, } self.load_plugins('zero') z = ZeroPlugin() z.write_event(item, item.path, tags) self.assertEqual(tags['comments'], None) self.assertEqual(tags['year'], 2016) def test_keep_fields_removes_preserved_tags(self): self.config['zero']['keep_fields'] = ['year'] self.config['zero']['fields'] = None self.config['zero']['update_database'] = True z = ZeroPlugin() self.assertNotIn('id', z.fields_to_progs) def test_fields_removes_preserved_tags(self): self.config['zero']['fields'] = ['year id'] self.config['zero']['update_database'] = True z = ZeroPlugin() self.assertNotIn('id', z.fields_to_progs) def test_empty_query_n_response_no_changes(self): item = self.add_item_fixture( year=2016, day=13, month=3, comments='test comment' ) item.write() item_id = item.id self.config['zero']['fields'] = ['comments'] self.config['zero']['update_database'] = True self.config['zero']['auto'] = False self.load_plugins('zero') with control_stdin('n'): self.run_command('zero') mf = MediaFile(syspath(item.path)) item = self.lib.get_item(item_id) self.assertEqual(item['year'], 2016) self.assertEqual(mf.year, 2016) self.assertEqual(mf.comments, 'test comment') self.assertEqual(item['comments'], 'test comment') def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1632858662.0 beets-1.6.0/test/testall.py0000755000076500000240000000234700000000000015337 0ustar00asampsonstaff#!/usr/bin/env python # This file is part of beets. # Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. import os import re import sys import unittest pkgpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) or '..' sys.path.insert(0, pkgpath) def suite(): s = unittest.TestSuite() # Get the suite() of every module in this directory beginning with # "test_". for fname in os.listdir(os.path.join(pkgpath, 'test')): 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')