diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/bug-report.md similarity index 57% rename from .github/ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/bug-report.md index 0f5481f0c..646243812 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,6 +1,17 @@ -### Problem +--- +name: "\U0001F41B Bug report" +about: Report a problem with beets -(Describe your problem, feature request, or discussion topic here. If you're reporting a bug, please fill out this and the "Setup" section below. Otherwise, you can delete them.) +--- + + + +### Problem Running this command in verbose (`-vv`) mode: @@ -14,7 +25,7 @@ Led to this problem: (paste here) ``` -Here's a link to the music files that trigger the bug (if relevant): +Here's a link to the music files that trigger the bug (if relevant): ### Setup diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000..5eb651aac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,26 @@ +--- +name: "\U0001F680 Feature request" +about: Suggest a new idea for beets + +--- + +### Use case + +I'm trying to use beets to... + + +### Solution + + + +### Alternatives + diff --git a/.gitignore b/.gitignore index b93d93305..64f08abe5 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ ENV/ /.project /.pydevproject /.settings +.vscode diff --git a/.travis.yml b/.travis.yml index 46c1bd8d6..455ab4ca4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,18 +24,21 @@ matrix: - python: 3.7 env: {TOX_ENV: py37-test} dist: xenial + # - python: 3.8-dev + # env: {TOX_ENV: py38-test} + # dist: xenial # - python: pypy # - env: {TOX_ENV: pypy-test} - - python: 3.4 - env: {TOX_ENV: py34-flake8} + - python: 3.6 + env: {TOX_ENV: py36-flake8} - python: 2.7.13 env: {TOX_ENV: docs} # Non-Python dependencies. addons: apt: sources: - - sourceline: "deb http://archive.ubuntu.com/ubuntu/ trusty multiverse" - - sourceline: "deb http://archive.ubuntu.com/ubuntu/ trusty-updates multiverse" + - sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty multiverse" + - sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty-updates multiverse" packages: - bash-completion - gir1.2-gst-plugins-base-1.0 diff --git a/README.rst b/README.rst index a3ea6302f..f9be39c52 100644 --- a/README.rst +++ b/README.rst @@ -1,12 +1,15 @@ -.. image:: http://img.shields.io/pypi/v/beets.svg +.. image:: https://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets -.. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg +.. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets .. image:: https://travis-ci.org/beetbox/beets.svg?branch=master :target: https://travis-ci.org/beetbox/beets +.. image:: https://repology.org/badge/tiny-repos/beets.svg + :target: https://repology.org/project/beets/versions + beets ===== @@ -51,37 +54,39 @@ imagine for your music collection. Via `plugins`_, beets becomes a panacea: If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. -.. _plugins: http://beets.readthedocs.org/page/plugins/ -.. _MPD: http://www.musicpd.org/ -.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/ +.. _plugins: https://beets.readthedocs.org/page/plugins/ +.. _MPD: https://www.musicpd.org/ +.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/ .. _writing your own plugin: - http://beets.readthedocs.org/page/dev/plugins.html + https://beets.readthedocs.org/page/dev/plugins.html .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html .. _albums that are missing tracks: - http://beets.readthedocs.org/page/plugins/missing.html + https://beets.readthedocs.org/page/plugins/missing.html .. _duplicate tracks and albums: - http://beets.readthedocs.org/page/plugins/duplicates.html + https://beets.readthedocs.org/page/plugins/duplicates.html .. _Transcode audio: - http://beets.readthedocs.org/page/plugins/convert.html -.. _Discogs: http://www.discogs.com/ + https://beets.readthedocs.org/page/plugins/convert.html +.. _Discogs: https://www.discogs.com/ .. _acoustic fingerprints: - http://beets.readthedocs.org/page/plugins/chroma.html -.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html -.. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html -.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html -.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html -.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html -.. _MusicBrainz: http://musicbrainz.org/ + 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``. Then check out the -`Getting Started`_ guide. +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: http://beets.readthedocs.org/page/guides/main.html +.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html +.. _software repositories: https://repology.org/project/beets/versions Contribute ---------- @@ -90,7 +95,7 @@ Check out the `Hacking`_ page on the wiki for tips on how to help out. You might also be interested in the `For Developers`_ section in the docs. .. _Hacking: https://github.com/beetbox/beets/wiki/Hacking -.. _For Developers: http://docs.beets.io/page/dev/ +.. _For Developers: https://beets.readthedocs.io/en/stable/dev/ Read More --------- @@ -98,8 +103,8 @@ Read More Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for news and updates. -.. _its Web site: http://beets.io/ -.. _@b33ts: http://twitter.com/b33ts/ +.. _its Web site: https://beets.io/ +.. _@b33ts: https://twitter.com/b33ts/ Authors ------- @@ -108,4 +113,4 @@ Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help, please visit our `forum`_. .. _forum: https://discourse.beets.io -.. _Adrian Sampson: http://www.cs.cornell.edu/~asampson/ +.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/ diff --git a/README_kr.rst b/README_kr.rst index 18389061c..25dd052d8 100644 --- a/README_kr.rst +++ b/README_kr.rst @@ -1,7 +1,7 @@ -.. image:: http://img.shields.io/pypi/v/beets.svg +.. image:: https://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets -.. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg +.. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg :target: https://codecov.io/github/beetbox/beets .. image:: https://travis-ci.org/beetbox/beets.svg?branch=master @@ -34,7 +34,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들 - 필요하는 메타 데이터를 계산하거나 패치 할 때: `album art`_, `lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic fingerprints`_. -- `MusicBrainz`_, `Discogs`_,`Beatport`_로부터 메타데이터를 가져오거나, +- `MusicBrainz`_, `Discogs`_,`Beatport`_로부터 메타데이터를 가져오거나, 노래 제목이나 음향 특징으로 메타데이터를 추측한다 - `Transcode audio`_ 당신이 좋아하는 어떤 포맷으로든 변경한다. - 당신의 라이브러리에서 `duplicate tracks and albums`_ 이나 `albums that are missing tracks`_ 를 검사한다. @@ -45,31 +45,31 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들 - 명령어로부터 음악 파일의 메타데이터를 분석할 수 있다. - `MPD`_ 프로토콜을 사용하여 음악 플레이어로 음악을 들으면, 엄청나게 다양한 인터페이스로 작동한다. -만약 Beets에 당신이 원하는게 아직 없다면, +만약 Beets에 당신이 원하는게 아직 없다면, 당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로 간단하다. -.. _plugins: http://beets.readthedocs.org/page/plugins/ -.. _MPD: http://www.musicpd.org/ -.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/ +.. _plugins: https://beets.readthedocs.org/page/plugins/ +.. _MPD: https://www.musicpd.org/ +.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/ .. _writing your own plugin: - http://beets.readthedocs.org/page/dev/plugins.html + https://beets.readthedocs.org/page/dev/plugins.html .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html .. _albums that are missing tracks: - http://beets.readthedocs.org/page/plugins/missing.html + https://beets.readthedocs.org/page/plugins/missing.html .. _duplicate tracks and albums: - http://beets.readthedocs.org/page/plugins/duplicates.html + https://beets.readthedocs.org/page/plugins/duplicates.html .. _Transcode audio: - http://beets.readthedocs.org/page/plugins/convert.html -.. _Discogs: http://www.discogs.com/ + https://beets.readthedocs.org/page/plugins/convert.html +.. _Discogs: https://www.discogs.com/ .. _acoustic fingerprints: - http://beets.readthedocs.org/page/plugins/chroma.html -.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html -.. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html -.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html -.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html -.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html -.. _MusicBrainz: http://musicbrainz.org/ + 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 설치 @@ -78,7 +78,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들 당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다. 그리고 `Getting Started`_ 가이드를 확인할 수 있다. -.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html +.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html 컨트리뷰션 ---------- @@ -87,16 +87,16 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들 당신은 docs 안에 `For Developers`_ 에도 관심이 있을수 있다. .. _Hacking: https://github.com/beetbox/beets/wiki/Hacking -.. _For Developers: http://docs.beets.io/page/dev/ +.. _For Developers: https://beets.readthedocs.io/en/stable/dev/ Read More --------- -`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다. +`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다. 트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수 있다. -.. _its Web site: http://beets.io/ -.. _@b33ts: http://twitter.com/b33ts/ +.. _its Web site: https://beets.io/ +.. _@b33ts: https://twitter.com/b33ts/ 저자들 ------- @@ -105,4 +105,4 @@ Read More 돕고 싶다면 `forum`_.를 방문하면 된다. .. _forum: https://discourse.beets.io -.. _Adrian Sampson: http://www.cs.cornell.edu/~asampson/ +.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/ diff --git a/appveyor.yml b/appveyor.yml index 938d3a5a4..00a3eb189 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -16,14 +16,16 @@ environment: TOX_ENV: py35-test - PYTHON: C:\Python36 TOX_ENV: py36-test + - PYTHON: C:\Python37 + TOX_ENV: py37-test # Install Tox for running tests. install: - - cinst imagemagick -y + - appveyor-retry cinst imagemagick -y # TODO: remove --allow-empty-checksums when unrar offers a proper checksum - - cinst unrar -y --allow-empty-checksums - - "%PYTHON%/Scripts/pip.exe install tox" - - "%PYTHON%/Scripts/tox.exe -e %TOX_ENV% --notest" + - appveyor-retry cinst unrar -y --allow-empty-checksums + - 'appveyor-retry %PYTHON%/Scripts/pip.exe install "tox<=3.8.1"' + - "appveyor-retry %PYTHON%/Scripts/tox.exe -e %TOX_ENV% --notest" test_script: - "%PYTHON%/Scripts/tox.exe -e %TOX_ENV%" diff --git a/beets/__init__.py b/beets/__init__.py index c76f633d2..20075073c 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -17,14 +17,14 @@ from __future__ import division, absolute_import, print_function import os -from beets.util import confit +import confuse -__version__ = u'1.4.8' +__version__ = u'1.5.0' __author__ = u'Adrian Sampson ' -class IncludeLazyConfig(confit.LazyConfig): - """A version of Confit's LazyConfig that also merges in data from +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): @@ -35,7 +35,7 @@ class IncludeLazyConfig(confit.LazyConfig): filename = view.as_filename() if os.path.isfile(filename): self.set_file(filename) - except confit.NotFoundError: + except confuse.NotFoundError: pass diff --git a/beets/art.py b/beets/art.py index 84c3a02d5..e7a087a05 100644 --- a/beets/art.py +++ b/beets/art.py @@ -26,7 +26,7 @@ import os from beets.util import displayable_path, syspath, bytestring_path from beets.util.artresizer import ArtResizer -from beets import mediafile +import mediafile def mediafile_image(image_path, maxwidth=None): @@ -51,7 +51,8 @@ def get_art(log, item): def embed_item(log, item, imagepath, maxwidth=None, itempath=None, - compare_threshold=0, ifempty=False, as_album=False): + compare_threshold=0, ifempty=False, as_album=False, + id3v23=None): """Embed an image into the item's media file. """ # Conditions and filters. @@ -80,7 +81,7 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None, image.mime_type) return - item.try_write(path=itempath, tags={'images': [image]}) + item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23) def embed_album(log, album, maxwidth=None, quiet=False, diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index a71b9b0a6..ede4fbe12 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -54,6 +54,12 @@ def apply_item_metadata(item, track_info): item.composer_sort = track_info.composer_sort if track_info.arranger is not None: item.arranger = track_info.arranger + if track_info.work is not None: + item.work = track_info.work + if track_info.mb_workid is not None: + item.mb_workid = track_info.mb_workid + if track_info.work_disambig is not None: + item.work_disambig = track_info.work_disambig # At the moment, the other metadata is left intact (including album # and track number). Perhaps these should be emptied? @@ -167,6 +173,9 @@ def apply_metadata(album_info, mapping): 'composer', 'composer_sort', 'arranger', + 'work', + 'mb_workid', + 'work_disambig', ) } diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index ec7047b7c..57cd1c309 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -159,6 +159,9 @@ class TrackInfo(object): - ``composer_sort``: individual track composer sort name - ``arranger`: individual track arranger name - ``track_alt``: alternative track number (tape, vinyl, etc.) + - ``work`: individual track work title + - ``mb_workid`: individual track work id + - ``work_disambig`: individual track work diambiguation Only ``title`` and ``track_id`` are required. The rest of the fields may be None. The indices ``index``, ``medium``, and ``medium_index`` @@ -169,7 +172,8 @@ class TrackInfo(object): 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): + composer_sort=None, arranger=None, track_alt=None, + work=None, mb_workid=None, work_disambig=None): self.title = title self.track_id = track_id self.release_track_id = release_track_id @@ -191,6 +195,9 @@ class TrackInfo(object): 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 # As above, work around a bug in python-musicbrainz-ngs. def decode(self, codec='utf-8'): diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 4ea56af7f..1a6e0b1f1 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -39,7 +39,7 @@ else: SKIPPED_TRACKS = ['[data track]'] musicbrainzngs.set_useragent('beets', beets.__version__, - 'http://beets.io/') + 'https://beets.io/') class MusicBrainzAPIError(util.HumanReadableException): @@ -213,6 +213,11 @@ def track_info(recording, index=None, medium=None, medium_index=None, 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: diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index e92cba40c..3195b52c9 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -23,14 +23,17 @@ from collections import defaultdict import threading import sqlite3 import contextlib -import collections import beets -from beets.util.functemplate import Template +from beets.util import functemplate from beets.util import py3_path from beets.dbcore import types from .query import MatchQuery, NullSort, TrueQuery import six +if six.PY2: + from collections import Mapping +else: + from collections.abc import Mapping class DBAccessError(Exception): @@ -42,7 +45,7 @@ class DBAccessError(Exception): """ -class FormattedMapping(collections.Mapping): +class FormattedMapping(Mapping): """A `dict`-like formatted view of a model. The accessor `mapping[key]` returns the formatted version of @@ -88,6 +91,100 @@ class FormattedMapping(collections.Mapping): return value +class LazyConvertDict(object): + """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(object): @@ -143,6 +240,11 @@ class Model(object): 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 @@ -172,8 +274,8 @@ class Model(object): """ self._db = db self._dirty = set() - self._values_fixed = {} - self._values_flex = {} + self._values_fixed = LazyConvertDict(self) + self._values_flex = LazyConvertDict(self) # Initial contents. self.update(values) @@ -187,10 +289,10 @@ class Model(object): ordinary construction are bypassed. """ obj = cls(db) - for key, value in fixed_values.items(): - obj._values_fixed[key] = cls._type(key).from_sql(value) - for key, value in flex_values.items(): - obj._values_flex[key] = cls._type(key).from_sql(value) + + obj._values_fixed.init(fixed_values) + obj._values_flex.init(flex_values) + return obj def __repr__(self): @@ -251,7 +353,10 @@ class Model(object): if key in getters: # Computed. return getters[key](self) elif key in self._fields: # Fixed. - return self._values_fixed.get(key, self._type(key).null) + 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] else: @@ -431,8 +536,8 @@ class Model(object): self._check_db() stored_obj = self._db._get(type(self), self.id) assert stored_obj is not None, u"object {0} not in DB".format(self.id) - self._values_fixed = {} - self._values_flex = {} + self._values_fixed = LazyConvertDict(self) + self._values_flex = LazyConvertDict(self) self.update(dict(stored_obj)) self.clear_dirty() @@ -492,7 +597,7 @@ class Model(object): """ # Perform substitution. if isinstance(template, six.string_types): - template = Template(template) + template = functemplate.template(template) return template.substitute(self.formatted(for_path), self._template_funcs()) @@ -519,7 +624,8 @@ class Results(object): """An item query result set. Iterating over the collection lazily constructs LibModel objects that reflect database rows. """ - def __init__(self, model_class, rows, db, query=None, sort=None): + 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`. @@ -539,6 +645,7 @@ class Results(object): 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 @@ -560,6 +667,10 @@ class Results(object): 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? @@ -572,7 +683,7 @@ class Results(object): else: while self._rows: row = self._rows.pop(0) - obj = self._make_model(row) + 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): @@ -594,20 +705,24 @@ class Results(object): # Objects are pre-sorted (i.e., by the database). return self._get_objects() - def _make_model(self, row): - # Get the flexible attributes for the object. - with self.db.transaction() as tx: - flex_rows = tx.query( - 'SELECT * FROM {0} WHERE entity_id=?'.format( - self.model_class._flex_table - ), - (row['id'],) - ) + def _get_indexed_flex_attrs(self): + """ Index flexible attributes by the entity id they belong to + """ + flex_values = dict() + for row in self.flex_rows: + if row['entity_id'] not in flex_values: + flex_values[row['entity_id']] = dict() + 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 = dict((k, v) for (k, v) in cols.items() if not k[:4] == 'flex') - flex_values = dict((row['key'], row['value']) for row in flex_rows) # Construct the Python object obj = self.model_class._awaken(self.db, values, flex_values) @@ -735,16 +850,21 @@ class Database(object): """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""" + 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. @@ -794,6 +914,13 @@ class Database(object): 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 @@ -822,6 +949,18 @@ class Database(object): """ 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): @@ -894,11 +1033,25 @@ class Database(object): "ORDER BY {0}".format(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 {0} WHERE entity_id IN + (SELECT id FROM {1} WHERE {2}); + """.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, + model_cls, rows, self, flex_rows, None if where else query, # Slow query component. sort if sort.is_slow() else None, # Slow sort component. ) diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py index ce88fa3bd..fee38afdd 100644 --- a/beets/dbcore/queryparse.py +++ b/beets/dbcore/queryparse.py @@ -20,7 +20,6 @@ from __future__ import division, absolute_import, print_function import re import itertools from . import query -import beets PARSE_QUERY_PART_REGEX = re.compile( # Non-capturing optional segment for the keyword. @@ -119,12 +118,13 @@ def construct_query_part(model_cls, prefixes, query_part): if not query_part: return query.TrueQuery() - # Use `model_cls` to build up a map from field names to `Query` - # classes. + # 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 = \ @@ -137,26 +137,27 @@ def construct_query_part(model_cls, prefixes, query_part): # The query type matches a specific field, but none was # specified. So we use a version of the query that matches # any field. - q = query.AnyFieldQuery(pattern, model_cls._search_fields, - query_class) - if negate: - return query.NotQuery(q) - else: - return q + out_query = query.AnyFieldQuery(pattern, model_cls._search_fields, + query_class) else: # Non-field query type. - if negate: - return query.NotQuery(query_class(pattern)) - else: - return query_class(pattern) + out_query = query_class(pattern) - # Otherwise, this must be a `FieldQuery`. Use the field name to - # construct the query object. - key = key.lower() - q = query_class(key.lower(), pattern, key in model_cls._fields) + # 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(q) - return q + return query.NotQuery(out_query) + else: + return out_query def query_from_strings(query_cls, model_cls, prefixes, query_parts): @@ -172,11 +173,13 @@ def query_from_strings(query_cls, model_cls, prefixes, query_parts): return query_cls(subqueries) -def construct_sort_part(model_cls, part): +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. + 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] @@ -185,7 +188,6 @@ def construct_sort_part(model_cls, part): assert direction in ('+', '-'), "part must end with + or -" is_ascending = direction == '+' - case_insensitive = beets.config['sort_case_insensitive'].get(bool) if field in model_cls._sorts: sort = model_cls._sorts[field](model_cls, is_ascending, case_insensitive) @@ -197,21 +199,23 @@ def construct_sort_part(model_cls, part): return sort -def sort_from_strings(model_cls, sort_parts): +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]) + 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)) + sort.add_sort(construct_sort_part(model_cls, part, + case_insensitive)) return sort -def parse_sorted_query(model_cls, parts, prefixes={}): +def parse_sorted_query(model_cls, parts, prefixes={}, + case_insensitive=True): """Given a list of strings, create the `Query` and `Sort` that they represent. """ @@ -246,5 +250,5 @@ def parse_sorted_query(model_cls, parts, prefixes={}): # 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) + s = sort_from_strings(model_cls, sort_parts, case_insensitive) return q, s diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 935d03870..521a5a1ee 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -97,7 +97,7 @@ class Type(object): 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: - http://www.sqlite.org/datatype3.html + 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 @@ -173,14 +173,18 @@ class Id(Integer): class Float(Type): - """A basic floating-point type. + """A basic floating-point type. The `digits` parameter specifies how + many decimal places to use in the human-readable representation. """ sql = u'REAL' query = query.NumericQuery model_type = float + def __init__(self, digits=1): + self.digits = digits + def format(self, value): - return u'{0:.1f}'.format(value or 0.0) + return u'{0:.{1}f}'.format(value or 0, self.digits) class NullFloat(Float): diff --git a/beets/importer.py b/beets/importer.py index 889f1297e..d2943b511 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -40,7 +40,7 @@ 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 -from beets import mediafile +import mediafile action = Enum('action', ['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG']) diff --git a/beets/library.py b/beets/library.py index 1e46fe5ef..bb49d0e99 100644 --- a/beets/library.py +++ b/beets/library.py @@ -26,12 +26,12 @@ import six import string from beets import logging -from beets.mediafile import MediaFile, UnreadableFileError +from mediafile import MediaFile, UnreadableFileError from beets import plugins from beets import util from beets.util import bytestring_path, syspath, normpath, samefile, \ - MoveOperation -from beets.util.functemplate import Template + MoveOperation, lazy_property +from beets.util.functemplate import template, Template from beets import dbcore from beets.dbcore import types import beets @@ -376,13 +376,25 @@ class FormattedItemMapping(dbcore.db.FormattedMapping): def __init__(self, item, for_path=False): super(FormattedItemMapping, self).__init__(item, for_path) - self.album = item.get_album() - self.album_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: for key in self.album.keys(True): - if key in Album.item_keys or key not in item._fields.keys(): - self.album_keys.append(key) - self.all_keys = set(self.model_keys).union(self.album_keys) + if key in Album.item_keys \ + or key not in self.item._fields.keys(): + album_keys.append(key) + return album_keys + + @lazy_property + def album(self): + return self.item.get_album() def _get(self, key): """Get the value for a key, either from the album or the item. @@ -439,6 +451,9 @@ class Item(LibModel): '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), @@ -611,7 +626,7 @@ class Item(LibModel): self.path = read_path - def write(self, path=None, tags=None): + 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 @@ -623,6 +638,9 @@ class Item(LibModel): `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: @@ -630,6 +648,9 @@ class Item(LibModel): 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() @@ -640,8 +661,7 @@ class Item(LibModel): # Open the file. try: - mediafile = MediaFile(syspath(path), - id3v23=beets.config['id3v23'].get(bool)) + mediafile = MediaFile(syspath(path), id3v23=id3v23) except UnreadableFileError as exc: raise ReadError(path, exc) @@ -657,14 +677,14 @@ class Item(LibModel): self.mtime = self.current_mtime() plugins.send('after_write', item=self, path=path) - def try_write(self, path=None, tags=None): + 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(path, tags) + self.write(*args, **kwargs) return True except FileOperationError as exc: log.error(u"{0}", exc) @@ -850,7 +870,7 @@ class Item(LibModel): if isinstance(path_format, Template): subpath_tmpl = path_format else: - subpath_tmpl = Template(path_format) + subpath_tmpl = template(path_format) # Evaluate the selected template. subpath = self.evaluate_template(subpath_tmpl, True) @@ -930,7 +950,7 @@ class Album(LibModel): 'releasegroupdisambig': types.STRING, 'rg_album_gain': types.NULL_FLOAT, 'rg_album_peak': types.NULL_FLOAT, - 'r128_album_gain': types.PaddedInt(6), + 'r128_album_gain': types.NullPaddedInt(6), 'original_year': types.PaddedInt(4), 'original_month': types.PaddedInt(2), 'original_day': types.PaddedInt(2), @@ -1129,7 +1149,7 @@ class Album(LibModel): image = bytestring_path(image) item_dir = item_dir or self.item_dir() - filename_tmpl = Template( + filename_tmpl = template( beets.config['art_filename'].as_str()) subpath = self.evaluate_template(filename_tmpl, True) if beets.config['asciify_paths']: @@ -1234,8 +1254,10 @@ def parse_query_parts(parts, model_cls): 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 + model_cls, non_path_parts, prefixes, case_insensitive ) # Add path queries to aggregate query. diff --git a/beets/mediafile.py b/beets/mediafile.py index 32a32fe1d..373642b42 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -13,2096 +13,16 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Handles low-level interfacing for files' tags. Wraps Mutagen to -automatically detect file types and provide a unified interface for a -useful subset of music files' tags. - -Usage: - - >>> f = MediaFile('Lucy.mp3') - >>> f.title - u'Lucy in the Sky with Diamonds' - >>> f.artist = 'The Beatles' - >>> f.save() - -A field will always return a reasonable value of the correct type, even -if no tag is present. If no value is available, the value will be false -(e.g., zero or the empty string). - -Internally ``MediaFile`` uses ``MediaField`` descriptors to access the -data from the tags. In turn ``MediaField`` uses a number of -``StorageStyle`` strategies to handle format specific logic. -""" from __future__ import division, absolute_import, print_function -import mutagen -import mutagen.id3 -import mutagen.mp4 -import mutagen.flac -import mutagen.asf +import mediafile -import codecs -import datetime -import re -import base64 -import binascii -import math -import struct -import imghdr -import os -import traceback -import enum -import logging -import six +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 -__all__ = ['UnreadableFileError', 'FileTypeError', 'MediaFile'] - -log = logging.getLogger(__name__) - -# Human-readable type names. -TYPES = { - 'mp3': 'MP3', - 'aac': 'AAC', - 'alac': 'ALAC', - 'ogg': 'OGG', - 'opus': 'Opus', - 'flac': 'FLAC', - 'ape': 'APE', - 'wv': 'WavPack', - 'mpc': 'Musepack', - 'asf': 'Windows Media', - 'aiff': 'AIFF', - 'dsf': 'DSD Stream File', -} - -PREFERRED_IMAGE_EXTENSIONS = {'jpeg': 'jpg'} - - -# Exceptions. - -class UnreadableFileError(Exception): - """Mutagen is not able to extract information from the file. - """ - def __init__(self, path, msg): - Exception.__init__(self, msg if msg else repr(path)) - - -class FileTypeError(UnreadableFileError): - """Reading this type of file is not supported. - - If passed the `mutagen_type` argument this indicates that the - mutagen type is not supported by `Mediafile`. - """ - def __init__(self, path, mutagen_type=None): - if mutagen_type is None: - msg = u'{0!r}: not in a recognized format'.format(path) - else: - msg = u'{0}: of mutagen type {1}'.format(repr(path), mutagen_type) - Exception.__init__(self, msg) - - -class MutagenError(UnreadableFileError): - """Raised when Mutagen fails unexpectedly---probably due to a bug. - """ - def __init__(self, path, mutagen_exc): - msg = u'{0}: {1}'.format(repr(path), mutagen_exc) - Exception.__init__(self, msg) - - -# Interacting with Mutagen. - -def mutagen_call(action, path, func, *args, **kwargs): - """Call a Mutagen function with appropriate error handling. - - `action` is a string describing what the function is trying to do, - and `path` is the relevant filename. The rest of the arguments - describe the callable to invoke. - - We require at least Mutagen 1.33, where `IOError` is *never* used, - neither for internal parsing errors *nor* for ordinary IO error - conditions such as a bad filename. Mutagen-specific parsing errors and IO - errors are reraised as `UnreadableFileError`. Other exceptions - raised inside Mutagen---i.e., bugs---are reraised as `MutagenError`. - """ - try: - return func(*args, **kwargs) - except mutagen.MutagenError as exc: - log.debug(u'%s failed: %s', action, six.text_type(exc)) - raise UnreadableFileError(path, six.text_type(exc)) - except Exception as exc: - # Isolate bugs in Mutagen. - log.debug(u'%s', traceback.format_exc()) - log.error(u'uncaught Mutagen exception in %s: %s', action, exc) - raise MutagenError(path, exc) - - -# Utility. - -def _safe_cast(out_type, val): - """Try to covert val to out_type but never raise an exception. If - the value can't be converted, then a sensible default value is - returned. out_type should be bool, int, or unicode; otherwise, the - value is just passed through. - """ - if val is None: - return None - - if out_type == int: - if isinstance(val, int) or isinstance(val, float): - # Just a number. - return int(val) - else: - # Process any other type as a string. - if isinstance(val, bytes): - val = val.decode('utf-8', 'ignore') - elif not isinstance(val, six.string_types): - val = six.text_type(val) - # Get a number from the front of the string. - match = re.match(r'[\+-]?[0-9]+', val.strip()) - return int(match.group(0)) if match else 0 - - elif out_type == bool: - try: - # Should work for strings, bools, ints: - return bool(int(val)) - except ValueError: - return False - - elif out_type == six.text_type: - if isinstance(val, bytes): - return val.decode('utf-8', 'ignore') - elif isinstance(val, six.text_type): - return val - else: - return six.text_type(val) - - elif out_type == float: - if isinstance(val, int) or isinstance(val, float): - return float(val) - else: - if isinstance(val, bytes): - val = val.decode('utf-8', 'ignore') - else: - val = six.text_type(val) - match = re.match(r'[\+-]?([0-9]+\.?[0-9]*|[0-9]*\.[0-9]+)', - val.strip()) - if match: - val = match.group(0) - if val: - return float(val) - return 0.0 - - else: - return val - - -# Image coding for ASF/WMA. - -def _unpack_asf_image(data): - """Unpack image data from a WM/Picture tag. Return a tuple - containing the MIME type, the raw image data, a type indicator, and - the image's description. - - This function is treated as "untrusted" and could throw all manner - of exceptions (out-of-bounds, etc.). We should clean this up - sometime so that the failure modes are well-defined. - """ - type, size = struct.unpack_from(' 0: - gain = math.log10(maxgain / 1000.0) * -10 - else: - # Invalid gain value found. - gain = 0.0 - - # SoundCheck stores peak values as the actual value of the sample, - # and again separately for the left and right channels. We need to - # convert this to a percentage of full scale, which is 32768 for a - # 16 bit sample. Once again, we play it safe by using the larger of - # the two values. - peak = max(soundcheck[6:8]) / 32768.0 - - return round(gain, 2), round(peak, 6) - - -def _sc_encode(gain, peak): - """Encode ReplayGain gain/peak values as a Sound Check string. - """ - # SoundCheck stores the peak value as the actual value of the - # sample, rather than the percentage of full scale that RG uses, so - # we do a simple conversion assuming 16 bit samples. - peak *= 32768.0 - - # SoundCheck stores absolute RMS values in some unknown units rather - # than the dB values RG uses. We can calculate these absolute values - # from the gain ratio using a reference value of 1000 units. We also - # enforce the maximum value here, which is equivalent to about - # -18.2dB. - g1 = int(min(round((10 ** (gain / -10)) * 1000), 65534)) - # Same as above, except our reference level is 2500 units. - g2 = int(min(round((10 ** (gain / -10)) * 2500), 65534)) - - # The purpose of these values are unknown, but they also seem to be - # unused so we just use zero. - uk = 0 - values = (g1, g1, g2, g2, uk, uk, int(peak), int(peak), uk, uk) - return (u' %08X' * 10) % values - - -# Cover art and other images. -def _imghdr_what_wrapper(data): - """A wrapper around imghdr.what to account for jpeg files that can only be - identified as such using their magic bytes - See #1545 - See https://github.com/file/file/blob/master/magic/Magdir/jpeg#L12 - """ - # imghdr.what returns none for jpegs with only the magic bytes, so - # _wider_test_jpeg is run in that case. It still returns None if it didn't - # match such a jpeg file. - return imghdr.what(None, h=data) or _wider_test_jpeg(data) - - -def _wider_test_jpeg(data): - """Test for a jpeg file following the UNIX file implementation which - uses the magic bytes rather than just looking for the bytes that - represent 'JFIF' or 'EXIF' at a fixed position. - """ - if data[:2] == b'\xff\xd8': - return 'jpeg' - - -def image_mime_type(data): - """Return the MIME type of the image data (a bytestring). - """ - # This checks for a jpeg file with only the magic bytes (unrecognized by - # imghdr.what). imghdr.what returns none for that type of file, so - # _wider_test_jpeg is run in that case. It still returns None if it didn't - # match such a jpeg file. - kind = _imghdr_what_wrapper(data) - if kind in ['gif', 'jpeg', 'png', 'tiff', 'bmp']: - return 'image/{0}'.format(kind) - elif kind == 'pgm': - return 'image/x-portable-graymap' - elif kind == 'pbm': - return 'image/x-portable-bitmap' - elif kind == 'ppm': - return 'image/x-portable-pixmap' - elif kind == 'xbm': - return 'image/x-xbitmap' - else: - return 'image/x-{0}'.format(kind) - - -def image_extension(data): - ext = _imghdr_what_wrapper(data) - return PREFERRED_IMAGE_EXTENSIONS.get(ext, ext) - - -class ImageType(enum.Enum): - """Indicates the kind of an `Image` stored in a file's tag. - """ - other = 0 - icon = 1 - other_icon = 2 - front = 3 - back = 4 - leaflet = 5 - media = 6 - lead_artist = 7 - artist = 8 - conductor = 9 - group = 10 - composer = 11 - lyricist = 12 - recording_location = 13 - recording_session = 14 - performance = 15 - screen_capture = 16 - fish = 17 - illustration = 18 - artist_logo = 19 - publisher_logo = 20 - - -class Image(object): - """Structure representing image data and metadata that can be - stored and retrieved from tags. - - The structure has four properties. - * ``data`` The binary data of the image - * ``desc`` An optional description of the image - * ``type`` An instance of `ImageType` indicating the kind of image - * ``mime_type`` Read-only property that contains the mime type of - the binary data - """ - def __init__(self, data, desc=None, type=None): - assert isinstance(data, bytes) - if desc is not None: - assert isinstance(desc, six.text_type) - self.data = data - self.desc = desc - if isinstance(type, int): - try: - type = list(ImageType)[type] - except IndexError: - log.debug(u"ignoring unknown image type index %s", type) - type = ImageType.other - self.type = type - - @property - def mime_type(self): - if self.data: - return image_mime_type(self.data) - - @property - def type_index(self): - if self.type is None: - # This method is used when a tag format requires the type - # index to be set, so we return "other" as the default value. - return 0 - return self.type.value - - -# StorageStyle classes describe strategies for accessing values in -# Mutagen file objects. - -class StorageStyle(object): - """A strategy for storing a value for a certain tag format (or set - of tag formats). This basic StorageStyle describes simple 1:1 - mapping from raw values to keys in a Mutagen file object; subclasses - describe more sophisticated translations or format-specific access - strategies. - - MediaFile uses a StorageStyle via three methods: ``get()``, - ``set()``, and ``delete()``. It passes a Mutagen file object to - each. - - Internally, the StorageStyle implements ``get()`` and ``set()`` - using two steps that may be overridden by subtypes. To get a value, - the StorageStyle first calls ``fetch()`` to retrieve the value - corresponding to a key and then ``deserialize()`` to convert the raw - Mutagen value to a consumable Python value. Similarly, to set a - field, we call ``serialize()`` to encode the value and then - ``store()`` to assign the result into the Mutagen object. - - Each StorageStyle type has a class-level `formats` attribute that is - a list of strings indicating the formats that the style applies to. - MediaFile only uses StorageStyles that apply to the correct type for - a given audio file. - """ - - formats = ['FLAC', 'OggOpus', 'OggTheora', 'OggSpeex', 'OggVorbis', - 'OggFlac', 'APEv2File', 'WavPack', 'Musepack', 'MonkeysAudio'] - """List of mutagen classes the StorageStyle can handle. - """ - - def __init__(self, key, as_type=six.text_type, suffix=None, - float_places=2): - """Create a basic storage strategy. Parameters: - - - `key`: The key on the Mutagen file object used to access the - field's data. - - `as_type`: The Python type that the value is stored as - internally (`unicode`, `int`, `bool`, or `bytes`). - - `suffix`: When `as_type` is a string type, append this before - storing the value. - - `float_places`: When the value is a floating-point number and - encoded as a string, the number of digits to store after the - decimal point. - """ - self.key = key - self.as_type = as_type - self.suffix = suffix - self.float_places = float_places - - # Convert suffix to correct string type. - if self.suffix and self.as_type is six.text_type \ - and not isinstance(self.suffix, six.text_type): - self.suffix = self.suffix.decode('utf-8') - - # Getter. - - def get(self, mutagen_file): - """Get the value for the field using this style. - """ - return self.deserialize(self.fetch(mutagen_file)) - - def fetch(self, mutagen_file): - """Retrieve the raw value of for this tag from the Mutagen file - object. - """ - try: - return mutagen_file[self.key][0] - except (KeyError, IndexError): - return None - - def deserialize(self, mutagen_value): - """Given a raw value stored on a Mutagen object, decode and - return the represented value. - """ - if self.suffix and isinstance(mutagen_value, six.text_type) \ - and mutagen_value.endswith(self.suffix): - return mutagen_value[:-len(self.suffix)] - else: - return mutagen_value - - # Setter. - - def set(self, mutagen_file, value): - """Assign the value for the field using this style. - """ - self.store(mutagen_file, self.serialize(value)) - - def store(self, mutagen_file, value): - """Store a serialized value in the Mutagen file object. - """ - mutagen_file[self.key] = [value] - - def serialize(self, value): - """Convert the external Python value to a type that is suitable for - storing in a Mutagen file object. - """ - if isinstance(value, float) and self.as_type is six.text_type: - value = u'{0:.{1}f}'.format(value, self.float_places) - value = self.as_type(value) - elif self.as_type is six.text_type: - if isinstance(value, bool): - # Store bools as 1/0 instead of True/False. - value = six.text_type(int(bool(value))) - elif isinstance(value, bytes): - value = value.decode('utf-8', 'ignore') - else: - value = six.text_type(value) - else: - value = self.as_type(value) - - if self.suffix: - value += self.suffix - - return value - - def delete(self, mutagen_file): - """Remove the tag from the file. - """ - if self.key in mutagen_file: - del mutagen_file[self.key] - - -class ListStorageStyle(StorageStyle): - """Abstract storage style that provides access to lists. - - The ListMediaField descriptor uses a ListStorageStyle via two - methods: ``get_list()`` and ``set_list()``. It passes a Mutagen file - object to each. - - Subclasses may overwrite ``fetch`` and ``store``. ``fetch`` must - return a (possibly empty) list and ``store`` receives a serialized - list of values as the second argument. - - The `serialize` and `deserialize` methods (from the base - `StorageStyle`) are still called with individual values. This class - handles packing and unpacking the values into lists. - """ - def get(self, mutagen_file): - """Get the first value in the field's value list. - """ - try: - return self.get_list(mutagen_file)[0] - except IndexError: - return None - - def get_list(self, mutagen_file): - """Get a list of all values for the field using this style. - """ - return [self.deserialize(item) for item in self.fetch(mutagen_file)] - - def fetch(self, mutagen_file): - """Get the list of raw (serialized) values. - """ - try: - return mutagen_file[self.key] - except KeyError: - return [] - - def set(self, mutagen_file, value): - """Set an individual value as the only value for the field using - this style. - """ - self.set_list(mutagen_file, [value]) - - def set_list(self, mutagen_file, values): - """Set all values for the field using this style. `values` - should be an iterable. - """ - self.store(mutagen_file, [self.serialize(value) for value in values]) - - def store(self, mutagen_file, values): - """Set the list of all raw (serialized) values for this field. - """ - mutagen_file[self.key] = values - - -class SoundCheckStorageStyleMixin(object): - """A mixin for storage styles that read and write iTunes SoundCheck - analysis values. The object must have an `index` field that - indicates which half of the gain/peak pair---0 or 1---the field - represents. - """ - def get(self, mutagen_file): - data = self.fetch(mutagen_file) - if data is not None: - return _sc_decode(data)[self.index] - - def set(self, mutagen_file, value): - data = self.fetch(mutagen_file) - if data is None: - gain_peak = [0, 0] - else: - gain_peak = list(_sc_decode(data)) - gain_peak[self.index] = value or 0 - data = self.serialize(_sc_encode(*gain_peak)) - self.store(mutagen_file, data) - - -class ASFStorageStyle(ListStorageStyle): - """A general storage style for Windows Media/ASF files. - """ - formats = ['ASF'] - - def deserialize(self, data): - if isinstance(data, mutagen.asf.ASFBaseAttribute): - data = data.value - return data - - -class MP4StorageStyle(StorageStyle): - """A general storage style for MPEG-4 tags. - """ - formats = ['MP4'] - - def serialize(self, value): - value = super(MP4StorageStyle, self).serialize(value) - if self.key.startswith('----:') and isinstance(value, six.text_type): - value = value.encode('utf-8') - return value - - -class MP4TupleStorageStyle(MP4StorageStyle): - """A style for storing values as part of a pair of numbers in an - MPEG-4 file. - """ - def __init__(self, key, index=0, **kwargs): - super(MP4TupleStorageStyle, self).__init__(key, **kwargs) - self.index = index - - def deserialize(self, mutagen_value): - items = mutagen_value or [] - packing_length = 2 - return list(items) + [0] * (packing_length - len(items)) - - def get(self, mutagen_file): - value = super(MP4TupleStorageStyle, self).get(mutagen_file)[self.index] - if value == 0: - # The values are always present and saved as integers. So we - # assume that "0" indicates it is not set. - return None - else: - return value - - def set(self, mutagen_file, value): - if value is None: - value = 0 - items = self.deserialize(self.fetch(mutagen_file)) - items[self.index] = int(value) - self.store(mutagen_file, items) - - def delete(self, mutagen_file): - if self.index == 0: - super(MP4TupleStorageStyle, self).delete(mutagen_file) - else: - self.set(mutagen_file, None) - - -class MP4ListStorageStyle(ListStorageStyle, MP4StorageStyle): - pass - - -class MP4SoundCheckStorageStyle(SoundCheckStorageStyleMixin, MP4StorageStyle): - def __init__(self, key, index=0, **kwargs): - super(MP4SoundCheckStorageStyle, self).__init__(key, **kwargs) - self.index = index - - -class MP4BoolStorageStyle(MP4StorageStyle): - """A style for booleans in MPEG-4 files. (MPEG-4 has an atom type - specifically for representing booleans.) - """ - def get(self, mutagen_file): - try: - return mutagen_file[self.key] - except KeyError: - return None - - def get_list(self, mutagen_file): - raise NotImplementedError(u'MP4 bool storage does not support lists') - - def set(self, mutagen_file, value): - mutagen_file[self.key] = value - - def set_list(self, mutagen_file, values): - raise NotImplementedError(u'MP4 bool storage does not support lists') - - -class MP4ImageStorageStyle(MP4ListStorageStyle): - """Store images as MPEG-4 image atoms. Values are `Image` objects. - """ - def __init__(self, **kwargs): - super(MP4ImageStorageStyle, self).__init__(key='covr', **kwargs) - - def deserialize(self, data): - return Image(data) - - def serialize(self, image): - if image.mime_type == 'image/png': - kind = mutagen.mp4.MP4Cover.FORMAT_PNG - elif image.mime_type == 'image/jpeg': - kind = mutagen.mp4.MP4Cover.FORMAT_JPEG - else: - raise ValueError(u'MP4 files only supports PNG and JPEG images') - return mutagen.mp4.MP4Cover(image.data, kind) - - -class MP3StorageStyle(StorageStyle): - """Store data in ID3 frames. - """ - formats = ['MP3', 'AIFF', 'DSF'] - - def __init__(self, key, id3_lang=None, **kwargs): - """Create a new ID3 storage style. `id3_lang` is the value for - the language field of newly created frames. - """ - self.id3_lang = id3_lang - super(MP3StorageStyle, self).__init__(key, **kwargs) - - def fetch(self, mutagen_file): - try: - return mutagen_file[self.key].text[0] - except (KeyError, IndexError): - return None - - def store(self, mutagen_file, value): - frame = mutagen.id3.Frames[self.key](encoding=3, text=[value]) - mutagen_file.tags.setall(self.key, [frame]) - - -class MP3PeopleStorageStyle(MP3StorageStyle): - """Store list of people in ID3 frames. - """ - def __init__(self, key, involvement='', **kwargs): - self.involvement = involvement - super(MP3PeopleStorageStyle, self).__init__(key, **kwargs) - - def store(self, mutagen_file, value): - frames = mutagen_file.tags.getall(self.key) - - # Try modifying in place. - found = False - for frame in frames: - if frame.encoding == mutagen.id3.Encoding.UTF8: - for pair in frame.people: - if pair[0].lower() == self.involvement.lower(): - pair[1] = value - found = True - - # Try creating a new frame. - if not found: - frame = mutagen.id3.Frames[self.key]( - encoding=mutagen.id3.Encoding.UTF8, - people=[[self.involvement, value]] - ) - mutagen_file.tags.add(frame) - - def fetch(self, mutagen_file): - for frame in mutagen_file.tags.getall(self.key): - for pair in frame.people: - if pair[0].lower() == self.involvement.lower(): - try: - return pair[1] - except IndexError: - return None - - -class MP3ListStorageStyle(ListStorageStyle, MP3StorageStyle): - """Store lists of data in multiple ID3 frames. - """ - def fetch(self, mutagen_file): - try: - return mutagen_file[self.key].text - except KeyError: - return [] - - def store(self, mutagen_file, values): - frame = mutagen.id3.Frames[self.key](encoding=3, text=values) - mutagen_file.tags.setall(self.key, [frame]) - - -class MP3UFIDStorageStyle(MP3StorageStyle): - """Store string data in a UFID ID3 frame with a particular owner. - """ - def __init__(self, owner, **kwargs): - self.owner = owner - super(MP3UFIDStorageStyle, self).__init__('UFID:' + owner, **kwargs) - - def fetch(self, mutagen_file): - try: - return mutagen_file[self.key].data - except KeyError: - return None - - def store(self, mutagen_file, value): - # This field type stores text data as encoded data. - assert isinstance(value, six.text_type) - value = value.encode('utf-8') - - frames = mutagen_file.tags.getall(self.key) - for frame in frames: - # Replace existing frame data. - if frame.owner == self.owner: - frame.data = value - else: - # New frame. - frame = mutagen.id3.UFID(owner=self.owner, data=value) - mutagen_file.tags.setall(self.key, [frame]) - - -class MP3DescStorageStyle(MP3StorageStyle): - """Store data in a TXXX (or similar) ID3 frame. The frame is - selected based its ``desc`` field. - """ - def __init__(self, desc=u'', key='TXXX', **kwargs): - assert isinstance(desc, six.text_type) - self.description = desc - super(MP3DescStorageStyle, self).__init__(key=key, **kwargs) - - def store(self, mutagen_file, value): - frames = mutagen_file.tags.getall(self.key) - if self.key != 'USLT': - value = [value] - - # Try modifying in place. - found = False - for frame in frames: - if frame.desc.lower() == self.description.lower(): - frame.text = value - frame.encoding = mutagen.id3.Encoding.UTF8 - found = True - - # Try creating a new frame. - if not found: - frame = mutagen.id3.Frames[self.key]( - desc=self.description, - text=value, - encoding=mutagen.id3.Encoding.UTF8, - ) - if self.id3_lang: - frame.lang = self.id3_lang - mutagen_file.tags.add(frame) - - def fetch(self, mutagen_file): - for frame in mutagen_file.tags.getall(self.key): - if frame.desc.lower() == self.description.lower(): - if self.key == 'USLT': - return frame.text - try: - return frame.text[0] - except IndexError: - return None - - def delete(self, mutagen_file): - found_frame = None - for frame in mutagen_file.tags.getall(self.key): - if frame.desc.lower() == self.description.lower(): - found_frame = frame - break - if found_frame is not None: - del mutagen_file[frame.HashKey] - - -class MP3SlashPackStorageStyle(MP3StorageStyle): - """Store value as part of pair that is serialized as a slash- - separated string. - """ - def __init__(self, key, pack_pos=0, **kwargs): - super(MP3SlashPackStorageStyle, self).__init__(key, **kwargs) - self.pack_pos = pack_pos - - def _fetch_unpacked(self, mutagen_file): - data = self.fetch(mutagen_file) - if data: - items = six.text_type(data).split('/') - else: - items = [] - packing_length = 2 - return list(items) + [None] * (packing_length - len(items)) - - def get(self, mutagen_file): - return self._fetch_unpacked(mutagen_file)[self.pack_pos] - - def set(self, mutagen_file, value): - items = self._fetch_unpacked(mutagen_file) - items[self.pack_pos] = value - if items[0] is None: - items[0] = '' - if items[1] is None: - items.pop() # Do not store last value - self.store(mutagen_file, '/'.join(map(six.text_type, items))) - - def delete(self, mutagen_file): - if self.pack_pos == 0: - super(MP3SlashPackStorageStyle, self).delete(mutagen_file) - else: - self.set(mutagen_file, None) - - -class MP3ImageStorageStyle(ListStorageStyle, MP3StorageStyle): - """Converts between APIC frames and ``Image`` instances. - - The `get_list` method inherited from ``ListStorageStyle`` returns a - list of ``Image``s. Similarly, the `set_list` method accepts a - list of ``Image``s as its ``values`` argument. - """ - def __init__(self): - super(MP3ImageStorageStyle, self).__init__(key='APIC') - self.as_type = bytes - - def deserialize(self, apic_frame): - """Convert APIC frame into Image.""" - return Image(data=apic_frame.data, desc=apic_frame.desc, - type=apic_frame.type) - - def fetch(self, mutagen_file): - return mutagen_file.tags.getall(self.key) - - def store(self, mutagen_file, frames): - mutagen_file.tags.setall(self.key, frames) - - def delete(self, mutagen_file): - mutagen_file.tags.delall(self.key) - - def serialize(self, image): - """Return an APIC frame populated with data from ``image``. - """ - assert isinstance(image, Image) - frame = mutagen.id3.Frames[self.key]() - frame.data = image.data - frame.mime = image.mime_type - frame.desc = image.desc or u'' - - # For compatibility with OS X/iTunes prefer latin-1 if possible. - # See issue #899 - try: - frame.desc.encode("latin-1") - except UnicodeEncodeError: - frame.encoding = mutagen.id3.Encoding.UTF16 - else: - frame.encoding = mutagen.id3.Encoding.LATIN1 - - frame.type = image.type_index - return frame - - -class MP3SoundCheckStorageStyle(SoundCheckStorageStyleMixin, - MP3DescStorageStyle): - def __init__(self, index=0, **kwargs): - super(MP3SoundCheckStorageStyle, self).__init__(**kwargs) - self.index = index - - -class ASFImageStorageStyle(ListStorageStyle): - """Store images packed into Windows Media/ASF byte array attributes. - Values are `Image` objects. - """ - formats = ['ASF'] - - def __init__(self): - super(ASFImageStorageStyle, self).__init__(key='WM/Picture') - - def deserialize(self, asf_picture): - mime, data, type, desc = _unpack_asf_image(asf_picture.value) - return Image(data, desc=desc, type=type) - - def serialize(self, image): - pic = mutagen.asf.ASFByteArrayAttribute() - pic.value = _pack_asf_image(image.mime_type, image.data, - type=image.type_index, - description=image.desc or u'') - return pic - - -class VorbisImageStorageStyle(ListStorageStyle): - """Store images in Vorbis comments. Both legacy COVERART fields and - modern METADATA_BLOCK_PICTURE tags are supported. Data is - base64-encoded. Values are `Image` objects. - """ - formats = ['OggOpus', 'OggTheora', 'OggSpeex', 'OggVorbis', - 'OggFlac'] - - def __init__(self): - super(VorbisImageStorageStyle, self).__init__( - key='metadata_block_picture' - ) - self.as_type = bytes - - def fetch(self, mutagen_file): - images = [] - if 'metadata_block_picture' not in mutagen_file: - # Try legacy COVERART tags. - if 'coverart' in mutagen_file: - for data in mutagen_file['coverart']: - images.append(Image(base64.b64decode(data))) - return images - for data in mutagen_file["metadata_block_picture"]: - try: - pic = mutagen.flac.Picture(base64.b64decode(data)) - except (TypeError, AttributeError): - continue - images.append(Image(data=pic.data, desc=pic.desc, - type=pic.type)) - return images - - def store(self, mutagen_file, image_data): - # Strip all art, including legacy COVERART. - if 'coverart' in mutagen_file: - del mutagen_file['coverart'] - if 'coverartmime' in mutagen_file: - del mutagen_file['coverartmime'] - super(VorbisImageStorageStyle, self).store(mutagen_file, image_data) - - def serialize(self, image): - """Turn a Image into a base64 encoded FLAC picture block. - """ - pic = mutagen.flac.Picture() - pic.data = image.data - pic.type = image.type_index - pic.mime = image.mime_type - pic.desc = image.desc or u'' - - # Encoding with base64 returns bytes on both Python 2 and 3. - # Mutagen requires the data to be a Unicode string, so we decode - # it before passing it along. - return base64.b64encode(pic.write()).decode('ascii') - - -class FlacImageStorageStyle(ListStorageStyle): - """Converts between ``mutagen.flac.Picture`` and ``Image`` instances. - """ - formats = ['FLAC'] - - def __init__(self): - super(FlacImageStorageStyle, self).__init__(key='') - - def fetch(self, mutagen_file): - return mutagen_file.pictures - - def deserialize(self, flac_picture): - return Image(data=flac_picture.data, desc=flac_picture.desc, - type=flac_picture.type) - - def store(self, mutagen_file, pictures): - """``pictures`` is a list of mutagen.flac.Picture instances. - """ - mutagen_file.clear_pictures() - for pic in pictures: - mutagen_file.add_picture(pic) - - def serialize(self, image): - """Turn a Image into a mutagen.flac.Picture. - """ - pic = mutagen.flac.Picture() - pic.data = image.data - pic.type = image.type_index - pic.mime = image.mime_type - pic.desc = image.desc or u'' - return pic - - def delete(self, mutagen_file): - """Remove all images from the file. - """ - mutagen_file.clear_pictures() - - -class APEv2ImageStorageStyle(ListStorageStyle): - """Store images in APEv2 tags. Values are `Image` objects. - """ - formats = ['APEv2File', 'WavPack', 'Musepack', 'MonkeysAudio', 'OptimFROG'] - - TAG_NAMES = { - ImageType.other: 'Cover Art (other)', - ImageType.icon: 'Cover Art (icon)', - ImageType.other_icon: 'Cover Art (other icon)', - ImageType.front: 'Cover Art (front)', - ImageType.back: 'Cover Art (back)', - ImageType.leaflet: 'Cover Art (leaflet)', - ImageType.media: 'Cover Art (media)', - ImageType.lead_artist: 'Cover Art (lead)', - ImageType.artist: 'Cover Art (artist)', - ImageType.conductor: 'Cover Art (conductor)', - ImageType.group: 'Cover Art (band)', - ImageType.composer: 'Cover Art (composer)', - ImageType.lyricist: 'Cover Art (lyricist)', - ImageType.recording_location: 'Cover Art (studio)', - ImageType.recording_session: 'Cover Art (recording)', - ImageType.performance: 'Cover Art (performance)', - ImageType.screen_capture: 'Cover Art (movie scene)', - ImageType.fish: 'Cover Art (colored fish)', - ImageType.illustration: 'Cover Art (illustration)', - ImageType.artist_logo: 'Cover Art (band logo)', - ImageType.publisher_logo: 'Cover Art (publisher logo)', - } - - def __init__(self): - super(APEv2ImageStorageStyle, self).__init__(key='') - - def fetch(self, mutagen_file): - images = [] - for cover_type, cover_tag in self.TAG_NAMES.items(): - try: - frame = mutagen_file[cover_tag] - text_delimiter_index = frame.value.find(b'\x00') - if text_delimiter_index > 0: - comment = frame.value[0:text_delimiter_index] - comment = comment.decode('utf-8', 'replace') - else: - comment = None - image_data = frame.value[text_delimiter_index + 1:] - images.append(Image(data=image_data, type=cover_type, - desc=comment)) - except KeyError: - pass - - return images - - def set_list(self, mutagen_file, values): - self.delete(mutagen_file) - - for image in values: - image_type = image.type or ImageType.other - comment = image.desc or '' - image_data = comment.encode('utf-8') + b'\x00' + image.data - cover_tag = self.TAG_NAMES[image_type] - mutagen_file[cover_tag] = image_data - - def delete(self, mutagen_file): - """Remove all images from the file. - """ - for cover_tag in self.TAG_NAMES.values(): - try: - del mutagen_file[cover_tag] - except KeyError: - pass - - -# MediaField is a descriptor that represents a single logical field. It -# aggregates several StorageStyles describing how to access the data for -# each file type. - -class MediaField(object): - """A descriptor providing access to a particular (abstract) metadata - field. - """ - def __init__(self, *styles, **kwargs): - """Creates a new MediaField. - - :param styles: `StorageStyle` instances that describe the strategy - for reading and writing the field in particular - formats. There must be at least one style for - each possible file format. - - :param out_type: the type of the value that should be returned when - getting this property. - - """ - self.out_type = kwargs.get('out_type', six.text_type) - self._styles = styles - - def styles(self, mutagen_file): - """Yields the list of storage styles of this field that can - handle the MediaFile's format. - """ - for style in self._styles: - if mutagen_file.__class__.__name__ in style.formats: - yield style - - def __get__(self, mediafile, owner=None): - out = None - for style in self.styles(mediafile.mgfile): - out = style.get(mediafile.mgfile) - if out: - break - return _safe_cast(self.out_type, out) - - def __set__(self, mediafile, value): - if value is None: - value = self._none_value() - for style in self.styles(mediafile.mgfile): - style.set(mediafile.mgfile, value) - - def __delete__(self, mediafile): - for style in self.styles(mediafile.mgfile): - style.delete(mediafile.mgfile) - - def _none_value(self): - """Get an appropriate "null" value for this field's type. This - is used internally when setting the field to None. - """ - if self.out_type == int: - return 0 - elif self.out_type == float: - return 0.0 - elif self.out_type == bool: - return False - elif self.out_type == six.text_type: - return u'' - - -class ListMediaField(MediaField): - """Property descriptor that retrieves a list of multiple values from - a tag. - - Uses ``get_list`` and set_list`` methods of its ``StorageStyle`` - strategies to do the actual work. - """ - def __get__(self, mediafile, _): - values = [] - for style in self.styles(mediafile.mgfile): - values.extend(style.get_list(mediafile.mgfile)) - return [_safe_cast(self.out_type, value) for value in values] - - def __set__(self, mediafile, values): - for style in self.styles(mediafile.mgfile): - style.set_list(mediafile.mgfile, values) - - def single_field(self): - """Returns a ``MediaField`` descriptor that gets and sets the - first item. - """ - options = {'out_type': self.out_type} - return MediaField(*self._styles, **options) - - -class DateField(MediaField): - """Descriptor that handles serializing and deserializing dates - - The getter parses value from tags into a ``datetime.date`` instance - and setter serializes such an instance into a string. - - For granular access to year, month, and day, use the ``*_field`` - methods to create corresponding `DateItemField`s. - """ - def __init__(self, *date_styles, **kwargs): - """``date_styles`` is a list of ``StorageStyle``s to store and - retrieve the whole date from. The ``year`` option is an - additional list of fallback styles for the year. The year is - always set on this style, but is only retrieved if the main - storage styles do not return a value. - """ - super(DateField, self).__init__(*date_styles) - year_style = kwargs.get('year', None) - if year_style: - self._year_field = MediaField(*year_style) - - def __get__(self, mediafile, owner=None): - year, month, day = self._get_date_tuple(mediafile) - if not year: - return None - try: - return datetime.date( - year, - month or 1, - day or 1 - ) - except ValueError: # Out of range values. - return None - - def __set__(self, mediafile, date): - if date is None: - self._set_date_tuple(mediafile, None, None, None) - else: - self._set_date_tuple(mediafile, date.year, date.month, date.day) - - def __delete__(self, mediafile): - super(DateField, self).__delete__(mediafile) - if hasattr(self, '_year_field'): - self._year_field.__delete__(mediafile) - - def _get_date_tuple(self, mediafile): - """Get a 3-item sequence representing the date consisting of a - year, month, and day number. Each number is either an integer or - None. - """ - # Get the underlying data and split on hyphens and slashes. - datestring = super(DateField, self).__get__(mediafile, None) - if isinstance(datestring, six.string_types): - datestring = re.sub(r'[Tt ].*$', '', six.text_type(datestring)) - items = re.split('[-/]', six.text_type(datestring)) - else: - items = [] - - # Ensure that we have exactly 3 components, possibly by - # truncating or padding. - items = items[:3] - if len(items) < 3: - items += [None] * (3 - len(items)) - - # Use year field if year is missing. - if not items[0] and hasattr(self, '_year_field'): - items[0] = self._year_field.__get__(mediafile) - - # Convert each component to an integer if possible. - items_ = [] - for item in items: - try: - items_.append(int(item)) - except (TypeError, ValueError): - items_.append(None) - return items_ - - def _set_date_tuple(self, mediafile, year, month=None, day=None): - """Set the value of the field given a year, month, and day - number. Each number can be an integer or None to indicate an - unset component. - """ - if year is None: - self.__delete__(mediafile) - return - - date = [u'{0:04d}'.format(int(year))] - if month: - date.append(u'{0:02d}'.format(int(month))) - if month and day: - date.append(u'{0:02d}'.format(int(day))) - date = map(six.text_type, date) - super(DateField, self).__set__(mediafile, u'-'.join(date)) - - if hasattr(self, '_year_field'): - self._year_field.__set__(mediafile, year) - - def year_field(self): - return DateItemField(self, 0) - - def month_field(self): - return DateItemField(self, 1) - - def day_field(self): - return DateItemField(self, 2) - - -class DateItemField(MediaField): - """Descriptor that gets and sets constituent parts of a `DateField`: - the month, day, or year. - """ - def __init__(self, date_field, item_pos): - self.date_field = date_field - self.item_pos = item_pos - - def __get__(self, mediafile, _): - return self.date_field._get_date_tuple(mediafile)[self.item_pos] - - def __set__(self, mediafile, value): - items = self.date_field._get_date_tuple(mediafile) - items[self.item_pos] = value - self.date_field._set_date_tuple(mediafile, *items) - - def __delete__(self, mediafile): - self.__set__(mediafile, None) - - -class CoverArtField(MediaField): - """A descriptor that provides access to the *raw image data* for the - cover image on a file. This is used for backwards compatibility: the - full `ImageListField` provides richer `Image` objects. - - When there are multiple images we try to pick the most likely to be a front - cover. - """ - def __init__(self): - pass - - def __get__(self, mediafile, _): - candidates = mediafile.images - if candidates: - return self.guess_cover_image(candidates).data - else: - return None - - @staticmethod - def guess_cover_image(candidates): - if len(candidates) == 1: - return candidates[0] - try: - return next(c for c in candidates if c.type == ImageType.front) - except StopIteration: - return candidates[0] - - def __set__(self, mediafile, data): - if data: - mediafile.images = [Image(data=data)] - else: - mediafile.images = [] - - def __delete__(self, mediafile): - delattr(mediafile, 'images') - - -class ImageListField(ListMediaField): - """Descriptor to access the list of images embedded in tags. - - The getter returns a list of `Image` instances obtained from - the tags. The setter accepts a list of `Image` instances to be - written to the tags. - """ - def __init__(self): - # The storage styles used here must implement the - # `ListStorageStyle` interface and get and set lists of - # `Image`s. - super(ImageListField, self).__init__( - MP3ImageStorageStyle(), - MP4ImageStorageStyle(), - ASFImageStorageStyle(), - VorbisImageStorageStyle(), - FlacImageStorageStyle(), - APEv2ImageStorageStyle(), - out_type=Image, - ) - - -# MediaFile is a collection of fields. - -class MediaFile(object): - """Represents a multimedia file on disk and provides access to its - metadata. - """ - def __init__(self, path, id3v23=False): - """Constructs a new `MediaFile` reflecting the file at path. May - throw `UnreadableFileError`. - - By default, MP3 files are saved with ID3v2.4 tags. You can use - the older ID3v2.3 standard by specifying the `id3v23` option. - """ - self.path = path - - self.mgfile = mutagen_call('open', path, mutagen.File, path) - - if self.mgfile is None: - # Mutagen couldn't guess the type - raise FileTypeError(path) - elif (type(self.mgfile).__name__ == 'M4A' or - type(self.mgfile).__name__ == 'MP4'): - info = self.mgfile.info - if info.codec and info.codec.startswith('alac'): - self.type = 'alac' - else: - self.type = 'aac' - elif (type(self.mgfile).__name__ == 'ID3' or - type(self.mgfile).__name__ == 'MP3'): - self.type = 'mp3' - elif type(self.mgfile).__name__ == 'FLAC': - self.type = 'flac' - elif type(self.mgfile).__name__ == 'OggOpus': - self.type = 'opus' - elif type(self.mgfile).__name__ == 'OggVorbis': - self.type = 'ogg' - elif type(self.mgfile).__name__ == 'MonkeysAudio': - self.type = 'ape' - elif type(self.mgfile).__name__ == 'WavPack': - self.type = 'wv' - elif type(self.mgfile).__name__ == 'Musepack': - self.type = 'mpc' - elif type(self.mgfile).__name__ == 'ASF': - self.type = 'asf' - elif type(self.mgfile).__name__ == 'AIFF': - self.type = 'aiff' - elif type(self.mgfile).__name__ == 'DSF': - self.type = 'dsf' - else: - raise FileTypeError(path, type(self.mgfile).__name__) - - # Add a set of tags if it's missing. - if self.mgfile.tags is None: - self.mgfile.add_tags() - - # Set the ID3v2.3 flag only for MP3s. - self.id3v23 = id3v23 and self.type == 'mp3' - - def save(self): - """Write the object's tags back to the file. May - throw `UnreadableFileError`. - """ - # Possibly save the tags to ID3v2.3. - kwargs = {} - if self.id3v23: - id3 = self.mgfile - if hasattr(id3, 'tags'): - # In case this is an MP3 object, not an ID3 object. - id3 = id3.tags - id3.update_to_v23() - kwargs['v2_version'] = 3 - - mutagen_call('save', self.path, self.mgfile.save, **kwargs) - - def delete(self): - """Remove the current metadata tag from the file. May - throw `UnreadableFileError`. - """ - mutagen_call('delete', self.path, self.mgfile.delete) - - # Convenient access to the set of available fields. - - @classmethod - def fields(cls): - """Get the names of all writable properties that reflect - metadata tags (i.e., those that are instances of - :class:`MediaField`). - """ - for property, descriptor in cls.__dict__.items(): - if isinstance(descriptor, MediaField): - if isinstance(property, bytes): - # On Python 2, class field names are bytes. This method - # produces text strings. - yield property.decode('utf8', 'ignore') - else: - yield property - - @classmethod - def _field_sort_name(cls, name): - """Get a sort key for a field name that determines the order - fields should be written in. - - Fields names are kept unchanged, unless they are instances of - :class:`DateItemField`, in which case `year`, `month`, and `day` - are replaced by `date0`, `date1`, and `date2`, respectively, to - make them appear in that order. - """ - if isinstance(cls.__dict__[name], DateItemField): - name = re.sub('year', 'date0', name) - name = re.sub('month', 'date1', name) - name = re.sub('day', 'date2', name) - return name - - @classmethod - def sorted_fields(cls): - """Get the names of all writable metadata fields, sorted in the - order that they should be written. - - This is a lexicographic order, except for instances of - :class:`DateItemField`, which are sorted in year-month-day - order. - """ - for property in sorted(cls.fields(), key=cls._field_sort_name): - yield property - - @classmethod - def readable_fields(cls): - """Get all metadata fields: the writable ones from - :meth:`fields` and also other audio properties. - """ - for property in cls.fields(): - yield property - for property in ('length', 'samplerate', 'bitdepth', 'bitrate', - 'channels', 'format'): - yield property - - @classmethod - def add_field(cls, name, descriptor): - """Add a field to store custom tags. - - :param name: the name of the property the field is accessed - through. It must not already exist on this class. - - :param descriptor: an instance of :class:`MediaField`. - """ - if not isinstance(descriptor, MediaField): - raise ValueError( - u'{0} must be an instance of MediaField'.format(descriptor)) - if name in cls.__dict__: - raise ValueError( - u'property "{0}" already exists on MediaField'.format(name)) - setattr(cls, name, descriptor) - - def update(self, dict): - """Set all field values from a dictionary. - - For any key in `dict` that is also a field to store tags the - method retrieves the corresponding value from `dict` and updates - the `MediaFile`. If a key has the value `None`, the - corresponding property is deleted from the `MediaFile`. - """ - for field in self.sorted_fields(): - if field in dict: - if dict[field] is None: - delattr(self, field) - else: - setattr(self, field, dict[field]) - - # Field definitions. - - title = MediaField( - MP3StorageStyle('TIT2'), - MP4StorageStyle('\xa9nam'), - StorageStyle('TITLE'), - ASFStorageStyle('Title'), - ) - artist = MediaField( - MP3StorageStyle('TPE1'), - MP4StorageStyle('\xa9ART'), - StorageStyle('ARTIST'), - ASFStorageStyle('Author'), - ) - album = MediaField( - MP3StorageStyle('TALB'), - MP4StorageStyle('\xa9alb'), - StorageStyle('ALBUM'), - ASFStorageStyle('WM/AlbumTitle'), - ) - genres = ListMediaField( - MP3ListStorageStyle('TCON'), - MP4ListStorageStyle('\xa9gen'), - ListStorageStyle('GENRE'), - ASFStorageStyle('WM/Genre'), - ) - genre = genres.single_field() - - lyricist = MediaField( - MP3StorageStyle('TEXT'), - MP4StorageStyle('----:com.apple.iTunes:LYRICIST'), - StorageStyle('LYRICIST'), - ASFStorageStyle('WM/Writer'), - ) - composer = MediaField( - MP3StorageStyle('TCOM'), - MP4StorageStyle('\xa9wrt'), - StorageStyle('COMPOSER'), - ASFStorageStyle('WM/Composer'), - ) - composer_sort = MediaField( - MP3StorageStyle('TSOC'), - MP4StorageStyle('soco'), - StorageStyle('COMPOSERSORT'), - ASFStorageStyle('WM/Composersortorder'), - ) - arranger = MediaField( - MP3PeopleStorageStyle('TIPL', involvement='arranger'), - MP4StorageStyle('----:com.apple.iTunes:Arranger'), - StorageStyle('ARRANGER'), - ASFStorageStyle('beets/Arranger'), - ) - - grouping = MediaField( - MP3StorageStyle('TIT1'), - MP4StorageStyle('\xa9grp'), - StorageStyle('GROUPING'), - ASFStorageStyle('WM/ContentGroupDescription'), - ) - track = MediaField( - MP3SlashPackStorageStyle('TRCK', pack_pos=0), - MP4TupleStorageStyle('trkn', index=0), - StorageStyle('TRACK'), - StorageStyle('TRACKNUMBER'), - ASFStorageStyle('WM/TrackNumber'), - out_type=int, - ) - tracktotal = MediaField( - MP3SlashPackStorageStyle('TRCK', pack_pos=1), - MP4TupleStorageStyle('trkn', index=1), - StorageStyle('TRACKTOTAL'), - StorageStyle('TRACKC'), - StorageStyle('TOTALTRACKS'), - ASFStorageStyle('TotalTracks'), - out_type=int, - ) - disc = MediaField( - MP3SlashPackStorageStyle('TPOS', pack_pos=0), - MP4TupleStorageStyle('disk', index=0), - StorageStyle('DISC'), - StorageStyle('DISCNUMBER'), - ASFStorageStyle('WM/PartOfSet'), - out_type=int, - ) - disctotal = MediaField( - MP3SlashPackStorageStyle('TPOS', pack_pos=1), - MP4TupleStorageStyle('disk', index=1), - StorageStyle('DISCTOTAL'), - StorageStyle('DISCC'), - StorageStyle('TOTALDISCS'), - ASFStorageStyle('TotalDiscs'), - out_type=int, - ) - lyrics = MediaField( - MP3DescStorageStyle(key='USLT'), - MP4StorageStyle('\xa9lyr'), - StorageStyle('LYRICS'), - ASFStorageStyle('WM/Lyrics'), - ) - comments = MediaField( - MP3DescStorageStyle(key='COMM'), - MP4StorageStyle('\xa9cmt'), - StorageStyle('DESCRIPTION'), - StorageStyle('COMMENT'), - ASFStorageStyle('WM/Comments'), - ASFStorageStyle('Description') - ) - bpm = MediaField( - MP3StorageStyle('TBPM'), - MP4StorageStyle('tmpo', as_type=int), - StorageStyle('BPM'), - ASFStorageStyle('WM/BeatsPerMinute'), - out_type=int, - ) - comp = MediaField( - MP3StorageStyle('TCMP'), - MP4BoolStorageStyle('cpil'), - StorageStyle('COMPILATION'), - ASFStorageStyle('WM/IsCompilation', as_type=bool), - out_type=bool, - ) - albumartist = MediaField( - MP3StorageStyle('TPE2'), - MP4StorageStyle('aART'), - StorageStyle('ALBUM ARTIST'), - StorageStyle('ALBUMARTIST'), - ASFStorageStyle('WM/AlbumArtist'), - ) - albumtype = MediaField( - MP3DescStorageStyle(u'MusicBrainz Album Type'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Album Type'), - StorageStyle('MUSICBRAINZ_ALBUMTYPE'), - ASFStorageStyle('MusicBrainz/Album Type'), - ) - label = MediaField( - MP3StorageStyle('TPUB'), - MP4StorageStyle('----:com.apple.iTunes:Label'), - MP4StorageStyle('----:com.apple.iTunes:publisher'), - StorageStyle('LABEL'), - StorageStyle('PUBLISHER'), # Traktor - ASFStorageStyle('WM/Publisher'), - ) - artist_sort = MediaField( - MP3StorageStyle('TSOP'), - MP4StorageStyle('soar'), - StorageStyle('ARTISTSORT'), - ASFStorageStyle('WM/ArtistSortOrder'), - ) - albumartist_sort = MediaField( - MP3DescStorageStyle(u'ALBUMARTISTSORT'), - MP4StorageStyle('soaa'), - StorageStyle('ALBUMARTISTSORT'), - ASFStorageStyle('WM/AlbumArtistSortOrder'), - ) - asin = MediaField( - MP3DescStorageStyle(u'ASIN'), - MP4StorageStyle('----:com.apple.iTunes:ASIN'), - StorageStyle('ASIN'), - ASFStorageStyle('MusicBrainz/ASIN'), - ) - catalognum = MediaField( - MP3DescStorageStyle(u'CATALOGNUMBER'), - MP4StorageStyle('----:com.apple.iTunes:CATALOGNUMBER'), - StorageStyle('CATALOGNUMBER'), - ASFStorageStyle('WM/CatalogNo'), - ) - disctitle = MediaField( - MP3StorageStyle('TSST'), - MP4StorageStyle('----:com.apple.iTunes:DISCSUBTITLE'), - StorageStyle('DISCSUBTITLE'), - ASFStorageStyle('WM/SetSubTitle'), - ) - encoder = MediaField( - MP3StorageStyle('TENC'), - MP4StorageStyle('\xa9too'), - StorageStyle('ENCODEDBY'), - StorageStyle('ENCODER'), - ASFStorageStyle('WM/EncodedBy'), - ) - script = MediaField( - MP3DescStorageStyle(u'Script'), - MP4StorageStyle('----:com.apple.iTunes:SCRIPT'), - StorageStyle('SCRIPT'), - ASFStorageStyle('WM/Script'), - ) - language = MediaField( - MP3StorageStyle('TLAN'), - MP4StorageStyle('----:com.apple.iTunes:LANGUAGE'), - StorageStyle('LANGUAGE'), - ASFStorageStyle('WM/Language'), - ) - country = MediaField( - MP3DescStorageStyle(u'MusicBrainz Album Release Country'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz ' - 'Album Release Country'), - StorageStyle('RELEASECOUNTRY'), - ASFStorageStyle('MusicBrainz/Album Release Country'), - ) - albumstatus = MediaField( - MP3DescStorageStyle(u'MusicBrainz Album Status'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Album Status'), - StorageStyle('MUSICBRAINZ_ALBUMSTATUS'), - ASFStorageStyle('MusicBrainz/Album Status'), - ) - media = MediaField( - MP3StorageStyle('TMED'), - MP4StorageStyle('----:com.apple.iTunes:MEDIA'), - StorageStyle('MEDIA'), - ASFStorageStyle('WM/Media'), - ) - albumdisambig = MediaField( - # This tag mapping was invented for beets (not used by Picard, etc). - MP3DescStorageStyle(u'MusicBrainz Album Comment'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Album Comment'), - StorageStyle('MUSICBRAINZ_ALBUMCOMMENT'), - ASFStorageStyle('MusicBrainz/Album Comment'), - ) - - # Release date. - date = DateField( - MP3StorageStyle('TDRC'), - MP4StorageStyle('\xa9day'), - StorageStyle('DATE'), - ASFStorageStyle('WM/Year'), - year=(StorageStyle('YEAR'),)) - - year = date.year_field() - month = date.month_field() - day = date.day_field() - - # *Original* release date. - original_date = DateField( - MP3StorageStyle('TDOR'), - MP4StorageStyle('----:com.apple.iTunes:ORIGINAL YEAR'), - StorageStyle('ORIGINALDATE'), - ASFStorageStyle('WM/OriginalReleaseYear')) - - original_year = original_date.year_field() - original_month = original_date.month_field() - original_day = original_date.day_field() - - # Nonstandard metadata. - artist_credit = MediaField( - MP3DescStorageStyle(u'Artist Credit'), - MP4StorageStyle('----:com.apple.iTunes:Artist Credit'), - StorageStyle('ARTIST_CREDIT'), - ASFStorageStyle('beets/Artist Credit'), - ) - albumartist_credit = MediaField( - MP3DescStorageStyle(u'Album Artist Credit'), - MP4StorageStyle('----:com.apple.iTunes:Album Artist Credit'), - StorageStyle('ALBUMARTIST_CREDIT'), - ASFStorageStyle('beets/Album Artist Credit'), - ) - - # Legacy album art field - art = CoverArtField() - - # Image list - images = ImageListField() - - # MusicBrainz IDs. - mb_trackid = MediaField( - MP3UFIDStorageStyle(owner='http://musicbrainz.org'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Track Id'), - StorageStyle('MUSICBRAINZ_TRACKID'), - ASFStorageStyle('MusicBrainz/Track Id'), - ) - mb_releasetrackid = MediaField( - MP3DescStorageStyle(u'MusicBrainz Release Track Id'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Release Track Id'), - StorageStyle('MUSICBRAINZ_RELEASETRACKID'), - ASFStorageStyle('MusicBrainz/Release Track Id'), - ) - mb_albumid = MediaField( - MP3DescStorageStyle(u'MusicBrainz Album Id'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Album Id'), - StorageStyle('MUSICBRAINZ_ALBUMID'), - ASFStorageStyle('MusicBrainz/Album Id'), - ) - mb_artistid = MediaField( - MP3DescStorageStyle(u'MusicBrainz Artist Id'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Artist Id'), - StorageStyle('MUSICBRAINZ_ARTISTID'), - ASFStorageStyle('MusicBrainz/Artist Id'), - ) - mb_albumartistid = MediaField( - MP3DescStorageStyle(u'MusicBrainz Album Artist Id'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Album Artist Id'), - StorageStyle('MUSICBRAINZ_ALBUMARTISTID'), - ASFStorageStyle('MusicBrainz/Album Artist Id'), - ) - mb_releasegroupid = MediaField( - MP3DescStorageStyle(u'MusicBrainz Release Group Id'), - MP4StorageStyle('----:com.apple.iTunes:MusicBrainz Release Group Id'), - StorageStyle('MUSICBRAINZ_RELEASEGROUPID'), - ASFStorageStyle('MusicBrainz/Release Group Id'), - ) - - # Acoustid fields. - acoustid_fingerprint = MediaField( - MP3DescStorageStyle(u'Acoustid Fingerprint'), - MP4StorageStyle('----:com.apple.iTunes:Acoustid Fingerprint'), - StorageStyle('ACOUSTID_FINGERPRINT'), - ASFStorageStyle('Acoustid/Fingerprint'), - ) - acoustid_id = MediaField( - MP3DescStorageStyle(u'Acoustid Id'), - MP4StorageStyle('----:com.apple.iTunes:Acoustid Id'), - StorageStyle('ACOUSTID_ID'), - ASFStorageStyle('Acoustid/Id'), - ) - - # ReplayGain fields. - rg_track_gain = MediaField( - MP3DescStorageStyle( - u'REPLAYGAIN_TRACK_GAIN', - float_places=2, suffix=u' dB' - ), - MP3DescStorageStyle( - u'replaygain_track_gain', - float_places=2, suffix=u' dB' - ), - MP3SoundCheckStorageStyle( - key='COMM', - index=0, desc=u'iTunNORM', - id3_lang='eng' - ), - MP4StorageStyle( - '----:com.apple.iTunes:replaygain_track_gain', - float_places=2, suffix=' dB' - ), - MP4SoundCheckStorageStyle( - '----:com.apple.iTunes:iTunNORM', - index=0 - ), - StorageStyle( - u'REPLAYGAIN_TRACK_GAIN', - float_places=2, suffix=u' dB' - ), - ASFStorageStyle( - u'replaygain_track_gain', - float_places=2, suffix=u' dB' - ), - out_type=float - ) - rg_album_gain = MediaField( - MP3DescStorageStyle( - u'REPLAYGAIN_ALBUM_GAIN', - float_places=2, suffix=u' dB' - ), - MP3DescStorageStyle( - u'replaygain_album_gain', - float_places=2, suffix=u' dB' - ), - MP4StorageStyle( - '----:com.apple.iTunes:replaygain_album_gain', - float_places=2, suffix=' dB' - ), - StorageStyle( - u'REPLAYGAIN_ALBUM_GAIN', - float_places=2, suffix=u' dB' - ), - ASFStorageStyle( - u'replaygain_album_gain', - float_places=2, suffix=u' dB' - ), - out_type=float - ) - rg_track_peak = MediaField( - MP3DescStorageStyle( - u'REPLAYGAIN_TRACK_PEAK', - float_places=6 - ), - MP3DescStorageStyle( - u'replaygain_track_peak', - float_places=6 - ), - MP3SoundCheckStorageStyle( - key=u'COMM', - index=1, desc=u'iTunNORM', - id3_lang='eng' - ), - MP4StorageStyle( - '----:com.apple.iTunes:replaygain_track_peak', - float_places=6 - ), - MP4SoundCheckStorageStyle( - '----:com.apple.iTunes:iTunNORM', - index=1 - ), - StorageStyle(u'REPLAYGAIN_TRACK_PEAK', float_places=6), - ASFStorageStyle(u'replaygain_track_peak', float_places=6), - out_type=float, - ) - rg_album_peak = MediaField( - MP3DescStorageStyle( - u'REPLAYGAIN_ALBUM_PEAK', - float_places=6 - ), - MP3DescStorageStyle( - u'replaygain_album_peak', - float_places=6 - ), - MP4StorageStyle( - '----:com.apple.iTunes:replaygain_album_peak', - float_places=6 - ), - StorageStyle(u'REPLAYGAIN_ALBUM_PEAK', float_places=6), - ASFStorageStyle(u'replaygain_album_peak', float_places=6), - out_type=float, - ) - - # EBU R128 fields. - r128_track_gain = MediaField( - MP3DescStorageStyle( - u'R128_TRACK_GAIN' - ), - MP4StorageStyle( - '----:com.apple.iTunes:R128_TRACK_GAIN' - ), - StorageStyle( - u'R128_TRACK_GAIN' - ), - ASFStorageStyle( - u'R128_TRACK_GAIN' - ), - out_type=int, - ) - r128_album_gain = MediaField( - MP3DescStorageStyle( - u'R128_ALBUM_GAIN' - ), - MP4StorageStyle( - '----:com.apple.iTunes:R128_ALBUM_GAIN' - ), - StorageStyle( - u'R128_ALBUM_GAIN' - ), - ASFStorageStyle( - u'R128_ALBUM_GAIN' - ), - out_type=int, - ) - - initial_key = MediaField( - MP3StorageStyle('TKEY'), - MP4StorageStyle('----:com.apple.iTunes:initialkey'), - StorageStyle('INITIALKEY'), - ASFStorageStyle('INITIALKEY'), - ) - - @property - def length(self): - """The duration of the audio in seconds (a float).""" - return self.mgfile.info.length - - @property - def samplerate(self): - """The audio's sample rate (an int).""" - if hasattr(self.mgfile.info, 'sample_rate'): - return self.mgfile.info.sample_rate - elif self.type == 'opus': - # Opus is always 48kHz internally. - return 48000 - return 0 - - @property - def bitdepth(self): - """The number of bits per sample in the audio encoding (an int). - Only available for certain file formats (zero where - unavailable). - """ - if hasattr(self.mgfile.info, 'bits_per_sample'): - return self.mgfile.info.bits_per_sample - return 0 - - @property - def channels(self): - """The number of channels in the audio (an int).""" - if hasattr(self.mgfile.info, 'channels'): - return self.mgfile.info.channels - return 0 - - @property - def bitrate(self): - """The number of bits per seconds used in the audio coding (an - int). If this is provided explicitly by the compressed file - format, this is a precise reflection of the encoding. Otherwise, - it is estimated from the on-disk file size. In this case, some - imprecision is possible because the file header is incorporated - in the file size. - """ - if hasattr(self.mgfile.info, 'bitrate') and self.mgfile.info.bitrate: - # Many formats provide it explicitly. - return self.mgfile.info.bitrate - else: - # Otherwise, we calculate bitrate from the file size. (This - # is the case for all of the lossless formats.) - if not self.length: - # Avoid division by zero if length is not available. - return 0 - size = os.path.getsize(self.path) - return int(size * 8 / self.length) - - @property - def format(self): - """A string describing the file format/codec.""" - return TYPES[self.type] +del key, value, warnings, mediafile diff --git a/beets/plugins.py b/beets/plugins.py index 6dec7ef2a..7c98225ca 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -17,16 +17,16 @@ from __future__ import division, absolute_import, print_function -import inspect import traceback import re +import inspect from collections import defaultdict from functools import wraps import beets from beets import logging -from beets import mediafile +import mediafile import six PLUGIN_NAMESPACE = 'beetsplug' @@ -127,7 +127,10 @@ class BeetsPlugin(object): value after the function returns). Also determines which params may not be sent for backwards-compatibility. """ - argspec = inspect.getargspec(func) + if six.PY2: + func_args = inspect.getargspec(func).args + else: + func_args = inspect.getfullargspec(func).args @wraps(func) def wrapper(*args, **kwargs): @@ -142,7 +145,7 @@ class BeetsPlugin(object): if exc.args[0].startswith(func.__name__): # caused by 'func' and not stuff internal to 'func' kwargs = dict((arg, val) for arg, val in kwargs.items() - if arg in argspec.args) + if arg in func_args) return func(*args, **kwargs) else: raise @@ -344,6 +347,16 @@ def types(model_cls): return types +def named_queries(model_cls): + # Gather `item_queries` and `album_queries` from the plugins. + attr_name = '{0}_queries'.format(model_cls.__name__.lower()) + 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. @@ -513,7 +526,7 @@ def sanitize_choices(choices, choices_all): def sanitize_pairs(pairs, pairs_all): """Clean up a single-element mapping configuration attribute as returned - by `confit`'s `Pairs` template: keep only two-element tuples present in + 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. diff --git a/beets/random.py b/beets/random.py new file mode 100644 index 000000000..5387da4da --- /dev/null +++ b/beets/random.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# 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 __future__ import division, absolute_import, print_function + +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) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 1abce2e67..aec0e80a9 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -36,12 +36,13 @@ from beets import logging from beets import library from beets import plugins from beets import util -from beets.util.functemplate import Template +from beets.util.functemplate import template from beets import config -from beets.util import confit, as_string +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 import six # On Windows platforms, use colorama to support "ANSI" terminal colors. @@ -203,7 +204,7 @@ def input_(prompt=None): """ # raw_input incorrectly sends prompts to stderr, not stdout, so we # use print_() explicitly to display prompts. - # http://bugs.python.org/issue1927 + # https://bugs.python.org/issue1927 if prompt: print_(prompt, end=u' ') @@ -474,7 +475,7 @@ def human_seconds_short(interval): # Colorization. # ANSI terminal colorization code heavily inspired by pygments: -# http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py +# 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 = { @@ -529,22 +530,22 @@ def colorize(color_name, text): """Colorize text if colored output is enabled. (Like _colorize but conditional.) """ - if config['ui']['color']: - global COLORS - if not COLORS: - COLORS = dict((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(u'Invalid color_name: {0}', color_name) - color = color_name - return _colorize(color, text) - else: + if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys(): return text + global COLORS + if not COLORS: + COLORS = dict((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(u'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'): @@ -616,12 +617,12 @@ def get_path_formats(subview=None): 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()))) + path_formats.append((query, template(view.as_str()))) return path_formats def get_replacements(): - """Confit validation function that reads regex/string pairs. + """Confuse validation function that reads regex/string pairs. """ replacements = [] for pattern, repl in config['replace'].get(dict).items(): @@ -928,7 +929,7 @@ class CommonOptionsParser(optparse.OptionParser, object): # # This is a fairly generic subcommand parser for optparse. It is # maintained externally here: -# http://gist.github.com/462717 +# https://gist.github.com/462717 # There you will also find a better description of the code and a more # succinct example program. @@ -1143,8 +1144,12 @@ def _setup(options, lib=None): if lib is None: lib = _open_library(config) plugins.send("library_opened", lib=lib) + + # Add types and queries defined by plugins. library.Item._types.update(plugins.types(library.Item)) library.Album._types.update(plugins.types(library.Album)) + library.Item._queries.update(plugins.named_queries(library.Item)) + library.Album._queries.update(plugins.named_queries(library.Album)) return subcommands, plugins, lib @@ -1273,7 +1278,7 @@ def main(args=None): log.debug('{}', traceback.format_exc()) log.error('{}', exc) sys.exit(1) - except confit.ConfigError as exc: + except confuse.ConfigError as exc: log.error(u'configuration error: {0}', exc) sys.exit(1) except db_query.InvalidQueryError as exc: diff --git a/beets/ui/commands.py b/beets/ui/commands.py index a38be7a15..53253c1da 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -39,7 +39,6 @@ from beets.util import syspath, normpath, ancestry, displayable_path, \ from beets import library from beets import config from beets import logging -from beets.util.confit import _package_path import six from . import _store_dict @@ -543,7 +542,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, print_(u"No matching release found for {0} tracks." .format(itemcount)) print_(u'For help, see: ' - u'http://beets.readthedocs.org/en/latest/faq.html#nomatch') + u'https://beets.readthedocs.org/en/latest/faq.html#nomatch') sel = ui.input_options(choice_opts) if sel in choice_actions: return choice_actions[sel] @@ -1177,7 +1176,7 @@ def update_items(lib, query, album, move, pretend, fields): # Manually moving and storing the album. items = list(album.items()) for item in items: - item.move(store=False) + item.move(store=False, with_album=False) item.store(fields=fields) album.move(store=False) album.store(fields=fields) @@ -1726,7 +1725,7 @@ def completion_script(commands): ``commands`` is alist of ``ui.Subcommand`` instances to generate completion data for. """ - base_script = os.path.join(_package_path('beets.ui'), 'completion_base.sh') + base_script = os.path.join(os.path.dirname(__file__), 'completion_base.sh') with open(base_script, 'r') as base_script: yield util.text_string(base_script.read()) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 69870edf2..162502eb1 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -23,7 +23,9 @@ import locale import re import shutil import fnmatch +import functools from collections import Counter +from multiprocessing.pool import ThreadPool import traceback import subprocess import platform @@ -282,13 +284,13 @@ def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')): continue clutter = [bytestring_path(c) for c in clutter] match_paths = [bytestring_path(d) for d in os.listdir(directory)] - if fnmatch_all(match_paths, clutter): - # Directory contains only clutter (or nothing). - try: + try: + if fnmatch_all(match_paths, clutter): + # Directory contains only clutter (or nothing). shutil.rmtree(directory) - except OSError: + else: break - else: + except OSError: break @@ -410,7 +412,7 @@ def syspath(path, prefix=True): path = path.decode(encoding, 'replace') # Add the magic prefix if it isn't already there. - # http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX): if path.startswith(u'\\\\'): # UNC path. Final path should look like \\?\UNC\... @@ -561,7 +563,7 @@ def unique_path(path): # Note: The Windows "reserved characters" are, of course, allowed on # Unix. They are forbidden here because they cause problems on Samba # shares, which are sufficiently common as to cause frequent problems. -# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx +# https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx CHAR_REPLACE = [ (re.compile(r'[\\/]'), u'_'), # / and \ -- forbidden everywhere. (re.compile(r'^\.'), u'_'), # Leading dot (hidden files on Unix). @@ -1009,3 +1011,47 @@ def asciify_path(path, sep_replace): 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. + """ + if sys.version_info[0] < 3: + # multiprocessing.pool.ThreadPool does not seem to work on + # Python 2. We could consider switching to futures instead. + for item in items: + transform(item) + else: + 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 diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index e58b356be..1ee3e560d 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -81,8 +81,10 @@ def pil_resize(maxwidth, path_in, path_out=None): def im_resize(maxwidth, path_in, path_out=None): - """Resize using ImageMagick's ``convert`` tool. - Return the output path of resized image. + """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(u'artresizer: ImageMagick resizing {0} to {1}', @@ -91,16 +93,18 @@ def im_resize(maxwidth, path_in, path_out=None): # "-resize WIDTHx>" shrinks images with the width larger # than the given width while maintaining the aspect ratio # with regards to the height. - try: - util.command_output([ - 'convert', util.syspath(path_in, prefix=False), + cmd = ArtResizer.shared.im_convert_cmd + \ + [util.syspath(path_in, prefix=False), '-resize', '{0}x>'.format(maxwidth), - util.syspath(path_out, prefix=False), - ]) + util.syspath(path_out, prefix=False)] + + try: + util.command_output(cmd) except subprocess.CalledProcessError: log.warning(u'artresizer: IM convert failed for {0}', util.displayable_path(path_in)) return path_in + return path_out @@ -121,8 +125,9 @@ def pil_getsize(path_in): def im_getsize(path_in): - cmd = ['identify', '-format', '%w %h', - util.syspath(path_in, prefix=False)] + cmd = ArtResizer.shared.im_identify_cmd + \ + ['-format', '%w %h', util.syspath(path_in, prefix=False)] + try: out = util.command_output(cmd) except subprocess.CalledProcessError as exc: @@ -173,6 +178,18 @@ class ArtResizer(six.with_metaclass(Shareable, object)): log.debug(u"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): """Manipulate an image file according to the method, returning a new path. For PIL or IMAGEMAGIC methods, resizes the image to a @@ -218,10 +235,20 @@ class ArtResizer(six.with_metaclass(Shareable, object)): @staticmethod def _check_method(): - """Return a tuple indicating an available method and its version.""" + """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: - return IMAGEMAGICK, version + version, legacy = version + return IMAGEMAGICK, version, legacy version = get_pil_version() if version: @@ -231,29 +258,32 @@ class ArtResizer(six.with_metaclass(Shareable, object)): def get_im_version(): - """Return Image Magick version or None if it is unavailable - Try invoking ImageMagick's "convert". + """Get the ImageMagick version and legacy flag as a pair. Or return + None if ImageMagick is not available. """ - try: - out = util.command_output(['convert', '--version']) + for cmd_name, legacy in ((['magick'], False), (['convert'], True)): + cmd = cmd_name + ['--version'] - if b'imagemagick' in out.lower(): - pattern = br".+ (\d+)\.(\d+)\.(\d+).*" - match = re.search(pattern, out) - if match: - return (int(match.group(1)), - int(match.group(2)), - int(match.group(3))) - return (0,) + try: + out = util.command_output(cmd) + except (subprocess.CalledProcessError, OSError) as exc: + log.debug(u'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 - except (subprocess.CalledProcessError, OSError) as exc: - log.debug(u'ImageMagick check `convert --version` failed: {}', exc) - return None + return None def get_pil_version(): - """Return Image Magick version or None if it is unavailable - Try importing PIL.""" + """Get the PIL/Pillow version, or None if it is unavailable. + """ try: __import__('PIL', fromlist=[str('Image')]) return (0,) diff --git a/beets/util/bluelet.py b/beets/util/bluelet.py index 0da17559b..dcc80e041 100644 --- a/beets/util/bluelet.py +++ b/beets/util/bluelet.py @@ -346,6 +346,10 @@ def run(root_coro): 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. diff --git a/beets/util/confit.py b/beets/util/confit.py index 5f4d862ea..450e37210 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# This file is part of Confuse. -# Copyright 2016, Adrian Sampson. +# 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 @@ -13,1504 +13,18 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Worry-free YAML configuration files. -""" from __future__ import division, absolute_import, print_function -import platform -import os -import pkgutil -import sys -import yaml -import collections -import re -from collections import OrderedDict +import confuse -UNIX_DIR_VAR = 'XDG_CONFIG_HOME' -UNIX_DIR_FALLBACK = '~/.config' -WINDOWS_DIR_VAR = 'APPDATA' -WINDOWS_DIR_FALLBACK = '~\\AppData\\Roaming' -MAC_DIR = '~/Library/Application Support' +import warnings +warnings.warn("beets.util.confit is deprecated; use confuse instead") -CONFIG_FILENAME = 'config.yaml' -DEFAULT_FILENAME = 'config_default.yaml' -ROOT_NAME = 'root' +# Import everything from the confuse module into this module. +for key, value in confuse.__dict__.items(): + if key not in ['__name__']: + globals()[key] = value -YAML_TAB_PROBLEM = "found character '\\t' that cannot start any token" -REDACTED_TOMBSTONE = 'REDACTED' - - -# Utilities. - -PY3 = sys.version_info[0] == 3 -STRING = str if PY3 else unicode # noqa: F821 -BASESTRING = str if PY3 else basestring # noqa: F821 -NUMERIC_TYPES = (int, float) if PY3 else (int, float, long) # noqa: F821 - - -def iter_first(sequence): - """Get the first element from an iterable or raise a ValueError if - the iterator generates no values. - """ - it = iter(sequence) - try: - return next(it) - except StopIteration: - raise ValueError() - - -# Exceptions. - -class ConfigError(Exception): - """Base class for exceptions raised when querying a configuration. - """ - - -class NotFoundError(ConfigError): - """A requested value could not be found in the configuration trees. - """ - - -class ConfigValueError(ConfigError): - """The value in the configuration is illegal.""" - - -class ConfigTypeError(ConfigValueError): - """The value in the configuration did not match the expected type. - """ - - -class ConfigTemplateError(ConfigError): - """Base class for exceptions raised because of an invalid template. - """ - - -class ConfigReadError(ConfigError): - """A configuration file could not be read.""" - def __init__(self, filename, reason=None): - self.filename = filename - self.reason = reason - - message = u'file {0} could not be read'.format(filename) - if isinstance(reason, yaml.scanner.ScannerError) and \ - reason.problem == YAML_TAB_PROBLEM: - # Special-case error message for tab indentation in YAML markup. - message += u': found tab character at line {0}, column {1}'.format( - reason.problem_mark.line + 1, - reason.problem_mark.column + 1, - ) - elif reason: - # Generic error message uses exception's message. - message += u': {0}'.format(reason) - - super(ConfigReadError, self).__init__(message) - - -# Views and sources. - -class ConfigSource(dict): - """A dictionary augmented with metadata about the source of the - configuration. - """ - def __init__(self, value, filename=None, default=False): - super(ConfigSource, self).__init__(value) - if filename is not None and not isinstance(filename, BASESTRING): - raise TypeError(u'filename must be a string or None') - self.filename = filename - self.default = default - - def __repr__(self): - return 'ConfigSource({0!r}, {1!r}, {2!r})'.format( - super(ConfigSource, self), - self.filename, - self.default, - ) - - @classmethod - def of(cls, value): - """Given either a dictionary or a `ConfigSource` object, return - a `ConfigSource` object. This lets a function accept either type - of object as an argument. - """ - if isinstance(value, ConfigSource): - return value - elif isinstance(value, dict): - return ConfigSource(value) - else: - raise TypeError(u'source value must be a dict') - - -class ConfigView(object): - """A configuration "view" is a query into a program's configuration - data. A view represents a hypothetical location in the configuration - tree; to extract the data from the location, a client typically - calls the ``view.get()`` method. The client can access children in - the tree (subviews) by subscripting the parent view (i.e., - ``view[key]``). - """ - - name = None - """The name of the view, depicting the path taken through the - configuration in Python-like syntax (e.g., ``foo['bar'][42]``). - """ - - def resolve(self): - """The core (internal) data retrieval method. Generates (value, - source) pairs for each source that contains a value for this - view. May raise ConfigTypeError if a type error occurs while - traversing a source. - """ - raise NotImplementedError - - def first(self): - """Return a (value, source) pair for the first object found for - this view. This amounts to the first element returned by - `resolve`. If no values are available, a NotFoundError is - raised. - """ - pairs = self.resolve() - try: - return iter_first(pairs) - except ValueError: - raise NotFoundError(u"{0} not found".format(self.name)) - - def exists(self): - """Determine whether the view has a setting in any source. - """ - try: - self.first() - except NotFoundError: - return False - return True - - def add(self, value): - """Set the *default* value for this configuration view. The - specified value is added as the lowest-priority configuration - data source. - """ - raise NotImplementedError - - def set(self, value): - """*Override* the value for this configuration view. The - specified value is added as the highest-priority configuration - data source. - """ - raise NotImplementedError - - def root(self): - """The RootView object from which this view is descended. - """ - raise NotImplementedError - - def __repr__(self): - return '<{}: {}>'.format(self.__class__.__name__, self.name) - - def __iter__(self): - """Iterate over the keys of a dictionary view or the *subviews* - of a list view. - """ - # Try getting the keys, if this is a dictionary view. - try: - keys = self.keys() - for key in keys: - yield key - - except ConfigTypeError: - # Otherwise, try iterating over a list. - collection = self.get() - if not isinstance(collection, (list, tuple)): - raise ConfigTypeError( - u'{0} must be a dictionary or a list, not {1}'.format( - self.name, type(collection).__name__ - ) - ) - - # Yield all the indices in the list. - for index in range(len(collection)): - yield self[index] - - def __getitem__(self, key): - """Get a subview of this view.""" - return Subview(self, key) - - def __setitem__(self, key, value): - """Create an overlay source to assign a given key under this - view. - """ - self.set({key: value}) - - def __contains__(self, key): - return self[key].exists() - - def set_args(self, namespace): - """Overlay parsed command-line arguments, generated by a library - like argparse or optparse, onto this view's value. ``namespace`` - can be a ``dict`` or namespace object. - """ - args = {} - if isinstance(namespace, dict): - items = namespace.items() - else: - items = namespace.__dict__.items() - for key, value in items: - if value is not None: # Avoid unset options. - args[key] = value - self.set(args) - - # Magical conversions. These special methods make it possible to use - # View objects somewhat transparently in certain circumstances. For - # example, rather than using ``view.get(bool)``, it's possible to - # just say ``bool(view)`` or use ``view`` in a conditional. - - def __str__(self): - """Get the value for this view as a bytestring. - """ - if PY3: - return self.__unicode__() - else: - return bytes(self.get()) - - def __unicode__(self): - """Get the value for this view as a Unicode string. - """ - return STRING(self.get()) - - def __nonzero__(self): - """Gets the value for this view as a boolean. (Python 2 only.) - """ - return self.__bool__() - - def __bool__(self): - """Gets the value for this view as a boolean. (Python 3 only.) - """ - return bool(self.get()) - - # Dictionary emulation methods. - - def keys(self): - """Returns a list containing all the keys available as subviews - of the current views. This enumerates all the keys in *all* - dictionaries matching the current view, in contrast to - ``view.get(dict).keys()``, which gets all the keys for the - *first* dict matching the view. If the object for this view in - any source is not a dict, then a ConfigTypeError is raised. The - keys are ordered according to how they appear in each source. - """ - keys = [] - - for dic, _ in self.resolve(): - try: - cur_keys = dic.keys() - except AttributeError: - raise ConfigTypeError( - u'{0} must be a dict, not {1}'.format( - self.name, type(dic).__name__ - ) - ) - - for key in cur_keys: - if key not in keys: - keys.append(key) - - return keys - - def items(self): - """Iterates over (key, subview) pairs contained in dictionaries - from *all* sources at this view. If the object for this view in - any source is not a dict, then a ConfigTypeError is raised. - """ - for key in self.keys(): - yield key, self[key] - - def values(self): - """Iterates over all the subviews contained in dictionaries from - *all* sources at this view. If the object for this view in any - source is not a dict, then a ConfigTypeError is raised. - """ - for key in self.keys(): - yield self[key] - - # List/sequence emulation. - - def all_contents(self): - """Iterates over all subviews from collections at this view from - *all* sources. If the object for this view in any source is not - iterable, then a ConfigTypeError is raised. This method is - intended to be used when the view indicates a list; this method - will concatenate the contents of the list from all sources. - """ - for collection, _ in self.resolve(): - try: - it = iter(collection) - except TypeError: - raise ConfigTypeError( - u'{0} must be an iterable, not {1}'.format( - self.name, type(collection).__name__ - ) - ) - for value in it: - yield value - - # Validation and conversion. - - def flatten(self, redact=False): - """Create a hierarchy of OrderedDicts containing the data from - this view, recursively reifying all views to get their - represented values. - - If `redact` is set, then sensitive values are replaced with - the string "REDACTED". - """ - od = OrderedDict() - for key, view in self.items(): - if redact and view.redact: - od[key] = REDACTED_TOMBSTONE - else: - try: - od[key] = view.flatten(redact=redact) - except ConfigTypeError: - od[key] = view.get() - return od - - def get(self, template=None): - """Retrieve the value for this view according to the template. - - The `template` against which the values are checked can be - anything convertible to a `Template` using `as_template`. This - means you can pass in a default integer or string value, for - example, or a type to just check that something matches the type - you expect. - - May raise a `ConfigValueError` (or its subclass, - `ConfigTypeError`) or a `NotFoundError` when the configuration - doesn't satisfy the template. - """ - return as_template(template).value(self, template) - - # Shortcuts for common templates. - - def as_filename(self): - """Get the value as a path. Equivalent to `get(Filename())`. - """ - return self.get(Filename()) - - def as_choice(self, choices): - """Get the value from a list of choices. Equivalent to - `get(Choice(choices))`. - """ - return self.get(Choice(choices)) - - def as_number(self): - """Get the value as any number type: int or float. Equivalent to - `get(Number())`. - """ - return self.get(Number()) - - def as_str_seq(self, split=True): - """Get the value as a sequence of strings. Equivalent to - `get(StrSeq())`. - """ - return self.get(StrSeq(split=split)) - - def as_pairs(self, default_value=None): - """Get the value as a sequence of pairs of two strings. Equivalent to - `get(Pairs())`. - """ - return self.get(Pairs(default_value=default_value)) - - def as_str(self): - """Get the value as a (Unicode) string. Equivalent to - `get(unicode)` on Python 2 and `get(str)` on Python 3. - """ - return self.get(String()) - - # Redaction. - - @property - def redact(self): - """Whether the view contains sensitive information and should be - redacted from output. - """ - return () in self.get_redactions() - - @redact.setter - def redact(self, flag): - self.set_redaction((), flag) - - def set_redaction(self, path, flag): - """Add or remove a redaction for a key path, which should be an - iterable of keys. - """ - raise NotImplementedError() - - def get_redactions(self): - """Get the set of currently-redacted sub-key-paths at this view. - """ - raise NotImplementedError() - - -class RootView(ConfigView): - """The base of a view hierarchy. This view keeps track of the - sources that may be accessed by subviews. - """ - def __init__(self, sources): - """Create a configuration hierarchy for a list of sources. At - least one source must be provided. The first source in the list - has the highest priority. - """ - self.sources = list(sources) - self.name = ROOT_NAME - self.redactions = set() - - def add(self, obj): - self.sources.append(ConfigSource.of(obj)) - - def set(self, value): - self.sources.insert(0, ConfigSource.of(value)) - - def resolve(self): - return ((dict(s), s) for s in self.sources) - - def clear(self): - """Remove all sources (and redactions) from this - configuration. - """ - del self.sources[:] - self.redactions.clear() - - def root(self): - return self - - def set_redaction(self, path, flag): - if flag: - self.redactions.add(path) - elif path in self.redactions: - self.redactions.remove(path) - - def get_redactions(self): - return self.redactions - - -class Subview(ConfigView): - """A subview accessed via a subscript of a parent view.""" - def __init__(self, parent, key): - """Make a subview of a parent view for a given subscript key. - """ - self.parent = parent - self.key = key - - # Choose a human-readable name for this view. - if isinstance(self.parent, RootView): - self.name = '' - else: - self.name = self.parent.name - if not isinstance(self.key, int): - self.name += '.' - if isinstance(self.key, int): - self.name += u'#{0}'.format(self.key) - elif isinstance(self.key, bytes): - self.name += self.key.decode('utf-8') - elif isinstance(self.key, STRING): - self.name += self.key - else: - self.name += repr(self.key) - - def resolve(self): - for collection, source in self.parent.resolve(): - try: - value = collection[self.key] - except IndexError: - # List index out of bounds. - continue - except KeyError: - # Dict key does not exist. - continue - except TypeError: - # Not subscriptable. - raise ConfigTypeError( - u"{0} must be a collection, not {1}".format( - self.parent.name, type(collection).__name__ - ) - ) - yield value, source - - def set(self, value): - self.parent.set({self.key: value}) - - def add(self, value): - self.parent.add({self.key: value}) - - def root(self): - return self.parent.root() - - def set_redaction(self, path, flag): - self.parent.set_redaction((self.key,) + path, flag) - - def get_redactions(self): - return (kp[1:] for kp in self.parent.get_redactions() - if kp and kp[0] == self.key) - - -# Config file paths, including platform-specific paths and in-package -# defaults. - -# Based on get_root_path from Flask by Armin Ronacher. -def _package_path(name): - """Returns the path to the package containing the named module or - None if the path could not be identified (e.g., if - ``name == "__main__"``). - """ - loader = pkgutil.get_loader(name) - if loader is None or name == '__main__': - return None - - if hasattr(loader, 'get_filename'): - filepath = loader.get_filename(name) - else: - # Fall back to importing the specified module. - __import__(name) - filepath = sys.modules[name].__file__ - - return os.path.dirname(os.path.abspath(filepath)) - - -def config_dirs(): - """Return a platform-specific list of candidates for user - configuration directories on the system. - - The candidates are in order of priority, from highest to lowest. The - last element is the "fallback" location to be used when no - higher-priority config file exists. - """ - paths = [] - - if platform.system() == 'Darwin': - paths.append(MAC_DIR) - paths.append(UNIX_DIR_FALLBACK) - if UNIX_DIR_VAR in os.environ: - paths.append(os.environ[UNIX_DIR_VAR]) - - elif platform.system() == 'Windows': - paths.append(WINDOWS_DIR_FALLBACK) - if WINDOWS_DIR_VAR in os.environ: - paths.append(os.environ[WINDOWS_DIR_VAR]) - - else: - # Assume Unix. - paths.append(UNIX_DIR_FALLBACK) - if UNIX_DIR_VAR in os.environ: - paths.append(os.environ[UNIX_DIR_VAR]) - - # Expand and deduplicate paths. - out = [] - for path in paths: - path = os.path.abspath(os.path.expanduser(path)) - if path not in out: - out.append(path) - return out - - -# YAML loading. - -class Loader(yaml.SafeLoader): - """A customized YAML loader. This loader deviates from the official - YAML spec in a few convenient ways: - - - All strings as are Unicode objects. - - All maps are OrderedDicts. - - Strings can begin with % without quotation. - """ - # All strings should be Unicode objects, regardless of contents. - def _construct_unicode(self, node): - return self.construct_scalar(node) - - # Use ordered dictionaries for every YAML map. - # From https://gist.github.com/844388 - def construct_yaml_map(self, node): - data = OrderedDict() - yield data - value = self.construct_mapping(node) - data.update(value) - - def construct_mapping(self, node, deep=False): - if isinstance(node, yaml.MappingNode): - self.flatten_mapping(node) - else: - raise yaml.constructor.ConstructorError( - None, None, - u'expected a mapping node, but found %s' % node.id, - node.start_mark - ) - - mapping = OrderedDict() - for key_node, value_node in node.value: - key = self.construct_object(key_node, deep=deep) - try: - hash(key) - except TypeError as exc: - raise yaml.constructor.ConstructorError( - u'while constructing a mapping', - node.start_mark, 'found unacceptable key (%s)' % exc, - key_node.start_mark - ) - value = self.construct_object(value_node, deep=deep) - mapping[key] = value - return mapping - - # Allow bare strings to begin with %. Directives are still detected. - def check_plain(self): - plain = super(Loader, self).check_plain() - return plain or self.peek() == '%' - - -Loader.add_constructor('tag:yaml.org,2002:str', Loader._construct_unicode) -Loader.add_constructor('tag:yaml.org,2002:map', Loader.construct_yaml_map) -Loader.add_constructor('tag:yaml.org,2002:omap', Loader.construct_yaml_map) - - -def load_yaml(filename): - """Read a YAML document from a file. If the file cannot be read or - parsed, a ConfigReadError is raised. - """ - try: - with open(filename, 'rb') as f: - return yaml.load(f, Loader=Loader) - except (IOError, yaml.error.YAMLError) as exc: - raise ConfigReadError(filename, exc) - - -# YAML dumping. - -class Dumper(yaml.SafeDumper): - """A PyYAML Dumper that represents OrderedDicts as ordinary mappings - (in order, of course). - """ - # From http://pyyaml.org/attachment/ticket/161/use_ordered_dict.py - def represent_mapping(self, tag, mapping, flow_style=None): - value = [] - node = yaml.MappingNode(tag, value, flow_style=flow_style) - if self.alias_key is not None: - self.represented_objects[self.alias_key] = node - best_style = False - if hasattr(mapping, 'items'): - mapping = list(mapping.items()) - for item_key, item_value in mapping: - node_key = self.represent_data(item_key) - node_value = self.represent_data(item_value) - if not (isinstance(node_key, yaml.ScalarNode) and - not node_key.style): - best_style = False - if not (isinstance(node_value, yaml.ScalarNode) and - not node_value.style): - best_style = False - value.append((node_key, node_value)) - if flow_style is None: - if self.default_flow_style is not None: - node.flow_style = self.default_flow_style - else: - node.flow_style = best_style - return node - - def represent_list(self, data): - """If a list has less than 4 items, represent it in inline style - (i.e. comma separated, within square brackets). - """ - node = super(Dumper, self).represent_list(data) - length = len(data) - if self.default_flow_style is None and length < 4: - node.flow_style = True - elif self.default_flow_style is None: - node.flow_style = False - return node - - def represent_bool(self, data): - """Represent bool as 'yes' or 'no' instead of 'true' or 'false'. - """ - if data: - value = u'yes' - else: - value = u'no' - return self.represent_scalar('tag:yaml.org,2002:bool', value) - - def represent_none(self, data): - """Represent a None value with nothing instead of 'none'. - """ - return self.represent_scalar('tag:yaml.org,2002:null', '') - - -Dumper.add_representer(OrderedDict, Dumper.represent_dict) -Dumper.add_representer(bool, Dumper.represent_bool) -Dumper.add_representer(type(None), Dumper.represent_none) -Dumper.add_representer(list, Dumper.represent_list) - - -def restore_yaml_comments(data, default_data): - """Scan default_data for comments (we include empty lines in our - definition of comments) and place them before the same keys in data. - Only works with comments that are on one or more own lines, i.e. - not next to a yaml mapping. - """ - comment_map = dict() - default_lines = iter(default_data.splitlines()) - for line in default_lines: - if not line: - comment = "\n" - elif line.startswith("#"): - comment = "{0}\n".format(line) - else: - continue - while True: - line = next(default_lines) - if line and not line.startswith("#"): - break - comment += "{0}\n".format(line) - key = line.split(':')[0].strip() - comment_map[key] = comment - out_lines = iter(data.splitlines()) - out_data = "" - for line in out_lines: - key = line.split(':')[0].strip() - if key in comment_map: - out_data += comment_map[key] - out_data += "{0}\n".format(line) - return out_data - - -# Main interface. - -class Configuration(RootView): - def __init__(self, appname, modname=None, read=True): - """Create a configuration object by reading the - automatically-discovered config files for the application for a - given name. If `modname` is specified, it should be the import - name of a module whose package will be searched for a default - config file. (Otherwise, no defaults are used.) Pass `False` for - `read` to disable automatic reading of all discovered - configuration files. Use this when creating a configuration - object at module load time and then call the `read` method - later. - """ - super(Configuration, self).__init__([]) - self.appname = appname - self.modname = modname - - # Resolve default source location. We do this ahead of time to - # avoid unexpected problems if the working directory changes. - self._package_path = _package_path(appname) - - self._env_var = '{0}DIR'.format(self.appname.upper()) - - if read: - self.read() - - def user_config_path(self): - """Points to the location of the user configuration. - - The file may not exist. - """ - return os.path.join(self.config_dir(), CONFIG_FILENAME) - - def _add_user_source(self): - """Add the configuration options from the YAML file in the - user's configuration directory (given by `config_dir`) if it - exists. - """ - filename = self.user_config_path() - if os.path.isfile(filename): - self.add(ConfigSource(load_yaml(filename) or {}, filename)) - - def _add_default_source(self): - """Add the package's default configuration settings. This looks - for a YAML file located inside the package for the module - `modname` if it was given. - """ - if self.modname: - if self._package_path: - filename = os.path.join(self._package_path, DEFAULT_FILENAME) - if os.path.isfile(filename): - self.add(ConfigSource(load_yaml(filename), filename, True)) - - def read(self, user=True, defaults=True): - """Find and read the files for this configuration and set them - as the sources for this configuration. To disable either - discovered user configuration files or the in-package defaults, - set `user` or `defaults` to `False`. - """ - if user: - self._add_user_source() - if defaults: - self._add_default_source() - - def config_dir(self): - """Get the path to the user configuration directory. The - directory is guaranteed to exist as a postcondition (one may be - created if none exist). - - If the application's ``...DIR`` environment variable is set, it - is used as the configuration directory. Otherwise, - platform-specific standard configuration locations are searched - for a ``config.yaml`` file. If no configuration file is found, a - fallback path is used. - """ - # If environment variable is set, use it. - if self._env_var in os.environ: - appdir = os.environ[self._env_var] - appdir = os.path.abspath(os.path.expanduser(appdir)) - if os.path.isfile(appdir): - raise ConfigError(u'{0} must be a directory'.format( - self._env_var - )) - - else: - # Search platform-specific locations. If no config file is - # found, fall back to the final directory in the list. - for confdir in config_dirs(): - appdir = os.path.join(confdir, self.appname) - if os.path.isfile(os.path.join(appdir, CONFIG_FILENAME)): - break - - # Ensure that the directory exists. - if not os.path.isdir(appdir): - os.makedirs(appdir) - return appdir - - def set_file(self, filename): - """Parses the file as YAML and inserts it into the configuration - sources with highest priority. - """ - filename = os.path.abspath(filename) - self.set(ConfigSource(load_yaml(filename), filename)) - - def dump(self, full=True, redact=False): - """Dump the Configuration object to a YAML file. - - The order of the keys is determined from the default - configuration file. All keys not in the default configuration - will be appended to the end of the file. - - :param filename: The file to dump the configuration to, or None - if the YAML string should be returned instead - :type filename: unicode - :param full: Dump settings that don't differ from the defaults - as well - :param redact: Remove sensitive information (views with the `redact` - flag set) from the output - """ - if full: - out_dict = self.flatten(redact=redact) - else: - # Exclude defaults when flattening. - sources = [s for s in self.sources if not s.default] - temp_root = RootView(sources) - temp_root.redactions = self.redactions - out_dict = temp_root.flatten(redact=redact) - - yaml_out = yaml.dump(out_dict, Dumper=Dumper, - default_flow_style=None, indent=4, - width=1000) - - # Restore comments to the YAML text. - default_source = None - for source in self.sources: - if source.default: - default_source = source - break - if default_source and default_source.filename: - with open(default_source.filename, 'rb') as fp: - default_data = fp.read() - yaml_out = restore_yaml_comments(yaml_out, - default_data.decode('utf8')) - - return yaml_out - - -class LazyConfig(Configuration): - """A Configuration at reads files on demand when it is first - accessed. This is appropriate for using as a global config object at - the module level. - """ - def __init__(self, appname, modname=None): - super(LazyConfig, self).__init__(appname, modname, False) - self._materialized = False # Have we read the files yet? - self._lazy_prefix = [] # Pre-materialization calls to set(). - self._lazy_suffix = [] # Calls to add(). - - def read(self, user=True, defaults=True): - self._materialized = True - super(LazyConfig, self).read(user, defaults) - - def resolve(self): - if not self._materialized: - # Read files and unspool buffers. - self.read() - self.sources += self._lazy_suffix - self.sources[:0] = self._lazy_prefix - return super(LazyConfig, self).resolve() - - def add(self, value): - super(LazyConfig, self).add(value) - if not self._materialized: - # Buffer additions to end. - self._lazy_suffix += self.sources - del self.sources[:] - - def set(self, value): - super(LazyConfig, self).set(value) - if not self._materialized: - # Buffer additions to beginning. - self._lazy_prefix[:0] = self.sources - del self.sources[:] - - def clear(self): - """Remove all sources from this configuration.""" - super(LazyConfig, self).clear() - self._lazy_suffix = [] - self._lazy_prefix = [] - - -# "Validated" configuration views: experimental! - - -REQUIRED = object() -"""A sentinel indicating that there is no default value and an exception -should be raised when the value is missing. -""" - - -class Template(object): - """A value template for configuration fields. - - The template works like a type and instructs Confuse about how to - interpret a deserialized YAML value. This includes type conversions, - providing a default value, and validating for errors. For example, a - filepath type might expand tildes and check that the file exists. - """ - def __init__(self, default=REQUIRED): - """Create a template with a given default value. - - If `default` is the sentinel `REQUIRED` (as it is by default), - then an error will be raised when a value is missing. Otherwise, - missing values will instead return `default`. - """ - self.default = default - - def __call__(self, view): - """Invoking a template on a view gets the view's value according - to the template. - """ - return self.value(view, self) - - def value(self, view, template=None): - """Get the value for a `ConfigView`. - - May raise a `NotFoundError` if the value is missing (and the - template requires it) or a `ConfigValueError` for invalid values. - """ - if view.exists(): - value, _ = view.first() - return self.convert(value, view) - elif self.default is REQUIRED: - # Missing required value. This is an error. - raise NotFoundError(u"{0} not found".format(view.name)) - else: - # Missing value, but not required. - return self.default - - def convert(self, value, view): - """Convert the YAML-deserialized value to a value of the desired - type. - - Subclasses should override this to provide useful conversions. - May raise a `ConfigValueError` when the configuration is wrong. - """ - # Default implementation does no conversion. - return value - - def fail(self, message, view, type_error=False): - """Raise an exception indicating that a value cannot be - accepted. - - `type_error` indicates whether the error is due to a type - mismatch rather than a malformed value. In this case, a more - specific exception is raised. - """ - exc_class = ConfigTypeError if type_error else ConfigValueError - raise exc_class( - u'{0}: {1}'.format(view.name, message) - ) - - def __repr__(self): - return '{0}({1})'.format( - type(self).__name__, - '' if self.default is REQUIRED else repr(self.default), - ) - - -class Integer(Template): - """An integer configuration value template. - """ - def convert(self, value, view): - """Check that the value is an integer. Floats are rounded. - """ - if isinstance(value, int): - return value - elif isinstance(value, float): - return int(value) - else: - self.fail(u'must be a number', view, True) - - -class Number(Template): - """A numeric type: either an integer or a floating-point number. - """ - def convert(self, value, view): - """Check that the value is an int or a float. - """ - if isinstance(value, NUMERIC_TYPES): - return value - else: - self.fail( - u'must be numeric, not {0}'.format(type(value).__name__), - view, - True - ) - - -class MappingTemplate(Template): - """A template that uses a dictionary to specify other types for the - values for a set of keys and produce a validated `AttrDict`. - """ - def __init__(self, mapping): - """Create a template according to a dict (mapping). The - mapping's values should themselves either be Types or - convertible to Types. - """ - subtemplates = {} - for key, typ in mapping.items(): - subtemplates[key] = as_template(typ) - self.subtemplates = subtemplates - - def value(self, view, template=None): - """Get a dict with the same keys as the template and values - validated according to the value types. - """ - out = AttrDict() - for key, typ in self.subtemplates.items(): - out[key] = typ.value(view[key], self) - return out - - def __repr__(self): - return 'MappingTemplate({0})'.format(repr(self.subtemplates)) - - -class String(Template): - """A string configuration value template. - """ - def __init__(self, default=REQUIRED, pattern=None): - """Create a template with the added optional `pattern` argument, - a regular expression string that the value should match. - """ - super(String, self).__init__(default) - self.pattern = pattern - if pattern: - self.regex = re.compile(pattern) - - def __repr__(self): - args = [] - - if self.default is not REQUIRED: - args.append(repr(self.default)) - - if self.pattern is not None: - args.append('pattern=' + repr(self.pattern)) - - return 'String({0})'.format(', '.join(args)) - - def convert(self, value, view): - """Check that the value is a string and matches the pattern. - """ - if isinstance(value, BASESTRING): - if self.pattern and not self.regex.match(value): - self.fail( - u"must match the pattern {0}".format(self.pattern), - view - ) - return value - else: - self.fail(u'must be a string', view, True) - - -class Choice(Template): - """A template that permits values from a sequence of choices. - """ - def __init__(self, choices): - """Create a template that validates any of the values from the - iterable `choices`. - - If `choices` is a map, then the corresponding value is emitted. - Otherwise, the value itself is emitted. - """ - self.choices = choices - - def convert(self, value, view): - """Ensure that the value is among the choices (and remap if the - choices are a mapping). - """ - if value not in self.choices: - self.fail( - u'must be one of {0}, not {1}'.format( - repr(list(self.choices)), repr(value) - ), - view - ) - - if isinstance(self.choices, collections.Mapping): - return self.choices[value] - else: - return value - - def __repr__(self): - return 'Choice({0!r})'.format(self.choices) - - -class OneOf(Template): - """A template that permits values complying to one of the given templates. - """ - def __init__(self, allowed, default=REQUIRED): - super(OneOf, self).__init__(default) - self.allowed = list(allowed) - - def __repr__(self): - args = [] - - if self.allowed is not None: - args.append('allowed=' + repr(self.allowed)) - - if self.default is not REQUIRED: - args.append(repr(self.default)) - - return 'OneOf({0})'.format(', '.join(args)) - - def value(self, view, template): - self.template = template - return super(OneOf, self).value(view, template) - - def convert(self, value, view): - """Ensure that the value follows at least one template. - """ - is_mapping = isinstance(self.template, MappingTemplate) - - for candidate in self.allowed: - try: - if is_mapping: - if isinstance(candidate, Filename) and \ - candidate.relative_to: - next_template = candidate.template_with_relatives( - view, - self.template - ) - - next_template.subtemplates[view.key] = as_template( - candidate - ) - else: - next_template = MappingTemplate({view.key: candidate}) - - return view.parent.get(next_template)[view.key] - else: - return view.get(candidate) - except ConfigTemplateError: - raise - except ConfigError: - pass - except ValueError as exc: - raise ConfigTemplateError(exc) - - self.fail( - u'must be one of {0}, not {1}'.format( - repr(self.allowed), repr(value) - ), - view - ) - - -class StrSeq(Template): - """A template for values that are lists of strings. - - Validates both actual YAML string lists and single strings. Strings - can optionally be split on whitespace. - """ - def __init__(self, split=True): - """Create a new template. - - `split` indicates whether, when the underlying value is a single - string, it should be split on whitespace. Otherwise, the - resulting value is a list containing a single string. - """ - super(StrSeq, self).__init__() - self.split = split - - def _convert_value(self, x, view): - if isinstance(x, STRING): - return x - elif isinstance(x, bytes): - return x.decode('utf-8', 'ignore') - else: - self.fail(u'must be a list of strings', view, True) - - def convert(self, value, view): - if isinstance(value, bytes): - value = value.decode('utf-8', 'ignore') - - if isinstance(value, STRING): - if self.split: - value = value.split() - else: - value = [value] - else: - try: - value = list(value) - except TypeError: - self.fail(u'must be a whitespace-separated string or a list', - view, True) - - return [self._convert_value(v, view) for v in value] - - -class Pairs(StrSeq): - """A template for ordered key-value pairs. - - This can either be given with the same syntax as for `StrSeq` (i.e. without - values), or as a list of strings and/or single-element mappings such as:: - - - key: value - - [key, value] - - key - - The result is a list of two-element tuples. If no value is provided, the - `default_value` will be returned as the second element. - """ - - def __init__(self, default_value=None): - """Create a new template. - - `default` is the dictionary value returned for items that are not - a mapping, but a single string. - """ - super(Pairs, self).__init__(split=True) - self.default_value = default_value - - def _convert_value(self, x, view): - try: - return (super(Pairs, self)._convert_value(x, view), - self.default_value) - except ConfigTypeError: - if isinstance(x, collections.Mapping): - if len(x) != 1: - self.fail(u'must be a single-element mapping', view, True) - k, v = iter_first(x.items()) - elif isinstance(x, collections.Sequence): - if len(x) != 2: - self.fail(u'must be a two-element list', view, True) - k, v = x - else: - # Is this even possible? -> Likely, if some !directive cause - # YAML to parse this to some custom type. - self.fail(u'must be a single string, mapping, or a list' - u'' + str(x), - view, True) - return (super(Pairs, self)._convert_value(k, view), - super(Pairs, self)._convert_value(v, view)) - - -class Filename(Template): - """A template that validates strings as filenames. - - Filenames are returned as absolute, tilde-free paths. - - Relative paths are relative to the template's `cwd` argument - when it is specified, then the configuration directory (see - the `config_dir` method) if they come from a file. Otherwise, - they are relative to the current working directory. This helps - attain the expected behavior when using command-line options. - """ - def __init__(self, default=REQUIRED, cwd=None, relative_to=None, - in_app_dir=False): - """`relative_to` is the name of a sibling value that is - being validated at the same time. - - `in_app_dir` indicates whether the path should be resolved - inside the application's config directory (even when the setting - does not come from a file). - """ - super(Filename, self).__init__(default) - self.cwd = cwd - self.relative_to = relative_to - self.in_app_dir = in_app_dir - - def __repr__(self): - args = [] - - if self.default is not REQUIRED: - args.append(repr(self.default)) - - if self.cwd is not None: - args.append('cwd=' + repr(self.cwd)) - - if self.relative_to is not None: - args.append('relative_to=' + repr(self.relative_to)) - - if self.in_app_dir: - args.append('in_app_dir=True') - - return 'Filename({0})'.format(', '.join(args)) - - def resolve_relative_to(self, view, template): - if not isinstance(template, (collections.Mapping, MappingTemplate)): - # disallow config.get(Filename(relative_to='foo')) - raise ConfigTemplateError( - u'relative_to may only be used when getting multiple values.' - ) - - elif self.relative_to == view.key: - raise ConfigTemplateError( - u'{0} is relative to itself'.format(view.name) - ) - - elif self.relative_to not in view.parent.keys(): - # self.relative_to is not in the config - self.fail( - ( - u'needs sibling value "{0}" to expand relative path' - ).format(self.relative_to), - view - ) - - old_template = {} - old_template.update(template.subtemplates) - - # save time by skipping MappingTemplate's init loop - next_template = MappingTemplate({}) - next_relative = self.relative_to - - # gather all the needed templates and nothing else - while next_relative is not None: - try: - # pop to avoid infinite loop because of recursive - # relative paths - rel_to_template = old_template.pop(next_relative) - except KeyError: - if next_relative in template.subtemplates: - # we encountered this config key previously - raise ConfigTemplateError(( - u'{0} and {1} are recursively relative' - ).format(view.name, self.relative_to)) - else: - raise ConfigTemplateError(( - u'missing template for {0}, needed to expand {1}\'s' + - u'relative path' - ).format(self.relative_to, view.name)) - - next_template.subtemplates[next_relative] = rel_to_template - next_relative = rel_to_template.relative_to - - return view.parent.get(next_template)[self.relative_to] - - def value(self, view, template=None): - path, source = view.first() - if not isinstance(path, BASESTRING): - self.fail( - u'must be a filename, not {0}'.format(type(path).__name__), - view, - True - ) - path = os.path.expanduser(STRING(path)) - - if not os.path.isabs(path): - if self.cwd is not None: - # relative to the template's argument - path = os.path.join(self.cwd, path) - - elif self.relative_to is not None: - path = os.path.join( - self.resolve_relative_to(view, template), - path, - ) - - elif source.filename or self.in_app_dir: - # From defaults: relative to the app's directory. - path = os.path.join(view.root().config_dir(), path) - - return os.path.abspath(path) - - -class TypeTemplate(Template): - """A simple template that checks that a value is an instance of a - desired Python type. - """ - def __init__(self, typ, default=REQUIRED): - """Create a template that checks that the value is an instance - of `typ`. - """ - super(TypeTemplate, self).__init__(default) - self.typ = typ - - def convert(self, value, view): - if not isinstance(value, self.typ): - self.fail( - u'must be a {0}, not {1}'.format( - self.typ.__name__, - type(value).__name__, - ), - view, - True - ) - return value - - -class AttrDict(dict): - """A `dict` subclass that can be accessed via attributes (dot - notation) for convenience. - """ - def __getattr__(self, key): - if key in self: - return self[key] - else: - raise AttributeError(key) - - -def as_template(value): - """Convert a simple "shorthand" Python value to a `Template`. - """ - if isinstance(value, Template): - # If it's already a Template, pass it through. - return value - elif isinstance(value, collections.Mapping): - # Dictionaries work as templates. - return MappingTemplate(value) - elif value is int: - return Integer() - elif isinstance(value, int): - return Integer(value) - elif isinstance(value, type) and issubclass(value, BASESTRING): - return String() - elif isinstance(value, BASESTRING): - return String(value) - elif isinstance(value, set): - # convert to list to avoid hash related problems - return Choice(list(value)) - elif isinstance(value, list): - return OneOf(value) - elif value is float: - return Number() - elif value is None: - return Template() - elif value is dict: - return TypeTemplate(collections.Mapping) - elif value is list: - return TypeTemplate(collections.Sequence) - elif isinstance(value, type): - return TypeTemplate(value) - else: - raise ValueError(u'cannot convert to template: {0!r}'.format(value)) +# Cleanup namespace. +del key, value, warnings, confuse diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 0e13db4a0..af22b7908 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -35,6 +35,7 @@ import dis import types import sys import six +import functools SYMBOL_DELIM = u'$' FUNC_DELIM = u'%' @@ -117,31 +118,38 @@ def compile_func(arg_names, statements, name='_the_func', debug=False): bytecode of the compiled function. """ if six.PY2: - func_def = ast.FunctionDef( - name=name.encode('utf-8'), - args=ast.arguments( - args=[ast.Name(n, ast.Param()) for n in arg_names], - vararg=None, - kwarg=None, - defaults=[ex_literal(None) for _ in arg_names], - ), - body=statements, - decorator_list=[], + name = name.encode('utf-8') + args = ast.arguments( + args=[ast.Name(n, ast.Param()) for n in arg_names], + vararg=None, + kwarg=None, + defaults=[ex_literal(None) for _ in arg_names], ) else: - func_def = ast.FunctionDef( - name=name, - args=ast.arguments( - args=[ast.arg(arg=n, annotation=None) for n in arg_names], - kwonlyargs=[], - kw_defaults=[], - defaults=[ex_literal(None) for _ in arg_names], - ), - body=statements, - decorator_list=[], - ) + 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]) - mod = ast.Module([func_def]) ast.fix_missing_locations(mod) prog = compile(mod, '', 'exec') @@ -547,8 +555,23 @@ def _parse(template): return Expression(parts) -# External interface. +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(object): """A string template, including text, Symbols, and Calls. """ diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index 5cce11bc0..d9525e1d2 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -24,9 +24,7 @@ import json import os import subprocess import tempfile -import sys -from multiprocessing.pool import ThreadPool from distutils.spawn import find_executable import requests @@ -75,8 +73,8 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): call([self.extractor]) except OSError: raise ui.UserError( - u'No extractor command found: please install the ' - u'extractor binary from http://acousticbrainz.org/download' + u'No extractor command found: please install the extractor' + u' binary from https://acousticbrainz.org/download' ) except ABSubmitError: # Extractor found, will exit with an error if not called with @@ -106,15 +104,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): def command(self, lib, opts, args): # Get items from arguments items = lib.items(ui.decargs(args)) - if sys.version_info[0] < 3: - for item in items: - self.analyze_submit(item) - else: - # Analyze in parallel using a thread pool. - pool = ThreadPool() - pool.map(self.analyze_submit, items) - pool.close() - pool.join() + util.par_map(self.analyze_submit, items) def analyze_submit(self, item): analysis = self._get_analysis(item) diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py index f4960c301..01f3ac6ac 100644 --- a/beetsplug/acousticbrainz.py +++ b/beetsplug/acousticbrainz.py @@ -17,10 +17,12 @@ """ from __future__ import division, absolute_import, print_function +from collections import defaultdict + import requests -from collections import defaultdict from beets import plugins, ui +from beets.dbcore import types ACOUSTIC_BASE = "https://acousticbrainz.org/" LEVELS = ["/low-level", "/high-level"] @@ -104,6 +106,29 @@ ABSCHEME = { 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), + 'rhythm': types.Float(6), + 'tonal': types.Float(6), + 'voice_instrumental': types.STRING, + } + def __init__(self): super(AcousticPlugin, self).__init__() diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 62c6d8af5..36b45de3a 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -18,16 +18,18 @@ from __future__ import division, absolute_import, print_function -from beets.plugins import BeetsPlugin -from beets.ui import Subcommand -from beets.util import displayable_path, confit -from beets import ui from subprocess import check_output, CalledProcessError, list2cmdline, STDOUT + import shlex import os import errno import sys import six +import confuse +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand +from beets.util import displayable_path, par_map +from beets import ui class CheckerCommandException(Exception): @@ -48,6 +50,10 @@ class CheckerCommandException(Exception): class BadFiles(BeetsPlugin): + def __init__(self): + super(BadFiles, self).__init__() + self.verbose = False + def run_command(self, cmd): self._log.debug(u"running command: {}", displayable_path(list2cmdline(cmd))) @@ -61,7 +67,7 @@ class BadFiles(BeetsPlugin): status = e.returncode except OSError as e: raise CheckerCommandException(cmd, e) - output = output.decode(sys.getfilesystemencoding()) + output = output.decode(sys.getdefaultencoding(), 'replace') return status, errors, [line for line in output.split("\n") if line] def check_mp3val(self, path): @@ -85,60 +91,64 @@ class BadFiles(BeetsPlugin): ext = ext.lower() try: command = self.config['commands'].get(dict).get(ext) - except confit.NotFoundError: + except confuse.NotFoundError: command = None if command: return self.check_custom(command) - elif ext == "mp3": + if ext == "mp3": return self.check_mp3val - elif ext == "flac": + if ext == "flac": return self.check_flac - def check_bad(self, lib, opts, args): - for item in lib.items(ui.decargs(args)): + 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(u"checking path: {}", dpath) + if not os.path.exists(item.path): + ui.print_(u"{}: file does not exist".format( + ui.colorize('text_error', dpath))) - # 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(u"checking path: {}", dpath) - if not os.path.exists(item.path): - ui.print_(u"{}: 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(u"no checker specified in the config for {}", + ext) + return + path = item.path + if not isinstance(path, six.text_type): + path = item.path.decode(sys.getfilesystemencoding()) + try: + status, errors, output = checker(path) + except CheckerCommandException as e: + if e.errno == errno.ENOENT: + self._log.error( + u"command not found: {} when validating file: {}", + e.checker, + e.path + ) + else: + self._log.error(u"error invoking {}: {}", e.checker, e.msg) + return + if status > 0: + ui.print_(u"{}: checker exited with status {}" + .format(ui.colorize('text_error', dpath), status)) + for line in output: + ui.print_(u" {}".format(line)) + elif errors > 0: + ui.print_(u"{}: checker found {} errors or warnings" + .format(ui.colorize('text_warning', dpath), errors)) + for line in output: + ui.print_(u" {}".format(line)) + elif self.verbose: + ui.print_(u"{}: ok".format(ui.colorize('text_success', 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(u"no checker specified in the config for {}", - ext) - continue - path = item.path - if not isinstance(path, six.text_type): - path = item.path.decode(sys.getfilesystemencoding()) - try: - status, errors, output = checker(path) - except CheckerCommandException as e: - if e.errno == errno.ENOENT: - self._log.error( - u"command not found: {} when validating file: {}", - e.checker, - e.path - ) - else: - self._log.error(u"error invoking {}: {}", e.checker, e.msg) - continue - if status > 0: - ui.print_(u"{}: checker exited with status {}" - .format(ui.colorize('text_error', dpath), status)) - for line in output: - ui.print_(u" {}".format(displayable_path(line))) - elif errors > 0: - ui.print_(u"{}: checker found {} errors or warnings" - .format(ui.colorize('text_warning', dpath), errors)) - for line in output: - ui.print_(u" {}".format(displayable_path(line))) - elif opts.verbose: - ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath))) + def command(self, lib, opts, args): + # Get items from arguments + items = lib.items(ui.decargs(args)) + self.verbose = opts.verbose + par_map(self.check_item, items) def commands(self): bad_command = Subcommand('bad', @@ -148,5 +158,5 @@ class BadFiles(BeetsPlugin): action='store_true', default=False, dest='verbose', help=u'view results for both the bad and uncorrupted files' ) - bad_command.func = self.check_bad + bad_command.func = self.command return [bad_command] diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index fc412d998..3462f118a 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -30,11 +30,11 @@ import beets import beets.ui from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.plugins import BeetsPlugin -from beets.util import confit +import confuse AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing) -USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__) +USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__) class BeatportAPIError(Exception): @@ -109,7 +109,7 @@ class BeatportClient(object): :rtype: (unicode, unicode) tuple """ self.api.parse_authorization_response( - "http://beets.io/auth?" + auth_data) + "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'] @@ -191,7 +191,7 @@ class BeatportClient(object): response = self.api.get(self._make_url(endpoint), params=kwargs) except Exception as e: raise BeatportAPIError("Error connecting to Beatport API: {}" - .format(e.message)) + .format(e)) if not response: raise BeatportAPIError( "Error {0.status_code} for '{0.request.path_url}" @@ -224,7 +224,7 @@ class BeatportRelease(BeatportObject): if 'category' in data: self.category = data['category'] if 'slug' in data: - self.url = "http://beatport.com/release/{0}/{1}".format( + self.url = "https://beatport.com/release/{0}/{1}".format( data['slug'], data['id']) @@ -252,8 +252,8 @@ class BeatportTrack(BeatportObject): except ValueError: pass if 'slug' in data: - self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'], - data['id']) + self.url = "https://beatport.com/track/{0}/{1}" \ + .format(data['slug'], data['id']) self.track_number = data.get('trackNumber') @@ -318,7 +318,7 @@ class BeatportPlugin(BeetsPlugin): def _tokenfile(self): """Get the path to the JSON file for storing the OAuth token. """ - return self.config['tokenfile'].get(confit.Filename(in_app_dir=True)) + 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 diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 1049f0c76..045bce035 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -25,19 +25,21 @@ 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 logging from beets import vfs from beets.util import bluelet from beets.library import Item from beets import dbcore -from beets.mediafile import MediaFile +from mediafile import MediaFile import six -PROTOCOL_VERSION = '0.13.0' +PROTOCOL_VERSION = '0.16.0' BUFSIZE = 1024 HELLO = u'OK MPD %s' % PROTOCOL_VERSION @@ -71,11 +73,15 @@ SAFE_COMMANDS = ( u'close', u'commands', u'notcommands', u'password', u'ping', ) -ITEM_KEYS_WRITABLE = set(MediaFile.fields()).intersection(Item._fields.keys()) +# List of subsystems/events used by the `idle` command. +SUBSYSTEMS = [ + u'update', u'player', u'mixer', u'options', u'playlist', u'database', + # Related to unsupported commands: + u'stored_playlist', u'output', u'subscription', u'sticker', u'message', + u'partition', +] -# Loggers. -log = logging.getLogger('beets.bpd') -global_log = logging.getLogger('beets') +ITEM_KEYS_WRITABLE = set(MediaFile.fields()).intersection(Item._fields.keys()) # Gstreamer import error. @@ -150,6 +156,16 @@ class BPDClose(Exception): 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(BPDIdle, self).__init__() + self.subsystems = set(subsystems) + + # Generic server infrastructure, implementing the basic protocol. @@ -166,34 +182,87 @@ class BaseServer(object): This is a generic superclass and doesn't support many commands. """ - def __init__(self, host, port, password): + 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() - bluelet.run(bluelet.server(self.host, self.port, - Connection.handler(self))) + + 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 + u'\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 @@ -231,10 +300,10 @@ class BaseServer(object): def _succ_idx(self): """Returns the index for the next song to play. - It also considers random and repeat flags. + It also considers random, single and repeat flags. No boundaries are checked. """ - if self.repeat: + if self.repeat and self.single: return self.current_index if self.random: return self._random_idx() @@ -245,7 +314,7 @@ class BaseServer(object): It also considers random and repeat flags. No boundaries are checked. """ - if self.repeat: + if self.repeat and self.single: return self.current_index if self.random: return self._random_idx() @@ -255,6 +324,14 @@ class BaseServer(object): """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, + u'Unrecognised idle event: {}'.format(system)) + raise BPDIdle(subsystems) # put the connection into idle mode + def cmd_kill(self, conn): """Exits the server process.""" exit(0) @@ -306,14 +383,23 @@ class BaseServer(object): playlist, playlistlength, and xfade. """ yield ( - u'volume: ' + six.text_type(self.volume), u'repeat: ' + six.text_type(int(self.repeat)), u'random: ' + six.text_type(int(self.random)), + u'consume: ' + six.text_type(int(self.consume)), + u'single: ' + six.text_type(int(self.single)), u'playlist: ' + six.text_type(self.playlist_version), u'playlistlength: ' + six.text_type(len(self.playlist)), - u'xfade: ' + six.text_type(self.crossfade), + u'mixrampdb: ' + six.text_type(self.mixrampdb), ) + if self.volume > 0: + yield u'volume: ' + six.text_type(self.volume) + + if not math.isnan(self.mixrampdelay): + yield u'mixrampdelay: ' + six.text_type(self.mixrampdelay) + if self.crossfade > 0: + yield u'xfade: ' + six.text_type(self.crossfade) + if self.current_index == -1: state = u'stop' elif self.paused: @@ -326,6 +412,11 @@ class BaseServer(object): current_id = self._item_id(self.playlist[self.current_index]) yield u'song: ' + six.text_type(self.current_index) yield u'songid: ' + six.text_type(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 u'nextsong: ' + six.text_type(self.current_index + 1) + yield u'nextsongid: ' + six.text_type(next_id) if self.error: yield u'error: ' + self.error @@ -340,10 +431,23 @@ class BaseServer(object): 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).""" @@ -351,18 +455,58 @@ class BaseServer(object): if vol < VOLUME_MIN or vol > VOLUME_MAX: raise BPDError(ERROR_ARG, u'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, u'crossfade time must be nonnegative') + self._log.warning(u'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, u'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, u'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, u'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 u'replay_gain_mode: ' + six.text_type(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.""" @@ -378,6 +522,7 @@ class BaseServer(object): 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)) @@ -401,6 +546,7 @@ class BaseServer(object): 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) @@ -426,6 +572,7 @@ class BaseServer(object): 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) @@ -436,23 +583,27 @@ class BaseServer(object): """Indicates supported URL schemes. None by default.""" pass - def cmd_playlistinfo(self, conn, index=-1): + def cmd_playlistinfo(self, conn, index=None): """Gives metadata information about the entire playlist or a single track, given by its index. """ - index = cast_arg(int, index) - if index == -1: + 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: - track = self.playlist[index] + tracks = [self.playlist[i] for i in indices] except IndexError: raise ArgumentIndexError() - yield self._item_info(track) + for track in tracks: + yield self._item_info(track) - def cmd_playlistid(self, conn, track_id=-1): - return self.cmd_playlistinfo(conn, self._id_to_index(track_id)) + def cmd_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. @@ -481,20 +632,36 @@ class BaseServer(object): 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 if self.current_index >= len(self.playlist): - # Fallen off the end. Just move to stopped state. + # 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: - return self.cmd_stop(conn) - else: - return self.cmd_play(conn) + 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.""" @@ -502,12 +669,13 @@ class BaseServer(object): 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): + if index < -1 or index >= len(self.playlist): raise ArgumentIndexError() if index == -1: # No index specified: start where we are. @@ -521,6 +689,7 @@ class BaseServer(object): 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) @@ -534,6 +703,7 @@ class BaseServer(object): """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.""" @@ -541,28 +711,40 @@ class BaseServer(object): 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) - def cmd_profile(self, conn): - """Memory profiling for debugging.""" - from guppy import hpy - heap = hpy().heap() - print(heap) + # 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(object): - """A connection between a client and the server. Handles input and - output from and to the client. + """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.authenticated = False + self.address = u'{}:{}'.format(*sock.sock.getpeername()) + + def debug(self, message, kind=' '): + """Log a debug message about this connection. + """ + self.server._log.debug(u'{}[{}]: {}', kind, self.address, message) + + def run(self): + pass def send(self, lines): """Send lines, which which is either a single string or an @@ -573,11 +755,32 @@ class Connection(object): if isinstance(lines, six.string_types): lines = [lines] out = NEWLINE.join(lines) + NEWLINE - log.debug('{}', out[:-1]) # Don't log trailing newline. + for l in out.split(NEWLINE)[:-1]: + self.debug(l, kind='>') if isinstance(out, six.text_type): 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(MPDConnection, self).__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.""" @@ -590,28 +793,75 @@ class Connection(object): # 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(u'changed: {}'.format(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, u'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. - log.debug(u'{}', line) + for l in line.split(NEWLINE): + self.debug(l, kind='<') + + if self.idle_subscriptions: + # The connection is in idle mode. + if line == u'noidle': + yield bluelet.call(self.send_notifications(True)) + else: + err = BPDError(ERROR_UNKNOWN, + u'Got command while idle: {}'.format(line)) + yield self.send(err.response()) + break + continue + if line == u'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)) @@ -626,15 +876,71 @@ class Connection(object): 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()) - @classmethod - def handler(cls, server): - def _handle(sock): - """Creates a new `Connection` and runs it. - """ - return cls(server, sock).run() - return _handle + +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(ControlConnection, self).__init__(server, sock) + + def debug(self, message, kind=' '): + self.server._log.debug(u'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(u'ERROR: no such client: {}'.format(oldlabel)) class Command(object): @@ -663,15 +969,52 @@ class Command(object): 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(u'unknown command "{}"'.format(self.name)) + func = getattr(target, func_name) + + if six.PY2: + # caution: the fields of the namedtuple are slightly different + # between the results of getargspec and getfullargspec. + argspec = inspect.getargspec(func) + else: + 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(u'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. """ - # Attempt to get correct command function. - func_name = 'cmd_' + self.name - if not hasattr(conn.server, func_name): - raise BPDError(ERROR_UNKNOWN, u'unknown command', self.name) - func = getattr(conn.server, func_name) + 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 \ @@ -697,9 +1040,12 @@ class Command(object): # it on the Connection. raise - except Exception as e: + except BPDIdle: + raise + + except Exception: # An "unintentional" error. Hide it from the client. - log.error('{}', traceback.format_exc(e)) + conn.server._log.error('{}', traceback.format_exc()) raise BPDError(ERROR_SYSTEM, u'server error', self.name) @@ -729,7 +1075,7 @@ class CommandList(list): e.index = i # Give the error the correct index. raise e - # Otherwise, possibly send the output delimeter if we're in a + # Otherwise, possibly send the output delimiter if we're in a # verbose ("OK") command list. if self.verbose: yield conn.send(RESP_CLIST_VERBOSE) @@ -743,7 +1089,7 @@ class Server(BaseServer): to store its library. """ - def __init__(self, library, host, port, password): + def __init__(self, library, host, port, password, ctrl_port, log): try: from beetsplug.bpd import gstplayer except ImportError as e: @@ -752,20 +1098,25 @@ class Server(BaseServer): raise NoGstreamerError() else: raise - super(Server, self).__init__(host, port, password) + log.info(u'Starting server...') + super(Server, self).__init__(host, port, password, ctrl_port, log) self.lib = library self.player = gstplayer.GstPlayer(self.play_finished) self.cmd_update(None) + log.info(u'Server ready and listening on {}:{}'.format( + host, port)) + log.debug(u'Listening for control signals on {}:{}'.format( + host, ctrl_port)) def run(self): self.player.run() super(Server, self).run() def play_finished(self): - """A callback invoked every time our player finishes a - track. + """A callback invoked every time our player finishes a track. """ self.cmd_next(None) + self._ctrl_send(u'play_finished') # Metadata helper functions. @@ -773,19 +1124,10 @@ class Server(BaseServer): info_lines = [ u'file: ' + item.destination(fragment=True), u'Time: ' + six.text_type(int(item.length)), - u'Title: ' + item.title, - u'Artist: ' + item.artist, - u'Album: ' + item.album, - u'Genre: ' + item.genre, + u'duration: ' + u'{:.3f}'.format(item.length), + u'Id: ' + six.text_type(item.id), ] - track = six.text_type(item.track) - if item.tracktotal: - track += u'/' + six.text_type(item.tracktotal) - info_lines.append(u'Track: ' + track) - - info_lines.append(u'Date: ' + six.text_type(item.year)) - try: pos = self._id_to_index(item.id) info_lines.append(u'Pos: ' + six.text_type(pos)) @@ -793,10 +1135,27 @@ class Server(BaseServer): # Don't include position if not in playlist. pass - info_lines.append(u'Id: ' + six.text_type(item.id)) + for tagtype, field in self.tagtype_map.items(): + info_lines.append(u'{}: {}'.format( + tagtype, six.text_type(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, u'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 @@ -807,10 +1166,12 @@ class Server(BaseServer): """ # Path is ignored. Also, the real MPD does this asynchronously; # this is done inline. - print(u'Building directory tree...') + self._log.debug(u'Building directory tree...') self.tree = vfs.libtree(self.lib) - print(u'... done.') + self._log.debug(u'Finished building directory tree.') self.updated_time = time.time() + self._send_event('update') + self._send_event('database') # Path (directory tree) browsing. @@ -920,6 +1281,7 @@ class Server(BaseServer): if send_id: yield u'Id: ' + six.text_type(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 @@ -939,11 +1301,24 @@ class Server(BaseServer): if self.current_index > -1: item = self.playlist[self.current_index] - yield u'bitrate: ' + six.text_type(item.bitrate / 1000) - # Missing 'audio'. + yield ( + u'bitrate: ' + six.text_type(item.bitrate / 1000), + u'audio: {}:{}:{}'.format( + six.text_type(item.samplerate), + six.text_type(item.bitdepth), + six.text_type(item.channels), + ), + ) (pos, total) = self.player.time() - yield u'time: ' + six.text_type(pos) + u':' + six.text_type(total) + yield ( + u'time: {}:{}'.format( + six.text_type(int(pos)), + six.text_type(int(total)), + ), + u'elapsed: ' + u'{:.3f}'.format(pos), + u'duration: ' + u'{:.3f}'.format(total), + ) # Also missing 'updating_db'. @@ -967,22 +1342,38 @@ class Server(BaseServer): u'db_update: ' + six.text_type(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 u'plugin: {}'.format(name) + for ext in exts: + yield u'suffix: {}'.format(ext) + for mime in mimes: + yield u'mime_type: {}'.format(mime) + # Searching. tagtype_map = { u'Artist': u'artist', + u'ArtistSort': u'artist_sort', u'Album': u'album', u'Title': u'title', u'Track': u'track', u'AlbumArtist': u'albumartist', u'AlbumArtistSort': u'albumartist_sort', - # Name? + u'Label': u'label', u'Genre': u'genre', u'Date': u'year', + u'OriginalDate': u'original_year', u'Composer': u'composer', - # Performer? u'Disc': u'disc', - u'filename': u'path', # Suspect. + u'Comment': u'comments', + u'MUSICBRAINZ_TRACKID': u'mb_trackid', + u'MUSICBRAINZ_ALBUMID': u'mb_albumid', + u'MUSICBRAINZ_ARTISTID': u'mb_artistid', + u'MUSICBRAINZ_ALBUMARTISTID': u'mb_albumartistid', + u'MUSICBRAINZ_RELEASETRACKID': u'mb_releasetrackid', } def cmd_tagtypes(self, conn): @@ -1050,16 +1441,31 @@ class Server(BaseServer): 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, u'should be "Album" for 3 arguments') + elif len(kv) % 2 != 0: + raise BPDError(ERROR_ARG, u'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 + u': ' + six.text_type(row[0]) def cmd_count(self, conn, tag, value): @@ -1075,6 +1481,42 @@ class Server(BaseServer): yield u'songs: ' + six.text_type(songs) yield u'playtime: ' + six.text_type(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, u'No such playlist') + + def cmd_listplaylistinfo(self, conn, playlist): + raise BPDError(ERROR_NO_EXIST, u'No such playlist') + + def cmd_listplaylists(self, conn): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_load(self, conn, playlist): + raise BPDError(ERROR_NO_EXIST, u'Stored playlists are disabled') + + def cmd_playlistadd(self, conn, playlist, uri): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_playlistclear(self, conn, playlist): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_playlistdelete(self, conn, playlist, index): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_playlistmove(self, conn, playlist, from_index, to_index): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_rename(self, conn, playlist, new_name): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_rm(self, conn, playlist): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_save(self, conn, playlist): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + # "Outputs." Just a dummy implementation because we don't control # any outputs. @@ -1128,7 +1570,7 @@ class Server(BaseServer): def cmd_seek(self, conn, index, pos): """Seeks to the specified position in the specified song.""" index = cast_arg(int, index) - pos = cast_arg(int, pos) + pos = cast_arg(float, pos) super(Server, self).cmd_seek(conn, index, pos) self.player.seek(pos) @@ -1151,45 +1593,42 @@ class BPDPlugin(BeetsPlugin): self.config.add({ 'host': u'', 'port': 6600, + 'control_port': 6601, 'password': u'', 'volume': VOLUME_MAX, }) self.config['password'].redact = True - def start_bpd(self, lib, host, port, password, volume, debug): + def start_bpd(self, lib, host, port, password, volume, ctrl_port): """Starts a BPD server.""" - if debug: # FIXME this should be managed by BeetsPlugin - self._log.setLevel(logging.DEBUG) - else: - self._log.setLevel(logging.WARNING) try: - server = Server(lib, host, port, password) + server = Server(lib, host, port, password, ctrl_port, self._log) server.cmd_setvol(None, volume) server.run() except NoGstreamerError: - global_log.error(u'Gstreamer Python bindings not found.') - global_log.error(u'Install "gstreamer1.0" and "python-gi"' - u'or similar package to use BPD.') + self._log.error(u'Gstreamer Python bindings not found.') + self._log.error(u'Install "gstreamer1.0" and "python-gi"' + u'or similar package to use BPD.') def commands(self): cmd = beets.ui.Subcommand( 'bpd', help=u'run an MPD-compatible music player server' ) - cmd.parser.add_option( - '-d', '--debug', action='store_true', - help=u'dump all MPD traffic to stdout' - ) 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(u'too many arguments') password = self.config['password'].as_str() volume = self.config['volume'].get(int) - debug = opts.debug or False - self.start_bpd(lib, host, int(port), password, volume, debug) + self.start_bpd(lib, host, int(port), password, volume, + int(ctrl_port)) cmd.func = func return [cmd] diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index 705692aa5..3ba293bf2 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -64,7 +64,8 @@ class GstPlayer(object): """ # Set up the Gstreamer player. From the pygst tutorial: - # http://pygstdocs.berlios.de/pygst-tutorial/playbin.html + # 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 @@ -177,12 +178,12 @@ class GstPlayer(object): posq = self.player.query_position(fmt) if not posq[0]: raise QueryError("query_position failed") - pos = posq[1] // (10 ** 9) + 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) + length = lengthq[1] / (10 ** 9) self.cached_time = (pos, length) return (pos, length) @@ -215,6 +216,59 @@ class GstPlayer(object): 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 diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 42abe09b5..c4230b069 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -22,8 +22,8 @@ from beets import plugins from beets import ui from beets import util from beets import config -from beets.util import confit from beets.autotag import hooks +import confuse import acoustid from collections import defaultdict from functools import partial @@ -221,7 +221,7 @@ class AcoustidPlugin(plugins.BeetsPlugin): def submit_cmd_func(lib, opts, args): try: apikey = config['acoustid']['apikey'].as_str() - except confit.NotFoundError: + except confuse.NotFoundError: raise ui.UserError(u'no Acoustid user API key provided') submit_items(self._log, apikey, lib.items(ui.decargs(args))) submit_cmd.func = submit_cmd_func diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 3c9080d1f..6ed139da0 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -28,7 +28,7 @@ import platform from beets import ui, util, plugins, config from beets.plugins import BeetsPlugin -from beets.util.confit import ConfigTypeError +from confuse import ConfigTypeError from beets import art from beets.util.artresizer import ArtResizer from beets.library import parse_query_string @@ -116,6 +116,7 @@ class ConvertPlugin(BeetsPlugin): u'pretend': False, u'threads': util.cpu_count(), u'format': u'mp3', + u'id3v23': u'inherit', u'formats': { u'aac': { u'command': u'ffmpeg -i $source -y -vn -acodec aac ' @@ -316,8 +317,12 @@ class ConvertPlugin(BeetsPlugin): 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) + item.try_write(path=converted, id3v23=id3v23) if keep_new: # If we're keeping the transcoded file, read it again (after @@ -332,7 +337,7 @@ class ConvertPlugin(BeetsPlugin): self._log.debug(u'embedding album art from {}', util.displayable_path(album.artpath)) art.embed_item(self._log, item, album.artpath, - itempath=converted) + itempath=converted, id3v23=id3v23) if keep_new: plugins.send('after_convert', item=item, diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 5b11b9617..6a0a9c531 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -22,7 +22,7 @@ import beets.ui from beets import config from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.plugins import BeetsPlugin -from beets.util import confit +import confuse from discogs_client import Release, Master, Client from discogs_client.exceptions import DiscogsAPIError from requests.exceptions import ConnectionError @@ -37,7 +37,7 @@ import traceback from string import ascii_lowercase -USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__) +USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__) # Exceptions that discogs_client should really handle but does not. CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException, @@ -61,6 +61,8 @@ class DiscogsPlugin(BeetsPlugin): self.config['user_token'].redact = True self.discogs_client = None self.register_listener('import_begin', self.setup) + self.rate_limit_per_minute = 25 + self.last_request_timestamp = 0 def setup(self, session=None): """Create the `discogs_client` field. Authenticate if necessary. @@ -71,6 +73,9 @@ class DiscogsPlugin(BeetsPlugin): # 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.rate_limit_per_minute = 60 self.discogs_client = Client(USER_AGENT, user_token=user_token) return @@ -88,6 +93,26 @@ class DiscogsPlugin(BeetsPlugin): self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret) + def _time_to_next_request(self): + seconds_between_requests = 60 / self.rate_limit_per_minute + seconds_since_last_request = time.time() - self.last_request_timestamp + seconds_to_wait = seconds_between_requests - seconds_since_last_request + return seconds_to_wait + + def request_start(self): + """wait for rate limit if needed + """ + time_to_next_request = self._time_to_next_request() + if time_to_next_request > 0: + self._log.debug('hit rate limit, waiting for {0} seconds', + time_to_next_request) + time.sleep(time_to_next_request) + + def request_finished(self): + """update timestamp for rate limiting + """ + self.last_request_timestamp = time.time() + def reset_auth(self): """Delete token file & redo the auth steps. """ @@ -97,7 +122,7 @@ class DiscogsPlugin(BeetsPlugin): def _tokenfile(self): """Get the path to the JSON file for storing the OAuth token. """ - return self.config['tokenfile'].get(confit.Filename(in_app_dir=True)) + 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. @@ -206,9 +231,13 @@ class DiscogsPlugin(BeetsPlugin): # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query) + + self.request_start() try: releases = self.discogs_client.search(query, type='release').page(1) + self.request_finished() + except CONNECTION_ERRORS: self._log.debug(u"Communication error while searching for {0!r}", query, exc_info=True) @@ -222,8 +251,11 @@ class DiscogsPlugin(BeetsPlugin): """ self._log.debug(u'Searching for master release {0}', master_id) result = Master(self.discogs_client, {'id': master_id}) + + self.request_start() try: year = result.fetch('year') + self.request_finished() return year except DiscogsAPIError as e: if e.status_code != 404: @@ -252,7 +284,7 @@ class DiscogsPlugin(BeetsPlugin): # 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.warn(u"Release does not contain the required fields") + self._log.warning(u"Release does not contain the required fields") return None artist, artist_id = self.get_artist([a.data for a in result.artists]) diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 631a1b584..9dbfcdd17 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -74,7 +74,7 @@ def load(s): """ try: out = [] - for d in yaml.load_all(s): + for d in yaml.safe_load_all(s): if not isinstance(d, dict): raise ParseError( u'each entry must be a dictionary; found {}'.format( diff --git a/beetsplug/export.py b/beetsplug/export.py index 641b9fefc..d783f5b93 100644 --- a/beetsplug/export.py +++ b/beetsplug/export.py @@ -24,7 +24,7 @@ import codecs from datetime import datetime, date from beets.plugins import BeetsPlugin from beets import ui -from beets import mediafile +import mediafile from beetsplug.info import make_key_filter, library_data, tag_data diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index d7a885315..a815d4d9b 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -29,10 +29,11 @@ from beets import importer from beets import ui from beets import util from beets import config -from beets.mediafile import image_mime_type +from mediafile import image_mime_type from beets.util.artresizer import ArtResizer -from beets.util import confit, sorted_walk +from beets.util import sorted_walk from beets.util import syspath, bytestring_path, py3_path +import confuse import six CONTENT_TYPES = { @@ -310,7 +311,10 @@ class CoverArtArchive(RemoteArtSource): class Amazon(RemoteArtSource): NAME = u"Amazon" - URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' + if util.SNI_SUPPORTED: + URL = 'https://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' + else: + URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg' INDICES = (1, 2) def get(self, album, plugin, paths): @@ -324,7 +328,10 @@ class Amazon(RemoteArtSource): class AlbumArtOrg(RemoteArtSource): NAME = u"AlbumArt.org scraper" - URL = 'http://www.albumart.org/index_detail.php' + if util.SNI_SUPPORTED: + URL = 'https://www.albumart.org/index_detail.php' + else: + URL = 'http://www.albumart.org/index_detail.php' PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"' def get(self, album, plugin, paths): @@ -365,12 +372,17 @@ class GoogleImages(RemoteArtSource): if not (album.albumartist and album.album): return search_string = (album.albumartist + ',' + album.album).encode('utf-8') - response = self.request(self.URL, params={ - 'key': self.key, - 'cx': self.cx, - 'q': search_string, - 'searchType': 'image' - }) + + try: + response = self.request(self.URL, params={ + 'key': self.key, + 'cx': self.cx, + 'q': search_string, + 'searchType': 'image' + }) + except requests.RequestException: + self._log.debug(u'google: error receiving response') + return # Get results using JSON. try: @@ -406,10 +418,14 @@ class FanartTV(RemoteArtSource): if not album.mb_releasegroupid: return - response = self.request( - self.API_ALBUMS + album.mb_releasegroupid, - headers={'api-key': self.PROJECT_KEY, - 'client-key': self.client_key}) + 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(u'fanart.tv: error receiving response') + return try: data = response.json() @@ -545,16 +561,22 @@ class Wikipedia(RemoteArtSource): # Find the name of the cover art filename on DBpedia cover_filename, page_id = None, None - 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'}, - ) + + 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(u'dbpedia: error receiving response') + return + try: data = dbpedia_response.json() results = data['results']['bindings'] @@ -584,17 +606,21 @@ class Wikipedia(RemoteArtSource): lpart, rpart = cover_filename.rsplit(' .', 1) # Query all the images in the page - wikipedia_response = self.request( - self.WIKIPEDIA_URL, - params={ - 'format': 'json', - 'action': 'query', - 'continue': '', - 'prop': 'images', - 'pageids': page_id, - }, - headers={'content-type': 'application/json'}, - ) + 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(u'wikipedia: error receiving response') + return # Try to see if one of the images on the pages matches our # incomplete cover_filename @@ -613,18 +639,22 @@ class Wikipedia(RemoteArtSource): return # Find the absolute url of the cover art on Wikipedia - 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'}, - ) + 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(u'wikipedia: error receiving response') + return try: data = wikipedia_response.json() @@ -753,9 +783,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): # allow both pixel and percentage-based margin specifications self.enforce_ratio = self.config['enforce_ratio'].get( - confit.OneOf([bool, - confit.String(pattern=self.PAT_PX), - confit.String(pattern=self.PAT_PERCENT)])) + confuse.OneOf([bool, + confuse.String(pattern=self.PAT_PX), + confuse.String(pattern=self.PAT_PERCENT)])) self.margin_px = None self.margin_percent = None if type(self.enforce_ratio) is six.text_type: @@ -765,7 +795,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin): self.margin_px = int(self.enforce_ratio[:-2]) else: # shouldn't happen - raise confit.ConfigValueError() + raise confuse.ConfigValueError() self.enforce_ratio = True cover_names = self.config['cover_names'].as_str_seq() diff --git a/beetsplug/gmusic.py b/beetsplug/gmusic.py index c2fda19d4..f548d1944 100644 --- a/beetsplug/gmusic.py +++ b/beetsplug/gmusic.py @@ -31,12 +31,19 @@ class Gmusic(BeetsPlugin): def __init__(self): super(Gmusic, self).__init__() self.m = Musicmanager() + + # OAUTH_FILEPATH was moved in gmusicapi 12.0.0. + if hasattr(Musicmanager, 'OAUTH_FILEPATH'): + oauth_file = Musicmanager.OAUTH_FILEPATH + else: + oauth_file = gmusicapi.clients.OAUTH_FILEPATH + self.config.add({ u'auto': False, u'uploader_id': '', u'uploader_name': '', u'device_id': '', - u'oauth_file': gmusicapi.clients.OAUTH_FILEPATH, + u'oauth_file': oauth_file, }) if self.config['auto']: self.import_stages = [self.autoupload] @@ -62,7 +69,7 @@ class Gmusic(BeetsPlugin): return # Checks for OAuth2 credentials, # if they don't exist - performs authorization - oauth_file = self.config['oauth_file'].as_str() + oauth_file = self.config['oauth_file'].as_filename() if os.path.isfile(oauth_file): uploader_id = self.config['uploader_id'] uploader_name = self.config['uploader_name'] diff --git a/beetsplug/hook.py b/beetsplug/hook.py index de44c1b81..ac0c4acad 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -18,7 +18,6 @@ from __future__ import division, absolute_import, print_function import string import subprocess -import six from beets.plugins import BeetsPlugin from beets.util import shlex_split, arg_encoding @@ -46,10 +45,8 @@ class CodingFormatter(string.Formatter): See str.format and string.Formatter.format. """ - try: + if isinstance(format_string, bytes): format_string = format_string.decode(self._coding) - except UnicodeEncodeError: - pass return super(CodingFormatter, self).format(format_string, *args, **kwargs) @@ -96,10 +93,7 @@ class HookPlugin(BeetsPlugin): return # Use a string formatter that works on Unicode strings. - if six.PY2: - formatter = CodingFormatter(arg_encoding()) - else: - formatter = string.Formatter() + formatter = CodingFormatter(arg_encoding()) command_pieces = shlex_split(command) diff --git a/beetsplug/info.py b/beetsplug/info.py index 0f7ccb875..b8a0c9375 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -23,7 +23,7 @@ import re from beets.plugins import BeetsPlugin from beets import ui -from beets import mediafile +import mediafile from beets.library import Item from beets.util import displayable_path, normpath, syspath diff --git a/beetsplug/inline.py b/beetsplug/inline.py index fd0e9fc30..bf6ff92da 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -117,9 +117,13 @@ class InlinePlugin(BeetsPlugin): # 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 diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index a3fbc8211..3a738478e 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -66,7 +66,7 @@ class KeyFinderPlugin(BeetsPlugin): continue except UnicodeEncodeError: # Workaround for Python 2 Windows bug. - # http://bugs.python.org/issue1759845 + # https://bugs.python.org/issue1759845 self._log.error(u'execution failed for Unicode path: {0!r}', item.path) continue diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 2f660206e..cf90facbd 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -14,6 +14,7 @@ # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function + import six """Gets genres for imported music based on Last.fm tags. @@ -152,7 +153,7 @@ class LastGenrePlugin(plugins.BeetsPlugin): 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.load(f) + genres_tree = yaml.safe_load(f) flatten_tree(genres_tree, [], self.c14n_branches) @property @@ -373,35 +374,53 @@ class LastGenrePlugin(plugins.BeetsPlugin): lastgenre_cmd = ui.Subcommand('lastgenre', help=u'fetch genres') lastgenre_cmd.parser.add_option( u'-f', u'--force', dest='force', - action='store_true', default=False, + action='store_true', help=u're-download genre when already present' ) lastgenre_cmd.parser.add_option( u'-s', u'--source', dest='source', type='string', help=u'genre source: artist, album, or track' ) + lastgenre_cmd.parser.add_option( + u'-A', u'--items', action='store_false', dest='album', + help=u'match items instead of albums') + lastgenre_cmd.parser.add_option( + u'-a', u'--albums', action='store_true', dest='album', + help=u'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) - for album in lib.albums(ui.decargs(args)): - album.genre, src = self._get_genre(album) - self._log.info(u'genre for album {0} ({1}): {0.genre}', - album, src) - album.store() + 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(u'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(u'genre for track {0} ({1}): {0.genre}', - item, src) + 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( + u'genre for track {0} ({1}): {0.genre}', + item, src) - if write: - item.try_write() + 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(u'added last.fm item genre ({0}): {1}', + src, item.genre) + item.store() lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index d7b84b0aa..ca97004cf 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2016, Rafael Bodill http://github.com/rafi +# 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 diff --git a/beetsplug/loadext.py b/beetsplug/loadext.py new file mode 100644 index 000000000..5ab98bd59 --- /dev/null +++ b/beetsplug/loadext.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2019, Jack Wilsdon +# +# 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 __future__ import division, absolute_import, print_function + +from beets.dbcore import Database +from beets.plugins import BeetsPlugin +import sqlite3 + + +class LoadExtPlugin(BeetsPlugin): + def __init__(self): + super(LoadExtPlugin, self).__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(u'loading extension {}', ext) + + try: + lib.load_extension(ext) + except sqlite3.OperationalError as e: + self._log.error(u'failed to load extension {}: {}', ext, e) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 6ecdbd1d0..16699d9d3 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -55,6 +55,7 @@ except ImportError: from beets import plugins from beets import ui +from beets import util import beets DIV_RE = re.compile(r'<(/?)div>?', re.I) @@ -406,7 +407,10 @@ class Genius(Backend): class LyricsWiki(SymbolsReplaced): """Fetch lyrics from LyricsWiki.""" - URL_PATTERN = 'http://lyrics.wikia.com/%s:%s' + if util.SNI_SUPPORTED: + URL_PATTERN = 'https://lyrics.wikia.com/%s:%s' + else: + URL_PATTERN = 'http://lyrics.wikia.com/%s:%s' def fetch(self, artist, title): url = self.build_url(artist, title) @@ -446,7 +450,7 @@ def _scrape_strip_cruft(html, plain_text_out=False): html = html.replace('\r', '\n') # Normalize EOL. html = re.sub(r' +', ' ', html) # Whitespaces collapse. html = BREAK_RE.sub('\n', html) #
eats up surrounding '\n'. - html = re.sub(r'<(script).*?(?s)', '', html) # Strip script tags. + html = re.sub(r'(?s)<(script).*?', '', html) # Strip script tags. if plain_text_out: # Strip remaining HTML tags html = COMMENT_RE.sub('', html) diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index 02bd5f697..44a476d15 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -19,7 +19,7 @@ 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] http://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings +[1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings """ from __future__ import division, absolute_import, print_function diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py index 02f0b0f9b..943dbac1f 100644 --- a/beetsplug/metasync/__init__.py +++ b/beetsplug/metasync/__init__.py @@ -21,7 +21,7 @@ from __future__ import division, absolute_import, print_function from abc import abstractmethod, ABCMeta from importlib import import_module -from beets.util.confit import ConfigValueError +from confuse import ConfigValueError from beets import ui from beets.plugins import BeetsPlugin import six diff --git a/beetsplug/metasync/itunes.py b/beetsplug/metasync/itunes.py index 17ab1637f..067ca8d91 100644 --- a/beetsplug/metasync/itunes.py +++ b/beetsplug/metasync/itunes.py @@ -24,13 +24,14 @@ import shutil import tempfile import plistlib +import six from six.moves.urllib.parse import urlparse, unquote from time import mktime from beets import util from beets.dbcore import types from beets.library import DateType -from beets.util.confit import ConfigValueError +from confuse import ConfigValueError from beetsplug.metasync import MetaSource @@ -84,7 +85,11 @@ class Itunes(MetaSource): self._log.debug( u'loading iTunes library from {0}'.format(library_path)) with create_temporary_copy(library_path) as library_copy: - raw_library = plistlib.readPlist(library_copy) + if six.PY2: + raw_library = plistlib.readPlist(library_copy) + else: + with open(library_copy, 'rb') as library_copy_f: + raw_library = plistlib.load(library_copy_f) except IOError as e: raise ConfigValueError(u'invalid iTunes library: ' + e.strerror) except Exception: diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index e5e82d480..f232d87e9 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -107,17 +107,17 @@ class MPDClientWrapper(object): self.connect() return self.get(command, retries=retries - 1) - def playlist(self): - """Return the currently active playlist. Prefixes paths with the + def currentsong(self): + """Return the path to the currently playing song. Prefixes paths with the music_directory, to get the absolute path. """ - result = {} - for entry in self.get('playlistinfo'): + result = None + entry = self.get('currentsong') + if 'file' in entry: if not is_url(entry['file']): - result[entry['id']] = os.path.join( - self.music_directory, entry['file']) + result = os.path.join(self.music_directory, entry['file']) else: - result[entry['id']] = entry['file'] + result = entry['file'] return result def status(self): @@ -250,8 +250,8 @@ class MPDStats(object): self.now_playing = None def on_play(self, status): - playlist = self.mpd.playlist() - path = playlist.get(status['songid']) + + path = self.mpd.currentsong() if not path: return @@ -326,7 +326,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin): 'rating': True, 'rating_mix': 0.75, 'host': os.environ.get('MPD_HOST', u'localhost'), - 'port': 6600, + 'port': int(os.environ.get('MPD_PORT', 6600)), 'password': u'', }) mpd_config['password'].redact = True diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index 6ecc92131..72a98af0e 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -69,7 +69,7 @@ class MPDUpdatePlugin(BeetsPlugin): super(MPDUpdatePlugin, self).__init__() config['mpd'].add({ 'host': os.environ.get('MPD_HOST', u'localhost'), - 'port': 6600, + 'port': int(os.environ.get('MPD_PORT', 6600)), 'password': u'', }) config['mpd']['password'].redact = True diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py new file mode 100644 index 000000000..4ab02c6b7 --- /dev/null +++ b/beetsplug/playlist.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# 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 __future__ import division, absolute_import, print_function + +import os +import fnmatch +import tempfile +import beets + + +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(), + '{0}.m3u'.format(pattern), + )), + ) + + 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, IOError): + 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 ({0})'.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(PlaylistPlugin, self).__init__() + self.config.add({ + 'auto': False, + 'playlist_dir': '.', + 'relative_to': 'library', + }) + + 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('Updating playlist: {0}'.format(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: {0}'.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 {0}'.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: + 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) + + tempfp.write(line.replace(original_path, new_path)) + + if changes or deletions: + self._log.info( + 'Updated playlist {0} ({1} changes, {2} deletions)'.format( + filename, changes, deletions)) + beets.util.copy(new_playlist, filename, replace=True) + beets.util.remove(new_playlist) diff --git a/beetsplug/random.py b/beetsplug/random.py index 65caaf908..a8e29313a 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -19,97 +19,7 @@ from __future__ import division, absolute_import, print_function from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs, print_ -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'): - """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. - """ - # 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 = random.choice(list(objs_by_artists.keys())) - objs_from_artist = objs_by_artists[artist] - i = random.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): - """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. - """ - # Permute the objects either in a straightforward way or an - # artist-balanced way. - if equal_chance: - perm = _equal_chance_permutation(objs) - else: - perm = objs - random.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) +from beets.random import random_objs def random_func(lib, opts, args): diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 40d228490..4cc5f435c 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -250,7 +250,14 @@ class Bs1770gainBackend(Backend): state['gain'] = state['peak'] = None parser.StartElementHandler = start_element_handler parser.EndElementHandler = end_element_handler - parser.Parse(text, True) + + try: + parser.Parse(text, True) + except xml.parsers.expat.ExpatError: + raise ReplayGainError( + u'The bs1770gain tool produced malformed XML. ' + 'Using version >=0.4.10 may solve this problem.' + ) if len(per_file_gain) != len(path_list): raise ReplayGainError( diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index be6e7fd1f..a905899da 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -23,7 +23,7 @@ from beets.plugins import BeetsPlugin from beets import ui from beets import util from beets import config -from beets import mediafile +import mediafile import mutagen _MUTAGEN_FORMATS = { diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 75f2c8523..d8d7637d6 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -14,7 +14,7 @@ import requests from beets import ui from beets.plugins import BeetsPlugin -from beets.util import confit +import confuse from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance @@ -22,7 +22,7 @@ class SpotifyPlugin(BeetsPlugin): # 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 = 'http://open.spotify.com/track/' + 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/' @@ -49,7 +49,7 @@ class SpotifyPlugin(BeetsPlugin): self.config['client_secret'].redact = True self.tokenfile = self.config['tokenfile'].get( - confit.Filename(in_app_dir=True) + confuse.Filename(in_app_dir=True) ) # Path to the JSON file for storing the OAuth access token. self.setup() diff --git a/beetsplug/the.py b/beetsplug/the.py index 83d1089de..238aec32f 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -93,8 +93,8 @@ class ThePlugin(BeetsPlugin): for p in self.patterns: r = self.unthe(text, p) if r != text: + self._log.debug(u'\"{0}\" -> \"{1}\"', text, r) break - self._log.debug(u'\"{0}\" -> \"{1}\"', text, r) return r else: return u'' diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 04845e880..fe36fbd13 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -160,7 +160,7 @@ class ThumbnailsPlugin(BeetsPlugin): def thumbnail_file_name(self, path): """Compute the thumbnail file name - See http://standards.freedesktop.org/thumbnail-spec/latest/x227.html + See https://standards.freedesktop.org/thumbnail-spec/latest/x227.html """ uri = self.get_uri(path) hash = md5(uri.encode('utf-8')).hexdigest() @@ -168,7 +168,7 @@ class ThumbnailsPlugin(BeetsPlugin): def add_tags(self, album, image_path): """Write required metadata to the thumbnail - See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html + 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), diff --git a/beetsplug/types.py b/beetsplug/types.py index 0c078881c..4a39f05b1 100644 --- a/beetsplug/types.py +++ b/beetsplug/types.py @@ -17,7 +17,7 @@ from __future__ import division, absolute_import, print_function from beets.plugins import BeetsPlugin from beets.dbcore import types -from beets.util.confit import ConfigValueError +from confuse import ConfigValueError from beets import library diff --git a/beetsplug/web/static/beets.js b/beetsplug/web/static/beets.js index 51985c183..97af70110 100644 --- a/beetsplug/web/static/beets.js +++ b/beetsplug/web/static/beets.js @@ -129,7 +129,7 @@ $.fn.player = function(debug) { // Simple selection disable for jQuery. // Cut-and-paste from: -// http://stackoverflow.com/questions/2700000 +// https://stackoverflow.com/questions/2700000 $.fn.disableSelection = function() { $(this).attr('unselectable', 'on') .css('-moz-user-select', 'none') diff --git a/beetsplug/zero.py b/beetsplug/zero.py index 022c2c721..0cca199c5 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -21,10 +21,10 @@ import six import re from beets.plugins import BeetsPlugin -from beets.mediafile import MediaFile +from mediafile import MediaFile from beets.importer import action from beets.ui import Subcommand, decargs, input_yn -from beets.util import confit +import confuse __author__ = 'baobab@heresiarch.info' @@ -98,7 +98,7 @@ class ZeroPlugin(BeetsPlugin): for pattern in self.config[field].as_str_seq(): prog = re.compile(pattern, re.IGNORECASE) self.fields_to_progs.setdefault(field, []).append(prog) - except confit.NotFoundError: + except confuse.NotFoundError: # Matches everything self.fields_to_progs[field] = [] diff --git a/docs/changelog.rst b/docs/changelog.rst index e5d1507a7..59def9fae 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,44 +1,119 @@ Changelog ========= -1.4.8 (in development) +1.5.0 (in development) ---------------------- New features: -* The disambiguation string for identifying albums in the importer now shows - the catalog number. - Thanks to :user:`8h2a`. - :bug:`2951` -* :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` -* Added whitespace padding to missing tracks dialog to improve readability. - Thanks to :user:`jams2`. - :bug:`2962` -* :bug:`/plugins/gmusic`: Add a new option to automatically upload to Google - Play Music library on track import. - Thanks to :user:`shuaiscott`. -* :doc:`/plugins/gmusic`: Add new options for Google Play Music - authentication. - Thanks to :user:`thetarkus`. - :bug:`3002` -* :doc:`/plugins/absubmit`: Analysis now works in parallel (on Python 3 only). - Thanks to :user:`bemeurer`. - :bug:`2442` :bug:`3003` -* :doc:`/plugins/replaygain`: albumpeak on large collections is calculated as - the average, not the maximum. - :bug:`3008` :bug:`3009` -* A new :doc:`/plugins/subsonicupdate` can automatically update your Subsonic library. - Thanks to :user:`maffo999`. - :bug:`3001` -* :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` -* A new ``aunique`` configuration option allows setting default options - for the :ref:`aunique` template function. +* 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` +* :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` + +Fixes: + +* :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` + +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. +* We've replaced beets' configuration library 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 + might stop doing this in a future release. + +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 (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. + +.. _MediaFile: https://github.com/beetbox/mediafile +.. _Confuse: https://github.com/beetbox/confuse +.. _works: https://musicbrainz.org/doc/Work + + +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` @@ -70,30 +145,216 @@ New features: level. Thanks to :user:`samuelnilsson` - -Changes: - -* :doc:`/plugins/mbsync` 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` now also exports ``path`` field if user explicitly - specifies it with ``-i`` parameter. Only works when exporting library fields. - :bug:`3084` - -.. _Groups: https://groups.google.com/forum/#!searchin/beets-users/mbsync|sort:date/beets-users/iwCF6bNdh9A/i1xl4Gx8BQAJ - -Fixes: - +======= * A new importer option, :ref:`ignore_data_tracks`, lets you skip audio tracks - contained in data files :bug:`3021` + 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 - python-itunes_, which had gone unmaintained and was not py3 compatible. - Thanks to :user:`ocelma` for creating python-itunes_ in the first place. + :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` -* Fix compatibility Python 3.7 and its change to a name in the ``re`` module. - :bug:`2978` +* :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`. @@ -104,38 +365,49 @@ Fixes: * 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` -* LastGenre: Allow to set the configuration option ``prefer_specific`` - without setting ``canonical``. - :bug:`2973` -* :doc:`/plugins/web`: Fix an error when using more recent versions of Flask - with CORS enabled. - Thanks to :user:`rveachkc`. - :bug:`2979`: :bug:`2980` -* Improve error reporting: during startup if sqlite returns an error the - sqlite error message is attached to the beets message. - :bug:`3005` -* Fix a problem when resizing images with PIL/Pillow on Python 3. - Thanks to :user:`architek`. - :bug:`2504` :bug:`3029` -* Avoid a crash when archive extraction fails during import. - :bug:`3041` * The ``%aunique`` template function now works correctly with the ``-f/--format`` option. :bug:`3043` -* 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` * Fixed the ordering of items when manually selecting changes while updating tags Thanks to :user:`TaizoSimpson`. :bug:`3501` -* Confusing typo when the convert plugin copies the art covers. :bug:`3063` * The ``%title`` template function now works correctly with apostrophes. Thanks to :user:`GuilhermeHideki`. :bug:`3033` -* Fetchart now respects the ``ignore`` and ``ignore_hidden`` settings. :bug:`1632` +* :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` -.. _python-itunes: https://github.com/ocelma/python-itunes +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) @@ -969,7 +1241,7 @@ There are even more new features: don't actually need to be moved. :bug:`1583` .. _Google Code-In: https://codein.withgoogle.com/ -.. _AcousticBrainz: http://acousticbrainz.org/ +.. _AcousticBrainz: https://acousticbrainz.org/ Fixes: @@ -994,7 +1266,7 @@ Fixes: * :doc:`/plugins/replaygain`: Fix a crash using the Python Audio Tools backend. :bug:`1873` -.. _beets.io: http://beets.io/ +.. _beets.io: https://beets.io/ .. _Beetbox: https://github.com/beetbox @@ -1111,7 +1383,7 @@ Fixes: communication errors. The backend has also been disabled by default, since the API it depends on is currently down. :bug:`1770` -.. _Emby: http://emby.media +.. _Emby: https://emby.media 1.3.15 (October 17, 2015) @@ -1273,8 +1545,8 @@ Fixes: * :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows under Python 3. :bug:`2515` :bug:`2516` -.. _Python bug: http://bugs.python.org/issue16512 -.. _ipfs: http://ipfs.io +.. _Python bug: https://bugs.python.org/issue16512 +.. _ipfs: https://ipfs.io 1.3.13 (April 24, 2015) @@ -1625,7 +1897,7 @@ As usual, there are loads of little fixes and improvements: * The :ref:`config-cmd` command can now use ``$EDITOR`` variables with arguments. -.. _API changes: http://developer.echonest.com/forums/thread/3650 +.. _API changes: https://developer.echonest.com/forums/thread/3650 .. _Plex: https://plex.tv/ .. _musixmatch: https://www.musixmatch.com/ @@ -2105,7 +2377,7 @@ Fixes: * :doc:`/plugins/convert`: Display a useful error message when the FFmpeg executable can't be found. -.. _requests: http://www.python-requests.org/ +.. _requests: https://www.python-requests.org/ 1.3.3 (February 26, 2014) @@ -2287,7 +2559,7 @@ As usual, there are also innumerable little fixes and improvements: .. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html -.. _MPD: http://www.musicpd.org/ +.. _MPD: https://www.musicpd.org/ 1.3.1 (October 12, 2013) @@ -2354,7 +2626,7 @@ And some fixes: * :doc:`/plugins/scrub`: Avoid preserving certain non-standard ID3 tags such as NCON. -.. _Opus: http://www.opus-codec.org/ +.. _Opus: https://www.opus-codec.org/ .. _@Verrus: https://github.com/Verrus @@ -2392,7 +2664,7 @@ previous versions would spit out a warning and then list your entire library. There's more detail than you could ever need `on the beets blog`_. -.. _on the beets blog: http://beets.io/blog/flexattr.html +.. _on the beets blog: https://beets.io/blog/flexattr.html 1.2.2 (August 27, 2013) @@ -2586,8 +2858,8 @@ And a batch of fixes: * :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due to some fixes in dealing with special characters. -.. _Discogs: http://discogs.com/ -.. _Beatport: http://www.beatport.com/ +.. _Discogs: https://discogs.com/ +.. _Beatport: https://www.beatport.com/ 1.1.0 (April 29, 2013) @@ -2636,7 +2908,7 @@ will automatically migrate your configuration to the new system. header. Thanks to Uwe L. Korn. * :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization. -.. _Tomahawk: http://www.tomahawk-player.org/ +.. _Tomahawk: https://tomahawk-player.org/ 1.1b3 (March 16, 2013) ---------------------- @@ -2811,7 +3083,7 @@ Other new stuff: (YAML doesn't like tabs.) * Fix the ``-l`` (log path) command-line option for the ``import`` command. -.. _iTunes Sound Check: http://support.apple.com/kb/HT2425 +.. _iTunes Sound Check: https://support.apple.com/kb/HT2425 1.1b1 (January 29, 2013) ------------------------ @@ -2820,7 +3092,7 @@ This release entirely revamps beets' configuration system. The configuration file is now a `YAML`_ document and is located, along with other support files, in a common directory (e.g., ``~/.config/beets`` on Unix-like systems). -.. _YAML: http://en.wikipedia.org/wiki/YAML +.. _YAML: https://en.wikipedia.org/wiki/YAML * Renamed plugins: The ``rdm`` plugin has been renamed to ``random`` and ``fuzzy_search`` has been renamed to ``fuzzy``. @@ -2980,9 +3252,9 @@ begins today on features for version 1.1. unintentionally loading the plugins they contain. .. _The Echo Nest: http://the.echonest.com/ -.. _Tomahawk resolver: http://beets.io/blog/tomahawk-resolver.html +.. _Tomahawk resolver: https://beets.io/blog/tomahawk-resolver.html .. _mp3gain: http://mp3gain.sourceforge.net/download.php -.. _aacgain: http://aacgain.altosdesign.com +.. _aacgain: https://aacgain.altosdesign.com 1.0b15 (July 26, 2012) ---------------------- @@ -3091,7 +3363,7 @@ fetching cover art for your music, enable this plugin after upgrading to beets database with ``beet import -AWC /path/to/music``. * Fix ``import`` with relative path arguments on Windows. -.. _artist credits: http://wiki.musicbrainz.org/Artist_Credit +.. _artist credits: https://wiki.musicbrainz.org/Artist_Credit 1.0b14 (May 12, 2012) --------------------- @@ -3249,7 +3521,7 @@ to come in the next couple of releases. data. * Fix the ``list`` command in BPD (thanks to Simon Chopin). -.. _Colorama: http://pypi.python.org/pypi/colorama +.. _Colorama: https://pypi.python.org/pypi/colorama 1.0b12 (January 16, 2012) ------------------------- @@ -3362,12 +3634,12 @@ release: one for assigning genres and another for ReplayGain analysis. corrupted. .. _KraYmer: https://github.com/KraYmer -.. _Next Generation Schema: http://musicbrainz.org/doc/XML_Web_Service/Version_2 +.. _Next Generation Schema: https://musicbrainz.org/doc/XML_Web_Service/Version_2 .. _python-musicbrainzngs: https://github.com/alastair/python-musicbrainzngs -.. _acoustid: http://acoustid.org/ +.. _acoustid: https://acoustid.org/ .. _Peter Brunner: https://github.com/Lugoues .. _Simon Chopin: https://github.com/laarmen -.. _albumart.org: http://www.albumart.org/ +.. _albumart.org: https://www.albumart.org/ 1.0b10 (September 22, 2011) --------------------------- @@ -3536,8 +3808,8 @@ below, for a plethora of new features. * Fix a crash on album queries with item-only field names. -.. _xargs: http://en.wikipedia.org/wiki/xargs -.. _unidecode: http://pypi.python.org/pypi/Unidecode/0.04.1 +.. _xargs: https://en.wikipedia.org/wiki/xargs +.. _unidecode: https://pypi.python.org/pypi/Unidecode/0.04.1 1.0b8 (April 28, 2011) ---------------------- @@ -3680,7 +3952,7 @@ new configuration options and the ability to clean up empty directory subtrees. * The old "albumify" plugin for upgrading databases was removed. -.. _as specified by MusicBrainz: http://wiki.musicbrainz.org/ReleaseType +.. _as specified by MusicBrainz: https://wiki.musicbrainz.org/ReleaseType 1.0b6 (January 20, 2011) ------------------------ @@ -3796,7 +4068,7 @@ are also rolled into this release. * Fixed escaping of ``/`` characters in paths on Windows. -.. _!!!: http://musicbrainz.org/artist/f26c72d3-e52c-467b-b651-679c73d8e1a7.html +.. _!!!: https://musicbrainz.org/artist/f26c72d3-e52c-467b-b651-679c73d8e1a7.html 1.0b4 (August 9, 2010) ---------------------- @@ -3985,7 +4257,7 @@ Vorbis) and an option to log untaggable albums during import. removed dependency on the aging ``cmdln`` module in favor of `a hand-rolled solution`_. -.. _a hand-rolled solution: http://gist.github.com/462717 +.. _a hand-rolled solution: https://gist.github.com/462717 1.0b1 (June 17, 2010) --------------------- diff --git a/docs/conf.py b/docs/conf.py index c260ebb78..bb3e3d00f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,15 +15,17 @@ master_doc = 'index' project = u'beets' copyright = u'2016, Adrian Sampson' -version = '1.4' -release = '1.4.8' +version = '1.5' +release = '1.5.0' pygments_style = 'sphinx' -# External links to the bug tracker. +# External links to the bug tracker and other sites. extlinks = { 'bug': ('https://github.com/beetbox/beets/issues/%s', '#'), 'user': ('https://github.com/%s', ''), + 'pypi': ('https://pypi.org/project/%s/', ''), + 'stdlib': ('https://docs.python.org/3/library/%s.html', ''), } # Options for HTML output diff --git a/docs/dev/api.rst b/docs/dev/api.rst deleted file mode 100644 index d9e68481d..000000000 --- a/docs/dev/api.rst +++ /dev/null @@ -1,84 +0,0 @@ -API Documentation -================= - -.. currentmodule:: beets.library - -This page describes the internal API of beets' core. It's a work in -progress---since beets is an application first and a library second, its API -has been mainly undocumented until recently. Please file bugs if you run -across incomplete or incorrect docs here. - -The :class:`Library` object is the central repository for data in beets. It -represents a database containing songs, which are :class:`Item` instances, and -groups of items, which are :class:`Album` instances. - -The Library Class ------------------ - -.. autoclass:: Library(path, directory[, path_formats[, replacements]]) - - .. automethod:: items - - .. automethod:: albums - - .. automethod:: get_item - - .. automethod:: get_album - - .. automethod:: add - - .. automethod:: add_album - - .. automethod:: transaction - -Transactions -'''''''''''' - -The :class:`Library` class provides the basic methods necessary to access and -manipulate its contents. To perform more complicated operations atomically, or -to interact directly with the underlying SQLite database, you must use a -*transaction*. For example:: - - lib = Library() - with lib.transaction() as tx: - items = lib.items(query) - lib.add_album(list(items)) - -.. currentmodule:: beets.dbcore.db - -.. autoclass:: Transaction - :members: - -Model Classes -------------- - -The two model entities in beets libraries, :class:`Item` and :class:`Album`, -share a base class, :class:`Model`, that provides common functionality and -ORM-like abstraction. - -The fields model classes can be accessed using attributes (dots, as in -``item.artist``) or items (brackets, as in ``item['artist']``). The -:class:`Model` base class provides some methods that resemble `dict` -objects. - -Model base -'''''''''' - -.. currentmodule:: beets.dbcore - -.. autoclass:: Model - :members: - -Item -'''' - -.. currentmodule:: beets.library - -.. autoclass:: Item - :members: - -Album -''''' - -.. autoclass:: Album - :members: diff --git a/docs/dev/cli.rst b/docs/dev/cli.rst new file mode 100644 index 000000000..77d3af5a5 --- /dev/null +++ b/docs/dev/cli.rst @@ -0,0 +1,9 @@ +Providing a CLI +=============== + +The ``beets.ui`` module houses interactions with the user via a terminal, the +:doc:`/reference/cli`. +The main function is called when the user types beet on the command line. +The CLI functionality is organized into commands, some of which are built-in +and some of which are provided by plugins. The built-in commands are all +implemented in the ``beets.ui.commands`` submodule. diff --git a/docs/dev/importer.rst b/docs/dev/importer.rst new file mode 100644 index 000000000..5182c7134 --- /dev/null +++ b/docs/dev/importer.rst @@ -0,0 +1,19 @@ +Music Importer +============== + +The importer component is responsible for the user-centric workflow that adds +music to a library. This is one of the first aspects that a user experiences +when using beets: it finds music in the filesystem, groups it into albums, +finds corresponding metadata in MusicBrainz, asks the user for intervention, +applies changes, and moves/copies files. A description of its user interface is +given in :doc:`/guides/tagger`. + +The workflow is implemented in the ``beets.importer`` module and is +distinct from the core logic for matching MusicBrainz metadata (in the +``beets.autotag`` module). The workflow is also decoupled from the command-line +interface with the hope that, eventually, other (graphical) interfaces can be +bolted onto the same importer implementation. + +The importer is multithreaded and follows the pipeline pattern. Each pipeline +stage is a Python coroutine. The ``beets.util.pipeline`` module houses +a generic, reusable implementation of a multithreaded pipeline. diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 82651a781..f1465494d 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -4,8 +4,14 @@ For Developers This section contains information for developers. Read on if you're interested in hacking beets itself or creating plugins for it. +See also the documentation for `MediaFile`_, the library used by beets to read +and write metadata tags in media files. + +.. _MediaFile: https://mediafile.readthedocs.io/ + .. toctree:: plugins - api - media_file + library + importer + cli diff --git a/docs/dev/library.rst b/docs/dev/library.rst new file mode 100644 index 000000000..77e218b93 --- /dev/null +++ b/docs/dev/library.rst @@ -0,0 +1,279 @@ +Library Database API +==================== + +.. currentmodule:: beets.library + +This page describes the internal API of beets' core database features. It +doesn't exhaustively document the API, but is aimed at giving an overview of +the architecture to orient anyone who wants to dive into the code. + +The :class:`Library` object is the central repository for data in beets. It +represents a database containing songs, which are :class:`Item` instances, and +groups of items, which are :class:`Album` instances. + +The Library Class +----------------- + +The :class:`Library` is typically instantiated as a singleton. A single +invocation of beets usually has only one :class:`Library`. It's powered by +:class:`dbcore.Database` under the hood, which handles the `SQLite`_ +abstraction, something like a very minimal `ORM`_. The library is also +responsible for handling queries to retrieve stored objects. + +.. autoclass:: Library(path, directory[, path_formats[, replacements]]) + + .. automethod:: __init__ + + You can add new items or albums to the library: + + .. automethod:: add + + .. automethod:: add_album + + And there are methods for querying the database: + + .. automethod:: items + + .. automethod:: albums + + .. automethod:: get_item + + .. automethod:: get_album + + Any modifications must go through a :class:`Transaction` which you get can + using this method: + + .. automethod:: transaction + +.. _SQLite: https://sqlite.org/ +.. _ORM: https://en.wikipedia.org/wiki/Object-relational_mapping + + +Model Classes +------------- + +The two model entities in beets libraries, :class:`Item` and :class:`Album`, +share a base class, :class:`LibModel`, that provides common functionality. That +class itself specialises :class:`dbcore.Model` which provides an ORM-like +abstraction. + +To get or change the metadata of a model (an item or album), either access its +attributes (e.g., ``print(album.year)`` or ``album.year = 2012``) or use the +``dict``-like interface (e.g. ``item['artist']``). + + +Model base +'''''''''' + +Models use dirty-flags to track when the object's metadata goes out of +sync with the database. The dirty dictionary maps field names to booleans +indicating whether the field has been written since the object was last +synchronized (via load or store) with the database. + +.. autoclass:: LibModel + + .. automethod:: all_keys + + .. automethod:: __init__ + + .. autoattribute:: _types + + .. autoattribute:: _fields + + There are CRUD-like methods for interacting with the database: + + .. automethod:: store + + .. automethod:: load + + .. automethod:: remove + + .. automethod:: add + + The base class :class:`dbcore.Model` has a ``dict``-like interface, so + normal the normal mapping API is supported: + + .. automethod:: keys + + .. automethod:: update + + .. automethod:: items + + .. automethod:: get + +Item +'''' + +Each :class:`Item` object represents a song or track. (We use the more generic +term item because, one day, beets might support non-music media.) An item can +either be purely abstract, in which case it's just a bag of metadata fields, +or it can have an associated file (indicated by ``item.path``). + +In terms of the underlying SQLite database, items are backed by a single table +called items with one column per metadata fields. The metadata fields currently +in use are listed in ``library.py`` in ``Item._fields``. + +To read and write a file's tags, we use the `MediaFile`_ library. +To make changes to either the database or the tags on a file, you +update an item's fields (e.g., ``item.title = "Let It Be"``) and then call +``item.write()``. + +.. _MediaFile: https://mediafile.readthedocs.io/ + +Items also track their modification times (mtimes) to help detect when they +become out of sync with on-disk metadata, mainly to speed up the +:ref:`update-cmd` (which needs to check whether the database is in sync with +the filesystem). This feature turns out to be sort of complicated. + +For any :class:`Item`, there are two mtimes: the on-disk mtime (maintained by +the OS) and the database mtime (maintained by beets). Correspondingly, there is +on-disk metadata (ID3 tags, for example) and DB metadata. The goal with the +mtime is to ensure that the on-disk and DB mtimes match when the on-disk and DB +metadata are in sync; this lets beets do a quick mtime check and avoid +rereading files in some circumstances. + +Specifically, beets attempts to maintain the following invariant: + + If the on-disk metadata differs from the DB metadata, then the on-disk + mtime must be greater than the DB mtime. + +As a result, it is always valid for the DB mtime to be zero (assuming that real +disk mtimes are always positive). However, whenever possible, beets tries to +set ``db_mtime = disk_mtime`` at points where it knows the metadata is +synchronized. When it is possible that the metadata is out of sync, beets can +then just set ``db_mtime = 0`` to return to a consistent state. + +This leads to the following implementation policy: + + * On every write of disk metadata (``Item.write()``), the DB mtime is updated + to match the post-write disk mtime. + * Same for metadata reads (``Item.read()``). + * On every modification to DB metadata (``item.field = ...``), the DB mtime + is reset to zero. + + +.. autoclass:: Item + + .. automethod:: __init__ + + .. automethod:: from_path + + .. automethod:: get_album + + .. automethod:: destination + + .. automethod:: current_mtime + + The methods ``read()`` and ``write()`` are complementary: one reads a + file's tags and updates the item's metadata fields accordingly while the + other takes the item's fields and writes them to the file's tags. + + .. automethod:: read + + .. automethod:: write + + .. automethod:: try_write + + .. automethod:: try_sync + + The :class:`Item` class supplements the normal model interface so that they + interacting with the filesystem as well: + + .. automethod:: move + + .. automethod:: remove + +Album +''''' + +An :class:`Album` is a collection of Items in the database. Every item in the +database has either zero or one associated albums (accessible via +``item.album_id``). An item that has no associated album is called a +singleton. +Changing fields on an album (e.g. ``album.year = 2012``) updates the album +itself and also changes the same field in all associated items. + +An :class:`Album` object keeps track of album-level metadata, which is (mostly) +a subset of the track-level metadata. The album-level metadata fields are +listed in ``Album._fields``. +For those fields that are both item-level and album-level (e.g., ``year`` or +``albumartist``), every item in an album should share the same value. Albums +use an SQLite table called ``albums``, in which each column is an album +metadata field. + +.. autoclass:: Album + + .. automethod:: __init__ + + .. automethod:: item_dir + + Albums extend the normal model interface to also forward changes to their + items: + + .. autoattribute:: item_keys + + .. automethod:: store + + .. automethod:: try_sync + + .. automethod:: move + + .. automethod:: remove + + Albums also manage album art, image files that are associated with each + album: + + .. automethod:: set_art + + .. automethod:: move_art + + .. automethod:: art_destination + +Transactions +'''''''''''' + +The :class:`Library` class provides the basic methods necessary to access and +manipulate its contents. To perform more complicated operations atomically, or +to interact directly with the underlying SQLite database, you must use a +*transaction* (see this `blog post`_ for motivation). For example:: + + lib = Library() + with lib.transaction() as tx: + items = lib.items(query) + lib.add_album(list(items)) + +.. _blog post: https://beets.io/blog/sqlite-nightmare.html + +.. currentmodule:: beets.dbcore.db + +.. autoclass:: Transaction + :members: + + +Queries +------- + +To access albums and items in a library, we use :doc:`/reference/query`. +In beets, the :class:`Query` abstract base class represents a criterion that +matches items or albums in the database. +Every subclass of :class:`Query` must implement two methods, which implement +two different ways of identifying matching items/albums. + +The ``clause()`` method should return an SQLite ``WHERE`` clause that matches +appropriate albums/items. This allows for efficient batch queries. +Correspondingly, the ``match(item)`` method should take an :class:`Item` object +and return a boolean, indicating whether or not a specific item matches the +criterion. This alternate implementation allows clients to determine whether +items that have already been fetched from the database match the query. + +There are many different types of queries. Just as an example, +:class:`FieldQuery` determines whether a certain field matches a certain value +(an equality query). +:class:`AndQuery` (like its abstract superclass, :class:`CollectionQuery`) +takes a set of other query objects and bundles them together, matching only +albums/items that match all constituent queries. + +Beets has a human-writable plain-text query syntax that can be parsed into +:class:`Query` objects. Calling ``AndQuery.from_strings`` parses a list of +query parts into a query object that can then be used with :class:`Library` +objects. diff --git a/docs/dev/media_file.rst b/docs/dev/media_file.rst deleted file mode 100644 index c703377d8..000000000 --- a/docs/dev/media_file.rst +++ /dev/null @@ -1,21 +0,0 @@ -.. _mediafile: - -MediaFile ---------- - -.. currentmodule:: beets.mediafile - -.. autoclass:: MediaFile - - .. automethod:: __init__ - .. automethod:: fields - .. automethod:: readable_fields - .. automethod:: save - .. automethod:: update - -.. autoclass:: MediaField - - .. automethod:: __init__ - -.. autoclass:: StorageStyle - :members: diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index bab0e604d..3328654e0 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -15,7 +15,7 @@ structure should look like this:: myawesomeplugin.py .. _Stack Overflow question about namespace packages: - http://stackoverflow.com/questions/1675734/how-do-i-create-a-namespace-package-in-python/1676069#1676069 + https://stackoverflow.com/questions/1675734/how-do-i-create-a-namespace-package-in-python/1676069#1676069 Then, you'll need to put this stuff in ``__init__.py`` to make ``beetsplug`` a namespace package:: @@ -42,7 +42,7 @@ Then, as described above, edit your ``config.yaml`` to include ``plugins: myawesomeplugin`` (substituting the name of the Python module containing your plugin). -.. _virtualenv: http://pypi.python.org/pypi/virtualenv +.. _virtualenv: https://pypi.org/project/virtualenv .. _add_subcommands: @@ -73,7 +73,7 @@ but it defaults to an empty parser (you can extend it later). ``help`` is a description of your command, and ``aliases`` is a list of shorthand versions of your command name. -.. _OptionParser instance: http://docs.python.org/library/optparse.html +.. _OptionParser instance: https://docs.python.org/library/optparse.html You'll need to add a function to your command by saying ``mycommand.func = myfunction``. This function should take the following parameters: ``lib`` (a @@ -81,7 +81,7 @@ beets ``Library`` object) and ``opts`` and ``args`` (command-line options and arguments as returned by `OptionParser.parse_args`_). .. _OptionParser.parse_args: - http://docs.python.org/library/optparse.html#parsing-arguments + https://docs.python.org/library/optparse.html#parsing-arguments The function should use any of the utility functions defined in ``beets.ui``. Try running ``pydoc beets.ui`` to see what's available. @@ -103,19 +103,18 @@ operation. For instance, a plugin could write a log message every time an album is successfully autotagged or update MPD's index whenever the database is changed. -You can "listen" for events using the ``BeetsPlugin.listen`` decorator. Here's +You can "listen" for events using ``BeetsPlugin.register_listener``. Here's an example:: from beets.plugins import BeetsPlugin - class SomePlugin(BeetsPlugin): - pass - - @SomePlugin.listen('pluginload') def loaded(): print 'Plugin loaded!' -Pass the name of the event in question to the ``listen`` decorator. + class SomePlugin(BeetsPlugin): + def __init__(self): + super(SomePlugin, self).__init__() + self.register_listener('pluginload', loaded) Note that if you want to access an attribute of your plugin (e.g. ``config`` or ``log``) you'll have to define a method and not a function. Here is the usual @@ -299,10 +298,10 @@ this in their ``config.yaml``:: foo: bar To access this value, say ``self.config['foo'].get()`` at any point in your -plugin's code. The `self.config` object is a *view* as defined by the `Confit`_ +plugin's code. The `self.config` object is a *view* as defined by the `Confuse`_ library. -.. _Confit: http://confit.readthedocs.org/ +.. _Confuse: https://confuse.readthedocs.org/ If you want to access configuration values *outside* of your plugin's section, import the `config` object from the `beets` module. That is, just put ``from @@ -371,17 +370,16 @@ template fields by adding a function accepting an ``Album`` argument to the Extend MediaFile ^^^^^^^^^^^^^^^^ -:ref:`MediaFile` is the file tag abstraction layer that beets uses to make +`MediaFile`_ is the file tag abstraction layer that beets uses to make cross-format metadata manipulation simple. Plugins can add fields to MediaFile to extend the kinds of metadata that they can easily manage. The ``MediaFile`` class uses ``MediaField`` descriptors to provide -access to file tags. Have a look at the ``beets.mediafile`` source code -to learn how to use this descriptor class. If you have created a -descriptor you can add it through your plugins ``add_media_field()`` -method. +access to file tags. If you have created a descriptor you can add it through +your plugins ``add_media_field()`` method. .. automethod:: beets.plugins.BeetsPlugin.add_media_field +.. _MediaFile: https://mediafile.readthedocs.io/ Here's an example plugin that provides a meaningless new field "foo":: @@ -443,15 +441,24 @@ Extend the Query Syntax ^^^^^^^^^^^^^^^^^^^^^^^ You can add new kinds of queries to beets' :doc:`query syntax -` indicated by a prefix. As an example, beets already +`. There are two ways to add custom queries: using a prefix +and using a name. Prefix-based query extension can apply to *any* field, while +named queries are not associated with any field. For example, beets already supports regular expression queries, which are indicated by a colon prefix---plugins can do the same. -To do so, define a subclass of the ``Query`` type from the -``beets.dbcore.query`` module. Then, in the ``queries`` method of your plugin -class, return a dictionary mapping prefix strings to query classes. +For either kind of query extension, define a subclass of the ``Query`` type +from the ``beets.dbcore.query`` module. Then: -One simple kind of query you can extend is the ``FieldQuery``, which +- To define a prefix-based query, define a ``queries`` method in your plugin + class. Return from this method a dictionary mapping prefix strings to query + classes. +- To define a named query, defined dictionaries named either ``item_queries`` + or ``album_queries``. These should map names to query types. So if you + use ``{ "foo": FooQuery }``, then the query ``foo:bar`` will construct a + query like ``FooQuery("bar")``. + +For prefix-based queries, you will want to extend ``FieldQuery``, which implements string comparisons on fields. To use it, create a subclass inheriting from that class and override the ``value_match`` class method. (Remember the ``@classmethod`` decorator!) The following example plugin diff --git a/docs/faq.rst b/docs/faq.rst index b7ec10df5..9732a4725 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -6,8 +6,8 @@ Got a question that isn't answered here? Try `IRC`_, the `discussion board`_, or :ref:`filing an issue ` in the bug tracker. .. _IRC: irc://irc.freenode.net/beets -.. _mailing list: http://groups.google.com/group/beets-users -.. _discussion board: http://discourse.beets.io +.. _mailing list: https://groups.google.com/group/beets-users +.. _discussion board: https://discourse.beets.io .. contents:: :local: @@ -94,14 +94,14 @@ the tracks into a single directory to force them to be tagged together. An MBID looks like one of these: -- ``http://musicbrainz.org/release/ded77dcf-7279-457e-955d-625bd3801b87`` +- ``https://musicbrainz.org/release/ded77dcf-7279-457e-955d-625bd3801b87`` - ``d569deba-8c6b-4d08-8c43-d0e5a1b8c7f3`` Beets can recognize either the hex-with-dashes UUID-style string or the full URL that contains it (as of 1.0b11). You can get these IDs by `searching on the MusicBrainz web -site `__ and going to a *release* page (when +site `__ and going to a *release* page (when tagging full albums) or a *recording* page (when tagging singletons). Then, copy the URL of the page and paste it into beets. @@ -119,7 +119,7 @@ Run a command like this:: pip install -U beets -The ``-U`` flag tells `pip `__ to upgrade +The ``-U`` flag tells `pip `__ to upgrade beets to the latest version. If you want a specific version, you can specify with using ``==`` like so:: @@ -163,10 +163,10 @@ on GitHub. `Enter a new issue `__ there to report a bug. Please follow these guidelines when reporting an issue: - Most importantly: if beets is crashing, please `include the - traceback `__. Tracebacks can be more + traceback `__. Tracebacks can be more readable if you put them in a pastebin (e.g., `Gist `__ or - `Hastebin `__), especially when communicating + `Hastebin `__), especially when communicating over IRC or email. - Turn on beets' debug output (using the -v option: for example, ``beet -v import ...``) and include that with your bug report. Look @@ -188,7 +188,7 @@ there to report a bug. Please follow these guidelines when reporting an issue: If you've never reported a bug before, Mozilla has some well-written `general guidelines for good bug -reports `__. +reports `__. .. _find-config: @@ -237,7 +237,7 @@ Why does beets… There are a number of possibilities: - First, make sure the album is in `the MusicBrainz - database `__. You + database `__. You can search on their site to make sure it's cataloged there. (If not, anyone can edit MusicBrainz---so consider adding the data yourself.) - If the album in question is a multi-disc release, see the relevant @@ -320,7 +320,7 @@ it encounters files that *look* like music files (according to their extension) but seem to be broken. Most of the time, this is because the file is corrupted. To check whether the file is intact, try opening it in another media player (e.g., -`VLC `__) to see whether it can +`VLC `__) to see whether it can read the file. You can also use specialized programs for checking file integrity---for example, type ``metaflac --list music.flac`` to check FLAC files. @@ -378,4 +378,4 @@ installed using pip, the command ``pip show -f beets`` can show you where ``beet`` was placed on your system. If you need help extending your ``$PATH``, try `this Super User answer`_. -.. _this Super User answer: http://superuser.com/a/284361/4569 +.. _this Super User answer: https://superuser.com/a/284361/4569 diff --git a/docs/guides/advanced.rst b/docs/guides/advanced.rst index ba7ca12b6..f4f8d3cd9 100644 --- a/docs/guides/advanced.rst +++ b/docs/guides/advanced.rst @@ -93,7 +93,7 @@ everything by the Long Winters for listening on the go. The plugin has many more dials you can fiddle with to get your conversions how you like them. Check out :doc:`its documentation `. -.. _ffmpeg: http://www.ffmpeg.org +.. _ffmpeg: https://www.ffmpeg.org Store any data you like @@ -127,7 +127,7 @@ And, unlike :ref:`built-in fields `, such fields can be removed:: Read more than you ever wanted to know about the *flexible attributes* feature `on the beets blog`_. -.. _on the beets blog: http://beets.io/blog/flexattr.html +.. _on the beets blog: https://beets.io/blog/flexattr.html Choose a path style manually for some music @@ -151,3 +151,55 @@ differently. Put something like this in your configuration file:: Used together, flexible attributes and path format conditions let you sort your music by any criteria you can imagine. + + +Automatically add new music to your library +------------------------------------------- + +As a command-line tool, beets is perfect for automated operation via a cron job +or the like. To use it this way, you might want to use these options in your +:doc:`config file `: + +.. code-block:: yaml + + import: + incremental: yes + quiet: yes + log: /path/to/log.txt + +The :ref:`incremental` option will skip importing any directories that have +been imported in the past. +:ref:`quiet` avoids asking you any questions (since this will be run +automatically, no input is possible). +You might also want to use the :ref:`quiet_fallback` options to configure +what should happen when no near-perfect match is found -- this option depends +on your level of paranoia. +Finally, :ref:`import_log` will make beets record its decisions so you can come +back later and see what you need to handle manually. + +The last step is to set up cron or some other automation system to run +``beet import /path/to/incoming/music``. + + +Useful reports +-------------- + +Since beets has a quite powerful query tool, this list contains some useful and +powerful queries to run on your library. + +* See a list of all albums which have files which are 128 bit rate:: + + beet list bitrate:128000 + +* See a list of all albums with the tracks listed in order of bit rate:: + + beet ls -f '$bitrate $artist - $title' bitrate+ + +* See a list of albums and their formats:: + + beet ls -f '$albumartist $album $format' | sort | uniq + + Note that ``beet ls --album -f '... $format'`` doesn't do what you want, + because ``format`` is an item-level field, not an album-level one. + If an album's tracks exist in multiple formats, the album will appear in the + list once for each format. diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 1c0eb80e0..2f05634d9 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -4,7 +4,7 @@ Getting Started Welcome to `beets`_! This guide will help you begin using it to make your music collection better. -.. _beets: http://beets.io/ +.. _beets: https://beets.io/ Installing ---------- @@ -12,7 +12,7 @@ Installing You will need Python. Beets works on `Python 2.7`_ and Python 3.4 or later. -.. _Python 2.7: http://www.python.org/download/ +.. _Python 2.7: https://www.python.org/download/ * **macOS** v10.7 (Lion) and later include Python 2.7 out of the box. You can opt for Python 3 by installing it via `Homebrew`_: @@ -26,37 +26,37 @@ Beets works on `Python 2.7`_ and Python 3.4 or later. as described below by running: ``apt-get install python-dev python-pip`` -* On **Arch Linux**, `beets is in [community]`_, so just run ``pacman -S - beets``. (There's also a bleeding-edge `dev package`_ in the AUR, which will +* On **Arch Linux**, `beets is in [community] `_, so just run ``pacman -S + beets``. (There's also a bleeding-edge `dev package `_ in the AUR, which will probably set your computer on fire.) * For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run ``emerge beets`` to install. There are several USE flags available for optional plugin dependencies. -* On **FreeBSD**, there's a `beets port`_ at ``audio/beets``. +* On **FreeBSD**, there's a `beets port `_ at ``audio/beets``. -* On **OpenBSD**, beets can be installed with ``pkg_add beets``. +* On **OpenBSD**, there's a `beets port `_ can be installed with ``pkg_add beets``. * For **Slackware**, there's a `SlackBuild`_ available. -* On **Fedora** 22 or later, there is a `DNF package`_ (or three):: +* On **Fedora** 22 or later, there is a `DNF package`_:: $ sudo dnf install beets beets-plugins beets-doc - + * On **Solus**, run ``eopkg install beets``. -* On **NixOS**, run ``nix-env -i beets``. +* On **NixOS**, there's a `package `_ you can install with ``nix-env -i beets``. -.. _copr: https://copr.fedoraproject.org/coprs/afreof/beets/ -.. _dnf package: https://apps.fedoraproject.org/packages/beets -.. _SlackBuild: http://slackbuilds.org/repository/14.1/multimedia/beets/ -.. _beets port: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets -.. _beets from AUR: https://aur.archlinux.org/packages/beets-git/ -.. _dev package: https://aur.archlinux.org/packages/beets-git/ -.. _Debian details: http://packages.qa.debian.org/b/beets.html +.. _DNF package: https://apps.fedoraproject.org/packages/beets +.. _SlackBuild: https://slackbuilds.org/repository/14.2/multimedia/beets/ +.. _FreeBSD: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets +.. _AUR: https://aur.archlinux.org/packages/beets-git/ +.. _Debian details: https://tracker.debian.org/pkg/beets .. _Ubuntu details: https://launchpad.net/ubuntu/+source/beets -.. _beets is in [community]: https://www.archlinux.org/packages/community/any/beets/ +.. _OpenBSD: http://openports.se/audio/beets +.. _Arch community: https://www.archlinux.org/packages/community/any/beets/ +.. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets If you have `pip`_, just say ``pip install beets`` (or ``pip install --user beets`` if you run into permissions problems). @@ -64,14 +64,14 @@ beets`` if you run into permissions problems). To install without pip, download beets from `its PyPI page`_ and run ``python setup.py install`` in the directory therein. -.. _its PyPI page: http://pypi.python.org/pypi/beets#downloads -.. _pip: http://www.pip-installer.org/ +.. _its PyPI page: https://pypi.org/project/beets#downloads +.. _pip: https://pip.pypa.io The best way to upgrade beets to a new version is by running ``pip install -U beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on new versions. -.. _@b33ts: http://twitter.com/b33ts +.. _@b33ts: https://twitter.com/b33ts Installing on macOS 10.11 and Higher ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -87,7 +87,7 @@ If this happens, you can install beets for the current user only by typing ``~/Library/Python/3.6/bin`` to your ``$PATH``. .. _System Integrity Protection: https://support.apple.com/en-us/HT204899 -.. _Homebrew: http://brew.sh +.. _Homebrew: https://brew.sh Installing on Windows ^^^^^^^^^^^^^^^^^^^^^ @@ -122,10 +122,10 @@ Because I don't use Windows myself, I may have missed something. If you have trouble or you have more detail to contribute here, please direct it to `the mailing list`_. -.. _install Python: http://python.org/download/ +.. _install Python: https://python.org/download/ .. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg -.. _install pip: http://www.pip-installer.org/en/latest/installing.html#install-pip -.. _get-pip.py: https://raw.github.com/pypa/pip/master/contrib/get-pip.py +.. _install pip: https://pip.pypa.io/en/stable/installing/ +.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py Configuring @@ -179,7 +179,7 @@ There are approximately six million other configuration options you can set here, including the directory and file naming scheme. See :doc:`/reference/config` for a full reference. -.. _YAML: http://yaml.org/ +.. _YAML: https://yaml.org/ Importing Your Library ---------------------- @@ -300,6 +300,6 @@ import`` gives more specific help about the ``import`` command. Please let me know what you think of beets via `the discussion board`_ or `Twitter`_. -.. _the mailing list: http://groups.google.com/group/beets-users -.. _the discussion board: http://discourse.beets.io -.. _twitter: http://twitter.com/b33ts +.. _the mailing list: https://groups.google.com/group/beets-users +.. _the discussion board: https://discourse.beets.io +.. _twitter: https://twitter.com/b33ts diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index b70857ca5..467d605a4 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -272,7 +272,7 @@ Before you jump into acoustic fingerprinting with both feet, though, give beets a try without it. You may be surprised at how well metadata-based matching works. -.. _Chromaprint: http://acoustid.org/chromaprint +.. _Chromaprint: https://acoustid.org/chromaprint Album Art, Lyrics, Genres and Such ---------------------------------- @@ -292,7 +292,7 @@ sure the album is present in `the MusicBrainz database`_. You can search on their site to make sure it's cataloged there. If not, anyone can edit MusicBrainz---so consider adding the data yourself. -.. _the MusicBrainz database: http://musicbrainz.org/ +.. _the MusicBrainz database: https://musicbrainz.org/ If you think beets is ignoring an album that's listed in MusicBrainz, please `file a bug report`_. @@ -305,5 +305,5 @@ I Hope That Makes Sense If we haven't made the process clear, please post on `the discussion board`_ and we'll try to improve this guide. -.. _the mailing list: http://groups.google.com/group/beets-users -.. _the discussion board: http://discourse.beets.io +.. _the mailing list: https://groups.google.com/group/beets-users +.. _the discussion board: https://discourse.beets.io diff --git a/docs/index.rst b/docs/index.rst index 43ba0526a..4919147ce 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,10 +17,10 @@ Freenode, drop by `the discussion board`_, send email to `the mailing list`_, or `file a bug`_ in the issue tracker. Please let us know where you think this documentation can be improved. -.. _beets: http://beets.io/ -.. _the mailing list: http://groups.google.com/group/beets-users +.. _beets: https://beets.io/ +.. _the mailing list: https://groups.google.com/group/beets-users .. _file a bug: https://github.com/beetbox/beets/issues -.. _the discussion board: http://discourse.beets.io +.. _the discussion board: https://discourse.beets.io Contents -------- diff --git a/docs/plugins/absubmit.rst b/docs/plugins/absubmit.rst index feebc1642..3221a07b3 100644 --- a/docs/plugins/absubmit.rst +++ b/docs/plugins/absubmit.rst @@ -1,15 +1,15 @@ AcousticBrainz Submit Plugin ============================ -The `absubmit` plugin lets you submit acoustic analysis results to the +The ``absubmit`` plugin lets you submit acoustic analysis results to the `AcousticBrainz`_ server. Installation ------------ -The `absubmit` plugin requires the `streaming_extractor_music`_ program to run. Its source can be found on `GitHub`_, and while it is possible to compile the extractor from source, AcousticBrainz would prefer if you used their binary (see the AcousticBrainz `FAQ`_). +The ``absubmit`` plugin requires the `streaming_extractor_music`_ program to run. Its source can be found on `GitHub`_, and while it is possible to compile the extractor from source, AcousticBrainz would prefer if you used their binary (see the AcousticBrainz `FAQ`_). -The `absubmit` also plugin requires `requests`_, which you can install using `pip`_ by typing:: +The ``absubmit`` plugin also requires `requests`_, which you can install using `pip`_ by typing:: pip install requests @@ -41,9 +41,9 @@ To configure the plugin, make a ``absubmit:`` section in your configuration file - **extractor**: The absolute path to the `streaming_extractor_music`_ binary. Default: search for the program in your ``$PATH`` -.. _streaming_extractor_music: http://acousticbrainz.org/download -.. _FAQ: http://acousticbrainz.org/faq -.. _pip: http://www.pip-installer.org/ -.. _requests: http://docs.python-requests.org/en/master/ +.. _streaming_extractor_music: https://acousticbrainz.org/download +.. _FAQ: https://acousticbrainz.org/faq +.. _pip: https://pip.pypa.io +.. _requests: https://docs.python-requests.org/en/master/ .. _github: https://github.com/MTG/essentia .. _AcousticBrainz: https://acousticbrainz.org diff --git a/docs/plugins/acousticbrainz.rst b/docs/plugins/acousticbrainz.rst index 5bd514c64..7c24ffe0d 100644 --- a/docs/plugins/acousticbrainz.rst +++ b/docs/plugins/acousticbrainz.rst @@ -4,7 +4,7 @@ AcousticBrainz Plugin The ``acousticbrainz`` plugin gets acoustic-analysis information from the `AcousticBrainz`_ project. -.. _AcousticBrainz: http://acousticbrainz.org/ +.. _AcousticBrainz: https://acousticbrainz.org/ Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing:: diff --git a/docs/plugins/badfiles.rst b/docs/plugins/badfiles.rst index 0a32f1a36..a59dbd0d1 100644 --- a/docs/plugins/badfiles.rst +++ b/docs/plugins/badfiles.rst @@ -48,11 +48,11 @@ Here is an example where the FLAC decoder signals a corrupt file:: 00.flac: ERROR while decoding data state = FLAC__STREAM_DECODER_READ_FRAME -Note that the default `mp3val` checker is a bit verbose and can output a lot +Note that the default ``mp3val`` checker is a bit verbose and can output a lot of "stream error" messages, even for files that play perfectly well. Generally, if more than one stream error happens, or if a stream error happens in the middle of a file, this is a bad sign. By default, only errors for the bad files will be shown. In order for the -results for all of the checked files to be seen, including the uncorrupted +results for all of the checked files to be seen, including the uncorrupted ones, use the ``-v`` or ``--verbose`` option. diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst index da77cd4cd..709dbb0a8 100644 --- a/docs/plugins/beatport.rst +++ b/docs/plugins/beatport.rst @@ -31,6 +31,6 @@ from MusicBrainz and other sources. If you have a Beatport ID or a URL for a release or track you want to tag, you can just enter one of the two at the "enter Id" prompt in the importer. -.. _requests: http://docs.python-requests.org/en/latest/ +.. _requests: https://docs.python-requests.org/en/latest/ .. _requests_oauthlib: https://github.com/requests/requests-oauthlib -.. _Beatport: http://beatport.com +.. _Beatport: https://beetport.com diff --git a/docs/plugins/bpd.rst b/docs/plugins/bpd.rst index fc22846de..49563a73a 100644 --- a/docs/plugins/bpd.rst +++ b/docs/plugins/bpd.rst @@ -6,7 +6,7 @@ implements the MPD protocol, so it's compatible with all the great MPD clients out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully. .. _Theremin: https://theremin.sigterm.eu/ -.. _gmpc: http://gmpc.wikia.com/wiki/Gnome_Music_Player_Client +.. _gmpc: https://gmpc.wikia.com/wiki/Gnome_Music_Player_Client .. _Sonata: http://sonata.berlios.de/ .. _Ario: http://ario-player.sourceforge.net/ @@ -20,7 +20,7 @@ with its Python bindings) on your system. gst-plugins-base pygobject3``. * On Linux, you need to install GStreamer 1.0 and the GObject bindings for - python. Under Ubuntu, they are called `python-gi` and `gstreamer1.0`. + python. Under Ubuntu, they are called ``python-gi`` and ``gstreamer1.0``. * On Windows, you may want to try `GStreamer WinBuilds`_ (caveat emptor: I haven't tried this). @@ -29,8 +29,8 @@ You will also need the various GStreamer plugin packages to make everything work. See the :doc:`/plugins/chroma` documentation for more information on installing GStreamer plugins. -.. _GStreamer WinBuilds: http://www.gstreamer-winbuild.ylatuya.es/ -.. _Homebrew: http://mxcl.github.com/homebrew/ +.. _GStreamer WinBuilds: https://www.gstreamer-winbuild.ylatuya.es/ +.. _Homebrew: https://brew.sh Usage ----- @@ -44,7 +44,7 @@ Then, you can run BPD by invoking:: Fire up your favorite MPD client to start playing music. The MPD site has `a long list of available clients`_. Here are my favorites: -.. _a long list of available clients: http://mpd.wikia.com/wiki/Clients +.. _a long list of available clients: https://mpd.wikia.com/wiki/Clients * Linux: `gmpc`_, `Sonata`_ @@ -52,9 +52,9 @@ long list of available clients`_. Here are my favorites: * Windows: I don't know. Get in touch if you have a recommendation. -* iPhone/iPod touch: `MPoD`_ +* iPhone/iPod touch: `Rigelian`_ -.. _MPoD: http://www.katoemba.net/makesnosenseatall/mpod/ +.. _Rigelian: https://www.rigelian.net/ One nice thing about MPD's (and thus BPD's) client-server architecture is that the client can just as easily on a different computer from the server as it can @@ -75,6 +75,8 @@ The available options are: Default: No password. - **volume**: Initial volume, as a percentage. Default: 100 +- **control_port**: Port for the internal control socket. + Default: 6601 Here's an example:: @@ -95,40 +97,41 @@ on-disk directory structure can. (Note that an obvious solution to this is just string matching on items' destination, but this requires examining the entire library Python-side for every query.) -We don't currently support versioned playlists. Many clients, however, use -plchanges instead of playlistinfo to get the current playlist, so plchanges -contains a dummy implementation that just calls playlistinfo. +BPD plays music using GStreamer's ``playbin`` player, which has a simple API +but doesn't support many advanced playback features. -The ``stats`` command always send zero for ``playtime``, which is supposed to -indicate the amount of time the server has spent playing music. BPD doesn't -currently keep track of this. +Differences from the real MPD +----------------------------- -The ``update`` command regenerates the directory tree from the beets database. +BPD currently supports version 0.16 of `the MPD protocol`_, but several of the +commands and features are "pretend" implementations or have slightly different +behaviour to their MPD equivalents. BPD aims to look enough like MPD that it +can interact with the ecosystem of clients, but doesn't try to be +a fully-fledged MPD replacement in terms of its playback capabilities. -Unimplemented Commands ----------------------- +.. _the MPD protocol: https://www.musicpd.org/doc/protocol/ -These are the commands from `the MPD protocol`_ that have not yet been -implemented in BPD. +These are some of the known differences between BPD and MPD: -.. _the MPD protocol: http://www.musicpd.org/doc/protocol/ - -Saved playlists: - -* playlistclear -* playlistdelete -* playlistmove -* playlistadd -* playlistsearch -* listplaylist -* listplaylistinfo -* playlistfind -* rm -* save -* load -* rename - -Deprecated: - -* playlist -* volume +* BPD doesn't currently support versioned playlists. Many clients, however, use + plchanges instead of playlistinfo to get the current playlist, so plchanges + contains a dummy implementation that just calls playlistinfo. +* Stored playlists aren't supported (BPD understands the commands though). +* The ``stats`` command always send zero for ``playtime``, which is supposed to + indicate the amount of time the server has spent playing music. BPD doesn't + currently keep track of this. +* The ``update`` command regenerates the directory tree from the beets database + synchronously, whereas MPD does this in the background. +* Advanced playback features like cross-fade, ReplayGain and MixRamp are not + supported due to BPD's simple audio player backend. +* Advanced query syntax is not currently supported. +* Clients can't use the ``tagtypes`` mask to hide fields. +* BPD's ``random`` mode is not deterministic and doesn't support priorities. +* Mounts and streams are not supported. BPD can only play files from disk. +* Stickers are not supported (although this is basically a flexattr in beets + nomenclature so this is feasible to add). +* There is only a single password, and is enabled it grants access to all + features rather than having permissions-based granularity. +* Partitions and alternative outputs are not supported; BPD can only play one + song at a time. +* Client channels are not implemented. diff --git a/docs/plugins/bucket.rst b/docs/plugins/bucket.rst index 99975968f..ee1857777 100644 --- a/docs/plugins/bucket.rst +++ b/docs/plugins/bucket.rst @@ -27,19 +27,19 @@ The ``bucket_year`` parameter is used for all substitutions occurring on the The definition of a range is somewhat loose, and multiple formats are allowed: - For alpha ranges: the range is defined by the lowest and highest (ASCII-wise) - alphanumeric characters in the string you provide. For example, *ABCD*, - *A-D*, *A->D*, and *[AD]* are all equivalent. + alphanumeric characters in the string you provide. For example, ``ABCD``, + ``A-D``, ``A->D``, and ``[AD]`` are all equivalent. - For year ranges: digits characters are extracted and the two extreme years - define the range. For example, *1975-77*, *1975,76,77* and *1975-1977* are + define the range. For example, ``1975-77``, ``1975,76,77`` and ``1975-1977`` are equivalent. If no upper bound is given, the range is extended to current year - (unless a later range is defined). For example, *1975* encompasses all years + (unless a later range is defined). For example, ``1975`` encompasses all years from 1975 until now. -The `%bucket` template function guesses whether to use alpha- or year-style +The ``%bucket`` template function guesses whether to use alpha- or year-style buckets depending on the text it receives. It can guess wrong if, for example, -an artist or album happens to begin with four digits. Provide `alpha` as the +an artist or album happens to begin with four digits. Provide ``alpha`` as the second argument to the template to avoid this automatic detection: for -example, use `%bucket{$artist,alpha}`. +example, use ``%bucket{$artist,alpha}``. Configuration @@ -56,7 +56,7 @@ The available options are: overrides original range definition. Default: none. - **bucket_year**: Ranges to use for all substitutions occurring on the - `$year` field. + ``$year`` field. Default: none. - **extrapolate**: Enable this if you want to group your files into multiple year ranges without enumerating them all. This option will generate year @@ -73,5 +73,5 @@ Here's an example:: 'A - D': ^[0-9a-dA-D…äÄ] This configuration creates five-year ranges for any input year. -The *A - D* bucket now matches also all artists starting with ä or Ä and 0 to 9 +The `A - D` bucket now matches also all artists starting with ä or Ä and 0 to 9 and … (ellipsis). The other alpha buckets work as ranges. diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst index 617d8cf69..1b86073b8 100644 --- a/docs/plugins/chroma.rst +++ b/docs/plugins/chroma.rst @@ -8,8 +8,8 @@ information at all (or have completely incorrect data). This plugin uses an open-source fingerprinting technology called `Chromaprint`_ and its associated Web service, called `Acoustid`_. -.. _Chromaprint: http://acoustid.org/chromaprint -.. _acoustid: http://acoustid.org/ +.. _Chromaprint: https://acoustid.org/chromaprint +.. _acoustid: https://acoustid.org/ Turning on fingerprinting can increase the accuracy of the autotagger---especially on files with very poor metadata---but it comes at a @@ -31,7 +31,7 @@ First, install pyacoustid itself. You can do this using `pip`_, like so:: $ pip install pyacoustid -.. _pip: http://www.pip-installer.org/ +.. _pip: https://pip.pypa.io Then, you will need to install `Chromaprint`_, either as a dynamic library or in the form of a command-line tool (``fpcalc``). @@ -45,7 +45,7 @@ The simplest way to get up and running, especially on Windows, is to means something like ``C:\\Program Files``. On OS X or Linux, put the executable somewhere like ``/usr/local/bin``. -.. _download: http://acoustid.org/chromaprint +.. _download: https://acoustid.org/chromaprint Installing the Library '''''''''''''''''''''' @@ -56,7 +56,7 @@ site has links to packages for major Linux distributions. If you use `Homebrew`_ on Mac OS X, you can install the library with ``brew install chromaprint``. -.. _Homebrew: http://mxcl.github.com/homebrew/ +.. _Homebrew: https://brew.sh/ You will also need a mechanism for decoding audio files supported by the `audioread`_ library: @@ -78,12 +78,12 @@ You will also need a mechanism for decoding audio files supported by the * On Windows, builds are provided by `GStreamer`_ .. _audioread: https://github.com/beetbox/audioread -.. _pyacoustid: http://github.com/beetbox/pyacoustid -.. _FFmpeg: http://ffmpeg.org/ -.. _MAD: http://spacepants.org/src/pymad/ -.. _pymad: http://www.underbit.com/products/mad/ -.. _Core Audio: http://developer.apple.com/technologies/mac/audio-and-video.html -.. _Gstreamer: http://gstreamer.freedesktop.org/ +.. _pyacoustid: https://github.com/beetbox/pyacoustid +.. _FFmpeg: https://ffmpeg.org/ +.. _MAD: https://spacepants.org/src/pymad/ +.. _pymad: https://www.underbit.com/products/mad/ +.. _Core Audio: https://developer.apple.com/technologies/mac/audio-and-video.html +.. _Gstreamer: https://gstreamer.freedesktop.org/ .. _PyGObject: https://wiki.gnome.org/Projects/PyGObject To decode audio formats (MP3, FLAC, etc.) with GStreamer, you'll need the @@ -132,4 +132,4 @@ Then, run ``beet submit``. (You can also provide a query to submit a subset of your library.) The command will use stored fingerprints if they're available; otherwise it will fingerprint each file before submitting it. -.. _get an API key: http://acoustid.org/api-key +.. _get an API key: https://acoustid.org/api-key diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index a631f7891..59670c269 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -14,7 +14,7 @@ To use the ``convert`` plugin, first enable it in your configuration (see :ref:`using-plugins`). By default, the plugin depends on `FFmpeg`_ to transcode the audio, so you might want to install it. -.. _FFmpeg: http://ffmpeg.org +.. _FFmpeg: https://ffmpeg.org Usage @@ -68,6 +68,8 @@ file. The available options are: - **dest**: The directory where the files will be converted (or copied) to. Default: none. - **embed**: Embed album art in converted items. Default: ``yes``. +- **id3v23**: Can be used to override the global ``id3v23`` option. Default: + ``inherit``. - **max_bitrate**: All lossy files with a higher bitrate will be transcoded and those with a lower bitrate will simply be copied. Note that this does not guarantee that all converted files will have a lower @@ -120,7 +122,7 @@ and select a command with the ``--format`` command-line option or the In this example ``beet convert`` will use the *speex* command by default. To convert the audio to `wav`, run ``beet convert -f wav``. -This will also use the format key (`wav`) as the file extension. +This will also use the format key (``wav``) as the file extension. Each entry in the ``formats`` map consists of a key (the name of the format) as well as the command and optionally the file extension. @@ -168,6 +170,6 @@ can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME options and a thorough discussion of MP3 encoding. .. _documentation: http://lame.sourceforge.net/using.php -.. _HydrogenAudio wiki: http://wiki.hydrogenaud.io/index.php?title=LAME -.. _gapless: http://wiki.hydrogenaud.io/index.php?title=Gapless_playback -.. _LAME: http://lame.sourceforge.net/ +.. _HydrogenAudio wiki: https://wiki.hydrogenaud.io/index.php?title=LAME +.. _gapless: https://wiki.hydrogenaud.io/index.php?title=Gapless_playback +.. _LAME: https://lame.sourceforge.net/ diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index a02b34590..622a085b4 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -4,7 +4,7 @@ Discogs Plugin The ``discogs`` plugin extends the autotagger's search capabilities to include matches from the `Discogs`_ database. -.. _Discogs: http://discogs.com +.. _Discogs: https://discogs.com Installation ------------ diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index 2a34a59e8..cc2fe6fc8 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -69,7 +69,7 @@ Note: ``compare_threshold`` option requires `ImageMagick`_, and ``maxwidth`` requires either `ImageMagick`_ or `Pillow`_. .. _Pillow: https://github.com/python-pillow/Pillow -.. _ImageMagick: http://www.imagemagick.org/ +.. _ImageMagick: https://www.imagemagick.org/ .. _PHASH: http://www.fmwconcepts.com/misc_tests/perceptual_hash_test_results_510/ Manually Embedding and Extracting Art diff --git a/docs/plugins/embyupdate.rst b/docs/plugins/embyupdate.rst index 00373b98c..626fafa9d 100644 --- a/docs/plugins/embyupdate.rst +++ b/docs/plugins/embyupdate.rst @@ -17,8 +17,8 @@ To use the ``embyupdate`` plugin you need to install the `requests`_ library wit With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library. -.. _Emby: http://emby.media/ -.. _requests: http://docs.python-requests.org/en/latest/ +.. _Emby: https://emby.media/ +.. _requests: https://docs.python-requests.org/en/latest/ Configuration ------------- @@ -34,5 +34,5 @@ The available options under the ``emby:`` section are: - **password**: The password for the user. (This is only necessary if no API key is provided.) -You can choose to authenticate either with `apikey` or `password`, but only +You can choose to authenticate either with ``apikey`` or ``password``, but only one of those two is required. diff --git a/docs/plugins/export.rst b/docs/plugins/export.rst index 4192f00c5..d712dfc8b 100644 --- a/docs/plugins/export.rst +++ b/docs/plugins/export.rst @@ -4,7 +4,7 @@ Export Plugin The ``export`` plugin lets you get data from the items and export the content as `JSON`_. -.. _JSON: http://www.json.org +.. _JSON: https://www.json.org Enable the ``export`` plugin (see :ref:`using-plugins` for help). Then, type ``beet export`` followed by a :doc:`query ` to get the data from your library. For example, run this:: @@ -42,7 +42,7 @@ Configuration To configure the plugin, make a ``export:`` section in your configuration file. Under the ``json`` key, these options are available: -- **ensure_ascii**: Escape non-ASCII characters with `\uXXXX` entities. +- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities. - **indent**: The number of spaces for indentation. diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 002471ec1..f23fec765 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -11,7 +11,7 @@ To use the ``fetchart`` plugin, first enable it in your configuration (see The plugin uses `requests`_ to fetch album art from the Web. -.. _requests: http://docs.python-requests.org/en/latest/ +.. _requests: https://docs.python-requests.org/en/latest/ Fetching Album Art During Import -------------------------------- @@ -73,18 +73,18 @@ or `Pillow`_. .. note:: - Previously, there was a `remote_priority` option to specify when to + Previously, there was a ``remote_priority`` option to specify when to look for art on the filesystem. This is still respected, but a deprecation message will be shown until you - replace this configuration with the new `filesystem` value in the - `sources` array. + replace this configuration with the new ``filesystem`` value in the + ``sources`` array. .. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm .. _Pillow: https://github.com/python-pillow/Pillow -.. _ImageMagick: http://www.imagemagick.org/ +.. _ImageMagick: https://www.imagemagick.org/ -Here's an example that makes plugin select only images that contain *front* or -*back* keywords in their filenames and prioritizes the iTunes source over +Here's an example that makes plugin select only images that contain ``front`` or +``back`` keywords in their filenames and prioritizes the iTunes source over others:: fetchart: @@ -135,7 +135,7 @@ On some versions of Windows, the program can be shadowed by a system-provided environment variable so that ImageMagick comes first or use Pillow instead. .. _Pillow: https://github.com/python-pillow/Pillow -.. _ImageMagick: http://www.imagemagick.org/ +.. _ImageMagick: https://www.imagemagick.org/ .. _album-art-sources: @@ -191,7 +191,7 @@ Optionally, you can `define a custom search engine`_. Get your search engine's token and use it for your ``google_engine`` configuration option. The default engine searches the entire web for cover art. -.. _define a custom search engine: http://www.google.com/cse/all +.. _define a custom search engine: https://www.google.com/cse/all Note that the Google custom search API is limited to 100 queries per day. After that, the fetchart plugin will fall back on other declared data sources. diff --git a/docs/plugins/freedesktop.rst b/docs/plugins/freedesktop.rst index 61943718e..0368cc5da 100644 --- a/docs/plugins/freedesktop.rst +++ b/docs/plugins/freedesktop.rst @@ -3,4 +3,4 @@ Freedesktop Plugin The ``freedesktop`` plugin created .directory files in your album folders. This plugin is now deprecated and replaced by the :doc:`/plugins/thumbnails` -with the `dolphin` option enabled. +with the ``dolphin`` option enabled. diff --git a/docs/plugins/ftintitle.rst b/docs/plugins/ftintitle.rst index 8a080b3e2..66c9ecd69 100644 --- a/docs/plugins/ftintitle.rst +++ b/docs/plugins/ftintitle.rst @@ -41,4 +41,4 @@ your entire collection. Use the ``-d`` flag to remove featured artists (equivalent of the ``drop`` config option). -.. _MusicBrainz style: http://musicbrainz.org/doc/Style +.. _MusicBrainz style: https://musicbrainz.org/doc/Style diff --git a/docs/plugins/gmusic.rst b/docs/plugins/gmusic.rst index a4f4c8e05..94ee2dae4 100644 --- a/docs/plugins/gmusic.rst +++ b/docs/plugins/gmusic.rst @@ -8,7 +8,7 @@ songs in your library. Installation ------------ -The plugin requires `gmusicapi`_. You can install it using `pip`:: +The plugin requires :pypi:`gmusicapi`. You can install it using ``pip``:: pip install gmusicapi diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 6bf50e227..1c8a8d417 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -13,11 +13,11 @@ Using Plugins ------------- To use one of the plugins included with beets (see the rest of this page for a -list), just use the `plugins` option in your :doc:`config.yaml ` file, like so:: +list), just use the ``plugins`` option in your :doc:`config.yaml ` file, like so:: plugins: inline convert web -The value for `plugins` can be a space-separated list of plugin names or a +The value for ``plugins`` can be a space-separated list of plugin names or a YAML list like ``[foo, bar]``. You can see which plugins are currently enabled by typing ``beet version``. @@ -30,7 +30,7 @@ Each plugin has its own set of options that can be defined in a section bearing Some plugins have special dependencies that you'll need to install. The documentation page for each plugin will list them in the setup instructions. -For some, you can use `pip`'s "extras" feature to install the dependencies, +For some, you can use ``pip``'s "extras" feature to install the dependencies, like this:: pip install beets[fetchart,lyrics,lastgenre] @@ -71,6 +71,7 @@ like this:: kodiupdate lastgenre lastimport + loadext lyrics mbcollection mbsubmit @@ -81,6 +82,7 @@ like this:: mpdupdate permissions play + playlist plexupdate random replaygain @@ -105,7 +107,7 @@ Autotagger Extensions * :doc:`fromfilename`: Guess metadata for untagged tracks from their filenames. -.. _Discogs: http://www.discogs.com/ +.. _Discogs: https://www.discogs.com/ Metadata -------- @@ -134,7 +136,7 @@ Metadata * :doc:`zero`: Nullify fields by pattern or unconditionally. .. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/ -.. _streaming_extractor_music: http://acousticbrainz.org/download +.. _streaming_extractor_music: https://acousticbrainz.org/download Path Formats ------------ @@ -158,6 +160,7 @@ Interoperability * :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library changes. * :doc:`play`: Play beets queries in your music player. +* :doc:`playlist`: Use M3U playlists to query the beets library. * :doc:`plexupdate`: Automatically notifies `Plex`_ whenever the beets library changes. * :doc:`smartplaylist`: Generate smart playlists based on beets queries. @@ -166,10 +169,10 @@ Interoperability * :doc:`thumbnails`: Get thumbnails with the cover art on your album folders. -.. _Emby: http://emby.media -.. _Plex: http://plex.tv -.. _Kodi: http://kodi.tv -.. _Sonos: http://sonos.com +.. _Emby: https://emby.media +.. _Plex: https://plex.tv +.. _Kodi: https://kodi.tv +.. _Sonos: https://sonos.com Miscellaneous ------------- @@ -187,17 +190,18 @@ Miscellaneous * :doc:`hook`: Run a command when an event is emitted by beets. * :doc:`ihate`: Automatically skip albums and tracks during the import process. * :doc:`info`: Print music files' tags to the console. +* :doc:`loadext`: Load SQLite extensions. * :doc:`mbcollection`: Maintain your MusicBrainz collection list. * :doc:`mbsubmit`: Print an album's tracks in a MusicBrainz-friendly format. * :doc:`missing`: List missing tracks. -* `mstream`_: A music streaming server + webapp that can be used alongside beets. +* `mstream`_: A music streaming server + webapp that can be used alongside beets. * :doc:`random`: Randomly choose albums and tracks from your library. * :doc:`spotify`: Create Spotify playlists from the Beets library. * :doc:`types`: Declare types for flexible attributes. * :doc:`web`: An experimental Web-based GUI for beets. -.. _MPD: http://www.musicpd.org/ -.. _MPD clients: http://mpd.wikia.com/wiki/Clients +.. _MPD: https://www.musicpd.org/ +.. _MPD clients: https://mpd.wikia.com/wiki/Clients .. _mstream: https://github.com/IrosTheBeggar/mStream .. _other-plugins: @@ -209,14 +213,14 @@ In addition to the plugins that come with beets, there are several plugins that are maintained by the beets community. To use an external plugin, there are two options for installation: -* Make sure it's in the Python path (known as `sys.path` to developers). This +* Make sure it's in the Python path (known as ``sys.path`` to developers). This just means the plugin has to be installed on your system (e.g., with a - `setup.py` script or a command like `pip` or `easy_install`). + ``setup.py`` script or a command like ``pip`` or ``easy_install``). -* Set the `pluginpath` config variable to point to the directory containing the +* Set the ``pluginpath`` config variable to point to the directory containing the plugin. (See :doc:`/reference/config`.) -Once the plugin is installed, enable it by placing its name on the `plugins` +Once the plugin is installed, enable it by placing its name on the ``plugins`` line in your config file. Here are a few of the plugins written by the beets community: @@ -254,6 +258,11 @@ Here are a few of the plugins written by the beets community: * `beets-barcode`_ lets you scan or enter barcodes for physical media to search for their metadata. +* `beets-ydl`_ downloads audio from youtube-dl sources and import into beets. + +* `beet-summarize`_ can compute lots of counts and statistics about your music + library. + .. _beets-barcode: https://github.com/8h2a/beets-barcode .. _beets-check: https://github.com/geigerzaehler/beets-check .. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts @@ -273,3 +282,5 @@ Here are a few of the plugins written by the beets community: .. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets .. _beets-usertag: https://github.com/igordertigor/beets-usertag .. _beets-popularity: https://github.com/abba23/beets-popularity +.. _beets-ydl: https://github.com/vmassuchetto/beets-ydl +.. _beet-summarize: https://github.com/steven-murray/beet-summarize diff --git a/docs/plugins/info.rst b/docs/plugins/info.rst index 238a957ff..d628fb4ad 100644 --- a/docs/plugins/info.rst +++ b/docs/plugins/info.rst @@ -42,4 +42,4 @@ Additional command-line options include: * ``--keys-only`` or ``-k``: Show the name of the tags without the values. .. _id3v2: http://id3v2.sourceforge.net -.. _mp3info: http://www.ibiblio.org/mp3info/ +.. _mp3info: https://www.ibiblio.org/mp3info/ diff --git a/docs/plugins/ipfs.rst b/docs/plugins/ipfs.rst index 141143ae7..5bf8ca906 100644 --- a/docs/plugins/ipfs.rst +++ b/docs/plugins/ipfs.rst @@ -4,7 +4,7 @@ IPFS Plugin The ``ipfs`` plugin makes it easy to share your library and music with friends. The plugin uses `ipfs`_ for storing the library and file content. -.. _ipfs: http://ipfs.io/ +.. _ipfs: https://ipfs.io/ Installation ------------ diff --git a/docs/plugins/keyfinder.rst b/docs/plugins/keyfinder.rst index 856939ecc..878830f29 100644 --- a/docs/plugins/keyfinder.rst +++ b/docs/plugins/keyfinder.rst @@ -29,4 +29,4 @@ configuration file. The available options are: `initial_key` value. Default: ``no``. -.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/ +.. _KeyFinder: https://www.ibrahimshaath.co.uk/keyfinder/ diff --git a/docs/plugins/kodiupdate.rst b/docs/plugins/kodiupdate.rst index a1ec04775..e60f503f2 100644 --- a/docs/plugins/kodiupdate.rst +++ b/docs/plugins/kodiupdate.rst @@ -26,8 +26,8 @@ In Kodi's interface, navigate to System/Settings/Network/Services and choose "Al With that all in place, you'll see beets send the "update" command to your Kodi host every time you change your beets library. -.. _Kodi: http://kodi.tv/ -.. _requests: http://docs.python-requests.org/en/latest/ +.. _Kodi: https://kodi.tv/ +.. _requests: https://docs.python-requests.org/en/latest/ Configuration ------------- diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 5e3235bd7..5fcdd2254 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -7,8 +7,8 @@ importing and autotagging music, beets does not assign a genre. The to your albums and items. .. _does not contain genre information: - http://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F -.. _Last.fm: http://last.fm/ + https://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F +.. _Last.fm: https://last.fm/ Installation ------------ @@ -34,7 +34,7 @@ The genre list file should contain one genre per line. Blank lines are ignored. For the curious, the default genre list is generated by a `script that scrapes Wikipedia`_. -.. _pip: http://www.pip-installer.org/ +.. _pip: https://pip.pypa.io .. _pylast: https://github.com/pylast/pylast .. _script that scrapes Wikipedia: https://gist.github.com/1241307 .. _internal whitelist: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres.txt @@ -72,7 +72,7 @@ nothing would ever be matched to a more generic node since all the specific subgenres are in the whitelist to begin with. -.. _YAML: http://www.yaml.org/ +.. _YAML: https://www.yaml.org/ .. _tree of nested genre names: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres-tree.yaml @@ -155,7 +155,11 @@ Running Manually In addition to running automatically on import, the plugin can also be run manually from the command line. Use the command ``beet lastgenre [QUERY]`` to fetch -genres for albums matching a certain query. +genres for albums or items matching a certain query. + +By default, ``beet lastgenre`` matches albums. To match +individual tracks or singletons, use the ``-A`` switch: +``beet lastgenre -A [QUERY]``. To disable automatic genre fetching on import, set the ``auto`` config option to false. diff --git a/docs/plugins/lastimport.rst b/docs/plugins/lastimport.rst index 8006d6bbb..1c12b8616 100644 --- a/docs/plugins/lastimport.rst +++ b/docs/plugins/lastimport.rst @@ -6,7 +6,7 @@ library into beets' database. You can later create :doc:`smart playlists ` by querying ``play_count`` and do other fun stuff with this field. -.. _Last.fm: http://last.fm +.. _Last.fm: https://last.fm Installation ------------ @@ -23,7 +23,7 @@ Next, add your Last.fm username to your beets configuration file:: lastfm: user: beetsfanatic -.. _pip: http://www.pip-installer.org/ +.. _pip: https://pip.pypa.io .. _pylast: https://github.com/pylast/pylast Importing Play Counts diff --git a/docs/plugins/loadext.rst b/docs/plugins/loadext.rst new file mode 100644 index 000000000..5acd10ec7 --- /dev/null +++ b/docs/plugins/loadext.rst @@ -0,0 +1,53 @@ +Load Extension Plugin +===================== + +Beets uses an SQLite database to store and query library information, which +has support for extensions to extend its functionality. The ``loadext`` plugin +lets you enable these SQLite extensions within beets. + +One of the primary uses of this within beets is with the `"ICU" extension`_, +which adds support for case insensitive querying of non-ASCII characters. + +.. _"ICU" extension: https://www.sqlite.org/src/dir?ci=7461d2e120f21493&name=ext/icu + +Configuration +------------- + +To configure the plugin, make a ``loadext`` section in your configuration +file. The section must consist of a list of paths to extensions to load, which +looks like this: + +.. code-block:: yaml + + loadext: + - libicu + +If a relative path is specified, it is resolved relative to the beets +configuration directory. + +If no file extension is specified, the default dynamic library extension for +the current platform will be used. + +Building the ICU extension +-------------------------- +This section is for **advanced** users only, and is not an in-depth guide on +building the extension. + +To compile the ICU extension, you will need a few dependencies: + + - gcc + - icu-devtools + - libicu + - libicu-dev + - libsqlite3-dev + +Here's roughly how to download, build and install the extension (although the +specifics may vary from system to system): + +.. code-block:: shell + + $ wget https://sqlite.org/2019/sqlite-src-3280000.zip + $ unzip sqlite-src-3280000.zip + $ cd sqlite-src-3280000/ext/icu + $ gcc -shared -fPIC icu.c `icu-config --ldflags` -o libicu.so + $ cp libicu.so ~/.config/beets diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst index 4131c85d9..fac07ad87 100644 --- a/docs/plugins/lyrics.rst +++ b/docs/plugins/lyrics.rst @@ -5,9 +5,9 @@ The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web. Namely, the current version of the plugin uses `Lyric Wiki`_, `Musixmatch`_, `Genius.com`_, and, optionally, the Google custom search API. -.. _Lyric Wiki: http://lyrics.wikia.com/ +.. _Lyric Wiki: https://lyrics.wikia.com/ .. _Musixmatch: https://www.musixmatch.com/ -.. _Genius.com: http://genius.com/ +.. _Genius.com: https://genius.com/ Fetch Lyrics During Import @@ -26,7 +26,7 @@ already have them. The lyrics will be stored in the beets database. If the ``import.write`` config option is on, then the lyrics will also be written to the files' tags. -.. _requests: http://docs.python-requests.org/en/latest/ +.. _requests: https://docs.python-requests.org/en/latest/ Configuration @@ -60,9 +60,9 @@ configuration file. The available options are: - **sources**: List of sources to search for lyrics. An asterisk ``*`` expands to all available sources. Default: ``google lyricwiki musixmatch genius``, i.e., all the - available sources. The `google` source will be automatically + available sources. The ``google`` source will be automatically deactivated if no ``google_API_key`` is setup. - Both it and the `genius` source will only be enabled if BeautifulSoup is + Both it and the ``genius`` source will only be enabled if BeautifulSoup is installed. Here's an example of ``config.yaml``:: @@ -105,11 +105,11 @@ A minimal ``conf.py`` and ``index.rst`` files are created the first time the command is run. They are not overwritten on subsequent runs, so you can safely modify these files to customize the output. -.. _Sphinx: http://www.sphinx-doc.org/ +.. _Sphinx: https://www.sphinx-doc.org/ .. _reStructuredText: http://docutils.sourceforge.net/rst.html Sphinx supports various `builders -`_, but here are a +`_, but here are a few suggestions. * Build an HTML version:: @@ -148,13 +148,13 @@ Optionally, you can `define a custom search engine`_. Get your search engine's token and use it for your ``google_engine_ID`` configuration option. By default, beets use a list of sources known to be scrapeable. -.. _define a custom search engine: http://www.google.com/cse/all +.. _define a custom search engine: https://www.google.com/cse/all Note that the Google custom search API is limited to 100 queries per day. After that, the lyrics plugin will fall back on other declared data sources. -.. _pip: http://www.pip-installer.org/ -.. _BeautifulSoup: http://www.crummy.com/software/BeautifulSoup/bs4/doc/ +.. _pip: https://pip.pypa.io +.. _BeautifulSoup: https://www.crummy.com/software/BeautifulSoup/bs4/doc/ Activate Genius Lyrics ---------------------- diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst index 803d34904..113855bce 100644 --- a/docs/plugins/mbcollection.rst +++ b/docs/plugins/mbcollection.rst @@ -4,7 +4,7 @@ MusicBrainz Collection Plugin The ``mbcollection`` plugin lets you submit your catalog to MusicBrainz to maintain your `music collection`_ list there. -.. _music collection: http://musicbrainz.org/doc/Collections +.. _music collection: https://musicbrainz.org/doc/Collections To begin, just enable the ``mbcollection`` plugin in your configuration (see :ref:`using-plugins`). diff --git a/docs/plugins/mbsubmit.rst b/docs/plugins/mbsubmit.rst index 5c13375ba..70e14662d 100644 --- a/docs/plugins/mbsubmit.rst +++ b/docs/plugins/mbsubmit.rst @@ -5,7 +5,7 @@ The ``mbsubmit`` plugin provides an extra prompt choice during an import session that prints the tracks of the current album in a format that is parseable by MusicBrainz's `track parser`_. -.. _track parser: http://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings +.. _track parser: https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings Usage ----- @@ -15,7 +15,7 @@ and select the ``Print tracks`` choice which is by default displayed when no strong recommendations are found for the album:: No matching release found for 3 tracks. - For help, see: http://beets.readthedocs.org/en/latest/faq.html#nomatch + For help, see: https://beets.readthedocs.org/en/latest/faq.html#nomatch [U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort, Print tracks? p 01. An Obscure Track - An Obscure Artist (3:37) @@ -23,7 +23,7 @@ strong recommendations are found for the album:: 03. The Third Track - Another Obscure Artist (3:02) No matching release found for 3 tracks. - For help, see: http://beets.readthedocs.org/en/latest/faq.html#nomatch + For help, see: https://beets.readthedocs.org/en/latest/faq.html#nomatch [U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort, Print tracks? diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst index a7633a500..1c8663dca 100644 --- a/docs/plugins/mbsync.rst +++ b/docs/plugins/mbsync.rst @@ -30,7 +30,7 @@ The command has a few command-line options: * By default, files will be moved (renamed) according to their metadata if they are inside your beets library directory. To disable this, use the ``-M`` (``--nomove``) command-line option. -* If you have the `import.write` configuration option enabled, then this +* If you have the ``import.write`` configuration option enabled, then this plugin will write new metadata to files' tags. To disable this, use the ``-W`` (``--nowrite``) option. * To customize the output of unrecognized items, use the ``-f`` diff --git a/docs/plugins/metasync.rst b/docs/plugins/metasync.rst index 6703d3c19..691550595 100644 --- a/docs/plugins/metasync.rst +++ b/docs/plugins/metasync.rst @@ -22,7 +22,7 @@ Enable the ``metasync`` plugin in your configuration (see To synchronize with Amarok, you'll need the `dbus-python`_ library. There are packages for most major Linux distributions. -.. _dbus-python: http://dbus.freedesktop.org/releases/dbus-python/ +.. _dbus-python: https://dbus.freedesktop.org/releases/dbus-python/ Configuration diff --git a/docs/plugins/mpdstats.rst b/docs/plugins/mpdstats.rst index 5472e7294..b769e7468 100644 --- a/docs/plugins/mpdstats.rst +++ b/docs/plugins/mpdstats.rst @@ -9,7 +9,7 @@ habits from `MPD`_. It collects the following information about tracks: * last_played: UNIX timestamp when you last played this track. * rating: A rating based on *play_count* and *skip_count*. -.. _MPD: http://www.musicpd.org/ +.. _MPD: https://www.musicpd.org/ Installing Dependencies ----------------------- @@ -23,7 +23,7 @@ Install the library from `pip`_, like so:: Add the ``mpdstats`` plugin to your configuration (see :ref:`using-plugins`). -.. _pip: http://www.pip-installer.org/ +.. _pip: https://pip.pypa.io Usage ----- @@ -42,7 +42,8 @@ configuration file. The available options are: Default: The ``$MPD_HOST`` environment variable if set, falling back to ``localhost`` otherwise. - **port**: The MPD server port. - Default: 6600. + Default: The ``$MPD_PORT`` environment variable if set, + falling back to 6600 otherwise. - **password**: The MPD server password. Default: None. - **music_directory**: If your MPD library is at a different location from the diff --git a/docs/plugins/mpdupdate.rst b/docs/plugins/mpdupdate.rst index c846b917f..01a6a9fe7 100644 --- a/docs/plugins/mpdupdate.rst +++ b/docs/plugins/mpdupdate.rst @@ -4,7 +4,7 @@ MPDUpdate Plugin ``mpdupdate`` is a very simple plugin for beets that lets you automatically update `MPD`_'s index whenever you change your beets library. -.. _MPD: http://www.musicpd.org/ +.. _MPD: https://www.musicpd.org/ To use ``mpdupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). @@ -33,6 +33,7 @@ The available options under the ``mpd:`` section are: - **host**: The MPD server name. Default: The ``$MPD_HOST`` environment variable if set, falling back to ``localhost`` otherwise. - **port**: The MPD server port. - Default: 6600. + Default: The ``$MPD_PORT`` environment variable if set, falling back to 6600 + otherwise. - **password**: The MPD server password. Default: None. diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index 86920c9ac..d72ec4e0d 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -32,8 +32,8 @@ command-line oriented, and you'll get its output in real time. Interactive Usage ----------------- -The `play` plugin can also be invoked during an import. If enabled, the plugin -adds a `plaY` option to the prompt, so pressing `y` will execute the configured +The ``play`` plugin can also be invoked during an import. If enabled, the plugin +adds a ``plaY`` option to the prompt, so pressing ``y`` will execute the configured command and play the items currently being imported. Once the configured command exits, you will be returned to the import diff --git a/docs/plugins/playlist.rst b/docs/plugins/playlist.rst new file mode 100644 index 000000000..3622581db --- /dev/null +++ b/docs/plugins/playlist.rst @@ -0,0 +1,47 @@ +Playlist Plugin +=============== + +``playlist`` is a plugin to use playlists in m3u format. + +To use it, enable the ``playlist`` plugin in your configuration +(see :ref:`using-plugins`). +Then configure your playlists like this:: + + playlist: + auto: no + relative_to: ~/Music + playlist_dir: ~/.mpd/playlists + +It is possible to query the library based on a playlist by speicifying its +absolute path:: + + $ beet ls playlist:/path/to/someplaylist.m3u + +The plugin also supports referencing playlists by name. The playlist is then +seached in the playlist_dir and the ".m3u" extension is appended to the +name:: + + $ beet ls playlist:anotherplaylist + +The plugin can also update playlists in the playlist directory automatically +every time an item is moved or deleted. This can be controlled by the ``auto`` +configuration option. + +Configuration +------------- + +To configure the plugin, make a ``smartplaylist:`` section in your +configuration file. In addition to the ``playlists`` described above, the +other configuration options are: + +- **auto**: If this is set to ``yes``, then anytime an item in the library is + moved or removed, the plugin will update all playlists in the + ``playlist_dir`` directory that contain that item to reflect the change. + Default: ``no`` +- **playlist_dir**: Where to read playlist files from. + Default: The current working directory (i.e., ``'.'``). +- **relative_to**: Interpret paths in the playlist files relative to a base + directory. Instead of setting it to a fixed path, it is also possible to + set it to ``playlist`` to use the playlist's parent directory or to + ``library`` to use the library directory. + Default: ``library`` diff --git a/docs/plugins/plexupdate.rst b/docs/plugins/plexupdate.rst index 4ac047660..f9312280a 100644 --- a/docs/plugins/plexupdate.rst +++ b/docs/plugins/plexupdate.rst @@ -21,11 +21,11 @@ To use the ``plexupdate`` plugin you need to install the `requests`_ library wit pip install requests -With that all in place, you'll see beets send the "update" command to your Plex +With that all in place, you'll see beets send the "update" command to your Plex server every time you change your beets library. -.. _Plex: http://plex.tv/ -.. _requests: http://docs.python-requests.org/en/latest/ +.. _Plex: https://plex.tv/ +.. _requests: https://docs.python-requests.org/en/latest/ .. _documentation about tokens: https://support.plex.tv/hc/en-us/articles/204059436-Finding-your-account-token-X-Plex-Token Configuration diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 3f1667c8f..57630f1d6 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -4,7 +4,7 @@ ReplayGain Plugin This plugin adds support for `ReplayGain`_, a technique for normalizing audio playback levels. -.. _ReplayGain: http://wiki.hydrogenaudio.org/index.php?title=ReplayGain +.. _ReplayGain: https://wiki.hydrogenaudio.org/index.php?title=ReplayGain Installation @@ -24,10 +24,10 @@ GStreamer To use `GStreamer`_ for ReplayGain analysis, you will of course need to install GStreamer and plugins for compatibility with your audio files. -You will need at least GStreamer 1.0 and `PyGObject 3.x`_ (a.k.a. python-gi). +You will need at least GStreamer 1.0 and `PyGObject 3.x`_ (a.k.a. ``python-gi``). -.. _PyGObject 3.x: https://wiki.gnome.org/action/show/Projects/PyGObject -.. _GStreamer: http://gstreamer.freedesktop.org/ +.. _PyGObject 3.x: https://pygobject.readthedocs.io/en/latest/ +.. _GStreamer: https://gstreamer.freedesktop.org/ Then, enable the ``replaygain`` plugin (see :ref:`using-plugins`) and specify the GStreamer backend by adding this to your configuration file:: @@ -47,8 +47,8 @@ command-line tool or the `aacgain`_ fork thereof. Here are some hints: * On Windows, download and install the original `mp3gain`_. .. _mp3gain: http://mp3gain.sourceforge.net/download.php -.. _aacgain: http://aacgain.altosdesign.com -.. _Homebrew: http://mxcl.github.com/homebrew/ +.. _aacgain: https://aacgain.altosdesign.com +.. _Homebrew: https://brew.sh Then, enable the plugin (see :ref:`using-plugins`) and specify the "command" backend in your configuration file:: diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index 2f691c4fe..e68217657 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -5,7 +5,7 @@ Smart Playlist Plugin beets queries every time your library changes. This plugin is specifically created to work well with `MPD's`_ playlist functionality. -.. _MPD's: http://www.musicpd.org/ +.. _MPD's: https://www.musicpd.org/ To use it, enable the ``smartplaylist`` plugin in your configuration (see :ref:`using-plugins`). @@ -36,7 +36,7 @@ For more advanced usage, you can use template syntax (see query: 'year::201(0|1)' This will query all the songs in 2010 and 2011 and generate the two playlist -files `ReleasedIn2010.m3u` and `ReleasedIn2011.m3u` using those songs. +files ``ReleasedIn2010.m3u`` and ``ReleasedIn2011.m3u`` using those songs. You can also gather the results of several queries by putting them in a list. (Items that match both queries are not included twice.) For example:: diff --git a/docs/plugins/sonosupdate.rst b/docs/plugins/sonosupdate.rst index 97a13bd07..cae69d554 100644 --- a/docs/plugins/sonosupdate.rst +++ b/docs/plugins/sonosupdate.rst @@ -14,5 +14,5 @@ To use the ``sonosupdate`` plugin you need to install the `soco`_ library with:: With that all in place, you'll see beets send the "update" command to your Sonos controller every time you change your beets library. -.. _Sonos: http://sonos.com/ +.. _Sonos: https://sonos.com/ .. _soco: http://python-soco.com diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 3f4c6c43d..5d6ae8f47 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -31,8 +31,8 @@ Here's an example:: $ beet spotify "In The Lonely Hour" Processing 14 tracks... - http://open.spotify.com/track/19w0OHr8SiZzRhjpnjctJ4 - http://open.spotify.com/track/3PRLM4FzhplXfySa4B7bxS + https://open.spotify.com/track/19w0OHr8SiZzRhjpnjctJ4 + https://open.spotify.com/track/3PRLM4FzhplXfySa4B7bxS [...] Command-line options include: diff --git a/docs/plugins/subsonicupdate.rst b/docs/plugins/subsonicupdate.rst index daf4a0cfb..2d9331b7c 100644 --- a/docs/plugins/subsonicupdate.rst +++ b/docs/plugins/subsonicupdate.rst @@ -4,7 +4,7 @@ SubsonicUpdate Plugin ``subsonicupdate`` is a very simple plugin for beets that lets you automatically update `Subsonic`_'s index whenever you change your beets library. -.. _Subsonic: http://www.subsonic.org +.. _Subsonic: https://www.subsonic.org To use ``subsonicupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). diff --git a/docs/plugins/thumbnails.rst b/docs/plugins/thumbnails.rst index 5753a9f7e..0f46e04e8 100644 --- a/docs/plugins/thumbnails.rst +++ b/docs/plugins/thumbnails.rst @@ -7,13 +7,13 @@ Nautilus or Thunar, and is therefore POSIX-only. To use the ``thumbnails`` plugin, enable it (see :doc:`/plugins/index`) as well as the :doc:`/plugins/fetchart`. You'll need 2 additional python packages: -`pyxdg` and `pathlib`. +:pypi:`pyxdg` and :pypi:`pathlib`. ``thumbnails`` needs to resize the covers, and therefore requires either `ImageMagick`_ or `Pillow`_. .. _Pillow: https://github.com/python-pillow/Pillow -.. _ImageMagick: http://www.imagemagick.org/ +.. _ImageMagick: https://www.imagemagick.org/ Configuration ------------- diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst index 35287acc8..d416b1b7d 100644 --- a/docs/plugins/web.rst +++ b/docs/plugins/web.rst @@ -19,13 +19,13 @@ The Web interface depends on `Flask`_. To get it, just run ``pip install flask``. Then enable the ``web`` plugin in your configuration (see :ref:`using-plugins`). -.. _Flask: http://flask.pocoo.org/ +.. _Flask: https://flask.pocoo.org/ If you need CORS (it's disabled by default---see :ref:`web-cors`, below), then you also need `flask-cors`_. Just type ``pip install flask-cors``. .. _flask-cors: https://github.com/CoryDolphin/flask-cors -.. _CORS: http://en.wikipedia.org/wiki/Cross-origin_resource_sharing +.. _CORS: https://en.wikipedia.org/wiki/Cross-origin_resource_sharing Run the Server @@ -78,8 +78,8 @@ The Web backend is built using a simple REST+JSON API with the excellent `Flask`_ library. The frontend is a single-page application written with `Backbone.js`_. This allows future non-Web clients to use the same backend API. -.. _Flask: http://flask.pocoo.org/ -.. _Backbone.js: http://backbonejs.org +.. _Flask: https://flask.pocoo.org/ +.. _Backbone.js: https://backbonejs.org Eventually, to make the Web player really viable, we should use a Flash fallback for unsupported formats/browsers. There are a number of options for this: @@ -88,9 +88,9 @@ for unsupported formats/browsers. There are a number of options for this: * `html5media`_ * `MediaElement.js`_ -.. _audio.js: http://kolber.github.com/audiojs/ -.. _html5media: http://html5media.info/ -.. _MediaElement.js: http://mediaelementjs.com/ +.. _audio.js: https://kolber.github.io/audiojs/ +.. _html5media: https://html5media.info/ +.. _MediaElement.js: https://mediaelementjs.com/ .. _web-cors: diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 7b9e9eb72..e17d5b42f 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -217,7 +217,7 @@ beatles`` prints out the number of tracks on each Beatles album. In Unix shells, remember to enclose the template argument in single quotes to avoid environment variable expansion. -.. _xargs: http://en.wikipedia.org/wiki/Xargs +.. _xargs: https://en.wikipedia.org/wiki/Xargs .. _remove-cmd: @@ -453,7 +453,7 @@ available via your package manager. On OS X, you can install it via Homebrew with ``brew install bash-completion``; Homebrew will give you instructions for sourcing the script. -.. _bash-completion: http://bash-completion.alioth.debian.org/ +.. _bash-completion: https://github.com/scop/bash-completion .. _bash: https://www.gnu.org/software/bash/ The completion script suggests names of subcommands and (after typing @@ -498,6 +498,6 @@ defines some bash-specific functions to make this work without errors:: See Also -------- - ``http://beets.readthedocs.org/`` + ``https://beets.readthedocs.org/`` :manpage:`beetsconfig(5)` diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 684dea20c..687f6c3f9 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -31,7 +31,7 @@ In YAML, you will need to use spaces (not tabs!) to indent some lines. If you have questions about more sophisticated syntax, take a look at the `YAML`_ documentation. -.. _YAML: http://yaml.org/ +.. _YAML: https://yaml.org/ The rest of this page enumerates the dizzying litany of configuration options available in beets. You might also want to see an @@ -167,7 +167,7 @@ equivalent to wrapping all your path templates in the ``%asciify{}`` Default: ``no``. -.. _unidecode module: http://pypi.python.org/pypi/Unidecode +.. _unidecode module: https://pypi.org/project/Unidecode .. _art-filename: @@ -314,7 +314,7 @@ standard output. It's also used to read messages from the standard input. By default, this is determined automatically from the locale environment variables. -.. _known to python: http://docs.python.org/2/library/codecs.html#standard-encodings +.. _known to python: https://docs.python.org/2/library/codecs.html#standard-encodings .. _clutter: @@ -508,9 +508,10 @@ incremental_skip_later ~~~~~~~~~~~~~~~~~~~~~~ Either ``yes`` or ``no``, controlling whether skipped directories are -recorded in the incremental list. Set this option to ``yes`` if you would -like to revisit skipped directories later whilst using incremental -mode. Defaults to ``no``. +recorded in the incremental list. When set to ``yes``, skipped directories +will be recorded, and skipped later. When set to ``no``, skipped +directories won't be recorded, and beets will try to import them again +later. Defaults to ``no``. .. _from_scratch: @@ -521,6 +522,17 @@ Either ``yes`` or ``no`` (default), controlling whether existing metadata is discarded when a match is applied. This corresponds to the ``--from_scratch`` flag to ``beet import``. +.. _quiet: + +quiet +~~~~~ + +Either ``yes`` or ``no`` (default), controlling whether to ask for a manual +decision from the user when the importer is unsure how to proceed. This +corresponds to the ``--quiet`` flag to ``beet import``. + +.. _quiet_fallback: + quiet_fallback ~~~~~~~~~~~~~~ @@ -571,9 +583,11 @@ languages ~~~~~~~~~ A list of locale names to search for preferred aliases. For example, setting -this to "en" uses the transliterated artist name "Pyotr Ilyich Tchaikovsky" +this to ``en`` uses the transliterated artist name "Pyotr Ilyich Tchaikovsky" instead of the Cyrillic script for the composer's name when tagging from -MusicBrainz. Defaults to an empty list, meaning that no language is preferred. +MusicBrainz. You can use a space-separated list of language abbreviations, like +``en jp es``, to specify a preference order. Defaults to an empty list, meaning +that no language is preferred. .. _detail: @@ -674,7 +688,7 @@ to one request per second. .. _your own MusicBrainz database: https://musicbrainz.org/doc/MusicBrainz_Server/Setup .. _main server: https://musicbrainz.org/ -.. _limited: http://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting +.. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting .. _Building search indexes: https://musicbrainz.org/doc/MusicBrainz_Server/Setup#Building_search_indexes .. _searchlimit: @@ -802,6 +816,8 @@ the penalty name to the ``ignored`` setting:: The available penalties are the same as those for the :ref:`max_rec` setting. +For example, setting ``ignored: missing_tracks`` will skip any album matches where your audio files are missing some of the tracks. The importer will not attempt to display these matches. It does not ignore the fact that the album is missing tracks, which would allow these matches to apply more easily. To do that, you'll want to adjust the penalty for missing tracks. + .. _required: required @@ -965,6 +981,6 @@ Here's an example file:: See Also -------- - ``http://beets.readthedocs.org/`` + ``https://beets.readthedocs.org/`` :manpage:`beet(1)` diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 79998a9e1..9213cae4b 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -23,7 +23,7 @@ a dollars sign. As with `Python template strings`_, ``${title}`` is equivalent to ``$title``; you can use this if you need to separate a field name from the text that follows it. -.. _Python template strings: http://docs.python.org/library/string.html#template-strings +.. _Python template strings: https://docs.python.org/library/string.html#template-strings A Note About Artists @@ -38,7 +38,7 @@ tracks in a "Talking Heads" directory and one in a "Tom Tom Club" directory. You probably don't want that! So use ``$albumartist``. .. _Stop Making Sense: - http://musicbrainz.org/release/798dcaab-0f1a-4f02-a9cb-61d5b0ddfd36.html + https://musicbrainz.org/release/798dcaab-0f1a-4f02-a9cb-61d5b0ddfd36.html As a convenience, however, beets allows ``$albumartist`` to fall back to the value for ``$artist`` and vice-versa if one tag is present but the other is not. @@ -89,8 +89,8 @@ These functions are built in to beets: without ``$``. Note that this doesn't work with built-in :ref:`itemfields`, as they are always defined. -.. _unidecode module: http://pypi.python.org/pypi/Unidecode -.. _strftime: http://docs.python.org/2/library/time.html#time.strftime +.. _unidecode module: https://pypi.org/project/Unidecode +.. _strftime: https://docs.python.org/3/library/time.html#time.strftime Plugins can extend beets with more template functions (see :ref:`templ_plugins`). @@ -228,8 +228,8 @@ Ordinary metadata: * disctitle * encoder -.. _artist credit: http://wiki.musicbrainz.org/Artist_Credit -.. _list of type names: http://musicbrainz.org/doc/Release_Group/Type +.. _artist credit: https://wiki.musicbrainz.org/Artist_Credit +.. _list of type names: https://musicbrainz.org/doc/Release_Group/Type Audio information: diff --git a/docs/reference/query.rst b/docs/reference/query.rst index d103d9aec..5c16f610b 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -122,7 +122,7 @@ expressions, such as ``()[]|``. To type those characters, you'll need to escape them (e.g., with backslashes or quotation marks, depending on your shell). -.. _Python's built-in implementation: http://docs.python.org/library/re.html +.. _Python's built-in implementation: https://docs.python.org/library/re.html .. _numericquery: diff --git a/extra/_beet b/extra/_beet index 56c86d036..129c0485e 100644 --- a/extra/_beet +++ b/extra/_beet @@ -1,6 +1,6 @@ #compdef beet -# zsh completion for beets music library manager and MusicBrainz tagger: http://beets.radbox.org/ +# zsh completion for beets music library manager and MusicBrainz tagger: https://beets.io/ # Default values for BEETS_LIBRARY & BEETS_CONFIG needed for the cache checking function. # They will be updated under the assumption that the config file is in the same directory as the library. @@ -34,7 +34,7 @@ _beet_check_cache () { # useful: argument to _regex_arguments for matching any word local matchany=/$'[^\0]##\0'/ # arguments to _regex_arguments for completing files and directories -local -a files dirs +local -a files dirs files=("$matchany" ':file:file:_files') dirs=("$matchany" ':dir:directory:_dirs') @@ -73,7 +73,7 @@ if ! _retrieve_cache beetscmds || _cache_invalid beetscmds; then # create completion function for queries _regex_arguments _beet_query "$matchany" \# \( "$matchquery" ":query:query string:$queryelem" \) \# local "beets_query"="$(which _beet_query)" - # arguments for _regex_arguments for completing lists of queries and modifications + # arguments for _regex_arguments for completing lists of queries and modifications beets_query_args=( \( "$matchquery" ":query:query string:{_beet_query}" \) \# ) beets_modify_args=( \( "$matchmodify" ":modify:modify string:$modifyelem" \) \# ) # now build arguments for _beet and _beet_help completion functions @@ -82,7 +82,7 @@ if ! _retrieve_cache beetscmds || _cache_invalid beetscmds; then subcmd="${i[(w)1]}" # remove first word and parenthesised alias, replace : with -, [ with (, ] with ), and remove single quotes cmddesc="${${${${${i[(w)2,-1]##\(*\) #}//:/-}//\[/(}//\]/)}//\'/}" - # update arguments needed for creating _beet + # update arguments needed for creating _beet beets_regex_words_subcmds+=(/"${subcmd}"$'\0'/ ":subcmds:subcommands:((${subcmd}:${cmddesc// /\ }))") beets_regex_words_subcmds+=(\( "${matchany}" ":option:option:{_beet_subcmd ${subcmd}}" \) \# \|) # update arguments needed for creating _beet_help @@ -137,7 +137,7 @@ _beet_subcmd_options() { fi ;; (LOG) - local -a files + local -a files files=("$matchany" ':file:file:_files') regex_words+=("$opt:$optdesc:\$files") ;; @@ -180,7 +180,7 @@ _beet_subcmd() { if [[ ! $(type _beet_${subcmd} | grep function) =~ function ]]; then if ! _retrieve_cache "beets${subcmd}" || _cache_invalid "beets${subcmd}"; then local matchany=/$'[^\0]##\0'/ - local -a files + local -a files files=("$matchany" ':file:file:_files') # get arguments for completing subcommand options _beet_subcmd_options "$subcmd" @@ -197,7 +197,7 @@ _beet_subcmd() { (fields|migrate|version|config) _regex_arguments _beet_${subcmd} "${matchany}" /"${subcmd}"$'\0'/ "${options[@]}" ;; - (help) + (help) _regex_words subcmds "subcommands" "${beets_regex_words_help[@]}" _regex_arguments _beet_help "${matchany}" /$'help\0'/ "${options[@]}" "${reply[@]}" ;; @@ -232,6 +232,6 @@ zstyle ":completion:${curcontext}:" tag-order '! options' # Execute the completion function _beet "$@" -# Local Variables: +# Local Variables: # mode:shell-script # End: diff --git a/setup.py b/setup.py index 19c03041a..1078d6cc9 100755 --- a/setup.py +++ b/setup.py @@ -56,15 +56,16 @@ if 'sdist' in sys.argv: setup( name='beets', - version='1.4.8', + version='1.5.0', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', - url='http://beets.io/', + url='https://beets.io/', license='MIT', platforms='ALL', long_description=_read('README.rst'), test_suite='test.testall.suite', + zip_safe=False, include_package_data=True, # Install plugin resources. packages=[ @@ -87,14 +88,26 @@ setup( install_requires=[ 'six>=1.9', - 'mutagen>=1.33', - 'munkres', 'unidecode', 'musicbrainzngs>=0.4', 'pyyaml', - 'jellyfish', - ] + (['colorama'] if (sys.platform == 'win32') else []) + - (['enum34>=1.0.4'] if sys.version_info < (3, 4, 0) else []), + 'mediafile>=0.1.0', + 'confuse>=1.0.0', + ] + [ + # Avoid a version of munkres incompatible with Python 3. + 'munkres~=1.0.0' if sys.version_info < (3, 5, 0) else + 'munkres!=1.1.0,!=1.1.1' if sys.version_info < (3, 6, 0) else + 'munkres>=1.0.0', + ] + ( + # Use the backport of Python 3.4's `enum` module. + ['enum34>=1.0.4'] if sys.version_info < (3, 4, 0) else [] + ) + ( + # Pin a Python 2-compatible version of Jellyfish. + ['jellyfish==0.6.0'] if sys.version_info < (3, 4, 0) else ['jellyfish'] + ) + ( + # Support for ANSI console colors on Windows. + ['colorama'] if (sys.platform == 'win32') else [] + ), tests_require=[ 'beautifulsoup4', @@ -104,30 +117,50 @@ setup( 'rarfile', 'responses', 'pyxdg', - 'pathlib', 'python-mpd2', 'discogs-client' - ], + ] + ( + # Tests for the thumbnails plugin need pathlib on Python 2 too. + ['pathlib'] if (sys.version_info < (3, 4, 0)) else [] + ), # Plugin (optional) dependencies: extras_require={ 'absubmit': ['requests'], - 'fetchart': ['requests'], + 'fetchart': ['requests', 'Pillow'], + 'embedart': ['Pillow'], + 'embyupdate': ['requests'], 'chroma': ['pyacoustid'], + 'gmusic': ['gmusicapi'], 'discogs': ['discogs-client>=2.2.1'], 'beatport': ['requests-oauthlib>=0.6.1'], + 'kodiupdate': ['requests'], 'lastgenre': ['pylast'], + 'lastimport': ['pylast'], + 'lyrics': ['requests', 'beautifulsoup4', 'langdetect'], 'mpdstats': ['python-mpd2>=0.4.2'], + 'plexupdate': ['requests'], 'web': ['flask', 'flask-cors'], 'import': ['rarfile'], - 'thumbnails': ['pyxdg'] + + 'thumbnails': ['pyxdg', 'Pillow'] + (['pathlib'] if (sys.version_info < (3, 4, 0)) else []), 'metasync': ['dbus-python'], + 'sonosupdate': ['soco'], + 'scrub': ['mutagen>=1.33'], + 'bpd': ['PyGObject'], + 'replaygain': ['PyGObject'], }, # Non-Python/non-PyPI plugin dependencies: - # convert: ffmpeg - # bpd: python-gi and GStreamer - # absubmit: extractor binary from http://acousticbrainz.org/download + # chroma: chromaprint or fpcalc + # convert: ffmpeg + # badfiles: mp3val and flac + # bpd: python-gi and GStreamer 1.0+ + # embedart: ImageMagick + # absubmit: extractor binary from https://acousticbrainz.org/download + # keyfinder: KeyFinder + # replaygain: python-gi and GStreamer 1.0+ or mp3gain/aacgain + # or Python Audio Tools + # ipfs: go-ipfs classifiers=[ 'Topic :: Multimedia :: Sound/Audio', @@ -143,6 +176,7 @@ setup( 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: Implementation :: CPython', ], ) diff --git a/test/_common.py b/test/_common.py index f5e65ca76..5412ab650 100644 --- a/test/_common.py +++ b/test/_common.py @@ -70,6 +70,9 @@ def item(lib=None): composer=u'the composer', arranger=u'the arranger', grouping=u'the grouping', + work=u'the work title', + mb_workid=u'the work musicbrainz id', + work_disambig=u'the work disambiguation', year=1, month=2, day=3, @@ -172,8 +175,7 @@ class TestCase(unittest.TestCase, Assertions): beets.config['directory'] = \ util.py3_path(os.path.join(self.temp_dir, b'libdir')) - # Set $HOME, which is used by confit's `config_dir()` to create - # directories. + # Set $HOME, which is used by Confuse to create directories. self._old_home = os.environ.get('HOME') os.environ['HOME'] = util.py3_path(self.temp_dir) @@ -284,6 +286,9 @@ class DummyIn(object): else: self.buf.append(s + '\n') + def close(self): + pass + def readline(self): if not self.buf: if self.out: diff --git a/test/helper.py b/test/helper.py index 92128f511..0b6eba718 100644 --- a/test/helper.py +++ b/test/helper.py @@ -24,7 +24,7 @@ information or mock the environment. - The `generate_album_info` and `generate_track_info` functions return fixtures to be used when mocking the autotagger. -- The `TestImportSession` allows one to run importer code while +- The `ImportSessionFixture` allows one to run importer code while controlling the interactions through code. - The `TestHelper` class encapsulates various fixtures that can be set up. @@ -50,7 +50,7 @@ import beets.plugins from beets.library import Library, Item, Album from beets import importer from beets.autotag.hooks import AlbumInfo, TrackInfo -from beets.mediafile import MediaFile, Image +from mediafile import MediaFile, Image from beets import util from beets.util import MoveOperation @@ -222,12 +222,19 @@ class TestHelper(object): beets.config['plugins'] = plugins beets.plugins.load_plugins(plugins) beets.plugins.find_plugins() - # Take a backup of the original _types to restore when unloading + + # Take a backup of the original _types and _queries to restore + # when unloading. Item._original_types = dict(Item._types) Album._original_types = dict(Album._types) Item._types.update(beets.plugins.types(Item)) Album._types.update(beets.plugins.types(Album)) + Item._original_queries = dict(Item._queries) + Album._original_queries = dict(Album._queries) + Item._queries.update(beets.plugins.named_queries(Item)) + Album._queries.update(beets.plugins.named_queries(Album)) + def unload_plugins(self): """Unload all plugins and remove the from the configuration. """ @@ -237,12 +244,14 @@ class TestHelper(object): beets.plugins._instances = {} Item._types = Item._original_types Album._types = Album._original_types + Item._queries = Item._original_queries + Album._queries = Album._original_queries def create_importer(self, item_count=1, album_count=1): """Create files to import and return corresponding session. Copies the specified number of files to a subdirectory of - `self.temp_dir` and creates a `TestImportSession` for this path. + `self.temp_dir` and creates a `ImportSessionFixture` for this path. """ import_dir = os.path.join(self.temp_dir, b'import') if not os.path.isdir(import_dir): @@ -285,8 +294,8 @@ class TestHelper(object): config['import']['autotag'] = False config['import']['resume'] = False - return TestImportSession(self.lib, loghandler=None, query=None, - paths=[import_dir]) + return ImportSessionFixture(self.lib, loghandler=None, query=None, + paths=[import_dir]) # Library fixtures methods @@ -492,11 +501,11 @@ class TestHelper(object): return path -class TestImportSession(importer.ImportSession): +class ImportSessionFixture(importer.ImportSession): """ImportSession that can be controlled programaticaly. >>> lib = Library(':memory:') - >>> importer = TestImportSession(lib, paths=['/path/to/import']) + >>> importer = ImportSessionFixture(lib, paths=['/path/to/import']) >>> importer.add_choice(importer.action.SKIP) >>> importer.add_choice(importer.action.ASIS) >>> importer.default_choice = importer.action.APPLY @@ -508,7 +517,7 @@ class TestImportSession(importer.ImportSession): """ def __init__(self, *args, **kwargs): - super(TestImportSession, self).__init__(*args, **kwargs) + super(ImportSessionFixture, self).__init__(*args, **kwargs) self._choices = [] self._resolutions = [] diff --git a/test/test_art.py b/test/test_art.py index 857f5d3c6..f4b3a6e62 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -34,7 +34,7 @@ from beets import importer from beets import logging from beets import util from beets.util.artresizer import ArtResizer, WEBPROXY -from beets.util import confit +import confuse logger = logging.getLogger('beets.test_art') @@ -159,9 +159,9 @@ class FSArtTest(UseThePlugin): class CombinedTest(FetchImageHelper, UseThePlugin): ASIN = 'xxxx' MBID = 'releaseid' - AMAZON_URL = 'http://images.amazon.com/images/P/{0}.01.LZZZZZZZ.jpg' \ + AMAZON_URL = 'https://images.amazon.com/images/P/{0}.01.LZZZZZZZ.jpg' \ .format(ASIN) - AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}' \ + AAO_URL = 'https://www.albumart.org/index_detail.php?asin={0}' \ .format(ASIN) CAA_URL = 'coverartarchive.org/release/{0}/front' \ .format(MBID) @@ -240,7 +240,7 @@ class CombinedTest(FetchImageHelper, UseThePlugin): class AAOTest(UseThePlugin): ASIN = 'xxxx' - AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}'.format(ASIN) + AAO_URL = 'https://www.albumart.org/index_detail.php?asin={0}'.format(ASIN) def setUp(self): super(AAOTest, self).setUp() @@ -753,7 +753,7 @@ class EnforceRatioConfigTest(_common.TestCase): if should_raise: for v in values: config['fetchart']['enforce_ratio'] = v - with self.assertRaises(confit.ConfigValueError): + with self.assertRaises(confuse.ConfigValueError): fetchart.FetchArtPlugin() else: for v in values: diff --git a/test/test_config_command.py b/test/test_config_command.py index 35ba6ca0e..0d16dbf19 100644 --- a/test/test_config_command.py +++ b/test/test_config_command.py @@ -45,7 +45,7 @@ class ConfigCommandTest(unittest.TestCase, TestHelper): def _run_with_yaml_output(self, *args): output = self.run_with_output(*args) - return yaml.load(output) + return yaml.safe_load(output) def test_show_user_config(self): output = self._run_with_yaml_output('config', '-c') diff --git a/test/test_convert.py b/test/test_convert.py index aa0cd0a34..33bdb3b24 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -24,7 +24,7 @@ from test import _common from test import helper from test.helper import control_stdin, capture_log -from beets.mediafile import MediaFile +from mediafile import MediaFile from beets import util diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 89aca442b..9bf78de67 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -32,11 +32,22 @@ import six # Fixture: concrete database and model classes. For migration tests, we # have multiple models with different numbers of fields. -class TestSort(dbcore.query.FieldSort): +class SortFixture(dbcore.query.FieldSort): pass -class TestModel1(dbcore.Model): +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 = { @@ -47,7 +58,10 @@ class TestModel1(dbcore.Model): 'some_float_field': dbcore.types.FLOAT, } _sorts = { - 'some_sort': TestSort, + 'some_sort': SortFixture, + } + _queries = { + 'some_query': QueryFixture, } @classmethod @@ -58,12 +72,12 @@ class TestModel1(dbcore.Model): return {} -class TestDatabase1(dbcore.Database): - _models = (TestModel1,) +class DatabaseFixture1(dbcore.Database): + _models = (ModelFixture1,) pass -class TestModel2(TestModel1): +class ModelFixture2(ModelFixture1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, @@ -71,12 +85,12 @@ class TestModel2(TestModel1): } -class TestDatabase2(dbcore.Database): - _models = (TestModel2,) +class DatabaseFixture2(dbcore.Database): + _models = (ModelFixture2,) pass -class TestModel3(TestModel1): +class ModelFixture3(ModelFixture1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, @@ -85,12 +99,12 @@ class TestModel3(TestModel1): } -class TestDatabase3(dbcore.Database): - _models = (TestModel3,) +class DatabaseFixture3(dbcore.Database): + _models = (ModelFixture3,) pass -class TestModel4(TestModel1): +class ModelFixture4(ModelFixture1): _fields = { 'id': dbcore.types.PRIMARY_ID, 'field_one': dbcore.types.INTEGER, @@ -100,12 +114,12 @@ class TestModel4(TestModel1): } -class TestDatabase4(dbcore.Database): - _models = (TestModel4,) +class DatabaseFixture4(dbcore.Database): + _models = (ModelFixture4,) pass -class AnotherTestModel(TestModel1): +class AnotherModelFixture(ModelFixture1): _table = 'another' _flex_table = 'anotherflex' _fields = { @@ -114,7 +128,7 @@ class AnotherTestModel(TestModel1): } -class TestModel5(TestModel1): +class ModelFixture5(ModelFixture1): _fields = { 'some_string_field': dbcore.types.STRING, 'some_float_field': dbcore.types.FLOAT, @@ -122,17 +136,17 @@ class TestModel5(TestModel1): } -class TestDatabase5(dbcore.Database): - _models = (TestModel5,) +class DatabaseFixture5(dbcore.Database): + _models = (ModelFixture5,) pass -class TestDatabaseTwoModels(dbcore.Database): - _models = (TestModel2, AnotherTestModel) +class DatabaseFixtureTwoModels(dbcore.Database): + _models = (ModelFixture2, AnotherModelFixture) pass -class TestModelWithGetters(dbcore.Model): +class ModelFixtureWithGetters(dbcore.Model): @classmethod def _getters(cls): @@ -153,7 +167,7 @@ class MigrationTest(unittest.TestCase): handle, cls.orig_libfile = mkstemp('orig_db') os.close(handle) # Set up a database with the two-field schema. - old_lib = TestDatabase2(cls.orig_libfile) + old_lib = DatabaseFixture2(cls.orig_libfile) # Add an item to the old library. old_lib._connection().execute( @@ -175,35 +189,35 @@ class MigrationTest(unittest.TestCase): os.remove(self.libfile) def test_open_with_same_fields_leaves_untouched(self): - new_lib = TestDatabase2(self.libfile) + new_lib = DatabaseFixture2(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() - self.assertEqual(len(row.keys()), len(TestModel2._fields)) + self.assertEqual(len(row.keys()), len(ModelFixture2._fields)) def test_open_with_new_field_adds_column(self): - new_lib = TestDatabase3(self.libfile) + new_lib = DatabaseFixture3(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() - self.assertEqual(len(row.keys()), len(TestModel3._fields)) + self.assertEqual(len(row.keys()), len(ModelFixture3._fields)) def test_open_with_fewer_fields_leaves_untouched(self): - new_lib = TestDatabase1(self.libfile) + new_lib = DatabaseFixture1(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() - self.assertEqual(len(row.keys()), len(TestModel2._fields)) + self.assertEqual(len(row.keys()), len(ModelFixture2._fields)) def test_open_with_multiple_new_fields(self): - new_lib = TestDatabase4(self.libfile) + new_lib = DatabaseFixture4(self.libfile) c = new_lib._connection().cursor() c.execute("select * from test") row = c.fetchone() - self.assertEqual(len(row.keys()), len(TestModel4._fields)) + self.assertEqual(len(row.keys()), len(ModelFixture4._fields)) def test_extra_model_adds_table(self): - new_lib = TestDatabaseTwoModels(self.libfile) + new_lib = DatabaseFixtureTwoModels(self.libfile) try: new_lib._connection().execute("select * from another") except sqlite3.OperationalError: @@ -212,19 +226,19 @@ class MigrationTest(unittest.TestCase): class ModelTest(unittest.TestCase): def setUp(self): - self.db = TestDatabase1(':memory:') + self.db = DatabaseFixture1(':memory:') def tearDown(self): self.db._connection().close() def test_add_model(self): - model = TestModel1() + 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 = TestModel1() + model = ModelFixture1() model.add(self.db) model.field_one = 123 model.store() @@ -232,54 +246,54 @@ class ModelTest(unittest.TestCase): self.assertEqual(row['field_one'], 123) def test_retrieve_by_id(self): - model = TestModel1() + model = ModelFixture1() model.add(self.db) - other_model = self.db._get(TestModel1, model.id) + other_model = self.db._get(ModelFixture1, model.id) self.assertEqual(model.id, other_model.id) def test_store_and_retrieve_flexattr(self): - model = TestModel1() + model = ModelFixture1() model.add(self.db) model.foo = 'bar' model.store() - other_model = self.db._get(TestModel1, model.id) + other_model = self.db._get(ModelFixture1, model.id) self.assertEqual(other_model.foo, 'bar') def test_delete_flexattr(self): - model = TestModel1() + 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 = TestModel1() + 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 = TestModel1() + model = ModelFixture1() model.add(self.db) model.foo = 'bar' model.store() - model = self.db._get(TestModel1, model.id) + model = self.db._get(ModelFixture1, model.id) del model['foo'] model.store() - model = self.db._get(TestModel1, model.id) + model = self.db._get(ModelFixture1, model.id) self.assertFalse('foo' in model) def test_delete_non_existent_attribute(self): - model = TestModel1() + model = ModelFixture1() with self.assertRaises(KeyError): del model['foo'] def test_delete_fixed_attribute(self): - model = TestModel5() + model = ModelFixture5() model.some_string_field = 'foo' model.some_float_field = 1.23 model.some_boolean_field = True @@ -292,26 +306,26 @@ class ModelTest(unittest.TestCase): self.assertEqual(model[field], type_.null) def test_null_value_normalization_by_type(self): - model = TestModel1() + model = ModelFixture1() model.field_one = None self.assertEqual(model.field_one, 0) def test_null_value_stays_none_for_untyped_field(self): - model = TestModel1() + model = ModelFixture1() model.foo = None self.assertEqual(model.foo, None) def test_normalization_for_typed_flex_fields(self): - model = TestModel1() + model = ModelFixture1() model.some_float_field = None self.assertEqual(model.some_float_field, 0.0) def test_load_deleted_flex_field(self): - model1 = TestModel1() + model1 = ModelFixture1() model1['flex_field'] = True model1.add(self.db) - model2 = self.db._get(TestModel1, model1.id) + model2 = self.db._get(ModelFixture1, model1.id) self.assertIn('flex_field', model2) del model1['flex_field'] @@ -324,22 +338,22 @@ class ModelTest(unittest.TestCase): with assertRaisesRegex(self, ValueError, 'no database'): dbcore.Model()._check_db() with assertRaisesRegex(self, ValueError, 'no id'): - TestModel1(self.db)._check_db() + ModelFixture1(self.db)._check_db() dbcore.Model(self.db)._check_db(need_id=False) def test_missing_field(self): with self.assertRaises(AttributeError): - TestModel1(self.db).nonExistingKey + ModelFixture1(self.db).nonExistingKey def test_computed_field(self): - model = TestModelWithGetters() + model = ModelFixtureWithGetters() self.assertEqual(model.aComputedField, 'thing') with assertRaisesRegex(self, KeyError, u'computed field .+ deleted'): del model.aComputedField def test_items(self): - model = TestModel1(self.db) + model = ModelFixture1(self.db) model.id = 5 self.assertEqual({('id', 5), ('field_one', 0)}, set(model.items())) @@ -357,31 +371,31 @@ class ModelTest(unittest.TestCase): class FormatTest(unittest.TestCase): def test_format_fixed_field(self): - model = TestModel1() + model = ModelFixture1() model.field_one = u'caf\xe9' value = model.formatted().get('field_one') self.assertEqual(value, u'caf\xe9') def test_format_flex_field(self): - model = TestModel1() + model = ModelFixture1() model.other_field = u'caf\xe9' value = model.formatted().get('other_field') self.assertEqual(value, u'caf\xe9') def test_format_flex_field_bytes(self): - model = TestModel1() + model = ModelFixture1() model.other_field = u'caf\xe9'.encode('utf-8') value = model.formatted().get('other_field') self.assertTrue(isinstance(value, six.text_type)) self.assertEqual(value, u'caf\xe9') def test_format_unset_field(self): - model = TestModel1() + model = ModelFixture1() value = model.formatted().get('other_field') self.assertEqual(value, u'') def test_format_typed_flex_field(self): - model = TestModel1() + model = ModelFixture1() model.some_float_field = 3.14159265358979 value = model.formatted().get('some_float_field') self.assertEqual(value, u'3.1') @@ -389,40 +403,40 @@ class FormatTest(unittest.TestCase): class FormattedMappingTest(unittest.TestCase): def test_keys_equal_model_keys(self): - model = TestModel1() + model = ModelFixture1() formatted = model.formatted() self.assertEqual(set(model.keys(True)), set(formatted.keys())) def test_get_unset_field(self): - model = TestModel1() + model = ModelFixture1() formatted = model.formatted() with self.assertRaises(KeyError): formatted['other_field'] def test_get_method_with_default(self): - model = TestModel1() + model = ModelFixture1() formatted = model.formatted() self.assertEqual(formatted.get('other_field'), u'') def test_get_method_with_specified_default(self): - model = TestModel1() + model = ModelFixture1() formatted = model.formatted() self.assertEqual(formatted.get('other_field', 'default'), 'default') class ParseTest(unittest.TestCase): def test_parse_fixed_field(self): - value = TestModel1._parse('field_one', u'2') + value = ModelFixture1._parse('field_one', u'2') self.assertIsInstance(value, int) self.assertEqual(value, 2) def test_parse_flex_field(self): - value = TestModel1._parse('some_float_field', u'2') + value = ModelFixture1._parse('some_float_field', u'2') self.assertIsInstance(value, float) self.assertEqual(value, 2.0) def test_parse_untyped_field(self): - value = TestModel1._parse('field_nine', u'2') + value = ModelFixture1._parse('field_nine', u'2') self.assertEqual(value, u'2') @@ -489,7 +503,7 @@ class QueryFromStringsTest(unittest.TestCase): def qfs(self, strings): return dbcore.queryparse.query_from_strings( dbcore.query.AndQuery, - TestModel1, + ModelFixture1, {':': dbcore.query.RegexpQuery}, strings, ) @@ -519,11 +533,15 @@ class QueryFromStringsTest(unittest.TestCase): 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( - TestModel1, + ModelFixture1, strings, ) @@ -553,13 +571,13 @@ class SortFromStringsTest(unittest.TestCase): def test_special_sort(self): s = self.sfs(['some_sort+']) - self.assertIsInstance(s, TestSort) + self.assertIsInstance(s, SortFixture) class ParseSortedQueryTest(unittest.TestCase): def psq(self, parts): return dbcore.parse_sorted_query( - TestModel1, + ModelFixture1, parts.split(), ) @@ -608,11 +626,11 @@ class ParseSortedQueryTest(unittest.TestCase): class ResultsIteratorTest(unittest.TestCase): def setUp(self): - self.db = TestDatabase1(':memory:') - model = TestModel1() + self.db = DatabaseFixture1(':memory:') + model = ModelFixture1() model['foo'] = 'baz' model.add(self.db) - model = TestModel1() + model = ModelFixture1() model['foo'] = 'bar' model.add(self.db) @@ -620,16 +638,16 @@ class ResultsIteratorTest(unittest.TestCase): self.db._connection().close() def test_iterate_once(self): - objs = self.db._fetch(TestModel1) + objs = self.db._fetch(ModelFixture1) self.assertEqual(len(list(objs)), 2) def test_iterate_twice(self): - objs = self.db._fetch(TestModel1) + objs = self.db._fetch(ModelFixture1) list(objs) self.assertEqual(len(list(objs)), 2) def test_concurrent_iterators(self): - results = self.db._fetch(TestModel1) + results = self.db._fetch(ModelFixture1) it1 = iter(results) it2 = iter(results) next(it1) @@ -638,44 +656,44 @@ class ResultsIteratorTest(unittest.TestCase): def test_slow_query(self): q = dbcore.query.SubstringQuery('foo', 'ba', False) - objs = self.db._fetch(TestModel1, q) + 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(TestModel1, q) + 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(TestModel1, sort=s) + 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(TestModel1) + 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(TestModel1, sort=s) + 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(TestModel1) + objs = self.db._fetch(ModelFixture1) self.assertEqual(len(objs), 2) def test_out_of_range(self): - objs = self.db._fetch(TestModel1) + objs = self.db._fetch(ModelFixture1) with self.assertRaises(IndexError): objs[100] def test_no_results(self): self.assertIsNone(self.db._fetch( - TestModel1, dbcore.query.FalseQuery()).get()) + ModelFixture1, dbcore.query.FalseQuery()).get()) def suite(): diff --git a/test/test_embedart.py b/test/test_embedart.py index 049e4694d..c465a5a96 100644 --- a/test/test_embedart.py +++ b/test/test_embedart.py @@ -24,7 +24,7 @@ import unittest from test import _common from test.helper import TestHelper -from beets.mediafile import MediaFile +from mediafile import MediaFile from beets import config, logging, ui from beets.util import syspath, displayable_path from beets.util.artresizer import ArtResizer diff --git a/test/test_filefilter.py b/test/test_filefilter.py index 57310fd2a..0b0da0d3f 100644 --- a/test/test_filefilter.py +++ b/test/test_filefilter.py @@ -26,7 +26,7 @@ from test import _common from test.helper import capture_log from test.test_importer import ImportHelper from beets import config -from beets.mediafile import MediaFile +from mediafile import MediaFile from beets.util import displayable_path, bytestring_path from beetsplug.filefilter import FileFilterPlugin diff --git a/test/test_hook.py b/test/test_hook.py index 39fd08959..81363c73c 100644 --- a/test/test_hook.py +++ b/test/test_hook.py @@ -110,6 +110,25 @@ class HookTest(_common.TestCase, TestHelper): self.assertTrue(os.path.isfile(path)) os.remove(path) + 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('test_bytes_event_{0}'.format(index), + 'touch "{path}"') + + self.load_plugins('hook') + + for index, path in enumerate(temporary_paths): + plugins.send('test_bytes_event_{0}'.format(index), path=path) + + for path in temporary_paths: + self.assertTrue(os.path.isfile(path)) + os.remove(path) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_importer.py b/test/test_importer.py index 8d6ba425a..8f637a077 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -32,10 +32,11 @@ import unittest from test import _common from beets.util import displayable_path, bytestring_path, py3_path -from test.helper import TestImportSession, TestHelper, has_program, capture_log +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 beets.mediafile import MediaFile +from mediafile import MediaFile from beets import autotag from beets.autotag import AlbumInfo, TrackInfo, AlbumMatch from beets import config @@ -223,7 +224,7 @@ class ImportHelper(TestHelper): config['import']['link'] = link config['import']['hardlink'] = hardlink - self.importer = TestImportSession( + self.importer = ImportSessionFixture( self.lib, loghandler=None, query=None, paths=[import_dir or self.import_dir] ) diff --git a/test/test_info.py b/test/test_info.py index f5375e2f6..814205527 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -18,7 +18,7 @@ from __future__ import division, absolute_import, print_function import unittest from test.helper import TestHelper -from beets.mediafile import MediaFile +from mediafile import MediaFile from beets.util import displayable_path diff --git a/test/test_library.py b/test/test_library.py index e8cdb0530..4e3be878c 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -30,12 +30,11 @@ import unittest from test import _common from test._common import item import beets.library -import beets.mediafile import beets.dbcore.query from beets import util from beets import plugins from beets import config -from beets.mediafile import MediaFile +from mediafile import MediaFile, UnreadableFileError from beets.util import syspath, bytestring_path from test.helper import TestHelper import six @@ -1169,7 +1168,7 @@ class ItemReadTest(unittest.TestCase): with self.assertRaises(beets.library.ReadError) as cm: item.read(unreadable) self.assertIsInstance(cm.exception.reason, - beets.mediafile.UnreadableFileError) + UnreadableFileError) def test_nonexistent_raise_read_error(self): item = beets.library.Item() diff --git a/test/test_lyrics.py b/test/test_lyrics.py index 4d48ed9ed..f7ea538e2 100644 --- a/test/test_lyrics.py +++ b/test/test_lyrics.py @@ -28,7 +28,8 @@ from test import _common from beets import logging from beets.library import Item -from beets.util import bytestring_path, confit +from beets.util import bytestring_path +import confuse from beetsplug import lyrics @@ -222,7 +223,8 @@ def is_lyrics_content_ok(title, text): return keywords <= words LYRICS_ROOT_DIR = os.path.join(_common.RSRC, b'lyrics') -LYRICS_TEXTS = confit.load_yaml(os.path.join(_common.RSRC, b'lyricstext.yaml')) +yaml_path = os.path.join(_common.RSRC, b'lyricstext.yaml') +LYRICS_TEXTS = confuse.load_yaml(yaml_path) class LyricsGoogleBaseTest(unittest.TestCase): diff --git a/test/test_mb.py b/test/test_mb.py index d5cb7c468..de1ffd9a7 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -459,7 +459,7 @@ class ParseIDTest(_common.TestCase): def test_parse_id_url_finds_id(self): id_string = "28e32c71-1450-463e-92bf-e0a46446fc11" - id_url = "http://musicbrainz.org/entity/%s" % id_string + id_url = "https://musicbrainz.org/entity/%s" % id_string out = mb._parse_id(id_url) self.assertEqual(out, id_string) diff --git a/test/test_mediafile.py b/test/test_mediafile.py deleted file mode 100644 index 36a2c53ac..000000000 --- a/test/test_mediafile.py +++ /dev/null @@ -1,972 +0,0 @@ -# -*- coding: utf-8 -*- -# 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. - -"""Automatically-generated blanket testing for the MediaFile metadata -layer. -""" -from __future__ import division, absolute_import, print_function - -import os -import shutil -import datetime -import time -import unittest -from six import assertCountEqual - -from test import _common -from beets.mediafile import MediaFile, Image, \ - ImageType, CoverArtField, UnreadableFileError - - -class ArtTestMixin(object): - """Test reads and writes of the ``art`` property. - """ - - @property - def png_data(self): - if not self._png_data: - image_file = os.path.join(_common.RSRC, b'image-2x3.png') - with open(image_file, 'rb') as f: - self._png_data = f.read() - return self._png_data - _png_data = None - - @property - def jpg_data(self): - if not self._jpg_data: - image_file = os.path.join(_common.RSRC, b'image-2x3.jpg') - with open(image_file, 'rb') as f: - self._jpg_data = f.read() - return self._jpg_data - _jpg_data = None - - @property - def tiff_data(self): - if not self._jpg_data: - image_file = os.path.join(_common.RSRC, b'image-2x3.tiff') - with open(image_file, 'rb') as f: - self._jpg_data = f.read() - return self._jpg_data - _jpg_data = None - - def test_set_png_art(self): - mediafile = self._mediafile_fixture('empty') - mediafile.art = self.png_data - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.art, self.png_data) - - def test_set_jpg_art(self): - mediafile = self._mediafile_fixture('empty') - mediafile.art = self.jpg_data - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.art, self.jpg_data) - - def test_delete_art(self): - mediafile = self._mediafile_fixture('empty') - mediafile.art = self.jpg_data - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertIsNotNone(mediafile.art) - - del mediafile.art - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertIsNone(mediafile.art) - - -class ImageStructureTestMixin(ArtTestMixin): - """Test reading and writing multiple image tags. - - The tests use the `image` media file fixture. The tags of these files - include two images, on in the PNG format, the other in JPEG format. If - the tag format supports it they also include additional metadata. - """ - - def test_read_image_structures(self): - mediafile = self._mediafile_fixture('image') - - self.assertEqual(len(mediafile.images), 2) - - image = next(i for i in mediafile.images - if i.mime_type == 'image/png') - self.assertEqual(image.data, self.png_data) - self.assertExtendedImageAttributes(image, desc=u'album cover', - type=ImageType.front) - - image = next(i for i in mediafile.images - if i.mime_type == 'image/jpeg') - self.assertEqual(image.data, self.jpg_data) - self.assertExtendedImageAttributes(image, desc=u'the artist', - type=ImageType.artist) - - def test_set_image_structure(self): - mediafile = self._mediafile_fixture('empty') - image = Image(data=self.png_data, desc=u'album cover', - type=ImageType.front) - mediafile.images = [image] - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(len(mediafile.images), 1) - - image = mediafile.images[0] - self.assertEqual(image.data, self.png_data) - self.assertEqual(image.mime_type, 'image/png') - self.assertExtendedImageAttributes(image, desc=u'album cover', - type=ImageType.front) - - def test_add_image_structure(self): - mediafile = self._mediafile_fixture('image') - self.assertEqual(len(mediafile.images), 2) - - image = Image(data=self.png_data, desc=u'the composer', - type=ImageType.composer) - mediafile.images += [image] - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(len(mediafile.images), 3) - - images = (i for i in mediafile.images if i.desc == u'the composer') - image = next(images, None) - self.assertExtendedImageAttributes( - image, desc=u'the composer', type=ImageType.composer - ) - - def test_delete_image_structures(self): - mediafile = self._mediafile_fixture('image') - self.assertEqual(len(mediafile.images), 2) - - del mediafile.images - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(len(mediafile.images), 0) - - def test_guess_cover(self): - mediafile = self._mediafile_fixture('image') - self.assertEqual(len(mediafile.images), 2) - cover = CoverArtField.guess_cover_image(mediafile.images) - self.assertEqual(cover.desc, u'album cover') - self.assertEqual(mediafile.art, cover.data) - - def assertExtendedImageAttributes(self, image, **kwargs): # noqa - """Ignore extended image attributes in the base tests. - """ - pass - - -class ExtendedImageStructureTestMixin(ImageStructureTestMixin): - """Checks for additional attributes in the image structure. - - Like the base `ImageStructureTestMixin`, per-format test classes - should include this mixin to add image-related tests. - """ - - def assertExtendedImageAttributes(self, image, desc=None, type=None): # noqa - self.assertEqual(image.desc, desc) - self.assertEqual(image.type, type) - - def test_add_tiff_image(self): - mediafile = self._mediafile_fixture('image') - self.assertEqual(len(mediafile.images), 2) - - image = Image(data=self.tiff_data, desc=u'the composer', - type=ImageType.composer) - mediafile.images += [image] - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(len(mediafile.images), 3) - - # WMA does not preserve the order, so we have to work around this - image = list(filter(lambda i: i.mime_type == 'image/tiff', - mediafile.images))[0] - self.assertExtendedImageAttributes( - image, desc=u'the composer', type=ImageType.composer) - - -class LazySaveTestMixin(object): - """Mediafile should only write changes when tags have changed - """ - - @unittest.skip(u'not yet implemented') - def test_unmodified(self): - mediafile = self._mediafile_fixture('full') - mtime = self._set_past_mtime(mediafile.path) - self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) - - mediafile.save() - self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) - - @unittest.skip(u'not yet implemented') - def test_same_tag_value(self): - mediafile = self._mediafile_fixture('full') - mtime = self._set_past_mtime(mediafile.path) - self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) - - mediafile.title = mediafile.title - mediafile.save() - self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) - - def test_update_same_tag_value(self): - mediafile = self._mediafile_fixture('full') - mtime = self._set_past_mtime(mediafile.path) - self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) - - mediafile.update({'title': mediafile.title}) - mediafile.save() - self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) - - @unittest.skip(u'not yet implemented') - def test_tag_value_change(self): - mediafile = self._mediafile_fixture('full') - mtime = self._set_past_mtime(mediafile.path) - self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) - - mediafile.title = mediafile.title - mediafile.album = u'another' - mediafile.save() - self.assertNotEqual(os.stat(mediafile.path).st_mtime, mtime) - - def test_update_changed_tag_value(self): - mediafile = self._mediafile_fixture('full') - mtime = self._set_past_mtime(mediafile.path) - self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) - - mediafile.update({'title': mediafile.title, 'album': u'another'}) - mediafile.save() - self.assertNotEqual(os.stat(mediafile.path).st_mtime, mtime) - - def _set_past_mtime(self, path): - mtime = round(time.time() - 10000) - os.utime(path, (mtime, mtime)) - return mtime - - -class GenreListTestMixin(object): - """Tests access to the ``genres`` property as a list. - """ - - def test_read_genre_list(self): - mediafile = self._mediafile_fixture('full') - assertCountEqual(self, mediafile.genres, ['the genre']) - - def test_write_genre_list(self): - mediafile = self._mediafile_fixture('empty') - mediafile.genres = [u'one', u'two'] - mediafile.save() - - mediafile = MediaFile(mediafile.path) - assertCountEqual(self, mediafile.genres, [u'one', u'two']) - - def test_write_genre_list_get_first(self): - mediafile = self._mediafile_fixture('empty') - mediafile.genres = [u'one', u'two'] - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.genre, u'one') - - def test_append_genre_list(self): - mediafile = self._mediafile_fixture('full') - self.assertEqual(mediafile.genre, u'the genre') - mediafile.genres += [u'another'] - mediafile.save() - - mediafile = MediaFile(mediafile.path) - assertCountEqual(self, mediafile.genres, [u'the genre', u'another']) - - -class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, - _common.TempDirMixin): - """Test writing and reading tags. Subclasses must set ``extension`` - and ``audio_properties``. - - The basic tests for all audio formats encompass three files provided - in our `rsrc` folder: `full.*`, `empty.*`, and `unparseable.*`. - Respectively, they should contain a full slate of common fields - listed in `full_initial_tags` below; no fields contents at all; and - an unparseable release date field. - - To add support for a new file format to MediaFile, add these three - files and then create a `ReadWriteTestBase` subclass by copying n' - pasting one of the existing subclasses below. You will want to - update the `format` field in that subclass, and you will probably - need to fiddle with the `bitrate` and other format-specific fields. - - You can also add image tests (using an additional `image.*` fixture - file) by including one of the image-related mixins. - """ - - full_initial_tags = { - 'title': u'full', - 'artist': u'the artist', - 'album': u'the album', - 'genre': u'the genre', - 'composer': u'the composer', - 'grouping': u'the grouping', - 'year': 2001, - 'month': None, - 'day': None, - 'date': datetime.date(2001, 1, 1), - 'track': 2, - 'tracktotal': 3, - 'disc': 4, - 'disctotal': 5, - 'lyrics': u'the lyrics', - 'comments': u'the comments', - 'bpm': 6, - 'comp': True, - 'mb_trackid': '8b882575-08a5-4452-a7a7-cbb8a1531f9e', - 'mb_releasetrackid': 'c29f3a57-b439-46fd-a2e2-93776b1371e0', - 'mb_albumid': '9e873859-8aa4-4790-b985-5a953e8ef628', - 'mb_artistid': '7cf0ea9d-86b9-4dad-ba9e-2355a64899ea', - 'art': None, - 'label': u'the label', - } - - tag_fields = [ - 'title', - 'artist', - 'album', - 'genre', - 'lyricist', - 'composer', - 'composer_sort', - 'arranger', - 'grouping', - 'year', - 'month', - 'day', - 'date', - 'track', - 'tracktotal', - 'disc', - 'disctotal', - 'lyrics', - 'comments', - 'bpm', - 'comp', - 'mb_trackid', - 'mb_releasetrackid', - 'mb_albumid', - 'mb_artistid', - 'art', - 'label', - 'rg_track_peak', - 'rg_track_gain', - 'rg_album_peak', - 'rg_album_gain', - 'r128_track_gain', - 'r128_album_gain', - 'albumartist', - 'mb_albumartistid', - 'artist_sort', - 'albumartist_sort', - 'acoustid_fingerprint', - 'acoustid_id', - 'mb_releasegroupid', - 'asin', - 'catalognum', - 'disctitle', - 'script', - 'language', - 'country', - 'albumstatus', - 'media', - 'albumdisambig', - 'artist_credit', - 'albumartist_credit', - 'original_year', - 'original_month', - 'original_day', - 'original_date', - 'initial_key', - ] - - def setUp(self): - self.create_temp_dir() - - def tearDown(self): - self.remove_temp_dir() - - def test_read_nonexisting(self): - mediafile = self._mediafile_fixture('full') - os.remove(mediafile.path) - self.assertRaises(UnreadableFileError, MediaFile, mediafile.path) - - def test_save_nonexisting(self): - mediafile = self._mediafile_fixture('full') - os.remove(mediafile.path) - try: - mediafile.save() - except UnreadableFileError: - pass - - def test_delete_nonexisting(self): - mediafile = self._mediafile_fixture('full') - os.remove(mediafile.path) - try: - mediafile.delete() - except UnreadableFileError: - pass - - def test_read_audio_properties(self): - mediafile = self._mediafile_fixture('full') - for key, value in self.audio_properties.items(): - if isinstance(value, float): - self.assertAlmostEqual(getattr(mediafile, key), value, - delta=0.1) - else: - self.assertEqual(getattr(mediafile, key), value) - - def test_read_full(self): - mediafile = self._mediafile_fixture('full') - self.assertTags(mediafile, self.full_initial_tags) - - def test_read_empty(self): - mediafile = self._mediafile_fixture('empty') - for field in self.tag_fields: - self.assertIsNone(getattr(mediafile, field)) - - def test_write_empty(self): - mediafile = self._mediafile_fixture('empty') - tags = self._generate_tags() - - for key, value in tags.items(): - setattr(mediafile, key, value) - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertTags(mediafile, tags) - - def test_update_empty(self): - mediafile = self._mediafile_fixture('empty') - tags = self._generate_tags() - - mediafile.update(tags) - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertTags(mediafile, tags) - - def test_overwrite_full(self): - mediafile = self._mediafile_fixture('full') - tags = self._generate_tags() - - for key, value in tags.items(): - setattr(mediafile, key, value) - mediafile.save() - - # Make sure the tags are already set when writing a second time - for key, value in tags.items(): - setattr(mediafile, key, value) - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertTags(mediafile, tags) - - def test_update_full(self): - mediafile = self._mediafile_fixture('full') - tags = self._generate_tags() - - mediafile.update(tags) - mediafile.save() - # Make sure the tags are already set when writing a second time - mediafile.update(tags) - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertTags(mediafile, tags) - - def test_write_date_components(self): - mediafile = self._mediafile_fixture('full') - mediafile.year = 2001 - mediafile.month = 1 - mediafile.day = 2 - mediafile.original_year = 1999 - mediafile.original_month = 12 - mediafile.original_day = 30 - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.year, 2001) - self.assertEqual(mediafile.month, 1) - self.assertEqual(mediafile.day, 2) - self.assertEqual(mediafile.date, datetime.date(2001, 1, 2)) - self.assertEqual(mediafile.original_year, 1999) - self.assertEqual(mediafile.original_month, 12) - self.assertEqual(mediafile.original_day, 30) - self.assertEqual(mediafile.original_date, datetime.date(1999, 12, 30)) - - def test_write_incomplete_date_components(self): - mediafile = self._mediafile_fixture('empty') - mediafile.year = 2001 - mediafile.month = None - mediafile.day = 2 - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.year, 2001) - self.assertIsNone(mediafile.month) - self.assertIsNone(mediafile.day) - self.assertEqual(mediafile.date, datetime.date(2001, 1, 1)) - - def test_write_dates(self): - mediafile = self._mediafile_fixture('full') - mediafile.date = datetime.date(2001, 1, 2) - mediafile.original_date = datetime.date(1999, 12, 30) - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.year, 2001) - self.assertEqual(mediafile.month, 1) - self.assertEqual(mediafile.day, 2) - self.assertEqual(mediafile.date, datetime.date(2001, 1, 2)) - self.assertEqual(mediafile.original_year, 1999) - self.assertEqual(mediafile.original_month, 12) - self.assertEqual(mediafile.original_day, 30) - self.assertEqual(mediafile.original_date, datetime.date(1999, 12, 30)) - - def test_write_packed(self): - mediafile = self._mediafile_fixture('empty') - - mediafile.tracktotal = 2 - mediafile.track = 1 - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.track, 1) - self.assertEqual(mediafile.tracktotal, 2) - - def test_write_counters_without_total(self): - mediafile = self._mediafile_fixture('full') - self.assertEqual(mediafile.track, 2) - self.assertEqual(mediafile.tracktotal, 3) - self.assertEqual(mediafile.disc, 4) - self.assertEqual(mediafile.disctotal, 5) - - mediafile.track = 10 - delattr(mediafile, 'tracktotal') - mediafile.disc = 10 - delattr(mediafile, 'disctotal') - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.track, 10) - self.assertEqual(mediafile.tracktotal, None) - self.assertEqual(mediafile.disc, 10) - self.assertEqual(mediafile.disctotal, None) - - def test_unparseable_date(self): - """The `unparseable.*` fixture should not crash but should return None - for all parts of the release date. - """ - mediafile = self._mediafile_fixture('unparseable') - - self.assertIsNone(mediafile.date) - self.assertIsNone(mediafile.year) - self.assertIsNone(mediafile.month) - self.assertIsNone(mediafile.day) - - def test_delete_tag(self): - mediafile = self._mediafile_fixture('full') - - keys = self.full_initial_tags.keys() - for key in set(keys) - set(['art', 'month', 'day']): - self.assertIsNotNone(getattr(mediafile, key)) - for key in keys: - delattr(mediafile, key) - mediafile.save() - mediafile = MediaFile(mediafile.path) - - for key in keys: - self.assertIsNone(getattr(mediafile, key)) - - def test_delete_packed_total(self): - mediafile = self._mediafile_fixture('full') - - delattr(mediafile, 'tracktotal') - delattr(mediafile, 'disctotal') - - mediafile.save() - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.track, self.full_initial_tags['track']) - self.assertEqual(mediafile.disc, self.full_initial_tags['disc']) - - def test_delete_partial_date(self): - mediafile = self._mediafile_fixture('empty') - - mediafile.date = datetime.date(2001, 12, 3) - mediafile.save() - mediafile = MediaFile(mediafile.path) - self.assertIsNotNone(mediafile.date) - self.assertIsNotNone(mediafile.year) - self.assertIsNotNone(mediafile.month) - self.assertIsNotNone(mediafile.day) - - delattr(mediafile, 'month') - mediafile.save() - mediafile = MediaFile(mediafile.path) - self.assertIsNotNone(mediafile.date) - self.assertIsNotNone(mediafile.year) - self.assertIsNone(mediafile.month) - self.assertIsNone(mediafile.day) - - def test_delete_year(self): - mediafile = self._mediafile_fixture('full') - - self.assertIsNotNone(mediafile.date) - self.assertIsNotNone(mediafile.year) - - delattr(mediafile, 'year') - mediafile.save() - mediafile = MediaFile(mediafile.path) - self.assertIsNone(mediafile.date) - self.assertIsNone(mediafile.year) - - def assertTags(self, mediafile, tags): # noqa - errors = [] - for key, value in tags.items(): - try: - value2 = getattr(mediafile, key) - except AttributeError: - errors.append(u'Tag %s does not exist' % key) - else: - if value2 != value: - errors.append(u'Tag %s: %r != %r' % (key, value2, value)) - if any(errors): - errors = [u'Tags did not match'] + errors - self.fail('\n '.join(errors)) - - def _mediafile_fixture(self, name): - name = name + '.' + self.extension - if not isinstance(name, bytes): - name = name.encode('utf8') - src = os.path.join(_common.RSRC, name) - target = os.path.join(self.temp_dir, name) - shutil.copy(src, target) - return MediaFile(target) - - def _generate_tags(self, base=None): - """Return dictionary of tags, mapping tag names to values. - """ - tags = {} - - for key in self.tag_fields: - if key.startswith('rg_'): - # ReplayGain is float - tags[key] = 1.0 - elif key.startswith('r128_'): - # R128 is int - tags[key] = -1 - else: - tags[key] = 'value\u2010%s' % key - - for key in ['disc', 'disctotal', 'track', 'tracktotal', 'bpm']: - tags[key] = 1 - - tags['art'] = self.jpg_data - tags['comp'] = True - - date = datetime.date(2001, 4, 3) - tags['date'] = date - tags['year'] = date.year - tags['month'] = date.month - tags['day'] = date.day - - original_date = datetime.date(1999, 5, 6) - tags['original_date'] = original_date - tags['original_year'] = original_date.year - tags['original_month'] = original_date.month - tags['original_day'] = original_date.day - - return tags - - -class PartialTestMixin(object): - tags_without_total = { - 'track': 2, - 'tracktotal': 0, - 'disc': 4, - 'disctotal': 0, - } - - def test_read_track_without_total(self): - mediafile = self._mediafile_fixture('partial') - self.assertEqual(mediafile.track, 2) - self.assertIsNone(mediafile.tracktotal) - self.assertEqual(mediafile.disc, 4) - self.assertIsNone(mediafile.disctotal) - - -class MP3Test(ReadWriteTestBase, PartialTestMixin, - ExtendedImageStructureTestMixin, - unittest.TestCase): - extension = 'mp3' - audio_properties = { - 'length': 1.0, - 'bitrate': 80000, - 'format': 'MP3', - 'samplerate': 44100, - 'bitdepth': 0, - 'channels': 1, - } - - def test_unknown_apic_type(self): - mediafile = self._mediafile_fixture('image_unknown_type') - self.assertEqual(mediafile.images[0].type, ImageType.other) - - -class MP4Test(ReadWriteTestBase, PartialTestMixin, - ImageStructureTestMixin, unittest.TestCase): - extension = 'm4a' - audio_properties = { - 'length': 1.0, - 'bitrate': 64000, - 'format': 'AAC', - 'samplerate': 44100, - 'bitdepth': 16, - 'channels': 2, - } - - def test_add_tiff_image_fails(self): - mediafile = self._mediafile_fixture('empty') - with self.assertRaises(ValueError): - mediafile.images = [Image(data=self.tiff_data)] - - def test_guess_cover(self): - # There is no metadata associated with images, we pick one at random - pass - - -class AlacTest(ReadWriteTestBase, unittest.TestCase): - extension = 'alac.m4a' - audio_properties = { - 'length': 1.0, - 'bitrate': 21830, - # 'format': 'ALAC', - 'samplerate': 44100, - 'bitdepth': 16, - 'channels': 1, - } - - -class MusepackTest(ReadWriteTestBase, unittest.TestCase): - extension = 'mpc' - audio_properties = { - 'length': 1.0, - 'bitrate': 24023, - 'format': u'Musepack', - 'samplerate': 44100, - 'bitdepth': 0, - 'channels': 2, - } - - -class WMATest(ReadWriteTestBase, ExtendedImageStructureTestMixin, - unittest.TestCase): - extension = 'wma' - audio_properties = { - 'length': 1.0, - 'bitrate': 128000, - 'format': u'Windows Media', - 'samplerate': 44100, - 'bitdepth': 0, - 'channels': 1, - } - - def test_write_genre_list_get_first(self): - # WMA does not preserve list order - mediafile = self._mediafile_fixture('empty') - mediafile.genres = [u'one', u'two'] - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertIn(mediafile.genre, [u'one', u'two']) - - def test_read_pure_tags(self): - mediafile = self._mediafile_fixture('pure') - self.assertEqual(mediafile.comments, u'the comments') - self.assertEqual(mediafile.title, u'the title') - self.assertEqual(mediafile.artist, u'the artist') - - -class OggTest(ReadWriteTestBase, ExtendedImageStructureTestMixin, - unittest.TestCase): - extension = 'ogg' - audio_properties = { - 'length': 1.0, - 'bitrate': 48000, - 'format': u'OGG', - 'samplerate': 44100, - 'bitdepth': 0, - 'channels': 1, - } - - def test_read_date_from_year_tag(self): - mediafile = self._mediafile_fixture('year') - self.assertEqual(mediafile.year, 2000) - self.assertEqual(mediafile.date, datetime.date(2000, 1, 1)) - - def test_write_date_to_year_tag(self): - mediafile = self._mediafile_fixture('empty') - mediafile.year = 2000 - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.mgfile['YEAR'], [u'2000']) - - def test_legacy_coverart_tag(self): - mediafile = self._mediafile_fixture('coverart') - self.assertTrue('coverart' in mediafile.mgfile) - self.assertEqual(mediafile.art, self.png_data) - - mediafile.art = self.png_data - mediafile.save() - - mediafile = MediaFile(mediafile.path) - self.assertFalse('coverart' in mediafile.mgfile) - - def test_date_tag_with_slashes(self): - mediafile = self._mediafile_fixture('date_with_slashes') - self.assertEqual(mediafile.year, 2005) - self.assertEqual(mediafile.month, 6) - self.assertEqual(mediafile.day, 5) - - -class FlacTest(ReadWriteTestBase, PartialTestMixin, - ExtendedImageStructureTestMixin, - unittest.TestCase): - extension = 'flac' - audio_properties = { - 'length': 1.0, - 'bitrate': 108688, - 'format': u'FLAC', - 'samplerate': 44100, - 'bitdepth': 16, - 'channels': 1, - } - - -class ApeTest(ReadWriteTestBase, ExtendedImageStructureTestMixin, - unittest.TestCase): - extension = 'ape' - audio_properties = { - 'length': 1.0, - 'bitrate': 112608, - 'format': u'APE', - 'samplerate': 44100, - 'bitdepth': 16, - 'channels': 1, - } - - -class WavpackTest(ReadWriteTestBase, unittest.TestCase): - extension = 'wv' - audio_properties = { - 'length': 1.0, - 'bitrate': 109312, - 'format': u'WavPack', - 'samplerate': 44100, - 'bitdepth': 0, - 'channels': 1, - } - - -class OpusTest(ReadWriteTestBase, unittest.TestCase): - extension = 'opus' - audio_properties = { - 'length': 1.0, - 'bitrate': 66792, - 'format': u'Opus', - 'samplerate': 48000, - 'bitdepth': 0, - 'channels': 1, - } - - -class AIFFTest(ReadWriteTestBase, unittest.TestCase): - extension = 'aiff' - audio_properties = { - 'length': 1.0, - 'bitrate': 705600, - 'format': u'AIFF', - 'samplerate': 44100, - 'bitdepth': 0, - 'channels': 1, - } - - -# Check whether we have a Mutagen version with DSF support. We can -# remove this once we require a version that includes the feature. -try: - import mutagen.dsf # noqa -except ImportError: - HAVE_DSF = False -else: - HAVE_DSF = True - - -@unittest.skipIf(not HAVE_DSF, "Mutagen does not have DSF support") -class DSFTest(ReadWriteTestBase, unittest.TestCase): - extension = 'dsf' - audio_properties = { - 'length': 0.01, - 'bitrate': 11289600, - 'format': u'DSD Stream File', - 'samplerate': 5644800, - 'bitdepth': 1, - 'channels': 2, - } - - -class MediaFieldTest(unittest.TestCase): - - def test_properties_from_fields(self): - path = os.path.join(_common.RSRC, b'full.mp3') - mediafile = MediaFile(path) - for field in MediaFile.fields(): - self.assertTrue(hasattr(mediafile, field)) - - def test_properties_from_readable_fields(self): - path = os.path.join(_common.RSRC, b'full.mp3') - mediafile = MediaFile(path) - for field in MediaFile.readable_fields(): - self.assertTrue(hasattr(mediafile, field)) - - def test_known_fields(self): - fields = list(ReadWriteTestBase.tag_fields) - fields.extend(('encoder', 'images', 'genres', 'albumtype')) - assertCountEqual(self, MediaFile.fields(), fields) - - def test_fields_in_readable_fields(self): - readable = MediaFile.readable_fields() - for field in MediaFile.fields(): - self.assertIn(field, readable) - - -def suite(): - return unittest.TestLoader().loadTestsFromName(__name__) - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/test/test_mediafile_edge.py b/test/test_mediafile_edge.py deleted file mode 100644 index 8bf9e1916..000000000 --- a/test/test_mediafile_edge.py +++ /dev/null @@ -1,411 +0,0 @@ -# -*- coding: utf-8 -*- -# 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. - -"""Specific, edge-case tests for the MediaFile metadata layer. -""" -from __future__ import division, absolute_import, print_function - -import os -import shutil -import unittest -import mutagen.id3 - -from test import _common - -from beets import mediafile -import six - - -_sc = mediafile._safe_cast - - -class EdgeTest(unittest.TestCase): - def test_emptylist(self): - # Some files have an ID3 frame that has a list with no elements. - # This is very hard to produce, so this is just the first 8192 - # bytes of a file found "in the wild". - emptylist = mediafile.MediaFile( - os.path.join(_common.RSRC, b'emptylist.mp3') - ) - genre = emptylist.genre - self.assertEqual(genre, None) - - def test_release_time_with_space(self): - # Ensures that release times delimited by spaces are ignored. - # Amie Street produces such files. - space_time = mediafile.MediaFile( - os.path.join(_common.RSRC, b'space_time.mp3') - ) - self.assertEqual(space_time.year, 2009) - self.assertEqual(space_time.month, 9) - self.assertEqual(space_time.day, 4) - - def test_release_time_with_t(self): - # Ensures that release times delimited by Ts are ignored. - # The iTunes Store produces such files. - t_time = mediafile.MediaFile( - os.path.join(_common.RSRC, b't_time.m4a') - ) - self.assertEqual(t_time.year, 1987) - self.assertEqual(t_time.month, 3) - self.assertEqual(t_time.day, 31) - - def test_tempo_with_bpm(self): - # Some files have a string like "128 BPM" in the tempo field - # rather than just a number. - f = mediafile.MediaFile(os.path.join(_common.RSRC, b'bpm.mp3')) - self.assertEqual(f.bpm, 128) - - def test_discc_alternate_field(self): - # Different taggers use different vorbis comments to reflect - # the disc and disc count fields: ensure that the alternative - # style works. - f = mediafile.MediaFile(os.path.join(_common.RSRC, b'discc.ogg')) - self.assertEqual(f.disc, 4) - self.assertEqual(f.disctotal, 5) - - def test_old_ape_version_bitrate(self): - media_file = os.path.join(_common.RSRC, b'oldape.ape') - f = mediafile.MediaFile(media_file) - self.assertEqual(f.bitrate, 0) - - def test_only_magic_bytes_jpeg(self): - # Some jpeg files can only be recognized by their magic bytes and as - # such aren't recognized by imghdr. Ensure that this still works thanks - # to our own follow up mimetype detection based on - # https://github.com/file/file/blob/master/magic/Magdir/jpeg#L12 - magic_bytes_file = os.path.join(_common.RSRC, b'only-magic-bytes.jpg') - with open(magic_bytes_file, 'rb') as f: - jpg_data = f.read() - self.assertEqual( - mediafile._imghdr_what_wrapper(jpg_data), 'jpeg') - - def test_soundcheck_non_ascii(self): - # Make sure we don't crash when the iTunes SoundCheck field contains - # non-ASCII binary data. - f = mediafile.MediaFile(os.path.join(_common.RSRC, - b'soundcheck-nonascii.m4a')) - self.assertEqual(f.rg_track_gain, 0.0) - - -class InvalidValueToleranceTest(unittest.TestCase): - - def test_safe_cast_string_to_int(self): - self.assertEqual(_sc(int, u'something'), 0) - - def test_safe_cast_string_to_int_with_no_numbers(self): - self.assertEqual(_sc(int, u'-'), 0) - - def test_safe_cast_int_string_to_int(self): - self.assertEqual(_sc(int, u'20'), 20) - - def test_safe_cast_string_to_bool(self): - self.assertEqual(_sc(bool, u'whatever'), False) - - def test_safe_cast_intstring_to_bool(self): - self.assertEqual(_sc(bool, u'5'), True) - - def test_safe_cast_string_to_float(self): - self.assertAlmostEqual(_sc(float, u'1.234'), 1.234) - - def test_safe_cast_int_to_float(self): - self.assertAlmostEqual(_sc(float, 2), 2.0) - - def test_safe_cast_string_with_cruft_to_float(self): - self.assertAlmostEqual(_sc(float, u'1.234stuff'), 1.234) - - def test_safe_cast_negative_string_to_float(self): - self.assertAlmostEqual(_sc(float, u'-1.234'), -1.234) - - def test_safe_cast_special_chars_to_unicode(self): - us = _sc(six.text_type, 'caf\xc3\xa9') - self.assertTrue(isinstance(us, six.text_type)) - self.assertTrue(us.startswith(u'caf')) - - def test_safe_cast_float_with_no_numbers(self): - v = _sc(float, u'+') - self.assertEqual(v, 0.0) - - def test_safe_cast_float_with_dot_only(self): - v = _sc(float, u'.') - self.assertEqual(v, 0.0) - - def test_safe_cast_float_with_multiple_dots(self): - v = _sc(float, u'1.0.0') - self.assertEqual(v, 1.0) - - -class SafetyTest(unittest.TestCase, _common.TempDirMixin): - def setUp(self): - self.create_temp_dir() - - def tearDown(self): - self.remove_temp_dir() - - def _exccheck(self, fn, exc, data=''): - fn = os.path.join(self.temp_dir, fn) - with open(fn, 'w') as f: - f.write(data) - try: - self.assertRaises(exc, mediafile.MediaFile, fn) - finally: - os.unlink(fn) # delete the temporary file - - def test_corrupt_mp3_raises_unreadablefileerror(self): - # Make sure we catch Mutagen reading errors appropriately. - self._exccheck(b'corrupt.mp3', mediafile.UnreadableFileError) - - def test_corrupt_mp4_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.m4a', mediafile.UnreadableFileError) - - def test_corrupt_flac_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.flac', mediafile.UnreadableFileError) - - def test_corrupt_ogg_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.ogg', mediafile.UnreadableFileError) - - def test_invalid_ogg_header_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.ogg', mediafile.UnreadableFileError, - 'OggS\x01vorbis') - - def test_corrupt_monkeys_raises_unreadablefileerror(self): - self._exccheck(b'corrupt.ape', mediafile.UnreadableFileError) - - def test_invalid_extension_raises_filetypeerror(self): - self._exccheck(b'something.unknown', mediafile.FileTypeError) - - def test_magic_xml_raises_unreadablefileerror(self): - self._exccheck(b'nothing.xml', mediafile.UnreadableFileError, - "ftyp") - - @unittest.skipUnless(_common.HAVE_SYMLINK, u'platform lacks symlink') - def test_broken_symlink(self): - fn = os.path.join(_common.RSRC, b'brokenlink') - os.symlink('does_not_exist', fn) - try: - self.assertRaises(mediafile.UnreadableFileError, - mediafile.MediaFile, fn) - finally: - os.unlink(fn) - - -class SideEffectsTest(unittest.TestCase): - def setUp(self): - self.empty = os.path.join(_common.RSRC, b'empty.mp3') - - def test_opening_tagless_file_leaves_untouched(self): - old_mtime = os.stat(self.empty).st_mtime - mediafile.MediaFile(self.empty) - new_mtime = os.stat(self.empty).st_mtime - self.assertEqual(old_mtime, new_mtime) - - -class MP4EncodingTest(unittest.TestCase, _common.TempDirMixin): - def setUp(self): - self.create_temp_dir() - src = os.path.join(_common.RSRC, b'full.m4a') - self.path = os.path.join(self.temp_dir, b'test.m4a') - shutil.copy(src, self.path) - - self.mf = mediafile.MediaFile(self.path) - - def tearDown(self): - self.remove_temp_dir() - - def test_unicode_label_in_m4a(self): - self.mf.label = u'foo\xe8bar' - self.mf.save() - new_mf = mediafile.MediaFile(self.path) - self.assertEqual(new_mf.label, u'foo\xe8bar') - - -class MP3EncodingTest(unittest.TestCase, _common.TempDirMixin): - def setUp(self): - self.create_temp_dir() - src = os.path.join(_common.RSRC, b'full.mp3') - self.path = os.path.join(self.temp_dir, b'test.mp3') - shutil.copy(src, self.path) - - self.mf = mediafile.MediaFile(self.path) - - def test_comment_with_latin1_encoding(self): - # Set up the test file with a Latin1-encoded COMM frame. The encoding - # indices defined by MP3 are listed here: - # http://id3.org/id3v2.4.0-structure - self.mf.mgfile['COMM::eng'].encoding = 0 - - # Try to store non-Latin1 text. - self.mf.comments = u'\u2028' - self.mf.save() - - -class ZeroLengthMediaFile(mediafile.MediaFile): - @property - def length(self): - return 0.0 - - -class MissingAudioDataTest(unittest.TestCase): - def setUp(self): - super(MissingAudioDataTest, self).setUp() - path = os.path.join(_common.RSRC, b'full.mp3') - self.mf = ZeroLengthMediaFile(path) - - def test_bitrate_with_zero_length(self): - del self.mf.mgfile.info.bitrate # Not available directly. - self.assertEqual(self.mf.bitrate, 0) - - -class TypeTest(unittest.TestCase): - def setUp(self): - super(TypeTest, self).setUp() - path = os.path.join(_common.RSRC, b'full.mp3') - self.mf = mediafile.MediaFile(path) - - def test_year_integer_in_string(self): - self.mf.year = u'2009' - self.assertEqual(self.mf.year, 2009) - - def test_set_replaygain_gain_to_none(self): - self.mf.rg_track_gain = None - self.assertEqual(self.mf.rg_track_gain, 0.0) - - def test_set_replaygain_peak_to_none(self): - self.mf.rg_track_peak = None - self.assertEqual(self.mf.rg_track_peak, 0.0) - - def test_set_year_to_none(self): - self.mf.year = None - self.assertIsNone(self.mf.year) - - def test_set_track_to_none(self): - self.mf.track = None - self.assertEqual(self.mf.track, 0) - - def test_set_date_to_none(self): - self.mf.date = None - self.assertIsNone(self.mf.date) - self.assertIsNone(self.mf.year) - self.assertIsNone(self.mf.month) - self.assertIsNone(self.mf.day) - - -class SoundCheckTest(unittest.TestCase): - def test_round_trip(self): - data = mediafile._sc_encode(1.0, 1.0) - gain, peak = mediafile._sc_decode(data) - self.assertEqual(gain, 1.0) - self.assertEqual(peak, 1.0) - - def test_decode_zero(self): - data = b' 80000000 80000000 00000000 00000000 00000000 00000000 ' \ - b'00000000 00000000 00000000 00000000' - gain, peak = mediafile._sc_decode(data) - self.assertEqual(gain, 0.0) - self.assertEqual(peak, 0.0) - - def test_malformatted(self): - gain, peak = mediafile._sc_decode(b'foo') - self.assertEqual(gain, 0.0) - self.assertEqual(peak, 0.0) - - def test_special_characters(self): - gain, peak = mediafile._sc_decode(u'caf\xe9'.encode('utf-8')) - self.assertEqual(gain, 0.0) - self.assertEqual(peak, 0.0) - - def test_decode_handles_unicode(self): - # Most of the time, we expect to decode the raw bytes. But some formats - # might give us text strings, which we need to handle. - gain, peak = mediafile._sc_decode(u'caf\xe9') - self.assertEqual(gain, 0.0) - self.assertEqual(peak, 0.0) - - -class ID3v23Test(unittest.TestCase, _common.TempDirMixin): - def _make_test(self, ext=b'mp3', id3v23=False): - self.create_temp_dir() - src = os.path.join(_common.RSRC, - b'full.' + ext) - self.path = os.path.join(self.temp_dir, - b'test.' + ext) - shutil.copy(src, self.path) - return mediafile.MediaFile(self.path, id3v23=id3v23) - - def _delete_test(self): - self.remove_temp_dir() - - def test_v24_year_tag(self): - mf = self._make_test(id3v23=False) - try: - mf.year = 2013 - mf.save() - frame = mf.mgfile['TDRC'] - self.assertTrue('2013' in six.text_type(frame)) - self.assertTrue('TYER' not in mf.mgfile) - finally: - self._delete_test() - - def test_v23_year_tag(self): - mf = self._make_test(id3v23=True) - try: - mf.year = 2013 - mf.save() - frame = mf.mgfile['TYER'] - self.assertTrue('2013' in six.text_type(frame)) - self.assertTrue('TDRC' not in mf.mgfile) - finally: - self._delete_test() - - def test_v23_on_non_mp3_is_noop(self): - mf = self._make_test(b'm4a', id3v23=True) - try: - mf.year = 2013 - mf.save() - finally: - self._delete_test() - - def test_image_encoding(self): - """For compatibility with OS X/iTunes. - - See https://github.com/beetbox/beets/issues/899#issuecomment-62437773 - """ - - for v23 in [True, False]: - mf = self._make_test(id3v23=v23) - try: - mf.images = [ - mediafile.Image(b'data', desc=u""), - mediafile.Image(b'data', desc=u"foo"), - mediafile.Image(b'data', desc=u"\u0185"), - ] - mf.save() - apic_frames = mf.mgfile.tags.getall('APIC') - encodings = dict([(f.desc, f.encoding) for f in apic_frames]) - self.assertEqual(encodings, { - u"": mutagen.id3.Encoding.LATIN1, - u"foo": mutagen.id3.Encoding.LATIN1, - u"\u0185": mutagen.id3.Encoding.UTF16, - }) - finally: - self._delete_test() - - -def suite(): - return unittest.TestLoader().loadTestsFromName(__name__) - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/test/test_mpdstats.py b/test/test_mpdstats.py index 7452be86b..0117e22aa 100644 --- a/test/test_mpdstats.py +++ b/test/test_mpdstats.py @@ -65,7 +65,7 @@ class MPDStatsTest(unittest.TestCase, TestHelper): @patch("beetsplug.mpdstats.MPDClientWrapper", return_value=Mock(**{ "events.side_effect": EVENTS, "status.side_effect": STATUSES, - "playlist.return_value": {1: item_path}})) + "currentsong.return_value": item_path})) def test_run_mpdstats(self, mpd_mock): item = Item(title=u'title', path=self.item_path, id=1) item.add(self.lib) diff --git a/test/test_pipeline.py b/test/test_pipeline.py index 228deb50e..82f155521 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -43,7 +43,7 @@ def _consume(l): # A worker that raises an exception. -class TestException(Exception): +class ExceptionFixture(Exception): pass @@ -52,7 +52,7 @@ def _exc_work(num=3): while True: i = yield i if i == num: - raise TestException() + raise ExceptionFixture() i *= 2 @@ -126,10 +126,10 @@ class ExceptionTest(unittest.TestCase): _consume(self.l))) def test_run_sequential(self): - self.assertRaises(TestException, self.pl.run_sequential) + self.assertRaises(ExceptionFixture, self.pl.run_sequential) def test_run_parallel(self): - self.assertRaises(TestException, self.pl.run_parallel) + self.assertRaises(ExceptionFixture, self.pl.run_parallel) def test_pull(self): pl = pipeline.Pipeline((_produce(), _exc_work())) @@ -137,9 +137,9 @@ class ExceptionTest(unittest.TestCase): for i in range(3): next(pull) if six.PY2: - self.assertRaises(TestException, pull.next) + self.assertRaises(ExceptionFixture, pull.next) else: - self.assertRaises(TestException, pull.__next__) + self.assertRaises(ExceptionFixture, pull.__next__) class ParallelExceptionTest(unittest.TestCase): @@ -150,7 +150,7 @@ class ParallelExceptionTest(unittest.TestCase): )) def test_run_parallel(self): - self.assertRaises(TestException, self.pl.run_parallel) + self.assertRaises(ExceptionFixture, self.pl.run_parallel) class ConstrainedThreadedPipelineTest(unittest.TestCase): @@ -166,7 +166,7 @@ class ConstrainedThreadedPipelineTest(unittest.TestCase): # Raise an exception in a constrained pipeline. l = [] pl = pipeline.Pipeline((_produce(1000), _exc_work(), _consume(l))) - self.assertRaises(TestException, pl.run_parallel, 1) + self.assertRaises(ExceptionFixture, pl.run_parallel, 1) def test_constrained_parallel(self): l = [] diff --git a/test/test_player.py b/test/test_player.py index 523a39d1b..959d77eb3 100644 --- a/test/test_player.py +++ b/test/test_player.py @@ -13,12 +13,51 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Tests for BPD and music playing. +"""Tests for BPD's implementation of the MPD protocol. """ from __future__ import division, absolute_import, print_function 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 +import random + +from beets.util import py3_path from beetsplug import bpd +import confuse + + +# Mock GstPlayer so that the forked process doesn't attempt to import gi: +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): @@ -63,6 +102,928 @@ class CommandParseTest(unittest.TestCase): self.assertEqual(c.args, [u'hello \\ there']) +class MPCResponse(object): + 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('Unexpected status: {!r}'.format(status)) + + 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('Unexpected line: {!r}'.format(line)) + 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(object): + 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('Unexpected response: {!r}'.format(line)) + + 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 start_beets(*args): + import beets.ui + beets.ui.main(list(args)) + + +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 + + +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', port=None, 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. + """ + # Choose a port (randomly) to avoid conflicts between parallel + # tests. + if not port: + port = 9876 + random.randint(0, 10000) + + # Create a config file: + config = { + 'pluginpath': [py3_path(self.temp_dir)], + 'plugins': 'bpd', + 'bpd': {'host': host, 'port': port, 'control_port': port + 1}, + } + 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: + args = ( + '--library', self.config['library'].as_filename(), + '--directory', py3_path(self.libdir), + '--config', py3_path(config_file.name), + 'bpd' + ) + server = mp.Process(target=start_beets, args=args) + server.start() + + # Wait until the socket is connected: + sock, sock2 = None, None + for _ in range(20): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if sock.connect_ex((host, port)) == 0: + break + else: + sock.close() + time.sleep(0.01) + else: + raise RuntimeError('Timed out waiting for the BPD server') + + try: + if second_client: + sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock2.connect((host, port)) + yield MPCClient(sock, do_hello), MPCClient(sock2, do_hello) + else: + yield MPCClient(sock, do_hello) + finally: + sock.close() + if sock2: + sock2.close() + 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__) diff --git a/test/test_playlist.py b/test/test_playlist.py new file mode 100644 index 000000000..edd98e711 --- /dev/null +++ b/test/test_playlist.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- +# 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 __future__ import division, absolute_import, print_function +from six.moves import shlex_quote + +import os +import shutil +import tempfile +import unittest + +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 = u'some item' + i1.album = u'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 = u'playlist:absolute' + results = self.lib.items(q) + self.assertEqual(set([i.title for i in results]), set([ + u'some item', + u'another item', + ])) + + def test_path_query_with_absolute_paths_in_playlist(self): + q = u'playlist:{0}'.format(shlex_quote(os.path.join( + self.playlist_dir, + 'absolute.m3u', + ))) + results = self.lib.items(q) + self.assertEqual(set([i.title for i in results]), set([ + u'some item', + u'another item', + ])) + + def test_name_query_with_relative_paths_in_playlist(self): + q = u'playlist:relative' + results = self.lib.items(q) + self.assertEqual(set([i.title for i in results]), set([ + u'some item', + u'another item', + ])) + + def test_path_query_with_relative_paths_in_playlist(self): + q = u'playlist:{0}'.format(shlex_quote(os.path.join( + self.playlist_dir, + 'relative.m3u', + ))) + results = self.lib.items(q) + self.assertEqual(set([i.title for i in results]), set([ + u'some item', + u'another item', + ])) + + def test_name_query_with_nonexisting_playlist(self): + q = u'playlist:nonexisting'.format(self.playlist_dir) + results = self.lib.items(q) + self.assertEqual(set(results), set()) + + def test_path_query_with_nonexisting_playlist(self): + q = u'playlist:{0}'.format(shlex_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('{0}\n'.format(os.path.join( + self.music_dir, 'a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'd', 'e', 'f.mp3'))) + f.write('{0}\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('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) + f.write('{0}\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('{0}\n'.format(os.path.join( + self.music_dir, 'a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'd', 'e', 'f.mp3'))) + f.write('{0}\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('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) + f.write('{0}\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('{0}\n'.format(os.path.join( + self.music_dir, 'a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'd', 'e', 'f.mp3'))) + f.write('{0}\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('{0}\n'.format(os.path.relpath( + os.path.join(self.music_dir, 'a', 'b', 'c.mp3'), + start=self.playlist_dir, + ))) + f.write('{0}\n'.format(os.path.relpath( + os.path.join(self.music_dir, 'd', 'e', 'f.mp3'), + start=self.playlist_dir, + ))) + f.write('{0}\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('{0}\n'.format(os.path.join( + self.music_dir, 'a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'd', 'e', 'f.mp3'))) + f.write('{0}\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('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) + f.write('{0}\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(u'path:{0}'.format(shlex_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(u'path:{0}'.format(shlex_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, 'r') 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, 'r') 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(u'path:{0}'.format(shlex_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(u'path:{0}'.format(shlex_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, 'r') 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, 'r') 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') diff --git a/test/test_plugin_mediafield.py b/test/test_plugin_mediafield.py index 983f6e2c8..a08db4542 100644 --- a/test/test_plugin_mediafield.py +++ b/test/test_plugin_mediafield.py @@ -24,7 +24,7 @@ import unittest from test import _common from beets.library import Item -from beets import mediafile +import mediafile from beets.plugins import BeetsPlugin from beets.util import bytestring_path diff --git a/test/test_plugins.py b/test/test_plugins.py index 7c32e9aca..884aa7875 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -26,7 +26,7 @@ from beets.importer import SingletonImportTask, SentinelImportTask, \ from beets import plugins, config, ui from beets.library import Item from beets.dbcore import types -from beets.mediafile import MediaFile +from mediafile import MediaFile from beets.util import displayable_path, bytestring_path, syspath from test.test_importer import ImportHelper, AutotagStub @@ -322,7 +322,7 @@ class ListenersTest(unittest.TestCase, TestHelper): @patch('beets.plugins.find_plugins') @patch('beets.plugins.inspect') def test_events_called(self, mock_inspect, mock_find_plugins): - mock_inspect.getargspec.return_value = None + mock_inspect.getargspec.args.return_value = None class DummyPlugin(plugins.BeetsPlugin): def __init__(self): diff --git a/test/test_random.py b/test/test_random.py new file mode 100644 index 000000000..4f243efd7 --- /dev/null +++ b/test/test_random.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# 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. +""" + +from __future__ import division, absolute_import, print_function + +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') diff --git a/test/test_replaygain.py b/test/test_replaygain.py index 6ddee54da..9f14374cc 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -19,10 +19,11 @@ from __future__ import division, absolute_import, print_function import unittest import six -from test.helper import TestHelper, has_program +from mock import patch +from test.helper import TestHelper, capture_log, has_program from beets import config -from beets.mediafile import MediaFile +from mediafile import MediaFile from beetsplug.replaygain import (FatalGstreamerPluginReplayGainError, GStreamerBackend) @@ -44,6 +45,15 @@ else: LOUDNESS_PROG_AVAILABLE = False +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() + + class ReplayGainCliTestBase(TestHelper): def setUp(self): @@ -68,20 +78,12 @@ class ReplayGainCliTestBase(TestHelper): album = self.add_album_fixture(2) for item in album.items(): - self._reset_replaygain(item) + 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_gain'] = None - item['rg_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) @@ -166,6 +168,43 @@ class ReplayGainLdnsCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'bs1770gain' +class ReplayGainLdnsCliMalformedTest(TestHelper, unittest.TestCase): + + @patch('beetsplug.replaygain.call') + def setUp(self, call_patch): + self.setup_beets() + self.config['replaygain']['backend'] = 'bs1770gain' + + # Patch call to return nothing, bypassing the bs1770gain installation + # check. + call_patch.return_value = None + self.load_plugins('replaygain') + + for item in self.add_album_fixture(2).items(): + reset_replaygain(item) + + @patch('beetsplug.replaygain.call') + def test_malformed_output(self, call_patch): + # Return malformed XML (the ampersand should be &) + call_patch.return_value = """ + + + + + + + """ + + with capture_log('beets.replaygain') as logs: + self.run_command('replaygain') + + # Count how many lines match the expected error. + matching = [line for line in logs if + 'malformed XML' in line] + + self.assertEqual(len(matching), 2) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_types_plugin.py b/test/test_types_plugin.py index 541bd30df..77d6c8bcf 100644 --- a/test/test_types_plugin.py +++ b/test/test_types_plugin.py @@ -21,7 +21,7 @@ import unittest from test.helper import TestHelper -from beets.util.confit import ConfigValueError +from confuse import ConfigValueError class TypesPluginTest(unittest.TestCase, TestHelper): diff --git a/test/test_ui.py b/test/test_ui.py index b9039d236..110e80782 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -35,10 +35,10 @@ from beets import ui from beets.ui import commands from beets import autotag from beets.autotag.match import distance -from beets.mediafile import MediaFile +from mediafile import MediaFile from beets import config from beets import plugins -from beets.util.confit import ConfigError +from confuse import ConfigError from beets import util from beets.util import syspath, MoveOperation @@ -507,10 +507,14 @@ class UpdateTest(_common.TestCase): # 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.album = self.lib.add_album([self.i]) + 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') @@ -531,12 +535,14 @@ class UpdateTest(_common.TestCase): 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()) @@ -544,6 +550,7 @@ class UpdateTest(_common.TestCase): artpath = self.album.artpath self.assertExists(artpath) os.remove(self.i.path) + os.remove(self.i2.path) self._update() self.assertNotExists(artpath) @@ -607,6 +614,7 @@ class UpdateTest(_common.TestCase): 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)) @@ -910,6 +918,7 @@ class ConfigTest(unittest.TestCase, TestHelper, _common.Assertions): ) 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(), @@ -1215,13 +1224,14 @@ class CommonOptionsParserCliTest(unittest.TestCase, TestHelper): """ def setUp(self): self.setup_beets() - self.lib = library.Library(':memory:') 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): diff --git a/test/test_ui_importer.py b/test/test_ui_importer.py index 48a66dc5f..229dac5d2 100644 --- a/test/test_ui_importer.py +++ b/test/test_ui_importer.py @@ -30,11 +30,11 @@ from beets import config import six -class TestTerminalImportSession(TerminalImportSession): +class TerminalImportSessionFixture(TerminalImportSession): def __init__(self, *args, **kwargs): self.io = kwargs.pop('io') - super(TestTerminalImportSession, self).__init__(*args, **kwargs) + super(TerminalImportSessionFixture, self).__init__(*args, **kwargs) self._choices = [] default_choice = importer.action.APPLY @@ -47,11 +47,11 @@ class TestTerminalImportSession(TerminalImportSession): def choose_match(self, task): self._add_choice_input() - return super(TestTerminalImportSession, self).choose_match(task) + return super(TerminalImportSessionFixture, self).choose_match(task) def choose_item(self, task): self._add_choice_input() - return super(TestTerminalImportSession, self).choose_item(task) + return super(TerminalImportSessionFixture, self).choose_item(task) def _add_choice_input(self): try: @@ -96,7 +96,7 @@ class TerminalImportSessionSetup(object): if not hasattr(self, 'io'): self.io = DummyIO() self.io.install() - self.importer = TestTerminalImportSession( + self.importer = TerminalImportSessionFixture( self.lib, loghandler=None, query=None, io=self.io, paths=[import_dir or self.import_dir], ) diff --git a/test/test_zero.py b/test/test_zero.py index 025eaa540..1b7cc92d7 100644 --- a/test/test_zero.py +++ b/test/test_zero.py @@ -9,7 +9,7 @@ from test.helper import TestHelper, control_stdin from beets.library import Item from beetsplug.zero import ZeroPlugin -from beets.mediafile import MediaFile +from mediafile import MediaFile from beets.util import syspath diff --git a/test/testall.py b/test/testall.py index 88eb70117..418b4a3ca 100755 --- a/test/testall.py +++ b/test/testall.py @@ -22,16 +22,15 @@ import re import sys import unittest -pkgpath = os.path.dirname(__file__) or '.' -sys.path.append(pkgpath) -os.chdir(pkgpath) +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(pkgpath): + for fname in os.listdir(os.path.join(pkgpath, 'test')): match = re.match(r'(test_\S+)\.py$', fname) if match: modname = match.group(1) diff --git a/tox.ini b/tox.ini index eeacf2af5..8736f0f3c 100644 --- a/tox.ini +++ b/tox.ini @@ -40,17 +40,18 @@ passenv = deps = {test,cov}: {[_test]deps} py27: pathlib - py{27,34,35,36,37}-flake8: {[_flake8]deps} + py{27,34,35,36,37,38}-flake8: {[_flake8]deps} commands = py27-cov: python -m nose --with-coverage {posargs} py27-test: python -m nose {posargs} - py3{4,5,6,7}-cov: python -bb -m nose --with-coverage {posargs} - py3{4,5,6,7}-test: python -bb -m nose {posargs} + py3{4,5,6,7,8}-cov: python -bb -m nose --with-coverage {posargs} + py3{4,5,6,7,8}-test: python -bb -m nose {posargs} py27-flake8: flake8 --min-version 2.7 {posargs} {[_flake8]files} py34-flake8: flake8 --min-version 3.4 {posargs} {[_flake8]files} py35-flake8: flake8 --min-version 3.5 {posargs} {[_flake8]files} py36-flake8: flake8 --min-version 3.6 {posargs} {[_flake8]files} py37-flake8: flake8 --min-version 3.7 {posargs} {[_flake8]files} + py38-flake8: flake8 --min-version 3.8 {posargs} {[_flake8]files} [testenv:docs] basepython = python2.7