mirror of
https://github.com/beetbox/beets.git
synced 2025-12-15 21:14:19 +01:00
commit
5a3cea2b8e
167 changed files with 6054 additions and 6531 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:
|
||||
|
||||
|
|
@ -14,7 +25,7 @@ Led to this problem:
|
|||
(paste here)
|
||||
```
|
||||
|
||||
Here's a link to the music files that trigger the bug (if relevant):
|
||||
Here's a link to the music files that trigger the bug (if relevant):
|
||||
|
||||
|
||||
### Setup
|
||||
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
|
||||
/.pydevproject
|
||||
/.settings
|
||||
.vscode
|
||||
|
|
|
|||
11
.travis.yml
11
.travis.yml
|
|
@ -24,18 +24,21 @@ matrix:
|
|||
- python: 3.7
|
||||
env: {TOX_ENV: py37-test}
|
||||
dist: xenial
|
||||
# - python: 3.8-dev
|
||||
# env: {TOX_ENV: py38-test}
|
||||
# dist: xenial
|
||||
# - python: pypy
|
||||
# - env: {TOX_ENV: pypy-test}
|
||||
- python: 3.4
|
||||
env: {TOX_ENV: py34-flake8}
|
||||
- python: 3.6
|
||||
env: {TOX_ENV: py36-flake8}
|
||||
- python: 2.7.13
|
||||
env: {TOX_ENV: docs}
|
||||
# Non-Python dependencies.
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- sourceline: "deb http://archive.ubuntu.com/ubuntu/ trusty multiverse"
|
||||
- sourceline: "deb http://archive.ubuntu.com/ubuntu/ trusty-updates multiverse"
|
||||
- sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty multiverse"
|
||||
- sourceline: "deb https://archive.ubuntu.com/ubuntu/ trusty-updates multiverse"
|
||||
packages:
|
||||
- bash-completion
|
||||
- gir1.2-gst-plugins-base-1.0
|
||||
|
|
|
|||
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
|
||||
|
||||
.. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg
|
||||
.. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg
|
||||
:target: https://codecov.io/github/beetbox/beets
|
||||
|
||||
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
||||
:target: https://travis-ci.org/beetbox/beets
|
||||
|
||||
.. image:: https://repology.org/badge/tiny-repos/beets.svg
|
||||
:target: https://repology.org/project/beets/versions
|
||||
|
||||
|
||||
beets
|
||||
=====
|
||||
|
|
@ -51,37 +54,39 @@ imagine for your music collection. Via `plugins`_, beets becomes a panacea:
|
|||
If beets doesn't do what you want yet, `writing your own plugin`_ is
|
||||
shockingly simple if you know a little Python.
|
||||
|
||||
.. _plugins: http://beets.readthedocs.org/page/plugins/
|
||||
.. _MPD: http://www.musicpd.org/
|
||||
.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
|
||||
.. _plugins: https://beets.readthedocs.org/page/plugins/
|
||||
.. _MPD: https://www.musicpd.org/
|
||||
.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
|
||||
.. _writing your own plugin:
|
||||
http://beets.readthedocs.org/page/dev/plugins.html
|
||||
https://beets.readthedocs.org/page/dev/plugins.html
|
||||
.. _HTML5 Audio:
|
||||
http://www.w3.org/TR/html-markup/audio.html
|
||||
.. _albums that are missing tracks:
|
||||
http://beets.readthedocs.org/page/plugins/missing.html
|
||||
https://beets.readthedocs.org/page/plugins/missing.html
|
||||
.. _duplicate tracks and albums:
|
||||
http://beets.readthedocs.org/page/plugins/duplicates.html
|
||||
https://beets.readthedocs.org/page/plugins/duplicates.html
|
||||
.. _Transcode audio:
|
||||
http://beets.readthedocs.org/page/plugins/convert.html
|
||||
.. _Discogs: http://www.discogs.com/
|
||||
https://beets.readthedocs.org/page/plugins/convert.html
|
||||
.. _Discogs: https://www.discogs.com/
|
||||
.. _acoustic fingerprints:
|
||||
http://beets.readthedocs.org/page/plugins/chroma.html
|
||||
.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html
|
||||
.. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||
.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||
.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html
|
||||
.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html
|
||||
.. _MusicBrainz: http://musicbrainz.org/
|
||||
https://beets.readthedocs.org/page/plugins/chroma.html
|
||||
.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
|
||||
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
|
||||
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
|
||||
.. _MusicBrainz: https://musicbrainz.org/
|
||||
.. _Beatport: https://www.beatport.com
|
||||
|
||||
Install
|
||||
-------
|
||||
|
||||
You can install beets by typing ``pip install beets``. Then check out the
|
||||
`Getting Started`_ guide.
|
||||
You can install beets by typing ``pip install beets``.
|
||||
Beets has also been packaged in the `software repositories`_ of several distributions.
|
||||
Check out the `Getting Started`_ guide for more information.
|
||||
|
||||
.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
|
||||
.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
|
||||
.. _software repositories: https://repology.org/project/beets/versions
|
||||
|
||||
Contribute
|
||||
----------
|
||||
|
|
@ -90,7 +95,7 @@ Check out the `Hacking`_ page on the wiki for tips on how to help out.
|
|||
You might also be interested in the `For Developers`_ section in the docs.
|
||||
|
||||
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
|
||||
.. _For Developers: http://docs.beets.io/page/dev/
|
||||
.. _For Developers: https://beets.readthedocs.io/en/stable/dev/
|
||||
|
||||
Read More
|
||||
---------
|
||||
|
|
@ -98,8 +103,8 @@ Read More
|
|||
Learn more about beets at `its Web site`_. Follow `@b33ts`_ on Twitter for
|
||||
news and updates.
|
||||
|
||||
.. _its Web site: http://beets.io/
|
||||
.. _@b33ts: http://twitter.com/b33ts/
|
||||
.. _its Web site: https://beets.io/
|
||||
.. _@b33ts: https://twitter.com/b33ts/
|
||||
|
||||
Authors
|
||||
-------
|
||||
|
|
@ -108,4 +113,4 @@ Beets is by `Adrian Sampson`_ with a supporting cast of thousands. For help,
|
|||
please visit our `forum`_.
|
||||
|
||||
.. _forum: https://discourse.beets.io
|
||||
.. _Adrian Sampson: http://www.cs.cornell.edu/~asampson/
|
||||
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.. image:: http://img.shields.io/pypi/v/beets.svg
|
||||
.. image:: https://img.shields.io/pypi/v/beets.svg
|
||||
:target: https://pypi.python.org/pypi/beets
|
||||
|
||||
.. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg
|
||||
.. image:: https://img.shields.io/codecov/c/github/beetbox/beets.svg
|
||||
:target: https://codecov.io/github/beetbox/beets
|
||||
|
||||
.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master
|
||||
|
|
@ -34,7 +34,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
|
|||
- 필요하는 메타 데이터를 계산하거나 패치 할 때: `album art`_,
|
||||
`lyrics`_, `genres`_, `tempos`_, `ReplayGain`_ levels, or `acoustic
|
||||
fingerprints`_.
|
||||
- `MusicBrainz`_, `Discogs`_,`Beatport`_로부터 메타데이터를 가져오거나,
|
||||
- `MusicBrainz`_, `Discogs`_,`Beatport`_로부터 메타데이터를 가져오거나,
|
||||
노래 제목이나 음향 특징으로 메타데이터를 추측한다
|
||||
- `Transcode audio`_ 당신이 좋아하는 어떤 포맷으로든 변경한다.
|
||||
- 당신의 라이브러리에서 `duplicate tracks and albums`_ 이나 `albums that are missing tracks`_ 를 검사한다.
|
||||
|
|
@ -45,31 +45,31 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
|
|||
- 명령어로부터 음악 파일의 메타데이터를 분석할 수 있다.
|
||||
- `MPD`_ 프로토콜을 사용하여 음악 플레이어로 음악을 들으면, 엄청나게 다양한 인터페이스로 작동한다.
|
||||
|
||||
만약 Beets에 당신이 원하는게 아직 없다면,
|
||||
만약 Beets에 당신이 원하는게 아직 없다면,
|
||||
당신이 python을 안다면 `writing your own plugin`_ _은 놀라울정도로 간단하다.
|
||||
|
||||
.. _plugins: http://beets.readthedocs.org/page/plugins/
|
||||
.. _MPD: http://www.musicpd.org/
|
||||
.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/
|
||||
.. _plugins: https://beets.readthedocs.org/page/plugins/
|
||||
.. _MPD: https://www.musicpd.org/
|
||||
.. _MusicBrainz music collection: https://musicbrainz.org/doc/Collections/
|
||||
.. _writing your own plugin:
|
||||
http://beets.readthedocs.org/page/dev/plugins.html
|
||||
https://beets.readthedocs.org/page/dev/plugins.html
|
||||
.. _HTML5 Audio:
|
||||
http://www.w3.org/TR/html-markup/audio.html
|
||||
.. _albums that are missing tracks:
|
||||
http://beets.readthedocs.org/page/plugins/missing.html
|
||||
https://beets.readthedocs.org/page/plugins/missing.html
|
||||
.. _duplicate tracks and albums:
|
||||
http://beets.readthedocs.org/page/plugins/duplicates.html
|
||||
https://beets.readthedocs.org/page/plugins/duplicates.html
|
||||
.. _Transcode audio:
|
||||
http://beets.readthedocs.org/page/plugins/convert.html
|
||||
.. _Discogs: http://www.discogs.com/
|
||||
https://beets.readthedocs.org/page/plugins/convert.html
|
||||
.. _Discogs: https://www.discogs.com/
|
||||
.. _acoustic fingerprints:
|
||||
http://beets.readthedocs.org/page/plugins/chroma.html
|
||||
.. _ReplayGain: http://beets.readthedocs.org/page/plugins/replaygain.html
|
||||
.. _tempos: http://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||
.. _genres: http://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||
.. _album art: http://beets.readthedocs.org/page/plugins/fetchart.html
|
||||
.. _lyrics: http://beets.readthedocs.org/page/plugins/lyrics.html
|
||||
.. _MusicBrainz: http://musicbrainz.org/
|
||||
https://beets.readthedocs.org/page/plugins/chroma.html
|
||||
.. _ReplayGain: https://beets.readthedocs.org/page/plugins/replaygain.html
|
||||
.. _tempos: https://beets.readthedocs.org/page/plugins/acousticbrainz.html
|
||||
.. _genres: https://beets.readthedocs.org/page/plugins/lastgenre.html
|
||||
.. _album art: https://beets.readthedocs.org/page/plugins/fetchart.html
|
||||
.. _lyrics: https://beets.readthedocs.org/page/plugins/lyrics.html
|
||||
.. _MusicBrainz: https://musicbrainz.org/
|
||||
.. _Beatport: https://www.beatport.com
|
||||
|
||||
설치
|
||||
|
|
@ -78,7 +78,7 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
|
|||
당신은 ``pip install beets`` 을 사용해서 Beets를 설치할 수 있다.
|
||||
그리고 `Getting Started`_ 가이드를 확인할 수 있다.
|
||||
|
||||
.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html
|
||||
.. _Getting Started: https://beets.readthedocs.org/page/guides/main.html
|
||||
|
||||
컨트리뷰션
|
||||
----------
|
||||
|
|
@ -87,16 +87,16 @@ Beets는 라이브러리로 디자인 되었기 때문에, 당신이 음악들
|
|||
당신은 docs 안에 `For Developers`_ 에도 관심이 있을수 있다.
|
||||
|
||||
.. _Hacking: https://github.com/beetbox/beets/wiki/Hacking
|
||||
.. _For Developers: http://docs.beets.io/page/dev/
|
||||
.. _For Developers: https://beets.readthedocs.io/en/stable/dev/
|
||||
|
||||
Read More
|
||||
---------
|
||||
|
||||
`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다.
|
||||
`its Web site`_ 에서 Beets에 대해 조금 더 알아볼 수 있다.
|
||||
트위터에서 `@b33ts`_ 를 팔로우하면 새 소식을 볼 수 있다.
|
||||
|
||||
.. _its Web site: http://beets.io/
|
||||
.. _@b33ts: http://twitter.com/b33ts/
|
||||
.. _its Web site: https://beets.io/
|
||||
.. _@b33ts: https://twitter.com/b33ts/
|
||||
|
||||
저자들
|
||||
-------
|
||||
|
|
@ -105,4 +105,4 @@ Read More
|
|||
돕고 싶다면 `forum`_.를 방문하면 된다.
|
||||
|
||||
.. _forum: https://discourse.beets.io
|
||||
.. _Adrian Sampson: http://www.cs.cornell.edu/~asampson/
|
||||
.. _Adrian Sampson: https://www.cs.cornell.edu/~asampson/
|
||||
|
|
|
|||
10
appveyor.yml
10
appveyor.yml
|
|
@ -16,14 +16,16 @@ environment:
|
|||
TOX_ENV: py35-test
|
||||
- PYTHON: C:\Python36
|
||||
TOX_ENV: py36-test
|
||||
- PYTHON: C:\Python37
|
||||
TOX_ENV: py37-test
|
||||
|
||||
# Install Tox for running tests.
|
||||
install:
|
||||
- cinst imagemagick -y
|
||||
- appveyor-retry cinst imagemagick -y
|
||||
# TODO: remove --allow-empty-checksums when unrar offers a proper checksum
|
||||
- cinst unrar -y --allow-empty-checksums
|
||||
- "%PYTHON%/Scripts/pip.exe install tox"
|
||||
- "%PYTHON%/Scripts/tox.exe -e %TOX_ENV% --notest"
|
||||
- appveyor-retry cinst unrar -y --allow-empty-checksums
|
||||
- 'appveyor-retry %PYTHON%/Scripts/pip.exe install "tox<=3.8.1"'
|
||||
- "appveyor-retry %PYTHON%/Scripts/tox.exe -e %TOX_ENV% --notest"
|
||||
|
||||
test_script:
|
||||
- "%PYTHON%/Scripts/tox.exe -e %TOX_ENV%"
|
||||
|
|
|
|||
|
|
@ -17,14 +17,14 @@ from __future__ import division, absolute_import, print_function
|
|||
|
||||
import os
|
||||
|
||||
from beets.util import confit
|
||||
import confuse
|
||||
|
||||
__version__ = u'1.4.8'
|
||||
__version__ = u'1.5.0'
|
||||
__author__ = u'Adrian Sampson <adrian@radbox.org>'
|
||||
|
||||
|
||||
class IncludeLazyConfig(confit.LazyConfig):
|
||||
"""A version of Confit's LazyConfig that also merges in data from
|
||||
class IncludeLazyConfig(confuse.LazyConfig):
|
||||
"""A version of Confuse's LazyConfig that also merges in data from
|
||||
YAML files specified in an `include` setting.
|
||||
"""
|
||||
def read(self, user=True, defaults=True):
|
||||
|
|
@ -35,7 +35,7 @@ class IncludeLazyConfig(confit.LazyConfig):
|
|||
filename = view.as_filename()
|
||||
if os.path.isfile(filename):
|
||||
self.set_file(filename)
|
||||
except confit.NotFoundError:
|
||||
except confuse.NotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
11
beets/art.py
11
beets/art.py
|
|
@ -26,7 +26,7 @@ import os
|
|||
|
||||
from beets.util import displayable_path, syspath, bytestring_path
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from beets import mediafile
|
||||
import mediafile
|
||||
|
||||
|
||||
def mediafile_image(image_path, maxwidth=None):
|
||||
|
|
@ -51,7 +51,8 @@ def get_art(log, item):
|
|||
|
||||
|
||||
def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
|
||||
compare_threshold=0, ifempty=False, as_album=False):
|
||||
compare_threshold=0, ifempty=False, as_album=False,
|
||||
id3v23=None):
|
||||
"""Embed an image into the item's media file.
|
||||
"""
|
||||
# Conditions and filters.
|
||||
|
|
@ -60,8 +61,8 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
|
|||
log.info(u'Image not similar; skipping.')
|
||||
return
|
||||
if ifempty and get_art(log, item):
|
||||
log.info(u'media file already contained art')
|
||||
return
|
||||
log.info(u'media file already contained art')
|
||||
return
|
||||
if maxwidth and not as_album:
|
||||
imagepath = resize_image(log, imagepath, maxwidth)
|
||||
|
||||
|
|
@ -80,7 +81,7 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
|
|||
image.mime_type)
|
||||
return
|
||||
|
||||
item.try_write(path=itempath, tags={'images': [image]})
|
||||
item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
|
||||
|
||||
|
||||
def embed_album(log, album, maxwidth=None, quiet=False,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ def apply_item_metadata(item, track_info):
|
|||
item.composer_sort = track_info.composer_sort
|
||||
if track_info.arranger is not None:
|
||||
item.arranger = track_info.arranger
|
||||
if track_info.work is not None:
|
||||
item.work = track_info.work
|
||||
if track_info.mb_workid is not None:
|
||||
item.mb_workid = track_info.mb_workid
|
||||
if track_info.work_disambig is not None:
|
||||
item.work_disambig = track_info.work_disambig
|
||||
|
||||
# At the moment, the other metadata is left intact (including album
|
||||
# and track number). Perhaps these should be emptied?
|
||||
|
|
@ -142,34 +148,50 @@ def apply_metadata(album_info, mapping):
|
|||
# Compilation flag.
|
||||
item.comp = album_info.va
|
||||
|
||||
# Miscellaneous metadata.
|
||||
for field in ('albumtype',
|
||||
'label',
|
||||
'asin',
|
||||
'catalognum',
|
||||
'script',
|
||||
'language',
|
||||
'country',
|
||||
'albumstatus',
|
||||
'albumdisambig',
|
||||
'releasegroupdisambig',
|
||||
'data_source',):
|
||||
value = getattr(album_info, field)
|
||||
if value is not None:
|
||||
item[field] = value
|
||||
if track_info.disctitle is not None:
|
||||
item.disctitle = track_info.disctitle
|
||||
|
||||
if track_info.media is not None:
|
||||
item.media = track_info.media
|
||||
|
||||
if track_info.lyricist is not None:
|
||||
item.lyricist = track_info.lyricist
|
||||
if track_info.composer is not None:
|
||||
item.composer = track_info.composer
|
||||
if track_info.composer_sort is not None:
|
||||
item.composer_sort = track_info.composer_sort
|
||||
if track_info.arranger is not None:
|
||||
item.arranger = track_info.arranger
|
||||
|
||||
# Track alt.
|
||||
item.track_alt = track_info.track_alt
|
||||
|
||||
# Miscellaneous/nullable metadata.
|
||||
misc_fields = {
|
||||
'album': (
|
||||
'albumtype',
|
||||
'label',
|
||||
'asin',
|
||||
'catalognum',
|
||||
'script',
|
||||
'language',
|
||||
'country',
|
||||
'style',
|
||||
'albumstatus',
|
||||
'albumdisambig',
|
||||
'releasegroupdisambig',
|
||||
'data_source',
|
||||
),
|
||||
'track': (
|
||||
'disctitle',
|
||||
'lyricist',
|
||||
'media',
|
||||
'composer',
|
||||
'composer_sort',
|
||||
'arranger',
|
||||
'work',
|
||||
'mb_workid',
|
||||
'work_disambig',
|
||||
)
|
||||
}
|
||||
|
||||
# Don't overwrite fields with empty values unless the
|
||||
# field is explicitly allowed to be overwritten
|
||||
for field in misc_fields['album']:
|
||||
clobber = field in config['overwrite_null']['album'].as_str_seq()
|
||||
value = getattr(album_info, field)
|
||||
if value is None and not clobber:
|
||||
continue
|
||||
item[field] = value
|
||||
|
||||
for field in misc_fields['track']:
|
||||
clobber = field in config['overwrite_null']['track'].as_str_seq()
|
||||
value = getattr(track_info, field)
|
||||
if value is None and not clobber:
|
||||
continue
|
||||
item[field] = value
|
||||
|
|
|
|||
|
|
@ -72,15 +72,15 @@ class AlbumInfo(object):
|
|||
- ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
|
||||
- ``data_url``: The data source release URL.
|
||||
|
||||
The fields up through ``tracks`` are required. The others are
|
||||
optional and may be None.
|
||||
``mediums`` along with the fields up through ``tracks`` are required.
|
||||
The others are optional and may be None.
|
||||
"""
|
||||
def __init__(self, album, album_id, artist, artist_id, tracks, asin=None,
|
||||
albumtype=None, va=False, year=None, month=None, day=None,
|
||||
label=None, mediums=None, artist_sort=None,
|
||||
releasegroup_id=None, catalognum=None, script=None,
|
||||
language=None, country=None, albumstatus=None, media=None,
|
||||
albumdisambig=None, releasegroupdisambig=None,
|
||||
language=None, country=None, style=None, albumstatus=None,
|
||||
media=None, albumdisambig=None, releasegroupdisambig=None,
|
||||
artist_credit=None, original_year=None, original_month=None,
|
||||
original_day=None, data_source=None, data_url=None):
|
||||
self.album = album
|
||||
|
|
@ -102,6 +102,7 @@ class AlbumInfo(object):
|
|||
self.script = script
|
||||
self.language = language
|
||||
self.country = country
|
||||
self.style = style
|
||||
self.albumstatus = albumstatus
|
||||
self.media = media
|
||||
self.albumdisambig = albumdisambig
|
||||
|
|
@ -121,7 +122,7 @@ class AlbumInfo(object):
|
|||
constituent `TrackInfo` objects, are decoded to Unicode.
|
||||
"""
|
||||
for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort',
|
||||
'catalognum', 'script', 'language', 'country',
|
||||
'catalognum', 'script', 'language', 'country', 'style',
|
||||
'albumstatus', 'albumdisambig', 'releasegroupdisambig',
|
||||
'artist_credit', 'media']:
|
||||
value = getattr(self, fld)
|
||||
|
|
@ -159,6 +160,9 @@ class TrackInfo(object):
|
|||
- ``composer_sort``: individual track composer sort name
|
||||
- ``arranger`: individual track arranger name
|
||||
- ``track_alt``: alternative track number (tape, vinyl, etc.)
|
||||
- ``work`: individual track work title
|
||||
- ``mb_workid`: individual track work id
|
||||
- ``work_disambig`: individual track work diambiguation
|
||||
|
||||
Only ``title`` and ``track_id`` are required. The rest of the fields
|
||||
may be None. The indices ``index``, ``medium``, and ``medium_index``
|
||||
|
|
@ -169,7 +173,8 @@ class TrackInfo(object):
|
|||
medium_index=None, medium_total=None, artist_sort=None,
|
||||
disctitle=None, artist_credit=None, data_source=None,
|
||||
data_url=None, media=None, lyricist=None, composer=None,
|
||||
composer_sort=None, arranger=None, track_alt=None):
|
||||
composer_sort=None, arranger=None, track_alt=None,
|
||||
work=None, mb_workid=None, work_disambig=None):
|
||||
self.title = title
|
||||
self.track_id = track_id
|
||||
self.release_track_id = release_track_id
|
||||
|
|
@ -191,6 +196,9 @@ class TrackInfo(object):
|
|||
self.composer_sort = composer_sort
|
||||
self.arranger = arranger
|
||||
self.track_alt = track_alt
|
||||
self.work = work
|
||||
self.mb_workid = mb_workid
|
||||
self.work_disambig = work_disambig
|
||||
|
||||
# As above, work around a bug in python-musicbrainz-ngs.
|
||||
def decode(self, codec='utf-8'):
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ else:
|
|||
SKIPPED_TRACKS = ['[data track]']
|
||||
|
||||
musicbrainzngs.set_useragent('beets', beets.__version__,
|
||||
'http://beets.io/')
|
||||
'https://beets.io/')
|
||||
|
||||
|
||||
class MusicBrainzAPIError(util.HumanReadableException):
|
||||
|
|
@ -213,6 +213,11 @@ def track_info(recording, index=None, medium=None, medium_index=None,
|
|||
for work_relation in recording.get('work-relation-list', ()):
|
||||
if work_relation['type'] != 'performance':
|
||||
continue
|
||||
info.work = work_relation['work']['title']
|
||||
info.mb_workid = work_relation['work']['id']
|
||||
if 'disambiguation' in work_relation['work']:
|
||||
info.work_disambig = work_relation['work']['disambiguation']
|
||||
|
||||
for artist_relation in work_relation['work'].get(
|
||||
'artist-relation-list', ()):
|
||||
if 'type' in artist_relation:
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ aunique:
|
|||
disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig
|
||||
bracket: '[]'
|
||||
|
||||
overwrite_null:
|
||||
album: []
|
||||
track: []
|
||||
|
||||
plugins: []
|
||||
pluginpath: []
|
||||
|
|
|
|||
|
|
@ -23,14 +23,17 @@ from collections import defaultdict
|
|||
import threading
|
||||
import sqlite3
|
||||
import contextlib
|
||||
import collections
|
||||
|
||||
import beets
|
||||
from beets.util.functemplate import Template
|
||||
from beets.util import functemplate
|
||||
from beets.util import py3_path
|
||||
from beets.dbcore import types
|
||||
from .query import MatchQuery, NullSort, TrueQuery
|
||||
import six
|
||||
if six.PY2:
|
||||
from collections import Mapping
|
||||
else:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
class DBAccessError(Exception):
|
||||
|
|
@ -42,7 +45,7 @@ class DBAccessError(Exception):
|
|||
"""
|
||||
|
||||
|
||||
class FormattedMapping(collections.Mapping):
|
||||
class FormattedMapping(Mapping):
|
||||
"""A `dict`-like formatted view of a model.
|
||||
|
||||
The accessor `mapping[key]` returns the formatted version of
|
||||
|
|
@ -88,6 +91,100 @@ class FormattedMapping(collections.Mapping):
|
|||
return value
|
||||
|
||||
|
||||
class LazyConvertDict(object):
|
||||
"""Lazily convert types for attributes fetched from the database
|
||||
"""
|
||||
|
||||
def __init__(self, model_cls):
|
||||
"""Initialize the object empty
|
||||
"""
|
||||
self.data = {}
|
||||
self.model_cls = model_cls
|
||||
self._converted = {}
|
||||
|
||||
def init(self, data):
|
||||
"""Set the base data that should be lazily converted
|
||||
"""
|
||||
self.data = data
|
||||
|
||||
def _convert(self, key, value):
|
||||
"""Convert the attribute type according the the SQL type
|
||||
"""
|
||||
return self.model_cls._type(key).from_sql(value)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""Set an attribute value, assume it's already converted
|
||||
"""
|
||||
self._converted[key] = value
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Get an attribute value, converting the type on demand
|
||||
if needed
|
||||
"""
|
||||
if key in self._converted:
|
||||
return self._converted[key]
|
||||
elif key in self.data:
|
||||
value = self._convert(key, self.data[key])
|
||||
self._converted[key] = value
|
||||
return value
|
||||
|
||||
def __delitem__(self, key):
|
||||
"""Delete both converted and base data
|
||||
"""
|
||||
if key in self._converted:
|
||||
del self._converted[key]
|
||||
if key in self.data:
|
||||
del self.data[key]
|
||||
|
||||
def keys(self):
|
||||
"""Get a list of available field names for this object.
|
||||
"""
|
||||
return list(self._converted.keys()) + list(self.data.keys())
|
||||
|
||||
def copy(self):
|
||||
"""Create a copy of the object.
|
||||
"""
|
||||
new = self.__class__(self.model_cls)
|
||||
new.data = self.data.copy()
|
||||
new._converted = self._converted.copy()
|
||||
return new
|
||||
|
||||
# Act like a dictionary.
|
||||
|
||||
def update(self, values):
|
||||
"""Assign all values in the given dict.
|
||||
"""
|
||||
for key, value in values.items():
|
||||
self[key] = value
|
||||
|
||||
def items(self):
|
||||
"""Iterate over (key, value) pairs that this object contains.
|
||||
Computed fields are not included.
|
||||
"""
|
||||
for key in self:
|
||||
yield key, self[key]
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""Get the value for a given key or `default` if it does not
|
||||
exist.
|
||||
"""
|
||||
if key in self:
|
||||
return self[key]
|
||||
else:
|
||||
return default
|
||||
|
||||
def __contains__(self, key):
|
||||
"""Determine whether `key` is an attribute on this object.
|
||||
"""
|
||||
return key in self.keys()
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the available field names (excluding computed
|
||||
fields).
|
||||
"""
|
||||
return iter(self.keys())
|
||||
|
||||
|
||||
# Abstract base for model classes.
|
||||
|
||||
class Model(object):
|
||||
|
|
@ -143,6 +240,11 @@ class Model(object):
|
|||
are subclasses of `Sort`.
|
||||
"""
|
||||
|
||||
_queries = {}
|
||||
"""Named queries that use a field-like `name:value` syntax but which
|
||||
do not relate to any specific field.
|
||||
"""
|
||||
|
||||
_always_dirty = False
|
||||
"""By default, fields only become "dirty" when their value actually
|
||||
changes. Enabling this flag marks fields as dirty even when the new
|
||||
|
|
@ -172,8 +274,8 @@ class Model(object):
|
|||
"""
|
||||
self._db = db
|
||||
self._dirty = set()
|
||||
self._values_fixed = {}
|
||||
self._values_flex = {}
|
||||
self._values_fixed = LazyConvertDict(self)
|
||||
self._values_flex = LazyConvertDict(self)
|
||||
|
||||
# Initial contents.
|
||||
self.update(values)
|
||||
|
|
@ -187,10 +289,10 @@ class Model(object):
|
|||
ordinary construction are bypassed.
|
||||
"""
|
||||
obj = cls(db)
|
||||
for key, value in fixed_values.items():
|
||||
obj._values_fixed[key] = cls._type(key).from_sql(value)
|
||||
for key, value in flex_values.items():
|
||||
obj._values_flex[key] = cls._type(key).from_sql(value)
|
||||
|
||||
obj._values_fixed.init(fixed_values)
|
||||
obj._values_flex.init(flex_values)
|
||||
|
||||
return obj
|
||||
|
||||
def __repr__(self):
|
||||
|
|
@ -251,7 +353,10 @@ class Model(object):
|
|||
if key in getters: # Computed.
|
||||
return getters[key](self)
|
||||
elif key in self._fields: # Fixed.
|
||||
return self._values_fixed.get(key, self._type(key).null)
|
||||
if key in self._values_fixed:
|
||||
return self._values_fixed[key]
|
||||
else:
|
||||
return self._type(key).null
|
||||
elif key in self._values_flex: # Flexible.
|
||||
return self._values_flex[key]
|
||||
else:
|
||||
|
|
@ -431,8 +536,8 @@ class Model(object):
|
|||
self._check_db()
|
||||
stored_obj = self._db._get(type(self), self.id)
|
||||
assert stored_obj is not None, u"object {0} not in DB".format(self.id)
|
||||
self._values_fixed = {}
|
||||
self._values_flex = {}
|
||||
self._values_fixed = LazyConvertDict(self)
|
||||
self._values_flex = LazyConvertDict(self)
|
||||
self.update(dict(stored_obj))
|
||||
self.clear_dirty()
|
||||
|
||||
|
|
@ -492,7 +597,7 @@ class Model(object):
|
|||
"""
|
||||
# Perform substitution.
|
||||
if isinstance(template, six.string_types):
|
||||
template = Template(template)
|
||||
template = functemplate.template(template)
|
||||
return template.substitute(self.formatted(for_path),
|
||||
self._template_funcs())
|
||||
|
||||
|
|
@ -519,7 +624,8 @@ class Results(object):
|
|||
"""An item query result set. Iterating over the collection lazily
|
||||
constructs LibModel objects that reflect database rows.
|
||||
"""
|
||||
def __init__(self, model_class, rows, db, query=None, sort=None):
|
||||
def __init__(self, model_class, rows, db, flex_rows,
|
||||
query=None, sort=None):
|
||||
"""Create a result set that will construct objects of type
|
||||
`model_class`.
|
||||
|
||||
|
|
@ -539,6 +645,7 @@ class Results(object):
|
|||
self.db = db
|
||||
self.query = query
|
||||
self.sort = sort
|
||||
self.flex_rows = flex_rows
|
||||
|
||||
# We keep a queue of rows we haven't yet consumed for
|
||||
# materialization. We preserve the original total number of
|
||||
|
|
@ -560,6 +667,10 @@ class Results(object):
|
|||
a `Results` object a second time should be much faster than the
|
||||
first.
|
||||
"""
|
||||
|
||||
# Index flexible attributes by the item ID, so we have easier access
|
||||
flex_attrs = self._get_indexed_flex_attrs()
|
||||
|
||||
index = 0 # Position in the materialized objects.
|
||||
while index < len(self._objects) or self._rows:
|
||||
# Are there previously-materialized objects to produce?
|
||||
|
|
@ -572,7 +683,7 @@ class Results(object):
|
|||
else:
|
||||
while self._rows:
|
||||
row = self._rows.pop(0)
|
||||
obj = self._make_model(row)
|
||||
obj = self._make_model(row, flex_attrs.get(row['id'], {}))
|
||||
# If there is a slow-query predicate, ensurer that the
|
||||
# object passes it.
|
||||
if not self.query or self.query.match(obj):
|
||||
|
|
@ -594,20 +705,24 @@ class Results(object):
|
|||
# Objects are pre-sorted (i.e., by the database).
|
||||
return self._get_objects()
|
||||
|
||||
def _make_model(self, row):
|
||||
# Get the flexible attributes for the object.
|
||||
with self.db.transaction() as tx:
|
||||
flex_rows = tx.query(
|
||||
'SELECT * FROM {0} WHERE entity_id=?'.format(
|
||||
self.model_class._flex_table
|
||||
),
|
||||
(row['id'],)
|
||||
)
|
||||
def _get_indexed_flex_attrs(self):
|
||||
""" Index flexible attributes by the entity id they belong to
|
||||
"""
|
||||
flex_values = dict()
|
||||
for row in self.flex_rows:
|
||||
if row['entity_id'] not in flex_values:
|
||||
flex_values[row['entity_id']] = dict()
|
||||
|
||||
flex_values[row['entity_id']][row['key']] = row['value']
|
||||
|
||||
return flex_values
|
||||
|
||||
def _make_model(self, row, flex_values={}):
|
||||
""" Create a Model object for the given row
|
||||
"""
|
||||
cols = dict(row)
|
||||
values = dict((k, v) for (k, v) in cols.items()
|
||||
if not k[:4] == 'flex')
|
||||
flex_values = dict((row['key'], row['value']) for row in flex_rows)
|
||||
|
||||
# Construct the Python object
|
||||
obj = self.model_class._awaken(self.db, values, flex_values)
|
||||
|
|
@ -735,16 +850,21 @@ class Database(object):
|
|||
"""A container for Model objects that wraps an SQLite database as
|
||||
the backend.
|
||||
"""
|
||||
|
||||
_models = ()
|
||||
"""The Model subclasses representing tables in this database.
|
||||
"""
|
||||
|
||||
supports_extensions = hasattr(sqlite3.Connection, 'enable_load_extension')
|
||||
"""Whether or not the current version of SQLite supports extensions"""
|
||||
|
||||
def __init__(self, path, timeout=5.0):
|
||||
self.path = path
|
||||
self.timeout = timeout
|
||||
|
||||
self._connections = {}
|
||||
self._tx_stacks = defaultdict(list)
|
||||
self._extensions = []
|
||||
|
||||
# A lock to protect the _connections and _tx_stacks maps, which
|
||||
# both map thread IDs to private resources.
|
||||
|
|
@ -794,6 +914,13 @@ class Database(object):
|
|||
py3_path(self.path), timeout=self.timeout
|
||||
)
|
||||
|
||||
if self.supports_extensions:
|
||||
conn.enable_load_extension(True)
|
||||
|
||||
# Load any extension that are already loaded for other connections.
|
||||
for path in self._extensions:
|
||||
conn.load_extension(path)
|
||||
|
||||
# Access SELECT results like dictionaries.
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
|
@ -822,6 +949,18 @@ class Database(object):
|
|||
"""
|
||||
return Transaction(self)
|
||||
|
||||
def load_extension(self, path):
|
||||
"""Load an SQLite extension into all open connections."""
|
||||
if not self.supports_extensions:
|
||||
raise ValueError(
|
||||
'this sqlite3 installation does not support extensions')
|
||||
|
||||
self._extensions.append(path)
|
||||
|
||||
# Load the extension into every open connection.
|
||||
for conn in self._connections.values():
|
||||
conn.load_extension(path)
|
||||
|
||||
# Schema setup and migration.
|
||||
|
||||
def _make_table(self, table, fields):
|
||||
|
|
@ -894,11 +1033,25 @@ class Database(object):
|
|||
"ORDER BY {0}".format(order_by) if order_by else '',
|
||||
)
|
||||
|
||||
# Fetch flexible attributes for items matching the main query.
|
||||
# Doing the per-item filtering in python is faster than issuing
|
||||
# one query per item to sqlite.
|
||||
flex_sql = ("""
|
||||
SELECT * FROM {0} WHERE entity_id IN
|
||||
(SELECT id FROM {1} WHERE {2});
|
||||
""".format(
|
||||
model_cls._flex_table,
|
||||
model_cls._table,
|
||||
where or '1',
|
||||
)
|
||||
)
|
||||
|
||||
with self.transaction() as tx:
|
||||
rows = tx.query(sql, subvals)
|
||||
flex_rows = tx.query(flex_sql, subvals)
|
||||
|
||||
return Results(
|
||||
model_cls, rows, self,
|
||||
model_cls, rows, self, flex_rows,
|
||||
None if where else query, # Slow query component.
|
||||
sort if sort.is_slow() else None, # Slow sort component.
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ from __future__ import division, absolute_import, print_function
|
|||
import re
|
||||
import itertools
|
||||
from . import query
|
||||
import beets
|
||||
|
||||
PARSE_QUERY_PART_REGEX = re.compile(
|
||||
# Non-capturing optional segment for the keyword.
|
||||
|
|
@ -119,12 +118,13 @@ def construct_query_part(model_cls, prefixes, query_part):
|
|||
if not query_part:
|
||||
return query.TrueQuery()
|
||||
|
||||
# Use `model_cls` to build up a map from field names to `Query`
|
||||
# classes.
|
||||
# Use `model_cls` to build up a map from field (or query) names to
|
||||
# `Query` classes.
|
||||
query_classes = {}
|
||||
for k, t in itertools.chain(model_cls._fields.items(),
|
||||
model_cls._types.items()):
|
||||
query_classes[k] = t.query
|
||||
query_classes.update(model_cls._queries) # Non-field queries.
|
||||
|
||||
# Parse the string.
|
||||
key, pattern, query_class, negate = \
|
||||
|
|
@ -137,26 +137,27 @@ def construct_query_part(model_cls, prefixes, query_part):
|
|||
# The query type matches a specific field, but none was
|
||||
# specified. So we use a version of the query that matches
|
||||
# any field.
|
||||
q = query.AnyFieldQuery(pattern, model_cls._search_fields,
|
||||
query_class)
|
||||
if negate:
|
||||
return query.NotQuery(q)
|
||||
else:
|
||||
return q
|
||||
out_query = query.AnyFieldQuery(pattern, model_cls._search_fields,
|
||||
query_class)
|
||||
else:
|
||||
# Non-field query type.
|
||||
if negate:
|
||||
return query.NotQuery(query_class(pattern))
|
||||
else:
|
||||
return query_class(pattern)
|
||||
out_query = query_class(pattern)
|
||||
|
||||
# Otherwise, this must be a `FieldQuery`. Use the field name to
|
||||
# construct the query object.
|
||||
key = key.lower()
|
||||
q = query_class(key.lower(), pattern, key in model_cls._fields)
|
||||
# Field queries get constructed according to the name of the field
|
||||
# they are querying.
|
||||
elif issubclass(query_class, query.FieldQuery):
|
||||
key = key.lower()
|
||||
out_query = query_class(key.lower(), pattern, key in model_cls._fields)
|
||||
|
||||
# Non-field (named) query.
|
||||
else:
|
||||
out_query = query_class(pattern)
|
||||
|
||||
# Apply negation.
|
||||
if negate:
|
||||
return query.NotQuery(q)
|
||||
return q
|
||||
return query.NotQuery(out_query)
|
||||
else:
|
||||
return out_query
|
||||
|
||||
|
||||
def query_from_strings(query_cls, model_cls, prefixes, query_parts):
|
||||
|
|
@ -172,11 +173,13 @@ def query_from_strings(query_cls, model_cls, prefixes, query_parts):
|
|||
return query_cls(subqueries)
|
||||
|
||||
|
||||
def construct_sort_part(model_cls, part):
|
||||
def construct_sort_part(model_cls, part, case_insensitive=True):
|
||||
"""Create a `Sort` from a single string criterion.
|
||||
|
||||
`model_cls` is the `Model` being queried. `part` is a single string
|
||||
ending in ``+`` or ``-`` indicating the sort.
|
||||
ending in ``+`` or ``-`` indicating the sort. `case_insensitive`
|
||||
indicates whether or not the sort should be performed in a case
|
||||
sensitive manner.
|
||||
"""
|
||||
assert part, "part must be a field name and + or -"
|
||||
field = part[:-1]
|
||||
|
|
@ -185,7 +188,6 @@ def construct_sort_part(model_cls, part):
|
|||
assert direction in ('+', '-'), "part must end with + or -"
|
||||
is_ascending = direction == '+'
|
||||
|
||||
case_insensitive = beets.config['sort_case_insensitive'].get(bool)
|
||||
if field in model_cls._sorts:
|
||||
sort = model_cls._sorts[field](model_cls, is_ascending,
|
||||
case_insensitive)
|
||||
|
|
@ -197,21 +199,23 @@ def construct_sort_part(model_cls, part):
|
|||
return sort
|
||||
|
||||
|
||||
def sort_from_strings(model_cls, sort_parts):
|
||||
def sort_from_strings(model_cls, sort_parts, case_insensitive=True):
|
||||
"""Create a `Sort` from a list of sort criteria (strings).
|
||||
"""
|
||||
if not sort_parts:
|
||||
sort = query.NullSort()
|
||||
elif len(sort_parts) == 1:
|
||||
sort = construct_sort_part(model_cls, sort_parts[0])
|
||||
sort = construct_sort_part(model_cls, sort_parts[0], case_insensitive)
|
||||
else:
|
||||
sort = query.MultipleSort()
|
||||
for part in sort_parts:
|
||||
sort.add_sort(construct_sort_part(model_cls, part))
|
||||
sort.add_sort(construct_sort_part(model_cls, part,
|
||||
case_insensitive))
|
||||
return sort
|
||||
|
||||
|
||||
def parse_sorted_query(model_cls, parts, prefixes={}):
|
||||
def parse_sorted_query(model_cls, parts, prefixes={},
|
||||
case_insensitive=True):
|
||||
"""Given a list of strings, create the `Query` and `Sort` that they
|
||||
represent.
|
||||
"""
|
||||
|
|
@ -246,5 +250,5 @@ def parse_sorted_query(model_cls, parts, prefixes={}):
|
|||
|
||||
# Avoid needlessly wrapping single statements in an OR
|
||||
q = query.OrQuery(query_parts) if len(query_parts) > 1 else query_parts[0]
|
||||
s = sort_from_strings(model_cls, sort_parts)
|
||||
s = sort_from_strings(model_cls, sort_parts, case_insensitive)
|
||||
return q, s
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class Type(object):
|
|||
For fixed fields the type of `value` is determined by the column
|
||||
type affinity given in the `sql` property and the SQL to Python
|
||||
mapping of the database adapter. For more information see:
|
||||
http://www.sqlite.org/datatype3.html
|
||||
https://www.sqlite.org/datatype3.html
|
||||
https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types
|
||||
|
||||
Flexible fields have the type affinity `TEXT`. This means the
|
||||
|
|
@ -173,14 +173,18 @@ class Id(Integer):
|
|||
|
||||
|
||||
class Float(Type):
|
||||
"""A basic floating-point type.
|
||||
"""A basic floating-point type. The `digits` parameter specifies how
|
||||
many decimal places to use in the human-readable representation.
|
||||
"""
|
||||
sql = u'REAL'
|
||||
query = query.NumericQuery
|
||||
model_type = float
|
||||
|
||||
def __init__(self, digits=1):
|
||||
self.digits = digits
|
||||
|
||||
def format(self, value):
|
||||
return u'{0:.1f}'.format(value or 0.0)
|
||||
return u'{0:.{1}f}'.format(value or 0, self.digits)
|
||||
|
||||
|
||||
class NullFloat(Float):
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ from beets import config
|
|||
from beets.util import pipeline, sorted_walk, ancestry, MoveOperation
|
||||
from beets.util import syspath, normpath, displayable_path
|
||||
from enum import Enum
|
||||
from beets import mediafile
|
||||
import mediafile
|
||||
|
||||
action = Enum('action',
|
||||
['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG'])
|
||||
|
|
|
|||
|
|
@ -23,14 +23,15 @@ import unicodedata
|
|||
import time
|
||||
import re
|
||||
import six
|
||||
import string
|
||||
|
||||
from beets import logging
|
||||
from beets.mediafile import MediaFile, UnreadableFileError
|
||||
from mediafile import MediaFile, UnreadableFileError
|
||||
from beets import plugins
|
||||
from beets import util
|
||||
from beets.util import bytestring_path, syspath, normpath, samefile, \
|
||||
MoveOperation
|
||||
from beets.util.functemplate import Template
|
||||
MoveOperation, lazy_property
|
||||
from beets.util.functemplate import template, Template
|
||||
from beets import dbcore
|
||||
from beets.dbcore import types
|
||||
import beets
|
||||
|
|
@ -375,13 +376,25 @@ class FormattedItemMapping(dbcore.db.FormattedMapping):
|
|||
|
||||
def __init__(self, item, for_path=False):
|
||||
super(FormattedItemMapping, self).__init__(item, for_path)
|
||||
self.album = item.get_album()
|
||||
self.album_keys = []
|
||||
self.item = item
|
||||
|
||||
@lazy_property
|
||||
def all_keys(self):
|
||||
return set(self.model_keys).union(self.album_keys)
|
||||
|
||||
@lazy_property
|
||||
def album_keys(self):
|
||||
album_keys = []
|
||||
if self.album:
|
||||
for key in self.album.keys(True):
|
||||
if key in Album.item_keys or key not in item._fields.keys():
|
||||
self.album_keys.append(key)
|
||||
self.all_keys = set(self.model_keys).union(self.album_keys)
|
||||
if key in Album.item_keys \
|
||||
or key not in self.item._fields.keys():
|
||||
album_keys.append(key)
|
||||
return album_keys
|
||||
|
||||
@lazy_property
|
||||
def album(self):
|
||||
return self.item.get_album()
|
||||
|
||||
def _get(self, key):
|
||||
"""Get the value for a key, either from the album or the item.
|
||||
|
|
@ -435,9 +448,13 @@ class Item(LibModel):
|
|||
'albumartist_sort': types.STRING,
|
||||
'albumartist_credit': types.STRING,
|
||||
'genre': types.STRING,
|
||||
'style': types.STRING,
|
||||
'lyricist': types.STRING,
|
||||
'composer': types.STRING,
|
||||
'composer_sort': types.STRING,
|
||||
'work': types.STRING,
|
||||
'mb_workid': types.STRING,
|
||||
'work_disambig': types.STRING,
|
||||
'arranger': types.STRING,
|
||||
'grouping': types.STRING,
|
||||
'year': types.PaddedInt(4),
|
||||
|
|
@ -610,7 +627,7 @@ class Item(LibModel):
|
|||
|
||||
self.path = read_path
|
||||
|
||||
def write(self, path=None, tags=None):
|
||||
def write(self, path=None, tags=None, id3v23=None):
|
||||
"""Write the item's metadata to a media file.
|
||||
|
||||
All fields in `_media_fields` are written to disk according to
|
||||
|
|
@ -622,6 +639,9 @@ class Item(LibModel):
|
|||
`tags` is a dictionary of additional metadata the should be
|
||||
written to the file. (These tags need not be in `_media_fields`.)
|
||||
|
||||
`id3v23` will override the global `id3v23` config option if it is
|
||||
set to something other than `None`.
|
||||
|
||||
Can raise either a `ReadError` or a `WriteError`.
|
||||
"""
|
||||
if path is None:
|
||||
|
|
@ -629,6 +649,9 @@ class Item(LibModel):
|
|||
else:
|
||||
path = normpath(path)
|
||||
|
||||
if id3v23 is None:
|
||||
id3v23 = beets.config['id3v23'].get(bool)
|
||||
|
||||
# Get the data to write to the file.
|
||||
item_tags = dict(self)
|
||||
item_tags = {k: v for k, v in item_tags.items()
|
||||
|
|
@ -639,8 +662,7 @@ class Item(LibModel):
|
|||
|
||||
# Open the file.
|
||||
try:
|
||||
mediafile = MediaFile(syspath(path),
|
||||
id3v23=beets.config['id3v23'].get(bool))
|
||||
mediafile = MediaFile(syspath(path), id3v23=id3v23)
|
||||
except UnreadableFileError as exc:
|
||||
raise ReadError(path, exc)
|
||||
|
||||
|
|
@ -656,14 +678,14 @@ class Item(LibModel):
|
|||
self.mtime = self.current_mtime()
|
||||
plugins.send('after_write', item=self, path=path)
|
||||
|
||||
def try_write(self, path=None, tags=None):
|
||||
def try_write(self, *args, **kwargs):
|
||||
"""Calls `write()` but catches and logs `FileOperationError`
|
||||
exceptions.
|
||||
|
||||
Returns `False` an exception was caught and `True` otherwise.
|
||||
"""
|
||||
try:
|
||||
self.write(path, tags)
|
||||
self.write(*args, **kwargs)
|
||||
return True
|
||||
except FileOperationError as exc:
|
||||
log.error(u"{0}", exc)
|
||||
|
|
@ -849,7 +871,7 @@ class Item(LibModel):
|
|||
if isinstance(path_format, Template):
|
||||
subpath_tmpl = path_format
|
||||
else:
|
||||
subpath_tmpl = Template(path_format)
|
||||
subpath_tmpl = template(path_format)
|
||||
|
||||
# Evaluate the selected template.
|
||||
subpath = self.evaluate_template(subpath_tmpl, True)
|
||||
|
|
@ -909,6 +931,7 @@ class Album(LibModel):
|
|||
'albumartist_credit': types.STRING,
|
||||
'album': types.STRING,
|
||||
'genre': types.STRING,
|
||||
'style': types.STRING,
|
||||
'year': types.PaddedInt(4),
|
||||
'month': types.PaddedInt(2),
|
||||
'day': types.PaddedInt(2),
|
||||
|
|
@ -929,7 +952,7 @@ class Album(LibModel):
|
|||
'releasegroupdisambig': types.STRING,
|
||||
'rg_album_gain': types.NULL_FLOAT,
|
||||
'rg_album_peak': types.NULL_FLOAT,
|
||||
'r128_album_gain': types.PaddedInt(6),
|
||||
'r128_album_gain': types.NullPaddedInt(6),
|
||||
'original_year': types.PaddedInt(4),
|
||||
'original_month': types.PaddedInt(2),
|
||||
'original_day': types.PaddedInt(2),
|
||||
|
|
@ -1128,7 +1151,7 @@ class Album(LibModel):
|
|||
image = bytestring_path(image)
|
||||
item_dir = item_dir or self.item_dir()
|
||||
|
||||
filename_tmpl = Template(
|
||||
filename_tmpl = template(
|
||||
beets.config['art_filename'].as_str())
|
||||
subpath = self.evaluate_template(filename_tmpl, True)
|
||||
if beets.config['asciify_paths']:
|
||||
|
|
@ -1233,8 +1256,10 @@ def parse_query_parts(parts, model_cls):
|
|||
else:
|
||||
non_path_parts.append(s)
|
||||
|
||||
case_insensitive = beets.config['sort_case_insensitive'].get(bool)
|
||||
|
||||
query, sort = dbcore.parse_sorted_query(
|
||||
model_cls, non_path_parts, prefixes
|
||||
model_cls, non_path_parts, prefixes, case_insensitive
|
||||
)
|
||||
|
||||
# Add path queries to aggregate query.
|
||||
|
|
@ -1456,7 +1481,7 @@ class DefaultTemplateFunctions(object):
|
|||
@staticmethod
|
||||
def tmpl_title(s):
|
||||
"""Convert a string to title case."""
|
||||
return s.title()
|
||||
return string.capwords(s)
|
||||
|
||||
@staticmethod
|
||||
def tmpl_left(s, chars):
|
||||
|
|
|
|||
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
|
||||
|
||||
import inspect
|
||||
import traceback
|
||||
import re
|
||||
import inspect
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
|
||||
|
||||
import beets
|
||||
from beets import logging
|
||||
from beets import mediafile
|
||||
import mediafile
|
||||
import six
|
||||
|
||||
PLUGIN_NAMESPACE = 'beetsplug'
|
||||
|
|
@ -127,7 +127,10 @@ class BeetsPlugin(object):
|
|||
value after the function returns). Also determines which params may not
|
||||
be sent for backwards-compatibility.
|
||||
"""
|
||||
argspec = inspect.getargspec(func)
|
||||
if six.PY2:
|
||||
func_args = inspect.getargspec(func).args
|
||||
else:
|
||||
func_args = inspect.getfullargspec(func).args
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
|
@ -142,7 +145,7 @@ class BeetsPlugin(object):
|
|||
if exc.args[0].startswith(func.__name__):
|
||||
# caused by 'func' and not stuff internal to 'func'
|
||||
kwargs = dict((arg, val) for arg, val in kwargs.items()
|
||||
if arg in argspec.args)
|
||||
if arg in func_args)
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
raise
|
||||
|
|
@ -344,6 +347,16 @@ def types(model_cls):
|
|||
return types
|
||||
|
||||
|
||||
def named_queries(model_cls):
|
||||
# Gather `item_queries` and `album_queries` from the plugins.
|
||||
attr_name = '{0}_queries'.format(model_cls.__name__.lower())
|
||||
queries = {}
|
||||
for plugin in find_plugins():
|
||||
plugin_queries = getattr(plugin, attr_name, {})
|
||||
queries.update(plugin_queries)
|
||||
return queries
|
||||
|
||||
|
||||
def track_distance(item, info):
|
||||
"""Gets the track distance calculated by all loaded plugins.
|
||||
Returns a Distance object.
|
||||
|
|
@ -513,7 +526,7 @@ def sanitize_choices(choices, choices_all):
|
|||
|
||||
def sanitize_pairs(pairs, pairs_all):
|
||||
"""Clean up a single-element mapping configuration attribute as returned
|
||||
by `confit`'s `Pairs` template: keep only two-element tuples present in
|
||||
by Confuse's `Pairs` template: keep only two-element tuples present in
|
||||
pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
|
||||
wildcards while keeping the original order. Note that ('*', '*') and
|
||||
('*', 'whatever') have the same effect.
|
||||
|
|
|
|||
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 plugins
|
||||
from beets import util
|
||||
from beets.util.functemplate import Template
|
||||
from beets.util.functemplate import template
|
||||
from beets import config
|
||||
from beets.util import confit, as_string
|
||||
from beets.util import as_string
|
||||
from beets.autotag import mb
|
||||
from beets.dbcore import query as db_query
|
||||
from beets.dbcore import db
|
||||
import confuse
|
||||
import six
|
||||
|
||||
# On Windows platforms, use colorama to support "ANSI" terminal colors.
|
||||
|
|
@ -203,7 +204,7 @@ def input_(prompt=None):
|
|||
"""
|
||||
# raw_input incorrectly sends prompts to stderr, not stdout, so we
|
||||
# use print_() explicitly to display prompts.
|
||||
# http://bugs.python.org/issue1927
|
||||
# https://bugs.python.org/issue1927
|
||||
if prompt:
|
||||
print_(prompt, end=u' ')
|
||||
|
||||
|
|
@ -408,9 +409,14 @@ def input_select_objects(prompt, objs, rep):
|
|||
out = []
|
||||
for obj in objs:
|
||||
rep(obj)
|
||||
if input_yn(u'%s? (yes/no)' % prompt, True):
|
||||
answer = input_options(
|
||||
('y', 'n', 'q'), True, u'%s? (yes/no/quit)' % prompt,
|
||||
u'Enter Y or N:'
|
||||
)
|
||||
if answer == u'y':
|
||||
out.append(obj)
|
||||
print() # go to a new line
|
||||
elif answer == u'q':
|
||||
return out
|
||||
return out
|
||||
|
||||
else: # No.
|
||||
|
|
@ -469,7 +475,7 @@ def human_seconds_short(interval):
|
|||
# Colorization.
|
||||
|
||||
# ANSI terminal colorization code heavily inspired by pygments:
|
||||
# http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py
|
||||
# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
|
||||
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
|
||||
COLOR_ESCAPE = "\x1b["
|
||||
DARK_COLORS = {
|
||||
|
|
@ -524,22 +530,22 @@ def colorize(color_name, text):
|
|||
"""Colorize text if colored output is enabled. (Like _colorize but
|
||||
conditional.)
|
||||
"""
|
||||
if config['ui']['color']:
|
||||
global COLORS
|
||||
if not COLORS:
|
||||
COLORS = dict((name,
|
||||
config['ui']['colors'][name].as_str())
|
||||
for name in COLOR_NAMES)
|
||||
# In case a 3rd party plugin is still passing the actual color ('red')
|
||||
# instead of the abstract color name ('text_error')
|
||||
color = COLORS.get(color_name)
|
||||
if not color:
|
||||
log.debug(u'Invalid color_name: {0}', color_name)
|
||||
color = color_name
|
||||
return _colorize(color, text)
|
||||
else:
|
||||
if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys():
|
||||
return text
|
||||
|
||||
global COLORS
|
||||
if not COLORS:
|
||||
COLORS = dict((name,
|
||||
config['ui']['colors'][name].as_str())
|
||||
for name in COLOR_NAMES)
|
||||
# In case a 3rd party plugin is still passing the actual color ('red')
|
||||
# instead of the abstract color name ('text_error')
|
||||
color = COLORS.get(color_name)
|
||||
if not color:
|
||||
log.debug(u'Invalid color_name: {0}', color_name)
|
||||
color = color_name
|
||||
return _colorize(color, text)
|
||||
|
||||
|
||||
def _colordiff(a, b, highlight='text_highlight',
|
||||
minor_highlight='text_highlight_minor'):
|
||||
|
|
@ -611,12 +617,12 @@ def get_path_formats(subview=None):
|
|||
subview = subview or config['paths']
|
||||
for query, view in subview.items():
|
||||
query = PF_KEY_QUERIES.get(query, query) # Expand common queries.
|
||||
path_formats.append((query, Template(view.as_str())))
|
||||
path_formats.append((query, template(view.as_str())))
|
||||
return path_formats
|
||||
|
||||
|
||||
def get_replacements():
|
||||
"""Confit validation function that reads regex/string pairs.
|
||||
"""Confuse validation function that reads regex/string pairs.
|
||||
"""
|
||||
replacements = []
|
||||
for pattern, repl in config['replace'].get(dict).items():
|
||||
|
|
@ -923,7 +929,7 @@ class CommonOptionsParser(optparse.OptionParser, object):
|
|||
#
|
||||
# This is a fairly generic subcommand parser for optparse. It is
|
||||
# maintained externally here:
|
||||
# http://gist.github.com/462717
|
||||
# https://gist.github.com/462717
|
||||
# There you will also find a better description of the code and a more
|
||||
# succinct example program.
|
||||
|
||||
|
|
@ -1138,8 +1144,12 @@ def _setup(options, lib=None):
|
|||
if lib is None:
|
||||
lib = _open_library(config)
|
||||
plugins.send("library_opened", lib=lib)
|
||||
|
||||
# Add types and queries defined by plugins.
|
||||
library.Item._types.update(plugins.types(library.Item))
|
||||
library.Album._types.update(plugins.types(library.Album))
|
||||
library.Item._queries.update(plugins.named_queries(library.Item))
|
||||
library.Album._queries.update(plugins.named_queries(library.Album))
|
||||
|
||||
return subcommands, plugins, lib
|
||||
|
||||
|
|
@ -1268,7 +1278,7 @@ def main(args=None):
|
|||
log.debug('{}', traceback.format_exc())
|
||||
log.error('{}', exc)
|
||||
sys.exit(1)
|
||||
except confit.ConfigError as exc:
|
||||
except confuse.ConfigError as exc:
|
||||
log.error(u'configuration error: {0}', exc)
|
||||
sys.exit(1)
|
||||
except db_query.InvalidQueryError as exc:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ from beets.util import syspath, normpath, ancestry, displayable_path, \
|
|||
from beets import library
|
||||
from beets import config
|
||||
from beets import logging
|
||||
from beets.util.confit import _package_path
|
||||
import six
|
||||
from . import _store_dict
|
||||
|
||||
|
|
@ -543,7 +542,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
|
|||
print_(u"No matching release found for {0} tracks."
|
||||
.format(itemcount))
|
||||
print_(u'For help, see: '
|
||||
u'http://beets.readthedocs.org/en/latest/faq.html#nomatch')
|
||||
u'https://beets.readthedocs.org/en/latest/faq.html#nomatch')
|
||||
sel = ui.input_options(choice_opts)
|
||||
if sel in choice_actions:
|
||||
return choice_actions[sel]
|
||||
|
|
@ -1177,7 +1176,7 @@ def update_items(lib, query, album, move, pretend, fields):
|
|||
# Manually moving and storing the album.
|
||||
items = list(album.items())
|
||||
for item in items:
|
||||
item.move(store=False)
|
||||
item.move(store=False, with_album=False)
|
||||
item.store(fields=fields)
|
||||
album.move(store=False)
|
||||
album.store(fields=fields)
|
||||
|
|
@ -1490,18 +1489,24 @@ def move_items(lib, dest, query, copy, album, pretend, confirm=False,
|
|||
"""
|
||||
items, albums = _do_query(lib, query, album, False)
|
||||
objs = albums if album else items
|
||||
num_objs = len(objs)
|
||||
|
||||
# Filter out files that don't need to be moved.
|
||||
isitemmoved = lambda item: item.path != item.destination(basedir=dest)
|
||||
isalbummoved = lambda album: any(isitemmoved(i) for i in album.items())
|
||||
objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)]
|
||||
num_unmoved = num_objs - len(objs)
|
||||
# Report unmoved files that match the query.
|
||||
unmoved_msg = u''
|
||||
if num_unmoved > 0:
|
||||
unmoved_msg = u' ({} already in place)'.format(num_unmoved)
|
||||
|
||||
copy = copy or export # Exporting always copies.
|
||||
action = u'Copying' if copy else u'Moving'
|
||||
act = u'copy' if copy else u'move'
|
||||
entity = u'album' if album else u'item'
|
||||
log.info(u'{0} {1} {2}{3}.', action, len(objs), entity,
|
||||
u's' if len(objs) != 1 else u'')
|
||||
log.info(u'{0} {1} {2}{3}{4}.', action, len(objs), entity,
|
||||
u's' if len(objs) != 1 else u'', unmoved_msg)
|
||||
if not objs:
|
||||
return
|
||||
|
||||
|
|
@ -1720,7 +1725,7 @@ def completion_script(commands):
|
|||
``commands`` is alist of ``ui.Subcommand`` instances to generate
|
||||
completion data for.
|
||||
"""
|
||||
base_script = os.path.join(_package_path('beets.ui'), 'completion_base.sh')
|
||||
base_script = os.path.join(os.path.dirname(__file__), 'completion_base.sh')
|
||||
with open(base_script, 'r') as base_script:
|
||||
yield util.text_string(base_script.read())
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ import locale
|
|||
import re
|
||||
import shutil
|
||||
import fnmatch
|
||||
import functools
|
||||
from collections import Counter
|
||||
from multiprocessing.pool import ThreadPool
|
||||
import traceback
|
||||
import subprocess
|
||||
import platform
|
||||
|
|
@ -282,13 +284,13 @@ def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
|
|||
continue
|
||||
clutter = [bytestring_path(c) for c in clutter]
|
||||
match_paths = [bytestring_path(d) for d in os.listdir(directory)]
|
||||
if fnmatch_all(match_paths, clutter):
|
||||
# Directory contains only clutter (or nothing).
|
||||
try:
|
||||
try:
|
||||
if fnmatch_all(match_paths, clutter):
|
||||
# Directory contains only clutter (or nothing).
|
||||
shutil.rmtree(directory)
|
||||
except OSError:
|
||||
else:
|
||||
break
|
||||
else:
|
||||
except OSError:
|
||||
break
|
||||
|
||||
|
||||
|
|
@ -410,7 +412,7 @@ def syspath(path, prefix=True):
|
|||
path = path.decode(encoding, 'replace')
|
||||
|
||||
# Add the magic prefix if it isn't already there.
|
||||
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
|
||||
# https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
|
||||
if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX):
|
||||
if path.startswith(u'\\\\'):
|
||||
# UNC path. Final path should look like \\?\UNC\...
|
||||
|
|
@ -561,7 +563,7 @@ def unique_path(path):
|
|||
# Note: The Windows "reserved characters" are, of course, allowed on
|
||||
# Unix. They are forbidden here because they cause problems on Samba
|
||||
# shares, which are sufficiently common as to cause frequent problems.
|
||||
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
|
||||
# https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
|
||||
CHAR_REPLACE = [
|
||||
(re.compile(r'[\\/]'), u'_'), # / and \ -- forbidden everywhere.
|
||||
(re.compile(r'^\.'), u'_'), # Leading dot (hidden files on Unix).
|
||||
|
|
@ -1009,3 +1011,47 @@ def asciify_path(path, sep_replace):
|
|||
sep_replace
|
||||
)
|
||||
return os.sep.join(path_components)
|
||||
|
||||
|
||||
def par_map(transform, items):
|
||||
"""Apply the function `transform` to all the elements in the
|
||||
iterable `items`, like `map(transform, items)` but with no return
|
||||
value. The map *might* happen in parallel: it's parallel on Python 3
|
||||
and sequential on Python 2.
|
||||
|
||||
The parallelism uses threads (not processes), so this is only useful
|
||||
for IO-bound `transform`s.
|
||||
"""
|
||||
if sys.version_info[0] < 3:
|
||||
# multiprocessing.pool.ThreadPool does not seem to work on
|
||||
# Python 2. We could consider switching to futures instead.
|
||||
for item in items:
|
||||
transform(item)
|
||||
else:
|
||||
pool = ThreadPool()
|
||||
pool.map(transform, items)
|
||||
pool.close()
|
||||
pool.join()
|
||||
|
||||
|
||||
def lazy_property(func):
|
||||
"""A decorator that creates a lazily evaluated property. On first access,
|
||||
the property is assigned the return value of `func`. This first value is
|
||||
stored, so that future accesses do not have to evaluate `func` again.
|
||||
|
||||
This behaviour is useful when `func` is expensive to evaluate, and it is
|
||||
not certain that the result will be needed.
|
||||
"""
|
||||
field_name = '_' + func.__name__
|
||||
|
||||
@property
|
||||
@functools.wraps(func)
|
||||
def wrapper(self):
|
||||
if hasattr(self, field_name):
|
||||
return getattr(self, field_name)
|
||||
|
||||
value = func(self)
|
||||
setattr(self, field_name, value)
|
||||
return value
|
||||
|
||||
return wrapper
|
||||
|
|
|
|||
|
|
@ -81,8 +81,10 @@ def pil_resize(maxwidth, path_in, path_out=None):
|
|||
|
||||
|
||||
def im_resize(maxwidth, path_in, path_out=None):
|
||||
"""Resize using ImageMagick's ``convert`` tool.
|
||||
Return the output path of resized image.
|
||||
"""Resize using ImageMagick.
|
||||
|
||||
Use the ``magick`` program or ``convert`` on older versions. Return
|
||||
the output path of resized image.
|
||||
"""
|
||||
path_out = path_out or temp_file_for(path_in)
|
||||
log.debug(u'artresizer: ImageMagick resizing {0} to {1}',
|
||||
|
|
@ -91,16 +93,18 @@ def im_resize(maxwidth, path_in, path_out=None):
|
|||
# "-resize WIDTHx>" shrinks images with the width larger
|
||||
# than the given width while maintaining the aspect ratio
|
||||
# with regards to the height.
|
||||
try:
|
||||
util.command_output([
|
||||
'convert', util.syspath(path_in, prefix=False),
|
||||
cmd = ArtResizer.shared.im_convert_cmd + \
|
||||
[util.syspath(path_in, prefix=False),
|
||||
'-resize', '{0}x>'.format(maxwidth),
|
||||
util.syspath(path_out, prefix=False),
|
||||
])
|
||||
util.syspath(path_out, prefix=False)]
|
||||
|
||||
try:
|
||||
util.command_output(cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
log.warning(u'artresizer: IM convert failed for {0}',
|
||||
util.displayable_path(path_in))
|
||||
return path_in
|
||||
|
||||
return path_out
|
||||
|
||||
|
||||
|
|
@ -121,8 +125,9 @@ def pil_getsize(path_in):
|
|||
|
||||
|
||||
def im_getsize(path_in):
|
||||
cmd = ['identify', '-format', '%w %h',
|
||||
util.syspath(path_in, prefix=False)]
|
||||
cmd = ArtResizer.shared.im_identify_cmd + \
|
||||
['-format', '%w %h', util.syspath(path_in, prefix=False)]
|
||||
|
||||
try:
|
||||
out = util.command_output(cmd)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
|
@ -173,6 +178,18 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
|
|||
log.debug(u"artresizer: method is {0}", self.method)
|
||||
self.can_compare = self._can_compare()
|
||||
|
||||
# Use ImageMagick's magick binary when it's available. If it's
|
||||
# not, fall back to the older, separate convert and identify
|
||||
# commands.
|
||||
if self.method[0] == IMAGEMAGICK:
|
||||
self.im_legacy = self.method[2]
|
||||
if self.im_legacy:
|
||||
self.im_convert_cmd = ['convert']
|
||||
self.im_identify_cmd = ['identify']
|
||||
else:
|
||||
self.im_convert_cmd = ['magick']
|
||||
self.im_identify_cmd = ['magick', 'identify']
|
||||
|
||||
def resize(self, maxwidth, path_in, path_out=None):
|
||||
"""Manipulate an image file according to the method, returning a
|
||||
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
|
||||
|
|
@ -218,10 +235,20 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
|
|||
|
||||
@staticmethod
|
||||
def _check_method():
|
||||
"""Return a tuple indicating an available method and its version."""
|
||||
"""Return a tuple indicating an available method and its version.
|
||||
|
||||
The result has at least two elements:
|
||||
- The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK.
|
||||
- The version.
|
||||
|
||||
If the method is IMAGEMAGICK, there is also a third element: a
|
||||
bool flag indicating whether to use the `magick` binary or
|
||||
legacy single-purpose executables (`convert`, `identify`, etc.)
|
||||
"""
|
||||
version = get_im_version()
|
||||
if version:
|
||||
return IMAGEMAGICK, version
|
||||
version, legacy = version
|
||||
return IMAGEMAGICK, version, legacy
|
||||
|
||||
version = get_pil_version()
|
||||
if version:
|
||||
|
|
@ -231,29 +258,32 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
|
|||
|
||||
|
||||
def get_im_version():
|
||||
"""Return Image Magick version or None if it is unavailable
|
||||
Try invoking ImageMagick's "convert".
|
||||
"""Get the ImageMagick version and legacy flag as a pair. Or return
|
||||
None if ImageMagick is not available.
|
||||
"""
|
||||
try:
|
||||
out = util.command_output(['convert', '--version'])
|
||||
for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
|
||||
cmd = cmd_name + ['--version']
|
||||
|
||||
if b'imagemagick' in out.lower():
|
||||
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
|
||||
match = re.search(pattern, out)
|
||||
if match:
|
||||
return (int(match.group(1)),
|
||||
int(match.group(2)),
|
||||
int(match.group(3)))
|
||||
return (0,)
|
||||
try:
|
||||
out = util.command_output(cmd)
|
||||
except (subprocess.CalledProcessError, OSError) as exc:
|
||||
log.debug(u'ImageMagick version check failed: {}', exc)
|
||||
else:
|
||||
if b'imagemagick' in out.lower():
|
||||
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
|
||||
match = re.search(pattern, out)
|
||||
if match:
|
||||
version = (int(match.group(1)),
|
||||
int(match.group(2)),
|
||||
int(match.group(3)))
|
||||
return version, legacy
|
||||
|
||||
except (subprocess.CalledProcessError, OSError) as exc:
|
||||
log.debug(u'ImageMagick check `convert --version` failed: {}', exc)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_pil_version():
|
||||
"""Return Image Magick version or None if it is unavailable
|
||||
Try importing PIL."""
|
||||
"""Get the PIL/Pillow version, or None if it is unavailable.
|
||||
"""
|
||||
try:
|
||||
__import__('PIL', fromlist=[str('Image')])
|
||||
return (0,)
|
||||
|
|
|
|||
|
|
@ -346,6 +346,10 @@ def run(root_coro):
|
|||
exc.args[0] == errno.EPIPE:
|
||||
# Broken pipe. Remote host disconnected.
|
||||
pass
|
||||
elif isinstance(exc.args, tuple) and \
|
||||
exc.args[0] == errno.ECONNRESET:
|
||||
# Connection was reset by peer.
|
||||
pass
|
||||
else:
|
||||
traceback.print_exc()
|
||||
# Abort the coroutine.
|
||||
|
|
|
|||
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 sys
|
||||
import six
|
||||
import functools
|
||||
|
||||
SYMBOL_DELIM = u'$'
|
||||
FUNC_DELIM = u'%'
|
||||
|
|
@ -117,31 +118,38 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
|
|||
bytecode of the compiled function.
|
||||
"""
|
||||
if six.PY2:
|
||||
func_def = ast.FunctionDef(
|
||||
name=name.encode('utf-8'),
|
||||
args=ast.arguments(
|
||||
args=[ast.Name(n, ast.Param()) for n in arg_names],
|
||||
vararg=None,
|
||||
kwarg=None,
|
||||
defaults=[ex_literal(None) for _ in arg_names],
|
||||
),
|
||||
body=statements,
|
||||
decorator_list=[],
|
||||
name = name.encode('utf-8')
|
||||
args = ast.arguments(
|
||||
args=[ast.Name(n, ast.Param()) for n in arg_names],
|
||||
vararg=None,
|
||||
kwarg=None,
|
||||
defaults=[ex_literal(None) for _ in arg_names],
|
||||
)
|
||||
else:
|
||||
func_def = ast.FunctionDef(
|
||||
name=name,
|
||||
args=ast.arguments(
|
||||
args=[ast.arg(arg=n, annotation=None) for n in arg_names],
|
||||
kwonlyargs=[],
|
||||
kw_defaults=[],
|
||||
defaults=[ex_literal(None) for _ in arg_names],
|
||||
),
|
||||
body=statements,
|
||||
decorator_list=[],
|
||||
)
|
||||
args_fields = {
|
||||
'args': [ast.arg(arg=n, annotation=None) for n in arg_names],
|
||||
'kwonlyargs': [],
|
||||
'kw_defaults': [],
|
||||
'defaults': [ex_literal(None) for _ in arg_names],
|
||||
}
|
||||
if 'posonlyargs' in ast.arguments._fields: # Added in Python 3.8.
|
||||
args_fields['posonlyargs'] = []
|
||||
args = ast.arguments(**args_fields)
|
||||
|
||||
func_def = ast.FunctionDef(
|
||||
name=name,
|
||||
args=args,
|
||||
body=statements,
|
||||
decorator_list=[],
|
||||
)
|
||||
|
||||
# The ast.Module signature changed in 3.8 to accept a list of types to
|
||||
# ignore.
|
||||
if sys.version_info >= (3, 8):
|
||||
mod = ast.Module([func_def], [])
|
||||
else:
|
||||
mod = ast.Module([func_def])
|
||||
|
||||
mod = ast.Module([func_def])
|
||||
ast.fix_missing_locations(mod)
|
||||
|
||||
prog = compile(mod, '<generated>', 'exec')
|
||||
|
|
@ -547,8 +555,23 @@ def _parse(template):
|
|||
return Expression(parts)
|
||||
|
||||
|
||||
# External interface.
|
||||
def cached(func):
|
||||
"""Like the `functools.lru_cache` decorator, but works (as a no-op)
|
||||
on Python < 3.2.
|
||||
"""
|
||||
if hasattr(functools, 'lru_cache'):
|
||||
return functools.lru_cache(maxsize=128)(func)
|
||||
else:
|
||||
# Do nothing when lru_cache is not available.
|
||||
return func
|
||||
|
||||
|
||||
@cached
|
||||
def template(fmt):
|
||||
return Template(fmt)
|
||||
|
||||
|
||||
# External interface.
|
||||
class Template(object):
|
||||
"""A string template, including text, Symbols, and Calls.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ import json
|
|||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from distutils.spawn import find_executable
|
||||
import requests
|
||||
|
||||
|
|
@ -75,8 +73,8 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
|
|||
call([self.extractor])
|
||||
except OSError:
|
||||
raise ui.UserError(
|
||||
u'No extractor command found: please install the '
|
||||
u'extractor binary from http://acousticbrainz.org/download'
|
||||
u'No extractor command found: please install the extractor'
|
||||
u' binary from https://acousticbrainz.org/download'
|
||||
)
|
||||
except ABSubmitError:
|
||||
# Extractor found, will exit with an error if not called with
|
||||
|
|
@ -106,15 +104,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
|
|||
def command(self, lib, opts, args):
|
||||
# Get items from arguments
|
||||
items = lib.items(ui.decargs(args))
|
||||
if sys.version_info[0] < 3:
|
||||
for item in items:
|
||||
self.analyze_submit(item)
|
||||
else:
|
||||
# Analyze in parallel using a thread pool.
|
||||
pool = ThreadPool()
|
||||
pool.map(self.analyze_submit, items)
|
||||
pool.close()
|
||||
pool.join()
|
||||
util.par_map(self.analyze_submit, items)
|
||||
|
||||
def analyze_submit(self, item):
|
||||
analysis = self._get_analysis(item)
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@
|
|||
"""
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import requests
|
||||
|
||||
from collections import defaultdict
|
||||
from beets import plugins, ui
|
||||
from beets.dbcore import types
|
||||
|
||||
ACOUSTIC_BASE = "https://acousticbrainz.org/"
|
||||
LEVELS = ["/low-level", "/high-level"]
|
||||
|
|
@ -104,6 +106,29 @@ ABSCHEME = {
|
|||
|
||||
|
||||
class AcousticPlugin(plugins.BeetsPlugin):
|
||||
item_types = {
|
||||
'average_loudness': types.Float(6),
|
||||
'chords_changes_rate': types.Float(6),
|
||||
'chords_key': types.STRING,
|
||||
'chords_number_rate': types.Float(6),
|
||||
'chords_scale': types.STRING,
|
||||
'danceable': types.Float(6),
|
||||
'gender': types.STRING,
|
||||
'genre_rosamerica': types.STRING,
|
||||
'initial_key': types.STRING,
|
||||
'key_strength': types.Float(6),
|
||||
'mood_acoustic': types.Float(6),
|
||||
'mood_aggressive': types.Float(6),
|
||||
'mood_electronic': types.Float(6),
|
||||
'mood_happy': types.Float(6),
|
||||
'mood_party': types.Float(6),
|
||||
'mood_relaxed': types.Float(6),
|
||||
'mood_sad': types.Float(6),
|
||||
'rhythm': types.Float(6),
|
||||
'tonal': types.Float(6),
|
||||
'voice_instrumental': types.STRING,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super(AcousticPlugin, self).__init__()
|
||||
|
||||
|
|
|
|||
|
|
@ -18,16 +18,18 @@
|
|||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand
|
||||
from beets.util import displayable_path, confit
|
||||
from beets import ui
|
||||
from subprocess import check_output, CalledProcessError, list2cmdline, STDOUT
|
||||
|
||||
import shlex
|
||||
import os
|
||||
import errno
|
||||
import sys
|
||||
import six
|
||||
import confuse
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand
|
||||
from beets.util import displayable_path, par_map
|
||||
from beets import ui
|
||||
|
||||
|
||||
class CheckerCommandException(Exception):
|
||||
|
|
@ -48,6 +50,10 @@ class CheckerCommandException(Exception):
|
|||
|
||||
|
||||
class BadFiles(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(BadFiles, self).__init__()
|
||||
self.verbose = False
|
||||
|
||||
def run_command(self, cmd):
|
||||
self._log.debug(u"running command: {}",
|
||||
displayable_path(list2cmdline(cmd)))
|
||||
|
|
@ -61,7 +67,7 @@ class BadFiles(BeetsPlugin):
|
|||
status = e.returncode
|
||||
except OSError as e:
|
||||
raise CheckerCommandException(cmd, e)
|
||||
output = output.decode(sys.getfilesystemencoding())
|
||||
output = output.decode(sys.getdefaultencoding(), 'replace')
|
||||
return status, errors, [line for line in output.split("\n") if line]
|
||||
|
||||
def check_mp3val(self, path):
|
||||
|
|
@ -85,60 +91,64 @@ class BadFiles(BeetsPlugin):
|
|||
ext = ext.lower()
|
||||
try:
|
||||
command = self.config['commands'].get(dict).get(ext)
|
||||
except confit.NotFoundError:
|
||||
except confuse.NotFoundError:
|
||||
command = None
|
||||
if command:
|
||||
return self.check_custom(command)
|
||||
elif ext == "mp3":
|
||||
if ext == "mp3":
|
||||
return self.check_mp3val
|
||||
elif ext == "flac":
|
||||
if ext == "flac":
|
||||
return self.check_flac
|
||||
|
||||
def check_bad(self, lib, opts, args):
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
def check_item(self, item):
|
||||
# First, check whether the path exists. If not, the user
|
||||
# should probably run `beet update` to cleanup your library.
|
||||
dpath = displayable_path(item.path)
|
||||
self._log.debug(u"checking path: {}", dpath)
|
||||
if not os.path.exists(item.path):
|
||||
ui.print_(u"{}: file does not exist".format(
|
||||
ui.colorize('text_error', dpath)))
|
||||
|
||||
# First, check whether the path exists. If not, the user
|
||||
# should probably run `beet update` to cleanup your library.
|
||||
dpath = displayable_path(item.path)
|
||||
self._log.debug(u"checking path: {}", dpath)
|
||||
if not os.path.exists(item.path):
|
||||
ui.print_(u"{}: file does not exist".format(
|
||||
ui.colorize('text_error', dpath)))
|
||||
# Run the checker against the file if one is found
|
||||
ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore')
|
||||
checker = self.get_checker(ext)
|
||||
if not checker:
|
||||
self._log.error(u"no checker specified in the config for {}",
|
||||
ext)
|
||||
return
|
||||
path = item.path
|
||||
if not isinstance(path, six.text_type):
|
||||
path = item.path.decode(sys.getfilesystemencoding())
|
||||
try:
|
||||
status, errors, output = checker(path)
|
||||
except CheckerCommandException as e:
|
||||
if e.errno == errno.ENOENT:
|
||||
self._log.error(
|
||||
u"command not found: {} when validating file: {}",
|
||||
e.checker,
|
||||
e.path
|
||||
)
|
||||
else:
|
||||
self._log.error(u"error invoking {}: {}", e.checker, e.msg)
|
||||
return
|
||||
if status > 0:
|
||||
ui.print_(u"{}: checker exited with status {}"
|
||||
.format(ui.colorize('text_error', dpath), status))
|
||||
for line in output:
|
||||
ui.print_(u" {}".format(line))
|
||||
elif errors > 0:
|
||||
ui.print_(u"{}: checker found {} errors or warnings"
|
||||
.format(ui.colorize('text_warning', dpath), errors))
|
||||
for line in output:
|
||||
ui.print_(u" {}".format(line))
|
||||
elif self.verbose:
|
||||
ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath)))
|
||||
|
||||
# Run the checker against the file if one is found
|
||||
ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore')
|
||||
checker = self.get_checker(ext)
|
||||
if not checker:
|
||||
self._log.error(u"no checker specified in the config for {}",
|
||||
ext)
|
||||
continue
|
||||
path = item.path
|
||||
if not isinstance(path, six.text_type):
|
||||
path = item.path.decode(sys.getfilesystemencoding())
|
||||
try:
|
||||
status, errors, output = checker(path)
|
||||
except CheckerCommandException as e:
|
||||
if e.errno == errno.ENOENT:
|
||||
self._log.error(
|
||||
u"command not found: {} when validating file: {}",
|
||||
e.checker,
|
||||
e.path
|
||||
)
|
||||
else:
|
||||
self._log.error(u"error invoking {}: {}", e.checker, e.msg)
|
||||
continue
|
||||
if status > 0:
|
||||
ui.print_(u"{}: checker exited with status {}"
|
||||
.format(ui.colorize('text_error', dpath), status))
|
||||
for line in output:
|
||||
ui.print_(u" {}".format(displayable_path(line)))
|
||||
elif errors > 0:
|
||||
ui.print_(u"{}: checker found {} errors or warnings"
|
||||
.format(ui.colorize('text_warning', dpath), errors))
|
||||
for line in output:
|
||||
ui.print_(u" {}".format(displayable_path(line)))
|
||||
elif opts.verbose:
|
||||
ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath)))
|
||||
def command(self, lib, opts, args):
|
||||
# Get items from arguments
|
||||
items = lib.items(ui.decargs(args))
|
||||
self.verbose = opts.verbose
|
||||
par_map(self.check_item, items)
|
||||
|
||||
def commands(self):
|
||||
bad_command = Subcommand('bad',
|
||||
|
|
@ -148,5 +158,5 @@ class BadFiles(BeetsPlugin):
|
|||
action='store_true', default=False, dest='verbose',
|
||||
help=u'view results for both the bad and uncorrupted files'
|
||||
)
|
||||
bad_command.func = self.check_bad
|
||||
bad_command.func = self.command
|
||||
return [bad_command]
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ import beets
|
|||
import beets.ui
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import confit
|
||||
import confuse
|
||||
|
||||
|
||||
AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing)
|
||||
USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__)
|
||||
USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__)
|
||||
|
||||
|
||||
class BeatportAPIError(Exception):
|
||||
|
|
@ -109,7 +109,7 @@ class BeatportClient(object):
|
|||
:rtype: (unicode, unicode) tuple
|
||||
"""
|
||||
self.api.parse_authorization_response(
|
||||
"http://beets.io/auth?" + auth_data)
|
||||
"https://beets.io/auth?" + auth_data)
|
||||
access_data = self.api.fetch_access_token(
|
||||
self._make_url('/identity/1/oauth/access-token'))
|
||||
return access_data['oauth_token'], access_data['oauth_token_secret']
|
||||
|
|
@ -191,7 +191,7 @@ class BeatportClient(object):
|
|||
response = self.api.get(self._make_url(endpoint), params=kwargs)
|
||||
except Exception as e:
|
||||
raise BeatportAPIError("Error connecting to Beatport API: {}"
|
||||
.format(e.message))
|
||||
.format(e))
|
||||
if not response:
|
||||
raise BeatportAPIError(
|
||||
"Error {0.status_code} for '{0.request.path_url}"
|
||||
|
|
@ -224,7 +224,7 @@ class BeatportRelease(BeatportObject):
|
|||
if 'category' in data:
|
||||
self.category = data['category']
|
||||
if 'slug' in data:
|
||||
self.url = "http://beatport.com/release/{0}/{1}".format(
|
||||
self.url = "https://beatport.com/release/{0}/{1}".format(
|
||||
data['slug'], data['id'])
|
||||
|
||||
|
||||
|
|
@ -252,8 +252,8 @@ class BeatportTrack(BeatportObject):
|
|||
except ValueError:
|
||||
pass
|
||||
if 'slug' in data:
|
||||
self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'],
|
||||
data['id'])
|
||||
self.url = "https://beatport.com/track/{0}/{1}" \
|
||||
.format(data['slug'], data['id'])
|
||||
self.track_number = data.get('trackNumber')
|
||||
|
||||
|
||||
|
|
@ -318,7 +318,7 @@ class BeatportPlugin(BeetsPlugin):
|
|||
def _tokenfile(self):
|
||||
"""Get the path to the JSON file for storing the OAuth token.
|
||||
"""
|
||||
return self.config['tokenfile'].get(confit.Filename(in_app_dir=True))
|
||||
return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True))
|
||||
|
||||
def album_distance(self, items, album_info, mapping):
|
||||
"""Returns the beatport source weight and the maximum source weight
|
||||
|
|
|
|||
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:
|
||||
# http://pygstdocs.berlios.de/pygst-tutorial/playbin.html
|
||||
# https://pygstdocs.berlios.de/pygst-tutorial/playbin.html (gone)
|
||||
# https://brettviren.github.io/pygst-tutorial-org/pygst-tutorial.html
|
||||
####
|
||||
# Updated to GStreamer 1.0 with:
|
||||
# https://wiki.ubuntu.com/Novacut/GStreamer1.0
|
||||
|
|
@ -177,12 +178,12 @@ class GstPlayer(object):
|
|||
posq = self.player.query_position(fmt)
|
||||
if not posq[0]:
|
||||
raise QueryError("query_position failed")
|
||||
pos = posq[1] // (10 ** 9)
|
||||
pos = posq[1] / (10 ** 9)
|
||||
|
||||
lengthq = self.player.query_duration(fmt)
|
||||
if not lengthq[0]:
|
||||
raise QueryError("query_duration failed")
|
||||
length = lengthq[1] // (10 ** 9)
|
||||
length = lengthq[1] / (10 ** 9)
|
||||
|
||||
self.cached_time = (pos, length)
|
||||
return (pos, length)
|
||||
|
|
@ -215,6 +216,59 @@ class GstPlayer(object):
|
|||
while self.playing:
|
||||
time.sleep(1)
|
||||
|
||||
def get_decoders(self):
|
||||
return get_decoders()
|
||||
|
||||
|
||||
def get_decoders():
|
||||
"""Get supported audio decoders from GStreamer.
|
||||
Returns a dict mapping decoder element names to the associated media types
|
||||
and file extensions.
|
||||
"""
|
||||
# We only care about audio decoder elements.
|
||||
filt = (Gst.ELEMENT_FACTORY_TYPE_DEPAYLOADER |
|
||||
Gst.ELEMENT_FACTORY_TYPE_DEMUXER |
|
||||
Gst.ELEMENT_FACTORY_TYPE_PARSER |
|
||||
Gst.ELEMENT_FACTORY_TYPE_DECODER |
|
||||
Gst.ELEMENT_FACTORY_TYPE_MEDIA_AUDIO)
|
||||
|
||||
decoders = {}
|
||||
mime_types = set()
|
||||
for f in Gst.ElementFactory.list_get_elements(filt, Gst.Rank.NONE):
|
||||
for pad in f.get_static_pad_templates():
|
||||
if pad.direction == Gst.PadDirection.SINK:
|
||||
caps = pad.static_caps.get()
|
||||
mimes = set()
|
||||
for i in range(caps.get_size()):
|
||||
struct = caps.get_structure(i)
|
||||
mime = struct.get_name()
|
||||
if mime == 'unknown/unknown':
|
||||
continue
|
||||
mimes.add(mime)
|
||||
mime_types.add(mime)
|
||||
if mimes:
|
||||
decoders[f.get_name()] = (mimes, set())
|
||||
|
||||
# Check all the TypeFindFactory plugin features form the registry. If they
|
||||
# are associated with an audio media type that we found above, get the list
|
||||
# of corresponding file extensions.
|
||||
mime_extensions = {mime: set() for mime in mime_types}
|
||||
for feat in Gst.Registry.get().get_feature_list(Gst.TypeFindFactory):
|
||||
caps = feat.get_caps()
|
||||
if caps:
|
||||
for i in range(caps.get_size()):
|
||||
struct = caps.get_structure(i)
|
||||
mime = struct.get_name()
|
||||
if mime in mime_types:
|
||||
mime_extensions[mime].update(feat.get_extensions())
|
||||
|
||||
# Fill in the slot we left for file extensions.
|
||||
for name, (mimes, exts) in decoders.items():
|
||||
for mime in mimes:
|
||||
exts.update(mime_extensions[mime])
|
||||
|
||||
return decoders
|
||||
|
||||
|
||||
def play_simple(paths):
|
||||
"""Play the files in paths in a straightforward way, without
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ from beets import plugins
|
|||
from beets import ui
|
||||
from beets import util
|
||||
from beets import config
|
||||
from beets.util import confit
|
||||
from beets.autotag import hooks
|
||||
import confuse
|
||||
import acoustid
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
|
|
@ -93,6 +93,7 @@ def acoustid_match(log, path):
|
|||
log.error(u'fingerprinting of {0} failed: {1}',
|
||||
util.displayable_path(repr(path)), exc)
|
||||
return None
|
||||
fp = fp.decode()
|
||||
_fingerprints[path] = fp
|
||||
try:
|
||||
res = acoustid.lookup(API_KEY, fp, duration,
|
||||
|
|
@ -220,7 +221,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
def submit_cmd_func(lib, opts, args):
|
||||
try:
|
||||
apikey = config['acoustid']['apikey'].as_str()
|
||||
except confit.NotFoundError:
|
||||
except confuse.NotFoundError:
|
||||
raise ui.UserError(u'no Acoustid user API key provided')
|
||||
submit_items(self._log, apikey, lib.items(ui.decargs(args)))
|
||||
submit_cmd.func = submit_cmd_func
|
||||
|
|
@ -334,7 +335,7 @@ def fingerprint_item(log, item, write=False):
|
|||
util.displayable_path(item.path))
|
||||
try:
|
||||
_, fp = acoustid.fingerprint_file(util.syspath(item.path))
|
||||
item.acoustid_fingerprint = fp
|
||||
item.acoustid_fingerprint = fp.decode()
|
||||
if write:
|
||||
log.info(u'{0}: writing fingerprint',
|
||||
util.displayable_path(item.path))
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import platform
|
|||
|
||||
from beets import ui, util, plugins, config
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util.confit import ConfigTypeError
|
||||
from confuse import ConfigTypeError
|
||||
from beets import art
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from beets.library import parse_query_string
|
||||
|
|
@ -116,6 +116,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
u'pretend': False,
|
||||
u'threads': util.cpu_count(),
|
||||
u'format': u'mp3',
|
||||
u'id3v23': u'inherit',
|
||||
u'formats': {
|
||||
u'aac': {
|
||||
u'command': u'ffmpeg -i $source -y -vn -acodec aac '
|
||||
|
|
@ -316,8 +317,12 @@ class ConvertPlugin(BeetsPlugin):
|
|||
if pretend:
|
||||
continue
|
||||
|
||||
id3v23 = self.config['id3v23'].as_choice([True, False, 'inherit'])
|
||||
if id3v23 == 'inherit':
|
||||
id3v23 = None
|
||||
|
||||
# Write tags from the database to the converted file.
|
||||
item.try_write(path=converted)
|
||||
item.try_write(path=converted, id3v23=id3v23)
|
||||
|
||||
if keep_new:
|
||||
# If we're keeping the transcoded file, read it again (after
|
||||
|
|
@ -332,7 +337,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
self._log.debug(u'embedding album art from {}',
|
||||
util.displayable_path(album.artpath))
|
||||
art.embed_item(self._log, item, album.artpath,
|
||||
itempath=converted)
|
||||
itempath=converted, id3v23=id3v23)
|
||||
|
||||
if keep_new:
|
||||
plugins.send('after_convert', item=item,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import beets.ui
|
|||
from beets import config
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import confit
|
||||
import confuse
|
||||
from discogs_client import Release, Master, Client
|
||||
from discogs_client.exceptions import DiscogsAPIError
|
||||
from requests.exceptions import ConnectionError
|
||||
|
|
@ -37,7 +37,7 @@ import traceback
|
|||
from string import ascii_lowercase
|
||||
|
||||
|
||||
USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__)
|
||||
USER_AGENT = u'beets/{0} +https://beets.io/'.format(beets.__version__)
|
||||
|
||||
# Exceptions that discogs_client should really handle but does not.
|
||||
CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException,
|
||||
|
|
@ -55,12 +55,15 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
'tokenfile': 'discogs_token.json',
|
||||
'source_weight': 0.5,
|
||||
'user_token': '',
|
||||
'separator': u', '
|
||||
})
|
||||
self.config['apikey'].redact = True
|
||||
self.config['apisecret'].redact = True
|
||||
self.config['user_token'].redact = True
|
||||
self.discogs_client = None
|
||||
self.register_listener('import_begin', self.setup)
|
||||
self.rate_limit_per_minute = 25
|
||||
self.last_request_timestamp = 0
|
||||
|
||||
def setup(self, session=None):
|
||||
"""Create the `discogs_client` field. Authenticate if necessary.
|
||||
|
|
@ -71,6 +74,9 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
# Try using a configured user token (bypassing OAuth login).
|
||||
user_token = self.config['user_token'].as_str()
|
||||
if user_token:
|
||||
# The rate limit for authenticated users goes up to 60
|
||||
# requests per minute.
|
||||
self.rate_limit_per_minute = 60
|
||||
self.discogs_client = Client(USER_AGENT, user_token=user_token)
|
||||
return
|
||||
|
||||
|
|
@ -88,6 +94,26 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
self.discogs_client = Client(USER_AGENT, c_key, c_secret,
|
||||
token, secret)
|
||||
|
||||
def _time_to_next_request(self):
|
||||
seconds_between_requests = 60 / self.rate_limit_per_minute
|
||||
seconds_since_last_request = time.time() - self.last_request_timestamp
|
||||
seconds_to_wait = seconds_between_requests - seconds_since_last_request
|
||||
return seconds_to_wait
|
||||
|
||||
def request_start(self):
|
||||
"""wait for rate limit if needed
|
||||
"""
|
||||
time_to_next_request = self._time_to_next_request()
|
||||
if time_to_next_request > 0:
|
||||
self._log.debug('hit rate limit, waiting for {0} seconds',
|
||||
time_to_next_request)
|
||||
time.sleep(time_to_next_request)
|
||||
|
||||
def request_finished(self):
|
||||
"""update timestamp for rate limiting
|
||||
"""
|
||||
self.last_request_timestamp = time.time()
|
||||
|
||||
def reset_auth(self):
|
||||
"""Delete token file & redo the auth steps.
|
||||
"""
|
||||
|
|
@ -97,7 +123,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
def _tokenfile(self):
|
||||
"""Get the path to the JSON file for storing the OAuth token.
|
||||
"""
|
||||
return self.config['tokenfile'].get(confit.Filename(in_app_dir=True))
|
||||
return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True))
|
||||
|
||||
def authenticate(self, c_key, c_secret):
|
||||
# Get the link for the OAuth page.
|
||||
|
|
@ -206,9 +232,13 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
# Strip medium information from query, Things like "CD1" and "disk 1"
|
||||
# can also negate an otherwise positive result.
|
||||
query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query)
|
||||
|
||||
self.request_start()
|
||||
try:
|
||||
releases = self.discogs_client.search(query,
|
||||
type='release').page(1)
|
||||
self.request_finished()
|
||||
|
||||
except CONNECTION_ERRORS:
|
||||
self._log.debug(u"Communication error while searching for {0!r}",
|
||||
query, exc_info=True)
|
||||
|
|
@ -222,8 +252,11 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
"""
|
||||
self._log.debug(u'Searching for master release {0}', master_id)
|
||||
result = Master(self.discogs_client, {'id': master_id})
|
||||
|
||||
self.request_start()
|
||||
try:
|
||||
year = result.fetch('year')
|
||||
self.request_finished()
|
||||
return year
|
||||
except DiscogsAPIError as e:
|
||||
if e.status_code != 404:
|
||||
|
|
@ -252,7 +285,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
# https://www.discogs.com/help/doc/submission-guidelines-general-rules
|
||||
if not all([result.data.get(k) for k in ['artists', 'title', 'id',
|
||||
'tracklist']]):
|
||||
self._log.warn(u"Release does not contain the required fields")
|
||||
self._log.warning(u"Release does not contain the required fields")
|
||||
return None
|
||||
|
||||
artist, artist_id = self.get_artist([a.data for a in result.artists])
|
||||
|
|
@ -270,6 +303,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
mediums = [t.medium for t in tracks]
|
||||
country = result.data.get('country')
|
||||
data_url = result.data.get('uri')
|
||||
style = self.format_style(result.data.get('styles'))
|
||||
|
||||
# Extract information for the optional AlbumInfo fields that are
|
||||
# contained on nested discogs fields.
|
||||
|
|
@ -286,7 +320,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
if va:
|
||||
artist = config['va_name'].as_str()
|
||||
if catalogno == 'none':
|
||||
catalogno = None
|
||||
catalogno = None
|
||||
# Explicitly set the `media` for the tracks, since it is expected by
|
||||
# `autotag.apply_metadata`, and set `medium_total`.
|
||||
for track in tracks:
|
||||
|
|
@ -307,12 +341,19 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
day=None, label=label, mediums=len(set(mediums)),
|
||||
artist_sort=None, releasegroup_id=master_id,
|
||||
catalognum=catalogno, script=None, language=None,
|
||||
country=country, albumstatus=None, media=media,
|
||||
country=country, style=style,
|
||||
albumstatus=None, media=media,
|
||||
albumdisambig=None, artist_credit=None,
|
||||
original_year=original_year, original_month=None,
|
||||
original_day=None, data_source='Discogs',
|
||||
data_url=data_url)
|
||||
|
||||
def format_style(self, style):
|
||||
if style is None:
|
||||
self._log.debug('Style not Found')
|
||||
else:
|
||||
return self.config['separator'].as_str().join(sorted(style))
|
||||
|
||||
def get_artist(self, artists):
|
||||
"""Returns an artist string (all artists) and an artist_id (the main
|
||||
artist) for a list of discogs album or track artists.
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ def load(s):
|
|||
"""
|
||||
try:
|
||||
out = []
|
||||
for d in yaml.load_all(s):
|
||||
for d in yaml.safe_load_all(s):
|
||||
if not isinstance(d, dict):
|
||||
raise ParseError(
|
||||
u'each entry must be a dictionary; found {}'.format(
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
|
||||
def remove_artfile(self, album):
|
||||
"""Possibly delete the album art file for an album (if the
|
||||
appropriate configuration option is enabled.
|
||||
appropriate configuration option is enabled).
|
||||
"""
|
||||
if self.config['remove_art_file'] and album.artpath:
|
||||
if os.path.isfile(album.artpath):
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import codecs
|
|||
from datetime import datetime, date
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui
|
||||
from beets import mediafile
|
||||
import mediafile
|
||||
from beetsplug.info import make_key_filter, library_data, tag_data
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,11 @@ from beets import importer
|
|||
from beets import ui
|
||||
from beets import util
|
||||
from beets import config
|
||||
from beets.mediafile import image_mime_type
|
||||
from mediafile import image_mime_type
|
||||
from beets.util.artresizer import ArtResizer
|
||||
from beets.util import confit
|
||||
from beets.util import sorted_walk
|
||||
from beets.util import syspath, bytestring_path, py3_path
|
||||
import confuse
|
||||
import six
|
||||
|
||||
CONTENT_TYPES = {
|
||||
|
|
@ -310,7 +311,10 @@ class CoverArtArchive(RemoteArtSource):
|
|||
|
||||
class Amazon(RemoteArtSource):
|
||||
NAME = u"Amazon"
|
||||
URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
|
||||
if util.SNI_SUPPORTED:
|
||||
URL = 'https://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
|
||||
else:
|
||||
URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
|
||||
INDICES = (1, 2)
|
||||
|
||||
def get(self, album, plugin, paths):
|
||||
|
|
@ -324,7 +328,10 @@ class Amazon(RemoteArtSource):
|
|||
|
||||
class AlbumArtOrg(RemoteArtSource):
|
||||
NAME = u"AlbumArt.org scraper"
|
||||
URL = 'http://www.albumart.org/index_detail.php'
|
||||
if util.SNI_SUPPORTED:
|
||||
URL = 'https://www.albumart.org/index_detail.php'
|
||||
else:
|
||||
URL = 'http://www.albumart.org/index_detail.php'
|
||||
PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
|
||||
|
||||
def get(self, album, plugin, paths):
|
||||
|
|
@ -365,12 +372,17 @@ class GoogleImages(RemoteArtSource):
|
|||
if not (album.albumartist and album.album):
|
||||
return
|
||||
search_string = (album.albumartist + ',' + album.album).encode('utf-8')
|
||||
response = self.request(self.URL, params={
|
||||
'key': self.key,
|
||||
'cx': self.cx,
|
||||
'q': search_string,
|
||||
'searchType': 'image'
|
||||
})
|
||||
|
||||
try:
|
||||
response = self.request(self.URL, params={
|
||||
'key': self.key,
|
||||
'cx': self.cx,
|
||||
'q': search_string,
|
||||
'searchType': 'image'
|
||||
})
|
||||
except requests.RequestException:
|
||||
self._log.debug(u'google: error receiving response')
|
||||
return
|
||||
|
||||
# Get results using JSON.
|
||||
try:
|
||||
|
|
@ -406,10 +418,14 @@ class FanartTV(RemoteArtSource):
|
|||
if not album.mb_releasegroupid:
|
||||
return
|
||||
|
||||
response = self.request(
|
||||
self.API_ALBUMS + album.mb_releasegroupid,
|
||||
headers={'api-key': self.PROJECT_KEY,
|
||||
'client-key': self.client_key})
|
||||
try:
|
||||
response = self.request(
|
||||
self.API_ALBUMS + album.mb_releasegroupid,
|
||||
headers={'api-key': self.PROJECT_KEY,
|
||||
'client-key': self.client_key})
|
||||
except requests.RequestException:
|
||||
self._log.debug(u'fanart.tv: error receiving response')
|
||||
return
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
|
|
@ -545,16 +561,22 @@ class Wikipedia(RemoteArtSource):
|
|||
|
||||
# Find the name of the cover art filename on DBpedia
|
||||
cover_filename, page_id = None, None
|
||||
dbpedia_response = self.request(
|
||||
self.DBPEDIA_URL,
|
||||
params={
|
||||
'format': 'application/sparql-results+json',
|
||||
'timeout': 2500,
|
||||
'query': self.SPARQL_QUERY.format(
|
||||
artist=album.albumartist.title(), album=album.album)
|
||||
},
|
||||
headers={'content-type': 'application/json'},
|
||||
)
|
||||
|
||||
try:
|
||||
dbpedia_response = self.request(
|
||||
self.DBPEDIA_URL,
|
||||
params={
|
||||
'format': 'application/sparql-results+json',
|
||||
'timeout': 2500,
|
||||
'query': self.SPARQL_QUERY.format(
|
||||
artist=album.albumartist.title(), album=album.album)
|
||||
},
|
||||
headers={'content-type': 'application/json'},
|
||||
)
|
||||
except requests.RequestException:
|
||||
self._log.debug(u'dbpedia: error receiving response')
|
||||
return
|
||||
|
||||
try:
|
||||
data = dbpedia_response.json()
|
||||
results = data['results']['bindings']
|
||||
|
|
@ -584,17 +606,21 @@ class Wikipedia(RemoteArtSource):
|
|||
lpart, rpart = cover_filename.rsplit(' .', 1)
|
||||
|
||||
# Query all the images in the page
|
||||
wikipedia_response = self.request(
|
||||
self.WIKIPEDIA_URL,
|
||||
params={
|
||||
'format': 'json',
|
||||
'action': 'query',
|
||||
'continue': '',
|
||||
'prop': 'images',
|
||||
'pageids': page_id,
|
||||
},
|
||||
headers={'content-type': 'application/json'},
|
||||
)
|
||||
try:
|
||||
wikipedia_response = self.request(
|
||||
self.WIKIPEDIA_URL,
|
||||
params={
|
||||
'format': 'json',
|
||||
'action': 'query',
|
||||
'continue': '',
|
||||
'prop': 'images',
|
||||
'pageids': page_id,
|
||||
},
|
||||
headers={'content-type': 'application/json'},
|
||||
)
|
||||
except requests.RequestException:
|
||||
self._log.debug(u'wikipedia: error receiving response')
|
||||
return
|
||||
|
||||
# Try to see if one of the images on the pages matches our
|
||||
# incomplete cover_filename
|
||||
|
|
@ -613,18 +639,22 @@ class Wikipedia(RemoteArtSource):
|
|||
return
|
||||
|
||||
# Find the absolute url of the cover art on Wikipedia
|
||||
wikipedia_response = self.request(
|
||||
self.WIKIPEDIA_URL,
|
||||
params={
|
||||
'format': 'json',
|
||||
'action': 'query',
|
||||
'continue': '',
|
||||
'prop': 'imageinfo',
|
||||
'iiprop': 'url',
|
||||
'titles': cover_filename.encode('utf-8'),
|
||||
},
|
||||
headers={'content-type': 'application/json'},
|
||||
)
|
||||
try:
|
||||
wikipedia_response = self.request(
|
||||
self.WIKIPEDIA_URL,
|
||||
params={
|
||||
'format': 'json',
|
||||
'action': 'query',
|
||||
'continue': '',
|
||||
'prop': 'imageinfo',
|
||||
'iiprop': 'url',
|
||||
'titles': cover_filename.encode('utf-8'),
|
||||
},
|
||||
headers={'content-type': 'application/json'},
|
||||
)
|
||||
except requests.RequestException:
|
||||
self._log.debug(u'wikipedia: error receiving response')
|
||||
return
|
||||
|
||||
try:
|
||||
data = wikipedia_response.json()
|
||||
|
|
@ -666,12 +696,16 @@ class FileSystem(LocalArtSource):
|
|||
|
||||
# Find all files that look like images in the directory.
|
||||
images = []
|
||||
for fn in os.listdir(syspath(path)):
|
||||
fn = bytestring_path(fn)
|
||||
for ext in IMAGE_EXTENSIONS:
|
||||
if fn.lower().endswith(b'.' + ext) and \
|
||||
os.path.isfile(syspath(os.path.join(path, fn))):
|
||||
images.append(fn)
|
||||
ignore = config['ignore'].as_str_seq()
|
||||
ignore_hidden = config['ignore_hidden'].get(bool)
|
||||
for _, _, files in sorted_walk(path, ignore=ignore,
|
||||
ignore_hidden=ignore_hidden):
|
||||
for fn in files:
|
||||
fn = bytestring_path(fn)
|
||||
for ext in IMAGE_EXTENSIONS:
|
||||
if fn.lower().endswith(b'.' + ext) and \
|
||||
os.path.isfile(syspath(os.path.join(path, fn))):
|
||||
images.append(fn)
|
||||
|
||||
# Look for "preferred" filenames.
|
||||
images = sorted(images,
|
||||
|
|
@ -749,9 +783,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
|
||||
# allow both pixel and percentage-based margin specifications
|
||||
self.enforce_ratio = self.config['enforce_ratio'].get(
|
||||
confit.OneOf([bool,
|
||||
confit.String(pattern=self.PAT_PX),
|
||||
confit.String(pattern=self.PAT_PERCENT)]))
|
||||
confuse.OneOf([bool,
|
||||
confuse.String(pattern=self.PAT_PX),
|
||||
confuse.String(pattern=self.PAT_PERCENT)]))
|
||||
self.margin_px = None
|
||||
self.margin_percent = None
|
||||
if type(self.enforce_ratio) is six.text_type:
|
||||
|
|
@ -761,7 +795,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
self.margin_px = int(self.enforce_ratio[:-2])
|
||||
else:
|
||||
# shouldn't happen
|
||||
raise confit.ConfigValueError()
|
||||
raise confuse.ConfigValueError()
|
||||
self.enforce_ratio = True
|
||||
|
||||
cover_names = self.config['cover_names'].as_str_seq()
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ class FileFilterPlugin(BeetsPlugin):
|
|||
bytestring_path(self.config['album_path'].get()))
|
||||
|
||||
if 'singleton_path' in self.config:
|
||||
self.path_singleton_regex = re.compile(
|
||||
bytestring_path(self.config['singleton_path'].get()))
|
||||
self.path_singleton_regex = re.compile(
|
||||
bytestring_path(self.config['singleton_path'].get()))
|
||||
|
||||
def import_task_created_event(self, session, task):
|
||||
if task.items and len(task.items) > 0:
|
||||
|
|
|
|||
|
|
@ -31,12 +31,19 @@ class Gmusic(BeetsPlugin):
|
|||
def __init__(self):
|
||||
super(Gmusic, self).__init__()
|
||||
self.m = Musicmanager()
|
||||
|
||||
# OAUTH_FILEPATH was moved in gmusicapi 12.0.0.
|
||||
if hasattr(Musicmanager, 'OAUTH_FILEPATH'):
|
||||
oauth_file = Musicmanager.OAUTH_FILEPATH
|
||||
else:
|
||||
oauth_file = gmusicapi.clients.OAUTH_FILEPATH
|
||||
|
||||
self.config.add({
|
||||
u'auto': False,
|
||||
u'uploader_id': '',
|
||||
u'uploader_name': '',
|
||||
u'device_id': '',
|
||||
u'oauth_file': gmusicapi.clients.OAUTH_FILEPATH,
|
||||
u'oauth_file': oauth_file,
|
||||
})
|
||||
if self.config['auto']:
|
||||
self.import_stages = [self.autoupload]
|
||||
|
|
@ -62,7 +69,7 @@ class Gmusic(BeetsPlugin):
|
|||
return
|
||||
# Checks for OAuth2 credentials,
|
||||
# if they don't exist - performs authorization
|
||||
oauth_file = self.config['oauth_file'].as_str()
|
||||
oauth_file = self.config['oauth_file'].as_filename()
|
||||
if os.path.isfile(oauth_file):
|
||||
uploader_id = self.config['uploader_id']
|
||||
uploader_name = self.config['uploader_name']
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ from __future__ import division, absolute_import, print_function
|
|||
|
||||
import string
|
||||
import subprocess
|
||||
import six
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import shlex_split, arg_encoding
|
||||
|
|
@ -46,10 +45,8 @@ class CodingFormatter(string.Formatter):
|
|||
|
||||
See str.format and string.Formatter.format.
|
||||
"""
|
||||
try:
|
||||
if isinstance(format_string, bytes):
|
||||
format_string = format_string.decode(self._coding)
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
|
||||
return super(CodingFormatter, self).format(format_string, *args,
|
||||
**kwargs)
|
||||
|
|
@ -91,28 +88,25 @@ class HookPlugin(BeetsPlugin):
|
|||
|
||||
def create_and_register_hook(self, event, command):
|
||||
def hook_function(**kwargs):
|
||||
if command is None or len(command) == 0:
|
||||
self._log.error('invalid command "{0}"', command)
|
||||
return
|
||||
if command is None or len(command) == 0:
|
||||
self._log.error('invalid command "{0}"', command)
|
||||
return
|
||||
|
||||
# Use a string formatter that works on Unicode strings.
|
||||
if six.PY2:
|
||||
formatter = CodingFormatter(arg_encoding())
|
||||
else:
|
||||
formatter = string.Formatter()
|
||||
# Use a string formatter that works on Unicode strings.
|
||||
formatter = CodingFormatter(arg_encoding())
|
||||
|
||||
command_pieces = shlex_split(command)
|
||||
command_pieces = shlex_split(command)
|
||||
|
||||
for i, piece in enumerate(command_pieces):
|
||||
command_pieces[i] = formatter.format(piece, event=event,
|
||||
**kwargs)
|
||||
for i, piece in enumerate(command_pieces):
|
||||
command_pieces[i] = formatter.format(piece, event=event,
|
||||
**kwargs)
|
||||
|
||||
self._log.debug(u'running command "{0}" for event {1}',
|
||||
u' '.join(command_pieces), event)
|
||||
self._log.debug(u'running command "{0}" for event {1}',
|
||||
u' '.join(command_pieces), event)
|
||||
|
||||
try:
|
||||
subprocess.Popen(command_pieces).wait()
|
||||
except OSError as exc:
|
||||
self._log.error(u'hook for {0} failed: {1}', event, exc)
|
||||
try:
|
||||
subprocess.Popen(command_pieces).wait()
|
||||
except OSError as exc:
|
||||
self._log.error(u'hook for {0} failed: {1}', event, exc)
|
||||
|
||||
self.register_listener(event, hook_function)
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ class ImportAddedPlugin(BeetsPlugin):
|
|||
util.displayable_path(item.path), item.added)
|
||||
item.store()
|
||||
|
||||
def update_after_write_time(self, item):
|
||||
def update_after_write_time(self, item, path):
|
||||
"""Update the mtime of the item's file with the item.added value
|
||||
after each write of the item if `preserve_write_mtimes` is enabled.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import re
|
|||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui
|
||||
from beets import mediafile
|
||||
import mediafile
|
||||
from beets.library import Item
|
||||
from beets.util import displayable_path, normpath, syspath
|
||||
|
||||
|
|
@ -66,7 +66,6 @@ def library_data(lib, args):
|
|||
def library_data_emitter(item):
|
||||
def emitter():
|
||||
data = dict(item.formatted())
|
||||
data.pop('path', None) # path is fetched from item
|
||||
|
||||
return data, item
|
||||
return emitter
|
||||
|
|
@ -184,6 +183,8 @@ class InfoPlugin(BeetsPlugin):
|
|||
included_keys = []
|
||||
for keys in opts.included_keys:
|
||||
included_keys.extend(keys.split(','))
|
||||
# Drop path even if user provides it multiple times
|
||||
included_keys = [k for k in included_keys if k != 'path']
|
||||
key_filter = make_key_filter(included_keys)
|
||||
|
||||
first = True
|
||||
|
|
@ -219,8 +220,13 @@ def make_key_filter(include):
|
|||
dictionary that only includes the key-value pairs where the key
|
||||
glob-matches one of the keys in `include`.
|
||||
"""
|
||||
# By default, if no field inclusions are specified, include
|
||||
# everything but `path`.
|
||||
if not include:
|
||||
return identity
|
||||
def filter_(data):
|
||||
return {k: v for k, v in data.items()
|
||||
if k != 'path'}
|
||||
return filter_
|
||||
|
||||
matchers = []
|
||||
for key in include:
|
||||
|
|
@ -236,7 +242,3 @@ def make_key_filter(include):
|
|||
return filtered
|
||||
|
||||
return filter_
|
||||
|
||||
|
||||
def identity(val):
|
||||
return val
|
||||
|
|
|
|||
|
|
@ -117,9 +117,13 @@ class InlinePlugin(BeetsPlugin):
|
|||
# For function bodies, invoke the function with values as global
|
||||
# variables.
|
||||
def _func_func(obj):
|
||||
old_globals = dict(func.__globals__)
|
||||
func.__globals__.update(_dict_for(obj))
|
||||
try:
|
||||
return func()
|
||||
except Exception as exc:
|
||||
raise InlineError(python_code, exc)
|
||||
finally:
|
||||
func.__globals__.clear()
|
||||
func.__globals__.update(old_globals)
|
||||
return _func_func
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class IPFSPlugin(BeetsPlugin):
|
|||
super(IPFSPlugin, self).__init__()
|
||||
self.config.add({
|
||||
'auto': True,
|
||||
'nocopy': False,
|
||||
})
|
||||
|
||||
if self.config['auto']:
|
||||
|
|
@ -116,7 +117,10 @@ class IPFSPlugin(BeetsPlugin):
|
|||
|
||||
self._log.info('Adding {0} to ipfs', album_dir)
|
||||
|
||||
cmd = "ipfs add -q -r".split()
|
||||
if self.config['nocopy']:
|
||||
cmd = "ipfs add --nocopy -q -r".split()
|
||||
else:
|
||||
cmd = "ipfs add -q -r".split()
|
||||
cmd.append(album_dir)
|
||||
try:
|
||||
output = util.command_output(cmd).split()
|
||||
|
|
@ -174,7 +178,10 @@ class IPFSPlugin(BeetsPlugin):
|
|||
with tempfile.NamedTemporaryFile() as tmp:
|
||||
self.ipfs_added_albums(lib, tmp.name)
|
||||
try:
|
||||
cmd = "ipfs add -q ".split()
|
||||
if self.config['nocopy']:
|
||||
cmd = "ipfs add --nocopy -q ".split()
|
||||
else:
|
||||
cmd = "ipfs add -q ".split()
|
||||
cmd.append(tmp.name)
|
||||
output = util.command_output(cmd)
|
||||
except (OSError, subprocess.CalledProcessError) as err:
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class KeyFinderPlugin(BeetsPlugin):
|
|||
continue
|
||||
except UnicodeEncodeError:
|
||||
# Workaround for Python 2 Windows bug.
|
||||
# http://bugs.python.org/issue1759845
|
||||
# https://bugs.python.org/issue1759845
|
||||
self._log.error(u'execution failed for Unicode path: {0!r}',
|
||||
item.path)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import six
|
||||
|
||||
"""Gets genres for imported music based on Last.fm tags.
|
||||
|
|
@ -152,7 +153,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
self._log.debug('Loading canonicalization tree {0}', c14n_filename)
|
||||
c14n_filename = normpath(c14n_filename)
|
||||
with codecs.open(c14n_filename, 'r', encoding='utf-8') as f:
|
||||
genres_tree = yaml.load(f)
|
||||
genres_tree = yaml.safe_load(f)
|
||||
flatten_tree(genres_tree, [], self.c14n_branches)
|
||||
|
||||
@property
|
||||
|
|
@ -373,35 +374,53 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
lastgenre_cmd = ui.Subcommand('lastgenre', help=u'fetch genres')
|
||||
lastgenre_cmd.parser.add_option(
|
||||
u'-f', u'--force', dest='force',
|
||||
action='store_true', default=False,
|
||||
action='store_true',
|
||||
help=u're-download genre when already present'
|
||||
)
|
||||
lastgenre_cmd.parser.add_option(
|
||||
u'-s', u'--source', dest='source', type='string',
|
||||
help=u'genre source: artist, album, or track'
|
||||
)
|
||||
lastgenre_cmd.parser.add_option(
|
||||
u'-A', u'--items', action='store_false', dest='album',
|
||||
help=u'match items instead of albums')
|
||||
lastgenre_cmd.parser.add_option(
|
||||
u'-a', u'--albums', action='store_true', dest='album',
|
||||
help=u'match albums instead of items')
|
||||
lastgenre_cmd.parser.set_defaults(album=True)
|
||||
|
||||
def lastgenre_func(lib, opts, args):
|
||||
write = ui.should_write()
|
||||
self.config.set_args(opts)
|
||||
|
||||
for album in lib.albums(ui.decargs(args)):
|
||||
album.genre, src = self._get_genre(album)
|
||||
self._log.info(u'genre for album {0} ({1}): {0.genre}',
|
||||
album, src)
|
||||
album.store()
|
||||
if opts.album:
|
||||
# Fetch genres for whole albums
|
||||
for album in lib.albums(ui.decargs(args)):
|
||||
album.genre, src = self._get_genre(album)
|
||||
self._log.info(u'genre for album {0} ({1}): {0.genre}',
|
||||
album, src)
|
||||
album.store()
|
||||
|
||||
for item in album.items():
|
||||
# If we're using track-level sources, also look up each
|
||||
# track on the album.
|
||||
if 'track' in self.sources:
|
||||
item.genre, src = self._get_genre(item)
|
||||
item.store()
|
||||
self._log.info(u'genre for track {0} ({1}): {0.genre}',
|
||||
item, src)
|
||||
for item in album.items():
|
||||
# If we're using track-level sources, also look up each
|
||||
# track on the album.
|
||||
if 'track' in self.sources:
|
||||
item.genre, src = self._get_genre(item)
|
||||
item.store()
|
||||
self._log.info(
|
||||
u'genre for track {0} ({1}): {0.genre}',
|
||||
item, src)
|
||||
|
||||
if write:
|
||||
item.try_write()
|
||||
if write:
|
||||
item.try_write()
|
||||
else:
|
||||
# Just query singletons, i.e. items that are not part of
|
||||
# an album
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
item.genre, src = self._get_genre(item)
|
||||
self._log.debug(u'added last.fm item genre ({0}): {1}',
|
||||
src, item.genre)
|
||||
item.store()
|
||||
|
||||
lastgenre_cmd.func = lastgenre_func
|
||||
return [lastgenre_cmd]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2016, Rafael Bodill http://github.com/rafi
|
||||
# Copyright 2016, Rafael Bodill https://github.com/rafi
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
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 ui
|
||||
from beets import util
|
||||
import beets
|
||||
|
||||
DIV_RE = re.compile(r'<(/?)div>?', re.I)
|
||||
|
|
@ -406,7 +407,10 @@ class Genius(Backend):
|
|||
class LyricsWiki(SymbolsReplaced):
|
||||
"""Fetch lyrics from LyricsWiki."""
|
||||
|
||||
URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
|
||||
if util.SNI_SUPPORTED:
|
||||
URL_PATTERN = 'https://lyrics.wikia.com/%s:%s'
|
||||
else:
|
||||
URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
|
||||
|
||||
def fetch(self, artist, title):
|
||||
url = self.build_url(artist, title)
|
||||
|
|
@ -446,7 +450,7 @@ def _scrape_strip_cruft(html, plain_text_out=False):
|
|||
html = html.replace('\r', '\n') # Normalize EOL.
|
||||
html = re.sub(r' +', ' ', html) # Whitespaces collapse.
|
||||
html = BREAK_RE.sub('\n', html) # <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
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from __future__ import division, absolute_import, print_function
|
|||
from abc import abstractmethod, ABCMeta
|
||||
from importlib import import_module
|
||||
|
||||
from beets.util.confit import ConfigValueError
|
||||
from confuse import ConfigValueError
|
||||
from beets import ui
|
||||
from beets.plugins import BeetsPlugin
|
||||
import six
|
||||
|
|
|
|||
|
|
@ -24,13 +24,14 @@ import shutil
|
|||
import tempfile
|
||||
import plistlib
|
||||
|
||||
import six
|
||||
from six.moves.urllib.parse import urlparse, unquote
|
||||
from time import mktime
|
||||
|
||||
from beets import util
|
||||
from beets.dbcore import types
|
||||
from beets.library import DateType
|
||||
from beets.util.confit import ConfigValueError
|
||||
from confuse import ConfigValueError
|
||||
from beetsplug.metasync import MetaSource
|
||||
|
||||
|
||||
|
|
@ -84,7 +85,11 @@ class Itunes(MetaSource):
|
|||
self._log.debug(
|
||||
u'loading iTunes library from {0}'.format(library_path))
|
||||
with create_temporary_copy(library_path) as library_copy:
|
||||
raw_library = plistlib.readPlist(library_copy)
|
||||
if six.PY2:
|
||||
raw_library = plistlib.readPlist(library_copy)
|
||||
else:
|
||||
with open(library_copy, 'rb') as library_copy_f:
|
||||
raw_library = plistlib.load(library_copy_f)
|
||||
except IOError as e:
|
||||
raise ConfigValueError(u'invalid iTunes library: ' + e.strerror)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -107,17 +107,17 @@ class MPDClientWrapper(object):
|
|||
self.connect()
|
||||
return self.get(command, retries=retries - 1)
|
||||
|
||||
def playlist(self):
|
||||
"""Return the currently active playlist. Prefixes paths with the
|
||||
def currentsong(self):
|
||||
"""Return the path to the currently playing song. Prefixes paths with the
|
||||
music_directory, to get the absolute path.
|
||||
"""
|
||||
result = {}
|
||||
for entry in self.get('playlistinfo'):
|
||||
result = None
|
||||
entry = self.get('currentsong')
|
||||
if 'file' in entry:
|
||||
if not is_url(entry['file']):
|
||||
result[entry['id']] = os.path.join(
|
||||
self.music_directory, entry['file'])
|
||||
result = os.path.join(self.music_directory, entry['file'])
|
||||
else:
|
||||
result[entry['id']] = entry['file']
|
||||
result = entry['file']
|
||||
return result
|
||||
|
||||
def status(self):
|
||||
|
|
@ -250,8 +250,8 @@ class MPDStats(object):
|
|||
self.now_playing = None
|
||||
|
||||
def on_play(self, status):
|
||||
playlist = self.mpd.playlist()
|
||||
path = playlist.get(status['songid'])
|
||||
|
||||
path = self.mpd.currentsong()
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
|
@ -326,7 +326,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
|
|||
'rating': True,
|
||||
'rating_mix': 0.75,
|
||||
'host': os.environ.get('MPD_HOST', u'localhost'),
|
||||
'port': 6600,
|
||||
'port': int(os.environ.get('MPD_PORT', 6600)),
|
||||
'password': u'',
|
||||
})
|
||||
mpd_config['password'].redact = True
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class MPDUpdatePlugin(BeetsPlugin):
|
|||
super(MPDUpdatePlugin, self).__init__()
|
||||
config['mpd'].add({
|
||||
'host': os.environ.get('MPD_HOST', u'localhost'),
|
||||
'port': 6600,
|
||||
'port': int(os.environ.get('MPD_PORT', 6600)),
|
||||
'password': u'',
|
||||
})
|
||||
config['mpd']['password'].redact = True
|
||||
|
|
|
|||
204
beetsplug/parentwork.py
Normal file
204
beetsplug/parentwork.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2017, Dorian Soergel.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Gets parent work, its disambiguation and id, composer, composer sort name
|
||||
and work composition date
|
||||
"""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
from beets import ui
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
import musicbrainzngs
|
||||
|
||||
|
||||
def direct_parent_id(mb_workid, work_date=None):
|
||||
"""Given a Musicbrainz work id, find the id one of the works the work is
|
||||
part of and the first composition date it encounters.
|
||||
"""
|
||||
work_info = musicbrainzngs.get_work_by_id(mb_workid,
|
||||
includes=["work-rels",
|
||||
"artist-rels"])
|
||||
if 'artist-relation-list' in work_info['work'] and work_date is None:
|
||||
for artist in work_info['work']['artist-relation-list']:
|
||||
if artist['type'] == 'composer':
|
||||
if 'end' in artist.keys():
|
||||
work_date = artist['end']
|
||||
|
||||
if 'work-relation-list' in work_info['work']:
|
||||
for direct_parent in work_info['work']['work-relation-list']:
|
||||
if direct_parent['type'] == 'parts' \
|
||||
and direct_parent.get('direction') == 'backward':
|
||||
direct_id = direct_parent['work']['id']
|
||||
return direct_id, work_date
|
||||
return None, work_date
|
||||
|
||||
|
||||
def work_parent_id(mb_workid):
|
||||
"""Find the parent work id and composition date of a work given its id.
|
||||
"""
|
||||
work_date = None
|
||||
while True:
|
||||
new_mb_workid, work_date = direct_parent_id(mb_workid, work_date)
|
||||
if not new_mb_workid:
|
||||
return mb_workid, work_date
|
||||
mb_workid = new_mb_workid
|
||||
return mb_workid, work_date
|
||||
|
||||
|
||||
def find_parentwork_info(mb_workid):
|
||||
"""Get the MusicBrainz information dict about a parent work, including
|
||||
the artist relations, and the composition date for a work's parent work.
|
||||
"""
|
||||
parent_id, work_date = work_parent_id(mb_workid)
|
||||
work_info = musicbrainzngs.get_work_by_id(parent_id,
|
||||
includes=["artist-rels"])
|
||||
return work_info, work_date
|
||||
|
||||
|
||||
class ParentWorkPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(ParentWorkPlugin, self).__init__()
|
||||
|
||||
self.config.add({
|
||||
'auto': False,
|
||||
'force': False,
|
||||
})
|
||||
|
||||
if self.config['auto']:
|
||||
self.import_stages = [self.imported]
|
||||
|
||||
def commands(self):
|
||||
|
||||
def func(lib, opts, args):
|
||||
self.config.set_args(opts)
|
||||
force_parent = self.config['force'].get(bool)
|
||||
write = ui.should_write()
|
||||
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
self.find_work(item, force_parent)
|
||||
item.store()
|
||||
if write:
|
||||
item.try_write()
|
||||
command = ui.Subcommand(
|
||||
'parentwork',
|
||||
help=u'fetche parent works, composers and dates')
|
||||
|
||||
command.parser.add_option(
|
||||
u'-f', u'--force', dest='force',
|
||||
action='store_true', default=None,
|
||||
help=u're-fetch when parent work is already present')
|
||||
|
||||
command.func = func
|
||||
return [command]
|
||||
|
||||
def imported(self, session, task):
|
||||
"""Import hook for fetching parent works automatically.
|
||||
"""
|
||||
force_parent = self.config['force'].get(bool)
|
||||
|
||||
for item in task.imported_items():
|
||||
self.find_work(item, force_parent)
|
||||
item.store()
|
||||
|
||||
def get_info(self, item, work_info):
|
||||
"""Given the parent work info dict, fetch parent_composer,
|
||||
parent_composer_sort, parentwork, parentwork_disambig, mb_workid and
|
||||
composer_ids.
|
||||
"""
|
||||
|
||||
parent_composer = []
|
||||
parent_composer_sort = []
|
||||
parentwork_info = {}
|
||||
|
||||
composer_exists = False
|
||||
if 'artist-relation-list' in work_info['work']:
|
||||
for artist in work_info['work']['artist-relation-list']:
|
||||
if artist['type'] == 'composer':
|
||||
parent_composer.append(artist['artist']['name'])
|
||||
parent_composer_sort.append(artist['artist']['sort-name'])
|
||||
|
||||
parentwork_info['parent_composer'] = u', '.join(parent_composer)
|
||||
parentwork_info['parent_composer_sort'] = u', '.join(
|
||||
parent_composer_sort)
|
||||
|
||||
if not composer_exists:
|
||||
self._log.debug(
|
||||
'no composer for {}; add one at '
|
||||
'https://musicbrainz.org/work/{}',
|
||||
item, work_info['work']['id'],
|
||||
)
|
||||
|
||||
parentwork_info['parentwork'] = work_info['work']['title']
|
||||
parentwork_info['mb_parentworkid'] = work_info['work']['id']
|
||||
|
||||
if 'disambiguation' in work_info['work']:
|
||||
parentwork_info['parentwork_disambig'] = work_info[
|
||||
'work']['disambiguation']
|
||||
|
||||
else:
|
||||
parentwork_info['parentwork_disambig'] = None
|
||||
|
||||
return parentwork_info
|
||||
|
||||
def find_work(self, item, force):
|
||||
"""Finds the parent work of a recording and populates the tags
|
||||
accordingly.
|
||||
|
||||
The parent work is found recursively, by finding the direct parent
|
||||
repeatedly until there are no more links in the chain. We return the
|
||||
final, topmost work in the chain.
|
||||
|
||||
Namely, the tags parentwork, parentwork_disambig, mb_parentworkid,
|
||||
parent_composer, parent_composer_sort and work_date are populated.
|
||||
"""
|
||||
|
||||
if not item.mb_workid:
|
||||
self._log.info('No work for {}, \
|
||||
add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
|
||||
return
|
||||
|
||||
hasparent = hasattr(item, 'parentwork')
|
||||
if force or not hasparent:
|
||||
try:
|
||||
work_info, work_date = find_parentwork_info(item.mb_workid)
|
||||
except musicbrainzngs.musicbrainz.WebServiceError as e:
|
||||
self._log.debug("error fetching work: {}", e)
|
||||
return
|
||||
parent_info = self.get_info(item, work_info)
|
||||
if 'parent_composer' in parent_info:
|
||||
self._log.debug("Work fetched: {} - {}",
|
||||
parent_info['parentwork'],
|
||||
parent_info['parent_composer'])
|
||||
else:
|
||||
self._log.debug("Work fetched: {} - no parent composer",
|
||||
parent_info['parentwork'])
|
||||
|
||||
elif hasparent:
|
||||
self._log.debug("{}: Work present, skipping", item)
|
||||
return
|
||||
|
||||
# apply all non-null values to the item
|
||||
for key, value in parent_info.items():
|
||||
if value:
|
||||
item[key] = value
|
||||
|
||||
if work_date:
|
||||
item['work_date'] = work_date
|
||||
ui.show_model_changes(
|
||||
item, fields=['parentwork', 'parentwork_disambig',
|
||||
'mb_parentworkid', 'parent_composer',
|
||||
'parent_composer_sort', 'work_date'])
|
||||
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.ui import Subcommand, decargs, print_
|
||||
import random
|
||||
from operator import attrgetter
|
||||
from itertools import groupby
|
||||
|
||||
|
||||
def _length(obj, album):
|
||||
"""Get the duration of an item or album.
|
||||
"""
|
||||
if album:
|
||||
return sum(i.length for i in obj.items())
|
||||
else:
|
||||
return obj.length
|
||||
|
||||
|
||||
def _equal_chance_permutation(objs, field='albumartist'):
|
||||
"""Generate (lazily) a permutation of the objects where every group
|
||||
with equal values for `field` have an equal chance of appearing in
|
||||
any given position.
|
||||
"""
|
||||
# Group the objects by artist so we can sample from them.
|
||||
key = attrgetter(field)
|
||||
objs.sort(key=key)
|
||||
objs_by_artists = {}
|
||||
for artist, v in groupby(objs, key):
|
||||
objs_by_artists[artist] = list(v)
|
||||
|
||||
# While we still have artists with music to choose from, pick one
|
||||
# randomly and pick a track from that artist.
|
||||
while objs_by_artists:
|
||||
# Choose an artist and an object for that artist, removing
|
||||
# this choice from the pool.
|
||||
artist = random.choice(list(objs_by_artists.keys()))
|
||||
objs_from_artist = objs_by_artists[artist]
|
||||
i = random.randint(0, len(objs_from_artist) - 1)
|
||||
yield objs_from_artist.pop(i)
|
||||
|
||||
# Remove the artist if we've used up all of its objects.
|
||||
if not objs_from_artist:
|
||||
del objs_by_artists[artist]
|
||||
|
||||
|
||||
def _take(iter, num):
|
||||
"""Return a list containing the first `num` values in `iter` (or
|
||||
fewer, if the iterable ends early).
|
||||
"""
|
||||
out = []
|
||||
for val in iter:
|
||||
out.append(val)
|
||||
num -= 1
|
||||
if num <= 0:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _take_time(iter, secs, album):
|
||||
"""Return a list containing the first values in `iter`, which should
|
||||
be Item or Album objects, that add up to the given amount of time in
|
||||
seconds.
|
||||
"""
|
||||
out = []
|
||||
total_time = 0.0
|
||||
for obj in iter:
|
||||
length = _length(obj, album)
|
||||
if total_time + length <= secs:
|
||||
out.append(obj)
|
||||
total_time += length
|
||||
return out
|
||||
|
||||
|
||||
def random_objs(objs, album, number=1, time=None, equal_chance=False):
|
||||
"""Get a random subset of the provided `objs`.
|
||||
|
||||
If `number` is provided, produce that many matches. Otherwise, if
|
||||
`time` is provided, instead select a list whose total time is close
|
||||
to that number of minutes. If `equal_chance` is true, give each
|
||||
artist an equal chance of being included so that artists with more
|
||||
songs are not represented disproportionately.
|
||||
"""
|
||||
# Permute the objects either in a straightforward way or an
|
||||
# artist-balanced way.
|
||||
if equal_chance:
|
||||
perm = _equal_chance_permutation(objs)
|
||||
else:
|
||||
perm = objs
|
||||
random.shuffle(perm) # N.B. This shuffles the original list.
|
||||
|
||||
# Select objects by time our count.
|
||||
if time:
|
||||
return _take_time(perm, time * 60, album)
|
||||
else:
|
||||
return _take(perm, number)
|
||||
from beets.random import random_objs
|
||||
|
||||
|
||||
def random_func(lib, opts, args):
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class Backend(object):
|
|||
def compute_track_gain(self, items):
|
||||
raise NotImplementedError()
|
||||
|
||||
def compute_album_gain(self, album):
|
||||
def compute_album_gain(self, items):
|
||||
# TODO: implement album gain in terms of track gain of the
|
||||
# individual tracks which can be used for any backend.
|
||||
raise NotImplementedError()
|
||||
|
|
@ -125,15 +125,14 @@ class Bs1770gainBackend(Backend):
|
|||
output = self.compute_gain(items, False)
|
||||
return output
|
||||
|
||||
def compute_album_gain(self, album):
|
||||
def compute_album_gain(self, items):
|
||||
"""Computes the album gain of the given album, returns an
|
||||
AlbumGain object.
|
||||
"""
|
||||
# TODO: What should be done when not all tracks in the album are
|
||||
# supported?
|
||||
|
||||
supported_items = album.items()
|
||||
output = self.compute_gain(supported_items, True)
|
||||
output = self.compute_gain(items, True)
|
||||
|
||||
if not output:
|
||||
raise ReplayGainError(u'no output from bs1770gain')
|
||||
|
|
@ -251,7 +250,14 @@ class Bs1770gainBackend(Backend):
|
|||
state['gain'] = state['peak'] = None
|
||||
parser.StartElementHandler = start_element_handler
|
||||
parser.EndElementHandler = end_element_handler
|
||||
parser.Parse(text, True)
|
||||
|
||||
try:
|
||||
parser.Parse(text, True)
|
||||
except xml.parsers.expat.ExpatError:
|
||||
raise ReplayGainError(
|
||||
u'The bs1770gain tool produced malformed XML. '
|
||||
'Using version >=0.4.10 may solve this problem.'
|
||||
)
|
||||
|
||||
if len(per_file_gain) != len(path_list):
|
||||
raise ReplayGainError(
|
||||
|
|
@ -316,15 +322,15 @@ class CommandBackend(Backend):
|
|||
output = self.compute_gain(supported_items, False)
|
||||
return output
|
||||
|
||||
def compute_album_gain(self, album):
|
||||
def compute_album_gain(self, items):
|
||||
"""Computes the album gain of the given album, returns an
|
||||
AlbumGain object.
|
||||
"""
|
||||
# TODO: What should be done when not all tracks in the album are
|
||||
# supported?
|
||||
|
||||
supported_items = list(filter(self.format_supported, album.items()))
|
||||
if len(supported_items) != len(album.items()):
|
||||
supported_items = list(filter(self.format_supported, items))
|
||||
if len(supported_items) != len(items):
|
||||
self._log.debug(u'tracks are of unsupported format')
|
||||
return AlbumGain(None, [])
|
||||
|
||||
|
|
@ -521,8 +527,8 @@ class GStreamerBackend(Backend):
|
|||
|
||||
return ret
|
||||
|
||||
def compute_album_gain(self, album):
|
||||
items = list(album.items())
|
||||
def compute_album_gain(self, items):
|
||||
items = list(items)
|
||||
self.compute(items, True)
|
||||
if len(self._file_tags) != len(items):
|
||||
raise ReplayGainError(u"Some items in album did not receive tags")
|
||||
|
|
@ -707,7 +713,7 @@ class AudioToolsBackend(Backend):
|
|||
file format is not supported
|
||||
"""
|
||||
try:
|
||||
audiofile = self._mod_audiotools.open(item.path)
|
||||
audiofile = self._mod_audiotools.open(py3_path(syspath(item.path)))
|
||||
except IOError:
|
||||
raise ReplayGainError(
|
||||
u"File {} was not found".format(item.path)
|
||||
|
|
@ -777,22 +783,20 @@ class AudioToolsBackend(Backend):
|
|||
item.artist, item.title, rg_track_gain, rg_track_peak)
|
||||
return Gain(gain=rg_track_gain, peak=rg_track_peak)
|
||||
|
||||
def compute_album_gain(self, album):
|
||||
def compute_album_gain(self, items):
|
||||
"""Compute ReplayGain values for the requested album and its items.
|
||||
|
||||
:rtype: :class:`AlbumGain`
|
||||
"""
|
||||
self._log.debug(u'Analysing album {0}', album)
|
||||
|
||||
# The first item is taken and opened to get the sample rate to
|
||||
# initialize the replaygain object. The object is used for all the
|
||||
# tracks in the album to get the album values.
|
||||
item = list(album.items())[0]
|
||||
item = list(items)[0]
|
||||
audiofile = self.open_audio_file(item)
|
||||
rg = self.init_replaygain(audiofile, item)
|
||||
|
||||
track_gains = []
|
||||
for item in album.items():
|
||||
for item in items:
|
||||
audiofile = self.open_audio_file(item)
|
||||
rg_track_gain, rg_track_peak = self._title_gain(rg, audiofile)
|
||||
track_gains.append(
|
||||
|
|
@ -805,7 +809,7 @@ class AudioToolsBackend(Backend):
|
|||
# album values.
|
||||
rg_album_gain, rg_album_peak = rg.album_gain()
|
||||
self._log.debug(u'ReplayGain for album {0}: {1:.2f}, {2:.2f}',
|
||||
album, rg_album_gain, rg_album_peak)
|
||||
items[0].album, rg_album_gain, rg_album_peak)
|
||||
|
||||
return AlbumGain(
|
||||
Gain(gain=rg_album_gain, peak=rg_album_peak),
|
||||
|
|
@ -836,9 +840,11 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
'backend': u'command',
|
||||
'targetlevel': 89,
|
||||
'r128': ['Opus'],
|
||||
'per_disc': False
|
||||
})
|
||||
|
||||
self.overwrite = self.config['overwrite'].get(bool)
|
||||
self.per_disc = self.config['per_disc'].get(bool)
|
||||
backend_name = self.config['backend'].as_str()
|
||||
if backend_name not in self.backends:
|
||||
raise ui.UserError(
|
||||
|
|
@ -895,29 +901,28 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
item.rg_track_gain = track_gain.gain
|
||||
item.rg_track_peak = track_gain.peak
|
||||
item.store()
|
||||
|
||||
self._log.debug(u'applied track gain {0}, peak {1}',
|
||||
self._log.debug(u'applied track gain {0} LU, peak {1} of FS',
|
||||
item.rg_track_gain, item.rg_track_peak)
|
||||
|
||||
def store_album_gain(self, item, album_gain):
|
||||
item.rg_album_gain = album_gain.gain
|
||||
item.rg_album_peak = album_gain.peak
|
||||
item.store()
|
||||
self._log.debug(u'applied album gain {0} LU, peak {1} of FS',
|
||||
item.rg_album_gain, item.rg_album_peak)
|
||||
|
||||
def store_track_r128_gain(self, item, track_gain):
|
||||
item.r128_track_gain = int(round(track_gain.gain * pow(2, 8)))
|
||||
item.r128_track_gain = track_gain.gain
|
||||
item.store()
|
||||
|
||||
self._log.debug(u'applied r128 track gain {0}', item.r128_track_gain)
|
||||
self._log.debug(u'applied r128 track gain {0} LU',
|
||||
item.r128_track_gain)
|
||||
|
||||
def store_album_gain(self, album, album_gain):
|
||||
album.rg_album_gain = album_gain.gain
|
||||
album.rg_album_peak = album_gain.peak
|
||||
album.store()
|
||||
|
||||
self._log.debug(u'applied album gain {0}, peak {1}',
|
||||
album.rg_album_gain, album.rg_album_peak)
|
||||
|
||||
def store_album_r128_gain(self, album, album_gain):
|
||||
album.r128_album_gain = int(round(album_gain.gain * pow(2, 8)))
|
||||
album.store()
|
||||
|
||||
self._log.debug(u'applied r128 album gain {0}', album.r128_album_gain)
|
||||
def store_album_r128_gain(self, item, album_gain):
|
||||
item.r128_album_gain = album_gain.gain
|
||||
item.store()
|
||||
self._log.debug(u'applied r128 album gain {0} LU',
|
||||
item.r128_album_gain)
|
||||
|
||||
def handle_album(self, album, write, force=False):
|
||||
"""Compute album and track replay gain store it in all of the
|
||||
|
|
@ -935,10 +940,10 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
|
||||
if (any([self.should_use_r128(item) for item in album.items()]) and not
|
||||
all(([self.should_use_r128(item) for item in album.items()]))):
|
||||
raise ReplayGainError(
|
||||
u"Mix of ReplayGain and EBU R128 detected"
|
||||
u" for some tracks in album {0}".format(album)
|
||||
)
|
||||
raise ReplayGainError(
|
||||
u"Mix of ReplayGain and EBU R128 detected"
|
||||
u" for some tracks in album {0}".format(album)
|
||||
)
|
||||
|
||||
if any([self.should_use_r128(item) for item in album.items()]):
|
||||
if self.r128_backend_instance == '':
|
||||
|
|
@ -951,24 +956,34 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
store_track_gain = self.store_track_gain
|
||||
store_album_gain = self.store_album_gain
|
||||
|
||||
try:
|
||||
album_gain = backend_instance.compute_album_gain(album)
|
||||
if len(album_gain.track_gains) != len(album.items()):
|
||||
raise ReplayGainError(
|
||||
u"ReplayGain backend failed "
|
||||
u"for some tracks in album {0}".format(album)
|
||||
)
|
||||
discs = dict()
|
||||
if self.per_disc:
|
||||
for item in album.items():
|
||||
if discs.get(item.disc) is None:
|
||||
discs[item.disc] = []
|
||||
discs[item.disc].append(item)
|
||||
else:
|
||||
discs[1] = album.items()
|
||||
|
||||
store_album_gain(album, album_gain.album_gain)
|
||||
for item, track_gain in zip(album.items(), album_gain.track_gains):
|
||||
store_track_gain(item, track_gain)
|
||||
if write:
|
||||
item.try_write()
|
||||
except ReplayGainError as e:
|
||||
self._log.info(u"ReplayGain error: {0}", e)
|
||||
except FatalReplayGainError as e:
|
||||
raise ui.UserError(
|
||||
u"Fatal replay gain error: {0}".format(e))
|
||||
for discnumber, items in discs.items():
|
||||
try:
|
||||
album_gain = backend_instance.compute_album_gain(items)
|
||||
if len(album_gain.track_gains) != len(items):
|
||||
raise ReplayGainError(
|
||||
u"ReplayGain backend failed "
|
||||
u"for some tracks in album {0}".format(album)
|
||||
)
|
||||
|
||||
for item, track_gain in zip(items, album_gain.track_gains):
|
||||
store_track_gain(item, track_gain)
|
||||
store_album_gain(item, album_gain.album_gain)
|
||||
if write:
|
||||
item.try_write()
|
||||
except ReplayGainError as e:
|
||||
self._log.info(u"ReplayGain error: {0}", e)
|
||||
except FatalReplayGainError as e:
|
||||
raise ui.UserError(
|
||||
u"Fatal replay gain error: {0}".format(e))
|
||||
|
||||
def handle_track(self, item, write, force=False):
|
||||
"""Compute track replay gain and store it in the item.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from beets.plugins import BeetsPlugin
|
|||
from beets import ui
|
||||
from beets import util
|
||||
from beets import config
|
||||
from beets import mediafile
|
||||
import mediafile
|
||||
import mutagen
|
||||
|
||||
_MUTAGEN_FORMATS = {
|
||||
|
|
|
|||
|
|
@ -3,59 +3,452 @@
|
|||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import re
|
||||
import json
|
||||
import base64
|
||||
import webbrowser
|
||||
import collections
|
||||
|
||||
import six
|
||||
import unidecode
|
||||
import requests
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import decargs
|
||||
|
||||
from beets import ui
|
||||
from requests.exceptions import HTTPError
|
||||
from beets.plugins import BeetsPlugin
|
||||
import confuse
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
||||
|
||||
|
||||
class SpotifyPlugin(BeetsPlugin):
|
||||
|
||||
# URL for the Web API of Spotify
|
||||
# Documentation here: https://developer.spotify.com/web-api/search-item/
|
||||
base_url = "https://api.spotify.com/v1/search"
|
||||
open_url = "http://open.spotify.com/track/"
|
||||
playlist_partial = "spotify:trackset:Playlist:"
|
||||
# Base URLs for the Spotify API
|
||||
# Documentation: https://developer.spotify.com/web-api
|
||||
oauth_token_url = 'https://accounts.spotify.com/api/token'
|
||||
open_track_url = 'https://open.spotify.com/track/'
|
||||
search_url = 'https://api.spotify.com/v1/search'
|
||||
album_url = 'https://api.spotify.com/v1/albums/'
|
||||
track_url = 'https://api.spotify.com/v1/tracks/'
|
||||
playlist_partial = 'spotify:trackset:Playlist:'
|
||||
|
||||
def __init__(self):
|
||||
super(SpotifyPlugin, self).__init__()
|
||||
self.config.add({
|
||||
'mode': 'list',
|
||||
'tiebreak': 'popularity',
|
||||
'show_failures': False,
|
||||
'artist_field': 'albumartist',
|
||||
'album_field': 'album',
|
||||
'track_field': 'title',
|
||||
'region_filter': None,
|
||||
'regex': []
|
||||
})
|
||||
self.config.add(
|
||||
{
|
||||
'mode': 'list',
|
||||
'tiebreak': 'popularity',
|
||||
'show_failures': False,
|
||||
'artist_field': 'albumartist',
|
||||
'album_field': 'album',
|
||||
'track_field': 'title',
|
||||
'region_filter': None,
|
||||
'regex': [],
|
||||
'client_id': '4e414367a1d14c75a5c5129a627fcab8',
|
||||
'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc',
|
||||
'tokenfile': 'spotify_token.json',
|
||||
'source_weight': 0.5,
|
||||
}
|
||||
)
|
||||
self.config['client_secret'].redact = True
|
||||
|
||||
self.tokenfile = self.config['tokenfile'].get(
|
||||
confuse.Filename(in_app_dir=True)
|
||||
) # Path to the JSON file for storing the OAuth access token.
|
||||
self.setup()
|
||||
|
||||
def setup(self):
|
||||
"""Retrieve previously saved OAuth token or generate a new one."""
|
||||
try:
|
||||
with open(self.tokenfile) as f:
|
||||
token_data = json.load(f)
|
||||
except IOError:
|
||||
self._authenticate()
|
||||
else:
|
||||
self.access_token = token_data['access_token']
|
||||
|
||||
def _authenticate(self):
|
||||
"""Request an access token via the Client Credentials Flow:
|
||||
https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
|
||||
"""
|
||||
headers = {
|
||||
'Authorization': 'Basic {}'.format(
|
||||
base64.b64encode(
|
||||
':'.join(
|
||||
self.config[k].as_str()
|
||||
for k in ('client_id', 'client_secret')
|
||||
).encode()
|
||||
).decode()
|
||||
)
|
||||
}
|
||||
response = requests.post(
|
||||
self.oauth_token_url,
|
||||
data={'grant_type': 'client_credentials'},
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise ui.UserError(
|
||||
u'Spotify authorization failed: {}\n{}'.format(
|
||||
e, response.text
|
||||
)
|
||||
)
|
||||
self.access_token = response.json()['access_token']
|
||||
|
||||
# Save the token for later use.
|
||||
self._log.debug(u'Spotify access token: {}', self.access_token)
|
||||
with open(self.tokenfile, 'w') as f:
|
||||
json.dump({'access_token': self.access_token}, f)
|
||||
|
||||
def _handle_response(self, request_type, url, params=None):
|
||||
"""Send a request, reauthenticating if necessary.
|
||||
|
||||
:param request_type: Type of :class:`Request` constructor,
|
||||
e.g. ``requests.get``, ``requests.post``, etc.
|
||||
:type request_type: function
|
||||
:param url: URL for the new :class:`Request` object.
|
||||
:type url: str
|
||||
:param params: (optional) list of tuples or bytes to send
|
||||
in the query string for the :class:`Request`.
|
||||
:type params: dict
|
||||
:return: JSON data for the class:`Response <Response>` object.
|
||||
:rtype: dict
|
||||
"""
|
||||
response = request_type(
|
||||
url,
|
||||
headers={'Authorization': 'Bearer {}'.format(self.access_token)},
|
||||
params=params,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
if u'token expired' in response.text:
|
||||
self._log.debug(
|
||||
'Spotify access token has expired. Reauthenticating.'
|
||||
)
|
||||
self._authenticate()
|
||||
return self._handle_response(request_type, url, params=params)
|
||||
else:
|
||||
raise ui.UserError(u'Spotify API error:\n{}', response.text)
|
||||
return response.json()
|
||||
|
||||
def _get_spotify_id(self, url_type, id_):
|
||||
"""Parse a Spotify ID from its URL if necessary.
|
||||
|
||||
:param url_type: Type of Spotify URL, either 'album' or 'track'.
|
||||
:type url_type: str
|
||||
:param id_: Spotify ID or URL.
|
||||
:type id_: str
|
||||
:return: Spotify ID.
|
||||
:rtype: str
|
||||
"""
|
||||
# Spotify IDs consist of 22 alphanumeric characters
|
||||
# (zero-left-padded base62 representation of randomly generated UUID4)
|
||||
id_regex = r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})'
|
||||
self._log.debug(u'Searching for {} {}', url_type, id_)
|
||||
match = re.search(id_regex.format(url_type), id_)
|
||||
return match.group(2) if match else None
|
||||
|
||||
def album_for_id(self, album_id):
|
||||
"""Fetch an album by its Spotify ID or URL and return an
|
||||
AlbumInfo object or None if the album is not found.
|
||||
|
||||
:param album_id: Spotify ID or URL for the album
|
||||
:type album_id: str
|
||||
:return: AlbumInfo object for album
|
||||
:rtype: beets.autotag.hooks.AlbumInfo or None
|
||||
"""
|
||||
spotify_id = self._get_spotify_id('album', album_id)
|
||||
if spotify_id is None:
|
||||
return None
|
||||
|
||||
response_data = self._handle_response(
|
||||
requests.get, self.album_url + spotify_id
|
||||
)
|
||||
artist, artist_id = self._get_artist(response_data['artists'])
|
||||
|
||||
date_parts = [
|
||||
int(part) for part in response_data['release_date'].split('-')
|
||||
]
|
||||
|
||||
release_date_precision = response_data['release_date_precision']
|
||||
if release_date_precision == 'day':
|
||||
year, month, day = date_parts
|
||||
elif release_date_precision == 'month':
|
||||
year, month = date_parts
|
||||
day = None
|
||||
elif release_date_precision == 'year':
|
||||
year = date_parts
|
||||
month = None
|
||||
day = None
|
||||
else:
|
||||
raise ui.UserError(
|
||||
u"Invalid `release_date_precision` returned "
|
||||
u"by Spotify API: '{}'".format(release_date_precision)
|
||||
)
|
||||
|
||||
tracks = []
|
||||
medium_totals = collections.defaultdict(int)
|
||||
for i, track_data in enumerate(response_data['tracks']['items']):
|
||||
track = self._get_track(track_data)
|
||||
track.index = i + 1
|
||||
medium_totals[track.medium] += 1
|
||||
tracks.append(track)
|
||||
for track in tracks:
|
||||
track.medium_total = medium_totals[track.medium]
|
||||
|
||||
return AlbumInfo(
|
||||
album=response_data['name'],
|
||||
album_id=spotify_id,
|
||||
artist=artist,
|
||||
artist_id=artist_id,
|
||||
tracks=tracks,
|
||||
albumtype=response_data['album_type'],
|
||||
va=len(response_data['artists']) == 1
|
||||
and artist.lower() == 'various artists',
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
label=response_data['label'],
|
||||
mediums=max(medium_totals.keys()),
|
||||
data_source='Spotify',
|
||||
data_url=response_data['external_urls']['spotify'],
|
||||
)
|
||||
|
||||
def _get_track(self, track_data):
|
||||
"""Convert a Spotify track object dict to a TrackInfo object.
|
||||
|
||||
:param track_data: Simplified track object
|
||||
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
|
||||
:type track_data: dict
|
||||
:return: TrackInfo object for track
|
||||
:rtype: beets.autotag.hooks.TrackInfo
|
||||
"""
|
||||
artist, artist_id = self._get_artist(track_data['artists'])
|
||||
return TrackInfo(
|
||||
title=track_data['name'],
|
||||
track_id=track_data['id'],
|
||||
artist=artist,
|
||||
artist_id=artist_id,
|
||||
length=track_data['duration_ms'] / 1000,
|
||||
index=track_data['track_number'],
|
||||
medium=track_data['disc_number'],
|
||||
medium_index=track_data['track_number'],
|
||||
data_source='Spotify',
|
||||
data_url=track_data['external_urls']['spotify'],
|
||||
)
|
||||
|
||||
def track_for_id(self, track_id=None, track_data=None):
|
||||
"""Fetch a track by its Spotify ID or URL and return a
|
||||
TrackInfo object or None if the track is not found.
|
||||
|
||||
:param track_id: (Optional) Spotify ID or URL for the track. Either
|
||||
``track_id`` or ``track_data`` must be provided.
|
||||
:type track_id: str
|
||||
:param track_data: (Optional) Simplified track object dict. May be
|
||||
provided instead of ``track_id`` to avoid unnecessary API calls.
|
||||
:type track_data: dict
|
||||
:return: TrackInfo object for track
|
||||
:rtype: beets.autotag.hooks.TrackInfo or None
|
||||
"""
|
||||
if track_data is None:
|
||||
spotify_id = self._get_spotify_id('track', track_id)
|
||||
if spotify_id is None:
|
||||
return None
|
||||
track_data = self._handle_response(
|
||||
requests.get, self.track_url + spotify_id
|
||||
)
|
||||
track = self._get_track(track_data)
|
||||
|
||||
# Get album's tracks to set `track.index` (position on the entire
|
||||
# release) and `track.medium_total` (total number of tracks on
|
||||
# the track's disc).
|
||||
album_data = self._handle_response(
|
||||
requests.get, self.album_url + track_data['album']['id']
|
||||
)
|
||||
medium_total = 0
|
||||
for i, track_data in enumerate(album_data['tracks']['items']):
|
||||
if track_data['disc_number'] == track.medium:
|
||||
medium_total += 1
|
||||
if track_data['id'] == track.track_id:
|
||||
track.index = i + 1
|
||||
track.medium_total = medium_total
|
||||
return track
|
||||
|
||||
@staticmethod
|
||||
def _get_artist(artists):
|
||||
"""Returns an artist string (all artists) and an artist_id (the main
|
||||
artist) for a list of Spotify artist object dicts.
|
||||
|
||||
:param artists: Iterable of simplified Spotify artist objects
|
||||
(https://developer.spotify.com/documentation/web-api/reference/object-model/#artist-object-simplified)
|
||||
:type artists: list[dict]
|
||||
:return: Normalized artist string
|
||||
:rtype: str
|
||||
"""
|
||||
artist_id = None
|
||||
artist_names = []
|
||||
for artist in artists:
|
||||
if not artist_id:
|
||||
artist_id = artist['id']
|
||||
name = artist['name']
|
||||
# Move articles to the front.
|
||||
name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
|
||||
artist_names.append(name)
|
||||
artist = ', '.join(artist_names).replace(' ,', ',') or None
|
||||
return artist, artist_id
|
||||
|
||||
def album_distance(self, items, album_info, mapping):
|
||||
"""Returns the Spotify source weight and the maximum source weight
|
||||
for albums.
|
||||
"""
|
||||
dist = Distance()
|
||||
if album_info.data_source == 'Spotify':
|
||||
dist.add('source', self.config['source_weight'].as_number())
|
||||
return dist
|
||||
|
||||
def track_distance(self, item, track_info):
|
||||
"""Returns the Spotify source weight and the maximum source weight
|
||||
for individual tracks.
|
||||
"""
|
||||
dist = Distance()
|
||||
if track_info.data_source == 'Spotify':
|
||||
dist.add('source', self.config['source_weight'].as_number())
|
||||
return dist
|
||||
|
||||
def candidates(self, items, artist, album, va_likely):
|
||||
"""Returns a list of AlbumInfo objects for Spotify Search API results
|
||||
matching an ``album`` and ``artist`` (if not various).
|
||||
|
||||
:param items: List of items comprised by an album to be matched.
|
||||
:type items: list[beets.library.Item]
|
||||
:param artist: The artist of the album to be matched.
|
||||
:type artist: str
|
||||
:param album: The name of the album to be matched.
|
||||
:type album: str
|
||||
:param va_likely: True if the album to be matched likely has
|
||||
Various Artists.
|
||||
:type va_likely: bool
|
||||
:return: Candidate AlbumInfo objects.
|
||||
:rtype: list[beets.autotag.hooks.AlbumInfo]
|
||||
"""
|
||||
query_filters = {'album': album}
|
||||
if not va_likely:
|
||||
query_filters['artist'] = artist
|
||||
response_data = self._search_spotify(
|
||||
query_type='album', filters=query_filters
|
||||
)
|
||||
if response_data is None:
|
||||
return []
|
||||
return [
|
||||
self.album_for_id(album_id=album_data['id'])
|
||||
for album_data in response_data['albums']['items']
|
||||
]
|
||||
|
||||
def item_candidates(self, item, artist, title):
|
||||
"""Returns a list of TrackInfo objects for Spotify Search API results
|
||||
matching ``title`` and ``artist``.
|
||||
|
||||
:param item: Singleton item to be matched.
|
||||
:type item: beets.library.Item
|
||||
:param artist: The artist of the track to be matched.
|
||||
:type artist: str
|
||||
:param title: The title of the track to be matched.
|
||||
:type title: str
|
||||
:return: Candidate TrackInfo objects.
|
||||
:rtype: list[beets.autotag.hooks.TrackInfo]
|
||||
"""
|
||||
response_data = self._search_spotify(
|
||||
query_type='track', keywords=title, filters={'artist': artist}
|
||||
)
|
||||
if response_data is None:
|
||||
return []
|
||||
return [
|
||||
self.track_for_id(track_data=track_data)
|
||||
for track_data in response_data['tracks']['items']
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _construct_search_query(filters=None, keywords=''):
|
||||
"""Construct a query string with the specified filters and keywords to
|
||||
be provided to the Spotify Search API
|
||||
(https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines).
|
||||
|
||||
:param filters: (Optional) Field filters to apply.
|
||||
:type filters: dict
|
||||
:param keywords: (Optional) Query keywords to use.
|
||||
:type keywords: str
|
||||
:return: Query string to be provided to the Search API.
|
||||
:rtype: str
|
||||
"""
|
||||
query_components = [
|
||||
keywords,
|
||||
' '.join(':'.join((k, v)) for k, v in filters.items()),
|
||||
]
|
||||
query = ' '.join([q for q in query_components if q])
|
||||
if not isinstance(query, six.text_type):
|
||||
query = query.decode('utf8')
|
||||
return unidecode.unidecode(query)
|
||||
|
||||
def _search_spotify(self, query_type, filters=None, keywords=''):
|
||||
"""Query the Spotify Search API for the specified ``keywords``, applying
|
||||
the provided ``filters``.
|
||||
|
||||
:param query_type: A comma-separated list of item types to search
|
||||
across. Valid types are: 'album', 'artist', 'playlist', and
|
||||
'track'. Search results include hits from all the specified item
|
||||
types.
|
||||
:type query_type: str
|
||||
:param filters: (Optional) Field filters to apply.
|
||||
:type filters: dict
|
||||
:param keywords: (Optional) Query keywords to use.
|
||||
:type keywords: str
|
||||
:return: JSON data for the class:`Response <Response>` object or None
|
||||
if no search results are returned.
|
||||
:rtype: dict or None
|
||||
"""
|
||||
query = self._construct_search_query(
|
||||
keywords=keywords, filters=filters
|
||||
)
|
||||
if not query:
|
||||
return None
|
||||
self._log.debug(u"Searching Spotify for '{}'".format(query))
|
||||
response_data = self._handle_response(
|
||||
requests.get,
|
||||
self.search_url,
|
||||
params={'q': query, 'type': query_type},
|
||||
)
|
||||
num_results = 0
|
||||
for result_type_data in response_data.values():
|
||||
num_results += len(result_type_data['items'])
|
||||
self._log.debug(
|
||||
u"Found {} results from Spotify for '{}'", num_results, query
|
||||
)
|
||||
return response_data if num_results > 0 else None
|
||||
|
||||
def commands(self):
|
||||
def queries(lib, opts, args):
|
||||
success = self.parse_opts(opts)
|
||||
success = self._parse_opts(opts)
|
||||
if success:
|
||||
results = self.query_spotify(lib, decargs(args))
|
||||
self.output_results(results)
|
||||
results = self._match_library_tracks(lib, ui.decargs(args))
|
||||
self._output_match_results(results)
|
||||
|
||||
spotify_cmd = ui.Subcommand(
|
||||
'spotify',
|
||||
help=u'build a Spotify playlist'
|
||||
'spotify', help=u'build a Spotify playlist'
|
||||
)
|
||||
spotify_cmd.parser.add_option(
|
||||
u'-m', u'--mode', action='store',
|
||||
u'-m',
|
||||
u'--mode',
|
||||
action='store',
|
||||
help=u'"open" to open Spotify with playlist, '
|
||||
u'"list" to print (default)'
|
||||
u'"list" to print (default)',
|
||||
)
|
||||
spotify_cmd.parser.add_option(
|
||||
u'-f', u'--show-failures',
|
||||
action='store_true', dest='show_failures',
|
||||
help=u'list tracks that did not match a Spotify ID'
|
||||
u'-f',
|
||||
u'--show-failures',
|
||||
action='store_true',
|
||||
dest='show_failures',
|
||||
help=u'list tracks that did not match a Spotify ID',
|
||||
)
|
||||
spotify_cmd.func = queries
|
||||
return [spotify_cmd]
|
||||
|
||||
def parse_opts(self, opts):
|
||||
def _parse_opts(self, opts):
|
||||
if opts.mode:
|
||||
self.config['mode'].set(opts.mode)
|
||||
|
||||
|
|
@ -63,35 +456,46 @@ class SpotifyPlugin(BeetsPlugin):
|
|||
self.config['show_failures'].set(True)
|
||||
|
||||
if self.config['mode'].get() not in ['list', 'open']:
|
||||
self._log.warning(u'{0} is not a valid mode',
|
||||
self.config['mode'].get())
|
||||
self._log.warning(
|
||||
u'{0} is not a valid mode', self.config['mode'].get()
|
||||
)
|
||||
return False
|
||||
|
||||
self.opts = opts
|
||||
return True
|
||||
|
||||
def query_spotify(self, lib, query):
|
||||
def _match_library_tracks(self, library, keywords):
|
||||
"""Get a list of simplified track object dicts for library tracks
|
||||
matching the specified ``keywords``.
|
||||
|
||||
:param library: beets library object to query.
|
||||
:type library: beets.library.Library
|
||||
:param keywords: Query to match library items against.
|
||||
:type keywords: str
|
||||
:return: List of simplified track object dicts for library items
|
||||
matching the specified query.
|
||||
:rtype: list[dict]
|
||||
"""
|
||||
results = []
|
||||
failures = []
|
||||
|
||||
items = lib.items(query)
|
||||
items = library.items(keywords)
|
||||
|
||||
if not items:
|
||||
self._log.debug(u'Your beets query returned no items, '
|
||||
u'skipping spotify')
|
||||
self._log.debug(
|
||||
u'Your beets query returned no items, skipping Spotify.'
|
||||
)
|
||||
return
|
||||
|
||||
self._log.info(u'Processing {0} tracks...', len(items))
|
||||
self._log.info(u'Processing {} tracks...', len(items))
|
||||
|
||||
for item in items:
|
||||
|
||||
# Apply regex transformations if provided
|
||||
for regex in self.config['regex'].get():
|
||||
if (
|
||||
not regex['field'] or
|
||||
not regex['search'] or
|
||||
not regex['replace']
|
||||
not regex['field']
|
||||
or not regex['search']
|
||||
or not regex['replace']
|
||||
):
|
||||
continue
|
||||
|
||||
|
|
@ -103,73 +507,84 @@ class SpotifyPlugin(BeetsPlugin):
|
|||
# Custom values can be passed in the config (just in case)
|
||||
artist = item[self.config['artist_field'].get()]
|
||||
album = item[self.config['album_field'].get()]
|
||||
query = item[self.config['track_field'].get()]
|
||||
search_url = query + " album:" + album + " artist:" + artist
|
||||
keywords = item[self.config['track_field'].get()]
|
||||
|
||||
# Query the Web API for each track, look for the items' JSON data
|
||||
r = requests.get(self.base_url, params={
|
||||
"q": search_url, "type": "track"
|
||||
})
|
||||
self._log.debug('{}', r.url)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
except HTTPError as e:
|
||||
self._log.debug(u'URL returned a {0} error',
|
||||
e.response.status_code)
|
||||
failures.append(search_url)
|
||||
query_filters = {'artist': artist, 'album': album}
|
||||
response_data = self._search_spotify(
|
||||
query_type='track', keywords=keywords, filters=query_filters
|
||||
)
|
||||
if response_data is None:
|
||||
query = self._construct_search_query(
|
||||
keywords=keywords, filters=query_filters
|
||||
)
|
||||
failures.append(query)
|
||||
continue
|
||||
|
||||
r_data = r.json()['tracks']['items']
|
||||
response_data_tracks = response_data['tracks']['items']
|
||||
|
||||
# Apply market filter if requested
|
||||
region_filter = self.config['region_filter'].get()
|
||||
if region_filter:
|
||||
r_data = [x for x in r_data if region_filter
|
||||
in x['available_markets']]
|
||||
response_data_tracks = [
|
||||
track_data
|
||||
for track_data in response_data_tracks
|
||||
if region_filter in track_data['available_markets']
|
||||
]
|
||||
|
||||
# Simplest, take the first result
|
||||
chosen_result = None
|
||||
if len(r_data) == 1 or self.config['tiebreak'].get() == "first":
|
||||
self._log.debug(u'Spotify track(s) found, count: {0}',
|
||||
len(r_data))
|
||||
chosen_result = r_data[0]
|
||||
elif len(r_data) > 1:
|
||||
# Use the popularity filter
|
||||
self._log.debug(u'Most popular track chosen, count: {0}',
|
||||
len(r_data))
|
||||
chosen_result = max(r_data, key=lambda x: x['popularity'])
|
||||
|
||||
if chosen_result:
|
||||
results.append(chosen_result)
|
||||
if (
|
||||
len(response_data_tracks) == 1
|
||||
or self.config['tiebreak'].get() == 'first'
|
||||
):
|
||||
self._log.debug(
|
||||
u'Spotify track(s) found, count: {}',
|
||||
len(response_data_tracks),
|
||||
)
|
||||
chosen_result = response_data_tracks[0]
|
||||
else:
|
||||
self._log.debug(u'No spotify track found: {0}', search_url)
|
||||
failures.append(search_url)
|
||||
# Use the popularity filter
|
||||
self._log.debug(
|
||||
u'Most popular track chosen, count: {}',
|
||||
len(response_data_tracks),
|
||||
)
|
||||
chosen_result = max(
|
||||
response_data_tracks, key=lambda x: x['popularity']
|
||||
)
|
||||
results.append(chosen_result)
|
||||
|
||||
failure_count = len(failures)
|
||||
if failure_count > 0:
|
||||
if self.config['show_failures'].get():
|
||||
self._log.info(u'{0} track(s) did not match a Spotify ID:',
|
||||
failure_count)
|
||||
self._log.info(
|
||||
u'{} track(s) did not match a Spotify ID:', failure_count
|
||||
)
|
||||
for track in failures:
|
||||
self._log.info(u'track: {0}', track)
|
||||
self._log.info(u'track: {}', track)
|
||||
self._log.info(u'')
|
||||
else:
|
||||
self._log.warning(u'{0} track(s) did not match a Spotify ID;\n'
|
||||
u'use --show-failures to display',
|
||||
failure_count)
|
||||
self._log.warning(
|
||||
u'{} track(s) did not match a Spotify ID;\n'
|
||||
u'use --show-failures to display',
|
||||
failure_count,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def output_results(self, results):
|
||||
if results:
|
||||
ids = [x['id'] for x in results]
|
||||
if self.config['mode'].get() == "open":
|
||||
self._log.info(u'Attempting to open Spotify with playlist')
|
||||
spotify_url = self.playlist_partial + ",".join(ids)
|
||||
webbrowser.open(spotify_url)
|
||||
def _output_match_results(self, results):
|
||||
"""Open a playlist or print Spotify URLs for the provided track
|
||||
object dicts.
|
||||
|
||||
:param results: List of simplified track object dicts
|
||||
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
|
||||
:type results: list[dict]
|
||||
"""
|
||||
if results:
|
||||
spotify_ids = [track_data['id'] for track_data in results]
|
||||
if self.config['mode'].get() == 'open':
|
||||
self._log.info(u'Attempting to open Spotify with playlist')
|
||||
spotify_url = self.playlist_partial + ",".join(spotify_ids)
|
||||
webbrowser.open(spotify_url)
|
||||
else:
|
||||
for item in ids:
|
||||
print(self.open_url + item)
|
||||
for spotify_id in spotify_ids:
|
||||
print(self.open_track_url + spotify_id)
|
||||
else:
|
||||
self._log.warning(u'No Spotify tracks found from beets query')
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class SubsonicUpdate(BeetsPlugin):
|
|||
'v': '1.15.0', # Subsonic 6.1 and newer.
|
||||
'c': 'beets'
|
||||
}
|
||||
if contextpath is '/':
|
||||
if contextpath == '/':
|
||||
contextpath = ''
|
||||
url = "http://{}:{}{}/rest/startScan".format(host, port, contextpath)
|
||||
response = requests.post(url, params=payload)
|
||||
|
|
|
|||
|
|
@ -93,8 +93,8 @@ class ThePlugin(BeetsPlugin):
|
|||
for p in self.patterns:
|
||||
r = self.unthe(text, p)
|
||||
if r != text:
|
||||
self._log.debug(u'\"{0}\" -> \"{1}\"', text, r)
|
||||
break
|
||||
self._log.debug(u'\"{0}\" -> \"{1}\"', text, r)
|
||||
return r
|
||||
else:
|
||||
return u''
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ class ThumbnailsPlugin(BeetsPlugin):
|
|||
|
||||
def thumbnail_file_name(self, path):
|
||||
"""Compute the thumbnail file name
|
||||
See http://standards.freedesktop.org/thumbnail-spec/latest/x227.html
|
||||
See https://standards.freedesktop.org/thumbnail-spec/latest/x227.html
|
||||
"""
|
||||
uri = self.get_uri(path)
|
||||
hash = md5(uri.encode('utf-8')).hexdigest()
|
||||
|
|
@ -168,7 +168,7 @@ class ThumbnailsPlugin(BeetsPlugin):
|
|||
|
||||
def add_tags(self, album, image_path):
|
||||
"""Write required metadata to the thumbnail
|
||||
See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html
|
||||
See https://standards.freedesktop.org/thumbnail-spec/latest/x142.html
|
||||
"""
|
||||
mtime = os.stat(album.artpath).st_mtime
|
||||
metadata = {"Thumb::URI": self.get_uri(album.artpath),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from __future__ import division, absolute_import, print_function
|
|||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.dbcore import types
|
||||
from beets.util.confit import ConfigValueError
|
||||
from confuse import ConfigValueError
|
||||
from beets import library
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ $.fn.player = function(debug) {
|
|||
|
||||
// Simple selection disable for jQuery.
|
||||
// Cut-and-paste from:
|
||||
// http://stackoverflow.com/questions/2700000
|
||||
// https://stackoverflow.com/questions/2700000
|
||||
$.fn.disableSelection = function() {
|
||||
$(this).attr('unselectable', 'on')
|
||||
.css('-moz-user-select', 'none')
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ import six
|
|||
import re
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.mediafile import MediaFile
|
||||
from mediafile import MediaFile
|
||||
from beets.importer import action
|
||||
from beets.ui import Subcommand, decargs, input_yn
|
||||
from beets.util import confit
|
||||
import confuse
|
||||
|
||||
__author__ = 'baobab@heresiarch.info'
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ class ZeroPlugin(BeetsPlugin):
|
|||
for pattern in self.config[field].as_str_seq():
|
||||
prog = re.compile(pattern, re.IGNORECASE)
|
||||
self.fields_to_progs.setdefault(field, []).append(prog)
|
||||
except confit.NotFoundError:
|
||||
except confuse.NotFoundError:
|
||||
# Matches everything
|
||||
self.fields_to_progs[field] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,44 +1,139 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
1.4.8 (in development)
|
||||
1.5.0 (in development)
|
||||
----------------------
|
||||
|
||||
New features:
|
||||
|
||||
* The disambiguation string for identifying albums in the importer now shows
|
||||
the catalog number.
|
||||
Thanks to :user:`8h2a`.
|
||||
:bug:`2951`
|
||||
* :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some
|
||||
issues with foobar2000 and Winamp.
|
||||
Thanks to :user:`mz2212`.
|
||||
:bug:`2944`
|
||||
* Added whitespace padding to missing tracks dialog to improve readability.
|
||||
Thanks to :user:`jams2`.
|
||||
:bug:`2962`
|
||||
* :bug:`/plugins/gmusic`: Add a new option to automatically upload to Google
|
||||
Play Music library on track import.
|
||||
Thanks to :user:`shuaiscott`.
|
||||
* :doc:`/plugins/gmusic`: Add new options for Google Play Music
|
||||
authentication.
|
||||
Thanks to :user:`thetarkus`.
|
||||
:bug:`3002`
|
||||
* :doc:`/plugins/absubmit`: Analysis now works in parallel (on Python 3 only).
|
||||
Thanks to :user:`bemeurer`.
|
||||
:bug:`2442` :bug:`3003`
|
||||
* :doc:`/plugins/replaygain`: albumpeak on large collections is calculated as
|
||||
the average, not the maximum.
|
||||
:bug:`3008` :bug:`3009`
|
||||
* A new :doc:`/plugins/subsonicupdate` can automatically update your Subsonic library.
|
||||
Thanks to :user:`maffo999`.
|
||||
:bug:`3001`
|
||||
* :doc:`/plugins/chroma`: Now optionally has a bias toward looking up more
|
||||
relevant releases according to the :ref:`preferred` configuration options.
|
||||
Thanks to :user:`archer4499`.
|
||||
:bug:`3017`
|
||||
* A new ``aunique`` configuration option allows setting default options
|
||||
for the :ref:`aunique` template function.
|
||||
* We now fetch information about `works`_ from MusicBrainz.
|
||||
MusicBrainz matches provide the fields ``work`` (the title), ``mb_workid``
|
||||
(the MBID), and ``work_disambig`` (the disambiguation string).
|
||||
Thanks to :user:`dosoe`.
|
||||
:bug:`2580` :bug:`3272`
|
||||
* :doc:`/plugins/bpd`: BPD now supports most of the features of version 0.16
|
||||
of the MPD protocol. This is enough to get it talking to more complicated
|
||||
clients like ncmpcpp, but there are still some incompatibilities, largely due
|
||||
to MPD commands we don't support yet. Let us know if you find an MPD client
|
||||
that doesn't get along with BPD!
|
||||
:bug:`3214` :bug:`800`
|
||||
* :doc:`/plugins/replaygain`: The plugin now supports a ``per_disc`` option
|
||||
which enables calculation of album ReplayGain on disc level instead of album
|
||||
level.
|
||||
Thanks to :user:`samuelnilsson`
|
||||
:bug:`293`
|
||||
* A new :doc:`/plugins/parentwork` gets information about the original work,
|
||||
which is useful for classical music.
|
||||
Thanks to :user:`dosoe`.
|
||||
:bug:`2580` :bug:`3279`
|
||||
* :doc:`/plugins/discogs`: The field now collects the "style" field.
|
||||
Thanks to :user:`thedevilisinthedetails`.
|
||||
:bug:`2579` :bug:`3251`
|
||||
|
||||
Fixes:
|
||||
|
||||
* :doc:`/plugins/inline`: In function-style field definitions that refer to
|
||||
flexible attributes, values could stick around from one function invocation
|
||||
to the next. This meant that, when displaying a list of objects, later
|
||||
objects could seem to reuse values from earlier objects when they were
|
||||
missing a value for a given field. These values are now properly undefined.
|
||||
:bug:`2406`
|
||||
* :doc:`/plugins/bpd`: Seeking by fractions of a second now works as intended,
|
||||
fixing crashes in MPD clients like mpDris2 on seek.
|
||||
The ``playlistid`` command now works properly in its zero-argument form.
|
||||
:bug:`3214`
|
||||
* :doc:`/plugins/replaygain`: Fix a Python 3 incompatibility in the Python
|
||||
Audio Tools backend.
|
||||
:bug:`3305`
|
||||
* :doc:`/plugins/importadded`: Fixed a crash that occurred when the
|
||||
``after_write`` signal was emitted.
|
||||
:bug:`3301`
|
||||
* :doc:`plugins/replaygain`: Fix the storage format in R128 gain tags.
|
||||
:bug:`3311` :bug:`3314`
|
||||
|
||||
For plugin developers:
|
||||
|
||||
* `MediaFile`_ has been split into a standalone project. Where you used to do
|
||||
``from beets import mediafile``, now just do ``import mediafile``. Beets
|
||||
re-exports MediaFile at the old location for backwards-compatibility, but a
|
||||
deprecation warning is raised if you do this since we might drop this wrapper
|
||||
in a future release.
|
||||
* We've replaced beets' configuration library confit with a standalone
|
||||
version called `Confuse`_. Where you used to do
|
||||
``from beets.util import confit``, now just do ``import confuse``. The code
|
||||
is almost identical apart from the name change. Again, we'll re-export at the
|
||||
old location (with a deprecation warning) for backwards compatibility, but
|
||||
might stop doing this in a future release.
|
||||
|
||||
For packagers:
|
||||
|
||||
* Beets' library for manipulating media file metadata has now been split to a
|
||||
standalone project called `MediaFile`_, released as :pypi:`mediafile`. Beets
|
||||
now depends on this new package. Beets now depends on Mutagen transitively
|
||||
through MediaFile rather than directly, except in the case of one of beets'
|
||||
plugins (scrub).
|
||||
* Beets' library for configuration has been split into a standalone project
|
||||
called `Confuse`_, released as :pypi:`confuse`. Beets now depends on this
|
||||
package. Confuse has existed separately for some time and is used by
|
||||
unrelated projects, but until now we've been bundling a copy within beets.
|
||||
* We attempted to fix an unreliable test, so a patch to `skip <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
|
||||
disambiguation comment. A new ``releasegroupdisambig`` field has been added.
|
||||
:bug:`3024`
|
||||
|
|
@ -46,27 +141,215 @@ New features:
|
|||
example, ``beet modify -a artist:beatles artpath!`` resets ``artpath``
|
||||
attribute from matching albums back to the default value.
|
||||
:bug:`2497`
|
||||
|
||||
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`.
|
||||
|
||||
.. _Groups: https://groups.google.com/forum/#!searchin/beets-users/mbsync|sort:date/beets-users/iwCF6bNdh9A/i1xl4Gx8BQAJ
|
||||
|
||||
Fixes:
|
||||
|
||||
* A new importer option, :ref:`ignore_data_tracks`, lets you skip audio tracks
|
||||
contained in data files :bug:`3021`
|
||||
contained in data files. :bug:`3021`
|
||||
|
||||
There are some new plugins:
|
||||
|
||||
* The :doc:`/plugins/playlist` can query the beets library using M3U playlists.
|
||||
Thanks to :user:`Holzhaus` and :user:`Xenopathic`.
|
||||
:bug:`123` :bug:`3145`
|
||||
* The :doc:`/plugins/loadext` allows loading of SQLite extensions, primarily
|
||||
for use with the ICU SQLite extension for internationalization.
|
||||
:bug:`3160` :bug:`3226`
|
||||
* The :doc:`/plugins/subsonicupdate` can automatically update your Subsonic
|
||||
library.
|
||||
Thanks to :user:`maffo999`.
|
||||
:bug:`3001`
|
||||
|
||||
And many improvements to existing plugins:
|
||||
|
||||
* :doc:`/plugins/lastgenre`: Added option ``-A`` to match individual tracks
|
||||
and singletons.
|
||||
:bug:`3220` :bug:`3219`
|
||||
* :doc:`/plugins/play`: The plugin can now emit a UTF-8 BOM, fixing some
|
||||
issues with foobar2000 and Winamp.
|
||||
Thanks to :user:`mz2212`.
|
||||
:bug:`2944`
|
||||
* :doc:`/plugins/gmusic`:
|
||||
* Add a new option to automatically upload to Google Play Music library on
|
||||
track import.
|
||||
Thanks to :user:`shuaiscott`.
|
||||
* Add new options for Google Play Music authentication.
|
||||
Thanks to :user:`thetarkus`.
|
||||
:bug:`3002`
|
||||
* :doc:`/plugins/replaygain`: ``albumpeak`` on large collections is calculated
|
||||
as the average, not the maximum.
|
||||
:bug:`3008` :bug:`3009`
|
||||
* :doc:`/plugins/chroma`:
|
||||
* Now optionally has a bias toward looking up more relevant releases
|
||||
according to the :ref:`preferred` configuration options.
|
||||
Thanks to :user:`archer4499`.
|
||||
:bug:`3017`
|
||||
* Fingerprint values are now properly stored as strings, which prevents
|
||||
strange repeated output when running ``beet write``.
|
||||
Thanks to :user:`Holzhaus`.
|
||||
:bug:`3097` :bug:`2942`
|
||||
* :doc:`/plugins/convert`: The plugin now has an ``id3v23`` option that allows
|
||||
you to override the global ``id3v23`` option.
|
||||
Thanks to :user:`Holzhaus`.
|
||||
:bug:`3104`
|
||||
* :doc:`/plugins/spotify`:
|
||||
* The plugin now uses OAuth for authentication to the Spotify API.
|
||||
Thanks to :user:`rhlahuja`.
|
||||
:bug:`2694` :bug:`3123`
|
||||
* The plugin now works as an import metadata
|
||||
provider: you can match tracks and albums using the Spotify database.
|
||||
Thanks to :user:`rhlahuja`.
|
||||
:bug:`3123`
|
||||
* :doc:`/plugins/ipfs`: The plugin now supports a ``nocopy`` option which
|
||||
passes that flag to ipfs.
|
||||
Thanks to :user:`wildthyme`.
|
||||
* :doc:`/plugins/discogs`: The plugin now has rate limiting for the Discogs API.
|
||||
:bug:`3081`
|
||||
* :doc:`/plugins/mpdstats`, :doc:`/plugins/mpdupdate`: These plugins now use
|
||||
the ``MPD_PORT`` environment variable if no port is specified in the
|
||||
configuration file.
|
||||
:bug:`3223`
|
||||
* :doc:`/plugins/bpd`:
|
||||
* MPD protocol commands ``consume`` and ``single`` are now supported along
|
||||
with updated semantics for ``repeat`` and ``previous`` and new fields for
|
||||
``status``. The bpd server now understands and ignores some additional
|
||||
commands.
|
||||
:bug:`3200` :bug:`800`
|
||||
* MPD protocol command ``idle`` is now supported, allowing the MPD version
|
||||
to be bumped to 0.14.
|
||||
:bug:`3205` :bug:`800`
|
||||
* MPD protocol command ``decoders`` is now supported.
|
||||
:bug:`3222`
|
||||
* The plugin now uses the main beets logging system.
|
||||
The special-purpose ``--debug`` flag has been removed.
|
||||
Thanks to :user:`arcresu`.
|
||||
:bug:`3196`
|
||||
* :doc:`/plugins/mbsync`: The plugin no longer queries MusicBrainz when either
|
||||
the ``mb_albumid`` or ``mb_trackid`` field is invalid.
|
||||
See also the discussion on `Google Groups`_
|
||||
Thanks to :user:`arogl`.
|
||||
* :doc:`/plugins/export`: The plugin now also exports ``path`` field if the user
|
||||
explicitly specifies it with ``-i`` parameter. This only works when exporting
|
||||
library fields.
|
||||
:bug:`3084`
|
||||
* :doc:`/plugins/acousticbrainz`: The plugin now declares types for all its
|
||||
fields, which enables easier querying and avoids a problem where very small
|
||||
numbers would be stored as strings.
|
||||
Thanks to :user:`rain0r`.
|
||||
:bug:`2790` :bug:`3238`
|
||||
|
||||
.. _Google Groups: https://groups.google.com/forum/#!searchin/beets-users/mbsync|sort:date/beets-users/iwCF6bNdh9A/i1xl4Gx8BQAJ
|
||||
|
||||
Some improvements have been focused on improving beets' performance:
|
||||
|
||||
* Querying the library is now faster:
|
||||
* We only convert fields that need to be displayed.
|
||||
Thanks to :user:`pprkut`.
|
||||
:bug:`3089`
|
||||
* We now compile templates once and reuse them instead of recompiling them
|
||||
to print out each matching object.
|
||||
Thanks to :user:`SimonPersson`.
|
||||
:bug:`3258`
|
||||
* Querying the library for items is now faster, for all queries that do not
|
||||
need to access album level properties. This was implemented by lazily
|
||||
fetching the album only when needed.
|
||||
Thanks to :user:`SimonPersson`.
|
||||
:bug:`3260`
|
||||
* :doc:`/plugins/absubmit`, :doc:`/plugins/badfiles`: Analysis now works in
|
||||
parallel (on Python 3 only).
|
||||
Thanks to :user:`bemeurer`.
|
||||
:bug:`2442` :bug:`3003`
|
||||
* :doc:`/plugins/mpdstats`: Use the ``currentsong`` MPD command instead of
|
||||
``playlist`` to get the current song, improving performance when the playlist
|
||||
is long.
|
||||
Thanks to :user:`ray66`.
|
||||
:bug:`3207` :bug:`2752`
|
||||
|
||||
Several improvements are related to usability:
|
||||
|
||||
* The disambiguation string for identifying albums in the importer now shows
|
||||
the catalog number.
|
||||
Thanks to :user:`8h2a`.
|
||||
:bug:`2951`
|
||||
* Added whitespace padding to missing tracks dialog to improve readability.
|
||||
Thanks to :user:`jams2`.
|
||||
:bug:`2962`
|
||||
* The :ref:`move-cmd` command now lists the number of items already in-place.
|
||||
Thanks to :user:`RollingStar`.
|
||||
:bug:`3117`
|
||||
* Modify selection can now be applied early without selecting every item.
|
||||
:bug:`3083`
|
||||
* Beets now emits more useful messages during startup if SQLite returns an error. The
|
||||
SQLite error message is now attached to the beets message.
|
||||
:bug:`3005`
|
||||
* Fixed a confusing typo when the :doc:`/plugins/convert` plugin copies the art
|
||||
covers.
|
||||
:bug:`3063`
|
||||
|
||||
Many fixes have been focused on issues where beets would previously crash:
|
||||
|
||||
* Avoid a crash when archive extraction fails during import.
|
||||
:bug:`3041`
|
||||
* Missing album art file during an update no longer causes a fatal exception
|
||||
(instead, an error is logged and the missing file path is removed from the
|
||||
library).
|
||||
:bug:`3030`
|
||||
* When updating the database, beets no longer tries to move album art twice.
|
||||
:bug:`3189`
|
||||
* Fix an unhandled exception when pruning empty directories.
|
||||
:bug:`1996` :bug:`3209`
|
||||
* :doc:`/plugins/fetchart`: Added network connection error handling to backends
|
||||
so that beets won't crash if a request fails.
|
||||
Thanks to :user:`Holzhaus`.
|
||||
:bug:`1579`
|
||||
* :doc:`/plugins/badfiles`: Avoid a crash when the underlying tool emits
|
||||
undecodable output.
|
||||
:bug:`3165`
|
||||
* :doc:`/plugins/beatport`: Avoid a crash when the server produces an error.
|
||||
:bug:`3184`
|
||||
* :doc:`/plugins/bpd`: Fix crashes in the bpd server during exception handling.
|
||||
:bug:`3200`
|
||||
* :doc:`/plugins/bpd`: Fix a crash triggered when certain clients tried to list
|
||||
the albums belonging to a particular artist.
|
||||
:bug:`3007` :bug:`3215`
|
||||
* :doc:`/plugins/replaygain`: Avoid a crash when the ``bs1770gain`` tool emits
|
||||
malformed XML.
|
||||
:bug:`2983` :bug:`3247`
|
||||
|
||||
There are many fixes related to compatibility with our dependencies including
|
||||
addressing changes interfaces:
|
||||
|
||||
* On Python 2, pin the :pypi:`jellyfish` requirement to version 0.6.0 for
|
||||
compatibility.
|
||||
* Fix compatibility with Python 3.7 and its change to a name in the
|
||||
:stdlib:`re` module.
|
||||
:bug:`2978`
|
||||
* Fix several uses of deprecated standard-library features on Python 3.7.
|
||||
Thanks to :user:`arcresu`.
|
||||
:bug:`3197`
|
||||
* Fix compatibility with pre-release versions of Python 3.8.
|
||||
:bug:`3201` :bug:`3202`
|
||||
* :doc:`/plugins/web`: Fix an error when using more recent versions of Flask
|
||||
with CORS enabled.
|
||||
Thanks to :user:`rveachkc`.
|
||||
:bug:`2979`: :bug:`2980`
|
||||
* Avoid some deprecation warnings with certain versions of the MusicBrainz
|
||||
library.
|
||||
Thanks to :user:`zhelezov`.
|
||||
:bug:`2826` :bug:`3092`
|
||||
* Restore iTunes Store album art source, and remove the dependency on
|
||||
python-itunes_, which had gone unmaintained and was not py3 compatible.
|
||||
Thanks to :user:`ocelma` for creating python-itunes_ in the first place.
|
||||
:pypi:`python-itunes`, which had gone unmaintained and was not
|
||||
Python-3-compatible.
|
||||
Thanks to :user:`ocelma` for creating :pypi:`python-itunes` in the first place.
|
||||
Thanks to :user:`nathdwek`.
|
||||
:bug:`2371` :bug:`2551` :bug:`2718`
|
||||
* Fix compatibility Python 3.7 and its change to a name in the ``re`` module.
|
||||
:bug:`2978`
|
||||
* :doc:`/plugins/lastgenre`, :doc:`/plugins/edit`: Avoid a deprecation warnings
|
||||
from the :pypi:`PyYAML` library by switching to the safe loader.
|
||||
Thanks to :user:`translit` and :user:`sbraz`.
|
||||
:bug:`3192` :bug:`3225`
|
||||
* Fix a problem when resizing images with :pypi:`PIL`/:pypi:`pillow` on Python 3.
|
||||
Thanks to :user:`architek`.
|
||||
:bug:`2504` :bug:`3029`
|
||||
|
||||
And there are many other fixes:
|
||||
|
||||
* R128 normalization tags are now properly deleted from files when the values
|
||||
are missing.
|
||||
Thanks to :user:`autrimpo`.
|
||||
|
|
@ -77,35 +360,49 @@ Fixes:
|
|||
* With the :ref:`from_scratch` configuration option set, only writable fields
|
||||
are cleared. Beets now no longer ignores the format your music is saved in.
|
||||
:bug:`2972`
|
||||
* LastGenre: Allow to set the configuration option ``prefer_specific``
|
||||
without setting ``canonical``.
|
||||
:bug:`2973`
|
||||
* :doc:`/plugins/web`: Fix an error when using more recent versions of Flask
|
||||
with CORS enabled.
|
||||
Thanks to :user:`rveachkc`.
|
||||
:bug:`2979`: :bug:`2980`
|
||||
* Improve error reporting: during startup if sqlite returns an error the
|
||||
sqlite error message is attached to the beets message.
|
||||
:bug:`3005`
|
||||
* Fix a problem when resizing images with PIL/Pillow on Python 3.
|
||||
Thanks to :user:`architek`.
|
||||
:bug:`2504` :bug:`3029`
|
||||
* Avoid a crash when archive extraction fails during import.
|
||||
:bug:`3041`
|
||||
* The ``%aunique`` template function now works correctly with the
|
||||
``-f/--format`` option.
|
||||
:bug:`3043`
|
||||
* Missing album art file during an update no longer causes a fatal exception
|
||||
(instead, an error is logged and the missing file path is removed from the
|
||||
library). :bug:`3030`
|
||||
* Fixed the ordering of items when manually selecting changes while updating
|
||||
tags
|
||||
Thanks to :user:`TaizoSimpson`.
|
||||
:bug:`3501`
|
||||
* Confusing typo when the convert plugin copies the art covers. :bug:`3063`
|
||||
* The ``%title`` template function now works correctly with apostrophes.
|
||||
Thanks to :user:`GuilhermeHideki`.
|
||||
:bug:`3033`
|
||||
* :doc:`/plugins/lastgenre`: It's now possible to set the ``prefer_specific``
|
||||
option without also setting ``canonical``.
|
||||
:bug:`2973`
|
||||
* :doc:`/plugins/fetchart`: The plugin now respects the ``ignore`` and
|
||||
``ignore_hidden`` settings.
|
||||
:bug:`1632`
|
||||
* :doc:`/plugins/hook`: Fix byte string interpolation in hook commands.
|
||||
:bug:`2967` :bug:`3167`
|
||||
* :doc:`/plugins/the`: Log a message when something has changed, not when it
|
||||
hasn't.
|
||||
Thanks to :user:`arcresu`.
|
||||
:bug:`3195`
|
||||
* :doc:`/plugins/lastgenre`: The ``force`` config option now actually works.
|
||||
:bug:`2704` :bug:`3054`
|
||||
* Resizing image files with ImageMagick now avoids problems on systems where
|
||||
there is a ``convert`` command that is *not* ImageMagick's by using the
|
||||
``magick`` executable when it is available.
|
||||
Thanks to :user:`ababyduck`.
|
||||
:bug:`2093` :bug:`3236`
|
||||
|
||||
There is one new thing for plugin developers to know about:
|
||||
|
||||
.. _python-itunes: https://github.com/ocelma/python-itunes
|
||||
* 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)
|
||||
|
|
@ -939,7 +1236,7 @@ There are even more new features:
|
|||
don't actually need to be moved. :bug:`1583`
|
||||
|
||||
.. _Google Code-In: https://codein.withgoogle.com/
|
||||
.. _AcousticBrainz: http://acousticbrainz.org/
|
||||
.. _AcousticBrainz: https://acousticbrainz.org/
|
||||
|
||||
Fixes:
|
||||
|
||||
|
|
@ -964,7 +1261,7 @@ Fixes:
|
|||
* :doc:`/plugins/replaygain`: Fix a crash using the Python Audio Tools
|
||||
backend. :bug:`1873`
|
||||
|
||||
.. _beets.io: http://beets.io/
|
||||
.. _beets.io: https://beets.io/
|
||||
.. _Beetbox: https://github.com/beetbox
|
||||
|
||||
|
||||
|
|
@ -1081,7 +1378,7 @@ Fixes:
|
|||
communication errors. The backend has also been disabled by default, since
|
||||
the API it depends on is currently down. :bug:`1770`
|
||||
|
||||
.. _Emby: http://emby.media
|
||||
.. _Emby: https://emby.media
|
||||
|
||||
|
||||
1.3.15 (October 17, 2015)
|
||||
|
|
@ -1243,8 +1540,8 @@ Fixes:
|
|||
* :doc:`/plugins/convert`: Fix a problem with filename encoding on Windows
|
||||
under Python 3. :bug:`2515` :bug:`2516`
|
||||
|
||||
.. _Python bug: http://bugs.python.org/issue16512
|
||||
.. _ipfs: http://ipfs.io
|
||||
.. _Python bug: https://bugs.python.org/issue16512
|
||||
.. _ipfs: https://ipfs.io
|
||||
|
||||
|
||||
1.3.13 (April 24, 2015)
|
||||
|
|
@ -1595,7 +1892,7 @@ As usual, there are loads of little fixes and improvements:
|
|||
* The :ref:`config-cmd` command can now use ``$EDITOR`` variables with
|
||||
arguments.
|
||||
|
||||
.. _API changes: http://developer.echonest.com/forums/thread/3650
|
||||
.. _API changes: https://developer.echonest.com/forums/thread/3650
|
||||
.. _Plex: https://plex.tv/
|
||||
.. _musixmatch: https://www.musixmatch.com/
|
||||
|
||||
|
|
@ -2075,7 +2372,7 @@ Fixes:
|
|||
* :doc:`/plugins/convert`: Display a useful error message when the FFmpeg
|
||||
executable can't be found.
|
||||
|
||||
.. _requests: http://www.python-requests.org/
|
||||
.. _requests: https://www.python-requests.org/
|
||||
|
||||
|
||||
1.3.3 (February 26, 2014)
|
||||
|
|
@ -2257,7 +2554,7 @@ As usual, there are also innumerable little fixes and improvements:
|
|||
|
||||
|
||||
.. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html
|
||||
.. _MPD: http://www.musicpd.org/
|
||||
.. _MPD: https://www.musicpd.org/
|
||||
|
||||
|
||||
1.3.1 (October 12, 2013)
|
||||
|
|
@ -2324,7 +2621,7 @@ And some fixes:
|
|||
* :doc:`/plugins/scrub`: Avoid preserving certain non-standard ID3 tags such
|
||||
as NCON.
|
||||
|
||||
.. _Opus: http://www.opus-codec.org/
|
||||
.. _Opus: https://www.opus-codec.org/
|
||||
.. _@Verrus: https://github.com/Verrus
|
||||
|
||||
|
||||
|
|
@ -2362,7 +2659,7 @@ previous versions would spit out a warning and then list your entire library.
|
|||
|
||||
There's more detail than you could ever need `on the beets blog`_.
|
||||
|
||||
.. _on the beets blog: http://beets.io/blog/flexattr.html
|
||||
.. _on the beets blog: https://beets.io/blog/flexattr.html
|
||||
|
||||
|
||||
1.2.2 (August 27, 2013)
|
||||
|
|
@ -2556,8 +2853,8 @@ And a batch of fixes:
|
|||
* :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due
|
||||
to some fixes in dealing with special characters.
|
||||
|
||||
.. _Discogs: http://discogs.com/
|
||||
.. _Beatport: http://www.beatport.com/
|
||||
.. _Discogs: https://discogs.com/
|
||||
.. _Beatport: https://www.beatport.com/
|
||||
|
||||
|
||||
1.1.0 (April 29, 2013)
|
||||
|
|
@ -2606,7 +2903,7 @@ will automatically migrate your configuration to the new system.
|
|||
header. Thanks to Uwe L. Korn.
|
||||
* :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization.
|
||||
|
||||
.. _Tomahawk: http://www.tomahawk-player.org/
|
||||
.. _Tomahawk: https://tomahawk-player.org/
|
||||
|
||||
1.1b3 (March 16, 2013)
|
||||
----------------------
|
||||
|
|
@ -2781,7 +3078,7 @@ Other new stuff:
|
|||
(YAML doesn't like tabs.)
|
||||
* Fix the ``-l`` (log path) command-line option for the ``import`` command.
|
||||
|
||||
.. _iTunes Sound Check: http://support.apple.com/kb/HT2425
|
||||
.. _iTunes Sound Check: https://support.apple.com/kb/HT2425
|
||||
|
||||
1.1b1 (January 29, 2013)
|
||||
------------------------
|
||||
|
|
@ -2790,7 +3087,7 @@ This release entirely revamps beets' configuration system. The configuration
|
|||
file is now a `YAML`_ document and is located, along with other support files,
|
||||
in a common directory (e.g., ``~/.config/beets`` on Unix-like systems).
|
||||
|
||||
.. _YAML: http://en.wikipedia.org/wiki/YAML
|
||||
.. _YAML: https://en.wikipedia.org/wiki/YAML
|
||||
|
||||
* Renamed plugins: The ``rdm`` plugin has been renamed to ``random`` and
|
||||
``fuzzy_search`` has been renamed to ``fuzzy``.
|
||||
|
|
@ -2950,9 +3247,9 @@ begins today on features for version 1.1.
|
|||
unintentionally loading the plugins they contain.
|
||||
|
||||
.. _The Echo Nest: http://the.echonest.com/
|
||||
.. _Tomahawk resolver: http://beets.io/blog/tomahawk-resolver.html
|
||||
.. _Tomahawk resolver: https://beets.io/blog/tomahawk-resolver.html
|
||||
.. _mp3gain: http://mp3gain.sourceforge.net/download.php
|
||||
.. _aacgain: http://aacgain.altosdesign.com
|
||||
.. _aacgain: https://aacgain.altosdesign.com
|
||||
|
||||
1.0b15 (July 26, 2012)
|
||||
----------------------
|
||||
|
|
@ -3061,7 +3358,7 @@ fetching cover art for your music, enable this plugin after upgrading to beets
|
|||
database with ``beet import -AWC /path/to/music``.
|
||||
* Fix ``import`` with relative path arguments on Windows.
|
||||
|
||||
.. _artist credits: http://wiki.musicbrainz.org/Artist_Credit
|
||||
.. _artist credits: https://wiki.musicbrainz.org/Artist_Credit
|
||||
|
||||
1.0b14 (May 12, 2012)
|
||||
---------------------
|
||||
|
|
@ -3219,7 +3516,7 @@ to come in the next couple of releases.
|
|||
data.
|
||||
* Fix the ``list`` command in BPD (thanks to Simon Chopin).
|
||||
|
||||
.. _Colorama: http://pypi.python.org/pypi/colorama
|
||||
.. _Colorama: https://pypi.python.org/pypi/colorama
|
||||
|
||||
1.0b12 (January 16, 2012)
|
||||
-------------------------
|
||||
|
|
@ -3332,12 +3629,12 @@ release: one for assigning genres and another for ReplayGain analysis.
|
|||
corrupted.
|
||||
|
||||
.. _KraYmer: https://github.com/KraYmer
|
||||
.. _Next Generation Schema: http://musicbrainz.org/doc/XML_Web_Service/Version_2
|
||||
.. _Next Generation Schema: https://musicbrainz.org/doc/XML_Web_Service/Version_2
|
||||
.. _python-musicbrainzngs: https://github.com/alastair/python-musicbrainzngs
|
||||
.. _acoustid: http://acoustid.org/
|
||||
.. _acoustid: https://acoustid.org/
|
||||
.. _Peter Brunner: https://github.com/Lugoues
|
||||
.. _Simon Chopin: https://github.com/laarmen
|
||||
.. _albumart.org: http://www.albumart.org/
|
||||
.. _albumart.org: https://www.albumart.org/
|
||||
|
||||
1.0b10 (September 22, 2011)
|
||||
---------------------------
|
||||
|
|
@ -3506,8 +3803,8 @@ below, for a plethora of new features.
|
|||
|
||||
* Fix a crash on album queries with item-only field names.
|
||||
|
||||
.. _xargs: http://en.wikipedia.org/wiki/xargs
|
||||
.. _unidecode: http://pypi.python.org/pypi/Unidecode/0.04.1
|
||||
.. _xargs: https://en.wikipedia.org/wiki/xargs
|
||||
.. _unidecode: https://pypi.python.org/pypi/Unidecode/0.04.1
|
||||
|
||||
1.0b8 (April 28, 2011)
|
||||
----------------------
|
||||
|
|
@ -3650,7 +3947,7 @@ new configuration options and the ability to clean up empty directory subtrees.
|
|||
|
||||
* The old "albumify" plugin for upgrading databases was removed.
|
||||
|
||||
.. _as specified by MusicBrainz: http://wiki.musicbrainz.org/ReleaseType
|
||||
.. _as specified by MusicBrainz: https://wiki.musicbrainz.org/ReleaseType
|
||||
|
||||
1.0b6 (January 20, 2011)
|
||||
------------------------
|
||||
|
|
@ -3766,7 +4063,7 @@ are also rolled into this release.
|
|||
|
||||
* Fixed escaping of ``/`` characters in paths on Windows.
|
||||
|
||||
.. _!!!: http://musicbrainz.org/artist/f26c72d3-e52c-467b-b651-679c73d8e1a7.html
|
||||
.. _!!!: https://musicbrainz.org/artist/f26c72d3-e52c-467b-b651-679c73d8e1a7.html
|
||||
|
||||
1.0b4 (August 9, 2010)
|
||||
----------------------
|
||||
|
|
@ -3955,7 +4252,7 @@ Vorbis) and an option to log untaggable albums during import.
|
|||
removed dependency on the aging ``cmdln`` module in favor of `a hand-rolled
|
||||
solution`_.
|
||||
|
||||
.. _a hand-rolled solution: http://gist.github.com/462717
|
||||
.. _a hand-rolled solution: https://gist.github.com/462717
|
||||
|
||||
1.0b1 (June 17, 2010)
|
||||
---------------------
|
||||
|
|
|
|||
|
|
@ -15,15 +15,17 @@ master_doc = 'index'
|
|||
project = u'beets'
|
||||
copyright = u'2016, Adrian Sampson'
|
||||
|
||||
version = '1.4'
|
||||
release = '1.4.8'
|
||||
version = '1.5'
|
||||
release = '1.5.0'
|
||||
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# External links to the bug tracker.
|
||||
# External links to the bug tracker and other sites.
|
||||
extlinks = {
|
||||
'bug': ('https://github.com/beetbox/beets/issues/%s', '#'),
|
||||
'user': ('https://github.com/%s', ''),
|
||||
'pypi': ('https://pypi.org/project/%s/', ''),
|
||||
'stdlib': ('https://docs.python.org/3/library/%s.html', ''),
|
||||
}
|
||||
|
||||
# Options for HTML output
|
||||
|
|
|
|||
|
|
@ -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
|
||||
in hacking beets itself or creating plugins for it.
|
||||
|
||||
See also the documentation for `MediaFile`_, the library used by beets to read
|
||||
and write metadata tags in media files.
|
||||
|
||||
.. _MediaFile: https://mediafile.readthedocs.io/
|
||||
|
||||
.. toctree::
|
||||
|
||||
plugins
|
||||
api
|
||||
media_file
|
||||
library
|
||||
importer
|
||||
cli
|
||||
|
|
|
|||
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
|
||||
|
||||
.. _Stack Overflow question about namespace packages:
|
||||
http://stackoverflow.com/questions/1675734/how-do-i-create-a-namespace-package-in-python/1676069#1676069
|
||||
https://stackoverflow.com/questions/1675734/how-do-i-create-a-namespace-package-in-python/1676069#1676069
|
||||
|
||||
Then, you'll need to put this stuff in ``__init__.py`` to make ``beetsplug`` a
|
||||
namespace package::
|
||||
|
|
@ -42,7 +42,7 @@ Then, as described above, edit your ``config.yaml`` to include
|
|||
``plugins: myawesomeplugin`` (substituting the name of the Python module
|
||||
containing your plugin).
|
||||
|
||||
.. _virtualenv: http://pypi.python.org/pypi/virtualenv
|
||||
.. _virtualenv: https://pypi.org/project/virtualenv
|
||||
|
||||
.. _add_subcommands:
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ but it defaults to an empty parser (you can extend it later). ``help`` is a
|
|||
description of your command, and ``aliases`` is a list of shorthand versions of
|
||||
your command name.
|
||||
|
||||
.. _OptionParser instance: http://docs.python.org/library/optparse.html
|
||||
.. _OptionParser instance: https://docs.python.org/library/optparse.html
|
||||
|
||||
You'll need to add a function to your command by saying ``mycommand.func =
|
||||
myfunction``. This function should take the following parameters: ``lib`` (a
|
||||
|
|
@ -81,7 +81,7 @@ beets ``Library`` object) and ``opts`` and ``args`` (command-line options and
|
|||
arguments as returned by `OptionParser.parse_args`_).
|
||||
|
||||
.. _OptionParser.parse_args:
|
||||
http://docs.python.org/library/optparse.html#parsing-arguments
|
||||
https://docs.python.org/library/optparse.html#parsing-arguments
|
||||
|
||||
The function should use any of the utility functions defined in ``beets.ui``.
|
||||
Try running ``pydoc beets.ui`` to see what's available.
|
||||
|
|
@ -103,19 +103,18 @@ operation. For instance, a plugin could write a log message every time an album
|
|||
is successfully autotagged or update MPD's index whenever the database is
|
||||
changed.
|
||||
|
||||
You can "listen" for events using the ``BeetsPlugin.listen`` decorator. Here's
|
||||
You can "listen" for events using ``BeetsPlugin.register_listener``. Here's
|
||||
an example::
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
class SomePlugin(BeetsPlugin):
|
||||
pass
|
||||
|
||||
@SomePlugin.listen('pluginload')
|
||||
def loaded():
|
||||
print 'Plugin loaded!'
|
||||
|
||||
Pass the name of the event in question to the ``listen`` decorator.
|
||||
class SomePlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(SomePlugin, self).__init__()
|
||||
self.register_listener('pluginload', loaded)
|
||||
|
||||
Note that if you want to access an attribute of your plugin (e.g. ``config`` or
|
||||
``log``) you'll have to define a method and not a function. Here is the usual
|
||||
|
|
@ -299,10 +298,10 @@ this in their ``config.yaml``::
|
|||
foo: bar
|
||||
|
||||
To access this value, say ``self.config['foo'].get()`` at any point in your
|
||||
plugin's code. The `self.config` object is a *view* as defined by the `Confit`_
|
||||
plugin's code. The `self.config` object is a *view* as defined by the `Confuse`_
|
||||
library.
|
||||
|
||||
.. _Confit: http://confit.readthedocs.org/
|
||||
.. _Confuse: https://confuse.readthedocs.org/
|
||||
|
||||
If you want to access configuration values *outside* of your plugin's section,
|
||||
import the `config` object from the `beets` module. That is, just put ``from
|
||||
|
|
@ -371,17 +370,16 @@ template fields by adding a function accepting an ``Album`` argument to the
|
|||
Extend MediaFile
|
||||
^^^^^^^^^^^^^^^^
|
||||
|
||||
:ref:`MediaFile` is the file tag abstraction layer that beets uses to make
|
||||
`MediaFile`_ is the file tag abstraction layer that beets uses to make
|
||||
cross-format metadata manipulation simple. Plugins can add fields to MediaFile
|
||||
to extend the kinds of metadata that they can easily manage.
|
||||
|
||||
The ``MediaFile`` class uses ``MediaField`` descriptors to provide
|
||||
access to file tags. Have a look at the ``beets.mediafile`` source code
|
||||
to learn how to use this descriptor class. If you have created a
|
||||
descriptor you can add it through your plugins ``add_media_field()``
|
||||
method.
|
||||
access to file tags. If you have created a descriptor you can add it through
|
||||
your plugins ``add_media_field()`` method.
|
||||
|
||||
.. automethod:: beets.plugins.BeetsPlugin.add_media_field
|
||||
.. _MediaFile: https://mediafile.readthedocs.io/
|
||||
|
||||
|
||||
Here's an example plugin that provides a meaningless new field "foo"::
|
||||
|
|
@ -443,15 +441,24 @@ Extend the Query Syntax
|
|||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
You can add new kinds of queries to beets' :doc:`query syntax
|
||||
</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
|
||||
prefix---plugins can do the same.
|
||||
|
||||
To do so, define a subclass of the ``Query`` type from the
|
||||
``beets.dbcore.query`` module. Then, in the ``queries`` method of your plugin
|
||||
class, return a dictionary mapping prefix strings to query classes.
|
||||
For either kind of query extension, define a subclass of the ``Query`` type
|
||||
from the ``beets.dbcore.query`` module. Then:
|
||||
|
||||
One simple kind of query you can extend is the ``FieldQuery``, which
|
||||
- To define a prefix-based query, define a ``queries`` method in your plugin
|
||||
class. Return from this method a dictionary mapping prefix strings to query
|
||||
classes.
|
||||
- To define a named query, defined dictionaries named either ``item_queries``
|
||||
or ``album_queries``. These should map names to query types. So if you
|
||||
use ``{ "foo": FooQuery }``, then the query ``foo:bar`` will construct a
|
||||
query like ``FooQuery("bar")``.
|
||||
|
||||
For prefix-based queries, you will want to extend ``FieldQuery``, which
|
||||
implements string comparisons on fields. To use it, create a subclass
|
||||
inheriting from that class and override the ``value_match`` class method.
|
||||
(Remember the ``@classmethod`` decorator!) The following example plugin
|
||||
|
|
|
|||
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.
|
||||
|
||||
.. _IRC: irc://irc.freenode.net/beets
|
||||
.. _mailing list: http://groups.google.com/group/beets-users
|
||||
.. _discussion board: http://discourse.beets.io
|
||||
.. _mailing list: https://groups.google.com/group/beets-users
|
||||
.. _discussion board: https://discourse.beets.io
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
|
@ -94,14 +94,14 @@ the tracks into a single directory to force them to be tagged together.
|
|||
|
||||
An MBID looks like one of these:
|
||||
|
||||
- ``http://musicbrainz.org/release/ded77dcf-7279-457e-955d-625bd3801b87``
|
||||
- ``https://musicbrainz.org/release/ded77dcf-7279-457e-955d-625bd3801b87``
|
||||
- ``d569deba-8c6b-4d08-8c43-d0e5a1b8c7f3``
|
||||
|
||||
Beets can recognize either the hex-with-dashes UUID-style string or the
|
||||
full URL that contains it (as of 1.0b11).
|
||||
|
||||
You can get these IDs by `searching on the MusicBrainz web
|
||||
site <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).
|
||||
Then, copy the URL of the page and paste it into beets.
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ Run a command like this::
|
|||
|
||||
pip install -U beets
|
||||
|
||||
The ``-U`` flag tells `pip <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
|
||||
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:
|
||||
|
||||
- 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.,
|
||||
`Gist <https://gist.github.com/>`__ or
|
||||
`Hastebin <http://hastebin.com/>`__), especially when communicating
|
||||
`Hastebin <https://hastebin.com/>`__), especially when communicating
|
||||
over IRC or email.
|
||||
- Turn on beets' debug output (using the -v option: for example,
|
||||
``beet -v import ...``) and include that with your bug report. Look
|
||||
|
|
@ -188,7 +188,7 @@ there to report a bug. Please follow these guidelines when reporting an issue:
|
|||
|
||||
If you've never reported a bug before, Mozilla has some well-written
|
||||
`general guidelines for good bug
|
||||
reports <http://www.mozilla.org/bugs/>`__.
|
||||
reports <https://www.mozilla.org/bugs/>`__.
|
||||
|
||||
|
||||
.. _find-config:
|
||||
|
|
@ -237,7 +237,7 @@ Why does beets…
|
|||
There are a number of possibilities:
|
||||
|
||||
- First, make sure the album is in `the MusicBrainz
|
||||
database <http://musicbrainz.org/>`__. You
|
||||
database <https://musicbrainz.org/>`__. You
|
||||
can search on their site to make sure it's cataloged there. (If not,
|
||||
anyone can edit MusicBrainz---so consider adding the data yourself.)
|
||||
- If the album in question is a multi-disc release, see the relevant
|
||||
|
|
@ -320,7 +320,7 @@ it encounters files that *look* like music files (according to their
|
|||
extension) but seem to be broken. Most of the time, this is because the
|
||||
file is corrupted. To check whether the file is intact, try opening it
|
||||
in another media player (e.g.,
|
||||
`VLC <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
|
||||
integrity---for example, type ``metaflac --list music.flac`` to check
|
||||
FLAC files.
|
||||
|
|
@ -378,4 +378,4 @@ installed using pip, the command ``pip show -f beets`` can show you where
|
|||
``beet`` was placed on your system. If you need help extending your ``$PATH``,
|
||||
try `this Super User answer`_.
|
||||
|
||||
.. _this Super User answer: http://superuser.com/a/284361/4569
|
||||
.. _this Super User answer: https://superuser.com/a/284361/4569
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ everything by the Long Winters for listening on the go.
|
|||
The plugin has many more dials you can fiddle with to get your conversions how
|
||||
you like them. Check out :doc:`its documentation </plugins/convert>`.
|
||||
|
||||
.. _ffmpeg: http://www.ffmpeg.org
|
||||
.. _ffmpeg: https://www.ffmpeg.org
|
||||
|
||||
|
||||
Store any data you like
|
||||
|
|
@ -127,7 +127,7 @@ And, unlike :ref:`built-in fields <itemfields>`, such fields can be removed::
|
|||
Read more than you ever wanted to know about the *flexible attributes*
|
||||
feature `on the beets blog`_.
|
||||
|
||||
.. _on the beets blog: http://beets.io/blog/flexattr.html
|
||||
.. _on the beets blog: https://beets.io/blog/flexattr.html
|
||||
|
||||
|
||||
Choose a path style manually for some music
|
||||
|
|
@ -151,3 +151,55 @@ differently. Put something like this in your configuration file::
|
|||
|
||||
Used together, flexible attributes and path format conditions let you sort
|
||||
your music by any criteria you can imagine.
|
||||
|
||||
|
||||
Automatically add new music to your library
|
||||
-------------------------------------------
|
||||
|
||||
As a command-line tool, beets is perfect for automated operation via a cron job
|
||||
or the like. To use it this way, you might want to use these options in your
|
||||
:doc:`config file </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
|
||||
collection better.
|
||||
|
||||
.. _beets: http://beets.io/
|
||||
.. _beets: https://beets.io/
|
||||
|
||||
Installing
|
||||
----------
|
||||
|
|
@ -12,7 +12,7 @@ Installing
|
|||
You will need Python.
|
||||
Beets works on `Python 2.7`_ and Python 3.4 or later.
|
||||
|
||||
.. _Python 2.7: http://www.python.org/download/
|
||||
.. _Python 2.7: https://www.python.org/download/
|
||||
|
||||
* **macOS** v10.7 (Lion) and later include Python 2.7 out of the box.
|
||||
You can opt for Python 3 by installing it via `Homebrew`_:
|
||||
|
|
@ -26,37 +26,37 @@ Beets works on `Python 2.7`_ and Python 3.4 or later.
|
|||
as described below by running:
|
||||
``apt-get install python-dev python-pip``
|
||||
|
||||
* On **Arch Linux**, `beets is in [community]`_, so just run ``pacman -S
|
||||
beets``. (There's also a bleeding-edge `dev package`_ in the AUR, which will
|
||||
* On **Arch Linux**, `beets is in [community] <Arch community_>`_, so just run ``pacman -S
|
||||
beets``. (There's also a bleeding-edge `dev package <AUR_>`_ in the AUR, which will
|
||||
probably set your computer on fire.)
|
||||
|
||||
* For **Gentoo Linux**, beets is in Portage as ``media-sound/beets``. Just run
|
||||
``emerge beets`` to install. There are several USE flags available for
|
||||
optional plugin dependencies.
|
||||
|
||||
* On **FreeBSD**, there's a `beets port`_ at ``audio/beets``.
|
||||
* On **FreeBSD**, there's a `beets port <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.
|
||||
|
||||
* On **Fedora** 22 or later, there is a `DNF package`_ (or three)::
|
||||
* On **Fedora** 22 or later, there is a `DNF package`_::
|
||||
|
||||
$ sudo dnf install beets beets-plugins beets-doc
|
||||
|
||||
|
||||
* On **Solus**, run ``eopkg install beets``.
|
||||
|
||||
* On **NixOS**, run ``nix-env -i beets``.
|
||||
* On **NixOS**, there's a `package <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
|
||||
.. _SlackBuild: http://slackbuilds.org/repository/14.1/multimedia/beets/
|
||||
.. _beets port: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
|
||||
.. _beets from AUR: https://aur.archlinux.org/packages/beets-git/
|
||||
.. _dev package: https://aur.archlinux.org/packages/beets-git/
|
||||
.. _Debian details: http://packages.qa.debian.org/b/beets.html
|
||||
.. _DNF package: https://apps.fedoraproject.org/packages/beets
|
||||
.. _SlackBuild: https://slackbuilds.org/repository/14.2/multimedia/beets/
|
||||
.. _FreeBSD: http://portsmon.freebsd.org/portoverview.py?category=audio&portname=beets
|
||||
.. _AUR: https://aur.archlinux.org/packages/beets-git/
|
||||
.. _Debian details: https://tracker.debian.org/pkg/beets
|
||||
.. _Ubuntu details: https://launchpad.net/ubuntu/+source/beets
|
||||
.. _beets is in [community]: https://www.archlinux.org/packages/community/any/beets/
|
||||
.. _OpenBSD: http://openports.se/audio/beets
|
||||
.. _Arch community: https://www.archlinux.org/packages/community/any/beets/
|
||||
.. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/tools/audio/beets
|
||||
|
||||
If you have `pip`_, just say ``pip install beets`` (or ``pip install --user
|
||||
beets`` if you run into permissions problems).
|
||||
|
|
@ -64,14 +64,14 @@ beets`` if you run into permissions problems).
|
|||
To install without pip, download beets from `its PyPI page`_ and run ``python
|
||||
setup.py install`` in the directory therein.
|
||||
|
||||
.. _its PyPI page: http://pypi.python.org/pypi/beets#downloads
|
||||
.. _pip: http://www.pip-installer.org/
|
||||
.. _its PyPI page: https://pypi.org/project/beets#downloads
|
||||
.. _pip: https://pip.pypa.io
|
||||
|
||||
The best way to upgrade beets to a new version is by running ``pip install -U
|
||||
beets``. You may want to follow `@b33ts`_ on Twitter to hear about progress on
|
||||
new versions.
|
||||
|
||||
.. _@b33ts: http://twitter.com/b33ts
|
||||
.. _@b33ts: https://twitter.com/b33ts
|
||||
|
||||
Installing on macOS 10.11 and Higher
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
@ -87,7 +87,7 @@ If this happens, you can install beets for the current user only by typing
|
|||
``~/Library/Python/3.6/bin`` to your ``$PATH``.
|
||||
|
||||
.. _System Integrity Protection: https://support.apple.com/en-us/HT204899
|
||||
.. _Homebrew: http://brew.sh
|
||||
.. _Homebrew: https://brew.sh
|
||||
|
||||
Installing on Windows
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
@ -122,10 +122,10 @@ Because I don't use Windows myself, I may have missed something. If you have
|
|||
trouble or you have more detail to contribute here, please direct it to
|
||||
`the mailing list`_.
|
||||
|
||||
.. _install Python: http://python.org/download/
|
||||
.. _install Python: https://python.org/download/
|
||||
.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg
|
||||
.. _install pip: http://www.pip-installer.org/en/latest/installing.html#install-pip
|
||||
.. _get-pip.py: https://raw.github.com/pypa/pip/master/contrib/get-pip.py
|
||||
.. _install pip: https://pip.pypa.io/en/stable/installing/
|
||||
.. _get-pip.py: https://bootstrap.pypa.io/get-pip.py
|
||||
|
||||
|
||||
Configuring
|
||||
|
|
@ -179,7 +179,7 @@ There are approximately six million other configuration options you can set
|
|||
here, including the directory and file naming scheme. See
|
||||
:doc:`/reference/config` for a full reference.
|
||||
|
||||
.. _YAML: http://yaml.org/
|
||||
.. _YAML: https://yaml.org/
|
||||
|
||||
Importing Your Library
|
||||
----------------------
|
||||
|
|
@ -300,6 +300,6 @@ import`` gives more specific help about the ``import`` command.
|
|||
Please let me know what you think of beets via `the discussion board`_ or
|
||||
`Twitter`_.
|
||||
|
||||
.. _the mailing list: http://groups.google.com/group/beets-users
|
||||
.. _the discussion board: http://discourse.beets.io
|
||||
.. _twitter: http://twitter.com/b33ts
|
||||
.. _the mailing list: https://groups.google.com/group/beets-users
|
||||
.. _the discussion board: https://discourse.beets.io
|
||||
.. _twitter: https://twitter.com/b33ts
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ Before you jump into acoustic fingerprinting with both feet, though, give beets
|
|||
a try without it. You may be surprised at how well metadata-based matching
|
||||
works.
|
||||
|
||||
.. _Chromaprint: http://acoustid.org/chromaprint
|
||||
.. _Chromaprint: https://acoustid.org/chromaprint
|
||||
|
||||
Album Art, Lyrics, Genres and Such
|
||||
----------------------------------
|
||||
|
|
@ -292,7 +292,7 @@ sure the album is present in `the MusicBrainz database`_. You can search on
|
|||
their site to make sure it's cataloged there. If not, anyone can edit
|
||||
MusicBrainz---so consider adding the data yourself.
|
||||
|
||||
.. _the MusicBrainz database: http://musicbrainz.org/
|
||||
.. _the MusicBrainz database: https://musicbrainz.org/
|
||||
|
||||
If you think beets is ignoring an album that's listed in MusicBrainz, please
|
||||
`file a bug report`_.
|
||||
|
|
@ -305,5 +305,5 @@ I Hope That Makes Sense
|
|||
If we haven't made the process clear, please post on `the discussion
|
||||
board`_ and we'll try to improve this guide.
|
||||
|
||||
.. _the mailing list: http://groups.google.com/group/beets-users
|
||||
.. _the discussion board: http://discourse.beets.io
|
||||
.. _the mailing list: https://groups.google.com/group/beets-users
|
||||
.. _the discussion board: https://discourse.beets.io
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ Freenode, drop by `the discussion board`_, send email to `the mailing list`_,
|
|||
or `file a bug`_ in the issue tracker. Please let us know where you think this
|
||||
documentation can be improved.
|
||||
|
||||
.. _beets: http://beets.io/
|
||||
.. _the mailing list: http://groups.google.com/group/beets-users
|
||||
.. _beets: https://beets.io/
|
||||
.. _the mailing list: https://groups.google.com/group/beets-users
|
||||
.. _file a bug: https://github.com/beetbox/beets/issues
|
||||
.. _the discussion board: http://discourse.beets.io
|
||||
.. _the discussion board: https://discourse.beets.io
|
||||
|
||||
Contents
|
||||
--------
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
AcousticBrainz Submit Plugin
|
||||
============================
|
||||
|
||||
The `absubmit` plugin lets you submit acoustic analysis results to the
|
||||
The ``absubmit`` plugin lets you submit acoustic analysis results to the
|
||||
`AcousticBrainz`_ server.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
The `absubmit` plugin requires the `streaming_extractor_music`_ program to run. Its source can be found on `GitHub`_, and while it is possible to compile the extractor from source, AcousticBrainz would prefer if you used their binary (see the AcousticBrainz `FAQ`_).
|
||||
The ``absubmit`` plugin requires the `streaming_extractor_music`_ program to run. Its source can be found on `GitHub`_, and while it is possible to compile the extractor from source, AcousticBrainz would prefer if you used their binary (see the AcousticBrainz `FAQ`_).
|
||||
|
||||
The `absubmit` also plugin requires `requests`_, which you can install using `pip`_ by typing::
|
||||
The ``absubmit`` plugin also requires `requests`_, which you can install using `pip`_ by typing::
|
||||
|
||||
pip install requests
|
||||
|
||||
|
|
@ -41,9 +41,9 @@ To configure the plugin, make a ``absubmit:`` section in your configuration file
|
|||
- **extractor**: The absolute path to the `streaming_extractor_music`_ binary.
|
||||
Default: search for the program in your ``$PATH``
|
||||
|
||||
.. _streaming_extractor_music: http://acousticbrainz.org/download
|
||||
.. _FAQ: http://acousticbrainz.org/faq
|
||||
.. _pip: http://www.pip-installer.org/
|
||||
.. _requests: http://docs.python-requests.org/en/master/
|
||||
.. _streaming_extractor_music: https://acousticbrainz.org/download
|
||||
.. _FAQ: https://acousticbrainz.org/faq
|
||||
.. _pip: https://pip.pypa.io
|
||||
.. _requests: https://docs.python-requests.org/en/master/
|
||||
.. _github: https://github.com/MTG/essentia
|
||||
.. _AcousticBrainz: https://acousticbrainz.org
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ AcousticBrainz Plugin
|
|||
The ``acousticbrainz`` plugin gets acoustic-analysis information from the
|
||||
`AcousticBrainz`_ project.
|
||||
|
||||
.. _AcousticBrainz: http://acousticbrainz.org/
|
||||
.. _AcousticBrainz: https://acousticbrainz.org/
|
||||
|
||||
Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing::
|
||||
|
||||
|
|
|
|||
|
|
@ -48,11 +48,11 @@ Here is an example where the FLAC decoder signals a corrupt file::
|
|||
00.flac: ERROR while decoding data
|
||||
state = FLAC__STREAM_DECODER_READ_FRAME
|
||||
|
||||
Note that the default `mp3val` checker is a bit verbose and can output a lot
|
||||
Note that the default ``mp3val`` checker is a bit verbose and can output a lot
|
||||
of "stream error" messages, even for files that play perfectly well.
|
||||
Generally, if more than one stream error happens, or if a stream error happens
|
||||
in the middle of a file, this is a bad sign.
|
||||
|
||||
By default, only errors for the bad files will be shown. In order for the
|
||||
results for all of the checked files to be seen, including the uncorrupted
|
||||
results for all of the checked files to be seen, including the uncorrupted
|
||||
ones, use the ``-v`` or ``--verbose`` option.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@ from MusicBrainz and other sources.
|
|||
If you have a Beatport ID or a URL for a release or track you want to tag, you
|
||||
can just enter one of the two at the "enter Id" prompt in the importer.
|
||||
|
||||
.. _requests: http://docs.python-requests.org/en/latest/
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
.. _requests_oauthlib: https://github.com/requests/requests-oauthlib
|
||||
.. _Beatport: http://beatport.com
|
||||
.. _Beatport: https://beetport.com
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ implements the MPD protocol, so it's compatible with all the great MPD clients
|
|||
out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
|
||||
|
||||
.. _Theremin: https://theremin.sigterm.eu/
|
||||
.. _gmpc: http://gmpc.wikia.com/wiki/Gnome_Music_Player_Client
|
||||
.. _gmpc: https://gmpc.wikia.com/wiki/Gnome_Music_Player_Client
|
||||
.. _Sonata: http://sonata.berlios.de/
|
||||
.. _Ario: http://ario-player.sourceforge.net/
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ with its Python bindings) on your system.
|
|||
gst-plugins-base pygobject3``.
|
||||
|
||||
* On Linux, you need to install GStreamer 1.0 and the GObject bindings for
|
||||
python. Under Ubuntu, they are called `python-gi` and `gstreamer1.0`.
|
||||
python. Under Ubuntu, they are called ``python-gi`` and ``gstreamer1.0``.
|
||||
|
||||
* On Windows, you may want to try `GStreamer WinBuilds`_ (caveat emptor: I
|
||||
haven't tried this).
|
||||
|
|
@ -29,8 +29,8 @@ You will also need the various GStreamer plugin packages to make everything
|
|||
work. See the :doc:`/plugins/chroma` documentation for more information on
|
||||
installing GStreamer plugins.
|
||||
|
||||
.. _GStreamer WinBuilds: http://www.gstreamer-winbuild.ylatuya.es/
|
||||
.. _Homebrew: http://mxcl.github.com/homebrew/
|
||||
.. _GStreamer WinBuilds: https://www.gstreamer-winbuild.ylatuya.es/
|
||||
.. _Homebrew: https://brew.sh
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
|
@ -44,7 +44,7 @@ Then, you can run BPD by invoking::
|
|||
Fire up your favorite MPD client to start playing music. The MPD site has `a
|
||||
long list of available clients`_. Here are my favorites:
|
||||
|
||||
.. _a long list of available clients: http://mpd.wikia.com/wiki/Clients
|
||||
.. _a long list of available clients: https://mpd.wikia.com/wiki/Clients
|
||||
|
||||
* Linux: `gmpc`_, `Sonata`_
|
||||
|
||||
|
|
@ -52,9 +52,9 @@ long list of available clients`_. Here are my favorites:
|
|||
|
||||
* Windows: I don't know. Get in touch if you have a recommendation.
|
||||
|
||||
* iPhone/iPod touch: `MPoD`_
|
||||
* iPhone/iPod touch: `Rigelian`_
|
||||
|
||||
.. _MPoD: http://www.katoemba.net/makesnosenseatall/mpod/
|
||||
.. _Rigelian: https://www.rigelian.net/
|
||||
|
||||
One nice thing about MPD's (and thus BPD's) client-server architecture is that
|
||||
the client can just as easily on a different computer from the server as it can
|
||||
|
|
@ -75,6 +75,8 @@ The available options are:
|
|||
Default: No password.
|
||||
- **volume**: Initial volume, as a percentage.
|
||||
Default: 100
|
||||
- **control_port**: Port for the internal control socket.
|
||||
Default: 6601
|
||||
|
||||
Here's an example::
|
||||
|
||||
|
|
@ -95,40 +97,41 @@ on-disk directory structure can. (Note that an obvious solution to this is just
|
|||
string matching on items' destination, but this requires examining the entire
|
||||
library Python-side for every query.)
|
||||
|
||||
We don't currently support versioned playlists. Many clients, however, use
|
||||
plchanges instead of playlistinfo to get the current playlist, so plchanges
|
||||
contains a dummy implementation that just calls playlistinfo.
|
||||
BPD plays music using GStreamer's ``playbin`` player, which has a simple API
|
||||
but doesn't support many advanced playback features.
|
||||
|
||||
The ``stats`` command always send zero for ``playtime``, which is supposed to
|
||||
indicate the amount of time the server has spent playing music. BPD doesn't
|
||||
currently keep track of this.
|
||||
Differences from the real MPD
|
||||
-----------------------------
|
||||
|
||||
The ``update`` command regenerates the directory tree from the beets database.
|
||||
BPD currently supports version 0.16 of `the MPD protocol`_, but several of the
|
||||
commands and features are "pretend" implementations or have slightly different
|
||||
behaviour to their MPD equivalents. BPD aims to look enough like MPD that it
|
||||
can interact with the ecosystem of clients, but doesn't try to be
|
||||
a fully-fledged MPD replacement in terms of its playback capabilities.
|
||||
|
||||
Unimplemented Commands
|
||||
----------------------
|
||||
.. _the MPD protocol: https://www.musicpd.org/doc/protocol/
|
||||
|
||||
These are the commands from `the MPD protocol`_ that have not yet been
|
||||
implemented in BPD.
|
||||
These are some of the known differences between BPD and MPD:
|
||||
|
||||
.. _the MPD protocol: http://www.musicpd.org/doc/protocol/
|
||||
|
||||
Saved playlists:
|
||||
|
||||
* playlistclear
|
||||
* playlistdelete
|
||||
* playlistmove
|
||||
* playlistadd
|
||||
* playlistsearch
|
||||
* listplaylist
|
||||
* listplaylistinfo
|
||||
* playlistfind
|
||||
* rm
|
||||
* save
|
||||
* load
|
||||
* rename
|
||||
|
||||
Deprecated:
|
||||
|
||||
* playlist
|
||||
* volume
|
||||
* BPD doesn't currently support versioned playlists. Many clients, however, use
|
||||
plchanges instead of playlistinfo to get the current playlist, so plchanges
|
||||
contains a dummy implementation that just calls playlistinfo.
|
||||
* Stored playlists aren't supported (BPD understands the commands though).
|
||||
* The ``stats`` command always send zero for ``playtime``, which is supposed to
|
||||
indicate the amount of time the server has spent playing music. BPD doesn't
|
||||
currently keep track of this.
|
||||
* The ``update`` command regenerates the directory tree from the beets database
|
||||
synchronously, whereas MPD does this in the background.
|
||||
* Advanced playback features like cross-fade, ReplayGain and MixRamp are not
|
||||
supported due to BPD's simple audio player backend.
|
||||
* Advanced query syntax is not currently supported.
|
||||
* Clients can't use the ``tagtypes`` mask to hide fields.
|
||||
* BPD's ``random`` mode is not deterministic and doesn't support priorities.
|
||||
* Mounts and streams are not supported. BPD can only play files from disk.
|
||||
* Stickers are not supported (although this is basically a flexattr in beets
|
||||
nomenclature so this is feasible to add).
|
||||
* There is only a single password, and is enabled it grants access to all
|
||||
features rather than having permissions-based granularity.
|
||||
* Partitions and alternative outputs are not supported; BPD can only play one
|
||||
song at a time.
|
||||
* Client channels are not implemented.
|
||||
|
|
|
|||
|
|
@ -27,19 +27,19 @@ The ``bucket_year`` parameter is used for all substitutions occurring on the
|
|||
The definition of a range is somewhat loose, and multiple formats are allowed:
|
||||
|
||||
- For alpha ranges: the range is defined by the lowest and highest (ASCII-wise)
|
||||
alphanumeric characters in the string you provide. For example, *ABCD*,
|
||||
*A-D*, *A->D*, and *[AD]* are all equivalent.
|
||||
alphanumeric characters in the string you provide. For example, ``ABCD``,
|
||||
``A-D``, ``A->D``, and ``[AD]`` are all equivalent.
|
||||
- For year ranges: digits characters are extracted and the two extreme years
|
||||
define the range. For example, *1975-77*, *1975,76,77* and *1975-1977* are
|
||||
define the range. For example, ``1975-77``, ``1975,76,77`` and ``1975-1977`` are
|
||||
equivalent. If no upper bound is given, the range is extended to current year
|
||||
(unless a later range is defined). For example, *1975* encompasses all years
|
||||
(unless a later range is defined). For example, ``1975`` encompasses all years
|
||||
from 1975 until now.
|
||||
|
||||
The `%bucket` template function guesses whether to use alpha- or year-style
|
||||
The ``%bucket`` template function guesses whether to use alpha- or year-style
|
||||
buckets depending on the text it receives. It can guess wrong if, for example,
|
||||
an artist or album happens to begin with four digits. Provide `alpha` as the
|
||||
an artist or album happens to begin with four digits. Provide ``alpha`` as the
|
||||
second argument to the template to avoid this automatic detection: for
|
||||
example, use `%bucket{$artist,alpha}`.
|
||||
example, use ``%bucket{$artist,alpha}``.
|
||||
|
||||
|
||||
Configuration
|
||||
|
|
@ -56,7 +56,7 @@ The available options are:
|
|||
overrides original range definition.
|
||||
Default: none.
|
||||
- **bucket_year**: Ranges to use for all substitutions occurring on the
|
||||
`$year` field.
|
||||
``$year`` field.
|
||||
Default: none.
|
||||
- **extrapolate**: Enable this if you want to group your files into multiple
|
||||
year ranges without enumerating them all. This option will generate year
|
||||
|
|
@ -73,5 +73,5 @@ Here's an example::
|
|||
'A - D': ^[0-9a-dA-D…äÄ]
|
||||
|
||||
This configuration creates five-year ranges for any input year.
|
||||
The *A - D* bucket now matches also all artists starting with ä or Ä and 0 to 9
|
||||
The `A - D` bucket now matches also all artists starting with ä or Ä and 0 to 9
|
||||
and … (ellipsis). The other alpha buckets work as ranges.
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ information at all (or have completely incorrect data). This plugin uses an
|
|||
open-source fingerprinting technology called `Chromaprint`_ and its associated
|
||||
Web service, called `Acoustid`_.
|
||||
|
||||
.. _Chromaprint: http://acoustid.org/chromaprint
|
||||
.. _acoustid: http://acoustid.org/
|
||||
.. _Chromaprint: https://acoustid.org/chromaprint
|
||||
.. _acoustid: https://acoustid.org/
|
||||
|
||||
Turning on fingerprinting can increase the accuracy of the
|
||||
autotagger---especially on files with very poor metadata---but it comes at a
|
||||
|
|
@ -31,7 +31,7 @@ First, install pyacoustid itself. You can do this using `pip`_, like so::
|
|||
|
||||
$ pip install pyacoustid
|
||||
|
||||
.. _pip: http://www.pip-installer.org/
|
||||
.. _pip: https://pip.pypa.io
|
||||
|
||||
Then, you will need to install `Chromaprint`_, either as a dynamic library or
|
||||
in the form of a command-line tool (``fpcalc``).
|
||||
|
|
@ -45,7 +45,7 @@ The simplest way to get up and running, especially on Windows, is to
|
|||
means something like ``C:\\Program Files``. On OS X or Linux, put the
|
||||
executable somewhere like ``/usr/local/bin``.
|
||||
|
||||
.. _download: http://acoustid.org/chromaprint
|
||||
.. _download: https://acoustid.org/chromaprint
|
||||
|
||||
Installing the Library
|
||||
''''''''''''''''''''''
|
||||
|
|
@ -56,7 +56,7 @@ site has links to packages for major Linux distributions. If you use
|
|||
`Homebrew`_ on Mac OS X, you can install the library with ``brew install
|
||||
chromaprint``.
|
||||
|
||||
.. _Homebrew: http://mxcl.github.com/homebrew/
|
||||
.. _Homebrew: https://brew.sh/
|
||||
|
||||
You will also need a mechanism for decoding audio files supported by the
|
||||
`audioread`_ library:
|
||||
|
|
@ -78,12 +78,12 @@ You will also need a mechanism for decoding audio files supported by the
|
|||
* On Windows, builds are provided by `GStreamer`_
|
||||
|
||||
.. _audioread: https://github.com/beetbox/audioread
|
||||
.. _pyacoustid: http://github.com/beetbox/pyacoustid
|
||||
.. _FFmpeg: http://ffmpeg.org/
|
||||
.. _MAD: http://spacepants.org/src/pymad/
|
||||
.. _pymad: http://www.underbit.com/products/mad/
|
||||
.. _Core Audio: http://developer.apple.com/technologies/mac/audio-and-video.html
|
||||
.. _Gstreamer: http://gstreamer.freedesktop.org/
|
||||
.. _pyacoustid: https://github.com/beetbox/pyacoustid
|
||||
.. _FFmpeg: https://ffmpeg.org/
|
||||
.. _MAD: https://spacepants.org/src/pymad/
|
||||
.. _pymad: https://www.underbit.com/products/mad/
|
||||
.. _Core Audio: https://developer.apple.com/technologies/mac/audio-and-video.html
|
||||
.. _Gstreamer: https://gstreamer.freedesktop.org/
|
||||
.. _PyGObject: https://wiki.gnome.org/Projects/PyGObject
|
||||
|
||||
To decode audio formats (MP3, FLAC, etc.) with GStreamer, you'll need the
|
||||
|
|
@ -132,4 +132,4 @@ Then, run ``beet submit``. (You can also provide a query to submit a subset of
|
|||
your library.) The command will use stored fingerprints if they're available;
|
||||
otherwise it will fingerprint each file before submitting it.
|
||||
|
||||
.. _get an API key: http://acoustid.org/api-key
|
||||
.. _get an API key: https://acoustid.org/api-key
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ To use the ``convert`` plugin, first enable it in your configuration (see
|
|||
:ref:`using-plugins`). By default, the plugin depends on `FFmpeg`_ to
|
||||
transcode the audio, so you might want to install it.
|
||||
|
||||
.. _FFmpeg: http://ffmpeg.org
|
||||
.. _FFmpeg: https://ffmpeg.org
|
||||
|
||||
|
||||
Usage
|
||||
|
|
@ -68,6 +68,8 @@ file. The available options are:
|
|||
- **dest**: The directory where the files will be converted (or copied) to.
|
||||
Default: none.
|
||||
- **embed**: Embed album art in converted items. Default: ``yes``.
|
||||
- **id3v23**: Can be used to override the global ``id3v23`` option. Default:
|
||||
``inherit``.
|
||||
- **max_bitrate**: All lossy files with a higher bitrate will be
|
||||
transcoded and those with a lower bitrate will simply be copied. Note that
|
||||
this does not guarantee that all converted files will have a lower
|
||||
|
|
@ -120,7 +122,7 @@ and select a command with the ``--format`` command-line option or the
|
|||
|
||||
In this example ``beet convert`` will use the *speex* command by
|
||||
default. To convert the audio to `wav`, run ``beet convert -f wav``.
|
||||
This will also use the format key (`wav`) as the file extension.
|
||||
This will also use the format key (``wav``) as the file extension.
|
||||
|
||||
Each entry in the ``formats`` map consists of a key (the name of the
|
||||
format) as well as the command and optionally the file extension.
|
||||
|
|
@ -168,6 +170,6 @@ can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME
|
|||
options and a thorough discussion of MP3 encoding.
|
||||
|
||||
.. _documentation: http://lame.sourceforge.net/using.php
|
||||
.. _HydrogenAudio wiki: http://wiki.hydrogenaud.io/index.php?title=LAME
|
||||
.. _gapless: http://wiki.hydrogenaud.io/index.php?title=Gapless_playback
|
||||
.. _LAME: http://lame.sourceforge.net/
|
||||
.. _HydrogenAudio wiki: https://wiki.hydrogenaud.io/index.php?title=LAME
|
||||
.. _gapless: https://wiki.hydrogenaud.io/index.php?title=Gapless_playback
|
||||
.. _LAME: https://lame.sourceforge.net/
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Discogs Plugin
|
|||
The ``discogs`` plugin extends the autotagger's search capabilities to
|
||||
include matches from the `Discogs`_ database.
|
||||
|
||||
.. _Discogs: http://discogs.com
|
||||
.. _Discogs: https://discogs.com
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ Note: ``compare_threshold`` option requires `ImageMagick`_, and ``maxwidth``
|
|||
requires either `ImageMagick`_ or `Pillow`_.
|
||||
|
||||
.. _Pillow: https://github.com/python-pillow/Pillow
|
||||
.. _ImageMagick: http://www.imagemagick.org/
|
||||
.. _ImageMagick: https://www.imagemagick.org/
|
||||
.. _PHASH: http://www.fmwconcepts.com/misc_tests/perceptual_hash_test_results_510/
|
||||
|
||||
Manually Embedding and Extracting Art
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ To use the ``embyupdate`` plugin you need to install the `requests`_ library wit
|
|||
|
||||
With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library.
|
||||
|
||||
.. _Emby: http://emby.media/
|
||||
.. _requests: http://docs.python-requests.org/en/latest/
|
||||
.. _Emby: https://emby.media/
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
|
@ -34,5 +34,5 @@ The available options under the ``emby:`` section are:
|
|||
- **password**: The password for the user. (This is only necessary if no API
|
||||
key is provided.)
|
||||
|
||||
You can choose to authenticate either with `apikey` or `password`, but only
|
||||
You can choose to authenticate either with ``apikey`` or ``password``, but only
|
||||
one of those two is required.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Export Plugin
|
|||
The ``export`` plugin lets you get data from the items and export the content
|
||||
as `JSON`_.
|
||||
|
||||
.. _JSON: http://www.json.org
|
||||
.. _JSON: https://www.json.org
|
||||
|
||||
Enable the ``export`` plugin (see :ref:`using-plugins` for help). Then, type ``beet export`` followed by a :doc:`query </reference/query>` to get the data from
|
||||
your library. For example, run this::
|
||||
|
|
@ -42,7 +42,7 @@ Configuration
|
|||
To configure the plugin, make a ``export:`` section in your configuration
|
||||
file. Under the ``json`` key, these options are available:
|
||||
|
||||
- **ensure_ascii**: Escape non-ASCII characters with `\uXXXX` entities.
|
||||
- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities.
|
||||
|
||||
- **indent**: The number of spaces for indentation.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ To use the ``fetchart`` plugin, first enable it in your configuration (see
|
|||
|
||||
The plugin uses `requests`_ to fetch album art from the Web.
|
||||
|
||||
.. _requests: http://docs.python-requests.org/en/latest/
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
|
||||
Fetching Album Art During Import
|
||||
--------------------------------
|
||||
|
|
@ -73,18 +73,18 @@ or `Pillow`_.
|
|||
|
||||
.. note::
|
||||
|
||||
Previously, there was a `remote_priority` option to specify when to
|
||||
Previously, there was a ``remote_priority`` option to specify when to
|
||||
look for art on the filesystem. This is
|
||||
still respected, but a deprecation message will be shown until you
|
||||
replace this configuration with the new `filesystem` value in the
|
||||
`sources` array.
|
||||
replace this configuration with the new ``filesystem`` value in the
|
||||
``sources`` array.
|
||||
|
||||
.. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm
|
||||
.. _Pillow: https://github.com/python-pillow/Pillow
|
||||
.. _ImageMagick: http://www.imagemagick.org/
|
||||
.. _ImageMagick: https://www.imagemagick.org/
|
||||
|
||||
Here's an example that makes plugin select only images that contain *front* or
|
||||
*back* keywords in their filenames and prioritizes the iTunes source over
|
||||
Here's an example that makes plugin select only images that contain ``front`` or
|
||||
``back`` keywords in their filenames and prioritizes the iTunes source over
|
||||
others::
|
||||
|
||||
fetchart:
|
||||
|
|
@ -135,7 +135,7 @@ On some versions of Windows, the program can be shadowed by a system-provided
|
|||
environment variable so that ImageMagick comes first or use Pillow instead.
|
||||
|
||||
.. _Pillow: https://github.com/python-pillow/Pillow
|
||||
.. _ImageMagick: http://www.imagemagick.org/
|
||||
.. _ImageMagick: https://www.imagemagick.org/
|
||||
|
||||
.. _album-art-sources:
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ Optionally, you can `define a custom search engine`_. Get your search engine's
|
|||
token and use it for your ``google_engine`` configuration option. The
|
||||
default engine searches the entire web for cover art.
|
||||
|
||||
.. _define a custom search engine: http://www.google.com/cse/all
|
||||
.. _define a custom search engine: https://www.google.com/cse/all
|
||||
|
||||
Note that the Google custom search API is limited to 100 queries per day.
|
||||
After that, the fetchart plugin will fall back on other declared data sources.
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ Freedesktop Plugin
|
|||
|
||||
The ``freedesktop`` plugin created .directory files in your album folders.
|
||||
This plugin is now deprecated and replaced by the :doc:`/plugins/thumbnails`
|
||||
with the `dolphin` option enabled.
|
||||
with the ``dolphin`` option enabled.
|
||||
|
|
|
|||
|
|
@ -41,4 +41,4 @@ your entire collection.
|
|||
Use the ``-d`` flag to remove featured artists (equivalent of the ``drop``
|
||||
config option).
|
||||
|
||||
.. _MusicBrainz style: http://musicbrainz.org/doc/Style
|
||||
.. _MusicBrainz style: https://musicbrainz.org/doc/Style
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ songs in your library.
|
|||
Installation
|
||||
------------
|
||||
|
||||
The plugin requires `gmusicapi`_. You can install it using `pip`::
|
||||
The plugin requires :pypi:`gmusicapi`. You can install it using ``pip``::
|
||||
|
||||
pip install gmusicapi
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue