mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 16:42:42 +01:00
Merge branch 'master' into replaygain
This commit is contained in:
commit
b8b99d9396
154 changed files with 4708 additions and 6234 deletions
|
|
@ -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.)
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe your problem, feature request, or discussion topic here.
|
||||||
|
|
||||||
|
Please fill out this and the "Setup" section below and remember to include
|
||||||
|
enough detail so that other people can reproduce the problem.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
Running this command in verbose (`-vv`) mode:
|
Running this command in verbose (`-vv`) mode:
|
||||||
|
|
||||||
26
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
|
|
@ -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
|
||||||
|
<!--
|
||||||
|
Do you have a proposal for how beets should work?
|
||||||
|
|
||||||
|
Try to be as specific as possible—for example, you could propose the name for
|
||||||
|
a new command-line option or refer to the particular ID3 frame you wish
|
||||||
|
were supported.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
### Alternatives
|
||||||
|
<!--
|
||||||
|
Have you tried using an existing plugin to do something similar?
|
||||||
|
Is there any current feature that _almost_ does what you need?
|
||||||
|
-->
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -89,3 +89,4 @@ ENV/
|
||||||
/.project
|
/.project
|
||||||
/.pydevproject
|
/.pydevproject
|
||||||
/.settings
|
/.settings
|
||||||
|
.vscode
|
||||||
|
|
|
||||||
11
.travis.yml
11
.travis.yml
|
|
@ -24,18 +24,21 @@ matrix:
|
||||||
- python: 3.7
|
- python: 3.7
|
||||||
env: {TOX_ENV: py37-test}
|
env: {TOX_ENV: py37-test}
|
||||||
dist: xenial
|
dist: xenial
|
||||||
|
# - python: 3.8-dev
|
||||||
|
# env: {TOX_ENV: py38-test}
|
||||||
|
# dist: xenial
|
||||||
# - python: pypy
|
# - python: pypy
|
||||||
# - env: {TOX_ENV: pypy-test}
|
# - env: {TOX_ENV: pypy-test}
|
||||||
- python: 3.4
|
- python: 3.6
|
||||||
env: {TOX_ENV: py34-flake8}
|
env: {TOX_ENV: py36-flake8}
|
||||||
- python: 2.7.13
|
- python: 2.7.13
|
||||||
env: {TOX_ENV: docs}
|
env: {TOX_ENV: docs}
|
||||||
# Non-Python dependencies.
|
# Non-Python dependencies.
|
||||||
addons:
|
addons:
|
||||||
apt:
|
apt:
|
||||||
sources:
|
sources:
|
||||||
- sourceline: "deb http://archive.ubuntu.com/ubuntu/ trusty multiverse"
|
- sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty multiverse"
|
||||||
- sourceline: "deb http://archive.ubuntu.com/ubuntu/ trusty-updates multiverse"
|
- sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty-updates multiverse"
|
||||||
packages:
|
packages:
|
||||||
- bash-completion
|
- bash-completion
|
||||||
- gir1.2-gst-plugins-base-1.0
|
- gir1.2-gst-plugins-base-1.0
|
||||||
|
|
|
||||||
53
README.rst
53
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
|
: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
|
:target: https://codecov.io/github/beetbox/beets
|
||||||
|
|
||||||
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
||||||
:target: https://travis-ci.org/beetbox/beets
|
:target: https://travis-ci.org/beetbox/beets
|
||||||
|
|
||||||
|
.. image:: https://repology.org/badge/tiny-repos/beets.svg
|
||||||
|
:target: https://repology.org/project/beets/versions
|
||||||
|
|
||||||
|
|
||||||
beets
|
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
|
If beets doesn't do what you want yet, `writing your own plugin`_ is
|
||||||
shockingly simple if you know a little Python.
|
shockingly simple if you know a little Python.
|
||||||
|
|
||||||
.. _plugins: http://beets.readthedocs.org/page/plugins/
|
.. _plugins: https://beets.readthedocs.org/page/plugins/
|
||||||
.. _MPD: http://www.musicpd.org/
|
.. _MPD: https://www.musicpd.org/
|
||||||
.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
|
.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
|
||||||
.. _writing your own plugin:
|
.. _writing your own plugin:
|
||||||
http://beets.readthedocs.org/page/dev/plugins.html
|
https://beets.readthedocs.org/page/dev/plugins.html
|
||||||
.. _HTML5 Audio:
|
.. _HTML5 Audio:
|
||||||
http://www.w3.org/TR/html-markup/audio.html
|
http://www.w3.org/TR/html-markup/audio.html
|
||||||
.. _albums that are missing tracks:
|
.. _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:
|
.. _duplicate tracks and albums:
|
||||||
http://beets.readthedocs.org/page/plugins/duplicates.html
|
https://beets.readthedocs.org/page/plugins/duplicates.html
|
||||||
.. _Transcode audio:
|
.. _Transcode audio:
|
||||||
http://beets.readthedocs.org/page/plugins/convert.html
|
https://beets.readthedocs.org/page/plugins/convert.html
|
||||||
.. _Discogs: http://www.discogs.com/
|
.. _Discogs: https://www.discogs.com/
|
||||||
.. _acoustic fingerprints:
|
.. _acoustic fingerprints:
|
||||||
http://beets.readthedocs.org/page/plugins/chroma.html
|
https://beets.readthedocs.org/page/plugins/chroma.html
|
||||||
.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html
|
.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
|
||||||
.. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||||
.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html
|
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||||
.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html
|
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
|
||||||
.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html
|
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
|
||||||
.. _MusicBrainz: http://musicbrainz.org/
|
.. _MusicBrainz: https://musicbrainz.org/
|
||||||
.. _Beatport: https://www.beatport.com
|
.. _Beatport: https://www.beatport.com
|
||||||
|
|
||||||
Install
|
Install
|
||||||
-------
|
-------
|
||||||
|
|
||||||
You can install beets by typing ``pip install beets``. Then check out the
|
You can install beets by typing ``pip install beets``.
|
||||||
`Getting Started`_ guide.
|
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
|
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.
|
You might also be interested in the `For Developers`_ section in the docs.
|
||||||
|
|
||||||
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
|
.. _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
|
Read More
|
||||||
---------
|
---------
|
||||||
|
|
@ -98,8 +103,8 @@ Read More
|
||||||
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for
|
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for
|
||||||
news and updates.
|
news and updates.
|
||||||
|
|
||||||
.. _its Web site: http://beets.io/
|
.. _its Web site: https://beets.io/
|
||||||
.. _@b33ts: http://twitter.com/b33ts/
|
.. _@b33ts: https://twitter.com/b33ts/
|
||||||
|
|
||||||
Authors
|
Authors
|
||||||
-------
|
-------
|
||||||
|
|
@ -108,4 +113,4 @@ Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help,
|
||||||
please visit our `forum`_.
|
please visit our `forum`_.
|
||||||
|
|
||||||
.. _forum: https://discourse.beets.io
|
.. _forum: https://discourse.beets.io
|
||||||
.. _Adrian Sampson: http://www.cs.cornell.edu/~asampson/
|
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
|
||||||
|
|
|
||||||
|
|
@ -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
|
: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
|
:target: https://codecov.io/github/beetbox/beets
|
||||||
|
|
||||||
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
||||||
|
|
@ -48,28 +48,28 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
|
||||||
만약 Beets에 당신이 원하는게 아직 없다면,
|
만약 Beets에 당신이 원하는게 아직 없다면,
|
||||||
당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로 간단하다.
|
당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로 간단하다.
|
||||||
|
|
||||||
.. _plugins: http://beets.readthedocs.org/page/plugins/
|
.. _plugins: https://beets.readthedocs.org/page/plugins/
|
||||||
.. _MPD: http://www.musicpd.org/
|
.. _MPD: https://www.musicpd.org/
|
||||||
.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
|
.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
|
||||||
.. _writing your own plugin:
|
.. _writing your own plugin:
|
||||||
http://beets.readthedocs.org/page/dev/plugins.html
|
https://beets.readthedocs.org/page/dev/plugins.html
|
||||||
.. _HTML5 Audio:
|
.. _HTML5 Audio:
|
||||||
http://www.w3.org/TR/html-markup/audio.html
|
http://www.w3.org/TR/html-markup/audio.html
|
||||||
.. _albums that are missing tracks:
|
.. _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:
|
.. _duplicate tracks and albums:
|
||||||
http://beets.readthedocs.org/page/plugins/duplicates.html
|
https://beets.readthedocs.org/page/plugins/duplicates.html
|
||||||
.. _Transcode audio:
|
.. _Transcode audio:
|
||||||
http://beets.readthedocs.org/page/plugins/convert.html
|
https://beets.readthedocs.org/page/plugins/convert.html
|
||||||
.. _Discogs: http://www.discogs.com/
|
.. _Discogs: https://www.discogs.com/
|
||||||
.. _acoustic fingerprints:
|
.. _acoustic fingerprints:
|
||||||
http://beets.readthedocs.org/page/plugins/chroma.html
|
https://beets.readthedocs.org/page/plugins/chroma.html
|
||||||
.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html
|
.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
|
||||||
.. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||||
.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html
|
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||||
.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html
|
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
|
||||||
.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html
|
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
|
||||||
.. _MusicBrainz: http://musicbrainz.org/
|
.. _MusicBrainz: https://musicbrainz.org/
|
||||||
.. _Beatport: https://www.beatport.com
|
.. _Beatport: https://www.beatport.com
|
||||||
|
|
||||||
설치
|
설치
|
||||||
|
|
@ -78,7 +78,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
|
||||||
당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다.
|
당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다.
|
||||||
그리고 `Getting Started`_ 가이드를 확인할 수 있다.
|
그리고 `Getting Started`_ 가이드를 확인할 수 있다.
|
||||||
|
|
||||||
.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
|
.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
|
||||||
|
|
||||||
컨트리뷰션
|
컨트리뷰션
|
||||||
----------
|
----------
|
||||||
|
|
@ -87,7 +87,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
|
||||||
당신은 docs 안에 `For Developers`_ 에도 관심이 있을수 있다.
|
당신은 docs 안에 `For Developers`_ 에도 관심이 있을수 있다.
|
||||||
|
|
||||||
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
|
.. _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
|
Read More
|
||||||
---------
|
---------
|
||||||
|
|
@ -95,8 +95,8 @@ Read More
|
||||||
`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다.
|
`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다.
|
||||||
트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수 있다.
|
트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수 있다.
|
||||||
|
|
||||||
.. _its Web site: http://beets.io/
|
.. _its Web site: https://beets.io/
|
||||||
.. _@b33ts: http://twitter.com/b33ts/
|
.. _@b33ts: https://twitter.com/b33ts/
|
||||||
|
|
||||||
저자들
|
저자들
|
||||||
-------
|
-------
|
||||||
|
|
@ -105,4 +105,4 @@ Read More
|
||||||
돕고 싶다면 `forum`_.를 방문하면 된다.
|
돕고 싶다면 `forum`_.를 방문하면 된다.
|
||||||
|
|
||||||
.. _forum: https://discourse.beets.io
|
.. _forum: https://discourse.beets.io
|
||||||
.. _Adrian Sampson: http://www.cs.cornell.edu/~asampson/
|
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
|
||||||
|
|
|
||||||
10
appveyor.yml
10
appveyor.yml
|
|
@ -16,14 +16,16 @@ environment:
|
||||||
TOX_ENV: py35-test
|
TOX_ENV: py35-test
|
||||||
- PYTHON: C:\Python36
|
- PYTHON: C:\Python36
|
||||||
TOX_ENV: py36-test
|
TOX_ENV: py36-test
|
||||||
|
- PYTHON: C:\Python37
|
||||||
|
TOX_ENV: py37-test
|
||||||
|
|
||||||
# Install Tox for running tests.
|
# Install Tox for running tests.
|
||||||
install:
|
install:
|
||||||
- cinst imagemagick -y
|
- appveyor-retry cinst imagemagick -y
|
||||||
# TODO: remove --allow-empty-checksums when unrar offers a proper checksum
|
# TODO: remove --allow-empty-checksums when unrar offers a proper checksum
|
||||||
- cinst unrar -y --allow-empty-checksums
|
- appveyor-retry cinst unrar -y --allow-empty-checksums
|
||||||
- "%PYTHON%/Scripts/pip.exe install tox"
|
- 'appveyor-retry %PYTHON%/Scripts/pip.exe install "tox<=3.8.1"'
|
||||||
- "%PYTHON%/Scripts/tox.exe -e %TOX_ENV% --notest"
|
- "appveyor-retry %PYTHON%/Scripts/tox.exe -e %TOX_ENV% --notest"
|
||||||
|
|
||||||
test_script:
|
test_script:
|
||||||
- "%PYTHON%/Scripts/tox.exe -e %TOX_ENV%"
|
- "%PYTHON%/Scripts/tox.exe -e %TOX_ENV%"
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,14 @@ from __future__ import division, absolute_import, print_function
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from beets.util import confit
|
import confuse
|
||||||
|
|
||||||
__version__ = u'1.4.8'
|
__version__ = u'1.5.0'
|
||||||
__author__ = u'Adrian Sampson <adrian@radbox.org>'
|
__author__ = u'Adrian Sampson <adrian@radbox.org>'
|
||||||
|
|
||||||
|
|
||||||
class IncludeLazyConfig(confit.LazyConfig):
|
class IncludeLazyConfig(confuse.LazyConfig):
|
||||||
"""A version of Confit's LazyConfig that also merges in data from
|
"""A version of Confuse's LazyConfig that also merges in data from
|
||||||
YAML files specified in an `include` setting.
|
YAML files specified in an `include` setting.
|
||||||
"""
|
"""
|
||||||
def read(self, user=True, defaults=True):
|
def read(self, user=True, defaults=True):
|
||||||
|
|
@ -35,7 +35,7 @@ class IncludeLazyConfig(confit.LazyConfig):
|
||||||
filename = view.as_filename()
|
filename = view.as_filename()
|
||||||
if os.path.isfile(filename):
|
if os.path.isfile(filename):
|
||||||
self.set_file(filename)
|
self.set_file(filename)
|
||||||
except confit.NotFoundError:
|
except confuse.NotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import os
|
||||||
|
|
||||||
from beets.util import displayable_path, syspath, bytestring_path
|
from beets.util import displayable_path, syspath, bytestring_path
|
||||||
from beets.util.artresizer import ArtResizer
|
from beets.util.artresizer import ArtResizer
|
||||||
from beets import mediafile
|
import mediafile
|
||||||
|
|
||||||
|
|
||||||
def mediafile_image(image_path, maxwidth=None):
|
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,
|
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.
|
"""Embed an image into the item's media file.
|
||||||
"""
|
"""
|
||||||
# Conditions and filters.
|
# Conditions and filters.
|
||||||
|
|
@ -80,7 +81,7 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
|
||||||
image.mime_type)
|
image.mime_type)
|
||||||
return
|
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,
|
def embed_album(log, album, maxwidth=None, quiet=False,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ def apply_item_metadata(item, track_info):
|
||||||
item.composer_sort = track_info.composer_sort
|
item.composer_sort = track_info.composer_sort
|
||||||
if track_info.arranger is not None:
|
if track_info.arranger is not None:
|
||||||
item.arranger = track_info.arranger
|
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
|
# At the moment, the other metadata is left intact (including album
|
||||||
# and track number). Perhaps these should be emptied?
|
# and track number). Perhaps these should be emptied?
|
||||||
|
|
@ -167,6 +173,9 @@ def apply_metadata(album_info, mapping):
|
||||||
'composer',
|
'composer',
|
||||||
'composer_sort',
|
'composer_sort',
|
||||||
'arranger',
|
'arranger',
|
||||||
|
'work',
|
||||||
|
'mb_workid',
|
||||||
|
'work_disambig',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,9 @@ class TrackInfo(object):
|
||||||
- ``composer_sort``: individual track composer sort name
|
- ``composer_sort``: individual track composer sort name
|
||||||
- ``arranger`: individual track arranger name
|
- ``arranger`: individual track arranger name
|
||||||
- ``track_alt``: alternative track number (tape, vinyl, etc.)
|
- ``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
|
Only ``title`` and ``track_id`` are required. The rest of the fields
|
||||||
may be None. The indices ``index``, ``medium``, and ``medium_index``
|
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,
|
medium_index=None, medium_total=None, artist_sort=None,
|
||||||
disctitle=None, artist_credit=None, data_source=None,
|
disctitle=None, artist_credit=None, data_source=None,
|
||||||
data_url=None, media=None, lyricist=None, composer=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.title = title
|
||||||
self.track_id = track_id
|
self.track_id = track_id
|
||||||
self.release_track_id = release_track_id
|
self.release_track_id = release_track_id
|
||||||
|
|
@ -191,6 +195,9 @@ class TrackInfo(object):
|
||||||
self.composer_sort = composer_sort
|
self.composer_sort = composer_sort
|
||||||
self.arranger = arranger
|
self.arranger = arranger
|
||||||
self.track_alt = track_alt
|
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.
|
# As above, work around a bug in python-musicbrainz-ngs.
|
||||||
def decode(self, codec='utf-8'):
|
def decode(self, codec='utf-8'):
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ else:
|
||||||
SKIPPED_TRACKS = ['[data track]']
|
SKIPPED_TRACKS = ['[data track]']
|
||||||
|
|
||||||
musicbrainzngs.set_useragent('beets', beets.__version__,
|
musicbrainzngs.set_useragent('beets', beets.__version__,
|
||||||
'http://beets.io/')
|
'https://beets.io/')
|
||||||
|
|
||||||
|
|
||||||
class MusicBrainzAPIError(util.HumanReadableException):
|
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', ()):
|
for work_relation in recording.get('work-relation-list', ()):
|
||||||
if work_relation['type'] != 'performance':
|
if work_relation['type'] != 'performance':
|
||||||
continue
|
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(
|
for artist_relation in work_relation['work'].get(
|
||||||
'artist-relation-list', ()):
|
'artist-relation-list', ()):
|
||||||
if 'type' in artist_relation:
|
if 'type' in artist_relation:
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,17 @@ from collections import defaultdict
|
||||||
import threading
|
import threading
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import contextlib
|
import contextlib
|
||||||
import collections
|
|
||||||
|
|
||||||
import beets
|
import beets
|
||||||
from beets.util.functemplate import Template
|
from beets.util import functemplate
|
||||||
from beets.util import py3_path
|
from beets.util import py3_path
|
||||||
from beets.dbcore import types
|
from beets.dbcore import types
|
||||||
from .query import MatchQuery, NullSort, TrueQuery
|
from .query import MatchQuery, NullSort, TrueQuery
|
||||||
import six
|
import six
|
||||||
|
if six.PY2:
|
||||||
|
from collections import Mapping
|
||||||
|
else:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
|
||||||
class DBAccessError(Exception):
|
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.
|
"""A `dict`-like formatted view of a model.
|
||||||
|
|
||||||
The accessor `mapping[key]` returns the formatted version of
|
The accessor `mapping[key]` returns the formatted version of
|
||||||
|
|
@ -88,6 +91,100 @@ class FormattedMapping(collections.Mapping):
|
||||||
return value
|
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.
|
# Abstract base for model classes.
|
||||||
|
|
||||||
class Model(object):
|
class Model(object):
|
||||||
|
|
@ -143,6 +240,11 @@ class Model(object):
|
||||||
are subclasses of `Sort`.
|
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
|
_always_dirty = False
|
||||||
"""By default, fields only become "dirty" when their value actually
|
"""By default, fields only become "dirty" when their value actually
|
||||||
changes. Enabling this flag marks fields as dirty even when the new
|
changes. Enabling this flag marks fields as dirty even when the new
|
||||||
|
|
@ -172,8 +274,8 @@ class Model(object):
|
||||||
"""
|
"""
|
||||||
self._db = db
|
self._db = db
|
||||||
self._dirty = set()
|
self._dirty = set()
|
||||||
self._values_fixed = {}
|
self._values_fixed = LazyConvertDict(self)
|
||||||
self._values_flex = {}
|
self._values_flex = LazyConvertDict(self)
|
||||||
|
|
||||||
# Initial contents.
|
# Initial contents.
|
||||||
self.update(values)
|
self.update(values)
|
||||||
|
|
@ -187,10 +289,10 @@ class Model(object):
|
||||||
ordinary construction are bypassed.
|
ordinary construction are bypassed.
|
||||||
"""
|
"""
|
||||||
obj = cls(db)
|
obj = cls(db)
|
||||||
for key, value in fixed_values.items():
|
|
||||||
obj._values_fixed[key] = cls._type(key).from_sql(value)
|
obj._values_fixed.init(fixed_values)
|
||||||
for key, value in flex_values.items():
|
obj._values_flex.init(flex_values)
|
||||||
obj._values_flex[key] = cls._type(key).from_sql(value)
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|
@ -251,7 +353,10 @@ class Model(object):
|
||||||
if key in getters: # Computed.
|
if key in getters: # Computed.
|
||||||
return getters[key](self)
|
return getters[key](self)
|
||||||
elif key in self._fields: # Fixed.
|
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.
|
elif key in self._values_flex: # Flexible.
|
||||||
return self._values_flex[key]
|
return self._values_flex[key]
|
||||||
else:
|
else:
|
||||||
|
|
@ -431,8 +536,8 @@ class Model(object):
|
||||||
self._check_db()
|
self._check_db()
|
||||||
stored_obj = self._db._get(type(self), self.id)
|
stored_obj = self._db._get(type(self), self.id)
|
||||||
assert stored_obj is not None, u"object {0} not in DB".format(self.id)
|
assert stored_obj is not None, u"object {0} not in DB".format(self.id)
|
||||||
self._values_fixed = {}
|
self._values_fixed = LazyConvertDict(self)
|
||||||
self._values_flex = {}
|
self._values_flex = LazyConvertDict(self)
|
||||||
self.update(dict(stored_obj))
|
self.update(dict(stored_obj))
|
||||||
self.clear_dirty()
|
self.clear_dirty()
|
||||||
|
|
||||||
|
|
@ -492,7 +597,7 @@ class Model(object):
|
||||||
"""
|
"""
|
||||||
# Perform substitution.
|
# Perform substitution.
|
||||||
if isinstance(template, six.string_types):
|
if isinstance(template, six.string_types):
|
||||||
template = Template(template)
|
template = functemplate.template(template)
|
||||||
return template.substitute(self.formatted(for_path),
|
return template.substitute(self.formatted(for_path),
|
||||||
self._template_funcs())
|
self._template_funcs())
|
||||||
|
|
||||||
|
|
@ -519,7 +624,8 @@ class Results(object):
|
||||||
"""An item query result set. Iterating over the collection lazily
|
"""An item query result set. Iterating over the collection lazily
|
||||||
constructs LibModel objects that reflect database rows.
|
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
|
"""Create a result set that will construct objects of type
|
||||||
`model_class`.
|
`model_class`.
|
||||||
|
|
||||||
|
|
@ -539,6 +645,7 @@ class Results(object):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.query = query
|
self.query = query
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
|
self.flex_rows = flex_rows
|
||||||
|
|
||||||
# We keep a queue of rows we haven't yet consumed for
|
# We keep a queue of rows we haven't yet consumed for
|
||||||
# materialization. We preserve the original total number of
|
# 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
|
a `Results` object a second time should be much faster than the
|
||||||
first.
|
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.
|
index = 0 # Position in the materialized objects.
|
||||||
while index < len(self._objects) or self._rows:
|
while index < len(self._objects) or self._rows:
|
||||||
# Are there previously-materialized objects to produce?
|
# Are there previously-materialized objects to produce?
|
||||||
|
|
@ -572,7 +683,7 @@ class Results(object):
|
||||||
else:
|
else:
|
||||||
while self._rows:
|
while self._rows:
|
||||||
row = self._rows.pop(0)
|
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
|
# If there is a slow-query predicate, ensurer that the
|
||||||
# object passes it.
|
# object passes it.
|
||||||
if not self.query or self.query.match(obj):
|
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).
|
# Objects are pre-sorted (i.e., by the database).
|
||||||
return self._get_objects()
|
return self._get_objects()
|
||||||
|
|
||||||
def _make_model(self, row):
|
def _get_indexed_flex_attrs(self):
|
||||||
# Get the flexible attributes for the object.
|
""" Index flexible attributes by the entity id they belong to
|
||||||
with self.db.transaction() as tx:
|
"""
|
||||||
flex_rows = tx.query(
|
flex_values = dict()
|
||||||
'SELECT * FROM {0} WHERE entity_id=?'.format(
|
for row in self.flex_rows:
|
||||||
self.model_class._flex_table
|
if row['entity_id'] not in flex_values:
|
||||||
),
|
flex_values[row['entity_id']] = dict()
|
||||||
(row['id'],)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
flex_values[row['entity_id']][row['key']] = row['value']
|
||||||
|
|
||||||
|
return flex_values
|
||||||
|
|
||||||
|
def _make_model(self, row, flex_values={}):
|
||||||
|
""" Create a Model object for the given row
|
||||||
|
"""
|
||||||
cols = dict(row)
|
cols = dict(row)
|
||||||
values = dict((k, v) for (k, v) in cols.items()
|
values = dict((k, v) for (k, v) in cols.items()
|
||||||
if not k[:4] == 'flex')
|
if not k[:4] == 'flex')
|
||||||
flex_values = dict((row['key'], row['value']) for row in flex_rows)
|
|
||||||
|
|
||||||
# Construct the Python object
|
# Construct the Python object
|
||||||
obj = self.model_class._awaken(self.db, values, flex_values)
|
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
|
"""A container for Model objects that wraps an SQLite database as
|
||||||
the backend.
|
the backend.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_models = ()
|
_models = ()
|
||||||
"""The Model subclasses representing tables in this database.
|
"""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):
|
def __init__(self, path, timeout=5.0):
|
||||||
self.path = path
|
self.path = path
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
self._connections = {}
|
self._connections = {}
|
||||||
self._tx_stacks = defaultdict(list)
|
self._tx_stacks = defaultdict(list)
|
||||||
|
self._extensions = []
|
||||||
|
|
||||||
# A lock to protect the _connections and _tx_stacks maps, which
|
# A lock to protect the _connections and _tx_stacks maps, which
|
||||||
# both map thread IDs to private resources.
|
# both map thread IDs to private resources.
|
||||||
|
|
@ -794,6 +914,13 @@ class Database(object):
|
||||||
py3_path(self.path), timeout=self.timeout
|
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.
|
# Access SELECT results like dictionaries.
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
@ -822,6 +949,18 @@ class Database(object):
|
||||||
"""
|
"""
|
||||||
return Transaction(self)
|
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.
|
# Schema setup and migration.
|
||||||
|
|
||||||
def _make_table(self, table, fields):
|
def _make_table(self, table, fields):
|
||||||
|
|
@ -894,11 +1033,25 @@ class Database(object):
|
||||||
"ORDER BY {0}".format(order_by) if order_by else '',
|
"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:
|
with self.transaction() as tx:
|
||||||
rows = tx.query(sql, subvals)
|
rows = tx.query(sql, subvals)
|
||||||
|
flex_rows = tx.query(flex_sql, subvals)
|
||||||
|
|
||||||
return Results(
|
return Results(
|
||||||
model_cls, rows, self,
|
model_cls, rows, self, flex_rows,
|
||||||
None if where else query, # Slow query component.
|
None if where else query, # Slow query component.
|
||||||
sort if sort.is_slow() else None, # Slow sort component.
|
sort if sort.is_slow() else None, # Slow sort component.
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ from __future__ import division, absolute_import, print_function
|
||||||
import re
|
import re
|
||||||
import itertools
|
import itertools
|
||||||
from . import query
|
from . import query
|
||||||
import beets
|
|
||||||
|
|
||||||
PARSE_QUERY_PART_REGEX = re.compile(
|
PARSE_QUERY_PART_REGEX = re.compile(
|
||||||
# Non-capturing optional segment for the keyword.
|
# Non-capturing optional segment for the keyword.
|
||||||
|
|
@ -119,12 +118,13 @@ def construct_query_part(model_cls, prefixes, query_part):
|
||||||
if not query_part:
|
if not query_part:
|
||||||
return query.TrueQuery()
|
return query.TrueQuery()
|
||||||
|
|
||||||
# Use `model_cls` to build up a map from field names to `Query`
|
# Use `model_cls` to build up a map from field (or query) names to
|
||||||
# classes.
|
# `Query` classes.
|
||||||
query_classes = {}
|
query_classes = {}
|
||||||
for k, t in itertools.chain(model_cls._fields.items(),
|
for k, t in itertools.chain(model_cls._fields.items(),
|
||||||
model_cls._types.items()):
|
model_cls._types.items()):
|
||||||
query_classes[k] = t.query
|
query_classes[k] = t.query
|
||||||
|
query_classes.update(model_cls._queries) # Non-field queries.
|
||||||
|
|
||||||
# Parse the string.
|
# Parse the string.
|
||||||
key, pattern, query_class, negate = \
|
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
|
# The query type matches a specific field, but none was
|
||||||
# specified. So we use a version of the query that matches
|
# specified. So we use a version of the query that matches
|
||||||
# any field.
|
# any field.
|
||||||
q = query.AnyFieldQuery(pattern, model_cls._search_fields,
|
out_query = query.AnyFieldQuery(pattern, model_cls._search_fields,
|
||||||
query_class)
|
query_class)
|
||||||
if negate:
|
|
||||||
return query.NotQuery(q)
|
|
||||||
else:
|
|
||||||
return q
|
|
||||||
else:
|
else:
|
||||||
# Non-field query type.
|
# Non-field query type.
|
||||||
if negate:
|
out_query = query_class(pattern)
|
||||||
return query.NotQuery(query_class(pattern))
|
|
||||||
else:
|
|
||||||
return query_class(pattern)
|
|
||||||
|
|
||||||
# Otherwise, this must be a `FieldQuery`. Use the field name to
|
# Field queries get constructed according to the name of the field
|
||||||
# construct the query object.
|
# they are querying.
|
||||||
|
elif issubclass(query_class, query.FieldQuery):
|
||||||
key = key.lower()
|
key = key.lower()
|
||||||
q = query_class(key.lower(), pattern, key in model_cls._fields)
|
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:
|
if negate:
|
||||||
return query.NotQuery(q)
|
return query.NotQuery(out_query)
|
||||||
return q
|
else:
|
||||||
|
return out_query
|
||||||
|
|
||||||
|
|
||||||
def query_from_strings(query_cls, model_cls, prefixes, query_parts):
|
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)
|
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.
|
"""Create a `Sort` from a single string criterion.
|
||||||
|
|
||||||
`model_cls` is the `Model` being queried. `part` is a single string
|
`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 -"
|
assert part, "part must be a field name and + or -"
|
||||||
field = part[:-1]
|
field = part[:-1]
|
||||||
|
|
@ -185,7 +188,6 @@ def construct_sort_part(model_cls, part):
|
||||||
assert direction in ('+', '-'), "part must end with + or -"
|
assert direction in ('+', '-'), "part must end with + or -"
|
||||||
is_ascending = direction == '+'
|
is_ascending = direction == '+'
|
||||||
|
|
||||||
case_insensitive = beets.config['sort_case_insensitive'].get(bool)
|
|
||||||
if field in model_cls._sorts:
|
if field in model_cls._sorts:
|
||||||
sort = model_cls._sorts[field](model_cls, is_ascending,
|
sort = model_cls._sorts[field](model_cls, is_ascending,
|
||||||
case_insensitive)
|
case_insensitive)
|
||||||
|
|
@ -197,21 +199,23 @@ def construct_sort_part(model_cls, part):
|
||||||
return sort
|
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).
|
"""Create a `Sort` from a list of sort criteria (strings).
|
||||||
"""
|
"""
|
||||||
if not sort_parts:
|
if not sort_parts:
|
||||||
sort = query.NullSort()
|
sort = query.NullSort()
|
||||||
elif len(sort_parts) == 1:
|
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:
|
else:
|
||||||
sort = query.MultipleSort()
|
sort = query.MultipleSort()
|
||||||
for part in sort_parts:
|
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
|
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
|
"""Given a list of strings, create the `Query` and `Sort` that they
|
||||||
represent.
|
represent.
|
||||||
"""
|
"""
|
||||||
|
|
@ -246,5 +250,5 @@ def parse_sorted_query(model_cls, parts, prefixes={}):
|
||||||
|
|
||||||
# Avoid needlessly wrapping single statements in an OR
|
# Avoid needlessly wrapping single statements in an OR
|
||||||
q = query.OrQuery(query_parts) if len(query_parts) > 1 else query_parts[0]
|
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
|
return q, s
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ class Type(object):
|
||||||
For fixed fields the type of `value` is determined by the column
|
For fixed fields the type of `value` is determined by the column
|
||||||
type affinity given in the `sql` property and the SQL to Python
|
type affinity given in the `sql` property and the SQL to Python
|
||||||
mapping of the database adapter. For more information see:
|
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
|
https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types
|
||||||
|
|
||||||
Flexible fields have the type affinity `TEXT`. This means the
|
Flexible fields have the type affinity `TEXT`. This means the
|
||||||
|
|
@ -173,14 +173,18 @@ class Id(Integer):
|
||||||
|
|
||||||
|
|
||||||
class Float(Type):
|
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'
|
sql = u'REAL'
|
||||||
query = query.NumericQuery
|
query = query.NumericQuery
|
||||||
model_type = float
|
model_type = float
|
||||||
|
|
||||||
|
def __init__(self, digits=1):
|
||||||
|
self.digits = digits
|
||||||
|
|
||||||
def format(self, value):
|
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):
|
class NullFloat(Float):
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ from beets import config
|
||||||
from beets.util import pipeline, sorted_walk, ancestry, MoveOperation
|
from beets.util import pipeline, sorted_walk, ancestry, MoveOperation
|
||||||
from beets.util import syspath, normpath, displayable_path
|
from beets.util import syspath, normpath, displayable_path
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from beets import mediafile
|
import mediafile
|
||||||
|
|
||||||
action = Enum('action',
|
action = Enum('action',
|
||||||
['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG'])
|
['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG'])
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,12 @@ import six
|
||||||
import string
|
import string
|
||||||
|
|
||||||
from beets import logging
|
from beets import logging
|
||||||
from beets.mediafile import MediaFile, UnreadableFileError
|
from mediafile import MediaFile, UnreadableFileError
|
||||||
from beets import plugins
|
from beets import plugins
|
||||||
from beets import util
|
from beets import util
|
||||||
from beets.util import bytestring_path, syspath, normpath, samefile, \
|
from beets.util import bytestring_path, syspath, normpath, samefile, \
|
||||||
MoveOperation
|
MoveOperation, lazy_property
|
||||||
from beets.util.functemplate import Template
|
from beets.util.functemplate import template, Template
|
||||||
from beets import dbcore
|
from beets import dbcore
|
||||||
from beets.dbcore import types
|
from beets.dbcore import types
|
||||||
import beets
|
import beets
|
||||||
|
|
@ -376,13 +376,25 @@ class FormattedItemMapping(dbcore.db.FormattedMapping):
|
||||||
|
|
||||||
def __init__(self, item, for_path=False):
|
def __init__(self, item, for_path=False):
|
||||||
super(FormattedItemMapping, self).__init__(item, for_path)
|
super(FormattedItemMapping, self).__init__(item, for_path)
|
||||||
self.album = item.get_album()
|
self.item = item
|
||||||
self.album_keys = []
|
|
||||||
|
@lazy_property
|
||||||
|
def all_keys(self):
|
||||||
|
return set(self.model_keys).union(self.album_keys)
|
||||||
|
|
||||||
|
@lazy_property
|
||||||
|
def album_keys(self):
|
||||||
|
album_keys = []
|
||||||
if self.album:
|
if self.album:
|
||||||
for key in self.album.keys(True):
|
for key in self.album.keys(True):
|
||||||
if key in Album.item_keys or key not in item._fields.keys():
|
if key in Album.item_keys \
|
||||||
self.album_keys.append(key)
|
or key not in self.item._fields.keys():
|
||||||
self.all_keys = set(self.model_keys).union(self.album_keys)
|
album_keys.append(key)
|
||||||
|
return album_keys
|
||||||
|
|
||||||
|
@lazy_property
|
||||||
|
def album(self):
|
||||||
|
return self.item.get_album()
|
||||||
|
|
||||||
def _get(self, key):
|
def _get(self, key):
|
||||||
"""Get the value for a key, either from the album or the item.
|
"""Get the value for a key, either from the album or the item.
|
||||||
|
|
@ -439,6 +451,9 @@ class Item(LibModel):
|
||||||
'lyricist': types.STRING,
|
'lyricist': types.STRING,
|
||||||
'composer': types.STRING,
|
'composer': types.STRING,
|
||||||
'composer_sort': types.STRING,
|
'composer_sort': types.STRING,
|
||||||
|
'work': types.STRING,
|
||||||
|
'mb_workid': types.STRING,
|
||||||
|
'work_disambig': types.STRING,
|
||||||
'arranger': types.STRING,
|
'arranger': types.STRING,
|
||||||
'grouping': types.STRING,
|
'grouping': types.STRING,
|
||||||
'year': types.PaddedInt(4),
|
'year': types.PaddedInt(4),
|
||||||
|
|
@ -611,7 +626,7 @@ class Item(LibModel):
|
||||||
|
|
||||||
self.path = read_path
|
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.
|
"""Write the item's metadata to a media file.
|
||||||
|
|
||||||
All fields in `_media_fields` are written to disk according to
|
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
|
`tags` is a dictionary of additional metadata the should be
|
||||||
written to the file. (These tags need not be in `_media_fields`.)
|
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`.
|
Can raise either a `ReadError` or a `WriteError`.
|
||||||
"""
|
"""
|
||||||
if path is None:
|
if path is None:
|
||||||
|
|
@ -630,6 +648,9 @@ class Item(LibModel):
|
||||||
else:
|
else:
|
||||||
path = normpath(path)
|
path = normpath(path)
|
||||||
|
|
||||||
|
if id3v23 is None:
|
||||||
|
id3v23 = beets.config['id3v23'].get(bool)
|
||||||
|
|
||||||
# Get the data to write to the file.
|
# Get the data to write to the file.
|
||||||
item_tags = dict(self)
|
item_tags = dict(self)
|
||||||
item_tags = {k: v for k, v in item_tags.items()
|
item_tags = {k: v for k, v in item_tags.items()
|
||||||
|
|
@ -640,8 +661,7 @@ class Item(LibModel):
|
||||||
|
|
||||||
# Open the file.
|
# Open the file.
|
||||||
try:
|
try:
|
||||||
mediafile = MediaFile(syspath(path),
|
mediafile = MediaFile(syspath(path), id3v23=id3v23)
|
||||||
id3v23=beets.config['id3v23'].get(bool))
|
|
||||||
except UnreadableFileError as exc:
|
except UnreadableFileError as exc:
|
||||||
raise ReadError(path, exc)
|
raise ReadError(path, exc)
|
||||||
|
|
||||||
|
|
@ -657,14 +677,14 @@ class Item(LibModel):
|
||||||
self.mtime = self.current_mtime()
|
self.mtime = self.current_mtime()
|
||||||
plugins.send('after_write', item=self, path=path)
|
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`
|
"""Calls `write()` but catches and logs `FileOperationError`
|
||||||
exceptions.
|
exceptions.
|
||||||
|
|
||||||
Returns `False` an exception was caught and `True` otherwise.
|
Returns `False` an exception was caught and `True` otherwise.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self.write(path, tags)
|
self.write(*args, **kwargs)
|
||||||
return True
|
return True
|
||||||
except FileOperationError as exc:
|
except FileOperationError as exc:
|
||||||
log.error(u"{0}", exc)
|
log.error(u"{0}", exc)
|
||||||
|
|
@ -850,7 +870,7 @@ class Item(LibModel):
|
||||||
if isinstance(path_format, Template):
|
if isinstance(path_format, Template):
|
||||||
subpath_tmpl = path_format
|
subpath_tmpl = path_format
|
||||||
else:
|
else:
|
||||||
subpath_tmpl = Template(path_format)
|
subpath_tmpl = template(path_format)
|
||||||
|
|
||||||
# Evaluate the selected template.
|
# Evaluate the selected template.
|
||||||
subpath = self.evaluate_template(subpath_tmpl, True)
|
subpath = self.evaluate_template(subpath_tmpl, True)
|
||||||
|
|
@ -930,7 +950,7 @@ class Album(LibModel):
|
||||||
'releasegroupdisambig': types.STRING,
|
'releasegroupdisambig': types.STRING,
|
||||||
'rg_album_gain': types.NULL_FLOAT,
|
'rg_album_gain': types.NULL_FLOAT,
|
||||||
'rg_album_peak': 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_year': types.PaddedInt(4),
|
||||||
'original_month': types.PaddedInt(2),
|
'original_month': types.PaddedInt(2),
|
||||||
'original_day': types.PaddedInt(2),
|
'original_day': types.PaddedInt(2),
|
||||||
|
|
@ -1129,7 +1149,7 @@ class Album(LibModel):
|
||||||
image = bytestring_path(image)
|
image = bytestring_path(image)
|
||||||
item_dir = item_dir or self.item_dir()
|
item_dir = item_dir or self.item_dir()
|
||||||
|
|
||||||
filename_tmpl = Template(
|
filename_tmpl = template(
|
||||||
beets.config['art_filename'].as_str())
|
beets.config['art_filename'].as_str())
|
||||||
subpath = self.evaluate_template(filename_tmpl, True)
|
subpath = self.evaluate_template(filename_tmpl, True)
|
||||||
if beets.config['asciify_paths']:
|
if beets.config['asciify_paths']:
|
||||||
|
|
@ -1234,8 +1254,10 @@ def parse_query_parts(parts, model_cls):
|
||||||
else:
|
else:
|
||||||
non_path_parts.append(s)
|
non_path_parts.append(s)
|
||||||
|
|
||||||
|
case_insensitive = beets.config['sort_case_insensitive'].get(bool)
|
||||||
|
|
||||||
query, sort = dbcore.parse_sorted_query(
|
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.
|
# Add path queries to aggregate query.
|
||||||
|
|
|
||||||
2096
beets/mediafile.py
2096
beets/mediafile.py
File diff suppressed because it is too large
Load diff
|
|
@ -17,16 +17,16 @@
|
||||||
|
|
||||||
from __future__ import division, absolute_import, print_function
|
from __future__ import division, absolute_import, print_function
|
||||||
|
|
||||||
import inspect
|
|
||||||
import traceback
|
import traceback
|
||||||
import re
|
import re
|
||||||
|
import inspect
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
import beets
|
import beets
|
||||||
from beets import logging
|
from beets import logging
|
||||||
from beets import mediafile
|
import mediafile
|
||||||
import six
|
import six
|
||||||
|
|
||||||
PLUGIN_NAMESPACE = 'beetsplug'
|
PLUGIN_NAMESPACE = 'beetsplug'
|
||||||
|
|
@ -127,7 +127,10 @@ class BeetsPlugin(object):
|
||||||
value after the function returns). Also determines which params may not
|
value after the function returns). Also determines which params may not
|
||||||
be sent for backwards-compatibility.
|
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)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
|
|
@ -142,7 +145,7 @@ class BeetsPlugin(object):
|
||||||
if exc.args[0].startswith(func.__name__):
|
if exc.args[0].startswith(func.__name__):
|
||||||
# caused by 'func' and not stuff internal to 'func'
|
# caused by 'func' and not stuff internal to 'func'
|
||||||
kwargs = dict((arg, val) for arg, val in kwargs.items()
|
kwargs = dict((arg, val) for arg, val in kwargs.items()
|
||||||
if arg in argspec.args)
|
if arg in func_args)
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
@ -344,6 +347,16 @@ def types(model_cls):
|
||||||
return types
|
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):
|
def track_distance(item, info):
|
||||||
"""Gets the track distance calculated by all loaded plugins.
|
"""Gets the track distance calculated by all loaded plugins.
|
||||||
Returns a Distance object.
|
Returns a Distance object.
|
||||||
|
|
@ -513,7 +526,7 @@ def sanitize_choices(choices, choices_all):
|
||||||
|
|
||||||
def sanitize_pairs(pairs, pairs_all):
|
def sanitize_pairs(pairs, pairs_all):
|
||||||
"""Clean up a single-element mapping configuration attribute as returned
|
"""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 ('*', '*')
|
pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
|
||||||
wildcards while keeping the original order. Note that ('*', '*') and
|
wildcards while keeping the original order. Note that ('*', '*') and
|
||||||
('*', 'whatever') have the same effect.
|
('*', 'whatever') have the same effect.
|
||||||
|
|
|
||||||
115
beets/random.py
Normal file
115
beets/random.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -36,12 +36,13 @@ from beets import logging
|
||||||
from beets import library
|
from beets import library
|
||||||
from beets import plugins
|
from beets import plugins
|
||||||
from beets import util
|
from beets import util
|
||||||
from beets.util.functemplate import Template
|
from beets.util.functemplate import template
|
||||||
from beets import config
|
from beets import config
|
||||||
from beets.util import confit, as_string
|
from beets.util import as_string
|
||||||
from beets.autotag import mb
|
from beets.autotag import mb
|
||||||
from beets.dbcore import query as db_query
|
from beets.dbcore import query as db_query
|
||||||
from beets.dbcore import db
|
from beets.dbcore import db
|
||||||
|
import confuse
|
||||||
import six
|
import six
|
||||||
|
|
||||||
# On Windows platforms, use colorama to support "ANSI" terminal colors.
|
# 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
|
# raw_input incorrectly sends prompts to stderr, not stdout, so we
|
||||||
# use print_() explicitly to display prompts.
|
# use print_() explicitly to display prompts.
|
||||||
# http://bugs.python.org/issue1927
|
# https://bugs.python.org/issue1927
|
||||||
if prompt:
|
if prompt:
|
||||||
print_(prompt, end=u' ')
|
print_(prompt, end=u' ')
|
||||||
|
|
||||||
|
|
@ -474,7 +475,7 @@ def human_seconds_short(interval):
|
||||||
# Colorization.
|
# Colorization.
|
||||||
|
|
||||||
# ANSI terminal colorization code heavily inspired by pygments:
|
# 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.)
|
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
|
||||||
COLOR_ESCAPE = "\x1b["
|
COLOR_ESCAPE = "\x1b["
|
||||||
DARK_COLORS = {
|
DARK_COLORS = {
|
||||||
|
|
@ -529,7 +530,9 @@ def colorize(color_name, text):
|
||||||
"""Colorize text if colored output is enabled. (Like _colorize but
|
"""Colorize text if colored output is enabled. (Like _colorize but
|
||||||
conditional.)
|
conditional.)
|
||||||
"""
|
"""
|
||||||
if config['ui']['color']:
|
if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys():
|
||||||
|
return text
|
||||||
|
|
||||||
global COLORS
|
global COLORS
|
||||||
if not COLORS:
|
if not COLORS:
|
||||||
COLORS = dict((name,
|
COLORS = dict((name,
|
||||||
|
|
@ -542,8 +545,6 @@ def colorize(color_name, text):
|
||||||
log.debug(u'Invalid color_name: {0}', color_name)
|
log.debug(u'Invalid color_name: {0}', color_name)
|
||||||
color = color_name
|
color = color_name
|
||||||
return _colorize(color, text)
|
return _colorize(color, text)
|
||||||
else:
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _colordiff(a, b, highlight='text_highlight',
|
def _colordiff(a, b, highlight='text_highlight',
|
||||||
|
|
@ -616,12 +617,12 @@ def get_path_formats(subview=None):
|
||||||
subview = subview or config['paths']
|
subview = subview or config['paths']
|
||||||
for query, view in subview.items():
|
for query, view in subview.items():
|
||||||
query = PF_KEY_QUERIES.get(query, query) # Expand common queries.
|
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
|
return path_formats
|
||||||
|
|
||||||
|
|
||||||
def get_replacements():
|
def get_replacements():
|
||||||
"""Confit validation function that reads regex/string pairs.
|
"""Confuse validation function that reads regex/string pairs.
|
||||||
"""
|
"""
|
||||||
replacements = []
|
replacements = []
|
||||||
for pattern, repl in config['replace'].get(dict).items():
|
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
|
# This is a fairly generic subcommand parser for optparse. It is
|
||||||
# maintained externally here:
|
# 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
|
# There you will also find a better description of the code and a more
|
||||||
# succinct example program.
|
# succinct example program.
|
||||||
|
|
||||||
|
|
@ -1143,8 +1144,12 @@ def _setup(options, lib=None):
|
||||||
if lib is None:
|
if lib is None:
|
||||||
lib = _open_library(config)
|
lib = _open_library(config)
|
||||||
plugins.send("library_opened", lib=lib)
|
plugins.send("library_opened", lib=lib)
|
||||||
|
|
||||||
|
# Add types and queries defined by plugins.
|
||||||
library.Item._types.update(plugins.types(library.Item))
|
library.Item._types.update(plugins.types(library.Item))
|
||||||
library.Album._types.update(plugins.types(library.Album))
|
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
|
return subcommands, plugins, lib
|
||||||
|
|
||||||
|
|
@ -1273,7 +1278,7 @@ def main(args=None):
|
||||||
log.debug('{}', traceback.format_exc())
|
log.debug('{}', traceback.format_exc())
|
||||||
log.error('{}', exc)
|
log.error('{}', exc)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except confit.ConfigError as exc:
|
except confuse.ConfigError as exc:
|
||||||
log.error(u'configuration error: {0}', exc)
|
log.error(u'configuration error: {0}', exc)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except db_query.InvalidQueryError as exc:
|
except db_query.InvalidQueryError as exc:
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ from beets.util import syspath, normpath, ancestry, displayable_path, \
|
||||||
from beets import library
|
from beets import library
|
||||||
from beets import config
|
from beets import config
|
||||||
from beets import logging
|
from beets import logging
|
||||||
from beets.util.confit import _package_path
|
|
||||||
import six
|
import six
|
||||||
from . import _store_dict
|
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."
|
print_(u"No matching release found for {0} tracks."
|
||||||
.format(itemcount))
|
.format(itemcount))
|
||||||
print_(u'For help, see: '
|
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)
|
sel = ui.input_options(choice_opts)
|
||||||
if sel in choice_actions:
|
if sel in choice_actions:
|
||||||
return choice_actions[sel]
|
return choice_actions[sel]
|
||||||
|
|
@ -1177,7 +1176,7 @@ def update_items(lib, query, album, move, pretend, fields):
|
||||||
# Manually moving and storing the album.
|
# Manually moving and storing the album.
|
||||||
items = list(album.items())
|
items = list(album.items())
|
||||||
for item in items:
|
for item in items:
|
||||||
item.move(store=False)
|
item.move(store=False, with_album=False)
|
||||||
item.store(fields=fields)
|
item.store(fields=fields)
|
||||||
album.move(store=False)
|
album.move(store=False)
|
||||||
album.store(fields=fields)
|
album.store(fields=fields)
|
||||||
|
|
@ -1726,7 +1725,7 @@ def completion_script(commands):
|
||||||
``commands`` is alist of ``ui.Subcommand`` instances to generate
|
``commands`` is alist of ``ui.Subcommand`` instances to generate
|
||||||
completion data for.
|
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:
|
with open(base_script, 'r') as base_script:
|
||||||
yield util.text_string(base_script.read())
|
yield util.text_string(base_script.read())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ import locale
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
import functools
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from multiprocessing.pool import ThreadPool
|
||||||
import traceback
|
import traceback
|
||||||
import subprocess
|
import subprocess
|
||||||
import platform
|
import platform
|
||||||
|
|
@ -282,14 +284,14 @@ def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
|
||||||
continue
|
continue
|
||||||
clutter = [bytestring_path(c) for c in clutter]
|
clutter = [bytestring_path(c) for c in clutter]
|
||||||
match_paths = [bytestring_path(d) for d in os.listdir(directory)]
|
match_paths = [bytestring_path(d) for d in os.listdir(directory)]
|
||||||
|
try:
|
||||||
if fnmatch_all(match_paths, clutter):
|
if fnmatch_all(match_paths, clutter):
|
||||||
# Directory contains only clutter (or nothing).
|
# Directory contains only clutter (or nothing).
|
||||||
try:
|
|
||||||
shutil.rmtree(directory)
|
shutil.rmtree(directory)
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
def components(path):
|
def components(path):
|
||||||
|
|
@ -410,7 +412,7 @@ def syspath(path, prefix=True):
|
||||||
path = path.decode(encoding, 'replace')
|
path = path.decode(encoding, 'replace')
|
||||||
|
|
||||||
# Add the magic prefix if it isn't already there.
|
# 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 prefix and not path.startswith(WINDOWS_MAGIC_PREFIX):
|
||||||
if path.startswith(u'\\\\'):
|
if path.startswith(u'\\\\'):
|
||||||
# UNC path. Final path should look like \\?\UNC\...
|
# 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
|
# Note: The Windows "reserved characters" are, of course, allowed on
|
||||||
# Unix. They are forbidden here because they cause problems on Samba
|
# Unix. They are forbidden here because they cause problems on Samba
|
||||||
# shares, which are sufficiently common as to cause frequent problems.
|
# 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 = [
|
CHAR_REPLACE = [
|
||||||
(re.compile(r'[\\/]'), u'_'), # / and \ -- forbidden everywhere.
|
(re.compile(r'[\\/]'), u'_'), # / and \ -- forbidden everywhere.
|
||||||
(re.compile(r'^\.'), u'_'), # Leading dot (hidden files on Unix).
|
(re.compile(r'^\.'), u'_'), # Leading dot (hidden files on Unix).
|
||||||
|
|
@ -1009,3 +1011,47 @@ def asciify_path(path, sep_replace):
|
||||||
sep_replace
|
sep_replace
|
||||||
)
|
)
|
||||||
return os.sep.join(path_components)
|
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
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,10 @@ def pil_resize(maxwidth, path_in, path_out=None):
|
||||||
|
|
||||||
|
|
||||||
def im_resize(maxwidth, path_in, path_out=None):
|
def im_resize(maxwidth, path_in, path_out=None):
|
||||||
"""Resize using ImageMagick's ``convert`` tool.
|
"""Resize using ImageMagick.
|
||||||
Return the output path of resized image.
|
|
||||||
|
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)
|
path_out = path_out or temp_file_for(path_in)
|
||||||
log.debug(u'artresizer: ImageMagick resizing {0} to {1}',
|
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
|
# "-resize WIDTHx>" shrinks images with the width larger
|
||||||
# than the given width while maintaining the aspect ratio
|
# than the given width while maintaining the aspect ratio
|
||||||
# with regards to the height.
|
# with regards to the height.
|
||||||
try:
|
cmd = ArtResizer.shared.im_convert_cmd + \
|
||||||
util.command_output([
|
[util.syspath(path_in, prefix=False),
|
||||||
'convert', util.syspath(path_in, prefix=False),
|
|
||||||
'-resize', '{0}x>'.format(maxwidth),
|
'-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:
|
except subprocess.CalledProcessError:
|
||||||
log.warning(u'artresizer: IM convert failed for {0}',
|
log.warning(u'artresizer: IM convert failed for {0}',
|
||||||
util.displayable_path(path_in))
|
util.displayable_path(path_in))
|
||||||
return path_in
|
return path_in
|
||||||
|
|
||||||
return path_out
|
return path_out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,8 +125,9 @@ def pil_getsize(path_in):
|
||||||
|
|
||||||
|
|
||||||
def im_getsize(path_in):
|
def im_getsize(path_in):
|
||||||
cmd = ['identify', '-format', '%w %h',
|
cmd = ArtResizer.shared.im_identify_cmd + \
|
||||||
util.syspath(path_in, prefix=False)]
|
['-format', '%w %h', util.syspath(path_in, prefix=False)]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
out = util.command_output(cmd)
|
out = util.command_output(cmd)
|
||||||
except subprocess.CalledProcessError as exc:
|
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)
|
log.debug(u"artresizer: method is {0}", self.method)
|
||||||
self.can_compare = self._can_compare()
|
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):
|
def resize(self, maxwidth, path_in, path_out=None):
|
||||||
"""Manipulate an image file according to the method, returning a
|
"""Manipulate an image file according to the method, returning a
|
||||||
new path. For PIL or IMAGEMAGIC methods, resizes the image to 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
|
@staticmethod
|
||||||
def _check_method():
|
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()
|
version = get_im_version()
|
||||||
if version:
|
if version:
|
||||||
return IMAGEMAGICK, version
|
version, legacy = version
|
||||||
|
return IMAGEMAGICK, version, legacy
|
||||||
|
|
||||||
version = get_pil_version()
|
version = get_pil_version()
|
||||||
if version:
|
if version:
|
||||||
|
|
@ -231,29 +258,32 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
|
||||||
|
|
||||||
|
|
||||||
def get_im_version():
|
def get_im_version():
|
||||||
"""Return Image Magick version or None if it is unavailable
|
"""Get the ImageMagick version and legacy flag as a pair. Or return
|
||||||
Try invoking ImageMagick's "convert".
|
None if ImageMagick is not available.
|
||||||
"""
|
"""
|
||||||
try:
|
for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
|
||||||
out = util.command_output(['convert', '--version'])
|
cmd = cmd_name + ['--version']
|
||||||
|
|
||||||
|
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():
|
if b'imagemagick' in out.lower():
|
||||||
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
|
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
|
||||||
match = re.search(pattern, out)
|
match = re.search(pattern, out)
|
||||||
if match:
|
if match:
|
||||||
return (int(match.group(1)),
|
version = (int(match.group(1)),
|
||||||
int(match.group(2)),
|
int(match.group(2)),
|
||||||
int(match.group(3)))
|
int(match.group(3)))
|
||||||
return (0,)
|
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():
|
def get_pil_version():
|
||||||
"""Return Image Magick version or None if it is unavailable
|
"""Get the PIL/Pillow version, or None if it is unavailable.
|
||||||
Try importing PIL."""
|
"""
|
||||||
try:
|
try:
|
||||||
__import__('PIL', fromlist=[str('Image')])
|
__import__('PIL', fromlist=[str('Image')])
|
||||||
return (0,)
|
return (0,)
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,10 @@ def run(root_coro):
|
||||||
exc.args[0] == errno.EPIPE:
|
exc.args[0] == errno.EPIPE:
|
||||||
# Broken pipe. Remote host disconnected.
|
# Broken pipe. Remote host disconnected.
|
||||||
pass
|
pass
|
||||||
|
elif isinstance(exc.args, tuple) and \
|
||||||
|
exc.args[0] == errno.ECONNRESET:
|
||||||
|
# Connection was reset by peer.
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
# Abort the coroutine.
|
# Abort the coroutine.
|
||||||
|
|
|
||||||
1508
beets/util/confit.py
1508
beets/util/confit.py
File diff suppressed because it is too large
Load diff
|
|
@ -35,6 +35,7 @@ import dis
|
||||||
import types
|
import types
|
||||||
import sys
|
import sys
|
||||||
import six
|
import six
|
||||||
|
import functools
|
||||||
|
|
||||||
SYMBOL_DELIM = u'$'
|
SYMBOL_DELIM = u'$'
|
||||||
FUNC_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.
|
bytecode of the compiled function.
|
||||||
"""
|
"""
|
||||||
if six.PY2:
|
if six.PY2:
|
||||||
func_def = ast.FunctionDef(
|
name = name.encode('utf-8')
|
||||||
name=name.encode('utf-8'),
|
|
||||||
args = ast.arguments(
|
args = ast.arguments(
|
||||||
args=[ast.Name(n, ast.Param()) for n in arg_names],
|
args=[ast.Name(n, ast.Param()) for n in arg_names],
|
||||||
vararg=None,
|
vararg=None,
|
||||||
kwarg=None,
|
kwarg=None,
|
||||||
defaults=[ex_literal(None) for _ in arg_names],
|
defaults=[ex_literal(None) for _ in arg_names],
|
||||||
),
|
|
||||||
body=statements,
|
|
||||||
decorator_list=[],
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
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(
|
func_def = ast.FunctionDef(
|
||||||
name=name,
|
name=name,
|
||||||
args=ast.arguments(
|
args=args,
|
||||||
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,
|
body=statements,
|
||||||
decorator_list=[],
|
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)
|
ast.fix_missing_locations(mod)
|
||||||
|
|
||||||
prog = compile(mod, '<generated>', 'exec')
|
prog = compile(mod, '<generated>', 'exec')
|
||||||
|
|
@ -547,8 +555,23 @@ def _parse(template):
|
||||||
return Expression(parts)
|
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):
|
class Template(object):
|
||||||
"""A string template, including text, Symbols, and Calls.
|
"""A string template, including text, Symbols, and Calls.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,7 @@ import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import sys
|
|
||||||
|
|
||||||
from multiprocessing.pool import ThreadPool
|
|
||||||
from distutils.spawn import find_executable
|
from distutils.spawn import find_executable
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
@ -75,8 +73,8 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
|
||||||
call([self.extractor])
|
call([self.extractor])
|
||||||
except OSError:
|
except OSError:
|
||||||
raise ui.UserError(
|
raise ui.UserError(
|
||||||
u'No extractor command found: please install the '
|
u'No extractor command found: please install the extractor'
|
||||||
u'extractor binary from http://acousticbrainz.org/download'
|
u' binary from https://acousticbrainz.org/download'
|
||||||
)
|
)
|
||||||
except ABSubmitError:
|
except ABSubmitError:
|
||||||
# Extractor found, will exit with an error if not called with
|
# 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):
|
def command(self, lib, opts, args):
|
||||||
# Get items from arguments
|
# Get items from arguments
|
||||||
items = lib.items(ui.decargs(args))
|
items = lib.items(ui.decargs(args))
|
||||||
if sys.version_info[0] < 3:
|
util.par_map(self.analyze_submit, items)
|
||||||
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()
|
|
||||||
|
|
||||||
def analyze_submit(self, item):
|
def analyze_submit(self, item):
|
||||||
analysis = self._get_analysis(item)
|
analysis = self._get_analysis(item)
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@
|
||||||
"""
|
"""
|
||||||
from __future__ import division, absolute_import, print_function
|
from __future__ import division, absolute_import, print_function
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
from beets import plugins, ui
|
from beets import plugins, ui
|
||||||
|
from beets.dbcore import types
|
||||||
|
|
||||||
ACOUSTIC_BASE = "https://acousticbrainz.org/"
|
ACOUSTIC_BASE = "https://acousticbrainz.org/"
|
||||||
LEVELS = ["/low-level", "/high-level"]
|
LEVELS = ["/low-level", "/high-level"]
|
||||||
|
|
@ -104,6 +106,29 @@ ABSCHEME = {
|
||||||
|
|
||||||
|
|
||||||
class AcousticPlugin(plugins.BeetsPlugin):
|
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):
|
def __init__(self):
|
||||||
super(AcousticPlugin, self).__init__()
|
super(AcousticPlugin, self).__init__()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,18 @@
|
||||||
|
|
||||||
from __future__ import division, absolute_import, print_function
|
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
|
from subprocess import check_output, CalledProcessError, list2cmdline, STDOUT
|
||||||
|
|
||||||
import shlex
|
import shlex
|
||||||
import os
|
import os
|
||||||
import errno
|
import errno
|
||||||
import sys
|
import sys
|
||||||
import six
|
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):
|
class CheckerCommandException(Exception):
|
||||||
|
|
@ -48,6 +50,10 @@ class CheckerCommandException(Exception):
|
||||||
|
|
||||||
|
|
||||||
class BadFiles(BeetsPlugin):
|
class BadFiles(BeetsPlugin):
|
||||||
|
def __init__(self):
|
||||||
|
super(BadFiles, self).__init__()
|
||||||
|
self.verbose = False
|
||||||
|
|
||||||
def run_command(self, cmd):
|
def run_command(self, cmd):
|
||||||
self._log.debug(u"running command: {}",
|
self._log.debug(u"running command: {}",
|
||||||
displayable_path(list2cmdline(cmd)))
|
displayable_path(list2cmdline(cmd)))
|
||||||
|
|
@ -61,7 +67,7 @@ class BadFiles(BeetsPlugin):
|
||||||
status = e.returncode
|
status = e.returncode
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise CheckerCommandException(cmd, 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]
|
return status, errors, [line for line in output.split("\n") if line]
|
||||||
|
|
||||||
def check_mp3val(self, path):
|
def check_mp3val(self, path):
|
||||||
|
|
@ -85,18 +91,16 @@ class BadFiles(BeetsPlugin):
|
||||||
ext = ext.lower()
|
ext = ext.lower()
|
||||||
try:
|
try:
|
||||||
command = self.config['commands'].get(dict).get(ext)
|
command = self.config['commands'].get(dict).get(ext)
|
||||||
except confit.NotFoundError:
|
except confuse.NotFoundError:
|
||||||
command = None
|
command = None
|
||||||
if command:
|
if command:
|
||||||
return self.check_custom(command)
|
return self.check_custom(command)
|
||||||
elif ext == "mp3":
|
if ext == "mp3":
|
||||||
return self.check_mp3val
|
return self.check_mp3val
|
||||||
elif ext == "flac":
|
if ext == "flac":
|
||||||
return self.check_flac
|
return self.check_flac
|
||||||
|
|
||||||
def check_bad(self, lib, opts, args):
|
def check_item(self, item):
|
||||||
for item in lib.items(ui.decargs(args)):
|
|
||||||
|
|
||||||
# First, check whether the path exists. If not, the user
|
# First, check whether the path exists. If not, the user
|
||||||
# should probably run `beet update` to cleanup your library.
|
# should probably run `beet update` to cleanup your library.
|
||||||
dpath = displayable_path(item.path)
|
dpath = displayable_path(item.path)
|
||||||
|
|
@ -111,7 +115,7 @@ class BadFiles(BeetsPlugin):
|
||||||
if not checker:
|
if not checker:
|
||||||
self._log.error(u"no checker specified in the config for {}",
|
self._log.error(u"no checker specified in the config for {}",
|
||||||
ext)
|
ext)
|
||||||
continue
|
return
|
||||||
path = item.path
|
path = item.path
|
||||||
if not isinstance(path, six.text_type):
|
if not isinstance(path, six.text_type):
|
||||||
path = item.path.decode(sys.getfilesystemencoding())
|
path = item.path.decode(sys.getfilesystemencoding())
|
||||||
|
|
@ -126,20 +130,26 @@ class BadFiles(BeetsPlugin):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._log.error(u"error invoking {}: {}", e.checker, e.msg)
|
self._log.error(u"error invoking {}: {}", e.checker, e.msg)
|
||||||
continue
|
return
|
||||||
if status > 0:
|
if status > 0:
|
||||||
ui.print_(u"{}: checker exited with status {}"
|
ui.print_(u"{}: checker exited with status {}"
|
||||||
.format(ui.colorize('text_error', dpath), status))
|
.format(ui.colorize('text_error', dpath), status))
|
||||||
for line in output:
|
for line in output:
|
||||||
ui.print_(u" {}".format(displayable_path(line)))
|
ui.print_(u" {}".format(line))
|
||||||
elif errors > 0:
|
elif errors > 0:
|
||||||
ui.print_(u"{}: checker found {} errors or warnings"
|
ui.print_(u"{}: checker found {} errors or warnings"
|
||||||
.format(ui.colorize('text_warning', dpath), errors))
|
.format(ui.colorize('text_warning', dpath), errors))
|
||||||
for line in output:
|
for line in output:
|
||||||
ui.print_(u" {}".format(displayable_path(line)))
|
ui.print_(u" {}".format(line))
|
||||||
elif opts.verbose:
|
elif self.verbose:
|
||||||
ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath)))
|
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):
|
def commands(self):
|
||||||
bad_command = Subcommand('bad',
|
bad_command = Subcommand('bad',
|
||||||
help=u'check for corrupt or missing files')
|
help=u'check for corrupt or missing files')
|
||||||
|
|
@ -148,5 +158,5 @@ class BadFiles(BeetsPlugin):
|
||||||
action='store_true', default=False, dest='verbose',
|
action='store_true', default=False, dest='verbose',
|
||||||
help=u'view results for both the bad and uncorrupted files'
|
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]
|
return [bad_command]
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,11 @@ import beets
|
||||||
import beets.ui
|
import beets.ui
|
||||||
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.util import confit
|
import confuse
|
||||||
|
|
||||||
|
|
||||||
AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing)
|
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):
|
class BeatportAPIError(Exception):
|
||||||
|
|
@ -109,7 +109,7 @@ class BeatportClient(object):
|
||||||
:rtype: (unicode, unicode) tuple
|
:rtype: (unicode, unicode) tuple
|
||||||
"""
|
"""
|
||||||
self.api.parse_authorization_response(
|
self.api.parse_authorization_response(
|
||||||
"http://beets.io/auth?" + auth_data)
|
"https://beets.io/auth?" + auth_data)
|
||||||
access_data = self.api.fetch_access_token(
|
access_data = self.api.fetch_access_token(
|
||||||
self._make_url('/identity/1/oauth/access-token'))
|
self._make_url('/identity/1/oauth/access-token'))
|
||||||
return access_data['oauth_token'], access_data['oauth_token_secret']
|
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)
|
response = self.api.get(self._make_url(endpoint), params=kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BeatportAPIError("Error connecting to Beatport API: {}"
|
raise BeatportAPIError("Error connecting to Beatport API: {}"
|
||||||
.format(e.message))
|
.format(e))
|
||||||
if not response:
|
if not response:
|
||||||
raise BeatportAPIError(
|
raise BeatportAPIError(
|
||||||
"Error {0.status_code} for '{0.request.path_url}"
|
"Error {0.status_code} for '{0.request.path_url}"
|
||||||
|
|
@ -224,7 +224,7 @@ class BeatportRelease(BeatportObject):
|
||||||
if 'category' in data:
|
if 'category' in data:
|
||||||
self.category = data['category']
|
self.category = data['category']
|
||||||
if 'slug' in data:
|
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'])
|
data['slug'], data['id'])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -252,8 +252,8 @@ class BeatportTrack(BeatportObject):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
if 'slug' in data:
|
if 'slug' in data:
|
||||||
self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'],
|
self.url = "https://beatport.com/track/{0}/{1}" \
|
||||||
data['id'])
|
.format(data['slug'], data['id'])
|
||||||
self.track_number = data.get('trackNumber')
|
self.track_number = data.get('trackNumber')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -318,7 +318,7 @@ class BeatportPlugin(BeetsPlugin):
|
||||||
def _tokenfile(self):
|
def _tokenfile(self):
|
||||||
"""Get the path to the JSON file for storing the OAuth token.
|
"""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):
|
def album_distance(self, items, album_info, mapping):
|
||||||
"""Returns the beatport source weight and the maximum source weight
|
"""Returns the beatport source weight and the maximum source weight
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -64,7 +64,8 @@ class GstPlayer(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Set up the Gstreamer player. From the pygst tutorial:
|
# 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:
|
# Updated to GStreamer 1.0 with:
|
||||||
# https://wiki.ubuntu.com/Novacut/GStreamer1.0
|
# https://wiki.ubuntu.com/Novacut/GStreamer1.0
|
||||||
|
|
@ -177,12 +178,12 @@ class GstPlayer(object):
|
||||||
posq = self.player.query_position(fmt)
|
posq = self.player.query_position(fmt)
|
||||||
if not posq[0]:
|
if not posq[0]:
|
||||||
raise QueryError("query_position failed")
|
raise QueryError("query_position failed")
|
||||||
pos = posq[1] // (10 ** 9)
|
pos = posq[1] / (10 ** 9)
|
||||||
|
|
||||||
lengthq = self.player.query_duration(fmt)
|
lengthq = self.player.query_duration(fmt)
|
||||||
if not lengthq[0]:
|
if not lengthq[0]:
|
||||||
raise QueryError("query_duration failed")
|
raise QueryError("query_duration failed")
|
||||||
length = lengthq[1] // (10 ** 9)
|
length = lengthq[1] / (10 ** 9)
|
||||||
|
|
||||||
self.cached_time = (pos, length)
|
self.cached_time = (pos, length)
|
||||||
return (pos, length)
|
return (pos, length)
|
||||||
|
|
@ -215,6 +216,59 @@ class GstPlayer(object):
|
||||||
while self.playing:
|
while self.playing:
|
||||||
time.sleep(1)
|
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):
|
def play_simple(paths):
|
||||||
"""Play the files in paths in a straightforward way, without
|
"""Play the files in paths in a straightforward way, without
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ from beets import plugins
|
||||||
from beets import ui
|
from beets import ui
|
||||||
from beets import util
|
from beets import util
|
||||||
from beets import config
|
from beets import config
|
||||||
from beets.util import confit
|
|
||||||
from beets.autotag import hooks
|
from beets.autotag import hooks
|
||||||
|
import confuse
|
||||||
import acoustid
|
import acoustid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
@ -221,7 +221,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
||||||
def submit_cmd_func(lib, opts, args):
|
def submit_cmd_func(lib, opts, args):
|
||||||
try:
|
try:
|
||||||
apikey = config['acoustid']['apikey'].as_str()
|
apikey = config['acoustid']['apikey'].as_str()
|
||||||
except confit.NotFoundError:
|
except confuse.NotFoundError:
|
||||||
raise ui.UserError(u'no Acoustid user API key provided')
|
raise ui.UserError(u'no Acoustid user API key provided')
|
||||||
submit_items(self._log, apikey, lib.items(ui.decargs(args)))
|
submit_items(self._log, apikey, lib.items(ui.decargs(args)))
|
||||||
submit_cmd.func = submit_cmd_func
|
submit_cmd.func = submit_cmd_func
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import platform
|
||||||
|
|
||||||
from beets import ui, util, plugins, config
|
from beets import ui, util, plugins, config
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.util.confit import ConfigTypeError
|
from confuse import ConfigTypeError
|
||||||
from beets import art
|
from beets import art
|
||||||
from beets.util.artresizer import ArtResizer
|
from beets.util.artresizer import ArtResizer
|
||||||
from beets.library import parse_query_string
|
from beets.library import parse_query_string
|
||||||
|
|
@ -116,6 +116,7 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
u'pretend': False,
|
u'pretend': False,
|
||||||
u'threads': util.cpu_count(),
|
u'threads': util.cpu_count(),
|
||||||
u'format': u'mp3',
|
u'format': u'mp3',
|
||||||
|
u'id3v23': u'inherit',
|
||||||
u'formats': {
|
u'formats': {
|
||||||
u'aac': {
|
u'aac': {
|
||||||
u'command': u'ffmpeg -i $source -y -vn -acodec aac '
|
u'command': u'ffmpeg -i $source -y -vn -acodec aac '
|
||||||
|
|
@ -316,8 +317,12 @@ class ConvertPlugin(BeetsPlugin):
|
||||||
if pretend:
|
if pretend:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
id3v23 = self.config['id3v23'].as_choice([True, False, 'inherit'])
|
||||||
|
if id3v23 == 'inherit':
|
||||||
|
id3v23 = None
|
||||||
|
|
||||||
# Write tags from the database to the converted file.
|
# 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 keep_new:
|
||||||
# If we're keeping the transcoded file, read it again (after
|
# 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 {}',
|
self._log.debug(u'embedding album art from {}',
|
||||||
util.displayable_path(album.artpath))
|
util.displayable_path(album.artpath))
|
||||||
art.embed_item(self._log, item, album.artpath,
|
art.embed_item(self._log, item, album.artpath,
|
||||||
itempath=converted)
|
itempath=converted, id3v23=id3v23)
|
||||||
|
|
||||||
if keep_new:
|
if keep_new:
|
||||||
plugins.send('after_convert', item=item,
|
plugins.send('after_convert', item=item,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import beets.ui
|
||||||
from beets import config
|
from beets import config
|
||||||
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.util import confit
|
import confuse
|
||||||
from discogs_client import Release, Master, Client
|
from discogs_client import Release, Master, Client
|
||||||
from discogs_client.exceptions import DiscogsAPIError
|
from discogs_client.exceptions import DiscogsAPIError
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError
|
||||||
|
|
@ -37,7 +37,7 @@ import traceback
|
||||||
from string import ascii_lowercase
|
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.
|
# Exceptions that discogs_client should really handle but does not.
|
||||||
CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException,
|
CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException,
|
||||||
|
|
@ -61,6 +61,8 @@ class DiscogsPlugin(BeetsPlugin):
|
||||||
self.config['user_token'].redact = True
|
self.config['user_token'].redact = True
|
||||||
self.discogs_client = None
|
self.discogs_client = None
|
||||||
self.register_listener('import_begin', self.setup)
|
self.register_listener('import_begin', self.setup)
|
||||||
|
self.rate_limit_per_minute = 25
|
||||||
|
self.last_request_timestamp = 0
|
||||||
|
|
||||||
def setup(self, session=None):
|
def setup(self, session=None):
|
||||||
"""Create the `discogs_client` field. Authenticate if necessary.
|
"""Create the `discogs_client` field. Authenticate if necessary.
|
||||||
|
|
@ -71,6 +73,9 @@ class DiscogsPlugin(BeetsPlugin):
|
||||||
# Try using a configured user token (bypassing OAuth login).
|
# Try using a configured user token (bypassing OAuth login).
|
||||||
user_token = self.config['user_token'].as_str()
|
user_token = self.config['user_token'].as_str()
|
||||||
if user_token:
|
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)
|
self.discogs_client = Client(USER_AGENT, user_token=user_token)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -88,6 +93,26 @@ class DiscogsPlugin(BeetsPlugin):
|
||||||
self.discogs_client = Client(USER_AGENT, c_key, c_secret,
|
self.discogs_client = Client(USER_AGENT, c_key, c_secret,
|
||||||
token, 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):
|
def reset_auth(self):
|
||||||
"""Delete token file & redo the auth steps.
|
"""Delete token file & redo the auth steps.
|
||||||
"""
|
"""
|
||||||
|
|
@ -97,7 +122,7 @@ class DiscogsPlugin(BeetsPlugin):
|
||||||
def _tokenfile(self):
|
def _tokenfile(self):
|
||||||
"""Get the path to the JSON file for storing the OAuth token.
|
"""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):
|
def authenticate(self, c_key, c_secret):
|
||||||
# Get the link for the OAuth page.
|
# 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"
|
# Strip medium information from query, Things like "CD1" and "disk 1"
|
||||||
# can also negate an otherwise positive result.
|
# can also negate an otherwise positive result.
|
||||||
query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query)
|
query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query)
|
||||||
|
|
||||||
|
self.request_start()
|
||||||
try:
|
try:
|
||||||
releases = self.discogs_client.search(query,
|
releases = self.discogs_client.search(query,
|
||||||
type='release').page(1)
|
type='release').page(1)
|
||||||
|
self.request_finished()
|
||||||
|
|
||||||
except CONNECTION_ERRORS:
|
except CONNECTION_ERRORS:
|
||||||
self._log.debug(u"Communication error while searching for {0!r}",
|
self._log.debug(u"Communication error while searching for {0!r}",
|
||||||
query, exc_info=True)
|
query, exc_info=True)
|
||||||
|
|
@ -222,8 +251,11 @@ class DiscogsPlugin(BeetsPlugin):
|
||||||
"""
|
"""
|
||||||
self._log.debug(u'Searching for master release {0}', master_id)
|
self._log.debug(u'Searching for master release {0}', master_id)
|
||||||
result = Master(self.discogs_client, {'id': master_id})
|
result = Master(self.discogs_client, {'id': master_id})
|
||||||
|
|
||||||
|
self.request_start()
|
||||||
try:
|
try:
|
||||||
year = result.fetch('year')
|
year = result.fetch('year')
|
||||||
|
self.request_finished()
|
||||||
return year
|
return year
|
||||||
except DiscogsAPIError as e:
|
except DiscogsAPIError as e:
|
||||||
if e.status_code != 404:
|
if e.status_code != 404:
|
||||||
|
|
@ -252,7 +284,7 @@ class DiscogsPlugin(BeetsPlugin):
|
||||||
# https://www.discogs.com/help/doc/submission-guidelines-general-rules
|
# https://www.discogs.com/help/doc/submission-guidelines-general-rules
|
||||||
if not all([result.data.get(k) for k in ['artists', 'title', 'id',
|
if not all([result.data.get(k) for k in ['artists', 'title', 'id',
|
||||||
'tracklist']]):
|
'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
|
return None
|
||||||
|
|
||||||
artist, artist_id = self.get_artist([a.data for a in result.artists])
|
artist, artist_id = self.get_artist([a.data for a in result.artists])
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ def load(s):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
out = []
|
out = []
|
||||||
for d in yaml.load_all(s):
|
for d in yaml.safe_load_all(s):
|
||||||
if not isinstance(d, dict):
|
if not isinstance(d, dict):
|
||||||
raise ParseError(
|
raise ParseError(
|
||||||
u'each entry must be a dictionary; found {}'.format(
|
u'each entry must be a dictionary; found {}'.format(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import codecs
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets import ui
|
from beets import ui
|
||||||
from beets import mediafile
|
import mediafile
|
||||||
from beetsplug.info import make_key_filter, library_data, tag_data
|
from beetsplug.info import make_key_filter, library_data, tag_data
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,11 @@ from beets import importer
|
||||||
from beets import ui
|
from beets import ui
|
||||||
from beets import util
|
from beets import util
|
||||||
from beets import config
|
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.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
|
from beets.util import syspath, bytestring_path, py3_path
|
||||||
|
import confuse
|
||||||
import six
|
import six
|
||||||
|
|
||||||
CONTENT_TYPES = {
|
CONTENT_TYPES = {
|
||||||
|
|
@ -310,6 +311,9 @@ class CoverArtArchive(RemoteArtSource):
|
||||||
|
|
||||||
class Amazon(RemoteArtSource):
|
class Amazon(RemoteArtSource):
|
||||||
NAME = u"Amazon"
|
NAME = u"Amazon"
|
||||||
|
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'
|
URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
|
||||||
INDICES = (1, 2)
|
INDICES = (1, 2)
|
||||||
|
|
||||||
|
|
@ -324,6 +328,9 @@ class Amazon(RemoteArtSource):
|
||||||
|
|
||||||
class AlbumArtOrg(RemoteArtSource):
|
class AlbumArtOrg(RemoteArtSource):
|
||||||
NAME = u"AlbumArt.org scraper"
|
NAME = u"AlbumArt.org scraper"
|
||||||
|
if util.SNI_SUPPORTED:
|
||||||
|
URL = 'https://www.albumart.org/index_detail.php'
|
||||||
|
else:
|
||||||
URL = 'http://www.albumart.org/index_detail.php'
|
URL = 'http://www.albumart.org/index_detail.php'
|
||||||
PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
|
PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
|
||||||
|
|
||||||
|
|
@ -365,12 +372,17 @@ class GoogleImages(RemoteArtSource):
|
||||||
if not (album.albumartist and album.album):
|
if not (album.albumartist and album.album):
|
||||||
return
|
return
|
||||||
search_string = (album.albumartist + ',' + album.album).encode('utf-8')
|
search_string = (album.albumartist + ',' + album.album).encode('utf-8')
|
||||||
|
|
||||||
|
try:
|
||||||
response = self.request(self.URL, params={
|
response = self.request(self.URL, params={
|
||||||
'key': self.key,
|
'key': self.key,
|
||||||
'cx': self.cx,
|
'cx': self.cx,
|
||||||
'q': search_string,
|
'q': search_string,
|
||||||
'searchType': 'image'
|
'searchType': 'image'
|
||||||
})
|
})
|
||||||
|
except requests.RequestException:
|
||||||
|
self._log.debug(u'google: error receiving response')
|
||||||
|
return
|
||||||
|
|
||||||
# Get results using JSON.
|
# Get results using JSON.
|
||||||
try:
|
try:
|
||||||
|
|
@ -406,10 +418,14 @@ class FanartTV(RemoteArtSource):
|
||||||
if not album.mb_releasegroupid:
|
if not album.mb_releasegroupid:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
response = self.request(
|
response = self.request(
|
||||||
self.API_ALBUMS + album.mb_releasegroupid,
|
self.API_ALBUMS + album.mb_releasegroupid,
|
||||||
headers={'api-key': self.PROJECT_KEY,
|
headers={'api-key': self.PROJECT_KEY,
|
||||||
'client-key': self.client_key})
|
'client-key': self.client_key})
|
||||||
|
except requests.RequestException:
|
||||||
|
self._log.debug(u'fanart.tv: error receiving response')
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
@ -545,6 +561,8 @@ class Wikipedia(RemoteArtSource):
|
||||||
|
|
||||||
# Find the name of the cover art filename on DBpedia
|
# Find the name of the cover art filename on DBpedia
|
||||||
cover_filename, page_id = None, None
|
cover_filename, page_id = None, None
|
||||||
|
|
||||||
|
try:
|
||||||
dbpedia_response = self.request(
|
dbpedia_response = self.request(
|
||||||
self.DBPEDIA_URL,
|
self.DBPEDIA_URL,
|
||||||
params={
|
params={
|
||||||
|
|
@ -555,6 +573,10 @@ class Wikipedia(RemoteArtSource):
|
||||||
},
|
},
|
||||||
headers={'content-type': 'application/json'},
|
headers={'content-type': 'application/json'},
|
||||||
)
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
self._log.debug(u'dbpedia: error receiving response')
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = dbpedia_response.json()
|
data = dbpedia_response.json()
|
||||||
results = data['results']['bindings']
|
results = data['results']['bindings']
|
||||||
|
|
@ -584,6 +606,7 @@ class Wikipedia(RemoteArtSource):
|
||||||
lpart, rpart = cover_filename.rsplit(' .', 1)
|
lpart, rpart = cover_filename.rsplit(' .', 1)
|
||||||
|
|
||||||
# Query all the images in the page
|
# Query all the images in the page
|
||||||
|
try:
|
||||||
wikipedia_response = self.request(
|
wikipedia_response = self.request(
|
||||||
self.WIKIPEDIA_URL,
|
self.WIKIPEDIA_URL,
|
||||||
params={
|
params={
|
||||||
|
|
@ -595,6 +618,9 @@ class Wikipedia(RemoteArtSource):
|
||||||
},
|
},
|
||||||
headers={'content-type': 'application/json'},
|
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
|
# Try to see if one of the images on the pages matches our
|
||||||
# incomplete cover_filename
|
# incomplete cover_filename
|
||||||
|
|
@ -613,6 +639,7 @@ class Wikipedia(RemoteArtSource):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find the absolute url of the cover art on Wikipedia
|
# Find the absolute url of the cover art on Wikipedia
|
||||||
|
try:
|
||||||
wikipedia_response = self.request(
|
wikipedia_response = self.request(
|
||||||
self.WIKIPEDIA_URL,
|
self.WIKIPEDIA_URL,
|
||||||
params={
|
params={
|
||||||
|
|
@ -625,6 +652,9 @@ class Wikipedia(RemoteArtSource):
|
||||||
},
|
},
|
||||||
headers={'content-type': 'application/json'},
|
headers={'content-type': 'application/json'},
|
||||||
)
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
self._log.debug(u'wikipedia: error receiving response')
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = wikipedia_response.json()
|
data = wikipedia_response.json()
|
||||||
|
|
@ -753,9 +783,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
||||||
|
|
||||||
# allow both pixel and percentage-based margin specifications
|
# allow both pixel and percentage-based margin specifications
|
||||||
self.enforce_ratio = self.config['enforce_ratio'].get(
|
self.enforce_ratio = self.config['enforce_ratio'].get(
|
||||||
confit.OneOf([bool,
|
confuse.OneOf([bool,
|
||||||
confit.String(pattern=self.PAT_PX),
|
confuse.String(pattern=self.PAT_PX),
|
||||||
confit.String(pattern=self.PAT_PERCENT)]))
|
confuse.String(pattern=self.PAT_PERCENT)]))
|
||||||
self.margin_px = None
|
self.margin_px = None
|
||||||
self.margin_percent = None
|
self.margin_percent = None
|
||||||
if type(self.enforce_ratio) is six.text_type:
|
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])
|
self.margin_px = int(self.enforce_ratio[:-2])
|
||||||
else:
|
else:
|
||||||
# shouldn't happen
|
# shouldn't happen
|
||||||
raise confit.ConfigValueError()
|
raise confuse.ConfigValueError()
|
||||||
self.enforce_ratio = True
|
self.enforce_ratio = True
|
||||||
|
|
||||||
cover_names = self.config['cover_names'].as_str_seq()
|
cover_names = self.config['cover_names'].as_str_seq()
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,19 @@ class Gmusic(BeetsPlugin):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(Gmusic, self).__init__()
|
super(Gmusic, self).__init__()
|
||||||
self.m = Musicmanager()
|
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({
|
self.config.add({
|
||||||
u'auto': False,
|
u'auto': False,
|
||||||
u'uploader_id': '',
|
u'uploader_id': '',
|
||||||
u'uploader_name': '',
|
u'uploader_name': '',
|
||||||
u'device_id': '',
|
u'device_id': '',
|
||||||
u'oauth_file': gmusicapi.clients.OAUTH_FILEPATH,
|
u'oauth_file': oauth_file,
|
||||||
})
|
})
|
||||||
if self.config['auto']:
|
if self.config['auto']:
|
||||||
self.import_stages = [self.autoupload]
|
self.import_stages = [self.autoupload]
|
||||||
|
|
@ -62,7 +69,7 @@ class Gmusic(BeetsPlugin):
|
||||||
return
|
return
|
||||||
# Checks for OAuth2 credentials,
|
# Checks for OAuth2 credentials,
|
||||||
# if they don't exist - performs authorization
|
# 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):
|
if os.path.isfile(oauth_file):
|
||||||
uploader_id = self.config['uploader_id']
|
uploader_id = self.config['uploader_id']
|
||||||
uploader_name = self.config['uploader_name']
|
uploader_name = self.config['uploader_name']
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ from __future__ import division, absolute_import, print_function
|
||||||
|
|
||||||
import string
|
import string
|
||||||
import subprocess
|
import subprocess
|
||||||
import six
|
|
||||||
|
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.util import shlex_split, arg_encoding
|
from beets.util import shlex_split, arg_encoding
|
||||||
|
|
@ -46,10 +45,8 @@ class CodingFormatter(string.Formatter):
|
||||||
|
|
||||||
See str.format and string.Formatter.format.
|
See str.format and string.Formatter.format.
|
||||||
"""
|
"""
|
||||||
try:
|
if isinstance(format_string, bytes):
|
||||||
format_string = format_string.decode(self._coding)
|
format_string = format_string.decode(self._coding)
|
||||||
except UnicodeEncodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return super(CodingFormatter, self).format(format_string, *args,
|
return super(CodingFormatter, self).format(format_string, *args,
|
||||||
**kwargs)
|
**kwargs)
|
||||||
|
|
@ -96,10 +93,7 @@ class HookPlugin(BeetsPlugin):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use a string formatter that works on Unicode strings.
|
# Use a string formatter that works on Unicode strings.
|
||||||
if six.PY2:
|
|
||||||
formatter = CodingFormatter(arg_encoding())
|
formatter = CodingFormatter(arg_encoding())
|
||||||
else:
|
|
||||||
formatter = string.Formatter()
|
|
||||||
|
|
||||||
command_pieces = shlex_split(command)
|
command_pieces = shlex_split(command)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import re
|
||||||
|
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets import ui
|
from beets import ui
|
||||||
from beets import mediafile
|
import mediafile
|
||||||
from beets.library import Item
|
from beets.library import Item
|
||||||
from beets.util import displayable_path, normpath, syspath
|
from beets.util import displayable_path, normpath, syspath
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,13 @@ class InlinePlugin(BeetsPlugin):
|
||||||
# For function bodies, invoke the function with values as global
|
# For function bodies, invoke the function with values as global
|
||||||
# variables.
|
# variables.
|
||||||
def _func_func(obj):
|
def _func_func(obj):
|
||||||
|
old_globals = dict(func.__globals__)
|
||||||
func.__globals__.update(_dict_for(obj))
|
func.__globals__.update(_dict_for(obj))
|
||||||
try:
|
try:
|
||||||
return func()
|
return func()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise InlineError(python_code, exc)
|
raise InlineError(python_code, exc)
|
||||||
|
finally:
|
||||||
|
func.__globals__.clear()
|
||||||
|
func.__globals__.update(old_globals)
|
||||||
return _func_func
|
return _func_func
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class KeyFinderPlugin(BeetsPlugin):
|
||||||
continue
|
continue
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
# Workaround for Python 2 Windows bug.
|
# 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}',
|
self._log.error(u'execution failed for Unicode path: {0!r}',
|
||||||
item.path)
|
item.path)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
from __future__ import division, absolute_import, print_function
|
from __future__ import division, absolute_import, print_function
|
||||||
|
|
||||||
import six
|
import six
|
||||||
|
|
||||||
"""Gets genres for imported music based on Last.fm tags.
|
"""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)
|
self._log.debug('Loading canonicalization tree {0}', c14n_filename)
|
||||||
c14n_filename = normpath(c14n_filename)
|
c14n_filename = normpath(c14n_filename)
|
||||||
with codecs.open(c14n_filename, 'r', encoding='utf-8') as f:
|
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)
|
flatten_tree(genres_tree, [], self.c14n_branches)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -373,18 +374,27 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
lastgenre_cmd = ui.Subcommand('lastgenre', help=u'fetch genres')
|
lastgenre_cmd = ui.Subcommand('lastgenre', help=u'fetch genres')
|
||||||
lastgenre_cmd.parser.add_option(
|
lastgenre_cmd.parser.add_option(
|
||||||
u'-f', u'--force', dest='force',
|
u'-f', u'--force', dest='force',
|
||||||
action='store_true', default=False,
|
action='store_true',
|
||||||
help=u're-download genre when already present'
|
help=u're-download genre when already present'
|
||||||
)
|
)
|
||||||
lastgenre_cmd.parser.add_option(
|
lastgenre_cmd.parser.add_option(
|
||||||
u'-s', u'--source', dest='source', type='string',
|
u'-s', u'--source', dest='source', type='string',
|
||||||
help=u'genre source: artist, album, or track'
|
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):
|
def lastgenre_func(lib, opts, args):
|
||||||
write = ui.should_write()
|
write = ui.should_write()
|
||||||
self.config.set_args(opts)
|
self.config.set_args(opts)
|
||||||
|
|
||||||
|
if opts.album:
|
||||||
|
# Fetch genres for whole albums
|
||||||
for album in lib.albums(ui.decargs(args)):
|
for album in lib.albums(ui.decargs(args)):
|
||||||
album.genre, src = self._get_genre(album)
|
album.genre, src = self._get_genre(album)
|
||||||
self._log.info(u'genre for album {0} ({1}): {0.genre}',
|
self._log.info(u'genre for album {0} ({1}): {0.genre}',
|
||||||
|
|
@ -397,11 +407,20 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
||||||
if 'track' in self.sources:
|
if 'track' in self.sources:
|
||||||
item.genre, src = self._get_genre(item)
|
item.genre, src = self._get_genre(item)
|
||||||
item.store()
|
item.store()
|
||||||
self._log.info(u'genre for track {0} ({1}): {0.genre}',
|
self._log.info(
|
||||||
|
u'genre for track {0} ({1}): {0.genre}',
|
||||||
item, src)
|
item, src)
|
||||||
|
|
||||||
if write:
|
if write:
|
||||||
item.try_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
|
lastgenre_cmd.func = lastgenre_func
|
||||||
return [lastgenre_cmd]
|
return [lastgenre_cmd]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# This file is part of beets.
|
# 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
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
# a copy of this software and associated documentation files (the
|
# a copy of this software and associated documentation files (the
|
||||||
|
|
|
||||||
46
beetsplug/loadext.py
Normal file
46
beetsplug/loadext.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# This file is part of beets.
|
||||||
|
# Copyright 2019, Jack Wilsdon <jack.wilsdon@gmail.com>
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
# a copy of this software and associated documentation files (the
|
||||||
|
# "Software"), to deal in the Software without restriction, including
|
||||||
|
# without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
# permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
# the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be
|
||||||
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
"""Load SQLite extensions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __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)
|
||||||
|
|
@ -55,6 +55,7 @@ except ImportError:
|
||||||
|
|
||||||
from beets import plugins
|
from beets import plugins
|
||||||
from beets import ui
|
from beets import ui
|
||||||
|
from beets import util
|
||||||
import beets
|
import beets
|
||||||
|
|
||||||
DIV_RE = re.compile(r'<(/?)div>?', re.I)
|
DIV_RE = re.compile(r'<(/?)div>?', re.I)
|
||||||
|
|
@ -406,6 +407,9 @@ class Genius(Backend):
|
||||||
class LyricsWiki(SymbolsReplaced):
|
class LyricsWiki(SymbolsReplaced):
|
||||||
"""Fetch lyrics from LyricsWiki."""
|
"""Fetch lyrics from LyricsWiki."""
|
||||||
|
|
||||||
|
if util.SNI_SUPPORTED:
|
||||||
|
URL_PATTERN = 'https://lyrics.wikia.com/%s:%s'
|
||||||
|
else:
|
||||||
URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
|
URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
|
||||||
|
|
||||||
def fetch(self, artist, title):
|
def fetch(self, artist, title):
|
||||||
|
|
@ -446,7 +450,7 @@ def _scrape_strip_cruft(html, plain_text_out=False):
|
||||||
html = html.replace('\r', '\n') # Normalize EOL.
|
html = html.replace('\r', '\n') # Normalize EOL.
|
||||||
html = re.sub(r' +', ' ', html) # Whitespaces collapse.
|
html = re.sub(r' +', ' ', html) # Whitespaces collapse.
|
||||||
html = BREAK_RE.sub('\n', html) # <br> eats up surrounding '\n'.
|
html = BREAK_RE.sub('\n', html) # <br> eats up surrounding '\n'.
|
||||||
html = re.sub(r'<(script).*?</\1>(?s)', '', html) # Strip script tags.
|
html = re.sub(r'(?s)<(script).*?</\1>', '', html) # Strip script tags.
|
||||||
|
|
||||||
if plain_text_out: # Strip remaining HTML tags
|
if plain_text_out: # Strip remaining HTML tags
|
||||||
html = COMMENT_RE.sub('', html)
|
html = COMMENT_RE.sub('', html)
|
||||||
|
|
|
||||||
|
|
@ -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
|
parseable by the MusicBrainz track parser [1]. Programmatic submitting is not
|
||||||
implemented by MusicBrainz yet.
|
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
|
from __future__ import division, absolute_import, print_function
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ from __future__ import division, absolute_import, print_function
|
||||||
from abc import abstractmethod, ABCMeta
|
from abc import abstractmethod, ABCMeta
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from beets.util.confit import ConfigValueError
|
from confuse import ConfigValueError
|
||||||
from beets import ui
|
from beets import ui
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
import six
|
import six
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,14 @@ import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import plistlib
|
import plistlib
|
||||||
|
|
||||||
|
import six
|
||||||
from six.moves.urllib.parse import urlparse, unquote
|
from six.moves.urllib.parse import urlparse, unquote
|
||||||
from time import mktime
|
from time import mktime
|
||||||
|
|
||||||
from beets import util
|
from beets import util
|
||||||
from beets.dbcore import types
|
from beets.dbcore import types
|
||||||
from beets.library import DateType
|
from beets.library import DateType
|
||||||
from beets.util.confit import ConfigValueError
|
from confuse import ConfigValueError
|
||||||
from beetsplug.metasync import MetaSource
|
from beetsplug.metasync import MetaSource
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,7 +85,11 @@ class Itunes(MetaSource):
|
||||||
self._log.debug(
|
self._log.debug(
|
||||||
u'loading iTunes library from {0}'.format(library_path))
|
u'loading iTunes library from {0}'.format(library_path))
|
||||||
with create_temporary_copy(library_path) as library_copy:
|
with create_temporary_copy(library_path) as library_copy:
|
||||||
|
if six.PY2:
|
||||||
raw_library = plistlib.readPlist(library_copy)
|
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:
|
except IOError as e:
|
||||||
raise ConfigValueError(u'invalid iTunes library: ' + e.strerror)
|
raise ConfigValueError(u'invalid iTunes library: ' + e.strerror)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -107,17 +107,17 @@ class MPDClientWrapper(object):
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.get(command, retries=retries - 1)
|
return self.get(command, retries=retries - 1)
|
||||||
|
|
||||||
def playlist(self):
|
def currentsong(self):
|
||||||
"""Return the currently active playlist. Prefixes paths with the
|
"""Return the path to the currently playing song. Prefixes paths with the
|
||||||
music_directory, to get the absolute path.
|
music_directory, to get the absolute path.
|
||||||
"""
|
"""
|
||||||
result = {}
|
result = None
|
||||||
for entry in self.get('playlistinfo'):
|
entry = self.get('currentsong')
|
||||||
|
if 'file' in entry:
|
||||||
if not is_url(entry['file']):
|
if not is_url(entry['file']):
|
||||||
result[entry['id']] = os.path.join(
|
result = os.path.join(self.music_directory, entry['file'])
|
||||||
self.music_directory, entry['file'])
|
|
||||||
else:
|
else:
|
||||||
result[entry['id']] = entry['file']
|
result = entry['file']
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def status(self):
|
def status(self):
|
||||||
|
|
@ -250,8 +250,8 @@ class MPDStats(object):
|
||||||
self.now_playing = None
|
self.now_playing = None
|
||||||
|
|
||||||
def on_play(self, status):
|
def on_play(self, status):
|
||||||
playlist = self.mpd.playlist()
|
|
||||||
path = playlist.get(status['songid'])
|
path = self.mpd.currentsong()
|
||||||
|
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
|
|
@ -326,7 +326,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
|
||||||
'rating': True,
|
'rating': True,
|
||||||
'rating_mix': 0.75,
|
'rating_mix': 0.75,
|
||||||
'host': os.environ.get('MPD_HOST', u'localhost'),
|
'host': os.environ.get('MPD_HOST', u'localhost'),
|
||||||
'port': 6600,
|
'port': int(os.environ.get('MPD_PORT', 6600)),
|
||||||
'password': u'',
|
'password': u'',
|
||||||
})
|
})
|
||||||
mpd_config['password'].redact = True
|
mpd_config['password'].redact = True
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class MPDUpdatePlugin(BeetsPlugin):
|
||||||
super(MPDUpdatePlugin, self).__init__()
|
super(MPDUpdatePlugin, self).__init__()
|
||||||
config['mpd'].add({
|
config['mpd'].add({
|
||||||
'host': os.environ.get('MPD_HOST', u'localhost'),
|
'host': os.environ.get('MPD_HOST', u'localhost'),
|
||||||
'port': 6600,
|
'port': int(os.environ.get('MPD_PORT', 6600)),
|
||||||
'password': u'',
|
'password': u'',
|
||||||
})
|
})
|
||||||
config['mpd']['password'].redact = True
|
config['mpd']['password'].redact = True
|
||||||
|
|
|
||||||
181
beetsplug/playlist.py
Normal file
181
beetsplug/playlist.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -19,97 +19,7 @@ from __future__ import division, absolute_import, print_function
|
||||||
|
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.ui import Subcommand, decargs, print_
|
from beets.ui import Subcommand, decargs, print_
|
||||||
import random
|
from beets.random import random_objs
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def random_func(lib, opts, args):
|
def random_func(lib, opts, args):
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,14 @@ class Bs1770gainBackend(Backend):
|
||||||
state['gain'] = state['peak'] = None
|
state['gain'] = state['peak'] = None
|
||||||
parser.StartElementHandler = start_element_handler
|
parser.StartElementHandler = start_element_handler
|
||||||
parser.EndElementHandler = end_element_handler
|
parser.EndElementHandler = end_element_handler
|
||||||
|
|
||||||
|
try:
|
||||||
parser.Parse(text, True)
|
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):
|
if len(per_file_gain) != len(path_list):
|
||||||
raise ReplayGainError(
|
raise ReplayGainError(
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from beets.plugins import BeetsPlugin
|
||||||
from beets import ui
|
from beets import ui
|
||||||
from beets import util
|
from beets import util
|
||||||
from beets import config
|
from beets import config
|
||||||
from beets import mediafile
|
import mediafile
|
||||||
import mutagen
|
import mutagen
|
||||||
|
|
||||||
_MUTAGEN_FORMATS = {
|
_MUTAGEN_FORMATS = {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import requests
|
||||||
|
|
||||||
from beets import ui
|
from beets import ui
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.util import confit
|
import confuse
|
||||||
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ class SpotifyPlugin(BeetsPlugin):
|
||||||
# Base URLs for the Spotify API
|
# Base URLs for the Spotify API
|
||||||
# Documentation: https://developer.spotify.com/web-api
|
# Documentation: https://developer.spotify.com/web-api
|
||||||
oauth_token_url = 'https://accounts.spotify.com/api/token'
|
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'
|
search_url = 'https://api.spotify.com/v1/search'
|
||||||
album_url = 'https://api.spotify.com/v1/albums/'
|
album_url = 'https://api.spotify.com/v1/albums/'
|
||||||
track_url = 'https://api.spotify.com/v1/tracks/'
|
track_url = 'https://api.spotify.com/v1/tracks/'
|
||||||
|
|
@ -49,7 +49,7 @@ class SpotifyPlugin(BeetsPlugin):
|
||||||
self.config['client_secret'].redact = True
|
self.config['client_secret'].redact = True
|
||||||
|
|
||||||
self.tokenfile = self.config['tokenfile'].get(
|
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.
|
) # Path to the JSON file for storing the OAuth access token.
|
||||||
self.setup()
|
self.setup()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,8 @@ class ThePlugin(BeetsPlugin):
|
||||||
for p in self.patterns:
|
for p in self.patterns:
|
||||||
r = self.unthe(text, p)
|
r = self.unthe(text, p)
|
||||||
if r != text:
|
if r != text:
|
||||||
break
|
|
||||||
self._log.debug(u'\"{0}\" -> \"{1}\"', text, r)
|
self._log.debug(u'\"{0}\" -> \"{1}\"', text, r)
|
||||||
|
break
|
||||||
return r
|
return r
|
||||||
else:
|
else:
|
||||||
return u''
|
return u''
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ class ThumbnailsPlugin(BeetsPlugin):
|
||||||
|
|
||||||
def thumbnail_file_name(self, path):
|
def thumbnail_file_name(self, path):
|
||||||
"""Compute the thumbnail file name
|
"""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)
|
uri = self.get_uri(path)
|
||||||
hash = md5(uri.encode('utf-8')).hexdigest()
|
hash = md5(uri.encode('utf-8')).hexdigest()
|
||||||
|
|
@ -168,7 +168,7 @@ class ThumbnailsPlugin(BeetsPlugin):
|
||||||
|
|
||||||
def add_tags(self, album, image_path):
|
def add_tags(self, album, image_path):
|
||||||
"""Write required metadata to the thumbnail
|
"""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
|
mtime = os.stat(album.artpath).st_mtime
|
||||||
metadata = {"Thumb::URI": self.get_uri(album.artpath),
|
metadata = {"Thumb::URI": self.get_uri(album.artpath),
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from __future__ import division, absolute_import, print_function
|
||||||
|
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.dbcore import types
|
from beets.dbcore import types
|
||||||
from beets.util.confit import ConfigValueError
|
from confuse import ConfigValueError
|
||||||
from beets import library
|
from beets import library
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ $.fn.player = function(debug) {
|
||||||
|
|
||||||
// Simple selection disable for jQuery.
|
// Simple selection disable for jQuery.
|
||||||
// Cut-and-paste from:
|
// Cut-and-paste from:
|
||||||
// http://stackoverflow.com/questions/2700000
|
// https://stackoverflow.com/questions/2700000
|
||||||
$.fn.disableSelection = function() {
|
$.fn.disableSelection = function() {
|
||||||
$(this).attr('unselectable', 'on')
|
$(this).attr('unselectable', 'on')
|
||||||
.css('-moz-user-select', 'none')
|
.css('-moz-user-select', 'none')
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ import six
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
from beets.mediafile import MediaFile
|
from mediafile import MediaFile
|
||||||
from beets.importer import action
|
from beets.importer import action
|
||||||
from beets.ui import Subcommand, decargs, input_yn
|
from beets.ui import Subcommand, decargs, input_yn
|
||||||
from beets.util import confit
|
import confuse
|
||||||
|
|
||||||
__author__ = 'baobab@heresiarch.info'
|
__author__ = 'baobab@heresiarch.info'
|
||||||
|
|
||||||
|
|
@ -98,7 +98,7 @@ class ZeroPlugin(BeetsPlugin):
|
||||||
for pattern in self.config[field].as_str_seq():
|
for pattern in self.config[field].as_str_seq():
|
||||||
prog = re.compile(pattern, re.IGNORECASE)
|
prog = re.compile(pattern, re.IGNORECASE)
|
||||||
self.fields_to_progs.setdefault(field, []).append(prog)
|
self.fields_to_progs.setdefault(field, []).append(prog)
|
||||||
except confit.NotFoundError:
|
except confuse.NotFoundError:
|
||||||
# Matches everything
|
# Matches everything
|
||||||
self.fields_to_progs[field] = []
|
self.fields_to_progs[field] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,119 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
1.4.8 (in development)
|
1.5.0 (in development)
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
New features:
|
New features:
|
||||||
|
|
||||||
* The disambiguation string for identifying albums in the importer now shows
|
* We now fetch information about `works`_ from MusicBrainz.
|
||||||
the catalog number.
|
MusicBrainz matches provide the fields ``work`` (the title), ``mb_workid``
|
||||||
Thanks to :user:`8h2a`.
|
(the MBID), and ``work_disambig`` (the disambiguation string).
|
||||||
:bug:`2951`
|
Thanks to :user:`dosoe`.
|
||||||
* :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some
|
:bug:`2580` :bug:`3272`
|
||||||
issues with foobar2000 and Winamp.
|
* :doc:`/plugins/bpd`: BPD now supports most of the features of version 0.16
|
||||||
Thanks to :user:`mz2212`.
|
of the MPD protocol. This is enough to get it talking to more complicated
|
||||||
:bug:`2944`
|
clients like ncmpcpp, but there are still some incompatibilities, largely due
|
||||||
* Added whitespace padding to missing tracks dialog to improve readability.
|
to MPD commands we don't support yet. Let us know if you find an MPD client
|
||||||
Thanks to :user:`jams2`.
|
that doesn't get along with BPD!
|
||||||
:bug:`2962`
|
:bug:`3214` :bug:`800`
|
||||||
* :bug:`/plugins/gmusic`: Add a new option to automatically upload to Google
|
|
||||||
Play Music library on track import.
|
Fixes:
|
||||||
Thanks to :user:`shuaiscott`.
|
|
||||||
* :doc:`/plugins/gmusic`: Add new options for Google Play Music
|
* :doc:`/plugins/inline`: In function-style field definitions that refer to
|
||||||
authentication.
|
flexible attributes, values could stick around from one function invocation
|
||||||
Thanks to :user:`thetarkus`.
|
to the next. This meant that, when displaying a list of objects, later
|
||||||
:bug:`3002`
|
objects could seem to reuse values from earlier objects when they were
|
||||||
* :doc:`/plugins/absubmit`: Analysis now works in parallel (on Python 3 only).
|
missing a value for a given field. These values are now properly undefined.
|
||||||
Thanks to :user:`bemeurer`.
|
:bug:`2406`
|
||||||
:bug:`2442` :bug:`3003`
|
* :doc:`/plugins/bpd`: Seeking by fractions of a second now works as intended,
|
||||||
* :doc:`/plugins/replaygain`: albumpeak on large collections is calculated as
|
fixing crashes in MPD clients like mpDris2 on seek.
|
||||||
the average, not the maximum.
|
The ``playlistid`` command now works properly in its zero-argument form.
|
||||||
:bug:`3008` :bug:`3009`
|
:bug:`3214`
|
||||||
* A new :doc:`/plugins/subsonicupdate` can automatically update your Subsonic library.
|
|
||||||
Thanks to :user:`maffo999`.
|
For plugin developers:
|
||||||
:bug:`3001`
|
|
||||||
* :doc:`/plugins/chroma`: Now optionally has a bias toward looking up more
|
* `MediaFile`_ has been split into a standalone project. Where you used to do
|
||||||
relevant releases according to the :ref:`preferred` configuration options.
|
``from beets import mediafile``, now just do ``import mediafile``. Beets
|
||||||
Thanks to :user:`archer4499`.
|
re-exports MediaFile at the old location for backwards-compatibility, but a
|
||||||
:bug:`3017`
|
deprecation warning is raised if you do this since we might drop this wrapper
|
||||||
* A new ``aunique`` configuration option allows setting default options
|
in a future release.
|
||||||
for the :ref:`aunique` template function.
|
* 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 <https://sources.debian.org/src/beets/1.4.7-2/debian/patches/skip-broken-test/>`_
|
||||||
|
or `repair <https://build.opensuse.org/package/view_file/openSUSE:Factory/beets/fix_test_command_line_option_relative_to_working_dir.diff?expand=1>`_
|
||||||
|
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 <https://sources.debian.org/src/beets/1.4.7-2/debian/patches/pathlib-is-stdlib/>`_.
|
||||||
|
: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
|
* The ``albumdisambig`` field no longer includes the MusicBrainz release group
|
||||||
disambiguation comment. A new ``releasegroupdisambig`` field has been added.
|
disambiguation comment. A new ``releasegroupdisambig`` field has been added.
|
||||||
:bug:`3024`
|
:bug:`3024`
|
||||||
|
|
@ -70,30 +145,216 @@ New features:
|
||||||
level.
|
level.
|
||||||
Thanks to :user:`samuelnilsson`
|
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
|
* 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
|
* Restore iTunes Store album art source, and remove the dependency on
|
||||||
python-itunes_, which had gone unmaintained and was not py3 compatible.
|
:pypi:`python-itunes`, which had gone unmaintained and was not
|
||||||
Thanks to :user:`ocelma` for creating python-itunes_ in the first place.
|
Python-3-compatible.
|
||||||
|
Thanks to :user:`ocelma` for creating :pypi:`python-itunes` in the first place.
|
||||||
Thanks to :user:`nathdwek`.
|
Thanks to :user:`nathdwek`.
|
||||||
:bug:`2371` :bug:`2551` :bug:`2718`
|
:bug:`2371` :bug:`2551` :bug:`2718`
|
||||||
* Fix compatibility Python 3.7 and its change to a name in the ``re`` module.
|
* :doc:`/plugins/lastgenre`, :doc:`/plugins/edit`: Avoid a deprecation warnings
|
||||||
:bug:`2978`
|
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
|
* R128 normalization tags are now properly deleted from files when the values
|
||||||
are missing.
|
are missing.
|
||||||
Thanks to :user:`autrimpo`.
|
Thanks to :user:`autrimpo`.
|
||||||
|
|
@ -104,38 +365,49 @@ Fixes:
|
||||||
* With the :ref:`from_scratch` configuration option set, only writable fields
|
* 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.
|
are cleared. Beets now no longer ignores the format your music is saved in.
|
||||||
:bug:`2972`
|
: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
|
* The ``%aunique`` template function now works correctly with the
|
||||||
``-f/--format`` option.
|
``-f/--format`` option.
|
||||||
:bug:`3043`
|
: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
|
* Fixed the ordering of items when manually selecting changes while updating
|
||||||
tags
|
tags
|
||||||
Thanks to :user:`TaizoSimpson`.
|
Thanks to :user:`TaizoSimpson`.
|
||||||
:bug:`3501`
|
:bug:`3501`
|
||||||
* Confusing typo when the convert plugin copies the art covers. :bug:`3063`
|
|
||||||
* The ``%title`` template function now works correctly with apostrophes.
|
* The ``%title`` template function now works correctly with apostrophes.
|
||||||
Thanks to :user:`GuilhermeHideki`.
|
Thanks to :user:`GuilhermeHideki`.
|
||||||
:bug:`3033`
|
: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)
|
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`
|
don't actually need to be moved. :bug:`1583`
|
||||||
|
|
||||||
.. _Google Code-In: https://codein.withgoogle.com/
|
.. _Google Code-In: https://codein.withgoogle.com/
|
||||||
.. _AcousticBrainz: http://acousticbrainz.org/
|
.. _AcousticBrainz: https://acousticbrainz.org/
|
||||||
|
|
||||||
Fixes:
|
Fixes:
|
||||||
|
|
||||||
|
|
@ -994,7 +1266,7 @@ Fixes:
|
||||||
* :doc:`/plugins/replaygain`: Fix a crash using the Python Audio Tools
|
* :doc:`/plugins/replaygain`: Fix a crash using the Python Audio Tools
|
||||||
backend. :bug:`1873`
|
backend. :bug:`1873`
|
||||||
|
|
||||||
.. _beets.io: http://beets.io/
|
.. _beets.io: https://beets.io/
|
||||||
.. _Beetbox: https://github.com/beetbox
|
.. _Beetbox: https://github.com/beetbox
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1111,7 +1383,7 @@ Fixes:
|
||||||
communication errors. The backend has also been disabled by default, since
|
communication errors. The backend has also been disabled by default, since
|
||||||
the API it depends on is currently down. :bug:`1770`
|
the API it depends on is currently down. :bug:`1770`
|
||||||
|
|
||||||
.. _Emby: http://emby.media
|
.. _Emby: https://emby.media
|
||||||
|
|
||||||
|
|
||||||
1.3.15 (October 17, 2015)
|
1.3.15 (October 17, 2015)
|
||||||
|
|
@ -1273,8 +1545,8 @@ Fixes:
|
||||||
* :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows
|
* :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows
|
||||||
under Python 3. :bug:`2515` :bug:`2516`
|
under Python 3. :bug:`2515` :bug:`2516`
|
||||||
|
|
||||||
.. _Python bug: http://bugs.python.org/issue16512
|
.. _Python bug: https://bugs.python.org/issue16512
|
||||||
.. _ipfs: http://ipfs.io
|
.. _ipfs: https://ipfs.io
|
||||||
|
|
||||||
|
|
||||||
1.3.13 (April 24, 2015)
|
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
|
* The :ref:`config-cmd` command can now use ``$EDITOR`` variables with
|
||||||
arguments.
|
arguments.
|
||||||
|
|
||||||
.. _API changes: http://developer.echonest.com/forums/thread/3650
|
.. _API changes: https://developer.echonest.com/forums/thread/3650
|
||||||
.. _Plex: https://plex.tv/
|
.. _Plex: https://plex.tv/
|
||||||
.. _musixmatch: https://www.musixmatch.com/
|
.. _musixmatch: https://www.musixmatch.com/
|
||||||
|
|
||||||
|
|
@ -2105,7 +2377,7 @@ Fixes:
|
||||||
* :doc:`/plugins/convert`: Display a useful error message when the FFmpeg
|
* :doc:`/plugins/convert`: Display a useful error message when the FFmpeg
|
||||||
executable can't be found.
|
executable can't be found.
|
||||||
|
|
||||||
.. _requests: http://www.python-requests.org/
|
.. _requests: https://www.python-requests.org/
|
||||||
|
|
||||||
|
|
||||||
1.3.3 (February 26, 2014)
|
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
|
.. _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)
|
1.3.1 (October 12, 2013)
|
||||||
|
|
@ -2354,7 +2626,7 @@ And some fixes:
|
||||||
* :doc:`/plugins/scrub`: Avoid preserving certain non-standard ID3 tags such
|
* :doc:`/plugins/scrub`: Avoid preserving certain non-standard ID3 tags such
|
||||||
as NCON.
|
as NCON.
|
||||||
|
|
||||||
.. _Opus: http://www.opus-codec.org/
|
.. _Opus: https://www.opus-codec.org/
|
||||||
.. _@Verrus: https://github.com/Verrus
|
.. _@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`_.
|
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)
|
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
|
* :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due
|
||||||
to some fixes in dealing with special characters.
|
to some fixes in dealing with special characters.
|
||||||
|
|
||||||
.. _Discogs: http://discogs.com/
|
.. _Discogs: https://discogs.com/
|
||||||
.. _Beatport: http://www.beatport.com/
|
.. _Beatport: https://www.beatport.com/
|
||||||
|
|
||||||
|
|
||||||
1.1.0 (April 29, 2013)
|
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.
|
header. Thanks to Uwe L. Korn.
|
||||||
* :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization.
|
* :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)
|
1.1b3 (March 16, 2013)
|
||||||
----------------------
|
----------------------
|
||||||
|
|
@ -2811,7 +3083,7 @@ Other new stuff:
|
||||||
(YAML doesn't like tabs.)
|
(YAML doesn't like tabs.)
|
||||||
* Fix the ``-l`` (log path) command-line option for the ``import`` command.
|
* 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)
|
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,
|
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).
|
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
|
* Renamed plugins: The ``rdm`` plugin has been renamed to ``random`` and
|
||||||
``fuzzy_search`` has been renamed to ``fuzzy``.
|
``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.
|
unintentionally loading the plugins they contain.
|
||||||
|
|
||||||
.. _The Echo Nest: http://the.echonest.com/
|
.. _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
|
.. _mp3gain: http://mp3gain.sourceforge.net/download.php
|
||||||
.. _aacgain: http://aacgain.altosdesign.com
|
.. _aacgain: https://aacgain.altosdesign.com
|
||||||
|
|
||||||
1.0b15 (July 26, 2012)
|
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``.
|
database with ``beet import -AWC /path/to/music``.
|
||||||
* Fix ``import`` with relative path arguments on Windows.
|
* 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)
|
1.0b14 (May 12, 2012)
|
||||||
---------------------
|
---------------------
|
||||||
|
|
@ -3249,7 +3521,7 @@ to come in the next couple of releases.
|
||||||
data.
|
data.
|
||||||
* Fix the ``list`` command in BPD (thanks to Simon Chopin).
|
* 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)
|
1.0b12 (January 16, 2012)
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
@ -3362,12 +3634,12 @@ release: one for assigning genres and another for ReplayGain analysis.
|
||||||
corrupted.
|
corrupted.
|
||||||
|
|
||||||
.. _KraYmer: https://github.com/KraYmer
|
.. _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
|
.. _python-musicbrainzngs: https://github.com/alastair/python-musicbrainzngs
|
||||||
.. _acoustid: http://acoustid.org/
|
.. _acoustid: https://acoustid.org/
|
||||||
.. _Peter Brunner: https://github.com/Lugoues
|
.. _Peter Brunner: https://github.com/Lugoues
|
||||||
.. _Simon Chopin: https://github.com/laarmen
|
.. _Simon Chopin: https://github.com/laarmen
|
||||||
.. _albumart.org: http://www.albumart.org/
|
.. _albumart.org: https://www.albumart.org/
|
||||||
|
|
||||||
1.0b10 (September 22, 2011)
|
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.
|
* Fix a crash on album queries with item-only field names.
|
||||||
|
|
||||||
.. _xargs: http://en.wikipedia.org/wiki/xargs
|
.. _xargs: https://en.wikipedia.org/wiki/xargs
|
||||||
.. _unidecode: http://pypi.python.org/pypi/Unidecode/0.04.1
|
.. _unidecode: https://pypi.python.org/pypi/Unidecode/0.04.1
|
||||||
|
|
||||||
1.0b8 (April 28, 2011)
|
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.
|
* 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)
|
1.0b6 (January 20, 2011)
|
||||||
------------------------
|
------------------------
|
||||||
|
|
@ -3796,7 +4068,7 @@ are also rolled into this release.
|
||||||
|
|
||||||
* Fixed escaping of ``/`` characters in paths on Windows.
|
* 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)
|
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
|
removed dependency on the aging ``cmdln`` module in favor of `a hand-rolled
|
||||||
solution`_.
|
solution`_.
|
||||||
|
|
||||||
.. _a hand-rolled solution: http://gist.github.com/462717
|
.. _a hand-rolled solution: https://gist.github.com/462717
|
||||||
|
|
||||||
1.0b1 (June 17, 2010)
|
1.0b1 (June 17, 2010)
|
||||||
---------------------
|
---------------------
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,17 @@ master_doc = 'index'
|
||||||
project = u'beets'
|
project = u'beets'
|
||||||
copyright = u'2016, Adrian Sampson'
|
copyright = u'2016, Adrian Sampson'
|
||||||
|
|
||||||
version = '1.4'
|
version = '1.5'
|
||||||
release = '1.4.8'
|
release = '1.5.0'
|
||||||
|
|
||||||
pygments_style = 'sphinx'
|
pygments_style = 'sphinx'
|
||||||
|
|
||||||
# External links to the bug tracker.
|
# External links to the bug tracker and other sites.
|
||||||
extlinks = {
|
extlinks = {
|
||||||
'bug': ('https://github.com/beetbox/beets/issues/%s', '#'),
|
'bug': ('https://github.com/beetbox/beets/issues/%s', '#'),
|
||||||
'user': ('https://github.com/%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
|
# Options for HTML output
|
||||||
|
|
|
||||||
|
|
@ -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:
|
|
||||||
9
docs/dev/cli.rst
Normal file
9
docs/dev/cli.rst
Normal file
|
|
@ -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.
|
||||||
19
docs/dev/importer.rst
Normal file
19
docs/dev/importer.rst
Normal file
|
|
@ -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.
|
||||||
|
|
@ -4,8 +4,14 @@ For Developers
|
||||||
This section contains information for developers. Read on if you're interested
|
This section contains information for developers. Read on if you're interested
|
||||||
in hacking beets itself or creating plugins for it.
|
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::
|
.. toctree::
|
||||||
|
|
||||||
plugins
|
plugins
|
||||||
api
|
library
|
||||||
media_file
|
importer
|
||||||
|
cli
|
||||||
|
|
|
||||||
279
docs/dev/library.rst
Normal file
279
docs/dev/library.rst
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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:
|
|
||||||
|
|
@ -15,7 +15,7 @@ structure should look like this::
|
||||||
myawesomeplugin.py
|
myawesomeplugin.py
|
||||||
|
|
||||||
.. _Stack Overflow question about namespace packages:
|
.. _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
|
Then, you'll need to put this stuff in ``__init__.py`` to make ``beetsplug`` a
|
||||||
namespace package::
|
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
|
``plugins: myawesomeplugin`` (substituting the name of the Python module
|
||||||
containing your plugin).
|
containing your plugin).
|
||||||
|
|
||||||
.. _virtualenv: http://pypi.python.org/pypi/virtualenv
|
.. _virtualenv: https://pypi.org/project/virtualenv
|
||||||
|
|
||||||
.. _add_subcommands:
|
.. _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
|
description of your command, and ``aliases`` is a list of shorthand versions of
|
||||||
your command name.
|
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 =
|
You'll need to add a function to your command by saying ``mycommand.func =
|
||||||
myfunction``. This function should take the following parameters: ``lib`` (a
|
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`_).
|
arguments as returned by `OptionParser.parse_args`_).
|
||||||
|
|
||||||
.. _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``.
|
The function should use any of the utility functions defined in ``beets.ui``.
|
||||||
Try running ``pydoc beets.ui`` to see what's available.
|
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
|
is successfully autotagged or update MPD's index whenever the database is
|
||||||
changed.
|
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::
|
an example::
|
||||||
|
|
||||||
from beets.plugins import BeetsPlugin
|
from beets.plugins import BeetsPlugin
|
||||||
|
|
||||||
class SomePlugin(BeetsPlugin):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@SomePlugin.listen('pluginload')
|
|
||||||
def loaded():
|
def loaded():
|
||||||
print 'Plugin 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
|
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
|
``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
|
foo: bar
|
||||||
|
|
||||||
To access this value, say ``self.config['foo'].get()`` at any point in your
|
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.
|
library.
|
||||||
|
|
||||||
.. _Confit: http://confit.readthedocs.org/
|
.. _Confuse: https://confuse.readthedocs.org/
|
||||||
|
|
||||||
If you want to access configuration values *outside* of your plugin's section,
|
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
|
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
|
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
|
cross-format metadata manipulation simple. Plugins can add fields to MediaFile
|
||||||
to extend the kinds of metadata that they can easily manage.
|
to extend the kinds of metadata that they can easily manage.
|
||||||
|
|
||||||
The ``MediaFile`` class uses ``MediaField`` descriptors to provide
|
The ``MediaFile`` class uses ``MediaField`` descriptors to provide
|
||||||
access to file tags. Have a look at the ``beets.mediafile`` source code
|
access to file tags. If you have created a descriptor you can add it through
|
||||||
to learn how to use this descriptor class. If you have created a
|
your plugins ``add_media_field()`` method.
|
||||||
descriptor you can add it through your plugins ``add_media_field()``
|
|
||||||
method.
|
|
||||||
|
|
||||||
.. automethod:: beets.plugins.BeetsPlugin.add_media_field
|
.. automethod:: beets.plugins.BeetsPlugin.add_media_field
|
||||||
|
.. _MediaFile: https://mediafile.readthedocs.io/
|
||||||
|
|
||||||
|
|
||||||
Here's an example plugin that provides a meaningless new field "foo"::
|
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
|
You can add new kinds of queries to beets' :doc:`query syntax
|
||||||
</reference/query>` indicated by a prefix. As an example, beets already
|
</reference/query>`. 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
|
supports regular expression queries, which are indicated by a colon
|
||||||
prefix---plugins can do the same.
|
prefix---plugins can do the same.
|
||||||
|
|
||||||
To do so, define a subclass of the ``Query`` type from the
|
For either kind of query extension, define a subclass of the ``Query`` type
|
||||||
``beets.dbcore.query`` module. Then, in the ``queries`` method of your plugin
|
from the ``beets.dbcore.query`` module. Then:
|
||||||
class, return a dictionary mapping prefix strings to query classes.
|
|
||||||
|
|
||||||
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
|
implements string comparisons on fields. To use it, create a subclass
|
||||||
inheriting from that class and override the ``value_match`` class method.
|
inheriting from that class and override the ``value_match`` class method.
|
||||||
(Remember the ``@classmethod`` decorator!) The following example plugin
|
(Remember the ``@classmethod`` decorator!) The following example plugin
|
||||||
|
|
|
||||||
22
docs/faq.rst
22
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 <bugs>` in the bug tracker.
|
:ref:`filing an issue <bugs>` in the bug tracker.
|
||||||
|
|
||||||
.. _IRC: irc://irc.freenode.net/beets
|
.. _IRC: irc://irc.freenode.net/beets
|
||||||
.. _mailing list: http://groups.google.com/group/beets-users
|
.. _mailing list: https://groups.google.com/group/beets-users
|
||||||
.. _discussion board: http://discourse.beets.io
|
.. _discussion board: https://discourse.beets.io
|
||||||
|
|
||||||
.. contents::
|
.. contents::
|
||||||
:local:
|
: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:
|
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``
|
- ``d569deba-8c6b-4d08-8c43-d0e5a1b8c7f3``
|
||||||
|
|
||||||
Beets can recognize either the hex-with-dashes UUID-style string or the
|
Beets can recognize either the hex-with-dashes UUID-style string or the
|
||||||
full URL that contains it (as of 1.0b11).
|
full URL that contains it (as of 1.0b11).
|
||||||
|
|
||||||
You can get these IDs by `searching on the MusicBrainz web
|
You can get these IDs by `searching on the MusicBrainz web
|
||||||
site <http://musicbrainz.org/>`__ and going to a *release* page (when
|
site <https://musicbrainz.org/>`__ and going to a *release* page (when
|
||||||
tagging full albums) or a *recording* page (when tagging singletons).
|
tagging full albums) or a *recording* page (when tagging singletons).
|
||||||
Then, copy the URL of the page and paste it into beets.
|
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
|
pip install -U beets
|
||||||
|
|
||||||
The ``-U`` flag tells `pip <http://www.pip-installer.org>`__ to upgrade
|
The ``-U`` flag tells `pip <https://pip.pypa.io/>`__ to upgrade
|
||||||
beets to the latest version. If you want a specific version, you can
|
beets to the latest version. If you want a specific version, you can
|
||||||
specify with using ``==`` like so::
|
specify with using ``==`` like so::
|
||||||
|
|
||||||
|
|
@ -163,10 +163,10 @@ on GitHub. `Enter a new issue <https://github.com/beetbox/beets/issues/new>`__
|
||||||
there to report a bug. Please follow these guidelines when reporting an issue:
|
there to report a bug. Please follow these guidelines when reporting an issue:
|
||||||
|
|
||||||
- Most importantly: if beets is crashing, please `include the
|
- Most importantly: if beets is crashing, please `include the
|
||||||
traceback <http://imgur.com/jacoj>`__. Tracebacks can be more
|
traceback <https://imgur.com/jacoj>`__. Tracebacks can be more
|
||||||
readable if you put them in a pastebin (e.g.,
|
readable if you put them in a pastebin (e.g.,
|
||||||
`Gist <https://gist.github.com/>`__ or
|
`Gist <https://gist.github.com/>`__ or
|
||||||
`Hastebin <http://hastebin.com/>`__), especially when communicating
|
`Hastebin <https://hastebin.com/>`__), especially when communicating
|
||||||
over IRC or email.
|
over IRC or email.
|
||||||
- Turn on beets' debug output (using the -v option: for example,
|
- Turn on beets' debug output (using the -v option: for example,
|
||||||
``beet -v import ...``) and include that with your bug report. Look
|
``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
|
If you've never reported a bug before, Mozilla has some well-written
|
||||||
`general guidelines for good bug
|
`general guidelines for good bug
|
||||||
reports <http://www.mozilla.org/bugs/>`__.
|
reports <https://www.mozilla.org/bugs/>`__.
|
||||||
|
|
||||||
|
|
||||||
.. _find-config:
|
.. _find-config:
|
||||||
|
|
@ -237,7 +237,7 @@ Why does beets…
|
||||||
There are a number of possibilities:
|
There are a number of possibilities:
|
||||||
|
|
||||||
- First, make sure the album is in `the MusicBrainz
|
- First, make sure the album is in `the MusicBrainz
|
||||||
database <http://musicbrainz.org/>`__. You
|
database <https://musicbrainz.org/>`__. You
|
||||||
can search on their site to make sure it's cataloged there. (If not,
|
can search on their site to make sure it's cataloged there. (If not,
|
||||||
anyone can edit MusicBrainz---so consider adding the data yourself.)
|
anyone can edit MusicBrainz---so consider adding the data yourself.)
|
||||||
- If the album in question is a multi-disc release, see the relevant
|
- 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
|
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
|
file is corrupted. To check whether the file is intact, try opening it
|
||||||
in another media player (e.g.,
|
in another media player (e.g.,
|
||||||
`VLC <http://www.videolan.org/vlc/index.html>`__) to see whether it can
|
`VLC <https://www.videolan.org/vlc/index.html>`__) to see whether it can
|
||||||
read the file. You can also use specialized programs for checking file
|
read the file. You can also use specialized programs for checking file
|
||||||
integrity---for example, type ``metaflac --list music.flac`` to check
|
integrity---for example, type ``metaflac --list music.flac`` to check
|
||||||
FLAC files.
|
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``,
|
``beet`` was placed on your system. If you need help extending your ``$PATH``,
|
||||||
try `this Super User answer`_.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
The plugin has many more dials you can fiddle with to get your conversions how
|
||||||
you like them. Check out :doc:`its documentation </plugins/convert>`.
|
you like them. Check out :doc:`its documentation </plugins/convert>`.
|
||||||
|
|
||||||
.. _ffmpeg: http://www.ffmpeg.org
|
.. _ffmpeg: https://www.ffmpeg.org
|
||||||
|
|
||||||
|
|
||||||
Store any data you like
|
Store any data you like
|
||||||
|
|
@ -127,7 +127,7 @@ And, unlike :ref:`built-in fields <itemfields>`, such fields can be removed::
|
||||||
Read more than you ever wanted to know about the *flexible attributes*
|
Read more than you ever wanted to know about the *flexible attributes*
|
||||||
feature `on the beets blog`_.
|
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
|
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
|
Used together, flexible attributes and path format conditions let you sort
|
||||||
your music by any criteria you can imagine.
|
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 </reference/config>`:
|
||||||
|
|
||||||
|
.. 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.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Getting Started
|
||||||
Welcome to `beets`_! This guide will help you begin using it to make your music
|
Welcome to `beets`_! This guide will help you begin using it to make your music
|
||||||
collection better.
|
collection better.
|
||||||
|
|
||||||
.. _beets: http://beets.io/
|
.. _beets: https://beets.io/
|
||||||
|
|
||||||
Installing
|
Installing
|
||||||
----------
|
----------
|
||||||
|
|
@ -12,7 +12,7 @@ Installing
|
||||||
You will need Python.
|
You will need Python.
|
||||||
Beets works on `Python 2.7`_ and Python 3.4 or later.
|
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.
|
* **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`_:
|
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:
|
as described below by running:
|
||||||
``apt-get install python-dev python-pip``
|
``apt-get install python-dev python-pip``
|
||||||
|
|
||||||
* On **Arch Linux**, `beets is in [community]`_, so just run ``pacman -S
|
* On **Arch Linux**, `beets is in [community] <Arch community_>`_, so just run ``pacman -S
|
||||||
beets``. (There's also a bleeding-edge `dev package`_ in the AUR, which will
|
beets``. (There's also a bleeding-edge `dev package <AUR_>`_ in the AUR, which will
|
||||||
probably set your computer on fire.)
|
probably set your computer on fire.)
|
||||||
|
|
||||||
* For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
|
* For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
|
||||||
``emerge beets`` to install. There are several USE flags available for
|
``emerge beets`` to install. There are several USE flags available for
|
||||||
optional plugin dependencies.
|
optional plugin dependencies.
|
||||||
|
|
||||||
* On **FreeBSD**, there's a `beets port`_ at ``audio/beets``.
|
* On **FreeBSD**, there's a `beets port <FreeBSD_>`_ at ``audio/beets``.
|
||||||
|
|
||||||
* On **OpenBSD**, beets can be installed with ``pkg_add beets``.
|
* On **OpenBSD**, there's a `beets port <OpenBSD_>`_ can be installed with ``pkg_add beets``.
|
||||||
|
|
||||||
* For **Slackware**, there's a `SlackBuild`_ available.
|
* 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
|
$ sudo dnf install beets beets-plugins beets-doc
|
||||||
|
|
||||||
* On **Solus**, run ``eopkg install beets``.
|
* On **Solus**, run ``eopkg install beets``.
|
||||||
|
|
||||||
* On **NixOS**, run ``nix-env -i beets``.
|
* On **NixOS**, there's a `package <NixOS_>`_ you can install with ``nix-env -i beets``.
|
||||||
|
|
||||||
.. _copr: https://copr.fedoraproject.org/coprs/afreof/beets/
|
.. _DNF package: https://apps.fedoraproject.org/packages/beets
|
||||||
.. _dnf package: https://apps.fedoraproject.org/packages/beets
|
.. _SlackBuild: https://slackbuilds.org/repository/14.2/multimedia/beets/
|
||||||
.. _SlackBuild: http://slackbuilds.org/repository/14.1/multimedia/beets/
|
.. _FreeBSD: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
|
||||||
.. _beets port: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
|
.. _AUR: https://aur.archlinux.org/packages/beets-git/
|
||||||
.. _beets from AUR: https://aur.archlinux.org/packages/beets-git/
|
.. _Debian details: https://tracker.debian.org/pkg/beets
|
||||||
.. _dev package: https://aur.archlinux.org/packages/beets-git/
|
|
||||||
.. _Debian details: http://packages.qa.debian.org/b/beets.html
|
|
||||||
.. _Ubuntu details: https://launchpad.net/ubuntu/+source/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
|
If you have `pip`_, just say ``pip install beets`` (or ``pip install --user
|
||||||
beets`` if you run into permissions problems).
|
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
|
To install without pip, download beets from `its PyPI page`_ and run ``python
|
||||||
setup.py install`` in the directory therein.
|
setup.py install`` in the directory therein.
|
||||||
|
|
||||||
.. _its PyPI page: http://pypi.python.org/pypi/beets#downloads
|
.. _its PyPI page: https://pypi.org/project/beets#downloads
|
||||||
.. _pip: http://www.pip-installer.org/
|
.. _pip: https://pip.pypa.io
|
||||||
|
|
||||||
The best way to upgrade beets to a new version is by running ``pip install -U
|
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
|
beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on
|
||||||
new versions.
|
new versions.
|
||||||
|
|
||||||
.. _@b33ts: http://twitter.com/b33ts
|
.. _@b33ts: https://twitter.com/b33ts
|
||||||
|
|
||||||
Installing on macOS 10.11 and Higher
|
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``.
|
``~/Library/Python/3.6/bin`` to your ``$PATH``.
|
||||||
|
|
||||||
.. _System Integrity Protection: https://support.apple.com/en-us/HT204899
|
.. _System Integrity Protection: https://support.apple.com/en-us/HT204899
|
||||||
.. _Homebrew: http://brew.sh
|
.. _Homebrew: https://brew.sh
|
||||||
|
|
||||||
Installing on Windows
|
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
|
trouble or you have more detail to contribute here, please direct it to
|
||||||
`the mailing list`_.
|
`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
|
.. _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
|
.. _install pip: https://pip.pypa.io/en/stable/installing/
|
||||||
.. _get-pip.py: https://raw.github.com/pypa/pip/master/contrib/get-pip.py
|
.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py
|
||||||
|
|
||||||
|
|
||||||
Configuring
|
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
|
here, including the directory and file naming scheme. See
|
||||||
:doc:`/reference/config` for a full reference.
|
:doc:`/reference/config` for a full reference.
|
||||||
|
|
||||||
.. _YAML: http://yaml.org/
|
.. _YAML: https://yaml.org/
|
||||||
|
|
||||||
Importing Your Library
|
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
|
Please let me know what you think of beets via `the discussion board`_ or
|
||||||
`Twitter`_.
|
`Twitter`_.
|
||||||
|
|
||||||
.. _the mailing list: http://groups.google.com/group/beets-users
|
.. _the mailing list: https://groups.google.com/group/beets-users
|
||||||
.. _the discussion board: http://discourse.beets.io
|
.. _the discussion board: https://discourse.beets.io
|
||||||
.. _twitter: http://twitter.com/b33ts
|
.. _twitter: https://twitter.com/b33ts
|
||||||
|
|
|
||||||
|
|
@ -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
|
a try without it. You may be surprised at how well metadata-based matching
|
||||||
works.
|
works.
|
||||||
|
|
||||||
.. _Chromaprint: http://acoustid.org/chromaprint
|
.. _Chromaprint: https://acoustid.org/chromaprint
|
||||||
|
|
||||||
Album Art, Lyrics, Genres and Such
|
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
|
their site to make sure it's cataloged there. If not, anyone can edit
|
||||||
MusicBrainz---so consider adding the data yourself.
|
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
|
If you think beets is ignoring an album that's listed in MusicBrainz, please
|
||||||
`file a bug report`_.
|
`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
|
If we haven't made the process clear, please post on `the discussion
|
||||||
board`_ and we'll try to improve this guide.
|
board`_ and we'll try to improve this guide.
|
||||||
|
|
||||||
.. _the mailing list: http://groups.google.com/group/beets-users
|
.. _the mailing list: https://groups.google.com/group/beets-users
|
||||||
.. _the discussion board: http://discourse.beets.io
|
.. _the discussion board: https://discourse.beets.io
|
||||||
|
|
|
||||||
|
|
@ -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
|
or `file a bug`_ in the issue tracker. Please let us know where you think this
|
||||||
documentation can be improved.
|
documentation can be improved.
|
||||||
|
|
||||||
.. _beets: http://beets.io/
|
.. _beets: https://beets.io/
|
||||||
.. _the mailing list: http://groups.google.com/group/beets-users
|
.. _the mailing list: https://groups.google.com/group/beets-users
|
||||||
.. _file a bug: https://github.com/beetbox/beets/issues
|
.. _file a bug: https://github.com/beetbox/beets/issues
|
||||||
.. _the discussion board: http://discourse.beets.io
|
.. _the discussion board: https://discourse.beets.io
|
||||||
|
|
||||||
Contents
|
Contents
|
||||||
--------
|
--------
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
AcousticBrainz Submit Plugin
|
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.
|
`AcousticBrainz`_ server.
|
||||||
|
|
||||||
Installation
|
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
|
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.
|
- **extractor**: The absolute path to the `streaming_extractor_music`_ binary.
|
||||||
Default: search for the program in your ``$PATH``
|
Default: search for the program in your ``$PATH``
|
||||||
|
|
||||||
.. _streaming_extractor_music: http://acousticbrainz.org/download
|
.. _streaming_extractor_music: https://acousticbrainz.org/download
|
||||||
.. _FAQ: http://acousticbrainz.org/faq
|
.. _FAQ: https://acousticbrainz.org/faq
|
||||||
.. _pip: http://www.pip-installer.org/
|
.. _pip: https://pip.pypa.io
|
||||||
.. _requests: http://docs.python-requests.org/en/master/
|
.. _requests: https://docs.python-requests.org/en/master/
|
||||||
.. _github: https://github.com/MTG/essentia
|
.. _github: https://github.com/MTG/essentia
|
||||||
.. _AcousticBrainz: https://acousticbrainz.org
|
.. _AcousticBrainz: https://acousticbrainz.org
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ AcousticBrainz Plugin
|
||||||
The ``acousticbrainz`` plugin gets acoustic-analysis information from the
|
The ``acousticbrainz`` plugin gets acoustic-analysis information from the
|
||||||
`AcousticBrainz`_ project.
|
`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::
|
Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing::
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ Here is an example where the FLAC decoder signals a corrupt file::
|
||||||
00.flac: ERROR while decoding data
|
00.flac: ERROR while decoding data
|
||||||
state = FLAC__STREAM_DECODER_READ_FRAME
|
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.
|
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
|
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.
|
in the middle of a file, this is a bad sign.
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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
|
.. _requests_oauthlib: https://github.com/requests/requests-oauthlib
|
||||||
.. _Beatport: http://beatport.com
|
.. _Beatport: https://beetport.com
|
||||||
|
|
|
||||||
|
|
@ -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.
|
out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
|
||||||
|
|
||||||
.. _Theremin: https://theremin.sigterm.eu/
|
.. _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/
|
.. _Sonata: http://sonata.berlios.de/
|
||||||
.. _Ario: http://ario-player.sourceforge.net/
|
.. _Ario: http://ario-player.sourceforge.net/
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ with its Python bindings) on your system.
|
||||||
gst-plugins-base pygobject3``.
|
gst-plugins-base pygobject3``.
|
||||||
|
|
||||||
* On Linux, you need to install GStreamer 1.0 and the GObject bindings for
|
* 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
|
* On Windows, you may want to try `GStreamer WinBuilds`_ (caveat emptor: I
|
||||||
haven't tried this).
|
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
|
work. See the :doc:`/plugins/chroma` documentation for more information on
|
||||||
installing GStreamer plugins.
|
installing GStreamer plugins.
|
||||||
|
|
||||||
.. _GStreamer WinBuilds: http://www.gstreamer-winbuild.ylatuya.es/
|
.. _GStreamer WinBuilds: https://www.gstreamer-winbuild.ylatuya.es/
|
||||||
.. _Homebrew: http://mxcl.github.com/homebrew/
|
.. _Homebrew: https://brew.sh
|
||||||
|
|
||||||
Usage
|
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
|
Fire up your favorite MPD client to start playing music. The MPD site has `a
|
||||||
long list of available clients`_. Here are my favorites:
|
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`_
|
* 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.
|
* 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
|
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
|
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.
|
Default: No password.
|
||||||
- **volume**: Initial volume, as a percentage.
|
- **volume**: Initial volume, as a percentage.
|
||||||
Default: 100
|
Default: 100
|
||||||
|
- **control_port**: Port for the internal control socket.
|
||||||
|
Default: 6601
|
||||||
|
|
||||||
Here's an example::
|
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
|
string matching on items' destination, but this requires examining the entire
|
||||||
library Python-side for every query.)
|
library Python-side for every query.)
|
||||||
|
|
||||||
We don't currently support versioned playlists. Many clients, however, use
|
BPD plays music using GStreamer's ``playbin`` player, which has a simple API
|
||||||
|
but doesn't support many advanced playback features.
|
||||||
|
|
||||||
|
Differences from the real MPD
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
.. _the MPD protocol: https://www.musicpd.org/doc/protocol/
|
||||||
|
|
||||||
|
These are some of the known differences between BPD and MPD:
|
||||||
|
|
||||||
|
* BPD doesn't currently support versioned playlists. Many clients, however, use
|
||||||
plchanges instead of playlistinfo to get the current playlist, so plchanges
|
plchanges instead of playlistinfo to get the current playlist, so plchanges
|
||||||
contains a dummy implementation that just calls playlistinfo.
|
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
|
* 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
|
indicate the amount of time the server has spent playing music. BPD doesn't
|
||||||
currently keep track of this.
|
currently keep track of this.
|
||||||
|
* The ``update`` command regenerates the directory tree from the beets database
|
||||||
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
|
||||||
Unimplemented Commands
|
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.
|
||||||
These are the commands from `the MPD protocol`_ that have not yet been
|
* BPD's ``random`` mode is not deterministic and doesn't support priorities.
|
||||||
implemented in BPD.
|
* 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
|
||||||
.. _the MPD protocol: http://www.musicpd.org/doc/protocol/
|
nomenclature so this is feasible to add).
|
||||||
|
* There is only a single password, and is enabled it grants access to all
|
||||||
Saved playlists:
|
features rather than having permissions-based granularity.
|
||||||
|
* Partitions and alternative outputs are not supported; BPD can only play one
|
||||||
* playlistclear
|
song at a time.
|
||||||
* playlistdelete
|
* Client channels are not implemented.
|
||||||
* playlistmove
|
|
||||||
* playlistadd
|
|
||||||
* playlistsearch
|
|
||||||
* listplaylist
|
|
||||||
* listplaylistinfo
|
|
||||||
* playlistfind
|
|
||||||
* rm
|
|
||||||
* save
|
|
||||||
* load
|
|
||||||
* rename
|
|
||||||
|
|
||||||
Deprecated:
|
|
||||||
|
|
||||||
* playlist
|
|
||||||
* volume
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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)
|
- For alpha ranges: the range is defined by the lowest and highest (ASCII-wise)
|
||||||
alphanumeric characters in the string you provide. For example, *ABCD*,
|
alphanumeric characters in the string you provide. For example, ``ABCD``,
|
||||||
*A-D*, *A->D*, and *[AD]* are all equivalent.
|
``A-D``, ``A->D``, and ``[AD]`` are all equivalent.
|
||||||
- For year ranges: digits characters are extracted and the two extreme years
|
- 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
|
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.
|
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,
|
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
|
second argument to the template to avoid this automatic detection: for
|
||||||
example, use `%bucket{$artist,alpha}`.
|
example, use ``%bucket{$artist,alpha}``.
|
||||||
|
|
||||||
|
|
||||||
Configuration
|
Configuration
|
||||||
|
|
@ -56,7 +56,7 @@ The available options are:
|
||||||
overrides original range definition.
|
overrides original range definition.
|
||||||
Default: none.
|
Default: none.
|
||||||
- **bucket_year**: Ranges to use for all substitutions occurring on the
|
- **bucket_year**: Ranges to use for all substitutions occurring on the
|
||||||
`$year` field.
|
``$year`` field.
|
||||||
Default: none.
|
Default: none.
|
||||||
- **extrapolate**: Enable this if you want to group your files into multiple
|
- **extrapolate**: Enable this if you want to group your files into multiple
|
||||||
year ranges without enumerating them all. This option will generate year
|
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…äÄ]
|
'A - D': ^[0-9a-dA-D…äÄ]
|
||||||
|
|
||||||
This configuration creates five-year ranges for any input year.
|
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.
|
and … (ellipsis). The other alpha buckets work as ranges.
|
||||||
|
|
|
||||||
|
|
@ -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
|
open-source fingerprinting technology called `Chromaprint`_ and its associated
|
||||||
Web service, called `Acoustid`_.
|
Web service, called `Acoustid`_.
|
||||||
|
|
||||||
.. _Chromaprint: http://acoustid.org/chromaprint
|
.. _Chromaprint: https://acoustid.org/chromaprint
|
||||||
.. _acoustid: http://acoustid.org/
|
.. _acoustid: https://acoustid.org/
|
||||||
|
|
||||||
Turning on fingerprinting can increase the accuracy of the
|
Turning on fingerprinting can increase the accuracy of the
|
||||||
autotagger---especially on files with very poor metadata---but it comes at a
|
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 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
|
Then, you will need to install `Chromaprint`_, either as a dynamic library or
|
||||||
in the form of a command-line tool (``fpcalc``).
|
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
|
means something like ``C:\\Program Files``. On OS X or Linux, put the
|
||||||
executable somewhere like ``/usr/local/bin``.
|
executable somewhere like ``/usr/local/bin``.
|
||||||
|
|
||||||
.. _download: http://acoustid.org/chromaprint
|
.. _download: https://acoustid.org/chromaprint
|
||||||
|
|
||||||
Installing the Library
|
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
|
`Homebrew`_ on Mac OS X, you can install the library with ``brew install
|
||||||
chromaprint``.
|
chromaprint``.
|
||||||
|
|
||||||
.. _Homebrew: http://mxcl.github.com/homebrew/
|
.. _Homebrew: https://brew.sh/
|
||||||
|
|
||||||
You will also need a mechanism for decoding audio files supported by the
|
You will also need a mechanism for decoding audio files supported by the
|
||||||
`audioread`_ library:
|
`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`_
|
* On Windows, builds are provided by `GStreamer`_
|
||||||
|
|
||||||
.. _audioread: https://github.com/beetbox/audioread
|
.. _audioread: https://github.com/beetbox/audioread
|
||||||
.. _pyacoustid: http://github.com/beetbox/pyacoustid
|
.. _pyacoustid: https://github.com/beetbox/pyacoustid
|
||||||
.. _FFmpeg: http://ffmpeg.org/
|
.. _FFmpeg: https://ffmpeg.org/
|
||||||
.. _MAD: http://spacepants.org/src/pymad/
|
.. _MAD: https://spacepants.org/src/pymad/
|
||||||
.. _pymad: http://www.underbit.com/products/mad/
|
.. _pymad: https://www.underbit.com/products/mad/
|
||||||
.. _Core Audio: http://developer.apple.com/technologies/mac/audio-and-video.html
|
.. _Core Audio: https://developer.apple.com/technologies/mac/audio-and-video.html
|
||||||
.. _Gstreamer: http://gstreamer.freedesktop.org/
|
.. _Gstreamer: https://gstreamer.freedesktop.org/
|
||||||
.. _PyGObject: https://wiki.gnome.org/Projects/PyGObject
|
.. _PyGObject: https://wiki.gnome.org/Projects/PyGObject
|
||||||
|
|
||||||
To decode audio formats (MP3, FLAC, etc.) with GStreamer, you'll need the
|
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;
|
your library.) The command will use stored fingerprints if they're available;
|
||||||
otherwise it will fingerprint each file before submitting it.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
:ref:`using-plugins`). By default, the plugin depends on `FFmpeg`_ to
|
||||||
transcode the audio, so you might want to install it.
|
transcode the audio, so you might want to install it.
|
||||||
|
|
||||||
.. _FFmpeg: http://ffmpeg.org
|
.. _FFmpeg: https://ffmpeg.org
|
||||||
|
|
||||||
|
|
||||||
Usage
|
Usage
|
||||||
|
|
@ -68,6 +68,8 @@ file. The available options are:
|
||||||
- **dest**: The directory where the files will be converted (or copied) to.
|
- **dest**: The directory where the files will be converted (or copied) to.
|
||||||
Default: none.
|
Default: none.
|
||||||
- **embed**: Embed album art in converted items. Default: ``yes``.
|
- **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
|
- **max_bitrate**: All lossy files with a higher bitrate will be
|
||||||
transcoded and those with a lower bitrate will simply be copied. Note that
|
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
|
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
|
In this example ``beet convert`` will use the *speex* command by
|
||||||
default. To convert the audio to `wav`, run ``beet convert -f wav``.
|
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
|
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.
|
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.
|
options and a thorough discussion of MP3 encoding.
|
||||||
|
|
||||||
.. _documentation: http://lame.sourceforge.net/using.php
|
.. _documentation: http://lame.sourceforge.net/using.php
|
||||||
.. _HydrogenAudio wiki: http://wiki.hydrogenaud.io/index.php?title=LAME
|
.. _HydrogenAudio wiki: https://wiki.hydrogenaud.io/index.php?title=LAME
|
||||||
.. _gapless: http://wiki.hydrogenaud.io/index.php?title=Gapless_playback
|
.. _gapless: https://wiki.hydrogenaud.io/index.php?title=Gapless_playback
|
||||||
.. _LAME: http://lame.sourceforge.net/
|
.. _LAME: https://lame.sourceforge.net/
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Discogs Plugin
|
||||||
The ``discogs`` plugin extends the autotagger's search capabilities to
|
The ``discogs`` plugin extends the autotagger's search capabilities to
|
||||||
include matches from the `Discogs`_ database.
|
include matches from the `Discogs`_ database.
|
||||||
|
|
||||||
.. _Discogs: http://discogs.com
|
.. _Discogs: https://discogs.com
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ Note: ``compare_threshold`` option requires `ImageMagick`_, and ``maxwidth``
|
||||||
requires either `ImageMagick`_ or `Pillow`_.
|
requires either `ImageMagick`_ or `Pillow`_.
|
||||||
|
|
||||||
.. _Pillow: https://github.com/python-pillow/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/
|
.. _PHASH: http://www.fmwconcepts.com/misc_tests/perceptual_hash_test_results_510/
|
||||||
|
|
||||||
Manually Embedding and Extracting Art
|
Manually Embedding and Extracting Art
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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/
|
.. _Emby: https://emby.media/
|
||||||
.. _requests: http://docs.python-requests.org/en/latest/
|
.. _requests: https://docs.python-requests.org/en/latest/
|
||||||
|
|
||||||
Configuration
|
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
|
- **password**: The password for the user. (This is only necessary if no API
|
||||||
key is provided.)
|
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.
|
one of those two is required.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Export Plugin
|
||||||
The ``export`` plugin lets you get data from the items and export the content
|
The ``export`` plugin lets you get data from the items and export the content
|
||||||
as `JSON`_.
|
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 </reference/query>` to get the data from
|
Enable the ``export`` plugin (see :ref:`using-plugins` for help). Then, type ``beet export`` followed by a :doc:`query </reference/query>` to get the data from
|
||||||
your library. For example, run this::
|
your library. For example, run this::
|
||||||
|
|
@ -42,7 +42,7 @@ Configuration
|
||||||
To configure the plugin, make a ``export:`` section in your configuration
|
To configure the plugin, make a ``export:`` section in your configuration
|
||||||
file. Under the ``json`` key, these options are available:
|
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.
|
- **indent**: The number of spaces for indentation.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
Fetching Album Art During Import
|
||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
@ -73,18 +73,18 @@ or `Pillow`_.
|
||||||
|
|
||||||
.. note::
|
.. 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
|
look for art on the filesystem. This is
|
||||||
still respected, but a deprecation message will be shown until you
|
still respected, but a deprecation message will be shown until you
|
||||||
replace this configuration with the new `filesystem` value in the
|
replace this configuration with the new ``filesystem`` value in the
|
||||||
`sources` array.
|
``sources`` array.
|
||||||
|
|
||||||
.. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm
|
.. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm
|
||||||
.. _Pillow: https://github.com/python-pillow/Pillow
|
.. _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
|
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
|
``back`` keywords in their filenames and prioritizes the iTunes source over
|
||||||
others::
|
others::
|
||||||
|
|
||||||
fetchart:
|
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.
|
environment variable so that ImageMagick comes first or use Pillow instead.
|
||||||
|
|
||||||
.. _Pillow: https://github.com/python-pillow/Pillow
|
.. _Pillow: https://github.com/python-pillow/Pillow
|
||||||
.. _ImageMagick: http://www.imagemagick.org/
|
.. _ImageMagick: https://www.imagemagick.org/
|
||||||
|
|
||||||
.. _album-art-sources:
|
.. _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
|
token and use it for your ``google_engine`` configuration option. The
|
||||||
default engine searches the entire web for cover art.
|
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.
|
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.
|
After that, the fetchart plugin will fall back on other declared data sources.
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ Freedesktop Plugin
|
||||||
|
|
||||||
The ``freedesktop`` plugin created .directory files in your album folders.
|
The ``freedesktop`` plugin created .directory files in your album folders.
|
||||||
This plugin is now deprecated and replaced by the :doc:`/plugins/thumbnails`
|
This plugin is now deprecated and replaced by the :doc:`/plugins/thumbnails`
|
||||||
with the `dolphin` option enabled.
|
with the ``dolphin`` option enabled.
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,4 @@ your entire collection.
|
||||||
Use the ``-d`` flag to remove featured artists (equivalent of the ``drop``
|
Use the ``-d`` flag to remove featured artists (equivalent of the ``drop``
|
||||||
config option).
|
config option).
|
||||||
|
|
||||||
.. _MusicBrainz style: http://musicbrainz.org/doc/Style
|
.. _MusicBrainz style: https://musicbrainz.org/doc/Style
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ songs in your library.
|
||||||
Installation
|
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
|
pip install gmusicapi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ Using Plugins
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
To use one of the plugins included with beets (see the rest of this page for a
|
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 </reference/config>` file, like so::
|
list), just use the ``plugins`` option in your :doc:`config.yaml </reference/config>` file, like so::
|
||||||
|
|
||||||
plugins: inline convert web
|
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
|
YAML list like ``[foo, bar]``. You can see which plugins are currently enabled
|
||||||
by typing ``beet version``.
|
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
|
Some plugins have special dependencies that you'll need to install. The
|
||||||
documentation page for each plugin will list them in the setup instructions.
|
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::
|
like this::
|
||||||
|
|
||||||
pip install beets[fetchart,lyrics,lastgenre]
|
pip install beets[fetchart,lyrics,lastgenre]
|
||||||
|
|
@ -71,6 +71,7 @@ like this::
|
||||||
kodiupdate
|
kodiupdate
|
||||||
lastgenre
|
lastgenre
|
||||||
lastimport
|
lastimport
|
||||||
|
loadext
|
||||||
lyrics
|
lyrics
|
||||||
mbcollection
|
mbcollection
|
||||||
mbsubmit
|
mbsubmit
|
||||||
|
|
@ -81,6 +82,7 @@ like this::
|
||||||
mpdupdate
|
mpdupdate
|
||||||
permissions
|
permissions
|
||||||
play
|
play
|
||||||
|
playlist
|
||||||
plexupdate
|
plexupdate
|
||||||
random
|
random
|
||||||
replaygain
|
replaygain
|
||||||
|
|
@ -105,7 +107,7 @@ Autotagger Extensions
|
||||||
* :doc:`fromfilename`: Guess metadata for untagged tracks from their
|
* :doc:`fromfilename`: Guess metadata for untagged tracks from their
|
||||||
filenames.
|
filenames.
|
||||||
|
|
||||||
.. _Discogs: http://www.discogs.com/
|
.. _Discogs: https://www.discogs.com/
|
||||||
|
|
||||||
Metadata
|
Metadata
|
||||||
--------
|
--------
|
||||||
|
|
@ -134,7 +136,7 @@ Metadata
|
||||||
* :doc:`zero`: Nullify fields by pattern or unconditionally.
|
* :doc:`zero`: Nullify fields by pattern or unconditionally.
|
||||||
|
|
||||||
.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/
|
.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/
|
||||||
.. _streaming_extractor_music: http://acousticbrainz.org/download
|
.. _streaming_extractor_music: https://acousticbrainz.org/download
|
||||||
|
|
||||||
Path Formats
|
Path Formats
|
||||||
------------
|
------------
|
||||||
|
|
@ -158,6 +160,7 @@ Interoperability
|
||||||
* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library
|
* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library
|
||||||
changes.
|
changes.
|
||||||
* :doc:`play`: Play beets queries in your music player.
|
* :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
|
* :doc:`plexupdate`: Automatically notifies `Plex`_ whenever the beets library
|
||||||
changes.
|
changes.
|
||||||
* :doc:`smartplaylist`: Generate smart playlists based on beets queries.
|
* :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.
|
* :doc:`thumbnails`: Get thumbnails with the cover art on your album folders.
|
||||||
|
|
||||||
|
|
||||||
.. _Emby: http://emby.media
|
.. _Emby: https://emby.media
|
||||||
.. _Plex: http://plex.tv
|
.. _Plex: https://plex.tv
|
||||||
.. _Kodi: http://kodi.tv
|
.. _Kodi: https://kodi.tv
|
||||||
.. _Sonos: http://sonos.com
|
.. _Sonos: https://sonos.com
|
||||||
|
|
||||||
Miscellaneous
|
Miscellaneous
|
||||||
-------------
|
-------------
|
||||||
|
|
@ -187,6 +190,7 @@ Miscellaneous
|
||||||
* :doc:`hook`: Run a command when an event is emitted by beets.
|
* :doc:`hook`: Run a command when an event is emitted by beets.
|
||||||
* :doc:`ihate`: Automatically skip albums and tracks during the import process.
|
* :doc:`ihate`: Automatically skip albums and tracks during the import process.
|
||||||
* :doc:`info`: Print music files' tags to the console.
|
* :doc:`info`: Print music files' tags to the console.
|
||||||
|
* :doc:`loadext`: Load SQLite extensions.
|
||||||
* :doc:`mbcollection`: Maintain your MusicBrainz collection list.
|
* :doc:`mbcollection`: Maintain your MusicBrainz collection list.
|
||||||
* :doc:`mbsubmit`: Print an album's tracks in a MusicBrainz-friendly format.
|
* :doc:`mbsubmit`: Print an album's tracks in a MusicBrainz-friendly format.
|
||||||
* :doc:`missing`: List missing tracks.
|
* :doc:`missing`: List missing tracks.
|
||||||
|
|
@ -196,8 +200,8 @@ Miscellaneous
|
||||||
* :doc:`types`: Declare types for flexible attributes.
|
* :doc:`types`: Declare types for flexible attributes.
|
||||||
* :doc:`web`: An experimental Web-based GUI for beets.
|
* :doc:`web`: An experimental Web-based GUI for beets.
|
||||||
|
|
||||||
.. _MPD: http://www.musicpd.org/
|
.. _MPD: https://www.musicpd.org/
|
||||||
.. _MPD clients: http://mpd.wikia.com/wiki/Clients
|
.. _MPD clients: https://mpd.wikia.com/wiki/Clients
|
||||||
.. _mstream: https://github.com/IrosTheBeggar/mStream
|
.. _mstream: https://github.com/IrosTheBeggar/mStream
|
||||||
|
|
||||||
.. _other-plugins:
|
.. _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
|
that are maintained by the beets community. To use an external plugin, there
|
||||||
are two options for installation:
|
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
|
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`.)
|
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.
|
line in your config file.
|
||||||
|
|
||||||
Here are a few of the plugins written by the beets community:
|
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
|
* `beets-barcode`_ lets you scan or enter barcodes for physical media to
|
||||||
search for their metadata.
|
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-barcode: https://github.com/8h2a/beets-barcode
|
||||||
.. _beets-check: https://github.com/geigerzaehler/beets-check
|
.. _beets-check: https://github.com/geigerzaehler/beets-check
|
||||||
.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
|
.. _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
|
.. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets
|
||||||
.. _beets-usertag: https://github.com/igordertigor/beets-usertag
|
.. _beets-usertag: https://github.com/igordertigor/beets-usertag
|
||||||
.. _beets-popularity: https://github.com/abba23/beets-popularity
|
.. _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
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,4 @@ Additional command-line options include:
|
||||||
* ``--keys-only`` or ``-k``: Show the name of the tags without the values.
|
* ``--keys-only`` or ``-k``: Show the name of the tags without the values.
|
||||||
|
|
||||||
.. _id3v2: http://id3v2.sourceforge.net
|
.. _id3v2: http://id3v2.sourceforge.net
|
||||||
.. _mp3info: http://www.ibiblio.org/mp3info/
|
.. _mp3info: https://www.ibiblio.org/mp3info/
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ IPFS Plugin
|
||||||
The ``ipfs`` plugin makes it easy to share your library and music with friends.
|
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.
|
The plugin uses `ipfs`_ for storing the library and file content.
|
||||||
|
|
||||||
.. _ipfs: http://ipfs.io/
|
.. _ipfs: https://ipfs.io/
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,4 @@ configuration file. The available options are:
|
||||||
`initial_key` value.
|
`initial_key` value.
|
||||||
Default: ``no``.
|
Default: ``no``.
|
||||||
|
|
||||||
.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/
|
.. _KeyFinder: https://www.ibrahimshaath.co.uk/keyfinder/
|
||||||
|
|
|
||||||
|
|
@ -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
|
With that all in place, you'll see beets send the "update" command to your Kodi
|
||||||
host every time you change your beets library.
|
host every time you change your beets library.
|
||||||
|
|
||||||
.. _Kodi: http://kodi.tv/
|
.. _Kodi: https://kodi.tv/
|
||||||
.. _requests: http://docs.python-requests.org/en/latest/
|
.. _requests: https://docs.python-requests.org/en/latest/
|
||||||
|
|
||||||
Configuration
|
Configuration
|
||||||
-------------
|
-------------
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ importing and autotagging music, beets does not assign a genre. The
|
||||||
to your albums and items.
|
to your albums and items.
|
||||||
|
|
||||||
.. _does not contain genre information:
|
.. _does not contain genre information:
|
||||||
http://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F
|
https://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F
|
||||||
.. _Last.fm: http://last.fm/
|
.. _Last.fm: https://last.fm/
|
||||||
|
|
||||||
Installation
|
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
|
For the curious, the default genre list is generated by a `script that scrapes
|
||||||
Wikipedia`_.
|
Wikipedia`_.
|
||||||
|
|
||||||
.. _pip: http://www.pip-installer.org/
|
.. _pip: https://pip.pypa.io
|
||||||
.. _pylast: https://github.com/pylast/pylast
|
.. _pylast: https://github.com/pylast/pylast
|
||||||
.. _script that scrapes Wikipedia: https://gist.github.com/1241307
|
.. _script that scrapes Wikipedia: https://gist.github.com/1241307
|
||||||
.. _internal whitelist: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres.txt
|
.. _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.
|
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
|
.. _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
|
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
|
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 disable automatic genre fetching on import, set the ``auto`` config option
|
||||||
to false.
|
to false.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ library into beets' database. You can later create :doc:`smart playlists
|
||||||
</plugins/smartplaylist>` by querying ``play_count`` and do other fun stuff
|
</plugins/smartplaylist>` by querying ``play_count`` and do other fun stuff
|
||||||
with this field.
|
with this field.
|
||||||
|
|
||||||
.. _Last.fm: http://last.fm
|
.. _Last.fm: https://last.fm
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
@ -23,7 +23,7 @@ Next, add your Last.fm username to your beets configuration file::
|
||||||
lastfm:
|
lastfm:
|
||||||
user: beetsfanatic
|
user: beetsfanatic
|
||||||
|
|
||||||
.. _pip: http://www.pip-installer.org/
|
.. _pip: https://pip.pypa.io
|
||||||
.. _pylast: https://github.com/pylast/pylast
|
.. _pylast: https://github.com/pylast/pylast
|
||||||
|
|
||||||
Importing Play Counts
|
Importing Play Counts
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue